Skip to main content

embassy_supervisor_syntax/
lib.rs

1#![deny(missing_docs)]
2
3//! Parser and AST for the [`embassy-supervisor`](https://docs.rs/embassy-supervisor)
4//! task-graph DSL.
5//!
6//! This crate exists because a `proc-macro` crate type cannot export anything
7//! other than macros, so the grammar shared between `embassy-supervisor-macros`
8//! and tooling lives here instead. The AST is an internal contract: it is not
9//! a stable API and changes whenever the graph syntax does.
10//!
11//! The parser checks grammatical shape (empty clauses, repeated paths, numeric
12//! ranges, and so on). Semantic checks such as duplicate names, missing
13//! fragments, or feature-gated constructs are left to callers.
14
15use proc_macro2::TokenStream as TokenStream2;
16use quote::quote;
17use syn::parse::{Parse, ParseStream};
18use syn::punctuated::Punctuated;
19use syn::{Attribute, Expr, Ident, LitInt, Meta, Result as SynResult, Token, Type, bracketed};
20
21/// Custom keywords used by the supervisor graph DSL.
22///
23/// These are `syn` keyword tokens for every named clause and marker in a
24/// `supervisor_graph!` / `supervisor_fragment!` / `compose_graph!` body.
25#[allow(missing_docs)]
26pub mod kw {
27    syn::custom_keyword!(node);
28    syn::custom_keyword!(pool);
29    syn::custom_keyword!(deps);
30    syn::custom_keyword!(discover);
31    syn::custom_keyword!(dataflow);
32    syn::custom_keyword!(spawn);
33    syn::custom_keyword!(task);
34    syn::custom_keyword!(pool_size);
35    syn::custom_keyword!(policy);
36    syn::custom_keyword!(min);
37    syn::custom_keyword!(max);
38    syn::custom_keyword!(disabled);
39    syn::custom_keyword!(executor);
40    syn::custom_keyword!(resources);
41    syn::custom_keyword!(provides);
42    syn::custom_keyword!(slot_timeout);
43    syn::custom_keyword!(ack_timeout);
44    syn::custom_keyword!(beat_timeout);
45    syn::custom_keyword!(beat_window);
46    syn::custom_keyword!(reads);
47    syn::custom_keyword!(writes);
48    syn::custom_keyword!(observe);
49    syn::custom_keyword!(ready_on_write);
50    syn::custom_keyword!(exit);
51    syn::custom_keyword!(name);
52    syn::custom_keyword!(state);
53    syn::custom_keyword!(zeroed);
54    syn::custom_keyword!(cancel);
55    syn::custom_keyword!(fragment);
56    syn::custom_keyword!(endfragment);
57}
58
59/// How a node's `state:` initial value is specified.
60#[allow(clippy::large_enum_variant)]
61#[derive(Clone)]
62pub enum StateInit {
63    /// An explicit expression, e.g. `state: Type = expr;`.
64    Expr(Expr),
65    /// The `zeroed` shorthand, e.g. `state: Type zeroed;`.
66    Zeroed(kw::zeroed),
67}
68
69/// A single entry in a `deps: [ ... ]` list.
70#[derive(Clone)]
71pub struct Dep {
72    /// `#[cfg(...)]` attributes attached to this dep.
73    pub cfg: Vec<Attribute>,
74    /// The name of the dependency node or pool.
75    pub ident: Ident,
76    /// Present when the dep is marked `ready`.
77    pub ready: Option<Ident>,
78    /// Present when the dep is marked `bound`.
79    pub bound: Option<Ident>,
80}
81
82/// Parse a bracketed dependency list of the form `[A, B ready, C ready bound]`.
83pub fn parse_dep_list(input: ParseStream) -> SynResult<Vec<Dep>> {
84    let content;
85    bracketed!(content in input);
86    let mut deps = Vec::new();
87    while !content.is_empty() {
88        let cfg = content.call(Attribute::parse_outer)?;
89        let ident: Ident = content.parse()?;
90        let mut ready: Option<Ident> = None;
91        let mut bound: Option<Ident> = None;
92        while content.peek(Ident) {
93            let marker: Ident = content.parse()?;
94            let slot = match () {
95                _ if marker == "ready" => &mut ready,
96                _ if marker == "bound" => &mut bound,
97                _ => {
98                    return Err(syn::Error::new_spanned(
99                        &marker,
100                        format!(
101                            "expected `,`, `]`, or a dep marker (`ready`, \
102                             `bound`), found `{marker}`"
103                        ),
104                    ));
105                }
106            };
107            if slot.is_some() {
108                return Err(syn::Error::new_spanned(
109                    &marker,
110                    format!("duplicate `{marker}` marker on this dep"),
111                ));
112            }
113            *slot = Some(marker);
114        }
115        if let (Some(b), None) = (&bound, &ready) {
116            return Err(syn::Error::new_spanned(
117                b,
118                "`bound` implies `ready` — write `deps: [X ready bound]`",
119            ));
120        }
121        deps.push(Dep {
122            cfg,
123            ident,
124            ready,
125            bound,
126        });
127        if content.peek(Token![,]) {
128            content.parse::<Token![,]>()?;
129        }
130    }
131    Ok(deps)
132}
133
134/// A single entry in a `reads:` or `writes:` signal list.
135#[derive(Clone)]
136pub struct SignalDecl {
137    /// `#[cfg(...)]` attributes attached to this entry.
138    pub cfg: Vec<Attribute>,
139    /// The path to the signal static.
140    pub path: syn::Path,
141    /// An array index, if the entry names one element (`SIGNAL[i]`).
142    pub index: Option<Expr>,
143    /// Present when the entry is marked `observed`.
144    pub observed: Option<Ident>,
145    /// Present when the entry is marked `beat`.
146    pub beat: Option<Ident>,
147    /// Present when the entry is marked `veto`: this writer holds one
148    /// contributor slot of a `VetoGate`.
149    pub veto: Option<Ident>,
150    /// The accessor expression supplied by `observed via <expr>`.
151    pub via: Option<Expr>,
152}
153
154impl SignalDecl {
155    /// Return the token stream that names the signal target, including any index.
156    pub fn target(&self) -> TokenStream2 {
157        let path = &self.path;
158        match &self.index {
159            Some(i) => quote!(#path[#i]),
160            None => quote!(#path),
161        }
162    }
163
164    /// Return a canonical string representation of the signal path.
165    pub fn display(&self) -> String {
166        let mut out = path_to_string(&self.path);
167        if let Some(i) = &self.index {
168            out.push('[');
169            out.push_str(&quote!(#i).to_string().replace(' ', ""));
170            out.push(']');
171        }
172        out
173    }
174}
175
176/// Parse a bracketed signal list such as `[S, T observed, U beat]`.
177pub fn parse_signal_list(input: ParseStream) -> SynResult<Vec<SignalDecl>> {
178    let content;
179    bracketed!(content in input);
180    let mut decls = Vec::new();
181    while !content.is_empty() {
182        let cfg = content.call(Attribute::parse_outer)?;
183        let path: syn::Path = content.parse()?;
184        let index = if content.peek(syn::token::Bracket) {
185            let idx;
186            bracketed!(idx in content);
187            Some(idx.parse::<Expr>()?)
188        } else {
189            None
190        };
191        let mut observed = None;
192        let mut beat = None;
193        let mut veto = None;
194        let mut via = None;
195        // Markers in any order; `via <expr>` qualifies `observed`, follows it,
196        // and ends the entry (an expression has no marker after it).
197        while content.peek(Ident) {
198            let marker: Ident = content.parse()?;
199            let slot = match marker.to_string().as_str() {
200                "observed" => &mut observed,
201                "beat" => &mut beat,
202                "veto" => &mut veto,
203                "via" => {
204                    if observed.is_none() {
205                        return Err(syn::Error::new_spanned(
206                            &marker,
207                            "`via` supplies the accessor an `observed` entry polls, \
208                             which only an `observed` entry has: write `observed via \
209                             <expr>`. `beat` only ever qualifies `observed`, and a \
210                             heartbeat the body can state is stated by its verb",
211                        ));
212                    }
213                    via = Some(content.parse::<Expr>()?);
214                    break;
215                }
216                other => {
217                    return Err(syn::Error::new_spanned(
218                        &marker,
219                        format!(
220                            "expected `,`, `]`, or the `observed`/`beat`/`veto` markers, \
221                             found `{other}`"
222                        ),
223                    ));
224                }
225            };
226            if slot.is_some() {
227                return Err(syn::Error::new_spanned(
228                    &marker,
229                    format!("duplicate `{marker}` marker"),
230                ));
231            }
232            *slot = Some(marker);
233        }
234        if let (Some(b), None) = (&beat, &observed) {
235            return Err(syn::Error::new_spanned(
236                b,
237                "a bare `beat` entry is not a declaration: write the heartbeat \
238                 at the site that produces it, with `node.beat_put(&SIG, v)` / \
239                 `node.beat_writer(&SIG)` or a `node.beat()` call in the body. \
240                 `observed beat` is the form for a body the supervisor cannot \
241                 see",
242            ));
243        }
244        decls.push(SignalDecl {
245            cfg,
246            path,
247            index,
248            observed,
249            beat,
250            veto,
251            via,
252        });
253        if !content.is_empty() {
254            content.parse::<Token![,]>()?;
255        }
256    }
257    Ok(decls)
258}
259
260/// Check that a `reads:` or `writes:` list is non-empty and has no duplicates.
261///
262/// `clause` is the clause name (`"reads"` or `"writes"`) used in diagnostics.
263pub fn check_signal_list<T: quote::ToTokens>(
264    tok: &T,
265    clause: &str,
266    decls: &[SignalDecl],
267) -> SynResult<()> {
268    if decls.is_empty() {
269        return Err(syn::Error::new_spanned(
270            tok,
271            format!(
272                "`{clause}:` must declare at least one signal path — omit the \
273                 clause entirely to declare nothing"
274            ),
275        ));
276    }
277    for (i, decl) in decls.iter().enumerate() {
278        let name = decl.display();
279        if decls[..i].iter().any(|prev| prev.display() == name) {
280            return Err(syn::Error::new_spanned(
281                &decl.path,
282                format!("duplicate `{clause}:` entry `{name}`"),
283            ));
284        }
285        let bare = path_to_string(&decl.path);
286        if decls[..i].iter().any(|prev| {
287            path_to_string(&prev.path) == bare && prev.index.is_some() != decl.index.is_some()
288        }) {
289            return Err(syn::Error::new_spanned(
290                &decl.path,
291                format!(
292                    "`{bare}` is declared both as a whole array and by element — \
293                     pick one. An element-0 reference has the same address as the \
294                     array, so nothing downstream can tell them apart"
295                ),
296            ));
297        }
298    }
299    Ok(())
300}
301
302/// Render a `syn::Path` as its `::`-separated string form.
303pub fn path_to_string(p: &syn::Path) -> String {
304    let mut out = String::new();
305    if p.leading_colon.is_some() {
306        out.push_str("::");
307    }
308    for (i, seg) in p.segments.iter().enumerate() {
309        if i > 0 {
310            out.push_str("::");
311        }
312        out.push_str(&seg.ident.to_string());
313    }
314    out
315}
316
317/// Parse a bracketed list of pool member mode identifiers.
318pub fn parse_mode_list(input: ParseStream) -> SynResult<Vec<Ident>> {
319    let content;
320    bracketed!(content in input);
321    let punct = Punctuated::<Ident, Token![,]>::parse_terminated(&content)?;
322    Ok(punct.into_iter().collect())
323}
324
325/// How a node or pool member obtains its task future.
326#[derive(Clone)]
327pub enum TaskSource {
328    /// A hand-written `#[embassy_executor::task]` fn referenced by `spawn:`.
329    Spawn(Expr),
330    /// A worker fn wrapped by a generated shell via `task:`.
331    Shell(Expr),
332}
333
334/// A single entry in a `resources: [ ... ]` list.
335#[derive(Clone)]
336pub struct ResourceDecl {
337    /// `#[cfg(...)]` attributes attached to this resource.
338    pub cfg: Vec<Attribute>,
339    /// The resource slot identifier.
340    pub ident: Ident,
341    /// The Rust type stored in the slot. `None` only for a `divisible` entry,
342    /// whose slot is a `Budget<K>` the graph sizes itself.
343    pub ty: Option<Type>,
344    /// Present when the resource is marked `local`.
345    pub local: Option<Ident>,
346    /// Present when the resource is marked `consume`.
347    pub consume: Option<Ident>,
348    /// Present when the resource is marked `shared`.
349    pub shared: Option<Ident>,
350    /// Present when the resource is marked `divisible`.
351    pub divisible: Option<Ident>,
352    /// Present when the resource is marked `serialized` (only beside `shared`).
353    pub serialized: Option<Ident>,
354}
355
356/// How a `resources:` entry hands its value to the task, as one value the
357/// consumers branch on instead of probing the marker fields.
358#[derive(Clone, Copy, Debug, PartialEq, Eq)]
359pub enum ResourceKind {
360    /// The default: taken out of the slot, lent to the worker as `&mut`, and
361    /// restored after it returns.
362    Lend,
363    /// `consume`: taken out by value; the slot stays empty after the task exits.
364    Consume,
365    /// `shared`: a `Copy` handle copied out; the slot stays filled.
366    Shared,
367    /// `divisible`: a budget of units the holder claims from, with one slot per
368    /// declaring node or pool member.
369    Divisible,
370}
371
372impl ResourceDecl {
373    /// The entry's kind, from its markers.
374    pub fn kind(&self) -> ResourceKind {
375        if self.divisible.is_some() {
376            ResourceKind::Divisible
377        } else if self.shared.is_some() {
378            ResourceKind::Shared
379        } else if self.consume.is_some() {
380            ResourceKind::Consume
381        } else {
382            ResourceKind::Lend
383        }
384    }
385
386    /// Return a human-readable signature string for the resource kind.
387    pub fn shared_signature(&self) -> String {
388        let ty = &self.ty;
389        format!(
390            "{}{}shared {}",
391            if self.local.is_some() { "local " } else { "" },
392            if self.serialized.is_some() {
393                "serialized "
394            } else {
395                ""
396            },
397            quote!(#ty)
398        )
399    }
400}
401
402const KIND_MARKERS: [&str; 5] = ["local", "consume", "shared", "divisible", "serialized"];
403
404/// Look ahead for a resource kind marker (`local`, `consume`, `shared`,
405/// `divisible`, or `serialized`).
406///
407/// Returns `None` for ordinary identifiers that happen to share a name with a
408/// marker, using the following token to decide.
409pub fn peek_kind_marker(content: ParseStream) -> Option<Ident> {
410    if !content.peek(syn::Ident) {
411        return None;
412    }
413    let fork = content.fork();
414    let ident: Ident = fork.parse().ok()?;
415    if !KIND_MARKERS.iter().any(|m| ident == m) {
416        return None;
417    }
418    // `divisible` is the one marker with no type behind it, so the end of the
419    // entry is where it is expected; a type by that name needs a path.
420    if ident == "divisible" {
421        return (!fork.peek(Token![::]) && !fork.peek(Token![<])).then_some(ident);
422    }
423    if fork.is_empty() || fork.peek(Token![,]) || fork.peek(Token![::]) || fork.peek(Token![<]) {
424        return None;
425    }
426    Some(ident)
427}
428
429/// Parse a bracketed resource list such as `[SLOT: Type local shared]`.
430pub fn parse_resource_list(input: ParseStream) -> SynResult<Vec<ResourceDecl>> {
431    let content;
432    bracketed!(content in input);
433    let mut resources = Vec::new();
434    while !content.is_empty() {
435        let cfg = content.call(Attribute::parse_outer)?;
436        let ident: Ident = content.parse()?;
437        content.parse::<Token![:]>()?;
438        let mut markers: [Option<Ident>; 5] = Default::default();
439        while let Some(marker) = peek_kind_marker(&content) {
440            content.parse::<Ident>()?;
441            let i = KIND_MARKERS
442                .iter()
443                .position(|m| marker == m)
444                .expect("peeked a kind marker");
445            if markers[i].is_some() {
446                return Err(syn::Error::new_spanned(
447                    &marker,
448                    format!("duplicate `{marker}` marker"),
449                ));
450            }
451            markers[i] = Some(marker);
452        }
453        let [local, consume, shared, divisible, serialized] = markers;
454        if let (Some(_), Some(s)) = (&consume, &shared) {
455            return Err(syn::Error::new_spanned(
456                s,
457                "`consume` and `shared` are mutually exclusive — `consume` takes \
458                 the single value out for one owner, `shared` copies it out to \
459                 any number of consumers",
460            ));
461        }
462        if let Some(d) = &divisible
463            && let Some(other) = local.as_ref().or(consume.as_ref()).or(shared.as_ref())
464        {
465            return Err(syn::Error::new_spanned(
466                other,
467                format!(
468                    "`divisible` is its own kind and takes no other marker: `{other}` \
469                     describes how one value is handed over, `{d}` declares a \
470                     budget of units the holder claims a share of"
471                ),
472            ));
473        }
474        if let (Some(s), None) = (&serialized, &shared) {
475            return Err(syn::Error::new_spanned(
476                s,
477                "`serialized` only qualifies `shared` — a slot with a single holder \
478                 cannot be contended, so there is nothing to serialize",
479            ));
480        }
481        let ty = if let Some(d) = &divisible {
482            if !(content.is_empty() || content.peek(Token![,])) {
483                return Err(syn::Error::new_spanned(
484                    d,
485                    "`divisible` takes no type — the slot is a `Budget<K>` the graph \
486                     sizes to its declaring nodes and pool members",
487                ));
488            }
489            None
490        } else {
491            Some(content.parse::<Type>()?)
492        };
493        resources.push(ResourceDecl {
494            cfg,
495            ident,
496            ty,
497            local,
498            consume,
499            shared,
500            divisible,
501            serialized,
502        });
503        if content.peek(Token![,]) {
504            content.parse::<Token![,]>()?;
505        }
506    }
507    Ok(resources)
508}
509
510/// A function adopted via a `dataflow: [ ... ]` clause.
511#[derive(Clone)]
512pub struct AdoptedFn {
513    /// `#[cfg(...)]` attributes attached to this adoption.
514    pub cfg: Vec<Attribute>,
515    /// The path to the `#[dataflow]` fn whose accesses are adopted.
516    pub path: syn::Path,
517}
518
519/// A clause value together with the `#[cfg(...)]` attributes gating it.
520#[derive(Clone)]
521pub struct Gated<K, V = ()> {
522    /// `#[cfg(...)]` attributes gating this clause.
523    pub cfg: Vec<Attribute>,
524    /// The clause keyword token, for diagnostics.
525    pub kw: K,
526    /// The clause value.
527    pub value: V,
528}
529
530/// A single `provides:` entry: a resource-slot name, optionally `#[cfg]`-gated.
531#[derive(Clone)]
532pub struct ProvideDecl {
533    /// `#[cfg(...)]` attributes attached to this entry.
534    pub cfg: Vec<Attribute>,
535    /// The resource slot this item fills.
536    pub ident: Ident,
537}
538
539/// A parsed `node NAME = Mode, ...;` declaration.
540#[derive(Clone)]
541pub struct NodeItem {
542    /// `#[cfg(...)]` attributes attached to the node.
543    pub cfg: Vec<Attribute>,
544    /// The node identifier.
545    pub ident: Ident,
546    /// The lifecycle mode (`Terminate`, `Pause`, `OnDemand`, ...).
547    pub mode: Ident,
548    /// The node's `deps:` list.
549    pub deps: Vec<Dep>,
550    /// The task source, if any (`spawn:` or `task:`).
551    pub source: Option<TaskSource>,
552    /// The generated task pool size, if specified.
553    pub pool_size: Option<LitInt>,
554    /// The `resources:` list.
555    pub resources: Vec<ResourceDecl>,
556    /// The `disabled` marker, if present, with any `#[cfg(...)]` gate.
557    pub disabled: Option<Gated<kw::disabled>>,
558    /// Spawning executor: either an explicit `executor:` clause or the graph's
559    /// inherited default.
560    pub executor: Option<Ident>,
561    /// `true` if the executor was inherited from the default, not written.
562    pub executor_defaulted: bool,
563    /// The `slot_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
564    pub slot_timeout: Option<Gated<kw::slot_timeout, LitInt>>,
565    /// The `ack_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
566    pub ack_timeout: Option<Gated<kw::ack_timeout, LitInt>>,
567    /// The `beat_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
568    pub beat_timeout: Option<Gated<kw::beat_timeout, LitInt>>,
569    /// The `beat_window:` value, with any `#[cfg(...)]` gate.
570    pub beat_window: Option<Gated<kw::beat_window, LitInt>>,
571    /// The `ready_on_write` marker, if present, with any `#[cfg(...)]` gate.
572    pub ready_on_write: Option<Gated<Ident>>,
573    /// The `reads:` signal list.
574    pub reads: Vec<SignalDecl>,
575    /// The `writes:` signal list.
576    pub writes: Vec<SignalDecl>,
577    /// The `exit:` result type.
578    pub exit: Option<syn::Type>,
579    /// The `state:` declaration, if any.
580    pub state: Option<(kw::state, syn::Type, StateInit)>,
581    /// Whether the node is marked `cancel`.
582    pub cancel: bool,
583    /// The `discover` marker, if present, with any `#[cfg(...)]` gate.
584    pub discover: Option<Gated<kw::discover>>,
585    /// Functions adopted via `dataflow:`.
586    pub dataflow: Vec<AdoptedFn>,
587    /// Slots this node `provides:`, each optionally `#[cfg]`-gated.
588    pub provides: Vec<ProvideDecl>,
589    /// The `provides` keyword token, for diagnostics.
590    pub provides_kw: Option<kw::provides>,
591    /// The fragment name this node belongs to, if any.
592    pub fragment: Option<String>,
593}
594
595/// A parsed `executor NAME;` declaration.
596#[derive(Clone)]
597pub struct ExecutorItem {
598    /// `#[cfg(...)]` attributes attached to the executor.
599    pub cfg: Vec<Attribute>,
600    /// The executor identifier.
601    pub ident: Ident,
602    /// `true` if this is the graph's default executor, inherited by eligible
603    /// nodes and pools.
604    pub default: bool,
605}
606
607/// A parsed `pool NAME = [Mode, ...], ...;` declaration.
608#[derive(Clone)]
609pub struct PoolItem {
610    /// `#[cfg(...)]` attributes attached to the pool.
611    pub cfg: Vec<Attribute>,
612    /// The pool identifier.
613    pub ident: Ident,
614    /// The allowed member lifecycle modes.
615    pub modes: Vec<Ident>,
616    /// The pool's `deps:` list.
617    pub deps: Vec<Dep>,
618    /// The task source (`spawn:` or `task:`).
619    pub source: TaskSource,
620    /// The scaling policy expression.
621    pub policy: Expr,
622    /// The optional explicit scaling policy type.
623    pub policy_ty: Option<Type>,
624    /// Spawning executor: either an explicit `executor:` clause or the graph's
625    /// inherited default.
626    pub executor: Option<Ident>,
627    /// `true` if the executor was inherited from the default, not written.
628    pub executor_defaulted: bool,
629    /// The `resources:` list.
630    pub resources: Vec<ResourceDecl>,
631    /// The `slot_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
632    pub slot_timeout: Option<Gated<kw::slot_timeout, LitInt>>,
633    /// The `ack_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
634    pub ack_timeout: Option<Gated<kw::ack_timeout, LitInt>>,
635    /// The `reads:` signal list.
636    pub reads: Vec<SignalDecl>,
637    /// The `writes:` signal list.
638    pub writes: Vec<SignalDecl>,
639    /// The `min:` expression for elastic scaling.
640    pub min: Expr,
641    /// The `max:` expression for elastic scaling.
642    pub max: Expr,
643    /// The `state:` declaration, if any.
644    pub state: Option<(kw::state, syn::Type, StateInit)>,
645    /// Whether pool members are marked `cancel`.
646    pub cancel: bool,
647    /// The `discover` marker, if present, with any `#[cfg(...)]` gate.
648    pub discover: Option<Gated<kw::discover>>,
649    /// Functions adopted via `dataflow:`.
650    pub dataflow: Vec<AdoptedFn>,
651    /// The fragment name this pool belongs to, if any.
652    pub fragment: Option<String>,
653}
654
655/// A top-level item inside a graph declaration.
656#[allow(clippy::large_enum_variant)]
657#[derive(Clone)]
658pub enum Item {
659    /// A supervised node.
660    Node(NodeItem),
661    /// An elastic pool.
662    Pool(PoolItem),
663    /// A named executor.
664    Executor(ExecutorItem),
665}
666
667/// The parsed contents of a `supervisor_graph!` or `supervisor_fragment!` body.
668#[derive(Clone)]
669pub struct GraphSpec {
670    /// The optional `name:` identifier.
671    pub name: Option<Ident>,
672    /// The default `observe writes:` accessor expression.
673    pub observe_writes: Option<(kw::observe, Expr)>,
674    /// The default `observe reads:` accessor expression.
675    pub observe_reads: Option<(kw::observe, Expr)>,
676    /// The `default executor NAME;` declaration, if any. Already applied to
677    /// `items` by the parser (see [`apply_default_executor`]).
678    pub default_executor: Option<Ident>,
679    /// The nodes, pools, and executors declared in the graph.
680    pub items: Vec<Item>,
681}
682
683/// Apply the graph's default executor to nodes and pools that do not write
684/// their own `executor:` clause. Only items whose task source is a `task:` fn
685/// or a `spawn:` path/partial call are eligible; parked nodes and verbatim
686/// `spawn:` closures keep the supervisor's executor.
687pub fn apply_default_executor(items: &mut [Item], ex: &Ident) {
688    fn eligible(source: Option<&TaskSource>) -> bool {
689        match source {
690            Some(TaskSource::Shell(_)) => true,
691            Some(TaskSource::Spawn(e)) => matches!(e, Expr::Path(_) | Expr::Call(_)),
692            None => false,
693        }
694    }
695    for item in items {
696        match item {
697            Item::Node(n) if n.executor.is_none() && eligible(n.source.as_ref()) => {
698                n.executor = Some(ex.clone());
699                n.executor_defaulted = true;
700            }
701            Item::Pool(p) if p.executor.is_none() && eligible(Some(&p.source)) => {
702                p.executor = Some(ex.clone());
703                p.executor_defaulted = true;
704            }
705            _ => {}
706        }
707    }
708}
709
710/// Return the resource declarations for an item, if any.
711pub fn item_resources(item: &Item) -> &[ResourceDecl] {
712    match item {
713        Item::Node(n) => &n.resources,
714        Item::Pool(p) => &p.resources,
715        Item::Executor(_) => &[],
716    }
717}
718
719/// Return the `executor:` an item routes through, if it names one.
720pub fn item_executor(item: &Item) -> Option<&Ident> {
721    match item {
722        Item::Node(n) => n.executor.as_ref(),
723        Item::Pool(p) => p.executor.as_ref(),
724        Item::Executor(_) => None,
725    }
726}
727
728/// Iterate over every signal entry declared by an item.
729///
730/// Yields `(is_write, decl)` pairs, where `is_write` is `true` for writes and
731/// `false` for reads.
732pub fn item_signal_entries(item: &Item) -> impl Iterator<Item = (bool, &SignalDecl)> {
733    let (reads, writes) = match item {
734        Item::Node(n) => (&n.reads[..], &n.writes[..]),
735        Item::Pool(p) => (&p.reads[..], &p.writes[..]),
736        Item::Executor(_) => (&[][..], &[][..]),
737    };
738    reads
739        .iter()
740        .map(|d| (false, d))
741        .chain(writes.iter().map(|d| (true, d)))
742}
743
744/// Return the identifying name and `#[cfg]` attributes of an item, if it has one.
745pub fn item_ident_cfg(item: &Item) -> Option<(&Ident, &[Attribute])> {
746    match item {
747        Item::Node(n) => Some((&n.ident, &n.cfg)),
748        Item::Pool(p) => Some((&p.ident, &p.cfg)),
749        Item::Executor(_) => None,
750    }
751}
752
753impl Parse for GraphSpec {
754    fn parse(input: ParseStream) -> SynResult<Self> {
755        let name = if input.peek(kw::name) && input.peek2(Token![:]) {
756            input.parse::<kw::name>()?;
757            input.parse::<Token![:]>()?;
758            let n: Ident = input.parse()?;
759            input.parse::<Token![;]>()?;
760            Some(n)
761        } else {
762            None
763        };
764        let mut observe_writes: Option<(kw::observe, Expr)> = None;
765        let mut observe_reads: Option<(kw::observe, Expr)> = None;
766        let mut items = Vec::new();
767        let mut default_executor: Option<Ident> = None;
768        let mut current_fragment: Option<String> = None;
769        while !input.is_empty() {
770            if input.peek(Token![@]) {
771                input.parse::<Token![@]>()?;
772                if input.peek(kw::fragment) {
773                    input.parse::<kw::fragment>()?;
774                    current_fragment = Some(input.parse::<Ident>()?.to_string());
775                } else if input.peek(kw::endfragment) {
776                    input.parse::<kw::endfragment>()?;
777                    current_fragment = None;
778                } else {
779                    return Err(input.error("expected `@fragment NAME;` or `@endfragment;`"));
780                }
781                input.parse::<Token![;]>()?;
782                continue;
783            }
784            if input.peek(kw::observe) {
785                let k = input.parse::<kw::observe>()?;
786                let (slot, dir) = if input.peek(kw::writes) {
787                    input.parse::<kw::writes>()?;
788                    (&mut observe_writes, "writes")
789                } else if input.peek(kw::reads) {
790                    input.parse::<kw::reads>()?;
791                    (&mut observe_reads, "reads")
792                } else {
793                    return Err(input.error("expected `observe writes:` or `observe reads:`"));
794                };
795                if slot.is_some() {
796                    return Err(syn::Error::new_spanned(
797                        k,
798                        format!("duplicate `observe {dir}:` default"),
799                    ));
800                }
801                input.parse::<Token![:]>()?;
802                *slot = Some((k, input.parse::<Expr>()?));
803                input.parse::<Token![;]>()?;
804                continue;
805            }
806            let cfg = input.call(Attribute::parse_outer)?;
807            if input.peek(kw::node) {
808                let mut n = parse_node(input, cfg)?;
809                n.fragment = current_fragment.clone();
810                items.push(Item::Node(n));
811            } else if input.peek(kw::pool) {
812                let mut p = parse_pool(input, cfg)?;
813                p.fragment = current_fragment.clone();
814                items.push(Item::Pool(p));
815            } else if input.peek(kw::executor) {
816                input.parse::<kw::executor>()?;
817                let ident: Ident = input.parse()?;
818                input.parse::<Token![;]>()?;
819                items.push(Item::Executor(ExecutorItem {
820                    cfg,
821                    ident,
822                    default: false,
823                }));
824            } else if input.peek(Token![default]) && input.peek2(kw::executor) {
825                let k = input.parse::<Token![default]>()?;
826                input.parse::<kw::executor>()?;
827                let ident: Ident = input.parse()?;
828                input.parse::<Token![;]>()?;
829                if let Some(attr) = cfg.first() {
830                    return Err(syn::Error::new_spanned(
831                        attr,
832                        "a `default executor` cannot be `#[cfg]`-gated — every \
833                         inheriting node would reference its slot unconditionally; \
834                         gate the nodes instead",
835                    ));
836                }
837                if current_fragment.is_some() {
838                    return Err(syn::Error::new_spanned(
839                        k,
840                        "a fragment cannot declare the graph's default executor; \
841                         declare it at the compose site",
842                    ));
843                }
844                if default_executor.is_some() {
845                    return Err(syn::Error::new_spanned(k, "duplicate `default executor`"));
846                }
847                default_executor = Some(ident.clone());
848                items.push(Item::Executor(ExecutorItem {
849                    cfg,
850                    ident,
851                    default: true,
852                }));
853            } else {
854                return Err(input.error(
855                    "expected `node`, `pool`, `executor`, `default executor`, or \
856                     `observe` (optionally `#[cfg(...)]`-prefixed)",
857                ));
858            }
859        }
860        if let Some(ex) = &default_executor {
861            apply_default_executor(&mut items, ex);
862        }
863        Ok(GraphSpec {
864            name,
865            observe_writes,
866            observe_reads,
867            default_executor,
868            items,
869        })
870    }
871}
872
873/// Clauses shared between `node` and `pool` declarations.
874///
875/// This is a mutable accumulator used while parsing comma-separated clauses.
876#[derive(Clone, Default)]
877pub struct CommonClauses {
878    /// The named executor, if `executor:` was given.
879    pub executor: Option<Ident>,
880    /// The `spawn:` expression, if any.
881    pub spawn: Option<Expr>,
882    /// The `task:` expression, if any.
883    pub task: Option<(kw::task, Expr)>,
884    /// The `resources:` list, if any.
885    pub resources: Option<(kw::resources, Vec<ResourceDecl>)>,
886    /// The `reads:` signal list.
887    pub reads: Vec<SignalDecl>,
888    /// The `writes:` signal list.
889    pub writes: Vec<SignalDecl>,
890    /// The `state:` declaration, if any.
891    pub state: Option<(kw::state, syn::Type, StateInit)>,
892    /// The `slot_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
893    pub slot_timeout: Option<Gated<kw::slot_timeout, LitInt>>,
894    /// The `ack_timeout:` value in milliseconds, with any `#[cfg(...)]` gate.
895    pub ack_timeout: Option<Gated<kw::ack_timeout, LitInt>>,
896    /// Present when the item is marked `cancel`.
897    pub cancel: Option<kw::cancel>,
898    /// The `discover` marker, if present, with any `#[cfg(...)]` gate.
899    pub discover: Option<Gated<kw::discover>>,
900    /// The `dataflow:` adoption list, if any.
901    pub dataflow: Option<(kw::dataflow, Vec<AdoptedFn>)>,
902}
903
904const COMMON_CLAUSE_NAMES: &str = "`task:`, `spawn:`, `executor:`, `resources:`, \
905     `reads:`, `writes:`, `discover`, `dataflow:`, `state:`, `slot_timeout:`, \
906     `ack_timeout:`, `cancel`";
907
908fn dup_clause<T: quote::ToTokens>(tok: &T, name: &str) -> syn::Error {
909    syn::Error::new_spanned(
910        tok,
911        format!("duplicate `{name}:` clause — one declaration is the contract"),
912    )
913}
914
915/// The `#[cfg]` attributes' token text, normalized for predicate comparison.
916pub fn cfg_text(attrs: &[Attribute]) -> String {
917    attrs
918        .iter()
919        .map(|a| {
920            quote::ToTokens::to_token_stream(a)
921                .to_string()
922                .replace(' ', "")
923        })
924        .collect::<Vec<_>>()
925        .join(",")
926}
927
928#[derive(Clone, Copy)]
929enum ClauseHost {
930    Node,
931    Pool,
932}
933
934fn require_cfg_list(a: &Attribute, what: &str) -> SynResult<()> {
935    match &a.meta {
936        Meta::List(l) if l.path.is_ident("cfg") => Ok(()),
937        _ => Err(syn::Error::new_spanned(
938            a,
939            format!("only `#[cfg(...)]` attributes may gate a {what}"),
940        )),
941    }
942}
943
944/// Parse an optional run of `#[cfg(...)]` attributes gating the NEXT clause.
945fn parse_clause_cfg(input: ParseStream, host: ClauseHost) -> SynResult<Vec<Attribute>> {
946    if !input.peek(Token![#]) {
947        return Ok(Vec::new());
948    }
949    let attrs = input.call(Attribute::parse_outer)?;
950    for a in &attrs {
951        require_cfg_list(a, "clause")?;
952    }
953    let common =
954        input.peek(kw::slot_timeout) || input.peek(kw::ack_timeout) || input.peek(kw::discover);
955    let gateable = match host {
956        ClauseHost::Node => {
957            common
958                || input.peek(kw::beat_timeout)
959                || input.peek(kw::beat_window)
960                || input.peek(kw::ready_on_write)
961                || input.peek(kw::disabled)
962        }
963        ClauseHost::Pool => common,
964    };
965    if !gateable {
966        return Err(input.error(match host {
967            ClauseHost::Node => {
968                "`#[cfg(...)]` may only gate `slot_timeout:`, `ack_timeout:`, \
969                 `beat_timeout:`, `beat_window:`, `ready_on_write`, `disabled`, \
970                 or `discover` — gate the whole node, or a single entry inside \
971                 `deps:`/`resources:`/`reads:`/`writes:`/`dataflow:`/`provides:`, \
972                 for anything structural"
973            }
974            ClauseHost::Pool => {
975                "`#[cfg(...)]` may only gate `slot_timeout:`, `ack_timeout:`, or \
976                 `discover` — gate the whole pool, or a single entry inside \
977                 `deps:`/`resources:`/`reads:`/`writes:`/`dataflow:`, for \
978                 anything structural"
979            }
980        }));
981    }
982    Ok(attrs)
983}
984
985impl CommonClauses {
986    /// Parse the next common clause from `input` into `self`.
987    pub fn parse_one(
988        &mut self,
989        input: ParseStream,
990        clause_cfg: &mut Vec<Attribute>,
991    ) -> SynResult<bool> {
992        if input.peek(kw::spawn) {
993            let k = input.parse::<kw::spawn>()?;
994            input.parse::<Token![:]>()?;
995            if self.spawn.is_some() {
996                return Err(dup_clause(&k, "spawn"));
997            }
998            self.spawn = Some(input.parse::<Expr>()?);
999        } else if input.peek(kw::task) {
1000            let k = input.parse::<kw::task>()?;
1001            input.parse::<Token![:]>()?;
1002            if self.task.is_some() {
1003                return Err(dup_clause(&k, "task"));
1004            }
1005            self.task = Some((k, input.parse::<Expr>()?));
1006        } else if input.peek(kw::executor) {
1007            let k = input.parse::<kw::executor>()?;
1008            input.parse::<Token![:]>()?;
1009            if self.executor.is_some() {
1010                return Err(dup_clause(&k, "executor"));
1011            }
1012            self.executor = Some(input.parse::<Ident>()?);
1013        } else if input.peek(kw::resources) {
1014            let k = input.parse::<kw::resources>()?;
1015            input.parse::<Token![:]>()?;
1016            if self.resources.is_some() {
1017                return Err(dup_clause(&k, "resources"));
1018            }
1019            self.resources = Some((k, parse_resource_list(input)?));
1020        } else if input.peek(kw::reads) {
1021            let k = input.parse::<kw::reads>()?;
1022            input.parse::<Token![:]>()?;
1023            if !self.reads.is_empty() {
1024                return Err(dup_clause(&k, "reads"));
1025            }
1026            self.reads = parse_signal_list(input)?;
1027            check_signal_list(&k, "reads", &self.reads)?;
1028        } else if input.peek(kw::writes) {
1029            let k = input.parse::<kw::writes>()?;
1030            input.parse::<Token![:]>()?;
1031            if !self.writes.is_empty() {
1032                return Err(dup_clause(&k, "writes"));
1033            }
1034            self.writes = parse_signal_list(input)?;
1035            check_signal_list(&k, "writes", &self.writes)?;
1036        } else if input.peek(kw::state) {
1037            let k = input.parse::<kw::state>()?;
1038            input.parse::<Token![:]>()?;
1039            if self.state.is_some() {
1040                return Err(dup_clause(&k, "state"));
1041            }
1042            if input.peek(kw::zeroed) && !input.peek2(Token![=]) {
1043                let z = input.parse::<kw::zeroed>()?;
1044                let ty: syn::Type = input.parse()?;
1045                self.state = Some((k, ty, StateInit::Zeroed(z)));
1046            } else {
1047                let ty: syn::Type = input.parse()?;
1048                input.parse::<Token![=]>()?;
1049                let init: Expr = input.parse()?;
1050                self.state = Some((k, ty, StateInit::Expr(init)));
1051            }
1052        } else if input.peek(kw::slot_timeout) {
1053            let k = input.parse::<kw::slot_timeout>()?;
1054            input.parse::<Token![:]>()?;
1055            if self.slot_timeout.is_some() {
1056                return Err(dup_clause(&k, "slot_timeout"));
1057            }
1058            let st: LitInt = input.parse()?;
1059            if st.base10_parse::<u64>()? == 0 {
1060                return Err(syn::Error::new_spanned(
1061                    &st,
1062                    "`slot_timeout:` must be at least 1 (milliseconds)",
1063                ));
1064            }
1065            self.slot_timeout = Some(Gated {
1066                cfg: core::mem::take(clause_cfg),
1067                kw: k,
1068                value: st,
1069            });
1070        } else if input.peek(kw::ack_timeout) {
1071            let k = input.parse::<kw::ack_timeout>()?;
1072            input.parse::<Token![:]>()?;
1073            if self.ack_timeout.is_some() {
1074                return Err(dup_clause(&k, "ack_timeout"));
1075            }
1076            let at: LitInt = input.parse()?;
1077            if at.base10_parse::<u64>()? == 0 {
1078                return Err(syn::Error::new_spanned(
1079                    &at,
1080                    "`ack_timeout:` must be at least 1 (milliseconds)",
1081                ));
1082            }
1083            self.ack_timeout = Some(Gated {
1084                cfg: core::mem::take(clause_cfg),
1085                kw: k,
1086                value: at,
1087            });
1088        } else if input.peek(kw::discover) {
1089            let k = input.parse::<kw::discover>()?;
1090            if self.discover.is_some() {
1091                return Err(syn::Error::new_spanned(k, "duplicate `discover` marker"));
1092            }
1093            if input.peek(Token![:]) {
1094                return Err(syn::Error::new_spanned(
1095                    k,
1096                    "`discover` takes no argument — the tables come from the \
1097                     task fn's `#[dataflow]` attribute, sized by its scan",
1098                ));
1099            }
1100            self.discover = Some(Gated {
1101                cfg: core::mem::take(clause_cfg),
1102                kw: k,
1103                value: (),
1104            });
1105        } else if input.peek(kw::dataflow) {
1106            let k = input.parse::<kw::dataflow>()?;
1107            input.parse::<Token![:]>()?;
1108            if self.dataflow.is_some() {
1109                return Err(dup_clause(&k, "dataflow"));
1110            }
1111            let content;
1112            bracketed!(content in input);
1113            let mut fns: Vec<AdoptedFn> = Vec::new();
1114            while !content.is_empty() {
1115                let cfg = content.call(Attribute::parse_outer)?;
1116                let path: syn::Path = content.parse()?;
1117                if fns
1118                    .iter()
1119                    .any(|f| tokens_text(&f.path) == tokens_text(&path))
1120                {
1121                    return Err(syn::Error::new_spanned(
1122                        &path,
1123                        "duplicate `dataflow:` fn — one adoption binds its tables",
1124                    ));
1125                }
1126                fns.push(AdoptedFn { cfg, path });
1127                if content.peek(Token![,]) {
1128                    content.parse::<Token![,]>()?;
1129                }
1130            }
1131            if fns.is_empty() {
1132                return Err(syn::Error::new_spanned(
1133                    k,
1134                    "`dataflow:` must name at least one `#[dataflow]` fn — omit \
1135                     the clause entirely to adopt nothing",
1136                ));
1137            }
1138            self.dataflow = Some((k, fns));
1139        } else if input.peek(kw::cancel) {
1140            let k = input.parse::<kw::cancel>()?;
1141            if self.cancel.is_some() {
1142                return Err(syn::Error::new_spanned(k, "duplicate `cancel` marker"));
1143            }
1144            self.cancel = Some(k);
1145        } else {
1146            return Ok(false);
1147        }
1148        Ok(true)
1149    }
1150}
1151
1152/// Resolve the optional `spawn:` / `task:` clauses into a [`TaskSource`].
1153///
1154/// Returns an error if both clauses are present.
1155pub fn task_source(
1156    spawn: Option<Expr>,
1157    task: Option<(kw::task, Expr)>,
1158) -> SynResult<Option<TaskSource>> {
1159    if let (Some(_), Some((k, _))) = (&spawn, &task) {
1160        return Err(syn::Error::new_spanned(
1161            k,
1162            "`task:` and `spawn:` are mutually exclusive — `spawn:` names a \
1163             hand-written `#[embassy_executor::task]` fn, `task:` generates one",
1164        ));
1165    }
1166    Ok(match (spawn, task) {
1167        (Some(e), _) => Some(TaskSource::Spawn(e)),
1168        (None, Some((_, e))) => Some(TaskSource::Shell(e)),
1169        (None, None) => None,
1170    })
1171}
1172
1173/// Parse a `node NAME = Mode, ...;` declaration after the leading `node` keyword.
1174pub fn parse_node(input: ParseStream, cfg: Vec<Attribute>) -> SynResult<NodeItem> {
1175    input.parse::<kw::node>()?;
1176    let ident: Ident = input.parse()?;
1177    input.parse::<Token![=]>()?;
1178    let mode: Ident = input.parse()?;
1179    let mut deps: Option<Vec<Dep>> = None;
1180
1181    let mut common = CommonClauses::default();
1182    let mut pool_size = None;
1183    let mut disabled: Option<Gated<kw::disabled>> = None;
1184    let mut beat_timeout = None;
1185    let mut beat_window = None;
1186    let mut ready_on_write: Option<Gated<Ident>> = None;
1187    let mut exit: Option<(kw::exit, syn::Type)> = None;
1188    let mut provides: Vec<ProvideDecl> = Vec::new();
1189    let mut provides_kw: Option<kw::provides> = None;
1190    while input.peek(Token![,]) {
1191        input.parse::<Token![,]>()?;
1192        let mut clause_cfg = parse_clause_cfg(input, ClauseHost::Node)?;
1193        if common.parse_one(input, &mut clause_cfg)? {
1194            continue;
1195        }
1196        if input.peek(kw::deps) {
1197            let k = input.parse::<kw::deps>()?;
1198            input.parse::<Token![:]>()?;
1199            if deps.is_some() {
1200                return Err(syn::Error::new_spanned(
1201                    k,
1202                    "duplicate `deps:` clause — one list declares them all",
1203                ));
1204            }
1205            deps = Some(parse_dep_list(input)?);
1206        } else if input.peek(kw::pool_size) {
1207            input.parse::<kw::pool_size>()?;
1208            input.parse::<Token![:]>()?;
1209            pool_size = Some(input.parse::<LitInt>()?);
1210        } else if input.peek(kw::disabled) {
1211            let k = input.parse::<kw::disabled>()?;
1212            if disabled.is_some() {
1213                return Err(syn::Error::new_spanned(k, "duplicate `disabled` marker"));
1214            }
1215            disabled = Some(Gated {
1216                cfg: clause_cfg,
1217                kw: k,
1218                value: (),
1219            });
1220        } else if input.peek(kw::ready_on_write) {
1221            let k = input.parse::<kw::ready_on_write>()?;
1222            if ready_on_write.is_some() {
1223                return Err(syn::Error::new_spanned(
1224                    k,
1225                    "duplicate `ready_on_write` marker",
1226                ));
1227            }
1228            ready_on_write = Some(Gated {
1229                cfg: clause_cfg,
1230                kw: Ident::new("ready_on_write", k.span),
1231                value: (),
1232            });
1233        } else if input.peek(kw::beat_timeout) {
1234            let k = input.parse::<kw::beat_timeout>()?;
1235            input.parse::<Token![:]>()?;
1236            if beat_timeout.is_some() {
1237                return Err(dup_clause(&k, "beat_timeout"));
1238            }
1239            beat_timeout = Some(Gated {
1240                cfg: clause_cfg,
1241                kw: k,
1242                value: input.parse::<LitInt>()?,
1243            });
1244        } else if input.peek(kw::beat_window) {
1245            let k = input.parse::<kw::beat_window>()?;
1246            input.parse::<Token![:]>()?;
1247            if beat_window.is_some() {
1248                return Err(dup_clause(&k, "beat_window"));
1249            }
1250            beat_window = Some(Gated {
1251                cfg: clause_cfg,
1252                kw: k,
1253                value: input.parse::<LitInt>()?,
1254            });
1255        } else if input.peek(kw::exit) {
1256            let k = input.parse::<kw::exit>()?;
1257            input.parse::<Token![:]>()?;
1258            exit = Some((k, input.parse::<syn::Type>()?));
1259        } else if input.peek(kw::provides) {
1260            let k = input.parse::<kw::provides>()?;
1261            input.parse::<Token![:]>()?;
1262            let content;
1263            bracketed!(content in input);
1264            while !content.is_empty() {
1265                let entry_cfg = content.call(Attribute::parse_outer)?;
1266                for a in &entry_cfg {
1267                    require_cfg_list(a, "`provides:` entry")?;
1268                }
1269                let slot: Ident = content.parse()?;
1270                if provides.iter().any(|p| p.ident == slot) {
1271                    return Err(syn::Error::new_spanned(
1272                        &slot,
1273                        "duplicate `provides:` slot — one entry clears it",
1274                    ));
1275                }
1276                provides.push(ProvideDecl {
1277                    cfg: entry_cfg,
1278                    ident: slot,
1279                });
1280                if content.peek(Token![,]) {
1281                    content.parse::<Token![,]>()?;
1282                }
1283            }
1284            if provides.is_empty() {
1285                return Err(syn::Error::new_spanned(
1286                    k,
1287                    "`provides:` must name at least one resource slot — omit \
1288                     the clause entirely to provide nothing",
1289                ));
1290            }
1291            provides_kw = Some(k);
1292        } else {
1293            return Err(input.error(format!(
1294                "expected {COMMON_CLAUSE_NAMES}, `deps:`, `pool_size:`, \
1295                 `beat_timeout:`, `beat_window:`, `ready_on_write`, `exit:`, \
1296                 `provides:`, or `disabled`"
1297            )));
1298        }
1299    }
1300    input.parse::<Token![;]>()?;
1301    let CommonClauses {
1302        executor,
1303        spawn,
1304        task,
1305        resources,
1306        reads,
1307        writes,
1308        state,
1309        slot_timeout,
1310        ack_timeout,
1311        cancel,
1312        discover,
1313        dataflow,
1314    } = common;
1315
1316    if let Some(bt) = &beat_timeout
1317        && bt.value.base10_parse::<u64>()? == 0
1318    {
1319        return Err(syn::Error::new_spanned(
1320            &bt.value,
1321            "`beat_timeout:` must be at least 1 (milliseconds) — omit the clause \
1322             to leave the node unpoliced",
1323        ));
1324    }
1325
1326    if let (Some(bw), None) = (&beat_window, &beat_timeout) {
1327        return Err(syn::Error::new_spanned(
1328            &bw.value,
1329            "`beat_window:` requires `beat_timeout:` — the window counts \
1330             consecutive sweeps that found the node past its beat budget",
1331        ));
1332    }
1333
1334    if let Some(bw) = &beat_window
1335        && !(1..=255).contains(&bw.value.base10_parse::<u64>()?)
1336    {
1337        return Err(syn::Error::new_spanned(
1338            &bw.value,
1339            "`beat_window:` must be in 1..=255 — omit the clause for the \
1340             default of 1, which reports on the first stale sweep",
1341        ));
1342    }
1343
1344    if let Some(bt) = &beat_timeout
1345        && !bt.cfg.is_empty()
1346    {
1347        if let Some(bw) = &beat_window
1348            && cfg_text(&bw.cfg) != cfg_text(&bt.cfg)
1349        {
1350            return Err(syn::Error::new_spanned(
1351                &bw.value,
1352                "`beat_window:` must carry the same `#[cfg]` predicate as its \
1353                 `beat_timeout:` — the window counts sweeps of a budget that \
1354                 gate compiles out",
1355            ));
1356        }
1357        if let Some(row) = &ready_on_write
1358            && cfg_text(&row.cfg) != cfg_text(&bt.cfg)
1359        {
1360            return Err(syn::Error::new_spanned(
1361                &row.kw,
1362                "`ready_on_write` must carry the same `#[cfg]` predicate as its \
1363                 `beat_timeout:` — readiness is asserted by the monitor sweep, \
1364                 which that gate compiles out",
1365            ));
1366        }
1367    }
1368
1369    for d in &reads {
1370        if let Some(b) = &d.beat {
1371            return Err(syn::Error::new_spanned(
1372                b,
1373                "`beat` belongs on a `writes:` entry — a node's heartbeat is \
1374                 something it produces, not something it consumes",
1375            ));
1376        }
1377    }
1378    for d in &reads {
1379        if let Some(v) = &d.veto {
1380            return Err(syn::Error::new_spanned(
1381                v,
1382                "`veto` belongs on a `writes:` entry — a veto is something a node \
1383                 asserts, not something it consumes",
1384            ));
1385        }
1386    }
1387
1388    if let Some(k) = &discover {
1389        for d in reads.iter().chain(writes.iter()) {
1390            if d.observed.is_none() && d.beat.is_none() && d.veto.is_none() {
1391                return Err(syn::Error::new_spanned(
1392                    &d.path,
1393                    "beside `discover`, a `reads:`/`writes:` entry may only add \
1394                     markers (`observed`, `beat`) to a signal the task fn \
1395                     already accesses — this one carries none, so it would \
1396                     declare a coupling the scan did not find. Drop the entry, \
1397                     or drop `discover` and declare the whole relation",
1398                ));
1399            }
1400        }
1401        if spawn.is_none() && task.is_none() {
1402            return Err(syn::Error::new_spanned(
1403                k.kw,
1404                "`discover` needs a `task:`/`spawn:` fn to take its tables \
1405                 from — a parked node has nothing to scan",
1406            ));
1407        }
1408    }
1409
1410    if let Some(row) = &ready_on_write {
1411        if !writes
1412            .iter()
1413            .any(|w| w.beat.is_some() && w.observed.is_some())
1414        {
1415            return Err(syn::Error::new_spanned(
1416                &row.kw,
1417                "`ready_on_write` requires an `observed beat` entry in \
1418                 `writes:` — the sweep's own poll of that write is what asserts \
1419                 the readiness. A body that beats through its verbs asserts \
1420                 readiness itself, with `set_ready()` at the same write",
1421            ));
1422        }
1423        if beat_timeout.is_none() {
1424            return Err(syn::Error::new_spanned(
1425                &row.kw,
1426                "`ready_on_write` requires `beat_timeout:` — readiness is \
1427                 asserted from the monitor sweep, which only visits nodes that \
1428                 declare a beat budget",
1429            ));
1430        }
1431    }
1432
1433    if let (Some(ps), None) = (&pool_size, &task) {
1434        return Err(syn::Error::new_spanned(
1435            ps,
1436            "`pool_size:` requires `task:` — a `spawn:` task fn sets its own \
1437             `#[embassy_executor::task(pool_size = ...)]`",
1438        ));
1439    }
1440    if let Some((k, decls)) = &resources {
1441        if task.is_none() {
1442            return Err(syn::Error::new_spanned(
1443                k,
1444                "`resources:` requires `task:` — resources are handed to the \
1445                 generated shell as owned arguments and restored by it; a \
1446                 `spawn:` task fn manages its own arguments",
1447            ));
1448        }
1449        if decls.is_empty() {
1450            return Err(syn::Error::new_spanned(
1451                k,
1452                "`resources:` must declare at least one `NAME: Type` entry",
1453            ));
1454        }
1455        for (i, d) in decls.iter().enumerate() {
1456            if decls[..i].iter().any(|prev| prev.ident == d.ident) {
1457                return Err(syn::Error::new_spanned(
1458                    &d.ident,
1459                    format!("duplicate resource name `{}`", d.ident),
1460                ));
1461            }
1462        }
1463    }
1464    if let Some(ps) = &pool_size
1465        && ps.base10_parse::<usize>()? == 0
1466    {
1467        return Err(syn::Error::new_spanned(
1468            ps,
1469            "`pool_size:` must be at least 1",
1470        ));
1471    }
1472    if let Some((k, _, _)) = &state
1473        && task.is_none()
1474    {
1475        return Err(syn::Error::new_spanned(
1476            k,
1477            "`state:` requires `task:` — the generated shell owns the boxed \
1478             state across the worker call and drops it on exit; a `spawn:` \
1479             task fn can Box its own state",
1480        ));
1481    }
1482    if let Some((k, _)) = &exit
1483        && task.is_none()
1484    {
1485        return Err(syn::Error::new_spanned(
1486            k,
1487            "`exit:` requires `task:` — the generated shell is what captures \
1488                 the worker's return value; a `spawn:` task fn can provide() into \
1489                 a slot itself",
1490        ));
1491    }
1492    if let Some(k) = &cancel {
1493        if task.is_none() {
1494            return Err(syn::Error::new_spanned(
1495                k,
1496                "`cancel` requires `task:` — it wraps the generated shell's call \
1497                 to the worker; a `spawn:` task fn can call \
1498                 `node.run_cancellable(..)` itself",
1499            ));
1500        }
1501        if mode == "Pause" {
1502            return Err(syn::Error::new_spanned(
1503                k,
1504                "`cancel` cannot be combined with `Mode::Pause` — a Pause worker \
1505                 must survive the stop and park on `wait_resume()`, but `cancel` \
1506                 drops its future and records an exit; use `Mode::Terminate` (or \
1507                 `OnDemand`), or drive the pause by hand in the worker",
1508            ));
1509        }
1510    }
1511    let source = task_source(spawn, task)?;
1512
1513    Ok(NodeItem {
1514        cfg,
1515        ident,
1516        mode,
1517        deps: deps.unwrap_or_default(),
1518        source,
1519        pool_size,
1520        disabled,
1521        executor,
1522        executor_defaulted: false,
1523        resources: resources.map(|(_, decls)| decls).unwrap_or_default(),
1524        slot_timeout,
1525        ack_timeout,
1526        beat_timeout,
1527        beat_window,
1528        ready_on_write,
1529        reads,
1530        writes,
1531        exit: exit.map(|(_, ty)| ty),
1532        state,
1533        cancel: cancel.is_some(),
1534        discover,
1535        dataflow: dataflow.map(|(_, f)| f).unwrap_or_default(),
1536        provides,
1537        provides_kw,
1538        fragment: None,
1539    })
1540}
1541
1542/// Parse a `pool NAME = [Mode, ...], ...;` declaration after the leading `pool` keyword.
1543pub fn parse_pool(input: ParseStream, cfg: Vec<Attribute>) -> SynResult<PoolItem> {
1544    input.parse::<kw::pool>()?;
1545    let ident: Ident = input.parse()?;
1546    input.parse::<Token![=]>()?;
1547    let modes = parse_mode_list(input)?;
1548
1549    let mut common = CommonClauses::default();
1550    let mut deps: Option<Vec<Dep>> = None;
1551    let mut policy: Option<Expr> = None;
1552    let mut policy_ty: Option<Type> = None;
1553    let mut min: Option<Expr> = None;
1554    let mut max: Option<Expr> = None;
1555
1556    while input.peek(Token![,]) {
1557        input.parse::<Token![,]>()?;
1558        let mut clause_cfg = parse_clause_cfg(input, ClauseHost::Pool)?;
1559        if common.parse_one(input, &mut clause_cfg)? {
1560            continue;
1561        }
1562        if input.peek(kw::deps) {
1563            let k = input.parse::<kw::deps>()?;
1564            input.parse::<Token![:]>()?;
1565            if deps.is_some() {
1566                return Err(syn::Error::new_spanned(
1567                    k,
1568                    "duplicate `deps:` clause — one list declares them all",
1569                ));
1570            }
1571            deps = Some(parse_dep_list(input)?);
1572        } else if input.peek(kw::policy) {
1573            let k = input.parse::<kw::policy>()?;
1574            input.parse::<Token![:]>()?;
1575            if policy.is_some() {
1576                return Err(dup_clause(&k, "policy"));
1577            }
1578            policy_ty = {
1579                let fork = input.fork();
1580                if fork.parse::<Type>().is_ok() && fork.peek(Token![=]) {
1581                    let ty: Type = input.parse()?;
1582                    input.parse::<Token![=]>()?;
1583                    Some(ty)
1584                } else {
1585                    None
1586                }
1587            };
1588            policy = Some(input.parse::<Expr>()?);
1589        } else if input.peek(kw::min) {
1590            let k = input.parse::<kw::min>()?;
1591            input.parse::<Token![:]>()?;
1592            if min.is_some() {
1593                return Err(dup_clause(&k, "min"));
1594            }
1595            min = Some(input.parse::<Expr>()?);
1596        } else if input.peek(kw::max) {
1597            let k = input.parse::<kw::max>()?;
1598            input.parse::<Token![:]>()?;
1599            if max.is_some() {
1600                return Err(dup_clause(&k, "max"));
1601            }
1602            max = Some(input.parse::<Expr>()?);
1603        } else if input.peek(kw::exit) {
1604            let k = input.parse::<kw::exit>()?;
1605            return Err(syn::Error::new_spanned(
1606                k,
1607                "`exit:` is not supported on `pool` — the K members share one shell, \
1608                 so per-member exit values need per-member storage; use per-node \
1609                 `exit:` declarations, or have the worker provide() into an \
1610                 app-declared slot itself",
1611            ));
1612        } else {
1613            return Err(input.error(format!(
1614                "expected {COMMON_CLAUSE_NAMES}, `deps:`, `policy:`, `min:`, or `max:`"
1615            )));
1616        }
1617    }
1618    input.parse::<Token![;]>()?;
1619    let CommonClauses {
1620        executor,
1621        spawn,
1622        task,
1623        resources,
1624        reads,
1625        writes,
1626        state,
1627        slot_timeout,
1628        ack_timeout,
1629        cancel,
1630        discover,
1631        dataflow,
1632    } = common;
1633    let source = task_source(spawn, task)?;
1634    let resources = resources.map(|(_, decls)| decls).unwrap_or_default();
1635
1636    if let Some(bad) = resources
1637        .iter()
1638        .find(|d| d.local.is_some() && d.shared.is_none())
1639    {
1640        return Err(syn::Error::new_spanned(
1641            &bad.ident,
1642            "`local` is not supported on take-kind `pool` resources (the one-executor \
1643             slot contract + per-member restore is deferred); a `shared local` entry \
1644             works (one pool-wide fan-out slot), or declare the take-kind `local` \
1645             resource on a node",
1646        ));
1647    }
1648
1649    let mut absent: Vec<&str> = Vec::new();
1650    if source.is_none() {
1651        absent.push("`task:` or `spawn:`");
1652    }
1653    if policy.is_none() {
1654        absent.push("`policy:`");
1655    }
1656    if min.is_none() {
1657        absent.push("`min:`");
1658    }
1659    if max.is_none() {
1660        absent.push("`max:`");
1661    }
1662    if !absent.is_empty() {
1663        return Err(syn::Error::new_spanned(
1664            &ident,
1665            format!(
1666                "`pool {ident}` is missing {} — an elastic pool needs its \
1667                 dependencies, a member task, a scaling policy, and the \
1668                 floor/ceiling the policy scales between",
1669                absent.join(", ")
1670            ),
1671        ));
1672    }
1673    let source = source.expect("absence checked above");
1674
1675    if let Some(k) = &cancel {
1676        if matches!(source, TaskSource::Spawn(_)) {
1677            return Err(syn::Error::new_spanned(
1678                k,
1679                "`cancel` requires `task:` — it wraps the generated shell's call to \
1680                 the member worker; a `spawn:` member fn can call \
1681                 `node.run_cancellable(..)` itself",
1682            ));
1683        }
1684        if let Some(m) = modes.iter().find(|m| *m == "Pause") {
1685            return Err(syn::Error::new_spanned(
1686                m,
1687                "`cancel` cannot be combined with a `Pause` member — a Pause worker \
1688                 must survive the stop and park on `wait_resume()`, but `cancel` \
1689                 drops its future and records an exit; use `Terminate` (or \
1690                 `OnDemand`) members, or drive the pause by hand in the worker",
1691            ));
1692        }
1693    }
1694    Ok(PoolItem {
1695        cfg,
1696        ident,
1697        modes,
1698        deps: deps.unwrap_or_default(),
1699        source,
1700        policy: policy.expect("absence checked above"),
1701        policy_ty,
1702        executor,
1703        executor_defaulted: false,
1704        resources,
1705        slot_timeout,
1706        ack_timeout,
1707        reads,
1708        writes,
1709        min: min.expect("absence checked above"),
1710        max: max.expect("absence checked above"),
1711        state,
1712        cancel: cancel.is_some(),
1713        discover,
1714        dataflow: dataflow.map(|(_, f)| f).unwrap_or_default(),
1715        fragment: None,
1716    })
1717}
1718/// Convert an identifier to a lower-case, hyphenated string.
1719///
1720/// Used to derive file-friendly names from graph identifiers.
1721pub fn name_string(ident: &Ident) -> String {
1722    ident.to_string().to_lowercase().replace('_', "-")
1723}
1724
1725/// Rewrite bare `crate` paths in a fragment body into `$crate`.
1726///
1727/// Fragments are parsed in isolation; this prepares them so that
1728/// [`substitute_dollar_crate`](fn@substitute_dollar_crate) can later resolve
1729/// them against the real crate path at the compose site.
1730pub fn normalize_fragment_crate(stream: TokenStream2) -> TokenStream2 {
1731    use proc_macro2::{Punct, Spacing, TokenStream as TS, TokenTree};
1732    let mut out = TS::new();
1733    let mut iter = stream.into_iter().peekable();
1734    while let Some(tt) = iter.next() {
1735        match tt {
1736            TokenTree::Group(g) => {
1737                let inner = normalize_fragment_crate(g.stream());
1738                let mut ng = proc_macro2::Group::new(g.delimiter(), inner);
1739                ng.set_span(g.span());
1740                out.extend([TokenTree::Group(ng)]);
1741            }
1742            TokenTree::Punct(p) if p.as_char() == '$' => {
1743                out.extend([TokenTree::Punct(p)]);
1744                if let Some(TokenTree::Ident(i)) = iter.peek()
1745                    && i == "crate"
1746                {
1747                    let i = iter.next().expect("peeked");
1748                    out.extend([i]);
1749                }
1750            }
1751            TokenTree::Ident(i) if i == "crate" => {
1752                let mut dollar = Punct::new('$', Spacing::Joint);
1753                dollar.set_span(i.span());
1754                out.extend([TokenTree::Punct(dollar), TokenTree::Ident(i)]);
1755            }
1756            other => out.extend([other]),
1757        }
1758    }
1759    out
1760}
1761
1762/// Replace every `$crate` occurrence in `stream` with `replacement`.
1763///
1764/// Used to resolve fragment paths against a placeholder while validating, or
1765/// the real crate path once a compose site has named it.
1766pub fn substitute_dollar_crate(stream: TokenStream2, replacement: &TokenStream2) -> TokenStream2 {
1767    use proc_macro2::{TokenStream as TS, TokenTree};
1768    let mut out = TS::new();
1769    let mut iter = stream.into_iter().peekable();
1770    while let Some(tt) = iter.next() {
1771        match tt {
1772            TokenTree::Group(g) => {
1773                let inner = substitute_dollar_crate(g.stream(), replacement);
1774                let mut ng = proc_macro2::Group::new(g.delimiter(), inner);
1775                ng.set_span(g.span());
1776                out.extend([TokenTree::Group(ng)]);
1777            }
1778            TokenTree::Punct(p) if p.as_char() == '$' => {
1779                if let Some(TokenTree::Ident(i)) = iter.peek()
1780                    && i == "crate"
1781                {
1782                    let span = iter.next().map(|t| t.span()).unwrap_or_else(|| p.span());
1783                    out.extend(respan(replacement.clone(), span));
1784                } else {
1785                    out.extend([TokenTree::Punct(p)]);
1786                }
1787            }
1788            other => out.extend([other]),
1789        }
1790    }
1791    out
1792}
1793
1794fn respan(stream: TokenStream2, span: proc_macro2::Span) -> TokenStream2 {
1795    stream
1796        .into_iter()
1797        .map(|mut tt| {
1798            tt.set_span(span);
1799            tt
1800        })
1801        .collect()
1802}
1803
1804/// A single dataflow verb call discovered in a `#[dataflow]` fn body.
1805#[derive(Clone)]
1806pub struct VerbCall {
1807    /// The verb name, e.g. `put` or `open`.
1808    pub verb: String,
1809    /// `#[cfg(...)]` predicates inherited from surrounding scopes.
1810    pub cfgs: Vec<TokenStream2>,
1811    /// `true` if this is a write, `false` if it is a read.
1812    pub write: bool,
1813    /// The expression passed as the signal argument.
1814    pub target: Expr,
1815    /// The signal path as a string.
1816    pub path: String,
1817}
1818
1819/// Built-in read verb names recognised by the dataflow scanner.
1820pub const BUILTIN_READS: &[&str] = &["get", "reader", "open", "lease"];
1821/// Built-in write verb names recognised by the dataflow scanner.
1822pub const BUILTIN_WRITES: &[&str] = &["put", "writer", "beat_put", "beat_writer", "retire", "veto"];
1823
1824/// Registry of read/write verbs used when scanning a `#[dataflow]` fn.
1825#[derive(Debug, Clone, Default)]
1826pub struct VerbTable {
1827    custom: Vec<(String, bool)>,
1828}
1829
1830impl VerbTable {
1831    /// Return a table containing only the built-in verbs.
1832    pub fn builtin() -> Self {
1833        Self::default()
1834    }
1835
1836    /// Return the direction of a verb, if known.
1837    ///
1838    /// `Some(true)` means write, `Some(false)` means read, and `None` means
1839    /// the verb is not registered.
1840    pub fn direction(&self, ident: &str) -> Option<bool> {
1841        if BUILTIN_WRITES.contains(&ident) {
1842            return Some(true);
1843        }
1844        if BUILTIN_READS.contains(&ident) {
1845            return Some(false);
1846        }
1847        self.custom
1848            .iter()
1849            .find(|(n, _)| n == ident)
1850            .map(|(_, w)| *w)
1851    }
1852
1853    fn add(&mut self, name: &Ident, write: bool) -> SynResult<()> {
1854        let text = name.to_string();
1855        if BUILTIN_READS.contains(&text.as_str()) || BUILTIN_WRITES.contains(&text.as_str()) {
1856            return Err(syn::Error::new_spanned(
1857                name,
1858                format!(
1859                    "`{text}` is a built-in verb and is always recognised: \
1860                     registering it would either repeat what the crate already \
1861                     says or contradict it. Give the new verb its own name"
1862                ),
1863            ));
1864        }
1865        if self.custom.iter().any(|(n, _)| *n == text) {
1866            return Err(syn::Error::new_spanned(
1867                name,
1868                format!("`{text}` is registered twice; a verb points one way"),
1869            ));
1870        }
1871        self.custom.push((text, write));
1872        Ok(())
1873    }
1874}
1875
1876impl Parse for VerbTable {
1877    fn parse(input: ParseStream) -> SynResult<Self> {
1878        let mut table = VerbTable::builtin();
1879        while !input.is_empty() {
1880            let kw: Ident = input.parse().map_err(|_| {
1881                syn::Error::new(
1882                    input.span(),
1883                    "`#[dataflow]` takes verb registrations: \
1884                     `read(<verb>, ..)` / `write(<verb>, ..)`, naming methods \
1885                     your own extension trait adds to `TaskNode`. A derived \
1886                     table states this fn's couplings and nothing else",
1887                )
1888            })?;
1889            let write = match kw.to_string().as_str() {
1890                "read" => false,
1891                "write" => true,
1892                other => {
1893                    return Err(syn::Error::new_spanned(
1894                        &kw,
1895                        format!(
1896                            "expected `read(..)` or `write(..)`, found `{other}`. \
1897                             The walker has no type information, so which way a \
1898                             registered verb points is stated here"
1899                        ),
1900                    ));
1901                }
1902            };
1903            let names;
1904            syn::parenthesized!(names in input);
1905            let names = names.parse_terminated(Ident::parse, Token![,])?;
1906            if names.is_empty() {
1907                return Err(syn::Error::new_spanned(
1908                    &kw,
1909                    format!("`{kw}(..)` names no verb"),
1910                ));
1911            }
1912            for name in &names {
1913                table.add(name, write)?;
1914            }
1915            if !input.is_empty() {
1916                input.parse::<Token![,]>()?;
1917            }
1918        }
1919        Ok(table)
1920    }
1921}
1922
1923/// Build a [`VerbTable`] from a `#[dataflow(...)]` attribute.
1924///
1925/// The attribute list may contain `read(verb, ...)` and `write(verb, ...)`
1926/// registrations for custom verbs. An empty or bare `#[dataflow]` attribute
1927/// yields the built-in table.
1928pub fn verb_table_of(attr: &Attribute) -> VerbTable {
1929    match &attr.meta {
1930        Meta::List(list) => syn::parse2(list.tokens.clone()).unwrap_or_default(),
1931        _ => VerbTable::builtin(),
1932    }
1933}
1934
1935/// Walk a `#[dataflow]` fn body and rewrite recognised verb calls.
1936///
1937/// For every call of the form `NODE.verb(&SIGNAL, ...)` where `verb` is
1938/// registered in `verbs`, `on_call` is invoked with a [`VerbCall`] describing
1939/// the access. The callback may return a replacement expression for the first
1940/// argument, or `None` to leave it unchanged.
1941pub fn rewrite_verb_calls(
1942    body: TokenStream2,
1943    node_param: &str,
1944    verbs: &VerbTable,
1945    on_call: &mut dyn FnMut(VerbCall) -> SynResult<Option<TokenStream2>>,
1946) -> SynResult<TokenStream2> {
1947    rewrite_verb_calls_in(body, node_param, verbs, &[], on_call)
1948}
1949
1950fn cfg_attr_predicate(g: &proc_macro2::Group) -> Option<TokenStream2> {
1951    use proc_macro2::{Delimiter, TokenTree};
1952    if g.delimiter() != Delimiter::Bracket {
1953        return None;
1954    }
1955    let mut it = g.stream().into_iter();
1956    match (it.next(), it.next(), it.next()) {
1957        (Some(TokenTree::Ident(i)), Some(TokenTree::Group(p)), None)
1958            if i == "cfg" && p.delimiter() == Delimiter::Parenthesis =>
1959        {
1960            Some(p.stream())
1961        }
1962        _ => None,
1963    }
1964}
1965
1966fn rewrite_verb_calls_in(
1967    body: TokenStream2,
1968    node_param: &str,
1969    verbs: &VerbTable,
1970    inherited: &[TokenStream2],
1971    on_call: &mut dyn FnMut(VerbCall) -> SynResult<Option<TokenStream2>>,
1972) -> SynResult<TokenStream2> {
1973    use proc_macro2::{Delimiter, Group, TokenTree};
1974
1975    let toks: Vec<TokenTree> = body.into_iter().collect();
1976    let mut out = TokenStream2::new();
1977    let mut i = 0;
1978    let mut pending: Vec<TokenStream2> = Vec::new();
1979    let mut current: Vec<TokenStream2> = Vec::new();
1980    while i < toks.len() {
1981        if let TokenTree::Punct(p) = &toks[i]
1982            && p.as_char() == '#'
1983            && let Some(TokenTree::Group(g)) = toks.get(i + 1)
1984            && g.delimiter() == Delimiter::Bracket
1985        {
1986            if let Some(pred) = cfg_attr_predicate(g) {
1987                pending.push(pred);
1988            }
1989            out.extend([toks[i].clone(), toks[i + 1].clone()]);
1990            i += 2;
1991            continue;
1992        }
1993        if !pending.is_empty() {
1994            current.append(&mut pending);
1995        }
1996        if matches!(&toks[i], TokenTree::Ident(id) if *id == "fn")
1997            && matches!(toks.get(i + 1), Some(TokenTree::Ident(_)))
1998        {
1999            while i < toks.len() {
2000                let done = matches!(&toks[i],
2001                    TokenTree::Group(g) if g.delimiter() == Delimiter::Brace)
2002                    || matches!(&toks[i], TokenTree::Punct(p) if p.as_char() == ';');
2003                out.extend([toks[i].clone()]);
2004                i += 1;
2005                if done {
2006                    break;
2007                }
2008            }
2009            current.clear();
2010            continue;
2011        }
2012        let own_receiver = i == 0
2013            || !matches!(&toks[i - 1], TokenTree::Punct(p)
2014                if p.as_char() == '.' || p.as_char() == ':');
2015        let matched = if own_receiver && i + 3 < toks.len() {
2016            match (&toks[i], &toks[i + 1], &toks[i + 2], &toks[i + 3]) {
2017                (
2018                    TokenTree::Ident(n),
2019                    TokenTree::Punct(dot),
2020                    TokenTree::Ident(verb),
2021                    TokenTree::Group(g),
2022                ) if *n == node_param
2023                    && dot.as_char() == '.'
2024                    && g.delimiter() == Delimiter::Parenthesis =>
2025                {
2026                    let name = verb.to_string();
2027                    verbs.direction(&name).map(|write| (name, write))
2028                }
2029                _ => None,
2030            }
2031        } else {
2032            None
2033        };
2034        if let Some((verb, write)) = matched {
2035            let TokenTree::Group(g) = &toks[i + 3] else {
2036                unreachable!()
2037            };
2038            let mut cfgs: Vec<TokenStream2> = inherited.to_vec();
2039            cfgs.extend(current.iter().cloned());
2040            let rebuilt = rewrite_first_arg(g, verb, write, node_param, verbs, &cfgs, on_call)?;
2041            out.extend(toks[i..i + 3].iter().cloned());
2042            let mut ng = Group::new(Delimiter::Parenthesis, rebuilt);
2043            ng.set_span(g.span());
2044            out.extend([TokenTree::Group(ng)]);
2045            i += 4;
2046            continue;
2047        }
2048        match &toks[i] {
2049            TokenTree::Group(g) => {
2050                let mut child = inherited.to_vec();
2051                child.extend(current.iter().cloned());
2052                let inner = rewrite_verb_calls_in(g.stream(), node_param, verbs, &child, on_call)?;
2053                let mut ng = Group::new(g.delimiter(), inner);
2054                ng.set_span(g.span());
2055                out.extend([TokenTree::Group(ng)]);
2056                if g.delimiter() == Delimiter::Brace {
2057                    let continues = matches!(
2058                        toks.get(i + 1),
2059                        Some(TokenTree::Ident(id)) if *id == "else"
2060                    ) || matches!(
2061                        toks.get(i + 1),
2062                        Some(TokenTree::Punct(p)) if matches!(p.as_char(), '.' | '?')
2063                    );
2064                    if !continues {
2065                        current.clear();
2066                    }
2067                }
2068            }
2069            TokenTree::Punct(p) if matches!(p.as_char(), ';' | ',') => {
2070                out.extend([toks[i].clone()]);
2071                current.clear();
2072            }
2073            t => out.extend([t.clone()]),
2074        }
2075        i += 1;
2076    }
2077    Ok(out)
2078}
2079
2080fn rewrite_first_arg(
2081    g: &proc_macro2::Group,
2082    verb: String,
2083    write: bool,
2084    node_param: &str,
2085    verbs: &VerbTable,
2086    cfgs: &[TokenStream2],
2087    on_call: &mut dyn FnMut(VerbCall) -> SynResult<Option<TokenStream2>>,
2088) -> SynResult<TokenStream2> {
2089    use proc_macro2::TokenTree;
2090
2091    let toks: Vec<TokenTree> = g.stream().into_iter().collect();
2092    let split = toks
2093        .iter()
2094        .position(|t| matches!(t, TokenTree::Punct(p) if p.as_char() == ','))
2095        .unwrap_or(toks.len());
2096    let (first, rest) = toks.split_at(split);
2097    let arg: TokenStream2 = first.iter().cloned().collect();
2098    let target = match syn::parse2::<Expr>(arg.clone()) {
2099        Ok(Expr::Reference(r))
2100            if matches!(*r.expr, Expr::Path(_)) || matches!(*r.expr, Expr::Index(_)) =>
2101        {
2102            *r.expr
2103        }
2104        _ => {
2105            return Err(syn::Error::new_spanned(
2106                &arg,
2107                "the supervisor derives dataflow from the literal path: name \
2108                 the signal directly (`&path::TO_SIGNAL`, `&ARR[i]`)",
2109            ));
2110        }
2111    };
2112    let path = tokens_text(&target);
2113    let replacement = on_call(VerbCall {
2114        verb,
2115        write,
2116        target,
2117        path,
2118        cfgs: cfgs.to_vec(),
2119    })?;
2120    let mut out = replacement.unwrap_or(arg);
2121    out.extend(rewrite_verb_calls_in(
2122        rest.iter().cloned().collect(),
2123        node_param,
2124        verbs,
2125        cfgs,
2126        on_call,
2127    )?);
2128    Ok(out)
2129}
2130
2131fn tokens_text<T: quote::ToTokens>(t: &T) -> String {
2132    t.to_token_stream().to_string().replace(' ', "")
2133}
2134
2135/// Find the name of the function argument whose type contains `TaskNode`.
2136///
2137/// Returns `None` if no such argument exists.
2138pub fn node_param(sig: &syn::Signature) -> Option<Ident> {
2139    sig.inputs.iter().find_map(|arg| match arg {
2140        syn::FnArg::Typed(t) if tokens_text(&t.ty).contains("TaskNode") => match &*t.pat {
2141            syn::Pat::Ident(p) => Some(p.ident.clone()),
2142            _ => None,
2143        },
2144        _ => None,
2145    })
2146}
2147
2148/// Return `true` if `attr` is a `#[dataflow]` attribute, written directly or
2149/// wrapped in `#[cfg_attr(.., dataflow)]`. See [`dataflow_attr`].
2150pub fn is_dataflow_attr(attr: &Attribute) -> bool {
2151    dataflow_attr(attr).is_some()
2152}
2153
2154/// Unwrap a `#[dataflow]` attribute, including any `#[cfg_attr(...)]` wrapper.
2155///
2156/// Returns the inner `#[dataflow]` attribute and the combined cfg predicate,
2157/// or `None` for non-dataflow attributes. A bare `#[dataflow]` returns the
2158/// attribute and `None`; nested `cfg_attr` predicates are folded into
2159/// `all(outer, inner)`.
2160///
2161/// Who needs the unwrap: everything that reads attributes as written. The
2162/// compiler applies `cfg_attr` before `#[dataflow]` itself runs, but the
2163/// `#[dataflow_bundle]` macro receives its module's member fns with their
2164/// attributes untouched, and the textual scanners ([`scan_dataflow`], the
2165/// tools crate) read source. The predicate is how a wrapped fn's accesses
2166/// keep the `cfg` they are conditional on, as a `#[cfg]` on the fn would.
2167pub fn dataflow_attr(attr: &Attribute) -> Option<(Attribute, Option<String>)> {
2168    let (meta, preds) = unwrap_cfg_attr(&attr.meta)?;
2169    let attr = if preds.is_empty() {
2170        attr.clone()
2171    } else {
2172        syn::parse_quote!(#[#meta])
2173    };
2174    let cfg = match preds.as_slice() {
2175        [] => None,
2176        [one] => Some(one.clone()),
2177        many => Some(format!("all({})", many.join(","))),
2178    };
2179    Some((attr, cfg))
2180}
2181
2182/// Peel `cfg_attr(pred, ..)` layers off `meta` until a `dataflow` attribute
2183/// surfaces, collecting the predicates outermost first.
2184fn unwrap_cfg_attr(meta: &Meta) -> Option<(Meta, Vec<String>)> {
2185    if meta
2186        .path()
2187        .segments
2188        .last()
2189        .is_some_and(|s| s.ident == "dataflow")
2190    {
2191        return Some((meta.clone(), Vec::new()));
2192    }
2193    let Meta::List(list) = meta else {
2194        return None;
2195    };
2196    if !list.path.is_ident("cfg_attr") {
2197        return None;
2198    }
2199    let mut args = list
2200        .parse_args_with(Punctuated::<Meta, Token![,]>::parse_terminated)
2201        .ok()?
2202        .into_iter();
2203    let pred = tokens_text(&args.next()?);
2204    let (inner, mut preds) = args.find_map(|m| unwrap_cfg_attr(&m))?;
2205    preds.insert(0, pred);
2206    Some((inner, preds))
2207}
2208
2209/// One dataflow access discovered by scanning a `#[dataflow]` fn body.
2210#[derive(Debug, Clone, PartialEq, Eq)]
2211pub struct Access {
2212    /// The name of the function that contains this access.
2213    pub func: String,
2214    /// The verb used, e.g. `put` or `get`.
2215    pub verb: String,
2216    /// `true` for writes, `false` for reads.
2217    pub write: bool,
2218    /// The signal path accessed, as a string.
2219    pub path: String,
2220    /// The predicates this access is conditional on, normalized (token text,
2221    /// spaces stripped), outermost first: the fn's `#[cfg(...)]`s, the
2222    /// `cfg_attr` predicate its `#[dataflow]` sits under if any, then the
2223    /// call site's own `#[cfg(...)]`s. A predicate repeated across those
2224    /// levels appears once.
2225    pub cfgs: Vec<String>,
2226}
2227
2228/// Scan Rust source for `#[dataflow]` functions and append their accesses to `out`.
2229///
2230/// This is a textual scan: it looks for calls on the `TaskNode` parameter
2231/// whose method name is a registered read or write verb, and records the
2232/// accessed signal path.
2233pub fn scan_dataflow(src: &str, out: &mut Vec<Access>) {
2234    let Ok(file) = syn::parse_file(src) else {
2235        return;
2236    };
2237    for item in &file.items {
2238        scan_item(item, out);
2239    }
2240}
2241
2242fn scan_item(item: &syn::Item, out: &mut Vec<Access>) {
2243    match item {
2244        syn::Item::Fn(f) => scan_fn(&f.attrs, &f.sig, &f.block, out),
2245        syn::Item::Mod(m) => {
2246            if let Some((_, items)) = &m.content {
2247                for i in items {
2248                    scan_item(i, out);
2249                }
2250            }
2251        }
2252        syn::Item::Impl(im) => {
2253            for ii in &im.items {
2254                if let syn::ImplItem::Fn(f) = ii {
2255                    scan_fn(&f.attrs, &f.sig, &f.block, out);
2256                }
2257            }
2258        }
2259        _ => {}
2260    }
2261}
2262
2263fn scan_fn(attrs: &[Attribute], sig: &syn::Signature, block: &syn::Block, out: &mut Vec<Access>) {
2264    let Some((attr, wrap_cfg)) = attrs.iter().find_map(dataflow_attr) else {
2265        return;
2266    };
2267    let Some(param) = node_param(sig) else {
2268        return;
2269    };
2270    let verbs = verb_table_of(&attr);
2271    let func = sig.ident.to_string();
2272    // The fn's own `#[cfg]`s, then the `cfg_attr` predicate the attribute
2273    // itself sits under, if any: an access through a conditionally-dataflow
2274    // fn is conditional on both.
2275    let fn_cfgs: Vec<String> = attrs
2276        .iter()
2277        .filter_map(|a| match &a.meta {
2278            syn::Meta::List(l) if l.path.is_ident("cfg") => {
2279                Some(l.tokens.to_string().replace(' ', ""))
2280            }
2281            _ => None,
2282        })
2283        .chain(wrap_cfg)
2284        .collect();
2285    let mut seen: Vec<(bool, String)> = Vec::new();
2286    let _ = rewrite_verb_calls(quote!(#block), &param.to_string(), &verbs, &mut |call| {
2287        if !seen.contains(&(call.write, call.path.clone())) {
2288            seen.push((call.write, call.path.clone()));
2289            // A predicate repeated at the fn and the call site (the usual
2290            // shape once a `cfg_attr` fn also gates its statements) is one
2291            // condition, so it is recorded once.
2292            let mut cfgs: Vec<String> = Vec::new();
2293            for c in fn_cfgs
2294                .iter()
2295                .cloned()
2296                .chain(call.cfgs.iter().map(|c| c.to_string().replace(' ', "")))
2297            {
2298                if !cfgs.contains(&c) {
2299                    cfgs.push(c);
2300                }
2301            }
2302            out.push(Access {
2303                func: func.clone(),
2304                verb: call.verb.clone(),
2305                write: call.write,
2306                path: call.path.clone(),
2307                cfgs,
2308            });
2309        }
2310        Ok(None)
2311    });
2312}
2313
2314#[cfg(test)]
2315mod tests {
2316    use super::*;
2317
2318    #[test]
2319    fn unknown_dep_marker_rejected() {
2320        match syn::parse_str::<GraphSpec>("node A = Terminate, deps: [B rdy];") {
2321            Ok(_) => panic!("unknown marker accepted"),
2322            Err(err) => {
2323                let msg = err.to_string();
2324                assert!(msg.contains("dep marker"), "got: {msg}");
2325                assert!(msg.contains("ready"), "got: {msg}");
2326                assert!(msg.contains("rdy"), "names the offending token: {msg}");
2327            }
2328        }
2329    }
2330
2331    #[test]
2332    fn beat_timeout_zero_rejected() {
2333        match syn::parse_str::<GraphSpec>("node A = Terminate, deps: [], beat_timeout: 0;") {
2334            Ok(_) => panic!("zero budget accepted"),
2335            Err(err) => assert!(
2336                err.to_string()
2337                    .contains("`beat_timeout:` must be at least 1"),
2338                "got: {err}"
2339            ),
2340        }
2341    }
2342
2343    #[test]
2344    fn beat_window_without_timeout_rejected() {
2345        match syn::parse_str::<GraphSpec>("node A = Terminate, deps: [], beat_window: 3;") {
2346            Ok(_) => panic!("orphan window accepted"),
2347            Err(err) => assert!(
2348                err.to_string()
2349                    .contains("`beat_window:` requires `beat_timeout:`"),
2350                "got: {err}"
2351            ),
2352        }
2353    }
2354
2355    #[test]
2356    fn beat_window_out_of_range_rejected() {
2357        for w in ["0", "256", "300"] {
2358            let src = format!("node A = Terminate, deps: [], beat_timeout: 100, beat_window: {w};");
2359            match syn::parse_str::<GraphSpec>(&src) {
2360                Ok(_) => panic!("`beat_window: {w}` accepted"),
2361                Err(err) => assert!(
2362                    err.to_string()
2363                        .contains("`beat_window:` must be in 1..=255"),
2364                    "got: {err}"
2365                ),
2366            }
2367        }
2368        assert!(
2369            syn::parse_str::<GraphSpec>(
2370                "node A = Terminate, deps: [], beat_timeout: 100, beat_window: 255;"
2371            )
2372            .is_ok(),
2373            "255 is in range"
2374        );
2375    }
2376
2377    #[test]
2378    fn ready_on_write_needs_a_heartbeat_source() {
2379        let nothing = "node A = Terminate, deps: [], beat_timeout: 100, \
2380             ready_on_write, writes: [crate::X];";
2381        match syn::parse_str::<GraphSpec>(nothing) {
2382            Ok(_) => panic!("accepted with no `beat` write"),
2383            Err(err) => assert!(
2384                err.to_string()
2385                    .contains("requires an `observed beat` entry"),
2386                "got: {err}"
2387            ),
2388        }
2389
2390        let no_budget = "observe writes: it.get();\n\
2391             node A = Terminate, deps: [], ready_on_write, \
2392             writes: [crate::X observed beat];";
2393        match syn::parse_str::<GraphSpec>(no_budget) {
2394            Ok(_) => panic!("accepted without `beat_timeout:`"),
2395            Err(err) => assert!(
2396                err.to_string().contains("requires `beat_timeout:`"),
2397                "got: {err}"
2398            ),
2399        }
2400
2401        let ok = "observe writes: it.get();\n\
2402             node A = Terminate, deps: [], beat_timeout: 100, ready_on_write, \
2403             writes: [crate::X observed beat];";
2404        assert!(
2405            syn::parse_str::<GraphSpec>(ok).is_ok(),
2406            "both halves present"
2407        );
2408
2409        let adopted = "node A = Terminate, deps: [], ready_on_write, \
2410             dataflow: [crate::hb::set_period];";
2411        match syn::parse_str::<GraphSpec>(adopted) {
2412            Ok(_) => panic!("adoption accepted as a heartbeat source"),
2413            Err(err) => assert!(
2414                err.to_string()
2415                    .contains("requires an `observed beat` entry"),
2416                "got: {err}"
2417            ),
2418        }
2419    }
2420
2421    #[test]
2422    fn dataflow_scan_keys_on_the_node_param() {
2423        let src = r#"
2424            #[embassy_supervisor::dataflow]
2425            async fn worker(node: &'static TaskNode, map: Map) {
2426                let v = node.get(&PERIOD);
2427                map.get(&KEY);
2428                node.put(&OUT, v);
2429                if v > 0 {
2430                    node.writer(&crate::stats::HITS[1]).fetch_add(1, O);
2431                }
2432                let mut rx = node.reader(&EST).receiver();
2433                node.put(&OUT, v + 1); // second site, one entry
2434            }
2435            async fn unannotated(node: &'static TaskNode) {
2436                node.put(&IGNORED, 1);
2437            }
2438        "#;
2439        let mut out = Vec::new();
2440        scan_dataflow(src, &mut out);
2441        let key: Vec<(&str, bool, &str)> = out
2442            .iter()
2443            .map(|a| (a.func.as_str(), a.write, a.path.as_str()))
2444            .collect();
2445        assert_eq!(
2446            key,
2447            [
2448                ("worker", false, "PERIOD"),
2449                ("worker", true, "OUT"),
2450                ("worker", true, "crate::stats::HITS[1]"),
2451                ("worker", false, "EST"),
2452            ],
2453            "{out:?}"
2454        );
2455    }
2456
2457    #[test]
2458    fn cfg_attr_wrapped_dataflow_is_scanned_with_its_predicate() {
2459        let src = r#"
2460            #[cfg(feature = "x")]
2461            #[cfg_attr(feature = "grown", embassy_supervisor::dataflow)]
2462            async fn worker(node: &'static TaskNode) {
2463                #[cfg(feature = "grown")] // same predicate again: recorded once
2464                let mut rx = node.reader(&LATEST).receiver();
2465            }
2466            #[cfg_attr(feature = "a", inline, dataflow)]
2467            async fn beside_others(node: &'static TaskNode) {
2468                node.put(&OUT, 1);
2469            }
2470            #[cfg_attr(feature = "a", cfg_attr(feature = "b", dataflow))]
2471            async fn nested(node: &'static TaskNode) {
2472                node.put(&DEEP, 1);
2473            }
2474            #[cfg_attr(feature = "a", inline)]
2475            async fn not_dataflow(node: &'static TaskNode) {
2476                node.put(&IGNORED, 1);
2477            }
2478        "#;
2479        let mut out = Vec::new();
2480        scan_dataflow(src, &mut out);
2481        let key: Vec<(&str, bool, &str, Vec<&str>)> = out
2482            .iter()
2483            .map(|a| {
2484                (
2485                    a.func.as_str(),
2486                    a.write,
2487                    a.path.as_str(),
2488                    a.cfgs.iter().map(String::as_str).collect(),
2489                )
2490            })
2491            .collect();
2492        assert_eq!(
2493            key,
2494            [
2495                (
2496                    "worker",
2497                    false,
2498                    "LATEST",
2499                    vec!["feature=\"x\"", "feature=\"grown\""]
2500                ),
2501                ("beside_others", true, "OUT", vec!["feature=\"a\""]),
2502                (
2503                    "nested",
2504                    true,
2505                    "DEEP",
2506                    vec!["all(feature=\"a\",feature=\"b\")"]
2507                ),
2508            ],
2509            "{out:?}"
2510        );
2511    }
2512
2513    #[test]
2514    fn cfg_attr_wrapped_verb_registrations_are_honoured() {
2515        let src = r#"
2516            #[cfg_attr(feature = "a", dataflow(read(subscribe), write(publish)))]
2517            async fn worker(node: &'static TaskNode) {
2518                let rx = node.subscribe(&EST);
2519                node.publish(&ARMED, true);
2520            }
2521        "#;
2522        let mut out = Vec::new();
2523        scan_dataflow(src, &mut out);
2524        let key: Vec<(&str, bool, &str)> = out
2525            .iter()
2526            .map(|a| (a.verb.as_str(), a.write, a.path.as_str()))
2527            .collect();
2528        assert_eq!(
2529            key,
2530            [("subscribe", false, "EST"), ("publish", true, "ARMED")],
2531            "{out:?}"
2532        );
2533    }
2534
2535    #[test]
2536    fn dataflow_attr_unwraps_cfg_attr() {
2537        let bare: Attribute = syn::parse_quote!(#[embassy_supervisor::dataflow]);
2538        let (attr, cfg) = dataflow_attr(&bare).unwrap();
2539        assert!(matches!(attr.meta, Meta::Path(_)));
2540        assert_eq!(cfg, None);
2541
2542        let wrapped: Attribute =
2543            syn::parse_quote!(#[cfg_attr(all(feature = "a", not(test)), dataflow(read(sub)))]);
2544        let (attr, cfg) = dataflow_attr(&wrapped).unwrap();
2545        assert!(is_dataflow_attr(&wrapped));
2546        assert_eq!(tokens_text(&attr), "#[dataflow(read(sub))]");
2547        assert_eq!(cfg.as_deref(), Some("all(feature=\"a\",not(test))"));
2548
2549        let other: Attribute = syn::parse_quote!(#[cfg_attr(feature = "a", inline)]);
2550        assert!(dataflow_attr(&other).is_none());
2551        assert!(!is_dataflow_attr(&other));
2552        let cfg_only: Attribute = syn::parse_quote!(#[cfg(feature = "a")]);
2553        assert!(!is_dataflow_attr(&cfg_only));
2554    }
2555
2556    #[test]
2557    fn nested_fns_are_not_walked() {
2558        let body = quote!({
2559            node.put(&OUT, 1);
2560            fn helper(node: &'static TaskNode) {
2561                node.put(&INNER, 2);
2562            }
2563            let f = || node.get(&IN);
2564        });
2565        let mut seen = Vec::new();
2566        let out = rewrite_verb_calls(body, "node", &VerbTable::builtin(), &mut |call| {
2567            seen.push(call.path.clone());
2568            Ok(Some(quote!(REPL)))
2569        })
2570        .unwrap()
2571        .to_string()
2572        .replace(' ', "");
2573        assert_eq!(seen, ["OUT", "IN"], "the nested fn's access is not ours");
2574        assert!(
2575            out.contains("put(&INNER,2)"),
2576            "and stays unrewritten: {out}"
2577        );
2578        assert!(out.contains("get(REPL)"), "the closure's is: {out}");
2579    }
2580
2581    /// The walk keys on whatever the fn names its node parameter.
2582    #[test]
2583    fn walker_keys_on_the_actual_param_name() {
2584        let src = r#"
2585            #[dataflow]
2586            fn f(n: &'static TaskNode, node: Map) {
2587                n.put(&OUT, 1);
2588                node.get(&KEY);
2589            }
2590        "#;
2591        let mut out = Vec::new();
2592        scan_dataflow(src, &mut out);
2593        let key: Vec<(&str, bool)> = out.iter().map(|a| (a.path.as_str(), a.write)).collect();
2594        assert_eq!(key, [("OUT", true)], "{out:?}");
2595    }
2596
2597    /// A computed first argument cannot become a compile-time table entry.
2598    #[test]
2599    fn dataflow_walker_rejects_a_computed_target() {
2600        let body = quote!({ node.get(some_binding) });
2601        let err =
2602            rewrite_verb_calls(body, "node", &VerbTable::builtin(), &mut |_| Ok(None)).unwrap_err();
2603        assert!(err.to_string().contains("literal path"), "{err}");
2604    }
2605
2606    /// The rewriter's replacement lands as the call's first argument, rest
2607    /// untouched.
2608    #[test]
2609    fn dataflow_walker_replaces_the_first_argument() {
2610        let body = quote!({
2611            node.put(&OUT, v);
2612            node.get(&IN)
2613        });
2614        let out = rewrite_verb_calls(body, "node", &VerbTable::builtin(), &mut |call| {
2615            let k = if call.write { 1u32 } else { 0 };
2616            Ok(Some(quote!(REPL(#k))))
2617        })
2618        .unwrap()
2619        .to_string()
2620        .replace(' ', "");
2621        assert!(out.contains("put(REPL(1u32),v)"), "{out}");
2622        assert!(out.contains("get(REPL(0u32))"), "{out}");
2623    }
2624
2625    /// A registered verb is walked exactly like a built-in one, and an
2626    /// unregistered method on the node is still left alone: a `#[dataflow]` fn
2627    /// calls `set_ready()` and `beat()` on its node like any other.
2628    #[test]
2629    fn registered_verbs_are_walked_and_others_are_not() {
2630        let verbs: VerbTable = syn::parse_str("read(subscribe), write(publish, emit)").unwrap();
2631        let body = quote!({
2632            node.subscribe(&IN);
2633            node.publish(&OUT, v);
2634            node.emit(&LOG, e);
2635            node.set_ready();
2636            node.reader(&ALSO);
2637        });
2638        let mut seen = Vec::new();
2639        let out = rewrite_verb_calls(body, "node", &verbs, &mut |call| {
2640            seen.push((call.verb.clone(), call.write, call.path.clone()));
2641            Ok(Some(quote!(REPL)))
2642        })
2643        .unwrap()
2644        .to_string()
2645        .replace(' ', "");
2646        assert_eq!(
2647            seen,
2648            [
2649                ("subscribe".into(), false, "IN".to_string()),
2650                ("publish".into(), true, "OUT".to_string()),
2651                ("emit".into(), true, "LOG".to_string()),
2652                ("reader".into(), false, "ALSO".to_string()),
2653            ],
2654            "registered verbs join the built-ins, direction as declared"
2655        );
2656        assert!(out.contains("set_ready()"), "not a verb, untouched: {out}");
2657    }
2658
2659    /// The two ways a registration is a mistake rather than an intent, and the
2660    /// shape errors around them. A bare `#[dataflow]` is an empty argument
2661    /// list, which is the built-in table.
2662    #[test]
2663    fn verb_registration_rejects_its_mistakes() {
2664        assert!(
2665            syn::parse_str::<VerbTable>("").is_ok(),
2666            "bare `#[dataflow]`"
2667        );
2668
2669        let cases = [
2670            ("read(put)", "built-in"),
2671            ("write(reader)", "built-in"),
2672            ("read(a), write(a)", "twice"),
2673            ("read(a, a)", "twice"),
2674            ("beat(a)", "expected `read(..)` or `write(..)`"),
2675            ("subscribe", "expected `read(..)` or `write(..)`"),
2676            ("read()", "names no verb"),
2677            // Not an ident at all: the arguments are not a marker list, and
2678            // the error says what they are instead.
2679            ("42", "verb registrations"),
2680        ];
2681        for (src, want) in cases {
2682            match syn::parse_str::<VerbTable>(src) {
2683                Ok(_) => panic!("`{src}` accepted"),
2684                Err(err) => assert!(
2685                    err.to_string().contains(want),
2686                    "`{src}`: wanted {want:?}, got: {err}"
2687                ),
2688            }
2689        }
2690    }
2691
2692    /// The diagram tool reads the registrations from the same attribute the
2693    /// build does, so a consumer's verbs reach the diagram with no
2694    /// configuration channel of their own.
2695    #[test]
2696    fn the_scanner_reads_the_registrations_too() {
2697        let src = r#"
2698            #[dataflow(read(subscribe), write(publish))]
2699            async fn entry(node: &'static TaskNode) {
2700                let rx = node.subscribe(&crate::EST);
2701                node.publish(&crate::ARMED, true);
2702                node.put(&crate::OTHER, 1);
2703            }
2704        "#;
2705        let mut out = Vec::new();
2706        scan_dataflow(src, &mut out);
2707        let key: Vec<(&str, &str, bool)> = out
2708            .iter()
2709            .map(|a| (a.verb.as_str(), a.path.as_str(), a.write))
2710            .collect();
2711        assert_eq!(
2712            key,
2713            [
2714                ("subscribe", "crate::EST", false),
2715                ("publish", "crate::ARMED", true),
2716                ("put", "crate::OTHER", true),
2717            ]
2718        );
2719    }
2720
2721    /// The entry markers: `observed`? `beat`? `via <expr>`?, in that order.
2722    /// `beat` only ever qualifies `observed`; alone it is rejected, because a
2723    /// body the supervisor can see states its heartbeat at the write.
2724    #[test]
2725    fn signal_entry_markers_compose() {
2726        match syn::parse_str::<GraphSpec>("node A = Terminate, deps: [], writes: [crate::X beat];")
2727        {
2728            Ok(_) => panic!("accepted a bare `beat` entry"),
2729            Err(err) => assert!(
2730                err.to_string().contains("beat_put"),
2731                "the rejection names the verb that carries it: {err}"
2732            ),
2733        }
2734        for src in [
2735            "node A = Terminate, deps: [], writes: [crate::X observed beat];",
2736            "node A = Terminate, deps: [], \
2737             writes: [crate::X observed beat via it.get()];",
2738        ] {
2739            let spec = syn::parse_str::<GraphSpec>(src).unwrap_or_else(|e| panic!("{src}: {e}"));
2740            let Item::Node(n) = &spec.items[0] else {
2741                unreachable!()
2742            };
2743            let entry = n.reads.first().or(n.writes.first()).unwrap();
2744            assert!(entry.beat.is_some(), "{src}");
2745        }
2746    }
2747
2748    /// `discover` binds the `#[dataflow]` tables: bare only, list-exclusive,
2749    /// and never on a node with nothing to scan.
2750    #[test]
2751    fn discover_clause_shape() {
2752        let spec = syn::parse_str::<GraphSpec>(
2753            "node A = Terminate, deps: [], task: w, discover;\n\
2754             pool P = [Terminate, OnDemand], deps: [], task: w, discover, \
2755             policy: Pol::new(), min: 1, max: 2;",
2756        )
2757        .unwrap();
2758        let Item::Node(n) = &spec.items[0] else {
2759            unreachable!()
2760        };
2761        assert!(n.discover.is_some());
2762        let Item::Pool(p) = &spec.items[1] else {
2763            unreachable!()
2764        };
2765        assert!(p.discover.is_some());
2766
2767        // A marked entry composes: the scan states the coupling, the list adds
2768        // the marker a derived table cannot carry.
2769        assert!(
2770            syn::parse_str::<GraphSpec>(
2771                "node A = Terminate, deps: [], task: w, discover, \
2772                 writes: [crate::X observed beat];"
2773            )
2774            .is_ok(),
2775            "a marked entry may sit beside `discover`"
2776        );
2777
2778        for (bad, want) in [
2779            (
2780                "node A = Terminate, deps: [], task: w, discover: 8;",
2781                "takes no argument",
2782            ),
2783            (
2784                "node A = Terminate, deps: [], task: w, discover, reads: [crate::X];",
2785                "may only add markers",
2786            ),
2787            ("node A = Terminate, deps: [], discover;", "nothing to scan"),
2788        ] {
2789            match syn::parse_str::<GraphSpec>(bad) {
2790                Ok(_) => panic!("accepted: {bad}"),
2791                Err(err) => assert!(err.to_string().contains(want), "{bad}: {err}"),
2792            }
2793        }
2794    }
2795
2796    /// Inside a fragment, bare `crate` and `$crate` can only mean the
2797    /// fragment's own crate, so normalization makes them one spelling — and
2798    #[test]
2799    fn bare_crate_normalizes_to_dollar_crate() {
2800        let ts: TokenStream2 = "task: crate::w, reads: [$crate::X, (crate::Y)]"
2801            .parse()
2802            .unwrap();
2803        let out = normalize_fragment_crate(ts.clone())
2804            .to_string()
2805            .replace(' ', "");
2806        assert_eq!(out.matches("$crate").count(), 3, "{out}");
2807
2808        let resolved =
2809            substitute_dollar_crate(normalize_fragment_crate(ts), &"::dep".parse().unwrap())
2810                .to_string()
2811                .replace(' ', "");
2812        assert_eq!(resolved, "task:::dep::w,reads:[::dep::X,(::dep::Y)]");
2813    }
2814
2815    #[test]
2816    fn dataflow_clause_shape() {
2817        let spec = syn::parse_str::<GraphSpec>(
2818            "node A = Terminate, deps: [], task: w, discover, \
2819             dataflow: [crate::hb::set_period];\n\
2820             node B = Terminate, deps: [], reads: [crate::X], \
2821             dataflow: [crate::hb::set_period, other::adjust];",
2822        )
2823        .unwrap();
2824        let Item::Node(a) = &spec.items[0] else {
2825            unreachable!()
2826        };
2827        assert_eq!(a.dataflow.len(), 1);
2828        let Item::Node(b) = &spec.items[1] else {
2829            unreachable!()
2830        };
2831        assert_eq!(b.dataflow.len(), 2);
2832
2833        for (bad, want) in [
2834            (
2835                "node A = Terminate, deps: [], dataflow: [];",
2836                "at least one",
2837            ),
2838            (
2839                "node A = Terminate, deps: [], dataflow: [f, f];",
2840                "duplicate",
2841            ),
2842        ] {
2843            match syn::parse_str::<GraphSpec>(bad) {
2844                Ok(_) => panic!("accepted: {bad}"),
2845                Err(err) => assert!(err.to_string().contains(want), "{bad}: {err}"),
2846            }
2847        }
2848    }
2849
2850    #[test]
2851    fn discover_cannot_carry_ready_on_write() {
2852        match syn::parse_str::<GraphSpec>(
2853            "node A = Terminate, deps: [], task: w, ready_on_write, discover;",
2854        ) {
2855            Ok(_) => panic!("accepted `ready_on_write` with nothing to fire from"),
2856            Err(err) => assert!(
2857                err.to_string()
2858                    .contains("requires an `observed beat` entry"),
2859                "got: {err}"
2860            ),
2861        }
2862        assert!(
2863            syn::parse_str::<GraphSpec>(
2864                "node A = Terminate, deps: [], task: w, ready_on_write, \
2865                 beat_timeout: 100, discover, \
2866                 writes: [crate::X observed beat];"
2867            )
2868            .is_ok(),
2869            "a marked entry beside `discover` is a heartbeat source"
2870        );
2871    }
2872
2873    #[test]
2874    fn via_on_a_beat_only_entry_is_rejected() {
2875        match syn::parse_str::<GraphSpec>(
2876            "node A = Terminate, deps: [], writes: [crate::X beat via it.get()];",
2877        ) {
2878            Ok(_) => panic!("`beat via` accepted"),
2879            Err(err) => {
2880                let msg = err.to_string();
2881                assert!(msg.contains("only an `observed` entry has"), "got: {msg}");
2882                assert!(
2883                    msg.contains("`beat` only ever qualifies `observed`"),
2884                    "the `beat` half must be named too, got: {msg}"
2885                );
2886            }
2887        }
2888    }
2889
2890    #[test]
2891    fn a_bare_qualifier_names_the_form_it_belongs_to() {
2892        let src = "node A = Terminate, deps: [], writes: [crate::X via it.get()];";
2893        match syn::parse_str::<GraphSpec>(src) {
2894            Ok(_) => panic!("accepted a bare qualifier: {src}"),
2895            Err(err) => assert!(
2896                err.to_string().contains("`via` supplies the accessor"),
2897                "got: {err}"
2898            ),
2899        }
2900    }
2901
2902    #[test]
2903    fn beat_on_a_read_is_rejected() {
2904        let src = "observe reads: it.get();\n\
2905             node A = Terminate, deps: [], reads: [crate::X observed beat];";
2906        match syn::parse_str::<GraphSpec>(src) {
2907            Ok(_) => panic!("`beat` accepted on a read"),
2908            Err(err) => assert!(
2909                err.to_string().contains("belongs on a `writes:` entry"),
2910                "got: {err}"
2911            ),
2912        }
2913    }
2914
2915    #[test]
2916    fn bound_without_ready_rejected() {
2917        match syn::parse_str::<GraphSpec>(
2918            "node A = Terminate, deps: [];\nnode B = Terminate, deps: [A bound];",
2919        ) {
2920            Ok(_) => panic!("`bound` without `ready` accepted"),
2921            Err(err) => assert!(
2922                err.to_string().contains("`bound` implies `ready`"),
2923                "got: {err}"
2924            ),
2925        }
2926    }
2927
2928    #[test]
2929    fn dep_markers_compose() {
2930        assert!(
2931            syn::parse_str::<GraphSpec>(
2932                "node A = Terminate, deps: [];\n\
2933                 node B = Terminate, deps: [A ready bound];\n\
2934                 node C = Terminate, deps: [A bound ready];",
2935            )
2936            .is_ok()
2937        );
2938        match syn::parse_str::<GraphSpec>(
2939            "node A = Terminate, deps: [];\nnode B = Terminate, deps: [A ready ready];",
2940        ) {
2941            Ok(_) => panic!("duplicate marker accepted"),
2942            Err(err) => assert!(err.to_string().contains("duplicate `ready`"), "got: {err}"),
2943        }
2944    }
2945
2946    #[test]
2947    fn pool_accepts_coupling_clauses() {
2948        assert!(
2949            syn::parse_str::<GraphSpec>(
2950                "pool P = [Terminate, OnDemand], deps: [], task: w, \
2951                 reads: [crate::IN], writes: [crate::OUT], \
2952                 policy: Pol::new(), min: 1, max: 2;",
2953            )
2954            .is_ok()
2955        );
2956        assert!(
2957            syn::parse_str::<GraphSpec>(
2958                "pool P = [Terminate], deps: [], task: w, writes: [crate::OUT], \
2959                 policy: Pol::new(), min: 1, max: 1;",
2960            )
2961            .is_ok()
2962        );
2963    }
2964
2965    #[test]
2966    fn empty_signal_list_rejected() {
2967        for clause in ["reads", "writes"] {
2968            let src = format!("node A = Terminate, deps: [], {clause}: [];");
2969            match syn::parse_str::<GraphSpec>(&src) {
2970                Ok(_) => panic!("empty `{clause}:` accepted"),
2971                Err(err) => assert!(
2972                    err.to_string()
2973                        .contains(&format!("`{clause}:` must declare at least one")),
2974                    "got: {err}"
2975                ),
2976            }
2977        }
2978        assert!(
2979            syn::parse_str::<GraphSpec>(
2980                "pool P = [Terminate], deps: [], task: w, reads: [], \
2981                 policy: Pol::new(), min: 1, max: 1;",
2982            )
2983            .is_err()
2984        );
2985    }
2986
2987    #[test]
2988    fn duplicate_signal_rejected() {
2989        match syn::parse_str::<GraphSpec>(
2990            "node A = Terminate, deps: [], reads: [crate::SIG, other::X, crate::SIG];",
2991        ) {
2992            Ok(_) => panic!("duplicate accepted"),
2993            Err(err) => assert!(
2994                err.to_string()
2995                    .contains("duplicate `reads:` entry `crate::SIG`"),
2996                "got: {err}"
2997            ),
2998        }
2999        match syn::parse_str::<GraphSpec>(
3000            "pool P = [Terminate], deps: [], task: w, writes: [a::B, a::B], \
3001             policy: Pol::new(), min: 1, max: 1;",
3002        ) {
3003            Ok(_) => panic!("duplicate accepted on a pool"),
3004            Err(err) => assert!(
3005                err.to_string().contains("duplicate `writes:` entry `a::B`"),
3006                "got: {err}"
3007            ),
3008        }
3009    }
3010
3011    #[test]
3012    fn distinct_paths_sharing_a_segment_are_fine() {
3013        assert!(
3014            syn::parse_str::<GraphSpec>(
3015                "node A = Terminate, deps: [], reads: [a::SIG, b::SIG, SIG];",
3016            )
3017            .is_ok()
3018        );
3019    }
3020
3021    #[test]
3022    fn parked_node_may_declare_coupling() {
3023        assert!(
3024            syn::parse_str::<GraphSpec>(
3025                "node A = Terminate, deps: [], reads: [crate::IN], writes: [crate::OUT];",
3026            )
3027            .is_ok()
3028        );
3029    }
3030
3031    #[test]
3032    fn signal_list_takes_paths() {
3033        assert!(
3034            syn::parse_str::<GraphSpec>(
3035                "node A = Terminate, deps: [], reads: [SIG, ::root::SIG, a::b::C];",
3036            )
3037            .is_ok()
3038        );
3039        assert!(
3040            syn::parse_str::<GraphSpec>("node A = Terminate, deps: [], reads: [1 + 2];").is_err()
3041        );
3042    }
3043
3044    #[test]
3045    fn ack_timeout_accepted_and_validated() {
3046        assert!(
3047            syn::parse_str::<GraphSpec>(
3048                "node A = Terminate, deps: [], task: f, ack_timeout: 5000;\n\
3049                 pool P = [Terminate], deps: [], task: w, policy: Pol::new(), \
3050                 min: 1, max: 1, ack_timeout: 100;",
3051            )
3052            .is_ok()
3053        );
3054        for (src, needle) in [
3055            (
3056                "node A = Terminate, deps: [], task: f, ack_timeout: 0;",
3057                "must be at least 1",
3058            ),
3059            (
3060                "node A = Terminate, deps: [], task: f, ack_timeout: 10, ack_timeout: 20;",
3061                "duplicate `ack_timeout:` clause",
3062            ),
3063        ] {
3064            match syn::parse_str::<GraphSpec>(src) {
3065                Ok(_) => panic!("accepted: {src}"),
3066                Err(err) => assert!(err.to_string().contains(needle), "got: {err}"),
3067            }
3068        }
3069    }
3070
3071    #[test]
3072    fn beat_clauses_accepted() {
3073        assert!(
3074            syn::parse_str::<GraphSpec>(
3075                "node A = Terminate, deps: [], beat_timeout: 100, beat_window: 3;\n\
3076                 node B = Terminate, deps: [], beat_window: 2, beat_timeout: 50, slot_timeout: 200;",
3077            )
3078            .is_ok()
3079        );
3080    }
3081
3082    #[test]
3083    fn cfg_gated_clauses() {
3084        const P: &str = "#[cfg(feature = \"x\")]";
3085        let spec = syn::parse_str::<GraphSpec>(&format!(
3086            "node A = Terminate, deps: [], task: w, discover, \
3087             {P} slot_timeout: 100, {P} ack_timeout: 200, \
3088             {P} beat_timeout: 100, {P} beat_window: 3, {P} disabled;\n\
3089             node B = Terminate, deps: [], task: w, {P} discover, \
3090             provides: [{P} R1, R2];",
3091        ))
3092        .expect("cfg-gated clauses parse");
3093        let Item::Node(a) = &spec.items[0] else {
3094            unreachable!()
3095        };
3096        assert_eq!(a.slot_timeout.as_ref().unwrap().cfg.len(), 1);
3097        assert_eq!(a.ack_timeout.as_ref().unwrap().cfg.len(), 1);
3098        assert_eq!(a.beat_timeout.as_ref().unwrap().cfg.len(), 1);
3099        assert_eq!(a.beat_window.as_ref().unwrap().cfg.len(), 1);
3100        assert_eq!(a.disabled.as_ref().unwrap().cfg.len(), 1);
3101        assert!(
3102            a.discover.as_ref().unwrap().cfg.is_empty(),
3103            "un-gated stays empty"
3104        );
3105        let Item::Node(b) = &spec.items[1] else {
3106            unreachable!()
3107        };
3108        assert_eq!(b.discover.as_ref().unwrap().cfg.len(), 1);
3109        assert_eq!(b.provides[0].cfg.len(), 1);
3110        assert!(b.provides[1].cfg.is_empty());
3111
3112        // `ready_on_write` needs its prerequisites in place to parse at all.
3113        let spec = syn::parse_str::<GraphSpec>(&format!(
3114            "node A = Terminate, deps: [], task: w, \
3115             writes: [crate::S observed beat via it.get()], \
3116             {P} beat_timeout: 100, {P} ready_on_write;",
3117        ))
3118        .expect("gated ready_on_write parses beside its gated beat_timeout");
3119        let Item::Node(a) = &spec.items[0] else {
3120            unreachable!()
3121        };
3122        assert_eq!(a.ready_on_write.as_ref().unwrap().cfg.len(), 1);
3123
3124        // Structural clauses reject the gate, naming the gateable set.
3125        for clause in [
3126            "task: w",
3127            "spawn: f()",
3128            "executor: HIGH",
3129            "exit: u32",
3130            "state: u32 = 0",
3131            "cancel",
3132            "pool_size: 2",
3133            "deps: [X]",
3134            "resources: [R: u32]",
3135            "reads: [crate::S]",
3136            "provides: [R]",
3137            "dataflow: [crate::f]",
3138        ] {
3139            match syn::parse_str::<GraphSpec>(&format!(
3140                "node A = Terminate, deps: [], {P} {clause};"
3141            )) {
3142                Ok(_) => panic!("`#[cfg]` on `{clause}` accepted"),
3143                Err(err) => assert!(
3144                    err.to_string().contains("may only gate `slot_timeout:`"),
3145                    "`{clause}`: wrong error: {err}"
3146                ),
3147            }
3148        }
3149        match syn::parse_str::<GraphSpec>(
3150            "node A = Terminate, deps: [], #[allow(dead_code)] beat_timeout: 100;",
3151        ) {
3152            Ok(_) => panic!("a non-cfg attribute accepted"),
3153            Err(err) => assert!(
3154                err.to_string()
3155                    .contains("only `#[cfg(...)]` attributes may gate a clause"),
3156                "wrong error: {err}"
3157            ),
3158        }
3159    }
3160
3161    #[test]
3162    fn gated_beat_timeout_predicate_pairing() {
3163        const P: &str = "#[cfg(feature = \"x\")]";
3164        assert!(
3165            syn::parse_str::<GraphSpec>(&format!(
3166                "node A = Terminate, deps: [], beat_timeout: 100, {P} beat_window: 3;"
3167            ))
3168            .is_ok()
3169        );
3170        for (tail, needle) in [
3171            (
3172                "beat_window: 3",
3173                "`beat_window:` must carry the same `#[cfg]`",
3174            ),
3175            (
3176                "#[cfg(feature = \"y\")] beat_window: 3",
3177                "`beat_window:` must carry the same `#[cfg]`",
3178            ),
3179        ] {
3180            match syn::parse_str::<GraphSpec>(&format!(
3181                "node A = Terminate, deps: [], {P} beat_timeout: 100, {tail};"
3182            )) {
3183                Ok(_) => panic!("mismatched gate accepted: {tail}"),
3184                Err(err) => assert!(err.to_string().contains(needle), "{tail}: {err}"),
3185            }
3186        }
3187        match syn::parse_str::<GraphSpec>(&format!(
3188            "node A = Terminate, deps: [], task: w, \
3189             writes: [crate::S observed beat via it.get()], \
3190             {P} beat_timeout: 100, ready_on_write;"
3191        )) {
3192            Ok(_) => panic!("un-gated ready_on_write over a gated beat_timeout accepted"),
3193            Err(err) => assert!(
3194                err.to_string()
3195                    .contains("`ready_on_write` must carry the same `#[cfg]`"),
3196                "{err}"
3197            ),
3198        }
3199    }
3200
3201    #[test]
3202    fn duplicate_clauses_rejected() {
3203        for (src, needle) in [
3204            (
3205                "node A = Terminate, deps: [], deps: [];",
3206                "duplicate `deps:` clause",
3207            ),
3208            (
3209                "node A = Terminate, deps: [], task: f, task: g;",
3210                "duplicate `task:` clause",
3211            ),
3212            (
3213                "node A = Terminate, deps: [], reads: [crate::X], reads: [crate::Y];",
3214                "duplicate `reads:` clause",
3215            ),
3216            (
3217                "pool P = [Terminate], deps: [A], deps: [], task: w, \
3218                 policy: Pol::new(), min: 1, max: 1;",
3219                "duplicate `deps:` clause",
3220            ),
3221            (
3222                "pool P = [Terminate], deps: [], task: w, policy: Pol::new(), \
3223                 policy: Other::new(), min: 1, max: 1;",
3224                "duplicate `policy:` clause",
3225            ),
3226            (
3227                "pool P = [Terminate], deps: [], task: w, policy: Pol::new(), \
3228                 min: 1, max: 1, max: 2;",
3229                "duplicate `max:` clause",
3230            ),
3231            (
3232                "node A = Terminate, deps: [], beat_timeout: 100, beat_timeout: 200;",
3233                "duplicate `beat_timeout:` clause",
3234            ),
3235            (
3236                "node A = Terminate, deps: [], beat_timeout: 100, \
3237                 beat_window: 3, beat_window: 4;",
3238                "duplicate `beat_window:` clause",
3239            ),
3240            (
3241                "node A = Terminate, deps: [], disabled, disabled;",
3242                "duplicate `disabled` marker",
3243            ),
3244            (
3245                "node A = Terminate, deps: [], task: w, \
3246                 writes: [crate::S observed beat via it.get()], \
3247                 beat_timeout: 100, ready_on_write, ready_on_write;",
3248                "duplicate `ready_on_write` marker",
3249            ),
3250        ] {
3251            match syn::parse_str::<GraphSpec>(src) {
3252                Ok(_) => panic!("duplicate accepted: {src}"),
3253                Err(err) => assert!(err.to_string().contains(needle), "got: {err}"),
3254            }
3255        }
3256    }
3257
3258    #[test]
3259    fn malformed_cfg_attribute_rejected() {
3260        for attr in ["#[cfg]", "#[cfg = \"x\"]", "#[cfg] #[cfg(feature = \"x\")]"] {
3261            for decl in [
3262                format!("node A = Terminate, deps: [], {attr} disabled;"),
3263                format!("node A = Terminate, deps: [], provides: [{attr} R];"),
3264            ] {
3265                match syn::parse_str::<GraphSpec>(&decl) {
3266                    Ok(_) => panic!("malformed cfg accepted: {decl}"),
3267                    Err(err) => assert!(
3268                        err.to_string().contains("only `#[cfg(...)]` attributes"),
3269                        "{decl}: {err}"
3270                    ),
3271                }
3272            }
3273        }
3274    }
3275
3276    #[test]
3277    fn pool_clause_cfg_rejection_names_pool_alternatives() {
3278        const P: &str = "#[cfg(feature = \"x\")]";
3279        let spec = syn::parse_str::<GraphSpec>(&format!(
3280            "pool P = [Terminate], deps: [], task: w, \
3281             policy: Pol::new(), min: 1, max: 1, \
3282             {P} slot_timeout: 100, {P} ack_timeout: 200;"
3283        ))
3284        .expect("gated pool timeouts parse");
3285        let Item::Pool(p) = &spec.items[0] else {
3286            unreachable!()
3287        };
3288        assert_eq!(p.slot_timeout.as_ref().unwrap().cfg.len(), 1);
3289        assert_eq!(p.ack_timeout.as_ref().unwrap().cfg.len(), 1);
3290
3291        for clause in ["policy: Pol::new()", "min: 1", "beat_timeout: 100"] {
3292            match syn::parse_str::<GraphSpec>(&format!(
3293                "pool P = [Terminate], deps: [], task: w, {P} {clause};"
3294            )) {
3295                Ok(_) => panic!("`#[cfg]` on pool `{clause}` accepted"),
3296                Err(err) => {
3297                    let msg = err.to_string();
3298                    assert!(msg.contains("gate the whole pool"), "`{clause}`: {msg}");
3299                    assert!(!msg.contains("provides"), "`{clause}`: {msg}");
3300                }
3301            }
3302        }
3303    }
3304
3305    #[test]
3306    fn missing_comma_after_marked_entry_is_an_error() {
3307        match syn::parse_str::<GraphSpec>(
3308            "node A = Terminate, deps: [], task: f, \
3309             writes: [crate::S observed via it.get() beat];",
3310        ) {
3311            Ok(_) => panic!("phantom entry accepted"),
3312            Err(err) => assert!(err.to_string().contains("expected `,`"), "got: {err}"),
3313        }
3314        assert!(
3315            syn::parse_str::<GraphSpec>(
3316                "node A = Terminate, deps: [], task: f, \
3317                 writes: [crate::S observed beat via it.get(),];",
3318            )
3319            .is_ok(),
3320            "a trailing comma stays legal"
3321        );
3322    }
3323
3324    #[test]
3325    fn scan_records_fn_level_cfgs() {
3326        let mut out = Vec::new();
3327        scan_dataflow(
3328            "#[cfg(feature = \"x\")]\n#[dataflow]\nasync fn f(node: &'static TaskNode) \
3329             { node.put(&crate::S, 1); }",
3330            &mut out,
3331        );
3332        assert_eq!(out.len(), 1, "{out:?}");
3333        assert!(
3334            out[0].cfgs.iter().any(|c| c.contains("feature=")),
3335            "{:?}",
3336            out[0].cfgs
3337        );
3338    }
3339
3340    #[test]
3341    fn qualified_receiver_is_not_a_verb_call() {
3342        let body = quote!({
3343            self.node.put(&NOT_OURS, 1);
3344            foo::node.get(&ALSO_NOT);
3345            node.put(&OURS, 2);
3346        });
3347        let mut seen = Vec::new();
3348        rewrite_verb_calls(body, "node", &VerbTable::builtin(), &mut |call| {
3349            seen.push(call.path.clone());
3350            Ok(None)
3351        })
3352        .unwrap();
3353        assert_eq!(seen, ["OURS"]);
3354    }
3355
3356    fn parse_err(src: &str) -> String {
3357        syn::parse_str::<GraphSpec>(src)
3358            .err()
3359            .expect("rejected")
3360            .to_string()
3361    }
3362
3363    #[test]
3364    fn divisible_parses_bare_and_takes_no_type() {
3365        let spec = syn::parse_str::<GraphSpec>(
3366            "node A = Terminate, deps: [], task: f, resources: [P: divisible, Q: divisible];",
3367        )
3368        .unwrap();
3369        let Item::Node(n) = &spec.items[0] else {
3370            panic!("node")
3371        };
3372        assert_eq!(n.resources.len(), 2);
3373        assert_eq!(n.resources[0].kind(), ResourceKind::Divisible);
3374        assert!(n.resources[0].ty.is_none());
3375        assert_eq!(n.resources[1].kind(), ResourceKind::Divisible);
3376        let err =
3377            parse_err("node A = Terminate, deps: [], task: f, resources: [P: divisible u32];");
3378        assert!(err.contains("takes no type"), "{err}");
3379    }
3380
3381    #[test]
3382    fn divisible_is_exclusive_with_the_other_kinds() {
3383        for other in ["shared", "consume", "local"] {
3384            let err = parse_err(&format!(
3385                "node A = Terminate, deps: [], task: f, resources: [P: {other} divisible];"
3386            ));
3387            assert!(err.contains("its own kind"), "{other}: {err}");
3388        }
3389        let err = parse_err(
3390            "node A = Terminate, deps: [], task: f, resources: [P: divisible divisible];",
3391        );
3392        assert!(err.contains("duplicate `divisible`"), "{err}");
3393    }
3394
3395    #[test]
3396    fn a_type_named_divisible_needs_a_path() {
3397        let spec = syn::parse_str::<GraphSpec>(
3398            "node A = Terminate, deps: [], task: f, resources: [P: crate::divisible];",
3399        )
3400        .unwrap();
3401        let Item::Node(n) = &spec.items[0] else {
3402            panic!("node")
3403        };
3404        assert_eq!(n.resources[0].kind(), ResourceKind::Lend);
3405        assert!(n.resources[0].ty.is_some());
3406    }
3407
3408    #[test]
3409    fn serialized_only_qualifies_shared() {
3410        let err =
3411            parse_err("node A = Terminate, deps: [], task: f, resources: [B: serialized Bus];");
3412        assert!(err.contains("only qualifies `shared`"), "{err}");
3413        let spec = syn::parse_str::<GraphSpec>(
3414            "node A = Terminate, deps: [], task: f, resources: [B: shared serialized Bus];",
3415        )
3416        .unwrap();
3417        let Item::Node(n) = &spec.items[0] else {
3418            panic!("node")
3419        };
3420        assert_eq!(n.resources[0].kind(), ResourceKind::Shared);
3421        assert_eq!(n.resources[0].shared_signature(), "serialized shared Bus");
3422        assert_eq!(item_executor(&spec.items[0]).map(ToString::to_string), None);
3423    }
3424
3425    #[test]
3426    fn item_executor_names_the_routing_slot() {
3427        let spec = syn::parse_str::<GraphSpec>(
3428            "executor HIGH; node A = Terminate, deps: [], executor: HIGH, spawn: f;",
3429        )
3430        .unwrap();
3431        assert_eq!(item_executor(&spec.items[0]), None);
3432        assert_eq!(
3433            item_executor(&spec.items[1]).map(ToString::to_string),
3434            Some("HIGH".into())
3435        );
3436    }
3437
3438    #[test]
3439    fn default_executor_routes_every_eligible_item() {
3440        let spec = syn::parse_str::<GraphSpec>(
3441            "default executor THREAD; executor HIGH; \
3442             node A = Terminate, deps: [], task: f; \
3443             node B = Terminate, deps: [], executor: HIGH, task: f; \
3444             node C = Pause, deps: []; \
3445             node D = Terminate, deps: [], spawn: |s| { let _ = s; Ok(()) }; \
3446             node E = Terminate, deps: [], spawn: g; \
3447             pool P = [Terminate], deps: [], task: f, policy: p, min: 1, max: 1;",
3448        )
3449        .unwrap();
3450        assert_eq!(spec.default_executor.as_ref().unwrap(), "THREAD");
3451        let Item::Executor(x) = &spec.items[0] else {
3452            panic!("executor")
3453        };
3454        assert!(x.default);
3455        let Item::Executor(x) = &spec.items[1] else {
3456            panic!("executor")
3457        };
3458        assert!(!x.default);
3459        let ex = |i: usize| item_executor(&spec.items[i]).map(ToString::to_string);
3460        let defaulted = |i: usize| match &spec.items[i] {
3461            Item::Node(n) => n.executor_defaulted,
3462            Item::Pool(p) => p.executor_defaulted,
3463            Item::Executor(_) => false,
3464        };
3465        // A (`task:`) and E (`spawn:` path) inherit; B keeps its own tier.
3466        assert_eq!(ex(2), Some("THREAD".into()));
3467        assert!(defaulted(2));
3468        assert_eq!(ex(3), Some("HIGH".into()));
3469        assert!(!defaulted(3));
3470        // C is parked and D spawns through a verbatim closure: the macro's own
3471        // guards reject `executor:` on both, so neither inherits.
3472        assert_eq!(ex(4), None);
3473        assert_eq!(ex(5), None);
3474        assert_eq!(ex(6), Some("THREAD".into()));
3475        assert!(defaulted(6));
3476        assert_eq!(ex(7), Some("THREAD".into()));
3477        assert!(defaulted(7));
3478    }
3479
3480    #[test]
3481    fn default_executor_applies_across_fragment_markers() {
3482        let spec = syn::parse_str::<GraphSpec>(
3483            "default executor THREAD; \
3484             @fragment F; node A = Terminate, deps: [], task: f; @endfragment;",
3485        )
3486        .unwrap();
3487        assert_eq!(
3488            item_executor(&spec.items[1]).map(ToString::to_string),
3489            Some("THREAD".into())
3490        );
3491    }
3492
3493    #[test]
3494    fn default_executor_rejects_cfg_fragment_and_duplicate() {
3495        let cases = [
3496            (
3497                "#[cfg(feature = \"x\")] default executor THREAD;",
3498                "cannot be `#[cfg]`-gated",
3499            ),
3500            (
3501                "@fragment F; default executor THREAD; @endfragment;",
3502                "fragment cannot declare",
3503            ),
3504            (
3505                "default executor A; default executor B;",
3506                "duplicate `default executor`",
3507            ),
3508        ];
3509        for (src, needle) in cases {
3510            let err = syn::parse_str::<GraphSpec>(src)
3511                .err()
3512                .unwrap_or_else(|| panic!("`{src}` parsed"))
3513                .to_string();
3514            assert!(err.contains(needle), "{src}: {err}");
3515        }
3516    }
3517
3518    #[test]
3519    fn local_may_route_through_an_executor() {
3520        let spec = syn::parse_str::<GraphSpec>(
3521            "executor HIGH; node A = Terminate, deps: [], executor: HIGH, task: f, \
3522             resources: [R: local consume T];",
3523        )
3524        .expect("`local` + `executor:` is a per-slot tier check in the macro, not a parse error");
3525        assert_eq!(
3526            item_executor(&spec.items[1]).map(ToString::to_string),
3527            Some("HIGH".into())
3528        );
3529    }
3530
3531    #[test]
3532    fn veto_is_a_writes_marker_in_any_order() {
3533        let spec = syn::parse_str::<GraphSpec>(
3534            "observe writes: it.get();\n\
3535             node A = Terminate, deps: [], task: f, \
3536             writes: [crate::T veto, crate::U observed veto beat, crate::V beat veto observed via it.x()];",
3537        )
3538        .unwrap();
3539        let Item::Node(n) = &spec.items[0] else {
3540            panic!("node")
3541        };
3542        assert!(n.writes[0].veto.is_some() && n.writes[0].observed.is_none());
3543        assert!(
3544            n.writes[1].veto.is_some()
3545                && n.writes[1].beat.is_some()
3546                && n.writes[1].observed.is_some()
3547        );
3548        assert!(n.writes[2].via.is_some() && n.writes[2].veto.is_some());
3549        let err = parse_err("node A = Terminate, deps: [], task: f, writes: [crate::T veto veto];");
3550        assert!(err.contains("duplicate `veto`"), "{err}");
3551        let err = parse_err("node A = Terminate, deps: [], task: f, reads: [crate::T veto];");
3552        assert!(err.contains("belongs on a `writes:` entry"), "{err}");
3553        let err =
3554            parse_err("node A = Terminate, deps: [], task: f, writes: [crate::T via it.x()];");
3555        assert!(err.contains("only an `observed` entry has"), "{err}");
3556        let err = parse_err(
3557            "node A = Terminate, deps: [], task: f, writes: [crate::T observed via it.x() veto];",
3558        );
3559        assert!(
3560            err.contains("expected `,`"),
3561            "`via <expr>` ends the entry: {err}"
3562        );
3563        let err = parse_err("node A = Terminate, deps: [], task: f, writes: [crate::T bogus];");
3564        assert!(err.contains("`observed`/`beat`/`veto` markers"), "{err}");
3565        assert!(BUILTIN_WRITES.contains(&"veto") && BUILTIN_WRITES.contains(&"retire"));
3566    }
3567
3568    #[test]
3569    fn veto_beside_discover_is_a_marker() {
3570        assert!(
3571            syn::parse_str::<GraphSpec>(
3572                "node A = Terminate, deps: [], task: w, discover, writes: [crate::T veto];"
3573            )
3574            .is_ok()
3575        );
3576    }
3577}