Skip to main content

allium_parser/
parser.rs

1//! Recursive descent parser for Allium.
2//!
3//! Expressions use a Pratt parser (precedence climbing). Declarations and block
4//! bodies use direct recursive descent. Multi-line clause values are detected
5//! by comparing the line/column of the next token against the clause keyword.
6
7use serde::Serialize;
8
9use crate::ast::*;
10use crate::diagnostic::Diagnostic;
11use crate::lexer::{lex, SourceMap, Token, TokenKind};
12use crate::Span;
13
14// ---------------------------------------------------------------------------
15// Public API
16// ---------------------------------------------------------------------------
17
18#[derive(Debug, Serialize)]
19pub struct ParseResult {
20    pub module: Module,
21    pub diagnostics: Vec<Diagnostic>,
22}
23
24/// Parse an Allium source file into a [`Module`].
25pub fn parse(source: &str) -> ParseResult {
26    let tokens = lex(source);
27    let source_map = SourceMap::new(source);
28    let mut p = Parser {
29        source,
30        tokens,
31        pos: 0,
32        source_map,
33        diagnostics: Vec::new(),
34    };
35    let module = p.parse_module();
36    ParseResult {
37        module,
38        diagnostics: p.diagnostics,
39    }
40}
41
42// ---------------------------------------------------------------------------
43// Parser state
44// ---------------------------------------------------------------------------
45
46struct Parser<'s> {
47    source: &'s str,
48    tokens: Vec<Token>,
49    pos: usize,
50    source_map: SourceMap,
51    diagnostics: Vec<Diagnostic>,
52}
53
54// ---------------------------------------------------------------------------
55// Navigation helpers
56// ---------------------------------------------------------------------------
57
58impl<'s> Parser<'s> {
59    fn peek(&self) -> Token {
60        self.tokens[self.pos]
61    }
62
63    fn peek_kind(&self) -> TokenKind {
64        self.tokens[self.pos].kind
65    }
66
67    fn peek_at(&self, offset: usize) -> Token {
68        let idx = (self.pos + offset).min(self.tokens.len() - 1);
69        self.tokens[idx]
70    }
71
72    fn advance(&mut self) -> Token {
73        let tok = self.tokens[self.pos];
74        if tok.kind != TokenKind::Eof {
75            self.pos += 1;
76        }
77        tok
78    }
79
80    fn at(&self, kind: TokenKind) -> bool {
81        self.peek_kind() == kind
82    }
83
84    fn at_eof(&self) -> bool {
85        self.at(TokenKind::Eof)
86    }
87
88    fn eat(&mut self, kind: TokenKind) -> Option<Token> {
89        if self.at(kind) {
90            Some(self.advance())
91        } else {
92            None
93        }
94    }
95
96    fn expect(&mut self, kind: TokenKind) -> Option<Token> {
97        if self.at(kind) {
98            Some(self.advance())
99        } else {
100            self.error(
101                self.peek().span,
102                format!("expected {kind}, found {}", self.peek_kind()),
103            );
104            None
105        }
106    }
107
108    fn text(&self, span: Span) -> &'s str {
109        &self.source[span.start..span.end]
110    }
111
112    fn line_of(&self, span: Span) -> u32 {
113        self.source_map.line_col(span.start).0
114    }
115
116    fn col_of(&self, span: Span) -> u32 {
117        self.source_map.line_col(span.start).1
118    }
119
120    fn error(&mut self, span: Span, msg: impl Into<String>) {
121        let line = self.source_map.line_col(span.start).0;
122        if let Some(last) = self.diagnostics.last() {
123            if last.severity == crate::diagnostic::Severity::Error
124                && self.source_map.line_col(last.span.start).0 == line
125            {
126                return;
127            }
128        }
129        self.diagnostics.push(Diagnostic::error(span, msg));
130    }
131
132    /// Consume and return an [`Ident`] from any word token.
133    fn parse_ident(&mut self) -> Option<Ident> {
134        self.parse_ident_in("identifier")
135    }
136
137    /// Consume and return an [`Ident`] with a context-specific label for errors.
138    fn parse_ident_in(&mut self, context: &str) -> Option<Ident> {
139        let tok = self.peek();
140        if tok.kind.is_word() {
141            self.advance();
142            Some(Ident {
143                span: tok.span,
144                name: self.text(tok.span).to_string(),
145            })
146        } else {
147            self.error(
148                tok.span,
149                format!("expected {context}, found {}", tok.kind),
150            );
151            None
152        }
153    }
154
155    /// Consume a string token and produce a [`StringLiteral`].
156    fn parse_string(&mut self) -> Option<StringLiteral> {
157        let tok = self.expect(TokenKind::String)?;
158        let raw = self.text(tok.span);
159        // Strip surrounding quotes
160        let inner = &raw[1..raw.len() - 1];
161        let parts = parse_string_parts(inner, tok.span.start + 1);
162        Some(StringLiteral {
163            span: tok.span,
164            parts,
165        })
166    }
167}
168
169/// Split the inner content of a string literal into text and interpolation
170/// parts. `base_offset` is the byte offset of the first character after the
171/// opening quote in the source file.
172fn parse_string_parts(inner: &str, base_offset: usize) -> Vec<StringPart> {
173    let mut parts = Vec::new();
174    let mut buf = String::new();
175    let bytes = inner.as_bytes();
176    let mut i = 0;
177    while i < bytes.len() {
178        if bytes[i] == b'\\' && i + 1 < bytes.len() {
179            buf.push(bytes[i + 1] as char);
180            i += 2;
181        } else if bytes[i] == b'{' {
182            if !buf.is_empty() {
183                parts.push(StringPart::Text(std::mem::take(&mut buf)));
184            }
185            i += 1; // skip {
186            let start = i;
187            while i < bytes.len() && bytes[i] != b'}' {
188                i += 1;
189            }
190            let name = std::str::from_utf8(&bytes[start..i]).unwrap_or("").to_string();
191            let span_start = base_offset + start;
192            let span_end = base_offset + i;
193            parts.push(StringPart::Interpolation(Ident {
194                span: Span::new(span_start, span_end),
195                name,
196            }));
197            if i < bytes.len() {
198                i += 1; // skip }
199            }
200        } else {
201            buf.push(bytes[i] as char);
202            i += 1;
203        }
204    }
205    if !buf.is_empty() {
206        parts.push(StringPart::Text(buf));
207    }
208    parts
209}
210
211// ---------------------------------------------------------------------------
212// Clause-keyword recognition
213// ---------------------------------------------------------------------------
214
215/// Returns true for identifiers that act as clause keywords inside blocks.
216/// These are parsed as `Clause` items rather than `Assignment` items.
217fn is_clause_keyword(text: &str) -> bool {
218    matches!(
219        text,
220        "when"
221            | "requires"
222            | "ensures"
223            | "facing"
224            | "context"
225            | "exposes"
226            | "provides"
227            | "related"
228            | "timeout"
229            | "contracts"
230            | "identified_by"
231            | "within"
232    )
233}
234
235/// True for clause keywords whose value can start with a `name: expr` binding.
236fn clause_allows_binding(keyword: &str) -> bool {
237    matches!(keyword, "when")
238}
239
240/// True for keywords that use `keyword name: value` syntax (no colon after the
241/// keyword). These directly embed a binding.
242fn is_binding_clause_keyword(text: &str) -> bool {
243    matches!(text, "facing" | "context")
244}
245
246/// True if the current token is a keyword that begins a clause.
247fn token_is_clause_keyword(kind: TokenKind) -> bool {
248    matches!(
249        kind,
250        TokenKind::When | TokenKind::Requires | TokenKind::Ensures | TokenKind::Within
251            | TokenKind::Invariant
252            | TokenKind::Transitions
253    )
254}
255
256/// Extract a `when` clause from a field declaration expression.
257///
258/// If the expression is `WhenGuard { action: Type, condition: status = v1 | v2 }`,
259/// decomposes it into the inner type expression and a `WhenClause`.
260fn extract_when_clause(expr: &Expr) -> Option<(Expr, WhenClause)> {
261    if let Expr::WhenGuard { action, condition, span } = expr {
262        // condition should be `status = value1 | value2 | ...`
263        if let Expr::Comparison {
264            left,
265            op: ComparisonOp::Eq,
266            right,
267            span: _cond_span,
268        } = condition.as_ref()
269        {
270            if let Expr::Ident(status_field) = left.as_ref() {
271                let mut qualifying_states = Vec::new();
272                collect_pipe_idents(right, &mut qualifying_states);
273                if !qualifying_states.is_empty() {
274                    return Some((
275                        *action.clone(),
276                        WhenClause {
277                            span: *span,
278                            status_field: status_field.clone(),
279                            qualifying_states,
280                        },
281                    ));
282                }
283            }
284        }
285        // Also handle single state: `when status = shipped` (no pipe)
286        // Already handled above since a single Ident goes through collect_pipe_idents
287    }
288    // Also handle `TypeOptional` wrapping a WhenGuard: `Type? when status = ...`
289    // The parser would parse `Type?` first (postfix), then `when` (infix on the TypeOptional).
290    // Actually, `?` is postfix and `when` is infix with lower BP, so `Type? when cond`
291    // parses as `WhenGuard { action: TypeOptional { Type }, condition: ... }`.
292    // That case is handled by the WhenGuard branch above — action will be TypeOptional.
293    None
294}
295
296fn collect_pipe_idents(expr: &Expr, out: &mut Vec<Ident>) {
297    match expr {
298        Expr::Ident(id) => out.push(id.clone()),
299        Expr::Pipe { left, right, .. } => {
300            collect_pipe_idents(left, out);
301            collect_pipe_idents(right, out);
302        }
303        _ => {}
304    }
305}
306
307// ---------------------------------------------------------------------------
308// Module parsing
309// ---------------------------------------------------------------------------
310
311impl<'s> Parser<'s> {
312    fn parse_module(&mut self) -> Module {
313        let start = self.peek().span;
314        // Version marker is a comment: `-- allium: N`. Detect it from the raw
315        // source before the lexer strips it.
316        let version = detect_version(self.source);
317
318        match version {
319            None => {
320                self.diagnostics.push(Diagnostic::warning(
321                    start,
322                    "missing version marker; expected '-- allium: 1' as the first line",
323                ));
324            }
325            Some(1) | Some(2) | Some(3) => {}
326            Some(v) => {
327                self.diagnostics.push(Diagnostic::error(
328                    start,
329                    format!("unsupported allium version {v}; this parser supports versions 1, 2 and 3"),
330                ));
331            }
332        }
333
334        let mut decls = Vec::new();
335        while !self.at_eof() {
336            if let Some(d) = self.parse_decl() {
337                decls.push(d);
338            } else {
339                // Recovery: skip one token and try again
340                self.advance();
341            }
342        }
343        let end = self.peek().span;
344        Module {
345            span: start.merge(end),
346            version,
347            declarations: decls,
348        }
349    }
350}
351
352fn detect_version(source: &str) -> Option<u32> {
353    for line in source.lines() {
354        let trimmed = line.trim();
355        if trimmed.is_empty() {
356            continue;
357        }
358        if let Some(rest) = trimmed.strip_prefix("--") {
359            let rest = rest.trim();
360            if let Some(ver) = rest.strip_prefix("allium:") {
361                return ver.trim().parse().ok();
362            }
363        }
364        break; // only check leading lines
365    }
366    None
367}
368
369// ---------------------------------------------------------------------------
370// Declaration parsing
371// ---------------------------------------------------------------------------
372
373impl<'s> Parser<'s> {
374    fn parse_decl(&mut self) -> Option<Decl> {
375        match self.peek_kind() {
376            TokenKind::Use => self.parse_use_decl().map(Decl::Use),
377            TokenKind::Rule => self.parse_block(BlockKind::Rule).map(Decl::Block),
378            TokenKind::Entity => self.parse_block(BlockKind::Entity).map(Decl::Block),
379            TokenKind::External => {
380                let start = self.advance().span;
381                if self.at(TokenKind::Entity) {
382                    self.parse_block_from(start, BlockKind::ExternalEntity)
383                        .map(Decl::Block)
384                } else {
385                    self.error(self.peek().span, "expected 'entity' after 'external'");
386                    None
387                }
388            }
389            TokenKind::Value => self.parse_block(BlockKind::Value).map(Decl::Block),
390            TokenKind::Enum => self.parse_block(BlockKind::Enum).map(Decl::Block),
391            TokenKind::Given => self.parse_anonymous_block(BlockKind::Given).map(Decl::Block),
392            TokenKind::Config => self.parse_anonymous_block(BlockKind::Config).map(Decl::Block),
393            TokenKind::Surface => self.parse_block(BlockKind::Surface).map(Decl::Block),
394            TokenKind::Actor => self.parse_block(BlockKind::Actor).map(Decl::Block),
395            TokenKind::Contract => self.parse_contract_decl().map(Decl::Block),
396            TokenKind::Invariant => self.parse_invariant_decl().map(Decl::Invariant),
397            TokenKind::Default => self.parse_default_decl().map(Decl::Default),
398            TokenKind::Variant => self.parse_variant_decl().map(Decl::Variant),
399            TokenKind::Deferred => self.parse_deferred_decl().map(Decl::Deferred),
400            TokenKind::Open => self.parse_open_question_decl().map(Decl::OpenQuestion),
401            // Qualified config: `alias/config { ... }`
402            TokenKind::Ident
403                if self.peek_at(1).kind == TokenKind::Slash
404                    && self.text(self.peek_at(2).span) == "config" =>
405            {
406                self.parse_qualified_config().map(Decl::Block)
407            }
408            _ => {
409                self.error(
410                    self.peek().span,
411                    format!(
412                        "expected declaration (entity, rule, enum, value, config, surface, actor, \
413                         given, default, variant, deferred, use, open question, contract, invariant), found {}",
414                        self.peek_kind(),
415                    ),
416                );
417                None
418            }
419        }
420    }
421
422    // -- module declaration -----------------------------------------------
423
424    // -- use declaration ------------------------------------------------
425
426    fn parse_use_decl(&mut self) -> Option<UseDecl> {
427        let start = self.expect(TokenKind::Use)?.span;
428        let path = self.parse_string()?;
429        let alias = if self.eat(TokenKind::As).is_some() {
430            Some(self.parse_ident_in("import alias")?)
431        } else {
432            None
433        };
434        let end = alias
435            .as_ref()
436            .map(|a| a.span)
437            .unwrap_or(path.span);
438        Some(UseDecl {
439            span: start.merge(end),
440            path,
441            alias,
442        })
443    }
444
445    // -- named block: `keyword Name { ... }` ----------------------------
446
447    fn parse_block(&mut self, kind: BlockKind) -> Option<BlockDecl> {
448        let start = self.advance().span; // consume keyword
449        self.parse_block_from(start, kind)
450    }
451
452    fn parse_block_from(&mut self, start: Span, kind: BlockKind) -> Option<BlockDecl> {
453        // For ExternalEntity the keyword was already consumed by the caller;
454        // here we consume Entity.
455        if kind == BlockKind::ExternalEntity {
456            self.expect(TokenKind::Entity)?;
457        }
458        let context = match kind {
459            BlockKind::Entity | BlockKind::ExternalEntity => "entity name",
460            BlockKind::Rule => "rule name",
461            BlockKind::Surface => "surface name",
462            BlockKind::Actor => "actor name",
463            BlockKind::Value => "value type name",
464            BlockKind::Enum => "enum name",
465            _ => "block name",
466        };
467        let name = Some(self.parse_ident_in(context)?);
468        self.expect(TokenKind::LBrace)?;
469        let items = if kind == BlockKind::Enum {
470            self.parse_enum_body()
471        } else {
472            self.parse_block_items(kind)
473        };
474        let end = self.expect(TokenKind::RBrace)?.span;
475        Some(BlockDecl {
476            span: start.merge(end),
477            kind,
478            name,
479            items,
480        })
481    }
482
483    // -- anonymous block: `keyword { ... }` -----------------------------
484
485    /// Parse enum body: pipe-separated variant names.
486    /// `{ pending | shipped | delivered }` or `` { en | `de-CH-1996` } ``
487    fn parse_enum_body(&mut self) -> Vec<BlockItem> {
488        let mut items = Vec::new();
489        while !self.at(TokenKind::RBrace) && !self.at_eof() {
490            if self.eat(TokenKind::Pipe).is_some() {
491                continue;
492            }
493            if self.at(TokenKind::BacktickLiteral) {
494                let t = self.advance();
495                let raw = self.text(t.span);
496                let value = raw[1..raw.len() - 1].to_string();
497                items.push(BlockItem {
498                    span: t.span,
499                    kind: BlockItemKind::EnumVariant {
500                        name: Ident { span: t.span, name: value },
501                        backtick_quoted: true,
502                    },
503                });
504            } else if let Some(ident) = self.parse_ident_in("enum variant") {
505                items.push(BlockItem {
506                    span: ident.span,
507                    kind: BlockItemKind::EnumVariant { name: ident, backtick_quoted: false },
508                });
509            } else {
510                self.advance(); // skip unrecognised token
511            }
512        }
513        items
514    }
515
516    fn parse_anonymous_block(&mut self, kind: BlockKind) -> Option<BlockDecl> {
517        let start = self.advance().span;
518        self.expect(TokenKind::LBrace)?;
519        let items = self.parse_block_items(kind);
520        let end = self.expect(TokenKind::RBrace)?.span;
521        Some(BlockDecl {
522            span: start.merge(end),
523            kind,
524            name: None,
525            items,
526        })
527    }
528
529    // -- qualified config: `alias/config { ... }` -----------------------
530
531    fn parse_qualified_config(&mut self) -> Option<BlockDecl> {
532        let alias = self.parse_ident_in("config qualifier")?;
533        let start = alias.span;
534        self.expect(TokenKind::Slash)?;
535        self.advance(); // consume "config" ident
536        self.expect(TokenKind::LBrace)?;
537        let items = self.parse_block_items(BlockKind::Config);
538        let end = self.expect(TokenKind::RBrace)?.span;
539        Some(BlockDecl {
540            span: start.merge(end),
541            kind: BlockKind::Config,
542            name: Some(alias),
543            items,
544        })
545    }
546
547    // -- default declaration -------------------------------------------
548
549    fn parse_default_decl(&mut self) -> Option<DefaultDecl> {
550        let start = self.expect(TokenKind::Default)?.span;
551
552        // `default [TypeName] instanceName = value`
553        // The type name is optional. If the next two tokens are both words
554        // and the second is followed by `=`, the first is the type.
555        let (type_name, name) = if self.peek_kind().is_word()
556            && self.peek_at(1).kind.is_word()
557            && self.peek_at(2).kind == TokenKind::Eq
558        {
559            let t = self.parse_ident_in("type name")?;
560            let n = self.parse_ident_in("default name")?;
561            (Some(t), n)
562        } else {
563            (None, self.parse_ident_in("default name")?)
564        };
565
566        self.expect(TokenKind::Eq)?;
567        let value = self.parse_expr(0)?;
568        Some(DefaultDecl {
569            span: start.merge(value.span()),
570            type_name,
571            name,
572            value,
573        })
574    }
575
576    // -- variant declaration -------------------------------------------
577
578    fn parse_variant_decl(&mut self) -> Option<VariantDecl> {
579        let start = self.expect(TokenKind::Variant)?.span;
580        let name = self.parse_ident_in("variant name")?;
581        self.expect(TokenKind::Colon)?;
582        let base = self.parse_expr(0)?;
583
584        let items = if self.eat(TokenKind::LBrace).is_some() {
585            let items = self.parse_block_items(BlockKind::Entity);
586            self.expect(TokenKind::RBrace)?;
587            items
588        } else {
589            Vec::new()
590        };
591
592        let end = if let Some(last) = items.last() {
593            last.span
594        } else {
595            base.span()
596        };
597        Some(VariantDecl {
598            span: start.merge(end),
599            name,
600            base,
601            items,
602        })
603    }
604
605    // -- deferred declaration ------------------------------------------
606
607    fn parse_deferred_decl(&mut self) -> Option<DeferredDecl> {
608        let start = self.expect(TokenKind::Deferred)?.span;
609        let path = self.parse_deferred_path()?;
610
611        // Optional quoted location hint on the same line:
612        // `deferred Foo.bar "detailed/foo.allium"`. A string on a later line
613        // is stray top-level input, not this declaration's hint.
614        let location_hint = if self.at(TokenKind::String)
615            && self.same_line(path.span().end, self.peek().span.start)
616        {
617            self.parse_string()
618        } else {
619            None
620        };
621
622        let end = location_hint
623            .as_ref()
624            .map_or(path.span(), |hint| hint.span);
625        Some(DeferredDecl {
626            span: start.merge(end),
627            path,
628            location_hint,
629        })
630    }
631
632    /// Parse the path of a `deferred` declaration: a dotted name with an
633    /// optional `alias/Name` qualifier (`Name`, `Name.field.sub`,
634    /// `alias/Name`, `alias/Name.field`). Unlike `parse_expr`, this never
635    /// absorbs operators, calls, or string literals into the path, and it
636    /// stops before a trailing `.` with no following identifier (e.g.
637    /// `deferred Foo.`) so the declaration still forms and the leftover token
638    /// errors at declaration level — keeping the location-hint check running
639    /// on the line, in step with the TypeScript analyzer.
640    ///
641    /// In declaration position there is no division to disambiguate from, so
642    /// a `/` after the leading identifier always introduces a qualifier,
643    /// mirroring `fulfils alias/Name` in the contracts clause.
644    ///
645    /// The path must sit on one line: newlines are not tokens and keywords
646    /// are word tokens, so without the line guards a dangling `.` or `/`
647    /// would absorb the next declaration's leading keyword as a path segment.
648    fn parse_deferred_path(&mut self) -> Option<Expr> {
649        let first = self.parse_ident_in("deferred name")?;
650
651        let mut expr = if self.at(TokenKind::Slash)
652            && self.same_line(first.span.end, self.peek().span.start)
653        {
654            self.advance(); // consume `/`
655            if !self.peek_kind().is_word()
656                || !self.same_line(first.span.end, self.peek().span.start)
657            {
658                let tok = self.peek();
659                self.error(
660                    tok.span,
661                    format!("expected deferred name after '/', found {}", tok.kind),
662                );
663                return None;
664            }
665            let name = self.parse_ident_in("deferred name after '/'")?;
666            Expr::QualifiedName(QualifiedName {
667                span: first.span.merge(name.span),
668                qualifier: Some(first.name),
669                name: name.name,
670            })
671        } else {
672            Expr::Ident(first)
673        };
674
675        while self.at(TokenKind::Dot)
676            && self.peek_at(1).kind.is_word()
677            && self.same_line(expr.span().end, self.peek_at(1).span.start)
678        {
679            self.advance(); // consume `.`
680            let field = self.parse_ident_in("field name")?;
681            expr = Expr::MemberAccess {
682                span: expr.span().merge(field.span),
683                object: Box::new(expr),
684                field,
685            };
686        }
687        Some(expr)
688    }
689
690    /// Whether the source between two byte offsets contains no line break.
691    fn same_line(&self, from: usize, to: usize) -> bool {
692        !self.source[from..to].contains(['\n', '\r'])
693    }
694
695    // -- open question --------------------------------------------------
696
697    fn parse_open_question_decl(&mut self) -> Option<OpenQuestionDecl> {
698        let start = self.expect(TokenKind::Open)?.span;
699        self.expect(TokenKind::Question)?;
700        let text = self.parse_string()?;
701        Some(OpenQuestionDecl {
702            span: start.merge(text.span),
703            text,
704        })
705    }
706
707    // -- contract declaration -------------------------------------------
708
709    fn parse_contract_decl(&mut self) -> Option<BlockDecl> {
710        let start = self.advance().span; // consume `contract`
711        let name = self.parse_ident_in("contract name")?;
712
713        // Reject lowercase contract names
714        if name.name.chars().next().is_some_and(|c| c.is_lowercase()) {
715            self.diagnostics.push(Diagnostic::error(
716                name.span,
717                "contract name must start with an uppercase letter",
718            ));
719        }
720
721        // Reject colon-delimited body
722        if self.at(TokenKind::Colon) {
723            self.error(
724                self.peek().span,
725                "contract body must use braces { }, not a colon",
726            );
727            return None;
728        }
729
730        self.expect(TokenKind::LBrace)?;
731        let items = self.parse_block_items(BlockKind::Contract);
732        let end = self.expect(TokenKind::RBrace)?.span;
733        Some(BlockDecl {
734            span: start.merge(end),
735            kind: BlockKind::Contract,
736            name: Some(name),
737            items,
738        })
739    }
740
741    // -- invariant declaration ------------------------------------------
742
743    fn parse_invariant_decl(&mut self) -> Option<InvariantDecl> {
744        let start = self.advance().span; // consume `invariant`
745        let name = self.parse_ident_in("invariant name")?;
746
747        // Reject lowercase invariant names
748        if name.name.chars().next().is_some_and(|c| c.is_lowercase()) {
749            self.diagnostics.push(Diagnostic::error(
750                name.span,
751                "invariant name must start with an uppercase letter",
752            ));
753        }
754
755        self.expect(TokenKind::LBrace)?;
756        let body = self.parse_invariant_body()?;
757        let end = self.expect(TokenKind::RBrace)?.span;
758        Some(InvariantDecl {
759            span: start.merge(end),
760            name,
761            body,
762        })
763    }
764
765    /// Parse the body of an invariant block — a sequence of expressions and
766    /// let bindings, similar to a clause value block.
767    fn parse_invariant_body(&mut self) -> Option<Expr> {
768        let start = self.peek().span;
769        let mut items = Vec::new();
770
771        while !self.at(TokenKind::RBrace) && !self.at_eof() {
772            if self.at(TokenKind::Let) {
773                let let_start = self.advance().span;
774                let name = self.parse_ident_in("binding name")?;
775                self.expect(TokenKind::Eq)?;
776                let value = self.parse_expr(0)?;
777                items.push(Expr::LetExpr {
778                    span: let_start.merge(value.span()),
779                    name,
780                    value: Box::new(value),
781                });
782            } else if let Some(expr) = self.parse_expr(0) {
783                items.push(expr);
784            } else {
785                self.advance();
786                break;
787            }
788        }
789
790        if items.len() == 1 {
791            Some(items.pop().unwrap())
792        } else {
793            let end = items.last().map(|e| e.span()).unwrap_or(start);
794            Some(Expr::Block {
795                span: start.merge(end),
796                items,
797            })
798        }
799    }
800}
801
802// ---------------------------------------------------------------------------
803// Block item parsing
804// ---------------------------------------------------------------------------
805
806impl<'s> Parser<'s> {
807    fn parse_block_items(&mut self, block_kind: BlockKind) -> Vec<BlockItem> {
808        let mut items = Vec::new();
809        while !self.at(TokenKind::RBrace) && !self.at_eof() {
810            if let Some(item) = self.parse_block_item(block_kind) {
811                items.push(item);
812                self.eat(TokenKind::Comma);
813            } else {
814                // Recovery: skip one token
815                self.advance();
816            }
817        }
818        items
819    }
820
821    fn parse_block_item(&mut self, block_kind: BlockKind) -> Option<BlockItem> {
822        let start = self.peek().span;
823
824        // `let name = value`
825        if self.at(TokenKind::Let) {
826            return self.parse_let_item(start);
827        }
828
829        // `for binding in collection [where filter]: ...` at block level
830        if self.at(TokenKind::For) {
831            return self.parse_for_block_item(start);
832        }
833
834        // `if condition: ... [else if ...: ...] [else: ...]` at block level
835        if self.at(TokenKind::If) {
836            return self.parse_if_block_item(start);
837        }
838
839        // `@invariant`, `@guidance`, `@guarantee` — prose annotations
840        if self.at(TokenKind::At) {
841            return self.parse_annotation(start);
842        }
843
844        // `invariant Name { expr }` — expression-bearing invariant inside a block
845        if self.at(TokenKind::Invariant) && self.peek_at(1).kind.is_word()
846            && self.peek_at(2).kind != TokenKind::Colon
847        {
848            return self.parse_invariant_block_item(start);
849        }
850
851        // `open question "text"` (inside a block)
852        if self.at(TokenKind::Open) && self.peek_at(1).kind == TokenKind::Question {
853            self.advance(); // open
854            self.advance(); // question
855            let text = self.parse_string()?;
856            return Some(BlockItem {
857                span: start.merge(text.span),
858                kind: BlockItemKind::OpenQuestion { text },
859            });
860        }
861
862        // `transitions field { ... }` — transition graph (v3)
863        if self.at(TokenKind::Transitions)
864            && self.peek_at(1).kind.is_word()
865            && self.peek_at(2).kind == TokenKind::LBrace
866        {
867            return self.parse_transitions_block(start);
868        }
869
870        // Migration diagnostics: old colon-form prose constructs
871        if self.peek_kind() == TokenKind::Ident {
872            let word = self.text(self.peek().span);
873            if (word == "guidance" || word == "guarantee")
874                && self.peek_at(1).kind == TokenKind::Colon
875            {
876                let kw = word.to_string();
877                self.error(
878                    self.peek().span,
879                    format!(
880                        "`{kw}:` syntax was replaced by `@{kw}`. Use `@{kw}` followed by indented comment lines."
881                    ),
882                );
883                // Fall through to normal clause parsing so we don't lose the rest
884            }
885        }
886
887        // Migration diagnostic: old `invariant:` colon form
888        if self.at(TokenKind::Invariant) && self.peek_at(1).kind == TokenKind::Colon {
889            self.error(
890                self.peek().span,
891                "`invariant:` syntax was replaced by `@invariant`. Use `@invariant Name` followed by indented comment lines.",
892            );
893            // Fall through to normal clause parsing
894        }
895
896        // Everything else: `name: value` or `keyword: value` or
897        // `name(params): value`
898        if self.peek_kind().is_word() {
899            // `contracts:` clause — dispatch before generic clause/assignment
900            if self.text(self.peek().span) == "contracts"
901                && self.peek_at(1).kind == TokenKind::Colon
902            {
903                return self.parse_contracts_clause(start);
904            }
905
906            // `facing name: Type` / `context name: Type [where ...]` — binding
907            // clause keywords that don't use `:` after the keyword itself.
908            if is_binding_clause_keyword(self.text(self.peek().span))
909                && self.peek_at(1).kind.is_word()
910                && self.peek_at(2).kind == TokenKind::Colon
911            {
912                return self.parse_binding_clause_item(start);
913            }
914
915            // Check for `Name.field:` — dot-path reverse relationship
916            if self.peek_at(1).kind == TokenKind::Dot
917                && self.peek_at(2).kind.is_word()
918                && self.peek_at(3).kind == TokenKind::Colon
919            {
920                return self.parse_path_assignment_item(start);
921            }
922
923            // Check for `name(` — potential parameterised assignment
924            if self.peek_at(1).kind == TokenKind::LParen {
925                return self.parse_param_or_clause_item(start);
926            }
927
928            // `produces:` and `consumes:` are removed in v3 — emit migration diagnostic
929            if block_kind == BlockKind::Rule
930                && (self.at(TokenKind::Produces) || self.at(TokenKind::Consumes))
931                && self.peek_at(1).kind == TokenKind::Colon
932            {
933                return self.parse_legacy_field_list_clause(start);
934            }
935
936            // Check for `name:` — assignment or clause
937            if self.peek_at(1).kind == TokenKind::Colon {
938                return self.parse_assign_or_clause_item(start);
939            }
940        }
941
942        // For clauses whose keyword is a separate TokenKind (when, requires, etc.)
943        if token_is_clause_keyword(self.peek_kind()) && self.peek_at(1).kind == TokenKind::Colon {
944            return self.parse_assign_or_clause_item(start);
945        }
946
947        self.error(
948            start,
949            format!(
950                "expected block item (name: value, let name = value, when:/requires:/ensures: clause, \
951                 for ... in ...:, or open question), found {}",
952                self.peek_kind(),
953            ),
954        );
955        None
956    }
957
958    /// Parse `transitions field { edges..., terminal: states }`.
959    fn parse_transitions_block(&mut self, start: Span) -> Option<BlockItem> {
960        self.advance(); // consume `transitions`
961        let field = self.parse_ident_in("transition field name")?;
962        self.expect(TokenKind::LBrace)?;
963
964        let mut edges = Vec::new();
965        let mut terminal = Vec::new();
966
967        while !self.at(TokenKind::RBrace) && !self.at_eof() {
968            // `terminal: state1, state2`
969            if self.at(TokenKind::Terminal) && self.peek_at(1).kind == TokenKind::Colon {
970                self.advance(); // consume `terminal`
971                self.advance(); // consume `:`
972                loop {
973                    let state = self.parse_ident_in("terminal state")?;
974                    terminal.push(state);
975                    if self.eat(TokenKind::Comma).is_none() {
976                        break;
977                    }
978                    // Allow trailing comma before `}`
979                    if self.at(TokenKind::RBrace) {
980                        break;
981                    }
982                }
983                continue;
984            }
985
986            // `from -> to`
987            let from = self.parse_ident_in("source state")?;
988            if self.expect(TokenKind::ThinArrow).is_none() {
989                // Recovery: skip to next line or `}`
990                while !self.at(TokenKind::RBrace) && !self.at_eof() {
991                    let cur_line = self.line_of(self.peek().span);
992                    self.advance();
993                    if self.line_of(self.peek().span) != cur_line {
994                        break;
995                    }
996                }
997                continue;
998            }
999            let to = self.parse_ident_in("target state")?;
1000            let edge_span = from.span.merge(to.span);
1001            edges.push(TransitionEdge {
1002                span: edge_span,
1003                from,
1004                to,
1005            });
1006
1007            // Optional comma between edges
1008            self.eat(TokenKind::Comma);
1009        }
1010
1011        let end = self.expect(TokenKind::RBrace)?.span;
1012
1013        Some(BlockItem {
1014            span: start.merge(end),
1015            kind: BlockItemKind::TransitionsBlock(TransitionGraph {
1016                span: start.merge(end),
1017                field,
1018                edges,
1019                terminal,
1020            }),
1021        })
1022    }
1023
1024    /// Parse legacy `produces:` / `consumes:` clauses, emitting a migration warning
1025    /// and skipping the clause body. Returns `None` so the item is dropped from the AST.
1026    fn parse_legacy_field_list_clause(&mut self, start: Span) -> Option<BlockItem> {
1027        let keyword_tok = self.advance(); // consume `produces` or `consumes`
1028        let keyword = self.text(keyword_tok.span).to_string();
1029        self.advance(); // consume `:`
1030
1031        // Skip the comma-separated field names
1032        let clause_line = self.line_of(start);
1033        loop {
1034            if self.at(TokenKind::RBrace) || self.at_eof() {
1035                break;
1036            }
1037            if self.line_of(self.peek().span) > clause_line {
1038                break;
1039            }
1040            self.advance();
1041        }
1042
1043        self.diagnostics.push(Diagnostic::warning(
1044            start.merge(keyword_tok.span),
1045            format!(
1046                "`{keyword}:` clauses are removed in v3; use `when` clauses on entity fields instead"
1047            ),
1048        ));
1049
1050        // Return None to drop this item — try the next item in the caller's loop
1051        self.parse_block_item(BlockKind::Rule)
1052    }
1053
1054    fn parse_let_item(&mut self, start: Span) -> Option<BlockItem> {
1055        self.advance(); // consume `let`
1056        let name = self.parse_ident_in("binding name")?;
1057        self.expect(TokenKind::Eq)?;
1058        let value = self.parse_clause_value(start)?;
1059        Some(BlockItem {
1060            span: start.merge(value.span()),
1061            kind: BlockItemKind::Let { name, value },
1062        })
1063    }
1064
1065    /// Parse `facing name: Type` or `context name: Type [where ...]`.
1066    /// These keywords don't take `:` after the keyword — they embed a binding directly.
1067    fn parse_binding_clause_item(&mut self, start: Span) -> Option<BlockItem> {
1068        let keyword_tok = self.advance(); // consume facing/context
1069        let keyword = self.text(keyword_tok.span).to_string();
1070        let binding_name = self.parse_ident_in(&format!("{keyword} binding name"))?;
1071        self.advance(); // consume ':'
1072        let type_expr = self.parse_clause_value(start)?;
1073        let value_span = type_expr.span();
1074        let value = Expr::Binding {
1075            span: binding_name.span.merge(value_span),
1076            name: binding_name,
1077            value: Box::new(type_expr),
1078        };
1079        Some(BlockItem {
1080            span: start.merge(value_span),
1081            kind: BlockItemKind::Clause { keyword, value },
1082        })
1083    }
1084
1085    /// Parse `for binding in collection [where filter]:` at block level.
1086    /// The body is a set of nested block items (let, requires, ensures, etc.).
1087    fn parse_for_block_item(&mut self, start: Span) -> Option<BlockItem> {
1088        self.advance(); // consume `for`
1089        let binding = self.parse_for_binding()?;
1090        self.expect(TokenKind::In)?;
1091
1092        let collection = self.parse_expr(BP_WITH_WHERE + 1)?;
1093
1094        let filter = if self.eat(TokenKind::Where).is_some() {
1095            // Parse filter at min_bp 0 — colon terminates naturally since
1096            // it's not an expression operator.
1097            Some(self.parse_expr(0)?)
1098        } else {
1099            None
1100        };
1101
1102        self.expect(TokenKind::Colon)?;
1103
1104        // The body contains nested block items at higher indentation.
1105        let for_line = self.line_of(start);
1106        let next_line = self.line_of(self.peek().span);
1107
1108        let items = if next_line > for_line {
1109            let base_col = self.col_of(self.peek().span);
1110            self.parse_indented_block_items(base_col)
1111        } else {
1112            // Single-line for: parse one block item
1113            let mut items = Vec::new();
1114            if let Some(item) = self.parse_block_item(BlockKind::Entity) {
1115                items.push(item);
1116            }
1117            items
1118        };
1119
1120        let end = items
1121            .last()
1122            .map(|i| i.span)
1123            .unwrap_or(start);
1124
1125        Some(BlockItem {
1126            span: start.merge(end),
1127            kind: BlockItemKind::ForBlock {
1128                binding,
1129                collection,
1130                filter,
1131                items,
1132            },
1133        })
1134    }
1135
1136    /// Collect block items at column >= `base_col` (for indented for-block bodies).
1137    fn parse_indented_block_items(&mut self, base_col: u32) -> Vec<BlockItem> {
1138        let mut items = Vec::new();
1139        while !self.at_eof()
1140            && !self.at(TokenKind::RBrace)
1141            && self.col_of(self.peek().span) >= base_col
1142        {
1143            if let Some(item) = self.parse_block_item(BlockKind::Entity) {
1144                items.push(item);
1145            } else {
1146                self.advance();
1147                break;
1148            }
1149        }
1150        items
1151    }
1152
1153    /// Parse `if condition: ... [else if ...: ...] [else: ...]` at block level.
1154    fn parse_if_block_item(&mut self, start: Span) -> Option<BlockItem> {
1155        self.advance(); // consume `if`
1156        let mut branches = Vec::new();
1157
1158        // First branch
1159        let condition = self.parse_expr(0)?;
1160        self.expect(TokenKind::Colon)?;
1161        let if_line = self.line_of(start);
1162        let items = self.parse_if_block_body(if_line);
1163        branches.push(CondBlockBranch {
1164            span: start.merge(items.last().map(|i| i.span).unwrap_or(start)),
1165            condition,
1166            items,
1167        });
1168
1169        // else if / else
1170        let mut else_items = None;
1171        while self.at(TokenKind::Else) {
1172            let else_tok = self.advance();
1173            if self.at(TokenKind::If) {
1174                let if_start = self.advance().span;
1175                let cond = self.parse_expr(0)?;
1176                self.expect(TokenKind::Colon)?;
1177                let body_items = self.parse_if_block_body(self.line_of(else_tok.span));
1178                branches.push(CondBlockBranch {
1179                    span: if_start.merge(body_items.last().map(|i| i.span).unwrap_or(if_start)),
1180                    condition: cond,
1181                    items: body_items,
1182                });
1183            } else {
1184                self.expect(TokenKind::Colon)?;
1185                let body_items = self.parse_if_block_body(self.line_of(else_tok.span));
1186                else_items = Some(body_items);
1187                break;
1188            }
1189        }
1190
1191        let end = else_items
1192            .as_ref()
1193            .and_then(|items| items.last().map(|i| i.span))
1194            .or_else(|| branches.last().and_then(|b| b.items.last().map(|i| i.span)))
1195            .unwrap_or(start);
1196
1197        Some(BlockItem {
1198            span: start.merge(end),
1199            kind: BlockItemKind::IfBlock {
1200                branches,
1201                else_items,
1202            },
1203        })
1204    }
1205
1206    /// Parse the body of an if/else if/else block branch.
1207    fn parse_if_block_body(&mut self, keyword_line: u32) -> Vec<BlockItem> {
1208        let next_line = self.line_of(self.peek().span);
1209        if next_line > keyword_line {
1210            let base_col = self.col_of(self.peek().span);
1211            self.parse_indented_block_items(base_col)
1212        } else {
1213            // Single-line: parse one block item
1214            let mut items = Vec::new();
1215            if let Some(item) = self.parse_block_item(BlockKind::Entity) {
1216                items.push(item);
1217            }
1218            items
1219        }
1220    }
1221
1222    /// Parse `contracts:` clause with indented `demands`/`fulfils` entries.
1223    fn parse_contracts_clause(&mut self, start: Span) -> Option<BlockItem> {
1224        self.advance(); // consume `contracts`
1225        self.advance(); // consume `:`
1226
1227        let contracts_col = self.col_of(start);
1228        let mut entries = Vec::new();
1229
1230        while !self.at_eof()
1231            && !self.at(TokenKind::RBrace)
1232            && self.col_of(self.peek().span) > contracts_col
1233        {
1234            if !self.peek_kind().is_word() {
1235                break;
1236            }
1237
1238            let entry_start = self.peek().span;
1239            let direction_tok = self.advance();
1240            let direction_text = self.text(direction_tok.span);
1241
1242            let direction = match direction_text {
1243                "demands" => ContractDirection::Demands,
1244                "fulfils" => ContractDirection::Fulfils,
1245                other => {
1246                    self.error(
1247                        direction_tok.span,
1248                        format!(
1249                            "Unknown direction '{other}' in contracts clause. Use `demands` or `fulfils`."
1250                        ),
1251                    );
1252                    // Skip the rest of this entry
1253                    if self.peek_kind().is_word() {
1254                        self.advance();
1255                    }
1256                    continue;
1257                }
1258            };
1259
1260            let first = self.parse_ident_in("contract name")?;
1261
1262            // Qualified contract reference: `fulfils base/MyContract`. The
1263            // language reference makes contracts importable across modules via
1264            // `use`, following the same coordinate system as entity imports.
1265            // Unlike expression position there is no division to disambiguate
1266            // from, so a `/` here can only introduce a qualified name.
1267            let (qualifier, name) = if self.at(TokenKind::Slash) {
1268                self.advance(); // consume `/`
1269                let name = self.parse_ident_in("contract name after '/'")?;
1270                (Some(first.name), name)
1271            } else {
1272                (None, first)
1273            };
1274
1275            // Reject inline braced blocks
1276            if self.at(TokenKind::LBrace) {
1277                self.error(
1278                    self.peek().span,
1279                    "Inline contract blocks are not allowed in `contracts:`. Declare the contract at module level.",
1280                );
1281                return None;
1282            }
1283
1284            let end = name.span;
1285            entries.push(ContractBinding {
1286                direction,
1287                qualifier,
1288                name,
1289                span: entry_start.merge(end),
1290            });
1291        }
1292
1293        if entries.is_empty() {
1294            self.error(
1295                start,
1296                "Empty `contracts:` clause. Add at least one `demands` or `fulfils` entry.",
1297            );
1298            return None;
1299        }
1300
1301        let end = entries.last().unwrap().span;
1302        Some(BlockItem {
1303            span: start.merge(end),
1304            kind: BlockItemKind::ContractsClause { entries },
1305        })
1306    }
1307
1308    /// Parse `@invariant Name`, `@guidance`, or `@guarantee Name` with comment body.
1309    fn parse_annotation(&mut self, start: Span) -> Option<BlockItem> {
1310        let at_tok = self.advance(); // consume `@`
1311        let at_col = self.col_of(at_tok.span);
1312
1313        if !self.peek_kind().is_word() {
1314            self.error(
1315                self.peek().span,
1316                format!("expected annotation keyword after `@`, found {}", self.peek_kind()),
1317            );
1318            return None;
1319        }
1320
1321        let keyword_tok = self.advance();
1322        let keyword_text = self.text(keyword_tok.span);
1323
1324        let kind = match keyword_text {
1325            "invariant" => AnnotationKind::Invariant,
1326            "guidance" => AnnotationKind::Guidance,
1327            "guarantee" => AnnotationKind::Guarantee,
1328            other => {
1329                self.error(
1330                    keyword_tok.span,
1331                    format!(
1332                        "Unknown annotation `@{other}`. Use `@invariant`, `@guidance` or `@guarantee`."
1333                    ),
1334                );
1335                return None;
1336            }
1337        };
1338
1339        // Parse optional name
1340        let name = match &kind {
1341            AnnotationKind::Invariant | AnnotationKind::Guarantee => {
1342                let n = self.parse_ident_in("annotation name")?;
1343                if n.name.chars().next().is_some_and(|c| c.is_lowercase()) {
1344                    self.diagnostics.push(Diagnostic::error(
1345                        n.span,
1346                        "Annotation names must be PascalCase.",
1347                    ));
1348                }
1349                Some(n)
1350            }
1351            AnnotationKind::Guidance => {
1352                // Reject name after @guidance
1353                if self.peek_kind().is_word()
1354                    && self.line_of(self.peek().span) == self.line_of(keyword_tok.span)
1355                {
1356                    self.error(
1357                        self.peek().span,
1358                        "`@guidance` does not take a name. Remove the name after `@guidance`.",
1359                    );
1360                    return None;
1361                }
1362                None
1363            }
1364        };
1365
1366        // Parse comment body from source lines.
1367        // The last consumed token tells us which line the annotation header is on.
1368        let last_header_span = name.as_ref().map(|n| n.span).unwrap_or(keyword_tok.span);
1369        let header_line = self.line_of(last_header_span);
1370        let body = self.parse_annotation_body(at_col, header_line);
1371
1372        if body.is_empty() {
1373            self.error(
1374                last_header_span,
1375                "Annotations must be followed by at least one indented comment line.",
1376            );
1377            return None;
1378        }
1379
1380        Some(BlockItem {
1381            span: start.merge(last_header_span),
1382            kind: BlockItemKind::Annotation(Annotation {
1383                kind,
1384                name,
1385                body,
1386                span: start.merge(last_header_span),
1387            }),
1388        })
1389    }
1390
1391    /// Scan source lines for indented `-- ` comment lines forming an annotation body.
1392    /// Starts from the line after `header_line` and collects lines indented deeper
1393    /// than `at_col`.
1394    fn parse_annotation_body(&self, at_col: u32, header_line: u32) -> Vec<String> {
1395        let mut body = Vec::new();
1396        let lines: Vec<&str> = self.source.lines().collect();
1397        let mut line_idx = (header_line + 1) as usize;
1398
1399        while line_idx < lines.len() {
1400            let line = lines[line_idx];
1401            let trimmed = line.trim_start();
1402
1403            if trimmed.is_empty() {
1404                if !body.is_empty() {
1405                    body.push(String::new());
1406                }
1407                line_idx += 1;
1408                continue;
1409            }
1410
1411            let indent = (line.len() - trimmed.len()) as u32;
1412            if indent <= at_col {
1413                break;
1414            }
1415
1416            if let Some(comment) = trimmed.strip_prefix("-- ") {
1417                body.push(comment.to_string());
1418            } else if trimmed == "--" {
1419                body.push(String::new());
1420            } else {
1421                break;
1422            }
1423
1424            line_idx += 1;
1425        }
1426
1427        // Trim trailing blank lines
1428        while body.last().is_some_and(|l| l.is_empty()) {
1429            body.pop();
1430        }
1431
1432        body
1433    }
1434
1435    /// Parse `invariant Name { expr }` inside a block (entity-level).
1436    fn parse_invariant_block_item(&mut self, start: Span) -> Option<BlockItem> {
1437        self.advance(); // consume `invariant`
1438        let name = self.parse_ident_in("invariant name")?;
1439
1440        // Reject lowercase invariant names
1441        if name.name.chars().next().is_some_and(|c| c.is_lowercase()) {
1442            self.diagnostics.push(Diagnostic::error(
1443                name.span,
1444                "invariant name must start with an uppercase letter",
1445            ));
1446        }
1447
1448        self.expect(TokenKind::LBrace)?;
1449        let body = self.parse_invariant_body()?;
1450        let end = self.expect(TokenKind::RBrace)?.span;
1451        Some(BlockItem {
1452            span: start.merge(end),
1453            kind: BlockItemKind::InvariantBlock { name, body },
1454        })
1455    }
1456
1457    fn parse_assign_or_clause_item(&mut self, start: Span) -> Option<BlockItem> {
1458        let name_tok = self.advance(); // consume name/keyword
1459        let name_text = self.text(name_tok.span).to_string();
1460        self.advance(); // consume ':'
1461
1462        let allows_binding = clause_allows_binding(&name_text);
1463        let value = self.parse_clause_value_maybe_binding(start, allows_binding)?;
1464        let value_span = value.span();
1465
1466        let kind = if is_clause_keyword(&name_text) {
1467            BlockItemKind::Clause {
1468                keyword: name_text,
1469                value,
1470            }
1471        } else if let Some((inner_value, when_clause)) = extract_when_clause(&value) {
1472            BlockItemKind::FieldWithWhen {
1473                name: Ident {
1474                    span: name_tok.span,
1475                    name: name_text,
1476                },
1477                value: inner_value,
1478                when_clause,
1479            }
1480        } else {
1481            BlockItemKind::Assignment {
1482                name: Ident {
1483                    span: name_tok.span,
1484                    name: name_text,
1485                },
1486                value,
1487            }
1488        };
1489
1490        Some(BlockItem {
1491            span: start.merge(value_span),
1492            kind,
1493        })
1494    }
1495
1496    /// Parse `Entity.field: value` — a dot-path reverse relationship declaration.
1497    fn parse_path_assignment_item(&mut self, start: Span) -> Option<BlockItem> {
1498        let obj_tok = self.advance(); // consume first ident
1499        self.advance(); // consume '.'
1500        let field = self.parse_ident_in("field name")?;
1501        self.advance(); // consume ':'
1502
1503        let path = Expr::MemberAccess {
1504            span: obj_tok.span.merge(field.span),
1505            object: Box::new(Expr::Ident(Ident {
1506                span: obj_tok.span,
1507                name: self.text(obj_tok.span).to_string(),
1508            })),
1509            field,
1510        };
1511
1512        let value = self.parse_clause_value(start)?;
1513        let value_span = value.span();
1514        Some(BlockItem {
1515            span: start.merge(value_span),
1516            kind: BlockItemKind::PathAssignment { path, value },
1517        })
1518    }
1519
1520    fn parse_param_or_clause_item(&mut self, start: Span) -> Option<BlockItem> {
1521        // Could be `name(params): value` (param assignment) or
1522        // `name(args)` which is an expression that happens to start a clause
1523        // value. Peek far enough to see if `)` is followed by `:`.
1524        let saved_pos = self.pos;
1525        let _name_tok = self.advance();
1526        self.advance(); // (
1527
1528        // Try to scan past balanced parens
1529        let mut depth = 1u32;
1530        while !self.at_eof() && depth > 0 {
1531            match self.peek_kind() {
1532                TokenKind::LParen => {
1533                    depth += 1;
1534                    self.advance();
1535                }
1536                TokenKind::RParen => {
1537                    depth -= 1;
1538                    self.advance();
1539                }
1540                _ => {
1541                    self.advance();
1542                }
1543            }
1544        }
1545
1546        if self.at(TokenKind::Colon) {
1547            // It's a parameterised assignment: restore and parse properly
1548            self.pos = saved_pos;
1549            let name = self.parse_ident_in("derived value name")?;
1550            self.expect(TokenKind::LParen)?;
1551            let params = self.parse_ident_list()?;
1552            self.expect(TokenKind::RParen)?;
1553            self.expect(TokenKind::Colon)?;
1554            let value = self.parse_clause_value(start)?;
1555            Some(BlockItem {
1556                span: start.merge(value.span()),
1557                kind: BlockItemKind::ParamAssignment {
1558                    name,
1559                    params,
1560                    value,
1561                },
1562            })
1563        } else {
1564            // Not a param assignment — restore and fall through to assignment
1565            self.pos = saved_pos;
1566            // Check for regular `name: value`
1567            if self.peek_at(1).kind == TokenKind::Colon {
1568            }
1569            // Fall back: treat as `name: value` where value starts with a call
1570            self.parse_assign_or_clause_item(start)
1571        }
1572    }
1573
1574    fn parse_ident_list(&mut self) -> Option<Vec<Ident>> {
1575        let mut params = Vec::new();
1576        if !self.at(TokenKind::RParen) {
1577            params.push(self.parse_ident_in("parameter name")?);
1578            while self.eat(TokenKind::Comma).is_some() {
1579                params.push(self.parse_ident_in("parameter name")?);
1580            }
1581        }
1582        Some(params)
1583    }
1584
1585    /// Parse a for-loop binding: either a single ident or `(a, b)` destructuring.
1586    fn parse_for_binding(&mut self) -> Option<ForBinding> {
1587        if self.at(TokenKind::LParen) {
1588            let start = self.advance().span; // consume '('
1589            let mut idents = Vec::new();
1590            idents.push(self.parse_ident_in("loop variable")?);
1591            while self.eat(TokenKind::Comma).is_some() {
1592                idents.push(self.parse_ident_in("loop variable")?);
1593            }
1594            let end = self.expect(TokenKind::RParen)?.span;
1595            Some(ForBinding::Destructured(idents, start.merge(end)))
1596        } else {
1597            let ident = self.parse_ident_in("loop variable")?;
1598            Some(ForBinding::Single(ident))
1599        }
1600    }
1601
1602    /// Parse a clause value, optionally checking for a `name: expr` binding
1603    /// pattern at the start. Used for when, facing and context clauses where
1604    /// the first `ident:` is a binding rather than a nested assignment.
1605    fn parse_clause_value_maybe_binding(
1606        &mut self,
1607        clause_start: Span,
1608        allow_binding: bool,
1609    ) -> Option<Expr> {
1610        if allow_binding
1611            && self.peek_kind().is_word()
1612            && self.peek_at(1).kind == TokenKind::Colon
1613        {
1614            // Check this isn't at the start of a new block item on the next line.
1615            // Bindings only apply on the same line or the immediate indented value.
1616            let clause_line = self.line_of(clause_start);
1617            let next_line = self.line_of(self.peek().span);
1618            let colon_is_block_item = next_line > clause_line
1619                && self.peek_at(2).kind != TokenKind::Eof
1620                && self.line_of(self.peek_at(2).span) == next_line;
1621
1622            if next_line == clause_line || colon_is_block_item {
1623                let name = self.parse_ident_in("binding name")?;
1624                self.advance(); // consume ':'
1625                let inner = self.parse_clause_value(clause_start)?;
1626                return Some(Expr::Binding {
1627                    span: name.span.merge(inner.span()),
1628                    name,
1629                    value: Box::new(inner),
1630                });
1631            }
1632        }
1633        self.parse_clause_value(clause_start)
1634    }
1635
1636    /// Parse a clause value. If the next token is on a new line (indented),
1637    /// collect a multi-line block. Otherwise parse a single expression.
1638    fn parse_clause_value(&mut self, clause_start: Span) -> Option<Expr> {
1639        let clause_line = self.line_of(clause_start);
1640        let next = self.peek();
1641        let next_line = self.line_of(next.span);
1642
1643        if next_line > clause_line {
1644            // Multi-line block — but only if the next token is actually
1645            // indented past the clause keyword. When a clause has only a
1646            // comment as its value (stripped by the lexer), the next visible
1647            // token is a sibling at the same indentation.
1648            let base_col = self.col_of(next.span);
1649            let clause_col = self.col_of(clause_start);
1650            if base_col <= clause_col {
1651                return Some(Expr::Block {
1652                    span: clause_start,
1653                    items: Vec::new(),
1654                });
1655            }
1656            self.parse_indented_block(base_col)
1657        } else {
1658            // Single-line clause value
1659            self.parse_expr(0)
1660        }
1661    }
1662
1663    /// Collect expressions that start at column >= `base_col` into a block.
1664    /// Also handles `let name = value` bindings inside clause value blocks.
1665    fn parse_indented_block(&mut self, base_col: u32) -> Option<Expr> {
1666        let start = self.peek().span;
1667        let mut items = Vec::new();
1668
1669        while !self.at_eof()
1670            && !self.at(TokenKind::RBrace)
1671            && self.col_of(self.peek().span) >= base_col
1672        {
1673            // Handle `let name = value` inside expression blocks
1674            if self.at(TokenKind::Let) {
1675                let let_start = self.advance().span;
1676                if let Some(name) = self.parse_ident_in("binding name") {
1677                    if self.expect(TokenKind::Eq).is_some() {
1678                        if let Some(value) = self.parse_expr(0) {
1679                            items.push(Expr::LetExpr {
1680                                span: let_start.merge(value.span()),
1681                                name,
1682                                value: Box::new(value),
1683                            });
1684                            continue;
1685                        }
1686                    }
1687                }
1688                break;
1689            }
1690
1691            if let Some(expr) = self.parse_expr(0) {
1692                items.push(expr);
1693            } else {
1694                self.advance();
1695                break;
1696            }
1697        }
1698
1699        if items.len() == 1 {
1700            Some(items.pop().unwrap())
1701        } else {
1702            let end = items.last().map(|e| e.span()).unwrap_or(start);
1703            Some(Expr::Block {
1704                span: start.merge(end),
1705                items,
1706            })
1707        }
1708    }
1709}
1710
1711// ---------------------------------------------------------------------------
1712// Expression parsing — Pratt parser
1713// ---------------------------------------------------------------------------
1714
1715// Binding powers (even = left, odd = right for right-associative)
1716const BP_LAMBDA: u8 = 4;
1717const BP_WHEN_GUARD: u8 = 5;
1718const BP_PROJECTION: u8 = 6;
1719const BP_WITH_WHERE: u8 = 7;
1720const BP_IMPLIES: u8 = 8;
1721const BP_OR: u8 = 10;
1722const BP_AND: u8 = 20;
1723const BP_COMPARE: u8 = 30;
1724const BP_TRANSITION: u8 = 32;
1725const BP_NULL_COALESCE: u8 = 40;
1726const BP_ADD: u8 = 50;
1727const BP_MUL: u8 = 60;
1728const BP_PIPE: u8 = 65;
1729const BP_PREFIX: u8 = 70;
1730const BP_POSTFIX: u8 = 80;
1731
1732impl<'s> Parser<'s> {
1733    pub fn parse_expr(&mut self, min_bp: u8) -> Option<Expr> {
1734        let mut lhs = self.parse_prefix()?;
1735
1736        loop {
1737            if let Some((l_bp, r_bp)) = self.infix_bp() {
1738                if l_bp < min_bp {
1739                    break;
1740                }
1741                lhs = self.parse_infix(lhs, r_bp)?;
1742            } else if let Some(l_bp) = self.postfix_bp() {
1743                if l_bp < min_bp {
1744                    break;
1745                }
1746                lhs = self.parse_postfix(lhs)?;
1747            } else {
1748                break;
1749            }
1750        }
1751
1752        Some(lhs)
1753    }
1754
1755    // -- prefix ---------------------------------------------------------
1756
1757    fn parse_prefix(&mut self) -> Option<Expr> {
1758        match self.peek_kind() {
1759            TokenKind::Not => {
1760                let start = self.advance().span;
1761                if self.at(TokenKind::Exists) {
1762                    self.advance();
1763                    let operand = self.parse_expr(BP_PREFIX)?;
1764                    Some(Expr::NotExists {
1765                        span: start.merge(operand.span()),
1766                        operand: Box::new(operand),
1767                    })
1768                } else {
1769                    let operand = self.parse_expr(BP_PREFIX)?;
1770                    Some(Expr::Not {
1771                        span: start.merge(operand.span()),
1772                        operand: Box::new(operand),
1773                    })
1774                }
1775            }
1776            TokenKind::Exists => {
1777                // When `exists` is not followed by an expression-start token,
1778                // treat it as a plain identifier (e.g. `label: exists`).
1779                let next = self.peek_at(1).kind;
1780                if matches!(
1781                    next,
1782                    TokenKind::RParen
1783                        | TokenKind::RBrace
1784                        | TokenKind::RBracket
1785                        | TokenKind::Comma
1786                        | TokenKind::Eof
1787                ) {
1788                    let id = self.parse_ident()?;
1789                    return Some(Expr::Ident(id));
1790                }
1791                let start = self.advance().span;
1792                let operand = self.parse_expr(BP_PREFIX)?;
1793                Some(Expr::Exists {
1794                    span: start.merge(operand.span()),
1795                    operand: Box::new(operand),
1796                })
1797            }
1798            TokenKind::If => self.parse_if_expr(),
1799            TokenKind::For => self.parse_for_expr(),
1800            TokenKind::LBrace => self.parse_brace_expr(),
1801            TokenKind::LBracket => {
1802                let t = self.advance();
1803                self.error(t.span, "list literals `[...]` are not supported; use `Set<T>` type annotation or `{...}` set literal");
1804                None
1805            }
1806            TokenKind::LParen => self.parse_paren_expr(),
1807            TokenKind::Number => {
1808                let t = self.advance();
1809                Some(Expr::NumberLiteral {
1810                    span: t.span,
1811                    value: self.text(t.span).to_string(),
1812                })
1813            }
1814            TokenKind::Duration => {
1815                let t = self.advance();
1816                Some(Expr::DurationLiteral {
1817                    span: t.span,
1818                    value: self.text(t.span).to_string(),
1819                })
1820            }
1821            TokenKind::String => {
1822                let sl = self.parse_string()?;
1823                Some(Expr::StringLiteral(sl))
1824            }
1825            TokenKind::BacktickLiteral => {
1826                let t = self.advance();
1827                let raw = self.text(t.span);
1828                // Strip surrounding backticks
1829                let value = raw[1..raw.len() - 1].to_string();
1830                Some(Expr::BacktickLiteral {
1831                    span: t.span,
1832                    value,
1833                })
1834            }
1835            TokenKind::True => {
1836                let t = self.advance();
1837                Some(Expr::BoolLiteral {
1838                    span: t.span,
1839                    value: true,
1840                })
1841            }
1842            TokenKind::False => {
1843                let t = self.advance();
1844                Some(Expr::BoolLiteral {
1845                    span: t.span,
1846                    value: false,
1847                })
1848            }
1849            TokenKind::Null => {
1850                let t = self.advance();
1851                Some(Expr::Null { span: t.span })
1852            }
1853            TokenKind::Now => {
1854                let t = self.advance();
1855                Some(Expr::Now { span: t.span })
1856            }
1857            TokenKind::This => {
1858                let t = self.advance();
1859                Some(Expr::This { span: t.span })
1860            }
1861            TokenKind::Within => {
1862                let t = self.advance();
1863                Some(Expr::Within { span: t.span })
1864            }
1865            k if k.is_word() => {
1866                let id = self.parse_ident()?;
1867                Some(Expr::Ident(id))
1868            }
1869            TokenKind::Star => {
1870                // Wildcard `*` in type position (e.g. `Codec<*>`)
1871                let t = self.advance();
1872                Some(Expr::Ident(Ident {
1873                    span: t.span,
1874                    name: "*".into(),
1875                }))
1876            }
1877            TokenKind::Minus => {
1878                // Unary minus: -expr → BinaryOp(0, Sub, expr)
1879                let start = self.advance().span;
1880                let operand = self.parse_expr(BP_PREFIX)?;
1881                Some(Expr::BinaryOp {
1882                    span: start.merge(operand.span()),
1883                    left: Box::new(Expr::NumberLiteral {
1884                        span: start,
1885                        value: "0".into(),
1886                    }),
1887                    op: BinaryOp::Sub,
1888                    right: Box::new(operand),
1889                })
1890            }
1891            _ => {
1892                self.error(
1893                    self.peek().span,
1894                    format!(
1895                        "expected expression (identifier, number, string, true/false, null, \
1896                         if/for/not/exists, '(', '{{', '['), found {}",
1897                        self.peek_kind(),
1898                    ),
1899                );
1900                None
1901            }
1902        }
1903    }
1904
1905    // -- infix binding powers -------------------------------------------
1906
1907    fn infix_bp(&self) -> Option<(u8, u8)> {
1908        match self.peek_kind() {
1909            TokenKind::FatArrow => Some((BP_LAMBDA, BP_LAMBDA - 1)), // right-assoc
1910            // `when` as an inline guard on provides/related items
1911            TokenKind::When => Some((BP_WHEN_GUARD, BP_WHEN_GUARD + 1)),
1912            TokenKind::Pipe => Some((BP_PIPE, BP_PIPE + 1)),
1913            TokenKind::Implies => Some((BP_IMPLIES, BP_IMPLIES - 1)), // right-assoc
1914            TokenKind::Or => Some((BP_OR, BP_OR + 1)),
1915            TokenKind::And => Some((BP_AND, BP_AND + 1)),
1916            TokenKind::Eq | TokenKind::BangEq => {
1917                Some((BP_COMPARE, BP_COMPARE + 1))
1918            }
1919            TokenKind::Lt => {
1920                // If `<` is immediately adjacent to a word token (no space),
1921                // treat as generic type postfix, not comparison infix.
1922                if self.pos > 0 {
1923                    let prev = self.tokens[self.pos - 1];
1924                    if prev.span.end == self.peek().span.start && prev.kind.is_word() {
1925                        return None;
1926                    }
1927                }
1928                Some((BP_COMPARE, BP_COMPARE + 1))
1929            }
1930            TokenKind::LtEq | TokenKind::Gt | TokenKind::GtEq => {
1931                Some((BP_COMPARE, BP_COMPARE + 1))
1932            }
1933            TokenKind::In => Some((BP_COMPARE, BP_COMPARE + 1)),
1934            // `not in` — only when followed by `in`
1935            TokenKind::Not if self.peek_at(1).kind == TokenKind::In => {
1936                Some((BP_COMPARE, BP_COMPARE + 1))
1937            }
1938            TokenKind::TransitionsTo => Some((BP_TRANSITION, BP_TRANSITION + 1)),
1939            TokenKind::Becomes => Some((BP_TRANSITION, BP_TRANSITION + 1)),
1940            TokenKind::Where => Some((BP_WITH_WHERE, BP_WITH_WHERE + 1)),
1941            TokenKind::With => Some((BP_WITH_WHERE, BP_WITH_WHERE + 1)),
1942            TokenKind::ThinArrow => Some((BP_PROJECTION, BP_PROJECTION + 1)),
1943            TokenKind::QuestionQuestion => Some((BP_NULL_COALESCE, BP_NULL_COALESCE + 1)),
1944            TokenKind::Plus | TokenKind::Minus => Some((BP_ADD, BP_ADD + 1)),
1945            TokenKind::Star | TokenKind::Slash => Some((BP_MUL, BP_MUL + 1)),
1946            _ => None,
1947        }
1948    }
1949
1950    fn parse_infix(&mut self, lhs: Expr, r_bp: u8) -> Option<Expr> {
1951        let op_tok = self.advance();
1952        match op_tok.kind {
1953            TokenKind::FatArrow => {
1954                let body = self.parse_expr(r_bp)?;
1955                Some(Expr::Lambda {
1956                    span: lhs.span().merge(body.span()),
1957                    param: Box::new(lhs),
1958                    body: Box::new(body),
1959                })
1960            }
1961            TokenKind::Pipe => {
1962                let rhs = self.parse_expr(r_bp)?;
1963                Some(Expr::Pipe {
1964                    span: lhs.span().merge(rhs.span()),
1965                    left: Box::new(lhs),
1966                    right: Box::new(rhs),
1967                })
1968            }
1969            TokenKind::Implies => {
1970                let rhs = self.parse_expr(r_bp)?;
1971                Some(Expr::LogicalOp {
1972                    span: lhs.span().merge(rhs.span()),
1973                    left: Box::new(lhs),
1974                    op: LogicalOp::Implies,
1975                    right: Box::new(rhs),
1976                })
1977            }
1978            TokenKind::Or => {
1979                let rhs = self.parse_expr(r_bp)?;
1980                Some(Expr::LogicalOp {
1981                    span: lhs.span().merge(rhs.span()),
1982                    left: Box::new(lhs),
1983                    op: LogicalOp::Or,
1984                    right: Box::new(rhs),
1985                })
1986            }
1987            TokenKind::And => {
1988                let rhs = self.parse_expr(r_bp)?;
1989                Some(Expr::LogicalOp {
1990                    span: lhs.span().merge(rhs.span()),
1991                    left: Box::new(lhs),
1992                    op: LogicalOp::And,
1993                    right: Box::new(rhs),
1994                })
1995            }
1996            TokenKind::Eq => {
1997                let rhs = self.parse_expr(r_bp)?;
1998                Some(Expr::Comparison {
1999                    span: lhs.span().merge(rhs.span()),
2000                    left: Box::new(lhs),
2001                    op: ComparisonOp::Eq,
2002                    right: Box::new(rhs),
2003                })
2004            }
2005            TokenKind::BangEq => {
2006                let rhs = self.parse_expr(r_bp)?;
2007                Some(Expr::Comparison {
2008                    span: lhs.span().merge(rhs.span()),
2009                    left: Box::new(lhs),
2010                    op: ComparisonOp::NotEq,
2011                    right: Box::new(rhs),
2012                })
2013            }
2014            TokenKind::Lt => {
2015                let rhs = self.parse_expr(r_bp)?;
2016                Some(Expr::Comparison {
2017                    span: lhs.span().merge(rhs.span()),
2018                    left: Box::new(lhs),
2019                    op: ComparisonOp::Lt,
2020                    right: Box::new(rhs),
2021                })
2022            }
2023            TokenKind::LtEq => {
2024                let rhs = self.parse_expr(r_bp)?;
2025                Some(Expr::Comparison {
2026                    span: lhs.span().merge(rhs.span()),
2027                    left: Box::new(lhs),
2028                    op: ComparisonOp::LtEq,
2029                    right: Box::new(rhs),
2030                })
2031            }
2032            TokenKind::Gt => {
2033                let rhs = self.parse_expr(r_bp)?;
2034                Some(Expr::Comparison {
2035                    span: lhs.span().merge(rhs.span()),
2036                    left: Box::new(lhs),
2037                    op: ComparisonOp::Gt,
2038                    right: Box::new(rhs),
2039                })
2040            }
2041            TokenKind::GtEq => {
2042                let rhs = self.parse_expr(r_bp)?;
2043                Some(Expr::Comparison {
2044                    span: lhs.span().merge(rhs.span()),
2045                    left: Box::new(lhs),
2046                    op: ComparisonOp::GtEq,
2047                    right: Box::new(rhs),
2048                })
2049            }
2050            TokenKind::In => {
2051                let rhs = self.parse_expr(r_bp)?;
2052                Some(Expr::In {
2053                    span: lhs.span().merge(rhs.span()),
2054                    element: Box::new(lhs),
2055                    collection: Box::new(rhs),
2056                })
2057            }
2058            TokenKind::Not => {
2059                // `not in`
2060                self.expect(TokenKind::In)?;
2061                let rhs = self.parse_expr(r_bp)?;
2062                Some(Expr::NotIn {
2063                    span: lhs.span().merge(rhs.span()),
2064                    element: Box::new(lhs),
2065                    collection: Box::new(rhs),
2066                })
2067            }
2068            TokenKind::Where => {
2069                let rhs = self.parse_expr(r_bp)?;
2070                Some(Expr::Where {
2071                    span: lhs.span().merge(rhs.span()),
2072                    source: Box::new(lhs),
2073                    condition: Box::new(rhs),
2074                })
2075            }
2076            TokenKind::With => {
2077                let rhs = self.parse_expr(r_bp)?;
2078                Some(Expr::With {
2079                    span: lhs.span().merge(rhs.span()),
2080                    source: Box::new(lhs),
2081                    predicate: Box::new(rhs),
2082                })
2083            }
2084            TokenKind::QuestionQuestion => {
2085                let rhs = self.parse_expr(r_bp)?;
2086                Some(Expr::NullCoalesce {
2087                    span: lhs.span().merge(rhs.span()),
2088                    left: Box::new(lhs),
2089                    right: Box::new(rhs),
2090                })
2091            }
2092            TokenKind::Plus => {
2093                let rhs = self.parse_expr(r_bp)?;
2094                Some(Expr::BinaryOp {
2095                    span: lhs.span().merge(rhs.span()),
2096                    left: Box::new(lhs),
2097                    op: BinaryOp::Add,
2098                    right: Box::new(rhs),
2099                })
2100            }
2101            TokenKind::Minus => {
2102                let rhs = self.parse_expr(r_bp)?;
2103                Some(Expr::BinaryOp {
2104                    span: lhs.span().merge(rhs.span()),
2105                    left: Box::new(lhs),
2106                    op: BinaryOp::Sub,
2107                    right: Box::new(rhs),
2108                })
2109            }
2110            TokenKind::Star => {
2111                let rhs = self.parse_expr(r_bp)?;
2112                Some(Expr::BinaryOp {
2113                    span: lhs.span().merge(rhs.span()),
2114                    left: Box::new(lhs),
2115                    op: BinaryOp::Mul,
2116                    right: Box::new(rhs),
2117                })
2118            }
2119            TokenKind::Slash => {
2120                // Check for qualified name: `alias/Name` or `alias/config`
2121                // Qualified if the LHS is a bare identifier and the RHS is a
2122                // word that either starts with uppercase or is a block keyword
2123                // (like `config`).
2124                if let Expr::Ident(ref id) = lhs {
2125                    if self.peek_kind().is_word() {
2126                        let next_text = self.text(self.peek().span);
2127                        let is_qualified = next_text
2128                            .chars()
2129                            .next()
2130                            .is_some_and(|c| c.is_uppercase())
2131                            || matches!(
2132                                self.peek_kind(),
2133                                TokenKind::Config | TokenKind::Entity | TokenKind::Value
2134                            );
2135                        if is_qualified {
2136                            let name_tok = self.advance();
2137                            return Some(Expr::QualifiedName(QualifiedName {
2138                                span: lhs.span().merge(name_tok.span),
2139                                qualifier: Some(id.name.clone()),
2140                                name: self.text(name_tok.span).to_string(),
2141                            }));
2142                        }
2143                    }
2144                }
2145                let rhs = self.parse_expr(r_bp)?;
2146                Some(Expr::BinaryOp {
2147                    span: lhs.span().merge(rhs.span()),
2148                    left: Box::new(lhs),
2149                    op: BinaryOp::Div,
2150                    right: Box::new(rhs),
2151                })
2152            }
2153            TokenKind::ThinArrow => {
2154                let field = self.parse_ident_in("projection field")?;
2155                Some(Expr::ProjectionMap {
2156                    span: lhs.span().merge(field.span),
2157                    source: Box::new(lhs),
2158                    field,
2159                })
2160            }
2161            TokenKind::TransitionsTo => {
2162                let rhs = self.parse_expr(r_bp)?;
2163                Some(Expr::TransitionsTo {
2164                    span: lhs.span().merge(rhs.span()),
2165                    subject: Box::new(lhs),
2166                    new_state: Box::new(rhs),
2167                })
2168            }
2169            TokenKind::Becomes => {
2170                let rhs = self.parse_expr(r_bp)?;
2171                Some(Expr::Becomes {
2172                    span: lhs.span().merge(rhs.span()),
2173                    subject: Box::new(lhs),
2174                    new_state: Box::new(rhs),
2175                })
2176            }
2177            TokenKind::When => {
2178                // Inline guard: `action when condition`
2179                let rhs = self.parse_expr(r_bp)?;
2180                Some(Expr::WhenGuard {
2181                    span: lhs.span().merge(rhs.span()),
2182                    action: Box::new(lhs),
2183                    condition: Box::new(rhs),
2184                })
2185            }
2186            _ => {
2187                self.error(
2188                    op_tok.span,
2189                    format!("unexpected infix operator {}", op_tok.kind),
2190                );
2191                None
2192            }
2193        }
2194    }
2195
2196    // -- postfix --------------------------------------------------------
2197
2198    fn postfix_bp(&self) -> Option<u8> {
2199        match self.peek_kind() {
2200            TokenKind::Dot | TokenKind::QuestionDot => Some(BP_POSTFIX),
2201            TokenKind::QuestionMark => Some(BP_POSTFIX),
2202            // `<` for generic types like `Set<T>`, `List<T>` — only treated
2203            // as postfix when it immediately follows a word with no space.
2204            TokenKind::Lt => {
2205                if self.pos > 0 {
2206                    let prev = self.tokens[self.pos - 1];
2207                    // Only if `<` starts immediately after the previous token
2208                    // (no whitespace gap) to distinguish from comparisons.
2209                    if prev.span.end == self.peek().span.start && prev.kind.is_word() {
2210                        return Some(BP_POSTFIX);
2211                    }
2212                }
2213                None
2214            }
2215            TokenKind::LParen => Some(BP_POSTFIX),
2216            TokenKind::LBrace => {
2217                // Join lookup: only when preceded by something that looks
2218                // like an entity name (handled generically — any expr can
2219                // be followed by { for join lookup in expression position).
2220                // But only if the { is on the same line to avoid consuming
2221                // a block body.
2222                let next = self.peek();
2223                let prev_end = if self.pos > 0 {
2224                    self.tokens[self.pos - 1].span.end
2225                } else {
2226                    0
2227                };
2228                // Same line check
2229                if self.line_of(Span::new(prev_end, prev_end))
2230                    == self.line_of(next.span)
2231                {
2232                    Some(BP_POSTFIX)
2233                } else {
2234                    None
2235                }
2236            }
2237            _ => None,
2238        }
2239    }
2240
2241    fn parse_postfix(&mut self, lhs: Expr) -> Option<Expr> {
2242        match self.peek_kind() {
2243            TokenKind::QuestionMark => {
2244                let end = self.advance().span;
2245                Some(Expr::TypeOptional {
2246                    span: lhs.span().merge(end),
2247                    inner: Box::new(lhs),
2248                })
2249            }
2250            TokenKind::Lt => {
2251                // Generic type: `Set<T>`, `List<Node?>`
2252                self.advance(); // consume <
2253                let mut args = Vec::new();
2254                // Parse args above comparison BP so `>` isn't consumed as infix
2255                while !self.at(TokenKind::Gt) && !self.at_eof() {
2256                    args.push(self.parse_expr(BP_COMPARE + 1)?);
2257                    self.eat(TokenKind::Comma);
2258                }
2259                let end = self.expect(TokenKind::Gt)?.span;
2260                Some(Expr::GenericType {
2261                    span: lhs.span().merge(end),
2262                    name: Box::new(lhs),
2263                    args,
2264                })
2265            }
2266            TokenKind::Dot => {
2267                self.advance();
2268                let field = self.parse_ident_in("field name")?;
2269                Some(Expr::MemberAccess {
2270                    span: lhs.span().merge(field.span),
2271                    object: Box::new(lhs),
2272                    field,
2273                })
2274            }
2275            TokenKind::QuestionDot => {
2276                self.advance();
2277                let field = self.parse_ident_in("field name")?;
2278                Some(Expr::OptionalAccess {
2279                    span: lhs.span().merge(field.span),
2280                    object: Box::new(lhs),
2281                    field,
2282                })
2283            }
2284            TokenKind::LParen => {
2285                self.advance();
2286                let args = self.parse_call_args()?;
2287                let end = self.expect(TokenKind::RParen)?.span;
2288                Some(Expr::Call {
2289                    span: lhs.span().merge(end),
2290                    function: Box::new(lhs),
2291                    args,
2292                })
2293            }
2294            TokenKind::LBrace => {
2295                self.advance();
2296                let fields = self.parse_join_fields()?;
2297                let end = self.expect(TokenKind::RBrace)?.span;
2298                Some(Expr::JoinLookup {
2299                    span: lhs.span().merge(end),
2300                    entity: Box::new(lhs),
2301                    fields,
2302                })
2303            }
2304            _ => None,
2305        }
2306    }
2307
2308    // -- call arguments -------------------------------------------------
2309
2310    fn parse_call_args(&mut self) -> Option<Vec<CallArg>> {
2311        let mut args = Vec::new();
2312        while !self.at(TokenKind::RParen) && !self.at_eof() {
2313            // Check for named argument: `name: value`
2314            if self.peek_kind().is_word() && self.peek_at(1).kind == TokenKind::Colon {
2315                let name = self.parse_ident_in("argument name")?;
2316                self.advance(); // :
2317                let value = self.parse_expr(0)?;
2318                args.push(CallArg::Named(NamedArg {
2319                    span: name.span.merge(value.span()),
2320                    name,
2321                    value,
2322                }));
2323            } else {
2324                let expr = self.parse_expr(0)?;
2325                args.push(CallArg::Positional(expr));
2326            }
2327            self.eat(TokenKind::Comma);
2328        }
2329        Some(args)
2330    }
2331
2332    // -- join fields ----------------------------------------------------
2333
2334    fn parse_join_fields(&mut self) -> Option<Vec<JoinField>> {
2335        let mut fields = Vec::new();
2336        while !self.at(TokenKind::RBrace) && !self.at_eof() {
2337            let field = self.parse_ident_in("join field name")?;
2338            let value = if self.eat(TokenKind::Colon).is_some() {
2339                Some(self.parse_expr(0)?)
2340            } else {
2341                None
2342            };
2343            fields.push(JoinField {
2344                span: field.span.merge(
2345                    value
2346                        .as_ref()
2347                        .map(|v| v.span())
2348                        .unwrap_or(field.span),
2349                ),
2350                field,
2351                value,
2352            });
2353            self.eat(TokenKind::Comma);
2354        }
2355        Some(fields)
2356    }
2357
2358    // -- if expression --------------------------------------------------
2359
2360    fn parse_if_expr(&mut self) -> Option<Expr> {
2361        let start = self.advance().span; // consume `if`
2362        let mut branches = Vec::new();
2363
2364        // First branch
2365        let condition = self.parse_expr(0)?;
2366        self.expect(TokenKind::Colon)?;
2367        let body = self.parse_branch_body(start)?;
2368        branches.push(CondBranch {
2369            span: start.merge(body.span()),
2370            condition,
2371            body,
2372        });
2373
2374        // else if / else
2375        let mut else_body = None;
2376        while self.at(TokenKind::Else) {
2377            let else_tok = self.advance();
2378            if self.at(TokenKind::If) {
2379                let if_start = self.advance().span;
2380                let cond = self.parse_expr(0)?;
2381                self.expect(TokenKind::Colon)?;
2382                let body = self.parse_branch_body(else_tok.span)?;
2383                branches.push(CondBranch {
2384                    span: if_start.merge(body.span()),
2385                    condition: cond,
2386                    body,
2387                });
2388            } else {
2389                self.expect(TokenKind::Colon)?;
2390                let body = self.parse_branch_body(else_tok.span)?;
2391                else_body = Some(Box::new(body));
2392                break;
2393            }
2394        }
2395
2396        let end = else_body
2397            .as_ref()
2398            .map(|b| b.span())
2399            .or_else(|| branches.last().map(|b| b.body.span()))
2400            .unwrap_or(start);
2401
2402        Some(Expr::Conditional {
2403            span: start.merge(end),
2404            branches,
2405            else_body,
2406        })
2407    }
2408
2409    fn parse_branch_body(&mut self, keyword_span: Span) -> Option<Expr> {
2410        let keyword_line = self.line_of(keyword_span);
2411        let next_line = self.line_of(self.peek().span);
2412
2413        if next_line > keyword_line {
2414            let base_col = self.col_of(self.peek().span);
2415            self.parse_indented_block(base_col)
2416        } else {
2417            self.parse_expr(0)
2418        }
2419    }
2420
2421    // -- for expression -------------------------------------------------
2422
2423    fn parse_for_expr(&mut self) -> Option<Expr> {
2424        let start = self.advance().span; // consume `for`
2425        let binding = self.parse_for_binding()?;
2426        self.expect(TokenKind::In)?;
2427
2428        // Parse collection, stopping before `where` and `:`
2429        let collection = self.parse_expr(BP_WITH_WHERE + 1)?;
2430
2431        let filter = if self.eat(TokenKind::Where).is_some() {
2432            // Parse filter at min_bp 0 — colon terminates naturally.
2433            Some(Box::new(self.parse_expr(0)?))
2434        } else {
2435            None
2436        };
2437
2438        self.expect(TokenKind::Colon)?;
2439        let body = self.parse_branch_body(start)?;
2440
2441        Some(Expr::For {
2442            span: start.merge(body.span()),
2443            binding,
2444            collection: Box::new(collection),
2445            filter,
2446            body: Box::new(body),
2447        })
2448    }
2449
2450    // -- brace expressions: set literal or object literal ---------------
2451
2452    fn parse_brace_expr(&mut self) -> Option<Expr> {
2453        let start = self.advance().span; // consume {
2454
2455        if self.at(TokenKind::RBrace) {
2456            let end = self.advance().span;
2457            return Some(Expr::SetLiteral {
2458                span: start.merge(end),
2459                elements: Vec::new(),
2460            });
2461        }
2462
2463        // Peek: if first item is `ident:`, it's an object literal
2464        if self.peek_kind().is_word() && self.peek_at(1).kind == TokenKind::Colon {
2465            return self.parse_object_literal(start);
2466        }
2467
2468        // Otherwise set literal
2469        self.parse_set_literal(start)
2470    }
2471
2472
2473    fn parse_object_literal(&mut self, start: Span) -> Option<Expr> {
2474        let mut fields = Vec::new();
2475        while !self.at(TokenKind::RBrace) && !self.at_eof() {
2476            let name = self.parse_ident_in("field name")?;
2477            self.expect(TokenKind::Colon)?;
2478            let value = self.parse_expr(0)?;
2479            fields.push(NamedArg {
2480                span: name.span.merge(value.span()),
2481                name,
2482                value,
2483            });
2484            self.eat(TokenKind::Comma);
2485        }
2486        let end = self.expect(TokenKind::RBrace)?.span;
2487        Some(Expr::ObjectLiteral {
2488            span: start.merge(end),
2489            fields,
2490        })
2491    }
2492
2493    fn parse_set_literal(&mut self, start: Span) -> Option<Expr> {
2494        let mut elements = Vec::new();
2495        while !self.at(TokenKind::RBrace) && !self.at_eof() {
2496            elements.push(self.parse_expr(0)?);
2497            self.eat(TokenKind::Comma);
2498        }
2499        let end = self.expect(TokenKind::RBrace)?.span;
2500        Some(Expr::SetLiteral {
2501            span: start.merge(end),
2502            elements,
2503        })
2504    }
2505
2506    // -- parenthesised expression ---------------------------------------
2507
2508    fn parse_paren_expr(&mut self) -> Option<Expr> {
2509        let start = self.advance().span; // (
2510
2511        // Detect `(name: expr, ...)` — typed signature parameters.
2512        if self.peek_kind().is_word() && self.peek_at(1).kind == TokenKind::Colon {
2513            let mut bindings = Vec::new();
2514            while !self.at(TokenKind::RParen) && !self.at_eof() {
2515                let name = self.parse_ident_in("parameter name")?;
2516                self.expect(TokenKind::Colon)?;
2517                let value = self.parse_expr(0)?;
2518                bindings.push(Expr::Binding {
2519                    span: name.span.merge(value.span()),
2520                    name,
2521                    value: Box::new(value),
2522                });
2523                self.eat(TokenKind::Comma);
2524            }
2525            self.expect(TokenKind::RParen)?;
2526            if bindings.len() == 1 {
2527                return Some(bindings.into_iter().next().unwrap());
2528            }
2529            let span = start.merge(bindings.last().unwrap().span());
2530            return Some(Expr::Block {
2531                span,
2532                items: bindings,
2533            });
2534        }
2535
2536        let expr = self.parse_expr(0)?;
2537        self.expect(TokenKind::RParen)?;
2538        Some(expr)
2539    }
2540}
2541
2542// ---------------------------------------------------------------------------
2543// Tests
2544// ---------------------------------------------------------------------------
2545
2546#[cfg(test)]
2547mod tests {
2548    use super::*;
2549    use crate::diagnostic::Severity;
2550
2551    fn parse_ok(src: &str) -> ParseResult {
2552        // Prefix with version marker if not already present, to avoid
2553        // spurious "missing version marker" warnings in every test.
2554        let owned;
2555        let input = if src.starts_with("-- allium:") {
2556            src
2557        } else {
2558            owned = format!("-- allium: 1\n{src}");
2559            &owned
2560        };
2561        let result = parse(input);
2562        if !result.diagnostics.is_empty() {
2563            for d in &result.diagnostics {
2564                eprintln!(
2565                    "  [{:?}] {} ({}..{})",
2566                    d.severity, d.message, d.span.start, d.span.end
2567                );
2568            }
2569        }
2570        result
2571    }
2572
2573    #[test]
2574    fn version_marker() {
2575        let r = parse_ok("-- allium: 1\n");
2576        assert_eq!(r.module.version, Some(1));
2577        assert_eq!(r.diagnostics.len(), 0);
2578    }
2579
2580    #[test]
2581    fn version_missing_warns() {
2582        let r = parse("entity User {}");
2583        assert_eq!(r.module.version, None);
2584        assert_eq!(r.diagnostics.len(), 1);
2585        assert_eq!(r.diagnostics[0].severity, Severity::Warning);
2586        assert!(r.diagnostics[0].message.contains("missing version marker"), "got: {}", r.diagnostics[0].message);
2587    }
2588
2589    #[test]
2590    fn version_unsupported_errors() {
2591        let r = parse("-- allium: 99\nentity User {}");
2592        assert_eq!(r.module.version, Some(99));
2593        assert!(r.diagnostics.iter().any(|d|
2594            d.severity == Severity::Error && d.message.contains("unsupported allium version 99")
2595        ), "expected unsupported version error, got: {:?}", r.diagnostics);
2596    }
2597
2598    #[test]
2599    fn empty_entity() {
2600        let r = parse_ok("entity User {}");
2601        assert_eq!(r.diagnostics.len(), 0);
2602        assert_eq!(r.module.declarations.len(), 1);
2603        match &r.module.declarations[0] {
2604            Decl::Block(b) => {
2605                assert_eq!(b.kind, BlockKind::Entity);
2606                assert_eq!(b.name.as_ref().unwrap().name, "User");
2607            }
2608            other => panic!("expected Block, got {other:?}"),
2609        }
2610    }
2611
2612    #[test]
2613    fn entity_with_fields() {
2614        let src = r#"entity Order {
2615    customer: Customer
2616    status: pending | active | completed
2617    total: Decimal
2618}"#;
2619        let r = parse_ok(src);
2620        assert_eq!(r.diagnostics.len(), 0);
2621        match &r.module.declarations[0] {
2622            Decl::Block(b) => {
2623                assert_eq!(b.items.len(), 3);
2624            }
2625            other => panic!("expected Block, got {other:?}"),
2626        }
2627    }
2628
2629    #[test]
2630    fn use_declaration() {
2631        let r = parse_ok(r#"use "github.com/specs/oauth/abc123" as oauth"#);
2632        assert_eq!(r.diagnostics.len(), 0);
2633        match &r.module.declarations[0] {
2634            Decl::Use(u) => {
2635                assert_eq!(u.alias.as_ref().unwrap().name, "oauth");
2636            }
2637            other => panic!("expected Use, got {other:?}"),
2638        }
2639    }
2640
2641    #[test]
2642    fn enum_declaration() {
2643        let src = "enum OrderStatus { pending | shipped | delivered }";
2644        let r = parse_ok(src);
2645        assert_eq!(r.diagnostics.len(), 0);
2646    }
2647
2648    #[test]
2649    fn config_block() {
2650        let src = r#"config {
2651    max_retries: Integer = 3
2652    timeout: Duration = 24.hours
2653}"#;
2654        // Config entries are `name: Type = default`. The parser sees
2655        // `name: Type = default` as an assignment where the value is
2656        // `Type = default` (comparison with Eq). That's fine for the
2657        // parse tree — semantic pass separates type from default.
2658        let r = parse_ok(src);
2659        assert_eq!(r.diagnostics.len(), 0);
2660    }
2661
2662    #[test]
2663    fn rule_declaration() {
2664        let src = r#"rule PlaceOrder {
2665    when: CustomerPlacesOrder(customer, items, total)
2666    requires: total > 0
2667    ensures: Order.created(customer: customer, status: pending, total: total)
2668}"#;
2669        let r = parse_ok(src);
2670        assert_eq!(r.diagnostics.len(), 0);
2671        match &r.module.declarations[0] {
2672            Decl::Block(b) => {
2673                assert_eq!(b.kind, BlockKind::Rule);
2674                assert_eq!(b.items.len(), 3);
2675            }
2676            other => panic!("expected Block, got {other:?}"),
2677        }
2678    }
2679
2680    #[test]
2681    fn expression_precedence() {
2682        let r = parse_ok("rule T { v: a + b * c }");
2683        // The value should be Add(a, Mul(b, c))
2684        match &r.module.declarations[0] {
2685            Decl::Block(b) => match &b.items[0].kind {
2686                BlockItemKind::Assignment { value, .. } => match value {
2687                    Expr::BinaryOp { op, right, .. } => {
2688                        assert_eq!(*op, BinaryOp::Add);
2689                        assert!(matches!(**right, Expr::BinaryOp { op: BinaryOp::Mul, .. }));
2690                    }
2691                    other => panic!("expected BinaryOp, got {other:?}"),
2692                },
2693                other => panic!("expected Assignment, got {other:?}"),
2694            },
2695            other => panic!("expected Block, got {other:?}"),
2696        }
2697    }
2698
2699    #[test]
2700    fn default_declaration() {
2701        let src = r#"default Role admin = { name: "admin", permissions: { "read" } }"#;
2702        let r = parse_ok(src);
2703        assert_eq!(r.diagnostics.len(), 0);
2704    }
2705
2706    #[test]
2707    fn open_question() {
2708        let src = r#"open question "Should admins be role-specific?""#;
2709        let r = parse_ok(src);
2710        assert_eq!(r.diagnostics.len(), 0);
2711    }
2712
2713    #[test]
2714    fn external_entity() {
2715        let src = "external entity Customer { email: String }";
2716        let r = parse_ok(src);
2717        assert_eq!(r.diagnostics.len(), 0);
2718        match &r.module.declarations[0] {
2719            Decl::Block(b) => assert_eq!(b.kind, BlockKind::ExternalEntity),
2720            other => panic!("expected Block, got {other:?}"),
2721        }
2722    }
2723
2724    #[test]
2725    fn where_expression() {
2726        let src = "entity E { active: items where status = active }";
2727        let r = parse_ok(src);
2728        assert_eq!(r.diagnostics.len(), 0);
2729    }
2730
2731    #[test]
2732    fn with_expression() {
2733        let src = "entity E { slots: InterviewSlot with candidacy = this }";
2734        let r = parse_ok(src);
2735        assert_eq!(r.diagnostics.len(), 0);
2736    }
2737
2738    #[test]
2739    fn lambda_expression() {
2740        let src = "entity E { v: items.any(i => i.active) }";
2741        let r = parse_ok(src);
2742        assert_eq!(r.diagnostics.len(), 0);
2743    }
2744
2745    #[test]
2746    fn deferred() {
2747        let src = "deferred InterviewerMatching.suggest";
2748        let r = parse_ok(src);
2749        assert_eq!(r.diagnostics.len(), 0);
2750        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2751        assert!(matches!(&d.path, Expr::MemberAccess { .. }));
2752        assert!(d.location_hint.is_none());
2753    }
2754
2755    #[test]
2756    fn deferred_qualified_path() {
2757        let src = "deferred billing/InvoiceWorkflow";
2758        let r = parse_ok(src);
2759        assert_eq!(r.diagnostics.len(), 0);
2760        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2761        let Expr::QualifiedName(q) = &d.path else {
2762            panic!("expected QualifiedName, got {:?}", d.path)
2763        };
2764        assert_eq!(q.qualifier.as_deref(), Some("billing"));
2765        assert_eq!(q.name, "InvoiceWorkflow");
2766    }
2767
2768    #[test]
2769    fn deferred_qualified_path_with_member() {
2770        let src = "deferred billing/InvoiceWorkflow.initiate";
2771        let r = parse_ok(src);
2772        assert_eq!(r.diagnostics.len(), 0);
2773        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2774        let Expr::MemberAccess { object, field, .. } = &d.path else {
2775            panic!("expected MemberAccess, got {:?}", d.path)
2776        };
2777        assert!(matches!(object.as_ref(), Expr::QualifiedName(_)));
2778        assert_eq!(field.name, "initiate");
2779    }
2780
2781    #[test]
2782    fn deferred_with_quoted_location_hint() {
2783        let src = "deferred Foo.bar \"detailed/foo.allium\"";
2784        let r = parse_ok(src);
2785        assert_eq!(r.diagnostics.len(), 0);
2786        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2787        let hint = d.location_hint.as_ref().expect("location hint parsed");
2788        assert_eq!(hint.text(), "detailed/foo.allium");
2789        assert_eq!(d.span.end, hint.span.end, "declaration span covers the hint");
2790    }
2791
2792    #[test]
2793    fn deferred_hint_string_must_share_the_line() {
2794        // A string on the next line is stray top-level input, not this
2795        // declaration's hint.
2796        let src = "deferred Foo\n\"detailed/foo.allium\"";
2797        let r = parse_ok(src);
2798        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2799        assert!(d.location_hint.is_none());
2800        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected declaration")));
2801    }
2802
2803    #[test]
2804    fn deferred_path_rejects_expression_shapes() {
2805        // The path is a dotted name, not an expression: calls and comparisons
2806        // leave their leftover tokens to error at declaration level, while the
2807        // declaration itself still forms with the flat name.
2808        for src in ["deferred Foo(\"x\")", "deferred Foo = \"x\""] {
2809            let r = parse_ok(src);
2810            let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2811            assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Foo"));
2812            assert!(
2813                r.diagnostics.iter().any(|d| d.message.contains("expected declaration")),
2814                "leftover tokens must error in {src:?}"
2815            );
2816        }
2817    }
2818
2819    #[test]
2820    fn deferred_parenthesised_path_errors() {
2821        let r = parse_ok("deferred (Foo)");
2822        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected deferred name")));
2823    }
2824
2825    #[test]
2826    fn deferred_trailing_dot_keeps_declaration() {
2827        // The path stops before a dangling `.`; the declaration still forms
2828        // (so the location-hint check covers the line) and the dot errors.
2829        let r = parse_ok("deferred Dangling.");
2830        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2831        assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Dangling"));
2832        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected declaration")));
2833    }
2834
2835    #[test]
2836    fn deferred_dangling_qualifier_errors() {
2837        let r = parse_ok("deferred billing/");
2838        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected deferred name after '/'")));
2839    }
2840
2841    #[test]
2842    fn deferred_path_does_not_cross_lines() {
2843        // Newlines are not tokens and keywords are word tokens, so without
2844        // line guards `Dangling.` would absorb the next declaration's
2845        // `deferred` keyword as a member name.
2846        let src = "deferred Dangling.\ndeferred Next.step";
2847        let r = parse_ok(src);
2848        let deferreds: Vec<_> = r
2849            .module
2850            .declarations
2851            .iter()
2852            .filter(|d| matches!(d, Decl::Deferred(_)))
2853            .collect();
2854        assert_eq!(deferreds.len(), 2, "both declarations survive");
2855        let Decl::Deferred(d) = deferreds[0] else { panic!() };
2856        assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Dangling"));
2857    }
2858
2859    #[test]
2860    fn variant_declaration() {
2861        let src = "variant Email : Notification { subject: String }";
2862        let r = parse_ok(src);
2863        assert_eq!(r.diagnostics.len(), 0);
2864    }
2865
2866    // -- projection mapping -----------------------------------------------
2867
2868    #[test]
2869    fn projection_arrow() {
2870        let src = "entity E { confirmed: confirmations where status = confirmed -> interviewer }";
2871        let r = parse_ok(src);
2872        assert_eq!(r.diagnostics.len(), 0);
2873    }
2874
2875    // -- transitions_to / becomes ------------------------------------------
2876
2877    #[test]
2878    fn transitions_to_trigger() {
2879        let src = "rule R { when: Interview.status transitions_to scheduled\n    ensures: Notification.created() }";
2880        let r = parse_ok(src);
2881        assert_eq!(r.diagnostics.len(), 0);
2882    }
2883
2884    #[test]
2885    fn becomes_trigger() {
2886        let src = "rule R { when: Interview.status becomes scheduled\n    ensures: Notification.created() }";
2887        let r = parse_ok(src);
2888        assert_eq!(r.diagnostics.len(), 0);
2889    }
2890
2891    // -- binding colon in clause values ------------------------------------
2892
2893    #[test]
2894    fn when_binding() {
2895        let src = "rule R {\n    when: interview: Interview.status transitions_to scheduled\n    ensures: Notification.created()\n}";
2896        let r = parse_ok(src);
2897        assert_eq!(r.diagnostics.len(), 0);
2898        // The when clause value should be a Binding wrapping a TransitionsTo
2899        let decl = &r.module.declarations[0];
2900        if let Decl::Block(b) = decl {
2901            if let BlockItemKind::Clause { keyword, value } = &b.items[0].kind {
2902                assert_eq!(keyword, "when");
2903                assert!(matches!(value, Expr::Binding { .. }));
2904            } else {
2905                panic!("expected clause");
2906            }
2907        } else {
2908            panic!("expected block decl");
2909        }
2910    }
2911
2912    #[test]
2913    fn when_binding_temporal() {
2914        let src = "rule R {\n    when: invitation: Invitation.expires_at <= now\n    ensures: Invitation.expired()\n}";
2915        let r = parse_ok(src);
2916        assert_eq!(r.diagnostics.len(), 0);
2917    }
2918
2919    #[test]
2920    fn when_binding_created() {
2921        let src = "rule R {\n    when: batch: DigestBatch.created\n    ensures: Email.created()\n}";
2922        let r = parse_ok(src);
2923        assert_eq!(r.diagnostics.len(), 0);
2924    }
2925
2926    #[test]
2927    fn facing_binding() {
2928        let src = "surface S {\n    facing viewer: Interviewer\n    exposes: InterviewList\n}";
2929        let r = parse_ok(src);
2930        assert_eq!(r.diagnostics.len(), 0);
2931    }
2932
2933    #[test]
2934    fn context_binding() {
2935        let src = "surface S {\n    facing viewer: Interviewer\n    context assignment: SlotConfirmation where interviewer = viewer\n}";
2936        let r = parse_ok(src);
2937        assert_eq!(r.diagnostics.len(), 0);
2938    }
2939
2940    // -- rule-level for block item -----------------------------------------
2941
2942    #[test]
2943    fn rule_level_for() {
2944        let src = r#"rule ProcessDigests {
2945    when: schedule: DigestSchedule.next_run_at <= now
2946    for user in Users where notification_setting.digest_enabled:
2947        ensures: DigestBatch.created(user: user)
2948}"#;
2949        let r = parse_ok(src);
2950        assert_eq!(r.diagnostics.len(), 0);
2951        if let Decl::Block(b) = &r.module.declarations[0] {
2952            // Should have when clause + for block item
2953            assert!(b.items.len() >= 2);
2954            assert!(matches!(b.items[1].kind, BlockItemKind::ForBlock { .. }));
2955        } else {
2956            panic!("expected block decl");
2957        }
2958    }
2959
2960    // -- let inside ensures blocks -----------------------------------------
2961
2962    #[test]
2963    fn let_in_ensures_block() {
2964        let src = r#"rule R {
2965    when: ScheduleInterview(candidacy, time, interviewers)
2966    ensures:
2967        let slot = InterviewSlot.created(time: time, candidacy: candidacy)
2968        for interviewer in interviewers:
2969            SlotConfirmation.created(slot: slot, interviewer: interviewer)
2970}"#;
2971        let r = parse_ok(src);
2972        assert_eq!(r.diagnostics.len(), 0);
2973    }
2974
2975    // -- when guard on provides items --------------------------------------
2976
2977    #[test]
2978    fn provides_when_guard() {
2979        let src = "surface S {\n    facing viewer: Interviewer\n    provides: ConfirmSlot(viewer, slot) when slot.status = pending\n}";
2980        let r = parse_ok(src);
2981        assert_eq!(r.diagnostics.len(), 0);
2982    }
2983
2984    // -- optional type suffix ----------------------------------------------
2985
2986    #[test]
2987    fn optional_type_suffix() {
2988        let src = "entity E { locked_until: Timestamp? }";
2989        let r = parse_ok(src);
2990        assert_eq!(r.diagnostics.len(), 0);
2991    }
2992
2993    #[test]
2994    fn optional_trigger_param() {
2995        let src = "rule R { when: Report(interviewer, interview, reason, details?)\n    ensures: Done() }";
2996        let r = parse_ok(src);
2997        assert_eq!(r.diagnostics.len(), 0);
2998    }
2999
3000    // -- qualified name with config ----------------------------------------
3001
3002    #[test]
3003    fn qualified_config_access() {
3004        let src = "entity E { duration: oauth/config.session_duration }";
3005        let r = parse_ok(src);
3006        assert_eq!(r.diagnostics.len(), 0);
3007    }
3008
3009    // -- comprehensive integration test ------------------------------------
3010
3011    #[test]
3012    fn realistic_spec() {
3013        let src = r#"-- allium: 1
3014
3015enum OrderStatus { pending | shipped | delivered }
3016
3017external entity Customer {
3018    email: String
3019    name: String
3020}
3021
3022entity Order {
3023    customer: Customer
3024    status: OrderStatus
3025    total: Decimal
3026    items: OrderItem with order = this
3027    shipped_items: items where status = shipped
3028    confirmed_items: items where status = confirmed -> item
3029    is_complete: status = delivered
3030    locked_until: Timestamp?
3031}
3032
3033config {
3034    max_retries: Integer = 3
3035    timeout: Duration = 24.hours
3036}
3037
3038rule PlaceOrder {
3039    when: CustomerPlacesOrder(customer, items, total)
3040    requires: total > 0
3041    ensures: Order.created(customer: customer, status: pending, total: total)
3042}
3043
3044rule ShipOrder {
3045    when: order: Order.status transitions_to shipped
3046    ensures: Email.created(to: order.customer.email, template: order_shipped)
3047}
3048
3049open question "How do we handle partial shipments?"
3050"#;
3051        let r = parse_ok(src);
3052        assert_eq!(r.diagnostics.len(), 0, "expected no errors");
3053        assert_eq!(r.module.version, Some(1));
3054        assert_eq!(r.module.declarations.len(), 7);
3055    }
3056
3057    #[test]
3058    fn extension_behaviour_excerpt() {
3059        // Exercises: inline enums, generic types, or-triggers, named call
3060        // args, config with typed defaults, module declaration.
3061        let src = r#"value Document {
3062    uri: String
3063    text: String
3064}
3065
3066entity Finding {
3067    code: String
3068    severity: error | warning | info
3069    range: FindingRange
3070}
3071
3072entity DiagnosticsMode {
3073    value: strict | relaxed
3074}
3075
3076config {
3077    duplicateKey: String = "allium.config.duplicateKey"
3078}
3079
3080rule RefreshDiagnostics {
3081    when: DocumentOpened(document) or DocumentChanged(document)
3082    requires: document.language_id = "allium"
3083    ensures: FindingsComputed(document)
3084}
3085
3086surface DiagnosticsDashboard {
3087    facing viewer: Developer
3088    context doc: Document where viewer.active_document = doc
3089    provides: RunChecks(viewer) when doc.language_id = "allium"
3090    exposes: FindingList
3091}
3092
3093rule ProcessDigests {
3094    when: schedule: DigestSchedule.next_run_at <= now
3095    for user in Users where notification_setting.digest_enabled:
3096        let settings = user.notification_setting
3097        ensures: DigestBatch.created(user: user)
3098}
3099"#;
3100        let r = parse_ok(src);
3101        assert_eq!(r.diagnostics.len(), 0, "expected no errors");
3102        // value + entity + entity + config + rule + surface + rule = 7
3103        assert_eq!(r.module.declarations.len(), 7);
3104    }
3105
3106    #[test]
3107    fn exists_as_identifier() {
3108        let src = r#"rule R {
3109    when: X()
3110    ensures: CompletionItemAvailable(label: exists)
3111}"#;
3112        let r = parse_ok(src);
3113        assert_eq!(r.diagnostics.len(), 0);
3114    }
3115
3116    // -- pipe precedence: tighter than boolean ops ----------------------------
3117
3118    #[test]
3119    fn pipe_binds_tighter_than_or() {
3120        // `a or b | c` should parse as `a or (b | c)`, not `(a or b) | c`
3121        let src = "entity E { v: a or b | c }";
3122        let r = parse_ok(src);
3123        assert_eq!(r.diagnostics.len(), 0);
3124        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3125        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3126        // Top-level should be LogicalOp(Or)
3127        let Expr::LogicalOp { op, right, .. } = value else {
3128            panic!("expected LogicalOp, got {value:?}");
3129        };
3130        assert_eq!(*op, LogicalOp::Or);
3131        // Right side should be Pipe(b, c)
3132        assert!(matches!(right.as_ref(), Expr::Pipe { .. }));
3133    }
3134
3135    // -- variant with expression base -----------------------------------------
3136
3137    #[test]
3138    fn variant_with_pipe_base() {
3139        let src = "variant Mixed : TypeA | TypeB";
3140        let r = parse_ok(src);
3141        assert_eq!(r.diagnostics.len(), 0);
3142        let Decl::Variant(v) = &r.module.declarations[0] else { panic!() };
3143        assert!(matches!(v.base, Expr::Pipe { .. }));
3144    }
3145
3146    // -- for-block with comparison in where filter ----------------------------
3147
3148    #[test]
3149    fn for_block_where_comparison() {
3150        let src = r#"rule R {
3151    when: X()
3152    for item in Items where item.status = active:
3153        ensures: Processed(item: item)
3154}"#;
3155        let r = parse_ok(src);
3156        assert_eq!(r.diagnostics.len(), 0);
3157        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3158        let BlockItemKind::ForBlock { filter, .. } = &b.items[1].kind else { panic!() };
3159        assert!(filter.is_some());
3160        assert!(matches!(filter.as_ref().unwrap(), Expr::Comparison { .. }));
3161    }
3162
3163    // -- for-expression with comparison in where filter -----------------------
3164
3165    #[test]
3166    fn for_expr_where_comparison() {
3167        let src = r#"rule R {
3168    when: X()
3169    ensures:
3170        for item in Items where item.active = true:
3171            Processed(item: item)
3172}"#;
3173        let r = parse_ok(src);
3174        assert_eq!(r.diagnostics.len(), 0);
3175    }
3176
3177    // -- if/else if/else chain ------------------------------------------------
3178
3179    #[test]
3180    fn if_else_if_else() {
3181        let src = r#"rule R {
3182    when: X(v)
3183    ensures:
3184        if v < 10: Small()
3185        else if v < 100: Medium()
3186        else: Large()
3187}"#;
3188        let r = parse_ok(src);
3189        assert_eq!(r.diagnostics.len(), 0);
3190    }
3191
3192    // -- null coalescing and optional chaining --------------------------------
3193
3194    #[test]
3195    fn null_coalesce_and_optional_chain() {
3196        let src = "entity E { v: a?.b ?? fallback }";
3197        let r = parse_ok(src);
3198        assert_eq!(r.diagnostics.len(), 0);
3199        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3200        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3201        // Top-level should be NullCoalesce
3202        assert!(matches!(value, Expr::NullCoalesce { .. }));
3203    }
3204
3205    // -- generic types --------------------------------------------------------
3206
3207    #[test]
3208    fn generic_type_nested() {
3209        let src = "entity E { v: List<Set<String>> }";
3210        let r = parse_ok(src);
3211        assert_eq!(r.diagnostics.len(), 0);
3212    }
3213
3214    // -- set literal, list literal, object literal ----------------------------
3215
3216    #[test]
3217    fn collection_literals() {
3218        let src = r#"rule R {
3219    when: X()
3220    ensures:
3221        let s = {a, b, c}
3222        let o = {name: "test", count: 42}
3223        Done()
3224}"#;
3225        let r = parse_ok(src);
3226        assert_eq!(r.diagnostics.len(), 0);
3227    }
3228
3229    #[test]
3230    fn spec_reject_list_literal() {
3231        // The spec does not define `[...]` list literal syntax.
3232        let src = r#"rule R {
3233    when: X()
3234    ensures:
3235        let l = [1, 2, 3]
3236        Done()
3237}"#;
3238        let r = parse_ok(src);
3239        assert!(
3240            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3241            "expected error for `[...]` list literal (not in spec), but parsed without errors"
3242        );
3243    }
3244
3245    // -- given block ----------------------------------------------------------
3246
3247    #[test]
3248    fn given_block() {
3249        let src = "given { viewer: User\n    time: Timestamp }";
3250        let r = parse_ok(src);
3251        assert_eq!(r.diagnostics.len(), 0);
3252        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3253        assert_eq!(b.kind, BlockKind::Given);
3254        assert!(b.name.is_none());
3255    }
3256
3257    // -- actor block ----------------------------------------------------------
3258
3259    #[test]
3260    fn actor_block() {
3261        let src = "actor Admin { identified_by: User where role = admin }";
3262        let r = parse_ok(src);
3263        assert_eq!(r.diagnostics.len(), 0);
3264        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3265        assert_eq!(b.kind, BlockKind::Actor);
3266    }
3267
3268    // -- join lookup ----------------------------------------------------------
3269
3270    #[test]
3271    fn join_lookup() {
3272        let src = "entity E { match: Other{field_a, field_b: value} }";
3273        let r = parse_ok(src);
3274        assert_eq!(r.diagnostics.len(), 0);
3275        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3276        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3277        assert!(matches!(value, Expr::JoinLookup { .. }));
3278    }
3279
3280    // -- in / not in with set literal -----------------------------------------
3281
3282    #[test]
3283    fn in_not_in_set() {
3284        let src = r#"rule R {
3285    when: X(s)
3286    requires: s in {a, b, c}
3287    requires: s not in {d, e}
3288    ensures: Done()
3289}"#;
3290        let r = parse_ok(src);
3291        assert_eq!(r.diagnostics.len(), 0);
3292    }
3293
3294    // -- comprehensive fixture file -------------------------------------------
3295
3296    #[test]
3297    fn comprehensive_fixture() {
3298        let src = include_str!("../tests/fixtures/comprehensive-edge-cases.allium");
3299        let r = parse(src);
3300        assert_eq!(
3301            r.diagnostics.len(),
3302            0,
3303            "expected no errors in comprehensive fixture, got: {:?}",
3304            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>(),
3305        );
3306        assert!(r.module.declarations.len() > 30, "expected many declarations");
3307    }
3308
3309    // -- error message quality ------------------------------------------------
3310
3311    #[test]
3312    fn error_expected_declaration() {
3313        let r = parse("-- allium: 1\n+ invalid");
3314        assert!(r.diagnostics.len() >= 1);
3315        let msg = &r.diagnostics[0].message;
3316        assert!(msg.contains("expected declaration"), "got: {msg}");
3317        assert!(msg.contains("entity"), "should list valid options, got: {msg}");
3318        assert!(msg.contains("rule"), "should list valid options, got: {msg}");
3319    }
3320
3321    #[test]
3322    fn error_expected_expression() {
3323        let r = parse("-- allium: 1\nentity E { v: }");
3324        assert!(r.diagnostics.len() >= 1);
3325        let msg = &r.diagnostics[0].message;
3326        assert!(msg.contains("expected expression"), "got: {msg}");
3327        assert!(msg.contains("identifier"), "should list valid starters, got: {msg}");
3328    }
3329
3330    #[test]
3331    fn error_expected_block_item() {
3332        let r = parse("-- allium: 1\nentity E { + }");
3333        assert!(r.diagnostics.len() >= 1);
3334        let msg = &r.diagnostics[0].message;
3335        assert!(msg.contains("expected block item"), "got: {msg}");
3336    }
3337
3338    #[test]
3339    fn error_expected_identifier() {
3340        let r = parse("-- allium: 1\nentity 123 {}");
3341        assert!(r.diagnostics.len() >= 1);
3342        let msg = &r.diagnostics[0].message;
3343        // Context-aware: says "entity name" not generic "identifier"
3344        assert!(msg.contains("expected entity name"), "got: {msg}");
3345        // Human-friendly: says "number" not "Number" or "TokenKind::Number"
3346        assert!(msg.contains("number"), "should say what was found, got: {msg}");
3347    }
3348
3349    #[test]
3350    fn error_missing_brace() {
3351        let r = parse("entity E {");
3352        assert!(r.diagnostics.len() >= 1);
3353        let msg = &r.diagnostics[0].message;
3354        assert!(msg.contains("expected"), "got: {msg}");
3355    }
3356
3357    #[test]
3358    fn error_recovery_multiple() {
3359        // Parser should recover and report multiple errors (on separate lines)
3360        let r = parse("entity E { + }\nentity F { - }");
3361        assert!(r.diagnostics.len() >= 2, "expected at least 2 errors, got {}", r.diagnostics.len());
3362    }
3363
3364    #[test]
3365    fn error_dedup_same_line() {
3366        // Multiple bad tokens on a single line should produce only one error
3367        let r = parse("-- allium: 1\n+ - * /");
3368        let errors: Vec<_> = r.diagnostics.iter()
3369            .filter(|d| d.severity == crate::diagnostic::Severity::Error)
3370            .collect();
3371        assert_eq!(errors.len(), 1, "expected 1 error for same-line bad tokens, got {}", errors.len());
3372    }
3373
3374    #[test]
3375    fn for_block() {
3376        let src = r#"rule R {
3377    when: X()
3378    for user in Users where user.active:
3379        ensures: Notified(user: user)
3380}"#;
3381        let r = parse_ok(src);
3382        assert_eq!(r.diagnostics.len(), 0);
3383        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3384        assert!(matches!(b.items[1].kind, BlockItemKind::ForBlock { .. }));
3385    }
3386
3387    #[test]
3388    fn for_expr() {
3389        let src = r#"rule R {
3390    when: X(project)
3391    ensures:
3392        let total = for task in project.tasks: task.effort
3393        Done(total: total)
3394}"#;
3395        let r = parse_ok(src);
3396        assert_eq!(r.diagnostics.len(), 0);
3397    }
3398
3399    #[test]
3400    fn for_where() {
3401        let src = r#"rule R {
3402    when: X()
3403    for item in Items where item.active:
3404        ensures: Processed(item: item)
3405}"#;
3406        let r = parse_ok(src);
3407        assert_eq!(r.diagnostics.len(), 0);
3408    }
3409
3410    #[test]
3411    fn spec_reject_for_with_filter() {
3412        // The spec uses `where` for iteration filtering; `with` is for
3413        // relationship declarations only.
3414        let src = r#"rule R {
3415    when: X()
3416    for slot in Slot with slot.role = reviewer:
3417        ensures: Reviewed(slot: slot)
3418}"#;
3419        let r = parse_ok(src);
3420        assert!(
3421            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3422            "expected error for `for ... with` (spec uses `where`), but parsed without errors"
3423        );
3424    }
3425
3426    #[test]
3427    fn block_level_if() {
3428        let src = r#"rule R {
3429    when: X(task)
3430    if task.priority = high:
3431        ensures: Escalated(task: task)
3432}"#;
3433        let r = parse_ok(src);
3434        assert_eq!(r.diagnostics.len(), 0);
3435        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3436        let BlockItemKind::IfBlock { branches, else_items } = &b.items[1].kind else {
3437            panic!("expected IfBlock, got {:?}", b.items[1].kind);
3438        };
3439        assert_eq!(branches.len(), 1);
3440        assert!(else_items.is_none());
3441    }
3442
3443    #[test]
3444    fn block_level_if_else() {
3445        let src = r#"rule R {
3446    when: X(score)
3447    if score > 80:
3448        ensures: High()
3449    else if score > 40:
3450        ensures: Medium()
3451    else:
3452        ensures: Low()
3453}"#;
3454        let r = parse_ok(src);
3455        assert_eq!(r.diagnostics.len(), 0);
3456        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3457        let BlockItemKind::IfBlock { branches, else_items } = &b.items[1].kind else {
3458            panic!("expected IfBlock, got {:?}", b.items[1].kind);
3459        };
3460        assert_eq!(branches.len(), 2);
3461        assert!(else_items.is_some());
3462    }
3463
3464    #[test]
3465    fn wildcard_type_parameter() {
3466        let src = "entity E { codec: Codec<*> }";
3467        let r = parse_ok(src);
3468        assert_eq!(r.diagnostics.len(), 0);
3469        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3470        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3471        if let Expr::GenericType { args, .. } = value {
3472            assert_eq!(args.len(), 1);
3473            if let Expr::Ident(id) = &args[0] {
3474                assert_eq!(id.name, "*");
3475            } else {
3476                panic!("expected wildcard ident, got {:?}", args[0]);
3477            }
3478        } else {
3479            panic!("expected GenericType, got {:?}", value);
3480        }
3481    }
3482
3483    #[test]
3484    fn guidance_clause_comment_only_value_migration() {
3485        // Old `guidance:` colon form should emit a migration diagnostic
3486        let src = "-- allium: 1\nrule R {\n    ensures: Done()\n    guidance: -- just a comment\n}";
3487        let r = parse(src);
3488        assert!(
3489            r.diagnostics.iter().any(|d| d.message.contains("`guidance:` syntax was replaced")),
3490            "expected migration diagnostic, got: {:?}",
3491            r.diagnostics
3492        );
3493    }
3494
3495    #[test]
3496    fn spec_reject_for_expr_with_filter() {
3497        // Expression-level `for` also only accepts `where`, not `with`.
3498        let src = r#"rule R {
3499    when: X(project)
3500    ensures:
3501        let total = for task in project.tasks with task.active: task.effort
3502        Done(total: total)
3503}"#;
3504        let r = parse_ok(src);
3505        assert!(
3506            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3507            "expected error for `for ... with` in expression (spec uses `where`), but parsed without errors"
3508        );
3509    }
3510
3511    #[test]
3512    fn for_destructured_binding() {
3513        let src = r#"rule R {
3514    when: X()
3515    for (key, value) in Pairs where key != null:
3516        ensures: Processed(key: key, value: value)
3517}"#;
3518        let r = parse_ok(src);
3519        assert_eq!(r.diagnostics.len(), 0);
3520        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3521        let BlockItemKind::ForBlock { binding, .. } = &b.items[1].kind else { panic!() };
3522        assert!(matches!(binding, ForBinding::Destructured(ids, _) if ids.len() == 2));
3523    }
3524
3525    #[test]
3526    fn dot_path_assignment() {
3527        let src = r#"entity Shard {
3528    ShardGroup.shard_cache: Shard with group = this
3529}"#;
3530        let r = parse_ok(src);
3531        assert_eq!(r.diagnostics.len(), 0);
3532        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3533        let BlockItemKind::PathAssignment { path, .. } = &b.items[0].kind else {
3534            panic!("expected PathAssignment, got {:?}", b.items[0].kind);
3535        };
3536        assert!(matches!(path, Expr::MemberAccess { .. }));
3537    }
3538
3539    #[test]
3540    fn language_reference_fixture() {
3541        let src = include_str!("../tests/fixtures/language-reference-constructs.allium");
3542        let r = parse(src);
3543        let errors: Vec<_> = r.diagnostics.iter()
3544            .filter(|d| d.severity == Severity::Error)
3545            .collect();
3546        assert_eq!(
3547            errors.len(),
3548            0,
3549            "expected no errors in language-reference fixture, got: {:?}",
3550            errors.iter().map(|d| &d.message).collect::<Vec<_>>(),
3551        );
3552    }
3553
3554    // =====================================================================
3555    // V1 SPEC CONFORMANCE TESTS
3556    //
3557    // These tests verify that the parser conforms to the Allium V1 language
3558    // reference (docs/allium-v1-language-reference.md). Each test is tagged
3559    // with the finding number from the audit.
3560    //
3561    // Tests marked "should reject" are expected to FAIL until the parser
3562    // is updated to reject non-spec constructs.
3563    //
3564    // Tests marked "should parse" are expected to FAIL until the parser
3565    // is updated to handle spec-defined constructs.
3566    // =====================================================================
3567
3568    // -- Finding 1: spec uses `for`, not `for each` ---------------------------
3569
3570    #[test]
3571    fn spec_for_bare_form() {
3572        // The spec uses bare `for` exclusively. This must parse cleanly.
3573        let src = r#"rule ProcessDigests {
3574    when: schedule: DigestSchedule.next_run_at <= now
3575    for user in Users where notification_setting.digest_enabled:
3576        let settings = user.notification_setting
3577        ensures: DigestBatch.created(user: user)
3578}"#;
3579        let r = parse_ok(src);
3580        assert_eq!(r.diagnostics.len(), 0);
3581    }
3582
3583    #[test]
3584    fn spec_reject_for_each() {
3585        // `for each` is not in the spec. The parser should reject it.
3586        let src = r#"rule R {
3587    when: X()
3588    for each user in Users where user.active:
3589        ensures: Notified(user: user)
3590}"#;
3591        let r = parse_ok(src);
3592        assert!(
3593            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3594            "expected error for `for each` (not in spec), but parsed without errors"
3595        );
3596    }
3597
3598    // -- Finding 2: spec uses `=`, not `==` -----------------------------------
3599
3600    #[test]
3601    fn spec_reject_double_equals() {
3602        // The spec uses `=` for equality. `==` should not be accepted.
3603        let src = "rule R { when: X(a)\n    requires: a.status == active\n    ensures: Done() }";
3604        let r = parse_ok(src);
3605        assert!(
3606            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3607            "expected error for `==` (not in spec), but parsed without errors"
3608        );
3609    }
3610
3611    // -- Finding 3: `system` blocks are not in the spec -----------------------
3612
3613    #[test]
3614    fn spec_reject_system_block() {
3615        // `system` is not a declaration type in the V1 spec.
3616        let src = "system PaymentGateway {\n    timeout: 30.seconds\n}";
3617        let r = parse_ok(src);
3618        assert!(
3619            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3620            "expected error for `system` block (not in spec), but parsed without errors"
3621        );
3622    }
3623
3624    // -- Finding 6: `tags` clause is not in the spec --------------------------
3625
3626    #[test]
3627    fn spec_reject_tags_clause() {
3628        // `tags:` is not a clause keyword in the V1 spec.
3629        let src = r#"rule R {
3630    when: MigrationTriggered()
3631    tags: infrastructure, migration
3632    ensures: MigrationComplete()
3633}"#;
3634        let r = parse_ok(src);
3635        assert!(
3636            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3637            "expected error for `tags:` clause (not in spec), but parsed without errors"
3638        );
3639    }
3640
3641    // -- Finding 7: `includes`/`excludes` are not in the spec -----------------
3642
3643    #[test]
3644    fn spec_reject_includes_operator() {
3645        // The spec uses `x in collection`, not `collection includes x`.
3646        let src = r#"rule R {
3647    when: X(a, b)
3648    requires: a.items includes b
3649    ensures: Done()
3650}"#;
3651        let r = parse_ok(src);
3652        assert!(
3653            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3654            "expected error for `includes` operator (not in spec), but parsed without errors"
3655        );
3656    }
3657
3658    #[test]
3659    fn spec_reject_excludes_operator() {
3660        // The spec uses `x not in collection`, not `collection excludes x`.
3661        let src = r#"rule R {
3662    when: X(a, b)
3663    requires: a.items excludes b
3664    ensures: Done()
3665}"#;
3666        let r = parse_ok(src);
3667        assert!(
3668            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3669            "expected error for `excludes` operator (not in spec), but parsed without errors"
3670        );
3671    }
3672
3673    // -- Finding 8: range literals (`..`) are not in the spec -----------------
3674
3675    #[test]
3676    fn spec_reject_range_literal() {
3677        // The `..` range operator is not defined in the V1 spec.
3678        let src = r#"rule R {
3679    when: X(v)
3680    requires: v in [1..100]
3681    ensures: Done()
3682}"#;
3683        let r = parse_ok(src);
3684        assert!(
3685            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3686            "expected error for `..` range (not in spec), but parsed without errors"
3687        );
3688    }
3689
3690    // -- Finding 9: `within` is only for actors, not rules --------------------
3691
3692    #[test]
3693    fn spec_within_in_actor() {
3694        // The spec defines `within:` as an actor clause.
3695        let src = r#"actor WorkspaceAdmin {
3696    within: Workspace
3697    identified_by: User where role = admin
3698}"#;
3699        let r = parse_ok(src);
3700        assert_eq!(r.diagnostics.len(), 0, "within: in actor should parse cleanly");
3701    }
3702
3703    // -- Finding 10: `module` declaration is not in the spec ------------------
3704
3705    #[test]
3706    fn spec_reject_module_declaration() {
3707        // `module Name` is not a declaration in the V1 spec.
3708        let src = "module my_spec";
3709        let r = parse_ok(src);
3710        assert!(
3711            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3712            "expected error for `module` declaration (not in spec), but parsed without errors"
3713        );
3714    }
3715
3716    // -- Finding 11: `guidance` at module level is not in the spec ------------
3717
3718    #[test]
3719    fn spec_reject_module_level_guidance() {
3720        // The spec shows `guidance:` only as a surface clause.
3721        let src = r#"guidance: "All rules must be idempotent""#;
3722        let r = parse_ok(src);
3723        assert!(
3724            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3725            "expected error for module-level `guidance:` (not in spec), but parsed without errors"
3726        );
3727    }
3728
3729    // -- Finding 12: `guarantee`/`timeout` in rules is not in the spec --------
3730
3731    #[test]
3732    fn spec_guarantee_in_surface_migration() {
3733        // Old `guarantee:` colon form should emit a migration diagnostic
3734        let src = "-- allium: 1\nsurface S {\n    facing viewer: User\n    guarantee: DataIntegrity\n}";
3735        let r = parse(src);
3736        assert!(
3737            r.diagnostics.iter().any(|d| d.message.contains("`guarantee:` syntax was replaced")),
3738            "expected migration diagnostic, got: {:?}",
3739            r.diagnostics
3740        );
3741    }
3742
3743    #[test]
3744    fn spec_timeout_in_surface() {
3745        // The spec defines `timeout:` as a surface clause with rule name syntax.
3746        let src = r#"surface InvitationView {
3747    facing recipient: Candidate
3748    context invitation: ResourceInvitation where email = recipient.email
3749    timeout: InvitationExpires
3750}"#;
3751        let r = parse_ok(src);
3752        assert_eq!(r.diagnostics.len(), 0, "timeout: in surface should parse cleanly");
3753    }
3754
3755    #[test]
3756    fn spec_timeout_in_surface_with_when() {
3757        // The spec shows `timeout: RuleName when condition`.
3758        let src = r#"surface InvitationView {
3759    facing recipient: Candidate
3760    context invitation: ResourceInvitation where email = recipient.email
3761    timeout: InvitationExpires when invitation.expires_at <= now
3762}"#;
3763        let r = parse_ok(src);
3764        assert_eq!(r.diagnostics.len(), 0, "timeout: with when guard should parse cleanly");
3765    }
3766
3767    // -- Finding 15: suffix predicates are not in the spec --------------------
3768
3769    #[test]
3770    fn spec_reject_suffix_predicate() {
3771        // The spec does not define suffix predicate syntax like `starts_with`.
3772        let src = r#"rule R {
3773    when: X()
3774    requires: finding.code starts_with "allium."
3775    ensures: Done()
3776}"#;
3777        let r = parse_ok(src);
3778        assert!(
3779            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3780            "expected error for suffix predicate (not in spec), but parsed without errors"
3781        );
3782    }
3783
3784    // -- Finding 17: `.add()`/`.remove()` are in the spec ---------------------
3785
3786    #[test]
3787    fn spec_add_remove_in_ensures() {
3788        // The spec documents `.add()` and `.remove()` as ensures-only mutations.
3789        // These parse as regular method calls, which is correct.
3790        let src = r#"rule R {
3791    when: AssignInterviewer(interview, new_interviewer)
3792    ensures:
3793        interview.interviewers.add(new_interviewer)
3794}"#;
3795        let r = parse_ok(src);
3796        assert_eq!(r.diagnostics.len(), 0, ".add() should parse cleanly");
3797    }
3798
3799    #[test]
3800    fn spec_remove_in_ensures() {
3801        let src = r#"rule R {
3802    when: RemoveInterviewer(interview, leaving)
3803    ensures:
3804        interview.interviewers.remove(leaving)
3805}"#;
3806        let r = parse_ok(src);
3807        assert_eq!(r.diagnostics.len(), 0, ".remove() should parse cleanly");
3808    }
3809
3810    // -- Finding 18: `.first`/`.last` are in the spec -------------------------
3811
3812    #[test]
3813    fn spec_first_last_access() {
3814        // The spec documents `.first` and `.last` for ordered collections.
3815        let src = "entity E { latest: attempts.last\n    earliest: attempts.first }";
3816        let r = parse_ok(src);
3817        assert_eq!(r.diagnostics.len(), 0, ".first/.last should parse cleanly");
3818    }
3819
3820    // -- Finding 19: set arithmetic is in the spec ----------------------------
3821
3822    #[test]
3823    fn spec_set_arithmetic() {
3824        // The spec documents `+` and `-` on collections as set arithmetic.
3825        let src = r#"entity Role {
3826    permissions: Set<String>
3827    inherited: Set<String>
3828    all_permissions: permissions + inherited
3829    removed: old_mentions - new_mentions
3830}"#;
3831        let r = parse_ok(src);
3832        assert_eq!(r.diagnostics.len(), 0, "set arithmetic should parse cleanly");
3833    }
3834
3835    // -- Finding 20: discard binding `_` is in the spec -----------------------
3836
3837    #[test]
3838    fn spec_discard_binding_in_trigger() {
3839        // The spec shows `when: _: LogProcessor.last_flush_check + ...`
3840        let src = r#"rule R {
3841    when: _: LogProcessor.last_flush_check <= now
3842    ensures: Flushed()
3843}"#;
3844        let r = parse_ok(src);
3845        assert_eq!(r.diagnostics.len(), 0, "discard binding _ in trigger should parse cleanly");
3846    }
3847
3848    #[test]
3849    fn spec_discard_in_trigger_params() {
3850        // The spec shows `when: SomeEvent(_, slot)`
3851        let src = r#"rule R {
3852    when: SomeEvent(_, slot)
3853    ensures: Processed(slot: slot)
3854}"#;
3855        let r = parse_ok(src);
3856        assert_eq!(r.diagnostics.len(), 0, "discard _ in trigger params should parse cleanly");
3857    }
3858
3859    #[test]
3860    fn spec_discard_in_for() {
3861        // The spec shows `for _ in items: Counted(batch)`
3862        let src = r#"rule R {
3863    when: X(items)
3864    ensures:
3865        for _ in items: Counted()
3866}"#;
3867        let r = parse_ok(src);
3868        assert_eq!(r.diagnostics.len(), 0, "discard _ in for should parse cleanly");
3869    }
3870
3871    // -- Finding 21: default with object literal is in the spec ---------------
3872
3873    #[test]
3874    fn spec_default_with_object_literal() {
3875        // The spec shows: default InterviewType all_in_one = { name: "All in one", duration: 75.minutes }
3876        let src = r#"default InterviewType all_in_one = { name: "All in one", duration: 75.minutes }"#;
3877        let r = parse_ok(src);
3878        assert_eq!(r.diagnostics.len(), 0, "default with object literal should parse cleanly");
3879    }
3880
3881    #[test]
3882    fn spec_default_multiline_object() {
3883        // The spec shows multi-line defaults with object literals.
3884        let src = r#"default Role viewer = {
3885    name: "viewer",
3886    permissions: { "documents.read" }
3887}"#;
3888        let r = parse_ok(src);
3889        assert_eq!(r.diagnostics.len(), 0, "multi-line default with object literal should parse cleanly");
3890    }
3891
3892    // -- Spec surface features: related, let, guarantee, timeout --------------
3893
3894    #[test]
3895    fn spec_surface_related_clause() {
3896        // The spec shows `related:` with surface references.
3897        let src = r#"surface InterviewerDashboard {
3898    facing viewer: Interviewer
3899    context assignment: SlotConfirmation where interviewer = viewer
3900    related: InterviewDetail(assignment.slot.interview) when assignment.slot.interview != null
3901}"#;
3902        let r = parse_ok(src);
3903        assert_eq!(r.diagnostics.len(), 0, "related: in surface should parse cleanly");
3904    }
3905
3906    #[test]
3907    fn spec_surface_let_binding() {
3908        // The spec shows `let` bindings inside surfaces.
3909        let src = r#"surface S {
3910    facing viewer: User
3911    let comments = Comments where parent = viewer
3912    exposes: CommentList
3913}"#;
3914        let r = parse_ok(src);
3915        assert_eq!(r.diagnostics.len(), 0, "let in surface should parse cleanly");
3916    }
3917
3918    #[test]
3919    fn spec_surface_multiline_context_where() {
3920        // The spec shows context with where on a continuation line.
3921        let src = r#"surface InterviewerPendingAssignments {
3922    facing viewer: Interviewer
3923    context assignment: InterviewAssignment
3924        where interviewer = viewer and status = pending
3925    exposes: AssignmentList
3926}"#;
3927        let r = parse_ok(src);
3928        assert_eq!(r.diagnostics.len(), 0, "multi-line context where should parse cleanly");
3929    }
3930
3931    // -- Spec: `for` inside surfaces ------------------------------------------
3932
3933    #[test]
3934    fn spec_for_in_surface_provides() {
3935        // The spec shows for iteration inside surface provides.
3936        let src = r#"surface TaskBoard {
3937    facing viewer: User
3938    for task in Task where task.assignee = viewer:
3939        provides: CompleteTask(viewer, task) when task.status = in_progress
3940    exposes: KanbanBoard
3941}"#;
3942        let r = parse_ok(src);
3943        assert_eq!(r.diagnostics.len(), 0, "for in surface provides should parse cleanly");
3944    }
3945
3946    // -- Spec: `use` without alias --------------------------------------------
3947
3948    #[test]
3949    fn spec_use_without_alias() {
3950        // The spec shows `use` both with and without `as alias`.
3951        let src = r#"use "github.com/specs/notifications/def456""#;
3952        let r = parse_ok(src);
3953        assert_eq!(r.diagnostics.len(), 0, "use without alias should parse cleanly");
3954    }
3955
3956    // -- Spec: empty external entity ------------------------------------------
3957
3958    #[test]
3959    fn spec_empty_external_entity() {
3960        // The spec shows external entities with empty bodies as type placeholders.
3961        let src = "external entity Commentable {}";
3962        let r = parse_ok(src);
3963        assert_eq!(r.diagnostics.len(), 0, "empty external entity should parse cleanly");
3964    }
3965
3966    // -- Spec: multi-line provides block in surface ---------------------------
3967
3968    #[test]
3969    fn spec_surface_multiline_provides() {
3970        // The spec shows provides as a multi-line block.
3971        let src = r#"surface ProjectDashboard {
3972    facing viewer: ProjectManager
3973    context project: Project where owner = viewer
3974    provides:
3975        CreateTask(viewer, project) when project.status = active
3976        ArchiveProject(viewer, project) when project.tasks.all(t => t.status = completed)
3977    exposes: TaskList
3978}"#;
3979        let r = parse_ok(src);
3980        assert_eq!(r.diagnostics.len(), 0, "multi-line provides should parse cleanly");
3981    }
3982
3983    // -- Spec: multi-line exposes block in surface ----------------------------
3984
3985    #[test]
3986    fn spec_surface_multiline_exposes() {
3987        // The spec shows exposes as a multi-line block.
3988        let src = r#"surface InterviewerDashboard {
3989    facing viewer: Interviewer
3990    context assignment: SlotConfirmation where interviewer = viewer
3991    exposes:
3992        assignment.slot.time
3993        assignment.status
3994}"#;
3995        let r = parse_ok(src);
3996        assert_eq!(r.diagnostics.len(), 0, "multi-line exposes should parse cleanly");
3997    }
3998
3999    // =====================================================================
4000    // COVERAGE GAP TESTS
4001    //
4002    // Dedicated unit tests for spec constructs that previously only had
4003    // fixture-file coverage.
4004    // =====================================================================
4005
4006    // -- Composite or-triggers ------------------------------------------------
4007
4008    #[test]
4009    fn composite_or_trigger() {
4010        let src = r#"rule R {
4011    when: EventA(x) or EventB(x) or EventC(x)
4012    ensures: Done()
4013}"#;
4014        let r = parse_ok(src);
4015        assert_eq!(r.diagnostics.len(), 0);
4016        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4017        let BlockItemKind::Clause { keyword, value } = &b.items[0].kind else { panic!() };
4018        assert_eq!(keyword, "when");
4019        // Top-level should be LogicalOp(Or) wrapping another Or
4020        let Expr::LogicalOp { op, left, .. } = value else {
4021            panic!("expected LogicalOp, got {value:?}");
4022        };
4023        assert_eq!(*op, LogicalOp::Or);
4024        assert!(matches!(left.as_ref(), Expr::LogicalOp { op: LogicalOp::Or, .. }));
4025    }
4026
4027    // -- Value type declaration -----------------------------------------------
4028
4029    #[test]
4030    fn value_type_declaration() {
4031        let src = r#"value TimeRange {
4032    start: Timestamp
4033    end: Timestamp
4034    duration: end - start
4035}"#;
4036        let r = parse_ok(src);
4037        assert_eq!(r.diagnostics.len(), 0);
4038        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4039        assert_eq!(b.kind, BlockKind::Value);
4040        assert_eq!(b.name.as_ref().unwrap().name, "TimeRange");
4041        assert_eq!(b.items.len(), 3);
4042    }
4043
4044    // -- Qualified config block -----------------------------------------------
4045
4046    #[test]
4047    fn qualified_config_block() {
4048        let src = r#"use "github.com/specs/oauth/abc123" as oauth
4049oauth/config {
4050    session_duration: Duration = 24.hours
4051}"#;
4052        let r = parse_ok(src);
4053        assert_eq!(r.diagnostics.len(), 0);
4054        assert_eq!(r.module.declarations.len(), 2);
4055    }
4056
4057    // -- String interpolation -------------------------------------------------
4058
4059    #[test]
4060    fn string_interpolation_parts() {
4061        let src = r#"rule R {
4062    when: X(name, action)
4063    ensures: Log.created(message: "User {name} did {action}")
4064}"#;
4065        let r = parse_ok(src);
4066        assert_eq!(r.diagnostics.len(), 0);
4067        // Dig into the message arg to verify interpolation parts
4068        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4069        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4070        let Expr::Call { args, .. } = value else { panic!() };
4071        let CallArg::Named(arg) = &args[0] else { panic!() };
4072        let Expr::StringLiteral(s) = &arg.value else { panic!() };
4073        assert_eq!(s.parts.len(), 4, "expected 4 string parts: text, interp, text, interp");
4074        assert!(matches!(&s.parts[0], StringPart::Text(t) if t == "User "));
4075        assert!(matches!(&s.parts[1], StringPart::Interpolation(id) if id.name == "name"));
4076        assert!(matches!(&s.parts[2], StringPart::Text(t) if t == " did "));
4077        assert!(matches!(&s.parts[3], StringPart::Interpolation(id) if id.name == "action"));
4078    }
4079
4080    // -- `this` keyword as expression -----------------------------------------
4081
4082    #[test]
4083    fn this_keyword_expression() {
4084        // `Item with parent = this` parses as With(Item, Eq(parent, this))
4085        // because `with` binds looser than `=`, capturing the full predicate.
4086        let src = "entity E { items: Item with parent = this }";
4087        let r = parse_ok(src);
4088        assert_eq!(r.diagnostics.len(), 0);
4089        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4090        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4091        let Expr::With { predicate, .. } = value else {
4092            panic!("expected With, got {value:?}");
4093        };
4094        let Expr::Comparison { op, right, .. } = predicate.as_ref() else {
4095            panic!("expected Comparison in with predicate, got {predicate:?}");
4096        };
4097        assert_eq!(*op, ComparisonOp::Eq);
4098        assert!(matches!(right.as_ref(), Expr::This { .. }));
4099    }
4100
4101    // -- `not` prefix operator (standalone) -----------------------------------
4102
4103    #[test]
4104    fn not_prefix_standalone() {
4105        let src = r#"rule R {
4106    when: X(user)
4107    requires: not user.is_locked
4108    ensures: Done()
4109}"#;
4110        let r = parse_ok(src);
4111        assert_eq!(r.diagnostics.len(), 0);
4112        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4113        let BlockItemKind::Clause { keyword, value } = &b.items[1].kind else { panic!() };
4114        assert_eq!(keyword, "requires");
4115        assert!(matches!(value, Expr::Not { .. }));
4116    }
4117
4118    // -- Unary minus ----------------------------------------------------------
4119
4120    #[test]
4121    fn unary_minus() {
4122        let src = "entity E { offset: -1 }";
4123        let r = parse_ok(src);
4124        assert_eq!(r.diagnostics.len(), 0);
4125        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4126        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4127        assert!(matches!(value, Expr::BinaryOp { op: BinaryOp::Sub, .. }
4128                        | Expr::NumberLiteral { .. }), "expected negation, got {value:?}");
4129    }
4130
4131    // -- Parenthesised expression grouping ------------------------------------
4132
4133    #[test]
4134    fn parenthesised_expression() {
4135        let src = "entity E { v: (a + b) * c }";
4136        let r = parse_ok(src);
4137        assert_eq!(r.diagnostics.len(), 0);
4138        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4139        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4140        // Top level should be Mul, with left being Add (grouped by parens)
4141        let Expr::BinaryOp { op, left, .. } = value else {
4142            panic!("expected BinaryOp, got {value:?}");
4143        };
4144        assert_eq!(*op, BinaryOp::Mul);
4145        assert!(matches!(left.as_ref(), Expr::BinaryOp { op: BinaryOp::Add, .. }));
4146    }
4147
4148    // -- Boolean literals -----------------------------------------------------
4149
4150    #[test]
4151    fn boolean_literals() {
4152        let src = r#"rule R {
4153    when: X(item)
4154    ensures:
4155        item.active = true
4156        item.deleted = false
4157}"#;
4158        let r = parse_ok(src);
4159        assert_eq!(r.diagnostics.len(), 0);
4160    }
4161
4162    // -- `null` literal -------------------------------------------------------
4163
4164    #[test]
4165    fn null_literal() {
4166        let src = "entity E { v: parent ?? null }";
4167        let r = parse_ok(src);
4168        assert_eq!(r.diagnostics.len(), 0);
4169        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4170        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4171        let Expr::NullCoalesce { right, .. } = value else { panic!() };
4172        assert!(matches!(right.as_ref(), Expr::Null { .. }));
4173    }
4174
4175    // -- Empty set literal ----------------------------------------------------
4176
4177    #[test]
4178    fn empty_set_literal() {
4179        let src = "entity E { tags: Set<String>\n    default_tags: {} }";
4180        let r = parse_ok(src);
4181        assert_eq!(r.diagnostics.len(), 0);
4182        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4183        let BlockItemKind::Assignment { value, .. } = &b.items[1].kind else { panic!() };
4184        let Expr::SetLiteral { elements, .. } = value else { panic!("expected SetLiteral, got {value:?}") };
4185        assert!(elements.is_empty());
4186    }
4187
4188    // =====================================================================
4189    // PRE-1.0 COVERAGE TESTS
4190    //
4191    // Additional tests addressing gaps identified during the pre-release
4192    // review: parameterised derived values, operator precedence matrix,
4193    // indentation-based multi-line detection, and set/object literal
4194    // disambiguation.
4195    // =====================================================================
4196
4197    // -- Parameterised derived values (ParamAssignment) --------------------
4198
4199    #[test]
4200    fn param_assignment_single() {
4201        let src = "entity Plan { can_use(feature): feature in features }";
4202        let r = parse_ok(src);
4203        assert_eq!(r.diagnostics.len(), 0);
4204        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4205        let BlockItemKind::ParamAssignment { name, params, value } = &b.items[0].kind else {
4206            panic!("expected ParamAssignment, got {:?}", b.items[0].kind);
4207        };
4208        assert_eq!(name.name, "can_use");
4209        assert_eq!(params.len(), 1);
4210        assert_eq!(params[0].name, "feature");
4211        assert!(matches!(value, Expr::In { .. }));
4212    }
4213
4214    #[test]
4215    fn param_assignment_multiple() {
4216        let src = "entity E { distance(x, y): (x * x + y * y) }";
4217        let r = parse_ok(src);
4218        assert_eq!(r.diagnostics.len(), 0);
4219        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4220        let BlockItemKind::ParamAssignment { name, params, .. } = &b.items[0].kind else {
4221            panic!("expected ParamAssignment, got {:?}", b.items[0].kind);
4222        };
4223        assert_eq!(name.name, "distance");
4224        assert_eq!(params.len(), 2);
4225        assert_eq!(params[0].name, "x");
4226        assert_eq!(params[1].name, "y");
4227    }
4228
4229    #[test]
4230    fn param_assignment_simple_expression() {
4231        let src = "entity Task { remaining_effort(total): total - effort }";
4232        let r = parse_ok(src);
4233        assert_eq!(r.diagnostics.len(), 0);
4234        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4235        let BlockItemKind::ParamAssignment { name, params, value } = &b.items[0].kind else {
4236            panic!("expected ParamAssignment, got {:?}", b.items[0].kind);
4237        };
4238        assert_eq!(name.name, "remaining_effort");
4239        assert_eq!(params.len(), 1);
4240        assert!(matches!(value, Expr::BinaryOp { op: BinaryOp::Sub, .. }));
4241    }
4242
4243    // -- Operator precedence matrix ----------------------------------------
4244
4245    #[test]
4246    fn precedence_logical_and_binds_tighter_than_or() {
4247        // `a or b and c` => Or(a, And(b, c))
4248        let src = "entity E { v: a or b and c }";
4249        let r = parse_ok(src);
4250        assert_eq!(r.diagnostics.len(), 0);
4251        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4252        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4253        let Expr::LogicalOp { op, right, .. } = value else {
4254            panic!("expected LogicalOp, got {value:?}");
4255        };
4256        assert_eq!(*op, LogicalOp::Or);
4257        assert!(matches!(right.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4258    }
4259
4260    #[test]
4261    fn precedence_comparison_binds_tighter_than_and() {
4262        // `a = b and c != d` => And(Eq(a, b), NotEq(c, d))
4263        let src = "entity E { v: a = b and c != d }";
4264        let r = parse_ok(src);
4265        assert_eq!(r.diagnostics.len(), 0);
4266        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4267        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4268        let Expr::LogicalOp { op, left, right, .. } = value else {
4269            panic!("expected LogicalOp, got {value:?}");
4270        };
4271        assert_eq!(*op, LogicalOp::And);
4272        assert!(matches!(left.as_ref(), Expr::Comparison { op: ComparisonOp::Eq, .. }));
4273        assert!(matches!(right.as_ref(), Expr::Comparison { op: ComparisonOp::NotEq, .. }));
4274    }
4275
4276    #[test]
4277    fn precedence_arithmetic_binds_tighter_than_comparison() {
4278        // `a + b > c * d` => Gt(Add(a, b), Mul(c, d))
4279        let src = "entity E { v: a + b > c * d }";
4280        let r = parse_ok(src);
4281        assert_eq!(r.diagnostics.len(), 0);
4282        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4283        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4284        let Expr::Comparison { op, left, right, .. } = value else {
4285            panic!("expected Comparison, got {value:?}");
4286        };
4287        assert_eq!(*op, ComparisonOp::Gt);
4288        assert!(matches!(left.as_ref(), Expr::BinaryOp { op: BinaryOp::Add, .. }));
4289        assert!(matches!(right.as_ref(), Expr::BinaryOp { op: BinaryOp::Mul, .. }));
4290    }
4291
4292    #[test]
4293    fn precedence_null_coalesce_binds_tighter_than_comparison() {
4294        // `a ?? b = c` => Eq(NullCoalesce(a, b), c)
4295        let src = "entity E { v: a ?? b = c }";
4296        let r = parse_ok(src);
4297        assert_eq!(r.diagnostics.len(), 0);
4298        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4299        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4300        let Expr::Comparison { op, left, .. } = value else {
4301            panic!("expected Comparison, got {value:?}");
4302        };
4303        assert_eq!(*op, ComparisonOp::Eq);
4304        assert!(matches!(left.as_ref(), Expr::NullCoalesce { .. }));
4305    }
4306
4307    #[test]
4308    fn precedence_not_binds_tighter_than_and() {
4309        // `not a and b` => And(Not(a), b)
4310        let src = r#"rule R {
4311    when: X(a, b)
4312    requires: not a and b
4313    ensures: Done()
4314}"#;
4315        let r = parse_ok(src);
4316        assert_eq!(r.diagnostics.len(), 0);
4317        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4318        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4319        let Expr::LogicalOp { op, left, .. } = value else {
4320            panic!("expected LogicalOp, got {value:?}");
4321        };
4322        assert_eq!(*op, LogicalOp::And);
4323        assert!(matches!(left.as_ref(), Expr::Not { .. }));
4324    }
4325
4326    #[test]
4327    fn precedence_where_captures_full_condition() {
4328        // `items where status = active` => Where(items, Eq(status, active))
4329        // where (BP 7) binds looser than comparison (BP 30), so the full
4330        // condition `status = active` is captured as the where predicate.
4331        let src = "entity E { v: items where status = active }";
4332        let r = parse_ok(src);
4333        assert_eq!(r.diagnostics.len(), 0);
4334        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4335        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4336        let Expr::Where { condition, .. } = value else {
4337            panic!("expected Where, got {value:?}");
4338        };
4339        assert!(matches!(condition.as_ref(), Expr::Comparison { op: ComparisonOp::Eq, .. }));
4340    }
4341
4342    #[test]
4343    fn precedence_where_captures_and_or_conditions() {
4344        // `items where status = active and count > 0` =>
4345        //   Where(items, And(Eq(status, active), Gt(count, 0)))
4346        let src = "entity E { v: items where status = active and count > 0 }";
4347        let r = parse_ok(src);
4348        assert_eq!(r.diagnostics.len(), 0);
4349        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4350        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4351        let Expr::Where { condition, .. } = value else {
4352            panic!("expected Where, got {value:?}");
4353        };
4354        assert!(matches!(condition.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4355    }
4356
4357    #[test]
4358    fn precedence_projection_applies_to_where_result() {
4359        // `items where status = confirmed -> interviewer` =>
4360        //   ProjectionMap(Where(items, Eq(status, confirmed)), interviewer)
4361        let src = "entity E { v: items where status = confirmed -> interviewer }";
4362        let r = parse_ok(src);
4363        assert_eq!(r.diagnostics.len(), 0);
4364        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4365        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4366        let Expr::ProjectionMap { source, field, .. } = value else {
4367            panic!("expected ProjectionMap, got {value:?}");
4368        };
4369        assert_eq!(field.name, "interviewer");
4370        assert!(matches!(source.as_ref(), Expr::Where { .. }));
4371    }
4372
4373    #[test]
4374    fn precedence_lambda_binds_loosest() {
4375        // `items.any(i => i.active and i.valid)` => Lambda(i, And(active, valid))
4376        let src = "entity E { v: items.any(i => i.active and i.valid) }";
4377        let r = parse_ok(src);
4378        assert_eq!(r.diagnostics.len(), 0);
4379        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4380        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4381        let Expr::Call { args, .. } = value else { panic!() };
4382        let CallArg::Positional(Expr::Lambda { body, .. }) = &args[0] else { panic!() };
4383        assert!(matches!(body.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4384    }
4385
4386    #[test]
4387    fn precedence_in_binds_at_comparison_level() {
4388        // `x in {a, b} and y not in {c}` => And(In(x, {a,b}), NotIn(y, {c}))
4389        let src = r#"rule R {
4390    when: X(x, y)
4391    requires: x in {a, b} and y not in {c}
4392    ensures: Done()
4393}"#;
4394        let r = parse_ok(src);
4395        assert_eq!(r.diagnostics.len(), 0);
4396        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4397        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4398        let Expr::LogicalOp { op, left, right, .. } = value else {
4399            panic!("expected LogicalOp, got {value:?}");
4400        };
4401        assert_eq!(*op, LogicalOp::And);
4402        assert!(matches!(left.as_ref(), Expr::In { .. }));
4403        assert!(matches!(right.as_ref(), Expr::NotIn { .. }));
4404    }
4405
4406    // -- Multi-line clause value detection ----------------------------------
4407
4408    #[test]
4409    fn multiline_ensures_block() {
4410        let src = r#"rule R {
4411    when: X(doc)
4412    ensures:
4413        doc.status = published
4414        Notification.created(to: doc.author)
4415}"#;
4416        let r = parse_ok(src);
4417        assert_eq!(r.diagnostics.len(), 0);
4418        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4419        let BlockItemKind::Clause { keyword, value } = &b.items[1].kind else { panic!() };
4420        assert_eq!(keyword, "ensures");
4421        let Expr::Block { items, .. } = value else {
4422            panic!("expected Block for multi-line ensures, got {value:?}");
4423        };
4424        assert_eq!(items.len(), 2);
4425    }
4426
4427    #[test]
4428    fn singleline_ensures_value() {
4429        let src = r#"rule R {
4430    when: X(doc)
4431    ensures: doc.status = published
4432}"#;
4433        let r = parse_ok(src);
4434        assert_eq!(r.diagnostics.len(), 0);
4435        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4436        let BlockItemKind::Clause { keyword, value } = &b.items[1].kind else { panic!() };
4437        assert_eq!(keyword, "ensures");
4438        // Single-line value should NOT be wrapped in a Block
4439        assert!(!matches!(value, Expr::Block { .. }), "single-line ensures should not be Block");
4440    }
4441
4442    #[test]
4443    fn multiline_requires_with_continuation() {
4444        let src = r#"rule R {
4445    when: X(a)
4446    requires:
4447        a.count >= 2
4448        or a.items.any(i => i.can_solo)
4449    ensures: Done()
4450}"#;
4451        let r = parse_ok(src);
4452        assert_eq!(r.diagnostics.len(), 0);
4453    }
4454
4455    // -- Set vs object literal disambiguation ------------------------------
4456
4457    #[test]
4458    fn object_literal_single_field() {
4459        let src = r#"rule R {
4460    when: X()
4461    ensures:
4462        let o = {name: "test"}
4463        Done()
4464}"#;
4465        let r = parse_ok(src);
4466        assert_eq!(r.diagnostics.len(), 0);
4467        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4468        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4469        let Expr::Block { items, .. } = value else { panic!() };
4470        let Expr::LetExpr { value: let_val, .. } = &items[0] else { panic!() };
4471        assert!(matches!(let_val.as_ref(), Expr::ObjectLiteral { .. }));
4472    }
4473
4474    #[test]
4475    fn set_literal_single_element() {
4476        let src = r#"rule R {
4477    when: X()
4478    ensures:
4479        let s = {active}
4480        Done()
4481}"#;
4482        let r = parse_ok(src);
4483        assert_eq!(r.diagnostics.len(), 0);
4484        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4485        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4486        let Expr::Block { items, .. } = value else { panic!() };
4487        let Expr::LetExpr { value: let_val, .. } = &items[0] else { panic!() };
4488        assert!(matches!(let_val.as_ref(), Expr::SetLiteral { .. }),
4489            "bare {{ident}} should parse as set literal, got {:?}", let_val);
4490    }
4491
4492    // -- Lambda variations -------------------------------------------------
4493
4494    #[test]
4495    fn lambda_with_chained_access() {
4496        let src = "entity E { v: items.all(t => t.item.status = active) }";
4497        let r = parse_ok(src);
4498        assert_eq!(r.diagnostics.len(), 0);
4499    }
4500
4501    #[test]
4502    fn nested_lambda() {
4503        let src = "entity E { v: groups.any(g => g.items.all(i => i.valid)) }";
4504        let r = parse_ok(src);
4505        assert_eq!(r.diagnostics.len(), 0);
4506    }
4507
4508    // -- Qualified name variations -----------------------------------------
4509
4510    #[test]
4511    fn qualified_name_with_member_access() {
4512        let src = "entity E { v: shared/Validator.check }";
4513        let r = parse_ok(src);
4514        assert_eq!(r.diagnostics.len(), 0);
4515        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4516        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4517        let Expr::MemberAccess { object, field, .. } = value else {
4518            panic!("expected MemberAccess, got {value:?}");
4519        };
4520        assert!(matches!(object.as_ref(), Expr::QualifiedName(_)));
4521        assert_eq!(field.name, "check");
4522    }
4523
4524    #[test]
4525    fn qualified_name_in_call() {
4526        let src = r#"rule R {
4527    when: X(item)
4528    requires: shared/Validator.check(item: item)
4529    ensures: Done()
4530}"#;
4531        let r = parse_ok(src);
4532        assert_eq!(r.diagnostics.len(), 0);
4533    }
4534
4535    // -- Nested control flow -----------------------------------------------
4536
4537    #[test]
4538    fn nested_if_inside_for() {
4539        let src = r#"rule R {
4540    when: X()
4541    for user in Users where user.active:
4542        if user.role = admin:
4543            ensures: AdminNotified(user: user)
4544        else:
4545            ensures: UserNotified(user: user)
4546}"#;
4547        let r = parse_ok(src);
4548        assert_eq!(r.diagnostics.len(), 0);
4549        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4550        let BlockItemKind::ForBlock { items, .. } = &b.items[1].kind else { panic!() };
4551        assert!(matches!(items[0].kind, BlockItemKind::IfBlock { .. }));
4552    }
4553
4554    #[test]
4555    fn for_with_let_before_ensures() {
4556        let src = r#"rule R {
4557    when: schedule: DigestSchedule.next_run_at <= now
4558    for user in Users where user.active:
4559        let pending = user.tasks where status = pending
4560        ensures: DigestEmail.created(to: user.email, tasks: pending)
4561}"#;
4562        let r = parse_ok(src);
4563        assert_eq!(r.diagnostics.len(), 0);
4564        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4565        let BlockItemKind::ForBlock { items, .. } = &b.items[1].kind else { panic!() };
4566        assert_eq!(items.len(), 2, "for body should have let + ensures");
4567        assert!(matches!(items[0].kind, BlockItemKind::Let { .. }));
4568        assert!(matches!(items[1].kind, BlockItemKind::Clause { .. }));
4569    }
4570
4571    // -- Join lookup variations --------------------------------------------
4572
4573    #[test]
4574    fn join_lookup_all_unnamed() {
4575        let src = "entity E { match: Other{a, b, c} }";
4576        let r = parse_ok(src);
4577        assert_eq!(r.diagnostics.len(), 0);
4578        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4579        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4580        let Expr::JoinLookup { fields, .. } = value else { panic!() };
4581        assert_eq!(fields.len(), 3);
4582        assert!(fields.iter().all(|f| f.value.is_none()));
4583    }
4584
4585    #[test]
4586    fn join_lookup_all_named() {
4587        let src = "entity E { match: Membership{user: actor, workspace: ws} }";
4588        let r = parse_ok(src);
4589        assert_eq!(r.diagnostics.len(), 0);
4590        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4591        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4592        let Expr::JoinLookup { fields, .. } = value else { panic!() };
4593        assert_eq!(fields.len(), 2);
4594        assert!(fields.iter().all(|f| f.value.is_some()));
4595    }
4596
4597    #[test]
4598    fn join_lookup_in_requires() {
4599        let src = r#"rule R {
4600    when: X(user, workspace)
4601    requires: exists WorkspaceMembership{user: user, workspace: workspace}
4602    ensures: Done()
4603}"#;
4604        let r = parse_ok(src);
4605        assert_eq!(r.diagnostics.len(), 0);
4606    }
4607
4608    #[test]
4609    fn join_lookup_negated_in_requires() {
4610        let src = r#"rule R {
4611    when: X(email)
4612    requires: not exists User{email: email}
4613    ensures: Done()
4614}"#;
4615        let r = parse_ok(src);
4616        assert_eq!(r.diagnostics.len(), 0);
4617    }
4618
4619    // -----------------------------------------------------------------------
4620    // ALP-11: implies operator
4621    // -----------------------------------------------------------------------
4622
4623    #[test]
4624    fn implies_basic() {
4625        let src = "rule R { requires: a implies b }";
4626        let r = parse_ok(src);
4627        assert_eq!(r.diagnostics.len(), 0);
4628        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4629        let BlockItemKind::Clause { value, .. } = &b.items[0].kind else { panic!() };
4630        let Expr::LogicalOp { op, .. } = value else { panic!("expected LogicalOp, got {value:?}") };
4631        assert_eq!(*op, LogicalOp::Implies);
4632    }
4633
4634    #[test]
4635    fn implies_precedence_and_binds_tighter() {
4636        // `a and b implies c` → `(a and b) implies c`
4637        let src = "rule R { v: a and b implies c }";
4638        let r = parse_ok(src);
4639        assert_eq!(r.diagnostics.len(), 0);
4640        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4641        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4642        let Expr::LogicalOp { op, left, .. } = value else { panic!() };
4643        assert_eq!(*op, LogicalOp::Implies);
4644        assert!(matches!(left.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4645    }
4646
4647    #[test]
4648    fn implies_precedence_or_binds_tighter() {
4649        // `a or b implies c` → `(a or b) implies c`
4650        let src = "rule R { v: a or b implies c }";
4651        let r = parse_ok(src);
4652        assert_eq!(r.diagnostics.len(), 0);
4653        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4654        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4655        let Expr::LogicalOp { op, left, .. } = value else { panic!() };
4656        assert_eq!(*op, LogicalOp::Implies);
4657        assert!(matches!(left.as_ref(), Expr::LogicalOp { op: LogicalOp::Or, .. }));
4658    }
4659
4660    #[test]
4661    fn implies_precedence_implies_above_or() {
4662        // `a implies b or c` → `a implies (b or c)`
4663        let src = "rule R { v: a implies b or c }";
4664        let r = parse_ok(src);
4665        assert_eq!(r.diagnostics.len(), 0);
4666        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4667        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4668        let Expr::LogicalOp { op, right, .. } = value else { panic!() };
4669        assert_eq!(*op, LogicalOp::Implies);
4670        assert!(matches!(right.as_ref(), Expr::LogicalOp { op: LogicalOp::Or, .. }));
4671    }
4672
4673    #[test]
4674    fn implies_precedence_not_binds_tighter() {
4675        // `not a implies b` → `(not a) implies b`
4676        let src = "rule R { v: not a implies b }";
4677        let r = parse_ok(src);
4678        assert_eq!(r.diagnostics.len(), 0);
4679        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4680        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4681        let Expr::LogicalOp { op, left, .. } = value else { panic!() };
4682        assert_eq!(*op, LogicalOp::Implies);
4683        assert!(matches!(left.as_ref(), Expr::Not { .. }));
4684    }
4685
4686    #[test]
4687    fn implies_right_associative() {
4688        // `a implies b implies c` → `a implies (b implies c)`
4689        let src = "rule R { v: a implies b implies c }";
4690        let r = parse_ok(src);
4691        assert_eq!(r.diagnostics.len(), 0);
4692        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4693        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4694        let Expr::LogicalOp { op, right, .. } = value else { panic!() };
4695        assert_eq!(*op, LogicalOp::Implies);
4696        assert!(matches!(right.as_ref(), Expr::LogicalOp { op: LogicalOp::Implies, .. }));
4697    }
4698
4699    #[test]
4700    fn implies_is_keyword_parsed_as_operator() {
4701        // `implies` is a keyword — in infix position it's the operator, not an ident.
4702        // `a implies b` parses as LogicalOp, never as two adjacent identifiers.
4703        let src = "entity E { v: a implies b }";
4704        let r = parse_ok(src);
4705        assert_eq!(r.diagnostics.len(), 0);
4706        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4707        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4708        assert!(matches!(value, Expr::LogicalOp { op: LogicalOp::Implies, .. }));
4709    }
4710
4711    #[test]
4712    fn implies_in_ensures() {
4713        let src = r#"rule R {
4714    when: X()
4715    ensures: a implies b
4716}"#;
4717        let r = parse_ok(src);
4718        assert_eq!(r.diagnostics.len(), 0);
4719    }
4720
4721    #[test]
4722    fn implies_in_derived_value() {
4723        let src = "entity E { v: a implies b }";
4724        let r = parse_ok(src);
4725        assert_eq!(r.diagnostics.len(), 0);
4726        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4727        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4728        assert!(matches!(value, Expr::LogicalOp { op: LogicalOp::Implies, .. }));
4729    }
4730
4731    // -----------------------------------------------------------------------
4732    // ALP-7: guidance clause ordering
4733    // -----------------------------------------------------------------------
4734
4735    #[test]
4736    fn guidance_ordering_tests_removed() {
4737        // Guidance ordering validation was moved to the structural validator.
4738        // The old `guidance:` colon form now emits migration diagnostics.
4739        // See guidance_colon_form_migration and annotation_guidance_in_rule tests.
4740    }
4741
4742    // -----------------------------------------------------------------------
4743    // ALP-9: contract declarations
4744    // -----------------------------------------------------------------------
4745
4746    #[test]
4747    fn contract_signatures_only() {
4748        let src = r#"contract Auditable {
4749    last_modified_by: Actor
4750    last_modified_at: Timestamp
4751}"#;
4752        let r = parse_ok(src);
4753        assert_eq!(r.diagnostics.len(), 0);
4754        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4755        assert_eq!(b.kind, BlockKind::Contract);
4756        assert_eq!(b.name.as_ref().unwrap().name, "Auditable");
4757        assert_eq!(b.items.len(), 2);
4758    }
4759
4760    #[test]
4761    fn contract_with_annotations() {
4762        let src = r#"contract Versioned {
4763    version: Integer
4764    @invariant Monotonic
4765        -- versions must increase
4766    @guidance
4767        -- use semantic versioning
4768}"#;
4769        let r = parse_ok(src);
4770        assert_eq!(r.diagnostics.len(), 0);
4771        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4772        assert_eq!(b.kind, BlockKind::Contract);
4773        assert_eq!(b.items.len(), 3);
4774    }
4775
4776    #[test]
4777    fn contract_with_any_type() {
4778        let src = r#"contract Identifiable {
4779    id: Any
4780}"#;
4781        let r = parse_ok(src);
4782        assert_eq!(r.diagnostics.len(), 0);
4783    }
4784
4785    #[test]
4786    fn contract_lowercase_name_rejected() {
4787        let src = "-- allium: 1\ncontract bad {}";
4788        let r = parse(src);
4789        assert!(
4790            r.diagnostics.iter().any(|d| d.message.contains("uppercase")),
4791            "expected uppercase error, got: {:?}",
4792            r.diagnostics
4793        );
4794    }
4795
4796    #[test]
4797    fn contract_colon_body_rejected() {
4798        let src = "-- allium: 1\ncontract Bad: something";
4799        let r = parse(src);
4800        assert!(
4801            r.diagnostics.iter().any(|d| d.message.contains("braces")),
4802            "expected braces error, got: {:?}",
4803            r.diagnostics
4804        );
4805    }
4806
4807    // -----------------------------------------------------------------------
4808    // ALP-15: contracts clause
4809    // -----------------------------------------------------------------------
4810
4811    #[test]
4812    fn contracts_clause_single_demands() {
4813        let src = "surface S {\n    contracts:\n        demands Auditable\n}";
4814        let r = parse_ok(src);
4815        assert_eq!(r.diagnostics.len(), 0);
4816        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4817        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4818            panic!("expected ContractsClause, got {:?}", b.items[0].kind)
4819        };
4820        assert_eq!(entries.len(), 1);
4821        assert!(matches!(entries[0].direction, ContractDirection::Demands));
4822        assert_eq!(entries[0].name.name, "Auditable");
4823    }
4824
4825    #[test]
4826    fn contracts_clause_single_fulfils() {
4827        let src = "surface S {\n    contracts:\n        fulfils EventSubmitter\n}";
4828        let r = parse_ok(src);
4829        assert_eq!(r.diagnostics.len(), 0);
4830        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4831        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4832            panic!("expected ContractsClause")
4833        };
4834        assert_eq!(entries.len(), 1);
4835        assert!(matches!(entries[0].direction, ContractDirection::Fulfils));
4836        assert_eq!(entries[0].name.name, "EventSubmitter");
4837    }
4838
4839    #[test]
4840    fn contracts_clause_qualified_fulfils() {
4841        let src = "surface S {\n    contracts:\n        fulfils base/MyContract\n}";
4842        let r = parse_ok(src);
4843        assert_eq!(r.diagnostics.len(), 0);
4844        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4845        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4846            panic!("expected ContractsClause, got {:?}", b.items[0].kind)
4847        };
4848        assert_eq!(entries.len(), 1);
4849        assert!(matches!(entries[0].direction, ContractDirection::Fulfils));
4850        assert_eq!(entries[0].qualifier.as_deref(), Some("base"));
4851        assert_eq!(entries[0].name.name, "MyContract");
4852    }
4853
4854    #[test]
4855    fn contracts_clause_qualified_demands() {
4856        let src = "surface S {\n    contracts:\n        demands base/MyContract\n        fulfils Local\n}";
4857        let r = parse_ok(src);
4858        assert_eq!(r.diagnostics.len(), 0);
4859        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4860        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4861            panic!("expected ContractsClause")
4862        };
4863        assert_eq!(entries.len(), 2);
4864        assert!(matches!(entries[0].direction, ContractDirection::Demands));
4865        assert_eq!(entries[0].qualifier.as_deref(), Some("base"));
4866        assert_eq!(entries[0].name.name, "MyContract");
4867        assert_eq!(entries[1].qualifier, None);
4868        assert_eq!(entries[1].name.name, "Local");
4869    }
4870
4871    #[test]
4872    fn contracts_clause_qualified_missing_name_errors() {
4873        let src = "surface S {\n    contracts:\n        fulfils base/\n}";
4874        let r = parse(src);
4875        assert!(
4876            r.diagnostics
4877                .iter()
4878                .any(|d| d.message.contains("contract name after '/'")),
4879            "expected an error about the missing name, got {:?}",
4880            r.diagnostics
4881        );
4882    }
4883
4884    #[test]
4885    fn contracts_clause_mixed() {
4886        let src = "surface S {\n    contracts:\n        demands Auditable\n        fulfils EventSubmitter\n}";
4887        let r = parse_ok(src);
4888        assert_eq!(r.diagnostics.len(), 0);
4889        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4890        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4891            panic!("expected ContractsClause")
4892        };
4893        assert_eq!(entries.len(), 2);
4894        assert!(matches!(entries[0].direction, ContractDirection::Demands));
4895        assert!(matches!(entries[1].direction, ContractDirection::Fulfils));
4896    }
4897
4898    #[test]
4899    fn contracts_with_other_clauses() {
4900        let src = r#"surface S {
4901    facing user: User
4902    contracts:
4903        demands Auditable
4904    exposes:
4905        user.name
4906}"#;
4907        let r = parse_ok(src);
4908        assert_eq!(r.diagnostics.len(), 0);
4909        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4910        assert_eq!(b.items.len(), 3);
4911    }
4912
4913    #[test]
4914    fn contracts_only_surface() {
4915        let src = "surface S {\n    contracts:\n        demands Foo\n        fulfils Bar\n}";
4916        let r = parse_ok(src);
4917        assert_eq!(r.diagnostics.len(), 0);
4918        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4919        assert_eq!(b.items.len(), 1);
4920    }
4921
4922    #[test]
4923    fn contracts_empty_rejected() {
4924        let src = "-- allium: 1\nsurface S {\n    contracts:\n}";
4925        let r = parse(src);
4926        assert!(
4927            r.diagnostics.iter().any(|d| d.message.contains("Empty `contracts:`")),
4928            "expected empty contracts error, got: {:?}",
4929            r.diagnostics
4930        );
4931    }
4932
4933    #[test]
4934    fn contracts_inline_block_rejected() {
4935        let src = "-- allium: 1\nsurface S {\n    contracts:\n        demands Foo {\n        }\n}";
4936        let r = parse(src);
4937        assert!(
4938            r.diagnostics.iter().any(|d| d.message.contains("Inline contract blocks")),
4939            "expected inline block error, got: {:?}",
4940            r.diagnostics
4941        );
4942    }
4943
4944    #[test]
4945    fn contracts_unknown_direction_rejected() {
4946        let src = "-- allium: 1\nsurface S {\n    contracts:\n        requires Foo\n}";
4947        let r = parse(src);
4948        assert!(
4949            r.diagnostics.iter().any(|d| d.message.contains("Unknown direction")),
4950            "expected unknown direction error, got: {:?}",
4951            r.diagnostics
4952        );
4953    }
4954
4955    // -----------------------------------------------------------------------
4956    // ALP-16: annotations
4957    // -----------------------------------------------------------------------
4958
4959    #[test]
4960    fn annotation_invariant() {
4961        let src = "contract C {\n    @invariant Determinism\n        -- all evaluations must be deterministic\n}";
4962        let r = parse_ok(src);
4963        assert_eq!(r.diagnostics.len(), 0);
4964        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4965        let BlockItemKind::Annotation(ann) = &b.items[0].kind else {
4966            panic!("expected Annotation, got {:?}", b.items[0].kind)
4967        };
4968        assert!(matches!(ann.kind, AnnotationKind::Invariant));
4969        assert_eq!(ann.name.as_ref().unwrap().name, "Determinism");
4970        assert_eq!(ann.body.len(), 1);
4971        assert_eq!(ann.body[0], "all evaluations must be deterministic");
4972    }
4973
4974    #[test]
4975    fn annotation_multiple_invariants() {
4976        let src = "contract C {\n    @invariant A\n        -- first\n    @invariant B\n        -- second\n}";
4977        let r = parse_ok(src);
4978        assert_eq!(r.diagnostics.len(), 0);
4979        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4980        assert_eq!(b.items.len(), 2);
4981        assert!(matches!(&b.items[0].kind, BlockItemKind::Annotation(_)));
4982        assert!(matches!(&b.items[1].kind, BlockItemKind::Annotation(_)));
4983    }
4984
4985    #[test]
4986    fn annotation_invariant_then_guidance() {
4987        let src = "contract C {\n    @invariant Safety\n        -- must be safe\n    @guidance\n        -- implementation notes\n}";
4988        let r = parse_ok(src);
4989        assert_eq!(r.diagnostics.len(), 0);
4990        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4991        assert_eq!(b.items.len(), 2);
4992    }
4993
4994    #[test]
4995    fn annotation_guidance_in_rule() {
4996        let src = "rule R {\n    when: Event.created\n    ensures: something\n    @guidance\n        -- do it this way\n}";
4997        let r = parse_ok(src);
4998        assert_eq!(r.diagnostics.len(), 0);
4999        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5000        let last = b.items.last().unwrap();
5001        let BlockItemKind::Annotation(ann) = &last.kind else { panic!() };
5002        assert!(matches!(ann.kind, AnnotationKind::Guidance));
5003        assert!(ann.name.is_none());
5004    }
5005
5006    #[test]
5007    fn annotation_guarantee() {
5008        let src = "surface S {\n    @guarantee ResponseTime\n        -- must respond within 100ms\n}";
5009        let r = parse_ok(src);
5010        assert_eq!(r.diagnostics.len(), 0);
5011        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5012        let BlockItemKind::Annotation(ann) = &b.items[0].kind else { panic!() };
5013        assert!(matches!(ann.kind, AnnotationKind::Guarantee));
5014        assert_eq!(ann.name.as_ref().unwrap().name, "ResponseTime");
5015    }
5016
5017    #[test]
5018    fn annotation_guarantee_then_guidance() {
5019        let src = "surface S {\n    @guarantee Fast\n        -- sub-second\n    @guidance\n        -- cache aggressively\n}";
5020        let r = parse_ok(src);
5021        assert_eq!(r.diagnostics.len(), 0);
5022        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5023        assert_eq!(b.items.len(), 2);
5024    }
5025
5026    #[test]
5027    fn annotation_contracts_guarantee_guidance() {
5028        let src = r#"surface S {
5029    contracts:
5030        demands Auditable
5031    @guarantee ResponseTime
5032        -- fast
5033    @guidance
5034        -- notes
5035}"#;
5036        let r = parse_ok(src);
5037        assert_eq!(r.diagnostics.len(), 0);
5038        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5039        assert_eq!(b.items.len(), 3);
5040    }
5041
5042    #[test]
5043    fn annotation_multiline_body() {
5044        let src = "contract C {\n    @invariant Multi\n        -- line one\n        -- line two\n        -- line three\n}";
5045        let r = parse_ok(src);
5046        assert_eq!(r.diagnostics.len(), 0);
5047        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5048        let BlockItemKind::Annotation(ann) = &b.items[0].kind else { panic!() };
5049        assert_eq!(ann.body.len(), 3);
5050        assert_eq!(ann.body[0], "line one");
5051        assert_eq!(ann.body[2], "line three");
5052    }
5053
5054    #[test]
5055    fn annotation_empty_body_rejected() {
5056        let src = "-- allium: 1\ncontract C {\n    @invariant NoBody\n}";
5057        let r = parse(src);
5058        assert!(
5059            r.diagnostics.iter().any(|d| d.message.contains("at least one indented comment line")),
5060            "expected empty body error, got: {:?}",
5061            r.diagnostics
5062        );
5063    }
5064
5065    #[test]
5066    fn annotation_unknown_keyword_rejected() {
5067        let src = "-- allium: 1\ncontract C {\n    @note Something\n        -- text\n}";
5068        let r = parse(src);
5069        assert!(
5070            r.diagnostics.iter().any(|d| d.message.contains("Unknown annotation")),
5071            "expected unknown annotation error, got: {:?}",
5072            r.diagnostics
5073        );
5074    }
5075
5076    #[test]
5077    fn expression_invariant_still_works() {
5078        let src = r#"entity E {
5079    status: pending | active
5080    invariant AllValid {
5081        this.status = active
5082    }
5083}"#;
5084        let r = parse_ok(src);
5085        assert_eq!(r.diagnostics.len(), 0);
5086        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5087        // Find the invariant block item
5088        let inv = b.items.iter().find(|i| matches!(&i.kind, BlockItemKind::InvariantBlock { .. }));
5089        assert!(inv.is_some(), "expression-bearing invariant should still parse");
5090    }
5091
5092    #[test]
5093    fn invariant_colon_form_migration() {
5094        let src = "-- allium: 1\ncontract C {\n    invariant: SomeName\n}";
5095        let r = parse(src);
5096        assert!(
5097            r.diagnostics.iter().any(|d| d.message.contains("`invariant:` syntax was replaced")),
5098            "expected migration diagnostic, got: {:?}",
5099            r.diagnostics
5100        );
5101    }
5102
5103    #[test]
5104    fn guidance_colon_form_migration() {
5105        let src = "-- allium: 1\nrule R {\n    when: Event.created\n    ensures: something\n    guidance: \"do it\"\n}";
5106        let r = parse(src);
5107        assert!(
5108            r.diagnostics.iter().any(|d| d.message.contains("`guidance:` syntax was replaced")),
5109            "expected migration diagnostic, got: {:?}",
5110            r.diagnostics
5111        );
5112    }
5113
5114    #[test]
5115    fn guarantee_colon_form_migration() {
5116        let src = "-- allium: 1\nsurface S {\n    guarantee: \"fast\"\n}";
5117        let r = parse(src);
5118        assert!(
5119            r.diagnostics.iter().any(|d| d.message.contains("`guarantee:` syntax was replaced")),
5120            "expected migration diagnostic, got: {:?}",
5121            r.diagnostics
5122        );
5123    }
5124
5125    #[test]
5126    fn annotation_guidance_with_name_rejected() {
5127        let src = "-- allium: 1\ncontract C {\n    @guidance Named\n        -- text\n}";
5128        let r = parse(src);
5129        assert!(
5130            r.diagnostics.iter().any(|d| d.message.contains("does not take a name")),
5131            "expected guidance name error, got: {:?}",
5132            r.diagnostics
5133        );
5134    }
5135
5136    // -----------------------------------------------------------------------
5137    // ALP-11 part 2: expression-bearing invariants
5138    // -----------------------------------------------------------------------
5139
5140    #[test]
5141    fn invariant_top_level_simple() {
5142        let src = r#"invariant PositiveBalance {
5143    this.balance > 0
5144}"#;
5145        let r = parse_ok(src);
5146        assert_eq!(r.diagnostics.len(), 0);
5147        let Decl::Invariant(inv) = &r.module.declarations[0] else {
5148            panic!("expected Invariant, got {:?}", r.module.declarations[0])
5149        };
5150        assert_eq!(inv.name.name, "PositiveBalance");
5151    }
5152
5153    #[test]
5154    fn invariant_top_level_for_quantifier() {
5155        let src = r#"invariant AllPositive {
5156    for item in items: item.value > 0
5157}"#;
5158        let r = parse_ok(src);
5159        assert_eq!(r.diagnostics.len(), 0);
5160        let Decl::Invariant(inv) = &r.module.declarations[0] else { panic!() };
5161        assert!(matches!(inv.body, Expr::For { .. }));
5162    }
5163
5164    #[test]
5165    fn invariant_top_level_nested_for() {
5166        let src = r#"invariant NestedFor {
5167    for a in items: for b in a.children: b.valid = true
5168}"#;
5169        let r = parse_ok(src);
5170        assert_eq!(r.diagnostics.len(), 0);
5171    }
5172
5173    #[test]
5174    fn invariant_top_level_implies() {
5175        let src = r#"invariant ImpliesTest {
5176    this.active implies this.balance > 0
5177}"#;
5178        let r = parse_ok(src);
5179        assert_eq!(r.diagnostics.len(), 0);
5180        let Decl::Invariant(inv) = &r.module.declarations[0] else { panic!() };
5181        assert!(matches!(inv.body, Expr::LogicalOp { op: LogicalOp::Implies, .. }));
5182    }
5183
5184    #[test]
5185    fn invariant_top_level_let_binding() {
5186        let src = r#"invariant WithLet {
5187    let total = this.items.count()
5188    total > 0
5189}"#;
5190        let r = parse_ok(src);
5191        assert_eq!(r.diagnostics.len(), 0);
5192    }
5193
5194    #[test]
5195    fn invariant_top_level_collection_ops() {
5196        let src = r#"invariant CollectionOps {
5197    this.items where active = true
5198}"#;
5199        let r = parse_ok(src);
5200        assert_eq!(r.diagnostics.len(), 0);
5201    }
5202
5203    #[test]
5204    fn invariant_top_level_exists() {
5205        let src = r#"invariant ExistsCheck {
5206    exists this.primary_contact
5207}"#;
5208        let r = parse_ok(src);
5209        assert_eq!(r.diagnostics.len(), 0);
5210    }
5211
5212    #[test]
5213    fn invariant_top_level_not_exists() {
5214        let src = r#"invariant NotExistsCheck {
5215    not exists this.deleted_at
5216}"#;
5217        let r = parse_ok(src);
5218        assert_eq!(r.diagnostics.len(), 0);
5219    }
5220
5221    #[test]
5222    fn invariant_top_level_optional_navigation() {
5223        let src = r#"invariant OptionalNav {
5224    this.owner?.email ?? "none" != "none"
5225}"#;
5226        let r = parse_ok(src);
5227        assert_eq!(r.diagnostics.len(), 0);
5228    }
5229
5230    #[test]
5231    fn invariant_top_level_lowercase_rejected() {
5232        let src = "-- allium: 1\ninvariant bad { true }";
5233        let r = parse(src);
5234        assert!(
5235            r.diagnostics.iter().any(|d| d.message.contains("uppercase")),
5236            "expected uppercase error, got: {:?}",
5237            r.diagnostics
5238        );
5239    }
5240
5241    #[test]
5242    fn invariant_entity_level() {
5243        let src = r#"entity Account {
5244    balance: Decimal
5245    invariant NonNegative { this.balance >= 0 }
5246}"#;
5247        let r = parse_ok(src);
5248        assert_eq!(r.diagnostics.len(), 0);
5249        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5250        let BlockItemKind::InvariantBlock { name, body: _ } = &b.items[1].kind else {
5251            panic!("expected InvariantBlock, got {:?}", b.items[1].kind)
5252        };
5253        assert_eq!(name.name, "NonNegative");
5254    }
5255
5256    #[test]
5257    fn invariant_entity_level_this_ref() {
5258        let src = r#"entity Order {
5259    total: Decimal
5260    invariant PositiveTotal { this.total > 0 }
5261}"#;
5262        let r = parse_ok(src);
5263        assert_eq!(r.diagnostics.len(), 0);
5264    }
5265
5266    #[test]
5267    fn invariant_entity_level_implies() {
5268        let src = r#"entity Subscription {
5269    active: Boolean
5270    balance: Decimal
5271    invariant ActiveMeansPositive { this.active implies this.balance > 0 }
5272}"#;
5273        let r = parse_ok(src);
5274        assert_eq!(r.diagnostics.len(), 0);
5275    }
5276
5277    #[test]
5278    fn invariant_entity_level_lowercase_rejected() {
5279        let src = "-- allium: 1\nentity E { invariant bad { true } }";
5280        let r = parse(src);
5281        assert!(
5282            r.diagnostics.iter().any(|d| d.message.contains("uppercase")),
5283            "expected uppercase error, got: {:?}",
5284            r.diagnostics
5285        );
5286    }
5287
5288    #[test]
5289    fn invariant_colon_form_in_entity_migration() {
5290        // Old `invariant:` colon form should emit migration diagnostic
5291        let src = "-- allium: 1\nentity E {\n    invariant: -- must be valid\n}";
5292        let r = parse(src);
5293        assert!(
5294            r.diagnostics.iter().any(|d| d.message.contains("`invariant:` syntax was replaced")),
5295            "expected migration diagnostic, got: {:?}",
5296            r.diagnostics
5297        );
5298    }
5299
5300    #[test]
5301    fn invariant_top_level_colon_rejected() {
5302        // Colon-delimited body at top level is wrong syntax
5303        let src = "-- allium: 1\ninvariant Bad: some text";
5304        let r = parse(src);
5305        assert!(
5306            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
5307            "expected error for colon-delimited invariant at top level, got: {:?}",
5308            r.diagnostics
5309        );
5310    }
5311
5312    #[test]
5313    fn invariant_same_name_different_scopes() {
5314        // Top-level and entity-level invariants with same name are valid (parser doesn't check)
5315        let src = r#"invariant SameName { true }
5316entity E {
5317    invariant SameName { true }
5318}"#;
5319        let r = parse_ok(src);
5320        assert_eq!(r.diagnostics.len(), 0);
5321    }
5322
5323    // -----------------------------------------------------------------------
5324    // ALP-10: cross-module config references
5325    // -----------------------------------------------------------------------
5326
5327    #[test]
5328    fn config_qualified_reference() {
5329        // `core/config.max_batch_size` should parse as MemberAccess(QualifiedName, field)
5330        let src = r#"config {
5331    param: Integer = core/config.max_batch_size
5332}"#;
5333        let r = parse_ok(src);
5334        assert_eq!(r.diagnostics.len(), 0);
5335    }
5336
5337    #[test]
5338    fn config_multiple_qualified_refs() {
5339        let src = r#"config {
5340    param_a: Integer = core/config.max_batch_size
5341    param_b: Duration = core/config.default_delay
5342}"#;
5343        let r = parse_ok(src);
5344        assert_eq!(r.diagnostics.len(), 0);
5345    }
5346
5347    #[test]
5348    fn config_qualified_ref_with_type() {
5349        let src = r#"config {
5350    publish_delay: Duration = core/config.default_delay
5351}"#;
5352        let r = parse_ok(src);
5353        assert_eq!(r.diagnostics.len(), 0);
5354    }
5355
5356    #[test]
5357    fn config_qualified_chain() {
5358        // Parameter with qualified default that itself could have a qualified default
5359        let src = r#"config {
5360    first: Integer = core/config.base
5361    second: Integer = first
5362}"#;
5363        let r = parse_ok(src);
5364        assert_eq!(r.diagnostics.len(), 0);
5365    }
5366
5367    #[test]
5368    fn config_renamed_param_with_qualified_ref() {
5369        let src = r#"config {
5370    my_timeout: Duration = core/config.base_timeout
5371}"#;
5372        let r = parse_ok(src);
5373        assert_eq!(r.diagnostics.len(), 0);
5374    }
5375
5376    // -----------------------------------------------------------------------
5377    // ALP-13: expression-form config defaults
5378    // -----------------------------------------------------------------------
5379
5380    #[test]
5381    fn config_default_arithmetic() {
5382        let src = r#"config {
5383    param: Integer = other_param + 1
5384}"#;
5385        let r = parse_ok(src);
5386        assert_eq!(r.diagnostics.len(), 0);
5387    }
5388
5389    #[test]
5390    fn config_default_qualified_arithmetic() {
5391        let src = r#"config {
5392    param: Duration = core/config.timeout * 2
5393}"#;
5394        let r = parse_ok(src);
5395        assert_eq!(r.diagnostics.len(), 0);
5396    }
5397
5398    #[test]
5399    fn config_default_parenthesised() {
5400        let src = r#"config {
5401    param: Integer = (base + 1) * factor
5402}"#;
5403        let r = parse_ok(src);
5404        assert_eq!(r.diagnostics.len(), 0);
5405    }
5406
5407    #[test]
5408    fn config_default_two_qualified_refs() {
5409        let src = r#"config {
5410    param: Duration = core/config.a + core/config.b
5411}"#;
5412        let r = parse_ok(src);
5413        assert_eq!(r.diagnostics.len(), 0);
5414    }
5415
5416    #[test]
5417    fn config_default_literal_only() {
5418        let src = r#"config {
5419    param: Integer = 5
5420}"#;
5421        let r = parse_ok(src);
5422        assert_eq!(r.diagnostics.len(), 0);
5423    }
5424
5425    #[test]
5426    fn config_default_decimal_literal() {
5427        let src = r#"config {
5428    param: Decimal = price * 1.5
5429}"#;
5430        let r = parse_ok(src);
5431        assert_eq!(r.diagnostics.len(), 0);
5432    }
5433
5434    #[test]
5435    fn config_default_mixed_operators() {
5436        let src = r#"config {
5437    param: Duration = timeout * 2 + 1.minute
5438}"#;
5439        let r = parse_ok(src);
5440        assert_eq!(r.diagnostics.len(), 0);
5441    }
5442
5443    #[test]
5444    fn config_default_operator_precedence() {
5445        // a + b * c should be Add(a, Mul(b, c))
5446        let src = r#"config {
5447    param: Integer = a + b * c
5448}"#;
5449        let r = parse_ok(src);
5450        assert_eq!(r.diagnostics.len(), 0);
5451    }
5452
5453    // -----------------------------------------------------------------------
5454    // Version 2 marker
5455    // -----------------------------------------------------------------------
5456
5457    #[test]
5458    fn version_2_accepted() {
5459        let r = parse("-- allium: 2\nentity User {}");
5460        assert_eq!(r.module.version, Some(2));
5461        assert_eq!(r.diagnostics.len(), 0);
5462    }
5463
5464    #[test]
5465    fn version_99_still_rejected() {
5466        let r = parse("-- allium: 99\nentity User {}");
5467        assert!(r.diagnostics.iter().any(|d|
5468            d.severity == Severity::Error && d.message.contains("unsupported")
5469        ));
5470    }
5471
5472    #[test]
5473    fn contract_typed_signature() {
5474        let src = r#"contract Codec {
5475    serialize: (value: Any) -> ByteArray
5476}"#;
5477        let r = parse_ok(src);
5478        assert_eq!(r.diagnostics.len(), 0);
5479        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5480        assert_eq!(b.kind, BlockKind::Contract);
5481        let BlockItemKind::Assignment { name, value } = &b.items[0].kind else { panic!() };
5482        assert_eq!(name.name, "serialize");
5483        assert!(matches!(value, Expr::ProjectionMap { .. }));
5484    }
5485
5486    #[test]
5487    fn contract_multi_param_signature() {
5488        let src = r#"contract Codec {
5489    serialize: (value: Any, format: String) -> ByteArray
5490}"#;
5491        let r = parse_ok(src);
5492        assert_eq!(r.diagnostics.len(), 0);
5493    }
5494
5495    #[test]
5496    fn comma_separated_entity_fields() {
5497        let src = "entity Point { x: Decimal, y: Decimal }";
5498        let r = parse_ok(src);
5499        assert_eq!(r.diagnostics.len(), 0);
5500        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5501        assert_eq!(b.items.len(), 2);
5502        assert!(matches!(&b.items[0].kind, BlockItemKind::Assignment { name, .. } if name.name == "x"));
5503        assert!(matches!(&b.items[1].kind, BlockItemKind::Assignment { name, .. } if name.name == "y"));
5504    }
5505
5506    #[test]
5507    fn comma_separated_value_fields() {
5508        let src = "value Coord { x: Integer, y: Integer }";
5509        let r = parse_ok(src);
5510        assert_eq!(r.diagnostics.len(), 0);
5511        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5512        assert_eq!(b.items.len(), 2);
5513    }
5514
5515    // -----------------------------------------------------------------------
5516    // Version 3: transitions, produces, consumes
5517    // -----------------------------------------------------------------------
5518
5519    #[test]
5520    fn version_3_accepted() {
5521        let r = parse("-- allium: 3\nentity User {}");
5522        assert_eq!(r.module.version, Some(3));
5523        assert_eq!(r.diagnostics.len(), 0);
5524    }
5525
5526    #[test]
5527    fn transitions_block_basic() {
5528        let src = r#"-- allium: 3
5529entity Order {
5530    status: pending | confirmed | shipped | delivered | cancelled
5531
5532    transitions status {
5533        pending -> confirmed
5534        confirmed -> shipped
5535        shipped -> delivered
5536        pending -> cancelled
5537        confirmed -> cancelled
5538        terminal: delivered, cancelled
5539    }
5540}"#;
5541        let r = parse(src);
5542        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5543        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5544        // items[0] is the status field, items[1] is the transitions block
5545        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5546            panic!("expected TransitionsBlock, got {:?}", b.items[1].kind)
5547        };
5548        assert_eq!(graph.field.name, "status");
5549        assert_eq!(graph.edges.len(), 5);
5550        assert_eq!(graph.edges[0].from.name, "pending");
5551        assert_eq!(graph.edges[0].to.name, "confirmed");
5552        assert_eq!(graph.terminal.len(), 2);
5553        assert_eq!(graph.terminal[0].name, "delivered");
5554        assert_eq!(graph.terminal[1].name, "cancelled");
5555    }
5556
5557    #[test]
5558    fn transitions_block_no_terminal() {
5559        let src = r#"-- allium: 3
5560entity Task {
5561    status: open | closed
5562    transitions status {
5563        open -> closed
5564    }
5565}"#;
5566        let r = parse(src);
5567        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5568        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5569        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5570            panic!("expected TransitionsBlock")
5571        };
5572        assert_eq!(graph.edges.len(), 1);
5573        assert!(graph.terminal.is_empty());
5574    }
5575
5576    #[test]
5577    fn produces_emits_migration_warning() {
5578        let src = r#"-- allium: 3
5579rule ShipOrder {
5580    when: ShipOrder(order, tracking)
5581    requires: order.status = picking
5582    produces: tracking_number, shipped_at
5583    ensures: order.status = shipped
5584}"#;
5585        let r = parse(src);
5586        let warnings: Vec<_> = r.diagnostics.iter()
5587            .filter(|d| d.severity == Severity::Warning)
5588            .collect();
5589        assert!(
5590            warnings.iter().any(|d| d.message.contains("`produces:` clauses are removed")),
5591            "expected migration warning for produces, got: {:?}", warnings
5592        );
5593    }
5594
5595    #[test]
5596    fn consumes_emits_migration_warning() {
5597        let src = r#"-- allium: 3
5598rule ReadOrder {
5599    when: Check(order)
5600    consumes: warehouse_assignment
5601    ensures: order.verified = true
5602}"#;
5603        let r = parse(src);
5604        let warnings: Vec<_> = r.diagnostics.iter()
5605            .filter(|d| d.severity == Severity::Warning)
5606            .collect();
5607        assert!(
5608            warnings.iter().any(|d| d.message.contains("`consumes:` clauses are removed")),
5609            "expected migration warning for consumes, got: {:?}", warnings
5610        );
5611    }
5612
5613    #[test]
5614    fn when_clause_on_field() {
5615        let src = r#"-- allium: 3
5616entity Order {
5617    status: pending | shipped | delivered
5618    tracking_number: String when status = shipped | delivered
5619    transitions status {
5620        pending -> shipped
5621        shipped -> delivered
5622        terminal: delivered
5623    }
5624}"#;
5625        let r = parse(src);
5626        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5627        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5628        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5629        let field_with_when = b.items.iter().find(|i| matches!(&i.kind, BlockItemKind::FieldWithWhen { .. }));
5630        assert!(field_with_when.is_some(), "expected FieldWithWhen item");
5631        if let BlockItemKind::FieldWithWhen { name, when_clause, .. } = &field_with_when.unwrap().kind {
5632            assert_eq!(name.name, "tracking_number");
5633            assert_eq!(when_clause.status_field.name, "status");
5634            assert_eq!(when_clause.qualifying_states.len(), 2);
5635            assert_eq!(when_clause.qualifying_states[0].name, "shipped");
5636            assert_eq!(when_clause.qualifying_states[1].name, "delivered");
5637        }
5638    }
5639
5640    #[test]
5641    fn when_clause_single_state() {
5642        let src = r#"-- allium: 3
5643entity Order {
5644    status: active | cancelled
5645    cancelled_at: Timestamp when status = cancelled
5646    transitions status {
5647        active -> cancelled
5648        terminal: cancelled
5649    }
5650}"#;
5651        let r = parse(src);
5652        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5653        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5654        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5655        if let BlockItemKind::FieldWithWhen { name, when_clause, .. } = &b.items[1].kind {
5656            assert_eq!(name.name, "cancelled_at");
5657            assert_eq!(when_clause.qualifying_states.len(), 1);
5658            assert_eq!(when_clause.qualifying_states[0].name, "cancelled");
5659        } else {
5660            panic!("expected FieldWithWhen, got {:?}", b.items[1].kind);
5661        }
5662    }
5663
5664    #[test]
5665    fn when_clause_with_optional() {
5666        let src = r#"-- allium: 3
5667entity Order {
5668    status: active | cancelled
5669    notes: String? when status = cancelled
5670    transitions status {
5671        active -> cancelled
5672        terminal: cancelled
5673    }
5674}"#;
5675        let r = parse(src);
5676        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5677        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5678        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5679        if let BlockItemKind::FieldWithWhen { name, value, when_clause } = &b.items[1].kind {
5680            assert_eq!(name.name, "notes");
5681            assert!(matches!(value, Expr::TypeOptional { .. }), "expected TypeOptional");
5682            assert_eq!(when_clause.qualifying_states.len(), 1);
5683        } else {
5684            panic!("expected FieldWithWhen, got {:?}", b.items[1].kind);
5685        }
5686    }
5687
5688    #[test]
5689    fn transitions_in_json_output() {
5690        let src = r#"-- allium: 3
5691entity Order {
5692    status: pending | done
5693    transitions status {
5694        pending -> done
5695        terminal: done
5696    }
5697}"#;
5698        let r = parse(src);
5699        let json = serde_json::to_string(&r.module).unwrap();
5700        assert!(json.contains("TransitionsBlock"), "JSON should contain TransitionsBlock: {}", json);
5701        assert!(json.contains("pending"), "JSON should contain 'pending'");
5702    }
5703
5704    #[test]
5705    fn transitions_block_with_commas() {
5706        let src = r#"-- allium: 3
5707entity Order {
5708    status: a | b | c
5709    transitions status {
5710        a -> b,
5711        b -> c,
5712        terminal: c,
5713    }
5714}"#;
5715        let r = parse(src);
5716        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5717        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5718        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5719            panic!("expected TransitionsBlock")
5720        };
5721        assert_eq!(graph.edges.len(), 2);
5722        assert_eq!(graph.terminal.len(), 1);
5723    }
5724
5725    #[test]
5726    fn v3_full_entity_with_transitions_and_rule() {
5727        let src = r#"-- allium: 3
5728entity Order {
5729    status: pending | shipped | delivered
5730    tracking: String when status = shipped | delivered
5731    shipped_at: Timestamp when status = shipped | delivered
5732
5733    transitions status {
5734        pending -> shipped
5735        shipped -> delivered
5736        terminal: delivered
5737    }
5738}
5739
5740rule ShipOrder {
5741    when: ShipOrder(order, tracking)
5742    requires: order.status = pending
5743    ensures:
5744        order.status = shipped
5745        order.tracking = tracking
5746        order.shipped_at = now
5747}"#;
5748        let r = parse(src);
5749        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5750        assert_eq!(r.module.declarations.len(), 2);
5751    }
5752
5753    // -----------------------------------------------------------------------
5754    // Transition graph: edge cases
5755    // -----------------------------------------------------------------------
5756
5757    #[test]
5758    fn transitions_empty_block() {
5759        let src = "-- allium: 3\nentity E {\n    status: a | b\n    transitions status {}\n}";
5760        let r = parse(src);
5761        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5762        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5763        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5764            panic!("expected TransitionsBlock, got {:?}", b.items[1].kind)
5765        };
5766        assert!(graph.edges.is_empty());
5767        assert!(graph.terminal.is_empty());
5768    }
5769
5770    #[test]
5771    fn transitions_terminal_only() {
5772        let src = r#"-- allium: 3
5773entity E {
5774    status: done
5775    transitions status {
5776        terminal: done
5777    }
5778}"#;
5779        let r = parse(src);
5780        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5781        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5782        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5783        assert!(graph.edges.is_empty());
5784        assert_eq!(graph.terminal.len(), 1);
5785        assert_eq!(graph.terminal[0].name, "done");
5786    }
5787
5788    #[test]
5789    fn transitions_terminal_before_edges() {
5790        let src = r#"-- allium: 3
5791entity E {
5792    status: a | b | c
5793    transitions status {
5794        terminal: c
5795        a -> b
5796        b -> c
5797    }
5798}"#;
5799        let r = parse(src);
5800        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5801        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5802        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5803        assert_eq!(graph.edges.len(), 2);
5804        assert_eq!(graph.terminal.len(), 1);
5805        assert_eq!(graph.terminal[0].name, "c");
5806    }
5807
5808    #[test]
5809    fn transitions_self_loop() {
5810        let src = r#"-- allium: 3
5811entity E {
5812    status: running | stopped
5813    transitions status {
5814        running -> running
5815        running -> stopped
5816        terminal: stopped
5817    }
5818}"#;
5819        let r = parse(src);
5820        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5821        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5822        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5823        assert_eq!(graph.edges.len(), 2);
5824        assert_eq!(graph.edges[0].from.name, "running");
5825        assert_eq!(graph.edges[0].to.name, "running");
5826    }
5827
5828    #[test]
5829    fn transitions_single_edge() {
5830        let src = "-- allium: 3\nentity E {\n    s: a | b\n    transitions s { a -> b }\n}";
5831        let r = parse(src);
5832        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5833        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5834        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5835        assert_eq!(graph.field.name, "s");
5836        assert_eq!(graph.edges.len(), 1);
5837    }
5838
5839    #[test]
5840    fn transitions_multiple_terminal_values() {
5841        let src = r#"-- allium: 3
5842entity E {
5843    status: a | b | c | d | e
5844    transitions status {
5845        a -> b
5846        b -> c
5847        terminal: c, d, e
5848    }
5849}"#;
5850        let r = parse(src);
5851        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5852        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5853        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5854        assert_eq!(graph.terminal.len(), 3);
5855    }
5856
5857    #[test]
5858    fn transitions_trailing_comma_in_terminal() {
5859        let src = "-- allium: 3\nentity E {\n    s: a | b\n    transitions s {\n        a -> b\n        terminal: b,\n    }\n}";
5860        let r = parse(src);
5861        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5862        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5863        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5864        assert_eq!(graph.terminal.len(), 1);
5865    }
5866
5867    #[test]
5868    fn transitions_among_other_entity_items() {
5869        // Transitions block between fields, relationships, invariants
5870        let src = r#"-- allium: 3
5871entity Order {
5872    status: pending | shipped | delivered
5873    customer: Customer
5874    tracking: String?
5875
5876    transitions status {
5877        pending -> shipped
5878        shipped -> delivered
5879        terminal: delivered
5880    }
5881
5882    active_items: items where status = active
5883    invariant Positive { this.total > 0 }
5884}"#;
5885        let r = parse(src);
5886        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5887        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5888        // Should have: status, customer, tracking, transitions, active_items, invariant
5889        assert_eq!(b.items.len(), 6);
5890        assert!(matches!(&b.items[3].kind, BlockItemKind::TransitionsBlock(_)));
5891        assert!(matches!(&b.items[5].kind, BlockItemKind::InvariantBlock { .. }));
5892    }
5893
5894    #[test]
5895    fn transitions_error_recovery_missing_arrow() {
5896        let src = r#"-- allium: 3
5897entity E {
5898    status: a | b | c
5899    transitions status {
5900        a b
5901        b -> c
5902    }
5903}"#;
5904        let r = parse(src);
5905        // Should get a diagnostic about missing `->`
5906        assert!(r.diagnostics.iter().any(|d| d.severity == Severity::Error),
5907            "expected error for missing arrow, got: {:?}", r.diagnostics);
5908        // But should still parse the valid edge
5909        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5910        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5911            panic!("expected TransitionsBlock")
5912        };
5913        assert_eq!(graph.edges.len(), 1, "should recover and parse second edge");
5914        assert_eq!(graph.edges[0].from.name, "b");
5915    }
5916
5917    #[test]
5918    fn transitions_field_name_preserved() {
5919        let src = "-- allium: 3\nentity E {\n    phase: x | y\n    transitions phase { x -> y }\n}";
5920        let r = parse(src);
5921        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5922        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5923        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5924        assert_eq!(graph.field.name, "phase");
5925    }
5926
5927    #[test]
5928    fn transitions_diamond_topology() {
5929        // Common pattern: multiple paths converge on a state
5930        let src = r#"-- allium: 3
5931entity E {
5932    status: new | path_a | path_b | done
5933    transitions status {
5934        new -> path_a
5935        new -> path_b
5936        path_a -> done
5937        path_b -> done
5938        terminal: done
5939    }
5940}"#;
5941        let r = parse(src);
5942        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5943        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5944        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5945        assert_eq!(graph.edges.len(), 4);
5946    }
5947
5948    #[test]
5949    fn transitions_edge_span_is_from_to_range() {
5950        let src = "-- allium: 3\nentity E {\n    s: a | b\n    transitions s {\n        a -> b\n    }\n}";
5951        let r = parse(src);
5952        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5953        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5954        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5955        let edge = &graph.edges[0];
5956        // Span should cover from `a` through `b`, not include the arrow
5957        assert!(edge.span.start <= edge.from.span.start);
5958        assert!(edge.span.end >= edge.to.span.end);
5959    }
5960
5961    // -----------------------------------------------------------------------
5962    // When clause: edge cases
5963    // -----------------------------------------------------------------------
5964
5965    #[test]
5966    fn when_clause_multiple_fields() {
5967        let src = r#"-- allium: 3
5968entity Order {
5969    status: pending | shipped | delivered
5970    tracking: String when status = shipped | delivered
5971    shipped_at: Timestamp when status = shipped | delivered
5972    delivered_at: Timestamp when status = delivered
5973    transitions status {
5974        pending -> shipped
5975        shipped -> delivered
5976        terminal: delivered
5977    }
5978}"#;
5979        let r = parse(src);
5980        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5981        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5982        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5983        let when_count = b.items.iter()
5984            .filter(|i| matches!(&i.kind, BlockItemKind::FieldWithWhen { .. }))
5985            .count();
5986        assert_eq!(when_count, 3);
5987    }
5988
5989    #[test]
5990    fn legacy_produces_consumes_skipped_with_warnings() {
5991        let src = r#"-- allium: 3
5992rule R {
5993    when: Go(x)
5994    produces: field_a
5995    consumes: field_b
5996    ensures: x.done = true
5997}"#;
5998        let r = parse(src);
5999        let warnings: Vec<_> = r.diagnostics.iter()
6000            .filter(|d| d.severity == Severity::Warning)
6001            .collect();
6002        assert!(warnings.len() >= 2, "expected at least 2 migration warnings, got {}", warnings.len());
6003        // The produces/consumes items should be dropped from the AST
6004        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6005        assert!(
6006            !b.items.iter().any(|i| matches!(&i.kind, BlockItemKind::FieldWithWhen { .. })),
6007            "legacy produces/consumes should not become FieldWithWhen"
6008        );
6009    }
6010
6011    // -----------------------------------------------------------------------
6012    // v3 interactions: transitions + produces/consumes + other constructs
6013    // -----------------------------------------------------------------------
6014
6015    #[test]
6016    fn v3_entity_with_transitions_and_invariant() {
6017        let src = r#"-- allium: 3
6018entity Account {
6019    status: open | frozen | closed
6020    balance: Decimal
6021
6022    transitions status {
6023        open -> frozen
6024        frozen -> open
6025        open -> closed
6026        frozen -> closed
6027        terminal: closed
6028    }
6029
6030    invariant NonNegative { this.balance >= 0 }
6031}"#;
6032        let r = parse(src);
6033        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6034        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6035        assert!(b.items.iter().any(|i| matches!(&i.kind, BlockItemKind::TransitionsBlock(_))));
6036        assert!(b.items.iter().any(|i| matches!(&i.kind, BlockItemKind::InvariantBlock { .. })));
6037    }
6038
6039    #[test]
6040    fn v3_rule_with_multiple_ensures() {
6041        let src = r#"-- allium: 3
6042rule CompleteOrder {
6043    when: Complete(order)
6044    requires: order.status = shipped
6045    ensures: order.status = delivered
6046    ensures: order.completed_at = now
6047    ensures: order.receipt_number = generate_receipt()
6048}"#;
6049        let r = parse(src);
6050        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6051        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6052        let ensures_count = b.items.iter()
6053            .filter(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "ensures"))
6054            .count();
6055        assert_eq!(ensures_count, 3);
6056    }
6057
6058    #[test]
6059    fn v3_rule_with_if_block() {
6060        let src = r#"-- allium: 3
6061rule Cancel {
6062    when: Cancel(order, reason)
6063    requires: order.status != delivered
6064    ensures:
6065        order.status = cancelled
6066        order.cancelled_at = now
6067        if reason = customer_request:
6068            order.cancelled_by = order.customer
6069}"#;
6070        let r = parse(src);
6071        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6072    }
6073
6074    #[test]
6075    fn v3_complete_lifecycle_spec() {
6076        let src = r#"-- allium: 3
6077
6078entity Subscription {
6079    status: trial | active | past_due | cancelled
6080    started_at: Timestamp when status = active | past_due | cancelled
6081    cancelled_at: Timestamp when status = cancelled
6082    balance: Decimal
6083
6084    transitions status {
6085        trial -> active
6086        active -> past_due
6087        past_due -> active
6088        active -> cancelled
6089        past_due -> cancelled
6090        terminal: cancelled
6091    }
6092
6093    invariant NonNegative { this.balance >= 0 }
6094}
6095
6096config {
6097    trial_period: Duration = 14.days
6098}
6099
6100rule ActivateSubscription {
6101    when: Activate(sub)
6102    requires: sub.status = trial
6103    ensures:
6104        sub.status = active
6105        sub.started_at = now
6106}
6107
6108rule CancelSubscription {
6109    when: Cancel(sub)
6110    requires: sub.status != cancelled
6111    ensures:
6112        sub.status = cancelled
6113        sub.cancelled_at = now
6114}
6115
6116invariant AllCancelledHaveTimestamp {
6117    for sub in Subscriptions where status = cancelled:
6118        sub.cancelled_at != null
6119}
6120
6121surface SubscriptionDashboard {
6122    facing user: User
6123    context sub: Subscription where owner = user
6124    exposes:
6125        sub.status
6126        sub.balance
6127    provides:
6128        Cancel(sub) when sub.status != cancelled
6129}
6130"#;
6131        let r = parse(src);
6132        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6133        // entity, config, 2 rules, invariant, surface = 6 declarations
6134        assert_eq!(r.module.declarations.len(), 6);
6135    }
6136
6137    #[test]
6138    fn v3_produces_consumes_are_field_names_in_entities() {
6139        // In non-rule blocks, `produces` and `consumes` are just field names
6140        let src = r#"-- allium: 3
6141entity Factory {
6142    produces: widget_a
6143    consumes: raw_material
6144}"#;
6145        let r = parse(src);
6146        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6147        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6148        assert!(matches!(&b.items[0].kind, BlockItemKind::Assignment { name, .. } if name.name == "produces"));
6149        assert!(matches!(&b.items[1].kind, BlockItemKind::Assignment { name, .. } if name.name == "consumes"));
6150    }
6151
6152    #[test]
6153    fn v3_legacy_produces_consumes_emit_warnings_in_rules() {
6154        let src = r#"-- allium: 3
6155rule Ship {
6156    when: Ship(order)
6157    produces: tracking_number
6158    consumes: warehouse
6159    ensures: order.status = shipped
6160}"#;
6161        let r = parse(src);
6162        let warnings: Vec<_> = r.diagnostics.iter()
6163            .filter(|d| d.severity == Severity::Warning)
6164            .collect();
6165        assert!(warnings.len() >= 2, "expected migration warnings, got {:?}", warnings);
6166    }
6167
6168    #[test]
6169    fn v3_version_preserved_in_module() {
6170        let src = "-- allium: 3\nentity E {}";
6171        let r = parse(src);
6172        assert_eq!(r.module.version, Some(3));
6173    }
6174
6175    #[test]
6176    fn v3_version_4_still_rejected() {
6177        let src = "-- allium: 4\nentity E {}";
6178        let r = parse(src);
6179        assert!(r.diagnostics.iter().any(|d| d.severity == Severity::Error
6180            && d.message.contains("unsupported")));
6181    }
6182
6183    // -----------------------------------------------------------------------
6184    // Backtick-quoted enum literals
6185    // -----------------------------------------------------------------------
6186
6187    #[test]
6188    fn backtick_in_named_enum() {
6189        let src = "-- allium: 3\nenum Locale { en | fr | `de-CH-1996` | `no-cache` }";
6190        let r = parse(src);
6191        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6192        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6193        assert_eq!(b.items.len(), 4);
6194        // First two are unquoted
6195        let BlockItemKind::EnumVariant { name, backtick_quoted } = &b.items[0].kind else { panic!() };
6196        assert_eq!(name.name, "en");
6197        assert!(!backtick_quoted);
6198        // Third is backtick-quoted
6199        let BlockItemKind::EnumVariant { name, backtick_quoted } = &b.items[2].kind else { panic!() };
6200        assert_eq!(name.name, "de-CH-1996");
6201        assert!(backtick_quoted);
6202        // Fourth is backtick-quoted
6203        let BlockItemKind::EnumVariant { name, backtick_quoted } = &b.items[3].kind else { panic!() };
6204        assert_eq!(name.name, "no-cache");
6205        assert!(backtick_quoted);
6206    }
6207
6208    #[test]
6209    fn backtick_in_inline_enum() {
6210        let src = "-- allium: 3\nentity E { cache: `no-cache` | `no-store` | `public` }";
6211        let r = parse(src);
6212        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6213        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6214        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
6215        // Top-level should be Pipe chains of BacktickLiteral
6216        assert!(matches!(value, Expr::Pipe { .. }));
6217    }
6218
6219    #[test]
6220    fn backtick_in_comparison() {
6221        let src = r#"-- allium: 3
6222rule R {
6223    when: Check(item)
6224    requires: item.locale = `de-CH-1996`
6225    ensures: Done()
6226}"#;
6227        let r = parse(src);
6228        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6229    }
6230
6231    #[test]
6232    fn backtick_mixed_with_unquoted() {
6233        let src = "-- allium: 3\nenum CacheDirective { `no-cache` | `no-store` | public | private }";
6234        let r = parse(src);
6235        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6236        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6237        assert_eq!(b.items.len(), 4);
6238        let BlockItemKind::EnumVariant { backtick_quoted, .. } = &b.items[0].kind else { panic!() };
6239        assert!(backtick_quoted);
6240        let BlockItemKind::EnumVariant { backtick_quoted, .. } = &b.items[2].kind else { panic!() };
6241        assert!(!backtick_quoted);
6242    }
6243
6244    // -----------------------------------------------------------------------
6245    // V3 fixture file
6246    // -----------------------------------------------------------------------
6247
6248    #[test]
6249    fn v3_lifecycle_fixture() {
6250        let src = include_str!("../tests/fixtures/v3-lifecycle.allium");
6251        let r = parse(src);
6252        let errors: Vec<_> = r.diagnostics.iter()
6253            .filter(|d| d.severity == Severity::Error)
6254            .collect();
6255        assert_eq!(
6256            errors.len(),
6257            0,
6258            "expected no errors in v3 lifecycle fixture, got: {:?}",
6259            errors.iter().map(|d| &d.message).collect::<Vec<_>>(),
6260        );
6261    }
6262}