toml11でコメントが増えるバグを修正した

ちょっと面白いバグを直したので久々にブログを書く。最近は少し状況が悪く、あまりよそ事に手を出せていなかった。今もそんなに状況は改善していないが、明らかに書けるネタがあるので書こう。

直したバグはこれだ。

github.com

背景

問題の話をする前に、背景としてtoml11がコメントをどう扱っているか書いておこうと思う。toml11は、preserve_commentsが指定された場合に限って、値の直前か同じ行にあるコメントをその値に対するコメントとして保存する。

# this is a comment about `a`
a = "foo"

b = "bar" # this is a comment about `b`

ここに変なところはないと思う。 保存されたコメントはvalue.comments()で取得できる。

値の直前にあるコメントが連続している場合、全て保存される。以下の場合、a.comments(){" comment 1", " comment 2"}を返す。

# comment 1
# comment 2
a = "foo"

空行が挟まっていると認識しない。

# this is independent from `a`

# comment about `a`
a = "foo"

問題

で、問題はと言うと。報告によれば、以下のようなファイルを読み込んで書き出したとき、

# comment 1
[[foo]]
# comment 2
[[foo]]
# comment 3
[[foo]]

最初のコメントだけ増えてしまうというのだ!

# comment 1
# comment 1
[[foo]]
# comment 2
[[foo]]
# comment 3
[[foo]]

この時点でちょっとおもしろい。根本的な解決方法が浮かばないまま、とりあえずarray-of-tablesのときだけコメントが既に書かれているかどうかをチェックするという超絶ad-hocな対応をして、ゆっくり考えることにした。(ゆっくり考えた結果直すことができてこのad-hoc patchはrevertされているのでご安心ください)

原因

しばらくシリアライザのコードを読んで、issueコメントで指摘されているコード片は、まさしく二回目のコメントを書き出している箇所ではあるものの、根本的な原因ではないことがわかってきた。その夜風呂に入っているときに原因がわかった(人間は風呂に入ると何かを思いつくことが知られているが、風呂には水気が多く紙や電子機器を使いづらいという重大なバグがある)。コメントが二重になっている真の原因は、TOMLファイルの問題の場所で二つの値が定義されていることだった。どういうことか説明していこう。

順に考えてみる。以下のようなTOMLファイルがある。

[[foo]]
bar = "baz"
[[foo]]
bar = "qux"

これをTOMLで等価な別の書き方にすると、

foo = [
    {bar = "baz"},
    {bar = "qux"},
]

となる。さて、よく見てもらうと、最初のTOMLファイルの一行目(最初の[[foo]])では二つの値が新しく定義されていることがわかる。fooという配列と、その要素である{bar = "baz"}というテーブルだ。普段このfooが配列であるということを意識することはあまりないが、テーブルの配列というのだからテーブルを要素に持つ配列があり、その配列は最初の[[foo]]の時点で暗黙に定義される。[[foo]]以下で定義されているのはその要素であるテーブルであり、区別して書くとfoo[0]が定義されている。

さて、toml11でのコメントの扱いがどうだったかというと、定義箇所の前後のコメントを自分のものだとみなすのだった。以下の場合、どうなるだろう?

# comment 1
[[foo]]

まず、暗黙に定義された配列foo# comment 1を自分のコメントだと思いこんで格納する。続いて、その中の第一要素であるテーブルも、# commentを自分のコメントだと思いこんで格納する。等価な形で書き直すと、内部的には以下のようになっている。

# comment 1
foo = [
    # comment 1
    {bar = "baz"},
]

さて、これを[[foo]]形式で出力しようとしたらどうなるだろうか。まず、配列を書き出そうとしているのだから、配列のコメントが書き出される。

# comment 1

続いて配列の要素を書き出すのだが、配列がテーブルの配列であることがわかっているので、キーがテーブル定義の形でコメントと同時に書き出される。

# comment 1
# comment 1
[[foo]]

おっと。コメントが被ってしまった。というわけでこれが原因だった。

解決方法

さて、これは単なるシリアライザのバグよりかは根が深い問題だということがわかった。解決するためには、細かいことをいくつか決断しないといけない。

  • 暗黙に定義された値にその箇所にあるコメントを付加するべき
    • [ ] である
    • [x] ではない

この選択肢を「べきである」にすると、今回の挙動は仕様ということになる。が、流石にそれはないだろう。暗黙に定義される値は普段ユーザーに思いを馳せられることはない。私もすぐには思いつかなかった。しばらく考えた結果、今回の場合、[[foo]]の前にあるコメントは全て、配列の第一要素であるテーブルに帰するということにしていいと思った。

そもそも、他にも暗黙に定義されるテーブルは存在する。例えば以下の例を考えてみよう。

# comment
[x.y.z]
w = "foo"

このファイルは、全て陽に書くと

[x]
[x.y]
[x.y.z]
w = "foo"

となる。つまり、[x.y.z]で暗黙に[x][x.y]が定義されているのだ。このとき[x.y.z]の前にあるcomment[x][x.y]にも当てはまるべきかというと、多分違うと思う。つまり、こうなるべきだと思うわけだ。

[x]
[x.y]

# comment
[x.y.z]
w = "foo"

同様に、dotted keysで定義される値も

# comment
foo.bar.baz = "qux"

こうなるべきだ。

[foo.bar]
# comment
baz = "qux"

そうなると、一貫性のことも考えて、「暗黙に定義されるレベルにはコメントは影響しない」という形が一番いいところではないだろうか。

この時点で最初の問題は解決する。というのも、件の問題は「暗黙に定義された配列にコメントが伝播している」という問題として解釈できるからだ。これはバグで、修正されるべきだ。解決方法は(比較的)簡単で、[[foo]]の形でテーブルの配列が定義されている場合は、そのコメントを配列に割り振らないようにチェックを挟めばよいからだ。

パーサの別の関数がtoml::valueを受け取る以上、こういう文脈は隠蔽されてしまうので少しややこしくなるが、各toml::valueは位置情報を持っているのでそれをチェックすればよい。

追加の問題

しかし、この方針ではまだ少し問題がある。

もし以下のようなコードが書かれていたならば、

# comment 1
foo = [
    # comment 2
    {bar = "baz"},
]

パースしてシリアライズしたときに以下のようになってしまう可能性がある。

# comment 1
# comment 2
[[foo]]
bar = "baz"

こうすると、再度読み込んだ際に意味が変わってしまう。

foo = [
    # comment 1
    # comment 2
    {bar = "baz"},
]

これはダメだろう。わざわざ陽に書いているのだから、この二つのコメントの位置は区別されてしかるべきだ。その場合、選択肢は二つある。

  • [ ] コメントにその対象を示すための特殊な記法を導入する
  • [x] 強制的にインラインテーブルの配列にする

前者は、一度許すと後々大変なことになりそうな気がするので避けたい。そんなところに独自のDSLをねじ込むと互換性や何やらの問題があとで噴出するに決まっているだろう。

後者にすることで何か困るかと言うと、インラインにするべきでないような長さのインラインテーブルが出現してしまう可能性だ。だが、ファイルをパースして少しいじってまた出力するのなら、その可能性はかなり低くなる。というのも、このようなコメントはインラインテーブルの形でしか書けないので、インラインテーブルとして許容範囲内の長さの入力しか来ないだろうからだ。

まあ、C++のコードの中で配列そのものにコメントを追加した場合はこの限りではなくなるが……。それはまあ、READMEとかで注意喚起をすればいいんじゃないだろうか。

と、ここまで書いたところで更に面倒なことに気づいた。パース後の値にコメントを挿入できることを考えると、例えば

# comment 1
foo = [
    # comment 2
    {bar = "baz"},
]

これのbarという値にコメントが入る可能性がある。そうなると、インラインテーブル内部の値にはコメントを付加する方法がないので、詰んでしまう。インラインテーブルはインラインなので内部での改行を許さない。だが、コメントは改行によって終了する。なのでインラインテーブル自体にはコメントは付けられるが、その中の値には付ける方法がない。これは無理だな……。

しかたがないので、配列がコメントを持っている場合は基本的に行幅制限を無視してインライン化するが、要素であるテーブル内の値がコメントを持っていた場合は(foo自体へのコメントは第一要素にマージされてしまうが、それは仕方ないことにして)複数行テーブルにするということにする。

ここでは

  • [ ] コメントとフォーマットが衝突したとき、インラインテーブル内のコメントが消える
  • [x] コメントとフォーマットが衝突したとき、テーブルの配列の配列自体へのコメントが第一要素へのコメントにされる

の二つの選択肢から後者を選択している。これは、設定したコメントが虚空に消えるよりは(対象がズレるとしても)近い位置に出力される方がまだマシだろうという想像による。通常、シリアライズされたファイルは人間(と、それが入力されるソフトウェア)しか読まないわけだし、そうなるとコメントが意味を持つのは人間にとっての話だ。人間は近くにあるコメントを見つけられる。消えるよりマシだ。

まとめ

というわけで今回は、以下のような状況について、

# comment 1
[[foo]]
bar = "baz"
  • このコメントはfooという配列の最初の要素へのコメントであり、fooという配列自体へのコメントではない。
  • fooという配列自体にコメントするには、インラインテーブルの配列を作る必要がある。
  • コメントによって要求されるフォーマットが衝突した場合は、コメントができるだけ残るようにフォーマットが選択される。

という選択をし、実装した。今は「追加の問題」の修正をしたブランチのCIを待っている(追記:通ったのでマージした)。

しかし、なんだかとても厄介なことになってしまった。軽い気持ちでコメントをサポートしはじめただけなのに……(?)。

言語仕様上無視されるものをサポートするのは難しい。そう考えると、コメントにも他の言語でのdoc comment的に言語仕様側からの何かしらのサポートが合ったほうがいいのかもしれない。しかしそれはコメントなのか? いやこれはコメントを(勝手に)サポートしてる実装が悪いという話か……。

シリアライズ自体の実装の方もかなりめちゃくちゃになってきているのでなんとかしないといけないが、一撃で綺麗にできるアイデアがまだ浮かんでいない。パラメータを外に出すくらいはできるだろうが、それで見通しがよくなるかというと疑問が残る。あるいは、そもそも条件が複雑だと複雑な条件分岐が登場するのをある程度は避けられないということなのかもしれない。