$ ls ~yifei/notes/

从 Zsh 迁移到 Fish,感觉还不错

Posted on:

Last modified:

用 zsh 有七八年了,开始用的是 oh my zsh,后来从 skywind3k 大佬的配置文件里抄来了 antigen, 用起来还算不错。不过,也有一个问题困扰了好久,antigen 每加载一个插件,就会往 PATH 中塞 一个路径,导致路径一大坨,需要改动的时候很难分辨。如下所示:

ubuntu@VM-0-11-ubuntu [09:37:09 PM] [~]
-> % echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/lib:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/tmux:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/ansible:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/git:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/pip:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/colored-man-pages:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/django:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/docker:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/fzf:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/history:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/kubectl:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/colorize:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/github:/home/ubuntu/.antigen/bundles/robbyrussell/oh-my-zsh/plugins/python:/home/ubuntu/.antigen/bundles/zsh-users/zsh-completions:/home/ubuntu/.antigen/bundles/zsh-users/zsh-autosuggestions:/home/ubuntu/.antigen/bundles/Vifon/deer:/home/ubuntu/.antigen/bundles/nojhan/liquidprompt:/home/ubuntu/.antigen/bundles/willghatch/zsh-cdr:/home/ubuntu/.antigen/bundles/zsh-users/zaw:/home/ubuntu/.antigen/bundles/zsh-users/zaw/functions:/home/ubuntu/.antigen/bundles/wfxr/forgit:/home/ubuntu/.antigen/bundles/zsh-users/zsh-syntax-highlighting:/home/ubuntu/.fzf/bin:/home/ubuntu/.local/bin:/home/ubuntu/.dotfiles/bin:/home/ubuntu/repos/lib/go:/home/ubuntu/repos/unity/go/bin

不知道是不是因为换了 M1 芯片的 Mac 有什么不兼容的地方(并不是),最近 zsh 的加载速度 从 1s 涨到了 4s,以前的速度还勉强可以忍,现在想敲个命令都得等 shell 加载半天,实在受不了 了。一开始是打算精简下自己的 zshrc 文件,后来看了看发现完全看不懂这个文件,瞎改一通高亮 补全都不见了,要啥啥没有,于是索性换个 shell 了。

第一次用 fish 还是刚去头条实习的时候。记得当时看到 fish 眼前一亮,但是因为 bash 用得都不 熟练,而 fish 又和 bash 不兼容就暂时放弃了。当时的 fish 为了特立独行,有些 bash 中受欢迎 的地方也删掉了,比如说 &&|| 条件执行两个命令,直到最近才补上。没想到和 fish 一别 就是六年,今天再次 brew install fish 还真有些一见如故的感觉。

安装好之后还要 sudo echo $(which fish) >> /etc/shells(zsh),然后就可以 chsh $(which fish) (in zsh),把 fish 扶到正宫位置了~

虽然 chsh 把 bash/zsh 打入冷宫了,但并不是彻底把它们卸载了。当你需要执行一些 bash 脚本 的时候,直接 bash my_script.sh 就好了。

开箱即用的补全和高亮

这是原来的 zsh,自动补全和高亮一应俱全,就是启动慢了点。

zsh

这是新安装的 fish,自动补全和高亮开箱即用,没有装任何插件,只换了一个原生主题,启动飞快

fish

更妙的两点是,fish 的自动补全是基于上下文的,在不同的位置会有不同的补全;而且 fish 还会 自动解析 man page,智能补全命令。

更简单易记的语法

前面提到 fish 和 bash 是不兼容的,更准确得说,fish 不是一个 posix compatible 的 shell, 虽然有些和以前的习惯用法不一样的地方,但是这也意味着 fish 可以摆脱一些历史上的设计错误, 从而拨乱反正。

比如说,fish 中不使用 $(cmd) 或者 `cmd` 来执行命令替换,直接使用 (cmd)

fish 中的 for 循环也更像现代编程语言 (Ruby):

for i in *.pdf
    echo $i
end

而在 bash/zsh 中需要:

for i in *.pdf; do
    echo $i;
done

至少对我而言,for 后面的那个分号是特别容易忘记的。

fish 中不需要 heredoc,因为字符串直接可以跨行。当然也可以在每行结尾处加上 \ 转义换行。

echo "some string
some more string"

相当于 bash 中的:

cat <<EOF
some string
some more string
EOF

如果你装的新版本的 fish,那么是支持浮点数的,以前在命令行做个简单运算还得打开 python, 现在直接:

math 2/5  # 0.4

就可以了。不过老版本的 fish 貌似并不支持浮点数,至少 fish 2.7 是这样的。

bash 中最混乱的部分要数字符串了,比如说

  • ${foo%bar}(从后向前删除)
  • ${foo#bar}(从前向后删除)
  • ${foo/bar/baz}(正则替换)

这个 %# 我从来都不知道是干吗的。在 fish 中全部都替换成了内置命令 string 的方法, 和其他语言比较接近,不再是加密代码了。

# 替换字符串 ${var/pattern/replacement}
bash -c 'export name=Apple; echo ${name/pp/qq}'
name=Apple string replace pp qq $name

# ${foo#bar}
bash -c 'export name=Apple; echo ${name#App}'
name=Apple string replace App '' $name

# ${foo%bar}
bash -c 'export name=Apple; echo ${name%le}'
name=Apple string replace --regex 'le$' '' $name

还有,bash 中的特殊变量也挺难记的,fish 中也都改成了单词:

$*, $@, $1 ...: $argv  # 函数或者脚本的参数
$0: status filename  # 函数或者脚本的名字
$#: 使用 $argv 的长度
$?: $status  # 上一个命令的返回值
$$: $fish_pid  # shell 的 pid
$!: $last_pid  # 上一个命令的 pid
$-: 大多数使用是 status is-interactive 和 status is-login

fish 中还有一个比较花哨的地方,可以打开浏览器选择主题和各种配置。直接执行 fish_config 就打开了。

另一个需要配置的一些地方就是我自己常用的两个函数了:

  • proxy, 打开关闭命令行代理
  • auto_venv,自动激活和关闭 Python 虚拟环境

以 proxy 来看一下,fish 的函数还是很直观的。

function proxy
  if test "$argv[1]" = "on"
    if test "$argv[2]" = ""
      echo "No port provided"
      return 2
    end
    # proxy offered by local shadowsocks
    export http_proxy="http://127.0.0.1:$argv[2]"
    export https_proxy="http://127.0.0.1:$argv[2]"
  else if test "$argv[1]" = "off"
    set -e http_proxy  # set --erase
    set -e https_proxy
  else if test "$argv[1]" != ""
    echo "Usage:
        proxy          - view current proxy
        proxy on PORT  - turn on proxy at localhost:PORT
        proxy off      - turn off proxy"
    return 1
  end
  echo "Current: http_proxy=$http_proxy https_proxy=$https_proxy"
end

最后,还可以把 zsh 的 history 迁移到 fish 中来。令人没想到的是,这都有现成的脚本:

pip install zsh-history-to-fish
zsh-history-to-fish -n  # -n 不要转化 && 和 ||

fish 需要一段时间才会重新读取 history 文件。至此,迁移完毕啦。

后记

在切换到 fish 之后我还是对 zsh 为什么这么慢念念不忘,在对 .zshrc 做了一番 profile 和二分 查找之后终于找到了罪魁祸首,和新电脑的硬件并没有什么关系,而是这样一行:

[ -d /home/linuxbrew/.linuxbrew/bin ] && path+=(/home/linuxbrew/.linuxbrew/bin)

这行人畜无害的命令意思是:如果机器上有 linuxbrew 就把它添加到路径里。而在我的 M1 MacBook, 不知道为啥 /home 被挂载到了一个网络目录,所以每次打开一个 shell 的时候都会执行一个网络 操作,而且这个服务器还可能在美国,能不慢么……删除了这行后,zsh 启动又恢复到正常水平。

┬─[yifei@bogon:~]─[21:45:06]
╰─>$ ll /home
lrwxr-xr-x  1 root  wheel    25B Dec  4 14:46 /home -> /some/netword/drive

不过即便如此,切换到 fish 还是值得的,一则简化并看懂了自己的配置,二则对于 shell 启动时间 也是有优化的:

┬─[yifei@bogon:~]─[19:05:15]
╰─>$ time  zsh -i -c exit

________________________________________________________
Executed in  254.61 millis    fish           external
   usr time  148.16 millis    0.07 millis  148.10 millis
   sys time   98.99 millis    1.59 millis   97.40 millis

┬─[yifei@bogon:~]─[19:07:44]
╰─>$ time fish -i -c exit

________________________________________________________
Executed in   44.84 millis    fish           external
   usr time   16.57 millis    0.08 millis   16.49 millis
   sys time   21.33 millis    1.67 millis   19.66 millis

可以看到在我的配置下,fish 比 zsh 还是快多了。

最后贴上自己的配置文件供参考:

###### .dotfiles/fishrc ######

# vi:ft=fish
set DISABLE_FZF_AUTO_COMPLETION true
export TERM="xterm-256color"
export EDITOR="vi"

# PATH settings
set PATH $HOME/.local/bin $HOME/.cargo/bin $HOME/.dotfiles/bin $PATH

# Load HomeBrew
export HOMEBREW_NO_AUTO_UPDATE=1
export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles
test -f /opt/homebrew/bin/brew && eval (/opt/homebrew/bin/brew shellenv)
test -f /usr/local/bin/brew && eval (/usr/local/bin/brew shellenv)

if uname | grep Linux
  set PATH /home/linuxbrew/.linuxbrew/bin $PATH
end

# Aliases
alias dc=docker-compose
alias pc=podman-compose
alias t='tmux -2'
alias tmux='tmux -2'
alias cd..='cd ..'
alias py=python
alias ipy='python -m IPython'
alias g='git'
alias ll='ls -alh'
alias :q='exit'
alias :wq='exit'
alias mkdirp='mkdir -p'
alias shn='sudo shutdown -h now'
alias mirror='wget -E -H -k -K -p'
alias sudo='sudo ' # magic trick to bring aliases to sudo
alias px="proxychains4"
alias lcurl='curl --noproxy localhost'
alias save-last-command='history | tail -n 2 | head -n 1 >> ~/.dotfiles/useful_commands'
alias topcpu='ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu | head'
alias topmem='ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%mem | head'

# Venv auto actiavation
function __auto_source_venv --on-variable PWD --description "Activate/Deactivate virtualenv on directory change"
  status --is-command-substitution; and return

  # Check if we are inside a git directory
  if git rev-parse --show-toplevel &>/dev/null
    set gitdir (realpath (git rev-parse --show-toplevel))
  else
    set gitdir ""
  end

  # If venv is not activated or a different venv is activated and venv exist.
  if test "$VIRTUAL_ENV" != "$gitdir/.venv" -a -e "$gitdir/.venv/bin/activate.fish"
    source $gitdir/.venv/bin/activate.fish
  # If venv activated but the current (git) dir has no venv.
  else if not test -z "$VIRTUAL_ENV" -o -e "$gitdir/.venv"
    deactivate
  end
end

# Proxy switcher
function proxy
  if test "$argv[1]" = "on"
    if test "$argv[2]" = ""
      echo "No port provided"
      return 2
    end
    # proxy offered by local shadowsocks
    export http_proxy="http://127.0.0.1:$argv[2]"
    export https_proxy="http://127.0.0.1:$argv[2]"
  else if test "$argv[1]" = "off"
    set -e http_proxy
    set -e https_proxy
  else if test "$argv[1]" != ""
    echo "Usage:
        proxy          - view current proxy
        proxy on PORT  - turn on proxy at localhost:PORT
        proxy off      - turn off proxy"
    return 1
  end
  echo "Current: http_proxy=$http_proxy https_proxy=$https_proxy"
end

# Load fzf config
test -f ~/.dotfiles/fzf.fish && source ~/.dotfiles/fzf.fish

###### .config/fish/config.fish ######
if status is-interactive
    # Commands to run in interactive sessions can go here
end

test -f ~/.dotfiles/fishrc && source ~/.dotfiles/fishrc

###### .dotfiles/fzf.fish ######
# vi:syntax=sh

export FZF_DEFAULT_COMMAND='fd --type f'
export FZF_CTRL_T_COMMAND='fd --type f'
export FZF_ALT_C_COMMAND='fd --type d'
export FZF_COMPLETION_TRIGGER=''
export FZF_DEFAULT_OPTS="--height 40% --reverse --border --prompt '>>> ' \
    --bind 'alt-j:preview-down,alt-k:preview-up,alt-v:execute(vi {})+abort,ctrl-y:execute-silent(cat {} | pbcopy)+abort,?:toggle-preview' \
    --header 'A-j/k: preview down/up, A-v: open in vim, C-y: copy, ?: toggle preview, C-x: split, C-v: vsplit, C-t: tabopen' \
    --preview 'test (du -k {} | cut -f1) -gt 1024 && echo too big || highlight -O ansi -l {} 2> /dev/null || cat {} || tree -C {} 2> /dev/null'"
export FZF_CTRL_T_OPTS=$FZF_DEFAULT_OPTS
export FZF_CTRL_R_OPTS="--preview 'echo {}' --preview-window hidden:wrap --bind '?:toggle-preview'"
export FZF_ALT_C_OPTS="--height 40% --reverse --border --prompt '>>> ' \
    --bind 'alt-j:preview-down,alt-k:preview-up,?:toggle-preview' \
    --header 'A-j/k: preview down/up, ?: toggle preview' \
    --preview 'tree -C {}'"
bind \cr 'commandline --replace -- (history | fzf) || commandline --function repaint'

附:Ubuntu 上安装 fish3

sudo apt-add-repository ppa:fish-shell/release-3
sudo apt update
sudo apt install fish

后记 2——踩的第一个坑

Fish 会对 PATH 做一些优化,确保没有重复,这反倒导致了 PATH 数组会发生“随机”顺序更换。而 HomeBrew 的目录/opt/homebrew 不在最前面的话,就会回退到系统在 /usr/local 的老命令。 以前用 zsh 的时候似乎不存在这个问题,也不知道是 zsh 实现比较稳妥,还是因为以前 HomeBrew 也装在 /usr/local。不过好在 fish 提供了 fish_add_path 这个函数,使用它可以确保自定义 路径在最前边。另外还有一个坑,python-dotenv 这个包似乎不兼容 fish,只好用 direnv 了, 似乎还更好用一些。配置文件更改如下:

diff --git a/fishrc b/fishrc
index 1faafb5..a1187c1 100644
--- a/fishrc
+++ b/fishrc
@@ -4,12 +4,19 @@ export TERM="xterm-256color"
 export EDITOR="vi"

 # PATH settings
-set PATH $HOME/.local/bin $HOME/.cargo/bin $HOME/.dotfiles/bin $PATH
+fish_add_path $HOME/.local/bin $HOME/.cargo/bin $HOME/.dotfiles/bin

 # Load HomeBrew
 export HOMEBREW_NO_AUTO_UPDATE=1
 export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.ustc.edu.cn/homebrew-bottles
-test -f /opt/homebrew/bin/brew && eval (/opt/homebrew/bin/brew shellenv)
+if test -f /opt/homebrew/bin/brew
+    set -gx HOMEBREW_PREFIX "/opt/homebrew";
+    set -gx HOMEBREW_CELLAR "/opt/homebrew/Cellar";
+    set -gx HOMEBREW_REPOSITORY "/opt/homebrew";
+    set -gx HOMEBREW_SHELLENV_PREFIX "/opt/homebrew";
+    fish_add_path "/opt/homebrew/bin" "/opt/homebrew/sbin";
+end
+
 test -f /usr/local/bin/brew && eval (/usr/local/bin/brew shellenv)

 if uname | grep Linux
@@ -84,3 +91,6 @@ end

 # Load fzf config
 test -f ~/.dotfiles/fzf.fish && source ~/.dotfiles/fzf.fish
+
+# Enable direnv
+type -q direnv && direnv hook fish | source

参考

  1. https://github.com/fish-shell/fish-shell/issues/989
  2. https://rmpr.xyz/the-fish-shell-is-amazing/
  3. Fish for bash users tutorial
  4. Venv auto activate
  5. https://jvns.ca/blog/2017/04/23/the-fish-shell-is-awesome/
  6. https://launchpad.net/~fish-shell/+archive/ubuntu/release-3?field.series_filter=bionic
  7. https://fishshell.com/docs/current/cmds/bind.html
  8. https://stevenvanbael.com/profiling-zsh-startup

© 2016-2022 Yifei Kong. Powered by ynotes

All contents are under the CC-BY-NC-SA license, if not otherwise specified.

Opinions expressed here are solely my own and do not express the views or opinions of my employer.