Vimでシェルコマンドを簡単に実行するcommand.vimを作った

5 min read読了の目安(約4800字

初めに

普段Vimでターミナルを使ってちょっとしたコマンドを実行することがよくあります。
たとえばgh pr createlazygitdocuiといった、インタラクティブな操作を必要とするコマンドが多いです。
その度に:term xxxと入力するのは不便だし、コマンドの履歴補完が効かないので、command.vimというプラグインを作りました。

本記事はプラグインの紹介と作るにあたって苦労したことについて書いていきます。

使い方

デモのとおり、command.vimはコマンドを実行するためのバッファを用意していて、そのバッファでコマンド履歴を補完してくれます。
バッファを開くには次のコマンドを使用します。

:CommandBufferOpen

また、キーマップを用意しているので、次のように設定するとすばやくバッファを開けます。

nmap c: <Plug>(command_buffer_open)

コマンドを入力したらEnterで実行します。
シンプルですが、:xxxとほぼ同じ感覚でコマンドをターミナル上で実行できるのでストレスがないです。

しくみ

command.vimdenops.vimを使用しています。
denops.vimについてはこちらの記事で詳しく書かれているのでよかったら読んでみてください。

vim scriptだけでも実装できたんですが、denoのエコシステムを利用できるdenops.vimが魅力的だったので使ってみました。
denoはテストを標準でサポートしているし、型システムもあるので、開発体験としてはとても良かったです。

ちょっと話逸れましたが、command.vimでは自動補完以外の処理は基本deno側でやっています。
たとえば、バッファを開く時の処理は以下のようになっています。denops.vimvim.cmd()でVimのExコマンド、vim.callでVimの関数を実行できるのでそれを利用しています。

// open buffer for execute shell command
async openExecuteBuffer() {
  // NOTE: using feedkeys because :startinsert doesn't work well in vim
  await vim.cmd(`botright 1new | call feedkeys("i")`);
  await vim.cmd(
    `setlocal buftype=nofile bufhidden=hide noswapfile nonumber nowrap ft=sh`,
  );
  await vim.cmd(
    `inoremap <silent> <buffer> <CR> <Esc>:call denops#notify("${vim.name}", "executeShellCommand", [&shell])<CR>`,
  );
  await vim.cmd(`nnoremap <silent> <buffer> <C-c> :bw!<CR>`);
  await vim.cmd(`inoremap <silent> <buffer> <C-c> <Esc>:bw!<CR>`);
  await vim.call(`command#complete#enable`);
},

command#complete#enableは自動補完を有効化し、シェル履歴を取得しています。
これはVim scriptで書くしかなかったので、autoloadに定義しています。

fun! s:complete() abort
  call feedkeys("\<C-x>\<C-u>")
endfun

fun! command#complete#enable() abort
  let b:histories = denops#request("command", "getShellHistory", [&shell])
  if empty(b:histories)
    return
  endif

  setlocal completefunc=command#complete#shell_history

  let s:old_completeopt = &completeopt
  set completeopt+=noinsert,menuone,noselect

  augroup denops-command-complete
    autocmd!
    autocmd InsertCharPre <buffer> call s:complete()
    autocmd BufWipeout <buffer> call s:wipe_buffer()
  augroup END
endfun

ハマったポイント

completefunccompleteopt

command.vimを実装する上で、自動補完にかなりハマってしまいました。
まずcompletefunc<C-x><C-u>で補完するときの関数を指定するのですが、関数が呼ばれるしくみを理解するのに時間がかかりました。
何度も試しながら、少しずつ理解していったという感じです。

そして、completeoptは補完の細かい動作を変更するオプションですが、completefuncはバッファごとに設定できるのに対してcompleteoptはグローバルの設定になっています。
completeoptの設定をコマンドの実行完了と同時に、もとに戻さなければほかのプラグインが動作しなくなることがあります。
特に補完プラグインはこのオプションを使っていることが多いので注意が必要です。

自動補完

入力するたびに、<C-x><C-u>で補完を実行すれば自動補完が完成ですが、ここでもかなりハマりました。
Vimにはautocmdという、何かを操作するたびに発生するイベントをhookして処理できるしくみがあります。
入力に関しては主に以下のイベントがあります。

  • TextChanged: ノーマルモードでテキストを変更した場合
  • TextChangedI: 挿入モードでテキストを変更した場合
  • TextChangedP: 挿入モードでテキストが変更されてポップアップウィンドウが表示されている場合

補完は挿入モードで行えばよいので、TextChangedIautocmdを定義すればよいと思ったんですが、completefuncはテキストを変更してしまうため、autocmd(TextChangedI) -> completefunc -> テキストが変更される -> autocmd(TextChangedI) -> completefunc ... というふうに無限ループになってしまいました。

結局回避方法がよく分からなかったので、InsertCharPreイベントとfeedkeysを使って自動補完を実装しました。
InsertCharPreは入力したテキストがバッファに書き込まれる前に動くのでautocmd時点では入力したテキストを取得できないんですが、feedkeysは非同期で動作するので、
実際<C-x><C-u>が実行されるのはバッファにテキストが書き込まれたあとのタイミングになるようです。
そのため、この組み合わせであればまず補完は問題なく動くという感じです。

シェル履歴ファイル

command.vimが対応しているシェルはbashzshfishの3種類ですが、これらの履歴フォーマットがすべて異なっています。
bashzshは次のようなシンプルなテキストになっていますが、改行がある場合のフォーマットが異なっています。

vim
echo
ls -la
...

bashの場合は\nとして記録しますが、zshは改行ごとで区切っています。
たとえば、次のコマンドを入力した場合、bashecho 1 2として記録しますが、zshecho 1\\2で別れます。

echo 1 \
2

更にfishの場合は次のフォーマットになっていて、時間と場所とコマンドの接頭辞がついています。

- cmd: echo 1 \\\n2
  when: 1611401590
  paths:
    - tmux

このような差分を吸収しつつ、改行の場合は1行に整形する必要があり、ちょっと面倒でした。

ターミナルのIF違い

VimNeovimのターミナルのIFが異なる部分もちょっとハマリポイントでした。
Neovimの場合シェルを経由してコマンドが実行されるので、:term echo $EDITORを実行するとvimが出ますが、
Vimはシェルを経由しないため$EDITORと出ます。

幸いなことに、Vimは去年あたりに++shellオプションが追加されたので:term ++shell echo $EDITORと実行すればシェル経由してくれます。
それを使って:terminalの動作を統一しました。

if (await vim.call(`has`, "nvim")) {
  await vim.cmd(`new`);
  await vim.call(`termopen`, cmd);
} else {
  await vim.cmd(`terminal ++shell ${cmd}`);
  await vim.cmd(`nnoremap <buffer> <silent> <CR> :bw<CR>`);
}

最後に

サクッと作れるのかなと思ってやってみたら以外とハマリポイントが多く、結局時間が掛かってしまいました。
vim-jpでいろいろ質問しながら、なんとか形にできたのは良かったです。
特に暗黒美無王のShougoさんvim-vsnipの作者のhrsh7thさんが教えてくれていたので、この場を借りてあらめてお礼を申し上げます。ありがとうございました!