Help us understand the problem. What is going on with this article?

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

More than 5 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

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

mercari
フリマアプリ「メルカリ」を、グローバルで開発しています。
https://tech.mercari.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした