Apply the failing command's own fix suggestions
This commit is contained in:
@@ -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'
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user