rbsのtree-sitterパーサを書いて、neovimのシンタックスハイライトに利用する

皆さん型書いてますか?私はそもそもRubyを書いていません!

とはいえ、最近Kaigi on RailsやRubyWorldとカンファレンスが続いていたので、ちょっとやる気を出してrbsを書くためのエコシステムに貢献しようと思い、rbs用のtree-sitterパーサを書いてみました。パーサ流行ってますからね。

github.com

READMEにしたがってnvim-treesitterでパーサをインストールし、このリポジトリをneovimプラグインとしてインストールすれば、rbsシンタックスハイライトがイカした感じになります。

しかし、しかしながらですね、これ半年ぐらい前に調べた時には誰も書いてなかったんですが、8割ぐらい書いた所で、既に別のtree-sitter-rbsがあることに気付いたんですよね……。

github.com

まあ、せっかく作ったんで完全に同じ車輪の再発明だろうが、tree-sitterでパーサを書く練習だと思って一通り書くところまでしっかりやりました。

自分の方の特色としては、neovimフレンドリーなところですかね。ちゃんと動きそうならnvim-treesitter側に取り込んでもらうとこまでやりたいところです。

というかそもそもvim-rbsってプラグインがあって、概ねそれで十分なんですけどね……。

rbsのパーサって、Ruby本体に比べたらめちゃくちゃ簡単な方だと思うんですが、それでも結構大変だったというか、評価内容が衝突するところは一杯あって、tree-sitterでどうやって解決するのか結構悩みました。結果的に動くものは出来たはずなんですが、やり方として正しいのか本当のところよく分からんですね。

本当にlramaとかprismとか正気か?と思いますよ。凄さが実感できますね。

tree-sitterとは

ここから、そもそもtree-sitterって何?という人のためと、自分の備忘録のためにtree-sitterの解説を書いていきます。

tree-sitterとはrustとCで書かれているパーサジェネレータです。パーサジェネレータ自体は概ねrustなんですが、文法の記述はJavaScriptによるDSLで行います。

tree-sitter-cliをインストールしてtree-sitter generateコマンドを使うと雛形のプロジェクトが生成されます。

トップディレクトリにgrammer.jsというファイルがあるので、そのファイルに文法を記述していきます。

tree-sitterはGLR parsingアルゴリズムを基盤にしていて、概ねBNFみたいな感じで文法が記述できます。主なDSLは以下の通り。

  • 文字列 or regex: その文字列か正規表現にマッチする
  • seq(rule1, rule2, ...): 引数で指定されたルールが順番通りに全てマッチする
  • choice(rule1, rule2, ...): 引数で指定されたルールのいずれかにマッチする
  • repeat(rule): 引数で指定されたルールの0回以上の繰り返し
  • repeat1(rule): 引数で指定されたルールの1回以上の繰り返し
  • optional(rule): 引数で指定されたルールが0回か1回マッチする
  • token(rule): 引数で指定されたルールを一まとまりのトークンとして扱う。デフォルトの文字列は1文字づつ別々のトークンの集合として扱われる。
  • token.immediate(rule): スペースなどのextras要素を間に含まずに即座に表れるトークンに対してマッチする。

その他、precedance(優先順位)を指定するDSLがあります。prec(number,rule), prec.left([number], rule),prec.right([number], rule)`で、優先順位をしたり左結合優先か右結合優先かを指定できます。二項演算子の結合順序を指定したりなんかが典型的な使い方ですね。

JSなので当然再起を使ったり、任意の関数でラップしたりできます。

Tree-sitter|Creating Parsers

Goっぽい簡単な型宣言にマッチする文法は以下の用に書けます。

module.exports = grammer({
  rules: {
    type: $ => choice($.primitive_type, $.array_type),

    primitive_type: $ => choice("int", "bool", "string"),
    array_type: $ => seq("[", "]", $.type,
  }
});

こんな感じで$.rule_nameという記述で定義済みのルールを参照できます。関数にwrapされてるので定義順は自由です。これを積み重ねてルールを書いていきます。

今回はrbsリポジトリにあるsyntax.mdBNFを、ストレートにtree-sitter記法に書き起こしてconflictが起こるところを都度調整して直しつつルールを記述していきました。

tree-sitterにはexternal scannerという機能もあり、これを利用すると、CまたはC++レキサー処理のロジックを実装することができます。DSLでは表現し切れないパーサを実現したい場合は、こちらに処理を移譲することで何でもできそうです。 今回書いてみたrbsのパーサでは必要無いものでしたが、tree-sitter-rubyではC++で書かれたscannerが利用されています。

tree-sitterのテスト

tree-sitterにはパーサのunit testを行う仕組みが用意されています。 test/corpus/ディレクトリ以下にテキストファイルで以下の様に記述します。

==================
class type
==================

type foo = String

---

(program
 (type_alias_decl
  (alias_name
   (variable))
  (type
   (class_type
    (class_name
     (constant))))))

==================
namespaced class type
==================

type foo = Foo::Bar::Baz

---

(program
 (type_alias_decl
  (alias_name
   (variable))
  (type
   (class_type
    (class_name
     (namespace
      (namespace
       (constant))
      (constant))
     (constant))))))

この様に記述してtree-sitter testコマンドを実行すると、コードの断片をパースして、結果のシンタックスツリーを期待した構造と比較しテストできます。

tree-sitterによるシンタックスハイライト

tree-sitterにはqueryという仕組みがあります。(https://tree-sitter.github.io/tree-sitter/using-parsers#pattern-matching-with-queries)

このクエリを利用してマッチする構造にマーキングしていくことでハイライト対象を認識します。

queries/highlights.scmというファイルにそのルールを記述します。

tree-sitter-rbsのルールの一部を抜粋すると以下の様な形になります。

[
  "class"
  "module"
  "interface"
  "type"
  "def"
  "attr_reader"
  "attr_writer"
  "attr_accessor"
  "end"
  "alias"
] @keyword

[
 "def"
] @keyword.function

(include_member "include" @function.method)  
(extend_member "extend" @function.method)  
(prepend_member "prepend" @function.method)  

特定の文字列のトークンとマッチすれば@keywordというマークが付きます。include_memberというツリー構造の中で"include"が出てきた時には@function.methodというマークを付けます。

このクエリには構造マッチ以外に追加で条件を加えることもできます。

(
  (pair
    key: (property_identifier) @key-name
    value: (identifier) @value-name)
  (#eq? @key-name @value-name)
)

この例では、pairというツリー構造のkeyというフィールドとvalueというフィールドの中身が同じ時にだけマッチします。

どういうマークを付けるとneovimで利用できるかは https://github.com/nvim-treesitter/nvim-treesitter/blob/master/CONTRIBUTING.md に書かれています。

シンタックスハイライトのテスト

tree-sitterではシンタックスハイライトもテストが可能です。

test/highlight/ 以下にソースコードを記述します。この時ちょっと面白い書き方をします。

class Foo[A]
  # <- keyword
        # <- type
          # <- constant
  def foo: (String) -> String
   # <- keyword
      # <- function.method
              # <- type
                        # <- type
end

この様にして、下の行にコメントを追加し、そのコメントの開始地点に期待されるマークが何なのかを記述します。 一つ一つの指定がアサーションになり、期待値と一致しているかを検証してくれます。

neovimでハイライトを使う場合の注意点

neovimで利用する場合、nvim-treesitterというプラグインを使うのですが、nvim-treesitterは微妙にtree-sitterのプロジェクト構造と要求するファイルの位置が異なっています。

具体的にはrbsのパーサであれば、neovimのruntimepathのいずれかの中にqueries/rbs/highlights.scmという形で配置する必要があります。これが微妙に面倒臭いので、理想的にはnvim-treesitterにPRを出して取り込んでもらうのが良さそうです。

今のところ自分が書いたtree-sitter-rbsでは、queries/rbs/以下にクエリファイルを置きつつ、tree-sitter testで認識できる様にシンボリックリンクを貼って調整しています。 こうすることで、neovimのパッケージ管理マネージャーでインストールし、パーサをコンパイルするだけで利用できる様にしています。

最後に

今回、tree-sitterならDSLあるしrbsのパーサぐらいならサクっと書けるんじゃないかと思って書いてみましたが、それでもまともに動くものを作るのに2日ぐらいはかかりました。rubyのパーサなんか絶対自分じゃ書きたくないですねw 面倒臭過ぎる……。 そんなパーサをゴリゴリ改善していってくれているKevinさんと金子さんには尊敬の念を禁じ得ません。

tree-sitter-rubyはかなり頑張っているパーサなのですが、それでもパース不可能なケースはいくつかあります。こういったユニバーサルパーサ実現に向けた流れの中で、tree-sitter-rubyに相当するものが自動で生成される様な世界になるとテキストエディタ界隈にもメリットがありそうです。