tsuquyomi を魔改造している

https://qiita.com/advent-calendar/2018/vim 22 日目の記事です。

tl;dr

tsuquyomi でエラーチェックが同期で走って Vim の UI をブロックしストレスがたまるから、魔改造して非同期で動く仕組みを作った。
https://github.com/heavenshell/tsuquyomi/tree/feature/async

はじまり

今年はお仕事で TypeScript を本格的に書くようになった。
2016 年に TypeScript を少し書いた時も tsuquyomi を使っていた。
今回も tsuquyomi を入れて、自作の Tslint チェックをする Vim plugin を作って開発をしている。
保存時に tsuquyomi が TSServer にチェックを行っていた。
(Tslint は完全に非同期になっているので、InsertLeave 時にチェック)


最初はプロジェクトのソースコードのファイル数や依存ライブラリ数が少ないので特に速度に問題を感じなかったが、ある程度の規模になってきてから保存しチェックが終わるまで、体感的に数秒かかり Vim の UI をブロックする場合があり、ストレスになってきた。
特に起動直後の :TsuGeterr が遅い。

シンタックスエラーがあるファイルを開き :TsuGeterr をすると、Vim の UI がブロックされていることがわかると思う。

なおこの問題は tsuquyomi の Issue にもある。
https://github.com/Quramy/tsuquyomi/issues/241

ale.vim

そんな時に同僚に ale.vim がエラーチェック、補完とともによくできていることを教えてもらった。
ale.vim のエラーチェックが非同期で動作するのは勿論知っていたが、lsp による補完までサポートしているのは知らなかった。
少し使ってみたが、現状ストレスに感じているエラーチェックも完全にバックグランドで動作し、Vim の UI をブロックすることないのが素晴らしかった。
一方で非同期で補完しているため、補完のポップアップがちらつくのが少し気になった。


目に付いた欠点はその程度(あとは TypeScript + Prettier + tslint --fix との連携がうまくいかなかったが、おそらくこれは自分の設定が悪そう)だが、ale.vim は非常によくできていて、なぜか悔しかった。


また vim-lsp は試していない(mattn さんがごくごく最近メンテナの一人になられたので、今後は安心して使えるとは思う)が、おそらく非同期でいい感じで動くのだろう。
その他の最近の TypeScript な Vim プラグインも試していないが、おそらく似たり寄ったりだろう。

tsuquyomi はなぜ UI をブロックするのか

tsuquyomi も TSServer を使っているし、Vim8 の Job / Channel を使っているので、ale.vim と同様のことができるはずと仮説を立てて、tsuquyomi のコードを読んだ。


Vim8 だと TSServer には `ch_sendraw()` でリクエストしている。
https://github.com/Quramy/tsuquyomi/blob/master/autoload/tsuquyomi/tsClient.vim#L154


問題はレスポンスの箇所で、指定してたレスポンス長を超えるまでループで待っている。
https://github.com/Quramy/tsuquyomi/blob/master/autoload/tsuquyomi/tsClient.vim#L160
https://github.com/Quramy/tsuquyomi/blob/master/autoload/tsuquyomi/tsClient.vim#L178

結果的に TSServer がレスポンスを返して QuickFix に追加するまでが同期として動いており、UI をブロックしている。
特に起動直後は TSServer からのレスポンスが遅い(これは ale.vim でも同様で、TSServer 上の仕様であると思う)。
そのため上記のスクリーンキャストのように :TsuGeterr を実行すると、遅い場合は数秒固まったようになる。


tsuquyomi の関数のヘッダーコメントに `pseudo synchronously` と書いてあるので意図した動きだろうが、エラーがあれば QuickFix に入れて、ユーザーに通知するだけで良いので、レスポンスをわざわざ待つ必要はない。
ちなみに tsuquyomi が作られた頃には Vim8 の Job/Channel なんてなかったのだから、そういう実装になっているのは理解できる。

魔改造の方針

元からある TsuGeterr を改良するのではなく、TsuAsyncGeterr というように新しくインターフェイスを加える方針にする。
現状はエラーチェック(TsuGeterr)だけだが、他の機能も容易に非同期化できる作りにしたい。
なお本魔改造は Vim8 の Job/Channel に全力で依存しているので、NeoVim は一切考慮していない。
(元々 tsuquyomi も NeoVim は考慮していない)

魔改造した箇所

まずは Vim8 の Job を開始しているところにコールバックを仕込む。


https://github.com/heavenshell/tsuquyomi/blob/feature/async/autoload/tsuquyomi/tsClient.vim#L87
これで TSServer が STDOUT に出力があるたびにコールバックが反応する。
今回はエラーチェックに関するものだけを受け取りたい。


コールバックを受け取り応答メッセージを解析しエラーがあれば QuickFix に入れ、ユーザーに通知するコールバックを呼び出すという設計にした。


https://github.com/heavenshell/tsuquyomi/blob/feature/async/autoload/tsuquyomi/config.vim#L313
tsuquyomi が起動した際にここで、エラーチェックに関する関数を登録する。
TSServer からの応答メッセージにエラーがあればそれを抽出し、requestCompleted を受けたら、QuickFix に設定するリストを作成し、通知用のコールバック関数に送信するようにした。


これにより Vim 起動直後にエラーチェックを行っても UI をブロックすることがなくなった。


上記は :TsuGeterr 時と同様の条件で行った様子。:TsuAsyncGeterr 実行時にすぐに Vim の UI が操作できるのがわかると思う。


また TextChange, InsertLeave, BufWritePost イベントを登録することによって、シームレスにエラーを通知できるようになった。


ただし :TsuAsyncGeterr は :TsuGeterr に挙動を合わせているため、エラーがあれば、QuickFix を自動で開き、エラーがなければ、自動で QuickFix を閉じる。
そのため TextChange や InsertLeave に紐付けていると、QuickFix を自動で開くのが UI の操作をブロックして鬱陶しい。
エラーの通知は ale.vim 同様そっとユーザーに知らせ、ユーザーが自分で QuickFix を開けば Vim の操作をブロックすることがなくなる。


下記のように自分独自の通知先関数を .vimrc で登録すると、QuickFix の制御をできるようにした。

let s:ts_notify = 0
function! s:ts_callback(qflist)
  if s:ts_notify == 1
    call setqflist(a:qflist)
    let cnt = len(getqflist())
    if cnt > 0
      echomsg printf('[TypeScript] %s errors', cnt)
    else
      echomsg '[TypeScript] No error'
    endif
  endif
  let s:ts_notify = 0
endfunction

function! s:ts_quickfix()
  let s:ts_notify = 1
  if g:tsuquyomi_is_available == 1
    call tsuquyomi#registerNotify(function('s:ts_callback'), 'diagnostics')
    call tsuquyomi#asyncCreateFixlist()
  endif
endfunction
autocmd InsertLeave,TextChanged,BufWritePost *.ts,*.tsx silent! call s:ts_quickfix()

tsuquyomi#registerNotify() は登録したコールバック関数の引数に QuickFix に入れるリストを詰めて呼び出すので、それを受けて、自分で setqflist() を呼び、エラーがあればエコーメッセージを出し、ユーザーに通知をする。

上記は、間違った型を設定しようとして、tsuquyomi がエラーを通知を受けた様子。
(エラーハイライトは別のプラグインを使っている)

拡張性

今はエラーチェックのみだが、例えば今後補完を非同期で実装したいとなると、エラーチェックと同様に専用の関数を作成し、 https://github.com/heavenshell/tsuquyomi/blob/feature/async/autoload/tsuquyomi/config.vim#L293 ここで登録し、応答メッセージをパースし、補完候補を構築し、Vim の complete() を呼べば良い。

おまけ

tsuquyomi で関数などのシグネチャがどう定義されているかを知りたい場合などが多々ある。
ale では :ALEHover を利用すれば良い。
同様のことを tsuquyomi でするには以下のように .vimrc に登録すればできる。

function! s:ts_hint()
  let content = tsuquyomi#hint()
  silent pedit __TsuquyomiScratch__
  silent wincmd P
  setlocal modifiable noreadonly
  setlocal nobuflisted buftype=nofile bufhidden=wipe ft=typescript
  put =content
  0d_
  setlocal nomodifiable readonly
  silent wincmd p
endfunction

command! TsHint silent! :call s:ts_hint()
noremap <silent> <buffer> <Plug>(TsuHint) :TsuHint<CR>
autocmd FileType typescript nnoremap <buffer> <leader>h :TsHint<CR>

まとめ & お願い

Vim7 時代の少し前に作られたプラグインを非同期化を試みた。
色々お世話になったプラグインなので、改良して貢献をしたい。


もし tsquyomi を使っていらっしゃる方がおり、エラーチェックで UI がブロックされて辛いという方は是非 https://github.com/heavenshell/tsuquyomi/tree/feature/async を試して頂いて、フィードバックを頂きたい。
自分で仕事でも使っていて、今の所それほど怪しい動きはしていないのでそのまま PR しようかと思ったが、先にご意見などを受けてから PR したい(集まらなければそのまま PR 出そうと思う)。
Twitter で @heavenshell あたりにメンションいただければとてもありがたいです。

謝辞

Vim アドベントカレンダーに登録したものの、今回のネタどうしようと悩んでた時に TypeScript で ale.vim がいけているというのを教えてくれた同僚に感謝します。