GitHub Action で Vim や Neovim を簡単にインストールできる action-setup-vim をつくった
今週ちまちまと git-messenger.vim や clever-f.vim の CI を GitHub Actions に移行していました.毎回 Vim プラグインの CI のために Vim や Neovim のセットアップを書くのが面倒なのと,Windows 上で Vim や Neovim を入れるのが(Powershell に不慣れなこともあり)大変だったので,GitHub Action として切り出すことにしました.
1ステップで Vim や Neovim を簡単にインストールできます.
使い方
下記のようにステップを書けば Vim または Neovim をインストールしてくれます.
macOS または Linux で安定版 Vim をインストール:
- uses: rhysd/action-setup-vim@v1
Windows では github-token
を input として与える必要があります.これは,vim-win32-installer から最新のリリースを持ってくる必要があり,そのために GitHub API を叩いているからです.
- uses: rhysd/action-setup-vim@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }}
最新の Vim をインストールするには version: nightly
を input に指定します.
- uses: rhysd/action-setup-vim@v1 with: version: nightly github-token: ${{ secrets.GITHUB_TOKEN }}
Neovim をインストールするには neovim: true
を input に指定します.最新の stable の Neovim がすべての OS でインストールできます.Vim とは異なり,Windows 上でも github-token
input は不要です.
- uses: rhysd/action-setup-vim@v1 with: neovim: true
昨晩ビルドされたばかりの nightly の Neovim をインストールするには version: nightly
を指定すれば OK です.
- uses: rhysd/action-setup-vim@v1 with: neovim: true version: nightly
これらのステップを実行後,Vim をインストールした場合は vim
コマンドが,Neovim をインストールした場合は nvim
コマンドがそれぞれ利用可能になっているはずです.
また,action の executable
output としてインストールした Vim または Neovim の実行ファイルへのフルパスをセットしていて,それを使うこともできます.
例えば checkout@v2
で themis.vim をインストールし,action-setup-vim で Vim をインストールして単体テストを走らせる例は
# テストしたいプラグインを checkout - uses: actions/checkout@v2 # themis.vim を checkout - uses: actions/checkout@v2 with: repository: thinca/vim-themis path: vim-themis # Vim をインストール - uses: rhysd/action-setup-vim@v1 id: vim # プラグインの単体テストを themis.vim で実行 - name: Run unit tests with themis.vim env: THEMIS_VIM: ${{ steps.vim.outputs.executable }} run: | ./vim-themis/bin/themis ./test
実際に clever-f.vim のワークフロー で利用しています.
インストールされる Vim および Neovim の詳細
インストール元は OS と version
input によって下記のようになっています.優先度は
- システムのパッケージマネージャ
- 公式リリース
- ソースからビルド
となっています.ユーザ数や実行の速さを考慮してこうなっています.
Vim
OS | Version | Installation |
---|---|---|
Linux | stable |
gvim-gnome パッケージを apt でインストール |
Linux | nightly |
vim/vim リポジトリの HEAD をビルド |
macOS | stable |
brew install macvim で Homebrew からインストール |
macOS | nightly |
vim リポジトリの HEAD をビルド |
Widnows | stable |
Windows での公式安定版は無いので,Nightly と同じ |
Windows | nightly |
公式インストーラ repository のリリースからインストール |
Neovim
OS | Version | Installation |
---|---|---|
Linux | stable |
Neovim 公式の stable release からインストール |
Linux | nightly |
Neovim 公式の nightly release からインストール |
macOS | stable |
Homebrew を使って brew install neovim でインストール |
macOS | nightly |
Neovim 公式の nightly release からインストール |
Windows | stable |
Neovim 公式の stable release からインストール |
Windows | nightly |
Neovim 公式の nightly release からインストール |
制限
今のところ,特定のバージョンを指定してインストールはできません.技術的制約があるわけではないので,もし需要があればやるかもしれません. v1.1.0 で指定可能になりました.
# Vim v8.2.0126 を全ての OS でインストール.Windows でも github-token input は必要ありません - uses: rhysd/action-setup-vim@v1 with: version: v8.2.0126 # Neovim v0.4.3 を全ての OS でインストール - uses: rhysd/action-setup-vim@v1 with: neovim: true version: v0.4.3
また,GUI バージョンについては現状ではサポートしていませんが,リリース物に含まれる場合はインストールされます.具体的には下記の場合です:
まとめ
Vim または Neovim を簡単にインストールできる action action-setup-vim を作成しました. GitHub Action の Vim プラグイン CI への導入の敷居が個人的にぐっと下がったので,他のプラグインについても順次移行していきたいところ.
目黒.vim #16 に参加して Vim script に const を実装するパッチを書いた
あいにくの雨でしたが,Meguro.vim #16 に参加しました.
「この雨の中 Vim のもくもく会に来た者たちだ.面構えが違う」 #megurovim
— ドッグ (@Linda_pp) June 15, 2019
目黒.vim は Vim に関したり関しなかったりする作業をするもくもく会で,今回は Vim に :const
を追加するパッチを書きました.
:const
コマンドの機能
Vim script では変数を :let
で定義・代入します.ですが,
- 新しい変数定義時も代入時も同じ
:let
を使う if
などで新しいスコープが作成されない(動的スコープ)
により,意図せず既存の変数を上書きしてしまうケースがあります.
" Define variable let i = 0 if some_condition " In heavily nested or big statements... let i = 1 " Unexpectedly using the same name variable endif echo i " => 1 (expected 0)
意図せず変数が上書きされないようにするためのコマンドとして :lockvar
があります.他のプラグインなどから勝手に上書きされないようにグローバル変数をロックするのに使われたりはしますが,ローカル変数に使うほどのお手軽さはありませんでした.
- 毎回ロックするのは面倒だし忘れる
- 余分に1コマンド実行するコスト(パースおよび解釈)がかかる
一方,JavaScript では ES2015 から const
が追加されました.これは JavaScript の変数定義をレキシカルスコープにし,参照を変更不能にする構文です.
const number = 42; number = 99; // TypeError: invalid assignment to const `number'
Vim script でも内部的に :lockvar
の実装を使えば,これと似たような実装ができると思い, 変更しない変数の定義に :let
の代替として使える :const
を実装しました.:let
の代わりに :const
を使うだけなので,別途 :lockvar
を使うのに比べ面倒ではありません.また,変数の定義と同時にロックするので別コマンドで実行するのに比べてコストが遥かに安いです(実際,ただフラグを立てるだけ).
" Define locked variable const i = 0 if some_condition let i = 1 " Error! `i` is locked endif echo i " => 0 (expected)
リストの分解など複雑な定義式も :let
と同様に使えます.
const [a, b, c] = [1, 2, 3] const [x; xs] = [1, 2, 3]
さらに,:const
は安全のため,既存の変数を上書きできないようになっています.もし既存の変数をロックしたい場合は従来どおり :lockvar
を使ってください.
let i = 1 const i = 2 " E995 cannot modify existing variable
:const
でロックする深さは 1 です(:lockvar 1
相当).なので例えばリストを定義した時,要素まではロックされません.
const l = [1] call add(l, 2) echo l " => [1, 2]
なぜ再帰的に全ての値をロックする :lockvar!
相当にしないのかというと,
let a = 1 let l = [a] lockvar! l " 再帰的にロックするので a もロックしてしまう let a = 2 " エラー!a はロックされているので変更できない
のように意図せず変数をロックしてしまう可能性があるからです.また,ネストが深い辞書やリストをロックすると深さに比例したコストがかかってしまいます.
その他,注意する点は
- その性質上,環境変数(
$FOO
)やオプション値(&filetype
)には:const
は使えません(E996
) :unlockvar
で無理やり:const
で定義した変数のロックを解除することが可能です- JavaScript の
const
はスコープがレキシカルですが,Vim script の:const
はスコープがダイナミックのままです - JavaScript 処理系はコードのパース時に
const
定義された変数への再代入を検知してエラーにできますが,Vim スクリプト処理系は素朴なインタープリタなので:const
で定義された変数が実際に書き換えられようとするまでエラーになりません
:const
コマンドの実装
diff はこちら. src/eval.c
に ex_const()
を定義し, src/ex_cmds.h
に追加したいコマンドを定義して ex_const
関数ポインタを登録しておくと,:const
で ex_const()
が呼ばれます.
ちなみに新しいコマンドを追加した時は make cmdidxs
でコマンド検索表である src/ex_cmdidxs.h
を再生成する必要があります.
:const
は変数をロックする以外は :let
と同じ挙動のため,実装を :let
と共有しています.定義した変数をロックするかどうかのフラグを :let
の実装関数に追加し,フラグが立っている場合は変数定義時に di_flags
の DI_FLAGS_LOCK
フラグと di_tv.v_lock
の VAR_LOCKED
フラグを立てるだけです.複合代入(+=
など)や環境変数への代入やオプション変数への代入のパスはフラグが立っているとエラーにします.
テストの作成方法は src/testdir/README.txt
に書いてありますが,src/testdir
内に test_const.vim
を作成し,src/testdir/Make_all.mak
をちょっといじるだけでかなり簡単に実装できます.test_const.vim
内に Test_*
な名前の関数を定義しておくと, make test_const
でテストが実行できます.
:const
コマンドのデバッグ
変数に値をセットする関数 set_var_lval()
で,一時的に読み先の文字に NUL をセットしてあとで復帰している部分にエラーチェックを追加した際に復帰処理を追加するのを忘れてエンバグしてしまいました(const [a] = ...
の解釈途中で Internal error).
Vim では標準出力はエディタが使っているので printf()
などで出力することは基本的にできません.デバッグ方法は src/README.md
に書いてあり,
- デバッグプリントを
ch_log()
で出しておき,デバッグのための Vim 起動時に:call ch_logfile('log.txt', 'w')
のようにしてファイルにデバッグログを吐く.タイミング問題を追う時などに便利 :packadd termdebug
し,:Termdebug
で Vim の中で Vim を起動してgdb
を使ってデバッグする.Mac でgdb
を使うには実行ファイルをコード署名する必要があり若干面倒ですが,大変便利です
vim/vim へのプルリク
ドキュメントを書いて,パッチを GitHub の vim/vim リポジトリにプルリクします.
新機能の実装の場合,取り込まれるかどうかは作者の Bram さん次第なのでどうかなと思ったのですが,サクッとマージしてもらえました(Vim では変更をパッチとして取り込むので,GitHub のマージ機能を使っていません).Bram さんは普段 JavaScript や TypeScript を書くらしく,同じことを考えたことがあったらしいです.Vim script の新機能を Bram さんに説明したいときは JavaScript を引き合いに出すと良いのかもしれません.
あまりに速くマージされたので,いくつか細かいミスに後から気付き追加のパッチを送りました.
Vim on Wasm on Web Worker on Browser with Atomics
この記事は以前の
の続編で,WebAssembly (Wasm) にポーティングした Vim の話です.
TLDR
Wasm にコンパイルした Vim のコードを Web Worker(ワーカスレッド)の中で動かすことで,メインスレッドで行われるユーザのインタラクションをエディタがブロックしなくなりました.
また,イベントループのポーリングを Atomics.wait()
でやってキー入力を共有メモリバッファで受け取ることで Emterpreter を捨て,実行速度・安定性・バイナリサイズ・ビルド時間・メンテ性が向上しました.
実装:
これまでの問題点
前回の記事では 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()
のように同期的に待つ関数のように使えますが,実際は一旦処理を中断して非同期に待つ処理に変換されます.emcc
(emscripten の C コンパイラ)は emscripten_sleep()
を使っている関数を直接 Wasm にコンパイルせず,Wasm の上で動くインタープリタ (Emterpreter) で実行するためのバイトコードに変換します (emterpretify).実行時にはそのバイトコードをインタープリタで実行し,emscripten_sleep()
の呼び出し箇所で一旦実行状態を保存(suspend)して JavaScript 側の setTimeout()
を読んで実行を中断します.タイマーがコールバックを呼んだら Wasm 側に戻り,インタープリタはその中で保存しておいた実行状態を復帰(resume)して実行を再開します.
このような力技により前回はどうにか Vim を動かすことに成功しました.ですが,前回の記事にも書いたように,次の問題が残りました
- Emterpreter で実行される関数は Wasm から直接呼び出すことができません(逆に Emterpreter から Wasm の関数は呼べる).なので,
emscripten_sleep()
を呼んでいる関数を呼んでいる関数,それを呼んでいる関数,... というふうに呼び出し元の関数も Wasm には直接コンパイルできず,Emterpreter のバイトコードにコンパイルする必要があります.Vim でsleep()
が必要な関数(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^ ̄
なんと,無いと思っていた JavaScript の sleep()
がこんなところにありました.
ですが,これを呼ぶとスレッドを止めてしまうので,もちろんこれをメインスレッドで呼ぶことはできません.Atomics.wait()
はワーカスレッドからつまり Web Worker 内で呼ばなければならないという仕様があります.メインスレッドで呼ぶと待たず即座に例外を投げます.
- メインスレッド(
<script>
で読まれるスクリプト)
// 共有メモリバッファをつくり,ワーカスレッド(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/wasm/main.ts
がメインスレッド側,vim.wasm/wasm/runtime.ts
がワーカスレッド側です.
全体の構成のイメージは下図のようになっています.
Wasm 側は Atomics.wait
で待っている間停止するので,ワーカスレッド側の onmessage
コールバックが発火せず,共有メモリバッファで値をやりとりする必要があります.
逆にワーカスレッドからメインスレッドに値を渡す時は postMessage()
で値を渡せます.
キー入力が発生しない時と発生するときの処理のシーケンスは下図のようになっています.
キー入力が発生しない時 (上段,first tick)
- Vim (Wasm) が起動して入力待ち状態になると,最終的に
gui_mch_wait_for_chars()
が呼ばれ,その中で JavaScript 側に処理を渡します(vimwasm_wait_for_event()
) vimwasm_wait_for_event()
内でAtomics.wait()
を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます- 今回は通知が来ず,
Atomics.wait()
がタイムアウトしました - そのまま
vimwasm_wait_for_event()
を抜け,Vim (Wasm) に処理を戻します
キー入力が発生する時 (下段,next tick)
vimwasm_wait_for_event()
内でAtomics.wait()
を使って共有メモリへのメインスレッドからの変更通知を同期で待ちます- キー入力が発生しました.
KeyEvent
をメインスレッド側で受け取って共有メモリバッファにキー入力情報(押されたキーの名前やキーコード,モディファイアキー情報など)を書きます - 共有メモリバッファのうちの1バイトを通知用の領域とし,そこに
Atomics.store()
でキー入力情報を書いたことを知らせる値を書いてから,ワーカスレッド側にAtomics.notify()
を通じて通知します - ワーカスレッド側は
Atomics.wait()
で待っておき,通知が来たら共有メモリバッファを見に行ってキー入力情報を得ます - キー入力情報を元に Vim に与えるキーシーケンスを計算し,Vim 側に JavaScript to Wasm API を使って渡します
- Vim (Wasm) は受け取ったシーケンスを入力バッファに加えます
- 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 脆弱性によって SharedArrayBuffer
や Atomics
API が一時的に多くのブラウザで無効にされているためです.Firefox や Safari ではブラウザのフィーチャフラグを立てることで動かすことができます.
今後
- 今は Web Worker を使っていますが,Wasm のマルチスレッド対応が使えるかもしれないと思っています.同時に
i32.atomic.wait
などのアトミック命令も追加されているので,JavaScript のAtomics
API の代わりに使うことで,Wasm と JavaScript 間のインタラクションを無くしてワーカスレッド側の入力待ち処理を Wasm で完結させられるかもしれません - 今は
<canvas/>
をメインスレッド側で描画していますが,ワーカスレッド側でキャンバスを描画してメインスレッド側に転送するOffscreenCanvas
を使って描画処理をワーカスレッド側に持ってくることができるかもしれません - upstream の追従作業
- 'small' や 'normal' feature でビルドできるようにする
- ファイル保存や読み込み,クリップボード対応など
感想
最初に Atomics
API を見た時は JavaScript で非同期処理ライブラリをつくるような人以外は用は無さそうだなと正直思っていたんですが,まさか自分が使うことになるとは思いませんでした…
Emterpreter の動作が不安定すぎる問題で長らく詰まってしまっていたのですが,ようやく次のステップに進めそうで良かったです.