diff --git a/tests/resolvers/test_help_resolver.py b/tests/resolvers/test_help_resolver.py index 77e4080..5f5881f 100644 --- a/tests/resolvers/test_help_resolver.py +++ b/tests/resolvers/test_help_resolver.py @@ -10,7 +10,7 @@ import time import pytest -from thefuck import shell_ast +from thefuck import shell_ast, typo from thefuck.resolvers.help_resolver import get_help_correction @@ -95,6 +95,22 @@ def test_returns_correction_when_subcommand_misspelled( assert _spawned(argv_log) == ['--help'] +def test_corrects_transposed_subcommand(fake_bin, argv_log, enable_cache): + # bulid -> build scores 2 * 4 / 10 = 0.8, right at the cutoff, + # and is also a single transposition (li <-> il). + fake_bin('fake') + assert get_help_correction('fake bulid .') == 'fake build .' + assert typo.single_edit('bulid', 'build') + + +def test_corrects_transposition_under_cutoff( + fake_bin, argv_log, enable_cache): + # psuh -> push scores 2 * 3 / 8 = 0.75, under the cutoff, so only + # the first-char-equal single-edit match fixes it. + fake_bin('fake', commands=('push', 'build', 'clean')) + assert get_help_correction('fake psuh x') == 'fake push x' + + def test_respawns_when_binary_mtime_changes( fake_bin, argv_log, enable_cache): path = fake_bin('fake') diff --git a/tests/resolvers/test_history_resolver.py b/tests/resolvers/test_history_resolver.py index 5655775..8ebaa2a 100644 --- a/tests/resolvers/test_history_resolver.py +++ b/tests/resolvers/test_history_resolver.py @@ -2,7 +2,7 @@ import difflib import pytest -from thefuck import shell_ast +from thefuck import shell_ast, typo from thefuck.resolvers.history_resolver import ( _CANDIDATES, _TOKEN_CUTOFF, @@ -42,20 +42,26 @@ def test_corrects_two_diverged_tokens_across_segments(history): assert get_history_correction(command) == 'git status | grep -i foo' -def test_declines_tokens_below_cutoff(history): - # The plan's flagship pair lands under the mandated token cutoff: - # SequenceMatcher(None, 'psuh', 'push').ratio() == 0.75 and - # SequenceMatcher(None, 'greo', 'grep').ratio() == 0.75, both - # below _TOKEN_CUTOFF, so the safety gate must decline them. +@requires_ast +def test_corrects_transposed_tokens_under_ratio_cutoff(history): + # psuh -> push and greo -> grep both score 2 * 3 / 8 = 0.75, + # under _TOKEN_CUTOFF, but each is a first-char-equal single + # transposition, which the token gate accepts alongside the + # ratio. history(['git push | grep -i foo']) command = Command('git psuh | greo -i foo', '') - assert get_history_correction(command) is None + assert get_history_correction(command) == 'git push | grep -i foo' def test_declines_token_just_below_cutoff(history): + # '--ignore-whitespaces' vs '--ignore-all-spaces' scores + # 2 * 15 / 39 = 0.769 (under _TOKEN_CUTOFF) AND spans two edits + # (pinned below), so even the amended single-edit gate declines. history(['git diff --ignore-all-spaces HEAD']) command = Command('git diff --ignore-whitespaces HEAD', '') assert get_history_correction(command) is None + assert not typo.single_edit('--ignore-whitespaces', + '--ignore-all-spaces') def test_token_cutoff_boundary_arithmetic(): @@ -115,13 +121,15 @@ def test_declines_when_history_is_only_unparseable(history): assert get_history_correction(command) is None -def test_flat_mode_declines_multi_segment_scripts(history, monkeypatch): - # With the parser unavailable the pipeline cannot be verified - # structurally and both diverged tokens fall under the cutoff. +def test_flat_mode_corrects_single_edit_tokens(history, monkeypatch): + # With the parser unavailable both scripts take the flat view; + # token counts still line up and both diverged tokens are + # first-char-equal single edits, so the amended gate corrects + # them there too. monkeypatch.setattr(shell_ast, 'AST_AVAILABLE', False) history(['git push | grep -i foo']) command = Command('git psuh | greo -i foo', '') - assert get_history_correction(command) is None + assert get_history_correction(command) == 'git push | grep -i foo' def test_declines_history_below_prefilter_cutoff(history): diff --git a/tests/test_learned.py b/tests/test_learned.py index 7fa4f01..95be0d9 100644 --- a/tests/test_learned.py +++ b/tests/test_learned.py @@ -180,6 +180,22 @@ class TestGuessFromPathSegments(object): assert (learned.guess_from_path('gi psuh | gre -i foo') == 'git psuh | grep -i foo') + def test_fixes_single_edit_typos_under_cutoff(self, learned, + path_bins): + # gti -> git scores 2 * 2 / 6 = 0.667 and greo -> grep scores + # 2 * 3 / 8 = 0.75, both under GUESS_CUTOFF; each is still a + # first-char-equal single edit with one executable match. + path_bins(executables=['git', 'grep', 'sed']) + assert (learned.guess_from_path('gti psuh | greo -i foo') + == 'git psuh | grep -i foo') + + def test_declines_single_edit_ambiguity(self, learned, path_bins): + # gti is one edit from both git (transposition) and gui + # (substitution), each scoring 2 * 2 / 6 = 0.667, so two + # candidates survive and the segment is skipped. + path_bins(executables=['git', 'gui']) + assert learned.guess_from_path('gti psuh') is None + def test_fixes_only_segment_with_unknown_head(self, learned, path_bins): path_bins(executables=['git', 'grep', 'sed'], existing=['git']) assert (learned.guess_from_path('git psuh | gre -i foo') diff --git a/tests/test_typo.py b/tests/test_typo.py new file mode 100644 index 0000000..4048005 --- /dev/null +++ b/tests/test_typo.py @@ -0,0 +1,84 @@ +from thefuck.typo import single_edit + + +class TestSubstitution(object): + def test_single_substitution(self): + assert single_edit('greo', 'grep') + + def test_single_substitution_one_char_words(self): + assert single_edit('a', 'b') + + +class TestTransposition(object): + def test_adjacent_transposition_gti(self): + assert single_edit('gti', 'git') + + def test_adjacent_transposition_psuh(self): + assert single_edit('psuh', 'push') + + def test_adjacent_transposition_bulid(self): + assert single_edit('bulid', 'build') + + def test_transposition_is_symmetric(self): + assert single_edit('git', 'gti') + + def test_non_adjacent_swap_is_two_edits(self): + # Swapping two NON-adjacent characters takes two moves: + # abcd -> badc diverges at every position. + assert not single_edit('abcd', 'badc') + + def test_two_differing_positions_not_crossed(self): + # Two substitutions shaped like a swap but with distinct + # characters are two edits, not one transposition. + assert not single_edit('ab', 'cd') + + +class TestInsertionAndDeletion(object): + def test_single_insertion(self): + assert single_edit('clea', 'clear') + + def test_single_deletion(self): + assert single_edit('clear', 'clea') + + def test_insertion_in_the_middle(self): + assert single_edit('gt', 'git') + + def test_two_insertions(self): + assert not single_edit('cl', 'clear') + + +class TestBoundaries(object): + def test_equal_strings(self): + assert not single_edit('git', 'git') + + def test_empty_strings(self): + assert not single_edit('', '') + + def test_empty_to_single_char(self): + assert single_edit('', 'a') + + def test_single_char_to_empty(self): + assert single_edit('a', '') + + def test_empty_to_two_chars(self): + assert not single_edit('', 'ab') + + def test_length_difference_over_one(self): + assert not single_edit('g', 'git') + + def test_shifted_words_are_two_edits(self): + # abc -> bcd keeps no common alignment: a deletion plus an + # insertion, i.e. two edits. + assert not single_edit('abc', 'bcd') + + def test_two_substitutions(self): + # Adjacent but not crossed: two substitutions, not one + # transposition. + assert not single_edit('abcd', 'abxy') + + def test_help_flag_boundary_pair(self): + # The pair the history-resolver boundary test pins: under the + # 0.8 ratio cutoff (0.769) AND not a single edit, so the + # amended gate still declines it. + assert not single_edit('--ignore-whitespaces', + '--ignore-all-spaces') diff --git a/thefuck/learned.py b/thefuck/learned.py index b22809c..b3de076 100644 --- a/thefuck/learned.py +++ b/thefuck/learned.py @@ -4,7 +4,7 @@ import shelve import time from difflib import get_close_matches -from . import logs, shell_ast +from . import logs, shell_ast, typo from .utils import get_all_executables, which try: @@ -164,12 +164,19 @@ class LearnedCorrections(object): token = token[1:-1] if not token or '/' in token or '.' in token or which(token): continue - candidates = [cmd for cmd in get_close_matches( - token, get_all_executables(), n=5, cutoff=GUESS_CUTOFF) - if cmd.startswith(token[0])] + executables = get_all_executables() + # The ratio cutoff underrates transpositions (gti -> git + # at 0.667), so same-first-char single edits join the + # difflib candidates; exactly one distinct survivor wins. + candidates = set(cmd for cmd in get_close_matches( + token, executables, n=5, cutoff=GUESS_CUTOFF) + if cmd.startswith(token[0])) + candidates.update(cmd for cmd in executables + if cmd[:1] == token[:1] + and typo.single_edit(token, cmd)) if len(candidates) != 1: continue - replacements.append((start, end, candidates[0])) + replacements.append((start, end, candidates.pop())) if not replacements: return None return shell_ast.splice(script, replacements) diff --git a/thefuck/resolvers/help_resolver.py b/thefuck/resolvers/help_resolver.py index b03dc35..a6c4bd3 100644 --- a/thefuck/resolvers/help_resolver.py +++ b/thefuck/resolvers/help_resolver.py @@ -18,7 +18,7 @@ import os import subprocess from difflib import get_close_matches -from thefuck import shell_ast +from thefuck import shell_ast, typo from thefuck.utils import cache, which @@ -72,12 +72,18 @@ def _token_replacement(word, binary_name, binary_path): token, start, end = word if token in commands: return None - matches = [match for match in get_close_matches( + # Same gate shape as learned.guess_from_path: difflib candidates + # plus same-first-char single edits (transpositions score under + # the cutoff), exactly one distinct survivor. + matches = set(match for match in get_close_matches( token, commands, n=_MATCHES, cutoff=_CUTOFF) - if match.startswith(token[0])] + if match.startswith(token[0])) + matches.update(command for command in commands + if command[:1] == token[:1] + and typo.single_edit(token, command)) if len(matches) != 1: return None - return start, end, matches[0] + return start, end, matches.pop() def _get_commands(binary_path, binary_name): diff --git a/thefuck/resolvers/history_resolver.py b/thefuck/resolvers/history_resolver.py index fd56452..04687c1 100644 --- a/thefuck/resolvers/history_resolver.py +++ b/thefuck/resolvers/history_resolver.py @@ -7,7 +7,7 @@ existing `history` rule keeps offering choices instead of auto-running. """ import difflib -from thefuck import shell_ast +from thefuck import shell_ast, typo from thefuck.utils import get_valid_history_without_current _PREFILTER_CUTOFF = 0.5 @@ -57,12 +57,27 @@ def _prefilter(script, history): return [line for _, line in scored[:_CANDIDATES]] +def _similar(token, candidate_token): + """Returns True when a diverged token pair passes the similarity gate. + + The ratio cutoff accepts substitutions; `single_edit` adds the + adjacent transpositions the ratio underrates (psuh -> push at + 0.75) while keeping every two-edit pair out. The first-char + equality keeps the single-edit path as narrow as the typo + intent: a slipped key, not a different word. + """ + return (difflib.SequenceMatcher( + None, token, candidate_token).ratio() >= _TOKEN_CUTOFF + or (token[:1] == candidate_token[:1] + and typo.single_edit(token, candidate_token))) + + def _correct(script, segments, candidate): """Returns the spliced correction when candidate passes every gate. The gates: identical segment and per-segment token counts, at - most `_MAX_DIVERGED` diverged tokens, every diverged pair at - least `_TOKEN_CUTOFF` similar, and a candidate different from + most `_MAX_DIVERGED` diverged tokens, every diverged pair + similar enough (see `_similar`), and a candidate different from the script itself. """ if candidate == script: @@ -82,8 +97,7 @@ def _correct(script, segments, candidate): continue if len(replacements) >= _MAX_DIVERGED: return None - if difflib.SequenceMatcher( - None, token, candidate_token).ratio() < _TOKEN_CUTOFF: + if not _similar(token, candidate_token): return None replacements.append((start, end, candidate_token)) if not replacements: diff --git a/thefuck/typo.py b/thefuck/typo.py new file mode 100644 index 0000000..2c99010 --- /dev/null +++ b/thefuck/typo.py @@ -0,0 +1,41 @@ +"""Single-edit (Damerau distance 1) typo predicate. + +`difflib.SequenceMatcher` rates adjacent transpositions (gti -> git) +well below similarity cutoffs that accept substitutions, so gates +that only use the ratio decline the most common keyboard slips. +Callers accept `single_edit(a, b)` matches as an additional path. +""" + + +def single_edit(a, b): + """Returns True when `a` and `b` differ by exactly one edit. + + One edit is a substitution, insertion, deletion or adjacent + transposition (restricted Damerau distance of 1); equal strings + are zero edits and return False. + """ + if a == b: + return False + if abs(len(a) - len(b)) > 1: + return False + if len(a) == len(b): + return _equal_length_single_edit(a, b) + if len(a) > len(b): + return _contains_single_insertion(a, b) + return _contains_single_insertion(b, a) + + +def _equal_length_single_edit(a, b): + diffs = [i for i in range(len(a)) if a[i] != b[i]] + if len(diffs) == 1: + return True + return (len(diffs) == 2 and diffs[1] == diffs[0] + 1 + and a[diffs[0]] == b[diffs[1]] + and a[diffs[1]] == b[diffs[0]]) + + +def _contains_single_insertion(longer, shorter): + for i in range(len(shorter)): + if shorter[i] != longer[i]: + return shorter[i:] == longer[i + 1:] + return True