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