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"), [