シンタックスハイライトを実装する(Ruby & Parslet 編)


2012年 02月 16日

問題

世の中にはシンタックスハイライトを行うツールが既に多数存在しています。例えば以下のようなものがあります:

メジャーな言語やフォーマットなら標準でシンタックスハイライトの設定が同梱されていますが、
ニッチな言語やフォーマットだとまずそのような設定は存在しません。
それならば独自に設定を書けばいいのですが、
大抵のツールでは構文の定義方法が特定のパーツに該当する正規表現を並べるだけなので、
言語によっては構文の妥当な記述が不可能な場合もあります。

となると独自に実装せざるを得ません。
例えば
Vim の :help ドキュメントを良い感じに Web ブラウザ上で見るためのツール
を作って Heroku で動かそうと思った場合、
Vim の :help ドキュメントの構文のいい加減さ
から、まず既存のツールを利用してのシンタックスハイライトはできません。
さらにこのツールの場合はリンク周りもあれこれ面倒を見る必要があるため、
ますます既存のツールの利用はできません。

という訳で元のソースをパースしていい感じにシンタックスハイライトする仕組みを作る必要があります。
ことパースに関して言えば Ruby には様々な
gem が存在しているので、そのどれかを使うことになります。
パース関連で言うと以下のような gem があるのですが:

  • treetop (Lex/Yacc のように独自 DSL を Ruby のコードへコンパイルする必要がある。ダサい)
  • citrus (treetop と同様のダサさ)
  • rsec (Ruby 上の DSL で構文の記述を行う。でもその DSL がダサい)
  • parslet (Ruby 上の DSL で構文の記述を行う。 DSL が超COOOOOOOOOL。構文エラー時の出力もCOOOOOOOOOL)

という感じなので parslet 一択という状態です。
そういう訳で parslet を使って Ruby でシンタックスハイライトを実装してみましょう。

実装

独自のパーサーの定義

require 'parslet'

class VimHelpParser < Parslet::Parser
  # TODO: ここにパースのルールをいろいろ書こう!
end

パースのルールの定義: rule

# 「指定した文字列が来る」形は str を使う。
rule(:rule_name) {
  # TODO: ここに入力規則を書こう!
}

固定の文字列にマッチさせる: str

rule(:note) {
  str('NOTE')
}

ある範囲の文字にマッチさせる: match

rule(:space) {
  match('[ \t]')
}
  • 1文字分に相当する正規表現しか記述できないので注意。
  • match('[a-z]+') などとは書けない。
  • match('[a-z]').repeat(1) と書く。

「aの次にbが来る」を表す: >>

rule(:vimscript_link) {
  str('vimscript#') >>
  match('[0-9]')
}

「aが繰り返し現れる」を表す: repeat

rule(:vimscript_link) {
  str('vimscript#') >>
  match('[0-9]').repeat(1)
}
  • 最初の引数は最低繰り返し回数。省略すると0。
  • match('[0-9]').repeat(1, 3) のように最大繰り返し回数も指定可能。

マッチしたパーツに名前を付ける: as

rule(:vimscript_link) {
  (
    str('vimscript#') >>
    match('[0-9]').repeat(1).as(:id)
  ).as(:vimscript_link)
}
  • この名前は後でパース結果を処理する際に使います。

「aまたはbが現れる」を表す: |

rule(:special_key) {
  str('CTRL-') >>
  (
    str('{char}') |
    match('[A-Za-z0-9]').repeat(1) |
    match('.')
  )
}

「任意の1文字」を表す: any

rule(:special_key) {
  str('CTRL-') >>
  (
    str('{char}') |
    match('[A-Za-z0-9]').repeat(1) |
    any
  )
}
  • match('.') でも同じ意味ですが、 any の方が読み易いです。

「aは省略可能」を表す: maybe

rule(:spaces?) {
  match('[ \t]').repeat(1).maybe
}
  • repeat(0, 1) でもほぼ同じ効果が得られますが、 maybe の方が読み易いです。
  • repeat(0, 1)maybe だとパース結果の表現が異なります。
  • 「aは省略可能」を表すなら maybe の方が扱い易いパース結果になるので、敢えて repeat(0, 1) を使うことはないと思います。

他のルールを参照する

rule(:spaces) {
  match('[ \t]').repeat(1)
}
rule(:spaces?) {
  spaces.maybe
}

「次にaが来る」「次にaが来ない」を表す: present? / absent?

rule(:tag_anchor) {
  star.as(:begin) >>
  ((space | newline | star | pipe).absent? >> any).
    repeat1.
    as(:tag_anchor) >>
  star.as(:end) >>
  ((space | newline).present? | any.absent?)
}
  • 入力を先読みしますが消費はしません。
  • 応用例:
    • 「a以外のもの」を表す: a.abscent? >> any
    • 「入力の最後まで到達した」を表す: any.abscent?

パースの開始点となるルールの指定: root

root(:help)
rule(:help) {
  token.repeat
}
rule(:token) {
  header |
  option |
  tag_anchor |
  ...
}

パースの実行

VimHelpParser.
new().
parse("*arpeggio.txt*  Vim plugin for ...")

パース結果

class VimHelpParser < Parslet::Parser
  rule(:vimscript_link) {
    (
      str('vimscript#') >>
      match('[0-9]').repeat(1).as(:id)
    ).as(:vimscript_link)
  }
end

VimHelpParser.new().vimscript_link.parse("vimscript#2100")
#==> {:vimscript_link => {:id => '2100'}}
  • strmatch の結果はマッチした文字列(に入力元での位置情報が付加されたオブジェクト)になります。
  • as を使うと結果は Hash になります。キーが as で指定したオブジェクト(普通はシンボル)で、対応する値がパース結果になります。
  • repeat を使うと個々のパース結果を要素に持つ Array になります(repeat されたのがただの strmatch の場合は Array ではなくマッチした文字列になります)。
  • maybe を使うと、該当する入力があった場合はそのパース結果がそのまま maybe のパース結果になります。該当する入力がなかった場合は nil が maybe のパース結果になります。

パース結果の変換

class VimHelpTransformer < Parslet::Transform
  rule(:vimscript_link => {:id => simple(:id)}) {
    base_uri = 'http://www.vim.org/scripts/script.php'
    %Q[<a class="vimscript_link" href="#{base_uri}?script_id=#{id.to_s}">vimscript##{id.to_s}</a>]
  }
end

VimHelpTransformer.new().apply({:vimscript_link => {:id => '2100'}})
# ==> %Q[<a class="vimscript_link" href="http://www.vim.org/scripts/script.php?script_id=2100">vimscript#2100</a>]
  • このような感じで rule(個々のパース結果を表すオブジェクト) {変換結果を導出する式} を書きます。
  • 「個々のパース結果を表すオブジェクト」はほぼそのままパース結果とマッチングされますが、ワイルドカード的なものを指定することができます。
    • ワイルドカードにマッチしたオブジェクトは適宜ローカル変数に束縛され、その状態で rule のブロックが実行されます。
    • 例えば simple(:id)Array でも Hash でもないオブジェクトにマッチします。マッチしたオブジェクトはローカル変数 id に束縛されます。
  • apply が受け取ったパース結果(普通はネストした Hash)の内容を適宜 rule に従って変換してくれます。

んでんでんで

一旦パーサーができてしまえば、
あとは Parslet::Transform を使って各種構文を span 要素で括って適切な class 属性を付けた HTML のスニペットへ変換してやり、
それっぽい CSS を用意してあげればシンタックスハイライトのできあがりという訳です。
やりましたね。

parslet は非常によくできているので、ちょろっと何かをパースする必要に迫られたときでも、上記のポイントを押さえていれば何とかなるでしょう。

付録