diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 8ebb19de43..71c11b1ad4 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -215,6 +215,17 @@ def __str__(self) -> str: ) +class IgnoreWordsUsage: + """Track which ignore words actually suppress a misspelling.""" + + def __init__(self) -> None: + # ignore words that match a dictionary entry, i.e. the only ones + # that can actually suppress a misspelling + self.candidates: set[str] = set() + # ignore words that suppressed a misspelling in the checked files + self.used: set[str] = set() + + class FileOpener: def __init__( self, @@ -513,6 +524,15 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: "by codespell. Words are case sensitive based on " "how they are written in the dictionary file.", ) + parser.add_argument( + "--report-unused-ignore-words", + action="store_true", + default=False, + help="report ignore words from --ignore-words and " + "--ignore-words-list that did not match anything in the " + "checked files, making it easier to remove stale entries. " + "The report is printed to stderr at the end of the run.", + ) parser.add_argument( "--uri-ignore-words-list", action="append", @@ -968,6 +988,7 @@ def parse_lines( summary: Optional[Summary], misspellings: dict[str, Misspelling], ignore_words_cased: set[str], + ignore_words_usage: Optional[IgnoreWordsUsage], exclude_lines: set[str], word_regex: Pattern[str], ignore_word_regex: Optional[Pattern[str]], @@ -1044,8 +1065,15 @@ def parse_lines( for match in check_matches: word = match.group() if word in ignore_words_cased: + if ignore_words_usage is not None and word.lower() in misspellings: + ignore_words_usage.used.add(word) continue lword = word.lower() + if ( + ignore_words_usage is not None + and lword in ignore_words_usage.candidates + ): + ignore_words_usage.used.add(lword) if lword in misspellings and lword not in extra_words_to_ignore: # Sometimes we find a 'misspelling' which is actually a valid word # preceded by a string escape sequence. Ignore such cases as @@ -1146,6 +1174,7 @@ def parse_file( summary: Optional[Summary], misspellings: dict[str, Misspelling], ignore_words_cased: set[str], + ignore_words_usage: Optional[IgnoreWordsUsage], exclude_lines: set[str], file_opener: FileOpener, word_regex: Pattern[str], @@ -1167,8 +1196,15 @@ def parse_file( if options.check_filenames: for word in extract_words(filename, word_regex, ignore_word_regex): if word in ignore_words_cased: + if ignore_words_usage is not None and word.lower() in misspellings: + ignore_words_usage.used.add(word) continue lword = word.lower() + if ( + ignore_words_usage is not None + and lword in ignore_words_usage.candidates + ): + ignore_words_usage.used.add(lword) if lword not in misspellings: continue fix = misspellings[lword].fix @@ -1231,6 +1267,7 @@ def parse_file( summary, misspellings, ignore_words_cased, + ignore_words_usage, exclude_lines, word_regex, ignore_word_regex, @@ -1431,8 +1468,16 @@ def main(*args: str) -> int: ) use_dictionaries.append(dictionary) misspellings: dict[str, Misspelling] = {} + ignore_words_usage: Optional[IgnoreWordsUsage] = ( + IgnoreWordsUsage() if options.report_unused_ignore_words else None + ) for dictionary in use_dictionaries: - build_dict(dictionary, misspellings, ignore_words) + build_dict( + dictionary, + misspellings, + ignore_words, + None if ignore_words_usage is None else ignore_words_usage.candidates, + ) colors = TermColors() if not options.colors: colors.disable() @@ -1510,6 +1555,7 @@ def main(*args: str) -> int: summary, misspellings, ignore_words_cased, + ignore_words_usage, exclude_lines, file_opener, word_regex, @@ -1535,6 +1581,7 @@ def main(*args: str) -> int: summary, misspellings, ignore_words_cased, + ignore_words_usage, exclude_lines, file_opener, word_regex, @@ -1548,6 +1595,15 @@ def main(*args: str) -> int: if summary: print("\n-------8<-------\nSUMMARY:") print(summary) + if ignore_words_usage is not None: + unused_ignore_words = sorted( + (ignore_words | ignore_words_cased) - ignore_words_usage.used + ) + if unused_ignore_words: + print( + "Unused ignore words: " + ", ".join(unused_ignore_words), + file=sys.stderr, + ) if options.count: print(bad_count, file=sys.stderr) return EX_DATAERR if bad_count else EX_OK diff --git a/codespell_lib/_spellchecker.py b/codespell_lib/_spellchecker.py index 7b511e6d3e..b21f634da0 100644 --- a/codespell_lib/_spellchecker.py +++ b/codespell_lib/_spellchecker.py @@ -16,6 +16,8 @@ Copyright (C) 2011 ProFUSION embedded systems """ +from typing import Optional + # Pass all misspellings through this translation table to generate # alternative misspellings and fixes. alt_chars = (("'", "’"),) # noqa: RUF001 @@ -50,6 +52,7 @@ def build_dict( filename: str, misspellings: dict[str, Misspelling], ignore_words: set[str], + skipped_ignore_words: Optional[set[str]] = None, ) -> None: with open(filename, encoding="utf-8") as f: translate_tables = [(x, str.maketrans(x, y)) for x, y in alt_chars] @@ -61,6 +64,8 @@ def build_dict( data = data.lower() if key not in ignore_words: add_misspelling(key, data, misspellings) + elif skipped_ignore_words is not None: + skipped_ignore_words.add(key) # generate alternative misspellings/fixes for x, table in translate_tables: if x in key: @@ -68,3 +73,5 @@ def build_dict( alt_data = data.translate(table) if alt_key not in ignore_words: add_misspelling(alt_key, alt_data, misspellings) + elif skipped_ignore_words is not None: + skipped_ignore_words.add(alt_key) diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index d1c66a18a5..bddc46b268 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -438,6 +438,69 @@ def test_ignore_word_list( assert cs.main("-Labandonned,someword", "-Labilty", tmp_path) == 1 +def test_report_unused_ignore_words( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that --report-unused-ignore-words reports stale ignore words.""" + bad_name = tmp_path / "bad.txt" + bad_name.write_text("abandonned abilty\n") + # every ignore word is used, nothing to report + result = cs.main("--report-unused-ignore-words", "-Labandonned", bad_name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 1 + assert "Unused ignore words" not in stderr + # entries that never suppressed anything are reported, sorted + result = cs.main( + "--report-unused-ignore-words", "-Lnwe,abandonned,fdindigd", bad_name, std=True + ) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 1 + assert "Unused ignore words: fdindigd, nwe" in stderr + # a word in the checked files that cannot suppress a misspelling + # (not a dictionary entry) is still unused + bad_name.write_text("abandonned goodword\n") + result = cs.main("--report-unused-ignore-words", "-Lgoodword", bad_name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 1 + assert "Unused ignore words: goodword" in stderr + # case sensitive ignore words are tracked as well + bad_name.write_text("MIS mis\n") + result = cs.main("--report-unused-ignore-words", "-LMIS,Mis", bad_name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 1 + assert "Unused ignore words: Mis" in stderr + # ignore words files are supported like --ignore-words-list + bad_name.write_text("abandonned abilty\n") + fname = tmp_path / "ignore.txt" + fname.write_text("abandonned\nfdindigd\n") + result = cs.main("--report-unused-ignore-words", "-I", fname, bad_name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 1 + assert "Unused ignore words: fdindigd" in stderr + # no report without the option + result = cs.main("-Lfdindigd", bad_name, std=True) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 2 + assert "Unused ignore words" not in stderr + # --check-filenames marks ignore words as used, too + short_name = tmp_path / "abandonned.txt" + short_name.write_text("good contents\n") + result = cs.main( + "--report-unused-ignore-words", "-f", "-Labandonned", short_name, std=True + ) + assert isinstance(result, tuple) + code, _, stderr = result + assert code == 0 + assert "Unused ignore words" not in stderr + + @pytest.mark.parametrize( ("content", "expected_error_count"), [ 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