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