Skip to content

Unify #[contract(...)] directive parsing into a single typed pass #24

@moCello

Description

@moCello

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:

  1. Adding a fifth keyword duplicates the boilerplate again. Tight coupling between attribute syntax and extraction logic makes changes fragile.
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions