Day 076 — 2026-10-16 | マクロ① macro_rules! ㉑ nested_fsm! を別クレートに移したら「行の入れ替え」の警告が消えた:同じ誤りが同じクレートでは unreachable pattern 1 件、別クレートでは #![deny(unreachable_patterns)] と -D warnings を付けても 0 件。原因は rustc の「外部マクロの中の lint は報告しない」規則(154 個の組み込み lint のうち例外は 7 個)。左辺を $sp:path に絞る版と、左辺を tt のまま受けて利用者の span を残す版を作り、8 通りの誤りで比べると、ビルドが通る誤りは 3 → 2 → 1 件に減った。cargo fix は誤字の束縛を _Ringng に直して警告を消す

2026-10-16 🟣 上級者 / Phase 3 デバッグ・コードレビュー マクロ①(宣言的マクロ macro_rules!)
フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: マクロ①(宣言的マクロ macro_rules!)

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

Day 075 で作った中身つきの nested_fsm!(案 B)には、ビルドが通ってしまう誤りが 3 種類ありました。

Day 075 の番号誤りDay 075(マクロと利用者が同じクレート)での結果
M2(Alarm(Sub::Ringing), Reset) と (Alarm(_), Reset) の順番を入れ替えるwarning: unreachable pattern 1 件(位置はマクロ定義の行)
M3cAlarm(Sub::Ringing) を Alarm(Ringng) と書く(Sub:: も省いた誤字)未使用の変数・snake case の警告 2 件
M7(Alarm(Sub::Muted), Timeout) を (Alarm(_), Timeout) と広く書く0 件

Day 075 の「扱わなかった範囲」に、こう書き残しました。

M2 を別クレートにしたときの警告の位置は測っていません(Day 071 の規則からの予想)

Day 071 の規則とは「マクロを別クレートから呼ぶと、マクロ定義の行を指すはずの診断は呼び出し nested_fsm! { 全体を指す」というものです。今日はその予想を確かめます。

実務では、状態機械のマクロはライブラリのクレートに置き、製品のクレートから使うのが普通です(serde の derive、tokio の select!、bitflags の bitflags! はどれも利用者から見て外部のクレートのマクロです)。つまり「別クレートでの振る舞い」こそが利用者が実際に見る振る舞いです。

もう 1 つ、Day 075 の M3c で分かったことがあります。左辺を $sp:pat で受けると、Ringng のような変種に無い名前は新しい束縛になり、どんな下位状態にも一致します。パターンの中の 1 語の名前は「変種(定数)を指す」か「新しい変数を作る」かのどちらかで、それは名前解決が成功するかどうかで決まります(Reference: Identifier patterns。Rust の match の性質で、マクロとは無関係)。

📝 問題

環境: rustc 1.99.0(stable)と 1.85.1、Apple M5 Pro・48GB。ワークスペースは 2 クレート:

  • fsmkit(ライブラリ): Ev・Act・Ctx・trait Machine・nested_fsm!(Day 075 の B と同じ)・参照実装 table(Day 075 の D、v1)
  • door(製品側): fsmkit に依存し、nested_fsm! で Door を書く

CI は次の 3 つを通しています。

  1. RUSTFLAGS="-D warnings" cargo build
  2. door/src/lib.rs の先頭に #![deny(unreachable_patterns)]
  3. cargo test --release(長さ 0〜5 の全イベント列 9,331 本 × failures ∈ {0, 2} を参照実装と突き合わせる。Day 075 の tests/equiv.rs と同じ)

ある日、door/src/b.rs が次の形でレビューに来ました(fsmkit 側は Day 075 のまま)。

use fsmkit::{nested_fsm, Act, Ctx, Ev, Machine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sub { Ringing, Muted }
nested_fsm! {
    state Door { Locked, Unlocked, Alarm(Sub) }
    ctx c: Ctx;
    rows {
        (Locked, Unlock) => Unlocked [Beep];
        (Locked, Fail) if c.failures >= 2 => Alarm(Sub::Ringing) [Log(c.failures + 1), Notify, SoundAlarm];
        (Locked, Fail) => Locked [Log(c.failures + 1)];
        (Unlocked, Lock) => Locked [];
        (Alarm(Ringng), Mute) => Alarm(Sub::Muted) [StopSound];
        (Alarm(Sub::Muted), Timeout) => Alarm(Sub::Ringing) [SoundAlarm];
        (Alarm(_), Reset) => Locked [ClearAlarm];
        (Alarm(Sub::Ringing), Reset) => Locked [StopSound, ClearAlarm];
        (Alarm(s), Fail) => Alarm(s) [Log(c.failures + 1)];
    }
}

-D warnings を外したビルドと、テストの出力(実測):

warning: unused variable: `Ringng` --> door/src/b.rs:12:16 warning: variable `Ringng` should have a snake case name --> door/src/b.rs:12:16 warning: `door` (lib) generated 2 warnings (run `cargo fix --lib -p door` to apply 1 suggestion) 9331 sequences x 2: mismatches local=0 cross_pat=1445 cross_path=0 cross_tt=0

(local は同じ Door を fsmkit の中で書いた版、cross_pat が上の door::b。cross_path・cross_tt は Q3 で作る版)

Q1: 誤りを 2 つ見つけて直す

上のコードには、仕様(Day 075 の v1 の表)から外れる誤りが 2 つあります。どの行の何かを指し、直してください。そのうえで、コンパイラが知らせたのはどちらで、黙っていたのはどちらかを答えてください。

Q2: 警告が消えた理由を突き止める

同じ「Reset 行の入れ替え」を Day 075(同じクレート)で入れたときは warning: unreachable pattern が 1 件出ました。今日の構成では、#![deny(unreachable_patterns)] と -D warnings を付けても0 件です。

  1. Day 075 に書いた予想(位置が nested_fsm! { 全体に移る)は当たったか
  2. なぜ 0 件になるのか。rustc のどの仕組みか。一次資料(rustc のソース)で確かめる
  3. unused variable: Ringng の方は別クレートでも出ている。何が違うのか

Q3: マクロの受け方を変えて、誰が止めるかを測る

fsmkit に次の 2 つを足し、door で同じ Door を書いて、8 通りの誤りを 1 つずつ入れてください。

版左辺の受け方利用者の書き方
B nested_fsm!(Day 075)($s:ident $( ( $sp:pat ) )?, $e:ident)Alarm(_)・Alarm(s) を書ける
P nested_fsm_p!($s:ident $( ( $sp:path ) )?, $e:ident)束縛は書けない。どの下位状態でも=Alarm、留まる=行き先 stay
T nested_fsm_t!$row:tt((状態, イベント) の括弧ごと 1 つの木)B と同じ。名前は生成コードの中のグロブ import で解決
#入れる誤り
M2Reset の 2 行を入れ替える
M3Alarm(Ringing)(Sub:: を省く)
M3bAlarm(Sub::Ringng)
M3cAlarm(Ringng)
M7Timeout 行を Alarm(_)(P では Alarm)に広げる
Eイベントの誤字 Reset → Rest
S状態の誤字 Unlocked → Unlockd
X状態とイベントの名前が衝突(Door に Mute を足す)

各セルに「エラー/警告だけ(ビルドは通る)/何も出ない」と、ビルドが通った場合は等価性テストの不一致本数を書きます。

Q4: どれを fsmkit の既定にするか(ADR-076)

採る案・採らなかった案と理由・前提が変わったら判断がどう変わるかを書いてください。

💡 ヒント

ヒント1(方向性)
  • 警告が「別の場所に出た」のか「出なかった」のかを、まず区別します。grep -c '^warning' で数えれば分かります
  • unreachable pattern と unused variable は、どちらも lint です。2 つの違いは診断が指す span がどちらのクレートのトークンかです。前者はマクロ定義の ( $State::$s ..., $crate::Ev::$e ) を、後者は利用者が書いた Ringng を指します
ヒント2(アプローチ)
  • rustc は lint を出す直前に、その span が外部クレートのマクロの展開の中かどうかを見ています。compiler/rustc_middle/src/lint.rs の in_external_macro、compiler/rustc_lint_defs/src/builtin.rs の report_in_external_macro を探します
  • $sp:path で受けたものをパターンの位置に置くと、それはパスのパターンとして構文解析されます。1 語の Ringng でも束縛にはなりません
  • T 版は「パターン全体を利用者のトークンにする」ことで lint の span を利用者側に持ってくる案です。代わりに名前をグロブ import で解決するので、誤字が束縛になる穴が状態名・イベント名にも広がります。それを塞ぐ手段として、生成する fn on に lint の属性を付けられます
ヒント3(コード骨格)
// P: 中身が無ければ { .. }(どの変種の形にも一致する)、あれば ( $sp ) を置く
(@pat $State:ident $s:ident) => { $State::$s { .. } };
(@pat $State:ident $s:ident $sp:path) => { $State::$s($sp) };
(@to $cur:ident $State:ident stay) => { $cur };
(@to $cur:ident $State:ident $to:ident $( ($($sub:tt)*) )?) => { $State::$to $( ($($sub)*) )? };

// T: 行の左辺をそのままパターンにする
impl $State {
    #[deny(unreachable_patterns, unused_variables, non_snake_case)]
    pub fn on(self, ev: $crate::Ev, $c: &$Ctx) -> Option<(Self, Vec<$crate::Act>)> {
        use $crate::Act::*; use $crate::Ev::*; use $State::*;
        match (self, ev) { $( $row $(if $g)? => ..., )* _ => None }
    }
}

✅ 模範解答

Q1 の答え: 黙っていたのは Reset の順番、知らせたのは誤字(の半分だけ)

行誤り直し方コンパイラ
12Alarm(Ringng) は変種ではなく束縛。Muted でも Mute を受けて StopSound が出るAlarm(Sub::Ringing)警告 2 件(未使用の変数・snake case)
14〜15(Alarm(_), Reset) が先にあるので、Ringing からの Reset でも [ClearAlarm] だけが出て StopSound が出ない(音が鳴りっぱなしで施錠に戻る)2 行を入れ替えて Alarm(Sub::Ringing) を先に0 件(deny でも -D warnings でも 0)

2 つ合わせて 1,445 本、片方ずつでは誤字 204 本・順番 1,243 本の不一致です(どれも実測)。

cargo fix に任せると悪化します。 警告文が勧める cargo fix --lib -p door を実行すると(実測)、

Fixed door/src/b.rs (1 fix) 12: (Alarm(_Ringng), Mute) => Alarm(Sub::Muted) [StopSound]; warning: variable `_Ringng` should have a snake case name 9331 sequences x 2: mismatches local=0 cross_pat=1445 cross_path=0 cross_tt=0

cargo fix は「使わない変数なら _ を付ける」という機械的な直しをするので、誤字の束縛が「意図して捨てる束縛」に変わり、未使用の警告が消えます。不一致は 1,445 本のまま、警告は snake case の 1 件だけが残りました。警告を消すことと、誤りを直すことは別です。

黙って通る誤り: 別クレートの nested_fsm! で Reset 行を入れ替えると、#![deny(unreachable_patterns)]・-D warnings・clippy のどれでも 0 件。音が鳴りっぱなしのまま施錠に戻る誤りを、等価性テスト(1,243 本不一致)だけが止めた。

Q2 の答え: 予想は外れ。位置が移ったのではなく、rustc が報告そのものを捨てていた

1. 予想は外れました(実測)。 位置が nested_fsm! { 全体に移るのではなく、診断が 1 件も出ません。

構成1.99.01.85.1
同じクレート(fsmkit/src/local.rs で M2)warning: unreachable pattern --> fsmkit/src/lib.rs:29:24同じ(29:24)
別クレート(door/src/b.rs で M2)0 件0 件
別クレート+#![deny(unreachable_patterns)]0 件(ビルド成功)—
別クレート+RUSTFLAGS="-D warnings"0 件(ビルド成功)—
別クレート+cargo clippy --all-targets0 件—

同じクレートでの警告文も、実は役に立っていませんでした。下線はマクロ定義の 1 行に引かれ、「no value can reach this」と「matches all the relevant values」が同じ下線を指しています。どの行が死んだかは書かれていません。

warning: unreachable pattern --> fsmkit/src/lib.rs:29:24 | 29 | $( ($State::$s $( ($sp) )?, $crate::Ev::$e) $(if $g)? | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | | | no value can reach this | matches all the relevant values | ::: fsmkit/src/local.rs:4:1 | 4 | / nested_fsm! {

2. 仕組み: 外部マクロの中の lint は、lint 側が許可しない限り捨てられる(一次資料)。 rustc の compiler/rustc_middle/src/lint.rs(rust-lang/rust の master、2026-10-16 に取得)の lint_level_impl はこうなっています。

let disable_suggestions = if let Some(ref span) = span
    // If this code originates in a foreign macro, aka something that this crate
    // did not itself author, then it's likely that there's nothing this crate
    // can do about it. We probably want to skip the lint entirely.
    && span.primary_spans().iter().any(|s| s.in_external_macro(sess.source_map()))
// …
if !incompatible && !lint.report_in_external_macro && !is_in_find_attr {
    // Don't continue further, …
    return;
}

診断が指す span が「このクレートが書いていないマクロ」の展開の中なら、lint の定義に report_in_external_macro が無い限り return します。lint のレベル(deny・-D warnings)を決めた後に捨てるので、レベルを上げても出ません。

compiler/rustc_lint_defs/src/builtin.rs(同日取得、6,021 行)を数えると、declare_lint! 154 個のうち report_in_external_macro を持つのは 7 個(UNREACHABLE_CODE・DEPRECATED・DEPRECATED_IN_FUTURE・UNNAMEABLE_TEST_ITEMS・UNEXPECTED_CFGS・LONG_RUNNING_CONST_EVAL・NEVER_TYPE_FALLBACK_FLOWING_INTO_UNSAFE)でした。UNREACHABLE_PATTERNS の定義は Warn, "detects unreachable patterns" だけで、例外に入っていません。似た名前の UNREACHABLE_CODE(到達しないコード)は入っているので、名前から推測すると外します。

設計の意図はコメントのとおりで、「利用者が直せない場所の警告で利用者を困らせない」ためです。serde の derive が生成するコードに警告が出ても利用者には直せない、という状況を想定しています。しかし nested_fsm! のような DSL では、到達不能の原因は利用者が書いた行の順番です。「直せない」という前提が当たらない場面で、この規則が誤りを隠します。

3. unused variable が残る理由: この lint の span は、利用者が書いた Ringng というトークンそのもの(door/src/b.rs:12:16)です。トークンの出どころが利用者のクレートなので in_external_macro が偽になり、報告されます。lint が出るかどうかは、lint の種類ではなく、その lint が指すトークンがどちらのクレートのものかで決まる——これが今日の一番の発見です。

Q3 の答え: ビルドが通る誤りは B 3 件 → P 2 件 → T 1 件(実測、別クレート、1.99.0)

#誤りB($sp:pat)P($sp:path)T(tt+#[deny])
M2Reset 行の入れ替え通る・0 件 / 1,243 本通る・0 件 / 1,243 本error: unreachable pattern t.rs:15:9
M3Alarm(Ringing)E0170E0531E0170
M3bAlarm(Sub::Ringng)E0599E0599E0599
M3cAlarm(Ringng)通る・警告 2 / 204 本E0531(p.rs:12:16)error: unused variable
M7Timeout を広く通る・0 件 / 1,060 本通る・0 件 / 1,060 本通る・0 件 / 1,060 本
ERestE0599E0599error: unreachable pattern+unused variable
SUnlockdE0599E0599error: unused variable
XDoor に Mute(名前の対応表の E0004 のみ)未測定E0659 Mute is ambiguous+E0170
ビルドが通る数3(うち警告 0 が 2)2(どちらも警告 0)1(警告 0)

Q3 を一目で(赤=ビルドが通り警告 0、黄=警告だけ、緑=コンパイラが止めた)

B $sp:pat
P $sp:path
T tt+deny
M2 入替
通る・0 件
1,243 本
通る・0 件
1,243 本
error
t.rs:15:9
M3
E0170
E0531
E0170
M3b
E0599
E0599
E0599
M3c
警告 2
204 本
E0531
error
M7 広い
通る・0 件
1,060 本
通る・0 件
1,060 本
通る・0 件
1,060 本
E Rest
E0599
E0599
error
S Unlockd
E0599
E0599
error
通る数
3
2
1

T の #[deny] を付ける前(ただのグロブ版、T0)も測っています: M2 は警告 1 件(t.rs:15:9、利用者の行)、M3c は警告 2 件 / 204 本、E は警告 3 件 / 2,976 本、S は警告 2 件 / 1,242 本で、ビルドが通る誤りは 5 件でした。T0 は B より悪く、#[deny] を足して初めて最良になります。

読み取れること:

  1. P は M3c を消した。 $sp:path で受けた Ringng はパスのパターンとして解析され、見つからなければ E0531(cannot find unit struct, unit variant or constant)です。束縛になる道が無くなりました。下線も利用者の Ringng の 6 文字です
  2. P は M2 を消せない。 P の行も (マクロのトークン, マクロのトークン) というタプルのパターンで、span は fsmkit 側です。外部マクロの規則で捨てられます
  3. T は M2 を利用者の行で知らせる。 パターン全体が利用者の (Alarm(Sub::Ringing), Reset) なので、Day 075 の同一クレート版より良い警告になりました(死んだ行と、それを食った行の両方に下線):
    warning: unreachable pattern --> door/src/t.rs:15:9 | 14 | (Alarm(_), Reset) => Locked [ClearAlarm]; | ----------------- matches all the relevant values 15 | (Alarm(Sub::Ringing), Reset) => Locked [StopSound, ClearAlarm]; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no value can reach this
  4. T の代償は「誤字が束縛になる」穴の拡大。 グロブ import で名前を解決するので、Rest・Unlockd は E0599 ではなく全部に一致する束縛になります(E は 2,976 本、今日の最大)。これを塞いでいるのは、生成する fn on に付けた #[deny(unreachable_patterns, unused_variables, non_snake_case)] です。この属性の lint は利用者のトークンを指すので、外部マクロの規則に捨てられず、利用者が door/src/lib.rs に #![allow(unreachable_patterns, unused_variables, non_snake_case)] を書いても、関数の属性が内側で勝ってエラー 3 件になりました(実測)
  5. non_snake_case も deny に入れた理由(実測): 誤字の束縛が使われている場合((Alarm(Ringng), Fail) => Alarm(Ringng))、unused_variables は出ません。unused_variables だけの deny では警告 1 件でビルドが通り、non_snake_case を足すとエラーになりました。変種名は CamelCase なので、CamelCase の束縛は「変種のつもりの誤字」の強いしるしです
  6. M7 はどの版でも止まらない(1,060 本)。 広すぎる行は、後ろの行を到達不能にしない限り、どの lint の材料にもなりません。等価性テストが要る理由は今日も変わりません
  7. 1.85.1 でも同じ: M2 の同一クレート警告(29:24)、別クレート 0 件、T の t.rs:15:9、P の E0531 はすべて 1.85.1 で一致しました

Q4 の答え(ADR-076: fsmkit の行の左辺の受け方)

決定: T(左辺を tt で受け、生成する fn on に #[deny(unreachable_patterns, unused_variables, non_snake_case)] を付ける)を既定にする。等価性テストは今まで通り必須。

案採否理由(実測)前提が変わったら
B $sp:pat採らない別クレートで M2 が 0 件(deny・-D warnings・clippy すべて 0)。M3c の警告は cargo fix で _Ringng になって消えるマクロを製品と同じクレートに置くなら、M2 の警告は出る(ただし位置はマクロ定義)
P $sp:path次点誤字の束縛が構文上できなくなる(M3c → E0531)。名前は全部パスで解決するので E・S も E0599。ただし M2 は B と同じく 0 件。Alarm(s) の束縛を書けず、留まるには stay という新しい語を覚えるrustc が unreachable_patterns に report_in_external_macro を付けたら(下記)、P が最良になる。グロブ import を嫌う方針(名前の衝突 X が怖い)でも P
T tt+deny採るビルドが通る誤りが 8 件中 1 件(M7 のみ)。M2 の警告が利用者の行を両方指す(Day 075 より良い)状態・イベント・アクションの名前が衝突する表(X: E0659)が多い製品では、名前の付け直しが要る。deny 属性を外す変更が入ったら、B より悪い T0(5 件)に戻ることを忘れない
T0 tt のみ採らないビルドが通る誤りが 5 件、Rest の誤字が 2,976 本の不一致で黙る—
proc-macro(マクロ②)今は採らないspan を自分で選べる(Day 074)ので P と T の良いところを両取りできる可能性があるが、依存(syn など)とビルド時間(Day 075 の statig で約 10 倍)が増える状態機械のマクロを社外に公開する、または行単位で「この行が死んでいる」と独自の診断を出したくなったら

この ADR が成り立つ前提: 「利用者のトークンを指す lint は外部マクロの規則に捨てられない」こと(1.85.1・1.99.0 で実測、rustc の master のソースで確認)。この規則が変わったら見直します。

🧪 再現用コード(cargo test --release で 1 テスト、依存 0)

ワークスペースの Cargo.toml:

[workspace]
members = ["fsmkit", "door"]
resolver = "2"

fsmkit/Cargo.toml は name = "fsmkit"・edition = "2021" だけ、door/Cargo.toml は [dependencies] fsmkit = { path = "../fsmkit" } を足したもの。

fsmkit/src/lib.rs(101 行。Ev・Act・Ctx・Machine と nested_fsm! は Day 075 と同じなので略。Act から v2 の CallGuard を除いた)に足した 2 つのマクロ:

/// P: 左辺の中身を $sp:path に絞る。中身を書かない `Alarm` は「どの下位状態でも」、
/// 行き先の `stay` は「いまの状態に留まる」。束縛は書けない
#[macro_export]
macro_rules! nested_fsm_p {
    (
        state $State:ident { $($S:ident $( ( $Sub:ty ) )?),* $(,)? }
        ctx $c:ident : $Ctx:ty;
        rows { $( ($s:ident $( ( $sp:path ) )?, $e:ident) $(if $g:expr)?
                  => $to:ident $( ( $($sub:tt)* ) )? [ $($acts:tt)* ] ; )* }
    ) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S $( ($Sub) )?),* }
        impl $State {
            pub fn on(self, ev: $crate::Ev, $c: &$Ctx) -> Option<(Self, Vec<$crate::Act>)> {
                #[allow(unused_imports)]
                use $crate::Act::*;
                let cur = self;
                match (self, ev) {
                    $( ($crate::nested_fsm_p!(@pat $State $s $($sp)?), $crate::Ev::$e) $(if $g)?
                        => Some(($crate::nested_fsm_p!(@to cur $State $to $( ($($sub)*) )?), vec![$($acts)*])), )*
                    _ => None,
                }
            }
        }
    };
    (@pat $State:ident $s:ident) => { $State::$s { .. } };
    (@pat $State:ident $s:ident $sp:path) => { $State::$s($sp) };
    (@to $cur:ident $State:ident stay) => { $cur };
    (@to $cur:ident $State:ident $to:ident $( ($($sub:tt)*) )?) => { $State::$to $( ($($sub)*) )? };
}

/// T: 行の左辺 `(状態, イベント)` を 1 つの tt のまま受け、利用者のトークンをそのままパターンにする。
/// 名前はブロック内のグロブ import で解決する(パターン全体の span が利用者の行になる)
#[macro_export]
macro_rules! nested_fsm_t {
    (
        state $State:ident { $($S:ident $( ( $Sub:ty ) )?),* $(,)? }
        ctx $c:ident : $Ctx:ty;
        rows { $( $row:tt $(if $g:expr)?
                  => $to:ident $( ( $($sub:tt)* ) )? [ $($acts:tt)* ] ; )* }
    ) => {
        #[derive(Debug, Clone, Copy, PartialEq, Eq)]
        pub enum $State { $($S $( ($Sub) )?),* }
        impl $State {
            // 利用者の行に付く誤り(到達不能な行・束縛になった名前)を、利用側の lint 設定に関係なくエラーにする
            #[deny(unreachable_patterns, unused_variables, non_snake_case)]
            pub fn on(self, ev: $crate::Ev, $c: &$Ctx) -> Option<(Self, Vec<$crate::Act>)> {
                #[allow(unused_imports)]
                use $crate::Act::*;
                #[allow(unused_imports)]
                use $crate::Ev::*;
                #[allow(unused_imports)]
                use $State::*;
                match (self, ev) {
                    $( $row $(if $g)? => Some(($State::$to $( ($($sub)*) )?, vec![$($acts)*])), )*
                    _ => None,
                }
            }
        }
    };
}

pub mod table; // Day 075 の D(v1)そのまま
pub mod local; // Day 075 の B と同じ Door を fsmkit の中で書いたもの(同一クレートの比較用)

door/src/p.rs(28 行。M の実装は Day 075 の B と同じ)の表:

use fsmkit::{nested_fsm_p, Act, Ctx, Ev, Machine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Sub { Ringing, Muted }
nested_fsm_p! {
    state Door { Locked, Unlocked, Alarm(Sub) }
    ctx c: Ctx;
    rows {
        (Locked, Unlock) => Unlocked [Beep];
        (Locked, Fail) if c.failures >= 2 => Alarm(Sub::Ringing) [Log(c.failures + 1), Notify, SoundAlarm];
        (Locked, Fail) => stay [Log(c.failures + 1)];
        (Unlocked, Lock) => Locked [];
        (Alarm(Sub::Ringing), Mute) => Alarm(Sub::Muted) [StopSound];
        (Alarm(Sub::Muted), Timeout) => Alarm(Sub::Ringing) [SoundAlarm];
        (Alarm(Sub::Ringing), Reset) => Locked [StopSound, ClearAlarm];
        (Alarm, Reset) => Locked [ClearAlarm];
        (Alarm, Fail) => stay [Log(c.failures + 1)];
    }
}

door/src/b.rs・door/src/t.rs は Day 075 の B の表そのまま(マクロ名だけ違う)。door/tests/equiv.rs(31 行)は Day 075 と同じ全列生成で、local・door::b・door::p・door::t の 4 つを fsmkit::table::M と突き合わせます。

$ cargo test --release -- --nocapture
9331 sequences x 2: mismatches local=0 cross_pat=0 cross_path=0 cross_tt=0
test all_agree ... ok

正しい表では 4 版とも 0 本、1.85.1 でも同じ、cargo clippy --all-targets は 0 件、テスト本体は 0.01 s(3 回)。

mut.sh(誤りを 1 つ当てて測る。環境変数 TC=1.85 で別の toolchain、CMD="clippy --all-targets" で別のコマンド、RUSTFLAGS もそのまま渡る):

#!/bin/bash
# 使い方: ./mut.sh <名前> <ファイル> <旧> <新>
set -u
cd "$(dirname "$0")"
name=$1; file=$2; old=$3; new=$4
rm -rf m && cp -R st m && rm -rf m/target
python3 - "$file" "$old" "$new" <<'PY'
import sys; f,o,n=sys.argv[1:]; p='m/'+f; s=open(p).read(); assert o in s, o; open(p,'w').write(s.replace(o,n,1))
PY
out=$(cd m && CARGO_TARGET_DIR=../tgt${TC:+-$TC}${RUSTFLAGS:+-dw} cargo ${TC:+"+$TC"} ${CMD:-test --release} 2>&1)
echo "== $name"
echo "$out" | grep -E -A1 '^(warning|error)(\[|:)' | grep -vE 'generated [0-9]+ warning|could not compile|^--$' | sed 's/^/  /' | head -12
echo "$out" | grep -E 'mismatches|FAILED|test result: ok. 1' | head -2 | sed 's/^/  test: /'

🪜 Step-by-Step 解説

Step 1: 「位置が移った」と「出なかった」を数で分ける

Day 075 の予想は「位置が移る」でした。今日まず grep -cE '^warning' で数え、0 だったので「移った」ではなく「消えた」と分かりました。次に #![deny]・-D warnings・clippy の 3 つでも 0 を確かめ、レベルの設定では取り戻せないことまで確かめてから、rustc のソースを読みに行きました。症状を数で切り分けてから原因を探すと、読むべきソースが 1 か所(lint を出す直前の分岐)に絞れます。

Step 2: lint が出るかは「span の持ち主」で決まる

同じ別クレート構成で、unused variable: Ringng は出て unreachable pattern は出ませんでした。差は lint の種類ではなく、指している spanです。前者は利用者のトークン、後者はマクロ定義のトークン(( $State::$s ..., $crate::Ev::$e ) の括弧)。Day 070〜072 で追いかけた「エラーがどの行を指すか」は見た目の話でしたが、lint では出るか出ないかまで span の持ち主で決まります。

Step 3: P は「束縛を構文で禁止する」

$sp:pat はパターンを丸ごと受けるので、1 語の名前は束縛にも変種にもなれます。$sp:path に絞ると、パターンの位置に置いたときパスのパターン(PatKind::Path)として扱われ、名前解決に失敗すれば E0531 です。Alarm(_) を書くと expected identifier, found reserved identifier '_' と、path の断片を解析中だったことまで示されます(ただし Door が見つからない E0425・E0433 が 6 件連鎖し、合計 7 件のエラーになります)。

Step 4: T は「パターン全体を利用者に返す」

$row:tt は (Alarm(_), Reset) を括弧ごと 1 つの木として受け、展開でそのまま match の腕に置きます。パターンの span が全部利用者のものになるので、unreachable_patterns は外部マクロの規則に当たりません。名前(Alarm・Reset)は生成コードの中の use $State::*; use $crate::Ev::*; で解決します(macro_rules! の衛生性はローカル変数とラベルにしか効かないので、グロブ import は利用者のトークンから見えます。Day 070 のアクション名と同じ仕組み)。

Step 5: T の穴を、T 自身の deny で塞ぐ

グロブで解決する名前は、見つからないと束縛になります。Rest は全イベントに一致する束縛になり、後ろの行を到達不能にし、2,976 本が食い違いました。ここで効くのが、マクロが生成する関数に付ける lint の属性です。属性はマクロのトークンですが、lint が指すのは利用者の Rest なので報告され、deny なのでエラーになります。「lint のレベルはマクロの作者が決め、lint の span は利用者のトークンに向ける」という組み合わせです。

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

  • 外部マクロの lint を捨てる規則は、正しい既定値です。derive マクロの生成コードの警告を利用者に見せても直せません。問題は、DSL を作るマクロでは利用者が書いた内容が生成コードの形を決めるので、「直せない」という前提が崩れることです。macro_rules! の作者は、自分のマクロが「利用者の内容をパターンや式として埋め込む」種類なら、利用者が間違えうる部分の span を利用者のトークンのまま残す設計を選ぶ必要があります
  • 断片指定子(fragment specifier)は、受け付ける言語を決める: pat は束縛を含むパターン全体、path は名前の参照だけ、tt は何でも。どれを選ぶかで、利用者が書ける誤りの種類が変わります。P は「書けることを減らして誤りを減らす」、T は「書けることは保ち、診断の届き方を変える」で、型で禁止するか検査で止めるかという Rust 全体の設計判断の縮図です
  • 警告は誤りの代わりにならない: cargo fix が _Ringng にしたように、警告を消す機械的な手段は誤りを直しません。今日も M7 はどの版でも 0 件でした。型と lint で止められない仕様は、全数比較のテストで止める(Day 075 の結論)は変わりません

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

P で Alarm(Ringng)(M3c)

error[E0531]: cannot find unit struct, unit variant or constant `Ringng` in this scope --> door/src/p.rs:12:16 | 12 | (Alarm(Ringng), Mute) => Alarm(Sub::Muted) [StopSound]; | ^^^^^^ not found in this scope

読み方: 「unit struct, unit variant or constant」はパスのパターンが指せるものの一覧です。束縛としては扱われていない、という意味でもあります。直し方は Sub::Ringing。B では同じ誤字が警告 2 件で通っていました。

P で Alarm(_) を書いた

error: expected identifier, found reserved identifier `_` --> door/src/p.rs:15:16 | 15 | (Alarm(_), Reset) => Locked [ClearAlarm]; | ^ expected identifier, found reserved identifier | ::: fsmkit/src/lib.rs:45:34 | 45 | rows { $( ($s:ident $( ( $sp:path ) )?, $e:ident) $(if $g:expr)? | -------- while parsing argument for this `path` macro fragment

読み方: $sp:path の解析中に _ で失敗したことを、マクロ定義の該当箇所つきで示しています。断片の解析失敗はマッチャーの次の規則を試さずに致命的エラーになるので(Day 070 で取得した Reference macros-by-example の lookahead の節の趣旨。今日は取得し直していない)、Door の定義が生成されず、後ろに E0425・E0433 が 6 件連鎖します。最初の 1 件だけを読むのが正解です。P では「どの下位状態でも」は (Alarm, Reset) と書きます。

T で名前の衝突(X)

error[E0659]: `Mute` is ambiguous --> door/src/t.rs:12:31

Door::Mute と Ev::Mute の 2 つのグロブ import が同じ名前を持ち込んだためです。T を採るなら、状態名とイベント名を重ねない命名規則が必要です。

🌐 他言語との比較

言語・仕組み「生成コードの中の警告」をどう扱うか根拠
Rust macro_rules!(今日)span が外部マクロの展開内なら lint を捨てる(例外 7/154)。利用者トークンを指す lint は出る実測+rustc ソース
Rust proc-macro同じ規則。ただし作者が Span を選べるので、利用者トークンの span を付ければ出せる(Day 074)Day 074 の実測からの導出(今日は未実行)
C/C++ のマクロプリプロセッサ展開後のコードに対して警告。GCC・Clang は「システムヘッダ由来のマクロ」の警告を既定で抑える記憶ベース
TypeScript(コード生成)生成した .ts は普通のソースとして型検査される。抑えたければ // @ts-nocheck を生成物に入れる記憶ベース
Go(go generate)生成物は普通のソース。// Code generated ... DO NOT EDIT. の行で lint ツールが対象外にする慣行記憶ベース

Rust の規則は「生成物を誰が直せるか」で決める点で、Go の DO NOT EDIT 慣行や C の system header の扱いと同じ発想です。違いは、Rust ではトークン 1 つ単位で持ち主が記録されている(span の衛生情報)ので、同じ生成コードの中でも利用者のトークンだけを報告できることです。

🏭 実務での使いどころ

  • 社内ライブラリにマクロを切り出すとき: 状態機械・ルーティング表・権限表などの DSL マクロを共通クレートに移すと、それまで出ていた unreachable_patterns が黙って消えます。移す前後で cargo build 2>&1 | grep -c '^warning' を比べ、減った警告が「直したから」か「捨てられたから」かを確かめます
  • lint を CI のゲートにしている組織: -D warnings は外部マクロ内の lint を取り戻しません。ゲートが緑であることは、マクロの中の誤りが無いことを意味しません
  • cargo fix・clippy --fix の自動適用: 未使用の束縛に _ を付ける直しは、誤字の束縛を「意図した捨て」に変えます。DSL マクロを使うファイルに自動修正をかけるときは、差分を人が読みます
  • 医療機器の警報・決済の取消: M2 は「鳴動中にリセットしても音が止まらない」誤りで、Day 075 で見たとおり安全や監査に直結します。今日の構成では、どの lint 設定でもコンパイラは知らせません

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「マクロを別クレートに移しても警告は同じく出る(位置が変わるだけ)」エラーは別クレートでも出るので、警告も同じと思うエラーは必ず出るが、lint は span が外部マクロ内なら捨てられる(例外 7/154)。Day 075 の筆者もこの予想を外した
「#![deny(...)]・-D warnings にすれば出る」レベルを上げれば出ると考えるレベルを決めた後で捨てるので出ない(実測 0 件)
「unreachable_code が出るなら unreachable_patterns も出る」名前が似ている前者は report_in_external_macro を持ち、後者は持たない
「cargo fix が直してくれた」警告が減ったので直ったと思う_Ringng になって警告が消えただけで、1,445 本の不一致は残る
「$sp:path にすれば並びの誤りも止まる」束縛が消えたので全部安全と思うP は M3c を止めるが、M2・M7 は B と同じく 0 件
「グロブ import で名前を解決するのは便利なだけ」名前解決の失敗はエラーになると思うパターンの中では失敗が束縛になる(T0 の E は 2,976 本)。deny 属性とセットでのみ使う

🛑 扱わなかった範囲(止め方)

  • P の名前の衝突(X)は測っていません。P は名前をパスで解決するので衝突しないと予想しますが未確認です
  • T を rustc が unreachable_patterns を外部マクロでも報告するようになった版で比べることはできません(そのような版は無い)。rust-lang/rust に同じ不満の issue があるかも探していません
  • proc-macro 版で、行の span を利用者のトークンにして同じ 8 件を当てる実験はしていません(Day 074 の結果からの導出のみ)
  • clippy の lint(clippy::match_same_arms など)が外部マクロでどう扱われるかは、M2 で 0 件だったことしか確かめていません。clippy は独自に in_external_macro を呼ぶ lint が多いはずですが、ソースは読んでいません
  • fsmkit を crates.io などレジストリの依存として使う場合、cargo は依存クレートに --cap-lints を付けますが、今日の lint は利用側(door)のコンパイルで出るものなので影響しないと考えています(未測定)
  • v2(Escalated)・statig 版(C)は今日は扱っていません。Day 075 の「発展」(statig 版に Day 073 のガードの誤り 13 通り)も未実施です
  • Day 074 の次回予定 (2)(UI ハーネスに K1・K6・K7 相当)と Day 073 の宿題(WARN 注釈・if-let 専用行規則・ui_test)は今日も進めていません
  • 他言語比較の C・TypeScript・Go の列は記憶ベースです

➡️ 次のステップ

  • 発展: T の #[deny] 版を Day 073 の UI ハーネス(//~ ERROR 注釈)に載せ、今日の 8 件を「どの行・どの列を指すか」まで固定する。外部マクロの規則が将来変わったときに、ハーネスが落ちて気づけるようにする
  • 次回予告: マクロ① の残り(〜10/24)。直前がデバッグ(076)・設計(075)なので、次は実装か概念理解。候補は (1) Day 074 からの持ち越し(fsm! の UI ハーネスに K1・K6・K7 相当と今日の M2・E・S を //~ ERROR 注釈つきで足す、実装)、(2)「lint・エラー・panic はそれぞれ誰のトークンを指し、どの段階で捨てられうるか」を rustc の診断の流れとして整理する概念問題

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

Q2 の予想(位置が移る/消える)は当たったか

Q3 の表で外したセル(24 セル中)

自分の回答(Q4 の ADR)

気づき・メモ