フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: マクロ①(宣言的マクロ macro_rules!)
📚 背景知識(読んでから問題へ)
Day 071 で trybuild を入れ、fsm! の誤り 10 種の位置をスナップショット(.stderr)で固定しました。そのとき分かった弱点が 2 つありました。
- スナップショットは「位置」と「文言」を区別しない。 rustc 1.85 に替えると 10 件中 8 件が文言だけの違いで不一致になり、本物の退行(位置のずれ)は 4 件だけでした
TRYBUILD=overwriteは退行もそのまま受け入れる。 上書きした瞬間に、ずれた位置が「正解」として保存されます
そして宿題に「ガード(if $g:expr)の誤りの位置は未確認」が残っていました。ガードは利用者が書く式で、表の行の中で唯一、型(bool)・借用(&Ctx しか持っていない)・衛生性(マクロ内の名前は見えない)の 3 つの規則に同時に触れる場所です。
今日は 2 つを一緒に片付けます。
- ガードを 13 通りに壊したとき、エラーがどこを指すかを測る
- rustc 自身のテスト(compiletest)が使う
//~ ERROR注釈方式を、依存クレートなしで小さく作る。rustc-dev-guide の UI テストの節は、注釈を "a comment with the diagnostic level (such asERROR) 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) エラー番号 を予想してください。
| # | ガード | 狙い |
|---|---|---|
| g01 | c.failures | bool でない(u32) |
| g02 | c.failurs + 1 >= 3 | フィールド名の誤字 |
| g03 | c.failures = 3 | == のつもりで = |
| g04 | c.bump() | &mut self のメソッド(c は &Ctx) |
| g05 | c.armed | Option<bool> |
| g06 | "yes" | &str |
| g07 | failures >= 3 | c. を書き忘れた |
| g08 | ev == Ev::Fail | マクロが作った引数 ev を使う |
| g09 | self == Door::Locked | マクロが作ったメソッドの self を使う |
| g10 | { c.failures += 1; true } | ガードの中で文脈を書き換える |
| g11 | let Some(true) = c.armed | if-let ガード(1.99 で安定しているか?) |
| g12 | c.failures >= 3 && | 式が途中で終わる |
| g13 | (空) | if => |
Q2: 手製の UI ハーネスを実装する
tests/ui_annot.rs を、依存クレートなしで書いてください。要件:
src/lib.rsをrustc --crate-type rlibで 1 回だけビルドし、各ケースを--extern guardfsm=…付きの別クレートとして検査する(環境変数UI_LIB_SRCでライブラリのソースを差し替えられること。退行の実験に使う)rustc --error-format=shortの出力(path:LINE:COL: error[E0308]: …)を読み、ソースの//~ ERROR <部分文字列>注釈と (行, 部分文字列) で突き合わせる。//~^は 1 行上を指す- 違反を 3 種類に分けて報告する:
[position](注釈の無い行に出た=位置がずれた)、[message](注釈の行に出たが文が違う)、[missing](注釈どおりのエラーが出なかった) - 注釈の無い
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 の最初の行)
| # | ガード | 位置 | 番号・文 |
|---|---|---|---|
| g01 | c.failures | 12:27 | E0308 expected bool, found u32 |
| g02 | c.failurs + 1 >= 3 | 12:29 | E0609 no field failurs on type &Ctx |
| g03 | c.failures = 3 | 12:27 | E0308 expected bool, found ()。help: you might have meant to compare for equality(== を提案) |
| g04 | c.bump() | 12:27(c の 1 文字) | E0596 cannot borrow data in a & reference as mutable。help は出ない |
| g05 | c.armed | 12:27 | E0308 expected bool, found Option<bool> |
| g06 | "yes" | 12:27 | E0308 expected bool, found &str |
| g07 | failures >= 3 | 12:27 | E0425 cannot find value failures |
| g08 | ev == Ev::Fail | 12:27 | E0425 cannot find value ev。help: an identifier with the same name is defined here, but is not accessible due to macro hygiene(副ラベルは 5:1) |
| g09 | self == Door::Locked | 12:27 | E0424 self value is a keyword only available in methods with a self parameter |
| g10 | { c.failures += 1; true } | 12:29 | E0594 cannot assign to data in a & reference |
| g11 | let Some(true) = c.armed | 12:27 | no rules expected keyword let(note: while trying to match meta-variable $g:expr) |
| g12 | c.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 に付く):
読み取れること:
- 型・借用・名前解決の誤りは、ガードの式そのものに下線が付く。 下線を持つトークンが利用者のもの(
$g:exprで受けてそのまま置いた)だからです - g04 は Day 066 と違う。 Day 066(同じクレート)では E0594 に「
on()の引数を&mutに変える」help が出ていました。別クレートでは help が出ません。直す場所($c: &$Ctxの&)がマクロ定義の中にあり、利用者には直せないので、rustc が提案を出さないと読めます(理由は rustc のソース未読。実測の差のみ) - g08・g09 は衛生性。
on(self, ev, $c)のevとselfはマクロ定義のトークンなので、利用者のガードからは見えません。一方cは利用者がctx c: Ctx;で書いた識別子($c:ident)なので見えます。ガードに渡したい名前は、利用者に名付けさせるのが macro_rules! の作法です - 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 letguards 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 件は E0277 | 7 違反(g05 は [missing] 1 件だけ) | 測っていない |
R4 ($g) == true | g01・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 も同じ形)
読み取れること:
- R4 が本物の退行。
if ($g)なら「$gがboolであること」を期待するので、$gに下線が付きます。($g) == trueでは比較の右辺true(マクロ定義のトークン)が$gの型に合わせられ、「trueがu32でない」と報告されます。期待する側と期待される側が入れ替わり、主役のトークンがマクロ定義に移ったので、位置が 5:1 に戻りました - R2 は無害なのに trybuild は 3 件落とす。 位置も番号も同じで、変わったのは副ラベルだけです。スナップショットは全文比較なので、Day 071 の「1.85 では 8/10 が文言だけで不一致」と同じ種類の誤検知です。手製ハーネスは主たる位置と注釈の部分文字列しか見ないので通します
- R1 は「文が悪くなる」退行。 位置は守られても、
==の提案が消え、From<u32>というトレイトの話にすり替わります。手製ハーネスは番号(E0308)を注釈に書いていたので[message]で止めました。番号まで注釈に書くか、位置だけにするかは、守りたい性質の選択です - 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 let | if-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 の compiletest | TypeScript の // @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--blessCLI 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 問の自己点検
- 同じ指示を 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 通りの受け方がすべて失敗すること、は今日ビルドして得た結果 - AI に指示するだけでは出ない部分 — 違反を
[position]・[message]・[missing]に分けたことで、R1(文の退行)と R4(位置の退行)を出力だけで区別できるようにしたこと。trybuild の E0601 が 1 回目の overwrite でスナップショットに入った事故を、overwrite の穴の実例として記録したこと - 世界の実物と比べて何割か — 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 の判断):
- 気づき・メモ: