From c87e5f09db40ef7ededb68e03288d23d6b41ad61 Mon Sep 17 00:00:00 2001 From: ANSHUL SINGH <72524975+ekanshul@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:48:48 +0530 Subject: [PATCH] Add --show-dictionary option to report the origin of each typo Each dictionary entry now records the dictionary it was loaded from. With --show-dictionary, every reported typo is annotated with the name of the builtin dictionary (e.g. 'clear', 'rare') or the path of the custom dictionary file it came from, which helps decide which builtin dictionaries to enable and makes dictionary entries easier to fix. Closing: #1470 --- codespell_lib/_codespell.py | 44 +++++++++++++++++++++++------ codespell_lib/_spellchecker.py | 14 ++++++--- codespell_lib/tests/test_basic.py | 47 +++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 13 deletions(-) diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py index 8ebb19de43..1c67fc853a 100644 --- a/codespell_lib/_codespell.py +++ b/codespell_lib/_codespell.py @@ -541,6 +541,14 @@ def convert_arg_line_to_args(self, arg_line: str) -> list[str]: help="regular expression that is used to find URIs " "and emails. A default expression is provided.", ) + parser.add_argument( + "--show-dictionary", + action="store_true", + default=False, + help="print for each typo the dictionary it was found in: " + "the name of a builtin dictionary or the path of a custom " + "dictionary file given with -D.", + ) parser.add_argument( "-s", "--summary", @@ -961,6 +969,17 @@ def _format_colored_output( return cfilename, cline, cwrongword, crightword +def _format_source( + source: str, + colors: TermColors, + options: argparse.Namespace, +) -> str: + """Format the dictionary a misspelling came from for output.""" + if not options.show_dictionary: + return "" + return f" (from {colors.FILE}{source}{colors.DISABLE})" + + def parse_lines( fragment: tuple[bool, int, list[str]], filename: str, @@ -1118,6 +1137,7 @@ def parse_lines( if options.quiet_level & QuietLevels.NON_AUTOMATIC_FIXES: continue creason = "" + csource = _format_source(misspellings[lword].source, colors, options) # If we get to this point (uncorrected error) we should change # our bad_count and thus return value @@ -1127,14 +1147,15 @@ def parse_lines( print_context(lines, i, context) if filename != "-": print( - f"{cfilename}:{cline}: {cwrongword} ==> {crightword}{creason}" + f"{cfilename}:{cline}: {cwrongword} ==> " + f"{crightword}{creason}{csource}" ) elif options.stdin_single_line: - print(f"{cline}: {cwrongword} ==> {crightword}{creason}") + print(f"{cline}: {cwrongword} ==> {crightword}{creason}{csource}") else: print( f"{cline}: {line.strip()}\n\t{cwrongword} " - f"==> {crightword}{creason}" + f"==> {crightword}{creason}{csource}" ) return bad_count, changed, changes_made @@ -1190,10 +1211,11 @@ def parse_file( if options.quiet_level & QuietLevels.NON_AUTOMATIC_FIXES: continue creason = "" + csource = _format_source(misspellings[lword].source, colors, options) bad_count += 1 - print(f"{cfilename}: {cwrongword} ==> {crightword}{creason}") + print(f"{cfilename}: {cwrongword} ==> {crightword}{creason}{csource}") # ignore irregular files if not os.path.isfile(filename): @@ -1302,7 +1324,8 @@ def _usage_error(parser: argparse.ArgumentParser, message: str) -> int: return EX_USAGE -def _select_builtin_dictionary(builtin_option: str) -> list[str]: +def _select_builtin_dictionary(builtin_option: str) -> list[tuple[str, str]]: + """Return a list of (filename, name) builtin dictionary tuples.""" use = sorted(set(builtin_option.split(","))) if "all" in use: use = [u for u in use if u != "all"] + [ @@ -1315,7 +1338,10 @@ def _select_builtin_dictionary(builtin_option: str) -> list[str]: for builtin in _builtin_dictionaries: if builtin[0] == u: use_dictionaries.append( - os.path.join(_data_root, f"dictionary{builtin[2]}.txt") + ( + os.path.join(_data_root, f"dictionary{builtin[2]}.txt"), + builtin[0], + ) ) break else: @@ -1429,10 +1455,10 @@ def main(*args: str) -> int: parser, f"ERROR: cannot find dictionary file: {dictionary}", ) - use_dictionaries.append(dictionary) + use_dictionaries.append((dictionary, dictionary)) misspellings: dict[str, Misspelling] = {} - for dictionary in use_dictionaries: - build_dict(dictionary, misspellings, ignore_words) + for dictionary, source in use_dictionaries: + build_dict(dictionary, misspellings, ignore_words, source) colors = TermColors() if not options.colors: colors.disable() diff --git a/codespell_lib/_spellchecker.py b/codespell_lib/_spellchecker.py index 7b511e6d3e..0682d59521 100644 --- a/codespell_lib/_spellchecker.py +++ b/codespell_lib/_spellchecker.py @@ -22,16 +22,18 @@ class Misspelling: - def __init__(self, data: str, fix: bool, reason: str) -> None: + def __init__(self, data: str, fix: bool, reason: str, source: str = "") -> None: self.data = data self.fix = fix self.reason = reason + self.source = source def add_misspelling( key: str, data: str, misspellings: dict[str, Misspelling], + source: str = "", ) -> None: data = data.strip() @@ -43,14 +45,18 @@ def add_misspelling( fix = True reason = "" - misspellings[key] = Misspelling(data, fix, reason) + misspellings[key] = Misspelling(data, fix, reason, source) def build_dict( filename: str, misspellings: dict[str, Misspelling], ignore_words: set[str], + source: str = "", ) -> None: + # name recorded as the origin of the entries, e.g. the name of a + # builtin dictionary; defaults to the dictionary file name + source = source or filename with open(filename, encoding="utf-8") as f: translate_tables = [(x, str.maketrans(x, y)) for x, y in alt_chars] for line in f: @@ -60,11 +66,11 @@ def build_dict( key = key.lower() data = data.lower() if key not in ignore_words: - add_misspelling(key, data, misspellings) + add_misspelling(key, data, misspellings, source) # 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) + add_misspelling(alt_key, alt_data, misspellings, source) diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py index d1c66a18a5..a04c9db6d8 100644 --- a/codespell_lib/tests/test_basic.py +++ b/codespell_lib/tests/test_basic.py @@ -438,6 +438,53 @@ def test_ignore_word_list( assert cs.main("-Labandonned,someword", "-Labilty", tmp_path) == 1 +def test_show_dictionary( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that --show-dictionary reports the dictionary a typo came from.""" + bad_name = tmp_path / "bad.txt" + bad_name.write_text("abandonned\nacapella\n") + # no source is printed without the option + result = cs.main(bad_name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 2 + assert "(from" not in stdout + # builtin dictionaries are reported by name + result = cs.main("--show-dictionary", bad_name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 2 + assert "abandonned ==> abandoned (from clear)" in stdout + assert "acapella ==> a cappella (from rare)" in stdout + # custom dictionaries are reported by their path + dictionary = tmp_path / "dict.txt" + dictionary.write_text("abandonned->abandoned\n") + result = cs.main("--show-dictionary", "-D", dictionary, bad_name, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 1 + assert f"abandonned ==> abandoned (from {dictionary})" in stdout + # the last dictionary defining an entry wins + result = cs.main( + "--show-dictionary", "-D", "-", "-D", dictionary, bad_name, std=True + ) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 2 + assert f"abandonned ==> abandoned (from {dictionary})" in stdout + assert "acapella ==> a cappella (from rare)" in stdout + # --check-filenames reports the source as well + fname = tmp_path / "abandonned.txt" + fname.write_text("good contents\n") + result = cs.main("--show-dictionary", "-f", fname, std=True) + assert isinstance(result, tuple) + code, stdout, _ = result + assert code == 1 + assert "abandonned ==> abandoned (from clear)" in stdout + + @pytest.mark.parametrize( ("content", "expected_error_count"), [