markus
Rustの練習に書き始めたMarkdownパーサー、markus。
概観
Markdownには、大別するとブロックという単位の要素と、インラインという単位の要素がある。ブロックは見出しやコードブロック、リスト、段落などの木構造を成す大きな単位で、インラインはリンクや強調などの、ブロックに内包される小さな単位。ブロックは更に、他のブロックを内包するコンテナーブロックと、内包しないリーフブロックに分かれ、木構造を成す。
Markdownをパースするときは、この構造に従い、まずブロック単位で木構造にパースしたあと、個々のブロックについてインライン単位でパースしていくというように、二段階に分けてパースするやり方が一般的なようだ。この辺の話は、CommonMarkのSpecで丁寧かつ簡潔に説明されている。
Paragraph
Paragraphはブロックの1つ。ブロックの中でも、最もベーシックでありふれた部類の存在。段落を開始するために特に記号は必要なく、適当な文字列を行頭から開始すればそれが段落として扱われる。そういう訳なので、文字列をブロック単位でパースする際は、他のいずれのブロックの開始記号も見つからなかった場合は、続く文字列をParagraphとして解釈する、という感じで実装していくのが妥当そうである。
とはいえ、先頭と末尾の空白類は無視したり、空行をParagraphの終わりと見なしたり、空行無しで他のブロックの開始記号によって同様にParagraphの終わりと見なしたりと、仕様をよく読んでみると、普段は意識しない細かいルールが意外と沢山ある。
ATX Heading
Markdownには、見出しを表現する記法が次の2種類ある。
- ATX Heading
- Setext Heading
我々が普段目にする見出しは大体ATX Headingで書かれている。
## foo
ATX Headingの先頭のマーカーっぽいものが見えたらATX Headingだと確定させて読み進む、そうでなければ他の (Paragraph等の) ブロックだとして別の分岐に入る、という感じで実装している。
あまり使われることのない機能だが、次のように、enclosing sequenceというものを末尾に置くことができる。enclosing sequenceは、1個以上の空白またはタブ、0個以上の#
、0個以上の空白またはタブというパターンで構成される。
## foo #
次のように、行頭に3つまでの空白を置くこともできる。4つになるとコードブロックとして扱われてしまうので、3つまで。
### foo
## foo
# foo
次のように、テキストノード無しの見出しも書ける。通常は、連続する #
の直後に空白を1つ以上置く必要があるが、テキストノードが無い場合に限っては空白を置かなくても良い。3行目の例は、前述したenclosing sequenceを併用している。
##
#
### ###
文字列の終端にきちんと改行があることを前提としていないので、実装中は、改行または文字列終端まで読む、というパターンがよく出てきて少し大変だったりする。
Setext Heading
二種類ある見出し記法のうち、もう片方の記法。
今となってはATX Headingと比べて見かけることも少なくなったが、次のような記法だ。
foo
===
bar
---
ba
zz
==
=
を使うとh1要素、-
を使うとh2要素ということになる。Markdownというのは元々、自分の解釈違いでなければ、プレーンテキストで読んだ場合にもそれらしく装飾された雰囲気で読めることを指向した形式だったように思う。その思想で言えば、ATX HeadingよりSetext Headingの方が、"それらしく" 見えるような気がするように思えるのは、自分だけだろうか。
この記法は、Paragraphのように、複数行のテキストを含むことがある。よって、入力文字列をブロック単位でパースする際には、ParagraphかSetext Headingか分からないが文字列を1行ずつ処理していき、Setext Headingの下線に符合するパターンが現れたらSetext Headingとして、そうでなければParagraphとして扱う、という実装になった。
テスト
CommonMarkが、Markdownと期待されるHTMLの対を数百件ほどサンプルデータとして持っている。これを利用することで、仕様に沿った実装ができているかどうかを上手くテストできる。
今回つくっているライブラリでは、ParagraphとATX Headingを実装した時点で19%、Setext Headingを実装した時点で21%程度のテストが成功している。