RustからC言語で作られたライブラリを呼び出すとき、その橋渡し(FFI:Foreign Function Interface)をするのが -sys クレートと呼ばれるパッケージです。
しかし、C言語の関数や構造体の定義を一つ一つRust側で手作業で定義するのは保守コストが高く現実的ではありません。
そこで、CのヘッダーファイルからRustのコードを自動生成してくれる bindgen という便利なツールがよく使われています。

Rubyの新しいパーサーである Prism も、Rustから使えるように ruby-prism-sys というクレートを提供しています。
最近、このクレートで「bindgen で生成したコードを、あらかじめリポジトリにコミットしておく」という変更(PR #4179)がマージされました。

この記事では、「なぜわざわざ自動生成ツールのアウトプットをコミットする必要があったのか?」を解説します。
また、複数の環境(特に32-bit環境)で動くようにするための工夫についても、実際の実装をもとに紹介します。

なぜ事前生成が必要になったのか?

bindgen を使う場合、通常は build.rs(クレートをビルドするときに最初に実行されるスクリプト)の中で、その都度コードを生成します。

// build.rs (従来の方式)
fn main() {
    let bindings = bindgen::Builder::default()
        .header("prism.h")
        .generate()
        .expect("Unable to generate bindings");

    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings.write_to_file(out_path.join("bindings.rs")).unwrap();
}

この方法には大きな弱点があります。それは、クレートを使う人全員に libclang のインストールを強いてしまうことです。

bindgen はC言語のコードを正確に読み取るために、裏側で LLVM の libclang というライブラリを使っています。
つまり、ruby-prism-sys を自分のプロジェクトで使おうとした人は、Rustの環境だけでなく LLVM までインストールしなければビルドできません。

これは環境構築のハードルを上げるだけでなく、CI(自動テスト環境)の実行時間を長くしてしまいます。
また、別のOS向けのビルド(クロスコンパイル)を行うのも非常に難しくなります。
できれば、シンプルなCコンパイラ(gcc や clang など)さえあればビルドできる状態が理想的です。

この問題を避けるため、libsqlite3-sys などの有名なクレートでは、開発者が生成した bindings.rs を最初からリポジトリに保存(コミット)しておく手法が取られています。
ruby-prism-sys もこれに倣い、ユーザーの環境から libclang への依存をなくすことにしました。

単純なコミットを阻む「移植性」の壁

開発者の環境で生成したファイルをそのままコミットすれば解決するように思えますが、ここには大きな落とし穴があります。

bindgen はデフォルトで、「生成した構造体のサイズが、元のC言語のサイズと合っているか」をチェックするテストコードを出力します(bindgen 0.72以降は #[test] 関数ではなく const アサーションとして出力されます)。

// bindgenがデフォルトで出力するレイアウト検証コードの例
const _: () = {
    assert!(::std::mem::size_of::<pm_node_t>() == 24usize);
    // ...
};

もし開発者が 64-bit の環境(macOS arm64 や Linux x86_64 など)でこのファイルを生成すると、テストコードには「64-bit環境でのサイズ」が固定値として書き込まれて(ハードコードされて)しまいます。

このファイルをそのままリポジトリにコミットし、別の開発者が 32-bit 環境(例えば WebAssembly の wasm32-wasip1)でビルドしようとするとどうなるでしょうか。
32-bit環境での実際のサイズと、コードに書き込まれた64-bit用のサイズが合わず、const-eval overflow といったコンパイルエラーが起きてしまいます。

layout_tests(false) で環境に依存しないコードにする

この問題を解決するのが layout_tests(false) というオプションです。

// https://github.com/ruby/prism/blob/f277c701714c6fa487f7bb2251552a11c0513df7/rust/ruby-prism-sys/build/main.rs#L143-L156
let bindings = bindgen::Builder::default()
    // ...
    .header(ruby_include_path.join("prism.h").to_str().unwrap())
    // ...
    .layout_tests(layout_tests) // 引数でテストコードの出力をコントロール
    // ...
    .generate()
    .unwrap();

レイアウト検証のテストコードを出力しないように設定することで、特定の環境(64-bitなど)に依存するサイズ情報がコードに埋め込まれなくなります。
これで、どの環境に持っていってもコンパイルできる(ソースコード互換な)状態になります。
実際のPRでも、この設定で作ったコードが macOS (arm64) と Linux (x86_64) で一言一句同じになることが確認されています。

運用を支える自動化の仕組み

とはいえ、手作業でファイルを更新し続けるのはミスの元です。
ruby-prism-sys では、安全に運用を続けるためにいくつかの仕組みを導入しています。

1. 環境差異を吸収するための buildtime_bindgen フィーチャー

基本的にはコミットされたファイルを使いますが、特定の環境に合わせて動的にコードを作り直す必要があるケースに備え、従来通りのビルド時生成機能も残しています。

Cargo.toml で、使いたい人だけがオンにできるオプション(フィーチャー)として定義します。

# https://github.com/ruby/prism/blob/f277c701714c6fa487f7bb2251552a11c0513df7/rust/ruby-prism-sys/Cargo.toml#L24-L32
[build-dependencies]
bindgen = { version = "0.72", optional = true }

[features]
buildtime_bindgen = ["dep:bindgen"]

src/lib.rs では、このフィーチャーがオンかどうかで、読み込むファイルを切り替えるようにします。

// https://github.com/ruby/prism/blob/f277c701714c6fa487f7bb2251552a11c0513df7/rust/ruby-prism-sys/src/lib.rs#L31-L39
mod bindings {
    // デフォルト: リポジトリに保存されたファイルを使う
    #[cfg(not(feature = "buildtime_bindgen"))]
    include!("bindings.rs");

    // フィーチャー有効時: 従来通りビルド時に生成されたファイルを使う
    #[cfg(feature = "buildtime_bindgen")]
    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

2. 更新忘れを防ぐ CI でのチェック

C言語側のコードが変わったにもかかわらず、Rust側の bindings.rs の更新を忘れてしまう事故を防ぐ仕組みも導入しています。
GitHub Actions の自動テスト(CI)の中で、再生成と git diff を使って「ファイルが最新の状態か」をチェックします。

# https://github.com/ruby/prism/blob/f277c701714c6fa487f7bb2251552a11c0513df7/.github/workflows/rust-bindings.yml#L63-L77
sys-bindings:
  name: cargo:bindings
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v7
    # ...セットアップ処理...
    - name: rake cargo:bindings
      run: bundle exec rake cargo:bindings
    - name: Check that the pregenerated bindings are up to date
      run: git diff --color --no-ext-diff --exit-code -- rust/ruby-prism-sys/src/bindings.rs

さらに、移植性が保たれていることを保証するために、CIの中で 32-bit 環境(wasm32-wasip1)向けのコンパイル確認も行っています。

# https://github.com/ruby/prism/blob/f277c701714c6fa487f7bb2251552a11c0513df7/.github/workflows/rust-bindings.yml#L54-L59
- name: Check pregenerated bindings on wasm32-wasip1
  run: cargo check --target wasm32-wasip1 --no-default-features
  working-directory: rust/ruby-prism-sys

まとめ

Rustの -sys クレートで bindgen の出力をリポジトリにコミットする際は、以下の点に気をつけることで、ユーザーの使いやすさと安全性を両立できます。

  1. layout_tests(false) を指定し、環境に依存するテストコードを消す。
  2. 必要に応じてビルド時に再生成できるオプション(buildtime_bindgen)を残す。
  3. CIで再生成と git diff を実行し、ファイルの更新忘れを防ぐ。
  4. 32-bit環境(wasm32-wasip1 など)でもコンパイルできるかCIで確認する。

C言語側のコードが頻繁に変わる時期を過ぎたライブラリなら、この方法はとても効果的です。
利用者に libclang のインストールを求めないため、ライブラリ導入のハードルを下げることができます。
もし自分で -sys クレートを作る機会があれば、ぜひ参考にしてみてください。