2017年12月19日
_ [reviewml] Re:VIEW Hacks (2) 〜コンパイラの仕組み
review-ext.rbでRe:VIEWの挙動を上書きするためには、当然ながらまずはRe:VIEWの仕掛けを理解しておく必要があります。実際のところ、私も青木さんの元コードを読んだりしながら習っているところがあり、完全には全容を理解しきれているとは言えません。
まずbin/にあってユーザーから呼び出されるreview-epubmaker, review-pdfmaker, review-compileあたりはオプション解析をする程度で、実体コードのほとんどはlib/reviewの中のライブラリ側に置いています。コマンド挙動を上書きするのはあまり意味がない(生成結果を加工処理するのはフック利用を期待している)ので、ここでは説明しません。
review-epubmaker, review-pdfmakerはReVIEW::Converter→ReVIEW::Compilerという呼び出し経路、review-compileはReVIEW::Compilerを直に呼び出して、与えられたRe:VIEW形式コンテンツを処理します。
ReVIEW::Compiler(lib/review/compiler.rb)は、以下の2つの重要な役割を担います。
- 基本のインラインタグ、ブロックタグの宣言
- ドキュメントパーサ
宣言は次のような形です。
defblock :read, 0 (ブロックタグの宣言。1つめが名前、2つめが引数の数) defblock :lead, 0 defblock :list, 2..3 defblock :emlist, 0..2 (0〜2個の可変の引数をとる) …… defsingle :footnote, 2 (1行で使用する({〜//}を持たない)ブロックタグの宣言) defsingle :noindent, 0 …… definline :chapref (インラインタグの宣言) definline :chap ……
あくまでもCompilerでは宣言するのみで、表現の実体は変換先、Re:VIEWで「ビルダ」と呼んでいる実装側に任されます。パーサで処理する際にどのようなタグが存在するのかわからないと困るので、このように設計されています。
パーサの中は、歴史的経緯もあって、現時点ではドキュメントオブジェクトモデルを構築するのではなく、正規表現で必死に処理しています(@takahashimさんがPEG版を開発していますが、正規表現パーサやビルダのあいまいさを消化するのに苦戦されているようです)。
def do_compile f = LineInput.new(StringIO.new(@chapter.content)) @strategy.bind self, @chapter, Location.new(@chapter.basename, f) tagged_section_init while f.next? case f.peek when /\A\#@/ f.gets # Nothing to do when /\A=+[\[\s\{]/ compile_headline f.gets when /\A\s+\*/ compile_ulist f when /\A\s+\d+\./ compile_olist f when /\A\s*:\s/ compile_dlist f when %r{\A//\}} f.gets error 'block end seen but not opened' when %r{\A//[a-z]+} name, args, lines = read_command(f) syntax = syntax_descriptor(name) unless syntax error "unknown command: //#{name}" compile_unknown_command args, lines next end compile_command syntax, args, lines when %r{\A//} line = f.gets warn "`//' seen but is not valid command: #{line.strip.inspect}" if block_open?(line) warn 'skipping block...' read_block(f, false) end else if f.peek.strip.empty? f.gets next end compile_paragraph f end end close_all_tagged_section end
入れ子の処理などができないというのはこれに起因するものですが、実装としては把握しやすいですね。コアのパーサロジックがたったこれだけの行で済んでいるというのはちょっと驚きます。@strategyはビルダのオブジェクトです。
このパーサの時点ではインラインタグは処理せず、コメントの無視化、特例的なタグである見出し・箇条書き、ブロックタグ、そして残ったものを段落に、という区分けをしています。
それぞれの処理の中では、ビルダ(@strategy)に渡す情報を整理します。その際、テキストと見なされる箇所はtextメソッドでインラインタグを処理します。これもだいぶ必死な正規表現ですね。
def text(str) return '' if str.empty? words = replace_fence(str).split(/(@<\w+>\{(?:[^\}\\]|\\.)*?\})/, -1) words.each { |w| error "`@<xxx>' seen but is not valid inline op: #{w}" if w.scan(/@<\w+>/).size > 1 && !/\A@<raw>/.match(w) } result = @strategy.nofunc_text(words.shift) until words.empty? result << compile_inline(words.shift.gsub(/\\\}/, '}').gsub(/\\\\/, '\\')) result << @strategy.nofunc_text(words.shift) end result.gsub("\x01", '@') rescue => err error err.message end …… def compile_inline(str) op, arg = /\A@<(\w+)>\{(.*?)\}\z/.match(str).captures raise CompileError, "no such inline op: #{op}" unless inline_defined?(op) raise "strategy does not support inline op: @<#{op}>" unless @strategy.respond_to?("inline_#{op}") @strategy.__send__("inline_#{op}", arg) rescue => err error err.message @strategy.nofunc_text(str) end
\x01という謎なものがありますが、これはフェンス記法のサポート(replace_fence)の副作用です。インラインタグでリテラルな}を使おうとすると\}とエスケープする必要があった(たとえば@<m>{\frac{1\}{2\}})のですが、これはTeX数式を書く際などにとても煩雑なため、@<m>$\frac{1}{2}$あるいは@<m>|\frac{1}{2}|といった代替の表記方法を提供します(あくまでも「代替」です)。このときに@が間に挟まっているといろいろと奇妙な動作を起こしてしまうため、いったん\x01に退避してから戻すという回避策をとりました(@znzさんのご提案)。インラインタグは以下の箇所のとおりビルダ側の「inline_タグ名」のメソッドで変換処理されます。
@strategy.__send__("inline_#{op}", arg)
さて、こうしてできあがった文字列をビルダに渡します。たとえば見出しなら
@strategy.headline level, label, caption
段落なら
@strategy.paragraph buf
ブロックなら
@strategy.__send__(syntax.name, (lines || default_block(syntax)), *args)
といった具合です。インラインタグが処理済みのものが渡されるという点に注意が必要です。
長くなりましたので今日はここまで。次はビルダの中に入っていきましょう。