Markdown パーサを書きたい!~レンダリング編~
この記事は某企業 2024 年度新卒 Advent Calender 2024 17 日目の記事です。
前回の記事ではMarkdownパーサを作りました。
今回はパーサして得た抽象構文木をレンダリングしてみるという記事です。
前回と比較すると軽めになると思います。
はじめに
前回作成した抽象構文木Token
はこのような構造でした。
#[derive(Debug, PartialEq, Clone)]
struct Token {
element: MDElement,
children: Vec<Token>,
}
Token
は木構造なので親のToken
をレンダリングするためには子どものVec<Token>
をレンダリングする必要があります。
レンダリング関数を作る
まず大枠のレンダリング関数render
を作ります。
fn render(token: &Token) -> String {
if token.children.is_empty() {
return render_content(&token.element);
}
let render_children = token
.children
.iter()
.map(render)
.collect::<Vec<String>>()
.join("");
render_element(&token.element, &render_children)
}
シンプルですね。
render
関数はToken
をとりString
を返します。
Token
は木構造なので、親のレンダリングをするために子どものレンダリングを行います。(変数render_children
の部分)
ここは再帰処理となっています。
そして子どものレンダリングした結果を使って、HTMLを実際にレンダリングします。(関数render_element
の部分)
関数render_element
はHTMLタグだけ追加するイメージです。
この再帰関数の停止条件は、Token
にトークンの子どもがいなくなったらです。
停止条件に達したときは関数render_content
を呼び、Token
の保持するテキストを含めたHTMLを返します。
では2つの関数render_element
とrender_content
を定義します。
render_element
とrender_content
を定義する
この2つの関数を定義する前に、MDElement
に対応するHTMLを生成する関数を作ります。
fn render_root(content: &String) -> String {
format!("<div>{}</div>", content)
}
fn render_line_break() -> String {
"".to_string()
}
fn render_header1(content: &String) -> String {
format!("<h1>{}</h1>", content)
}
fn render_header2(content: &String) -> String {
format!("<h2>{}</h2>", content)
}
fn render_header3(content: &String) -> String {
format!("<h3>{}</h3>", content)
}
fn render_header4(content: &String) -> String {
format!("<h4>{}</h4>", content)
}
fn render_header5(content: &String) -> String {
format!("<h5>{}</h5>", content)
}
fn render_header6(content: &String) -> String {
format!("<h6>{}</h6>", content)
}
fn render_paragraph(content: &String) -> String {
format!("<p>{}</p>", content)
}
fn render_span(content: &String) -> String {
format!("<span>{}</span>", content)
}
fn render_blockquote(content: &String) -> String {
format!("<blockquote>{}</blockquote>", content)
}
fn render_code_block(content: &String) -> String {
format!("<pre><code>{}</code></pre>", content)
}
fn render_unordered_list(content: &String) -> String {
format!("<ul>{}</ul>", content)
}
fn render_ordered_list(content: &String) -> String {
format!("<ol>{}</ol>", content)
}
fn render_list_item(content: &String) -> String {
format!("<li>{}</li>", content)
}
fn render_horizontal_rule() -> String {
"<hr>".to_string()
}
fn render_emphasis(content: &String) -> String {
format!("<em>{}</em>", content)
}
fn render_strong(content: &String) -> String {
format!("<strong>{}</strong>", content)
}
fn render_strikethrough(content: &String) -> String {
format!("<del>{}</del>", content)
}
fn render_link(text: &String, url: &String) -> String {
format!("<a href=\"{}\">{}</a>", url, text)
}
fn render_image(text: &String, url: &String) -> String {
format!("<img src=\"{}\" alt=\"{}\">", url, text)
}
これらを使ってまずrender_element
を作ります。
fn render_element(parent: &MDElement, content: &String) -> String {
match parent {
MDElement::Root => render_root(content),
MDElement::LineBreak => render_line_break(),
MDElement::HorizontalRule => render_horizontal_rule(),
MDElement::Header1(_) => render_header1(content),
MDElement::Header2(_) => render_header2(content),
MDElement::Header3(_) => render_header3(content),
MDElement::Header4(_) => render_header4(content),
MDElement::Header5(_) => render_header5(content),
MDElement::Header6(_) => render_header6(content),
MDElement::Paragraph(_) => render_paragraph(content),
MDElement::Span(_) => render_span(content),
MDElement::Blockquote(_) => render_blockquote(content),
MDElement::CodeBlock(_) => render_code_block(content),
MDElement::Emphasis(_) => render_emphasis(content),
MDElement::Strong(_) => render_strong(content),
MDElement::Strikethrough(_) => render_strikethrough(content),
MDElement::OrderedList => render_ordered_list(content),
MDElement::UnorderedList => render_unordered_list(content),
MDElement::OrderedListItem(_, _) => render_list_item(content),
MDElement::UnorderedListItem(_, _) => render_list_item(content),
MDElement::Link(_, url) => render_link(content, url),
MDElement::Image(text, url) => render_image(text, url),
MDElement::ListItems(_) => "".to_string(),
}
}
この関数は引数にテキスト取って、そのテキストにHTMLのタグを付与する感じです。
一方render_content
は
fn render_content(markdown: &MDElement) -> String {
match markdown {
MDElement::LineBreak => render_line_break(),
MDElement::Header1(content) => render_header1(content),
MDElement::Header2(content) => render_header2(content),
MDElement::Header3(content) => render_header3(content),
MDElement::Header4(content) => render_header4(content),
MDElement::Header5(content) => render_header5(content),
MDElement::Header6(content) => render_header6(content),
MDElement::Paragraph(content) => render_paragraph(content),
MDElement::Span(content) => render_span(content),
MDElement::Blockquote(content) => render_blockquote(content),
MDElement::CodeBlock(content) => render_code_block(content),
MDElement::Emphasis(content) => render_emphasis(content),
MDElement::Strong(content) => render_strong(content),
MDElement::Strikethrough(content) => render_strikethrough(content),
MDElement::OrderedListItem(_, content) => render_list_item(content).to_string(),
MDElement::UnorderedListItem(_, content) => render_list_item(content).to_string(),
MDElement::HorizontalRule => render_horizontal_rule(),
MDElement::Link(text, url) => render_link(text, url),
MDElement::Image(text, url) => render_image(text, url),
_ => "".to_string(),
}
}
と、render_element
と似ていますが、こちらはMDElement
で保持しているテキストにHTMLのタグをつけています。
これでレンダリング関数は完成です。
レンダリング関数自体はToken
の木構造そのままHTMLに落とし込むだけなので割と簡単です。
最後にちょっとだけテストをしてみます。
#[test]
fn test_redner() {
let markdown = Token {
element: MDElement::Root,
children: vec![
Token {
element: MDElement::Header1("Hello, world!".to_string()),
children: vec![],
},
Token {
element: MDElement::Paragraph("*Hello, world!*".to_string()),
children: vec![Token {
element: MDElement::Emphasis("Hello, world!".to_string()),
children: vec![],
}],
},
Token {
element: MDElement::UnorderedList,
children: vec![
Token {
element: MDElement::UnorderedListItem(0, "**Hello1**".to_string()),
children: vec![Token {
element: MDElement::Strong("Hello1".to_string()),
children: vec![],
}],
},
Token {
element: MDElement::UnorderedListItem(0, "Hello2".to_string()),
children: vec![],
},
Token {
element: MDElement::UnorderedList,
children: vec![Token {
element: MDElement::UnorderedListItem(1, "Hello3".to_string()),
children: vec![],
}],
},
],
},
],
};
let expected = "<div><h1>Hello, world!</h1><p><em>Hello, world!</em></p><ul><li><strong>Hello1</strong></li><li>Hello2</li><ul><li>Hello3</li></ul></ul></div>";
assert_eq!(render(&markdown), expected);
}
おわりに
ここまで、Markdownのパーサを作ってみるという3部作の記事を書いてきました。
書いてみた感想ですが、パーサコンビネータはかなり強力なツールだなとコードを書いていて思いました。
単体テストのしやすさもあって、テストで動作を保証しつつ色々試行錯誤でき、パーサの勉強にもなりました。
おかげで、なんとなくパーサを理解することができたと思います(書いてきた内容が王道かどうかは知りません)。
ただ、本当はパーサとレンダラを使ってCLIツールでも作ってみたかったのですが、アドベントカレンダーに間に合わなく断念しました。
というのも、Markdownのパーサにまだまだバグを含んでいて、普通のMarkdownですらぐちゃぐちゃにレンダリングされてしまいます。
なので、これからちょこちょこ修正していって、CLI化できたらいいなーと思います。
それでは。