Rustを使うと高速に動作するプログラムが作成できため、何かしらのパーサーやコンパイラを作ったりするのにも使われます。今回は、簡単な「マークダウン記法」のパーサーを作って、マークダウンファイルをHTMLに変換するツールを作ってみましょう。
マークダウン記法とは?
「マークダウン記法(Markdown)」とは、テキスト形式のマークアップ言語です。簡単な記号を使用して、タイトルや、リスト、プログラムコードなどの特定の書式を表現するものです。特に、Webコンテンツの作成やプログラミング関連のドキュメントを表現するのに利用されます。GitHub、Stack Overflow、DiscordやQiita、Zennなどさまざまなサイトで採用されています。
そして、既に多くのマークダウンパーサーが存在しており、それらを使う事で、マークダウン記法をHTMLやPDFなどの別の形式へ変換することができます。探してみると、Rustにもマークダウンを処理するためのライブラリが公開されています。それでも、実際に自分で作ってみることで、Rustの文字列の扱いや、パーサーの基本原理を学ぶことができるのでオススメです。
マークダウン記法には、いろいろな要素があるのですが、あまり複雑にするとプログラムが長くなって読みにくくなってしまいます。そこで、「見出し」「リスト」「コードブロック」など基本的な要素に限定したパーサーを実装してみましょう。
プログラムの動作
なお、マークダウンとHTMLは容易に変換できるため、マークダウンを読みながら、そのまま逐次HTMLを出力することもできます。しかし、プログラムを簡潔にするため、マークダウンを一度、要素のリストに変換しておいて、その後で、HTMLに変換するという仕組みにしてみましょう。
今回は作りませんが、このように一度、要素のリストを作る仕組みにしておけば、タイトル要素だけを選び出して目次を作るように改造することも容易になります。また、HTMLを出力するのではなく、PDFや別の形式のデータを出力する場合にもそのまま利用できます。
プロジェクトを作成しよう
最初にCargoコマンドを利用して、Rustのプロジェクトを作成しましょう。ターミナルを起動して、以下のコマンドを実行しましょう。
mkdir markdown
cd markdown
cargo init
すると、src/main.rs というファイルが作成されます。これが、プロジェクトのメインファイルとなります。今回は、このメインファイルを少しずつ作っていきましょう。なお、完成したプログラム全体は、こちらにアップしていますので、最終的なプログラムを動かすのに使ってみてください。
コマンドラインツールのメイン関数を作ろう
今回のプログラムは、コマンドラインから使えるツールにします。そこで、下記のようなmain関数を作成しましょう。
// メイン関数 --- (*1)
fn main() {
// コマンドライン引数が足りない場合は使い方を表示 --- (*2)
if std::env::args().len() < 2 {
println!("[Usage] markown [input.md]");
return;
}
// コマンドライン引数からファイル名を取得する --- (*3)
let filename = std::env::args().nth(1).unwrap();
// ファイルを読み込む --- (*4)
let markdown = std::fs::read_to_string(&filename).unwrap();
// パースしてHTMLに変換する --- (*5)
let items = parse_markdown(&markdown);
let html = reander_to_html(items);
// 完全なHTMLにするため、ヘッダとフッタをくっつける --- (*6)
let style = "pre { background-color:#EEF; padding:1em; }";
let html = format!("<html><body><style>{}</style>{}</body></html>", style, html);
// ファイルへ保存 --- (*7)
let html_filename = filename.replace(".md", "") + ".html";
std::fs::write(&html_filename, html).unwrap();
println!("saved: {}", html_filename);
}
このmain関数では、コマンドライン引数からファイル名を取り出して、そのファイルを読み出して、HTMLに変換して保存するという処理を記述しています。プログラムを詳しく見てみましょう。
(*1)では、main関数を定義します。Rustのプログラムを実行したとき、このmain関数が自動的に実行されます。
(*2)では、コマンドラインツール引数が足りない場合、使い方を表示します。
(*3)では、コマンドライン引数から最初の引数(ファイル名の部分)を取得します。引数は、std::env::args関数で取得できます。ただし、args関数はイテレータを返すため、通常の配列のように値を取得できません。for文で一つずつ処理するか、ここで指定するように、nth関数を利用して要素を取り出します。
(*4)では、マークダウン形式のテキストファイルを読み込みます。(*5)では読み込んだテキストをパースしてHTMLに変換します。このparse_markdown関数とreander_to_html関数をこの後作成します。
(*6)では完全なHTMLに変換するためHTMLのヘッダとフッタを付けて、(*7)でファイルへ保存します。なお、ここでは「test.md」のようなファイルであれば「test.html」というファイル名で保存するようにしました。
マークダウンパーサーを作ろう
次に、文字列を一行ずつ読んで、マークダウンを解析するパーサー部分を定義しましょう。パーサーを作るのに当たって、解析済みのマークダウン要素を記録する構造体を定義しましょう。ここでは以下のような、MakdownItemという構造体を定義しました。そして、アイテムの種類を判別するために、列挙型のMarkdownKindも定義しました。
// マークダウンのアイテム種類を表す列挙型 --- (*8)
#[derive(PartialEq, Debug, Clone, Copy)]
enum MarkdownKind {
None, Title, Text, Code, List
}
// マークダウンのアイテム要素を表す構造体
#[derive(PartialEq, Debug, Clone)]
struct MarkdownItem {
kind: MarkdownKind, // 要素の種類
text: String, // テキストデータ
level: isize // レベル
}
impl MarkdownItem { // 手軽に構造体を作るための関数
fn new(kind: MarkdownKind, text: String, level: isize) -> Self {
Self { kind, text, level }
}
}
そして、マークダウンを読んで、Vec
次に、文字列を解析する関数parse_markdownの定義を確認してみましょう。
// 文字列をマークダウンのアイテムに分割する
fn parse_markdown(text: &str) -> Vec<MarkdownItem> {
let mut items = Vec::new(); // 変換結果をこの変数に追加していく
let mut flag_code = false; // コードブロック内かどうか判定
let mut multiline = String::new(); // コードブロック内の文字列を覚えておく用
// テキストを一行ずつ処理する --- (*9)
for line in text.lines() {
// コードブロックの終端か判断する --- (*10)
if flag_code {
if line == "```" {
items.push(MarkdownItem::new(
MarkdownKind::Code, multiline.to_string(), 0));
flag_code = false;
} else {
multiline.push_str(line);
multiline.push_str("\n");
continue;
}
}
// マークダウンのアイテムを判定する --- (*11)
if line.starts_with("#") { // タイトル
let mut title = String::new();
let mut level = 0;
for c in line.chars() {
if c == '#' {
level += 1;
} else {
title.push(c);
}
}
items.push(MarkdownItem::new(MarkdownKind::Title,
title.trim().to_string(), level));
} else if line.trim().starts_with("-") { // リスト
items.push(MarkdownItem::new(
MarkdownKind::List, line[1..].to_string(), 0));
} else if line.starts_with("```") { // コードブロック --- (*12)
flag_code = true;
multiline = String::new();
} else {
items.push(MarkdownItem::new(MarkdownKind::Text, line.to_string() ,0));
}
}
items
}
プログラムを確認してみましょう。(*9)の部分でlinesメソッドを実行して一行ずつ文字列を読み取っていきます。
(*10)ではコードブロックの終端かどうかを判定します。マークダウン内にプログラムコードを記述する記法…
は一行で簡潔するのではく、複数行に渡って記述されます。そのため、変数flag_codeを用いて、コードブロック内の文字列を変数multilineに文字列を追加していきます。そして、ブロックの終端に達した時に、変数itemsにコードブロックを追加します。なお、コードブロックの開始は(*12)で確認します。
(*11)以降の部分では、行頭の数文字を調べて、マークダウンのどの要素なのかを調べます。"#"であれば見出し、"-"であればリスト、"```"であればコードブロックです。
なお、(*12)ではコードブロックの開始を確認します。コードブロックは複数行に渡るため、変数flag_codeを利用して、(*10)でコードブロックの終端判定をします。
HTMLレンダリング関数を作ろう
最後に、Vec
// マークダウンのアイテムをHTMLに変換する --- (*13)
fn reander_to_html(items: Vec<MarkdownItem>) -> String {
let mut result = String::new();
let mut last_kind = MarkdownKind::None;
let mut flag_list = false;
// 要素を一つずつHTMLに変換していく --- (*14)
for item in items.iter() {
if flag_list && item.kind != MarkdownKind::List {
result.push_str("</ul>\n");
flag_list = false;
}
// テキストをHTMLに変換する --- (*15)
let text = item.text.replace("&", "&")
.replace("<", "<").replace(">", ">");
// アイテムの種類によって処理を変える --- (*16)
match item.kind {
MarkdownKind::Title => {
let level = item.level;
result.push_str(&format!("<h{}>{}</h{}>\n", level, text, level));
},
MarkdownKind::Text => {
result.push_str(&format!("<p>{}</p>\n", text));
},
MarkdownKind::Code => {
result.push_str(&format!("<pre>{}</pre>\n", text));
},
MarkdownKind::List => {
if last_kind != MarkdownKind::List {
result.push_str("<ul>\n");
}
result.push_str(&format!("<li>{}</li>\n", text));
flag_list = true;
},
_ => {},
}
last_kind = item.kind;
}
result
}
プログラムの(*13)以降で、HTMLに変換するreander_to_htmlを定義します。(*14)ではVecの各要素を一つずつHTMLに変換します。
(*14)の部分では、特殊記号を変換してテキストをHTMLに変換します。そして、(*15)の部分で要素の種類に応じてタグで囲んでいきます。なお、リスト要素だけは、要素の開始と終了部分を<ul>と</ul>で囲う必要があるため、前回の要素が何だったのかlast_kindに記録するようにしています。