きっかけ
TOML ファイルはエントロピーが溜まる。コントリビューターごとにスタイルの好みが違う。キーをソートする人もしない人もいる。= を揃える人もいれば、セクション間に無意味な空行を入れる人もいる。時間が経つと Cargo.toml や pyproject.toml の一貫性が崩れ、diff がノイジーになりレビューが辛くなる。
toml-fmt はこの問題を解決するシングルバイナリの Rust CLI。TOML ファイルをパースし、キーソート・クォート正規化・スペース統一を施して出力する。--check モードはフォーマットが必要な場合に exit code 1 を返すので CI ゲートにぴったり。
🔗 GitHub: https://github.com/sen-ltd/toml-fmt
3つの動作モード
# stdout にフォーマット結果を出力
toml-fmt Cargo.toml
# ファイルを直接書き換え
toml-fmt --in-place Cargo.toml
# CI モード: 変更が必要なら exit 1
toml-fmt --check Cargo.toml
アーキテクチャ
lib.rs に純粋なフォーマットロジック、main.rs に CLI シェル。コア関数のシグネチャ:
pub fn format_toml(input: &str, opts: &Options) -> Result<String, Error>
文字列を受けて文字列を返す。ファイル I/O もサイドエフェクトもない。Options はソートと整列の2つのフラグを持つ。
toml クレートによるパース
toml::Value がパース済みの木構造を提供する。パースは input.parse::<toml::Value>() の1行。マルチライン文字列、インラインテーブル、ドットキー、テーブル配列など TOML スペックのエッジケースはクレートが処理してくれる。
トレードオフとして toml::Value はコメントを保持しない。コメントが重要なファイルには taplo や toml_edit を使うべき。toml-fmt は決定論的なソート出力が欲しいプロジェクト向け。
出力アルゴリズム
フォーマッターはパース済みの木を再帰的に走査し、各テーブルレベルで4種類のコンテンツを処理する。
- 単純なキーバリューペア — 文字列、整数、配列等
-
サブテーブル —
[section.subsection] -
テーブル配列 —
[[section]] -
インラインテーブル —
{key = val, ...}
単純ペアが先、次にサブテーブル、最後にテーブル配列。各セクションのキーがヘッダ直後に来る予測可能な出力になる。
キーソート
sort_keys(デフォルト有効)で各テーブル内のキーをアルファベット順にソートする。これが最もインパクトのあるルール。ソートされていれば依存関係の追加が diff の1行で済み、ランダムな位置への挿入にならない。
= の整列
--align フラグでセクション内の = を揃える。パディングはセクションごとにリセットされるので、あるセクションの長いキーが別セクションのパディングに影響しない。
整列前:
[package]
name = "my-app"
version = "0.1.0"
description = "A longer description"
edition = "2021"
整列後:
[package]
description = "A longer description"
edition = "2021"
name = "my-app"
version = "0.1.0"
キーのフォーマット
TOML はベアキー(name)とクォートキー("key with spaces")の2種類を許す。フォーマッターは可能な限りベアキーを使い、A-Za-z0-9_- 以外の文字を含む場合のみクォートする。
値のフォーマット
各型にルールがある。文字列は常にダブルクォート、整数はそのまま、浮動小数点は小数点を保証(1 → 1.0)、nan・inf・-inf は TOML のリテラル。配列は1行、インラインテーブルはキーソート済みで出力。
テスト
24件のテスト。基本フォーマット、ソート、整列、ネスト構造、エッジケース(空ドキュメント、空配列、空文字列、クォートキー)、文字列エスケープ、冪等性、エラー処理、実際の Cargo.toml 構造。
冪等性テストはフォーマッターにとって必須:
#[test]
fn idempotent() {
let input = "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\nclap = \"4\"\n";
let first = fmt(input);
let second = fmt(&first);
assert_eq!(first, second, "formatting should be idempotent");
}
フォーマット結果を再フォーマットして変わったら壊れている。ユーザーが何回実行しても同じ結果にならなければ、CI チェックが不安定になる。
リリースプロファイル
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "z"
panic = "abort"
サイズ最適化でコンパクトなバイナリを生成。Docker イメージや CI 環境に適している。
CI 連携
# GitHub Actions
- name: Check TOML formatting
run: |
cargo install --path .
toml-fmt --check Cargo.toml
exit code の規約(0 = フォーマット済み、1 = 変更が必要、2 = エラー)は rustfmt や black と同じパターン。既存のワークフローに組み込みやすい。
制限とトレードオフ
コメント非保持。 toml クレートの Value 型がパース時にコメントを捨てる。コメント保持が必要なら taplo を使うべき。
インラインテーブルの展開。 dep = { version = "1" } もフルセクションとして出力される。
部分フォーマットなし。 ファイル全体をフォーマットする。
すべて意図的なトレードオフ。小さく、速く、予測可能な出力を目標にした。コメント保持とフォーマット検出を足すとコードベースが約3倍になる。
おわりに
TOML はエッジケースが思った以上に多い。ベアキーとクォートキー、整数と浮動小数点の区別、テーブルとインラインテーブル、テーブル配列。フォーマッターを書くと出力フォーマットを深く理解できる。
toml クレートがパースの重労働をやり、フォーマッターは約200行の出力コード。CI パイプラインに TOML フォーマッターが欲しければ、toml-fmt を試してみてほしい。1バイナリ、設定不要、決定論的な出力。
