Problem
#[contract(feeds = ..., expose = [...], emits = [...], no_event)] accepts four keyword directives. Today they're parsed by four independent hand-rolled TokenTree walks, each starting with the same boilerplate (attr.path().is_ident("contract"), attr.meta.require_list(), walk tokens) then forking. After the recent parse-layer consolidation (#23) the four parsers now sit together in contract-macro/src/parse/directives.rs, but they are still independent walks with inconsistent failure modes:
| Parser |
Failure mode |
extract_feeds_attribute (feeds) |
continue on shape mismatch — silently ignored |
event_suppressed (no_event) |
tokens.to_string().contains("no_event") — substring scan, would match the literal text inside any value |
expose_list |
continue on shape mismatch — methods silently unexposed |
emits_list (+ tuple helpers) |
walks tokens; malformed tuples silently dropped |
Three structural problems compound:
- Adding a fifth keyword duplicates the boilerplate again. Tight coupling between attribute syntax and extraction logic makes changes fragile.
- Silent-drop failure mode is a footgun. A user typo (
#[contract(emit = ...)] singular, expose = (a, b) with parens, malformed emits tuple) does nothing and gives no diagnostic. The validator may then fire downstream errors that don't point at the real cause.
- Inconsistent parsing styles.
event_suppressed is a substring scan over the stringified tokens; the other three walk the TokenTree stream. The substring scan is the most fragile — it would match no_event appearing inside any other value.
Proposed Solution
Replace the four parsers with one typed pass over the #[contract(...)] argument list, returning a ContractDirectives { feeds, expose, emits, no_event } struct, with compile_error! diagnostics for shape mismatches.
pub(crate) struct ContractDirectives {
pub feeds: Option<TokenStream2>,
pub expose: Option<Vec<String>>,
pub emits: Option<Vec<EventInfo>>,
pub no_event: bool,
}
pub(crate) fn parse_contract_directives(attrs: &[Attribute]) -> Result<ContractDirectives, syn::Error>;
Each call site that today reaches for one of the four parsers instead reads its field off the result of parse_contract_directives. Method-, impl-, and trait-impl-level all use the same parser.
Use syn::Meta parsing where it cleanly applies (syn::parse2::<Meta>, Meta::list().parse_nested_meta) instead of hand-rolled TokenTree walks. Where the input shape doesn't fit Meta cleanly (the emits = [(TOPIC, EventType), ...] tuple list is the awkward one), keep a token walk but isolate it to one helper inside directives.rs.
Strengthening on shape mismatch
Where today's parsers continue (silently drop), the new parser returns syn::Error::new(span, "...") with a span pointing at the offending token:
| Input shape |
Today |
After |
#[contract(feeds)] (no =) |
silent drop |
error: expected `feeds = "<TypeName>"` |
#[contract(feeds = X)] (not a string lit) |
silent drop |
error: same as above |
#[contract(feeds = "<unparseable type>")] |
silent drop |
error: feeds type `...` is not a valid Rust type |
#[contract(expose = (a, b))] (parens) |
silent drop |
error: expected `expose = [m1, m2, ...]` |
#[contract(emits = [(BadShape)])] |
tuple silently dropped |
error pointing at the bad tuple |
#[contract(no_events)] (mistyped) |
substring scan misses; validator fires |
error: unknown contract directive `no_events`; expected one of: feeds, expose, emits, no_event |
#[contract(emit = ...)] (singular) |
silent drop |
error: unknown contract directive `emit`; did you mean `emits`? (best-effort) |
The "unknown directive" error is the highest-leverage diagnostic — it catches every typo in one rule.
The outer "this attribute is not #[contract(...)]" gate keeps continue-ing — methods carry unrelated attributes (#[doc], #[cfg], #[allow]) which must pass through.
Tests
Unit tests in parse/directives.rs#tests covering:
- Each valid shape parses to the expected
ContractDirectives.
- Combined directives in one
#[contract(...)] invocation parse correctly.
- Each shape-mismatch case from the table produces a
syn::Error whose message matches a stable substring.
- The "not a contract attr" gate continues over unrelated attributes.
End-to-end coverage (compile-fail fixtures pinning the user-facing compile_error! rendering) is a separate follow-up.
Constraints
- No change to the macro's public API. Same valid input produces the same output.
- Strengthening is observable for malformed input. Inputs that today silently produce no-op behavior will now error at compile time — that's intended; those inputs were already invalid in spirit. Document this in the PR description.
- No changes to other modules' logic — only to call sites that read directives.
Problem
#[contract(feeds = ..., expose = [...], emits = [...], no_event)]accepts four keyword directives. Today they're parsed by four independent hand-rolledTokenTreewalks, each starting with the same boilerplate (attr.path().is_ident("contract"),attr.meta.require_list(), walk tokens) then forking. After the recent parse-layer consolidation (#23) the four parsers now sit together incontract-macro/src/parse/directives.rs, but they are still independent walks with inconsistent failure modes:extract_feeds_attribute(feeds)continueon shape mismatch — silently ignoredevent_suppressed(no_event)tokens.to_string().contains("no_event")— substring scan, would match the literal text inside any valueexpose_listcontinueon shape mismatch — methods silently unexposedemits_list(+ tuple helpers)Three structural problems compound:
#[contract(emit = ...)]singular,expose = (a, b)with parens, malformedemitstuple) does nothing and gives no diagnostic. The validator may then fire downstream errors that don't point at the real cause.event_suppressedis a substring scan over the stringified tokens; the other three walk theTokenTreestream. The substring scan is the most fragile — it would matchno_eventappearing inside any other value.Proposed Solution
Replace the four parsers with one typed pass over the
#[contract(...)]argument list, returning aContractDirectives { feeds, expose, emits, no_event }struct, withcompile_error!diagnostics for shape mismatches.Each call site that today reaches for one of the four parsers instead reads its field off the result of
parse_contract_directives. Method-, impl-, and trait-impl-level all use the same parser.Use
syn::Metaparsing where it cleanly applies (syn::parse2::<Meta>,Meta::list().parse_nested_meta) instead of hand-rolledTokenTreewalks. Where the input shape doesn't fitMetacleanly (theemits = [(TOPIC, EventType), ...]tuple list is the awkward one), keep a token walk but isolate it to one helper insidedirectives.rs.Strengthening on shape mismatch
Where today's parsers
continue(silently drop), the new parser returnssyn::Error::new(span, "...")with a span pointing at the offending token:#[contract(feeds)](no=)expected `feeds = "<TypeName>"`#[contract(feeds = X)](not a string lit)#[contract(feeds = "<unparseable type>")]feeds type `...` is not a valid Rust type#[contract(expose = (a, b))](parens)expected `expose = [m1, m2, ...]`#[contract(emits = [(BadShape)])]#[contract(no_events)](mistyped)unknown contract directive `no_events`; expected one of: feeds, expose, emits, no_event#[contract(emit = ...)](singular)unknown contract directive `emit`; did you mean `emits`?(best-effort)The "unknown directive" error is the highest-leverage diagnostic — it catches every typo in one rule.
The outer "this attribute is not
#[contract(...)]" gate keepscontinue-ing — methods carry unrelated attributes (#[doc],#[cfg],#[allow]) which must pass through.Tests
Unit tests in
parse/directives.rs#testscovering:ContractDirectives.#[contract(...)]invocation parse correctly.syn::Errorwhose message matches a stable substring.End-to-end coverage (compile-fail fixtures pinning the user-facing
compile_error!rendering) is a separate follow-up.Constraints