😎

RustでMarkdownの構造を可視化

2021/09/22に公開

RustでMarkdownの構造を可視化

成果物

この記事の構成

動機

しっかりした構造の文書や、構造を共有して推敲すべき記事を書くとき、アウトライナーだけでなくマインドマップ風のグラフも作りたかったことが動機です。

Why Rust?

  • VS Codeで執筆しているため、CLIが適当
  • Rustでアプリケーションをつくれば、環境に応じてかんたんにビルドできる

マインドマップを書けばいいのでは?

  • 文書作成と構成の抽出ないしビジュアライズという2つの作業に分かれるのが面倒なので嫌です。
  • この方法だと(グラフの表現力に制限はかかるものの)Markdown記法の見出しの入れ方さえ覚えればいいので、楽です。

レポジトリ

https://github.com/Nakamurus/rust_markdown_graph

何をしているアプリケーション?

ざっくりですが、引数のハンドリングなど除けば3つのロジックからなっています。

Markdown文書をパースして見出しのラインだけを記録

  • 見出しのレベルも記録しています

見出しの入れ子関係をメモ

  • 今の見出しのレベルが次のレベルより同一以上ならこのネストは終了して記録
  • 見出しの親子関係というシンプルな構造なのでこの手法です
  • ルートから各葉までのパスをメモしたら、隣接した親子関係でペアを作ります

DOT言語に書き出し

こだわったところ

関心の分離

単一責任原則に則り、できるだけ一関数一機能としたうえでファイルも分割することで、構造を明確化、修正も楽にしました。
アプリケーションの機能が単純なこともありますが、以下のmain.rsをみれば何をしているかわかるよう構成できたと思っています。
また、修正について後述しますが、DOT言語への変換は当初使っていたpetgraphから自前実装に切り替えたのですが、問題の関数のみを修正すればよくストレスフリーでした。

main.rs
use markdown_outliner::input_handler;
use markdown_outliner::outline_filter;
use markdown_outliner::relation_builder;
use markdown_outliner::view::to_graphviz::visualize;

fn main() {
    let (filename, level, direction) = input_handler().unwrap();
    let titles = outline_filter(&filename, &level).unwrap();
    let connected = relation_builder(&titles);
    let filename_wo_extension = filename.split('.').next().unwrap();
    if let Ok(_) = visualize(&filename_wo_extension, &direction, connected) {
        println!("Created {}.dot successfully", filename_wo_extension);
    } else {
        println!("Could not create dot file");
    }
}

DOT言語への変換は自前で実装

当初、DOTファイル書き出しはグラフ関係のクレートであるpetgraphの一機能を使っていました。
しかし、DOT言語の記法が単純であるため、余分な依存関係を無くせることもあり、自前で書き出しロジックを実装しました。
さらに、変換時に日本語フォントを指定できるなどの柔軟性というメリットも生まれました(Petgraphでも設定できるとは思いますが…)。

メモしておきたいところ

これは私の備忘録的セクションです。微妙にハマったところや忘れそうな箇所を列挙しています。

他ファイルの関数や構造体の使い方

lib.rsで関数等を集約して、main.rsに流す

こうすることで、main.rsがすっきりしてアプリケーションの構造が明確になります。

lib.rs
pub use crate::view::to_graphviz::visualize;
pub use input_handler::input_handler;
pub use outline_filter::outline_filter;
pub use relation_builder::relation_builder;
pub use title::Title;

pub mod input_handler;
pub mod outline_filter;
pub mod relation_builder;
pub mod title;
pub mod view;
main.rs
use markdown_outliner::input_handler;
use markdown_outliner::outline_filter;
use markdown_outliner::relation_builder;
use markdown_outliner::view::to_graphviz::visualize;

fn main() {
    let (filename, level, direction) = input_handler().unwrap();
    let titles = outline_filter(&filename, &level).unwrap();
    let connected = relation_builder(&titles);
    let filename_wo_extension = filename.split('.').next().unwrap();
    if let Ok(_) = visualize(&filename_wo_extension, &direction, connected) {
        println!("Created {}.dot successfully", filename_wo_extension);
    } else {
        println!("Could not create dot file");
    }
}

ネストしたファイルの関数は少し面倒

レポジトリを見てもらえればわかりますが、プロジェクトにsrc/viewというディレクトリがあります。
同ディレクトリにはview/mod.rsview/to_graphviz.rsファイルがあります。
to_graphviz.rsで定義した関数を使うにはまず、mod.rsに次の一行を書き込みます。

mod.rs
pub mod to_graphviz;

そのご、lib.rsでpub mod DIRECTORYpub use crate::{SRC以下のパスと関数名};を指定します。

lib.rs
pub use crate::view::to_graphviz::visualize;
(中略…)
pub mod view;

clapで受取る値を制限するにはpossible_value(s)

clapは、CLIの引数を受取るのに便利なクレートです。
clapを使って受取る値を制限したい場合は、possible_value(s)を使います。複数の候補なら、valuesです。

clap_example.rs
let matches = App::new("markdown_outliner")
        .version("0.1")
        .author("Nakamura")
        .about("parse markdown file to create file outline")
        .arg(
            Arg::with_name("INPUT")
                .help("File path to use")
                .required(true)
                .index(1),
        )
        .arg(
            Arg::with_name("level")
                .takes_value(true)
                .short("l")
                .default_value("6")
                .help("Heading level. level 1 will catch only H1 title")
                .possible_values(&["1", "2", "3", "4", "5", "6"]),
        )
        .arg(
            Arg::with_name("direction")
                .takes_value(true)
                .short("d")
                .default_value("LR")
                .help(
                    "Graphviz image direction. L=Left, R=Right, T=Top, B=Bottom. LR=Left to Right",
                )
                .possible_values(&["LR", "RL", "TB", "BT"]),
        )
        .get_matches();
    let file_name = matches.value_of("INPUT").unwrap().to_string();
    let level = matches.value_of("level").unwrap().to_string();
    let direction = matches.value_of("direction").unwrap().to_string();

書き込みはバッファリングしてから

https://keens.github.io/blog/2017/10/05/rustdekousokunahyoujunshutsuryoku/
書き込みの箇所を一部だけ切り取ると、以下の通りです。

write.rs
let output = format!("{}.dot", file_name);
let f = File::create(output).expect("unable to create file");
let mut f = BufWriter::new(f);
writeln!(f, "digraph {{").unwrap();

終わりに

こと標準ライブラリに限って言えば、ドキュメンテーションが懇切丁寧で分かりやすいのでRustは親しみやすい良い言語です。ぜひ。

Discussion