Rust プロジェクトで自動で Git hook をセットできる cargo-husky をつくった

npm でよく使っている husky というツールがあります.これは npm install などを hook して Git hook を自動でセットしてくれるツールで,リモートにプッシュする前のチェックを強制してくれます.もちろん CI でもテストを回しているのですが,プッシュ前にもチェックすることによりケアレスミスに早く気付くことができます.

Rust でもこの仕組みを使いたかったのですが,そういうツールが無かった&つくれそうだったので cargo-husky というツールをつくりました.

github.com

基本的な使い方

cargo-husky パッケージを Cargo.tomldev-dependencies に追加して cargo test を実行するだけです.

[dev-dependencies]
cargo-husky = "1"
$ cargo test

cargo はそのパッケージが必要になった段階でパッケージをダウンロードします. dev-dependencies なので cargo build ではなく cargo test を実行する必要があります.大抵開発中にテストを一度は実行すると思うので,意識して cargo test を呼ぶ必要はなく,知らぬ間に Git hook がセットされているという意図です.

.git/hooks/ を見ると,こんな感じの pre-push スクリプトが置かれているはずです.

#!/bin/sh
#
# This hook was set by cargo-husky v1.0.0: https://github.com/rhysd/cargo-husky#readme
# Generated by script /path/to/cargo-husky/build.rs
# Output at /path/to/target/debug/build/cargo-husky-xxxxxx/out
#

set -e

echo '+cargo test'
cargo test

これによって git push 前に cargo test が自動で実行されるようになります.

フックをカスタマイズしたいとき

デフォルトでは pre-pushcargo test を実行するフックが置かれますが,cargo-husky パッケージの feature flag を使ってこの挙動を変えることができます. cargo book にもある通り,feature flag を指定するには [dev-dependencies.cargo-husky] というセクションをつくります.

[dev-dependencies.cargo-husky]
version = "1"
default-features = false # デフォルトの挙動を無効化する
features = ["precommit-hook", "run-cargo-test", "run-cargo-clippy"]

features の配列に指定している値が有効にする機能です.この例では「コミット前に実行する(precommit-hook)」,「cargo test を実行する(run-cargo-test)」,「cargo clippy を実行する(run-cargo-clippy)」機能を有効にすることにより,毎コミット前に cargo testcargo clippy を実行する git hook pre-commit が生成されます.

利用可能な feature flag は下記の通りです.

feature flag 意味 デフォルト値
prepush-hook pre-push hook を生成 有効
precommit-hook pre-commit hook を生成 無効
postmerge-hook post-merge hook を生成 無効
run-cargo-test hook で cargo test を実行 有効
run-cargo-clippy hook で cargo clippy を実行 無効
user-hooks 次の章を参照 無効

すでに生成された hook がある場合は,一旦 .git/hooks/ 以下を削除してから cargo test を実行してパッケージを再コンパイルしてください.

さらにフックをカスタマイズしたいとき

feature flag は固定値しか記述できないため,「自前で用意したスクリプトを実行したい」「cargo に特定のオプションを渡して実行したい」といったカスタマイズはできません.

さらなるカスタマイズを可能にするために,user-hooks という feature flag が用意されています.

[dev-dependencies.cargo-husky]
version = "1"
default-features = false
features = ["user-hooks"]

この flag が有効になっていると,cargo-husky は自前で git hook を生成せず,リポジトリ直下に置かれている .cargo-husky というディレクトリを探し,その中のスクリプトを代わりに .git/hooks に配置します.

your-repository/
├── .git
└── .cargo-husky
    └── hooks
        ├── post-merge
        └── pre-commit

例えばディレクトリ構成がこのようになっているとき, pre-commit および post-merge.git/hooks 以下に置かれる対象になります. cargo-husky は .git/hooksスクリプトを置く際,ファイルの先頭にメタ情報(cargo-husky のバージョンなど)をヘッダとして挿入します. その際,# 始まりを行コメントと想定しているため,それに準じた言語でスクリプトを書く必要があります.また,実行可能属性がついていないファイルはスクリプトとして認識せず無視します.なのでスクリプトには実行可能属性を付けておいてください(chmod +x).これは意図しないファイルが .git/hooks に置かれてしまわないようにするためです.

実装

husky は npm の install フックなどで Git hook をセットしますが,cargo にはそういった仕組みはありません. 代わりに cargo の build script 機能を濫用することで cargo-husky は実装されています. 本来は外部ライブラリのビルドなどを設定するためのbuild.rsでフックをセットする処理を行っています.

cargo がビルド時に自動でセットする $OUT_DIR に設定されたディレクトリを元にプロジェクトの .git ディレクトリを特定するので,万一 $OUT_DIRリポジトリの外になっているような特殊なケースでは動きません.

cargo-husky は Linux/macOS/Windows で stable チャンネルのツールチェーンを使ってテストされており(LinuxmacOSTravis CI,Windows は Appveyor),MIT ライセンスで配布されています.

Vim に WebAssembly のテキストフォーマットのサポートを入れた

VimWebAssembly のテキストフォーマット (wast) の対応を入れ,同時に filetype=wast で使われるファイルのメンテナになりました.

Wasm のテキストフォーマットとは

WebAssembly にはバイナリ形式とテキスト形式の2つのフォーマットがあります.Wasm はネットワークを介して配布される前提のため,サイズの小さいバイナリ形式のほうが良いですが,デバッグなどで処理を追いたい人間にとってはテキストフォーマットも必要になるためです.

emscriptenデバッグオプションをつけてコンパイルすると,生成物 .wasm のほかにテキストフォーマットの .wast およびそのソースマップを生成してくれます.

こんな感じのS式です

f:id:rhysd:20180801085232p:plain:w424

contribution の流れ

0. Vim プラグインとして実装

まだブラウザが Wasm を初めてサポートし始めた頃,その頃の binaryen の出力を眺めるときにハイライトが無いと不便なので,まずはたたき台として vim-wasm をつくりました.

github.com

今年の6月に入って WebAssembly の仕様をざっくり眺めて vim.wasm をつくりました.再び emscripten の出力を見る機会があり,以前つくったものが大分間違っていたことが分かったので,仕様に従って vim-wasm をそれに従って大幅に修正しました.

1. Vim にプルリクを出す

WebAssembly は主要な各ブラウザが対応しており,ウェブ標準として定義されているので,テキストフォーマットの対応が Vim 本家に入っていると有意義だと考えました. vim-wasm が良い感じに .wast ファイルをサポートできていることが確認できたので,Vim に取り込んでもらうことを提案します.

Vimvim_dev にパッチを送るか,vim/vim にプルリクを作成することでレビューを依頼できます.通常,runtime/syntaxruntime/indent といった各 filetype 対応のファイルにはメンテナがそれぞれついているので,まずはそちらに依頼すべきですが,今回は新規追加なので直接 Bram にレビューしてもらいました.

github.com

2. master に取り込んでもらう

レビューで OK が出ると,Bram が master ブランチに取り込みます. 今回は runtime/ 以下のファイル追加なので,他の runtime/ 向けの変更もまとめて1つのパッチが作成され,master ブランチに追加されました.

github.com

3. filetype=wast のメンテナになりました

先程書いたように,各 filetype のサポートはそれぞれにメンテナがいます.

そんなわけで,僕も vim/runtime/{syntax,indent,ftplugin}/wast.vim のメンテナになりました.開発は引き続き vim-wasm のほうでやっていくので,何か問題を発見された際は,Vimリポジトリvim_dev ではなく,まずはvim-wasm のほうに issue やプルリクをつくっていただけると助かります.

Vim を WebAssembly に移植した

久々のブログです.

6月ぐらいにWebAssembly の仕様をざっくり読んだので,なんか WebAssembly でやりたいなと思って,Vim を WebAssembly に移植してブラウザで動くようにしてみました,という話です.

github.com

多分実物を見ていただくのが一番早いので,下記のリンクにアクセスしてみてください.

デモページはこちら(下記の注意事項を先にお読みください)

  • 注意
    • デスクトップ版の ChromeFirefoxSafari か Edge を使ってください.どうやら macOS では Safari が一番動きが良いです.
    • デモページは全部で1MBほどのリソースを fetch します.モバイルネットワークなどからアクセスする場合はお気をつけください.
    • keydown でキー入力を取っているので,キー入力を横取りするブラウザ拡張などが有効になっているとうまく動かないかもしれません.その場合はプライベートブラウジング機能などを使ってアクセスしてみてください.
    • 今のところ有効になっているのは 'tiny' features のみです.そのため多くの制限があります.あと描画などにまだいくつかバグがある気がします
    • 入力しても何も起きない時は,一度画面の何処かをクリックしてみてください(Vim が描画されている <canvas/> がフォーカスを失っている可能性があります)
    • Mac でしかまだ試せていないので,他の OS で問題があれば教えてください…

こんな感じにページ全体に Vim の画面が表示されるはずです.これは実際にあなたのブラウザの上で動いている Vim が表示している画面なので,キー入力で自由に操作できます.

f:id:rhysd:20180709053707p:plain

今後について

まだ「とりあえず動いた」段階なので,次のようにして開発を進めていくつもりです

  • 'small' features を有効にしてビルドできるようにする.実行速度に問題が無ければ 'normal' も動かせるようにしたい
  • ブロッキングになっているイベントループを非同期に書き換えて Emterpreter(後述)を使わなくて良くする(かなりチャレンジング)
  • マウスのサポート
  • マルチバイト文字や IME のサポート
  • クリップボードのサポート
  • .vimrc をローカルストレージに保存できるようにする
  • WebComponents として wrap して,npm パッケージや ESM で配布できるようにする
  • PWA としてローカルのデスクトップアプリのように扱うことができるようにする
  • :write で保存ダイアログ経由でローカルに保存できるようにする

どうやって実装したのか

emscripten と Binaryen

そもそも WebAssembly が何かご存じない方は,先に MDN の日本語解説ページを読んでいただけると,技術の概要が分かると思います.

C や C++LLVM IR のコードをブラウザで動かすためのコンパイラツールチェーンとしてemscriptenというものがあります.emscripten は C をコンパイルするためのコンパイラやリンカ,libc などの標準ライブラリなどから成ります. emscripten は以前は C などのコードを asm.jsコンパイルしていたのですが,今ではデフォルトで WebAssembly 形式へコンパイルするようになっており,それには binaryen というコンパイラバックエンドが使われています.

今回は emscripten と binaryen を使って C で実装されている Vim のコードを WebAssembly にコンパイルできるようにしていきます.

ちなみに Mac では brew install emscripten binaryen で一発でインストールできます.

基本的な方針

もちろん,単にコンパイラgcc から emccemscripten が提供するコンパイラgcc インターフェース)に置き換えるだけではうまくいきません.

まず,Vim は ncurses などの何らかの端末ライブラリを用いて CUI の画面を描画しますが,emscripten はどの端末ライブラリもサポートしていません.ncurses を WebAssembly に移植する(描画は <canvas/> などに任せる)のも理論的には可能ですが,膨大な作業量が必要です.

そこで,今回は WebAssembly 向けの UI を (gui_gtkgui_w32, gui_mac のような) GUI gui_wasm として実装し,gui_wasm が有効になった場合には常に GUI 版を使うことにします.これによって端末版の Vim が起動されるパスはなくなるので,ncurses は不要になります.

では,VimGUI 版はどう実装されているかが次の問題になります.これは他の GUI 実装を参考に,gui.h を眺めれば何となく分かります. gui_*.c の中に実装されたいくつかの関数が Vim のコアから呼び出されるので,それらを実装すれば良いです.Vim のコア部分から呼び出される GUI が実装すべき関数は大きく2種類あり,

  1. ユーザからのインプットを待つ系(gui_mch_wait_for_chars()gui_mch_update()
  2. 描画系(特定の row/column に文字を描画したり,画面をスクロールするなど)

に分けられます.なので,WebAssembly でこれらの GUI 実装関数たちをどう実装するかを考えれば良いです.具体的には 1. をユーザからのキー入力を行う DOM イベントを待つことで実装し,2. をブラウザのページ内に置いた <canvas/> などに描画する形で実装すれば良さそうです.

ですが,ここで1つ問題があります.WebAssembly は(少なくとも今は)DOM に直接アクセスするインターフェースを持っていません.なので,上記を C のレイヤーでは直接実装できません. この問題を解決するため,JavaScript で書いたランタイムを用意します.C のレイヤーから JavaScript に描画イベントを送り,JavaScript 側で描画するようにします.また,DOM のキー入力イベントを JavaScript で拾い,それを C のレイヤーに送ることで伝えるようにします. C から JavaScript のコードを呼んだり,逆に JavaScript から C のコードを呼ぶには emscripten の API を利用することができます.

あとは WebAssembly モジュールと JavaScript のランタイムを HTML ファイル内で読み込み,.wasm モジュール内にある _main 関数を呼べば Vim がスタートします.

f:id:rhysd:20180709053830p:plain

詳細な実装

具体的なビルドの流れは下記の図のようになっています.

f:id:rhysd:20180709053904p:plain

C のソースファイルが Clang でそれぞれのソースファイルの LLVM bitcode にコンパイル(&最適化)され,llvm-link で1つの LLVM bitcode ファイル vim.bc にリンクされます.さらにその vim.bc と,エントリポイントとなる HTML ファイルのテンプレート template_vim.htmlJavaScript のランタイム runtime.js,カラースキームなど最低限のファイルを合わせて emscripten が提供するコンパイラ emcc を用いてビルドすると vim.htmlvim.wasmvim.js などの 'executable' がコンパイル結果として吐き出されます.あとはそれらをHTTPサーバでホストして vim.html にアクセスすれば OK です.

実装は configure の修正から始まります.まずは何もしない空の gui_wasm.c を実装します.この時点では実装が必要な関数の中身は空っぽ(もしくは戻り値が必要な場合は単に return FAIL; とだけ書いておく)です. emscriptenコンパイラである emcc を用いてまずは空の GUI 実装が通るところまで持っていきます.emscriptenconfigure を良い感じに emcc 向けに実行してくれる emconfigure という wrapper スクリプトを提供しているのでそれを使って ./configure を走らせます.

前述したターミナルライブラリのチェック(check tlib)を無視するようにしたり,なぜか uint32_t のチェックが通らないので見なかったことにする(手元で emcc を使って再現しようとすると再現しない)など,それなりのワークアラウンドsrc/configure.ac に入れていきます.

./configure が通るようになったところで,今度はビルドが通るように src/Makefile.c.h を修正していきます.emconfigure と同様に emscriptenemmake という emcc 向けにセットアップして make を走らせてくれる wrapper スクリプトがあるのでそれを使います(emmake make -j).

emscriptenLinux ライクな実行環境 (システムコール実装や libc など) を提供してくれるので,基本的には Linux 向けにビルドしてやれば良いですが,修正は必要になります. 例えば,fork (2) や PTY,シグナルは emscripten では利用できません.前者2つの機能は無効にし,シグナルは emscripten が stub を差し込むようにします.他にも WebAssembly では提供できない機能を #ifndef FEAT_GUI_WASM で囲むなどして無効にしていく地道な作業をします.

ビルドが通るようになったら,gui_wasm.c を実装していきます. 先述のとおり,描画イベントを扱う部分とユーザの入力を待つ部分があります.

前者は JavaScript の関数を呼ぶ形で実装します.JavaScript 側で呼びたい関数はプロトタイプ宣言だけしておけば emscripten がつないでくれるので,宣言を src/wasm_runtime.h に書いておきます(vimwasm_ で始まる関数群です).JavaScript 側では emscripten の API で JavaScript ライブラリのかたちで定義しておけば,C から vimwasm_* な関数を呼んだときに自動で JavaScript 側の関数につないでくれます(wasm/runtime.js).

後者のユーザの入力を待つ部分についても DOM の keydown イベントを拾ってユーザが入力した文字の文字コード(またはバックスペースキーなどで使われる2バイトのスペシャルキーコード)を C 側の関数に渡せば,あとは C 側でバッファにそれらの入力を追加する(add_to_buf())ことで実装できます.

ただ,ここで今回最も大きい問題にあたってしまいました.VimGUI 実装に同期的に入力待ちをする関数の実装を要求します.これはブロッキングな処理です. ですが,JavaScript や C からコンパイルされた WebAssembly は(今のところ)ノンブロッキングな処理しか行なえません. 要は,問題にならない範囲で sleep() を挟んでユーザの入力を良い感じに同期的に待つということが WebAssembly や JavaScript では出来ませんでした.emscriptensleep() 関数のコンパイルに対応していますが,なんとその実装はビジーループでした.これではブラウザで Vim を走らせると常にビジーループが回り,CPU をコア1つ専有してしまいます(実際になりました).

この問題を解決するために Emterpreter という emscripten の試験的な機能を使います.Empterpreter は emscripten_sleep() というC関数を提供します.これは C の側では sleep() とほぼ同様に使うことができ,しかもビジーループするようなこともありません. これはかなり強引な(しかしこれを実装し切るのはさすが kripken さんですが)実装になっています.コンパイル時に LLVM 中間表現レベルで emscripten_sleep() など同期的な処理が必要な関数呼び出しの制御フローを直接書き換えます.Emterpreter 有効時の emccコンパイル時に emscripten_sleep() の呼び出しを見つけると,「その呼び出し時点での実行情報をスタックに積んでおき,setTimeout() して一定時間後にそのスタックを resume することで処理を再開するようなコード」に LLVM IR レベルで書き換えます. これによって C 側では同期的に sleep() しているように見える処理が,実際には setTimeout を用いた非同期なコードとして実行されます.

また,emscripten では get_char() (poll() で使われていた) の実装が,なんと window.prompt() を使って実装されているので,謎の入力ダイアログが出まくる問題もありましたが,こちらは emscripten の FileSystem API を使って「もう標準入力からの入力はない」ことを示す(Module.stdin をすげ替える)ことで解決しました.

最後に emscripten の仮想 FileSystem と files preload plugin を使ってカラースキームファイルなどを Vim から読めるようにして,とりあえずの実装を完了しました.

7/1 ぐらいから手を動かし始めたので,今日(7/8)まで,とりあえず動くようになるのに8日ほどかかった感じです.実装行数は今時点で 6832++/2276-- でした.

f:id:rhysd:20180709054115p:plain

その他困ったところ

  • cprotoemcc ではうまく動かない(代わりに gcc を使う)
  • emconfigure おそい
  • select() は実装されているが exceptfds 引数は実装されていないので使えない
  • emscripten の JS library は特殊なプリプロセスを挟んでからビルド結果の JavaScript ファイルに挿入されるので注意が必要
  • emscripten は JS library の最適化に uglifyjs を使うが,uglifyjs は ES5 までの構文しか対応していない(なぜか const は使える).
  • Emterpreter を有効にすると,JavaScript から C の関数を呼ぶ時に文字列が渡せない.JavaScript からの C 関数の呼び出しは Emterpreter がコンパイル時に変換できないので,文字列を渡そうとするとスタックを壊してしまうっぽい.Module.ccall(){ async: true } を指定しても駄目
  • Emterpreter を有効にすると,C のシグネチャJavaScriptシグネチャが合わなくなる関数が発生する
  • Emterpreter を有効にすると,vim.bc からのコンパイルの時間が長くなる
  • Emterpreter を有効にすると,部分的に Emterpreter のインタープリタが実行するバイトコードが必要になるため,バイナリサイズが膨れる

感想

WebAssembly は最近気になっているので,そのツールチェーンである emscripten を使って実際に動くものに触れて良かったです.思ったよりもちゃんと動いているなという印象でした.

また,Vim のコードの GUI 部分についてもかなり理解が深まりました.普段端末の Vim を使っているので,この辺りを読む機会はほぼ無かったのですが,今回良い勉強になりました.

Vim のコード自体は何十万行もあるためそれをブラウザに移植するのはかなり大変ですが,emscripten や WebAssembly などの巨人の肩に乗ることで,わずか数千行で動くところまで持っていけるというのは,巨人の大きさを感じさせられて良い体験でした.