Day 071 — 2026-10-11 | マクロ① macro_rules! ⑯ エラーの位置を trybuild で固定する:スナップショットは rustc の版を変えると 10 件中 8 件が「不一致」になり、TRYBUILD=overwrite は退行を黙って受け入れる——「最初の --> が表の行か」を別に検査して、本物の退行 4 件+help: 1 件だけを止める

2026-10-11 🟣 上級者 / Phase 3 実装 マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: マクロ①(宣言的マクロ macro_rules!)

📚 背景知識(読んでから問題へ)

Day 070 で、fsm! の行のアクションを [ $($acts:tt)* ] で受け、__count! で形を検査し、__emit! で $a:expr として取り込み直す形にしました。7 種の誤りがすべて表の行を指すことを、手で 1 種ずつ入れ替えて確かめています。

手で確かめた結果は、次に誰かがマクロを触った瞬間に守られなくなります。「__emit! を消して emit($Action::$a ...) に戻したほうが短い」という善意の PR が来ても、cargo test は通ります。エラーの位置はコンパイルが失敗したときにしか現れないので、普通の単体テストでは守れません。

Rust の生態系でこれを止める道具が trybuild です(dtolnay 作。crates.io で累計 71,767,595 ダウンロード、逆依存 2,970 クレート、2026-10-11 に crates.io API から取得)。tests/ui/*.rs を 1 ファイルずつ別のクレートとしてコンパイルし、出たエラー文を *.stderr と比べます。serde・thiserror・tokio などの proc-macro の利用者体験は、この形で守られています(採用例は一般に知られている範囲の話で、今日は各リポジトリを開いていません)。

ただし trybuild が見ているのは「前回と同じ文か」だけです。今日は、それで足りるのかを実測で確かめます。

📝 問題

環境: rustc 1.96.0(stable)と rustc 1.85.1(+1.85)、trybuild = "=1.0.122"、edition 2024。マクロの本体は Day 070 の模範解答の src/lib.rs をそのまま使います(215 行。fsm!・__count!・__emit!・door・motor・単体テスト 4 件)。

Part 1: UI テストを書く

扉の表の 3 行目(警報の行)を壊したファイルを tests/ui/ に 10 個作り、壊していない表 ok_table.rs と合わせて trybuild で検査してください。E1〜E8 は Day 070 と同じ、E9・E10 は今日足すものです。

#3 行目に書くもの誤りの種類
E1Audit(c.failures + 1)引数が足りない
E2Audit(c.failures + 1, 0)引数の型が違う
E3NotifyGuard(1)unit variant に引数
E4Audit引数が要る variant に引数なし
E5Audti(c.failures + 1, false)綴り誤り
E6Audit(c.failures + 1, false, 9)引数が多い
E7if c.is_admin { SoundAlarm } else { NotifyGuard }variant 以外の式
E8Act::SoundAlarm完全修飾
E9遷移先を => Alrm存在しない状態($to:ident)
E10アクションを 5 個(max 4 超え)件数の上限

予想してから実行: E9 と E10 は、それぞれ何行目を指すでしょうか(表の 3 行目は、UI ファイルの 13 行目です)。

Part 2: 退行を検出できるか

src/lib.rs の 3 か所を Day 069 の形($a:ident $( ( $($arg:expr),* ) )? で受けて emit($Action::$a ...) を直接生成)に戻し、Part 1 で作ったスナップショットのまま cargo test --test ui を実行します。

  1. 何件が mismatch になるか
  2. そのうち、エラーの位置が本当に表の行から外れた(本物の退行)のは何件か

Part 3: TRYBUILD=overwrite の穴を塞ぐ

trybuild の README は、スナップショットの更新に TRYBUILD=overwrite を勧め、その後 git diff を見るよう書いています。Day 069 の形に戻した状態で TRYBUILD=overwrite cargo test --test ui を実行すると、どうなるでしょうか。

そのうえで、スナップショット(*.stderr)を読み、「最初の --> が UI ファイルの 13 行目を指すか」「help: がマクロ定義を書き換える提案をしていないか」を検査するテスト tests/ui_position.rs を書いてください。表の行を指せないと分かっている誤りは、理由つきの例外表に入れること。

Part 4: rustc の版を変える

rustc 1.85.1 で同じ UI テストを実行し、(a) trybuild のスナップショット照合、(b) Part 3 の位置の検査が、それぞれ何件で落ちるかを測ってください。trybuild 1.0.122 が 1.85 で使えない場合は、使える版を選び直すこと。

Part 5: CI でどちらを門にするか

Part 2〜4 の数字をもとに、CI で「落ちたらマージさせない」門にするのはどちらか(両方か)を、却下案と見直し条件つきで 5 行以内に書いてください。

💡 ヒント

ヒント1(方向性)
  • trybuild は UI ファイルを別のクレートとしてコンパイルします。Day 070 は同じクレートの中で試したので、エラーは src/lib.rs:56(マクロ定義)を指しました。別のクレートから使ったとき、依存先のマクロ定義の行は表示されるでしょうか
  • 「スナップショットが一致しない」と「エラーの位置が悪くなった」は、同じことではありません。文言・空白・help: の書き方が変わっても不一致になります
ヒント2(アプローチ)
  • 10 個の UI ファイルは、壊す 1 行以外は同じです。ひな形(tests/_base.txt)から sed で生成すれば、行番号が全ファイルで 13 にそろい、Part 3 の検査が単純になります
  • trybuild はスナップショットの中で、入力ファイルの行番号だけを残し、それ以外のファイル(依存先の src/lib.rs)の行番号を消します(src/normalize.rs のコメント)。だから --> tests/ui/<name>.rs:L:C の L を読めば位置が分かります
  • 1.85 で trybuild 1.0.122 が使えないときは、rust-version = "1.85" を書いて CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +1.85 generate-lockfile で、1.85 に対応する版を選ばせます
ヒント3(コード骨格)
// tests/ui.rs
#[test]
fn ui() {
    let t = trybuild::TestCases::new();
    t.pass("tests/ui/ok_table.rs");
    t.compile_fail("tests/ui/e*.rs");
}

// tests/ui_position.rs の中心
fn first_primary(stderr: &str) -> Option<(String, u32)> {
    let line = stderr.lines().find(|l| l.trim_start().starts_with("--> "))?;
    let loc = line.trim_start().strip_prefix("--> ")?;
    let mut parts = loc.rsplitn(3, ':');   // 列, 行, ファイル の順に取れる
    // ...
}

✅ 模範解答

ファイル構成

spanfsm/
├── Cargo.toml
├── src/lib.rs              # Day 070 の模範解答そのまま(215 行)
└── tests/
    ├── _base.txt           # UI ファイルのひな形(@ROW3@ を差し替える)
    ├── gen_ui.sh           # ひな形から 11 ファイルを生成
    ├── ui.rs               # trybuild(スナップショット照合)
    ├── ui_position.rs      # スナップショットに対する位置の方針の検査
    └── ui/
        ├── ok_table.rs
        ├── e1_missing_arg.rs  … e10_over_max.rs
        └── e1_missing_arg.stderr … e10_over_max.stderr   # TRYBUILD=overwrite で生成し、目で確認してから commit

Cargo.toml:

[package]
name = "spanfsm"
version = "0.1.0"
edition = "2024"

[dependencies]

[dev-dependencies]
trybuild = "=1.0.122"   # rust-version = "1.88"。1.85 では 1.0.119 を使う(Part 4)

tests/_base.txt(13 行目が壊す行):

// @CASE@
pub struct Ctx { pub failures: u32, pub is_admin: bool }
impl Ctx { pub fn code_ok(&self) -> bool { false } }

spanfsm::fsm! {
    context c: Ctx;
    state  Door { Locked, Closed, Open, Alarm }
    event  Ev   { Unlock, Lock, Push, Pull, Reset }
    action Act max 4 { RecordFailure, ResetFailures, SoundAlarm, NotifyGuard, Audit(u32, bool) }

    (Locked, Unlock) if c.failures >= 3     => Alarm  / [SoundAlarm, Audit(c.failures, false)];
    (Locked, Unlock) if c.code_ok()         => Closed / [ResetFailures, Audit(0, true)];
    @ROW3@
    (Locked, Unlock)                        => Locked / [RecordFailure, Audit(c.failures + 1, false)];
    (Closed, Lock)                          => Locked;
    (Closed, Push)                          => Open;
    (Open, Pull)                            => Closed;
    (Alarm, Reset) if c.is_admin            => Locked / [ResetFailures,];
}

fn main() {}

tests/gen_ui.sh(sh tests/gen_ui.sh で 11 ファイルを作り直す):

#!/bin/sh
set -eu
cd "$(dirname "$0")"
P='(Locked, Unlock) if c.failures + 1 >= 3 => '
gen() { sed -e "s|@CASE@|$1|" -e "s|@ROW3@|$P$2|" _base.txt > "ui/$1.rs"; }
gen ok_table       'Alarm  / [RecordFailure, Audit(c.failures + 1, false), SoundAlarm, NotifyGuard];'
gen e1_missing_arg 'Alarm  / [RecordFailure, Audit(c.failures + 1), SoundAlarm, NotifyGuard];'
gen e2_wrong_type  'Alarm  / [RecordFailure, Audit(c.failures + 1, 0), SoundAlarm, NotifyGuard];'
gen e3_arg_to_unit 'Alarm  / [RecordFailure, Audit(c.failures + 1, false), SoundAlarm, NotifyGuard(1)];'
gen e4_no_args     'Alarm  / [RecordFailure, Audit, SoundAlarm, NotifyGuard];'
gen e5_typo        'Alarm  / [RecordFailure, Audti(c.failures + 1, false), SoundAlarm, NotifyGuard];'
gen e6_extra_arg   'Alarm  / [RecordFailure, Audit(c.failures + 1, false, 9), SoundAlarm, NotifyGuard];'
gen e7_if_expr     'Alarm  / [RecordFailure, if c.is_admin { SoundAlarm } else { NotifyGuard }];'
gen e8_qualified   'Alarm  / [RecordFailure, Act::SoundAlarm];'
gen e9_bad_target  'Alrm   / [RecordFailure, Audit(c.failures + 1, false), SoundAlarm, NotifyGuard];'
gen e10_over_max   'Alarm  / [RecordFailure, Audit(c.failures + 1, false), SoundAlarm, NotifyGuard, SoundAlarm];'

tests/ui.rs:

//! エラーの位置の退行テスト。tests/ui/e*.rs はコンパイルに失敗し、
//! その stderr が同名の .stderr と一致しなければならない(trybuild)。
#[test]
fn ui() {
    let t = trybuild::TestCases::new();
    t.pass("tests/ui/ok_table.rs");
    t.compile_fail("tests/ui/e*.rs");
}

tests/ui_position.rs:

//! スナップショット(tests/ui/*.stderr)に対する「位置の方針」の検査。
//! trybuild は「前回と同じ文か」しか見ない。`TRYBUILD=overwrite` で退行を
//! 受け入れてしまっても、ここで「最初の `-->` が表の行を指すか」を止める。
use std::fs;
use std::path::Path;

/// 壊した行(_base.txt の @ROW3@)の行番号
const ROW: u32 = 13;

/// 表の行を指さなくてよい例外。理由を必ず書く
const EXCEPTIONS: &[(&str, u32, &str)] = &[(
    "e10_over_max",
    5,
    "E0080 は const 評価の panic!。macro_rules! から行の位置を渡せない(Day 069・070 の宿題)",
)];

/// stderr の最初の `--> tests/ui/<name>.rs:L:C` から (ファイル名, L) を取り出す
fn first_primary(stderr: &str) -> Option<(String, u32)> {
    let line = stderr.lines().find(|l| l.trim_start().starts_with("--> "))?;
    let loc = line.trim_start().strip_prefix("--> ")?;
    let mut parts = loc.rsplitn(3, ':');
    let _col = parts.next()?;
    let row: u32 = parts.next()?.parse().ok()?;
    let file = parts.next()?.to_string();
    Some((file, row))
}

#[test]
fn every_error_points_at_the_table_row() {
    let dir = Path::new("tests/ui");
    let mut checked = 0;
    let mut bad = Vec::new();
    let mut names: Vec<_> = fs::read_dir(dir)
        .unwrap()
        .map(|e| e.unwrap().path())
        .filter(|p| p.extension().is_some_and(|x| x == "stderr"))
        .collect();
    names.sort();
    for p in names {
        let name = p.file_stem().unwrap().to_str().unwrap().to_string();
        let text = fs::read_to_string(&p).unwrap();
        let want = EXCEPTIONS
            .iter()
            .find(|(n, _, _)| *n == name)
            .map_or(ROW, |(_, l, _)| *l);
        match first_primary(&text) {
            Some((file, row)) if file == format!("tests/ui/{name}.rs") && row == want => {}
            got => bad.push(format!("{name}: want line {want}, got {got:?}")),
        }
        // help: の提案がマクロ定義(依存クレートの src/lib.rs)を書き換えていないこと
        if text.contains("help:") && text.contains("--> src/lib.rs") {
            bad.push(format!("{name}: help: がマクロ定義を指している"));
        }
        checked += 1;
    }
    println!("checked {checked} snapshots, {} violations", bad.len());
    assert!(checked >= 10, "スナップショットが {checked} 件しかない");
    assert!(bad.is_empty(), "位置の方針に反する:\n{}", bad.join("\n"));
}

#[test]
fn parser_reads_trybuild_paths() {
    let s = "error[E0061]: x\n  --> tests/ui/e1_missing_arg.rs:13:73\n   |\n";
    assert_eq!(first_primary(s), Some(("tests/ui/e1_missing_arg.rs".into(), 13)));
    assert_eq!(first_primary("no arrow"), None);
}

実行と結果(実測、rustc 1.96.0、Apple M1・8GB、2026-10-11)

$ sh tests/gen_ui.sh                       # UI ファイル 11 個を生成
$ TRYBUILD=overwrite cargo test --test ui  # 初回だけ: スナップショット 10 個を書き出す(中身を目で確認する)
$ cargo test                               # 単体 4 + ui 1 + ui_position 2
test result: ok. 4 passed; 0 failed; ...                       (src/lib.rs の単体テスト)
test result: ok. 1 passed; 0 failed; ... finished in 2.63s     (trybuild 11 ケース)
test result: ok. 2 passed; 0 failed; ... finished in 0.00s     (ui_position: checked 10 snapshots, 0 violations)
$ cargo clippy --all-targets                # 警告 0 件(grep -c "^warning" で 0)

trybuild の 11 ケースの所要時間は 2.08〜3.40 秒(4 回、実測)。測定直前の load average は 16.58 で、ばらつきは大きいです。trybuild の作業ディレクトリ(target/tests/trybuild)を消した後も 3.08 秒でした(依存のビルド結果は target/ に残っているため)。

Part 1 の答え(実測)

#エラー最初の -->
E1E006113:73(表の行)
E2E030813:95
E3E061813:115
E4E030813:73
E5E042513:73
E6E006113:73
E7no rules expected c13:76
E8no rules expected ::13:76
E9E0425 cannot find value Alrm13:48(表の行)。help: が Alarm を提案
E10E0080 evaluation panicked5:1(spanfsm::fsm! { の行)。下線はマクロ呼び出し全体

E9 が表の行を指すのは、$to:ident で受けた識別子を Ok($to) にそのまま置いているからです。Day 070 の結論(「利用者が書いた識別子そのものが誤りの場所ならば表の行」)と合います。help: は表の行で Alrm → Alarm に + を 1 文字足す提案になっています。

E10 は表の行を指しません。メッセージの中には行の字面 (Locked, Unlock) if c.failures + 1 >= 3 が出ています(Day 069 で panic!("{}", rows[i].0) にした効果)が、下線はマクロ呼び出し全体です。これが Part 3 の例外表に入る 1 件です。

Part 2 の答え(実測)

Day 069 の形に戻す差分は 3 行です:

-           $( / [ $($acts:tt)* ] )? ; )*
+           $( / [ $( $a:ident $( ( $($arg:expr),* ) )? ),* $(,)? ] )? ; )*
-                    0usize $( + $crate::__count!($($acts)*) )?
+                    0usize $( $( + { let _ = stringify!($a); 1usize } )* )?
-                        $( $crate::__emit!(emit, $Action; $($acts)*); )?
+                        $( $( emit($Action::$a $( ( $($arg),* ) )?); )* )?
  1. 10 件中 8 件が mismatch(E1〜E8。E9・E10 は一致)
  2. そのうち本物の退行は 4 件(E1・E3・E4・E6)。最初の --> が 13 行目から 5:1(spanfsm::fsm! {)に移りました

残りの 4 件(E2・E5・E7・E8)は、位置は 13 行目のままで文言だけが変わったものです。

#Day 069 の形での違い位置
E2下線の描き方が「マクロ呼び出し全体の枠+13 行目の ^」に変わる13:95 のまま
E5E0425 → E0599(no variant ... named Audti found for enum Act)13:73 のまま
E7・E8note: while trying to match ']' が --> src/lib.rs(行番号なし)を指すようになる13:76 のまま

別のクレートから使うと、「マクロ定義を指す」はマクロ呼び出し全体を指すに変わります。 Day 070 は同じクレートの中で試したので src/lib.rs:56 が出ました。trybuild の UI ファイルは別クレートなので、依存先のマクロ定義の行は出ず、spanfsm::fsm! { から } までの 15 行に枠が付きます。表を書く人から見れば、どちらも「どの行が悪いか分からない」点は同じです。

E1 の Day 069 の形での出力:

error[E0061]: this enum variant takes 2 arguments but 1 argument was supplied --> tests/ui/e1_missing_arg.rs:5:1 | 5 | / spanfsm::fsm! { 6 | | context c: Ctx; ... | 19 | | } | |_^ argument #2 of type `bool` is missing

Part 3 の答え(実測)

TRYBUILD=overwrite は退行をそのまま書き込み、テストは通ります(test result: ok. 1 passed、実測)。README は "be sure the compiler's output is what you had in mind" と、確認を人の git diff に任せています(一次資料)。8 ファイルの差分のうち 4 件だけが本物の退行、という状態で人が見落とさない保証はありません。

ui_position.rs を、Day 069 の形で上書きされたスナップショットに対して実行した結果(実測):

checked 10 snapshots, 5 violations
e1_missing_arg: want line 13, got Some(("tests/ui/e1_missing_arg.rs", 5))
e3_arg_to_unit: want line 13, got Some(("tests/ui/e3_arg_to_unit.rs", 5))
e4_no_args: want line 13, got Some(("tests/ui/e4_no_args.rs", 5))
e4_no_args: help: がマクロ定義を指している
e6_extra_arg: want line 13, got Some(("tests/ui/e6_extra_arg.rs", 5))

本物の退行 4 件と、E4 の help: がマクロ定義を書き換える提案(emit($Action::$a(/* u32 */, /* bool */) ...))の 1 件だけを止め、文言だけ変わった 4 件は通しました。Day 070 の形のスナップショットでは 0 violations です。

Part 4 の答え(実測)

trybuild 1.0.122 は rust-version = "1.88" なので 1.85.1 では requires rustc 1.88 で止まりました。CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback で選び直すと trybuild 1.0.119 になりました。

検査rustc 1.85.1 での結果
(a) trybuild のスナップショット照合(1.96 で作ったもの)10 件中 8 件が mismatch(一致は E7・E8 のみ)
(b) 1.85 の出力で上書きしたスナップショットに ui_position0 violations(10 件すべて同じ位置)

8 件の不一致の中身は、行番号の欄の桁そろえ( 9 | → 9 |)、help: の差分の描き方(+++ → ~~~)、E0080 の文言(evaluation panicked: ... → evaluation of constant value failed)でした。位置は 1 件も変わっていません。 マクロは何も変えていないのに、スナップショットだけを見ると 8 件の「失敗」に見えます。

Part 5 の答えの例

門にするのは ui_position(位置の方針)。trybuild の照合は「文言が変わった」の通知に留め、rustc を上げる PR でだけ overwrite して差分を読む。
根拠: 1.85→1.96 で照合は 8/10 が不一致・位置は 0/10 の変化。Day 069 への退行では照合 8 件中 本物は 4 件、位置の検査は 4 件+help 1 件だけを止めた。
却下: trybuild 照合だけを門にする(rustc 更新のたびに 8 件の誤報、overwrite で退行が通る)/位置の検査だけにする(E5 の E0425→E0599 のような文言の悪化に気づけない)。
見直し条件: rustc を固定(rust-toolchain.toml)して版が動かないなら照合も門にしてよい。E10 を表の行に向けられたら例外表から消す。

🪜 Step-by-Step 解説

Step 1: UI ファイルをひな形から作る

10 個の誤りは「3 行目だけが違う」ファイルです。手で 10 個書くと、どこかで 1 行ずれて行番号がそろわなくなります。ひな形 _base.txt の @ROW3@ を sed で差し替えれば、全ファイルで壊した行が 13 行目になり、Part 3 の検査は ROW = 13 の 1 定数で済みます。ok_table.rs も同じひな形から作るので、「壊していない表はコンパイルが通る」ことも同じ条件で確かめられます。

Step 2: スナップショットは目で確認してから commit する

TRYBUILD=overwrite は「今の出力を正解にする」操作です。最初の 1 回は、10 個の .stderr を開いて、最初の --> が 13 行目か、help: が表の行を直す提案かを見ました(E10 だけが 5 行目)。この目視を、Step 4 でテストに置き換えます。

Step 3: 退行を入れて、テストが何を言うかを見る

テストは「通る」ことより「落ちるべきときに落ちる」ことを確かめないと信用できません。Day 069 の形に 3 行戻して実行すると 8 件が落ちました。ここで 8 件全部を「退行」と読むと間違えます。1 件ずつ最初の --> を見ると、位置が動いたのは 4 件でした。スナップショットの不一致は「何かが変わった」までしか言わないことが分かります。

Step 4: 方針をテストにする

「エラーは表の行を指す」は、このマクロの仕様です。仕様をスナップショット(ある日の出力)だけに持たせると、overwrite のたびに仕様が上書きされます。そこで仕様だけを抜き出して ui_position.rs にしました。

  • 読むのはスナップショットのファイルなので、コンパイルは走らず 0.00 秒
  • trybuild が入力ファイル以外の行番号を消す(src/normalize.rs)ので、--> src/lib.rs が出ていればマクロ定義を指している、と判定できる
  • 表の行を指せない E10 は、理由を書いた例外表に入れる。黙って除外しない

Step 5: rustc の版を変えて、2 つの検査の性質を測る

1.85 で 8 件が不一致になり、位置は 0 件変わりませんでした。スナップショットはrustc の表示の変化とマクロの変化を区別できません。位置の検査は区別できます。この差が Part 5 の判断の根拠です。

🧭 設計思想・なぜこう書くのか

Rust は「コンパイルが通らないこと」を設計の道具にする言語です。所有権・型・#[deny(...)]・Day 069 の const _ による件数の検査は、どれも誤った使い方をコンパイル時に止める仕組みでした。その仕組みを作る側にとって、どの入力で、どの位置で、どう止まるかはライブラリの公開仕様の一部です。だから Rust の生態系には、コンパイルの失敗そのものをテストする trybuild(や、rustc 本体の tests/ui を回す compiletest)が育ちました。

一方で rustc のエラー文は、Rust のリリースごとに良くなるよう書き換えられています(今日 1.85→1.96 で 10 件中 8 件の文面が変わったのが実例)。エラー文を丸ごと固定するテストは、コンパイラの改善を「失敗」と報告する性質を持ちます。今日の形は、変わりやすい文面(スナップショット)と変わってはいけない仕様(位置)を別のテストに分けたものです。

迷った他案と採らなかった理由

案中身採らなかった理由
(A) 今日の形: trybuild + スナップショットに対する位置の検査2 つのテスト採用。 退行 4 件+help: 1 件を止め、文言の変化 4 件と版の違い 8 件は止めない(実測)
(B) trybuild だけスナップショット照合版を変えると 8/10 誤報、overwrite で退行が通る(実測)
(C) 手製のハーネス(cargo build --message-format=json で主 span を読む)trybuild を使わない位置は JSON から正確に取れるが、UI ファイルごとのクレート生成・依存の解決・並列化を自前で持つことになる。trybuild がローカルに無い前提(Day 070 の宿題)で考えた代替で、今日はネットワークから取得できたので不要になった
(D) 位置の検査を trybuild の出力ではなく JSON の span で行う(A) の検査部分だけ (C) にする1 回余分に全ケースをコンパイルする。今日の 10 件では、trybuild が残す --> の行番号で足りた
(E) 文言を正規表現でゆるく照合*.stderr をパターン化trybuild にその機能は無い。自作すると「何をゆるめたか」が新しい仕様になる
(F) テストしない(Day 070 の手作業のまま)—次の変更で守られなくなる。Part 2 の 3 行の差分は「短くした」に見えて普通の単体テストは全部通る(4 passed、実測)

🚨 コンパイルエラーが出た場合

今日は「わざと失敗させる」ことが目的なので、出るべきエラーは Part 1 の表です。それ以外で出たもの:

エラー出た原因対処
rustc 1.85.1 is not supported by the following package: trybuild@1.0.122 requires rustc 1.88trybuild の rust-version が 1.88rust-version = "1.85" を書き、CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS=fallback cargo +1.85 generate-lockfile で 1.0.119 を選ばせる
trybuild の mismatchスナップショットと今の出力が違う差分を読み、位置が動いたか文言だけかを分ける。位置の検査(ui_position)が通っていれば文言だけ
ui_position の want line 13, got ... 5エラーがマクロ呼び出し全体を指すようになったマクロの本体で呼び出し式を組み立てていないかを見る(Day 070 の規則: 呼び出し全体を $a:expr で受ける)
help: がマクロ定義を指しているhelp: の提案が依存先の src/lib.rs を書き換える同上。利用者がその提案に従うとマクロを壊す

🌐 他言語との比較

観点Rust(trybuild、今日実行)Rust 本体(compiletest)C++(static_assert・concepts)TypeScript(@ts-expect-error)
「コンパイルが失敗すること」のテストtests/ui/*.rs を別クレートでビルドし .stderr と比較//~ ERROR 注釈で行とエラー番号を照合、.stderr も比較失敗のテストは CMake の WILL_FAIL などビルド系で行う次の行に型エラーが無いと、それ自体がエラーになる
位置の照合スナップショットに含まれる(今日はそれを読んで方針を検査)注釈が行を指すので位置が第一級標準の仕組みは無い注釈の直後の行に限られる
文言の変化への強さ弱い(1.85→1.96 で 8/10 不一致)本体の変更と一緒に更新される前提—文言は見ないので強い

trybuild の列は今日実行した結果です。compiletest・C++・TypeScript の列は仕様の記憶に基づく要旨で、今日は実行していません。compiletest の //~ ERROR 注釈は、今日の ui_position と同じ発想(行とエラー番号は仕様、文言は付随物)を rustc 自身が採っている例として挙げました。

🏭 実務での使いどころ

  • DSL マクロ・derive マクロの公開前: 利用者が書き間違えそうな入力を UI ファイルにし、エラーの位置を固定する。今日のように「壊し方の表」から生成すれば、表がそのまま仕様書になります
  • rustc を上げる PR: スナップショットの差分が大量に出ます。位置の検査が通っていれば「文言の更新」として overwrite してよい、と判断できます。今日の 1.85→1.96 では 8 件がこれに当たりました
  • マクロのリファクタリング: Part 2 の 3 行の差分は単体テストを全部通しました。エラーの位置を守るテストが無いと、レビューでも気づきにくい変更です

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「trybuild が通っていれば、エラーの位置は守られている」スナップショットに位置も含まれているTRYBUILD=overwrite で退行がそのまま正解になる(Part 3、実測)。位置は別に検査する
「スナップショットの不一致 = 退行」落ちたテストは悪いと読むDay 069 の形で 8 件中 4 件は文言だけ、1.85 では 8 件全部が文言だけ(実測)
「マクロ定義を指すエラーは、利用者には src/lib.rs:56 と見える」Day 070 は同じクレートで試した別クレートの利用者には fsm! { から } までの枠に見える(Part 2、実測)。テストは利用者と同じ別クレートの形で行う
「UI ファイルは手で 1 個ずつ書く」trybuild の例が手書きひな形から生成すれば行番号がそろい、方針の検査が 1 定数で済む
「E0080 も表の行を指すはず」E1〜E9 が全部表の行を指したのでconst 評価の panic! はマクロ呼び出し全体を指す(E10、実測)。例外表に理由つきで残す

🔗 一次資料(参照日: 2026-10-11)

  • trybuild 1.0.122 の README(ローカルの crates.io レジストリのソース、Workflow の節): *.stderr の更新に TRYBUILD=overwrite を使い、その後 "be sure the compiler's output is what you had in mind" と git diff の確認を求めている — Part 3 の「確認が人任せ」の根拠
  • trybuild 1.0.122 src/normalize.rs の 243〜245 行のコメント: 入力ファイルの中だけ行番号を残し、他のソースファイルの行番号は消す — ui_position が --> の行番号と --> src/lib.rs の有無で判定できる根拠。E7・E8 の Day 069 版スナップショットで --> src/lib.rs に行番号が無いことも実物で確認
  • trybuild 1.0.122 Cargo.toml: rust-version = "1.88" — Part 4 で 1.85 では使えなかった理由
  • crates.io API(/api/v1/crates/trybuild、/reverse_dependencies): 累計 71,767,595 ダウンロード、直近 16,819,191、逆依存 2,970 — 背景知識の採用規模の根拠
  • エラー文はすべて rustc 1.96.0 と 1.85.1 で今日実行した出力

🚧 今日扱わなかった範囲(次回以降)

扱わなかったこと理由・次の予定
E10(E0080)を表の行に向けること例外表に入れて止めた。行ごとに const _ を生成する形は未試行。proc-macro(マクロ②)の題材
ガード(if $g:expr)の誤りの位置今日足したのは $to(E9)と件数(E10)。ガードに bool 以外の式を書いたときの位置は未確認
手製ハーネス(案 C・D)の実装と、trybuild との時間比較trybuild がネットワークから取れたため。オフラインの機械で必要になったら実装する
compiletest の //~ ERROR 方式の実行rustc 本体の仕組みで、外部クレートから使う手順を確かめていない
CI(GitHub Actions など)への組み込み今日はローカルの cargo test まで。rustc の版を固定する rust-toolchain.toml も未作成
時間の静かな機械での取り直しload average 16.58 の M1 8GB で 4 回のみ。Mac mini での取り直しは Day 066・068・070 の宿題と一緒に
C++・TypeScript・compiletest の比較列の実行記憶ベース

3 問の自己点検

  1. 同じ指示を AI に出せば数時間で出るか — trybuild の使い方の一般論はすぐ出る。ただし、Day 069 の形への 3 行の退行で照合が 8 件落ちて本物は 4 件、overwrite で退行が通る、1.85→1.96 で 8/10 が文言だけ不一致・位置は 0/10、別クレートでは「マクロ定義を指す」が fsm! { の枠になる、E10 だけが表の行を指さない、は今日ビルドして得た数字
  2. AI に指示するだけでは出ない部分 — 「スナップショットが落ちる」を「位置が動いた」と「文言が変わった」に分け、変わってはいけない方だけを別のテストに抜き出したこと。それを退行の注入と rustc の版の変更という 2 つの実験で確かめたこと
  3. 世界の実物と比べて何割か — trybuild の機能のうち使ったのは pass・compile_fail・TRYBUILD=overwrite の 3 つ。wip ディレクトリの運用、TestCases の features 指定、normalize.rs の正規化の全規則(今日読んだのは行番号の消去の 1 か所)は扱っていない。rustc 本体の compiletest とは実行して比べていない

➡️ 次のステップ

  • 発展: E10 を表の行に向ける。行ごとに const _: () = assert!(N <= max) を生成すると、下線が行のトークンに付くかを試し、付いたら例外表から消す(例外表が空になることがゴール)
  • 次回予告: マクロ① の残り期間(〜10/24)。今日の実装の次は概念理解か設計。候補は (1) 入れ子の状態(Alarm の中に鳴動中/消音中)を自作 fsm! と statig の superstate で比べる設計、(2) const 評価でできること・できないこと(E0015、for 不可、generic_const_exprs)を ROW_ACTIONS と今日の E10 を題材に整理する概念理解

🎯 自己評価(解いた後に記入)

Part 1 の予想(E9・E10 が何行目を指すか)と実際

Part 2 の予想(何件 mismatch、本物の退行は何件)と実際

自分の回答(Part 5 の CI の門の判断)

気づき・メモ