.cabalファイルのパース
先日、staversionの新しいバージョンをリリースした。
staversionはStackageやHackageに上がっているパッケージバージョンを調べるコマンドラインツール。今回のバージョンでは、.cabalファイルを読みこんでそのbuild-dependsにあるパッケージのバージョンを根こそぎ調べる機能を追加した。
$ staversion --hackage staversion.cabal ------ latest in hackage -- staversion.cabal - library base ==4.9.0.0, unordered-containers ==0.2.7.2, aeson ==1.0.2.1, text ==1.2.2.1, bytestring ==0.10.8.1, yaml ==0.8.21.1, filepath ==1.4.1.1, directory ==1.3.0.0, optparse-applicative ==0.13.0.0, containers ==0.5.9.1, http-client ==0.5.5, http-client-tls ==0.3.3, http-types ==0.9.1, transformers ==0.5.2.0, transformers-compat ==0.5.1.4, megaparsec ==5.1.2 (以下省略)
問題は.cabalファイルをどうやってパースするかだ。
.cabalファイルではわりとクセの強い独自フォーマットが採用されている。そのパーサツールはCabalパッケージのDistribution.PackageDescription.Parseモジュールで提供されているが、お世辞にも使いやすいものとは言えない。また、このパーサツールではGenericPackageDescription型を結果として返してくるが、このデータ型では.cabalファイル内でのフィールドの出現順などの情報は失われてしまう。staversionではこの点がイヤだったので、仕方なくmegaparsecで簡単なパーサを実装した。
で、今回はもうちょっとCabalのコードを読んでパーサがどうなっているかを調べてみた(Cabal-1.24.2.0)。
トップレベルのパーサはParseモジュールのparsePackageDescription関数である。この関数は2段階で.cabalファイルをパースする。まずはStringから[Field]を作り、[Field]からGenericPackageDescriptionを作る。
Fieldなど、パーサで使う内部データ構造や関数の多くはDistribution.ParseUtilsモジュールで定義されている。Fieldは.cabalファイルの基本構造を木構造で表現するデータ構造である。Stringから[Field]を作るのはreadFields関数(ParseUtilsモジュール)であり、.cabalのややこしいブロックフォーマットをパースする。ただし、ブロックの中身はまだStringのまま保持する。
Field内に保持されているStringのパースの仕方はフィールド種別によって異なる。パースの仕方を保持するデータ構造がFieldDescr(ParseUtilsモジュール)である。FieldDescrのfieldSetメンバは、「Stringをパースし、その結果を型aにセットする」という一連の処理を行う。
なお、基本的なデータ構造に対するパース処理はTextクラスのparseメソッド(Distribution.Textモジュール)として定義されている。一部のFieldDescrはこのparseメソッドを使って定義される。
[Field]のパース処理はparseFields関数(Parseモジュール)が行う。入力Fieldを、名前のマッチするFieldDescrで順次パースしていき、その結果を型aへ畳み込む。ちなみにaccumFields関数(ParseUtilsモジュール)もほぼ同じことをやるが、parseFieldsのほうはエラーハンドリングをより丁寧にやっているように見える。
parseFields関数を用いて実際に"library"などのセクションをパースするのはparsePackageDescription関数の内部関数getBodyである。が、これはStTモナド変換子(StateTと同等)で入力[Field]を更新しながら再帰呼び出しを行うかなり複雑な関数である。なんとなく、.cabalファイル中のif節の処理が大変そうに見える。
なお、GenericPackageDescriptionは.cabalファイルの条件分岐も情報として含んでいる。それにフラグなどの各種設定を与えてPackageDescriptionを作るのがfinalizePackageDescription関数(Distribution.PackageDescription.Configurationモジュール)である。その際、build-dependsフィールドのdependencyリストは一度Map型(Data.Mapモジュール)にまとめられ、複数ある場合はマージされる。そのため、finalizePackageDescription関数にはbuild-dependsリストをソートするという副作用がある。
.cabalファイルのパーサは、様々な歴史的事情もあり、かなり複雑な作りになっているように見える。また、現状ではFieldなどの中間データ構造は(exposeされているものの)基本的にCabal内部に使用が制限されており、外からは活用できない。その点に関して、以下のissueが出されている。
よりよいCabalの中間データ構造(CabalAst)を導入するのはどうかという提案。また、パーサをParsecで実装し直すという話もあるようだ。