📘

ロボコン用にドキュメントジェネレータ作ってみた。#1 コンフィグファイルの生成まで

2024/09/08に公開

はじめに

皆様はじめまして。今夜は星が見えますか?流星 彗です。ながほし すいと読みます。私のX(@SuiSpace214)をご存知の方はご無沙汰してます。
今回から、私の所属している弊高専ロボコンプロジェクト用に、ドキュメントジェネレータを作っていきます。
というのも、我が校ロボコンプロジェクト(以下ロボコン)では、アジャイルとウォーターフォールの悪い所どりをしたような開発手法をとっており本当は取りたくない、効率があまりにも悪いことに今更気づかされてしまいました。そこでオフシーズン〜来年度以降に役立てられるよう、便利ツールを作っていこうと、私が一肌脱ぎ(指の一本や二本を捧げて)改革に踏み切ったわけです。
最終的に作りたいものは以下の通り

  • Doxygenのようなシンプルで使いやすいドキュメント生成ツール <- イマココ
    • 既存のプログラムを極力変更することなく、非破壊的に生成できる必要
    • なるべく少ないファイル数での出力
    • GitHubPagesでのホストを想定
    • できれば関数同士、ファイル同士の相関関係がわかるような機能を付けたい
  • 設計班、加工班、回路班、制御班各グループで仕事をする上で必要な知識を提供するドキュメント類
    • GitHubPagesでのホストを想定し、なるべく静的なドキュメントにする
    • できればエディタのようなものを提供し、他のメンバーや後輩が保守できるようにする
    • できれば画像、アニメーションのようなものを多めにグラフィカルな方向で
    • メンバーの力作を紹介、インデックスするページも付けたい
  • ロボコン各班で仕事する上で必要な本当に最低限のことを教えてくれるチュートリアル的なゲーム
    • 今の時代、長ったるい文章で教えるよりもゲームにした方がモチベーション維持につながる説
    • 正確さや再現性よりも、雰囲気やイメージを伝えることを重視
    • できればWebゲーム化

まあまあ一個人の手に負えないことをしている気がしますが、これもロボコンや将来の私のためです。私のやっているMISOLA Project.の活動にも少し活かせる部分はありますしね。まあ、半分ぐらい自己満なのは認めますが。
さて、長くなってしまいましたが、そんなわけで、手始めにドキュメント生成ツールを作っていきます。

シリーズ目次

#1 コンフィグファイルの生成まで(この記事)
#2 Coming Soon.

ノルマと環境構築

開発ノルマ

早速環境構築...の前に、この記事でのノルマというか目標を決めておきます。
初めは、最低限の生成機能まで作ろうかと思いましたが、明らかに長すぎる記事になってしまうことが容易に想像がつくため、動作に必要なプロパティを並べたコンフィグファイルを扱えるようにする所までで区切ろうと思います。

開発環境の構築

次に開発環境を構築します。
Rustをメインの開発言語とします。
エディタはお好みで。私はRustRoverを使っています。この記事でも、合わせてRustRover仕様となっています。必要に応じて、適宜読み替えてください。
正直Codespacesを使ってGitHub上で直に作業したいですが、生成物の確認が手間がかかるので、とりあえずローカルで構築しています。
Rustのセットアップは公式のインストールページ[1] がシンプルですので、今回は割愛します。時間があれば、私の方でも別途記事にします。

新規プロジェクトを作成し、雛形を作ります。
ここで初めてこのジェネレータの名前が決まるわけです。よろしく、Robodoc

Rustが正しくセットアップされているかの確認を兼ねて、一度実行しておきます。Hello, World!と出力されればOKです。

$ cargo run
~~~
Hello, World!

次にクレート[2]の選定です。が、事前準備なく選定したため、途中で変更する可能性があります。また、初めからなんでも入れずに、必要に応じて適宜追加していくことにします。

horrorshow

https://crates.io/crates/horrorshow
horrorshowは、Rustの持つ言語機能を活用して、そこそこ楽にHTMLが生成できるクレートです。決してホラー映画を上映する機能はありません。
マクロによるHTML生成だけでなく、生のHTMLを埋め込無こともできます。
forifなどが使えるため、テンプレートをゴニョゴニョしてドキュメントを生成する今回の用途にはもってこいというわけです。

$ cargo add horrorshow

toml & serde

https://crates.io/crates/toml
https://crates.io/crates/serde
tomlは、Rustの設定ファイルなどで使われているTOML形式で書かれたファイルを読み書きできるようにするクレートです。
serdeはRustの構造体とTOMLを相互変換できるようにするクレートです。
Robodocでは、設定ファイルにTOMLを用いるため、こちらのクレートを使います。お好みで、INIやJSONなどの形式を用いて開発することもできます。その場合はserde_jsonやinitなどのクレートを使うと良いでしょう。

chrono

https://crates.io/crates/chrono
chronoは、Rustで日時を扱うためのクレートです。
Robodocでは、作成した日付を記入するために使います。


求める機能が特殊すぎたのか思ったより使えそうなクレートが少なかったです。
これは大変な作業になりそう(遠い目)

実装

コマンドライン引数の解析

ようやっと開発に入れるわけですが、まずはコマンドライン引数[3]の解析を実装します。
一応便利なクレートはいくつかあるのですが、Robodocでは、すべての設定をコンフィグレーションファイル(以下コンフィグファイル)で行うため、クレートに頼るとかえって煩雑になってしまうため、自前で実装します。
実装したコードは以下になります。

main.rs
use std::env;

fn main() {
    let args :Vec<String> = env::args().collect();
    if args[1] == "init"  {
        println!("Initialization...");
    } else if args[1] == "generate" {
        println!("Generation...");
    }
}

use std::envでコマンドライン引数を扱うクレートをインポートし、
env::args().collect()で与えられた引数をベクタに格納します。
二つ目の引数が、init,generateのどちらであるかによって、イニシャライズ、ドキュメント生成を切り替えます。この段階では実行するコードが存在しないため、とりあえず文章で出力しています。
実行時にはこの二つのどちらかのみを受け付けるため、簡単化のために、エラーハンドリング[4]は実装せず、モードを指定しなかったり、ありえないものを指定するとパニックさせるようにしました。二つ目以降はどんな引数を与えても無視されます。

設定ファイルの生成

続いてinitモードの動作を実装します。
robodoc initを実行すると、問答無用でカレントディレクトリ[5]にconfig.tomlを生成し、デフォルトの設定を書き込みます。ユーザはこれを書き換えることで、カスタマイズというほどではないものの、Robodocの動作を制御できます。

まず、設定に必要なConfigをまとめた構造体を用意します。

config/config.rs
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
   common: CommonConfig,
   path: PathConfig,
   output: OutputConfig,
}

TOMLの読み書きに必要なアトリビュート[6]を書いた上で、
common: プロジェクト名などの設定項目
path: パスの設定をする
output: ドキュメント出力に関する設定をする
の三つのブロックを用意します。
次に各ブロックそれぞれの構造体を用意し、具体的な設定項目とデフォルト値を設定していきます。

config/config.rs
#[derive(Serialize, Deserialize, Debug)]
pub struct CommonConfig {
   #[serde(default = "default_license")]
   pub license: String,
   #[serde(default = "default_empty")]
   pub project_name: String,
   #[serde(default = "default_empty")]
   pub author_name: String,
   #[serde(default = "default_empty")]
   pub version: String,
   #[serde(default = "default_date")]
   pub date: String,
   #[serde(default = "default_empty")]
   pub language: String,
   #[serde(default = "default_empty")]
   pub entry_file: String,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct PathConfig {
   #[serde(default = "default_output_path")]
   output_path: String,
   #[serde(default = "default_input_path")]
   input_path: String,
}

#[derive(Serialize, Deserialize, Debug)]
pub struct OutputConfig {
   #[serde(default = "default_output_format")]
   format: String,
   #[serde(default = "default_output_language")]
   document_language: String,
   #[serde(default = "default_source_include")]
   source_include: bool,
}

serde(default = "...")アトリビュートはデフォルト値の設定を行うものです。ダブルクォートで囲った部分の同名の関数を作り、そこにデフォルト値を戻り値とします。そうすることで、デフォルト値が指定された状態で初期化されます。まあ、Defaultアトリビュートの代わりですね。
デフォルト値を返す関数の中でも、少々特殊なものを抜粋して以下に示します。

config/config.rs
fn default_date() -> String {
   Utc::now()
       .with_timezone(
           &FixedOffset::east_opt(9 * 3600).unwrap())
       .naive_local()
       .date()
       .to_string()
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
enum Language {
   JAPANESE,
   ENGLISH,
}
fn default_output_language() -> Language {
   Language::JAPANESE
}

default_date()は、chronoクレートを使い、プログラム実行時現在の日付を取得します。
 default_output_language()は、ドキュメントの言語を指定します。enumに列挙した、対応言語の中から、任意の言語を選んでもらう形です。rename_all = "lowercase"enumの中身、ここではJAPANESE ENGLISHを小文字に変換するアトリビュートです。

あとは、もともとあるdefault()の代わりに、デフォルト値を返す初期化関数を実装します。
 Rustにはimplというトレイトがあり、構造体に対して、いじいじする関数などを実装できます。

config/config.rs
impl Default for Config {
    fn default() -> Self {
        Self {
            common: CommonConfig {
                license: default_license(),
                project_name: default_empty(),
                author_name: default_empty(),
                version: default_empty(),
                date: default_date(),
                language: default_empty(),
                entry_file: default_empty(),
            },
            path: PathConfig {
                output_path: default_output_path(),
                input_path: default_input_path(),
            },
            output: OutputConfig {
                format: default_output_format(),
                document_language: default_output_language(),
                source_include: default_source_include(),
            },
        }
    }
}

設定ファイルの読み込み

最後に、コンフィグファイルを読み込む関数を用意します。

config/config.rs
pub fn import_config(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
       let content = fs::read_to_string(path)?;
       let config: Config = toml::from_str(&content)?;
       Ok(config)
}
main.rs
if args[1] == "init" {
-   println!("Initialization...");
+   let config = config::Config::new();
+   match config.to_toml() {
+       Ok(toml_string) => println!("{}", toml_string),
+       Err(e) => println!("エラー{}", e),
+   }
+   if let Err(e) = config.generate("config.toml") {
+       eprintln!("エラー{}", e);
+   }
} else if args[1] == "generate" {
-   println!("Generation...");
+   let mut config = Config::import_config("config.toml");
+   match config {
+       Ok(mut config) => {
+           println!("設定内容: {:#?}", config);
+           config = config;
+       }
+      Err(e) => eprintln!("設定ファイルの読み込みに失敗しました: {}", e),
+   }
}

read_to_string()でファイルを読み込み、toml::from_str(&content)で文字列から構造体として抽出します。
先程実装したコマンドライン引数の解析を行う部分を書き換え、今実装したコンフィグファイル関連の関数を実行し、結果をストリーム[7]に流す処理を追加します。

テスト

さて、ここまでで一通りの実装ノルマは達成したので軽くテストをしておきましょう。
まずはinitサブコマンドでイニシャライズをテストします。
成功すれば、コンフィグファイルが生成され、同じ内容がシェルにも出力されます。

$ cargo run init
~~~
[common]
license = "MIT or Apache-2.0"
project_name = ""
author_name = ""
version = ""
date = "2024-09-08"
language = ""
entry_file = ""

[path]
output_path = "docs/"
input_path = "src/"

[output]
format = "html"
document_language = "japanese"
source_include = true

続いて、コンフィグファイルの開いている項目を適当に埋めて、読み込みをテストします。
ここでは以下のようにしました。

config.toml
[common]
license = "MIT or Apache-2.0"
project_name = "sui"
author_name = "test"
version = "0.0.1"
date = "2024-09-08"
language = "rust"
entry_file = "main.rs"

[path]
output_path = "docs/"
input_path = "src/"

[output]
format = "html"
document_language = "japanese"
source_include = true

generateサブコマンドを使用するとコンフィグファイルを読み込み、構造体に各項目の内容をコピーしてシェルに出力します。構造体の形式がそのまま出力されるのでわかりやすいと思います。

$ cargo run generate
~~~
設定内容: Config {
    common: CommonConfig {
        license: "MIT or Apache-2.0",
        project_name: "sui",
        author_name: "test",
        version: "0.0.1",
        date: "2024-09-08",
        language: "rust",
        entry_file: "main.rs",
    },
    path: PathConfig {
        output_path: "docs/",
        input_path: "src/",
    },
    output: OutputConfig {
        format: HTML,
        document_language: JAPANESE,
        source_include: true,
    },
}

次回へ続く

長々とお付き合いありがとうございました。今回は一旦ここまでです。
駆け出しRustacean(ラスタシアン)としては、キツイとまではまだ(!)いってませんが、かなりやりがいのある作業でした。まあ、ここからはキツくなる一方ですが
次回は、第一関門である、ソースコード上のコメントから必要な情報を抜き出す部分を作ります。いわゆるパーサに近いことです。細かい便利機能は後々拡張すれば良いので、とりあえずなるべくシンプルにできるようにがんばります。
また、この記事や他の記事で誤字脱字や「もっとこう書いてくれるとわかりやすい」といったご意見、その他ご感想などございましたら、どうぞ遠慮なくバシバシいただけると嬉しいです。
最後に、このプロジェクトのGitHubリポジトリを載せておきますので、本文未掲載のコードの模写などにお役立てください。コーディングがお得意な方はフォークしてオリジナルを作ったり、プルリクやイシューを叩きつけてくださっても構いません。
それではまた次回、流れ星の降る頃にお会いしましょう。
https://github.com/SuiNagahoshi/robodoc

脚注
  1. https://www.rust-lang.org/ja/tools/install ↩︎

  2. いわゆる外部ライブラリのこと。自分のプロジェクトの中で、フォルダやファイルに分割した場合も、それはクレートと呼ばれる。 ↩︎

  3. ソフトウェアをコマンドで実行するときに渡されるオプションなどの値のこと。cargo --helpというコマンドを実行したら--helpの部分が引数になる。 ↩︎

  4. 予期せぬエラーが発生した時の処理。本当は大切 ↩︎

  5. 簡単に言えば、コマンドが実行されたディレクトリ。 ↩︎

  6. #[derive]などと書いてある部分。rustのコンパイラに渡す指示のようなものを書ける。 ↩︎

  7. シェルなどで文字の入出力がされる部分を担う機能。 ↩︎

Discussion