From 67e69063988ad9b56a9c9d26c927da8f72c4dc9f Mon Sep 17 00:00:00 2001 From: ANSHUL SINGH <72524975+ekanshul@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:45:56 +0530 Subject: [PATCH] Add --report-unused-ignore-words option Ignore words files tend to grow over time and nobody knows which entries are still needed. With this option codespell reports, at the end of the run, the entries from --ignore-words and --ignore-words-list that never suppressed a misspelling in the checked files, so stale entries can be removed. An entry only counts as used when it actually suppresses a dictionary match: an ignore word that does not match any dictionary entry is reported as unused even if it appears in the checked files. Closing: #2354 --- codespell_lib/_codespell.py | 58 +++++++++++++++++++++++++++- codespell_lib/_spellchecker.py | 7 ++++ codespell_lib/tests/test_basic.py | 63 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 4 files changed, 128 insertions(+), 2 deletions(-) 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