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] 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"