😊

antlr-kotlinの使い方がわからなくて困った

2021/03/18に公開

antlr-kotlinをKotlin MPPで使うのが割と一筋縄ではいかなかったのでメモ。

動機

自分のプロジェクトにmugeneというC#で書かれたMMLコンパイラがあるのだけど、構文解析をjayというyacc系のツール…をC#に移植したやつ…をもとに作っていた。今でも現役で使っている。jayは2001年にmonoのC#コンパイラであるところのmcsを実現するための仕組みとしてJavaから移植されたもので、たいへん古いやつだ。

今回Android用にMIDIライブラリがほしいと思って、同じくC#で書いたmanaged-midiをKotlinに移植してktmidiとしたプロジェクトを(fluidsynth-midi-service-jというこれまた別のプロジェクトから抜き出して)公開したので、長らくC#からの移行先の言語・フレームワークを決めかねていたMMLコンパイラも、とりあえずKotlinで継続しようと考えた。

ktmidi自体はfluidsynth-midi-service-jである程度は(少なくともSMFをロードしてFluidsynthにMIDIメッセージを送る方式で自然に演奏できる程度には)実績がある。ktmidiはKotlin MPPのプロジェクトとして構築したけど、MIDIプレイヤーなど便利な部分はまだjvmMainにしか存在しない。でもSMF読み書きはMPPで出来るので、MMLコンパイラもMPPで構築することにした。

構文解析系の移植が一番やっかいな問題だ。yacc系列の構文で作成していたので、LALRで済ませられるなら楽だ(まあ再帰下降パーサで書き換えても良いのだけど)。そういうわけでKotlinで使えるLALRの処理系を探したところ、ANTLRが(無難に)引っかかってきた。

(以下、大文字で書き続けると目についてうざいのでだんだん適当にlowercaseで書くが、書き分けに深い意味はない。)

Kotlin or Java

ANTLR本家はJavaでコードを生成できるが、Kotlinのコード生成は外部プロジェクトを利用しなければならない。これをやってくれていたのがStrumenta/antlr-kotlinだ。Kotlinのコード生成系と、実行時に必要になるランタイム、antlrによるコード生成結果をビルドに組み込むGradle Pluginの3つをよろしくやってくれる。しかも生成されるKotlinのコードはKotlin MPPでも利用できるくらいプラットフォーム中立に出来ているらしく、Kotlin MPPで組み込むサンプルまで公開されている。

antlr-kotlin自体は割と消極的にメンテナンスしている状態だが(issueが登録されるたびに「誰かやってほしい」と作者コメントが出てくる)、Strumenta自体がANTLR Mega Tutorialを公開している程度にANTLRにコミットしていて、それなりのやっていきで作られたふいんきはある。

他のアプローチとしては、ANTLRの利用はあくまでJavaにとどめておいて、JVMプロジェクトでのみ組み込むというやり方も無くはない。自分のプロジェクト自体はJVMオンリーで困ることはない。ただMPPでプロジェクトを構築していたらKotlin以外のプラットフォームについて別々にコード生成して組み込む必要が生じるし、その実装はプラットフォーム別になる。JVMでしかビルドできないコードを書くことになると面倒なので、Kotlinで出来るならそっちでやらない理由はない。

サンプルプロジェクトの構成を流用する

まあ最初はJavaを使うようなややこしい構成については考えていなかった。antlr-kotlinをMPPで使うのが素直な手段に思えたからだ。ただ、MPPの構成はちゃんと自分でプロジェクトの構成を把握しておかないといけなくなる。というのは、antlr-kotlinをプロジェクトで使う場合、構文定義ファイル.g4からソースコードを自動生成するところまでgradleプラグインに任せることになるので、そのための設定がbuild.gradle(.kts)上に必要になるからだ。

単独でantlr4ツールを呼び出してKotlinのソースコードを生成できるような仕組みは、少なくとも開発者からは提供されていない。ツールを作るにはantlr本家のソースの構成を十分に理解しないとできないだろう。それは今回の目的から逸脱しすぎている。

antlr-kotlinには、サンプルプロジェクトがKotlin/JVMの構成、Kotlin/JS (newIRなし))の構成、そしてKotlin MPPの構成で用意されている。全てのターゲットについて、コードのコンパイル前に.g4からのコード生成タスクを呼び出さなければならない。これはMPPプロジェクトでは主に次のようなタスクの追加と、

// 長いので中のコメントは消した
tasks.register<com.strumenta.antlrkotlin.gradleplugin.AntlrKotlinTask>("generateKotlinCommonGrammarSource") {
    antlrClasspath = configurations.detachedConfiguration(
            project.dependencies.create("$groupProperty:antlr-kotlin-target:$antlrKotlinVersion")
    )
    maxHeapSize = "64m"
    packageName = "com.strumenta.antlrkotlin.examples"
    arguments = listOf("-no-visitor", "-no-listener")
    source = project.objects
            .sourceDirectorySet("antlr", "antlr")
            .srcDir("src/commonAntlr/antlr").apply {
                include("*.g4")
            }
    outputDirectory = File("build/generated-src/commonAntlr/kotlin")
}

次のような依存関係の記述の追加で実現している。

tasks.getByName("compileKotlinJvm").dependsOn("generateKotlinCommonGrammarSource")

Mavenパッケージのダウンロードでハマる

MPPサンプルのbuild.gradle.ktsの中には、他にもMavenパッケージ依存関係の記述がある。これが実はややこしい。単にややこしいだけならまだ良いが、ビルドに失敗する。build.gradle.ktsの最初のブロックはこんな感じだ:

val version: String by project
val versionProperty = version
val group: String by project
val groupProperty = if (group.endsWith(".antlr-kotlin")) {
    group
} else {
    "$group.antlr-kotlin"
}

これはどうやらこのサンプルの環境でしか役に立たないようだ。自分のプロジェクトでantlr-kotlinパッケージのグループ名を解決する場合は素直に com.strumenta.antlr-kotlin としないとビルドに失敗する。他にもハマったっぽい人がちょいちょいいる

build.gradle (Groovy)で書こうとして挫折する

あと、自分は最初これをbuild.gradle (Groovy)で再現しようとして、どうしてもAntlrKotlinTasksetAntlrClasspath()を呼び出す部分でconfigurations.detachedConfiguration(project.dependencies.create("$groupProperty:antlr-kotlin-target:$antlrKotlinVersion") に相当するGradleの式をFileCollectionとして認識してもらえなくて四苦八苦した。

最終的にはantlr-kotlinのサンプルと同じ.ktsにして作り直すことになった…(その作業自体は大して難しくはない。)

Kotlin/Nativeプロジェクトのビルドでクラッシュする

もう一つハマったのは、gradleでビルドしていてもnativeターゲットをビルドする時点でビルドが空回りになって先に進まない状態になることだ。最初は単にnativeターゲットのビルドが極端に遅いんだと思っていたが、よく見てみるとビルド自体はエラーで終了していて、IDEAがそれを検出できずにずっとビルドしている状態から先に進めなかっただけだった。エラーはコレだった: https://youtrack.jetbrains.com/issue/KT-44884

さすがに一時的な問題であって、この説明が役に立つ場面はほぼ無くなるだろうと期待しているが、ここでハマった場合はbuild.gradle.ktsからnativeビルドに関する部分を根こそぎコメントアウトすれば先に奨める。nativeで使いたかったという人はここで一時中断するしかない。

ともあれ、ここまで対処したら、ようやくビルドが通るようになった。(他にも自分のコードの移植が不十分で何度もビルドは試行しているけど、それをここで書いてもあんまし本文と関係ないだろう。)

top levelのleft recursionの展開でクラッシュする

jayで定義していたのをそのままantlrに移植した自分の構文定義では、トップレベルが次のようになっていた:

calls : call | calls call ;

antlrにはIDEAプラグインがあって、これにはANTLR Previewという機能がある。これを使うと、構文定義と入力文字列をテストして問題がないか確認できる。それでこの構文そのものには問題がないことが分かったのだけど、antlr-kotlinで生成したコードだと、これが実行時にNullPointerExceptionを起こして落ちる(詳細はすぐ説明する)。

完全に意味がわからなかったのだけど、スタックトレースを追ってみるとantlr-kotlinのruntimeのバグのようだった。ここで少しantlr-kotlin runtimeがどう作られているか説明すると、基本的にはそのソース本家antlr4のruntimeのJava実装を、IDEAからも呼び出せるJava to Kotlinのソースコンバータ(kotlinリポジトリではnj2k)によって変換したものを、さらに(たぶん)手作業でMPP化している(もしかしたらnj2kではなく古いj2kかもしれない)。

単純にKotlin/JVMのソースに変換すると、@Override やら @Throws やらが付いてきたり、java.*パッケージのクラスへの参照がそのまま残ってしまうので、必要に応じて自前でやっつけ実装を追加したりしているようだ。

nj2kでソースを変換しているとハマりがちなのがnullabilityの違いだ。この辺のnullabilityの判別をnj2kがどのように行っているかは把握していないが、今回はここがnon-nullableな型に変換されていたのが原因だろう。

いずれにせよ、これくらいなら直せると思って本家のコードを修正してissueとPRを作って登録した(説明を省いた詳細はここに全部書いてある)。何日か経過したけど全く反応がない。バグフィックスは全くやる気がないように見えたけどPRはマージしているんじゃないのか…まあ自分もしばらく不在にしていたりするし忙しいこともあるしな…と思ってのんびり反応を待っている。

謎のambiguity検出でハマる(未解決)

バグを直したらクラッシュはしなくなったが、今度は謎のambiguity errorが実行時に検出されて先に進めない。ANTLR Previewでは何も問題ないと言われているのに…。issuesを見てみると、どうやらleft-recursionの展開が期待通りになされないとか、割と大味なレベルのissueが放置されている状態のようだった。いや実際この手のバグは発生しがちではあるし、詳しく挙動を把握していないとデバッグもままならないのだけど…(monoのC#コンパイラの実装の一部を担当していた頃にjayでさんざん経験してきた)

このissueのコメントには、コメントアウトされているコードが無いのが原因であるかのように書かれていたので、一念発起して自力で全部uncommentしてビルドしてみたのだけど、自分が直面したバグがこれで修正されることはなかった。(issueの最後にgistを残しておいた。)

コメントアウトされているのは、ランタイムでも移植(少なくともビルド)に影響しなそうな部分だろうと思うが、これだけ挙動が雑だとそれが信用できる推測なのかは疑わしい。これはもう自力で全部ランタイムを移植するしかないかと思い、作者がやったであろうと思われる移植作業を試しにやってみたが、単純にnj2kの変換結果をMPPのcommonMainに置いてビルドしようとしたら、2500件近いコンパイルエラーになって、これは1日2日では終わらなそうな作業になりそうだったので、そこまで時間を無駄にはできないと思ってあきらめた。

他の構文処理系を模索する(未解決)

登録したissueもPRも無反応なので、作者はもうPRに対応する程度のやる気すら無い可能性が高いと現時点では判断せざるを得ない。antlr-kotlinで今後も同様のバグが出てきたら対応不能になりそうなので、antlr-kotlin路線は放棄して他の構文処理系を探ることにした。もしかしたらantlr/Javaで再挑戦するかもしれないけど、それはKotlinでダメそうだったらだ。

ただKotlinをターゲットにできる処理系は今のところ残り2つくらいしか(持続的に開発されているものが)見当たらない。

ただどちらもサンプルが乏しくて不安しか無い。不安なのを承知でbetter-parse版を作ってみたけど、こっちはcombinatorを生成する段階で失敗して、issueを調べてみると基本的なorコンビネーターで優先順位を適切に処理できないレベルのissueが未解決で放置されているので、これは無理そうだとなった。せめてJSONレベルの構文解析サンプルがあるやつを使いたい。(自分の構文には四則演算に比較・三項演算子まであるので、それでも不十分なのだけど…)

手詰まり

そんなわけでこの辺で本当に手詰まり感がでてきた。どうしたらいいのだろう。antlr/JavaでJVM専用にして何とか組み込めるようにする、くらいしか思いつかない。何か良いアイディアがあったら思いつきレベルでも全然いいのでコメントなどで教えてください。

Discussion