Martin Fowler は(多分)やっていないこと

ThoughtWorks アンソロジー を読んでいたら Ruby を使った DSL の話が載っており, 仕事でうっかり Ruby DSL を作ってしまった私は興味深く読んだ. 特段目新しい話じゃないものの, (DSL と言えば聞こえはいいけれど要は設定ファイルですからね.) オレオレ DSL を作る際には "Martin Fowler もやっている" と言えば 説得力もあるってもんだろう.

説得力はさておき, Martin Fowler は私の DSL が抱える問題に答えてくれなかった. 最近の私は Ruby DSL の文書化に困っている. その DSL/設定ファイル はもともと余興にちまちま作っていたもので, 思ったより出来がよくなったため実プロジェクトで使いはじめたところだった. ちゃんと使ってみると案の定ぼろぼろと問題がでて, 後始末のために残業が続いている. まあドッグフードの自業自得は仕方ない. (隣の同僚はとばっちりも著しいがドンマイとする.)

文書化は山ほどみつかった問題の一つだ. DSL 言語自体の文書化ではなく, DSL で書かれた設定内容の文書化を求められている. DSL を持ち込んだ動機の一つは簡潔な表記と高可読性だから, もともと他形式での文書化は乗り気でない. DSL なんて趣味的なものを表立って使う予定もなかった. それを一見した出来の良さに気を良くして実プロジェクトに投入したところ, やんごとなき書式での文書化を求められて立ち往生している.

Ruby DSL 文書化問題の例

Martin Fowler の記事にあるように, 問題の DSL もメソッド呼び出しとブロックを中心に記述されている. 仮に JCFL(神保町カレー愛好言語) を考えよう.

shop("ethiopia") do type(:indian) since(1988) address(:city => "Tokyo", :address=> "chiyoda-ku, kanda....") menu() do curry(:beef) { price(880).limit(70) } curry(:chicken) { price(880).limit(70) } curry(:vegetable) { price(930).limit(70).may_with(:beef, :chicken) } end end

多くのプログラミング言語と同じく, JCFL もコメントとして文書を埋め込んでいる.

shop("ethiopia") do # 便宜的なもので, 典型的なインド風とは異なる type(:indian) # 昭和 63 年 since(1988) # @param city 東京 # @param address 千代田区神田小川町... address(:city => "Tokyo", :address=> "chiyoda-ku, kanda....") menu() do # @param limit 4 倍くらいがせいぜい # @param price まわりの店も似たようなもの. curry(:beef) { price(880).limit(70) } curry(:chicken) { price(880).limit(70) } # @param may_with 肉が欲しいら :vegetable ではなく :beef を頼むと良い. curry(:vegetable) { price(930).limit(70).may_with(:beef, :chicken) } end end

rdoc 風じゃなくて javadoc 風なあたりに ruby 愛の低さが露呈している.

ruby_parser

さて, このコメント部分と言語本体から, どうやってやんごとなきオフィス文書を生成しよう? Ruby DSL は自分でパーサを持たないから, コメント部分を解釈できない. Ruby のクラスやメソッド定義を注釈しているわけではないから rdoc も使えない. 仕方ないからコメントではなくヒアドキュメントを使うか, 正規表現と手動でがんばるか... などと悩みながらぐぐっていたら, ruby_parser というライブラリがあった. pure ruby の ruby パーサで, 構文木を作ってくれるらしい. 試してみよう.

#! ruby -rubygems require 'ruby_parser' TEXT = <<EOF # 縮めました. shop("ethiopia") do # 便宜的なもので, 典型的なインド風とは異なる type(:indian) # 昭和 63 年 menu() do # @param limit 4 倍くらいがせいぜい # @param price まわりの店も似たようなもの. curry(:beef) { price(880).limit(70) } end end EOF p RubyParser.new.parse(TEXT)

実行するとこうなる:

omo:~/work/jcfl$ ruby hello.rb s(:iter, s(:call, nil, :shop, s(:arglist, s(:str, "ethiopia"))), nil, s(:block, s(:call, nil, :type, s(:arglist, s(:lit, :indian))), s(:iter, s(:call, nil, :menu, s(:arglist)), nil, s(:iter, s(:call, nil, :curry, s(:arglist, s(:lit, :beef))), nil, s(:call, s(:call, nil, :price, s(:arglist, s(:lit, 880))), :limit, s(:arglist, s(:lit, 70)))))))

構文木がとれた! (適当に整形してある.) でもコメントは失われている...

... exp = RubyParser.new.parse(TEXT) p exp.comments p exp[1].comments

実行しても...:

omo:~/work/jcfl$ ruby hello.rb nil nil

class や def のコメントはとれる.

... TEXT2 = <<EOF #=begin # JCFL object model #=end class Curry #=begin # チーズが恋しい #=end def bondi(); end end EOF exp = RubyParser.new.parse(TEXT2) p exp.comments

実行:

omo:~/work/jcfl$ ruby hello.rb "#=begin

# JCFL object model

#=end

"

字句解析オブジェクトはコメントを保持しているようなので, パーサを適当にフックしてメソッド呼び出しのコメントを横取りできないか. こんなかんじ:

class CallCommentedParser < RubyParser def new_call(recv, meth, args = nil) c = lexer.comments c.empty? ? super : super << s(:comment, c) end end p CallCommentedParser.new.parse(TEXT)

実行:

omo@contentiss:~/work/jcfl$ ruby hello.rb s(:iter, s(:call, nil, :shop, s(:arglist, s(:str, "ethiopia")), s(:comment, "# \347\270....

")), nil, s(:block, s(:call, nil, :type, s(:arglist, s(:lit, :indian)), s(:comment, "# \344\276\277\...

"), ...)))

できた! エスケープが邪魔だけど..

e = CallCommentedParser.new.parse(TEXT) print e[1][4][1]

nilomo@contentiss:~/work/jcfl$ ruby hello.rb # 縮めました.

メソッド呼び出しに紐づくコメントを取得できた.

AST-XML

一番面倒そうだった部分は ruby_parser がなんとかしてくれた. けれど ruby_parser の出力は sexp.rb という Ruby 版 S 式になっている. (実際は配列の入れ子.) これは扱いにくい. Twitter で愚痴っていたら, scheme には SXPath があるから gauche を呼ぶといいんじゃないかと教わった. あいにくの Scheme 力不足ににつきそれは難しいけれど, XPath を使うアイデアはよさそうだ. この S 式(?) を XML に直そう.

class XMLTreeBuilder attr_reader :doc def initialize() @doc = REXML::Document.new("<tree />"); end def build(exp) traverse(doc.root, exp) doc end def traverse(parent, exp) with_element(parent, exp[0].to_s) do |elem| exp[1..-1].each_with_index do |t, i| case t when Sexp traverse(elem, t) when Symbol, Fixnum elem.add_attribute("at" + i.to_s, t.to_s) when String elem.add(REXML::CData.new(t)) when nil elem.add_element("nil") else raise "Unexpected term:#{t}" end end end end def with_element(parent, name, &block) parent.add(REXML::Element.new(name)) block.call(parent.children.last) end end exp = CallCommentedParser.new.parse(TEXT) doc = XMLTreeBuilder.new.build(exp) doc.write(STDOUT, 1)

やる気のないコードだけれど ruby_parser の AST 相手ならこんなもんでよかろう.

nilomo@contentiss:~/work/jcfl$ ruby hello.rb <tree> <iter> ... <nil/> <block> ... <iter> ... <iter> <call at1='curry'> <nil/> <arglist> <lit at0='beef'/> </arglist> <comment> <![CDATA[# @param limit 4 倍くらいがせいぜい # @param price まわりの店も似たようなもの. ]]> </comment> </call> ... </iter> </iter> </block> </iter> </tree>

XML になった! これで XPath が使える. めでたい.

やんごとなきまであとすこし

コメントつき構文木が XML になれば, それを DSL や文書の要件に応じて やんごと形式に変換するのは普段のスクリプティングの範疇に収まる. 続きはそのうち進めよう.

ただ構文木をトラバースしだすと, オブジェクトモデルさえ同じならミタメをあれこれ試せる Ruby DSL の利点は いくらか失われてしまう. AST-XML からのコンバータは木のレイアウトに依存せざるを得ないからだ. だからコンバータを書くときはこの AST だけを頼らず実際のオブジェクトモデルも構築し, そのモデルにコメントをマージするようなスタイルが良い気がする. それなら AST を使うのはコメントとモデルを関連づける部分に限られるから, なんとか保守していけるだろう.

そのほか ruby (ruby_parser) はツリーづくりで色々とトリッキーなことをしているらしく, DSL 側の書き方によってはパーサのコールバック順序が期待したもの (ツリートラバースの順序)にならないことがある. そのへんのやりくりは少し面倒だけれど, 場当たり的な対応も内部 DSL の面白さのうち, ということにしておこう...

そんなわけで, Ruby DSL の文書化には ruby_parser が使えそうだよという話でした.