Hamlのパーサぶっ壊れてる問題
本家のHamlは以下の入力を与えるとSyntaxErrorになる。
%div{ foo: "}" }
これはHamlが単に{
と}
の数だけ合わせてパースしているからである。 *1
通常この問題を解決するには字句解析器を使い、現在パースしているのが何のトークンなのか判別する必要がある。
Hamlitではこの問題を解決するため、標準ライブラリとして提供されているRipperを使っている。
そのため、Hamlitでは上記の入力を以下のように正しく解釈することができる。
<div foo='}'></div>
HamlのattributeがRubyの式としてvalidでない問題
上記のhamlの%div
を取り除いた部分であればRubyのHashとして解釈できそうに見える。
しかし実際には以下のような入力もHamlのattributeとして有効である。*2
- hash = { hoge: "piyo" } %div{ hash, foo: "bar", hello: "world" } hello
もはやHashでもなんでもない。
なぜこれが動くかというと、以下のようにコンパイルされるようになっているからだ。
hash = { hoge: "piyo" } _hamlout.push_text("<div#{_hamlout.attributes({}, nil, hash, foo: "bar", hello: "world" )}>hello</div>\n", 0, false);
つまり{}
の内側は_hamlout.attributes
に渡す可変長の引数の一部なのである。*3
末尾にHashっぽいのが書いてあるとそれは引数の一つとして解釈され、ここではHashが2つ渡されていることになる。
Ripperをどう使うか
パース時にどこまでがattributeか判別するため、%div
を除いた以下の入力をRipperに渡すと、
[3] pry (main)> Ripper.lex('{ hash, foo: "bar", hello: "world" } hello') # [[[1, 0], :on_lbrace, "{"], # [[1, 1], :on_sp, " "], # [[1, 2], :on_ident, "hash"], # [[1, 6], :on_comma, ","], # [[1, 7], :on_sp, " "], # [[1, 8], :on_label, "foo:"], # [[1, 12], :on_sp, " "], # [[1, 13], :on_tstring_beg, "\""], # [[1, 14], :on_tstring_content, "bar"], # [[1, 17], :on_tstring_end, "\""], # [[1, 18], :on_comma, ","]]
途中で終わる…
では可変長引数ならばと先頭をメソッド呼び出しに改変して渡すと、
Ripper.lex('a( hash, foo: "bar", hello: "world" } hello') # [[[1, 0], :on_ident, "a"], # [[1, 1], :on_lparen, "("], # [[1, 2], :on_sp, " "], # [[1, 3], :on_ident, "hash"], # [[1, 7], :on_comma, ","], # [[1, 8], :on_sp, " "], # [[1, 9], :on_label, "foo:"], # [[1, 13], :on_sp, " "], # [[1, 14], :on_tstring_beg, "\""], # [[1, 15], :on_tstring_content, "bar"], # [[1, 18], :on_tstring_end, "\""], # [[1, 19], :on_comma, ","], # [[1, 20], :on_sp, " "], # [[1, 21], :on_label, "hello:"], # [[1, 27], :on_sp, " "], # [[1, 28], :on_tstring_beg, "\""], # [[1, 29], :on_tstring_content, "world"], # [[1, 34], :on_tstring_end, "\""], # [[1, 35], :on_sp, " "], # [[1, 36], :on_embexpr_end, "}"], # [[1, 37], :on_sp, " "], # [[1, 38], :on_ident, "hello"]]
とりあえず最後までスキャンしてくれる。
ここで判別に使いたい}
が:on_embexpr_end
になっていることに着目する。
これは本来:on_embexpr_beg
(string interpolationの開始とか)と対になるトークンである。
したがって、非常にRipperの実装依存っぽい感じだが、:on_embexpr_end
の数が:on_embexpr_beg
の数より1つ少なくなる場所でストップすればいい感じに動く。
現在のHamlitのmasterはこのように実装されている。
:on_embexpr_end
の手前まではvalidな式を渡せているのでこれ以上マシな使い方は思いつかなかったが、Rubyのバージョンが上がっても動きそうな使い方をしたい…
文字列がRubyのHashとしてvalidか判別する方法
Hamlitでは、attributeの{}
内の部分がRubyの式としてvalidだった場合に限り静的なコンパイルを試みて最適化をしている。
https://bugs.ruby-lang.org/issues/10405 を見ると、Ripper.sexp
は引数がRubyの式としてinvalidなときはnil
を返すようなことが書かれていて、Hamlitでも一度それに依存した実装を行った。
バージョンによって異なる返り値
しかしRipper.sexp(str)
だけで判別するコードをpushしてみるとRuby2.1と2.0のCIが落ちた。
2.0.0-p645 (main)> Ripper.sexp('{a, b: "c"}') #=> [:program, [:string_literal, [:string_content, [:@tstring_content, "c", [1, 8]]]]] 2.1.6-p336 (main)> Ripper.sexp('{a, b: "c"}') #=> [:program, [:string_literal, [:string_content, [:@tstring_content, "c", [1, 8]]]]] 2.2.2-p95 (main)> Ripper.sexp('{a, b: "c"}') #=> nil
結局、以下のように対応した。
def valid_hash?(str) sexp = Ripper.sexp(str) return false unless sexp sexp.flatten[1] == :hash end
まとめ
Ripperは後方互換性が保証されないので必ずサポートしたいRubyのバージョン全てで動作確認を行いましょう
*1:https://github.com/haml/haml/blob/c89da381a055587fa212881f9fddd2ab3c1224ca/lib/haml/parser.rb#L625
*2:ここではattributeは内部でold attributeと呼ばれる{}で囲まれるattributeのことを指している
*3:https://github.com/haml/haml/blob/c89da381a055587fa212881f9fddd2ab3c1224ca/lib/haml/buffer.rb#L191-L199