Vim のドットリピートとテスト

2018/8/29 追記
以下に書いている問題は v8.0.0548 以降解消されています。


たまに Vim の拡張としてオペレータを書くのですが、テストを書こうとすると困ることがあります。例として、オペレータを書いてみました。指定された領域の先端に a を、末端に b を入力するオペレータです。

function! OperatorInputAB(motionwise) abort
  let head = getpos("'[")
  let tail = getpos("']")

  call setpos('.', tail)
  normal! ab

  call setpos('.', head)
  normal! ia
endfunction

nnoremap <silent> \a :set operatorfunc=OperatorInputAB<CR>g@

今回は、ビジュアルモードは考えません。またカウント、レジスター、 operatorfunc に渡される引数も使いません。詳しくは、 :help opfunc 及び :help map-operator を参照願います。

さて、簡単で、なんら役に立たないものではありますが、ひとまずオペレータができました。しかし、このオペレータのドットコマンド時の挙動をテストしようと思うとなかなかうまくいきません。 vim-themis を例にとりますが、次のようなテストを想定しています。

let g:assert = themis#helper('assert')
let s:suite  = themis#suite('test: ')

function! s:suite.before() abort
  function! OperatorInputAB(motionwise) abort
    let head = getpos("'[")
    let tail = getpos("']")

    call setpos('.', tail)
    normal! ab

    call setpos('.', head)
    normal! ia
  endfunction

  nnoremap <silent> \a :set operatorfunc=OperatorInputAB<CR>g@
endfunction

function! s:suite.dotrepeat() abort
  call setline('.', 'foo')

  " test 1
  normal 0\aiw
  call g:assert.equals(getline('.'), 'afoob', 'failed after an operator command')

  " test 2
  normal .
  call g:assert.equals(getline('.'), 'aafoobb', 'failed after a dot-repeating')
endfunction

実行した結果を見ると、

not ok 1 - test:  dotrepeat
# The equivalent values were expected, but it was not the case.
#
#     expected: 'aafoobb'
#          got: 'aafoob'
#
# failed after a dot-repeating

# tests 1
# passes 0
# fails 1

test 2 のところでこけていますね。なぜか、 b が最後に入力されていません。詳しくはわからないのですが、どうも要約するとドットリピートで繰り返される単位の更新のタイミングの問題のようです。次の二つのスクリプトを :source してみると、一見同じことをしているように見えて別の結果が得られます。

  • a.vim
function! OperatorInputAB(motionwise) abort
  let head = getpos("'[")
  let tail = getpos("']")

  call setpos('.', tail)
  normal! ab

  call setpos('.', head)
  normal! ia
endfunction

nnoremap <silent> \a :set operatorfunc=OperatorInputAB<CR>g@

call setline('.', 'foo')
normal \aiw
normal .
  • b.vim
function! OperatorInputAB(motionwise) abort
  let head = getpos("'[")
  let tail = getpos("']")

  call setpos('.', tail)
  normal! ab

  call setpos('.', head)
  normal! ia
endfunction

nnoremap <silent> \a :set operatorfunc=OperatorInputAB<CR>g@

function! s:operation()
  normal \aiw
  normal .
endfunction

call setline('.', 'foo')
call s:operation()

ほぼ同じことをしているように見えますが、 a.vim の結果は意図通りの aafoobb 、しかし b.vim の結果は最後の b が欠けて aafoob です。違いは a.vim が関数の外でドットリピートしているのに対し、 b.vim は関数の中でドットリピートをしている点です。どうやら、ドットリピートの単位はすべての関数を抜ける時にまとめて更新されるらしく、何らかの関数の中でドットリピートした場合は OperatorInputAB() の中の最後の編集 normal! ia のみがドットリピートの対象になっているみたいです。

と、いうことは逆に考えると a.vim をもう少し工夫するとドットリピート用のテストが書けて、あとはバッチなりシェルスクリプトなりを用意すれば大勝利、となるわけですけど未だ肝心のテストはかけてないです。書かないと、はー。