Zsh Line Editorってご存じですか。
皆さん使っているであろう、「Ctrl-A で行頭に移動、Ctrl-E で行末に移動」とかのアレである。zsh の持つコマンドライン編集機能を ZLE(Zsh 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 | ヒストリを辿るように(正方向)文字列を検索する |
1 〜 9 | 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 では独自の関数を作りウィジェットとして登録することにより、好きなように機能拡張することができる。
ウィジェットの作り方は以下だ。
- ウィジェットに登録するためのシェル関数を定義する
- 作ったシェル関数をウィジェットに登録する
- キーへ割り当てる(任意)
実際に作るとこんな感じになる。例えば pecoを使ったヒストリ補完を作ってみるとしよう。
# 1. ウィジェットに登録するためのシェル関数を定義する
peco-select-history(){# peco があるかないかで分岐する# なければ違うアプローチをするiftype"peco">/dev/null 2>&1;thenBUFFER=$(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
elsehistory-incremental-search-backward
fifi}# 2. 作ったシェル関数をウィジェットに登録する
zle -N peco-select-history
# 3. キーへ割り当てる(任意)
bindkey '^r' peco-select-history
シェル関数さえ書いてしまえば簡単に好きな機能を実現できる。
それと少し説明で、ウィジェット内で有用な決め打ちの変数が存在する。
変数 | 説明 |
---|---|
BUFFER | ZLE の編集バッファの内容を全て保持している文字列が入っている。この変数は書き込み可能で、値をセットすると編集バッファがその内容に置き換えられる |
CURSOR | バッファ中にカーソルが位置する桁位置を示す整数値が入っている。バッファの先頭はゼロとなる。この変数に先頭と末尾を越えないサイズの整数値を代入することでカーソルをその桁位置に移動することができる。また CURSOR の値を変更するとそれに連動して LBUFFER 、RBUFFER の値も変動し、それぞれカーソル位置より左の内容、右の内容を保持する |
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 による行内文字検索を繰り返し逆方向に一致する文字に移動する |
1 〜 9 | 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
とした時点ですぐに使用できる)。しかし、このプラグインを利用することでそれらに加えて、ciw
や ci"
を使うことができる。この便利さはあなたが 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";;esacPROMPT="%{$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
(右プロンプト)は使っていないので、ユーザの設定領域は確保してある。
- vi-mode (oh-my-zsh)
- make my zsh prompt show mode in vi mode
- How do I customize zsh's vim mode?
- zsh vi mode status line
まとめ
長々と zsh(ZLE)の vi モードについて取り扱った。より拡張してくれるプラグイン実装を使ったり、キー設定をすることでより便利なエディットが可能になる。Vimmer は特に導入して試してみる価値がある!!!!!!
あ、それと、長い設定例が続いたため、記事内のコードをまとめた ZLE vi mode のリポジトリを作成しました。
ダウンロードして source
すればプロンプトから何からいい感じに動きます。Antigenユーザなら、
$ antigen bundle b4b4r07/zle-vimode
でいけます。お試しあれ。