Ripperによるhamlのattributeパースの話

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のバージョン全てで動作確認を行いましょう