diff --git a/.travis.yml b/.travis.yml index b124bb3a..c14b697a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,4 +6,4 @@ python: install: - python setup.py develop - pip install -r requirements.txt -script: py.test +script: py.test -v diff --git a/README.md b/README.md index d3016f9e..43389f32 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,11 @@ Or in your `.zshrc`: alias fuck='eval $(thefuck $(fc -ln -1 | tail -n 1)); fc -R' ``` +If you are using `tcsh`: +```tcsh +alias fuck 'set fucked_cmd=`history -h 2 | head -n 1` && eval `thefuck ${fucked_cmd}`' +``` + Alternatively, you can redirect the output of `thefuck-alias`: ```bash @@ -140,37 +145,50 @@ sudo pip install thefuck --upgrade The Fuck tries to match a rule for the previous command, creates a new command using the matched rule and runs it. Rules enabled by default are as follows: -* `brew_unknown_command` – fixes wrong brew commands, for example `brew docto/brew doctor`; -* `cpp11` – add missing `-std=c++11` to `g++` or `clang++`; -* `cd_parent` – changes `cd..` to `cd ..`; +* `cd_correction` – spellchecks and correct failed cd commands; * `cd_mkdir` – creates directories before cd'ing into them; +* `cd_parent` – changes `cd..` to `cd ..`; +* `composer_not_command` – fixes composer command name; * `cp_omitting_directory` – adds `-a` when you `cp` directory; +* `cpp11` – add missing `-std=c++11` to `g++` or `clang++`; * `dry` – fix repetitions like "git git push"; +* `django_south_ghost` – adds `--delete-ghost-migrations` to failed because ghosts django south migration; +* `django_south_merge` – adds `--merge` to inconsistent django south migration; * `fix_alt_space` – replaces Alt+Space with Space character; * `git_add` – fix *"Did you forget to 'git add'?"*; * `git_checkout` – creates the branch before checking-out; * `git_no_command` – fixes wrong git commands like `git brnch`; +* `git_pull` – sets upstream before executing previous `git pull`; * `git_push` – adds `--set-upstream origin $branch` to previous failed `git push`; +* `git_stash` – stashes you local modifications before rebasing or switching branch; +* `grep_recursive` – adds `-r` when you trying to grep directory; * `has_exists_script` – prepends `./` when script/binary exists; * `lein_not_task` – fixes wrong `lein` tasks like `lein rpl`; +* `ls_lah` – adds -lah to ls; +* `man` – change manual section; +* `man_no_space` – fixes man commands without spaces, for example `mandiff`; * `mkdir_p` – adds `-p` when you trying to create directory without parent; * `no_command` – fixes wrong console commands, for example `vom/vim`; -* `man_no_space` – fixes man commands without spaces, for example `mandiff`; -* `pacman` – installs app with `pacman` or `yaourt` if it is not installed; +* `no_such_file` – creates missing directories with `mv` and `cp` commands; +* `open` – prepends `http` to address passed to `open`; * `pip_unknown_command` – fixes wrong pip commands, for example `pip instatl/pip install`; * `python_command` – prepends `python` when you trying to run not executable/without `./` python script; -* `sl_ls` – changes `sl` to `ls`; * `rm_dir` – adds `-rf` when you trying to remove directory; +* `sl_ls` – changes `sl` to `ls`; * `ssh_known_hosts` – removes host from `known_hosts` on warning; * `sudo` – prepends `sudo` to previous command if it failed because of permissions; * `switch_layout` – switches command from your local layout to en; +* `whois` – fixes `whois` command. + +Enabled by default only on specific platforms: + * `apt_get` – installs app from apt if it not installed; * `brew_install` – fixes formula name for `brew install`; -* `composer_not_command` – fixes composer command name. +* `brew_unknown_command` – fixes wrong brew commands, for example `brew docto/brew doctor`; +* `pacman` – installs app with `pacman` or `yaourt` if it is not installed. Bundled, but not enabled by default: -* `ls_lah` – adds -lah to ls; * `rm_root` – adds `--no-preserve-root` to `rm -rf /` command. ## Creating your own rules diff --git a/setup.py b/setup.py index c79654f0..32f0de69 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages -VERSION = '1.39' +VERSION = '1.43' setup(name='thefuck', diff --git a/tests/rules/__init__.py b/tests/rules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/rules/conftest.py b/tests/rules/conftest.py new file mode 100644 index 00000000..94152a6c --- /dev/null +++ b/tests/rules/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(autouse=True) +def generic_shell(monkeypatch): + monkeypatch.setattr('thefuck.shells.and_', lambda *x: ' && '.join(x)) diff --git a/tests/rules/test_apt_get.py b/tests/rules/test_apt_get.py new file mode 100644 index 00000000..56ad8208 --- /dev/null +++ b/tests/rules/test_apt_get.py @@ -0,0 +1,59 @@ +import pytest +from mock import Mock, patch +from thefuck.rules import apt_get +from thefuck.rules.apt_get import match, get_new_command +from tests.utils import Command + + +# python-commandnotfound is available in ubuntu 14.04+ +@pytest.mark.skipif(not getattr(apt_get, 'enabled_by_default', True), + reason='Skip if python-commandnotfound is not available') +@pytest.mark.parametrize('command', [ + Command(script='vim', stderr='vim: command not found')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command, return_value', [ + (Command(script='vim', stderr='vim: command not found'), + [('vim', 'main'), ('vim-tiny', 'main')])]) +@patch('thefuck.rules.apt_get.CommandNotFound', create=True) +@patch.multiple(apt_get, create=True, apt_get='apt_get') +def test_match_mocked(cmdnf_mock, command, return_value): + get_packages = Mock(return_value=return_value) + cmdnf_mock.CommandNotFound.return_value = Mock(getPackages=get_packages) + assert match(command, None) + assert cmdnf_mock.CommandNotFound.called + assert get_packages.called + + +@pytest.mark.parametrize('command', [ + Command(script='vim', stderr=''), Command()]) +def test_not_match(command): + assert not match(command, None) + + +# python-commandnotfound is available in ubuntu 14.04+ +@pytest.mark.skipif(not getattr(apt_get, 'enabled_by_default', True), + reason='Skip if python-commandnotfound is not available') +@pytest.mark.parametrize('command, new_command', [ + (Command('vim'), 'sudo apt-get install vim && vim'), + (Command('convert'), 'sudo apt-get install imagemagick && convert')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command + + +@pytest.mark.parametrize('command, new_command, return_value', [ + (Command('vim'), 'sudo apt-get install vim && vim', + [('vim', 'main'), ('vim-tiny', 'main')]), + (Command('convert'), 'sudo apt-get install imagemagick && convert', + [('imagemagick', 'main'), + ('graphicsmagick-imagemagick-compat', 'universe')])]) +@patch('thefuck.rules.apt_get.CommandNotFound', create=True) +@patch.multiple(apt_get, create=True, apt_get='apt_get') +def test_get_new_command_mocked(cmdnf_mock, command, new_command, return_value): + get_packages = Mock(return_value=return_value) + cmdnf_mock.CommandNotFound.return_value = Mock(getPackages=get_packages) + assert get_new_command(command, None) == new_command + assert cmdnf_mock.CommandNotFound.called + assert get_packages.called diff --git a/tests/rules/test_django_south_ghost.py b/tests/rules/test_django_south_ghost.py new file mode 100644 index 00000000..70fc8903 --- /dev/null +++ b/tests/rules/test_django_south_ghost.py @@ -0,0 +1,53 @@ +import pytest +from thefuck.rules.django_south_ghost import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return '''Traceback (most recent call last): + File "/home/nvbn/work/.../bin/python", line 42, in + exec(compile(__file__f.read(), __file__, "exec")) + File "/home/nvbn/work/.../app/manage.py", line 34, in + execute_from_command_line(sys.argv) + File "/home/nvbn/work/.../lib/django/core/management/__init__.py", line 443, in execute_from_command_line + utility.execute() + File "/home/nvbn/work/.../lib/django/core/management/__init__.py", line 382, in execute + self.fetch_command(subcommand).run_from_argv(self.argv) + File "/home/nvbn/work/.../lib/django/core/management/base.py", line 196, in run_from_argv + self.execute(*args, **options.__dict__) + File "/home/nvbn/work/.../lib/django/core/management/base.py", line 232, in execute + output = self.handle(*args, **options) + File "/home/nvbn/work/.../app/lib/south/management/commands/migrate.py", line 108, in handle + ignore_ghosts = ignore_ghosts, + File "/home/nvbn/work/.../app/lib/south/migration/__init__.py", line 193, in migrate_app + applied_all = check_migration_histories(applied_all, delete_ghosts, ignore_ghosts) + File "/home/nvbn/work/.../app/lib/south/migration/__init__.py", line 88, in check_migration_histories + raise exceptions.GhostMigrations(ghosts) +south.exceptions.GhostMigrations: + + ! These migrations are in the database but not on disk: + + + + + + + + ! I'm not trusting myself; either fix this yourself by fiddling + ! with the south_migrationhistory table, or pass --delete-ghost-migrations + ! to South to have it delete ALL of these records (this may not be good). +''' + + +def test_match(stderr): + assert match(Command('./manage.py migrate', stderr=stderr), None) + assert match(Command('python manage.py migrate', stderr=stderr), None) + assert not match(Command('./manage.py migrate'), None) + assert not match(Command('app migrate', stderr=stderr), None) + assert not match(Command('./manage.py test', stderr=stderr), None) + + +def test_get_new_command(): + assert get_new_command(Command('./manage.py migrate auth'), None)\ + == './manage.py migrate auth --delete-ghost-migrations' diff --git a/tests/rules/test_django_south_merge.py b/tests/rules/test_django_south_merge.py new file mode 100644 index 00000000..c0426122 --- /dev/null +++ b/tests/rules/test_django_south_merge.py @@ -0,0 +1,43 @@ +import pytest +from thefuck.rules.django_south_merge import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return '''Running migrations for app: + ! Migration app:0003_auto... should not have been applied before app:0002_auto__add_field_query_due_date_ but was. +Traceback (most recent call last): + File "/home/nvbn/work/.../bin/python", line 42, in + exec(compile(__file__f.read(), __file__, "exec")) + File "/home/nvbn/work/.../app/manage.py", line 34, in + execute_from_command_line(sys.argv) + File "/home/nvbn/work/.../lib/django/core/management/__init__.py", line 443, in execute_from_command_line + utility.execute() + File "/home/nvbn/work/.../lib/django/core/management/__init__.py", line 382, in execute + self.fetch_command(subcommand).run_from_argv(self.argv) + File "/home/nvbn/work/.../lib/django/core/management/base.py", line 196, in run_from_argv + self.execute(*args, **options.__dict__) + File "/home/nvbn/work/.../lib/django/core/management/base.py", line 232, in execute + output = self.handle(*args, **options) + File "/home/nvbn/work/.../app/lib/south/management/commands/migrate.py", line 108, in handle + ignore_ghosts = ignore_ghosts, + File "/home/nvbn/work/.../app/lib/south/migration/__init__.py", line 207, in migrate_app + raise exceptions.InconsistentMigrationHistory(problems) +south.exceptions.InconsistentMigrationHistory: Inconsistent migration history +The following options are available: + --merge: will just attempt the migration ignoring any potential dependency conflicts. +''' + + +def test_match(stderr): + assert match(Command('./manage.py migrate', stderr=stderr), None) + assert match(Command('python manage.py migrate', stderr=stderr), None) + assert not match(Command('./manage.py migrate'), None) + assert not match(Command('app migrate', stderr=stderr), None) + assert not match(Command('./manage.py test', stderr=stderr), None) + + +def test_get_new_command(): + assert get_new_command(Command('./manage.py migrate auth'), None) \ + == './manage.py migrate auth --merge' diff --git a/tests/rules/test_git_add.py b/tests/rules/test_git_add.py new file mode 100644 index 00000000..8bad9bb6 --- /dev/null +++ b/tests/rules/test_git_add.py @@ -0,0 +1,39 @@ +import pytest +from thefuck.rules.git_add import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def did_not_match(target, did_you_forget=True): + error = ("error: pathspec '{}' did not match any " + "file(s) known to git.".format(target)) + if did_you_forget: + error = ("{}\nDid you forget to 'git add'?'".format(error)) + return error + + +@pytest.mark.parametrize('command', [ + Command(script='git submodule update unknown', + stderr=did_not_match('unknown')), + Command(script='git commit unknown', + stderr=did_not_match('unknown'))]) # Older versions of Git +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command(script='git submodule update known', stderr=('')), + Command(script='git commit known', stderr=('')), + Command(script='git commit unknown', # Newer versions of Git + stderr=did_not_match('unknown', False))]) +def test_not_match(command): + assert not match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('git submodule update unknown', stderr=did_not_match('unknown')), + 'git add -- unknown && git submodule update unknown'), + (Command('git commit unknown', stderr=did_not_match('unknown')), # Old Git + 'git add -- unknown && git commit unknown')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_git_checkout.py b/tests/rules/test_git_checkout.py new file mode 100644 index 00000000..a540b62d --- /dev/null +++ b/tests/rules/test_git_checkout.py @@ -0,0 +1,37 @@ +import pytest +from thefuck.rules.git_checkout import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def did_not_match(target, did_you_forget=False): + error = ("error: pathspec '{}' did not match any " + "file(s) known to git.".format(target)) + if did_you_forget: + error = ("{}\nDid you forget to 'git add'?'".format(error)) + return error + + +@pytest.mark.parametrize('command', [ + Command(script='git checkout unknown', stderr=did_not_match('unknown')), + Command(script='git commit unknown', stderr=did_not_match('unknown'))]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command(script='git submodule update unknown', + stderr=did_not_match('unknown', True)), + Command(script='git checkout known', stderr=('')), + Command(script='git commit known', stderr=(''))]) +def test_not_match(command): + assert not match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command(script='git checkout unknown', stderr=did_not_match('unknown')), + 'git branch unknown && git checkout unknown'), + (Command('git commit unknown', stderr=did_not_match('unknown')), + 'git branch unknown && git commit unknown')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_git_pull.py b/tests/rules/test_git_pull.py new file mode 100644 index 00000000..87725f5a --- /dev/null +++ b/tests/rules/test_git_pull.py @@ -0,0 +1,29 @@ +import pytest +from thefuck.rules.git_pull import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def stderr(): + return '''There is no tracking information for the current branch. +Please specify which branch you want to merge with. +See git-pull(1) for details + + git pull + +If you wish to set tracking information for this branch you can do so with: + + git branch --set-upstream-to=/ master + +''' + + +def test_match(stderr): + assert match(Command('git pull', stderr=stderr), None) + assert not match(Command('git pull'), None) + assert not match(Command('ls', stderr=stderr), None) + + +def test_get_new_command(stderr): + assert get_new_command(Command('git pull', stderr=stderr), None) \ + == "git branch --set-upstream-to=origin/master master && git pull" diff --git a/tests/rules/test_git_stash.py b/tests/rules/test_git_stash.py new file mode 100644 index 00000000..c62a48aa --- /dev/null +++ b/tests/rules/test_git_stash.py @@ -0,0 +1,39 @@ +import pytest +from thefuck.rules.git_stash import match, get_new_command +from tests.utils import Command + + +@pytest.fixture +def cherry_pick_error(): + return ('error: Your local changes would be overwritten by cherry-pick.\n' + 'hint: Commit your changes or stash them to proceed.\n' + 'fatal: cherry-pick failed') + + +@pytest.fixture +def rebase_error(): + return ('Cannot rebase: Your index contains uncommitted changes.\n' + 'Please commit or stash them.') + + +@pytest.mark.parametrize('command', [ + Command(script='git cherry-pick a1b2c3d', stderr=cherry_pick_error()), + Command(script='git rebase -i HEAD~7', stderr=rebase_error())]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command(script='git cherry-pick a1b2c3d', stderr=('')), + Command(script='git rebase -i HEAD~7', stderr=(''))]) +def test_not_match(command): + assert not match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command(script='git cherry-pick a1b2c3d', stderr=cherry_pick_error), + 'git stash && git cherry-pick a1b2c3d'), + (Command('git rebase -i HEAD~7', stderr=rebase_error), + 'git stash && git rebase -i HEAD~7')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_grep_recursive.py b/tests/rules/test_grep_recursive.py new file mode 100644 index 00000000..0e3dae1d --- /dev/null +++ b/tests/rules/test_grep_recursive.py @@ -0,0 +1,12 @@ +from thefuck.rules.grep_recursive import match, get_new_command +from tests.utils import Command + + +def test_match(): + assert match(Command('grep blah .', stderr='grep: .: Is a directory'), None) + assert not match(Command(), None) + + +def test_get_new_command(): + assert get_new_command( + Command('grep blah .'), None) == 'grep -r blah .' diff --git a/tests/rules/test_man.py b/tests/rules/test_man.py new file mode 100644 index 00000000..883d7366 --- /dev/null +++ b/tests/rules/test_man.py @@ -0,0 +1,34 @@ +import pytest +from thefuck.rules.man import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command('man read'), + Command('man 2 read'), + Command('man 3 read'), + Command('man -s2 read'), + Command('man -s3 read'), + Command('man -s 2 read'), + Command('man -s 3 read')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command', [ + Command('man'), + Command('man ')]) +def test_not_match(command): + assert not match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('man read'), 'man 3 read'), + (Command('man 2 read'), 'man 3 read'), + (Command('man 3 read'), 'man 2 read'), + (Command('man -s2 read'), 'man -s3 read'), + (Command('man -s3 read'), 'man -s2 read'), + (Command('man -s 2 read'), 'man -s 3 read'), + (Command('man -s 3 read'), 'man -s 2 read')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_no_command.py b/tests/rules/test_no_command.py index 64a2423a..68ccf3f7 100644 --- a/tests/rules/test_no_command.py +++ b/tests/rules/test_no_command.py @@ -3,7 +3,7 @@ from thefuck.rules.no_command import match, get_new_command def test_match(): - with patch('thefuck.rules.no_command._get_all_bins', + with patch('thefuck.rules.no_command._get_all_callables', return_value=['vim', 'apt-get']): assert match(Mock(stderr='vom: not found', script='vom file.py'), None) assert not match(Mock(stderr='qweqwe: not found', script='qweqwe'), None) @@ -11,7 +11,7 @@ def test_match(): def test_get_new_command(): - with patch('thefuck.rules.no_command._get_all_bins', + with patch('thefuck.rules.no_command._get_all_callables', return_value=['vim', 'apt-get']): assert get_new_command( Mock(stderr='vom: not found', diff --git a/tests/rules/test_no_such_file.py b/tests/rules/test_no_such_file.py new file mode 100644 index 00000000..ba35477c --- /dev/null +++ b/tests/rules/test_no_such_file.py @@ -0,0 +1,19 @@ +import pytest +from thefuck.rules.no_such_file import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command(script='mv foo bar/foo', stderr="mv: cannot move 'foo' to 'bar/foo': No such file or directory"), + Command(script='mv foo bar/', stderr="mv: cannot move 'foo' to 'bar/': No such file or directory"), + ]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command(script='mv foo bar/foo', stderr="mv: cannot move 'foo' to 'bar/foo': No such file or directory"), 'mkdir -p bar && mv foo bar/foo'), + (Command(script='mv foo bar/', stderr="mv: cannot move 'foo' to 'bar/': No such file or directory"), 'mkdir -p bar && mv foo bar/'), + ]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_open.py b/tests/rules/test_open.py new file mode 100644 index 00000000..ea350b55 --- /dev/null +++ b/tests/rules/test_open.py @@ -0,0 +1,25 @@ +import pytest +from thefuck.rules.open import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command(script='open foo.com'), + Command(script='open foo.ly'), + Command(script='open foo.org'), + Command(script='open foo.net'), + Command(script='open foo.se'), + Command(script='open foo.io')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('open foo.com'), 'open http://foo.com'), + (Command('open foo.ly'), 'open http://foo.ly'), + (Command('open foo.org'), 'open http://foo.org'), + (Command('open foo.net'), 'open http://foo.net'), + (Command('open foo.se'), 'open http://foo.se'), + (Command('open foo.io'), 'open http://foo.io')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/rules/test_pacman.py b/tests/rules/test_pacman.py new file mode 100644 index 00000000..b9742e7f --- /dev/null +++ b/tests/rules/test_pacman.py @@ -0,0 +1,66 @@ +import pytest +from mock import patch +from thefuck.rules import pacman +from thefuck.rules.pacman import match, get_new_command +from tests.utils import Command + + +pacman_cmd = getattr(pacman, 'pacman', 'pacman') + +PKGFILE_OUTPUT_CONVERT = ''' +extra/imagemagick 6.9.1.0-1\t/usr/bin/convert +''' + +PKGFILE_OUTPUT_VIM = ''' +extra/gvim 7.4.712-1 \t/usr/bin/vim +extra/gvim-python3 7.4.712-1\t/usr/bin/vim +extra/vim 7.4.712-1 \t/usr/bin/vim +extra/vim-minimal 7.4.712-1 \t/usr/bin/vim +extra/vim-python3 7.4.712-1 \t/usr/bin/vim +''' + + +@pytest.mark.skipif(not getattr(pacman, 'enabled_by_default', True), + reason='Skip if pacman is not available') +@pytest.mark.parametrize('command', [ + Command(script='vim', stderr='vim: command not found')]) +def test_match(command): + assert match(command, None) + + +@pytest.mark.parametrize('command, return_value', [ + (Command(script='vim', stderr='vim: command not found'), PKGFILE_OUTPUT_VIM)]) +@patch('thefuck.rules.pacman.subprocess') +@patch.multiple(pacman, create=True, pacman=pacman_cmd) +def test_match_mocked(subp_mock, command, return_value): + subp_mock.check_output.return_value = return_value + assert match(command, None) + assert subp_mock.check_output.called + + +@pytest.mark.parametrize('command', [ + Command(script='vim', stderr=''), Command()]) +def test_not_match(command): + assert not match(command, None) + + +@pytest.mark.skipif(not getattr(pacman, 'enabled_by_default', True), + reason='Skip if pacman is not available') +@pytest.mark.parametrize('command, new_command', [ + (Command('vim'), '{} -S extra/gvim && vim'.format(pacman_cmd)), + (Command('convert'), '{} -S extra/imagemagick && convert'.format(pacman_cmd))]) +def test_get_new_command(command, new_command, mocker): + assert get_new_command(command, None) == new_command + + +@pytest.mark.parametrize('command, new_command, return_value', [ + (Command('vim'), '{} -S extra/gvim && vim'.format(pacman_cmd), + PKGFILE_OUTPUT_VIM), + (Command('convert'), '{} -S extra/imagemagick && convert'.format(pacman_cmd), + PKGFILE_OUTPUT_CONVERT)]) +@patch('thefuck.rules.pacman.subprocess') +@patch.multiple(pacman, create=True, pacman=pacman_cmd) +def test_get_new_command_mocked(subp_mock, command, new_command, return_value): + subp_mock.check_output.return_value = return_value + assert get_new_command(command, None) == new_command + assert subp_mock.check_output.called diff --git a/tests/rules/test_whois.py b/tests/rules/test_whois.py new file mode 100644 index 00000000..18548ce5 --- /dev/null +++ b/tests/rules/test_whois.py @@ -0,0 +1,23 @@ +import pytest +from thefuck.rules.whois import match, get_new_command +from tests.utils import Command + + +@pytest.mark.parametrize('command', [ + Command(script='whois https://en.wikipedia.org/wiki/Main_Page'), + Command(script='whois https://en.wikipedia.org/'), + Command(script='whois en.wikipedia.org')]) +def test_match(command): + assert match(command, None) + + +def test_not_match(): + assert not match(Command(script='whois'), None) + + +@pytest.mark.parametrize('command, new_command', [ + (Command('whois https://en.wikipedia.org/wiki/Main_Page'), 'whois en.wikipedia.org'), + (Command('whois https://en.wikipedia.org/'), 'whois en.wikipedia.org'), + (Command('whois en.wikipedia.org'), 'whois wikipedia.org')]) +def test_get_new_command(command, new_command): + assert get_new_command(command, None) == new_command diff --git a/tests/test_shells.py b/tests/test_shells.py index 449496c9..675cdd35 100644 --- a/tests/test_shells.py +++ b/tests/test_shells.py @@ -13,19 +13,33 @@ def isfile(mocker): class TestGeneric(object): - def test_from_shell(self): - assert shells.Generic().from_shell('pwd') == 'pwd' + @pytest.fixture + def shell(self): + return shells.Generic() - def test_to_shell(self): - assert shells.Generic().to_shell('pwd') == 'pwd' + def test_from_shell(self, shell): + assert shell.from_shell('pwd') == 'pwd' - def test_put_to_history(self, builtins_open): - assert shells.Generic().put_to_history('ls') is None + def test_to_shell(self, shell): + assert shell.to_shell('pwd') == 'pwd' + + def test_put_to_history(self, builtins_open, shell): + assert shell.put_to_history('ls') is None assert builtins_open.call_count == 0 + def test_and_(self, shell): + assert shell.and_('ls', 'cd') == 'ls && cd' + + def test_get_aliases(self, shell): + assert shell.get_aliases() == {} + @pytest.mark.usefixtures('isfile') class TestBash(object): + @pytest.fixture + def shell(self): + return shells.Bash() + @pytest.fixture(autouse=True) def Popen(self, mocker): mock = mocker.patch('thefuck.shells.Popen') @@ -38,20 +52,69 @@ class TestBash(object): @pytest.mark.parametrize('before, after', [ ('pwd', 'pwd'), ('ll', 'ls -alF')]) - def test_from_shell(self, before, after): - assert shells.Bash().from_shell(before) == after + def test_from_shell(self, before, after, shell): + assert shell.from_shell(before) == after - def test_to_shell(self): - assert shells.Bash().to_shell('pwd') == 'pwd' + def test_to_shell(self, shell): + assert shell.to_shell('pwd') == 'pwd' - def test_put_to_history(self, builtins_open): - shells.Bash().put_to_history('ls') + def test_put_to_history(self, builtins_open, shell): + shell.put_to_history('ls') builtins_open.return_value.__enter__.return_value. \ write.assert_called_once_with('ls\n') + def test_and_(self, shell): + assert shell.and_('ls', 'cd') == 'ls && cd' + + def test_get_aliases(self, shell): + assert shell.get_aliases() == {'l': 'ls -CF', + 'la': 'ls -A', + 'll': 'ls -alF'} + + +@pytest.mark.usefixtures('isfile') +class TestFish(object): + @pytest.fixture + def shell(self): + return shells.Fish() + + @pytest.fixture(autouse=True) + def Popen(self, mocker): + mock = mocker.patch('thefuck.shells.Popen') + mock.return_value.stdout.read.return_value = (b'funced\nfuncsave\ngrep') + return mock + + @pytest.mark.parametrize('before, after', [ + ('pwd', 'pwd'), + ('ll', 'll')]) # Fish has no aliases but functions + def test_from_shell(self, before, after, shell): + assert shell.from_shell(before) == after + + def test_to_shell(self, shell): + assert shell.to_shell('pwd') == 'pwd' + + def test_put_to_history(self, builtins_open, mocker, shell): + mocker.patch('thefuck.shells.time', + return_value=1430707243.3517463) + shell.put_to_history('ls') + builtins_open.return_value.__enter__.return_value. \ + write.assert_called_once_with('- cmd: ls\n when: 1430707243\n') + + def test_and_(self, shell): + assert shell.and_('foo', 'bar') == 'foo; and bar' + + def test_get_aliases(self, shell): + assert shell.get_aliases() == {'funced': 'funced', + 'funcsave': 'funcsave', + 'grep': 'grep'} + @pytest.mark.usefixtures('isfile') class TestZsh(object): + @pytest.fixture + def shell(self): + return shells.Zsh() + @pytest.fixture(autouse=True) def Popen(self, mocker): mock = mocker.patch('thefuck.shells.Popen') @@ -64,15 +127,23 @@ class TestZsh(object): @pytest.mark.parametrize('before, after', [ ('pwd', 'pwd'), ('ll', 'ls -alF')]) - def test_from_shell(self, before, after): - assert shells.Zsh().from_shell(before) == after + def test_from_shell(self, before, after, shell): + assert shell.from_shell(before) == after - def test_to_shell(self): - assert shells.Zsh().to_shell('pwd') == 'pwd' + def test_to_shell(self, shell): + assert shell.to_shell('pwd') == 'pwd' - def test_put_to_history(self, builtins_open, mocker): + def test_put_to_history(self, builtins_open, mocker, shell): mocker.patch('thefuck.shells.time', return_value=1430707243.3517463) - shells.Zsh().put_to_history('ls') + shell.put_to_history('ls') builtins_open.return_value.__enter__.return_value. \ write.assert_called_once_with(': 1430707243:0;ls\n') + + def test_and_(self, shell): + assert shell.and_('ls', 'cd') == 'ls && cd' + + def test_get_aliases(self, shell): + assert shell.get_aliases() == {'l': 'ls -CF', + 'la': 'ls -A', + 'll': 'ls -alF'} diff --git a/tests/test_utils.py b/tests/test_utils.py index 24d6b196..d180eb56 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ import pytest from mock import Mock -from thefuck.utils import sudo_support, wrap_settings +from thefuck.utils import sudo_support, wrap_settings, memoize from thefuck.types import Settings from tests.utils import Command @@ -24,3 +24,11 @@ def test_sudo_support(return_value, command, called, result): fn = Mock(return_value=return_value, __name__='') assert sudo_support(fn)(Command(command), None) == result fn.assert_called_once_with(Command(called), None) + + +def test_memoize(): + fn = Mock(__name__='fn') + memoized = memoize(fn) + memoized() + memoized() + fn.assert_called_once_with() diff --git a/thefuck/rules/apt_get.py b/thefuck/rules/apt_get.py index 4d5eca6b..b78bb743 100644 --- a/thefuck/rules/apt_get.py +++ b/thefuck/rules/apt_get.py @@ -1,3 +1,5 @@ +from thefuck import shells + try: import CommandNotFound except ImportError: @@ -20,4 +22,5 @@ def get_new_command(command, settings): c = CommandNotFound.CommandNotFound() pkgs = c.getPackages(command.script.split(" ")[0]) name, _ = pkgs[0] - return "sudo apt-get install {} && {}".format(name, command.script) + formatme = shells.and_('sudo apt-get install {}', '{}') + return formatme.format(name, command.script) diff --git a/thefuck/rules/brew_install.py b/thefuck/rules/brew_install.py index 0f339506..39deb236 100644 --- a/thefuck/rules/brew_install.py +++ b/thefuck/rules/brew_install.py @@ -3,12 +3,11 @@ import os import re from subprocess import check_output -import thefuck.logs - # Formulars are base on each local system's status brew_formulas = [] try: - brew_path_prefix = check_output(['brew', '--prefix']).strip() + brew_path_prefix = check_output(['brew', '--prefix'], + universal_newlines=True).strip() brew_formula_path = brew_path_prefix + '/Library/Formula' for file_name in os.listdir(brew_formula_path): diff --git a/thefuck/rules/brew_unknown_command.py b/thefuck/rules/brew_unknown_command.py index 6664d8e8..26c0924f 100644 --- a/thefuck/rules/brew_unknown_command.py +++ b/thefuck/rules/brew_unknown_command.py @@ -12,7 +12,8 @@ TAP_CMD_PATH = '/%s/%s/cmd' def _get_brew_path_prefix(): """To get brew path""" try: - return subprocess.check_output(['brew', '--prefix']).strip() + return subprocess.check_output(['brew', '--prefix'], + universal_newlines=True).strip() except: return None diff --git a/thefuck/rules/cd_correction.py b/thefuck/rules/cd_correction.py new file mode 100644 index 00000000..bdd7c34b --- /dev/null +++ b/thefuck/rules/cd_correction.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +__author__ = "mmussomele" + +"""Attempts to spellcheck and correct failed cd commands""" + +import os +from difflib import get_close_matches +from thefuck.utils import sudo_support +from thefuck.rules import cd_mkdir + +MAX_ALLOWED_DIFF = 0.6 + + +def _get_sub_dirs(parent): + """Returns a list of the child directories of the given parent directory""" + return [child for child in os.listdir(parent) if os.path.isdir(os.path.join(parent, child))] + + +@sudo_support +def match(command, settings): + """Match function copied from cd_mkdir.py""" + return (command.script.startswith('cd ') + and ('no such file or directory' in command.stderr.lower() + or 'cd: can\'t cd to' in command.stderr.lower())) + + +@sudo_support +def get_new_command(command, settings): + """ + Attempt to rebuild the path string by spellchecking the directories. + If it fails (i.e. no directories are a close enough match), then it + defaults to the rules of cd_mkdir. + Change sensitivity by changing MAX_ALLOWED_DIFF. Default value is 0.6 + """ + dest = command.script.split()[1].split(os.sep) + if dest[-1] == '': + dest = dest[:-1] + cwd = os.getcwd() + for directory in dest: + if directory == ".": + continue + elif directory == "..": + cwd = os.path.split(cwd)[0] + continue + best_matches = get_close_matches(directory, _get_sub_dirs(cwd), cutoff=MAX_ALLOWED_DIFF) + if best_matches: + cwd = os.path.join(cwd, best_matches[0]) + else: + return cd_mkdir.get_new_command(command, settings) + return "cd {0}".format(cwd) + + +enabled_by_default = True diff --git a/thefuck/rules/cd_mkdir.py b/thefuck/rules/cd_mkdir.py index 7aa1d9da..168a2ce0 100644 --- a/thefuck/rules/cd_mkdir.py +++ b/thefuck/rules/cd_mkdir.py @@ -1,4 +1,5 @@ import re +from thefuck import shells from thefuck.utils import sudo_support @@ -11,4 +12,5 @@ def match(command, settings): @sudo_support def get_new_command(command, settings): - return re.sub(r'^cd (.*)', 'mkdir -p \\1 && cd \\1', command.script) + repl = shells.and_('mkdir -p \\1', 'cd \\1') + return re.sub(r'^cd (.*)', repl, command.script) diff --git a/thefuck/rules/django_south_ghost.py b/thefuck/rules/django_south_ghost.py new file mode 100644 index 00000000..d3290c4a --- /dev/null +++ b/thefuck/rules/django_south_ghost.py @@ -0,0 +1,8 @@ +def match(command, settings): + return 'manage.py' in command.script and \ + 'migrate' in command.script \ + and 'or pass --delete-ghost-migrations' in command.stderr + + +def get_new_command(command, settings): + return u'{} --delete-ghost-migrations'.format(command.script) diff --git a/thefuck/rules/django_south_merge.py b/thefuck/rules/django_south_merge.py new file mode 100644 index 00000000..bef05970 --- /dev/null +++ b/thefuck/rules/django_south_merge.py @@ -0,0 +1,8 @@ +def match(command, settings): + return 'manage.py' in command.script and \ + 'migrate' in command.script \ + and '--merge: will just attempt the migration' in command.stderr + + +def get_new_command(command, settings): + return u'{} --merge'.format(command.script) diff --git a/thefuck/rules/git_add.py b/thefuck/rules/git_add.py index 66c7f1dc..bc05d011 100644 --- a/thefuck/rules/git_add.py +++ b/thefuck/rules/git_add.py @@ -1,4 +1,5 @@ import re +from thefuck import shells def match(command, settings): @@ -12,4 +13,5 @@ def get_new_command(command, settings): r"error: pathspec '([^']*)' " "did not match any file\(s\) known to git.", command.stderr)[0] - return 'git add -- {} && {}'.format(missing_file, command.script) + formatme = shells.and_('git add -- {}', '{}') + return formatme.format(missing_file, command.script) diff --git a/thefuck/rules/git_checkout.py b/thefuck/rules/git_checkout.py index 271562b8..6c9d259f 100644 --- a/thefuck/rules/git_checkout.py +++ b/thefuck/rules/git_checkout.py @@ -1,4 +1,5 @@ import re +from thefuck import shells def match(command, settings): @@ -12,4 +13,5 @@ def get_new_command(command, settings): r"error: pathspec '([^']*)' " "did not match any file\(s\) known to git.", command.stderr)[0] - return 'git branch {} && {}'.format(missing_file, command.script) + formatme = shells.and_('git branch {}', '{}') + return formatme.format(missing_file, command.script) diff --git a/thefuck/rules/git_pull.py b/thefuck/rules/git_pull.py new file mode 100644 index 00000000..cee34616 --- /dev/null +++ b/thefuck/rules/git_pull.py @@ -0,0 +1,12 @@ +def match(command, settings): + return ('git' in command.script + and 'pull' in command.script + and 'set-upstream' in command.stderr) + + +def get_new_command(command, settings): + line = command.stderr.split('\n')[-3].strip() + branch = line.split(' ')[-1] + set_upstream = line.replace('', 'origin')\ + .replace('', branch) + return u'{} && {}'.format(set_upstream, command.script) diff --git a/thefuck/rules/git_stash.py b/thefuck/rules/git_stash.py new file mode 100644 index 00000000..9e9034a3 --- /dev/null +++ b/thefuck/rules/git_stash.py @@ -0,0 +1,12 @@ +from thefuck import shells + + +def match(command, settings): + # catches "Please commit or stash them" and "Please, commit your changes or + # stash them before you can switch branches." + return 'git' in command.script and 'or stash them' in command.stderr + + +def get_new_command(command, settings): + formatme = shells.and_('git stash', '{}') + return formatme.format(command.script) diff --git a/thefuck/rules/grep_recursive.py b/thefuck/rules/grep_recursive.py new file mode 100644 index 00000000..f2876fe1 --- /dev/null +++ b/thefuck/rules/grep_recursive.py @@ -0,0 +1,7 @@ +def match(command, settings): + return (command.script.startswith('grep') + and 'is a directory' in command.stderr.lower()) + + +def get_new_command(command, settings): + return 'grep -r {}'.format(command.script[5:]) diff --git a/thefuck/rules/ls_lah.py b/thefuck/rules/ls_lah.py index 50fe9f5e..7eba5bda 100644 --- a/thefuck/rules/ls_lah.py +++ b/thefuck/rules/ls_lah.py @@ -1,6 +1,3 @@ -enabled_by_default = False - - def match(command, settings): return 'ls' in command.script and not ('ls -' in command.script) diff --git a/thefuck/rules/man.py b/thefuck/rules/man.py new file mode 100644 index 00000000..0b15c5fc --- /dev/null +++ b/thefuck/rules/man.py @@ -0,0 +1,13 @@ +def match(command, settings): + return command.script.strip().startswith('man ') + + +def get_new_command(command, settings): + if '3' in command.script: + return command.script.replace("3", "2") + if '2' in command.script: + return command.script.replace("2", "3") + + split_cmd = command.script.split() + split_cmd.insert(1, ' 3 ') + return "".join(split_cmd) diff --git a/thefuck/rules/no_command.py b/thefuck/rules/no_command.py index 1a152c99..3b310495 100644 --- a/thefuck/rules/no_command.py +++ b/thefuck/rules/no_command.py @@ -2,6 +2,7 @@ from difflib import get_close_matches import os from pathlib import Path from thefuck.utils import sudo_support +from thefuck.shells import get_aliases def _safe(fn, fallback): @@ -11,25 +12,25 @@ def _safe(fn, fallback): return fallback -def _get_all_bins(): +def _get_all_callables(): return [exe.name for path in os.environ.get('PATH', '').split(':') for exe in _safe(lambda: list(Path(path).iterdir()), []) - if not _safe(exe.is_dir, True)] + if not _safe(exe.is_dir, True)] + get_aliases() @sudo_support def match(command, settings): return 'not found' in command.stderr and \ bool(get_close_matches(command.script.split(' ')[0], - _get_all_bins())) + _get_all_callables())) @sudo_support def get_new_command(command, settings): old_command = command.script.split(' ')[0] new_command = get_close_matches(old_command, - _get_all_bins())[0] + _get_all_callables())[0] return ' '.join([new_command] + command.script.split(' ')[1:]) diff --git a/thefuck/rules/no_such_file.py b/thefuck/rules/no_such_file.py new file mode 100644 index 00000000..44572f19 --- /dev/null +++ b/thefuck/rules/no_such_file.py @@ -0,0 +1,30 @@ +import re +from thefuck import shells + + +patterns = ( + r"mv: cannot move '[^']*' to '([^']*)': No such file or directory", + r"mv: cannot move '[^']*' to '([^']*)': Not a directory", + r"cp: cannot create regular file '([^']*)': No such file or directory", + r"cp: cannot create regular file '([^']*)': Not a directory", +) + + +def match(command, settings): + for pattern in patterns: + if re.search(pattern, command.stderr): + return True + + return False + + +def get_new_command(command, settings): + for pattern in patterns: + file = re.findall(pattern, command.stderr) + + if file: + file = file[0] + dir = file[0:file.rfind('/')] + + formatme = shells.and_('mkdir -p {}', '{}') + return formatme.format(dir, command.script) diff --git a/thefuck/rules/open.py b/thefuck/rules/open.py new file mode 100644 index 00000000..c799b0a4 --- /dev/null +++ b/thefuck/rules/open.py @@ -0,0 +1,24 @@ +# Opens URL's in the default web browser +# +# Example: +# > open github.com +# The file ~/github.com does not exist. +# Perhaps you meant 'http://github.com'? +# +# + +def match(command, settings): + return (command.script.startswith ('open') + and ( + # Wanted to use this: + # 'http' in command.stderr + '.com' in command.script + or '.net' in command.script + or '.org' in command.script + or '.ly' in command.script + or '.io' in command.script + or '.se' in command.script + or '.edu' in command.script)) + +def get_new_command(command, settings): + return 'open http://' + command.script[5:] diff --git a/thefuck/rules/pacman.py b/thefuck/rules/pacman.py index 41157986..08a39ecf 100644 --- a/thefuck/rules/pacman.py +++ b/thefuck/rules/pacman.py @@ -1,23 +1,13 @@ import subprocess -from thefuck.utils import DEVNULL - - -def __command_available(command): - try: - subprocess.check_output([command], stderr=DEVNULL) - return True - except subprocess.CalledProcessError: - # command exists but is not happy to be called without any argument - return True - except OSError: - return False +from thefuck.utils import DEVNULL, which +from thefuck import shells def __get_pkgfile(command): try: return subprocess.check_output( ['pkgfile', '-b', '-v', command.script.split(" ")[0]], - universal_newlines=True, stderr=subprocess.DEVNULL + universal_newlines=True, stderr=DEVNULL ).split() except subprocess.CalledProcessError: return None @@ -30,14 +20,15 @@ def match(command, settings): def get_new_command(command, settings): package = __get_pkgfile(command)[0] - return '{} -S {} && {}'.format(pacman, package, command.script) + formatme = shells.and_('{} -S {}', '{}') + return formatme.format(pacman, package, command.script) -if not __command_available('pkgfile'): +if not which('pkgfile'): enabled_by_default = False -elif __command_available('yaourt'): +elif which('yaourt'): pacman = 'yaourt' -elif __command_available('pacman'): +elif which('pacman'): pacman = 'sudo pacman' else: enabled_by_default = False diff --git a/thefuck/rules/whois.py b/thefuck/rules/whois.py new file mode 100644 index 00000000..d27ecc16 --- /dev/null +++ b/thefuck/rules/whois.py @@ -0,0 +1,31 @@ +# -*- encoding: utf-8 -*- +from six.moves.urllib.parse import urlparse + + +def match(command, settings): + """ + What the `whois` command returns depends on the 'Whois server' it contacted + and is not consistent through different servers. But there can be only two + types of errors I can think of with `whois`: + - `whois https://en.wikipedia.org/` → `whois en.wikipedia.org`; + - `whois en.wikipedia.org` → `whois wikipedia.org`. + So we match any `whois` command and then: + - if there is a slash: keep only the FQDN; + - if there is no slash but there is a point: removes the left-most + subdomain. + + We cannot either remove all subdomains because we cannot know which part is + the subdomains and which is the domain, consider: + - www.google.fr → subdomain: www, domain: 'google.fr'; + - google.co.uk → subdomain: None, domain; 'google.co.uk'. + """ + return 'whois' in command.script and len(command.script.split()) > 1 + + +def get_new_command(command, settings): + url = command.script.split()[1] + + if '/' in command.script: + return 'whois ' + urlparse(url).netloc + elif '.' in command.script: + return 'whois ' + '.'.join(urlparse(url).path.split('.')[1:]) diff --git a/thefuck/shells.py b/thefuck/shells.py index e1203f45..6bf4bd7d 100644 --- a/thefuck/shells.py +++ b/thefuck/shells.py @@ -1,5 +1,5 @@ """Module with shell specific actions, each shell class should -implement `from_shell`, `to_shell`, `app_alias` and `put_to_history` +implement `from_shell`, `to_shell`, `app_alias`, `put_to_history` and `get_aliases` methods. """ @@ -8,15 +8,16 @@ from subprocess import Popen, PIPE from time import time import os from psutil import Process -from .utils import DEVNULL +from .utils import DEVNULL, memoize class Generic(object): - def _get_aliases(self): + + def get_aliases(self): return {} def _expand_aliases(self, command_script): - aliases = self._get_aliases() + aliases = self.get_aliases() binary = command_script.split(' ')[0] if binary in aliases: return command_script.replace(binary, aliases[binary], 1) @@ -47,16 +48,24 @@ class Generic(object): with open(history_file_name, 'a') as history: history.write(self._get_history_line(command_script)) + def and_(self, *commands): + return ' && '.join(commands) + class Bash(Generic): + def app_alias(self): + return "\nalias fuck='eval $(thefuck $(fc -ln -1)); history -r'\n" + def _parse_alias(self, alias): name, value = alias.replace('alias ', '', 1).split('=', 1) if value[0] == value[-1] == '"' or value[0] == value[-1] == "'": value = value[1:-1] return name, value - def _get_aliases(self): - proc = Popen('bash -ic alias', stdout=PIPE, stderr=DEVNULL, shell=True) + @memoize + def get_aliases(self): + proc = Popen('bash -ic alias', stdout=PIPE, stderr=DEVNULL, + shell=True) return dict( self._parse_alias(alias) for alias in proc.stdout.read().decode('utf-8').split('\n') @@ -70,15 +79,52 @@ class Bash(Generic): return u'{}\n'.format(command_script) +class Fish(Generic): + def app_alias(self): + return ("function fuck -d 'Correct your previous console command'\n" + " set -l exit_code $status\n" + " set -l eval_script" + " (mktemp 2>/dev/null ; or mktemp -t 'thefuck')\n" + " set -l fucked_up_commandd $history[1]\n" + " thefuck $fucked_up_commandd > $eval_script\n" + " . $eval_script\n" + " rm $eval_script\n" + " if test $exit_code -ne 0\n" + " history --delete $fucked_up_commandd\n" + " end\n" + "end") + + @memoize + def get_aliases(self): + proc = Popen('fish -ic functions', stdout=PIPE, stderr=DEVNULL, + shell=True) + functions = proc.stdout.read().decode('utf-8').strip().split('\n') + return {function: function for function in functions} + + def _get_history_file_name(self): + return os.path.expanduser('~/.config/fish/fish_history') + + def _get_history_line(self, command_script): + return u'- cmd: {}\n when: {}\n'.format(command_script, int(time())) + + def and_(self, *commands): + return '; and '.join(commands) + + class Zsh(Generic): + def app_alias(self): + return "\nalias fuck='eval $(thefuck $(fc -ln -1 | tail -n 1)); fc -R'\n" + def _parse_alias(self, alias): name, value = alias.split('=', 1) if value[0] == value[-1] == '"' or value[0] == value[-1] == "'": value = value[1:-1] return name, value - def _get_aliases(self): - proc = Popen('zsh -ic alias', stdout=PIPE, stderr=DEVNULL, shell=True) + @memoize + def get_aliases(self): + proc = Popen('zsh -ic alias', stdout=PIPE, stderr=DEVNULL, + shell=True) return dict( self._parse_alias(alias) for alias in proc.stdout.read().decode('utf-8').split('\n') @@ -92,13 +138,44 @@ class Zsh(Generic): return u': {}:0;{}\n'.format(int(time()), command_script) +class Tcsh(Generic): + def app_alias(self): + return "\nalias fuck 'set fucked_cmd=`history -h 2 | head -n 1` && eval `thefuck ${fucked_cmd}`'\n" + + def _parse_alias(self, alias): + name, value = alias.split("\t", 1) + return name, value + + @memoize + def get_aliases(self): + proc = Popen('tcsh -ic alias', stdout=PIPE, stderr=DEVNULL, + shell=True) + return dict( + self._parse_alias(alias) + for alias in proc.stdout.read().decode('utf-8').split('\n') + if alias and '\t' in alias) + + def _get_history_file_name(self): + return os.environ.get("HISTFILE", + os.path.expanduser('~/.history')) + + def _get_history_line(self, command_script): + return u'#+{}\n{}\n'.format(int(time()), command_script) + + shells = defaultdict(lambda: Generic(), { 'bash': Bash(), - 'zsh': Zsh()}) + 'fish': Fish(), + 'zsh': Zsh(), + 'csh': Tcsh(), + 'tcsh': Tcsh()}) def _get_shell(): - shell = Process(os.getpid()).parent().cmdline()[0] + try: + shell = Process(os.getpid()).parent().name() + except TypeError: + shell = Process(os.getpid()).parent.name return shells[shell] @@ -111,8 +188,16 @@ def to_shell(command): def app_alias(): - return _get_shell().app_alias() + print(_get_shell().app_alias()) def put_to_history(command): return _get_shell().put_to_history(command) + + +def and_(*commands): + return _get_shell().and_(*commands) + + +def get_aliases(): + return list(_get_shell().get_aliases().keys()) diff --git a/thefuck/utils.py b/thefuck/utils.py index 3247111d..8e5c9bda 100644 --- a/thefuck/utils.py +++ b/thefuck/utils.py @@ -1,5 +1,6 @@ from functools import wraps import os +import pickle import six from .types import Command @@ -62,3 +63,19 @@ def sudo_support(fn): else: return result return wrapper + + +def memoize(fn): + """Caches previous calls to the function.""" + memo = {} + + @wraps(fn) + def wrapper(*args, **kwargs): + key = pickle.dumps((args, kwargs)) + if key not in memo: + memo[key] = fn(*args, **kwargs) + + return memo[key] + + return wrapper +