Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

SN bindgen: CヘッダファイルからScalaの型定義を生成する + ネイティブにtree-sitterを呼び出してみた

ScalaをネイティブバイナリにコンパイルするプロジェクトであるScala Nativeというのがある。

scala-native.org

2023年現在では多くのScalaライブラリがScala Nativeに対応しているため、特に難しい対応をしなくてもネイティブバイナリを得られるようになってきた。

この記事では、SN bindgenというツールを利用してネイティブライブラリのヘッダファイル(なんとか.h)から自動的にScalaの型定義をコード生成し、これをもとにネイティブライブラリを呼び出す方法を紹介する。

Scala Nativeを利用したネイティブライブラリとの相互利用

Scala NativeはLLVM(clang)を利用してネイティブバイナリを得るので、ネイティブライブラリとの相互利用も行える(Scala Nativeの公式ページより抜粋):

@extern object stdlib {
  def malloc(size: CSize): Ptr[Byte] = extern
}

val ptr = stdlib.malloc(32)

こんな感じでmallocを呼び出すためのファサードを書くことができる。externはマクロになっていて*1、自動的にネイティブの呼び出しコードに変換される。

この方法で原理的にはあらゆるネイティブライブラリを呼び出すことができるのだが、毎回手でCヘッダファイルのAPIに合わせた型定義を、以下のページを参考にして行わなければならないという手間があった:

scala-native.org

一部のライブラリの一部の関数を利用するだけならまだしも、本格的にライブラリの機能を利用したい場合は結構不便である。

同じくScalaをJVM以外の言語にコンパイルするプロジェクトであるScala.jsには、ScalablyTypedというTypeScriptの型定義ファイルをScalaのファサードへと変換するプラグインが存在しており、簡単にTSライブラリを呼び出せるようになっているのだが、Scala Nativeにはこのようなプラグインはないのだろうか?

と思って検索していたところ、便利そうなのがあった。

sn-bindgen.indoorvivants.com

SN bindgenは、Cのヘッダーファイルを読み込み、自動的にScalaのファサードを生成してくれるという。

便利そうなので試しに使ってみることにした。今回はtree-sitterのAPIを呼び出してみる。tree-sitterのapi.hをもとにScalaの型定義を生成してみよう。

tree-sitterの準備

tree-sitterでパースを行うには言語の文法データ(これもコンパイル済みの.soファイルか.aファイルで提供される)が別途必要になるため、tree-sitterに加えてtree-sitter-jsonもコンパイルし、/usr/local/lib/にインストールしておく。

github.com

github.com

# (両ディレクトリで)
$ make
# デフォルトでは/usr/local/libにインストールされる
$ sudo make install

プロジェクトのセットアップ

まず、Scala Nativeが有効なsbtプロジェクトを作成する。sbt new scala-native/scala-native.g8を行うと、最初からScala Nativeが有効なプロジェクトを生成できる。

% sbt new scala-native/scala-native.g8
name [Scala Native Seed Project]: tree-sitter-scala                                                                                    

project/plugins.sbtでSN bindgenを追加する:

addSbtPlugin("com.indoorvivants" % "bindgen-sbt-plugin" % "0.0.23")

次に、Clang/LLVMをインストールしておく。SN bindgenの都合で、LLVMのバージョンは14になる。必要に応じて色々インストールしてほしい。

% sudo zypper in llvm14-libclang13 clang14

さらに、SN bindgenの都合でScalaのバージョンが制約される。今回はScala 3.3.0を利用するのでbuild.sbtをそのように書き換える(執筆時点のデフォルトでは3.3.1になっている)。

scalaVersion := "3.3.0"

加えて、SN bindgenを有効化する。

enablePlugins(ScalaNativePlugin, BindgenPlugin)

Scala NativeがCライブラリファイルをリンクできるように、リンク/コンパイル設定を行う:

import scala.scalanative.build._

nativeConfig := {
  val base = baseDirectory.value
  val c = nativeConfig.value
  c.withLTO(LTO.none) // リリース時はthin
    .withMode(Mode.debug) // リリース時はreleaseFast
    .withGC(GC.immix) // リリース時はcommixも選択できる
    .withLinkingOptions(
      // -l オプションはライブラリを利用する(lib○○.soやlib○○.aを探そうとする)
      // -L オプションはライブラリの捜索先を追加する
      List("-ltree-sitter", "-ltree-sitter-json", "-L", "/usr/local/lib/")
    )
}

最後に、利用したいライブラリのヘッダファイルの情報を追加する。

import bindgen.interface.Binding
bindgenBindings := Seq(
  Binding
    .builder(
        (Compile / resourceDirectory).value / "scala-native" / "api.h",
      /* ファサードの生成先となるパッケージ名 */  "treesitter"
    )
    .build
  )
import bindgen.interface.Binding
bindgenBindings := Seq(
  Binding
    .builder(
      /* ファサードの生成元となるヘッダファイルのパス */  (Compile / resourceDirectory).value / "scala-native" / "api.h",
      /* ファサードの生成先となるパッケージ名 */  "treesitter"
    )
    .addCImport("api.h") /* 変換後のファサードを利用するのに必要なヘッダファイル */
    .build
  )

これでbuild.sbt側の準備は整った。全体は以下の通りになっているはず。

scalaVersion := "3.3.0"

enablePlugins(ScalaNativePlugin, BindgenPlugin)

// set to Debug for compilation details (Info is default)
logLevel := Level.Info

// import to add Scala Native options
import scala.scalanative.build._

// defaults set with common options shown
nativeConfig := {
  val base = baseDirectory.value
  val c = nativeConfig.value
  c.withLTO(LTO.none) // thin
    .withMode(Mode.debug) // releaseFast
    .withGC(GC.immix) // commix
    .withLinkingOptions(
      List("-ltree-sitter", "-ltree-sitter-json", "-L", "/usr/local/lib/")
    )
}

import bindgen.interface.Binding
bindgenBindings := Seq(
  Binding
    .builder(
      (Compile / resourceDirectory).value / "scala-native" / "api.h",
      "treesitter"
    )
    .addCImport("api.h")
    .build
  )

ヘッダファイルの準備

build.sbtで指定した場所に、tree-sitterのヘッダファイルをコピーしておく。このデータをもとに型定義が生成される。

# tree-sitterのプロジェクトで
% cp lib/include/tree_sitter/api.h ~/temp/path-to-my-project/src/main/resources/scala-native/api.h

コンパイル

この状態でsbt runを実行すると、自動的にapi.hの型定義が生成されつつ、デフォルトのメッセージが表示されるはず。

% sbt run
Hello, world!

tree-sitterを呼び出してみる

さて公式ページを参考に、パーサを呼び出してみる。

tree-sitter.github.io

今回はAPIのts_tree_print_dot_graph関数を使って、Graphvizでレンダー可能なグラフを出力してもらう。

import treesitter.all.*
import scalanative.libc.string.strlen
import scalanative.unsafe.*
import scalanative.unsigned.UnsignedRichInt

// dynamic link
def tree_sitter_json(): Ptr[TSLanguage] = extern

object Main {
  // mallocした領域のfree忘れがないように、mallocをともなう関数はZoneというスコープが要求されるようになる(便利!)
  def main(args: Array[String]): Unit = Zone { implicit zone =>
    val parser = ts_parser_new()
    ts_parser_set_language(parser, tree_sitter_json())
    val source = c"""{"foo":42, "bar": "windymelt"}"""
    val tree = ts_parser_parse_string(parser, null, source, strlen(source).toUInt)
    ts_tree_print_dot_graph(tree, 1 /* stdout*/)
  }
}

これを実行すると以下のような出力が得られる。

% sbt run
digraph tree {
edge [arrowhead=none]
tree_0x7ffc1b8d0d08 [label="document", tooltip="range: 0 - 30
state: 0
error-cost: 0
has-changes: 0
...

これをdotに食わせてみた。

% ./target/scala-3.3.0/tree-sitter-scala-out | dot -Tpng > json.png

するとこのようなグラフが得られた。

tree-sitterのC APIを経由して生成したグラフ

きちんとJSONをパースできている。

バイナリをlddで確認すると、ライブラリがダイナミックリンクされていることがわかる:

% ldd ./target/scala-3.3.0/tree-sitter-scala-out
        linux-vdso.so.1 (0x00007ffd31f9d000)
        libtree-sitter.so.0 => /lib64/glibc-hwcaps/x86-64-v3/libtree-sitter.so.0.0 (0x00007faed942f000)
        libtree-sitter-json.so.0 => /usr/local/lib/libtree-sitter-json.so.0 (0x00007faed942a000)
        libstdc++.so.6 => /lib64/libstdc++.so.6 (0x00007faed9000000)
        libm.so.6 => /lib64/libm.so.6 (0x00007faed9341000)
        libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00007faed931c000)
        libc.so.6 => /lib64/libc.so.6 (0x00007faed8c00000)
        /lib64/ld-linux-x86-64.so.2 (0x00007faed9475000)

まとめと感想

SN bindgenを利用することで、CライブラリのヘッダファイルをもとにScalaの型定義を生成できることがわかった。同じことをTypeScriptに対して行うScalablyTypedはかなり遅かったが、SN bindgenは非常に高速に動作する印象だ。

また、SN bindgenが生成した型定義をもとに、Scala Native(が呼び出しているリンカやclang)のオプションを利用して外部ライブラリ(今回はlibtree-sitter.solibtree-sitter-json.so)をダイナミックリンクし、直接動作するバイナリを生成できることがわかった。

おまけ: 静的リンクする

静的リンクするにはそれ用のglibc-staticをまず用意しておく。

% sudo zypper install glibc-static

次にリンカオプションに-staticを追加する。

// ...
    .withLinkingOptions(
      List("-ltree-sitter", "-ltree-sitter-json", "-L", "/usr/local/lib/", "-static")
    )
// ...

再びsbt nativeLinkすると、静的リンクされたバイナリが得られる:

% ldd target/scala-3.3.0/tree-sitter-scala-out
        動的実行ファイルではありません

ファイルサイズは9MiBくらいになった。stripしたりupxしたらもっと小さくなる(stripしたら2.8MiBになり、さらにupxしたら1MiBになった)。

% ls -lah target/scala-3.3.0/tree-sitter-scala-out
Permissions Size User      Date Modified Name
.rwxr-xr-x  9.2M windymelt 31 12月 20:42 target/scala-3.3.0/tree-sitter-scala-out*

補足

ちなみに公式サンプルとしてtree-sitterを利用するコードもある。こちらではsn-vcpkgというツールを使って、C/C++用のパッケージマネージャからソースコードを取得してコンパイルしたりしている。

github.com

あわせて読みたい

blog.3qe.us

blog.3qe.us

*1:たぶん

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?