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
43    ///   `crates/polydat/docs/design/scope_model.md` §6
44    ///   "Shared mutable bindings".
45    /// - **`volatile`** — per-cycle, excluded from
46    ///   `hash_const`.
47    ///
48    /// Surface forms:
49    /// ```text
50    /// x := mul(cycle, 2)                  // per-cycle
51    /// const pi := 3.14                    // const, folds at compile
52    /// const ann_opts := str_concat(...)   // const, materializes at scope-init
53    /// shared budget := 100                // shared cell
54    /// (a, b) := split_pair(...)           // tuple destructuring
55    /// ```
56    Binding(Binding),
57    /// `name(param: type, ...) -> (output: type, ...) := { body }`
58    ModuleDef(ModuleDef),
59    /// `extern name: type = default`
60    ExternPort(ExternPort),
61    /// `cursor name = Cursor()` or `cursor name = constructor_expr`
62    Cursor(CursorDecl),
63    /// `pragma <name>` — a module-level directive opting into a
64    /// compile-time graph transform (SRD 15 §"Module-Level
65    /// Pragmas"). First-class grammar, distinct from line
66    /// comments. Recognised pragmas trigger
67    /// `CompileEvent::PragmaAcknowledged`; unknown names trigger
68    /// `CompileEvent::UnknownPragma` and are otherwise ignored
69    /// (forward-compatible).
70    Pragma {
71        /// The pragma's name, after the `pragma` keyword.
72        name: String,
73        /// Where the pragma appears.
74        span: Span,
75    },
76    /// `for <source> { body }` — a traversal scope (SRD 113 §3.2).
77    /// One child scope activates per tuple of the source; the
78    /// comprehension's element names are wires inside the body.
79    For(ForStmt),
80    /// `tile name : encoding (options) := body` — a compiled variate
81    /// template (SRD 114 §2).
82    Tile(TileDef),
83}
84
85/// Per-tile template options (SRD 114 §2.3): the hole delimiters, the
86/// directive sigil, and strictness.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct TileOptions {
89    /// The text that opens a hole, `${` by default.
90    pub open: String,
91    /// The text that closes a hole, `}` by default.
92    pub close: String,
93    /// The directive sigil, `@` by default.
94    pub sigil: String,
95    /// Whether an unknown hole or directive is an error rather than text.
96    pub strict: bool,
97    /// The body begins inside a JSON string literal (`instring`): holes
98    /// encode as escaped text from the first byte. The compiler sets
99    /// this on the tile it makes for a projection nested in a string
100    /// position; authors rarely need it.
101    pub in_string: bool,
102}
103
104impl Default for TileOptions {
105    fn default() -> Self {
106        Self {
107            open: "${".into(),
108            close: "}".into(),
109            sigil: "@".into(),
110            strict: false,
111            in_string: false,
112        }
113    }
114}
115
116/// How a tile body was written, so the printer can reproduce it.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum TileBodyKind {
119    /// A brace- or bracket-balanced block, as `json` templates are written.
120    Block,
121    /// Text between `<<<` and `>>>`.
122    Heredoc,
123    /// An ordinary string literal.
124    Literal,
125}
126
127/// A tile definition: its header, its raw body, and the parsed template.
128#[derive(Debug, Clone)]
129pub struct TileDef {
130    /// The tile's name: the wire it binds.
131    pub name: String,
132    /// The declared encoding, such as `json` or `csv`, if any.
133    pub encoding: Option<String>,
134    /// The delimiter and strictness options.
135    pub options: TileOptions,
136    /// How the body was written: heredoc or string literal.
137    pub body_kind: TileBodyKind,
138    /// The body text exactly as captured.
139    pub body: String,
140    /// The body parsed into static runs, holes, projections, and branches.
141    pub pieces: Vec<TilePiece>,
142    /// Where the definition appears.
143    pub span: Span,
144}
145
146/// One element of a parsed template.
147#[derive(Debug, Clone)]
148pub enum TilePiece {
149    /// Bytes copied as written.
150    Static(String),
151    /// `${expr : type | format !}`.
152    Hole(TileHole),
153    /// `@for <source> [sep "..."] { body }`.
154    Projection {
155        /// What the projection iterates.
156        source: ForSource,
157        /// The separator emitted between tuples, if any.
158        sep: Option<String>,
159        /// The template rendered once per tuple.
160        body: Vec<TilePiece>,
161        /// Where the directive appears.
162        span: Span,
163    },
164    /// `@if cond { body } [@else { body }]`.
165    Branch {
166        /// The condition, a boolean expression.
167        cond: Expr,
168        /// The template rendered when the condition holds.
169        then: Vec<TilePiece>,
170        /// The template rendered otherwise, if an `@else` was written.
171        otherwise: Option<Vec<TilePiece>>,
172        /// Where the directive appears.
173        span: Span,
174    },
175}
176
177/// A hole: an expression with an optional declared type, format spec,
178/// and raw flag.
179#[derive(Debug, Clone)]
180pub struct TileHole {
181    /// The text between the delimiters, as written.
182    pub text: String,
183    /// The expression the hole evaluates.
184    pub expr: Expr,
185    /// The declared type after the colon, if any.
186    pub decl_type: Option<String>,
187    /// The format after the bar, if any.
188    pub format: Option<String>,
189    /// Whether the value is emitted without the encoding's escaping.
190    pub raw: bool,
191    /// Where the hole appears.
192    pub span: Span,
193}
194
195/// What a `for` iterates: inline comprehension text, or the name of a
196/// bound producer wire.
197#[derive(Debug, Clone)]
198pub struct ForSource {
199    /// The raw text after `for`, preserved for diagnostics and for
200    /// pretty-printing round trips.
201    pub text: String,
202    /// What the text denotes once parsed.
203    pub kind: ForSourceKind,
204    /// Where the source appears.
205    pub span: Span,
206}
207
208#[derive(Debug, Clone)]
209/// The parsed form of a `for` source.
210pub enum ForSourceKind {
211    /// A bare identifier naming a `Streamer` wire bound by a `for`
212    /// expression elsewhere in scope.
213    Producer(String),
214    /// Comprehension text, parsed to the algebra AST.
215    Comprehension(crate::comprehension::Comprehension),
216    /// A derivation of a bound producer: `base where <pred>`,
217    /// `base order <spec>`, or both (SRD 113 §3.1). Resolved against
218    /// the producer at compile time.
219    Derived {
220        /// The producer wire the derivation starts from.
221        base: String,
222        /// The `where` predicate text, if any.
223        filter: Option<String>,
224        /// The `order` specification text, if any.
225        order: Option<String>,
226    },
227}
228
229impl ForSource {
230    /// The element names the source dispenses, when known statically.
231    /// A producer reference or derivation resolves its names at compile
232    /// time.
233    pub fn element_names(&self) -> Vec<String> {
234        match &self.kind {
235            ForSourceKind::Producer(_) | ForSourceKind::Derived { .. } => Vec::new(),
236            ForSourceKind::Comprehension(c) => c.coordinate_names(),
237        }
238    }
239}
240
241/// A traversal statement: `for <source> { statements }`.
242#[derive(Debug, Clone)]
243pub struct ForStmt {
244    /// What the traversal iterates.
245    pub source: ForSource,
246    /// The body: one child scope per tuple.
247    pub body: Vec<Statement>,
248    /// Where the statement appears.
249    pub span: Span,
250}
251
252/// An external input port declaration.
253///
254/// Ports persist across `set_inputs()` calls within a stanza.
255/// Written by capture extraction, read by Polydat nodes.
256///
257/// ```text
258/// extern balance: f64 = 0.0
259/// extern session_id: u64 = 0
260/// ```
261#[derive(Debug, Clone)]
262pub struct ExternPort {
263    /// The port's name.
264    pub name: String,
265    /// The declared type keyword.
266    pub typ: String,
267    /// The default value, if one was written; without one the port is `None` until set.
268    pub default: Option<Expr>,
269    /// Where the declaration appears.
270    pub span: Span,
271}
272
273/// One per-cycle kernel input slot.
274///
275/// Declared by `input <name>[: <type>]` (single) or
276/// `input (<name>[: <type>], ...)` (tuple, sugar for N decls).
277/// The name participates in the kernel's input-port wiring just
278/// like `extern` participates in its port set, but inputs are
279/// driven by the runtime cycle pump (cursors, captures, etc.)
280/// rather than by external port writes.
281///
282/// `ty` is `None` when the author omitted the annotation; typed
283/// downstream by inference. Authors are encouraged to declare
284/// the type for clarity and editor support.
285#[derive(Debug, Clone)]
286pub struct InputDecl {
287    /// The input's name.
288    pub name: String,
289    /// The declared type keyword, if one was written.
290    pub ty: Option<String>,
291    /// Where the declaration appears.
292    pub span: Span,
293}
294
295/// One wire-coloring keyword. The single enum that names every
296/// modifier the grammar recognises before a binding name.
297/// Future modifiers are new variants here.
298///
299/// Each variant maps to a token the lexer emits and a parser
300/// branch in `parse_modified_binding`. A binding can carry zero
301/// or more of these, stored as a [`BindingModifier`] set.
302#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
303pub enum WireModifier {
304    /// `const` — effectively-const for the scope's lifetime.
305    /// Materialized at the earliest opportunity: compile-time
306    /// const-fold when the RHS is fold-eligible, otherwise the
307    /// scope-init pull pass after materialize-wiring has
308    /// populated extern slots. The runtime contract is "fixed
309    /// once per scope activation, then immutable for the rest of
310    /// the scope's lifetime." Replaces the former `final` /
311    /// `init` distinction — the two were redundant axes of the
312    /// same lifecycle, and the surface now collapses to one
313    /// keyword whose materialization timing is an internal
314    /// optimization.
315    Const,
316    /// `shared` — mutable cell visible across kernel instances.
317    /// The runtime propagates iteration N's end state into
318    /// iteration N+1's start state.
319    Shared,
320    /// `volatile` — wire's value is excluded from `hash_const`
321    /// (the const-folded identity hash). Authors mark wires
322    /// whose value should NOT contribute to resume-identity
323    /// even when the source's structural detection would
324    /// otherwise allow folding.
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}