zsh を卒業し、fish に完全移行
fish という名前は、実はかなり前から聞いていた。oh-my-zsh を使い始めたころ、oh-my-zsh の便利な機能の多く――履歴に基づいて灰色の候補を出す autosuggestions、入力時のシンタックスハイライト、より賢い補完――が、実は zsh 上で fish 標準の機能を再現したものだと知った。
その後 oh-my-zsh から zinit に乗り換えたのは、速度のためだった。zinit は確かに速いが、その速さのために設定は wait、lucid、atinit といったものばかりで、補完では compinit のタイミングをわざわざ調整する必要があり、しばらくするとメンテナンスが必要になるうえ、頭の負担がとても重く、当時何をしたかったのかいつも思い出せない😭。
先日、fish が 4.0 から Rust で完全に書き直されているのをたまたま見かけた。Rust で書かれたものにはいつも少しだけ好感を持っているので、さっそくインストールして試してみた。さすがは名高い fish としか言いようがない。プラグインが一切なくても、zsh で何年も試行錯誤してようやく設定できた機能がすでに揃っている。それで数日もしないうちに、デフォルトシェルを完全に切り替えてしまった。
すぐに使える
インストールしたばかりの fish は、設定が何もなくても、すでに次の機能を備えている。
- 自動提案。履歴と現在のディレクトリ内のファイルに基づいて、入力中に灰色の文字で候補を表示し、右方向キーで受け入れ、
Alt + →は 1 語だけ受け入れる。 - シンタックスハイライト。コマンドが存在すればある色で表示され、存在しなければそのまま赤く表示され、パスが存在すれば下線も付く。
- 補完。多くのコマンドの補完は fish が man ページから自動で解析しているので、引数の説明まで表示され、Tab を押すだけで確認できる。
- 起動速度。遅延読み込みのような仕組みは不要で、開けばすぐに立ち上がる。
これらは zsh では、それぞれ zsh-autosuggestions、zsh-syntax-highlighting、zsh-completions、さらに fzf-tab に頼らないと実現できず、しかも読み込み順まで気を配る必要がある。
インストール
私の Mac は現在 nix-darwin で管理しているので、fish もそれでインストールする。nix-darwin の programs.fish.enable は fish をインストールし、Nix の環境を設定する。environment.shells はそれを加える。追加先は /etc/shells。
{ pkgs, ... }:
{
programs.fish.enable = true;
environment.shells = [ pkgs.fish ];
}
darwin-rebuild switch の後で、次に chsh でデフォルトシェルを切り替える。
chsh -s /run/current-system/sw/bin/fish
ここで使うのは /run/current-system/sw/bin/fish であり、/nix/store 内の特定のパスではない。これは常に現在のシステムバージョンを指すリンクなので、今後 fish がアップグレードしても、再実行しなくてよいのは chsh。
設定ファイルの構造
fish の設定はすべて ~/.config/fish の下にあり、zsh がすべてを 1 つの .zshrc に詰め込むのとは異なり、fish は設定を分割することをもともと推奨している。
~/.config/fish/
├── config.fish # 主配置
├── conf.d/ # 启动时会自动加载的片段
│ ├── 05-env.fish
│ ├── 10-colors.fish
│ ├── 20-abbrs.fish
│ └── 30-integrations.fish
├── functions/ # 一个文件一个函数,用到的时候才加载
│ ├── dog.fish
│ ├── git-amend-date.fish
│ ├── list_node_modules.fish
│ └── yy.fish
└── fish_plugins # fisher 的插件列表
conf.d 内のファイルはファイル名の順に読み込まれるので、先頭に数字を付けている。functions は fish の自動読み込みディレクトリで、関数が最初に呼び出されたときに初めて fish が対応するファイルを読む。だから関数をどれだけ書いても起動速度は落ちない。これは以前 zinit でモジュールを分割していた考え方とほぼ同じだが、fish ではネイティブにサポートされている。
config.fish
メイン設定の中心は PATH と、いくつかのツールの初期化だ。
set -g fish_greeting
fish_add_path -g /run/current-system/sw/bin /nix/var/nix/profiles/default/bin
fish_add_path -g $HOME/bin $HOME/go/bin $HOME/.local/bin $HOME/.cargo/bin
if test -x /opt/homebrew/bin/brew
fish_add_path -g /opt/homebrew/bin /opt/homebrew/sbin
end
set -gx LANG C.UTF-8
set -gx LC_ALL C.UTF-8
set -gx FZF_DEFAULT_COMMAND 'fd --type file --hidden --no-ignore'
if status is-interactive
set -gx GPG_TTY (tty)
if type -q mise
mise activate fish | source
end
if type -q starship
starship init fish | source
end
if type -q zoxide
zoxide init fish | source
end
if type -q direnv
direnv hook fish | source
end
end
set -g fish_greeting は fish が起動のたびに表示する歓迎メッセージをオフにする。fish_add_path は自動で重複を排除し、存在しないパスはそのまま飛ばすので、zsh で一行ずつ export PATH=... と書くよりずっとすっきりしている。
status is-interactive は現在のシェルが対話型かどうかを判定するために使い、対話型のときだけ starship や zoxide といったものを初期化すればよいので、fish でスクリプトを動かすときには省ける。type -q はコマンドが存在するかを確認する。これにより、まだ starship を入れていないマシンに移ってもエラーにならない。
変数とエイリアス
fish の変数は set で設定し、-g はグローバル変数、-x は環境変数としてエクスポートするという意味なので、zsh の export FOO=bar は fish では set -gx FOO bar。
エイリアスについては、fish には alias のほかに、abbr という省略形もある。
status is-interactive; or return
abbr -a fzf fzfp
abbr -a cat bat
abbr -a c clear
alias ls 'eza --group-directories-first'
alias la 'eza -la --group-directories-first'
alias ll 'eza -l --group-directories-first'
alias l 'eza -1 --group-directories-first'
abbr と alias の違いは、cat と入力してスペースを押すと、コマンドライン上で直接 bat に展開されることだ。実際に実行されるコマンドも、履歴に残るコマンドも、展開後の完全なコマンドになるので、他人が私のターミナル録画を見ても理解できる。私は今、あるコマンドを置き換えたいときは abbr を使い、ls のようにデフォルト引数を少し足したいだけのときは alias。
zsh の関数を移行する
fish の構文は bash や zsh と互換性がないので、元の関数はすべて書き直す必要がある。幸い、ほとんどの関数は短いので、修正はすぐ終わる。現在の DNS が Surge かどうかを判定する私の dog 関数を例にすると、zsh ではこうなっている。
DOG_SURGE_DNS="198.18.0.2"
DOG_DEFAULT_DNS="https://dns.alidns.com/dns-query"
function dog() {
if [[ "$@" == *@* ]]; then
doggo "$@"
else
current_dns=$(cat /etc/resolv.conf | grep '^nameserver' | awk '{print $2}' | head -n 1)
if [ "$current_dns" != "$DOG_SURGE_DNS" ]; then
doggo "$@"
else
doggo "$@" @"$DOG_DEFAULT_DNS"
fi
fi
}
fish に置き換えた後は、functions/dog.fish に置く。
function dog --description 'Run doggo with Surge-aware DNS fallback'
set -l dog_surge_dns 198.18.0.2
set -l dog_default_dns https://dns.alidns.com/dns-query
if string match -rq '@' -- "$argv"
doggo $argv
return
end
set -l current_dns (awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf)
if test "$current_dns" != "$dog_surge_dns"
doggo $argv
else
doggo $argv @"$dog_default_dns"
end
end
いくつか明らかな違いがある。引数は $argv であり、$@ ではない。コマンド置換では括弧 (...) を使う。使わないのは $(...)。if と function はどちらも end で終わり、fi や波括弧はない。文字列処理には組み込みの string コマンドがあり、string match -rq は正規表現でマッチし、結果を返すだけで出力しない。また fish の変数はデフォルトではスペースで分割されないので、zsh のようにあちこちに引用符を付ける必要もない。
関数には --description を付けることもでき、補完時に表示されるので、しばらくしてから見返しても、その関数が何をするものか分かる。
色
fish のデフォルト配色は、以前使っていた zsh-syntax-highlighting とはかなり違う。何年も使ってきた配色なので、急に変わるとまだ慣れない。そこで conf.d/10-colors.fish でハイライトの色を zsh 側とほぼ同じになるように調整した。
status is-interactive; or return
set -g fish_color_command green
set -g fish_color_keyword yellow
set -g fish_color_quote yellow
set -g fish_color_error red --bold
set -g fish_color_comment brblack --bold
set -g fish_color_autosuggestion brblack
set -g fish_color_valid_path --underline
コマンドは緑、打ち間違いは太字の赤、自動提案は暗い灰色で、以前とまったく同じなので、乗り換えた後もほとんど違いを感じない。
プラグイン
fish 標準の機能で十分だが、いくつか欲しいものもある。fish のプラグインマネージャーには fisher を使っている。とてもシンプルで、プラグイン一覧は fish_plugins というファイルに 1 行ずつ書く。
jorgebucaran/fisher
PatrickF1/fzf.fish
jhillyerd/plugin-git
xxxbrian/fzf-tab.fish
このファイルを変更した後、fisher update を実行すると、一覧に従ってプラグインがインストールまたは削除される。
plugin-git
fzf.fish は fish に fzf のショートカット群を追加し、履歴、ファイル、git log、git status、プロセス、変数を検索できるようにする。私は自分の習慣に合わせてキーを割り当て直した。
if type -q fzf_configure_bindings
fzf_configure_bindings --history=\cr --directory=\e\cf --git_log=\e\cl --git_status=\e\cs --processes=\e\cp --variables=\cv
end
その中で最もよく使うのは Ctrl + R で履歴を検索するもので、zsh での fzf の使い方と同じだ。
fzf.fish
plugin-git は oh-my-zsh の git プラグインにあった略語一式を fish に移植しており、gst、gco、gcmsg といったものも残っている。しかも fish では abbr で実装されているので、入力すると直接完全な git コマンドに展開される。
fzf-tab.fish
zsh では fzf-tab にかなり頼っていて、Tab を押したときに fzf で補完を選び、ファイルやディレクトリのプレビューもできる。fish では、対応するプラグインは fifc で、使い方はほぼ同じで、各種プレビューにも対応している。
しかし今使っているターミナルは Ghostty で、fifc は Ghostty で画像をプレビューするときにいくつか問題がある。たとえば画像が画面に残ったり、画像とテキストのプレビューを切り替えるときにちらついたりする。そこで私はフォークして、xxxbrian/fzf-tab.fish に変更した。主な変更は、画像プレビューを Ghostty がサポートする kitty グラフィックプロトコル経由にすること、プレビューを切り替えるときに前の画像を先に消すこと、メディアファイルのプレビューもかなり滑らかにすることだ。
必要な設定は、補完時にファイルを直接開くためのエディターを 1 つ指定するだけだ。私は Helix を優先し、なければ Neovim にフォールバックするようにしている。
if not set -q fifc_editor
if type -q hx
set -gx fifc_editor hx
else if type -q nvim
set -gx fifc_editor nvim
end
end
POSIX 互換
fish で最もよく言われる欠点は、POSIX に互換性がなく、bash や zsh の構文の多くを fish でそのまま使えないことだ。たとえばネットからコピーした 1 行の export FOO=bar && some_command や、for ... do ... done を含むループを fish に貼り付けるとエラーになる。
とはいえ、このことは思ったよりずっと影響が小さい。スクリプトファイルにはもともと独自の shebang があり、#!/bin/bash と書かれたスクリプトは、fish で実行してもやはり bash に処理を任せるので、まったく影響を受けない。普段問題になるのは、コマンドラインでその場で打つものだけだ。しかも今は、少し複雑なパイプコマンドやスクリプトはほとんど AI に書かせている。私が fish を使っていると伝えるか、そのまま bash スクリプトとして書かせてから実行すればよいので、それほど問題ではない。どうしても必要なときは、一時的に bash か zsh を開くだけの話だ。
現在
今では fish が私のデフォルトシェルだ。zsh と zinit の設定も削除せずに残してあり、いつか必要になったらいつでも戻せる。ただ、ここ数日使ってみた感じでは、どうやらその必要もなさそうだ。
振り返ると、oh-my-zsh から zinit まで、私は zsh を速く、使いやすくするために多くの時間を費やしてきた。しかし fish は最初からこの状態だった。設定ファイルも、以前の zi ice の山から、いくつかの明確な小さなファイルに変わった。