Vim 進捗旅行

木曜日午後〜日曜日午前中の4日間,Vim コミュニティつながりの知人と旅館に泊まり込んでもくもく作業する合宿的な旅行に行ってきました.

f:id:rhysd:20171127025245p:plain

当日の様子や旅館の便利情報についてはすでにブログ記事にまとめられているのでそちらを読んでいただいて,この記事内では僕がやった作業内容を備忘録的に書いておきます.

今回やったこと

neovim-componentNyaoVimPolymer v2 対応

今回の一番でかい成果はウェブアプリフレームワーク Polymer v1 を使って書いていたアプリを v2 にアップデートしたことでした.

NyaoVim という Neovim のフロントエンドなデスクトップアプリを Polymer と Electron を使ってつくっています.Neovim フロントエンドというのは,裏でヘッドレスな Neovim を起動して標準入出力経由の RPC で描画情報やユーザのキー入力などをやりとりすることで Neovim の表面部分を実装したものです.NyaoVim では Electron をフレームワークとして使うことで Neovim エディタのスクリーン部分を WebComponents のカスタム要素として描画することで,元来 Vim にはなかった,ユーザが自由に UI を拡張できる仕組みをウェブの技術を用いて提供します.

Web Components と Electron でつくる Neovim フロントエンドの未来 github.com

このアプリは Polymer という WebComponents をベースにしデータバインディングを乗っけた薄いコンポーネントライブラリを使って実装しています.ユーザは設定を <template> 要素と JavaScript を使って HTML ファイル(nyaovimrc.html)で書きます.<template> を使うことで自由にカスタム要素で作られた UI プラグインを配置でき,挙動を Polymer のデータバインディングを使ってカスタム要素の属性を使ってカスタマイズできます.NyaoVim を作り始めた当初は Polymer v1.3 ぐらいで,それ以降も v1 を使い続けていましたが,さすがにそろそろ v2 に移行すべきだと思って半年ぐらい経ってしまったので良い加減やることにしました.

Polymer v1 は WebComponents v0 時代につくられたということもあり,Polymer v2 では WebComponents v1 の仕様への乗り換えが行われました.それもあってか,カスタム要素定義の仕方やライフサイクルの扱いが大きく変わっています(見た感じデータバインディングについては大きくは変更なし). 公式にある Polymer 2.0 アップグレードガイド を読みつつ下記の手順で進めました.

  1. レガシースタイルのまま v1 から v2 に乗り換える
  2. ES2015 class を使った新しいスタイルに乗り換える
  3. 壊れた部分やテストを直す
  4. 関連 UI プラグインを Polymer v2 向けに修正する

最初の時点でまずは依存する Polymer のバージョンを v2 に上げます.Polymer v2 は移行のために v1 のレガシーなスタイルもある程度対応しているので,まずはバージョンだけ上げてアプリを起動してみてうまく動いていないことを確認し,修正していきます.

Polymer v1 までは外からもカスタム要素の中身が見えるのがデフォルトになっていたため,カスタム要素を構成する要素を取るのに雑に document.querySelector を使ってしまっていたのですが,v2 からは完全に見えなくなる(document.querySelector で要素を取ろうとしても null になる)ので,Polymer の APIthis.$ など)を使って要素を取ってくるように正しました. また attached コールバックの発火タイミングが変わっていて DOM の要素のサイズが取れなかった(NyaoVim では Neovim 起動時にエディタのサイズ(行数と桁数)を指定するためにフォントサイズと要素サイズから計算する必要がある)ので,Polymer.RenderStatus.beforeNextRender を使って,要素の first paint(次の request animation frame 発火直後)まで処理を遅延するようにしました.

Polymer({
    is: 'x-foo',
    // ...
    attached: function() {
        // <x-foo> が DOM に挿入された直後に実行される
        Polymer.RenderStatus.beforeNextRender(this, function() {
            // <x-foo> がページ内に描画された直後に実行される
        });
    }
});

次に Polymer v2 からは独自のファクトリ関数ではなく WebComponents v1 のカスタム要素のように ES2015 class を使って要素を定義することができるので,それを使ってレガシースタイルの要素を class による実装に移行します.

class XFoo extends Polymer.Element {
    static get is() { return 'x-foo'; }
    static get properties() {
        return {...};
    }
}
customElements.define(XFoo.is, XFoo);

Polymer v2 は標準の API を直接使えるところはなるべく使うという方針なので,カスタム要素の定義は標準の customElements.define を使います.また,v1 のレガシースタイルで用意されていた attached, connecteddisconnected といったライフサイクル系のコールバックは標準の attachedCallbackconnectedCallbackdisconnectedCallbback などのカスタム要素標準のコールバックを使って書き換えていきます.また,ユーザがデータバインディング経由でカスタム要素の属性の値を変更した場合は同様に標準の attributeChangedCallback を使って知ることができます.NyaoVim は TypeScript で書いていて,DefinitelyTyped の型定義は v1 のもので古かったのですが, v2 向けの型定義を書いている人を発見したので拝借して使わせてもらいました.

最後に,これらの変更によって壊れたテストや NyaoVim の UI を拡張するためのプラグインmarkdown プレビューやツールチップなど)の修正を行いました.E2E テストでカスタム要素の中を直接覗いていたのが v2 で不可視になったことによりテストできなくなったため,一番外側のカスタム要素に生やしてあったプロパティを経由するようにしたりなどの変更をしました.

まだできていないこと:

  • React の DOM を ShadowRoot を貼り付けたカスタム要素下の子要素にマウントすると React コンポーネント <div onClick={...}\> などのコールバックがなぜか発火しない
  • Web Component の中身が見えなくなったので,E2E テストでコンポーネントの中身をテストできなくなってしまった.web-component-tester あたりを使えば良さそう

UIプラグインの中に React で動的な処理を書いたアプリをカスタム要素で wrap したものがあったのですが,onClick が発火しなくなってしまい使えなくなってしまいました.誰か何か知っていたら教えてほしい…

clever-f.vim のテストを themis.vim に移行しテストカバレッジを取れるようにした

clever-f.vimVimfマッピングを拡張するプラグインで,使い続けているプラグインの1つです.

最近 covimerage という Vim の profilng 機能による実行ログを利用して Vim script のカバレッジを取るツールが(一部で)話題になっていて,@haya14busaさんのおかげで,どうやら結構簡単に導入できそうだということで導入してみました.

clever-f.vim には50弱のテストケースがあって,今までは vim-vspec を使ってテストを書いていたのですが,どうやら vim-vspec はテスト前 (bootstrap.vim)に特定の処理を挟むことができないらしいので,themis.vim を使って書き直しました.:profile でプロファイルを取る処理を入れる必要があります.

themis.vim:Describe:It を使った spec スタイルをサポートしているのでほぼ問題なく全てのテストを移行できました.

次に Travis CI を使って LinuxmacOScovimerage をインストールし,.themisrc:profile を使ってテスト中の profile を取り,covimerage を使ってカバレッジレポートを生成して codecov にアップロードする処理を入れます.

Dashboard ⋅ rhysd/clever-f.vim f:id:rhysd:20171127025717p:plain

このように Vim プラグインのテストカバレッジを取ることができました.今後はテストできない箇所のテストをちまちま追加していきたいと思っています.

1点うまくいかなかったのは,clever-f.vimmigemo 対応をする部分のコード がなぜか「UTF-8 でデコードするには不正なシーケンスが含まれている」というエラーで covimerage ではうまくパースできず外さざるをえなかった点です.

もくもく合宿の所感

タスクを詰め込みすぎない(どうせ全部できない)

3泊4日ということもあり,タスクを詰め込みがちですが,詰め込んでもどうせできないのでその日に必ずこれをやるというのを設定しておくのが良さそうでした.もくもく会は独りで集中して作業するのに比べると作業速度は落ちるので,あまり期待しすぎないのが大事な気がしました. 自分のリポジトリに来た issue 対応を合間にやったり,とあるアプリに初めてメールで脆弱性レポートが送られてきたり,他のメンバーの Splatoon2 プレイを見ていたのもあって,メインの作業は思ったほど進みませんでした.

ただその分,他のメンバーが踏んだ興味深いバグを一緒に追ってみたり,手元で温めているアイデアを他メンバーと議論・相談したり,分からないところのヘルプを頼んだりできて有意義だったと思います.(i.e. autoload 内のスクリプト:sourceruntime で読んではいけないことを知った)

2泊3日がベストっぽい

実は今年の4月頃にも一度同じところに1泊2日で行ったのですが,1泊2日は短すぎたので今回は3泊4日に挑戦という流れでした.3泊4日はしっかり時間が取れて1泊2日より良かったですが,若干だれてしまう感じだったので2泊3日が自分の中ではベストかなぁと思いました.

尊師スタイル

今回は HHKB を持っていったので,内蔵キーボードを Karabiner で無効にして尊師スタイルしてみたところ,(MacBook Pro 2016 late の残念なキーボードに比べて)はるかに打ちやすくて最高でした.これからもくもく会はこのスタイルで行きます.しかしセパレート型の持ち運び向けキーボードがベストっぽい…

旅行中のツイートハイライト

LLVM IR の alloca 命令のつかいかた

LLVM IR の alloca 命令の使い方について,リファレンスマニュアルに載ってない注意点があったのでメモがてら書きます.

alloca 命令とは

スタック上にメモリを確保し,確保した領域の先頭へのポインタを返します.スタック上にメモリを割り付けることでアドレスが必要なメモリ領域を得ることができ,自動変数などの実装に使うことができます.

http://llvm.org/docs/LangRef.html#alloca-instruction

%ptr = alloca i32                             ; yields i32*:ptr
%ptr = alloca i32, i32 4                      ; yields i32*:ptr
%ptr = alloca i32, i32 4, align 1024          ; yields i32*:ptr
%ptr = alloca i32, align 1024                 ; yields i32*:ptr

変数などは基本的にこの alloca 命令を使って領域を確保するお馴染みの命令です.スタック上に割り当てるので,関数を抜けると同時にその領域は解放されます.

と,ここまでならリファレンスマニュアルを読めばそれで解決なのですが,いくつか注意点があります.

alloca によるメモリ割り当てのタイミング

例えば下記のようなループを実装したいケース

int i = 0;
while (i < 10) {
    int j = i * 2;
    printf("%d\n", j);
    ++i;
}

そのまま書き下すと

  %i = alloca i32
  store i32 0 i32* %i
  br label %loop.init
loop.init:
  %i1 = load i32 i32* %i
  %0 = icmp sle i32 %i1 10
  br i1 %0 label %loop.end, label %loop.body
loop.body:
  %1 = mul i32 %i1, 2
  %j = alloca i32
  store i32 %1 i32* %j
  %2 = load i32 i32* %j
  call void @printf(i8* @str, i32 %2)
  %3 = add i32 %i1, 1
  store i32 %3 i32* %i
  br label %loop.init
loop.end:

のような IR をつくりたくなりますが,これだと10行目の %j = alloca i32 がループごとに確保されてしまうので,ループするだけで再帰でもないのにどんどんスタックを食っていってしまいます.

なので,下記のようにスタックのアロケーションは原則関数の頭でやります.

  %i = alloca i32
  %j = alloca i32
  store i32 0 i32* %i
  br label %loop.init
loop.init:
  %i1 = load i32 i32* %i
  %0 = icmp sle i32 %i1 10
  br i1 %0 label %loop.end, label %loop.body
loop.body:
  %1 = mul i32 %i1, 2
  store i32 %1 i32* %j
  %2 = load i32 i32* %j
  call void @printf(i8* @str, i32 %2)
  %3 = add i32 %i1, 1
  store i32 %3 i32* %i
  br label %loop.init
loop.end:

これは割と常識らしく,Clang は明示的にブロックをつくりこそしませんが,関数の頭で alloca を呼んでいます.Rust や Crystal は関数の最初にその関数内で必要なスタック領域をすべて alloca する基本ブロックを置く実装になっています.どちらでも良いですが,LLVMC++ API でなく C bindings な llvm-c を使う場合は後者の方が実装がかなり楽だと思います.

C89 までブロックの先頭でしか変数を定義できなかったのはこういった理由があるのかもしれません(未確認)

関数の頭でメモリを割り当てられない例外:動的サイズ配列

ここまでは一般的なケースですが,残念ながらこれではうまくいかないケースがあります.C99 の動的サイズ配列です. alloca 命令はN要素分の領域確保もできるためスタック上に動的サイズでメモリを確保すること自体は問題なくできます.

int i = 0;
while (i < 10) {
    int arr[i + 1];
    arr[i] = 42;
    printf("%d\n", arr[i]);
    ++i;
}

例えば上記のようなコードの場合,arr の確保する領域は i の値に応じて動的に変わります.なので,関数の先頭で alloca しておく前述の方法は使えません. しかし,ループの中で単に alloca してしまうとループが回るごとにスタックが積み上がっていってしまい,無限ループでもしようものならスタックを使い切ってプログラムがクラッシュしてしまうでしょう.

そこで,LLVM には llvm.stacksavellvm.stackrestore という intrinsic 関数があります.前者は現在のスタック位置をレジスタに保存し,後者はレジスタの値にスタック位置を復帰します.

  %0 = call i8* @llvm.stacksave()      ; 現在のスタック位置を %0 に保存
  %1 = alloca i32, i64 %x              ; i32 の値を %x 個分確保する
  ; %1 を使った処理
  call void @llvm.stackrestore(i8* %0) ; スタック位置を復帰.%1 の領域は解放される

これを上記の while ループのブロック内で行うことで一度消費したスタックを復帰してから次のループに臨むことができ,スタックを消費しっぱなしにならなくできます.

初めこれを Clang の出力で見た時は偶然関数の最初と最後に stacksavestackrestore があったので,どうしてこれが必要なんだろうと思いましたが,今回説明したような理由があったからでした.

Electron に Mac タッチバー API が実装された

個人的に気になっていた Electron のタッチバーサポートがついに master にマージされました.

実装は下記の PR で行われ,@MarshallOfSound さんの初期実装と @kevinsawicki さんのブラッシュアップで実装されました.

https://github.com/electron/electron/issues/8095

まだリリースされていないですが,待てなかったので master ブランチの実装を試してみました.

追記(2017/3/8): v1.6.3 beta としてリリースされました.

Electron の master ブランチをビルドする

ビルドの仕方はドキュメントにまとめられています. 再配布せず手元で試すだけであれば面倒そうな「macOS SDK」のセクションは無視してしまって大丈夫です.

# リポジトリを取得
$ git clone https://github.com/electron/electron && cd electron/
# ビルドに必要な依存関係パッケージの取得
$ ./script/bootstrap.py -v
# ビルド
$ ./script/build.py

ビルドにはそれなりに時間がかかります.out/R にリリースビルドの Electron.app が,out/Dデバッグビルドの Electron.app が生成されます.

Touchbar API

タッチバーはメインプロセスから制御することができます.レンダラプロセスからは IPC 経由でのアクセスになりそうです. macOS 10.12.1 以降のタッチバー搭載 MacBook Pro で利用できるようです

タッチバーの UI パーツにはいくつかの種類があり,各パーツごとの class と,それらをまとめる class が提供されています.

  • TouchBarButton : アイコンとテキストが置けるボタンです
  • TouchBarColorPicker : カラーピッカーです
  • TouchBarGroup : 複数の UI パーツを1つのグループとして配置できます
  • TouchBarLabel : テキストを表示できるラベルです
  • TouchBarPopover : アイコンとラベルとキャンセルボタンが表示できる,タッチバーに出るダイアログボックス的な UI です
  • TouchBarSlider : 音量などに使えるスライダーです
  • TouchBarSpacer : タッチバーのアイテムとアイテムの間にスペースを開けることができます.開けるスペースは small, large, flexible (スペースを取れるだけ取る) の中から選べます
  • TouchBar : TouchBar は上記 Touchbar の UI パーツを並べてタッチバーインスタンスを生成できます

タッチバーインスタンスBrowserWindowsetTouchbar() メソッドによってセットできます.セットされると即座にタッチバーが表示されます.また,setTouchbar メソッドに null を与えることでタッチバーを消すことができます.

サンプルアプリ

ドキュメントにのっているサンプルアプリを動かしてみます.ドキュメントにはコード片のみなので,動くコードを下記に置いてみました.

github.com

サンプルアプリではタッチバー上でスロットを回すことができます.

www.youtube.com

エントリポイントである main.js は下記の通りです.

const path = require('path')
const {app, BrowserWindow, TouchBar} = require('electron')

// 必要なタッチバーの UI パーツをインポートする
const {TouchBarLabel, TouchBarButton, TouchBarSpacer} = TouchBar

let spinning = false

// スロットの各リールの文字列を表示するラベル3つ
const reel1 = new TouchBarLabel()
const reel2 = new TouchBarLabel()
const reel3 = new TouchBarLabel()

// スロットを回した結果を表示するラベル
const result = new TouchBarLabel()

// 'Spin' ボタン
const spin = new TouchBarButton({
  label: '🎰 Spin', // ボタンのテキスト.ここでは絵文字だけどアイコンも置けるはず…
  backgroundColor: '#7851A9',
  click: () => {
    // スロットが回っている間はクリックを無視する
    if (spinning) {
      return
    }

    // スロットを回す処理
    spinning = true
    result.label = ''

    let timeout = 10
    const spinLength = 4 * 1000 // 4 seconds
    const startTime = Date.now()

    // クリックされてから4秒間スロットを回す
    const spinReels = () => {
      // スロットのリールを更新する.止まった時のリールが結果になる
      updateReels()

      if ((Date.now() - startTime) >= spinLength) {
        finishSpin()
      } else {
        // 1.1倍ずつスロットを遅くしていく(慣性っぽさ)
        timeout *= 1.1
        setTimeout(spinReels, timeout)
      }
    }

    spinReels()
  }
})

// ランダムに絵柄を1つ選ぶ関数
const getRandomValue = () => {
  const values = ['🍒', '💎', '7️⃣', '🍊', '🔔', '⭐', '🍇', '🍀']
  return values[Math.floor(Math.random() * values.length)]
}

// 3つのリールを更新
const updateReels = () => {
  reel1.label = getRandomValue()
  reel2.label = getRandomValue()
  reel3.label = getRandomValue()
}

const finishSpin = () => {
  // Set を使って重複を省き,残った要素数で絵柄がいくつ合ったかをチェックする
  const uniqueValues = new Set([reel1.label, reel2.label, reel3.label]).size

  if (uniqueValues === 1) {
    // すべての絵柄が揃ったとき:大当たり!
    result.label = '💰 Jackpot!'
    result.textColor = '#FDFF00'
  } else if (uniqueValues === 2) {
    // 3つ中2つのリールが同じ絵柄だったとき:当たり
    result.label = '😍 Winner!'
    result.textColor = '#FDFF00'
  } else {
    // 絵柄がまったく揃わなかったとき:はずれ
    result.label = '🙁 Spin Again'
    result.textColor = null
  }

  spinning = false
}

// [ボタン]  [r1] [r2] [r3]  [result]
//
// のように配置.配列に入れたパーツが入れた順にタッチバーに表示される
const touchBar = new TouchBar([
  spin,
  new TouchBarSpacer({size: 'large'}),
  reel1,
  new TouchBarSpacer({size: 'small'}),
  reel2,
  new TouchBarSpacer({size: 'small'}),
  reel3,
  new TouchBarSpacer({size: 'large'}),
  result
])

let window

app.once('ready', () => {
  window = new BrowserWindow({
    width: 200,
    height: 200
  })
  window.loadURL(`file://${path.join(__dirname, '/index.html')}`)
  // ブラウザウィンドウにタッチバーをセットする.
  // このタイミングでタッチバーにスロットマシンが表示される
  window.setTouchBar(touchBar)
})

// 全てのウィンドウが閉じたときアプリを閉じる
app.on('window-all-closed', () => {
  app.quit()
})

まとめ

ついに Electron アプリでもタッチバーにアクセスできるようになりました. まだ入ったところなので多少バグなどがあるかもしれませんが,面白い機能なので是非自分がつくっているアプリでも活用を考えたいと思います. 何事も無ければ次リリースに含まれると思うので,今週後半か来週あたりには v1.6 系で使えるようになるのではと思います.