diff --git a/tests/resolvers/test_error_suggestion.py b/tests/resolvers/test_error_suggestion.py new file mode 100644 index 0000000..7318c2e --- /dev/null +++ b/tests/resolvers/test_error_suggestion.py @@ -0,0 +1,173 @@ +"""Tests for the error-suggestion resolver. + +The main fixture is the byte-exact stderr of `git psuh` captured from +a real git (od -c verified, see .omo/evidence/ +task-1-intelligent-correction.txt); one test additionally runs a live +git so the resolver is pinned against the binary, not the fixture. +""" +import os +import shutil +import subprocess + +import pytest + +from thefuck import shell_ast +from thefuck.resolvers.error_suggestion import get_suggestion_candidates +from thefuck.types import Command + +# Real `git psuh` stderr on this machine (87 bytes, stdout empty): +# "git: 'psuh' is not a git command. See 'git --help'.\n" +# "\n" +# "The most similar command is\n" +# "\tpush\n" +_GIT_PSUH_OUTPUT = ( + "git: 'psuh' is not a git command. See 'git --help'.\n" + "\n" + "The most similar command is\n" + "\tpush\n") + +# lein-rpl-style output with two suggestions that each pass the +# length-scaled floor for 'rpl' (repl and rple are single edits). +_LEIN_MULTI_OUTPUT = ( + "'rpl' is not a task. See 'lein help'.\n" + "\n" + "Did you mean this?\n" + " repl\n" + " rple\n") + +requires_ast = pytest.mark.skipif(not shell_ast.AST_AVAILABLE, + reason='bashlex is not available') + + +def test_corrects_subcommand_from_git_output(): + command = Command('git psuh', _GIT_PSUH_OUTPUT) + assert get_suggestion_candidates(command) == 'git push' + + +def test_corrects_token_in_head_position(): + # The binary's error nominates the position: a token in the head + # is replaced exactly like a subcommand. + command = Command('psuh origin master', _GIT_PSUH_OUTPUT) + assert get_suggestion_candidates(command) == 'push origin master' + + +def test_replaces_whole_quoted_span(): + command = Command("git 'psuh'", _GIT_PSUH_OUTPUT) + assert get_suggestion_candidates(command) == 'git push' + + +def test_replaces_first_occurrence_only(): + command = Command('git psuh psuh', _GIT_PSUH_OUTPUT) + assert get_suggestion_candidates(command) == 'git push psuh' + + +def test_extracts_capitalized_unknown_command(): + output = "Unknown command 'gti'\n\nDid you mean\n\tgit\n" + command = Command('gti push', output) + assert get_suggestion_candidates(command) == 'git push' + + +def test_extracts_lowercase_unknown_command(): + output = "unknown command 'gti'\n\nDid you mean\n\tgit\n" + command = Command('gti push', output) + assert get_suggestion_candidates(command) == 'git push' + + +def test_duplicate_suggestions_are_not_ambiguity(): + # Two suggestion sections naming the same command collapse into + # one candidate (unique-survivor semantics); the second section's + # header is consumed as a separator, the quoted lines unquoted. + output = ("Unknown command 'psuh'\n" + "\n" + "Did you mean\n" + " 'push'\n" + "\n" + "The most similar command\n" + " push\n") + command = Command('git psuh', output) + assert get_suggestion_candidates(command) == 'git push' + + +def test_declines_multi_suggestion_output(): + command = Command('lein rpl', _LEIN_MULTI_OUTPUT) + assert get_suggestion_candidates(command) is None + + +def test_declines_when_no_suggestion_section(): + output = "git: 'psuh' is not a git command. See 'git --help'.\n" + command = Command('git psuh', output) + assert get_suggestion_candidates(command) is None + + +def test_declines_separator_with_no_lines_after(): + output = ("'psuh' is not a git command.\n" + "\n" + "The most similar command is\n") + command = Command('git psuh', output) + assert get_suggestion_candidates(command) is None + + +def test_declines_unrelated_output(): + output = ("E: Could not open lock file /var/lib/dpkg/lock - " + "open (13: Permission denied)\n") + command = Command('apt-get install vim', output) + assert get_suggestion_candidates(command) is None + + +def test_declines_empty_output(): + command = Command('git psuh', '') + assert get_suggestion_candidates(command) is None + + +@requires_ast +def test_declines_when_token_not_in_script(): + command = Command('git checkout', _GIT_PSUH_OUTPUT) + assert get_suggestion_candidates(command) is None + + +def test_declines_self_suggestion(): + # The binary naming the typed token back is not a fix: a + # candidate equal to the input would splice to a no-op. + output = ("'push' is not a task. See 'git help'.\n" + "\n" + "Did you mean this?\n" + "\tpush\n") + command = Command('git push', output) + assert get_suggestion_candidates(command) is None + + +def test_declines_when_suggestion_below_floor(): + # burnnoabcd vs burnno1234 scores 2 * 6 / 20 = 0.6, under the + # len-10 floor 0.7, and is not a single edit (pinned in + # tests/resolvers/test_help_resolver.py arithmetic). + output = ("'burnnoabcd' is not a task.\n" + "\n" + "Did you mean this?\n" + "\tburnno1234\n") + command = Command('fake burnnoabcd', output) + assert get_suggestion_candidates(command) is None + + +def test_returns_none_when_ast_unavailable(monkeypatch): + monkeypatch.setattr(shell_ast, 'AST_AVAILABLE', False) + command = Command('git psuh', _GIT_PSUH_OUTPUT) + assert get_suggestion_candidates(command) is None + + +def test_corrects_live_git_output(tmpdir): + if shutil.which('git') is None: + pytest.skip('git is not available') + env = dict(os.environ, LC_ALL='C', LANG='C') + try: + proc = subprocess.Popen( + ['git', 'psuh'], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, env=env, cwd=str(tmpdir)) + _, stderr = proc.communicate(timeout=5) + except (OSError, subprocess.TimeoutExpired): + pytest.skip('git psuh probe failed') + output = stderr.decode('utf-8', 'replace') + if ('The most similar command' not in output + and 'Did you mean' not in output): + pytest.skip('git prints no suggestions on this machine') + command = Command('git psuh', output) + assert get_suggestion_candidates(command) == 'git push' diff --git a/thefuck/resolvers/error_suggestion.py b/thefuck/resolvers/error_suggestion.py new file mode 100644 index 0000000..5556685 --- /dev/null +++ b/thefuck/resolvers/error_suggestion.py @@ -0,0 +1,79 @@ +"""Auto-fixes from the failing binary's own error suggestions. + +Trigger: the failed command's output names an offending token +(``'X' is not a ...``, ``Unknown command 'X'``) and then suggests +corrections (``The most similar command ...`` / ``Did you mean ...``). +The token is replaced at the position the binary's error nominates — +head or subcommand — and only when exactly one distinct suggestion +passes the length-scaled typo floor, so ambiguity stays on the ask +path and the `git_not_command`-style rules keep handling the rest. + +Personal fork only: depends on bashlex (GPL-3+), do not distribute. +""" +import re + +from thefuck import shell_ast, typo +from thefuck.utils import get_all_matched_commands + +_SEPARATORS = ['The most similar command', 'Did you mean'] + +_TOKEN_PATTERNS = ( + re.compile(r"'([^']+)' is not a"), + re.compile(r"Unknown command '([^']+)'"), + re.compile(r"unknown command '([^']+)'"), +) + + +def get_suggestion_candidates(command): + """Returns the corrected script for `command`, or None. + + :type command: thefuck.types.Command + :rtype: str | None + + """ + if not shell_ast.AST_AVAILABLE: + return None + broken = _offending_token(command.output) + if broken is None: + return None + # A suggestion equal to the token would splice to a no-op run, + # and duplicates of one command are a single option, not + # ambiguity (unique-survivor semantics, as in sibling resolvers). + eligible = set(suggestion for suggestion in _suggestions(command.output) + if suggestion != broken + and typo.floor_ok(broken, suggestion)) + if len(eligible) != 1: + return None + replacement = eligible.pop() + # First occurrence wins (replace_argument precedent); raw spans + # keep their quotes, so a quoted token's whole span is replaced. + for segment in shell_ast.parse(command.script): + for word, start, end in segment.words: + if _unquoted(word) == broken: + return shell_ast.splice( + command.script, [(start, end, replacement)]) + return None + + +def _offending_token(output): + """Returns the token the error output names, or None.""" + for pattern in _TOKEN_PATTERNS: + match = pattern.search(output) + if match: + return match.group(1) + return None + + +def _suggestions(output): + """Returns the distinct quote-stripped suggestion lines.""" + return set(stripped for stripped in + (_unquoted(line) for line in + get_all_matched_commands(output, _SEPARATORS)) + if stripped) + + +def _unquoted(token): + if (len(token) > 1 and token[0] == token[-1] + and token[0] in ('"', "'")): + return token[1:-1] + return token