Ogaへのパッチが取り込まれた、OgaはXPath評価時にRubyコードへコンパイルしていて興奮した
雑多にプログラミング、集中力の衰えで書いたようにRubyのXMLライブラリーOgaにパッチを投げていたんだけど、製作者のYorick Peterseさんとの幾つかのやり取りの上、マージされた:Improve XPath namespace support。やったあ、と思ってたら、バージョン2.16としてリリースされた。早い。
Rubyコードへのコンパイル
OgaのXPath実装は面白くて、
XPathをパースしてXPathのASTを作る(中ではトークナイズとパースがあるけどここではまとめてパースと呼ぶ)
XPathのASTを元に RubyのASTを作る
RubyのASTを元にRubyコードの文字列を作る
Rubyコードを評価(ざっくり言うと
eval
)して実行する
となっている。えっ、Rubyコードを作るの!? とびっくりしたけど、まじで作ってる。
OgaではXPathを使う時にはこうやって使う。
require "oga"
xml = DATA.read
doc = Oga.parse_xml(xml)
puts doc.xpath("//foo[@bar]")[0].to_xml
# <foo bar="baz">
# <qux />
# </foo>
__END__
<root>
<foo bar="baz">
<qux />
</foo>
</root>
//foo[@bar]
というXPath式は「ドキュメント中のどこにあってもいいから foo
という要素名で、 bar
という属性を持っている(値は問わない)要素全部」を意味する。CSSセレクターだと foo[bar]
に相当する。
この doc.xpath
の所で、上に書いたステップを踏んでいる。
まず、与えられたXPath式をパースする:
ast = Oga::XPath::Parser.parse_with_cache("//foo[@bar]")
pp ast
# s(:absolute_path,
# s(:axis, "descendant-or-self",
# s(:type_test, "node"),
# s(:predicate,
# s(:axis, "child",
# s(:test, nil, "foo")),
# s(:axis, "attribute",
# s(:test, nil, "bar")))))
これは抽象構文木になっていて、具体的にはこうなってる。
absolute_path
はXPathの最初の /
で、次の /
が axis
になる。その axis
の子ノードが三つあってそれぞれ文字列 "descendant-or-self"
と type_test
と predicate
……という風に読んでいく。
そしてこれを今度は、RubyのASTに変換する。これは Oga::XPath::Compiler.compile_with_cache
メソッドの中で行われるんだけど、ASTを組み立てる独立したメソッドがないので、メソッドに中に print
を仕込んだ結果がこう。
(block (lit "lambda") [(lit "node"), (assign (lit "variables") (lit "nil"))] (followed_by (assign (lit "original_input") (lit "node")) (followed_by (followed_by (assign (lit "matched") (send (lit "Oga::XML::NodeSet") "new")) (if (or (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Document")) (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Node"))) (followed_by (if (or (or (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Document")) (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Node"))) (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Attribute"))) (followed_by (assign (lit "index2") (lit "1")) (if (or (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Document")) (send (send (lit "node") "root_node") "is_a?" (lit "Oga::XML::Node"))) (block (send (send (send (lit "node") "root_node") "children") "each") [(lit "child4")] (if (and (or (send (lit "child4") "is_a?" (lit "Oga::XML::Element")) (send (lit "child4") "is_a?" (lit "Oga::XML::Attribute"))) (or (eq (send (lit "child4") "name") (string "foo")) (eq (send (send (lit "child4") "name") "casecmp" (string "foo")) (lit "0")))) (followed_by (followed_by (followed_by (assign (lit "pred_var3") (block (send nil "catch" (symbol :predicate_matched)) [] (followed_by (if (send (lit "child4") "is_a?" (lit "Oga::XML::Element")) (block (send (send (lit "child4") "attributes") "each") [(lit "attribute5")] (if (or (eq (send (lit "attribute5") "name") (string "bar")) (eq (send (send (lit "attribute5") "name") "casecmp" (string "bar")) (lit "0"))) (send nil "throw" (symbol :predicate_matched) (lit "true"))))) (lit "nil")))) (if (send (lit "pred_var3") "is_a?" (lit "Numeric")) (assign (lit "pred_var3") (eq (send (lit "pred_var3") "to_i") (lit "index2"))))) (if (send (lit "Oga::XPath::Conversion") "to_boolean" (lit "pred_var3")) (send (lit "matched") "push" (lit "child4")))) (assign (lit "index2") (send (lit "index2") "+" (lit "1"))))))))) (block (send (send (lit "node") "root_node") "each_node") [(lit "descendant1")] (if (or (or (send (lit "descendant1") "is_a?" (lit "Oga::XML::Document")) (send (lit "descendant1") "is_a?" (lit "Oga::XML::Node"))) (send (lit "descendant1") "is_a?" (lit "Oga::XML::Attribute"))) (followed_by (assign (lit "index6") (lit "1")) (if (or (send (lit "descendant1") "is_a?" (lit "Oga::XML::Document")) (send (lit "descendant1") "is_a?" (lit "Oga::XML::Node"))) (block (send (send (lit "descendant1") "children") "each") [(lit "child8")] (if (and (or (send (lit "child8") "is_a?" (lit "Oga::XML::Element")) (send (lit "child8") "is_a?" (lit "Oga::XML::Attribute"))) (or (eq (send (lit "child8") "name") (string "foo")) (eq (send (send (lit "child8") "name") "casecmp" (string "foo")) (lit "0")))) (followed_by (followed_by (followed_by (assign (lit "pred_var7") (block (send nil "catch" (symbol :predicate_matched)) [] (followed_by (if (send (lit "child8") "is_a?" (lit "Oga::XML::Element")) (block (send (send (lit "child8") "attributes") "each") [(lit "attribute9")] (if (or (eq (send (lit "attribute9") "name") (string "bar")) (eq (send (send (lit "attribute9") "name") "casecmp" (string "bar")) (lit "0"))) (send nil "throw" (symbol :predicate_matched) (lit "true"))))) (lit "nil")))) (if (send (lit "pred_var7") "is_a?" (lit "Numeric")) (assign (lit "pred_var7") (eq (send (lit "pred_var7") "to_i") (lit "index6"))))) (if (send (lit "Oga::XPath::Conversion") "to_boolean" (lit "pred_var7")) (send (lit "matched") "push" (lit "child8")))) (assign (lit "index6") (send (lit "index6") "+" (lit "1"))))))))))))) (lit "matched"))))
……これを図にしても読めないと思うのでしないけど、さっきのASTが読めれば「RubyのコードがASTとして表現されてるんだなー」という雰囲気は伝わると思う。あると便利かなと思う知識は、 (lit "lambda")
みたいな lit
ノードは、Rubyに於けるリテラル、つまりRubyコードとしてはベタで lambda
と書かれる物。あと、 (lit "node")
とか (list "child4")
とかはOgaが用意する変数で、 Oga::XML::Node
が入ってる。因みにこのASTを組み立てるところが、パッチを書く上で一番苦労した。
まあそもそもこれを頑張って細部まで読む必要がなくて、このASTを組み立てた直後にRubyコードの文字列にする処理があるので、その結果を貼り付けよう(インデントの調整だけ僕がしてある)。
lambda do |node, variables = nil|
original_input = node
matched = Oga::XML::NodeSet.new
if (node.root_node.is_a?(Oga::XML::Document) || node.root_node.is_a?(Oga::XML::Node))
if ((node.root_node.is_a?(Oga::XML::Document) || node.root_node.is_a?(Oga::XML::Node)) || node.root_node.is_a?(Oga::XML::Attribute))
index2 = 1
if (node.root_node.is_a?(Oga::XML::Document) || node.root_node.is_a?(Oga::XML::Node))
node.root_node.children.each do |child4|
if (child4.is_a?(Oga::XML::Element) || child4.is_a?(Oga::XML::Attribute)) && (child4.name == "foo" || child4.name.casecmp("foo") == 0)
pred_var3 = catch(:predicate_matched) do ||
if child4.is_a?(Oga::XML::Element)
child4.attributes.each do |attribute5|
if (attribute5.name == "bar" || attribute5.name.casecmp("bar") == 0)
throw(:predicate_matched, true)
end
end
end
nil
end
if pred_var3.is_a?(Numeric)
pred_var3 = pred_var3.to_i == index2
end
if Oga::XPath::Conversion.to_boolean(pred_var3)
matched.push(child4)
end
index2 = index2.+(1)
end
end
end
end
node.root_node.each_node do |descendant1|
if ((descendant1.is_a?(Oga::XML::Document) || descendant1.is_a?(Oga::XML::Node)) || descendant1.is_a?(Oga::XML::Attribute))
index6 = 1
if (descendant1.is_a?(Oga::XML::Document) || descendant1.is_a?(Oga::XML::Node))
descendant1.children.each do |child8|
if (child8.is_a?(Oga::XML::Element) || child8.is_a?(Oga::XML::Attribute)) && (child8.name == "foo" || child8.name.casecmp("foo") == 0)
pred_var7 = catch(:predicate_matched) do ||
if child8.is_a?(Oga::XML::Element)
child8.attributes.each do |attribute9|
if (attribute9.name == "bar" || attribute9.name.casecmp("bar") == 0)
throw(:predicate_matched, true)
end
end
end
nil
end
if pred_var7.is_a?(Numeric)
pred_var7 = pred_var7.to_i == index6
end
if Oga::XPath::Conversion.to_boolean(pred_var7)
matched.push(child8)
end
index6 = index6.+(1)
end
end
end
end
end
end
matched
end
最後にこれをRubyのブロックでラップして、XMLドキュメントの文脈で実行したら結果が返ってくるという次第。
block = Oga::XPath::Compiler.compile_with_cache(ast)
puts block.call(doc)[0].to_xml
# <foo bar="baz">
# <qux />
# </foo>
実行時にXMLドキュメントのDOMツリーを辿りながら、並行してXPath式のASTを辿る(インタープリター型)のかなあと思い込んでいたんだけど、いったん文字列にしてそれを評価するとは……確かに「コンパイル」だな、と驚いたし興奮した。
新たな問題
書いたパッチはこの xpath
メソッドにキーワード引数 namespaces
を足して、XMLのパース時ではなくてXPathでクエリーする時に任意の名前空間を使えるようにする、という物で、その追加についてREADMEにコメントを書こうと思ったら新たな問題を掘り起こしてしまった。それがこちら:XPath queries using the default XML namespace do not appear to work (any more)
XPathの扱いが、なんと四年も前から間違っていたとのこと……まじか。EPUB ParserでOgaを使えるようにしたいと思っていたのだけど、このバグあると取り入れられないので、この問題も引き続き調べていくことにしよう。