ab49450467
New thefuck/compose.py resolve() iterates the auto-run sources (learned-db exact, error-suggestion, history, path guess, help) per round on the previous round's script until stable, a revisit aborts or no source has a safe candidate; dangerous candidates are declined mid-round and consultation continues. Output-aware sources get a synthesized Command wrapping the round's script with the original failure's output. Zero accepted steps return None so the original failing script is never auto-executed; no round cap (termination contract: visited-set + fixed-range/word-count-preserving sources, stated in the module docstring; a compose-local memo acts only as a determinism tripwire). fix_command runs the inline chain through the composer; under settings.repeat or a non-nushell shell it records immediately before the run unless the only step replayed a learned-db exact hit; under nushell it closes the learned db first and records on returncode 0, forgets on failure (poison self-heal; CorrectedCommand.run now stashes the nu returncode on the instance). learned.forget(original, corrected) is the exact inverse of record (cmd: + matching-count word:/part: deletes, assembly failures are a documented no-op); learned.close_db is idempotent and atexit now registers the stable close_db method exactly once across close/reopen cycles. Test migration (authorized): test_fix_command_learned.py rebased to composer semantics (per-round consultation incl. error-suggestion; input-aware fakes so round 2 declines, preserving the recordings==[] pin in test_exact_learned_wins_over_guess).
369 lines
16 KiB
Python
369 lines
16 KiB
Python
import pytest
|
|
|
|
from thefuck import shell_ast
|
|
from thefuck.learned import LearnedCorrections
|
|
|
|
|
|
@pytest.fixture
|
|
def learned(tmp_path):
|
|
lc = LearnedCorrections()
|
|
lc._db = {}
|
|
return lc
|
|
|
|
|
|
class TestRecord(object):
|
|
def test_noop_when_scripts_identical(self, learned):
|
|
learned.record("git push", "git push")
|
|
assert len(learned.db) == 0
|
|
|
|
def test_stores_full_command_mapping(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
assert (
|
|
learned.db["cmd:git psuh origin main"]["corrected"]
|
|
== "git push origin main"
|
|
)
|
|
|
|
def test_increments_count_on_repeat(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
learned.record("git psuh", "git push")
|
|
assert learned.db["cmd:git psuh"]["count"] == 2
|
|
|
|
def test_updates_timestamp(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
first_ts = learned.db["cmd:git psuh"]["timestamp"]
|
|
learned.record("git psuh", "git push")
|
|
assert learned.db["cmd:git psuh"]["timestamp"] >= first_ts
|
|
|
|
def test_stores_command_word_replacement(self, learned):
|
|
learned.record("pyhton script.py", "python script.py")
|
|
assert learned.db["word:pyhton"]["replacement"] == "python"
|
|
|
|
def test_stores_subcommand_replacement(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
assert learned.db["part:git:psuh"]["replacement"] == "push"
|
|
|
|
def test_stores_multiple_word_diffs(self, learned):
|
|
learned.record("gti comit -m msg", "git commit -m msg")
|
|
assert learned.db["word:gti"]["replacement"] == "git"
|
|
assert learned.db["part:git:comit"]["replacement"] == "commit"
|
|
|
|
def test_no_word_level_when_lengths_differ(self, learned):
|
|
learned.record("git push", "git push --set-upstream origin main")
|
|
assert "cmd:git push" in learned.db
|
|
assert not any(
|
|
k.startswith("word:") or k.startswith("part:") for k in learned.db
|
|
)
|
|
|
|
def test_updates_correction_on_changed_choice(self, learned):
|
|
learned.record("apt install vim", "sudo apt install vim")
|
|
learned.record("apt install vim", "apt-get install vim")
|
|
assert learned.db["cmd:apt install vim"]["corrected"] == "apt-get install vim"
|
|
assert learned.db["cmd:apt install vim"]["count"] == 2
|
|
|
|
|
|
class TestGetCorrection(object):
|
|
def test_exact_full_command_match(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
assert learned.get_correction("git psuh origin main") == "git push origin main"
|
|
|
|
def test_generalises_subcommand_to_different_args(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
assert learned.get_correction("git psuh origin dev") == "git push origin dev"
|
|
|
|
def test_generalises_command_word(self, learned):
|
|
learned.record("pyhton script.py", "python script.py")
|
|
assert learned.get_correction("pyhton other.py") == "python other.py"
|
|
|
|
def test_combined_command_and_subcommand(self, learned):
|
|
learned.record("gti comit -m msg", "git commit -m msg")
|
|
assert learned.get_correction('gti comit -m "other"') == 'git commit -m "other"'
|
|
|
|
def test_returns_none_when_no_match(self, learned):
|
|
assert learned.get_correction("totally unknown cmd") is None
|
|
|
|
def test_returns_none_for_empty_script(self, learned):
|
|
assert learned.get_correction("") is None
|
|
|
|
def test_prefers_full_command_over_word_level(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
learned.db["cmd:git psuh origin main"]["corrected"] = (
|
|
"git push --force origin main"
|
|
)
|
|
assert (
|
|
learned.get_correction("git psuh origin main")
|
|
== "git push --force origin main"
|
|
)
|
|
|
|
def test_word_level_only_replaces_known_tokens(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
result = learned.get_correction("git psuh origin dev")
|
|
assert result == "git push origin dev"
|
|
|
|
def test_cross_resolve_command_and_part(self, learned):
|
|
"""When both cmd word and subcommand are typos, parts stored
|
|
under the corrected cmd name still resolve."""
|
|
learned.record("gti psuh origin main", "git push origin main")
|
|
assert learned.get_correction("gti psuh origin dev") == "git push origin dev"
|
|
|
|
def test_single_word_command(self, learned):
|
|
learned.record("sl", "ls")
|
|
assert learned.get_correction("sl") == "ls"
|
|
|
|
|
|
class TestGuessFromPath(object):
|
|
@pytest.fixture
|
|
def path_bins(self, monkeypatch):
|
|
def setup(executables, existing=()):
|
|
monkeypatch.setattr('thefuck.learned.get_all_executables',
|
|
lambda: list(executables))
|
|
monkeypatch.setattr('thefuck.learned.which',
|
|
lambda token: token in existing)
|
|
return setup
|
|
|
|
def test_guesses_unique_close_match(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep', 'sed'])
|
|
assert learned.guess_from_path('cler') == 'clear'
|
|
|
|
def test_keeps_arguments(self, learned, path_bins):
|
|
path_bins(executables=['python', 'pydoc', 'grep'])
|
|
assert (learned.guess_from_path('pyhton script.py')
|
|
== 'python script.py')
|
|
|
|
def test_returns_none_when_ambiguous(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'clean'])
|
|
assert learned.guess_from_path('clea') is None
|
|
|
|
def test_returns_none_when_token_is_executable(self, learned, path_bins):
|
|
path_bins(executables=['clear'], existing=['clear'])
|
|
assert learned.guess_from_path('clear') is None
|
|
|
|
def test_returns_none_for_path_like_token(self, learned, path_bins):
|
|
path_bins(executables=['git', 'grep', 'sed'])
|
|
assert learned.guess_from_path('./gti push') is None
|
|
|
|
def test_returns_none_for_token_with_extension(self, learned, path_bins):
|
|
path_bins(executables=['git', 'grep', 'sed'])
|
|
assert learned.guess_from_path('giti.py x') is None
|
|
|
|
def test_returns_none_when_first_char_differs(self, learned, path_bins):
|
|
path_bins(executables=['top'])
|
|
assert learned.guess_from_path('htop') is None
|
|
|
|
def test_returns_none_below_length_scaled_floor(self, learned,
|
|
path_bins):
|
|
# 10-char token vs executable sharing only the 6 leading
|
|
# chars scores 2 * 6 / 20 = 0.6, under the len-10 floor 0.7,
|
|
# and the pair is not a single edit.
|
|
path_bins(executables=['burnnoabcd'])
|
|
assert learned.guess_from_path('burnno1234 file') is None
|
|
|
|
def test_guesses_via_ratio_above_scaled_floor(self, learned,
|
|
path_bins):
|
|
# Three trailing substitutions score exactly the len-10
|
|
# floor 0.7 — a pair the old fixed 0.8 cutoff declined — and
|
|
# no other executable shares the first char, so the ratio
|
|
# path alone admits the match.
|
|
path_bins(executables=['abcdefgxyz', 'grep', 'sed'])
|
|
assert (learned.guess_from_path('abcdefghij file')
|
|
== 'abcdefgxyz file')
|
|
|
|
def test_guesses_after_sudo(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep', 'sed'])
|
|
assert learned.guess_from_path('sudo cler') == 'sudo clear'
|
|
|
|
def test_returns_none_for_empty_script(self, learned, path_bins):
|
|
path_bins(executables=['git'])
|
|
assert learned.guess_from_path('') is None
|
|
|
|
|
|
class TestGuessFromPathSegments(object):
|
|
pytestmark = pytest.mark.skipif(
|
|
not shell_ast.AST_AVAILABLE, reason='bashlex required')
|
|
|
|
@pytest.fixture
|
|
def path_bins(self, monkeypatch):
|
|
def setup(executables, existing=()):
|
|
monkeypatch.setattr('thefuck.learned.get_all_executables',
|
|
lambda: list(executables))
|
|
monkeypatch.setattr('thefuck.learned.which',
|
|
lambda token: token in existing)
|
|
return setup
|
|
|
|
def test_fixes_head_of_every_pipe_segment(self, learned, path_bins):
|
|
path_bins(executables=['git', 'grep', 'sed'])
|
|
assert (learned.guess_from_path('gi psuh | gre -i foo')
|
|
== 'git psuh | grep -i foo')
|
|
|
|
def test_fixes_single_edit_typos(self, learned, path_bins):
|
|
# gti -> git scores 2 * 2 / 6 = 0.667 and greo -> grep scores
|
|
# 2 * 3 / 8 = 0.75; each is 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')
|
|
== 'git psuh | grep -i foo')
|
|
|
|
def test_returns_none_when_all_heads_executable(self, learned, path_bins):
|
|
path_bins(executables=['git', 'grep'], existing=['git', 'grep'])
|
|
assert learned.guess_from_path('git psuh | grep -i foo') is None
|
|
|
|
def test_skips_ambiguous_segment_fixes_others(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'clean', 'grep'])
|
|
assert (learned.guess_from_path('clea psuh | gre -i foo')
|
|
== 'clea psuh | grep -i foo')
|
|
|
|
def test_guesses_after_sudo_in_pipe(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep'])
|
|
assert (learned.guess_from_path('sudo cler | gre -i foo')
|
|
== 'sudo clear | grep -i foo')
|
|
|
|
def test_guesses_after_sudo_flat(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep', 'sed'])
|
|
assert learned.guess_from_path('sudo cler') == 'sudo clear'
|
|
|
|
def test_replaces_quoted_head_whole(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep', 'sed'])
|
|
assert learned.guess_from_path('"cler" psuh') == 'clear psuh'
|
|
|
|
def test_preserves_spacing_between_words(self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep', 'sed'])
|
|
assert learned.guess_from_path('cler psuh') == 'clear psuh'
|
|
|
|
def test_unparseable_script_falls_back_to_flat_view(
|
|
self, learned, path_bins):
|
|
path_bins(executables=['clear', 'grep', 'sed'])
|
|
assert (
|
|
learned.guess_from_path('cler psuh; case $x in y) ;; esac')
|
|
== 'clear psuh; case $x in y) ;; esac')
|
|
|
|
|
|
class TestClear(object):
|
|
def test_removes_all_entries(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
learned.record("pyhton x.py", "python x.py")
|
|
learned.clear()
|
|
assert len(learned.db) == 0
|
|
|
|
def test_no_matches_after_clear(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
learned.clear()
|
|
assert learned.get_correction("git psuh") is None
|
|
|
|
|
|
class TestForget(object):
|
|
def test_removes_cmd_and_derived_part_entries(self, learned):
|
|
learned.record("git psuh origin main", "git push origin main")
|
|
learned.forget("git psuh origin main", "git push origin main")
|
|
assert "cmd:git psuh origin main" not in learned.db
|
|
assert "part:git:psuh" not in learned.db
|
|
|
|
def test_removes_cmd_and_derived_word_entry(self, learned):
|
|
learned.record("pyhton script.py", "python script.py")
|
|
learned.forget("pyhton script.py", "python script.py")
|
|
assert "cmd:pyhton script.py" not in learned.db
|
|
assert "word:pyhton" not in learned.db
|
|
|
|
def test_removes_every_diverged_position(self, learned):
|
|
learned.record("gti comit -m msg", "git commit -m msg")
|
|
learned.forget("gti comit -m msg", "git commit -m msg")
|
|
assert "cmd:gti comit -m msg" not in learned.db
|
|
assert "word:gti" not in learned.db
|
|
assert "part:git:comit" not in learned.db
|
|
|
|
def test_deletes_rather_than_decrements(self, learned):
|
|
for _ in range(3):
|
|
learned.record("git psuh", "git push")
|
|
learned.forget("git psuh", "git push")
|
|
assert "cmd:git psuh" not in learned.db
|
|
assert "part:git:psuh" not in learned.db
|
|
|
|
def test_keeps_unrelated_entries(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
learned.record("pyhton x.py", "python x.py")
|
|
learned.forget("git psuh", "git push")
|
|
assert "cmd:pyhton x.py" in learned.db
|
|
assert "word:pyhton" in learned.db
|
|
|
|
def test_mismatched_token_counts_forgets_cmd_only(self, learned):
|
|
learned.record("git push", "git push --set-upstream origin main")
|
|
learned.forget("git push", "git push --set-upstream origin main")
|
|
assert "cmd:git push" not in learned.db
|
|
assert not any(k.startswith(("word:", "part:"))
|
|
for k in learned.db)
|
|
|
|
def test_assembly_failure_is_a_noop(self, learned):
|
|
# "gti comit" resolves by assembling word:gti alone (no cmd:
|
|
# entry of its own); those mappings were validated by an
|
|
# earlier success of a different command and must stand.
|
|
learned.record("gti push", "git push")
|
|
assert learned.get_correction("gti comit") == "git comit"
|
|
learned.forget("gti comit", "git comit")
|
|
assert "word:gti" in learned.db
|
|
assert "cmd:gti comit" not in learned.db
|
|
|
|
def test_unknown_command_is_a_noop(self, learned):
|
|
learned.forget("never recorded", "anything at all")
|
|
assert len(learned.db) == 0
|
|
|
|
|
|
class TestCloseDb(object):
|
|
def test_close_sets_db_none_and_is_idempotent(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
learned.close_db()
|
|
assert learned._db is None
|
|
learned.close_db()
|
|
assert learned._db is None
|
|
|
|
def test_reopen_after_close_still_finds_entries(
|
|
self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
|
lc = LearnedCorrections()
|
|
lc.record("git psuh", "git push")
|
|
first_handle = lc._db
|
|
lc.close_db()
|
|
assert lc.get_correction("git psuh") == "git push"
|
|
assert lc._db is not None
|
|
assert lc._db is not first_handle
|
|
|
|
def test_atexit_binds_close_db_once_across_reopens(
|
|
self, tmp_path, monkeypatch):
|
|
registrations = []
|
|
monkeypatch.setattr(
|
|
"thefuck.learned.atexit.register", registrations.append)
|
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
|
lc = LearnedCorrections()
|
|
lc.record("git psuh", "git push")
|
|
lc.close_db()
|
|
lc.record("gti comit -m msg", "git commit -m msg")
|
|
lc.close_db()
|
|
assert registrations == [lc.close_db]
|
|
|
|
|
|
class TestRoundTrip(object):
|
|
def test_record_then_match(self, learned):
|
|
learned.record("docker bilud .", "docker build .")
|
|
assert learned.get_correction("docker bilud .") == "docker build ."
|
|
|
|
def test_record_then_generalise(self, learned):
|
|
learned.record("docker bilud -t foo .", "docker build -t foo .")
|
|
assert (
|
|
learned.get_correction("docker bilud -t bar .") == "docker build -t bar ."
|
|
)
|
|
|
|
def test_multiple_distinct_commands(self, learned):
|
|
learned.record("git psuh", "git push")
|
|
learned.record("pyhton x.py", "python x.py")
|
|
assert learned.get_correction("git psuh") == "git push"
|
|
assert learned.get_correction("pyhton y.py") == "python y.py"
|