Edited at

コマンドライン編集機能 Zsh Line Editor を使いこなす

More than 3 years have passed since last update.

Zsh Line Editor ってご存じですか。


皆さん使っているであろう、「Ctrl-A で行頭に移動、Ctrl-E で行末に移動」とかのアレである。zsh の持つコマンドライン編集機能を ZLEZsh Line Editor )と呼ぶ。ZLE でコマンドライン操作体系として Emacs ライクなものと vi ライクなものが選択できるようになっている。また、ZLE ではデフォルトで 4 つのキーマップ(キー割り当ての集合)が開放されている。


  • emacs(Emacs ライクなキーマップ)

  • viins(vi のインサートモードのキーマップ)

  • vicmd(vi のコマンドモードのキーマップ)

  • .safe(カスタマイズが禁止されているキーマップ)

これらとは別に main というキーマップがあり、ZLE では main に紐付いたキーマップをデフォルトキーマップとして使用する。

zsh では最初はコマンドライン編集機能を持たない .safe キーマップを選択した状態で起動される。コマンドライン編集機能を利用するには emacs か viins かを選択しなければならない。

$ bindkey -e    # emacs キーマップを選択

または

$ bindkey -v    # viins キーマップを選択

そしてこれによって選択したキーマップが main として設定される。

多くの人は emacs キーマップを使っているだろう。もしくは vi モードの存在すら知らずに過ごしているだろう。viins ならびに vicmd はライフチェインジングなほど便利な機能なので紹介する。


vi ライクなインターフェース

上で紹介したように vi ライクなコマンドライン編集機能のインターフェースを利用するには、

$ bindkey -v    # viins キーマップを選択

として viins キーマップを選択する必要がある。vi/Vim でいう「挿入(インサート)モード」と「コマンドモード」を行き来して操作する。よって、vi ライクな編集モードを選択した場合、viins と vicmd の両キーマップを使い分けることになる。

コマンドラインを開始したときの状態は viins モードになる。このモードは文字挿入が主目的で、文字削除程度の編集しかできない。そこで vicmd モードである。


viins

viins は vi/Vim の挿入モードにもあるように、打ち込んだ文字がそのまま表示される。つまり、なにか意味のある操作を行うには修飾キーが必要となる。

キー
機能
意味

^D
list-choices
マッチする補完候補を一覧表示する

^G
list-expand
マッチするものを展開する

^H \
^?
vi-backward-delete-char

^I
expand-or-complete
カーソル位置の単語について展開または補完を試みる

^J \
^M
accept-line

^L
clear-screen
端末画面をクリアする

^Q \
^V
vi-quoted-insert

^R
redisplay
編集バッファを再表示する

^U
vi-kill-line
viins モードに移ってから入力された文字をすべて消去する

^W
vi-backward-kill-word
カーソルの前の単語を削除する(viins モード中にタイプされた単語に限る)

^[
vi-cmd-mode
vicmd モードに移行する

機能はすべて vi/Vim に似せたものとなっている。

viins は emacs モードのように文字入力が可能であるが、^A(カーソルを行頭へ移動)や ^E(カーソルを行末に移動)などは定義されていないので使用できない。


vicmd

viins キーマップから ESC^[)をタイプすると vi/Vim のコマンドモードを模した vicmd モードに移行する。

キー
機能
意味

^D \
=
list-choices

^G
list-expand
マッチするものを展開する

SPC \
l
vi-forward-char

^H \

h \
^?

^J \
^M
accept-line

^L
clear-screen
端末画面をクリアする

^N
down-history
ヒストリリストの新しいほうに進む

^P
up-history
ヒストリリストの古いほうに進む

^R
redisplay
編集バッファを再表示する

#
pound-insert
行に # があれば剥ぎ取り、なければ行頭に追加して accept-line する

$
vi-end-of-line
行末に移動する

%
vi-match-bracket
カーソル位置が括弧上であれば対応する括弧に移動し、でなければ行末方向に見つかる括弧の対応括弧に移動する

+
vi-down-line-or-history
バッファ内の次の行の非空白文字に移動する。既に最下行にいる場合は新しい方向のヒストリ要素に移動する

-
vi-up-line-or-history
バッファ内の前の行の非空白文字に移動する。既に最上行にいる場合は古い方向のヒストリ要素に移動する

.
vi-repeat-change
前回のバッファ変更操作を繰り返す

;
vi-repeat-find

f/F による行内文字検索を繰り返し一致する文字に移動する

,
vi-rev-repeat-find

f/F による行内文字検索を繰り返し逆方向に一致する文字に移動する

/
vi-history-search-backward
ヒストリを遡るように(逆方向)文字列を検索する

?
vi-history-search-forward
ヒストリを辿るように(正方向)文字列を検索する

19

digit-argument
数引数を入力する

0
vi-digit-or-beginning-of-line
数引数を入力中(例えば 1 の次にタイプされたとき)ならゼロを意味し、それ以外なら行頭へ移動する

A
vi-add-eol
行末の文字の後ろに文字を追加する形で viins モードに移行する

B
vi-backward-blank-word
空白を単語区切りと見なして1単語前に移動する

C
vi-change-eol
カーソル位置から行末までを削除し、viins モードに移行する

D
vi-kill-eol
カーソル位置から行末までを削除する

E
vi-forward-blank-word
空白を単語区切りと見なして次の単語末尾に移動する

F
vi-find-prev-char
次にタイプする文字と同じ文字を行頭方向に向かって行内検索し、その文字の位置に移動する

G
vi-fetch-history
数引数で指定したヒストリ番号のコマンドラインを取り出す

I
vi-insert-bol
現在行の最初に現れる非空白文字の位置に移動して viins モードに移行する

J
vi-join
現在行と次の行を結合する

N
vi-rev-repeat-search

/ または ? による検索を、逆方向で進める

O
vi-open-line-above
現在行の上に新規に行を挿入して viins モードに移行する

P
vi-put-before
キルバッファにある文字列をカーソルよりも前の位置にペーストする

R
vi-replace
カーソル位置から上書き入力モードに移行する

S
vi-change-whole-line
現在行の内容をすべて削除したあと viins モードに移行する

T
vi-find-prev-char-skip
次にタイプする文字を行頭方向に向かって検索して、見つかった場合はその文字の一歩手前の文字の位置に移動する

W
vi-forward-blank-word
空白を単語区切りと見なして次の単語に進む

X
vi-backward-delete-char
カーソルの前の文字を消す

Y
vi-yank-whole-line
現在行の内容をキルバッファにコピーする

^
vi-first-non-blank
現在行の最初の非空白文字に移動する

a
vi-add-next
現在行の文字の次に挿入する形で viins モードに移行する

b
vi-backward-word
vi 風に1単語戻る

c
vi-replace
vi 風に修正する。移動ワードと組み合わせてその分、変更するが c が続けてタイプされたら vi-change-whole-line を実行する

d
vi-delete
vi 風に削除する。続けて d がタイプされたら1行すべてを削除する

e
vi-forward-word-end
次の単語末尾に移動する

f
vi-find-next-char
次にタイプする文字と同じ文字を行末方向に向かって行内検索し、その文字の位置に移動する

i
vi-insert
現在のカーソル位置で viins モードに移行する

j
down-line-or-history
下行に移動するかヒストリリストの新しい方に進む

k
up-line-or-history
上行に移動するかヒストリリストの古い方に進む

n
up-line-or-history

/ または ? による検索を繰り返す

o
vi-open-line-below
現在行の下に新規の行を追加して viins モードに移行する

p
vi-put-after
キルバッファの内容をカーソルより後ろにペーストする

s
vi-substitute
カーソル位置の文字を置換する形で viins モードに移行する

t
vi-find-next-char-skip
次にタイプする文字を行末方向に向かって検索して、見つかった場合はその文字の一歩手前の文字の位置に移動する

u
vi-undo-change
コマンドラインの編集操作を undo する。続けて u がタイプされたら redo する

w
vi-forward-word
vi 風に次の単語に移動する

x
vi-delete-char
カーソル位置の文字を削除する

y
vi-yank
更にカーソル移動コマンドを受け付け、現在の位置からカーソル移動コマンドで移動するポイントまでをキルバッファにコピーする。続けて y がタイプされたら行全体をヤンクする

マークやバッファ系のコマンドなどは省略したが、上にあるコマンド(ウィジェット)が vicmd モードで利用できるものになる。

さて、これらは非常に便利である。以下の gif アニメをみれば簡単にコマンドラインで操作ができる。


カスタマイズ

便利には便利なのだが、vi/Vim においても .vimrc ファイルでカスタマイズするようにより良いキーバインドにすることが望ましい。例えば、viins において emacs ライクなキーバインドを利用しようと思っても ^A などは未定義である。

また、使用できるキー機能(ウィジェット)は決め打ちではない。ユーザによって拡充することも可能である。


viins と emacs

viins モードと emacs モードの共通点は、打鍵したものがすぐ表示されるモードであること。文字入力に特化している。加えて、何か特別の操作をするにはコントロールキーなどの修飾キーが必要であること。そして viins にはそれらのキーカスタマイズがされていない。

viins モードに emacs モードのいいところを取り込めば、emacs モードとしての機能すらも持つ viins モードと、強力な編集操作体系を持つ vicmd モードが組み合わされば、vi ライクなインターフェースはコマンドライン編集機能としてとても有力なキーマップになる。

viins を emacs モードの如くカスタマイズして、より良い CLI ライフを提供するコードがこちら。

bindkey -M viins '\er' history-incremental-pattern-search-forward

bindkey -M viins '^?' backward-delete-char
bindkey -M viins '^A' beginning-of-line
bindkey -M viins '^B' backward-char
bindkey -M viins '^D' delete-char-or-list
bindkey -M viins '^E' end-of-line
bindkey -M viins '^F' forward-char
bindkey -M viins '^G' send-break
bindkey -M viins '^H' backward-delete-char
bindkey -M viins '^K' kill-line
bindkey -M viins '^N' down-line-or-history
bindkey -M viins '^P' up-line-or-history
bindkey -M viins '^R' history-incremental-pattern-search-backward
bindkey -M viins '^U' backward-kill-line
bindkey -M viins '^W' backward-kill-word
bindkey -M viins '^Y' yank


ウィジェット

ZLE で機能している accept-line などの単位のことをウィジェットという。ZLE では独自の関数を作りウィジェットとして登録することにより、好きなように機能拡張することができる。

ウィジェットの作り方は以下だ。


  1. ウィジェットに登録するためのシェル関数を定義する

  2. 作ったシェル関数をウィジェットに登録する

  3. キーへ割り当てる(任意)

実際に作るとこんな感じになる。例えば peco を使ったヒストリ補完を作ってみるとしよう。

# 1. ウィジェットに登録するためのシェル関数を定義する

peco-select-history()
{
# peco があるかないかで分岐する
# なければ違うアプローチをする
if type "peco" >/dev/null 2>&1; then
BUFFER=$(history 1 | sort -k1,1nr | perl -ne 'BEGIN { my @lines = (); } s/^\s*\d+\s*//; $in=$_; if (!(grep {$in eq $_} @lines)) { push(@lines, $in); print $in; }' | peco --query "$LBUFFER")
CURSOR=${#BUFFER}

# peco で選んでる最中に Enter を押した瞬間実行する
zle accept-line
# 画面をクリアする
zle clear-screen
else
# バージョンによって条件分岐するために使用するモジュールを開放する
autoload -Uz is-at-least

# 4.3.9 以降ではインクリメンタルパターンサーチが出来るので、それを利用する
# なければデフォルトでマッピングされているものを利用する
if is-at-least 4.3.9; then
# zsh -la <widget> とすることで、widget に完全一致するウィジェットが
# 存在する場合、返却値 0 で終了する
zle -la history-incremental-pattern-search-backward && bindkey "^r" history-incremental-pattern-search-backward
else
history-incremental-search-backward
fi
fi

}

# 2. 作ったシェル関数をウィジェットに登録する
zle -N peco-select-history

# 3. キーへ割り当てる(任意)
bindkey '^r' peco-select-history

シェル関数さえ書いてしまえば簡単に好きな機能を実現できる。

それと少し説明で、ウィジェット内で有用な決め打ちの変数が存在する。

変数
説明

BUFFER
ZLE の編集バッファの内容を全て保持している文字列が入っている。この変数は書き込み可能で、値をセットすると編集バッファがその内容に置き換えられる

CURSOR
バッファ中にカーソルが位置する桁位置を示す整数値が入っている。バッファの先頭はゼロとなる。この変数に先頭と末尾を越えないサイズの整数値を代入することでカーソルをその桁位置に移動することができる。また CURSOR の値を変更するとそれに連動して LBUFFERRBUFFER の値も変動し、それぞれカーソル位置より左の内容、右の内容を保持する

LBUFFER
バッファの中でカーソルよりも左側に位置する文字列の内容がセットされている。BUFFER 同様、新しい値をセットすることで、該当する部分のバッファの内容が置き換えられる

RBUFFER
バッファの中でカーソルよりも右側に位置する文字列の内容がセットされている。BUFFER 同様、新しい値をセットすることで、該当する部分のバッファの内容が置き換えられる

...
...

他にもたくさんあるのだが、よく利用されるものだけを紹介した。これによって、簡単にやりたいことがウィジェットとして実現できる。


より高度なカスタマイズ


Visual mode の実装

vi/Vim にはヴィジュアルモードという操作体系がある。簡単に文字列を反転させまとめて処理することができる。しかし、Zsh Line Editor に実装されているものは Insert mode(viins)と Command mode(vicmd)のみである(※)。

そこで、簡単な Visual mode を実装した ZLE キーマップを追加した。キーマップ名は vivis である。

これによって、従来通りの viins と vicmd の行き来に加え、vivis という新モードが追加され使用することができる。

実は、デフォルトで 4 つのキーマップが開放されていたが、このキー割り当ての領域をユーザによって拡張することが簡単にできる。

$ bindkey -N new_keymap


vivis

キー
機能
意味

^[
vi-visual-exit
作業を全て取りやめ vicmd モードに移行する

^M
vi-visual-yank
編集バッファ上の選択された文字列をクリップボードにコピーする

$
vi-visual-eol
行末に移動する

%
vi-visual-match-bracket
カーソル位置が括弧上であれば対応する括弧に移動し、でなければ行末方向に見つかる括弧の対応括弧に移動する

;
vi-visual-repeat-find

f/F による行内文字検索を繰り返し一致する文字に移動する

,
vi-visual-rev-repeat-find

f/F による行内文字検索を繰り返し逆方向に一致する文字に移動する

19

digit-argument
数引数を入力する

0
vi-visual-bol
数引数を入力中(例えば 1 の次にタイプされたとき)ならゼロを意味し、それ以外なら行頭へ移動する

B
vi-visual-backward-blank-word
空白を単語区切りと見なして1単語前に移動する

C
vi-visual-substitute-lines
カーソル位置から行末までを削除し、vicmd モードに移行する

D
vi-visual-kill-and-vicmd
カーソル位置から行末までを削除する

E
vi-visual-forward-blank-word
空白を単語区切りと見なして次の単語末尾に移動する

F
vi-visual-find-prev-char
次にタイプする文字と同じ文字を行頭方向に向かって行内検索し、その文字の位置に移動する

G
vi-visual-goto-line
末尾行(行末)に移動する

I
vi-visual-insert-bol
現在行の最初に現れる非空白文字の位置に移動して viins モードに移行する

J
vi-visual-join
現在行と次の行を結合する

O
vi-visual-exchange-points
現編集バッファ上の選択部分の末端を行き来する

R
vi-visual-substitute-lines
選択された文字列上で上書き入力モードに移行する

S
vi-visual-surround-space
選択文字列をスペースで囲うように置き換える

S'
vi-visual-surround-squote
選択文字列をシングルクォートで囲うように置き換える

S"
vi-visual-surround-dquote
選択文字列をダブルクォートで囲うように置き換える

S(
vi-visual-surround-parenthesis
選択文字列を()で囲うように置き換える

S)
vi-visual-surround-parenthesis
選択文字列を()で囲うように置き換える

T
vi-visual-find-prev-char-skip
次にタイプする文字を行頭方向に向かって検索して、見つかった場合はその文字の一歩手前の文字の位置に移動する

U
vi-visual-uppercase-region
編集バッファ上の選択部分を大文字に変換する

V
vi-visual-exit-to-vlines
行全体を選択し vivli モードに移行する

X
vi-visual-backward-delete-char
カーソルの前の文字を消す

Y
vi-visual-yank
編集バッファ上の選択された文字列をクリップボードにコピーする

^
vi-visual-first-non-blank
現在行の最初の非空白文字に移動する

b
vi-visual-backward-word
vi 風に1単語戻る

c
vi-visual-change
vi 風に修正する。移動ワードと組み合わせてその分、変更するが c が続けてタイプされたら vi-change-whole-line を実行する

d
vi-visual-kill-and-vicmd
vi 風に削除する。続けて d がタイプされたら1行すべてを削除する

e
vi-visual-forward-word-end
次の単語末尾に移動する

f
vi-visual-find-next-char
次にタイプする文字と同じ文字を行末方向に向かって行内検索し、その文字の位置に移動する

gg
vi-visual-goto-first-line
先頭行(行頭)に移動する

h
vi-visual-backward-char
行頭に向かって 1 文字移動する

j
vi-visual-down-line
下の行に移動する

k
vi-visual-up-line
上の行に移動する

l
vi-visual-forward-char
行末に向かって 1 文字移動する

o
vi-visual-exchange-points
編集バッファ上の選択部分の末端を行き来する

p
vi-visual-put
キルバッファの内容をカーソルより後ろにペーストする

r
vi-visual-replace-region
カーソル位置の文字を置換する形で viins モードに移行する

t
vi-visual-find-next-char-skip
次にタイプする文字を行末方向に向かって検索して、見つかった場合はその文字の一歩手前の文字の位置に移動する

u
vi-visual-lowercase-region
編集バッファ上の選択部分を大文字に変換する

v
vi-visual-exit
作業を全て取りやめ vicmd モードに移行する

w
vi-visual-forward-word
vi 風に次の単語に移動する

y
vi-visual-yank
編集バッファ上の選択された文字列をクリップボードにコピーする

vicmd モードで v または V キー押下で vivis モードに移行する。特に V では vivli という行全体を意味するモードに分けているが、ここらへんの実装は変わっていくかもしれない。また、ウィジェット名やキーも変わっていくかもしれない。これについては、公式の README を参照して欲しい。


テキストオブジェクト

Vim にはテキストオブジェクトという文字加工に役立つ便利な概念がある。'" によって囲われた文字列・単語をひとつのハンク(かたまり)と見なしてくれるのだ。これによって Vim の編集モードでは " 内の文字列などに対し、簡単に変更したり削除したりすることができる。

詳しくは Vim を開いて、

:help text-objects

とすればいい。

これを zsh に実現するプラグインがある。

既に zsh には cw といった簡単なものは実装されている(ので bindkey -v とした時点ですぐに使用できる)。しかし、このプラグインを利用することでそれらに加えて、ciwci" を使うことができる。この便利さはあなたが Vimmer なら説明する必要はないでしょう。

※ zsh 5.0.8 から autoload によって高度なテキストオブジェクトが開放された


zsh プラグイン

zsh 5.0.8 の機能拡充によってプラグインではなく、zsh の機能として高度なテキストオブジェクトや visual モードを使用できるようになったが少し欠点があった。それは他のプラグインに干渉することだ。

zsh でのテキストオブジェクトでは " などの字句解析にノイズが交じるとうまく判定できないようだ。例えば有名な zsh プラグインに Fish ライクなシンタックスハイライトを提供する zsh-syntax-highlighting がある。この色付けにはエスケープシーケンス的なものを使っているのだが、visual モードにて字句解析をするときにそれがノイズとなり狂いをもたらしている(と思う)。

しかし、上で紹介した 2 つのプラグイン実装である、

これらを使えば、テキストオブジェクトとビジュアルモードの両方を実現できる(ただし、visual モード時のテキストオブジェクト viw/vi" などは使用できない。しかし zsh visual では使用できる。ここらへんうまく出来ないものか)。


プロンプト

ここまで説明してきて、ZLE の emacs モードと比べて vi モードは有用なものであるとしてきた。しかし唯一、不便なことがある。それは現在のモードが分からないことだ。

emacs モードと比べ、vi モードはモードが複数ある。いま viins なのか vicmd なのか、それともプラグインによって拡張された vivis なのか分からなくなるのだ(残念ながら bindkey -v としただけじゃモード表示はしてくれない)。

その解決には、プロンプトが適役である。

autoload -Uz colors; colors

autoload -Uz add-zsh-hook
autoload -Uz terminfo

terminfo_down_sc=$terminfo[cud1]$terminfo[cuu1]$terminfo[sc]$terminfo[cud1]
left_down_prompt_preexec() {
print -rn -- $terminfo[el]
}
add-zsh-hook preexec left_down_prompt_preexec

function zle-keymap-select zle-line-init zle-line-finish
{
case $KEYMAP in
main|viins)
PROMPT_2="$fg[cyan]-- INSERT --$reset_color"
;;
vicmd)
PROMPT_2="$fg[white]-- NORMAL --$reset_color"
;;
vivis|vivli)
PROMPT_2="$fg[yellow]-- VISUAL --$reset_color"
;;
esac

PROMPT="%{$terminfo_down_sc$PROMPT_2$terminfo[rc]%}[%(?.%{${fg[green]}%}.%{${fg[red]}%})%n%{${reset_color}%}]%# "
zle reset-prompt
}

zle -N zle-line-init
zle -N zle-line-finish
zle -N zle-keymap-select
zle -N edit-command-line

プロンプトはコマンドラインを実行する(accept-line)たびに、表示内容を一新する。それにより、モードも毎回チェックして更新することが出来る。

これによって実現できるプロンプトがこれだ。

-- NORMAL -- は vicmd モードを指し、-- INSERT -- は viins モードを指す。プラグインによるモードも解釈するようになっていて -- VISUAL -- とでる。Vim そのものだ。

また、このプロンプトの実現には RPROMPT(右プロンプト)は使っていないので、ユーザの設定領域は確保してある。


まとめ

長々と zsh(ZLE)の vi モードについて取り扱った。より拡張してくれるプラグイン実装を使ったり、キー設定をすることでより便利なエディットが可能になる。Vimmer は特に導入して試してみる価値がある!!!!!!


あ、それと、長い設定例が続いたため、記事内のコードをまとめた ZLE vi mode のリポジトリを作成しました。

ダウンロードして source すればプロンプトから何からいい感じに動きます。Antigen ユーザなら、

$ antigen bundle b4b4r07/zle-vimode

でいけます。お試しあれ。