From 811472a0e6cfd4f0fbfaf47adc82cc23182c9951 Mon Sep 17 00:00:00 2001 From: Eljees <3.14hell@gmail.com> Date: Fri, 7 Aug 2026 03:21:09 +0300 Subject: [PATCH 1/4] Interactive mode: an answer is about one occurrence, not the whole run --- codespell_lib/_codespell.py | 49 +++++++++++++-------- codespell_lib/tests/test_basic.py | 72 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 19 deletions(-) diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 994c5c4a66..eb4d1dd883 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -815,6 +815,9 @@ def ask_for_word_fix( filename: str, lineno: int, ) -> tuple[bool, str]: + # This function must not mutate `misspelling`: the object is shared by every + # match of the word in the run, so a per-occurrence answer would leak into + # every later occurrence and file (GH-62). cfilename = f"{colors.FILE}{filename}{colors.DISABLE}" cline = f"{colors.FILE}{lineno}{colors.DISABLE}" @@ -845,9 +848,11 @@ def ask_for_word_fix( r = "" if r == "N": - misspelling.fix = False + return False, fixword - elif (interactivity & 2) and not misspelling.reason: + return True, fixword + + elif (interactivity & 2) and not misspelling.fix and not misspelling.reason: # if it is not disabled, i.e. it just has more than one possible fix, # we ask the user which word to use @@ -874,8 +879,7 @@ def ask_for_word_fix( print("Not a valid option\n") if r: - misspelling.fix = True - misspelling.data = r + return True, fix_case(wrongword, r) return misspelling.fix, fix_case(wrongword, misspelling.data) @@ -986,6 +990,11 @@ def parse_lines( next_line_ignore_words: Optional[set[str]] = None + # Memory of interactive answers for this fragment: lword -> (fix, fixword). + # An answer covers every later match of the word here, so each word is + # asked about once per file rather than once per line (GH-62). + asked_for: dict[str, tuple[bool, str]] = {} + for i, line in enumerate(lines): line = line.rstrip() # Apply any ignore-next-line directive carried from the previous line. @@ -1025,7 +1034,6 @@ def parse_lines( extra_words_to_ignore |= pending_next_line_ignore fixed_words = set() - asked_for = set() # If all URI spelling errors will be ignored, erase any URI before # extracting words. Otherwise, apply ignores after extracting words. @@ -1071,20 +1079,23 @@ def parse_lines( fix = misspellings[lword].fix fixword = fix_case(word, misspellings[lword].data) - if options.interactive and lword not in asked_for: - if context is not None: - context_shown = True - print_context(lines, i, context) - fix, fixword = ask_for_word_fix( - lines[i], - match, - misspellings[lword], - options.interactive, - colors=colors, - filename=filename, - lineno=i + 1, - ) - asked_for.add(lword) + if options.interactive: + if lword in asked_for: + fix, fixword = asked_for[lword] + else: + if context is not None: + context_shown = True + print_context(lines, i, context) + fix, fixword = ask_for_word_fix( + lines[i], + match, + misspellings[lword], + options.interactive, + colors=colors, + filename=filename, + lineno=i + 1, + ) + asked_for[lword] = (fix, fixword) if summary and fix: summary.update(lword) diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 930f09f14a..9a33d92e2a 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -1643,3 +1643,75 @@ def test_args_from_file( print("Testing with direct call to cs_.main()") r = cs_.main(*args[1:]) print(f"{r=}") + + +def run_codespell_interactive( + args: tuple[Any, ...], + answers: str, + cwd: Optional[Path] = None, +) -> "subprocess.CompletedProcess[str]": + """Run codespell feeding interactive answers on stdin.""" + args = tuple(str(arg) for arg in args) + return subprocess.run( # noqa: S603 + ["codespell", *args], # noqa: S607 + cwd=cwd, + input=answers, + capture_output=True, + encoding="utf-8", + check=False, + ) + + +def test_interactive_rejection_is_per_file( + tmp_path: Path, +) -> None: + """Rejecting a fix answers for one file, not for the whole run (GH-62).""" + f1 = tmp_path / "f1.txt" + f2 = tmp_path / "f2.txt" + f1.write_text("abandonned\n") + f2.write_text("abandonned\n") + proc = run_codespell_interactive(("-w", "-i", "1", f1, f2), answers="n\ny\n") + assert proc.stdout.count("(Y/n)") == 2 + assert f1.read_text() == "abandonned\n" + assert f2.read_text() == "abandoned\n" + + +def test_interactive_same_word_asked_once_per_file( + tmp_path: Path, +) -> None: + """Within one file the first answer is reused for later matches.""" + f = tmp_path / "f.txt" + f.write_text("abandonned\nabandonned\n") + proc = run_codespell_interactive(("-w", "-i", "1", f), answers="n\n") + assert proc.stdout.count("(Y/n)") == 1 + assert f.read_text() == "abandonned\nabandonned\n" + + +def test_interactive_level_2_no_prompt_for_single_fix( + tmp_path: Path, +) -> None: + """Level 2 prompts only when more than one fix is available (as --help says). + + A word with a single candidate used to get an option list where the blank + "none" answer still applied the fix (GH-62). + """ + f = tmp_path / "f.txt" + f.write_text("abandonned\n") + proc = run_codespell_interactive(("-w", "-i", "2", f), answers="\n") + assert "Choose an option" not in proc.stdout + assert f.read_text() == "abandoned\n" + + +def test_interactive_level_3_rejection_keeps_yn_prompt( + tmp_path: Path, +) -> None: + """A rejected word must stay a Y/n question, not degrade to an option list.""" + f1 = tmp_path / "f1.txt" + f2 = tmp_path / "f2.txt" + f1.write_text("abandonned\n") + f2.write_text("abandonned\n") + proc = run_codespell_interactive(("-w", "-i", "3", f1, f2), answers="n\nn\n") + assert proc.stdout.count("(Y/n)") == 2 + assert "Choose an option" not in proc.stdout + assert f1.read_text() == "abandonned\n" + assert f2.read_text() == "abandonned\n" From 8d0caf3b2ecc6dec83ce2185600e08d7ec0ac484 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 17 Aug 2026 21:39:45 -0400 Subject: [PATCH 2/4] More options --- codespell_lib/_codespell.py | 76 +++++++++++++-------- codespell_lib/tests/test_basic.py | 107 ++++++++++++++++++++++++++---- pyproject.toml | 2 +- 3 files changed, 145 insertions(+), 40 deletions(-) diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index eb4d1dd883..7a3953cb90 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -587,7 +587,7 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: choices=range(0, 4), help="set interactive mode when writing changes:\n" "- 0: no interactivity.\n" - "- 1: ask for confirmation.\n" + "- 1: ask for confirmation; 'a'/'s' answer for the rest of the file.\n" "- 2: ask user to choose one fix when more than one is available.\n" "- 3: both 1 and 2", metavar="MODE", @@ -806,6 +806,14 @@ def is_text_file(filename: str) -> bool: return b"\x00" not in s +def _no_more_input(misspelling: Misspelling) -> tuple[bool, str, bool]: + # An unanswered prompt must not count as a "yes": stdin being at EOF means + # the answers ran out (or never existed), so leave the word alone and stop + # asking about it in this file. + print("\nNo answer: leaving the rest of this file alone") + return False, misspelling.data, True + + def ask_for_word_fix( line: str, match: Match[str], @@ -814,16 +822,23 @@ def ask_for_word_fix( colors: TermColors, filename: str, lineno: int, -) -> tuple[bool, str]: - # This function must not mutate `misspelling`: the object is shared by every - # match of the word in the run, so a per-occurrence answer would leak into - # every later occurrence and file (GH-62). +) -> tuple[bool, str, bool]: + """Ask about one match. + + Returns (fix, data, remember), where data is the replacement in dictionary + form, uncased, for the caller to case per match, and remember says whether + the answer was given for the rest of the file rather than for this match. + + This function must not mutate `misspelling`: the object is shared by every + match of the word in the run, so an answer would leak into every later + match and file (GH-62). + """ cfilename = f"{colors.FILE}{filename}{colors.DISABLE}" cline = f"{colors.FILE}{lineno}{colors.DISABLE}" wrongword = match.group() if interactivity <= 0: - return misspelling.fix, fix_case(wrongword, misspelling.data) + return misspelling.fix, misspelling.data, False line_ui = ( f"{line[: match.start()]}" @@ -836,21 +851,24 @@ def ask_for_word_fix( fixword = fix_case(wrongword, misspelling.data) while not r: print( - f"{cfilename}:{cline}: {line_ui}\t{wrongword} ==> {fixword} (Y/n) ", + f"{cfilename}:{cline}: {line_ui}\t{wrongword} ==> {fixword} (Y/n/a/s) ", end="", flush=True, ) - r = sys.stdin.readline().strip().upper() + answer = sys.stdin.readline() + if not answer: + return _no_more_input(misspelling) + r = answer.strip().upper() if not r: r = "Y" - if r not in ("Y", "N"): - print("Say 'y' or 'n'") + if r not in ("Y", "N", "A", "S"): + print( + "Say 'y' or 'n' for this one, " + "'a' or 's' for all of them in this file" + ) r = "" - if r == "N": - return False, fixword - - return True, fixword + return r in ("Y", "A"), misspelling.data, r in ("A", "S") elif (interactivity & 2) and not misspelling.fix and not misspelling.reason: # if it is not disabled, i.e. it just has more than one possible fix, @@ -868,7 +886,10 @@ def ask_for_word_fix( print(f" {i}) {fixword}", end="") print(": ", end="", flush=True) - n = sys.stdin.readline().strip() + answer = sys.stdin.readline() + if not answer: + return _no_more_input(misspelling) + n = answer.strip() if not n: break @@ -879,9 +900,9 @@ def ask_for_word_fix( print("Not a valid option\n") if r: - return True, fix_case(wrongword, r) + return True, r, False - return misspelling.fix, fix_case(wrongword, misspelling.data) + return misspelling.fix, misspelling.data, False def print_context( @@ -981,6 +1002,7 @@ def parse_lines( uri_ignore_words: set[str], context: Optional[tuple[int, int]], options: argparse.Namespace, + asked_for: dict[str, tuple[bool, str]], ) -> tuple[int, bool, list[tuple[int, str, str]]]: bad_count = 0 changed = False @@ -990,11 +1012,6 @@ def parse_lines( next_line_ignore_words: Optional[set[str]] = None - # Memory of interactive answers for this fragment: lword -> (fix, fixword). - # An answer covers every later match of the word here, so each word is - # asked about once per file rather than once per line (GH-62). - asked_for: dict[str, tuple[bool, str]] = {} - for i, line in enumerate(lines): line = line.rstrip() # Apply any ignore-next-line directive carried from the previous line. @@ -1081,21 +1098,24 @@ def parse_lines( if options.interactive: if lword in asked_for: - fix, fixword = asked_for[lword] + fix, data = asked_for[lword] else: if context is not None: context_shown = True print_context(lines, i, context) - fix, fixword = ask_for_word_fix( + fix, data, remember = ask_for_word_fix( lines[i], match, misspellings[lword], options.interactive, colors=colors, filename=filename, - lineno=i + 1, + lineno=line_number + 1, ) - asked_for[lword] = (fix, fixword) + if remember: + asked_for[lword] = (fix, data) + # The answer is uncased: case it for this match. + fixword = fix_case(word, data) if summary and fix: summary.update(lword) @@ -1232,6 +1252,9 @@ def parse_file( # Parse lines. changed = False changes_made: list[tuple[int, str, str]] = [] + # Answers given for the whole file ('a'/'s'): lword -> (fix, uncased fix). + # Plain y/n answers are not remembered here: they are about one match. + asked_for: dict[str, tuple[bool, str]] = {} for fragment in fragments: ignore, _, _ = fragment if ignore: @@ -1251,6 +1274,7 @@ def parse_file( uri_ignore_words, context, options, + asked_for, ) bad_count += bad_count_update changed = changed or changed_update diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 9a33d92e2a..34097b45fb 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -1645,6 +1645,9 @@ def test_args_from_file( print(f"{r=}") +PROMPT = "(Y/n/a/s)" + + def run_codespell_interactive( args: tuple[Any, ...], answers: str, @@ -1662,29 +1665,107 @@ def run_codespell_interactive( ) -def test_interactive_rejection_is_per_file( +def test_interactive_rejection_is_per_match( tmp_path: Path, ) -> None: - """Rejecting a fix answers for one file, not for the whole run (GH-62).""" + """A y/n answer is about one match, not the rest of the run (GH-62).""" f1 = tmp_path / "f1.txt" f2 = tmp_path / "f2.txt" - f1.write_text("abandonned\n") + f1.write_text("abandonned\nabandonned\n") f2.write_text("abandonned\n") - proc = run_codespell_interactive(("-w", "-i", "1", f1, f2), answers="n\ny\n") - assert proc.stdout.count("(Y/n)") == 2 - assert f1.read_text() == "abandonned\n" + proc = run_codespell_interactive(("-w", "-i", "1", f1, f2), answers="n\ny\ny\n") + assert proc.stdout.count(PROMPT) == 3 + assert f1.read_text() == "abandonned\nabandoned\n" assert f2.read_text() == "abandoned\n" -def test_interactive_same_word_asked_once_per_file( +def test_interactive_answer_for_whole_file( + tmp_path: Path, +) -> None: + """'a' and 's' answer for the rest of the file, and stop at its end.""" + f1 = tmp_path / "f1.txt" + f2 = tmp_path / "f2.txt" + f1.write_text("abandonned\nabandonned\n") + f2.write_text("abandonned\nabandonned\n") + proc = run_codespell_interactive(("-w", "-i", "1", f1, f2), answers="s\na\n") + assert proc.stdout.count(PROMPT) == 2 + assert f1.read_text() == "abandonned\nabandonned\n" + assert f2.read_text() == "abandoned\nabandoned\n" + + +def test_interactive_whole_file_answer_spans_fragments( + tmp_path: Path, +) -> None: + """An ignored multiline region splits a file but must not re-ask.""" + f = tmp_path / "f.txt" + f.write_text("abandonned\nSKIPSTART\nfoo\nSKIPEND\nabandonned\n") + proc = run_codespell_interactive( + ("-w", "-i", "1", "--ignore-multiline-regex", r"SKIPSTART[\s\S]*?SKIPEND", f), + answers="s\n", + ) + assert proc.stdout.count(PROMPT) == 1 + assert f.read_text() == "abandonned\nSKIPSTART\nfoo\nSKIPEND\nabandonned\n" + + +def test_interactive_answer_keeps_case( + tmp_path: Path, +) -> None: + """An answer is cased for each match, not for the one that was asked about.""" + f = tmp_path / "f.txt" + f.write_text("abandonned\nAbandonned\nABANDONNED\n") + proc = run_codespell_interactive(("-w", "-i", "1", f), answers="a\n") + assert proc.stdout.count(PROMPT) == 1 + assert f.read_text() == "abandoned\nAbandoned\nABANDONED\n" + + +def test_interactive_invalid_answer_asks_again( + tmp_path: Path, +) -> None: + """Anything that is not y/n/a/s re-asks about the same match.""" + f = tmp_path / "f.txt" + f.write_text("abandonned\n") + proc = run_codespell_interactive(("-w", "-i", "1", f), answers="x\ny\n") + assert proc.stdout.count(PROMPT) == 2 + assert "Say 'y' or 'n'" in proc.stdout + assert f.read_text() == "abandoned\n" + + +@pytest.mark.parametrize( + ("level", "text"), + [ + ("1", "abandonned\nabandonned\n"), # asked with y/n/a/s + ("2", "aache\naache\n"), # asked with a list of fixes + ("3", "abandonned\naache\n"), # both + ], +) +def test_interactive_no_answer_fixes_nothing( + tmp_path: Path, + level: str, + text: str, +) -> None: + """Running out of answers must not count as accepting the rest.""" + f = tmp_path / "f.txt" + f.write_text(text) + proc = run_codespell_interactive(("-w", "-i", level, f), answers="") + assert "No answer" in proc.stdout + assert f.read_text() == text + + +def test_interactive_level_2_answer_keeps_case( tmp_path: Path, ) -> None: - """Within one file the first answer is reused for later matches.""" + """The same, for the answer chosen from a list of fixes. + + The list is asked about once per match, and keeps every candidate: choosing + one used to narrow the list for every later match (GH-62). + """ f = tmp_path / "f.txt" - f.write_text("abandonned\nabandonned\n") - proc = run_codespell_interactive(("-w", "-i", "1", f), answers="n\n") - assert proc.stdout.count("(Y/n)") == 1 - assert f.read_text() == "abandonned\nabandonned\n" + f.write_text("aache\nAache\n") + proc = run_codespell_interactive(("-w", "-i", "2", f), answers="0\n0\n") + assert proc.stdout.count("Choose an option") == 2 + assert proc.stdout.count("1) ache") == 1 + assert proc.stdout.count("1) Ache") == 1 + assert f.read_text() == "cache\nCache\n" def test_interactive_level_2_no_prompt_for_single_fix( @@ -1711,7 +1792,7 @@ def test_interactive_level_3_rejection_keeps_yn_prompt( f1.write_text("abandonned\n") f2.write_text("abandonned\n") proc = run_codespell_interactive(("-w", "-i", "3", f1, f2), answers="n\nn\n") - assert proc.stdout.count("(Y/n)") == 2 + assert proc.stdout.count(PROMPT) == 2 assert "Choose an option" not in proc.stdout assert f1.read_text() == "abandonned\n" assert f2.read_text() == "abandonned\n" diff --git a/pyproject.toml b/pyproject.toml index 6c9f452aa4..3a34418230 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,7 +177,7 @@ max-complexity = 45 [tool.ruff.lint.pylint] allow-magic-value-types = ["bytes", "int", "str",] -max-args = 13 +max-args = 14 max-branches = 48 max-returns = 12 max-statements = 120 From b8e84900d08060fa31f0478529a7bc202f540737 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 17 Aug 2026 22:01:12 -0400 Subject: [PATCH 3/4] FIX: More --- .coveragerc | 3 +++ codespell_lib/_codespell.py | 3 --- codespell_lib/tests/test_basic.py | 12 ++++++++++++ pyproject.toml | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.coveragerc b/.coveragerc index b160954c38..09fee0e09a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,3 +2,6 @@ branch = True source = codespell_lib omit = */codespell_lib/tests/* +# Some tests run the codespell entry point in a subprocess; without this their +# coverage is invisible (pytest-cov 7 dropped its own subprocess support). +patch = subprocess diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 7a3953cb90..4acf520c13 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -837,9 +837,6 @@ def ask_for_word_fix( cline = f"{colors.FILE}{lineno}{colors.DISABLE}" wrongword = match.group() - if interactivity <= 0: - return misspelling.fix, misspelling.data, False - line_ui = ( f"{line[: match.start()]}" f"{colors.WWORD}{wrongword}{colors.DISABLE}" diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 34097b45fb..5f663388a7 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -1718,6 +1718,18 @@ def test_interactive_answer_keeps_case( assert f.read_text() == "abandoned\nAbandoned\nABANDONED\n" +def test_interactive_context_is_shown_once( + tmp_path: Path, +) -> None: + """-C prints the surrounding lines before asking, and not again after.""" + f = tmp_path / "f.txt" + f.write_text("first line\nabandonned\n") + proc = run_codespell_interactive(("-w", "-i", "1", "-C", "1", f), answers="n\n") + assert proc.stdout.count(PROMPT) == 1 + assert proc.stdout.count("first line") == 1 + assert f.read_text() == "first line\nabandonned\n" + + def test_interactive_invalid_answer_asks_again( tmp_path: Path, ) -> None: diff --git a/pyproject.toml b/pyproject.toml index 3a34418230..7cbdb717e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dev = [ "chardet", "pre-commit", "pytest", - "pytest-cov", + "pytest-cov>=7", "pytest-dependency", "Pygments", "ruff", @@ -54,7 +54,7 @@ types = [ "chardet>=5.1.0", "mypy", "pytest", - "pytest-cov", + "pytest-cov>=7", "pytest-dependency", ] From 97e7cf7e15ba6f379ce5ff6aa655b6b9217ed7e4 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Mon, 24 Aug 2026 22:32:54 +0200 Subject: [PATCH 4/4] FIX: All option --- codespell_lib/_codespell.py | 16 ++++++++----- codespell_lib/tests/test_basic.py | 37 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 967d315f4a..1ec09fdf8f 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -588,7 +588,8 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: help="set interactive mode when writing changes:\n" "- 0: no interactivity.\n" "- 1: ask for confirmation; 'a'/'s' answer for the rest of the file.\n" - "- 2: ask user to choose one fix when more than one is available.\n" + "- 2: ask user to choose one fix when more than one is available;" + " 'Na'/'s' answer for the rest of the file.\n" "- 3: both 1 and 2", metavar="MODE", ) @@ -872,10 +873,12 @@ def ask_for_word_fix( # we ask the user which word to use r = "" + remember = False opt = [w.strip() for w in misspelling.data.split(",")] while not r: print( - f"{cfilename}:{cline}: {line_ui} Choose an option (blank for none): ", + f"{cfilename}:{cline}: {line_ui} Choose an option " + "(blank for none, Na for whole file, s to skip): ", end="", ) for i, o in enumerate(opt): @@ -886,18 +889,21 @@ def ask_for_word_fix( answer = sys.stdin.readline() if not answer: return _no_more_input(misspelling) - n = answer.strip() + n = answer.strip().lower() if not n: break + if n == "s": + return False, misspelling.data, True + remember = n.endswith("a") try: - i = int(n) + i = int(n[:-1] if remember else n) r = opt[i] except (ValueError, IndexError): print("Not a valid option\n") if r: - return True, r, False + return True, r, remember return misspelling.fix, misspelling.data, False diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index 515de1f53c..0127f57013 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -1808,6 +1808,43 @@ def test_interactive_level_2_answer_keeps_case( assert f.read_text() == "cache\nCache\n" +def test_interactive_level_2_answer_for_whole_file( + tmp_path: Path, +) -> None: + """A number with a trailing 'a' picks that fix for the rest of the file.""" + f = tmp_path / "f.txt" + f.write_text("aache\nAache\n") + proc = run_codespell_interactive(("-w", "-i", "2", f), answers="0a\n") + assert proc.stdout.count("Choose an option") == 1 + assert f.read_text() == "cache\nCache\n" + + +def test_interactive_level_2_skip_whole_file( + tmp_path: Path, +) -> None: + """'s' leaves the word alone for the rest of the file.""" + f1 = tmp_path / "f1.txt" + f2 = tmp_path / "f2.txt" + f1.write_text("aache\naache\n") + f2.write_text("aache\n") + proc = run_codespell_interactive(("-w", "-i", "2", f1, f2), answers="s\n0\n") + assert proc.stdout.count("Choose an option") == 2 + assert f1.read_text() == "aache\naache\n" + assert f2.read_text() == "cache\n" + + +def test_interactive_level_2_invalid_answer_asks_again( + tmp_path: Path, +) -> None: + """A bare 'a' is not a choice: it re-asks rather than picking something.""" + f = tmp_path / "f.txt" + f.write_text("aache\n") + proc = run_codespell_interactive(("-w", "-i", "2", f), answers="a\n9\n1\n") + assert proc.stdout.count("Choose an option") == 3 + assert proc.stdout.count("Not a valid option") == 2 + assert f.read_text() == "ache\n" + + def test_interactive_level_2_no_prompt_for_single_fix( tmp_path: Path, ) -> None: