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
58 changes: 57 additions & 1 deletion codespell_lib/_codespell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -1231,6 +1267,7 @@ def parse_file(
summary,
misspellings,
ignore_words_cased,
ignore_words_usage,
exclude_lines,
word_regex,
ignore_word_regex,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -1510,6 +1555,7 @@ def main(*args: str) -> int:
summary,
misspellings,
ignore_words_cased,
ignore_words_usage,
exclude_lines,
file_opener,
word_regex,
Expand All @@ -1535,6 +1581,7 @@ def main(*args: str) -> int:
summary,
misspellings,
ignore_words_cased,
ignore_words_usage,
exclude_lines,
file_opener,
word_regex,
Expand All @@ -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
7 changes: 7 additions & 0 deletions codespell_lib/_spellchecker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -61,10 +64,14 @@ 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:
alt_key = key.translate(table)
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)
63 changes: 63 additions & 0 deletions codespell_lib/tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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