koturnの日記

普通の人です.ブログ上のコードはコピペ自由です.

Vimのサーバー機能でタイマーを実現する

Vimのあまり知られていない(?)機能として,サーバー機能がある. この機能を用いると,他に立ち上げているVimとやりとりすることができる. ただし, +clientserverコンパイルされていないと,利用することはできない.

このサーバー機能を活かして,サーバーとして立ち上げたVimに時間を計測してもらい,時間がきたらクライアントに通知するという機能を実装してみる. (Windows以外の環境ではテストしていないので注意)

let s:VimServer = {}

function! s:VimServer.new(name) abort dict
  let self.name = a:name
  return copy(s:VimServer)
endfunction

function! s:VimServer.start(...) abort dict
  if has('win95') || has('win16') || has('win32') || has('win64')
    execute 'silent !start /min vim -u NONE -i NONE -n -N --servername' self.name
  else
    silent call system('vim -u NONE -i NONE -n -N --servername ' . self.name . ' &')
  endif
  execute 'sleep' (a:0 > 0 ? a:1 : '1000') . 'm'
  call remote_send(self.name, '', 'id')
  let self.id = id
endfunction

function! s:VimServer.execute(cmd) abort dict
  call remote_send(self.name, '<Esc>:<C-u>' . a:cmd . '<CR>')
endfunction

function! s:VimServer.define_function(name, args, attr, body) abort dict
  let head = '<Esc>:<C-u>function! ' . a:name . a:args . ' ' . a:attr
  call remote_send(self.name, join(add(insert(a:body, head), 'endfunction'), '<CR>') . '<CR>')
endfunction


function! s:check_reply(replied_id, server_id)
  if a:replied_id == a:server_id
    if remote_peek(a:replied_id) > 0
      echomsg remote_read(a:replied_id)
    endif
  endif
endfunction

augroup VimServer
  autocmd!
  autocmd RemoteReply * call s:check_reply(expand('<amatch>'), s:vs.id)
augroup END

let s:vs = s:VimServer.new('VIMSERVER')
call s:vs.start(500)
call s:vs.execute('set updatetime=1000')
call s:vs.execute('let g:time_to_stop = 3000')
call s:vs.execute('let g:clock = &updatetime')
call s:vs.define_function('Update', '()', 'abort', [
      \ '  echo (g:clock / 1000.0)',
      \ '  if g:clock < g:time_to_stop',
      \ '    call feedkeys(mode() ==# "i" ? "\<lt>C-g>\<lt>ESC>" : "g\<lt>ESC>", "n")',
      \ '    let g:clock += &updatetime',
      \ '  else',
      \ '    call server2client(expand("<client>"), "3 seconds elapsed")',
      \ '    quitall!',
      \ '  endif'
      \])
call s:vs.execute('augroup Server')
call s:vs.execute('  autocmd!')
call s:vs.execute('  autocmd CursorHold,CursorHoldI * call Update()')
call s:vs.execute('augroup END')

このスクリプトを実行すると,サーバーが立ち上がってから3秒後に 3 seconds elapsed と表示されるはずだ.

ポイントはいくつかある.

まず,サーバーの起動部分であるが,Windowsならば, !start を利用して,新しいウィンドウでサーバーとなるVimを起動,Windows以外ならば, system() 関数を利用して,バックグラウンドプロセスでサーバーとなるVimを起動している. Windowsでは start コマンドのオプション /min を指定することで,サーバーの起動が目立たないようにした.

次に, remote_send() 関数は,サーバーのVimにキー操作を送信する関数である. この関数を用いて,サーバーにコマンドを実行させたり,関数を定義させたりする. remote_send() 関数は,サーバーが起動していないとエラーを吐くので,サーバー起動後,数百ミリ秒程度おいてから呼び出す必要がある. 上記コードでは,500ms 待機している. しかし, sleep を用いて待機しているので,どうしてもVimがブロックされてしまう. ブロックさせない手法については後述する.

500ms 経過後に,

call remote_send(self.name, '', 'id')

とすることで,サーバーに空のキー操作,すなわち,何もしないことをするように要求しているが,これはサーバーのIDを取得するために実行している. remote_send() は第三引数(文字列)を指定した場合,その変数名の変数にサーバーのIDが格納されるためだ.

上記コードでは,サーバーに以下のスクリプトと同等のことを実行させている.

set updatetime=1000
let g:time_to_stop = 3000
let g:clock = &updatetime
function! Update() abort
  echo (g:clock / 1000.0)
  if g:clock < g:time_to_stop
    call feedkeys(mode() ==# "i" ? "\<C-g>\<ESC>" : "g\<ESC>", "n")
    let g:clock += &updatetime
  else
    call server2client(expand("<client>"), "3 seconds elapsed")
    quitall!
  endif
endfunction

augroup VimServer
  autocmd!
  autocmd CursorHold,CursorHoldI * call Update()
augroup END

このスクリプトを別ファイルに書き出し,サーバーに :source させてもよかったが,それでは芸が無いので,クライアントからのキー操作でスクリプトの実行を実現する形にした. サーバーは,1秒毎に経過秒数を表示し,3秒経過したときに,クライアントに "3 seconds elapsed" という文字列を送信し,終了する.

<C-g><ESC> という文字列は,サーバー側で CTRL-G + Esc と解釈されてしまうので,それを防いで,そのまま送信するために, \<lt>C-g>\<lt>ESC> と変換している. "\<lt>" はクライアントで "<" と解釈される.

サーバーからクライアントに返信する場合は, server2client() 関数を用いる. この関数を用いた返信は,クライアントのオートコマンド RemoteReply を発火させる. オートコマンドで実行するコマンド定義部分では,expand('<amatch>') とすることで, RemoteReply の発火の原因となったサーバーのIDを取得することができる.

server2client() からの返信は remote_read() で読み取ることができる. remote_read()FIFOであり,最も古い返信を1つ取り出す. この remote_read() はサーバーからの返信キューが空ならば,メッセージが来るまでブロックするので,以下のように, remote_peek() を用いて,メッセージがあるかどうかを判別した方がよい. remote_peek() は引数に指定したIDのサーバーから返信を受け取ることができるならば,正の整数を返す関数である.

if remote_peek(id) > 0
  echomsg remote_read(a:replied_id)
endif

大まかな解説は以上となる. もし,サーバーを起動する部分で,ブロッキングが行われないようにしたい場合は,次に紹介する2つの方法のいずれかをとるとよい.

let s:VimServer = {}
let s:server_list = []

function! s:VimServer.new(name) abort dict
  if !has('clientserver')
    echoerr 'This vim cannot use serverclient fucntions'
    return {}
  endif
  let server = copy(s:VimServer)
  let server.name = a:name
  let server._instance_id = len(s:server_list)
  call add(s:server_list, server)
  return server
endfunction

function! s:VimServer.lazy_start(...) abort dict
  if has('win95') || has('win16') || has('win32') || has('win64')
    execute 'silent !start /min vim -u NONE -i NONE -n -N --servername' self.name
  else
    silent call system('vim -u NONE -i NONE -n -N --servername ' . self.name . ' &')
  endif
  let self._wait = a:0 > 0 ? a:1 : 1000
  let self._clock = &updatetime
  if a:0 > 1
    let self.lazy_start_callback = a:2
  endif
  let group = 'VimServer' . self._instance_id
  execute 'augroup' group
  execute '  autocmd!'
  execute '  autocmd' group 'CursorHold,CursorHoldI * call s:server_list[' . self._instance_id . ']._update()'
  execute 'augroup END'
endfunction

function! s:VimServer._update() abort dict
  if self._clock < self._wait
    call feedkeys(mode() ==# 'i' ? "\<C-g>\<ESC>" : "g\<ESC>", 'n')
    let self._clock += &updatetime
  else
    execute 'autocmd! VimServer' . self._instance_id 'CursorHold,CursorHoldI *'
    call remote_send(self.name, '', 'id')
    let self.id = id
    if !exists('self.lazy_start_callback')
      return
    endif
    if type(self.lazy_start_callback) == type(function('function'))
      call self.lazy_start_callback()
    elseif type(self.lazy_start_callback) == type('')
      execute self.lazy_start_callback
    elseif type(self.lazy_start_callback) == type([])
      for callback in self.lazy_start_callback
        execute callback
      endfor
    endif
  endif
endfunction

function! s:VimServer.execute(cmd) abort dict
  call remote_send(self.name, '<Esc>:<C-u>' . a:cmd . '<CR>')
endfunction

function! s:VimServer.define_function(name, args, attr, body) abort dict
  let head = '<Esc>:<C-u>function! ' . a:name . a:args . ' ' . a:attr
  call remote_send(self.name, join(add(insert(a:body, head), 'endfunction'), '<CR>') . '<CR>')
endfunction


function! s:check_reply(replied_id, server_id)
  if a:replied_id == a:server_id
    if remote_peek(a:replied_id) > 0
      echomsg remote_read(a:replied_id)
    endif
  endif
endfunction

augroup VimServer
  autocmd!
  autocmd RemoteReply * call s:check_reply(expand('<amatch>'), s:vs.id)
augroup END

let s:vs = s:VimServer.new('VIMSERVER')
function! s:vs.lazy_start_callback() abort dict
  call self.execute('set updatetime=1000')
  call self.execute('let g:time_to_stop = 3000')
  call self.execute('let g:clock = &updatetime')
  call self.define_function('Update', '()', 'abort', [
        \ '  echo (g:clock / 1000.0)',
        \ '  if g:clock < g:time_to_stop',
        \ '    call feedkeys(mode() ==# "i" ? "\<lt>C-g>\<lt>ESC>" : "g\<lt>ESC>", "n")',
        \ '    let g:clock += &updatetime',
        \ '  else',
        \ '    call server2client(expand("<client>"), "3 seconds elapsed")',
        \ '    quitall!',
        \ '  endif'
        \])
  call self.execute('augroup Server')
  call self.execute('  autocmd!')
  call self.execute('  autocmd CursorHold,CursorHoldI * call Update()')
  call self.execute('augroup END')
endfunction
call s:vs.lazy_start(500)

例によって, CursorHold[I] を定期的に発火させることで,時間計測をしている. 指定時間(上記コードでは500ms)の満了後,コールバック関数 s:vs.lazy_start_callback() が呼び出され,サーバーに実行させるコマンドが送信される仕組みになっている. この方法だと,オプション updatetime に設定した値によっては,サーバーが立ち上がるまでにクライアントの負荷が大きくなったり,サーバーの立ち上がりまでに時間がかかる可能性がある.

ベストと思われる方法は次の方法だ.

let s:VimServer = {}
let s:server_list = []

function! s:SID() abort
  return matchstr(expand('<sfile>'), '<SNR>\zs\d\+\ze_SID$')
endfun

function! s:VimServer.new(name) abort dict
  if !has('clientserver')
    echoerr 'This vim cannot use serverclient fucntions'
    return {}
  endif
  let server = copy(s:VimServer)
  let server.name = a:name
  let server._instance_id = len(s:server_list)
  call add(s:server_list, server)
  return server
endfunction

function! s:VimServer.lazy_start(...) abort dict
  if has('win95') || has('win16') || has('win32') || has('win64')
    execute 'silent !start /min vim -u NONE -i NONE -n -N --servername' self.name
          \ printf('-c "call remote_expr(%s, %s)"', string(v:servername), string('<SNR>' . s:SID() . '_on_vim_server_start(' . self._instance_id . ')'))
  else
    silent call system('vim -u NONE -i NONE -n -N --servername ' . self.name
          \ . printf('-c "call remote_expr(%s, %s)"', string(v:servername), string('<SNR>' . s:SID() . '_on_vim_server_start(' . self._instance_id . ')'))
          \ . ' &')
  endif
  if a:0 > 0
    let self.lazy_start_callback = a:1
  endif
endfunction

function! s:on_vim_server_start(instance_id) abort
  let server = s:server_list[a:instance_id]
  call remote_send(server.name, '', 'id')
  let server.id = id
  if !exists('server.lazy_start_callback')
    return
  endif
  if type(server.lazy_start_callback) == type(function('function'))
    call server.lazy_start_callback()
  elseif type(server.lazy_start_callback) == type('')
    execute server.lazy_start_callback
  elseif type(server.lazy_start_callback) == type([])
    for callback in server.lazy_start_callback
      execute callback
    endfor
  endif
endfunction

function! s:VimServer.execute(cmd) abort dict
  call remote_send(self.name, '<Esc>:<C-u>' . a:cmd . '<CR>')
endfunction

function! s:VimServer.define_function(name, args, attr, body) abort dict
  let head = '<Esc>:<C-u>function! ' . a:name . a:args . ' ' . a:attr
  call remote_send(self.name, join(add(insert(a:body, head), 'endfunction'), '<CR>') . '<CR>')
endfunction


function! s:check_reply(replied_id, server_id)
  if a:replied_id == a:server_id
    if remote_peek(a:replied_id) > 0
      echomsg remote_read(a:replied_id)
    endif
  endif
endfunction

augroup VimServer
  autocmd!
  autocmd RemoteReply * call s:check_reply(expand('<amatch>'), s:vs.id)
augroup END

let s:vs = s:VimServer.new('VIMSERVER')
function! s:vs.lazy_start_callback() abort dict
  call self.execute('set updatetime=1000')
  call self.execute('let g:time_to_stop = 3000')
  call self.execute('let g:clock = &updatetime')
  call self.define_function('Update', '()', 'abort', [
        \ '  echo (g:clock / 1000.0)',
        \ '  if g:clock < g:time_to_stop',
        \ '    call feedkeys(mode() ==# "i" ? "\<lt>C-g>\<lt>ESC>" : "g\<lt>ESC>", "n")',
        \ '    let g:clock += &updatetime',
        \ '  else',
        \ '    call server2client(expand("<client>"), "3 seconds elapsed")',
        \ '    quitall!',
        \ '  endif'
        \])
  call self.execute('augroup Server')
  call self.execute('  autocmd!')
  call self.execute('  autocmd CursorHold,CursorHoldI * call Update()')
  call self.execute('augroup END')
endfunction
call s:vs.lazy_start()

Vimの起動オプション -c <command> を付加すると,Vimが起動し,最初のファイルをロードした後に,指定したコマンドを実行する. このオプションを利用することで,サーバーのが立ち上がった後に,サーバーが remote_expr() をコールすることを実現している. サーバーが remote_expr() を通じて,クライアントにスクリプトローカル関数 s:on_vim_server_start() を呼び出すことを要求する(正確にはクライアントにおいて,式 s:on_vim_server_start() を評価した結果を返却するように要求している). remote_expr() は,

call remote_expr('VIM', 's:on_vim_server_start()')

のように,単純な方法ではスクリプトローカル関数の呼び出しができない(スクリプト内ではないので,スクリプトローカル関数が見えない)が,

call remote_expr('VIM', '<SNR>42_on_vim_server_start()')

のように,スクリプト番号を渡すことで,スクリプトローカル関数の呼び出しが可能となる. スクリプト番号の取得方法については, :help <SID> を参照するとよい.

この待機方法は,サーバー自身に立ち上がったことを知らせてもらうので,クライアント側の負荷や無駄な待機時間も少なく,また,確実に立ち上がりを検知できる.

まとめ

今回はVimのサーバクライアント機能を用いて,タイマーを実装した. これにより,定期的に CursorHold[I] を発火させることによって実現するタイマーを,クライアントのVimで実行しなくてもよくなり,クライアントのVimの負荷が無くなる. サーバーのVimに,コマンド実行や関数定義,オートコマンドの定義など,一通りのことを行わせることができることもわかった. 重い処理をサーバーに行わせ,結果をクライアントで受け取るという使い方もできるだろう.

おまけ

Vimのサーバー機能を活かしたプラグインとして,

等がある. これらのプラグインについては,以下の記事あたりを参考にするとよいだろう.

このように,サーバー機能には様々な活用方法があるので,その可能性を模索していきたい.

追記

2015 08/05

tyruさんからサーバ機能を用いたプラグインを紹介していただいた. koron/minimap-vimosyo-manga/vim-sugarpot は,ややホビー用途のプラグインのように思えたが,このプラグインは実用的だと感じた.