Akatsuki Hackers Lab | 株式会社アカツキ(Akatsuki Inc.)

Akatsuki Hackers Labは株式会社アカツキが運営しています。

Vim で外部から情報を受ける

この記事は Akatsuki Advent Calendar 2020 の 15 日目の記事です。

thinca です。普段は Vim を使って開発をしています。

Vim は外部ツールとの連携を得意としているソフトウェアで、外部コマンドの実行結果を取り込んだり、Language Server のような外部ツールを起動してデータをやり取りしたり、といったことが行えます。

一方で Vim は、Vim の外部から情報を通知してもらうのが苦手です。

本記事では、まず前半で、Vim が外部から情報を受け取る手段についてまとめます。後半ではこれらを用いて Vim でどのようなことが可能になるかについて検討します。

外部から情報を受ける手法

clientserver

Vim の clientserver 機能は、まさしく外部から情報を受けるための機能です。

clientserver 機能が有効になっている Vim を起動すると、その Vim は外部から情報を受けられるようになります。別の Vim から、別のサーバとして起動している Vim の一覧を取得したり、それぞれの Vim に式を評価させたりすることができます。

この機能には 2 つの問題があります。

  • クライアントも Vim である必要がある
    • これは CLI で vim をワンショットで起動させることで回避が可能です。
  • 環境によっては動作しない
    • 特に Linux 環境においては X サーバとの連携が必要になるため、X の入っていないサーバのようなシステムでは利用できません。

上記の理由から、利用はかなり限定的になります。残念ながら私は、普段はプライベートでは X の入っていないサーバマシンを開発機として使っているため、この機能は利用できません。

Job

Job 機能を使うことで、Vim は外部プロセスを起動してその標準出力を待ち受けることができるようになります。

この機能を使えば外部からの情報の受け取りが現実的に行えます。vim-lsp などはこの機能を使って Language Server から情報を受け取っています。

デメリットとしては、外部コマンドが必要になるため使用するコマンドによっては環境に依存してしまう点です。しかし、最近はシングルバイナリで動作するツールも比較的容易に作れるため、必要であれば専用コマンドを同梱してしまうという手もあります。

ch_listen()

現在はまだ利用できませんが、ch_listen() という関数の追加が提案されています。これがあれば、TCP ポートや UNIX ドメインソケットで入力を受け付けて、処理を実行することができるようになります。Job と違って外部ツールに依存しない点が魅力です。

現在はユースケースが不足していて、必要性についてあと一歩説得力が足りていないようです。もっと実用的なユースケースが提案できれば動きがあるかもしれません。

活用できそうな事例

上記の事情から、私は主に Job を使った方法を利用しています。ここでは、実際にどのような場面で活用できるか、あるいはできそうかを紹介します。

別の Vim でファイルを開く

これは clientserver 機能の主目的とも言えますが、例えばすでに Vim が起動していた際に別の場所で新たに Vim でファイルを開こうとした場合、Vim が複数起動してしまいます。

このとき、新しく Vim を起動する代わりにすでに起動している Vim でファイルを開く、といったことが可能になります。

ファイルの再読み込み

Vim は、開いているファイルが外部で変更されたかどうかは逐一チェックしていません。:checktime Ex コマンドを使うとチェックしてくれますが、必要な際に実行する必要があります。

GUI 版の Vim であれば、例えば Vim のウィンドウがフォーカスを得た際に実行される FocusGained イベントで実行すれば、ほとんどの場合をカバーできそうですが、CUI だと難しそうです。

こんなときに、外部から変更を通知したり、あるいは CUI の Vim がフォーカスを得たことを通知できれば自動でのファイル再読み込みが捗りそうです。

hubot-vimexec

拙作の、チャット上で Vim のコマンドの実行結果を見れるようにする bot です。

thinca/hubot-vimexec

よくある言語処理系の実行結果を出すものと違い、裏で Vim が常駐し、状態を保持しています。なので以下のようなことができます。

f:id:thinca:20201214203600p:plain
状態を保持した bot

これを実現するためには、実行中の Vim に対して外部からスクリプトを与える必要があります。

一番簡単な方法は、Vim 側で新しいスクリプトがないか逐一ポーリングすることです。実際、最初はこの方法で実装したのですが、さすがに 1 秒に何度も readdir() するのはディスクへの負担も気になってきます。

これを Job で置き換えたいのですが、この bot は環境を閉じ込めるために Vim の実行にネットワークを無効にした Docker を使っています。ポーリングの際は、バインドマウントを行ってそこに実行したいファイルを置き、終わったら結果のファイルを置いてもらっていました。これを、Job を使って Vim の内部でプロセスを実行し、外からこのプロセスに対して何らかのアプローチをする必要があります。

そこで、named pipe を使いました。これであればバインドマウントだけでやりとりができます。

まず、マウントした領域にリクエストを受け付けるための named pipe を作成し、Vim はこのファイルを cat コマンドで読み取る Job を生成して待ち受けます。

bot にリクエストが来たら、まずはそのリクエストに対するレスポンスを受け付けるその場限りの named pipe をマウントした領域に生成します。そしてスクリプトの内容と、レスポンスすべきファイル名をリクエスト用のファイルに書き込み、あとはレスポンス用のファイルを、Node.js の fs.readFile() で読み、読み込めるのを待ちます。

Vim 側はリクエストを受けると cat のプロセスがファイルの内容を出力して終了するので、スクリプトを実行し、結果をレスポンス用のファイルに writefile() で書き込みます。その後、次のリクエストのために再び cat の Job を実行します。

bot はレスポンスを受けとると、専用の named pipe を削除し、結果をチャットに伝えます。ちなみにレスポンス用に毎回別のファイルを用意するのは、複数のリクエストが同時に来た場合のためです。

この手法は Windows だと難しいため利用範囲は限られますが、必要なコマンドが catmkfifo だけであるため、Windows 以外であれば比較的容易に応用が効きそうです。

ch_listen() の模倣

ch_listen() はまだ使えませんが、要は、port を LISTEN し、接続があれば入力を標準出力に流し、標準入力から受け取った内容をクライアントに返すようなコマンドがあれば、Job を使って ch_listen() を再現できそうです。

そんな便利なコマンドがあればよいのですが…そんなスイスアーミーナイフのようなコマンドが…アーミー…?あっ!

というわけで netcat を使って ch_listen() のようなことをしてみましょう。

簡易ですが、Vim を起動して、以下のスクリプトを実行します(:source server.vim)。

function s:handle(req) abort
  if a:req.method ==? 'POST'
    return {
    \   'status': 200,
    \   'status_text': 'OK',
    \   'body': execute(a:req.body),
    \ }
  endif
  return {
  \   'status': 404,
  \   'status_text': 'Not Found',
  \   'body': json_encode(a:req),
  \ }
endfunction

function s:parse_request(msg) abort
  let req = {}
  let matched = matchlist(a:msg, '^\v(.{-})\r\n\r\n(.*)')[1 : 2]
  let [header_block, body] = matched[1 : 2]
  let start_line = split(header_block, "\r\n")[0]
  let req.method = split(start_line, '\s\+')[0]
  let req.body = body
  return req
endfunction

function s:out_cb(cxt, ch, msg) abort
  try
    let req = s:parse_request(a:msg)
    let res = s:handle(req)
  catch
    let res = {
    \   'status': 400,
    \   'status_text': 'Bad Request',
    \   'body': v:exception,
    \ }
  endtry

  let response = join([
  \   printf('HTTP/1.1 %d %s', res.status, res.status_text),
  \   'Content-Length: ' .. len(res.body),
  \   '',
  \   res.body,
  \ ], "\r\n")
  call ch_sendraw(a:cxt.in, response)
endfunction

function s:start(port) abort
  let cxt = {}
  let job = job_start(['nc', '-lkp', a:port], {
  \   'in_mode': 'raw',
  \   'out_mode': 'raw',
  \   'out_cb': funcref('s:out_cb', [cxt]),
  \ })
  let cxt.in = job_getchannel(job)
endfunction

call s:start(11111)

サンプルなので処理をかなり省略していますが、どこからどう見ても HTTP サーバです。このサーバは 11111 番ポートで待ち受け、テキストを POST メソッドで投げると Vim script として実行し、結果の出力を返します。

というわけで、curl でアクセスしてみましょう。

$ curl -d 'echo "Hello, Vim server!"' 'localhost:11111'

Hello, Vim server!

結果が返ってきました!やりましたね。

注意点として、netcat には様々な派生バージョンがあるため、実際に使えるかどうかは環境に入っている netcat の機能について知る必要があります。

まとめ

Vim で外部から情報を受け取る手法についてまとめました。また、それらの活用方法についても検討しました。

これらの応用により、Vim をもっと便利にできる可能性があるのではないかと考えています。この記事がその一助になれば幸いです。