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