diff --git a/README.md b/README.md index 48b4b0fb3..724dc3569 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,7 @@ The following rules are enabled by default on specific platforms only: * `brew_update_formula` – turns `brew update ` into `brew upgrade `; * `dnf_no_such_command` – fixes mistyped DNF commands; * `nixos_cmd_not_found` – installs apps on NixOS; +* `nix_shell` – re-runs your command in a `nix-shell`; * `pacman` – installs app with `pacman` if it is not installed (uses `yay`, `pikaur` or `yaourt` if available); * `pacman_invalid_option` – replaces lowercase `pacman` options with uppercase. * `pacman_not_found` – fixes package name with `pacman`, `yay`, `pikaur` or `yaourt`. diff --git a/tests/rules/test_nix_shell.py b/tests/rules/test_nix_shell.py new file mode 100644 index 000000000..69ba14872 --- /dev/null +++ b/tests/rules/test_nix_shell.py @@ -0,0 +1,90 @@ +import pytest +from thefuck.rules.nix_shell import get_nixpkgs_names, match, get_new_command +from thefuck.types import Command +from unittest.mock import patch, MagicMock + + +@pytest.mark.parametrize( + "script,output,nixpkgs_names", + [ + # output can be retrived by running `THEFUCK_DEBUG=true thefuck lsof` + ( + "lsof", + "/nix/store/p6dlr3skfhxpyphipg2bqnj52999banh-bash-5.2-p15/bin/sh: line 1: lsof: command not found", + ["lsof"], + ), + ], +) +def test_match(script, output, nixpkgs_names): + with patch("thefuck.rules.nix_shell.get_nixpkgs_names") as mocked_get_nixpkgs_names: + mocked_get_nixpkgs_names.return_value = nixpkgs_names + command = Command(script, output) + assert match(command) + + +@pytest.mark.parametrize( + "script,output,nixpkgs_names", + [ + # output can be retrived by running `THEFUCK_DEBUG=true thefuck foo` + ( + "foo", + "/nix/store/p6dlr3skfhxpyphipg2bqnj52999banh-bash-5.2-p15/bin/sh: line 1: foo: command not found", + [], + ), + ], +) +def test_not_match(script, output, nixpkgs_names): + with patch("thefuck.rules.nix_shell.get_nixpkgs_names") as mocked_get_nixpkgs_names: + mocked_get_nixpkgs_names.return_value = nixpkgs_names + command = Command(script, output) + assert not match(command) + + +@pytest.mark.parametrize( + "script,nixpkgs_names,new_command", + [ + ( + "lsof -i :3000", + ["busybox", "lsof"], + [ + 'nix-shell -p busybox --run "lsof -i :3000"', + 'nix-shell -p lsof --run "lsof -i :3000"', + ], + ), + ("xev", ["xorg.xev"], ['nix-shell -p xorg.xev --run "xev"']), + ], +) +def test_get_new_command(script, nixpkgs_names, new_command): + """Check that flags and params are preserved in the new command""" + + command = Command(script, "") + with patch("thefuck.rules.nix_shell.get_nixpkgs_names") as mocked_get_nixpkgs_names: + mocked_get_nixpkgs_names.return_value = nixpkgs_names + assert get_new_command(command) == new_command + + +# Mocks the stderr of `command-not-found QUERY`. Mock values are retrieved by +# running `THEFUCK_DEBUG=true thefuck command-not-found lsof`. +mocked_cnf_stderr = { + "lsof": "The program 'lsof' is not in your PATH. It is provided by several packages.\nYou can make it available in an ephemeral shell by typing one of the following:\n nix-shell -p busybox\n nix-shell -p lsof", + "xev": "The program 'xev' is not in your PATH. You can make it available in an ephemeral shell by typing:\n nix-shell -p xorg.xev", + "foo": "foo: command not found", +} + + +@pytest.mark.parametrize( + "bin,expected_nixpkgs_names,cnf_stderr", + [ + ("lsof", ["busybox", "lsof"], mocked_cnf_stderr["lsof"]), + ("xev", ["xorg.xev"], mocked_cnf_stderr["xev"]), + ("foo", [], mocked_cnf_stderr["foo"]), + ], +) +def test_get_nixpkgs_names(bin, expected_nixpkgs_names, cnf_stderr): + """Check that `get_nixpkgs_names` returns the correct names""" + + with patch("subprocess.run") as mocked_run: + result = MagicMock() + result.stderr = cnf_stderr + mocked_run.return_value = result + assert get_nixpkgs_names(bin) == expected_nixpkgs_names diff --git a/thefuck/rules/nix_shell.py b/thefuck/rules/nix_shell.py new file mode 100644 index 000000000..7bfba7eac --- /dev/null +++ b/thefuck/rules/nix_shell.py @@ -0,0 +1,51 @@ +from thefuck.specific.nix import nix_available +import subprocess + +enabled_by_default = nix_available + +# Set the priority just ahead of `fix_file` rule, which can generate low quality matches due +# to the sheer amount of paths in the nix store. +priority = 999 + + +def get_nixpkgs_names(bin): + """ + Returns the name of the Nix package that provides the given binary. It uses the + `command-not-found` binary to do so, which is how nix-shell generates it's own suggestions. + """ + + result = subprocess.run( + ["command-not-found", bin], stderr=subprocess.PIPE, universal_newlines=True + ) + + # The suggestion, if any, will be found in stderr. Upstream definition: https://github.com/NixOS/nixpkgs/blob/b6fbd87328f8eabd82d65cc8f75dfb74341b0ace/nixos/modules/programs/command-not-found/command-not-found.nix#L48-L90 + text = result.stderr + + # return early if binary is not available through nix + if "nix-shell" not in text: + return [] + + nixpkgs_names = [ + line.split()[-1] for line in text.splitlines() if "nix-shell -p" in line + ] + return nixpkgs_names + + +def match(command): + bin = command.script_parts[0] + return ( + "command not found" in command.output # only match commands which had exit code: 127 # noqa: E501 + and get_nixpkgs_names(bin) # only match commands which could be made available through nix # noqa: E501 + ) + + +def get_new_command(command): + bin = command.script_parts[0] + nixpkgs_names = get_nixpkgs_names(bin) + + # Construct a command for each package name + commands = [ + 'nix-shell -p {} --run "{}"'.format(name, command.script) + for name in nixpkgs_names + ] + return commands