Slide 1

Slide 1 text

Rustで作る tree-sitterパーサーのRubyバインディング @joker1007 (Repro inc.)

Slide 2

Slide 2 text

自己紹介 橋立友宏 (@joker1007) Repro inc. チーフアーキテクト 人生における大事なことは ジョジョから学んだ 日本酒とクラフトビールが好き Asakusa.rb メンバー

Slide 3

Slide 3 text

パーフェクトRuby著 者の一人 C拡張の書き方のパートを書いた。

Slide 4

Slide 4 text

今のところ企画は無いんだけど、 パRubyを改訂する時にRustで書くRubyGemとか入 れたいなあと思ってた。 多分、国内のRuby書籍でそれについてまとめた本は 無かったと思う。

Slide 5

Slide 5 text

ということで、開発方法をちゃんと身に付けておきた かった。 また、Rustで実のあるコードを書いて勉強する機会に もなる。

Slide 6

Slide 6 text

今回ネタにしたtree-sitterについて Rustで書かれたパーサージェネレーター (皆大好きですよね) JSで記述するDSLによってパーサーのルールを記述する 色々な言語のパーサーと各言語から利用するためのライブラリがある つまりユニバーサルパーサー neovimのシンタックスハイライトやアウトライナーに採用されている より詳しくはこちらを! see. tree-sitter-rbsで作って学ぶRBSとパーサージェネレーター - Speaker Deck

Slide 7

Slide 7 text

tree-sitterのライブラリサポート状況について C, Go, Node, Python, Rust, Swiftは公式でサポートされていて、tree-sitterの機能でベ ースとなるスケルトンが生成されるが、Rubyは公式サポートが無い。 Rustのライブラリがあるなら、短期間でそれなりのものが作れるかも、と思ったので やってみた。 (実は、去年の時点で実用的なサードパーティ製のRubyバインディングがCで開発され てたので使いたい人はそっちを使う方がいいと思いますが……車輪の再発明上等ってこ とで)

Slide 8

Slide 8 text

tree-sitterの各種パーサーが出力するもの tree-sitterで定義されたparserが最終的に出力するものはCのソースコードとMakefile で、コンパイルをすると共有ライブラリが出来る。(.soとか.dyldとか) つまり、どの言語からパーサーを利用するにせよ、Cからコンパイルされた共有ライブ ラリを利用する必要がある。そのため各言語のAPIライブラリはFFIを利用したCのラッ パーになっている。

Slide 9

Slide 9 text

まず、今回作ったもの https://github.com/joker1007/tree_house

Slide 10

Slide 10 text

require "tree_house" require "rouge" TreeHouse.register_lang("ruby", "./libtree-sitter-ruby.so") parser = TreeHouse::Parser.new parser.set_language("ruby") source = File.read("./sample.rb") puts "== Source ==" puts source puts "\n" puts "== Tree ==" tree = parser.parse(source) puts tree.root_node.to_sexp

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

本題へ

Slide 13

Slide 13 text

RustによるRubyGem開発の始め方 Bundlerが雛形の生成をサポートしているのでそれを使います。 bundle gem --ext=rust gem_name

Slide 14

Slide 14 text

雛形の特徴 gemspecに spec.extensions = ["ext/gem_name/Cargo.toml"] プロジェクトルートにCargo.tomlがありworkspaceが指定されている workspaceとなるext/gem_nameにもCargo.tomlがあり、ここに依存ライブラリ などを記述する Rustをコンパイルするためにrb-sys gemがGemfileに追加される

Slide 15

Slide 15 text

Cargo.toml in workspace [package] name = "gem_name" version = "0.1.0" edition = "2021" authors = ["joker1007 "] publish = false [lib] crate-type = ["cdylib"] [dependencies] magnus = { version = "0.6.2" } # 雛形は最新ではない場合があるので更新推奨

Slide 16

Slide 16 text

magnusについて RustでRuby APIとやりとりするための高機能ライブラリ。 rb-sysというより低レイヤなライブラリがあり、それに依存している。 magnusにはRustのオブジェクトをRubyのTypedDataとして扱うための便利なマクロ や、Rubyのオブジェクトを定義・生成するための便利なAPIがある。 rb-sysはbindgenというライブラリを利用して、CのAPIから自動生成されたコードが ベースになっている。 ほとんどCのAPIの直接的なインターフェースで、magnusの利用者は余り意識しなく ても何とかなる。 利用する可能性が高いのは、rubygemとしてmkmfを拡張してる部分で、rustcのオプ ションなどを弄りたい時はそのAPIを調査する。

Slide 17

Slide 17 text

magnusのエントリポイント magnus::initというAttribute Macroを利用する。 #[magnus::init] fn init(ruby: &Ruby) -> Result<(), Error> { let namespace = ruby.define_module("TreeHouse")?; namespace.define_singleton_method("register_lang", function!(register_lang, 2))?; namespace.define_singleton_method("available_langs", function!(available_langs, 0))?; // ... let point_class = namespace.define_class("Point", ruby.class_object())?; point_class.define_singleton_method("new", function!(data::Point::new, 2))?; point_class.define_method("hash", method!(::hash, 0))?; point_class.define_method("==", method!(::is_eql, 1))?; point_class.define_method( "eql?", method!(::is_eql, 1), )?; }

Slide 18

Slide 18 text

magnus::init Attribute Macroが対象になっている関数を Init_ というC拡張のエントリ ポイントに変換してくれる。 C拡張を書いたことがあれば、ここでクラスやモジュールを定義して、メソッドに対応 する関数を紐付ければ良いことが分かる。

Slide 19

Slide 19 text

Rustの構造体をRubyのクラスとして利用する #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] #[magnus::wrap(class = "TreeHouse::Point", free_immediately)] // wrapマクロが構造体に必要な変換処理を自動実装する pub struct Point { pub row: usize, pub column: usize, } impl Point { pub fn new(row: usize, column: usize) -> Self { Self { row, column } } pub fn inspect(&self) -> String { format!("#", self.row, self.column) } pub fn to_s(&self) -> String { format!("({}, {})", self.row, self.column) } pub fn get_row(&self) -> usize { self.row } pub fn get_column(&self) -> usize { self.column } }

Slide 20

Slide 20 text

メソッドの割り当て // hash, is_eqlはマクロが自動実装している let point_class = namespace.define_class("Point", ruby.class_object())?; point_class.define_singleton_method("new", function!(data::Point::new, 2))?; point_class.define_method("hash", method!(::hash, 0))?; point_class.define_method("==", method!(::is_eql, 1))?; point_class.define_method( "eql?", method!(::is_eql, 1), )?; point_class.define_method("row", method!(data::Point::get_row, 0))?; point_class.define_method("column", method!(data::Point::get_column, 0))?; method! マクロが、メソッドのシグネチャを見てRubyのValueとして変換可能である かを検証し、define_methodの引数として適した形に変換してくれる。

Slide 21

Slide 21 text

基本的にはこれだけで良い 簡単そうに見える……が そんなに単純ではなかった

Slide 22

Slide 22 text

Rustはメモリ安全に非常に配慮した言語なので、 他の言語では余り気にしなかった箇所を意識する必要 がある。 つまり所有権とライフタイムの概念、またいくつかの 言語上の制約が、Rubyの世界とやり取りする時にど う関わってくるかを知る必要がある。

Slide 23

Slide 23 text

メソッドシグネチャの基本ルールと所有権 GitHubのリポジトリに型変換のルールが書かれている。 表がスライドに収まらないので表自体は割愛するが、かなりのバリエーションがある ので基本的な規則を覚えた方が良い。

Slide 24

Slide 24 text

RustがRubyから受け取れるもの 基本的にRust側で受け取れるのは以下の通り。 複製が容易な基本型(i32, u32, String etc.) TypedDataに変換可能な型(wrapマクロを付与したstruct)への参照型 上記の型の実体を typed_data::Obj でラップしたもの、これはラップしてい る型自体がポインタになっているので全体としては参照を意味する RubyのValueをラップした型(RString, RArrayなど) 汎用Value型 ライブラリの観点から言うとTryConvertトレイトを実装している型

Slide 25

Slide 25 text

RustがRubyに返せるもの 逆に基本的にRust側からRubyに返せるものは以下の通り。 複製が容易なプリミティブな型 unit型(空タプル) TypedDataに変換可能な型(wrapマクロを付与したstruct)の実体 上記の型の実体をOption, Resultでラップしたもの 汎用Value型 ライブラリの観点から言うとIntoValueトレイトを実装している型

Slide 26

Slide 26 text

考え方 基本的にmagnusにおいては、Rust側は参照を受け取って、Ruby側に所有権のある型 を返す形になっている。 参照を返そうとしても、Rustは必要がなくなったらすぐに実体のメモリを開放してし まう。 そうなると、参照だけではオブジェクトを維持できないので、structの所有権そのもの をRubyの世界に返さなければならない。 一方で、Rustの方で受け取るのはRubyの世界でメモリ管理されているオブジェクト情 報であり、もし所有権そのものを受け取ってしまったら、Rust側でメソッドが終了時 に別の値を返すとスタックから消えてしまって、オブジェクトが維持できなくなる。

Slide 27

Slide 27 text

メソッドシグネチャのパターン 上記を踏まえた上でメソッドシグネチャにはいくつかのパターンがある。

Slide 28

Slide 28 text

オブジェクトの持ってるコピー可能なデータを返す場 合 fn sample1(&self) -> usize { self.length } Rustの基本的なメソッド定義方法をそのまま使える。

Slide 29

Slide 29 text

オブジェクト自体を返したり、RubyのAPIを使いたい use magnus::typed_data::Obj; fn sample2(ruby: &Ruby, rb_self: Obj) -> Obj 第一引数 &Ruby 型にして、第二引数をselfのRubyオブジェクト表現にすると、 magnusで定義されているRuby API呼び出しのための参照が得られる。 &Ruby 型を経由すると、新しいクラスを定義したり、メソッドがブロックを受け取っ ているか調べたりできる。 ObjはRTypeDataとして扱えるstructへのポインタを示す型。 メソッドの戻り値として使えて、更にDeRefトレイトによりstructそのものと同じ様に メソッドが呼べる様になっている。 また、funcallやivar_setなどRubyオブジェクトとして扱いたい時のためのメソッドも 生えてて便利。

Slide 30

Slide 30 text

エラーが発生する可能性がある fn sample3(&self) -> Result { Result型を利用してエラー時はmagnus::Error型を返せる様にしておく。 Ruby関係のAPIの多くが Result を返すので、 ? suffixが使えて エラーハンドリングがしやすくなる。

Slide 31

Slide 31 text

メモリ安全のためのRustの制約と magnusの関係について

Slide 32

Slide 32 text

Rubyクラスへの参照とRustのstatic変数 例えば、Rubyで定義されたクラスを、Rustの中で呼び出してインスタンスを作成した いとする。 magnusのAPIドキュメントのRClassの箇所を読むとこういう例がある。 use magnus::{eval, RClass}; assert!(RClass::from_value(eval("String").unwrap()).is_some()); この取得したものを、他の箇所から簡単に参照可能にできるかというと、Rustではそ う簡単ではない。 static変数というものはあるが、基本的にRustのstatic変数は後から変更できない。 実行時に変更可能なstatic変数を扱おうとすると、unsafeになる。

Slide 33

Slide 33 text

Rustのstatic変数の扱い方 もちろんRustでもそういう要件には対処しなければならない。Rustではこういう時に はonce_cellやlazy_static!マクロなどを使って実現していた様だ。 しかし、これにも制約があり、SendとSyncトレイトを実装した型にしか利用できない らしい。 RClassなどのmagnusが提供している構造体は、Rubyの構造体に対するポインタを含 んでいて、Rubyの文脈以外で扱うと危険なので、SendでもSyncでもない。 流石に必要になる度に毎回 eval で取得は辛過ぎる。 これはunsafeなstaticを使うしかないのか?

Slide 34

Slide 34 text

magnus::value::Lazyを使う magnusライブラリはそういう用途をちゃんと考慮している。 staticに割り当てておいて、必要になった時点でValueとして表現可能な構造体への参 照に変換できる型が用意されている。 Lazyを利用するとさっきのコードはこうなる。 static STRING_CLASS: Lazy = Lazy::new(|ruby| RClass::from_value(eval("String").unwrap()).unwrap()); #[magnus::init] fn init(ruby: &Ruby) { Lazy::force(&STRING_CLASS, ruby); // requireされた時にRubyの情報が取れるので、その時にLazyの内容を確定させる } fn build_string(ruby: &Ruby, rb_self: Value) -> String { let string_class = STRING_CLASS.get_inner_with(ruby) string_class.new_instance(()).unwrap() }

Slide 35

Slide 35 text

この例の様に単純な例では分かりにくい落とし穴がい くつかある APIドキュメントを良く読めば何とかなることは多いのだが、APIドキュメントは事例 集や逆引きレシピの様なものではないため、分かっていない状態で必要な情報を探す のは難しい。

Slide 36

Slide 36 text

ハマりポイント1: Enumeratorを返す方法 Rubyでeach系のメソッドを定義する場合は、ブロックを受け取ったらyieldし、ブロ ックが無ければEnumeratorを返すのが一般的。 しかし、Rustの型でそれを表現するには一工夫必要になり、これまでに説明したメソ ッド定義の方法の例外となる。

Slide 37

Slide 37 text

Enumeratorのサンプル pub fn children<'cursor>( ruby: &Ruby, rb_self: typed_data::Obj, ) -> Result>>, Error> { let mut cursor = rb_self.raw_node.walk(); let nodes = rb_self.raw_node.children(&mut cursor); let array = ruby.ary_new_capa(nodes.len()); for n in nodes { let node = Self { raw_tree: Arc::clone(&rb_self.raw_tree), raw_node: n, }; array.push(node)? } array.freeze(); if ruby.block_given() { Ok(Yield::Iter(array.into_iter())) } else { Ok(Yield::Enumerator(rb_self.enumeratorize("children", ()))) } }

Slide 38

Slide 38 text

遅延イテレーターを返すのが難しい Rustで作ったIteratorの中に参照が残ってると、Ruby側に返した後に消えてしまうの で、評価を後回しにしようとしてもそう簡単にはいかない。 そのため、前述のコードでは先に全部評価してRArrayに変換した後で、所有権ごと引 き渡すIteratorに変換し Yield::Iter に包んでいる。 必要なものを全部まとめたIteratorを実装して最初にnextが呼ばれた時に内側を準備す ればいけるか? 加えて、Rubyオブジェクトをヒープに入れてはいけないという注意点もある。Rust側 でヒープに入れてしまうと、RubyのGCで辿れなくなってメモリが開放できなくなるら しい。なので気軽にVec型とか使えない。

Slide 39

Slide 39 text

ハマリポイント2: GCからの保護 parser = TreeHouse::Parser.new.tap do |p| p.set_language("ruby") end node = parser.parse(source).root_node node.child(0).child(0) GC.start node.child(0).child(0) # => die 最初に何も考えずに実装した時は、このコードはSegmentation Faultを起こした。 どうしてでしょうか?

Slide 40

Slide 40 text

何故SEGVが起きたか parser.parse(source) はTreeオブジェクトを返すが、Rubyコード上では変数に割り 当てられてないので参照が保持されていない。 よって、GCによってTreeオブジェクトが回収されて、Rustの世界でもTreeオブジェク トのメモリが開放される。 一方で、Nodeオブジェクトの実体はTreeの一部であって、Treeが保持していたNode へのポインタなので、Nodeを触ろうとしても既に参照先のメモリは開放されており、 SEGVとなる。

Slide 41

Slide 41 text

初期実装 pub struct Tree { raw_tree: tree_sitter::Tree, } pub struct Node<'tree> { pub raw_node: tree_sitter::Node<'tree>, } シンプルにRustライブラリが返すstructをwrapしていた。 Rustのライフタイム表現でNodeはTreeより長生きできないことが分かるが、それは RubyのGCの動きとは関係がない。 structから直接参照できる範囲にTreeが無いので、Rubyの世界ではオブジェクト同士 の関係も分からない。

Slide 42

Slide 42 text

改修後 pub struct Tree { raw_tree: Arc, } pub struct Node<'tree> { pub raw_tree: Arc, pub raw_node: tree_sitter::Node<'tree>, } ArcはRustでAtomicなReference Countを実現するためのスマートポインタと呼ばれて いる型。 Arcを利用して、TreeオブジェクトからNodeの参照を取り出す時に、Nodeが全てGC されてReference Countが0にならない限り、参照先のTreeがメモリから開放されない 様にした。 (ドキュメントにはBoxValueというGCからオブジェクトを保護するための型があった が、制約が色々あって利用できなかった)

Slide 43

Slide 43 text

GCムズイ……

Slide 44

Slide 44 text

おまけ

Slide 45

Slide 45 text

Rustでsoファイルを読み込む libloading crateを利用するのが良さそう。 https://github.com/nagisa/rust_libloading/

Slide 46

Slide 46 text

use libloading::Library; fn register_lang(lang: String, path: String) -> () { let func_name = String::from("tree_sitter_") + 〈 let libraries = LANG_LIBRARIES.get_or_init(|| Mutex::new(HashMap::new())); let languages = LANG_LANGUAGES.get_or_init(|| Mutex::new(HashMap::new())); unsafe { let mut libraries = libraries.lock().unwrap(); let lib = libraries.entry(lang.to_string()).or_insert_with(|| { let loaded = Library::new(path).expect("Failed to load library"); loaded }); let func: libloading::Symbol *const TSLanguage> = lib.get(func_name.as_bytes()).unwrap(); language = tree_sitter::Language::from_raw(func()); let mut languages = languages.lock().unwrap(); languages.insert(lang.to_string(), language); }; }

Slide 47

Slide 47 text

まとめ Rustで書いてみてRustの肝である所有権やライフタイムについて大分勉強になっ た Cと比較すると、メモリ安全のための制約で頭を悩ませることが多いが、文字列の 扱いやクロージャやIteratorなどリッチな機能が使える点はとても便利 Cargoで依存ライブラリが管理できるので、何らかのライブラリのバインディン グを書く時にはソースコード管理に悩まなくて済む Cでも同じだが、GCによって何が開放されるのか理解してないと、SEGVに繋が る。パッと見動くので難しい

Slide 48

Slide 48 text

RustでRubyGemを書く時の参考になれば幸いです