Vim のモーションについて

Vim でカーソルを動かすためのコマンドの一部はモーションと呼ばれます。 これについての基本的な説明と組み込みのモーションコマンドについてはヘルプを読む(:help motion.txt)のが一番なので、この記事ではモーションとはなにかを簡単に解説した後、ユーザー定義モーションの書き方などを説明します。

モーションとは

厳密な定義があるかはよくわかりませんが、私個人としてはモーションコマンドというと以下のような機能を備えていることを期待します。

  • ノーマルモードにてカーソルを移動させる。
  • ヴィジュアルモードにてカーソルを移動させ、ヴィジュアル選択範囲を変更する
  • オペレータ待機モードにおいてカーソル移動の軌跡をオペレータの作用対象とする
  • タブ、ウィンドウ、バッファは移動しない
  • 可能であれば [count] 指定を受け付ける

ひとまず、この記事では上のような機能を備えたものをモーションと呼ぶことにします。また、ヘルプの motion.txt においてはテキストオブジェクト(:help text-objects)についても書かれていますが、これは分けて考えることにしてこの記事ではあまり言及しないことにします。

組み込みのモーションコマンドはかなり種類があり、例えば文字単位でカーソルを動かす h, j, k, l や単語単位でカーソルを動かす w, b, e, ge、行頭・行末へカーソルを動かす 0, ^, $ がモーションというのは理解しやすいかと思いますが、カーソル下の単語を検索して移動する *, #、マークによる移動 ', `、さらには検索コマンド /, ? や ex コマンド : もカーソルが動く限りはモーションです。このように Vim の豊富なカーソル移動手段は実はほとんどすべてモーションとして働きます。

ノーマルモードにおけるモーション

ノーマルモードにおけるカーソル移動は、おそらく最も理解しやすいモーションの機能かと思います。

多くのモーションコマンドは [count] の入力を受け付けており、これを組み合わせることでさらに便利になります。 [count] の解釈はコマンドごとに違っており、例えば 3l と入力すると3文字分右へカーソルを動かしますが、 3G と入力するとバッファの3行目に移動します。 それぞれのコマンドが [count] をどのように解釈するかはヘルプを確認すると良いでしょう。

もう一つ留意する点としてはカーソルが大きく移動することがありうるようなコマンドは jumplist を更新する点です。(:help jump-motions) カーソルの大きな移動を CTRL-O および CTRL-I でたどることができる便利な機能です。 Vim のヘルプを読むときなどにはタグジャンプをした後にもとの位置に戻れたりするのでよく使いますね。 ちなみに CTRL-O および CTRL-I はこの記事でいうモーションコマンドではありません。 これらのコマンドはヴィジュアルモードやオペレータ待機モードでは機能しません。

ヴィジュアルモードにおけるモーション

基本的にはノーマルモードにおける機能と同じですが、ヴィジュアル選択の一端を移動させて範囲を変更します。

ヴィジュアルモードには文字単位、行単位、矩形ヴィジュアルモードの3種類が存在していますが、 矩形ヴィジュアルモードにおける $ コマンドは少し特殊で、選択している行のうち最も長い行にあわせて選択範囲を拡張します。(:help v_$)

オペレータ待機モードにおけるモーション

上2つに比べて理解が少し難しいのがオペレータ待機モードにおけるモーションの機能だと思います。 オペレータ待機モードとは、オペレータコマンド (:help operator) を使用したあと続けて入力待ちになっている状態です。 オペレータコマンドは例を挙げると y, d, c などのことで、例えばノーマルモードで d を押した後さらに入力を待っている状態がオペレータ待機モードです。 ここでモーションコマンドかテキストオブジェクトを入力することでオペレータが作用する範囲を決定します。 オペレータの作用範囲は2つの位置で指定できる範囲です。 モーションの場合はカーソルの初期位置と移動後の位置の2点がこれに当たるので、例えば d$ とするとカーソル位置から行末までを削除します。

さらにモーションによるオペレータの作用範囲の決定にはもう少し細かいルールがあります。 これはカーソルが同じ位置まで移動した場合でも、編集の対象となる範囲が違う場合があるということを指します。 例を挙げると、行が十分短く折り返し (:help 'wrap') が存在しない場合、jgj は同じ場所へカーソルを移動しますが djdgj の結果は違います。 このように作用範囲の形を決めるルールがあり、それらはそれぞれ文字単位/行単位/矩形と呼ばれます。 さらに文字単位で作用するモーションにはその作用範囲が排他的なものと内包的なものが存在します。

  • 文字単位 (characterwise)
    • 排他的 (exclusive)
    • 内包的 (inclusive)
  • 行単位 (linewise)
  • 矩形 (blockwise)

さて、たくさんの用語が出てきましたが、それぞれどのような意味かをカーソル位置と作用範囲を図示して解説します。 以下のようなバッファにてカーソルがBからCおよびKへ移動した場合のオペレータ作用範囲を赤く示します。

ABCD
EFGH
IJKL

文字単位(排他的)

characterwise(exclusive)

モーションによりカーソルが移動した場合にカーソルの初期位置から終了位置までの範囲をオペレータの作用範囲とします、ただし移動終了後のカーソル直下の文字は範囲に含まれません。 dl で一文字しか消えないのは l が排他的文字単位だからですね。

注意する点としては、内包的なモーションで右へ移動した場合も左へ移動した場合も範囲に含まれないのは常に右端1文字です。

文字単位(内包的)

characterwise(inclusive)

文字単位(内包的)な場合と似ていますが、内包的なモーションと違い範囲の右端1文字も含まれます。

行単位

linewise

文字単位の場合とは違い、カーソルの桁位置とは関係なくモーションで「通過」する行全体を作用範囲に取ります。

矩形

blockwise

カーソル移動の初期位置と終端位置を左上隅および右下隅(あるいは右上隅および左下隅)に持つ矩形の範囲を作用範囲に取ります。 デフォルトで矩形のモーションは存在しませんが、オペレータ待機モードで CTRL-V を押すことで強制的に矩形のモーションにすることができます。(:help o_CTRL-V)

ルールの切り替え

モーションごとにどのように作用範囲が決まるかが決まっており、ヘルプに書いてあります。 例えば、 j は行単位なモーションです。 以下にヘルプを引用します。

[count] 行下に移動(行単位|linewise|)

ちなみに組み込みのモーションには文字単位か行単位のもののみ存在します。 ただし、モーションを入力する前に v, V, CTRL-V を入力することで強制的に規則を変更することができます。 つまり、dvj は文字単位(排他的)で文字列を削除します。 なお、複数入力しても最後に入力したもののみ有効です。

  • v : 文字列単位のモーションの排他的/内包的を切り替える。行単位及び矩形のモーションは文字単位排他的にする。 (:help o_v)
  • V : モーションを行単位にする (:help o_V)
  • CTRL-V : モーションを矩形にする (:help o_CTRL-V)

カーソルがゼロ幅だとしていたら排他的/内包的は必要なかったような気もしますが…

ユーザー定義モーションを書く

組み込みにもたくさんの便利なモーションが実装されていますが、ユーザーが自らモーションを作ることもできます。 ノーマルモード、ビジュアルモードについてはもちろんですがオペレータ待機モードにおいても同じくカーソルを動かすだけでよいです。 カーソルの初期位置と移動後の位置に応じてオペレータの作用範囲が決定されます。

ユーザー定義モーションを書く場合、Ex コマンド : を使ってカーソルを移動するのが便利です。 Exコマンドを使ったカーソルの移動はオペレータ待機モードでは常に排他的文字単位に動作するので、例えば

d:call cursor(line('.'), col('.')+1)<CR>

と入力すると dl のように振る舞います。 この場合、

:call cursor(line('.'), col('.')+1)<CR>

が一つのモーションになります。 このように Ex コマンドをモーションとした場合、エンターキー <CR> を押した瞬間にモーションとして確定し、カーソルの移動先に応じて削除されます。

複数のモーションを組み合わせる

まずは簡単なものから始めましょう。 例えば csv ファイルなどを開いた場合に最初から3つ目の要素の最初の文字にカーソルを動かすモーションを作ってみます。 カーソルがどこにあるかわからないので、まず最初に行頭に移動して2つ目のコンマに移動し、その1文字右側にカーソルを動かすことにします。 簡単には以下のように書けますが、これはあまりよくない例です。

noremap <Space> 02f,l

どこが問題なのでしょう?

まずノーマルモードとヴィジュアルモードにおいては誤ってカウントが与えられた場合に問題が起こります。 すなわち、2<space> とユーザーが入力した場合、202f,l と入力するのと同じなので、202個目のコンマまで移動(202f,)してしまいます。 また、オペレータ待機モードでは全く期待したように動きません。 つまり d<Space> と入力した場合、d02f,l と入力するのと同じなので、まず d0 が実行され行頭までが削除されてから、2f,l が実行されます。 オペレータ待機モードでもカーソル位置から3つ目の要素の前までを削除するようにするには Ex コマンドとして1つのモーションにまとめましょう。

nnoremap <Space> :<C-u>normal! 02f,l<CR>
xnoremap <Space> :<C-u>normal! gv02f,l<CR>
onoremap <Space> :normal! 02f,l<CR>

<C-u>:normal コマンドに余計な {range} 指定が入ることを防ぎます。 例えば 2: などと入力するとコマンドラインが :.,.+1 となるように {range} が自動入力されているのが確認できます。 またヴィジュアルモードから Ex モードに入るとコマンドラインが :'<,'> となります。 このように {range} の自動入力が問題を起こさないために必要になります。

ヴィジュアルモードのマッピングのみ少し違うのはヴィジュアルモードで Ex コマンドを使った場合、ノーマルモードに戻ってしまうためです。 これを避けるため gv コマンドでヴィジュアルモードに入りなおしています。 このような手間は <Cmd> (:help :map-cmd) の使用によって将来は必要なくなるかと思われますが、いまのところは <Cmd> が使えるバージョンの Vim が行き渡っているとは言い難いので未来に期待します。

オペレータ待機モードでも 02f,l という複数のモーションではなく :normal! 02f,l<CR> という一つのモーションが実行されます。 何度かカーソルを動かしているようにも見えますが、Ex コマンドとして一つのモーションなので、カーソルの初期位置と :nomal! コマンドの終了時のカーソル位置のみが重要となります。 なお、こうして作ったモーションはオペレータと組み合わせて使うことで、すでにドットリピートが可能です(!)。

ユーザー定義関数を使う

ひとまず機能を完成させましたが、どうせならもう少し気の利いたマッピングを作ってみましょう。 次はカウントの指定を受けつけて、[count] 個目の最初の非空白文字にカーソルを移動するように改良してみましょう。 実現したい機能が複雑化して行くと単純なノーマルモードコマンドの組み合わせでは物足りなくなります。 このような場合には関数を定義してマッピングに使用しましょう。

function! s:my_motion(mode) abort
  let l:count = v:count1
  let l:line = getline('.')

  " 必要ならヴィジュアルモードに入り直す
  if a:mode is# 'x'
    normal! gv
  endif

  " n 個目の要素がなければ終了
  if count(l:line, ',') < l:count - 1
    return
  endif

  " まず行頭に移動する
  normal! 0

  " (n - 1) 個目のコンマへ移動する
  if l:count > 1
    execute 'normal! ' . (l:count - 1) . 'f,'
  endif

  " 最初の非空白文字へ移動する
  call search('\S', '', line('.'))
endfunction

nnoremap <silent> <space> :<C-u>call <SID>my_motion('n')<CR>
xnoremap <silent> <space> :<C-u>call <SID>my_motion('x')<CR>
onoremap <silent> <space> :call <SID>my_motion('o')<CR>

キーマッピングがどのモードで使われたかを確実に認識するために引数で与えることにしています。 関数 s:my_motion() の中で mode() 関数を使っても同じことはできません。 なぜなら関数が呼ばれたときにはすでにノーマルモードに遷移しているためです。 ただし、上の <Cmd><expr> を使った場合には mode() も機能するでしょう。

ところでこのマッピングは csv ファイルに文字列としてコンマが含まれていたら正しく動きませんね!

オペレータ待機モードでの挙動の制御

上にも書いた通り Ex コマンドをモーションとした場合、オペレータ待機モードでは必ず排他的文字単位の挙動になります。 ではこの挙動を変えたい場合はどうしたらいいでしょう? 例として組み込みの f コマンドを模倣しながら考えてみましょう。 f コマンドはユーザーの入力1文字を受けつけて、その文字を行内から探して移動するコマンドです。 ひとまずその機能を作りましょう。

ここでは入力のフィルタリングやマルチバイト対応に関しては本題ではないので大目に見ることにします。 また、とりあえずオペレータ待機モードのみ考えます。

  • 例1
function! s:my_f() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(l:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  call cursor(line('.'), l:idx + 1)
endfunction

onoremap <silent> <space>f :call <SID>my_f()<CR>

余談ですが、v:count1 は関数内でも :normal コマンドの使用などで容赦なく変更されるので、余計な心配を抱えないために関数のできるだけ先頭で退避させておくのがおすすめです。

さて、df{なにか適当な文字} などして使ってみるとなんとなく動くようですが、組み込みの f コマンドとは挙動が違いますね。 組み込みの f コマンドは内包的文字単位で動作するのに対し、上に書いた模倣版は排他的文字単位で動作するため、最後の一文字の扱いが違います。 具体的には abcde という文字列の a から c まで移動した場合(すなわち dfc あるいは d<space>fc)、組み込みの方は de となるのに対し模倣版は cde となります。

ではどうしたらよいでしょうか? いくつか実装をみながら考えてみましょう。

o_v を使ってみる:よくない例

まず簡単に思いつくのは上で紹介した o_v を使ってマッピングを以下のように定義することだと思います。

  • 例2
onoremap <silent> <space>f v:call <SID>my_f()<CR>

: の前の v に注目です。 これは一見意図したとおりに動くように見えますが、残念ながら重大な問題があります。 実はこのモーションは失敗できません、つまり指定した文字が見つかっても見つからなくても、あるいは <Esc> を押してさえ必ず1文字は作用範囲としてしまって「なにもせず終了する」ことができません。 たとえカーソルが動かなくともモーションを内包的文字単位や矩形単位にした場合はカーソル下の一文字を、行単位にした場合はカーソルのある一行を編集してしまうことになります。

実は、オペレータ待機モードにおいて「なにもせず終了する」ことができるのは排他的文字単位の場合のみです。 f コマンドは指定した文字が見つからなかった場合は何もせず終了するのが期待する動作なのでこれではだめです。

さらに <expr> も使ってみる:あまりよくない例

o_v を直接使うと失敗できないマッピングができてしまいました。 では、<expr> をつかってワンクッション挟んでみましょう。

  • 例3
function! s:my_f_expr() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return "\<Esc>"
  endif

  let l:cursor_right = getline('.')[col('.') :]
  if count(l:cursor_right, l:c) < l:count
    return "\<Esc>"
  endif

  return printf("v:call My_f('%s', %d)\<CR>", l:c, l:count)
endfunction

function! My_f(c, count) abort
  let l:c_regex = '\C' . escape(a:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), a:count)
  call cursor(line('.'), l:idx + 1)
endfunction

onoremap <silent><expr> <space>f <SID>my_f_expr()

こうすると<Esc> を押す、あるいは指定した文字が見つからない場合には何もせずに終了します。 ですが残念ながら、このマッピングはドットリピートした場合に o_v を直接つかった上の例と同じ問題を抱えます。 実は <expr> 属性がついたマッピングはドットリピート時に再評価されません。 <space>f を使った場合には s:my_f_expr() が評価され、その返り値をキーシークエンスとして実行しますが、 ドットリピート時には再評価されず、前回の評価値を使いまわします。 つまり、通常のマッピングでは

  • s:my_f_expr() が評価される → 評価値である v:call My_f({c}, {count})<CR> が実行される

の順に実行され、ドットリピート時には

  • v:call My_f({c}, {count})<CR> が実行される

となります。 つまり、ドットリピート時に実行されるのは前回の評価値 v:call My_f({c}, {count})<CR> というわけです。 これではドットリピートで探したい文字が見つからなかった場合に、意図せずカーソル下の文字を編集してしまいます。

実はこの実装にはドットリピート時に getchar() が呼ばれないというメリットがあるのですが、解説は後の章に譲ります。

単純に1文字分範囲を増やす:まあまあよい例

o_v を使うのはひとまずやめることにしてみます。 排他的文字単位の範囲と内包的文字単位の範囲を比べると、範囲の右端1文字分だけが違います。 なので、難しいことを考えることはやめて1文字分範囲を広げましょう。

  • 例4
function! s:my_f() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(l:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  call cursor(line('.'), l:idx + 1)

  " 1文字分余計に移動する
  normal! l
endfunction

onoremap <silent> <space>f :call <SID>my_f()<CR>

簡単ですね。 ただし内包的文字単位のモーションを作る場合のみ有効な手段で、行単位や矩形への応用は利かないのが課題として残ります。

ビジュアル選択を使う:よい例

前の章にて、オペレータ待機モードでカーソルを移動させれば初期位置・終了位置でオペレータの作用範囲を決定する、と書きましたが実はこれは完全な説明ではありません。 移動のみでなくテキストをビジュアル選択した場合はカーソル移動よりも優先してその選択範囲がオペレータの作用範囲になります。 すなわち、次の優先順位で決定されます。

  1. テキストがビジュアル選択されていれば選択範囲がオペレータの作用範囲となる
  2. ビジュアル選択されていなければ、カーソルの初期位置および最終位置によってオペレータの作用範囲を決定する

なので、内包的文字単位にしたい場合は文字単位選択(:help characterwise-visual)を、行単位にしたい場合は行単位選択(:help linewise-visual)を、矩形にしたい場合は矩形選択(:help blockwise-visual)を使えばよいのです。

  • 例5
function! s:my_f() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(l:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  " 内包的文字単位にするためには 'selection' オプションが inclusive である
  " 必要がある
  let l:selection = &selection
  set selection=inclusive
  try
    normal! v
    call cursor(line('.'), l:idx + 1)
  finally
    let &selection = l:selection
  endtry
endfunction

onoremap <silent> <space>f :call <SID>my_f()<CR>

この方法は正しく失敗するモーションを定義できるうえ、かつ比較的簡単に内包的文字単位、行単位、矩形のモーションを作ることができます。 気にかかる点といえば '<'> マークを否応なく更新してしまう点ですが、実用上ほとんど問題にならないと思います。

また、このオペレータ待機モードでビジュアル選択を使うという方法は実はテキストオブジェクトを作る方法でもあります。 オペレータ作用範囲の一端がカーソルの初期位置に縛られるモーションに対して、テキストオブジェクトにはこの制限がありません。 これはビジュアル選択によって任意の範囲を指定できることに対応しています。

ドットリピート時の挙動の制御

さて、上の模倣版の f コマンドですが、まだ挙動のおかしな点があります。 ドットコマンドによって編集を繰り返した場合の動作が違うのです。

組み込みの f コマンドであれば、例えば dfa と入力した場合は文字 a までを削除し、更に . を押すと「a まで削除」を繰り返します。 この挙動はよくよく考えると、通常のマッピングとドットリピートで異なる挙動をしていると言えますね。 つまり、通常のマッピングではユーザーの入力を促しますが、ドットリピートの場合では入力を待たずに以前の入力を使いまわします。 しかし、上の模倣版の f コマンドはドットリピートの際にもモーション :call <SID>my_f()<CR> を繰り返すので、毎度 getchar() 関数を呼び、入力を待ってしまいます。

このようにドットリピートの場合に処理を分けるにはどうしたらいいでしょうか?

これにはすでに出た <expr> を使うことができます。先にも述べたとおり、<expr> マッピングの式はドットリピート時に再評価されません。 この性質を利用することでドットリピート時の挙動を分けることができます。

  • 例6
function! s:my_f_expr() abort
  let l:count = v:count1
  let l:c = nr2char(getchar())
  if l:c is# "\<Esc>"
    return "\<Esc>"
  endif

  let l:cursor_right = getline('.')[col('.') :]
  if count(l:cursor_right, l:c) < l:count
    return "\<Esc>"
  endif

  return printf(":call My_f('%s', %d)\<CR>", l:c, l:count)
endfunction

function! My_f(c, count) abort
  let l:c_regex = '\C' . escape(a:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), a:count)

  let l:selection = &selection
  set selection=inclusive
  try
    normal! v
    call cursor(line('.'), l:idx + 1)
  finally
    let &selection = l:selection
  endtry
endfunction

onoremap <silent><expr> <space>f <SID>my_f_expr()

あるいは陽にドットリピートであることで挙動を分けることもできますね。

  • 例7
let s:FALSE = 0
let s:TRUE = 1

let s:dotrepeat = s:TRUE
let s:c = ''

function! s:my_f_expr() abort
  let s:dotrepeat = s:FALSE
  return ":call My_f()\<CR>"
endfunction

function! My_f() abort
  let l:count = v:count1
  if !s:dotrepeat
    let s:c = nr2char(getchar())
  endif

  if s:c is# "\<Esc>"
    return
  endif

  let l:c_regex = '\C' . escape(s:c, '~"\.^$[]*')
  let l:idx = match(getline('.'), l:c_regex, col('.'), l:count)
  if l:idx < 0
    return
  endif

  let l:selection = &selection
  set selection=inclusive
  try
    normal! v
    call cursor(line('.'), l:idx + 1)
  finally
    let &selection = l:selection
  endtry
  let s:dotrepeat = s:TRUE
endfunction

onoremap <silent><expr> <space>f <SID>my_f_expr()

余談:マクロ時の挙動

本当は模倣版の f コマンドには依然、不満点が残っています。 なぜなら、組み込みの f コマンドとはマクロ (:help complex-repeat) で使った時の挙動が異なるためです。 組み込みの f コマンドは指定した文字が見つからず、移動に失敗した場合にはマクロの実行を停止しますが、模倣版の f コマンドはしません。 残念ながら、ユーザー定義モーションが明示的にマクロを停止する手段はいまのところ知らないので、ご存知の方がいたら教えてください。

まとめ

  • モーションはノーマルモード、ヴィジュアルモード、オペレータ待機モードでカーソルを動かす機能
  • オペレータ待機モードでの挙動は文字単位/行単位/矩形のどれかに分類され、文字単位はさらに排他的/内包的の二種がある
  • ユーザー定義モーションを書く場合、Exコマンドとして実装するのが便利
    • :normal コマンドや :call コマンドに {range} が渡らないよう注意
    • <Cmd> が早く安心して使えるようになりますように
  • 内包的文字単位/行単位/矩形のモーションを作りたい場合は、ビジュアル選択を使うのがよさそう(?)
  • ドットリピート時の挙動を通常のマッピングと分けるためには <expr> マッピングを使う
  • ユーザー定義モーションがマクロを停止する手段をご存知の方は教えてください

おまけ

上の成果をプラグインにまとめました。

GitHub - machakann/vim-fim: "f" imitated; not improved.

次のように使うことができます。実用性はないかと思いますが。

map <space>f <Plug>(fim)

<Plug>(fim-trial01) から <Plug>(fim-trial07) までが上に示されたコードを使ったマッピングです。<Plug>(fim) はすべての成果をまとめたうえで、さらにノーマルモード、ヴィジュアルモード、オペレータ待機モードの三つのモードをサポートします。