Day 073 — 2026-10-13 | マクロ① macro_rules! ⑱ ガード(if $g:expr)の誤りの位置を //~ ERROR 注釈で固定する:13 通りの誤りは 13 件とも表の行(12 行目)を指す。依存 0 の手製ハーネス(146 行)は、ガードの展開を ($g) == true に変える退行で 4 件が fsm! {(5:1)に戻るのを [position] として止め、無害な書き換え(R2)は通す。trybuild は R2 でも 3 件 mismatch

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

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

Day 071 で trybuild を入れ、fsm! の誤り 10 種の位置をスナップショット(.stderr)で固定しました。そのとき分かった弱点が 2 つありました。

  1. スナップショットは「位置」と「文言」を区別しない。 rustc 1.85 に替えると 10 件中 8 件が文言だけの違いで不一致になり、本物の退行(位置のずれ)は 4 件だけでした
  2. TRYBUILD=overwrite は退行もそのまま受け入れる。 上書きした瞬間に、ずれた位置が「正解」として保存されます

そして宿題に「ガード(if $g:expr)の誤りの位置は未確認」が残っていました。ガードは利用者が書く式で、表の行の中で唯一、型(bool)・借用(&Ctx しか持っていない)・衛生性(マクロ内の名前は見えない)の 3 つの規則に同時に触れる場所です。

今日は 2 つを一緒に片付けます。

  • ガードを 13 通りに壊したとき、エラーがどこを指すかを測る
  • rustc 自身のテスト(compiletest)が使う //~ ERROR 注釈方式を、依存クレートなしで小さく作る。rustc-dev-guide の UI テストの節は、注釈を "a comment with the diagnostic level (such as ERROR) and a substring of the expected error output" と説明しています(一次資料)。期待はソースの行に書き、出力は保存しないので、上書きという操作がそもそもありません

Rust のマクロ設計で「位置」にこだわる理由は、所有権や型の検査と同じです。誤りを実行前に止めても、止めた場所が利用者の書いた行でなければ、直す人は展開結果を想像するしかない。位置を退行テストで守ることは、マクロの API の一部を守ることです。

注: Day 071・072 の spanfsm(215〜235 行)は作業場所の掃除で手元に残っていませんでした。今日はガードに関わる部分だけを持つ 57 行の guardfsm を作り直して使います(max の検査・SmallVec は無く、アクションは Vec で返す)。Day 072 の V6 との結合は「扱わなかった範囲」に回します。

📝 問題

環境: rustc 1.99.0(stable)と rustc 1.85.1(+1.85)、Apple M1・8GB。UI ケースは利用者と同じ条件にするため、マクロ定義とは別のクレートとしてコンパイルします(Day 071: 同じクレートだと位置が変わる)。

使うマクロ(src/lib.rs の本体、全文は模範解答)

#[macro_export]
macro_rules! fsm {
    (
        state $State:ident { $($S:ident),* $(,)? }
        event $Event:ident { $($E:ident),* $(,)? }
        ctx $c:ident : $Ctx:ty;
        action $Action:ident { $( $A:ident $( ( $($T:ty),* ) )? ),* $(,)? }
        rows {
            $( ($s:ident, $e:ident) $(if $g:expr)? => $to:ident [ $($acts:tt)* ] ; )*
        }
    ) => {
        /* enum 3 つ + */
        impl $State {
            pub fn on(self, ev: $Event, $c: &$Ctx) -> Option<(Self, Vec<$Action>)> {
                #[allow(unused_imports)]
                use $Action::*;
                match (self, ev) {
                    $( ($State::$s, $Event::$e) $(if $g)? => Some(($State::$to, vec![$($acts)*])), )*
                    _ => None,
                }
            }
        }
    };
}

UI ケースのひな形(12 行目のガードだけを差し替える):

//! g01_u32
pub struct Ctx { pub failures: u32, pub armed: Option<bool> }
impl Ctx { pub fn bump(&mut self) -> bool { self.failures += 1; true } }

guardfsm::fsm! {                                   // ← 5 行目(5:1 = マクロ呼び出し全体)
    state Door { Locked, Unlocked, Alarm }
    event Ev { Unlock, Fail, Lock }
    ctx c: Ctx;
    action Act { Beep, Log(u32), SoundAlarm }
    rows {
        (Locked, Unlock) => Unlocked [Beep];
        (Locked, Fail) if c.failures => Alarm [Log(c.failures + 1), SoundAlarm]; //~ ERROR E0308
        (Locked, Fail) => Locked [Log(c.failures + 1)];
        (Unlocked, Lock) => Locked [];
    }
}
fn main() {}

Q1: 13 通りのガードの誤りは、どこを指すか(予想してから確かめる)

12 行目の if の後ろを次のように変えます。各ケースについて (a) 最初のエラーが 12 行目か 5 行目(マクロ呼び出し全体)か、(b) エラー番号 を予想してください。

#ガード狙い
g01c.failuresbool でない(u32)
g02c.failurs + 1 >= 3フィールド名の誤字
g03c.failures = 3== のつもりで =
g04c.bump()&mut self のメソッド(c は &Ctx)
g05c.armedOption<bool>
g06"yes"&str
g07failures >= 3c. を書き忘れた
g08ev == Ev::Failマクロが作った引数 ev を使う
g09self == Door::Lockedマクロが作ったメソッドの self を使う
g10{ c.failures += 1; true }ガードの中で文脈を書き換える
g11let Some(true) = c.armedif-let ガード(1.99 で安定しているか?)
g12c.failures >= 3 &&式が途中で終わる
g13(空)if =>

Q2: 手製の UI ハーネスを実装する

tests/ui_annot.rs を、依存クレートなしで書いてください。要件:

  1. src/lib.rs を rustc --crate-type rlib で 1 回だけビルドし、各ケースを --extern guardfsm=… 付きの別クレートとして検査する(環境変数 UI_LIB_SRC でライブラリのソースを差し替えられること。退行の実験に使う)
  2. rustc --error-format=short の出力(path:LINE:COL: error[E0308]: …)を読み、ソースの //~ ERROR <部分文字列> 注釈と (行, 部分文字列) で突き合わせる。//~^ は 1 行上を指す
  3. 違反を 3 種類に分けて報告する: [position](注釈の無い行に出た=位置がずれた)、[message](注釈の行に出たが文が違う)、[missing](注釈どおりのエラーが出なかった)
  4. 注釈の無い ok.rs はエラー 0 件でなければ違反

Q3: 4 つの書き換えを、2 つのハーネスはどう判定するか

マクロの作者が、ガードの展開 $(if $g)? を次のように書き換えたとします。それぞれ (a) ガードの誤りの位置は変わるか、(b) 手製ハーネスの違反数、(c) trybuild の mismatch 数 を予想してください。

案展開作者の狙い
R1$(if ::core::convert::Into::<bool>::into($g))?bool に変換できる型を広く受けたい
R2$(if { let __g: bool = $g; __g })?ガードの型を明示したい(意味は同じ)
R3$(if $crate::IntoGuard::into_guard($g))?(bool と Option<bool> に実装)Option<bool> の None を偽として許したい
R4$(if ($g) == true)?「真のときだけ」を字面で明示したい

Q4: どちらのハーネスを残すか

Q1〜Q3 の結果から、fsm! の UI テストを手製ハーネス・trybuild・両方のどれにするかを、採らなかった案と見直し条件つきで 5 行以内に書いてください。

💡 ヒント

ヒント1(方向性)
  • $g:expr で受けた式は、展開後も利用者のトークンの span を持ちます(Day 070: 利用者のトークンをそのまま置けば表の行を指す)。位置が 5:1 に戻るのは、エラーの「主役」がマクロ定義のトークンになったときです
  • 型の不一致(E0308)は「期待した型」と「実際の型」のどちらに下線を引くかで位置が決まります。if 式 は「式が bool であること」を期待します。(式) == true はどうでしょう
ヒント2(アプローチ)
  • --error-format=short は 1 エラー 1 行で、help: や note: を出しません。パースは ": error" で分けて、左を後ろから : で 3 つに割れば足ります(パスに : が入っても壊れない)
  • テストから rustc を呼ぶときの出力先は env!("CARGO_TARGET_TMPDIR")(統合テストにだけ Cargo が渡す一時ディレクトリ)
  • ガードの中の ev や self は、マクロ定義の中で作られた名前です。macro_rules! の衛生性(ローカル変数は定義側の文脈)を思い出してください
  • trybuild はケースを bin としてビルドするので fn main() {} が要ります(無いと E0601 が出て、overwrite するとそれもスナップショットに保存される)
ヒント3(コード骨格)
fn annotations(src: &str) -> Vec<Annot> {
    // 各行で "//~" を探す → 続く '^' の数 = 何行上か → "ERROR" の後ろが部分文字列
}
fn parse_short(line: &str) -> Option<(String, usize, String)> {
    let idx = line.find(": error")?;
    let (loc, body) = (&line[..idx], &line[idx + 2..]);
    let mut parts = loc.rsplitn(3, ':');   // col, line, path の順に出てくる
    // …
}
// 突き合わせ: 一致 → matched[k] = true
//            同じ行に注釈があるが文が違う → [message]
//            注釈の無い行 → [position]
//            最後に matched が false の注釈 → [missing]

✅ 模範解答

Q1 の答え(実測、rustc 1.99.0、別クレート、--error-format=short の最初の行)

#ガード位置番号・文
g01c.failures12:27E0308 expected bool, found u32
g02c.failurs + 1 >= 312:29E0609 no field failurs on type &Ctx
g03c.failures = 312:27E0308 expected bool, found ()。help: you might have meant to compare for equality(== を提案)
g04c.bump()12:27(c の 1 文字)E0596 cannot borrow data in a & reference as mutable。help は出ない
g05c.armed12:27E0308 expected bool, found Option<bool>
g06"yes"12:27E0308 expected bool, found &str
g07failures >= 312:27E0425 cannot find value failures
g08ev == Ev::Fail12:27E0425 cannot find value ev。help: an identifier with the same name is defined here, but is not accessible due to macro hygiene(副ラベルは 5:1)
g09self == Door::Locked12:27E0424 self value is a keyword only available in methods with a self parameter
g10{ c.failures += 1; true }12:29E0594 cannot assign to data in a & reference
g11let Some(true) = c.armed12:27no rules expected keyword let(note: while trying to match meta-variable $g:expr)
g12c.failures >= 3 &&12:46(=>)expected expression, found =>
g13(空)12:28(=>)no rules expected =>

13 件すべてが表の 12 行目を指しました(rustc 1.85.1 でも 13 件とも 12 行目、手製ハーネスの違反 0 件)。Day 071 の 10 種と合わせて、ガードは「位置の例外」を増やしません。

g08 の全文(主たる位置は 12 行目、衛生性の説明は副ラベルとして 5:1 に付く):

error[E0425]: cannot find value `ev` in this scope --> tests/ui/g08_macro_local.rs:12:27 | 12 | (Locked, Fail) if ev == Ev::Fail => Alarm [Log(c.failures + 1), SoundAlarm]; | ^^ not found in this scope | help: an identifier with the same name is defined here, but is not accessible due to macro hygiene --> tests/ui/g08_macro_local.rs:5:1 | 5 | / guardfsm::fsm! { 6 | | state Door { Locked, Unlocked, Alarm } ... | 16 | | } | |_^

読み取れること:

  1. 型・借用・名前解決の誤りは、ガードの式そのものに下線が付く。 下線を持つトークンが利用者のもの($g:expr で受けてそのまま置いた)だからです
  2. g04 は Day 066 と違う。 Day 066(同じクレート)では E0594 に「on() の引数を &mut に変える」help が出ていました。別クレートでは help が出ません。直す場所($c: &$Ctx の &)がマクロ定義の中にあり、利用者には直せないので、rustc が提案を出さないと読めます(理由は rustc のソース未読。実測の差のみ)
  3. g08・g09 は衛生性。 on(self, ev, $c) の ev と self はマクロ定義のトークンなので、利用者のガードからは見えません。一方 c は利用者が ctx c: Ctx; で書いた識別子($c:ident)なので見えます。ガードに渡したい名前は、利用者に名付けさせるのが macro_rules! の作法です
  4. g11: if-let ガードは 1.99 では言語として安定しているが、$g:expr では受けられない。 match の中に直接書いた Some(o) if let Some(true) = o => … は 1.99.0 で edition 2021・2024 とも通り、1.85.1 では E0658 "if let guards are experimental" でした(実測)。let は式の断片ではないので、$g:expr が先頭の let で一致に失敗します

Q2 の答え(tests/ui_annot.rs、146 行、依存 0)

//! 手製の UI テスト(compiletest の `//~ ERROR` 注釈方式を最小限で再現。依存クレートなし)
//!
//! 1. src/lib.rs(環境変数 UI_LIB_SRC で差し替え可)を rlib にする — 利用者と同じ「別クレート」の条件にするため
//! 2. tests/ui/*.rs を `rustc --error-format=short` で 1 個ずつ検査する
//! 3. 出たエラーの (行, 文) と、ソースの `//~ ERROR <部分文字列>` 注釈の (行, 部分文字列) を突き合わせる
//!    - 注釈の無い行に出たエラー(例: 5:1 の `guardfsm::fsm! {`、src/lib.rs の中)→ 違反
//!    - エラーが出なかった注釈 → 違反
//!    - `//~^` は 1 行上、`//~^^` は 2 行上を指す(compiletest と同じ)
//!
//! スナップショット(.stderr)を持たないので、上書きで退行を受け入れる操作(TRYBUILD=overwrite)が存在しない。
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;

#[derive(Debug, PartialEq)]
struct Annot {
    line: usize,
    pat: String,
}

/// `//~ ERROR pat` / `//~^ ERROR pat` を拾う
fn annotations(src: &str) -> Vec<Annot> {
    let mut out = Vec::new();
    for (i, text) in src.lines().enumerate() {
        let Some(pos) = text.find("//~") else { continue };
        let rest = &text[pos + 3..];
        let carets = rest.chars().take_while(|&ch| ch == '^').count();
        let rest = rest[carets..].trim_start();
        let Some(pat) = rest.strip_prefix("ERROR") else {
            panic!("line {}: only `//~ ERROR` is supported: {text}", i + 1)
        };
        out.push(Annot { line: i + 1 - carets, pat: pat.trim().to_string() });
    }
    out
}

/// short 形式の 1 行 `path:LINE:COL: error[E0308]: msg` を (path, line, 本文) に分ける
fn parse_short(line: &str) -> Option<(String, usize, String)> {
    let idx = line.find(": error")?;
    let (loc, body) = (&line[..idx], &line[idx + 2..]);
    let mut parts = loc.rsplitn(3, ':');
    let _col = parts.next()?.parse::<usize>().ok()?;
    let ln = parts.next()?.parse::<usize>().ok()?;
    let path = parts.next()?.to_string();
    Some((path, ln, body.to_string()))
}

fn build_lib(root: &Path, out: &Path) -> PathBuf {
    let src = std::env::var("UI_LIB_SRC").unwrap_or_else(|_| "src/lib.rs".into());
    let rlib = out.join("libguardfsm.rlib");
    let st = Command::new("rustc")
        .current_dir(root)
        .args(["--edition", "2021", "--crate-type", "rlib", "--crate-name", "guardfsm", &src, "-o"])
        .arg(&rlib)
        .status()
        .expect("rustc not found");
    assert!(st.success(), "library {src} failed to build");
    rlib
}

/// 1 ケースを検査して違反の一覧を返す
fn check_case(root: &Path, out: &Path, rlib: &Path, case: &Path) -> Vec<String> {
    let rel = case.strip_prefix(root).unwrap().to_string_lossy().into_owned();
    let src = std::fs::read_to_string(case).unwrap();
    let annots = annotations(&src);
    let o = Command::new("rustc")
        .current_dir(root)
        .args(["--edition", "2021", "--crate-type", "lib", "--emit=metadata", "--error-format=short"])
        .arg("--extern")
        .arg(format!("guardfsm={}", rlib.display()))
        .arg("-o")
        .arg(out.join("case.rmeta"))
        .arg(&rel)
        .output()
        .unwrap();
    let stderr = String::from_utf8_lossy(&o.stderr);
    let mut matched = vec![false; annots.len()];
    let mut violations = Vec::new();
    for l in stderr.lines() {
        if l.starts_with("error: aborting") || l.starts_with("error: could not compile") {
            continue;
        }
        let Some((path, ln, body)) = parse_short(l) else {
            if l.starts_with("error") {
                violations.push(format!("{rel}: error without location: {l}"));
            }
            continue;
        };
        let hit = annots.iter().position(|a| a.line == ln && path == rel && body.contains(&a.pat));
        match hit {
            Some(k) => matched[k] = true,
            // 注釈のある行に別の文が出た=位置は合っていて文が変わった。注釈の無い行=位置がずれた
            None if annots.iter().any(|a| a.line == ln && path == rel) => {
                violations.push(format!("{rel}:{ln}: [message] same line, different error: {body}"))
            }
            None => violations.push(format!("{rel}: [position] error at {path}:{ln}: {body}")),
        }
    }
    for (a, m) in annots.iter().zip(&matched) {
        if !m {
            violations.push(format!("{rel}:{}: [missing] expected `{}` here", a.line, a.pat));
        }
    }
    violations
}

#[test]
fn ui_annotations() {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    let out = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("ui_annot");
    std::fs::create_dir_all(&out).unwrap();
    let t0 = Instant::now();
    let rlib = build_lib(&root, &out);
    let mut cases: Vec<_> = std::fs::read_dir(root.join("tests/ui"))
        .unwrap()
        .map(|e| e.unwrap().path())
        .filter(|p| p.extension().is_some_and(|x| x == "rs"))
        .collect();
    cases.sort();
    let mut all = Vec::new();
    for c in &cases {
        all.extend(check_case(&root, &out, &rlib, c));
    }
    eprintln!("checked {} cases in {:?}, {} violations", cases.len(), t0.elapsed(), all.len());
    for v in &all {
        eprintln!("  {v}");
    }
    assert!(all.is_empty());
}

#[test]
fn annotation_parser() {
    let src = "a\nb //~ ERROR E0308\nc\n//~^ ERROR no rules expected `=>`\n";
    assert_eq!(
        annotations(src),
        vec![
            Annot { line: 2, pat: "E0308".into() },
            Annot { line: 3, pat: "no rules expected `=>`".into() },
        ]
    );
    assert_eq!(
        parse_short("tests/ui/a.rs:12:27: error[E0308]: mismatched types: x"),
        Some(("tests/ui/a.rs".into(), 12, "error[E0308]: mismatched types: x".into()))
    );
    assert_eq!(parse_short("warning: unused"), None);
}

src/lib.rs(57 行)の残り(単体テスト):

#[cfg(test)]
mod tests {
    pub struct Ctx { pub failures: u32 }
    crate::fsm! {
        state Door { Locked, Unlocked, Alarm }
        event Ev { Unlock, Fail, Lock }
        ctx c: Ctx;
        action Act { Beep, Log(u32), SoundAlarm }
        rows {
            (Locked, Unlock) => Unlocked [Beep];
            (Locked, Fail) if c.failures + 1 >= 3 => Alarm [Log(c.failures + 1), SoundAlarm];
            (Locked, Fail) => Locked [Log(c.failures + 1)];
            (Unlocked, Lock) => Locked [];
        }
    }
    #[test]
    fn guard_selects_row() {
        assert_eq!(Door::Locked.on(Ev::Fail, &Ctx { failures: 2 }), Some((Door::Alarm, vec![Act::Log(3), Act::SoundAlarm])));
        assert_eq!(Door::Locked.on(Ev::Fail, &Ctx { failures: 0 }), Some((Door::Locked, vec![Act::Log(1)])));
        assert_eq!(Door::Alarm.on(Ev::Unlock, &Ctx { failures: 0 }), None);
        assert_eq!(Door::Unlocked.on(Ev::Lock, &Ctx { failures: 0 }), Some((Door::Locked, vec![])));
    }
}

ケースの生成(gen_ui.sh、23 行。./gen_ui.sh g01_u32 'c.failures' '//~ ERROR E0308' のように使い、make_cases.sh に 14 行で並べる):

#!/bin/sh
# gen_ui.sh NAME 'GUARD' ['ANNOT'] : 表の 12 行目のガードだけを差し替えた UI ケースを tests/ui/NAME.rs に作る
# ANNOT は compiletest 形式の注釈(例: '//~ ERROR E0308')。12 行目の行末に付ける
name=$1; guard=$2; annot=${3:-}
cat > tests/ui/$name.rs <<RS
//! $name
pub struct Ctx { pub failures: u32, pub armed: Option<bool> }
impl Ctx { pub fn bump(&mut self) -> bool { self.failures += 1; true } }

guardfsm::fsm! {
    state Door { Locked, Unlocked, Alarm }
    event Ev { Unlock, Fail, Lock }
    ctx c: Ctx;
    action Act { Beep, Log(u32), SoundAlarm }
    rows {
        (Locked, Unlock) => Unlocked [Beep];
        (Locked, Fail) if $guard => Alarm [Log(c.failures + 1), SoundAlarm]; $annot
        (Locked, Fail) => Locked [Log(c.failures + 1)];
        (Unlocked, Lock) => Locked [];
    }
}
fn main() {} // trybuild はケースを bin として作るので main が要る
RS

Cargo.toml は edition = "2021"、[dev-dependencies] trybuild = "1.0.122"(比較用。手製ハーネスだけなら依存 0)。

実行と結果(実測、2026-10-13)

$ cargo test                                       # 単体 1・手製ハーネス 2・trybuild 1
test result: ok. 1 passed                          # guard_selects_row
checked 14 cases in 715.68475ms, 0 violations      # ui_annotations
test result: ok. 2 passed
test result: ok. 1 passed                          # ui_trybuild(スナップショットは TRYBUILD=overwrite で作り、中身を目で確認)
$ cargo +1.85 test --test ui_annot -- --nocapture
checked 14 cases in 627.2465ms, 0 violations       # 1.85.1 でも 13 件とも 12 行目
$ cargo clippy --all-targets                       # 警告 0 件(単体テストに Lock の遷移を足して dead_code を解消)

Q3 の答え(実測、rustc 1.99.0)

案位置手製ハーネスtrybuild
R1 Into::<bool>::into($g)12 行目のまま。g01・g03・g05・g06 が E0308 → E0277(the trait From<u32> is not implemented for bool)。g03 の「== のつもりでは」の help が消える8 違反([message] 4 + [missing] 4)4 mismatch
R2 { let __g: bool = $g; __g }12 行目のまま、番号も同じ。副ラベル "expected due to this" がマクロ呼び出し全体(5〜16 行)に付く0 違反3 mismatch(g01・g05・g06。副ラベルの差だけ)
R3 IntoGuard::into_guard($g)12 行目のまま。g05(Option<bool>)は通る(仕様変更)。他の 3 件は E02777 違反(g05 は [missing] 1 件だけ)測っていない
R4 ($g) == trueg01・g03・g05・g06 の 4 件が 5:1 に移る。 しかも文が逆向き: "expected u32, found bool"(利用者の式ではなく true が悪いと言う)8 違反([position] 4 + [missing] 4)4 mismatch。TRYBUILD=overwrite の後は 0 件で通る
#### R4(UI_LIB_SRC=variants/r4.rs cargo test --test ui_annot -- --nocapture)
checked 14 cases in 715.355417ms, 8 violations
  tests/ui/g01_u32.rs: [position] error at tests/ui/g01_u32.rs:5: error[E0308]: mismatched types: expected `u32`, found `bool`
  tests/ui/g01_u32.rs:12: [missing] expected `E0308` here
  …(g03・g05・g06 も同じ形)

読み取れること:

  1. R4 が本物の退行。 if ($g) なら「$g が bool であること」を期待するので、$g に下線が付きます。($g) == true では比較の右辺 true(マクロ定義のトークン)が $g の型に合わせられ、「true が u32 でない」と報告されます。期待する側と期待される側が入れ替わり、主役のトークンがマクロ定義に移ったので、位置が 5:1 に戻りました
  2. R2 は無害なのに trybuild は 3 件落とす。 位置も番号も同じで、変わったのは副ラベルだけです。スナップショットは全文比較なので、Day 071 の「1.85 では 8/10 が文言だけで不一致」と同じ種類の誤検知です。手製ハーネスは主たる位置と注釈の部分文字列しか見ないので通します
  3. R1 は「文が悪くなる」退行。 位置は守られても、== の提案が消え、From<u32> というトレイトの話にすり替わります。手製ハーネスは番号(E0308)を注釈に書いていたので [message] で止めました。番号まで注釈に書くか、位置だけにするかは、守りたい性質の選択です
  4. R3 は仕様変更。 g05 が通るようになったのは作者の狙いどおりで、[missing] 1 件は「注釈を消す」作業を促すだけです。スナップショットと違い、注釈を消すのは人の判断で、上書きの一括操作ではありません

時間(ケース 14 個、5 回交互に、load average 12〜16 の M1 で並行作業あり): 手製ハーネス 623〜675 ms(テスト内の計測、ライブラリの rlib ビルドを含む)、trybuild 1.11〜1.22 s(テストの finished in)。trybuild は内部で Cargo のプロジェクトを作ってケースを bin としてビルドするので、--emit=metadata で止める手製より重いと読めます(内訳は未分解)。

Q4 の答えの例

採るのは手製の //~ ERROR ハーネスを主にし、trybuild は外す。根拠: ガード 13 件で位置の退行(R4)は [position] 4 件として止め、無害な R2 は 0 違反で通した。trybuild は R2 で 3 件誤検知し、overwrite で R4 も通した。時間も 0.62〜0.68 s 対 1.11〜1.22 s。
採らなかった案: trybuild のみ(誤検知と overwrite の穴、Day 071 と今日で 2 度確認)/両方(同じケースに 2 種類の期待を持つと、片方だけ更新される)/位置だけの注釈(R1 の help 消失を見逃す)。
注釈には番号まで書く(E0308)。help の消失そのものは short 形式では見えないので、番号の変化を代わりの信号にする。
見直し条件: help・note の文面も守りたくなったら(例: 利用者向けの移行ガイドで help を引用する)trybuild を help のあるケースだけに戻す。compiletest のように WARN も必須にするなら、警告を出す表のケースを足してから。

🪜 Step-by-Step 解説

Step 1: 「利用者のトークンをそのまま置く」と位置は守られる

$(if $g)? は、利用者のガードのトークンを match のガードの位置に置くだけです。型検査(E0308)・フィールド(E0609)・借用(E0596・E0594)・名前解決(E0425・E0424)はどれも、そのトークンに下線を引きます。13 件中 13 件が 12 行目だったのはこのためです。Day 070 で「行のアクションを $a:expr で取り込み直す」としたのと同じ原理です。

Step 2: 衛生性で見えない名前と、見える名前を分ける

ev・self(g08・g09)はマクロ定義が書いた名前なので、利用者のガードからは別の文脈の名前に見えます。c は利用者が ctx c: Ctx; で書いた $c:ident なので、on() の引数名として置いても利用者の文脈のままです。「ガードから文脈を読ませたい」なら、名前を利用者に決めさせる設計が必要です。g08 の help("not accessible due to macro hygiene")は、rustc がこの区別を知っていることを示しています。

Step 3: 注釈は「どの行に、何が出るか」だけを書く

compiletest の注釈は、エラーが指す行に //~ ERROR <部分文字列> を書きます。rustc-dev-guide は ERROR と WARN を "required to be exhaustively covered by line annotations" としています(一次資料)。今日の最小版は ERROR だけを必須にし、警告は見ません(fn main が lib では未使用になる警告などを無視するため)。

Step 4: short 形式を使い、パースを壊れにくくする

--error-format=short は path:LINE:COL: error[CODE]: 文: ラベル の 1 行です。": error" で分けて左を rsplitn(3, ':') で後ろから割ると、パスに : が入っても壊れません。JSON(--error-format=json)なら副ラベルや help まで取れますが、依存 0 で JSON を読むのは割に合わないので見送りました(下の「迷った他案」)。

Step 5: 違反を 3 種類に分ける

「同じ行に別の文」と「別の行」を分けると、R1(文の退行)と R4(位置の退行)を出力だけで見分けられます。Day 071 の trybuild では、同じ mismatch の中から人が diff を読んで分けていました。

Step 6: 退行の実験はライブラリのソースを差し替えて行う

UI_LIB_SRC=variants/r4.rs cargo test --test ui_annot のように、ハーネスがビルドするライブラリだけを替えると、src/lib.rs を書き換えずに 4 案を並べて測れます。trybuild はクレート自身をビルドするので、src/lib.rs を一時的に置き換えて測りました。

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

Rust のコンパイラは、自分自身の診断を compiletest の注釈方式と .stderr スナップショットの両方で守っています(rustc-dev-guide: .stderr は "normally generate these files with the --bless CLI option" で作り、目で確認する)。注釈は「どの行に何が出るべきか」という仕様、スナップショットは「今どう出ているか」という記録です。仕様は人が書き、記録は機械が書きます。

マクロの作者にとって守りたいのは、ほとんどの場合「利用者の行を指すこと」と「エラーの種類」で、副ラベルの位置や文の細部は rustc の版で変わってよいものです。だから仕様(注釈)を主に、記録(スナップショット)は必要なケースだけにするのが、今日の実測に合う形です。

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

案中身採らなかった理由
trybuild のみ(Day 071 の形).stderr 全文比較R2 で 3 件誤検知、overwrite で R4 を通す(実測)
手製 + trybuild の両方同じケースを 2 回検査期待が 2 か所に分かれ、片方だけ更新される。時間も足し算
手製 //~ ERROR(番号まで書く)行 + 部分文字列採用。 R4 を [position]、R1 を [message] で止め、R2 は通す
手製(行だけ、文は見ない)//~ ERROR の後ろを空にするR1 の help 消失(E0308 → E0277)を見逃す
--error-format=json を読む副ラベル・help まで照合JSON パーサが要る(serde_json)。依存 0 の利点を捨てる割に、守りたい性質(主たる位置)は short で足りる
本物の compiletest(ui_test クレート)rustc と同じ注釈の方言を完全に実装今日は入れていない。中身・依存の数は未確認。注釈の書式は同じなので、移行は注釈をそのまま流用できる見込み
ガードの展開を R1・R3 にする型を広く受けるR1 は文が悪化。R3 は Option<bool> を通す仕様変更で、None を偽とするかは表の利用者が決めること(今日は採らない)

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

エラー出た原因対処
E0308 expected bool, found u32(12:27)ガードが bool でない比較を書く(c.failures >= 3)
E0308 expected bool, found () + help: compare for equality== のつもりで =help どおり ==
E0596 / E0594 cannot borrow / assign … in a & referenceガードで文脈を書き換えた(c は &Ctx)ガードは読むだけにする。回数の更新はアクション(Log(..))として返し、呼び出し側で行う(Day 065・066 の結論)
E0425 cannot find value ev + help: … not accessible due to macro hygieneマクロが作った名前をガードで使った使いたい値は ctx の中身として渡す
no rules expected keyword letif-let ガードを書いた今の fsm! では不可。matches!(c.armed, Some(true)) のような bool の式に書き換える
E0601 main function not found(trybuild のみ)trybuild はケースを bin でビルドするケースに fn main() {} を足す。overwrite するとこの E0601 もスナップショットに入る(今日 1 回目で実際に入った)
[position] error at …:5(手製ハーネス)マクロの展開を変えて、誤りの主役がマクロ定義のトークンになった展開を if $g の形に戻す(R4 → 元の形)

🌐 他言語との比較

観点手製 //~ ERROR(今日実装)trybuild(今日実行)rustc の compiletestTypeScript の // @ts-expect-error
期待の書き場所ソースの行末別ファイル .stderr行末の注釈 + .stderr誤りの1 行上
何を照合するか行 + 部分文字列出力の全文行 + 部分文字列(ERROR・WARN は全件必須)+ 全文次の行にエラーがあること(種類・文は見ない)
期待の更新人が注釈を書き換えるTRYBUILD=overwrite--bless で .stderr を更新人が書く
位置の退行(R4)[position] で止めるmismatch。overwrite で通る注釈で止まる止まる(行が変われば unused 扱いでエラー)
文だけの変化(R2)通す誤検知.stderr 側で落ちる通す

手製ハーネスと trybuild の列は今日実行した結果です。compiletest の列は rustc-dev-guide の記述(今日取得)、TypeScript の列は仕様の記憶に基づく要旨で、今日は実行していません。

🏭 実務での使いどころ

  • DSL マクロ・derive マクロを公開するクレート: 「誤りが利用者の行を指す」は API の約束です。マクロの展開をリファクタリングするたびに、R4 のような退行が気づかれずに入ります。注釈方式なら、期待が仕様としてソースに残ります
  • rustc の版を上げる CI: スナップショット方式は rustc の文言の変更で毎回赤くなり、overwrite が習慣になります(Day 071 で 8/10、今日の R2 で 3/13)。注釈方式は位置と番号が変わらない限り緑のままです
  • ガードに渡す名前の設計: 状態機械・ルールエンジン・ルーターの DSL で「条件式から何が見えるか」は衛生性で決まります。ev・self のような内部名に頼らず、ctx c: Ctx; のように利用者に名付けさせると、g07〜g09 の誤りが「書き忘れ」として利用者の行に出ます

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「ガードの型の誤りはマクロの中で起きるので、マクロ呼び出しを指す」match はマクロが作ったので$g:expr をそのまま置けば利用者の行を指す(13/13、実測)
「($g) == true は if $g と同じ」意味は同じ型の不一致で期待する側が入れ替わり、true(マクロ定義)に下線が付いて 5:1 に移る(R4、実測)
「ガードの中で ev や self が使える」展開後のコードには書いてある衛生性で見えない(E0425・E0424)。help も "not accessible due to macro hygiene" と言う
「1.99 で if-let ガードが安定したので、fsm! でも書ける」言語としては通る$g:expr は let で始まる断片を受けない。$($g:tt)+ => は local ambiguity、$(if let …)? $(if $g:expr)? は follow-set 違反($x:expr の後に if は置けない)で、どちらも今日実測で失敗
「スナップショットが全部一致していれば位置も守られている」全文比較なので一致は守られるが、overwrite した後の一致は何も守らない(R4 を通した)
「trybuild のケースは lib でよい」手製ハーネスは lib で通るbin としてビルドされるので fn main が要る。無いまま overwrite すると E0601 が「正解」として保存される

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

  • rustc-dev-guide "UI tests"(https://rustc-dev-guide.rust-lang.org/tests/ui.html): 注釈は "a comment with the diagnostic level (such as ERROR) and a substring of the expected error output"/ERROR と WARN は "required to be exhaustively covered by line annotations //~"/.stderr は "normally generate these files with the --bless CLI option" — Q2 の設計と、WARN を扱わなかった差の根拠
  • trybuild 1.0.122(今日 crates.io から取得して実行)。ケースを bin としてビルドすること(E0601)と、TRYBUILD=overwrite で mismatch が 0 になることは今日の実行結果
  • エラー文はすべて rustc 1.99.0(2026-09-28 版)と 1.85.1 で今日実行した出力。if-let ガードの安定は、1.99.0 で match に直接書いて edition 2021・2024 とも通り、1.85.1 で E0658 になったことのみが根拠(リリースノートは未確認)

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

扱わなかったこと理由・次の予定
Day 072 の V6(max を _ の列で数える)と今日のハーネスの結合Day 071・072 の spanfsm が手元に無い。作り直すときに E1〜E10 に //~ ERROR を付け、ui_position.rs を置き換える
if-let ガードを fsm! で受ける書き方3 通り($g:expr・$($g:tt)+・$(if let …)? $(if …)?)が失敗することだけ実測。if let 専用の行の規則を別に書く案は未試行
WARN 注釈compiletest は WARN も必須。今日は ERROR のみ。到達しない行(同じ組の 2 行目にガード無し)が出す unreachable pattern 警告の位置は未確認
ui_test クレート(compiletest の外部版)との比較依存の数・注釈方言の差は未確認
R3 の trybuild での判定、trybuild を 1.85 で動かすこと測っていない
時間の内訳(rlib ビルド・ケースごと・trybuild の Cargo 起動)合計だけ。load average 12〜16 の M1 で 5 回のみ。静かな Mac mini での取り直しは Day 066〜072 の宿題と一緒に
g04 で help が出ない理由同じクレートの Day 066 と別クレートの今日で差があることのみ。rustc のソース未読
TypeScript の @ts-expect-error の比較列の実行記憶ベース

3 問の自己点検

  1. 同じ指示を AI に出せば数時間で出るか — 「compiletest 風の注釈ハーネス」の骨格はすぐ出る。ただし、ガードの誤り 13 通りがすべて 12 行目を指すこと(1.99・1.85 とも)、($g) == true だけが 4 件を 5:1 に移し文を逆向きにすること、let __g: bool は無害なのに trybuild が 3 件落とすこと、Into<bool> で == の help が消えること、if-let ガードが 1.99 で安定でも macro_rules! の 3 通りの受け方がすべて失敗すること、は今日ビルドして得た結果
  2. AI に指示するだけでは出ない部分 — 違反を [position]・[message]・[missing] に分けたことで、R1(文の退行)と R4(位置の退行)を出力だけで区別できるようにしたこと。trybuild の E0601 が 1 回目の overwrite でスナップショットに入った事故を、overwrite の穴の実例として記録したこと
  3. 世界の実物と比べて何割か — compiletest の注釈機能のうち、今日実装したのは //~ ERROR と //~^ の 2 つ。WARN・NOTE・HELP の注釈、//~|(同じ行に複数)、//@ ヘッダ(コンパイルフラグ・revisions)、.stderr との併用、--bless は扱っていない。数え方の目安で 2/8 程度

➡️ 次のステップ

  • 発展: spanfsm(Day 069〜072)の E1〜E10 と今日のガード 13 件を 1 つのクレートにまとめ、すべてに //~ ERROR を付けて ui_position.rs(例外表)を廃止する。V5(名前付きの行き止まりマクロで位置が戻る謎)も同じハーネスで $a:tt 版と並べて測る
  • 次回予告: マクロ① の残り期間(〜10/24)。今日の実装の次は設計か概念理解。候補は (1) 入れ子の状態(Alarm の中に鳴動中/消音中)を自作 fsm! と statig の superstate で比べる設計、(2) 衛生性の 3 種類(macro_rules! の mixed site・proc-macro の call site/def site)を、今日の g08 と同じ表で比べる概念理解

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

  • 理解度: [ ] 完全理解 / [ ] おおむね理解 / [ ] 要復習
  • Q1 の予想(13 件のうち 12 行目を指すと思った数)と実際(13 件):
  • Q3 の予想(位置が変わるのはどの案か)と実際(R4 だけ):
  • 自分の回答(Q2 のハーネスで工夫した点、Q4 の判断):
  • 気づき・メモ:

Q1 の予想(13 件のうち 12 行目を指すと思った数)と実際(13 件)

Q3 の予想(位置が変わるのはどの案か)と実際(R4 だけ)

自分の回答(Q2 のハーネスで工夫した点、Q4 の判断)

気づき・メモ