Skip to main content

polydat_grammar/
ast.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Abstract syntax tree for the Polydat DSL.
5
6use crate::lexer::Span;
7
8/// A complete `.polydat` file.
9#[derive(Debug, Clone)]
10pub struct PolydatFile {
11    /// The statements, in document order.
12    pub statements: Vec<Statement>,
13}
14
15/// A top-level statement.
16#[derive(Debug, Clone)]
17pub enum Statement {
18    /// `input name[: type]` — declares one per-cycle kernel input slot.
19    /// The name becomes both an input slot (settable via `set_input`)
20    /// and a passthrough output (readable via `get_constant`/`pull`).
21    ///
22    /// Surface forms (parser desugars the tuple into N `InputDecl`s,
23    /// mirroring the module-signature param-list shape from
24    /// a host-provided cycle module):
25    /// ```text
26    /// input cycle: u64
27    /// input (cycle: u64, q: f64)
28    /// ```
29    InputDecl(InputDecl),
30    /// A name-to-expression binding. The modifier on the
31    /// binding determines its lifecycle:
32    ///
33    /// - **no modifier** — per-cycle: re-evaluated every cycle.
34    /// - **`const`** — effectively-const for the scope's
35    ///   lifetime: materialized at the earliest opportunity
36    ///   (compile-time fold if the RHS is fold-eligible,
37    ///   otherwise scope-init pull after materialize-wiring
38    ///   has populated extern slots). Authors don't need to
39    ///   know which path the runtime takes — the contract is
40    ///   "fixed once, then immutable."
41    /// - **`shared`** — cell-backed, mutable across kernel
42    ///   instances in the same lineage. See SRD-16
43    ///   §"Mutability Rules: Shared Mutable".
44    /// - **`volatile`** — per-cycle, excluded from
45    ///   `hash_const`. See SRD-44.
46    ///
47    /// Surface forms:
48    /// ```text
49    /// x := mul(cycle, 2)                  // per-cycle
50    /// const pi := 3.14                    // const, folds at compile
51    /// const ann_opts := str_concat(...)   // const, materializes at scope-init
52    /// shared budget := 100                // shared cell
53    /// (a, b) := split_pair(...)           // tuple destructuring
54    /// ```
55    Binding(Binding),
56    /// `name(param: type, ...) -> (output: type, ...) := { body }`
57    ModuleDef(ModuleDef),
58    /// `extern name: type = default`
59    ExternPort(ExternPort),
60    /// `cursor name = Cursor()` or `cursor name = constructor_expr`
61    Cursor(CursorDecl),
62    /// `pragma <name>` — a module-level directive opting into a
63    /// compile-time graph transform (SRD 15 §"Module-Level
64    /// Pragmas"). First-class grammar, distinct from line
65    /// comments. Recognised pragmas trigger
66    /// `CompileEvent::PragmaAcknowledged`; unknown names trigger
67    /// `CompileEvent::UnknownPragma` and are otherwise ignored
68    /// (forward-compatible).
69    Pragma {
70        /// The pragma's name, after the `#`.
71        name: String,
72        /// Where the pragma appears.
73        span: Span,
74    },
75    /// `for <source> { body }` — a traversal scope (SRD 113 §3.2).
76    /// One child scope activates per tuple of the source; the
77    /// comprehension's element names are wires inside the body.
78    For(ForStmt),
79    /// `tile name : encoding (options) := body` — a compiled variate
80    /// template (SRD 114 §2).
81    Tile(TileDef),
82}
83
84/// Per-tile template options (SRD 114 §2.3): the hole delimiters, the
85/// directive sigil, and strictness.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct TileOptions {
88    /// The text that opens a hole, `${` by default.
89    pub open: String,
90    /// The text that closes a hole, `}` by default.
91    pub close: String,
92    /// The directive sigil, `@` by default.
93    pub sigil: String,
94    /// Whether an unknown hole or directive is an error rather than text.
95    pub strict: bool,
96    /// The body begins inside a JSON string literal (`instring`): holes
97    /// encode as escaped text from the first byte. The compiler sets
98    /// this on the tile it makes for a projection nested in a string
99    /// position; authors rarely need it.
100    pub in_string: bool,
101}
102
103impl Default for TileOptions {
104    fn default() -> Self {
105        Self {
106            open: "${".into(),
107            close: "}".into(),
108            sigil: "@".into(),
109            strict: false,
110            in_string: false,
111        }
112    }
113}
114
115/// How a tile body was written, so the printer can reproduce it.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum TileBodyKind {
118    /// A brace- or bracket-balanced block, as `json` templates are written.
119    Block,
120    /// Text between `<<<` and `>>>`.
121    Heredoc,
122    /// An ordinary string literal.
123    Literal,
124}
125
126/// A tile definition: its header, its raw body, and the parsed template.
127#[derive(Debug, Clone)]
128pub struct TileDef {
129    /// The tile's name: the wire it binds.
130    pub name: String,
131    /// The declared encoding, such as `json` or `csv`, if any.
132    pub encoding: Option<String>,
133    /// The delimiter and strictness options.
134    pub options: TileOptions,
135    /// How the body was written: heredoc or string literal.
136    pub body_kind: TileBodyKind,
137    /// The body text exactly as captured.
138    pub body: String,
139    /// The body parsed into static runs, holes, projections, and branches.
140    pub pieces: Vec<TilePiece>,
141    /// Where the definition appears.
142    pub span: Span,
143}
144
145/// One element of a parsed template.
146#[derive(Debug, Clone)]
147pub enum TilePiece {
148    /// Bytes copied as written.
149    Static(String),
150    /// `${expr : type | format !}`.
151    Hole(TileHole),
152    /// `@for <source> [sep "..."] { body }`.
153    Projection {
154        /// What the projection iterates.
155        source: ForSource,
156        /// The separator emitted between tuples, if any.
157        sep: Option<String>,
158        /// The template rendered once per tuple.
159        body: Vec<TilePiece>,
160        /// Where the directive appears.
161        span: Span,
162    },
163    /// `@if cond { body } [@else { body }]`.
164    Branch {
165        /// The condition, a boolean expression.
166        cond: Expr,
167        /// The template rendered when the condition holds.
168        then: Vec<TilePiece>,
169        /// The template rendered otherwise, if an `@else` was written.
170        otherwise: Option<Vec<TilePiece>>,
171        /// Where the directive appears.
172        span: Span,
173    },
174}
175
176/// A hole: an expression with an optional declared type, format spec,
177/// and raw flag.
178#[derive(Debug, Clone)]
179pub struct TileHole {
180    /// The text between the delimiters, as written.
181    pub text: String,
182    /// The expression the hole evaluates.
183    pub expr: Expr,
184    /// The declared type after the colon, if any.
185    pub decl_type: Option<String>,
186    /// The format after the bar, if any.
187    pub format: Option<String>,
188    /// Whether the value is emitted without the encoding's escaping.
189    pub raw: bool,
190    /// Where the hole appears.
191    pub span: Span,
192}
193
194/// What a `for` iterates: inline comprehension text, or the name of a
195/// bound producer wire.
196#[derive(Debug, Clone)]
197pub struct ForSource {
198    /// The raw text after `for`, preserved for diagnostics and for
199    /// pretty-printing round trips.
200    pub text: String,
201    /// What the text denotes once parsed.
202    pub kind: ForSourceKind,
203    /// Where the source appears.
204    pub span: Span,
205}
206
207#[derive(Debug, Clone)]
208/// The parsed form of a `for` source.
209pub enum ForSourceKind {
210    /// A bare identifier naming a `Streamer` wire bound by a `for`
211    /// expression elsewhere in scope.
212    Producer(String),
213    /// Comprehension text, parsed to the algebra AST.
214    Comprehension(crate::comprehension::Comprehension),
215    /// A derivation of a bound producer: `base where <pred>`,
216    /// `base order <spec>`, or both (SRD 113 §3.1). Resolved against
217    /// the producer at compile time.
218    Derived {
219        /// The producer wire the derivation starts from.
220        base: String,
221        /// The `where` predicate text, if any.
222        filter: Option<String>,
223        /// The `order` specification text, if any.
224        order: Option<String>,
225    },
226}
227
228impl ForSource {
229    /// The element names the source dispenses, when known statically.
230    /// A producer reference or derivation resolves its names at compile
231    /// time.
232    pub fn element_names(&self) -> Vec<String> {
233        match &self.kind {
234            ForSourceKind::Producer(_) | ForSourceKind::Derived { .. } => Vec::new(),
235            ForSourceKind::Comprehension(c) => c.coordinate_names(),
236        }
237    }
238}
239
240/// A traversal statement: `for <source> { statements }`.
241#[derive(Debug, Clone)]
242pub struct ForStmt {
243    /// What the traversal iterates.
244    pub source: ForSource,
245    /// The body: one child scope per tuple.
246    pub body: Vec<Statement>,
247    /// Where the statement appears.
248    pub span: Span,
249}
250
251/// An external input port declaration.
252///
253/// Ports persist across `set_inputs()` calls within a stanza.
254/// Written by capture extraction, read by Polydat nodes.
255///
256/// ```text
257/// extern balance: f64 = 0.0
258/// extern session_id: u64 = 0
259/// ```
260#[derive(Debug, Clone)]
261pub struct ExternPort {
262    /// The port's name.
263    pub name: String,
264    /// The declared type keyword.
265    pub typ: String,
266    /// The default value, if one was written; without one the port is `None` until set.
267    pub default: Option<Expr>,
268    /// Where the declaration appears.
269    pub span: Span,
270}
271
272/// One per-cycle kernel input slot.
273///
274/// Declared by `input <name>[: <type>]` (single) or
275/// `input (<name>[: <type>], ...)` (tuple, sugar for N decls).
276/// The name participates in the kernel's input-port wiring just
277/// like `extern` participates in its port set, but inputs are
278/// driven by the runtime cycle pump (cursors, captures, etc.)
279/// rather than by external port writes.
280///
281/// `ty` is `None` when the author omitted the annotation; typed
282/// downstream by inference. Authors are encouraged to declare
283/// the type for clarity and editor support.
284#[derive(Debug, Clone)]
285pub struct InputDecl {
286    /// The input's name.
287    pub name: String,
288    /// The declared type keyword, if one was written.
289    pub ty: Option<String>,
290    /// Where the declaration appears.
291    pub span: Span,
292}
293
294/// One wire-coloring keyword. The single enum that names every
295/// modifier the grammar recognises before a binding name.
296/// Future modifiers are new variants here.
297///
298/// Each variant maps to a token the lexer emits and a parser
299/// branch in `parse_modified_binding`. A binding can carry zero
300/// or more of these, stored as a [`BindingModifier`] set.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
302pub enum WireModifier {
303    /// `const` — effectively-const for the scope's lifetime.
304    /// Materialized at the earliest opportunity: compile-time
305    /// const-fold when the RHS is fold-eligible, otherwise the
306    /// scope-init pull pass after materialize-wiring has
307    /// populated extern slots. The runtime contract is "fixed
308    /// once per scope activation, then immutable for the rest of
309    /// the scope's lifetime." Replaces the former `final` /
310    /// `init` distinction — the two were redundant axes of the
311    /// same lifecycle, and the surface now collapses to one
312    /// keyword whose materialization timing is an internal
313    /// optimization.
314    Const,
315    /// `shared` — mutable cell visible across kernel instances.
316    /// The runtime propagates iteration N's end state into
317    /// iteration N+1's start state.
318    Shared,
319    /// `volatile` — wire's value is excluded from `hash_const`
320    /// (the const-folded identity hash). Authors mark wires
321    /// whose value should NOT contribute to resume-identity
322    /// even when the source's structural detection would
323    /// otherwise allow folding. See SRD-44 + design memo
324    /// `resumable_test_fixture.md`.
325    Volatile,
326}
327
328/// Set of wire modifiers carried by one binding declaration.
329/// Stored as a bitset under the hood; consumers use
330/// [`Self::has`] to test for individual modifiers and
331/// [`Self::insert`] / `Self::from_iter` to build instances.
332///
333/// **Validity:** the combination `const` + `volatile` is
334/// rejected at parse time as contradictory (`Self::from_iter`
335/// is the validating builder). All other combinations are
336/// representable.
337///
338/// Lives on every [`Statement::Binding`] — the modifier set
339/// determines the binding's lifecycle. Other statement kinds
340/// (`ExternPort`, `InputDecl`, etc.) don't carry modifiers
341/// because their semantics are fixed by their statement form.
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
343pub struct BindingModifier {
344    bits: u8,
345}
346
347impl BindingModifier {
348    /// All-modifiers-off; the default state of an unannotated
349    /// binding (per-cycle).
350    pub const NONE: Self = Self { bits: 0 };
351
352    /// Single-modifier convenience constants. Tests reach for
353    /// these to express their intent compactly.
354    pub const CONST: Self = Self {
355        bits: Self::bit(WireModifier::Const),
356    };
357    /// The `shared` modifier alone.
358    pub const SHARED: Self = Self {
359        bits: Self::bit(WireModifier::Shared),
360    };
361    /// The `volatile` modifier alone.
362    pub const VOLATILE: Self = Self {
363        bits: Self::bit(WireModifier::Volatile),
364    };
365
366    /// `true` iff `m` is set.
367    pub const fn has(&self, m: WireModifier) -> bool {
368        self.bits & Self::bit(m) != 0
369    }
370
371    /// `true` iff at least one modifier is set.
372    pub const fn has_any(&self) -> bool {
373        self.bits != 0
374    }
375
376    /// Add `m` to the set.
377    pub fn insert(&mut self, m: WireModifier) {
378        self.bits |= Self::bit(m);
379    }
380
381    /// Build a modifier set from an iterator of variants. The
382    /// parser uses this after collecting tokens. Rejects the
383    /// contradictory `const` + `volatile` combo with a clear
384    /// error.
385    pub fn try_from_iter<I: IntoIterator<Item = WireModifier>>(
386        items: I,
387    ) -> Result<Self, &'static str> {
388        let mut out = Self::NONE;
389        for m in items {
390            out.insert(m);
391        }
392        if out.has(WireModifier::Const) && out.has(WireModifier::Volatile) {
393            return Err(
394                "modifier conflict: `const` and `volatile` are contradictory \
395                 — `const` materializes the value once and freezes it; \
396                 `volatile` excludes the wire from const-fold and signals \
397                 per-cycle variability. Drop one.",
398            );
399        }
400        Ok(out)
401    }
402
403    /// Iterate the modifiers in the set, in fixed declaration
404    /// order (`Const`, `Shared`, `Volatile`). Used for
405    /// re-emission and stable hash output.
406    pub fn iter(&self) -> impl Iterator<Item = WireModifier> + '_ {
407        const ORDER: &[WireModifier] = &[
408            WireModifier::Const,
409            WireModifier::Shared,
410            WireModifier::Volatile,
411        ];
412        ORDER.iter().copied().filter(move |m| self.has(*m))
413    }
414
415    /// Direct field-style accessors retained for sites that
416    /// pattern-match on individual flags. Mechanically derive
417    /// from `has(...)` so adding a new modifier is one variant
418    /// + one bit assignment + (optionally) one accessor.
419    #[inline]
420    pub const fn is_const(&self) -> bool {
421        self.has(WireModifier::Const)
422    }
423    #[inline]
424    /// `true` iff `shared` is set.
425    pub const fn is_shared(&self) -> bool {
426        self.has(WireModifier::Shared)
427    }
428    #[inline]
429    /// `true` iff `volatile` is set.
430    pub const fn is_volatile(&self) -> bool {
431        self.has(WireModifier::Volatile)
432    }
433
434    /// Compile-time bit index for a modifier.
435    const fn bit(m: WireModifier) -> u8 {
436        match m {
437            WireModifier::Const => 1 << 0,
438            WireModifier::Shared => 1 << 1,
439            WireModifier::Volatile => 1 << 2,
440        }
441    }
442}
443
444/// A name-to-expression binding (per-cycle by default;
445/// `const`/`shared`/`volatile` modifier changes the
446/// lifecycle). Replaces the former `CycleBinding` and
447/// `InitBinding` AST variants — the surface unified to one
448/// shape `name := expr` (or `(a, b, c) := expr` for tuple
449/// destructuring), with the modifier driving runtime
450/// lifecycle.
451#[derive(Debug, Clone)]
452pub struct Binding {
453    /// The bound names: one, or several for tuple destructuring.
454    pub targets: Vec<String>,
455    /// The right-hand side.
456    pub value: Expr,
457    /// The wire-coloring keywords written before the names.
458    pub modifier: BindingModifier,
459    /// Optional explicit type annotation — `shared name: f64 := 1`.
460    /// Only meaningful on `shared` bindings (scope_model.md §"Type
461    /// stability"): it pins the CELL's PortType for life, winning over
462    /// literal inference (so `1` vs `1.0` stops being load-bearing).
463    /// The parser rejects annotations on non-shared bindings.
464    pub type_annotation: Option<String>,
465    /// Where the binding appears.
466    pub span: Span,
467}
468
469/// A cursor declaration: `cursor name = Cursor() [over partition_source]`
470///
471/// Declares a named positional cursor. The cursor's extent is
472/// discovered at init time by interrogating its downstream consumers
473/// for cardinality. The runtime advances the cursor to drive
474/// phase iteration.
475///
476/// The optional `over` clause (SRD 71) names a partition source
477/// — an in-scope wire that resolves to a `Partition` or a
478/// `PartitionList`. When bound, the cursor's effective extent
479/// narrows to the named partition's `[start_ord, end_ord)`
480/// range; without it, the cursor uses its full declared extent.
481#[derive(Debug, Clone)]
482pub struct CursorDecl {
483    /// The cursor's name.
484    pub name: String,
485    /// The constructor call, such as `range(0, 100)`.
486    pub constructor: Expr,
487    /// SRD 71 `over <expr>` clause. The expression is parsed
488    /// the same way as any other Polydat expression so authors can
489    /// name a workload parameter's `.partitions` projection
490    /// (e.g. `cursor.partitions`), an iter-var bound by an
491    /// enclosing `for:`, or a sibling cursor's `.cursor`
492    /// projection (`q1.cursor`). `None` means no narrowing —
493    /// the cursor uses its full declared extent.
494    pub over: Option<Expr>,
495    /// Where the declaration appears.
496    pub span: Span,
497}
498
499/// An expression (right-hand side of a binding).
500#[derive(Debug, Clone)]
501pub enum Expr {
502    /// A bare identifier referencing a wire or init binding: `cycle`, `lut`
503    Ident(String, Span),
504    /// An integer literal: `1000`
505    IntLit(u64, Span),
506    /// A float literal: `72.0`
507    FloatLit(f64, Span),
508    /// A string literal (may contain `{name}` interpolation): `"hello {name}"`
509    StringLit(String, Span),
510    /// An array literal: `[60.0, 20.0, 15.0]`
511    ArrayLit(Vec<Expr>, Span),
512    /// A function call: `hash(cycle)`, `dist_normal(mean: 72.0, stddev: 5.0)`
513    Call(CallExpr),
514    /// A binary arithmetic operation: `a + b`, `x * 0.25`.
515    /// Desugared by the compiler into the equivalent function call.
516    BinOp(Box<Expr>, BinOpKind, Box<Expr>),
517    /// Unary negation: `-x`.
518    /// Desugared to `f64_sub(0.0, x)`.
519    UnaryNeg(Box<Expr>, Span),
520    /// Unary bitwise NOT: `!x`.
521    /// Desugared to `u64_not(x)`.
522    UnaryBitNot(Box<Expr>, Span),
523    /// Source field projection: `base.ordinal`, `base.vector`.
524    /// Resolved by the compiler to a node that reads from the source item.
525    FieldAccess {
526        /// The wire the field is read from.
527        source: String,
528        /// The field's name.
529        field: String,
530        /// Where the projection appears.
531        span: Span,
532    },
533    /// `<expr> as <type>` — SRD-84 Part 1b type-coercion cast. An
534    /// *optional, alignment-only* type-fusion infill: a no-op when the
535    /// inner expression's type already matches the target, otherwise
536    /// the compiler inserts the SRD-79 fusion adapter (or errors if no
537    /// valid fusion exists). The cast's type is its target.
538    Cast(Box<Expr>, crate::PortType, Span),
539    /// `for <comprehension>` in expression position — a comprehension
540    /// producer (SRD 113 §3.1). Binds a `Streamer` wire.
541    For(Box<ForSource>),
542}
543
544/// Binary arithmetic operator kind.
545#[derive(Debug, Clone, Copy)]
546pub enum BinOpKind {
547    /// `+` — desugars to `u64_add` or `f64_add` based on operand types
548    Add,
549    /// `-` — desugars to `u64_sub` or `f64_sub` based on operand types
550    Sub,
551    /// `*` — desugars to `u64_mul` or `f64_mul` based on operand types
552    Mul,
553    /// `/` — desugars to `u64_div` or `f64_div` based on operand types
554    Div,
555    /// `%` — desugars to `u64_mod` or `f64_mod` based on operand types
556    Mod,
557    /// `**` — desugars to `pow(a, b)` (always f64)
558    Pow,
559    /// `&` — desugars to `u64_and(a, b)`
560    BitAnd,
561    /// `|` — desugars to `u64_or(a, b)`
562    BitOr,
563    /// `^` — desugars to `u64_xor(a, b)`
564    BitXor,
565    /// `<<` — desugars to `u64_shl(a, b)`
566    Shl,
567    /// `>>` — desugars to `u64_shr(a, b)`
568    Shr,
569    /// `==` — desugars to `u64_eq` / `f64_eq`. Output type is `u64`
570    /// (0 = false, 1 = true).
571    Eq,
572    /// `!=` — desugars to `u64_ne` / `f64_ne`. Output type is `u64`.
573    Ne,
574    /// `<` — desugars to `u64_lt` / `f64_lt`. Output type is `u64`.
575    Lt,
576    /// `>` — desugars to `u64_gt` / `f64_gt`. Output type is `u64`.
577    Gt,
578    /// `<=` — desugars to `u64_le` / `f64_le`. Output type is `u64`.
579    Le,
580    /// `>=` — desugars to `u64_ge` / `f64_ge`. Output type is `u64`.
581    Ge,
582    /// `&&` — eager logical-and (SRD-84 Part 1). Desugars to
583    /// `u64_and(a != 0, b != 0)`: both operands evaluate, each is
584    /// normalised to truthiness (`0`/`1`), and the bitwise-and of two
585    /// truthiness values is logical-and. Output type is `u64` (`0`/`1`).
586    /// Lowest precedence, below comparison. Short-circuit is a deferred
587    /// optimisation (SRD-84 §"eager").
588    And,
589    /// `||` — eager logical-or (SRD-84 Part 1). Desugars to
590    /// `u64_or(a != 0, b != 0)`. Output type is `u64` (`0`/`1`). Binds
591    /// looser than `&&`.
592    Or,
593}
594
595/// A typed parameter in a module signature.
596#[derive(Debug, Clone)]
597pub struct TypedParam {
598    /// The parameter's name.
599    pub name: String,
600    /// The declared type keyword.
601    pub typ: String, // "u64", "f64", "String", "bytes", etc.
602}
603
604/// A formal module definition with typed interface.
605///
606/// ```text
607/// hash_range(input: u64, max: u64) -> (value: u64) := {
608///     h := hash(input)
609///     value := mod(h, max)
610/// }
611/// ```
612#[derive(Debug, Clone)]
613pub struct ModuleDef {
614    /// The module's name.
615    pub name: String,
616    /// The typed inputs, in signature order.
617    pub params: Vec<TypedParam>,
618    /// The typed outputs, in signature order.
619    pub outputs: Vec<TypedParam>,
620    /// The module body.
621    pub body: Vec<Statement>,
622    /// Where the definition appears.
623    pub span: Span,
624}
625
626/// A function call expression.
627#[derive(Debug, Clone)]
628pub struct CallExpr {
629    /// The function's name.
630    pub func: String,
631    /// The arguments, in call order.
632    pub args: Vec<Arg>,
633    /// Where the call appears.
634    pub span: Span,
635}
636
637/// A function argument: positional or named.
638#[derive(Debug, Clone)]
639pub enum Arg {
640    /// Positional: just an expression
641    Positional(Expr),
642    /// Named: `name: expr`
643    Named(String, Expr),
644}
645
646#[cfg(test)]
647mod modifier_tests {
648    use super::*;
649
650    #[test]
651    fn empty_set_has_no_modifiers() {
652        let m = BindingModifier::NONE;
653        assert!(!m.has_any());
654        assert!(!m.is_const() && !m.is_shared() && !m.is_volatile());
655    }
656
657    #[test]
658    fn single_modifier_consts_match_expected_flags() {
659        assert!(BindingModifier::CONST.is_const());
660        assert!(!BindingModifier::CONST.is_shared());
661        assert!(!BindingModifier::CONST.is_volatile());
662
663        assert!(BindingModifier::SHARED.is_shared());
664        assert!(!BindingModifier::SHARED.is_const());
665
666        assert!(BindingModifier::VOLATILE.is_volatile());
667        assert!(!BindingModifier::VOLATILE.is_const());
668    }
669
670    #[test]
671    fn from_iter_collects_combinations() {
672        let m = BindingModifier::try_from_iter([WireModifier::Const, WireModifier::Shared])
673            .expect("const+shared is valid");
674        assert!(m.is_const() && m.is_shared());
675        assert!(!m.is_volatile());
676
677        let m = BindingModifier::try_from_iter([WireModifier::Shared, WireModifier::Volatile])
678            .expect("shared+volatile is valid");
679        assert!(m.is_shared() && m.is_volatile());
680    }
681
682    #[test]
683    fn from_iter_rejects_const_plus_volatile() {
684        let err = BindingModifier::try_from_iter([WireModifier::Const, WireModifier::Volatile])
685            .expect_err("const+volatile must be rejected");
686        assert!(
687            err.contains("const") && err.contains("volatile"),
688            "error should name both keywords: {err}"
689        );
690    }
691
692    #[test]
693    fn from_iter_rejects_const_shared_volatile() {
694        // Triple combination subsumes the contradiction.
695        let err = BindingModifier::try_from_iter([
696            WireModifier::Const,
697            WireModifier::Shared,
698            WireModifier::Volatile,
699        ])
700        .expect_err("triple combo includes the contradictory pair");
701        assert!(err.contains("const") && err.contains("volatile"));
702    }
703
704    #[test]
705    fn iter_yields_modifiers_in_stable_order() {
706        let m =
707            BindingModifier::try_from_iter([WireModifier::Volatile, WireModifier::Shared]).unwrap();
708        // Insertion order was Volatile, Shared — but iter yields
709        // in fixed declaration order: Const, Shared, Volatile.
710        let collected: Vec<_> = m.iter().collect();
711        assert_eq!(
712            collected,
713            vec![WireModifier::Shared, WireModifier::Volatile]
714        );
715    }
716
717    #[test]
718    fn equality_distinguishes_combinations() {
719        let const_only = BindingModifier::CONST;
720        let const_shared =
721            BindingModifier::try_from_iter([WireModifier::Const, WireModifier::Shared]).unwrap();
722        assert_ne!(
723            const_only, const_shared,
724            "const-only must not equal const+shared"
725        );
726    }
727}