Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 30 additions & 19 deletions codespell_lib/_codespell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions codespell_lib/tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"