🔧

ZLEを理解してzshのabbreviation(略語展開)を自作する

に公開

はじめに

改善版つくりました

zsh で alias を使っていると、こんな不満が出てきます。

  • 半年前の history をみて「gstって何のaliasだっけ?」となる
  • 履歴を共有しても再現できない
  • 短縮形に慣れすぎて元のコマンドを忘れる

これらを解決するのが abbreviation(略語展開) という機能です。
fish シェル由来の概念で、alias との違いは「展開が目に見える」点にあります。
alias は入力した短縮形がそのまま実行され、裏で置き換わるため画面にも履歴にも短縮形しか残りません。
一方 abbr は、Space を押した瞬間に短縮形が画面上で元のコマンドに展開されます。
入力は省略したコマンドで、実行時には元のコマンドが展開されるので、history にも正規のコマンドが残りますし、毎回目に入るので短縮形だけしか覚えていないという事態も防げます。

zsh ではzsh-abbrというプラグインが有名なようです。

とても便利な仕組みなので、早速自分のzshにも取り入れていきたい所ですが、

  • シェルの入力を解釈して展開する仕組みってどうなってるの?
  • 仕組みがわかればプラグインとして導入しなくてもいけるのでは?

という所を起点に色々と調べたらzsh が内蔵する ZLE( Zsh Line Editor )の仕組みを理解し、組み込みウィジェットを使って略語展開を自作することが出来そうだと分かりました。
これは実際に.zshrcに40行ほどのコードを追加することで abbreviation を実装することが出来た記録です。


略語展開を展開する

「略語展開」を目指していきますが、まずは「略語展開」という言葉自体を展開して 3 つの概念に分解してみます。

文字 扱うもの ZLE でいう何か
入力されている文字列 バッファ
展開 文字列をどう変換するか ウィジェット
何を略語として扱うか 選別と登録の仕組み

この 3 つの要素について記事の前半 3 章で個別に展開していき、

  1. 第 1 章 ── ZLE が扱う文字列とバッファの概念
  2. 第 2 章 展開 ── ウィジェットという変換単位
  3. 第 3 章 ── 略語として登録する仕組みの設計
  4. 第 4 章 統合 ── 3 つを組み合わせて実装する
  5. 第 5 章 発展 ── 応用機能の追加
  6. 第 6 章 評価 ── zsh-abbr との比較

最終的に第 4 章で 1 つのウィジェット関数の中に 1 行ずつとして集約されます。
分からないものは、分かるところまで細分化して、3 つの独立した概念として学び、最後に組み立てる構造です。

ZLE を理解すると、略語展開に限らずあらゆるキー入力のカスタマイズが怖くなくなります。
本記事の副次的な効用として、そこを目指します。

なお、言葉の並びは「略・語・展開」ですが、本記事では ZLE の基礎から順に積み上げて理解するため、語 → 展開 → 略 の順番で解説を進めます。


第 1 章 「語」 ── ZLE が扱う文字列

略語展開の対象となる「入力中の文字列」が、どこに、どのような形で存在するのかを最初に押さえていきます。

1.1 シェルには「エディタ」が内蔵されている

ターミナルでコマンドを打つとき、実はテキストエディタが動いています。bash なら Readline、zsh なら ZLE( Zsh Line Editor ) です。

┌─ ターミナル画面 ─────────────────────┐
│ $ git checkout main                │  ← ここに表示される文字列
└────────────────────────────────────┘

         ZLE が管理するバッファ

普段何気なく打っている文字入力、カーソル移動、Backspace による削除、Ctrl+A による行頭ジャンプ ―― これらはすべて ZLE の世界で起きている出来事です。画面に見えている文字列そのものが、ZLE が内部的に保持するバッファの写し絵と考えてください。

1.2 バッファ変数と読み書き可能性

ZLE は特殊変数を通じてバッファ状態を公開しています。

変数 意味
$BUFFER バッファ全体の文字列
$LBUFFER カーソルより左の文字列
$RBUFFER カーソルより右の文字列
$CURSOR カーソル位置(0-indexed)
$KEYS 押されたキー

これらの変数は読み書き可能です。ZLE の世界で動く関数の中で BUFFER="hello" と書けば、画面上のコマンドラインが hello に書き換わります。

この「バッファを書き換えられる」という事実が、略語展開の根幹にあります。短縮形の gst を完全形の git status に変換するというのは、バッファの文字列を書き換える行為そのものだからです。

1.3 キーマップ ── 状況別のキー解釈

ZLE のもう一つ重要な概念としてキーマップがあります。

同じキーでも、状況によって異なる振る舞いが必要なことがあります。
たとえば vi キーバインドを使っている場合、Space キーは挿入モード(viins)では文字挿入、ノーマルモード(vicmd)ではカーソル前進、というように分かれていてほしいはずです。

キーマップ 用途
main デフォルト(通常は emacsviins にリンク)
emacs emacs キーバインド
viins vi 挿入モード
vicmd vi ノーマルモード
isearch インクリメンタル検索中

キーバインドを設定するときは bindkey -M <keymap> で特定のキーマップに対してバインドできます。引数なしの bindkey はデフォルトキーマップへのバインドになります。

この概念は第 4 章で改めて取り上げます。
vi ユーザーは bindkey ' ' _expand_abbr と単純に書いただけでは挿入モードで展開が効かない、という問題に直結するからです。vi ユーザーである著者には重要な要素ですが、今は「キーマップは状況別のキーバインド帳が複数ある仕組み」とだけ覚えておいてください。

1.4 バッファの中を覗いてみる

ZLE の世界で起きていることを目に見える形で確認してみたいと思います。

試しに、以下を.zshrc に追加して新しいターミナルを開いてみます。

show-buffer() {
  zle -M "BUFFER=[$BUFFER] LBUFFER=[$LBUFFER] CURSOR=$CURSOR"
}
zle -N show-buffer
bindkey '^X^D' show-buffer   
# Ctrl+X Ctrl+D にバインド

何か入力した状態でCtrl+X Ctrl+D を押すと、バッファの中身がミニバッファに表示されます。
カーソル位置を動かして再度Ctrl+X Ctrl+D するとLBUFFERCURSORが変化しているのが分かります。
ZLEの世界が少し見えてきましたね。


第 2 章 「展開」── 文字列をどう変換するか

バッファの中身を変換する手段を見ていきます。
ZLE の世界ではあらゆる変換が ウィジェット( widget ) という単位で行われています。

2.1 ウィジェットという単位

ZLE の世界では、すべてのキー入力がウィジェットを呼び出す 仕組みになっています。

キー入力 → bindkey テーブル → ウィジェット関数 → バッファ操作

例えば、普通に文字を打つだけでも

キー バインドされたウィジェット 動作
a self-insert バッファにその文字を挿入
Backspace backward-delete-char カーソル手前の 1 文字を削除
Ctrl+A beginning-of-line カーソルを行頭に移動
Enter accept-line バッファの内容を実行
Space self-insert スペースを挿入(デフォルト)

というウィジェットが呼び出されています。
これをよく見ると、Space キーを押したときの動作を差し替えれば、Space に好きな処理を割り込ますことができるということです。
ここは Space に略語展開の処理を差し込めば、いい感じに出来そうですね。
やっていきましょう。

2.2 3種類のウィジェット

ZLE のウィジェットは3 種類存在します。

種類 接頭辞 説明
組み込み(builtin) . zsh がネイティブに提供(.self-insert, .accept-line 等)
ユーザー定義 なし zle -N で登録した関数
補完系 _ 補完システム)( compsys )が提供(_expand_alias 等)

現在zshに登録されているウィジェットはzle -laで一覧が取得できます。

2.3 略語展開で使うウィジェット

略語展開を実装するのに使うウィジェットは主に以下の 4 つです。

.self-insert

最も基本的なウィジェット。
押されたキーに対する文字をバッファに挿入します。

_expand_alias

zsh の補完システム(compsys)が提供するウィジェット。
カーソル直前の単語が alias テーブルに存在するか調べ、存在すればその場で展開します。
デフォルトではCtrl+X aにバインドされています。

$ bindkey | grep _expand_alias
"^Xa" _expand_alias

試してみます。

$ alias gst='git status'
$ gst          # ← ここで Ctrl+X a を押す
$ git status   # ← 画面上で展開される

大変abbreviationっぽさがありますね。

.expand-word

_expand_aliasより広い範囲のシェル展開を行うウィジェット。
global alias、パラメータ展開、グロブなどを含み、第 5 章で global alias に触れる時に使用します。

.magic-space

Space を挿入しつつ、!!!$のような履歴展開を行うウィジェット。
略語展開を抑制したいときの「通常の Space 」として Ctrl+Space を割り当てるために使います。

2.4 ユーザー定義ウィジェットの作り方

ZLE のウィジェットは通常の zsh 関数として定義し、zle -N で ZLE に登録します。

# 1. 関数を定義
my-widget() {
  # ここで BUFFER, LBUFFER, CURSOR 等を操作
  # 他のウィジェットを zle コマンドで呼べる
}

# 2. ZLE に登録
zle -N my-widget

# 3. キーにバインド
bindkey '<key>' my-widget

ウィジェット関数の中でできること

  • バッファ変数の読み書き:BUFFER,LBUFFER,RBUFFER,CURSOR
  • 他のウィジェットの呼び出し:zle self-insert,zle _expand_aliasなど
  • ミニバッファへのメッセージ表示:zle -M "メッセージ"
  • 通常の zsh 構文:if,for,パラメータ展開、条件式

第 3 章 「略」── 何を略語として扱うか

どの「語」を「略」だと扱うかについての仕組みを考えます。

3.1 「すべて展開」ではなく「展開したいものだけを展開」

alias の中で展開してほしいものと、展開しなくてもよいものが出てきます。

alias gst='git status'     # → abbr にする。展開で git のサブコマンド体系が見える
alias ls='ls --color=auto' # → alias のまま。表示設定でありコマンドの意図と無関係
alias ..='cd ..'           # → alias のまま。短縮形自体が慣用表現として通じる

gstgit status に展開されると、git コマンドの status サブコマンドという体系が画面に現れます。
元のコマンドの知識が自然と定着しますし、履歴を別環境で見返したときや他人と共有したときにも何をしたか一目でわかります。
一方 ls='ls --color=auto' のように、コマンド名自体は変わらず環境設定的なオプションを暗黙で付与するタイプの alias は、展開されても --color=auto が目に入るだけでノイズになります。
コマンドの意図は ls であって、色設定は各環境の好みの問題にすぎないので展開は不要として扱いたいです。

そこで「略語として登録した alias だけ展開する」という選別の仕組みが必要になります。
つまり略語として登録された集合を alias とは別に保持する必要が出てきます。

3.2 登録対象を保持するデータ構造の選択

登録された略語名を保持するデータ構造として、主に2つの選択肢があります。

選択肢 データ構造 検索手法
A 配列 typeset -a \<(a|b|c)\$ のような正規表現で照合
B 連想配列 typeset -A キー存在チェック ${+hash[key]}

選択肢 A は「登録された略語名の配列を | で結合して正規表現を作り、カーソル直前の単語とマッチさせる」というアプローチです。直感的に書けますが、毎回正規表現を構築してコンパイルする必要があります。

選択肢 B は「連想配列のキーとして略語名を持ち、単語がキーとして存在するかをハッシュ検索する」というアプローチです。

この記事では選択肢 B (連想配列)を採用します。

3.3 連想配列を採用する4つの理由

1.計算量の差

実装 計算量
配列 + 正規表現 O(N × L)
連想配列 O(1) 平均

N = 登録数、L = 平均略語名長。配列版は登録数に対して線形に遅くなりますが、連想配列版は登録数に依存しません。

2.コード量が変わらない

正規表現版と連想配列版のコード行数がほぼ同じになりました。
「計算量が良くてコード量が同じ」なら連想配列を選ばない理由がありません。

3.毎回の文字列生成を避けられる

正規表現版は Space 押下のたびに${(j:|:)配列}で文字列を新規生成し、正規表現としてコンパイルする必要があります。
連想配列版は配列参照だけで済み、メモリ確保もコンパイルも発生しません。

4.zsh-abbr本家も連想配列を使用

zsh-abbr の内部実装を見ると、略語の管理は連想配列で行われています。
つまり、「実績のある選択」と「教科書的に正しい選択」が一致しています。

3.4 登録関数 abbr() の実装

これまでの判断をコードに落としてみます。

typeset -gA ZSH_ABBR_MAP

abbr() {
  alias "$@"
  ZSH_ABBR_MAP[${1%%=*}]=1
}
  • typeset -gA ZSH_ABBR_MAP で連想配列を宣言。-g は「関数スコープではなくグローバルスコープで宣言」の意味で、abbr 関数の中で参照するために必要です
  • abbr gst='git status' と呼ぶと、内部で alias gst='git status' を実行しつつ、ZSH_ABBR_MAPgst というキーを追加します
  • 値を 1 にしていますが、これは何でも構いません。キーの存在有無だけを使う設計です
  • ${1%%=*} は第 1 引数 gst='git status' から = の前の部分だけを取り出す zsh のパラメータ展開

これで「略語として登録されたもの」の集合を効率的に管理できるようになりました。

3.5 abbrで「何をしない」のか

略語展開は便利な機能ですが、意図的に踏み込まない領域としてサブシェル評価($(...) の動的展開) があることが zsh-abbr の issue tracker から読み解けます。

$(git rev-parse --show-cdup) のような動的コマンドを expansion に含めて、展開時に評価してほしい」という要望が上がったこと(olets/zsh-abbr#26)に対する作者 olets 氏の回答は明確です:

Thanks for the idea! At this time I'm not comfortable supporting evaluation in that way. Seems like a vector for easy abuse.
(提案ありがとう!でも、今のところそのやり方で評価できるようにするのはちょっと怖いかな。悪用の格好の標的になりそうだし。)

この判断が示しているのは、略語展開は「履歴を読みやすく保つ」「短縮形で素早く打つ」ための機能であり、動的計算や複雑な文字列生成の場ではないということです。

abbr に $(...) を含めて展開時に評価できるようにすると:

  • 履歴に残るのは評価後の具体的な文字列になり、再現性が失われる
  • Enter 押下前のプレビューで実コマンドが実行されるため、副作用のあるコマンド($(rm -rf ...) 等)を気軽に書けてしまう
  • 「展開は純粋な文字列置換」という単純モデルが崩れる

自前実装でも、複雑な処理が必要なら abbr ではなく関数を使うという考えを採用します。

例えば、

abbr cdroot='cd $(git rev-parse --show-cdup)'

この略語を Space で展開すると、バッファは cd $(git rev-parse --show-cdup) になります。
ここまでは問題ありませんが、次の 2 つの問題が発生します。

1 つ目は、展開時に $(...) が評価されるのか、Enter を押した実行時に評価されるのかが曖昧なことです。
_expand_alias による展開はバッファの文字列置換であり、通常はサブシェルを評価しません。
しかし expand-word を併用していたり、zsh の設定次第では展開時点で評価が走る可能性があります。
「 Space を押しただけで git rev-parse が実行される」のは、副作用のない読み取り系コマンドならまだしも、書き込み系のコマンドでは危険です。

2 つ目は、仮に展開時に評価された場合、バッファには評価結果(cd ../../)が入ることです。
展開前の意図(リポジトリルートに移動)は失われ、履歴にも具体的なパスだけが残ります。
別のディレクトリで同じ履歴を再実行しても意味がありません。

# 関数なら評価タイミングが明確: 実行時に 1 回だけ評価される
cdroot() {
  cd "$(git rev-parse --show-cdup)"
}

関数の場合、$(...) が評価されるのは関数を呼び出した瞬間です。
Enter を押すまで何も起きませんし、毎回その時点のカレントディレクトリに基づいて評価されます。評価タイミングに曖昧さがありません。

# global alias なら「評価は実行時」という契約がシェルの仕様で保証される
alias -g GR='$(git rev-parse --show-cdup)'
$ cd GR   # Enter で実行される時点で $(git rev-parse --show-cdup) が評価される

global alias の場合、GR はパーサーが $(git rev-parse --show-cdup) に置換するだけで、評価はコマンド実行時です。
これは zsh の alias 展開仕様で保証されている挙動であり、曖昧さがありません。
さらにバッファ上は cd GR のままなので、_expand_alias で展開すれば cd $(git rev-parse --show-cdup) が画面に現れ、何が実行されるか目視確認できます。

この 3 つの違いを整理すると:

手段 展開タイミング 評価タイミング 曖昧さ
abbr + $(...) Space 押下時 不定(設定依存) あり
関数 なし(展開しない) 関数呼び出し時 なし
alias -g パーサー処理時 コマンド実行時 なし

略語展開の expansion は「静的な文字列」に限定し、動的な要素が必要になった時点で関数や alias に切り替える――これが自前実装でも守るべき境界線とします。


第 4 章 統合 ── 「略」「語」「展開」を組み合わせる

ここまでで 3 つの素材が揃いました。

  • 第 1 章 ── LBUFFER で入力中の文字列を読める
  • 第 2 章 展開 ── _expand_alias で alias をその場で展開できる
  • 第 3 章 ── ZSH_ABBR_MAP で略語として登録されたものを O(1) で判定できる

「略」「語」「展開」それぞれの材料が揃いましたので、これらを組み合わせて設計していきます。

4.1 Space 押下時の判定フロー

Space キー押下

LBUFFER からカーソル直前の単語を抽出  ── 語

ZSH_ABBR_MAP に存在するか判定       ── 略
  ├── Yes → _expand_alias で展開  ── 展開
  │         その後 Space 挿入
  └── No  → 通常の Space 挿入

各ステップを経て略語展開全体を実行していきます。

4.2 ウィジェットの実装

_expand_abbr() {
  local word="${LBUFFER##*[[:space:];|&(]}"   # 語(第 1 章)
  if (( ${+ZSH_ABBR_MAP[$word]} )); then      # 略(第 3 章)
    zle _expand_alias                         # 展開(第 2 章)
  fi
  zle self-insert
}
zle -N _expand_abbr

コードの各行がどの章の内容かコメントで示しています。

ここで使っている 2 つの zsh イディオムを解説します。

${LBUFFER##*[[:space:];|&(]}

LBUFFER から「空白 / ; / | / & / (」のいずれかが最後に現れる位置以降を取り出すパラメータ展開です。結果としてカーソル直前の単語(コマンドの区切り以降の文字列)が取れます。

##*pattern は「最長マッチで先頭から削除」の意味。正規表現エンジンを経由しない zsh 組み込みのパラメータ展開なので高速です。

${+ZSH_ABBR_MAP[$word]}

連想配列にキー $word が存在すれば 1、なければ 0 を返します。(( ... )) で算術評価して真偽判定に使います。ハッシュ検索 1 回で済む O(1) 操作です。

4.3 viモード対応のキーバインド

筆者は基本的に vi キーバインドで諸々をよしなに行っていますが、bindkey _expand_abbrと単純に書いただけでは vi キーバインドで挿入モードに入った際に展開が効いてくれないようなので解決したいと思います。

zshは起動時にbindkey -vで vi モードが有効だと、デフォルトキーマップがviinsに変わります。emacs キーバインド(既定)ならmain=emacsですが、vi キーバインドならmain=viinsとなります。
片方のキーマップだけにバインドすると、もう片方で動かなくなってしまいます。

そのため、関係する全キーマップに明示的にバインドするのが安全と判断します。

# --- 3. キーバインド ---
# Space で展開(main / emacs / viins の 3 つに明示バインド)
bindkey -M main  ' ' _expand_abbr
bindkey -M emacs ' ' _expand_abbr
bindkey -M viins ' ' _expand_abbr

# Ctrl+Space で展開抑制(通常の Space として振る舞う)
bindkey -M main  '^ ' magic-space
bindkey -M emacs '^ ' magic-space
bindkey -M viins '^ ' magic-space

# インクリメンタル検索中は通常 Space
bindkey -M isearch ' ' self-insert

vicmd( vi ノーマルモード)はバインドしません。
vicmd の Space はvi-forward-char(カーソル前進)にバインドされており、これを上書きするとカーソル移動が出来なくなるためです。

Ctrl+Space を IME の切り替えに使っている場合は'^[ ' ( Alt+Space )など別のキーに変更する必要があります。

4.4 動作確認

ここまでのコードで略語展開が動作します。

# 略語を登録
abbr gst='git status'
abbr gco='git checkout'
abbr j='jj'
abbr jst='jj status'

# 展開の必要ない場合は alias
alias ls='ls --color=auto'
alias ..='cd ..'

ターミナルで試します:

$ gst<Space>
$ git status       ← 展開された

$ ls<Space>
$ ls               ← alias なので展開されない(意図通り)

$ gco<Space>main
$ git checkout main  ← 履歴にも完全形で残る

vi キーバインド(bindkey -v)を有効にしている場合、挿入モード(ia で入った状態)でも同じように動作することを確認してください。

4.5 完成コード全体

ここまでの実装を 1 つのブロックにまとめます。

# ~/.zshrc - ZLE 略語展開(基本版)

# 略語連想配列と登録関数
typeset -gA ZSH_ABBR_MAP

abbr() {
  alias "$@"
  ZSH_ABBR_MAP[${1%%=*}]=1
}

# ウィジェット
_expand_abbr() {
  local word="${LBUFFER##*[[:space:];|&(]}"
  if (( ${+ZSH_ABBR_MAP[$word]} )); then
    zle _expand_alias
  fi
  zle self-insert
}
zle -N _expand_abbr

# キーバインド
bindkey -M main  ' ' _expand_abbr
bindkey -M emacs ' ' _expand_abbr
bindkey -M viins ' ' _expand_abbr

bindkey -M main  '^ ' magic-space
bindkey -M emacs '^ ' magic-space
bindkey -M viins '^ ' magic-space

bindkey -M isearch ' ' self-insert

# 略語定義
abbr gst='git status'
abbr gco='git checkout'
abbr j='jj'
abbr jst='jj status'

# 展開の必要のない場合は alias のまま
alias ls='ls --color=auto'
alias ..='cd ..'

25 行程度です。これで略語展開の基本機能は完成しました。


第 5 章 発展 ── カーソル位置マーカー

基本実装ができたので、zsh-abbrの機能から実用性を高めるための拡張を取り入れてみます。

5.1 カーソル位置マーカー

git commit -m "" の引用符の中にカーソルを置きたい――こういったケースに対応します。

展開先の文字列に % をマーカーとして埋め込み、展開後にバッファから % を削除してその位置にカーソルを移動させる仕組みです。

_expand_abbr() {
  local word="${LBUFFER##*[[:space:];|&(]}"
  if (( ${+ZSH_ABBR_MAP[$word]} )); then
    zle _expand_alias
    # カーソル位置マーカー処理
    if [[ $BUFFER == *%* ]]; then
      local pos=${BUFFER[(i)%]}        # 最初の % の位置を取得
      BUFFER="${BUFFER[1,pos-1]}${BUFFER[pos+1,-1]}"  # % を削除
      CURSOR=$((pos - 1))              # カーソル移動
      return                            # Space 挿入はスキップ
    fi
  fi
  zle self-insert
}

ここで使っている zsh の文字列操作:

意味
${BUFFER[(i)%]} BUFFER 内で最初に % が出現するインデックスを返す
${BUFFER[1,pos-1]} 1 文字目から % の手前までを切り出す
${BUFFER[pos+1,-1]} % の次から末尾までを切り出す
CURSOR=$((pos - 1)) カーソル位置を直接指定(ZLE はこの変数で制御する)

使用例:

abbr gcm='git commit -m "%"'
abbr jc='jj commit -m "%"'
abbr sed='sed 's/"%"//g'

$ gcm<Space>
$ git commit -m "|"    # ← | はカーソル位置。引用符の中に入る

クォートの中にカーソルが自動で入るため、コミットメッセージのような頻繁に書く定型文の入力摩擦が大きく下げられます。

(あまり発生しないと思われるが) % リテラルを含む略語への対応

printf "%s\n" のように % 自体を略語の展開先に含めたい場合はどうするか、という問題があります。カーソル位置マーカーの % と衝突するため、エスケープ機構が必要です。

解決策は「\% を一時的にプレースホルダ文字に退避し、マーカー処理が終わってから元に戻す」という古典パターンです。

_expand_abbr() {
  local word="${LBUFFER##*[[:space:];|&(]}"
  if (( ${+ZSH_ABBR_MAP[$word]} )); then
    zle _expand_alias
    if [[ $BUFFER == *%* ]]; then
      BUFFER="${BUFFER//\\%/$'\x01'}"   # \% を SOH 制御文字に退避
      local pos=${BUFFER[(i)%]}
      BUFFER="${BUFFER[1,pos-1]}${BUFFER[pos+1,-1]}"
      BUFFER="${BUFFER//$'\x01'/%}"     # SOH を % に戻す
      CURSOR=$((pos - 1))
      return
    fi
  fi
  zle self-insert
}

$'\x01' は ASCII の SOH(Start of Heading)[1]で、ユーザーがまず入力することのない制御文字です。一時的なプレースホルダとして古典的に使われるパターンです。

これで abbr printf-s='printf "\%s\n"' のような略語も正しく扱えるようになります。
ただし日常用途で % リテラルを含む略語を登録したくなる頻度は低いので、まず基本版で運用して必要になったら拡張する方針で十分だと思われます。

5.2 その他の拡張の方向性

本記事ではカーソル位置マーカーまでで止めますが、同じパターンでさらに、

  • Enter でも展開して実行: accept-line ウィジェットをフックする
  • global alias の展開: ウィジェット内で expand-word を呼ぶ
  • autosuggestions の予告表示: 独自ストラテジー関数を追加する

というような機能を、いずれも基本的に ZLE の「ウィジェットをフックしてバッファを操作する」というパターンの応用で実装可能なはずです。

第 6 章 評価 ── zsh-abbr との比較

自前実装の立ち位置を、既存プラグイン zsh-abbr との比較で明確化します。

6.1 パフォーマンス分岐点

「zsh-abbr の方が速いのでは?」という疑問に対する答えは、両者とも十分高速で、しかも本記事の連想配列版には処理速度の分岐点が存在しないです。

1 回の Space 押下あたりの処理時間の見積もり:

登録数 配列+正規表現 連想配列(本記事) zsh-abbr
30 ~130μs ~20μs ~30μs
100 ~500μs ~20μs ~30μs
500 ~3ms ~20μs ~30μs
1000 ~6ms ~20μs ~30μs
5000 ~30ms ~20μs ~30μs

人間が体感できる遅延の閾値は 50ms 以上のようですので、配列+正規表現版なら 1000 個前後で体感できる遅延が出始めますが、連想配列版は登録数がいくら増えても O(1) のハッシュ検索で動くため処理時間がほぼ変わりません。

zsh-abbr も内部は連想配列ベースなので、本記事の実装と基本的に同じ計算量特性を持ちます。
パフォーマンスを理由に zsh-abbr へ乗り換える動機は、少なくとも個人使用の範囲では発生しません。

6.2 機能比較

項目 zsh-abbr 自前実装
Space で展開
Enter で展開して実行 △(accept-lineでの追記必要 )
カーソル位置マーカー ○(第 5 章)
vi モード対応 ○(第 4 章)
global abbr △(alias -g + expand-word で代用)
対話的追加(abbr add ×
fish/git alias インポート ×
autosuggestions 連携(展開後)
autosuggestions 連携(予告) △(独自ストラテジー追記必要)
サブシェル評価 × (#26) ×(哲学として同一)

zsh-abbrではabbr addコマンドで対話的な追加可能な点は特に利便性が高いですね。
一方の自前実装では略語展開の追加には .zshrc への記述と併せて、設定の反映にsource ~/.zshrcが必要な点で機能として劣ってしまいます。

あとは、zsh-abbr の専用プラグイン zsh-autosuggestions-abbreviations-strategy が提供する「短縮形を打っている最中に展開先をゴーストテキストで予告する」機能なんかも実装できていませんし、それを実装するなら素直に zsh-abbr を使うべきだと思います。

ただし zsh-autosuggestions を使っている場合、autosuggestions は ZLE ウィジェットにフックして動く仕組みで、BUFFER が変わるたびに候補を再検索します。
ですので、自前 abbr で略語が展開されて BUFFERgit checkout に変わった瞬間、autosuggestions は履歴から git checkout main 等をゴーストテキストとして提案してくれます。

自前実装で便利な点としては、コマンドラインのどの位置でも略語展開が Space 押下時に実行されることでしょうか。
zsh-abbr のレギュラーモードはコマンド位置(行の先頭やパイプの直後など)でしか展開を行わず、パイプで繋いだ先の略語が展開されない場合があります。
ただ、グローバルモードにすると引数位置でも該当文字列が展開されてしまうというジレンマがあるようです。
ただし、自前実装でも引数として略語登録された単語を使用する場合は展開を抑制するため Ctrl+Space を使用する必要があるので、優劣の問題ではなく仕様として判断していただければと思います。

6.3 非機能比較

項目 zsh-abbr 自前実装
外部依存 あり(プラグイン) なし(zsh 組み込みのみ)
管理コード量 数行(呼び出しのみ) 約 25〜40 行
プラグイン本体のコード量 約 2200 行 0
可搬性 clone が必要 .zshrc コピーのみ
制限環境(termux 等) 動作確認が必要 高確率で動作
エッジケース対応 高(長年の実績) 中(自己責任)
シェル理解の深まり

シェル理解の深まりへの評価は蛇足のようなものですが、 .zshrc 記述だけの実装によって実現される chezmoi などのドットファイル管理による可搬性の高さは筆者自身が目指したものなので、評価基準として含めさせてもらいました。

6.4 どちらを選ぶか

そもそも zsh-abbr が先にあり、その機能の仕組みを理解するために取り組んだ自前実装ですので、比較するべくもなく利便性としては zsh-abbr を使用するべき、という大前提があるわけですが、あえて自前実装も選択肢としてもらえるなら、

zsh-abbr が向いている人

  • fish からの移行者
  • 略語を試行錯誤で追加・削除する頻度が高い人
  • プラグインマネージャを既に使っている人
  • チーム共有 dotfiles を運用する人

自前実装が向いている人

  • 依存を増やしたくない人
  • 複数環境で同じ .zshrc を使う人
  • ZLE を理解したい人
  • 制限環境(termux、コンテナ、root 権限のないサーバー)でも使いたい人

といったところでしょうか。

まとめ

便利なプラグインが存在しているけれど、それは一体どのように便利な機能を提供しているのかを理解したい。
外部依存をあまり増やしたくないし、個人が登録している alias の数量を処理する程度であれば、簡潔に.zshrc への記述で実装が可能なのか、という点を検証してみたいという好奇心から始めてみました。
結果として普段使用しているシェルへの理解が深まり、便利なサービスが根本的に利用している低レイヤーでの機能について知り得たのが大きな収穫でした。
これからも色々と展開していきたいと思います。


参考リンク

脚注
  1. SOH 0x01 は 1963年の ASCII 規格および、それ以前の電信プロトコルに由来し、「これからヘッダ情報(宛先、ルーティング、メッセージIDなど)が始まる」というマーカーで、STX 0x02 で本文開始、ETX 0x03 で本文終了、EOT 0x04 で送信終了という枠組みで使われていました。蛇足ですが、SIGINT を送信する Ctrl+C は 0x03 のバイトを端末に送りますが、これは ETX 本来の「本文終了」という意味とは関係なく、"Cancel" の C に由来する慣習のようです。 ↩︎

Discussion