.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で実装し直すという話もあるようだ。