Vim on Wasm on Web Worker on Browser with Atomics

この記事は以前の

rhysd.hatenablog.com

の続編で,WebAssembly (Wasm) にポーティングした Vim の話です.

github.com

TLDR

Wasm にコンパイルした Vim のコードを Web Worker(ワーカスレッド)の中で動かすことで,メインスレッドで行われるユーザのインタラクションをエディタがブロックしなくなりました. また,イベントループのポーリングを Atomics.wait() でやってキー入力を共有メモリバッファで受け取ることで Emterpreter を捨て,実行速度・安定性・バイナリサイズ・ビルド時間・メンテ性が向上しました.

実装:

Run Vim in Web Worker and say goodbye to Emterpreter by rhysd · Pull Request #30 · rhysd/vim.wasm · GitHub

これまでの問題点

前回の記事では Vim のコードベースに手を入れて emscripten と binaryen で Wasm にビルドし, <canvas/> に描画する JavaScript ランタイムを整備することでブラウザ上で動く Vim を実現しました.

ポーティングは概ね問題はありませんでしたが,一点 sleep() が Wasm 及び JavaScript では実現できないという問題がありました.

ウェブページでメインスレッドをブロックすることはユーザとのインタラクションをブロックすることを意味します.なので JavaScript では時間がかかる処理はすべて非同期な API として実装され,ブロックできないようになっています.これと同様にメインスレッドで実行される Wasm もスレッドをブロックする機能は提供されていません.

ですが,C プログラムは sleep() を使っているものがあり,Wasm 移植ではこれが問題になることがあります.Vim では GUI フロントエンドが実装する必要がある関数 gui_mch_wait_for_chars()ブロッキングでユーザの入力を待つ処理を要求するため,これに該当しました.

そこで前回の実装では emscripten が提供する Emterpreter という機能を利用して解決しました.Emterpreter は sleep() ができない代わりに emscripten_sleep() などのマジック関数を提供します.これらは C のコードからは sleep() のように同期的に待つ関数のように使えますが,実際は一旦処理を中断して非同期に待つ処理に変換されます.emccemscripten の C コンパイラ)は emscripten_sleep() を使っている関数を直接 Wasm にコンパイルせず,Wasm の上で動くインタープリタ (Emterpreter) で実行するためのバイトコードに変換します (emterpretify).実行時にはそのバイトコードインタープリタで実行し,emscripten_sleep() の呼び出し箇所で一旦実行状態を保存(suspend)して JavaScript 側の setTimeout() を読んで実行を中断します.タイマーがコールバックを呼んだら Wasm 側に戻り,インタープリタはその中で保存しておいた実行状態を復帰(resume)して実行を再開します.

このような力技により前回はどうにか Vim を動かすことに成功しました.ですが,前回の記事にも書いたように,次の問題が残りました

  • Emterpreter で実行される関数は Wasm から直接呼び出すことができません(逆に Emterpreter から Wasm の関数は呼べる).なので,emscripten_sleep() を呼んでいる関数を呼んでいる関数,それを呼んでいる関数,... というふうに呼び出し元の関数も Wasm には直接コンパイルできず,Emterpreter のバイトコードコンパイルする必要があります.Vimsleep() が必要な関数(gui_mch_wait_for_chars())は Vim のメインループの一番奥にあたる箇所で呼ばれる関数だったため,それを呼ぶコールスタック上の大量の関数を emterpretify する必要がありました.emterpretify する関数は手動でリスト化して管理する必要があるため,そのリストが膨大になってメンテが大変になりました.しかもこのリストが間違っていて Wasm にコンパイルした関数から emterpretify された関数を呼ぼうとすると実行時にプログラムがクラッシュします
  • Emterpreter バイトコードへのコンパイルは,同期的な C のコードを非同期なコード( emscripten_sleep() の呼び出し箇所で処理を切ってコールバックを待つ)に置き換えるため,かなり大きな変換が走ります.そのためコンパイル時間がかなり延びます
  • Emterpreter バイトコードはかなりシンプルなので,その分バイトコードのバイナリサイズは膨れます
  • Emterpreter は試験的な機能なので,かなりバグが多く不安定です.特に JavaScript 側から emterpretify された C の関数を呼ぶ時は不安定で, char * を渡そうとすると何故かクラッシュしたりします(ccall(){async: true} がバグっている説)

上記の理由からどうにか Emterpreter を使うのをやめるのが再優先事項でした.

  • Sync XHR を使って同期でリクエストを投げ,ServiceWorker で受け取って待ってからレスポンスを返すことでなんとか sleep() を実現しようとする → Chrome ではバグで動かず,Firefox ではビジーループに近いほど CPU 利用率が上がるためダメ
  • Vim のメインループ内で gui_mch_wait_for_chars() を呼ぶ関数をすべて非同期(結果を return でなくコールバックで受け取る)に書き換え,emscripten非同期メインループ機能でメインループを回す → 変換が大変すぎる.10000行ぐらい書き換えてみたところで先が見えないのでメンテが厳しすぎると判断し断念

など試してみましたが,うまくいきませんでした.

今回の実装の改善

Atomics API

emscripten の pthread 対応のコードを呼んでいた時に,JavaScript同期プリミティブを提供する Atomics API があることを知りました. その中に Atomics.wait() という futex システムコールのような機能を提供する関数があるのとを見つけました.

The static Atomics.wait() method verifies that a given position in an Int32Array still contains a given value and if so sleeps, awaiting a wakeup or a timeout. It returns a string which is either "ok", "not-equal", or "timed-out".

_人人人人人人人人_
> if so sleeps <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^ ̄

なんと,無いと思っていた JavaScriptsleep() がこんなところにありました.

ですが,これを呼ぶとスレッドを止めてしまうので,もちろんこれをメインスレッドで呼ぶことはできません.Atomics.wait() はワーカスレッドからつまり Web Worker 内で呼ばなければならないという仕様があります.メインスレッドで呼ぶと待たず即座に例外を投げます.

// 共有メモリバッファをつくり,ワーカスレッド(dedicated Web Worker)を生成して渡す
const shared = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT * 4);
const buffer = new Int32Array(shared);

const worker = new Worker('worker.js');
worker.postMessage(buffer);

setTimeout(() => {
    // 最初の要素に 42 を保存
    Atomics.store(buffer, 0, 42);
    // 最初の要素が変更されたことを別スレッドに通知
    Atomics.notify(buffer, 0, 1);
}, 3000);
  • ワーカスレッド(worker.js)
onmessage = event => {
    const buffer = event.data;
    const start = Date.now();

    // 5秒タイムアウトで同期的に待つ.この間ワーカスレッドは停止する
    const result = Atomics.wait(buffer, 0, 5000);
    console.log('slept', Date.now() - start, 'ms:', result);

    const val = Atomics.load(buffer, 0);
    console.log(val); // => 42
};

Vim をワーカスレッドで動かす

Vim をワーカスレッドで動かすにあたって,C 側のコードの修正はまったく必要ありませんでした.emcc-o vim.js のように出力に JavaScript ファイルを指定すると,自動で WebWorker 内で動かす前提のコードを吐いてくれます.後は new Worker('vim.js') のように読み込めば Wasm ファイル(vim.wasm)を読み込んでくれます.

JavaScript 側はメインスレッドで動かすスクリプトとワーカスレッドで動かすスクリプトに分ける必要があります.

メインスレッド側は

  • ワーカスレッドから描画情報を受け取って <canvas> に描画する処理
  • キー入力を受け取って共有バッファに書き,ワーカスレッド側に通知する
  • ワーカを生成してスタートする

ワーカスレッド側は

  • メインスレッド側から通知を受け取って共有バッファから入力を読み,Vim (Wasm) 側に伝える
  • Vim (Wasm) 側から描画情報を受け取り,メインスレッド側に通知する

という処理を行います. vim.wasm/wasm/main.ts がメインスレッド側,vim.wasm/wasm/runtime.ts がワーカスレッド側です.

全体の構成のイメージは下図のようになっています.

全体の構成イメージ

Wasm 側は Atomics.wait で待っている間停止するので,ワーカスレッド側の onmessage コールバックが発火せず,共有メモリバッファで値をやりとりする必要があります. 逆にワーカスレッドからメインスレッドに値を渡す時は postMessage() で値を渡せます.

キー入力が発生しない時と発生するときの処理のシーケンスは下図のようになっています.

入力待ちの実行シーケンス

キー入力が発生しない時 (上段,first tick)

  1. Vim (Wasm) が起動して入力待ち状態になると,最終的に gui_mch_wait_for_chars() が呼ばれ,その中で JavaScript 側に処理を渡します(vimwasm_wait_for_event())
  2. vimwasm_wait_for_event() 内で Atomics.wait() を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます
  3. 今回は通知が来ず,Atomics.wait()タイムアウトしました
  4. そのまま vimwasm_wait_for_event() を抜け,Vim (Wasm) に処理を戻します

キー入力が発生する時 (下段,next tick)

  1. vimwasm_wait_for_event() 内で Atomics.wait() を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます
  2. キー入力が発生しました. KeyEvent をメインスレッド側で受け取って共有メモリバッファにキー入力情報(押されたキーの名前やキーコード,モディファイアキー情報など)を書きます
  3. 共有メモリバッファのうちの1バイトを通知用の領域とし,そこに Atomics.store() でキー入力情報を書いたことを知らせる値を書いてから,ワーカスレッド側に Atomics.notify() を通じて通知します
  4. ワーカスレッド側は Atomics.wait() で待っておき,通知が来たら共有メモリバッファを見に行ってキー入力情報を得ます
  5. キー入力情報を元に Vim に与えるキーシーケンスを計算し,Vim 側に JavaScript to Wasm API を使って渡します
  6. Vim (Wasm) は受け取ったシーケンスを入力バッファに加えます
  7. Vim (Wasm) は入力バッファから入力シーケンスを読んで処理し,結果として描画イベントが発生した時は適宜 JavaScript を通じてメインスレッド側に通知します.メインスレッドは受け取った描画イベントをアニメーションフレームで <canvas/> に描画します

本当は Atomics.wait() で待つ前や Vim の処理中にキー入力がバッファに書き込まれた場合など考えることが他にもあります.

今回の実装の結果

良くなった点

今回の実装の結果,Emterpreter を使わず,全ての Vim のコードが Wasm にコンパイルされてブラウザ上で動かすことができるようになりました.

メンテ性の向上

emterpretify する関数のリストを持つ必要がなくなり,メンテがしやすくなりました.以前はクラッシュする関数を見つけてはリストを更新する作業があり,upstream に追従した時に削除された関数や追加された関数を反映させないといけなかったのが解消されました

プログラムの安定性が向上

特に,たびたび報告されていた JavaScript 側から emterpretify された C の関数を呼ぶ時にアサーションエラーや不正メモリアクセスエラーがなくなりました.また,文字列(char *)を C 側に渡そうとするとクラッシュする問題が解消されました

バイナリサイズの削減

emterpretify のバイトコードが無くなり全て Wasm になったのと,Emterpreter 本体のコードが必要なくなったので,トータルのバイナリサイズがかなり小さくなりました.gzip 圧縮で,

  • Before: 1025.49 KB
  • After: 453.308 KB

と半分以下になりました.

ビルド速度

Emterpreter のための変換を行わなくて良くなったため,emcc によるビルド速度が向上しました.

  • Before:
emcc  48.25s user 3.55s system 309% cpu 16.745 total
  • After:
emcc  4.90s user 0.51s system 112% cpu 4.792 total

数倍速くなってますね.

ユーザとのインタラクション

Vim 本体はワーカスレッドで実行されているため,メインスレッドで処理されるユーザの入力処理(スクロールやキー入力など)を Vim がブロックしないようになりました.

実行速度

以前はユーザの入力を emscripten_sleep() で 10ms ごと polling しながら待っていたため,最大で 10ms の遅延がありました. また,10ms ごとに Emterpreter の suspend/resume が起きていたため,おそらくそれも実行を遅くしていたと思います(未確認).

10ms ごとのポーリングをやめ,ユーザからの入力を Atomics.wait() で待つため,メインスレッドから Atomics.notify() されてからの遅延は無くなり,少なくともその分速く実行できるようになりました.(比べないと分からないレベルですが…)

悪くなった点

Chrome (と Chromium ベースのブラウザ)以外ではデフォルトで動かなくなりました.これは Spectre 脆弱性によって SharedArrayBufferAtomics API が一時的に多くのブラウザで無効にされているためです.FirefoxSafari ではブラウザのフィーチャフラグを立てることで動かすことができます.

今後

  • 今は Web Worker を使っていますが,Wasm のマルチスレッド対応が使えるかもしれないと思っています.同時に i32.atomic.wait などのアトミック命令も追加されているので,JavaScriptAtomics API の代わりに使うことで,Wasm と JavaScript 間のインタラクションを無くしてワーカスレッド側の入力待ち処理を Wasm で完結させられるかもしれません
  • 今は <canvas/> をメインスレッド側で描画していますが,ワーカスレッド側でキャンバスを描画してメインスレッド側に転送する OffscreenCanvas を使って描画処理をワーカスレッド側に持ってくることができるかもしれません
  • upstream の追従作業
  • 'small' や 'normal' feature でビルドできるようにする
  • ファイル保存や読み込み,クリップボード対応など

感想

最初に Atomics API を見た時は JavaScript で非同期処理ライブラリをつくるような人以外は用は無さそうだなと正直思っていたんですが,まさか自分が使うことになるとは思いませんでした…

Emterpreter の動作が不安定すぎる問題で長らく詰まってしまっていたのですが,ようやく次のステップに進めそうで良かったです.

Neovim のフロートウィンドウ機能を使って git-messenger.vim をつくりなおした

git-messenger.vim は,カーソル下の行のコミット情報を表示する Vim プラグインです.

github.com

他所のプロジェクトのコードや OSS のコードを読んでいると,なぜこうなってるんだろう?と思うことがよくあります.コミットメッセージをまともに書いているプロジェクトでは,その部分の変更を加えたコミットメッセージに答えがあることがあるのですが,毎回 git blame で対象のコミットを引っ張ってきて git show で読むのは結構大変です.

git-messenger.vim では,その負担を軽減するために,(1) カーソルがいる行にコミット情報を git から引っ張ってきて (2) 良い感じに表示する という機能を提供します.

もう6年も前につくったプラグインなのですが,Vim の制約上いくつかの問題があり,syohex さんの Emacs port に比べて良いものになりませんでした.Vim のバルーン(マウスを一定時間バッファ上に置いておくと出せるツールチップ)が GUI にしか対応していないのが主な理由でした.

先日,Neovim にフロートウィンドウ機能が入り,CUI でもツールチップのような UI が簡単に使えるようになったので,git-messenger.vimフルスクラッチでつくりなおしました.Neovim のフロートウィンドウについては後の章でまとめました.

git-messenger.vim

インストール

すべて Vim script で実装されているので,他のプラグインと同様に rhysd/git-messenger.vim リポジトリをインストールするだけで OK です.あともちろん git コマンドも要ります.

コマンドの実行は以前は system() を使っていたのですが,大きいリポジトリでもユーザの入力をブロックしないように job を使って書いています.なので,Neovim または Vim (8 以降)が必要です.

また,フロートウィンドウは最近 Neovim の master に入ったところなのでまだ安定版としてはリリースされていません.開発版の 0.4.0-dev を使う必要があります.例えば macOS であれば brew install neovim --HEAD で入ります.

使い方

:GitMessenger もしくはデフォルトでマップされている <Leader>gm を入力すると,カーソルしたのコミット情報がポップアップウィンドウで表示されます.

  • Commit hash
  • Author, Committer
  • Summary (コミットメッセージの1行目)
  • Body (コミットメッセージの2行目以降)

ポップアップウィンドウは Neovim 0.4.0 以降では前述のフロートウィンドウで,それ以外ではプレビューウィンドウで実装されています.その後カーソルを動かすとポップアップは自動で閉じます.

https://github.com/rhysd/ss/blob/master/git-messenger.vim/demo.gif?raw=true

基本的にはこれだけですが,ポップアップを開いたあとにカーソルを動かさずそのままもう一度 :GitMessenger コマンドか <Leader>gm を入力するとポップアップウィンドウの中にカーソルを移動できます.コミットメッセージが長すぎて全部表示できなかった時のスクロールやメッセージのクリップボードへのコピーなどができます.

ウィンドウ内ではいくつかローカルなマッピングが定義されており,o でより古いコミットを手繰ることができます.直近のコミットにほしい情報が書いてあるとは限らない(例えばフォーマッタでの整形が挟まったり,別のリファクタリングが挟まったりなど)ので,そういうときはより古いコミットのメッセージにほしい情報があることがあります.さらに O で新しいコミットに戻ったり,q でウィンドウを閉じたりできます(? でヘルプ)

https://github.com/rhysd/ss/blob/master/git-messenger.vim/history.gif?raw=true

カスタマイズ

  • デフォルトのマッピング <Leader>gm が気に入らない場合は g:git_messenger_no_default_mappingsv:true をセットして <Plug>(git-messenger) をマップすれば OK です
  • 他にもいくつか <Plug> マップが定義されています
  • いくつかの挙動はグローバル変数で制御できるようになっています
  • Neovim のみポップアップウィンドウ内のハイライトをカスタマイズできます.デフォルトの色合いがお使いのカラースキームに合わないときは自分で色をカスタマイズできます.Neovim のみなのはウィンドウローカルにハイライト色を変更できる winhighlight というオプションを Neovim だけが持っているためです

詳しくはリポジトリREADME.md:help git-messenger で確認できます.

Neovim のフロートウィンドウについて

Vim のウィンドウは分割することでタイル型に配置されるレイアウトしか対応しておらず,ウィンドウが重なるようなレイアウトは(無理矢理バッファを書き換えてそれっぽく見せるようなことをしない限り)実現できませんでした.

そこで Neovim では CSSposition: relativeposition: absolute のように位置基準でウィンドウの重なりを許すウィンドウレイアウトを実装しました.

何が良いのか

この機能の設計で特に優れていると感じる点は,レイアウト以外はほぼ完全に普通の Vim のウィンドウと同じところです.

  • CUI で使える
  • プラグイン開発者はウィンドウの開き方だけ分かれば,あとは普通の Vim のウィンドウと同様に扱える
  • ウィンドウ内のコンテンツは普通の Vim のバッファなので,setline() などで自由に追加・変更・削除できる
  • filetype をセットしたり,自由にハイライトできる
  • ウィンドウを開く以外はバルーンのような専用の API が要らない

この機能により,プラグイン開発者はウィンドウの上にオーバーレイするような UI を変なハックをしたり妥協することなく実装することができます. ぱっと思いつくのは

  • 補完ウィンドウの自作
  • ドキュメント情報表示ツールチップ
  • fuzzy finder の選択ウィンドウ(VS Code のような)
  • ターミナル表示

などに使えそうです.

使い方

フロートウィンドウを開くのには nvim_open_win(),サイズや配置を変えるのには nvim_win_config() を使います.

" 開くウィンドウの幅
let width = 40

" 開くウィンドウの高さ
let height = 10

" ウィンドウを開いたあと,カーソルをそのウィンドウ内に移動するか
let enter = v:true

" カーソルを :wincmd やマウスでウィンドウ内に移動できるか
let focusable = v:true

" 何に対して相対的にウィンドウの配置位置を決めるか
"   - "editor": エディタのスクリーンに対して.スクリーン上の絶対座標で指定
"   - "win": 現在のウィンドウ位置に対して.これを使う場合はオプションに 'win' というキーで別途対象ウィンドウのウィンドウ ID を指定する
"   - "cursor": カーソル位置に対して
let relative = 'cursor'

" 基準となるウィンドウの角を四隅のどこにするかを指定します(デフォルト "NW"
"   - "NW": 左上
"   - "NE": 右上
"   - "SE": 右下
"   - "SW": 左下
let anchor = 'NW'

" 'relative' で指定した位置に対する相対的なオフセット
let row = 1
let col = 0

" Neovim 内ではなく,GUI フロントエンド側にウィンドウを出すよう依頼するか
let external = v:false

" 現在のバッファをフロートウィンドウで開く
" カーソルのすぐ下に 40x10 のウィンドウが開かれる
let win_id = nvim_open_win(bufnr('%'), enter, width, height, {
    \   'relative': relative,
    \   'anchor': anchor,
    \   'row': row,
    \   'col': col,
    \   'external': external,
    \})

" 新しいバッファを開いたり
enew

" 普通に setline() を使ってコンテンツをセットしたり
call setline('.', ['hello', 'world!'])

" filetype を指定してハイライトを定義したり
set filetype=ruby bufhidden=wipe nomodified buftype=nofile

" フロートウィンドウを別のハイライトで描画したり
set winhighlight=Normal:MyNormal,NormalNC:MyNormalNC

" nvim_win_config() でウィンドウのレイアウトをやり直せる
call nvim_win_config(win_id, 60, 30, {'relative': 'editor', 'row': 10, 'col': 10})

フロートウィンドウにはボーダーが無いので,デフォルトのままではフロートウィンドウと下のウィンドウの境界がわかりません.なので特に最後の既存のウィンドウと別のハイライトで背景色を描画するのは重要です.また,フロートウィンドウ内で新しいバッファを開いた際はウィンドウを閉じると中身も自動で開放されるよう bufhidden=wipe を指定しておきます.

nvim_win_config() でウィンドウのレイアウトをやりなおすことができるので,これを使ってウィンドウのサイズ変更や位置変更をします.

ちなみに rowcolwidthheight がエディタのスクリーンからはみ出してしまってもエラーになりません.Neovim はなるべくウィンドウをスクリーン内に収めるように描画します.git-messenger.vim では現在のカーソル位置からポップアップを出すのに十分な幅・高さがあるかを確認し,カーソルの上下左右にフロートウィンドウを出し分けます.

Rust 公式 linter の clippy に新しいルールを実装した

Rust 公式の linter,clippy に新しいルールを足すプルリクを出してマージされた時のメモです.

github.com

dbg! マクロ

Rust 1.32 で dbg! というマクロが追加されました.

これは値を1つ引数にとってその値を返すマクロで,受け取った値とソースコード上での位置を print します.

fn factorial(n: u32) -> u32 {
    if dbg!(n <= 1) {
        dbg!(1)
    } else {
        dbg!(n * factorial(n - 1))
    }
}

dbg!(factorial(4));

名前の通り,いわゆる print debug 用途のマクロなので,デバッグが終わってリポジトリに commit する前にはコードから dbg! マクロを削除しておくのがベストプラクティスです. 公式ドキュメントにも

Note that the macro is intended as a debugging tool and therefore you should avoid having uses of it in version control for longer periods.

と書いてあります.

ちょっとしたデバッグに非常に便利なのですが,値を move で受け取ってその値を返すだけなのでうっかり消し忘れるとテストを壊すこともなく気付けないケースがあることに気付きました.

dbg_macro ルール

そこで,コード内の dbg! マクロを検出できる dbg_macro ルールをつくりました.

#![deny(clippy::dbg_macro)]

fn main() {
    dbg!(42);
}

のようなコードに対して,

cargo clippy -- -W clippy::dbg_macro

のように実行すると

error: `dbg!` macro is intended as a debugging tool
 --> foo.rs:4:5
  |
4 |     dbg!(42);
  |     ^^^^^^^^
  |
help: ensure to avoid having uses of it in version control
  |
4 |     42;
  |     ^^

のように警告を出します.

デフォルトで有効になっているとデバッグ中にエディタが警告しまくってうるさいので,restriction カテゴリでデフォルトは無効になっています. restriction カテゴリは README には載っていませんが,「unimplemented! を使わない」や今回のルールのような特定の場面(production 前のチェックなど)で有効なルールや,「mem::forgetDrop を実装した型で使わない」といった万人向けではないキツめのルールが登録されています.

clippy のリポジトリを restriction で検索するとざっと見渡すことができます.

新しいルールの提案と追加

まずは issue で「こういうルールあると良いと思う」と提案し,特に反対もなくメイン開発者の upvote も付きました.'good first issue'(初めて contribute する人が取り組むと良い易しい issue)のラベルがついたので,自分で実装してみて,プルリクを出しました.あとは一般的なプルリクと同じで何度かレビューしてもらって OK が出てマージという流れでした.

clippy の実装

基本的にはまず CONTRIBUTING.md を読めば大体分かるようになっており,必読です.

ディレクトリ構成

  • src/*: clippy のコマンドライン部分とドライバ(実行の前段部分)の実装のみ
  • clippy_lints/: linter の各ルールの実装
    • clippy_lints/lib.rs: ルールすべてを import して登録しているところ
    • clippy_lints/*.rs: ルールの実装(lib.rs 以外)
    • clippy_lints/utils: ルールの実装に使うあれこれ
  • clippy_dev/: 開発時に使うツール群(ルールリストの更新など)
  • clippy_dummy/: テスト向け
  • tests/: pass を走らせる部分のテストや各ルールを適用して正しく警告が出るかどうかのテスト

新しいルールを足すには基本的に clippy_lints crate および tests/ に追加・修正を加えることになります.

Early Pass と Later Pass

clippy のルールは構文木syntax::ast)もしくは HIR(rustc::hir)に対する pass として実装します.HIR はパースした構文木コンパイラ向けの情報を足したもので,Rust RFC 1191に詳しく書いてあります.

自前の pass を実装して登録しておくと,clippy が構文木または HIR をトラバースしたときに各ノードにその pass を適用して,pass に実装したコールバックメソッドがマッチすると適宜呼び出されます.

構文木にマッチさせる pass を early pass,HIR にマッチさせる pass を later pass といい,下記のような順序で適用されます.

  1. parse して構文木を得る
  2. 構文木をトラバースして early pass を適用
  3. HIR への変換と型チェック
  4. HIR をトラバースして later pass を適用

early pass は構文木のみ,later pass は HIR,型情報,コンパイラコンフィグ(cfg!)などにアクセスできます.

構文木をパースしたり pass を適用するなどの処理は rustc コンパイラ本体に linter 向けの汎用的な API があり(rust/src/librustc/lint),rustc コンパイラ自体の unused 警告などもこれを利用しています(rust/src/librustc_lint).rust-clippy リポジトリではそれを利用してルール集とコマンドライン部分のみを実装しています.なので,clippy の実装時には rustcrustc_*syntax といった rustc コンパイラ API を知る必要があります.

具体的に early pass と later pass でどのパスを通しているかは rust/src/librustc/lint/mod.rs に実装があります.

https://github.com/rust-lang/rust/blob/master/src/librustc/lint/mod.rs

新しい Early Pass を追加する

まず clippy_lints/src 内にルール向けのソースファイルを作成します.ここでは clippy_lints/src/my_rule.rs としたとします.

declare_clippy_lint! {
    pub MY_RULE,
    style,
    "my rule for clippy"
}

第1引数が linter インスタンス,2引数目が perf, correctness, complexity, style などのカテゴリです. declare_clippy_lintrustc::lint が提供する declare_tool_lint! マクロの薄い wrapper になっていて,カテゴリを見てデフォルトの警告レベルをセットするなどの設定を行っています.

次に pass オブジェクトを定義します.

#[derive(Copy, Clone, Debug)]
pub struct MyRule;

impl LintPass for MyRule {
    fn get_lints(&self) -> LintArray {
        lint_array!(MY_RULE)
    }

    fn name(&self) -> &'static str {
        "MyRule"
    }
}

impl EarlyLintPass for MyRule {
    fn check_expr(&mut self, cx: &EarlyContext<'_>, e: &syntax::Expr) {
        // ここにチェック処理を実装
    }
}

rustc::lint::LintPass の実装ではその pass の共通情報を記述します.1つの pass で複数のルールをチェックすることができ,rustc::lint::lint_array! マクロで指定します.

early pass の実装本体は rustc::lint::EarlyLintPass を実装することで実装します.もともと EarlyLintPass にある check_* メソッドをオーバーライドすると,そのメソッドが対応する構文木ノードを visit したときに適用されます.rustc::lint::EarlyContext を通じて lint のコンテキスト情報を受け取ることができます. 上記では式に対する処理 check_expr をオーバーライドしていますが,一覧はrustc のソース内で確認できます.

構文木のパターンがチェックしたいパターンにマッチしているかをチェックし必要な情報を抜き出すには matchif let などのパターンマッチをネストさせまくることになるので,if_chain::if_chain! マクロが便利です. 例えば let x = EXPR; x を取り出す処理はこんな感じに書けます.

if_chain! {
    if let Some(retexpr) = it.next_back();
    if let ast::StmtKind::Expr(ref retexpr) = retexpr.node;
    if let Some(stmt) = it.next_back();
    if let ast::StmtKind::Local(ref local) = stmt.node;
    if let Some(ref initexpr) = local.init;
    if let ast::PatKind::Ident(_, ident, _) = local.pat.node;
    if let ast::ExprKind::Path(_, ref path) = retexpr.node;
    if !in_external_macro(cx.sess(), initexpr.span);
    then {
        // ここでマッチしたときの処理
    }
}

警告すべきコードをパターンマッチで見つけたら警告を出す処理を書きます.警告を出す方法は clippy_lints/src/utils に便利関数群があり,

  • 警告メッセージのみ: span_lint(), span_lint_node()
  • 警告メッセージとヘルプ: span_help_and_lint()
  • 警告メッセージとノート: span_note_and_lint()
  • 警告メッセージとヘルプと修正提案: span_lint_and_sugg()

などが使えます.span とはソースコード片のことで,開始位置・終了位置・コンテキスト情報がエンコードされた u32 の値で,ソースコードのうち警告を出す部分を指定するのに使えます.各構文木ノードは syntax::Spanned で wrap されて check_* メソッドに渡されるので,ノードに対応する span は簡単に取得できます.

span_lint_and_sugg() ではソースコードの修正提案を String で渡せます.rustc_errors::Applicability::MachineApplicable を指定することで,自動修正機能(おそらく rustfix?)で自動修正できます.渡した文字列で span で指定した範囲を置き換えます.span は utils::snippet() を使ってコード片(スニペット)として文字列化することができ,修正提案のための文字列が楽につくれるケースがあります.

最後に作成したルールを clippy に登録します.clippy_lints/src/lib.rs 内で下記のように対応するカテゴリに linter 情報を追加します.

// ...
pub mod my_rule;

// ...

reg.register_lint_group("clippy::style", Some("clippy_style"), vec![
    // ...
    my_rule::MY_RULE
    // ...
]);

// ...

最後につくった pass を登録するのですが、early pass の場合はここで注意が必要です。

// マクロ展開後で OK な pass は普通に early pass として登録
pub fn register_plugins(reg: &mut rustc_plugin::Registry<'_>, conf: &Conf) {
    // ...
    reg.register_early_lint_pass(box reference::Pass);
    // ...
}

// マクロに対する lint など,マクロの展開前でないと動かない pass はこっちに登録
pub fn register_pre_expansion_lints(...) {
    // ...
    store.register_pre_expansion_pass(Some(session), true, false, box dbg_macro::Pass);
}

マクロの構文木ノードにマッチさせる check_mac() などを使う場合は後者の register_pre_expansion_pass 側に pass を登録する必要があります. early pass の適用順序は

  1. pre_expansion_pass
  2. マクロの展開処理
  3. early_lint_pass

となっています。

新しい Later Pass を追加する

early pass と基本的には同じで,rustc::lint::EarlyLintPass<'a> の代わりに rustc::lint::LateLintPass<'a, 'tcx> を実装します.tcx は型情報のコンテキストの寿命を表しているようです.

impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule {
    fn check_expr(&mut self, cx: &LateContext<'a, 'tcx>, expr: &'tcx rustc::hir::Expr) {
        // ルールの実装
    }
}

オーバーライドできるメソッドはrustc のソース内で確認できます.

early pass と違い,check_expr などのメソッドに rustc::hir::* が渡され,型情報のコンテキストの寿命 tcx が制約として付きます. 型情報には LateContexttcx フィールドからアクセスできます.(e.g. cx.tcx.fn_sigコンパイラコンフィグ(cfg)は cx.tcx.sess.parse_sess.config にある rustc::session::config::Config な値にアクセスすることでチェックできるようです.構造体のサイズなどターゲット依存で変わるものをチェックする際に使われます.

また,later pass では call flow graph も rustc::cfg::CFG を使って取得できます.

impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule {
    fn check_fn(
        &mut self,
        cx: &LateContext<'a, 'tcx>,
        kind: intravisit::FnKind<'tcx>,
        decl: &'tcx FnDecl,
        body: &'tcx Body,
        span: Span,
        node_id: NodeId,
    ) {
        // call flow graph を生成
        let cfg = CFG::new(cx.tcx, body);
        // ...
    }
}

例えば cyclomatic complexity を計算する際に使われているようです.

pass の登録について,later pass の場合はマクロに対するテストかどうかで pass の登録先を分ける必要はありません.ある HIR ノードがマクロ展開結果生成されたものかは,そのノードの span を使って clippy_lints/src/utilsutils::is_expn_of で分かります.

テストの実装

テストは UI テスト(問題があるコードに実装したルールを適用して,結果として期待する警告の出力がコマンドライン出力として得られるかどうか)のみで行います.

CONTRIBUTING.md によると,実装前にまずは警告を出してほしいコードを tests/ui/my_rule.rs に書き,TESTNAME=ui/my_rule cargo test --test compile-test と実行すると,そのコードの構文木にマッチする linter 実装コードの雛形をつくってくれるらしいのですが,僕の場合は dbg! が展開された後の構文木にマッチするようなコードが吐かれてしまったため使えませんでした.

linter の実装が終わったら CLIPPY_TESTS=true cargo run --bin clippy-driver -- -L ./target/debug tests/ui/my_rule.rs で警告が意図通り出力されていることを確認し,その結果を多少整形して tests/ui/my_rule.stderr として保存します.最後に TESTNAME=ui/my_rule cargo test --test compile-testmy_rule 向けのテストを実行して結果を確認できます.

dbg_macro ルールの実装

今回実装したルールは dbg!(expr) マクロの使用箇所を検知して警告として表示し,dbg!(expr) の代わりに expr を修正提案として表示する小さなものです.

rustc::lint::EarlyLintPass として実装し,pre-expansion pass として登録します.マクロ呼出しにマッチする EarlyLintPass::check_mac メソッドをオーバーライドして実装し,渡された ast::Mac のパスが "dbg" かどうかチェックするだけです.

impl EarlyLintPass for Pass {
    fn check_mac(&mut self, cx: &EarlyContext<'_>, mac: &ast::Mac) {
        if mac.node.path == "dbg" {
            // 警告を表示
        }
    }
}

ast::Macsyntax::Spaned で wrap された型なので mac.span でマクロ呼出しのソースコード上での範囲(span)は簡単に取得できます.

あとは修正提案用の文字列を生成できれば終わりです. マクロは引数にトークン列を取るので,展開前は引数のトークン列のみが構文木ノードに格納されています(mac.node.tts). dbg!(expr) マクロの中身 expr の span が取得できれば,その範囲を utils::snippet でコード片化できます.

引数の span を取る方法は

  1. トークン列の最初のトークンの span の始まり位置とトークン列の最後のトークンの span の終わり位置からトークン列全体の範囲を表す span を生成する
  2. dbg!() は仕様として1つの値を取るので,トークン列を syntax::ast::Expr にパースした結果のノードから span を取り出す
  3. early pass をやめ,later pass にして dbg!() の引数部分から展開された HIR ノードを特定して span を取り出す

のざっくり3通りが考えられます.3. は大変そうだし 2. は式としてパースできなかった場合のエラーハンドリングやパースのコストがあるので,1. が一番良さそうです.

Rust のトークン列は syntax::tokenstream::TokenStream という型で表され,syntax::tokenstream::TokenTree の列で表現されています.どうやらこれらは proc macro の実装などで使う proc_macro::TokenStreamproc_macro::TokenTree とは別物のようです.

TokenStream.trees() で(clone した)TokenTreeイテレータを返せるので,そこから最初のトークンツリーと最後のトークンツリーを取得し,Span::to を使って最初のトークンツリーの span から最後のトークンツリーの span までの範囲を表す span を新たに生成します.

let mut cursor = mac.node.tts.trees();
let first = cursor.next().unwrap();
let last = cursor.last().unwrap_or(first);
let entire_span = first.span().to(last.span());

最後にスニペット文字列を span から生成すれば修正提案に使う文字列が生成できます.

let snip = utils::snippet_opt(cx, entire_span).unwrap().to_string();

最後に clippy_devcargo fmt を実行しておきます.clippy_devclippy_lints/src/lib.rs で登録されている pass をチェックしたり,CHANGELOG.md と README.md を更新したりしてくれます.

cd clippy_dev/
cargo run -- update_lints
cargo +nightly fmt --all

まとめ

dbg_macro ルールを Rust 公式 linter の clippy に追加しました.次の clippy のリリース時に入ると思うので,良ければ CI や Git の pre-commit もしくは pre-push フックなどで

cargo clippy -- -W clippy::dbg_macro

と実行して使ってみてください.

また clippy に新しいルールを追加する方法についても簡単に紹介しました.rustcrustc_*syntax あたりの nightly でしか使えない rustc コンパイラ API を使ったり読んだりする必要はなかなか無いので,実際使ったのはごく一部ですが良い機会だったと思います.ちなみにこれらの crate は rustc コンパイラの内部実装で頻繁に変更されるので,上記の紹介した内容もいずれ正しくなくなる可能性が高いです.