From 31d999f08285b3e6cc13c875c3797012992a5602 Mon Sep 17 00:00:00 2001 From: nbe Date: Fri, 28 Jul 2023 19:43:21 +0300 Subject: [PATCH 1/6] feat: new rule for `nix-shell` --- README.md | 1 + tests/rules/test_nix_shell.py | 39 ++++++++++++++++++++++++++++ thefuck/rules/nix_shell.py | 48 +++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 tests/rules/test_nix_shell.py create mode 100644 thefuck/rules/nix_shell.py 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..567be773e --- /dev/null +++ b/tests/rules/test_nix_shell.py @@ -0,0 +1,39 @@ +import pytest +from thefuck.rules.nix_shell import get_nixpkgs_name, get_new_command +from thefuck.rules import nix_shell +from thefuck.types import Command +from unittest.mock import patch + +mocked_nixpkgs = { + "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( + "command, output", [("lsof", "lsof"), ("xev", "xorg.xev"), ("foo", "")] +) +def test_get_nixpkgs_name(command, output): + """ Check that `get_nixpkgs_name` returns the correct name """ + + nix_shell.nixpkgs_name = "" + with patch("subprocess.run") as mocked_run: + instance = mocked_run.return_value + instance.stderr = mocked_nixpkgs[command] + assert get_nixpkgs_name(command) == output + + +# check that flags and params are preserved for the new command +@pytest.mark.parametrize('command_script, new_command', [ + ('lsof -i :3000', 'nix-shell -p lsof --run "lsof -i :3000"'), + ('xev', 'nix-shell -p xorg.xev --run "xev"')]) +def test_get_new_command(command_script, new_command): + """ Check that flags and params are preserved in the new command """ + + nix_shell.nixpkgs_name = "" + command = Command(command_script, '') + with patch('subprocess.run') as mocked_run: + instance = mocked_run.return_value + instance.stderr = mocked_nixpkgs[command.script_parts[0]] + assert get_new_command(command) == new_command diff --git a/thefuck/rules/nix_shell.py b/thefuck/rules/nix_shell.py new file mode 100644 index 000000000..1cfcc6514 --- /dev/null +++ b/thefuck/rules/nix_shell.py @@ -0,0 +1,48 @@ +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 + + +nixpkgs_name = "" + + +def get_nixpkgs_name(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. + """ + + # Avoid getting the nixpkgs_name twice + global nixpkgs_name + if nixpkgs_name: + return nixpkgs_name + + result = subprocess.run( + ["command-not-found", bin], stderr=subprocess.PIPE, universal_newlines=True + ) + + # return early if package is not available through nix + if "nix-shell" not in result.stderr: + return "" + + nixpkgs_name = result.stderr.split()[-1] if result.stderr.split() else "" + return nixpkgs_name + + +def match(command): + bin = command.script_parts[0] + return ( + "nix-shell" not in command.script + and "command not found" in command.output + and get_nixpkgs_name(bin) + ) + + +def get_new_command(command): + bin = command.script_parts[0] + return 'nix-shell -p {0} --run "{1}"'.format(get_nixpkgs_name(bin), command.script) From 63a4790e7826a82a1841c910b31a76dad9d81625 Mon Sep 17 00:00:00 2001 From: nbe Date: Fri, 28 Jul 2023 22:09:30 +0300 Subject: [PATCH 2/6] simplify --- tests/rules/test_nix_shell.py | 21 +++++++++++---------- thefuck/rules/nix_shell.py | 8 -------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/tests/rules/test_nix_shell.py b/tests/rules/test_nix_shell.py index 567be773e..1d1286af5 100644 --- a/tests/rules/test_nix_shell.py +++ b/tests/rules/test_nix_shell.py @@ -1,6 +1,5 @@ import pytest from thefuck.rules.nix_shell import get_nixpkgs_name, get_new_command -from thefuck.rules import nix_shell from thefuck.types import Command from unittest.mock import patch @@ -15,9 +14,8 @@ "command, output", [("lsof", "lsof"), ("xev", "xorg.xev"), ("foo", "")] ) def test_get_nixpkgs_name(command, output): - """ Check that `get_nixpkgs_name` returns the correct name """ + """Check that `get_nixpkgs_name` returns the correct name""" - nix_shell.nixpkgs_name = "" with patch("subprocess.run") as mocked_run: instance = mocked_run.return_value instance.stderr = mocked_nixpkgs[command] @@ -25,15 +23,18 @@ def test_get_nixpkgs_name(command, output): # check that flags and params are preserved for the new command -@pytest.mark.parametrize('command_script, new_command', [ - ('lsof -i :3000', 'nix-shell -p lsof --run "lsof -i :3000"'), - ('xev', 'nix-shell -p xorg.xev --run "xev"')]) +@pytest.mark.parametrize( + "command_script, new_command", + [ + ("lsof -i :3000", 'nix-shell -p lsof --run "lsof -i :3000"'), + ("xev", 'nix-shell -p xorg.xev --run "xev"'), + ], +) def test_get_new_command(command_script, new_command): - """ Check that flags and params are preserved in the new command """ + """Check that flags and params are preserved in the new command""" - nix_shell.nixpkgs_name = "" - command = Command(command_script, '') - with patch('subprocess.run') as mocked_run: + command = Command(command_script, "") + with patch("subprocess.run") as mocked_run: instance = mocked_run.return_value instance.stderr = mocked_nixpkgs[command.script_parts[0]] assert get_new_command(command) == new_command diff --git a/thefuck/rules/nix_shell.py b/thefuck/rules/nix_shell.py index 1cfcc6514..f4480e3cf 100644 --- a/thefuck/rules/nix_shell.py +++ b/thefuck/rules/nix_shell.py @@ -8,20 +8,12 @@ priority = 999 -nixpkgs_name = "" - - def get_nixpkgs_name(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. """ - # Avoid getting the nixpkgs_name twice - global nixpkgs_name - if nixpkgs_name: - return nixpkgs_name - result = subprocess.run( ["command-not-found", bin], stderr=subprocess.PIPE, universal_newlines=True ) From d0ce1b340f8d3ab2535f8542a59d5b1742b535b1 Mon Sep 17 00:00:00 2001 From: nbe Date: Mon, 31 Jul 2023 05:34:53 +0300 Subject: [PATCH 3/6] annotate match conditions --- thefuck/rules/nix_shell.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/thefuck/rules/nix_shell.py b/thefuck/rules/nix_shell.py index f4480e3cf..b3568febd 100644 --- a/thefuck/rules/nix_shell.py +++ b/thefuck/rules/nix_shell.py @@ -29,9 +29,9 @@ def get_nixpkgs_name(bin): def match(command): bin = command.script_parts[0] return ( - "nix-shell" not in command.script - and "command not found" in command.output - and get_nixpkgs_name(bin) + "nix-shell" not in command.script # avoid recursion # noqa: E501 + and "command not found" in command.output # only match commands which had exit code: 127 # noqa: E501 + and get_nixpkgs_name(bin) # only match commands which could be made available through nix # noqa: E501 ) From 872c8c79eacf78bc2e9feee29a14321fb5cd2ade Mon Sep 17 00:00:00 2001 From: nbe Date: Mon, 31 Jul 2023 10:40:35 +0300 Subject: [PATCH 4/6] remove redundant comment --- tests/rules/test_nix_shell.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/rules/test_nix_shell.py b/tests/rules/test_nix_shell.py index 1d1286af5..c2f3f5c14 100644 --- a/tests/rules/test_nix_shell.py +++ b/tests/rules/test_nix_shell.py @@ -22,7 +22,6 @@ def test_get_nixpkgs_name(command, output): assert get_nixpkgs_name(command) == output -# check that flags and params are preserved for the new command @pytest.mark.parametrize( "command_script, new_command", [ From f17fec9d9c593257e47b7c5a7f4203362b2b69a5 Mon Sep 17 00:00:00 2001 From: nbe Date: Tue, 1 Aug 2023 15:27:30 +0300 Subject: [PATCH 5/6] better unit tests --- tests/rules/test_nix_shell.py | 82 +++++++++++++++++++++++++---------- thefuck/rules/nix_shell.py | 14 +++--- 2 files changed, 67 insertions(+), 29 deletions(-) diff --git a/tests/rules/test_nix_shell.py b/tests/rules/test_nix_shell.py index c2f3f5c14..6c69259a6 100644 --- a/tests/rules/test_nix_shell.py +++ b/tests/rules/test_nix_shell.py @@ -1,39 +1,75 @@ import pytest -from thefuck.rules.nix_shell import get_nixpkgs_name, get_new_command +from thefuck.rules.nix_shell import get_nixpkgs_name, match, get_new_command from thefuck.types import Command -from unittest.mock import patch - -mocked_nixpkgs = { - "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", -} +from unittest.mock import patch, MagicMock @pytest.mark.parametrize( - "command, output", [("lsof", "lsof"), ("xev", "xorg.xev"), ("foo", "")] + "script,output,nixpkgs_name", + [ + # 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_get_nixpkgs_name(command, output): - """Check that `get_nixpkgs_name` returns the correct name""" +def test_match(script, output, nixpkgs_name): + with patch("thefuck.rules.nix_shell.get_nixpkgs_name") as mocked_get_nixpkgs_name: + mocked_get_nixpkgs_name.return_value = nixpkgs_name + command = Command(script, output) + assert match(command) - with patch("subprocess.run") as mocked_run: - instance = mocked_run.return_value - instance.stderr = mocked_nixpkgs[command] - assert get_nixpkgs_name(command) == output + +@pytest.mark.parametrize( + "script,output,nixpkgs_name", + [ + # 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_name): + with patch("thefuck.rules.nix_shell.get_nixpkgs_name") as mocked_get_nixpkgs_name: + mocked_get_nixpkgs_name.return_value = nixpkgs_name + command = Command(script, output) + assert not match(command) @pytest.mark.parametrize( - "command_script, new_command", + "script,nixpkgs_name,new_command", [ - ("lsof -i :3000", 'nix-shell -p lsof --run "lsof -i :3000"'), - ("xev", 'nix-shell -p xorg.xev --run "xev"'), + ("lsof -i :3000", "lsof", 'nix-shell -p lsof --run "lsof -i :3000"'), + ("xev", "xorg.xev", 'nix-shell -p xorg.xev --run "xev"'), ], ) -def test_get_new_command(command_script, new_command): +def test_get_new_command(script, nixpkgs_name, new_command): """Check that flags and params are preserved in the new command""" - command = Command(command_script, "") - with patch("subprocess.run") as mocked_run: - instance = mocked_run.return_value - instance.stderr = mocked_nixpkgs[command.script_parts[0]] + command = Command(script, "") + with patch("thefuck.rules.nix_shell.get_nixpkgs_name") as mocked_get_nixpkgs_name: + mocked_get_nixpkgs_name.return_value = nixpkgs_name 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_name,cnf_stderr", + [ + ("lsof", "lsof", mocked_cnf_stderr["lsof"]), + ("xev", "xorg.xev", mocked_cnf_stderr["xev"]), + ("foo", "", mocked_cnf_stderr["foo"]), + ], +) +def test_get_nixpkgs_name(bin, expected_nixpkgs_name, cnf_stderr): + """Check that `get_nixpkgs_name` returns the correct name""" + + with patch("subprocess.run") as mocked_run: + result = MagicMock() + result.stderr = cnf_stderr + mocked_run.return_value = result + assert get_nixpkgs_name(bin) == expected_nixpkgs_name diff --git a/thefuck/rules/nix_shell.py b/thefuck/rules/nix_shell.py index b3568febd..231eb5854 100644 --- a/thefuck/rules/nix_shell.py +++ b/thefuck/rules/nix_shell.py @@ -18,20 +18,22 @@ def get_nixpkgs_name(bin): ["command-not-found", bin], stderr=subprocess.PIPE, universal_newlines=True ) - # return early if package is not available through nix - if "nix-shell" not in result.stderr: + # 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_name = result.stderr.split()[-1] if result.stderr.split() else "" + nixpkgs_name = text.split()[-1] if text.split() else "" return nixpkgs_name def match(command): bin = command.script_parts[0] return ( - "nix-shell" not in command.script # avoid recursion # noqa: E501 - and "command not found" in command.output # only match commands which had exit code: 127 # noqa: E501 - and get_nixpkgs_name(bin) # only match commands which could be made available through nix # noqa: E501 + "command not found" in command.output # only match commands which had exit code: 127 # noqa: E501 + and get_nixpkgs_name(bin) # only match commands which could be made available through nix # noqa: E501 ) From 6202d325100c51076692d998e36a293955101365 Mon Sep 17 00:00:00 2001 From: nbe Date: Tue, 1 Aug 2023 17:48:48 +0300 Subject: [PATCH 6/6] handle multiple suggestions --- tests/rules/test_nix_shell.py | 63 ++++++++++++++++++++++------------- thefuck/rules/nix_shell.py | 21 ++++++++---- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/tests/rules/test_nix_shell.py b/tests/rules/test_nix_shell.py index 6c69259a6..69ba14872 100644 --- a/tests/rules/test_nix_shell.py +++ b/tests/rules/test_nix_shell.py @@ -1,50 +1,65 @@ import pytest -from thefuck.rules.nix_shell import get_nixpkgs_name, match, get_new_command +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_name", + "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"), + ( + "lsof", + "/nix/store/p6dlr3skfhxpyphipg2bqnj52999banh-bash-5.2-p15/bin/sh: line 1: lsof: command not found", + ["lsof"], + ), ], ) -def test_match(script, output, nixpkgs_name): - with patch("thefuck.rules.nix_shell.get_nixpkgs_name") as mocked_get_nixpkgs_name: - mocked_get_nixpkgs_name.return_value = nixpkgs_name +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_name", + "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", ""), + ( + "foo", + "/nix/store/p6dlr3skfhxpyphipg2bqnj52999banh-bash-5.2-p15/bin/sh: line 1: foo: command not found", + [], + ), ], ) -def test_not_match(script, output, nixpkgs_name): - with patch("thefuck.rules.nix_shell.get_nixpkgs_name") as mocked_get_nixpkgs_name: - mocked_get_nixpkgs_name.return_value = nixpkgs_name +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_name,new_command", + "script,nixpkgs_names,new_command", [ - ("lsof -i :3000", "lsof", 'nix-shell -p lsof --run "lsof -i :3000"'), - ("xev", "xorg.xev", 'nix-shell -p xorg.xev --run "xev"'), + ( + "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_name, new_command): +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_name") as mocked_get_nixpkgs_name: - mocked_get_nixpkgs_name.return_value = nixpkgs_name + 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 @@ -58,18 +73,18 @@ def test_get_new_command(script, nixpkgs_name, new_command): @pytest.mark.parametrize( - "bin,expected_nixpkgs_name,cnf_stderr", + "bin,expected_nixpkgs_names,cnf_stderr", [ - ("lsof", "lsof", mocked_cnf_stderr["lsof"]), - ("xev", "xorg.xev", mocked_cnf_stderr["xev"]), - ("foo", "", mocked_cnf_stderr["foo"]), + ("lsof", ["busybox", "lsof"], mocked_cnf_stderr["lsof"]), + ("xev", ["xorg.xev"], mocked_cnf_stderr["xev"]), + ("foo", [], mocked_cnf_stderr["foo"]), ], ) -def test_get_nixpkgs_name(bin, expected_nixpkgs_name, cnf_stderr): - """Check that `get_nixpkgs_name` returns the correct name""" +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_name(bin) == expected_nixpkgs_name + assert get_nixpkgs_names(bin) == expected_nixpkgs_names diff --git a/thefuck/rules/nix_shell.py b/thefuck/rules/nix_shell.py index 231eb5854..7bfba7eac 100644 --- a/thefuck/rules/nix_shell.py +++ b/thefuck/rules/nix_shell.py @@ -8,7 +8,7 @@ priority = 999 -def get_nixpkgs_name(bin): +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. @@ -23,20 +23,29 @@ def get_nixpkgs_name(bin): # return early if binary is not available through nix if "nix-shell" not in text: - return "" + return [] - nixpkgs_name = text.split()[-1] if text.split() else "" - return nixpkgs_name + 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_name(bin) # only match commands which could be made available through nix # 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] - return 'nix-shell -p {0} --run "{1}"'.format(get_nixpkgs_name(bin), command.script) + 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