Rust で書く CommonMark-to-HTML の SSG、その 1

# はじめに

最近 Rust で静的サイトジェネレータ (SSG) を書いているのですが、その途中で色々知ったことや、考えたことをここにまとめたいと思います。このまとめを書いている時点でのソースは[ここ](https://github.com/tsraom/writer2/blob/d64c5b4c9fe1879d7cc4de84def941e94333da9d/src/main.rs)です。

ちなみに、この SSG の名前は `writer2` と言います。なぜ 2 なのかというと、もともと CommonMark のパーサから作ろうと思って始めた `writer` というプロジェクトもあるのですが、さすがに時間がかかり過ぎるので、とりあえず便利な [`pulldown-cmark`](https://github.com/google/pulldown-cmark/) を使って簡単な SSG を作りました。これが `writer2` です。

`pulldown-cmark` は、まだ完全に CommonMark 0.28 のスペックに達していません。やはり、[`cmark`](https://github.com/jgm/CommonMark) あたりの Rust 向けのバインディングとかがあれば、そっちを使いたいですね。

# 何がしたいのか

指定されたディレクトリの中のファイルのうち、CommonMark で書かれたファイルを HTML に変換し、その他のファイルはそのままコピーするという、ごく一般的な SSG を作りたいと思います。自分が SSG を使うとしたら、言語学、数学やコーディングについて書くことが多いので、Syntax highlighting とか、MathJax とかも将来は組み込んでみたいのですが、とりあえず今回は基本的な機能の実装について話したいと思います。

`writer2` は、こんな風に使います。

```
usage: target\debug\writer2.exe INPUT-DIR [options]

Options:
    -o, --output-dir OUTPUT-DIR
                        set output directory
    -h, --help          print this help menu
    -v, --verbose       be very wordy
```

変換したいファイルが入っているディレクトリを指定は必ず指定しなければなりません。変換先のディレクトリは、オプション `-o` で指定できますが、指定しなければ入力ディレクトリと同じになります。

上記リンクのレポジトリの中にテスト用の `test/input` と `test/output` というディレクトリがあるので、こう使えます。

```shell
cargo run -- test/input -o test/output
```

`cargo run` に与えるオプションは、`--` 以前のものが `cargo run` のオプションとしてみなされ、`--` 以後のものは実行したバイナリのオプションとしてみなされます。

# コード

## 1. getopts でオプション解析

Rust でオプション解析を行うためには色々な crate があるみたいですが、中でもシンプルなのが公式によって開発されている [`getopts`](https://github.com/rust-lang-nursery/getopts) です。

今回書いた SSG のオプションはこういった構成です。

*   必要な引数：入力ディレクトリ
*   任意の引数：出力ディレクトリ (`-o`,`--output-dir`)
*   任意のフラグ：ヘルプ (`-h`, `--help`)、verbose (`-v`, `--verbose`)

各オプションが引き起こす挙動もまとめます。

*   入力ディレクトリ：入力ディレクトリを設定する。
*   出力ディレクトリ：指定されれば、出力ディレクトリを設定する。指定されなければ、出力ディレクトリを入力ディレクトリと同じに設定する。
*   ヘルプ：指定されれば、ヘルプを表示する。
*   verbose：指定されれば、何かする度に色々うるさく表示する。

ではコードを書きます。

まず、渡されたオプションを解析した後、その情報をまとめて格納する `struct` を作ります。

```rust
use std::path::PathBuf;

struct ProgramInfo {
    input_dir: PathBuf,   //  入力ディレクトリ 
    output_dir: PathBuf,  //  出力ディレクトリ
    verbose: bool,        //  verbose
}
```

`std::path::PathBuf` はパスを表す型です。`PathBuf` と `Path` の関係は、`String` と `&str` の関係に近いのかな？と自分は思っています。パスを接合する時に使える `join` とか、色々便利な機能があるので、`PathBuf` を使います。

ヘルプ (`-h`) は、渡されたらヘルプメッセージを表示すればいいだけなので、特にこの `struct` にフィールドを設ける必要はありません。

次に、`parse_options` というオプション解析を行う関数を書きます。成功すれば先程定義した `ProgramInfo` を `Ok()` に入れて返し、失敗すれば `Err(())` を返してくれればいいですね。

```rust
extern crate getopts;

use getopts::Options;
use std::env;

fn parse_options() -> Result<ProgramInfo, ()> {
    ...
}

fn main() {
    let info = parse_options()
        .expect("bad program options");
}
```

では、`parse_options` を実装します。

[`std::env::args`](https://doc.rust-lang.org/std/env/fn.args.html) はプログラムに渡された引数のイテレータを返します。引数の型は `String` で、Unicode じゃないと panic されます。これを使って、引数を `Vec` にまとめて、後で `getopts` の関数に渡します。

```rust
fn parse_options() -> Result<ProgramInfo, ()> {
    let args: Vec<String> = env::args().collect();
    let program = &args[0];  //  最初の引数はプログラムの名前
    ...
```

`program` は、後でヘルプメッセージを表示する時に使います。

`getopts::Options` を作って、それに解析したいオプションを登録していきます。`optopt` とか `optflag` とかの関数については、[docs](https://docs.rs/getopts/0.2.18/getopts/struct.Options.html#methods) に詳しく書いてあります。

`optopt` に渡される引数は、1 個目がハイフン 1 文字で指定される名前 (short name)、2 個目がハイフン 2 文字で指定される名前 (long name)、3 個目がヘルプで表示されるメッセージ、4 個目がヘルプメッセージで引数の値の例として表示される文字列です。このコードでは 4 個目の引数に `"OUTPUT-DIR"` を指定しているので、ヘルプメッセージはこうなります。

```
...
Options:
    -o, --output-dir OUTPUT-DIR
                        set output directory
...
```

`Options::parse` で先程の `args` を解析させます。`args` の最初の項目は、プログラム名であってオプションではないので、`parse` に渡すのは `args[..]` ではなく `args[1..]` となっています。成功すれば、`getopts::Matches` という `struct` を返してくれます。

```rust
    ...
    let mut opts = Options::new();
    opts.optopt("o", "output-dir", "set output directory", "OUTPUT-DIR");
    opts.optflag("h", "help", "print this help menu");
    opts.optflag("v", "verbose", "be very wordy");

    let matches = opts.parse(&args[1..]).unwrap_or_else(|f| panic!(f.to_string()));
    ...
```

次に、各オプションが引き起こすべき挙動を実装していきます。

まずはヘルプ (`-h`)。指定されていれば、ヘルプメッセージを表示します。ここでは、与えられたオプションが指定されているかどうかを教えてくれる関数 `Matches::opt_present` を使っています。

```rust
    ...
    if matches.opt_present("h") {
        print_usage(program, &opts);
    }
    ...
```

`print_usage` は、こんな感じです。先程定義した `program` を使っています。

```rust
fn print_usage(program: &str, opts: &Options) {
    let brief = format!("usage: {} INPUT-DIR [options]", program);
    print!("{}", opts.usage(&brief));
}
```

`Options::usage` は、うまいことヘルプメッセージをフォーマットしてくれます。

ではオプションに戻ります。次のオプションは、入力ディレクトリです。これは必要な引数なので、指定されていなければヘルプメッセージを表示し、`Err(())` を返します。指定されていれば、`PathBuf::from` を使って `PathBuf` である `input_dir` に格納します。

```rust
    ...
    let mut input_dir = match matches.free.is_empty() {
        true => {
            print_usage(program, &opts);
            return Err(());
        },

        false => PathBuf::from(matches.free[0].clone()),
    };
    ...
```

出力ディレクトリは、指定されていればそれを使い、そうでなければ入力ディレクトリを `clone()` します。

```rust
    ...
    let mut output_dir = match matches.opt_str("o") {
        Some(s) => PathBuf::from(s),
        None => input_dir.clone(),
    };
    ...
```

入・出力ディレクトリが相対パスだったら、絶対パスに変換します。このコードは、後にエラーメッセージの表示とかに役立つかと思い、書いたのですが、今の所は必要ありません。

```rust
    ...
    let pwd = env::current_dir().unwrap();
    if input_dir.is_relative() {
        input_dir = pwd.join(input_dir);
    }
    if output_dir.is_relative() {
        output_dir = pwd.join(output_dir);
    }
    ...
```

ここまで来れば、解析成功です。`Ok()` に結果をまとめて返しましょう。

```rust
    Ok(ProgramInfo {
        input_dir: input_dir,
        output_dir: output_dir,
        verbose: matches.opt_present("v"),
    })
}
```

## 2. グローバルアセットのコピー

CSS や、ブログのヘッダなどに使われる画像ファイルなどのアセットは、出力ディレクトリにコピーして、変換した HTML から引用することにします。

CSS のように、全てのページで使われるアセットもあれば、特定の投稿に含まれる画像のように、各ページで異なるアセットもあります。この二種類のアセットを**グローバルアセット**と**ローカルアセット**とでも呼びましょう。今回は、まずグローバルアセットをどう保管するかを考えます。

`writer2` では、[Skeleton](https://github.com/dhg/Skeleton) というフレームワークをフォントを変えて使っています。CSS ファイル二つで超軽量です。この 2 つのアセットが、レポジトリの `assets` というディレクトリ下に入っています。

```
writer2
├─assets
│      normalize.css
│      skeleton.css
```

変換された HTML ファイルは、全てこの 2 つのアセットを使います。例として、レポジトリにある `test/input` ディレクトリを見てみます。出力ディレクトリである `test/output` の中身も見てみます。

```
└─test
    ├─input
    │  │  notpost.txt
    │  │  post.md
    │  │
    │  └─inner
    │          inner-notpost.txt
    │          inner-post.md
    │
    └─output
        │  notpost.txt
        │  post.html
        │
        ├─assets
        │      normalize.css
        │      skeleton.css
        │
        └─inner
                inner-notpost.txt
                inner-post.html
```

`test/output` の中でも、`post.html` と `inner/inner-post.html` はディレクトリの深度が違うので、HTML 内での CSS の引用パスも違います。

```output/post.html
<link rel="stylesheet" href="assets/normalize.css" type="text/css">
<link rel="stylesheet" href="assets/skeleton.css" type="text/css">
```

```output/inner/inner-post.html
<link rel="stylesheet" href="../assets/normalize.css" type="text/css">
<link rel="stylesheet" href="../assets/skeleton.css" type="text/css">
```

ディレクトリが 1 レベル深くなると、パスの先頭の `../` が増えます。

注意すべきポイントをまとめると、

1.  アセットを一度だけコピーすること
2.  アセットのパスを間違えないこと

ですね。ではコードを書きます。

まずは、アセットを表す `struct` `Asset` を作ります。アセットのパスと、種類を指定します。

```rust
struct Asset {
    path: PathBuf,
    asset_type: AssetType,
}

//  アセットの種類は、とりあえず CSS だけ
enum AssetType {
    Css,
    Other,
}
```

なぜアセットの種類を指定するかというと、それによって対応する HTML タグが違うからです。

`Asset` を作る時、いちいち `Asset { path: ..., asset_type: ... }` と書くのは面倒なので、sugar を作ります。

```rust
fn make_asset(path: &str, ty: AssetType) -> Asset {
    Asset {
        path: PathBuf::from(path),
        asset_type: ty,
    }
}
```

`main` で、`writer2` が扱うアセットの情報を `Vec` にまとめましょう。

```rust
fn main() {
    ...
    let assets: Vec<Asset> = vec![
        make_asset("assets/normalize.css", AssetType::Css),
        make_asset("assets/skeleton.css", AssetType::Css),
    ];

    prepare_assets(&info, &assets);
}
```

この `prepare_assets` という関数で、アセットのコピーなどの作業を行います。引数として `ProgramInfo` が必要なのは、アセットのコピー先である出力ディレクトリを知る必要があるからです。

```rust
fn prepare_assets(info: &ProgramInfo, assets: &Vec<Asset>) {
    let output_dir = &info.output_dir;

    for asset in assets {
        let path = &asset.path;

        let parent_dir = path.parent()
            .expect(&format!("asset \"{}\" does not have a parent", path.display()));
        let parent_dir = output_dir.join(parent_dir);

        fs::create_dir_all(parent_dir.clone())
            .expect(&format!("could not create asset directory \"{}\"", parent_dir.display()));

        let asset_dir = path.file_name()
            .expect(&format!("asset \"{}\" does not have a filename", path.display()));
        let asset_dir = parent_dir.join(asset_dir);

        fs::copy(path, asset_dir.clone())
            .expect(&format!("could not copy asset \"{}\" to \"{}\"", path.display(), asset_dir.display()));
    }
}
```

`prepare_assets` は、`assets` の中の各 `Asset` に対して処理を行っています。`asset.path` が `"assets/skeleton.css"` で、出力ディレクトリが `"test/output"` の場合を例にとって説明します。

1.  出力ディレクトリを `output_dir` と呼びます。今、`output_dir` は `"test/output"` です。
2.  `path` の親ディレクトリを `PathBuf::parent` で取得し、それを `parent_dir` と呼びます。今、`parent_dir` は `"assets"` です。
3.  `parent_dir` を `output_dir` の後に接合し、再度 `parent_dir` と呼びます。今、`parent_dir` は `"test/output/assets"` です。
4.  `fs::create_dir_all` で `parent_dir` ディレクトリを作成します。
5.  `path` のファイル名を `PathBuf::file_name()` で取得し、それを `asset_dir` と呼びます。今、`asset_dir` は `"skeleton.css"` です。これは、本当は `asset_path` とでも呼ぶべきですね。
6.  `asset_dir` を `output_dir` の後に接合し、再度 `asset_dir` と呼びます。今、`asset_dir` は `"test/output/assets/skeleton.css"` です。
7.  `fs::copy` で `path` にあるアセットを `asset_dir` へコピーします。

### `fs::create_dir` と `fs::create_dir_all` の違いについて

指定したパスに何層もディレクトリがある時に、`fs::create_dir_all` は再帰的に必要なディレクトリを作成するのに対し、`fs::create_dir` は最後のディレクトリしか作成してくれないからです。例えば、何もないところに `a/b/c` というディレクトリを作成したいとします。

```rust
//  a を作成して、次に a/b を作成して、最後に a/b/c を作成してくれる
fs::create_dir_all(PathBuf::from("a/b/c"));

//  a/b はあらかじめ存在しないので、a/b/c を作成してくれない
fs::create_dir(PathBuf::from("a/b/c"));
```

### `PathBuf::display()` について

さっきのコードでは、`Result::expect` のメッセージを `format!` して作っているのですが、パスを表示させたい時には、`PathBuf` そのものではなく、`PathBuf::display()` の戻り値を使う必要があります。これは、`PathBuf` 自体は `std::fmt::Display` を実装していないためです。

```rust
let path = PathBuf::from("hello/world");
println!("{}", path);            //  ダメ
println!("{}", path.display());  //  OK
```

# 終わり

はじめて `PathBuf` を使ってみたんですが、便利です。次は与えられたディレクトリの中のファイルを再帰的に逐次処理するコードについて書きます。