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        // Empty parameter list `()` — e.g. a zero-argument contract signature
2539        // `op: () -> ReturnType`. Represented as an empty block so the
2540        // trailing `-> Type` projection attaches the return type with no
2541        // parameters, mirroring how multi-parameter lists become a block.
2542        if self.at(TokenKind::RParen) {
2543            let end = self.advance().span; // )
2544            return Some(Expr::Block {
2545                span: start.merge(end),
2546                items: Vec::new(),
2547            });
2548        }
2549
2550        // Detect `(name: expr, ...)` — typed signature parameters.
2551        if self.peek_kind().is_word() && self.peek_at(1).kind == TokenKind::Colon {
2552            let mut bindings = Vec::new();
2553            while !self.at(TokenKind::RParen) && !self.at_eof() {
2554                let name = self.parse_ident_in("parameter name")?;
2555                self.expect(TokenKind::Colon)?;
2556                let value = self.parse_expr(0)?;
2557                bindings.push(Expr::Binding {
2558                    span: name.span.merge(value.span()),
2559                    name,
2560                    value: Box::new(value),
2561                });
2562                self.eat(TokenKind::Comma);
2563            }
2564            self.expect(TokenKind::RParen)?;
2565            if bindings.len() == 1 {
2566                return Some(bindings.into_iter().next().unwrap());
2567            }
2568            let span = start.merge(bindings.last().unwrap().span());
2569            return Some(Expr::Block {
2570                span,
2571                items: bindings,
2572            });
2573        }
2574
2575        let expr = self.parse_expr(0)?;
2576        self.expect(TokenKind::RParen)?;
2577        Some(expr)
2578    }
2579}
2580
2581// ---------------------------------------------------------------------------
2582// Tests
2583// ---------------------------------------------------------------------------
2584
2585#[cfg(test)]
2586mod tests {
2587    use super::*;
2588    use crate::diagnostic::Severity;
2589
2590    fn parse_ok(src: &str) -> ParseResult {
2591        // Prefix with version marker if not already present, to avoid
2592        // spurious "missing version marker" warnings in every test.
2593        let owned;
2594        let input = if src.starts_with("-- allium:") {
2595            src
2596        } else {
2597            owned = format!("-- allium: 1\n{src}");
2598            &owned
2599        };
2600        let result = parse(input);
2601        if !result.diagnostics.is_empty() {
2602            for d in &result.diagnostics {
2603                eprintln!(
2604                    "  [{:?}] {} ({}..{})",
2605                    d.severity, d.message, d.span.start, d.span.end
2606                );
2607            }
2608        }
2609        result
2610    }
2611
2612    #[test]
2613    fn version_marker() {
2614        let r = parse_ok("-- allium: 1\n");
2615        assert_eq!(r.module.version, Some(1));
2616        assert_eq!(r.diagnostics.len(), 0);
2617    }
2618
2619    #[test]
2620    fn version_missing_warns() {
2621        let r = parse("entity User {}");
2622        assert_eq!(r.module.version, None);
2623        assert_eq!(r.diagnostics.len(), 1);
2624        assert_eq!(r.diagnostics[0].severity, Severity::Warning);
2625        assert!(r.diagnostics[0].message.contains("missing version marker"), "got: {}", r.diagnostics[0].message);
2626    }
2627
2628    #[test]
2629    fn version_unsupported_errors() {
2630        let r = parse("-- allium: 99\nentity User {}");
2631        assert_eq!(r.module.version, Some(99));
2632        assert!(r.diagnostics.iter().any(|d|
2633            d.severity == Severity::Error && d.message.contains("unsupported allium version 99")
2634        ), "expected unsupported version error, got: {:?}", r.diagnostics);
2635    }
2636
2637    #[test]
2638    fn empty_entity() {
2639        let r = parse_ok("entity User {}");
2640        assert_eq!(r.diagnostics.len(), 0);
2641        assert_eq!(r.module.declarations.len(), 1);
2642        match &r.module.declarations[0] {
2643            Decl::Block(b) => {
2644                assert_eq!(b.kind, BlockKind::Entity);
2645                assert_eq!(b.name.as_ref().unwrap().name, "User");
2646            }
2647            other => panic!("expected Block, got {other:?}"),
2648        }
2649    }
2650
2651    #[test]
2652    fn entity_with_fields() {
2653        let src = r#"entity Order {
2654    customer: Customer
2655    status: pending | active | completed
2656    total: Decimal
2657}"#;
2658        let r = parse_ok(src);
2659        assert_eq!(r.diagnostics.len(), 0);
2660        match &r.module.declarations[0] {
2661            Decl::Block(b) => {
2662                assert_eq!(b.items.len(), 3);
2663            }
2664            other => panic!("expected Block, got {other:?}"),
2665        }
2666    }
2667
2668    #[test]
2669    fn use_declaration() {
2670        let r = parse_ok(r#"use "github.com/specs/oauth/abc123" as oauth"#);
2671        assert_eq!(r.diagnostics.len(), 0);
2672        match &r.module.declarations[0] {
2673            Decl::Use(u) => {
2674                assert_eq!(u.alias.as_ref().unwrap().name, "oauth");
2675            }
2676            other => panic!("expected Use, got {other:?}"),
2677        }
2678    }
2679
2680    #[test]
2681    fn enum_declaration() {
2682        let src = "enum OrderStatus { pending | shipped | delivered }";
2683        let r = parse_ok(src);
2684        assert_eq!(r.diagnostics.len(), 0);
2685    }
2686
2687    #[test]
2688    fn config_block() {
2689        let src = r#"config {
2690    max_retries: Integer = 3
2691    timeout: Duration = 24.hours
2692}"#;
2693        // Config entries are `name: Type = default`. The parser sees
2694        // `name: Type = default` as an assignment where the value is
2695        // `Type = default` (comparison with Eq). That's fine for the
2696        // parse tree — semantic pass separates type from default.
2697        let r = parse_ok(src);
2698        assert_eq!(r.diagnostics.len(), 0);
2699    }
2700
2701    #[test]
2702    fn rule_declaration() {
2703        let src = r#"rule PlaceOrder {
2704    when: CustomerPlacesOrder(customer, items, total)
2705    requires: total > 0
2706    ensures: Order.created(customer: customer, status: pending, total: total)
2707}"#;
2708        let r = parse_ok(src);
2709        assert_eq!(r.diagnostics.len(), 0);
2710        match &r.module.declarations[0] {
2711            Decl::Block(b) => {
2712                assert_eq!(b.kind, BlockKind::Rule);
2713                assert_eq!(b.items.len(), 3);
2714            }
2715            other => panic!("expected Block, got {other:?}"),
2716        }
2717    }
2718
2719    #[test]
2720    fn expression_precedence() {
2721        let r = parse_ok("rule T { v: a + b * c }");
2722        // The value should be Add(a, Mul(b, c))
2723        match &r.module.declarations[0] {
2724            Decl::Block(b) => match &b.items[0].kind {
2725                BlockItemKind::Assignment { value, .. } => match value {
2726                    Expr::BinaryOp { op, right, .. } => {
2727                        assert_eq!(*op, BinaryOp::Add);
2728                        assert!(matches!(**right, Expr::BinaryOp { op: BinaryOp::Mul, .. }));
2729                    }
2730                    other => panic!("expected BinaryOp, got {other:?}"),
2731                },
2732                other => panic!("expected Assignment, got {other:?}"),
2733            },
2734            other => panic!("expected Block, got {other:?}"),
2735        }
2736    }
2737
2738    #[test]
2739    fn default_declaration() {
2740        let src = r#"default Role admin = { name: "admin", permissions: { "read" } }"#;
2741        let r = parse_ok(src);
2742        assert_eq!(r.diagnostics.len(), 0);
2743    }
2744
2745    #[test]
2746    fn open_question() {
2747        let src = r#"open question "Should admins be role-specific?""#;
2748        let r = parse_ok(src);
2749        assert_eq!(r.diagnostics.len(), 0);
2750    }
2751
2752    #[test]
2753    fn external_entity() {
2754        let src = "external entity Customer { email: String }";
2755        let r = parse_ok(src);
2756        assert_eq!(r.diagnostics.len(), 0);
2757        match &r.module.declarations[0] {
2758            Decl::Block(b) => assert_eq!(b.kind, BlockKind::ExternalEntity),
2759            other => panic!("expected Block, got {other:?}"),
2760        }
2761    }
2762
2763    #[test]
2764    fn where_expression() {
2765        let src = "entity E { active: items where status = active }";
2766        let r = parse_ok(src);
2767        assert_eq!(r.diagnostics.len(), 0);
2768    }
2769
2770    #[test]
2771    fn with_expression() {
2772        let src = "entity E { slots: InterviewSlot with candidacy = this }";
2773        let r = parse_ok(src);
2774        assert_eq!(r.diagnostics.len(), 0);
2775    }
2776
2777    #[test]
2778    fn lambda_expression() {
2779        let src = "entity E { v: items.any(i => i.active) }";
2780        let r = parse_ok(src);
2781        assert_eq!(r.diagnostics.len(), 0);
2782    }
2783
2784    #[test]
2785    fn deferred() {
2786        let src = "deferred InterviewerMatching.suggest";
2787        let r = parse_ok(src);
2788        assert_eq!(r.diagnostics.len(), 0);
2789        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2790        assert!(matches!(&d.path, Expr::MemberAccess { .. }));
2791        assert!(d.location_hint.is_none());
2792    }
2793
2794    #[test]
2795    fn deferred_qualified_path() {
2796        let src = "deferred billing/InvoiceWorkflow";
2797        let r = parse_ok(src);
2798        assert_eq!(r.diagnostics.len(), 0);
2799        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2800        let Expr::QualifiedName(q) = &d.path else {
2801            panic!("expected QualifiedName, got {:?}", d.path)
2802        };
2803        assert_eq!(q.qualifier.as_deref(), Some("billing"));
2804        assert_eq!(q.name, "InvoiceWorkflow");
2805    }
2806
2807    #[test]
2808    fn deferred_qualified_path_with_member() {
2809        let src = "deferred billing/InvoiceWorkflow.initiate";
2810        let r = parse_ok(src);
2811        assert_eq!(r.diagnostics.len(), 0);
2812        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2813        let Expr::MemberAccess { object, field, .. } = &d.path else {
2814            panic!("expected MemberAccess, got {:?}", d.path)
2815        };
2816        assert!(matches!(object.as_ref(), Expr::QualifiedName(_)));
2817        assert_eq!(field.name, "initiate");
2818    }
2819
2820    #[test]
2821    fn deferred_with_quoted_location_hint() {
2822        let src = "deferred Foo.bar \"detailed/foo.allium\"";
2823        let r = parse_ok(src);
2824        assert_eq!(r.diagnostics.len(), 0);
2825        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2826        let hint = d.location_hint.as_ref().expect("location hint parsed");
2827        assert_eq!(hint.text(), "detailed/foo.allium");
2828        assert_eq!(d.span.end, hint.span.end, "declaration span covers the hint");
2829    }
2830
2831    #[test]
2832    fn deferred_hint_string_must_share_the_line() {
2833        // A string on the next line is stray top-level input, not this
2834        // declaration's hint.
2835        let src = "deferred Foo\n\"detailed/foo.allium\"";
2836        let r = parse_ok(src);
2837        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2838        assert!(d.location_hint.is_none());
2839        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected declaration")));
2840    }
2841
2842    #[test]
2843    fn deferred_path_rejects_expression_shapes() {
2844        // The path is a dotted name, not an expression: calls and comparisons
2845        // leave their leftover tokens to error at declaration level, while the
2846        // declaration itself still forms with the flat name.
2847        for src in ["deferred Foo(\"x\")", "deferred Foo = \"x\""] {
2848            let r = parse_ok(src);
2849            let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2850            assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Foo"));
2851            assert!(
2852                r.diagnostics.iter().any(|d| d.message.contains("expected declaration")),
2853                "leftover tokens must error in {src:?}"
2854            );
2855        }
2856    }
2857
2858    #[test]
2859    fn deferred_parenthesised_path_errors() {
2860        let r = parse_ok("deferred (Foo)");
2861        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected deferred name")));
2862    }
2863
2864    #[test]
2865    fn deferred_trailing_dot_keeps_declaration() {
2866        // The path stops before a dangling `.`; the declaration still forms
2867        // (so the location-hint check covers the line) and the dot errors.
2868        let r = parse_ok("deferred Dangling.");
2869        let Decl::Deferred(d) = &r.module.declarations[0] else { panic!() };
2870        assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Dangling"));
2871        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected declaration")));
2872    }
2873
2874    #[test]
2875    fn deferred_dangling_qualifier_errors() {
2876        let r = parse_ok("deferred billing/");
2877        assert!(r.diagnostics.iter().any(|d| d.message.contains("expected deferred name after '/'")));
2878    }
2879
2880    #[test]
2881    fn deferred_path_does_not_cross_lines() {
2882        // Newlines are not tokens and keywords are word tokens, so without
2883        // line guards `Dangling.` would absorb the next declaration's
2884        // `deferred` keyword as a member name.
2885        let src = "deferred Dangling.\ndeferred Next.step";
2886        let r = parse_ok(src);
2887        let deferreds: Vec<_> = r
2888            .module
2889            .declarations
2890            .iter()
2891            .filter(|d| matches!(d, Decl::Deferred(_)))
2892            .collect();
2893        assert_eq!(deferreds.len(), 2, "both declarations survive");
2894        let Decl::Deferred(d) = deferreds[0] else { panic!() };
2895        assert!(matches!(&d.path, Expr::Ident(id) if id.name == "Dangling"));
2896    }
2897
2898    #[test]
2899    fn variant_declaration() {
2900        let src = "variant Email : Notification { subject: String }";
2901        let r = parse_ok(src);
2902        assert_eq!(r.diagnostics.len(), 0);
2903    }
2904
2905    // -- projection mapping -----------------------------------------------
2906
2907    #[test]
2908    fn projection_arrow() {
2909        let src = "entity E { confirmed: confirmations where status = confirmed -> interviewer }";
2910        let r = parse_ok(src);
2911        assert_eq!(r.diagnostics.len(), 0);
2912    }
2913
2914    // -- transitions_to / becomes ------------------------------------------
2915
2916    #[test]
2917    fn transitions_to_trigger() {
2918        let src = "rule R { when: Interview.status transitions_to scheduled\n    ensures: Notification.created() }";
2919        let r = parse_ok(src);
2920        assert_eq!(r.diagnostics.len(), 0);
2921    }
2922
2923    #[test]
2924    fn becomes_trigger() {
2925        let src = "rule R { when: Interview.status becomes scheduled\n    ensures: Notification.created() }";
2926        let r = parse_ok(src);
2927        assert_eq!(r.diagnostics.len(), 0);
2928    }
2929
2930    // -- binding colon in clause values ------------------------------------
2931
2932    #[test]
2933    fn when_binding() {
2934        let src = "rule R {\n    when: interview: Interview.status transitions_to scheduled\n    ensures: Notification.created()\n}";
2935        let r = parse_ok(src);
2936        assert_eq!(r.diagnostics.len(), 0);
2937        // The when clause value should be a Binding wrapping a TransitionsTo
2938        let decl = &r.module.declarations[0];
2939        if let Decl::Block(b) = decl {
2940            if let BlockItemKind::Clause { keyword, value } = &b.items[0].kind {
2941                assert_eq!(keyword, "when");
2942                assert!(matches!(value, Expr::Binding { .. }));
2943            } else {
2944                panic!("expected clause");
2945            }
2946        } else {
2947            panic!("expected block decl");
2948        }
2949    }
2950
2951    #[test]
2952    fn when_binding_temporal() {
2953        let src = "rule R {\n    when: invitation: Invitation.expires_at <= now\n    ensures: Invitation.expired()\n}";
2954        let r = parse_ok(src);
2955        assert_eq!(r.diagnostics.len(), 0);
2956    }
2957
2958    #[test]
2959    fn when_binding_created() {
2960        let src = "rule R {\n    when: batch: DigestBatch.created\n    ensures: Email.created()\n}";
2961        let r = parse_ok(src);
2962        assert_eq!(r.diagnostics.len(), 0);
2963    }
2964
2965    #[test]
2966    fn facing_binding() {
2967        let src = "surface S {\n    facing viewer: Interviewer\n    exposes: InterviewList\n}";
2968        let r = parse_ok(src);
2969        assert_eq!(r.diagnostics.len(), 0);
2970    }
2971
2972    #[test]
2973    fn context_binding() {
2974        let src = "surface S {\n    facing viewer: Interviewer\n    context assignment: SlotConfirmation where interviewer = viewer\n}";
2975        let r = parse_ok(src);
2976        assert_eq!(r.diagnostics.len(), 0);
2977    }
2978
2979    // -- rule-level for block item -----------------------------------------
2980
2981    #[test]
2982    fn rule_level_for() {
2983        let src = r#"rule ProcessDigests {
2984    when: schedule: DigestSchedule.next_run_at <= now
2985    for user in Users where notification_setting.digest_enabled:
2986        ensures: DigestBatch.created(user: user)
2987}"#;
2988        let r = parse_ok(src);
2989        assert_eq!(r.diagnostics.len(), 0);
2990        if let Decl::Block(b) = &r.module.declarations[0] {
2991            // Should have when clause + for block item
2992            assert!(b.items.len() >= 2);
2993            assert!(matches!(b.items[1].kind, BlockItemKind::ForBlock { .. }));
2994        } else {
2995            panic!("expected block decl");
2996        }
2997    }
2998
2999    // -- let inside ensures blocks -----------------------------------------
3000
3001    #[test]
3002    fn let_in_ensures_block() {
3003        let src = r#"rule R {
3004    when: ScheduleInterview(candidacy, time, interviewers)
3005    ensures:
3006        let slot = InterviewSlot.created(time: time, candidacy: candidacy)
3007        for interviewer in interviewers:
3008            SlotConfirmation.created(slot: slot, interviewer: interviewer)
3009}"#;
3010        let r = parse_ok(src);
3011        assert_eq!(r.diagnostics.len(), 0);
3012    }
3013
3014    // -- when guard on provides items --------------------------------------
3015
3016    #[test]
3017    fn provides_when_guard() {
3018        let src = "surface S {\n    facing viewer: Interviewer\n    provides: ConfirmSlot(viewer, slot) when slot.status = pending\n}";
3019        let r = parse_ok(src);
3020        assert_eq!(r.diagnostics.len(), 0);
3021    }
3022
3023    // -- optional type suffix ----------------------------------------------
3024
3025    #[test]
3026    fn optional_type_suffix() {
3027        let src = "entity E { locked_until: Timestamp? }";
3028        let r = parse_ok(src);
3029        assert_eq!(r.diagnostics.len(), 0);
3030    }
3031
3032    #[test]
3033    fn optional_trigger_param() {
3034        let src = "rule R { when: Report(interviewer, interview, reason, details?)\n    ensures: Done() }";
3035        let r = parse_ok(src);
3036        assert_eq!(r.diagnostics.len(), 0);
3037    }
3038
3039    // -- qualified name with config ----------------------------------------
3040
3041    #[test]
3042    fn qualified_config_access() {
3043        let src = "entity E { duration: oauth/config.session_duration }";
3044        let r = parse_ok(src);
3045        assert_eq!(r.diagnostics.len(), 0);
3046    }
3047
3048    // -- comprehensive integration test ------------------------------------
3049
3050    #[test]
3051    fn realistic_spec() {
3052        let src = r#"-- allium: 1
3053
3054enum OrderStatus { pending | shipped | delivered }
3055
3056external entity Customer {
3057    email: String
3058    name: String
3059}
3060
3061entity Order {
3062    customer: Customer
3063    status: OrderStatus
3064    total: Decimal
3065    items: OrderItem with order = this
3066    shipped_items: items where status = shipped
3067    confirmed_items: items where status = confirmed -> item
3068    is_complete: status = delivered
3069    locked_until: Timestamp?
3070}
3071
3072config {
3073    max_retries: Integer = 3
3074    timeout: Duration = 24.hours
3075}
3076
3077rule PlaceOrder {
3078    when: CustomerPlacesOrder(customer, items, total)
3079    requires: total > 0
3080    ensures: Order.created(customer: customer, status: pending, total: total)
3081}
3082
3083rule ShipOrder {
3084    when: order: Order.status transitions_to shipped
3085    ensures: Email.created(to: order.customer.email, template: order_shipped)
3086}
3087
3088open question "How do we handle partial shipments?"
3089"#;
3090        let r = parse_ok(src);
3091        assert_eq!(r.diagnostics.len(), 0, "expected no errors");
3092        assert_eq!(r.module.version, Some(1));
3093        assert_eq!(r.module.declarations.len(), 7);
3094    }
3095
3096    #[test]
3097    fn extension_behaviour_excerpt() {
3098        // Exercises: inline enums, generic types, or-triggers, named call
3099        // args, config with typed defaults, module declaration.
3100        let src = r#"value Document {
3101    uri: String
3102    text: String
3103}
3104
3105entity Finding {
3106    code: String
3107    severity: error | warning | info
3108    range: FindingRange
3109}
3110
3111entity DiagnosticsMode {
3112    value: strict | relaxed
3113}
3114
3115config {
3116    duplicateKey: String = "allium.config.duplicateKey"
3117}
3118
3119rule RefreshDiagnostics {
3120    when: DocumentOpened(document) or DocumentChanged(document)
3121    requires: document.language_id = "allium"
3122    ensures: FindingsComputed(document)
3123}
3124
3125surface DiagnosticsDashboard {
3126    facing viewer: Developer
3127    context doc: Document where viewer.active_document = doc
3128    provides: RunChecks(viewer) when doc.language_id = "allium"
3129    exposes: FindingList
3130}
3131
3132rule ProcessDigests {
3133    when: schedule: DigestSchedule.next_run_at <= now
3134    for user in Users where notification_setting.digest_enabled:
3135        let settings = user.notification_setting
3136        ensures: DigestBatch.created(user: user)
3137}
3138"#;
3139        let r = parse_ok(src);
3140        assert_eq!(r.diagnostics.len(), 0, "expected no errors");
3141        // value + entity + entity + config + rule + surface + rule = 7
3142        assert_eq!(r.module.declarations.len(), 7);
3143    }
3144
3145    #[test]
3146    fn exists_as_identifier() {
3147        let src = r#"rule R {
3148    when: X()
3149    ensures: CompletionItemAvailable(label: exists)
3150}"#;
3151        let r = parse_ok(src);
3152        assert_eq!(r.diagnostics.len(), 0);
3153    }
3154
3155    // -- pipe precedence: tighter than boolean ops ----------------------------
3156
3157    #[test]
3158    fn pipe_binds_tighter_than_or() {
3159        // `a or b | c` should parse as `a or (b | c)`, not `(a or b) | c`
3160        let src = "entity E { v: a or b | c }";
3161        let r = parse_ok(src);
3162        assert_eq!(r.diagnostics.len(), 0);
3163        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3164        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3165        // Top-level should be LogicalOp(Or)
3166        let Expr::LogicalOp { op, right, .. } = value else {
3167            panic!("expected LogicalOp, got {value:?}");
3168        };
3169        assert_eq!(*op, LogicalOp::Or);
3170        // Right side should be Pipe(b, c)
3171        assert!(matches!(right.as_ref(), Expr::Pipe { .. }));
3172    }
3173
3174    // -- variant with expression base -----------------------------------------
3175
3176    #[test]
3177    fn variant_with_pipe_base() {
3178        let src = "variant Mixed : TypeA | TypeB";
3179        let r = parse_ok(src);
3180        assert_eq!(r.diagnostics.len(), 0);
3181        let Decl::Variant(v) = &r.module.declarations[0] else { panic!() };
3182        assert!(matches!(v.base, Expr::Pipe { .. }));
3183    }
3184
3185    // -- for-block with comparison in where filter ----------------------------
3186
3187    #[test]
3188    fn for_block_where_comparison() {
3189        let src = r#"rule R {
3190    when: X()
3191    for item in Items where item.status = active:
3192        ensures: Processed(item: item)
3193}"#;
3194        let r = parse_ok(src);
3195        assert_eq!(r.diagnostics.len(), 0);
3196        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3197        let BlockItemKind::ForBlock { filter, .. } = &b.items[1].kind else { panic!() };
3198        assert!(filter.is_some());
3199        assert!(matches!(filter.as_ref().unwrap(), Expr::Comparison { .. }));
3200    }
3201
3202    // -- for-expression with comparison in where filter -----------------------
3203
3204    #[test]
3205    fn for_expr_where_comparison() {
3206        let src = r#"rule R {
3207    when: X()
3208    ensures:
3209        for item in Items where item.active = true:
3210            Processed(item: item)
3211}"#;
3212        let r = parse_ok(src);
3213        assert_eq!(r.diagnostics.len(), 0);
3214    }
3215
3216    // -- if/else if/else chain ------------------------------------------------
3217
3218    #[test]
3219    fn if_else_if_else() {
3220        let src = r#"rule R {
3221    when: X(v)
3222    ensures:
3223        if v < 10: Small()
3224        else if v < 100: Medium()
3225        else: Large()
3226}"#;
3227        let r = parse_ok(src);
3228        assert_eq!(r.diagnostics.len(), 0);
3229    }
3230
3231    // -- null coalescing and optional chaining --------------------------------
3232
3233    #[test]
3234    fn null_coalesce_and_optional_chain() {
3235        let src = "entity E { v: a?.b ?? fallback }";
3236        let r = parse_ok(src);
3237        assert_eq!(r.diagnostics.len(), 0);
3238        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3239        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3240        // Top-level should be NullCoalesce
3241        assert!(matches!(value, Expr::NullCoalesce { .. }));
3242    }
3243
3244    // -- generic types --------------------------------------------------------
3245
3246    #[test]
3247    fn generic_type_nested() {
3248        let src = "entity E { v: List<Set<String>> }";
3249        let r = parse_ok(src);
3250        assert_eq!(r.diagnostics.len(), 0);
3251    }
3252
3253    // -- set literal, list literal, object literal ----------------------------
3254
3255    #[test]
3256    fn collection_literals() {
3257        let src = r#"rule R {
3258    when: X()
3259    ensures:
3260        let s = {a, b, c}
3261        let o = {name: "test", count: 42}
3262        Done()
3263}"#;
3264        let r = parse_ok(src);
3265        assert_eq!(r.diagnostics.len(), 0);
3266    }
3267
3268    #[test]
3269    fn spec_list_literal_parses() {
3270        // `[...]` list literals are valid in any expression position.
3271        let src = r#"rule R {
3272    when: X()
3273    ensures:
3274        let l = [1, 2, 3]
3275        Done()
3276}"#;
3277        let r = parse_ok(src);
3278        assert_eq!(r.diagnostics.len(), 0, "list literal should parse cleanly");
3279    }
3280
3281    #[test]
3282    fn list_literal_ast_shape() {
3283        let r = parse_ok("default E e = { items: [1, 2, 3] }");
3284        let Decl::Default(def) = &r.module.declarations[0] else { panic!() };
3285        let Expr::ObjectLiteral { fields, .. } = &def.value else { panic!("expected object literal") };
3286        let Expr::ListLiteral { elements, .. } = &fields[0].value else {
3287            panic!("expected ListLiteral, got {:?}", fields[0].value)
3288        };
3289        assert_eq!(elements.len(), 3);
3290    }
3291
3292    #[test]
3293    fn empty_list_literal_parses() {
3294        let r = parse_ok("default E e = { items: [] }");
3295        assert_eq!(r.diagnostics.len(), 0);
3296    }
3297
3298    #[test]
3299    fn qualified_default_type_parses() {
3300        let r = parse_ok("use \"./p.allium\" as gp\n\ndefault gp/Policy my_policy = { id: \"x\" }");
3301        let def = r.module.declarations.iter().find_map(|d| match d {
3302            Decl::Default(def) => Some(def),
3303            _ => None,
3304        }).expect("default decl");
3305        assert_eq!(def.type_alias.as_ref().map(|a| a.name.as_str()), Some("gp"));
3306        assert_eq!(def.type_name.as_ref().map(|t| t.name.as_str()), Some("Policy"));
3307        assert_eq!(def.name.name, "my_policy");
3308    }
3309
3310    // -- given block ----------------------------------------------------------
3311
3312    #[test]
3313    fn given_block() {
3314        let src = "given { viewer: User\n    time: Timestamp }";
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::Given);
3319        assert!(b.name.is_none());
3320    }
3321
3322    // -- actor block ----------------------------------------------------------
3323
3324    #[test]
3325    fn actor_block() {
3326        let src = "actor Admin { identified_by: User where role = admin }";
3327        let r = parse_ok(src);
3328        assert_eq!(r.diagnostics.len(), 0);
3329        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3330        assert_eq!(b.kind, BlockKind::Actor);
3331    }
3332
3333    // -- join lookup ----------------------------------------------------------
3334
3335    #[test]
3336    fn join_lookup() {
3337        let src = "entity E { match: Other{field_a, field_b: value} }";
3338        let r = parse_ok(src);
3339        assert_eq!(r.diagnostics.len(), 0);
3340        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3341        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3342        assert!(matches!(value, Expr::JoinLookup { .. }));
3343    }
3344
3345    // -- in / not in with set literal -----------------------------------------
3346
3347    #[test]
3348    fn in_not_in_set() {
3349        let src = r#"rule R {
3350    when: X(s)
3351    requires: s in {a, b, c}
3352    requires: s not in {d, e}
3353    ensures: Done()
3354}"#;
3355        let r = parse_ok(src);
3356        assert_eq!(r.diagnostics.len(), 0);
3357    }
3358
3359    // -- comprehensive fixture file -------------------------------------------
3360
3361    #[test]
3362    fn comprehensive_fixture() {
3363        let src = include_str!("../tests/fixtures/comprehensive-edge-cases.allium");
3364        let r = parse(src);
3365        assert_eq!(
3366            r.diagnostics.len(),
3367            0,
3368            "expected no errors in comprehensive fixture, got: {:?}",
3369            r.diagnostics.iter().map(|d| &d.message).collect::<Vec<_>>(),
3370        );
3371        assert!(r.module.declarations.len() > 30, "expected many declarations");
3372    }
3373
3374    // -- error message quality ------------------------------------------------
3375
3376    #[test]
3377    fn error_expected_declaration() {
3378        let r = parse("-- allium: 1\n+ invalid");
3379        assert!(r.diagnostics.len() >= 1);
3380        let msg = &r.diagnostics[0].message;
3381        assert!(msg.contains("expected declaration"), "got: {msg}");
3382        assert!(msg.contains("entity"), "should list valid options, got: {msg}");
3383        assert!(msg.contains("rule"), "should list valid options, got: {msg}");
3384    }
3385
3386    #[test]
3387    fn error_expected_expression() {
3388        let r = parse("-- allium: 1\nentity E { v: }");
3389        assert!(r.diagnostics.len() >= 1);
3390        let msg = &r.diagnostics[0].message;
3391        assert!(msg.contains("expected expression"), "got: {msg}");
3392        assert!(msg.contains("identifier"), "should list valid starters, got: {msg}");
3393    }
3394
3395    #[test]
3396    fn error_expected_block_item() {
3397        let r = parse("-- allium: 1\nentity E { + }");
3398        assert!(r.diagnostics.len() >= 1);
3399        let msg = &r.diagnostics[0].message;
3400        assert!(msg.contains("expected block item"), "got: {msg}");
3401    }
3402
3403    #[test]
3404    fn error_expected_identifier() {
3405        let r = parse("-- allium: 1\nentity 123 {}");
3406        assert!(r.diagnostics.len() >= 1);
3407        let msg = &r.diagnostics[0].message;
3408        // Context-aware: says "entity name" not generic "identifier"
3409        assert!(msg.contains("expected entity name"), "got: {msg}");
3410        // Human-friendly: says "number" not "Number" or "TokenKind::Number"
3411        assert!(msg.contains("number"), "should say what was found, got: {msg}");
3412    }
3413
3414    #[test]
3415    fn error_missing_brace() {
3416        let r = parse("entity E {");
3417        assert!(r.diagnostics.len() >= 1);
3418        let msg = &r.diagnostics[0].message;
3419        assert!(msg.contains("expected"), "got: {msg}");
3420    }
3421
3422    #[test]
3423    fn error_recovery_multiple() {
3424        // Parser should recover and report multiple errors (on separate lines)
3425        let r = parse("entity E { + }\nentity F { - }");
3426        assert!(r.diagnostics.len() >= 2, "expected at least 2 errors, got {}", r.diagnostics.len());
3427    }
3428
3429    #[test]
3430    fn error_dedup_same_line() {
3431        // Multiple bad tokens on a single line should produce only one error
3432        let r = parse("-- allium: 1\n+ - * /");
3433        let errors: Vec<_> = r.diagnostics.iter()
3434            .filter(|d| d.severity == crate::diagnostic::Severity::Error)
3435            .collect();
3436        assert_eq!(errors.len(), 1, "expected 1 error for same-line bad tokens, got {}", errors.len());
3437    }
3438
3439    #[test]
3440    fn for_block() {
3441        let src = r#"rule R {
3442    when: X()
3443    for user in Users where user.active:
3444        ensures: Notified(user: user)
3445}"#;
3446        let r = parse_ok(src);
3447        assert_eq!(r.diagnostics.len(), 0);
3448        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3449        assert!(matches!(b.items[1].kind, BlockItemKind::ForBlock { .. }));
3450    }
3451
3452    #[test]
3453    fn for_expr() {
3454        let src = r#"rule R {
3455    when: X(project)
3456    ensures:
3457        let total = for task in project.tasks: task.effort
3458        Done(total: total)
3459}"#;
3460        let r = parse_ok(src);
3461        assert_eq!(r.diagnostics.len(), 0);
3462    }
3463
3464    #[test]
3465    fn for_where() {
3466        let src = r#"rule R {
3467    when: X()
3468    for item in Items where item.active:
3469        ensures: Processed(item: item)
3470}"#;
3471        let r = parse_ok(src);
3472        assert_eq!(r.diagnostics.len(), 0);
3473    }
3474
3475    #[test]
3476    fn spec_reject_for_with_filter() {
3477        // The spec uses `where` for iteration filtering; `with` is for
3478        // relationship declarations only.
3479        let src = r#"rule R {
3480    when: X()
3481    for slot in Slot with slot.role = reviewer:
3482        ensures: Reviewed(slot: slot)
3483}"#;
3484        let r = parse_ok(src);
3485        assert!(
3486            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3487            "expected error for `for ... with` (spec uses `where`), but parsed without errors"
3488        );
3489    }
3490
3491    #[test]
3492    fn block_level_if() {
3493        let src = r#"rule R {
3494    when: X(task)
3495    if task.priority = high:
3496        ensures: Escalated(task: task)
3497}"#;
3498        let r = parse_ok(src);
3499        assert_eq!(r.diagnostics.len(), 0);
3500        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3501        let BlockItemKind::IfBlock { branches, else_items } = &b.items[1].kind else {
3502            panic!("expected IfBlock, got {:?}", b.items[1].kind);
3503        };
3504        assert_eq!(branches.len(), 1);
3505        assert!(else_items.is_none());
3506    }
3507
3508    #[test]
3509    fn block_level_if_else() {
3510        let src = r#"rule R {
3511    when: X(score)
3512    if score > 80:
3513        ensures: High()
3514    else if score > 40:
3515        ensures: Medium()
3516    else:
3517        ensures: Low()
3518}"#;
3519        let r = parse_ok(src);
3520        assert_eq!(r.diagnostics.len(), 0);
3521        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3522        let BlockItemKind::IfBlock { branches, else_items } = &b.items[1].kind else {
3523            panic!("expected IfBlock, got {:?}", b.items[1].kind);
3524        };
3525        assert_eq!(branches.len(), 2);
3526        assert!(else_items.is_some());
3527    }
3528
3529    #[test]
3530    fn wildcard_type_parameter() {
3531        let src = "entity E { codec: Codec<*> }";
3532        let r = parse_ok(src);
3533        assert_eq!(r.diagnostics.len(), 0);
3534        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3535        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
3536        if let Expr::GenericType { args, .. } = value {
3537            assert_eq!(args.len(), 1);
3538            if let Expr::Ident(id) = &args[0] {
3539                assert_eq!(id.name, "*");
3540            } else {
3541                panic!("expected wildcard ident, got {:?}", args[0]);
3542            }
3543        } else {
3544            panic!("expected GenericType, got {:?}", value);
3545        }
3546    }
3547
3548    #[test]
3549    fn guidance_clause_comment_only_value_migration() {
3550        // Old `guidance:` colon form should emit a migration diagnostic
3551        let src = "-- allium: 1\nrule R {\n    ensures: Done()\n    guidance: -- just a comment\n}";
3552        let r = parse(src);
3553        assert!(
3554            r.diagnostics.iter().any(|d| d.message.contains("`guidance:` syntax was replaced")),
3555            "expected migration diagnostic, got: {:?}",
3556            r.diagnostics
3557        );
3558    }
3559
3560    #[test]
3561    fn spec_reject_for_expr_with_filter() {
3562        // Expression-level `for` also only accepts `where`, not `with`.
3563        let src = r#"rule R {
3564    when: X(project)
3565    ensures:
3566        let total = for task in project.tasks with task.active: task.effort
3567        Done(total: total)
3568}"#;
3569        let r = parse_ok(src);
3570        assert!(
3571            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3572            "expected error for `for ... with` in expression (spec uses `where`), but parsed without errors"
3573        );
3574    }
3575
3576    #[test]
3577    fn for_destructured_binding() {
3578        let src = r#"rule R {
3579    when: X()
3580    for (key, value) in Pairs where key != null:
3581        ensures: Processed(key: key, value: value)
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::ForBlock { binding, .. } = &b.items[1].kind else { panic!() };
3587        assert!(matches!(binding, ForBinding::Destructured(ids, _) if ids.len() == 2));
3588    }
3589
3590    #[test]
3591    fn dot_path_assignment() {
3592        let src = r#"entity Shard {
3593    ShardGroup.shard_cache: Shard with group = this
3594}"#;
3595        let r = parse_ok(src);
3596        assert_eq!(r.diagnostics.len(), 0);
3597        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
3598        let BlockItemKind::PathAssignment { path, .. } = &b.items[0].kind else {
3599            panic!("expected PathAssignment, got {:?}", b.items[0].kind);
3600        };
3601        assert!(matches!(path, Expr::MemberAccess { .. }));
3602    }
3603
3604    #[test]
3605    fn language_reference_fixture() {
3606        let src = include_str!("../tests/fixtures/language-reference-constructs.allium");
3607        let r = parse(src);
3608        let errors: Vec<_> = r.diagnostics.iter()
3609            .filter(|d| d.severity == Severity::Error)
3610            .collect();
3611        assert_eq!(
3612            errors.len(),
3613            0,
3614            "expected no errors in language-reference fixture, got: {:?}",
3615            errors.iter().map(|d| &d.message).collect::<Vec<_>>(),
3616        );
3617    }
3618
3619    // =====================================================================
3620    // V1 SPEC CONFORMANCE TESTS
3621    //
3622    // These tests verify that the parser conforms to the Allium V1 language
3623    // reference (docs/allium-v1-language-reference.md). Each test is tagged
3624    // with the finding number from the audit.
3625    //
3626    // Tests marked "should reject" are expected to FAIL until the parser
3627    // is updated to reject non-spec constructs.
3628    //
3629    // Tests marked "should parse" are expected to FAIL until the parser
3630    // is updated to handle spec-defined constructs.
3631    // =====================================================================
3632
3633    // -- Finding 1: spec uses `for`, not `for each` ---------------------------
3634
3635    #[test]
3636    fn spec_for_bare_form() {
3637        // The spec uses bare `for` exclusively. This must parse cleanly.
3638        let src = r#"rule ProcessDigests {
3639    when: schedule: DigestSchedule.next_run_at <= now
3640    for user in Users where notification_setting.digest_enabled:
3641        let settings = user.notification_setting
3642        ensures: DigestBatch.created(user: user)
3643}"#;
3644        let r = parse_ok(src);
3645        assert_eq!(r.diagnostics.len(), 0);
3646    }
3647
3648    #[test]
3649    fn spec_reject_for_each() {
3650        // `for each` is not in the spec. The parser should reject it.
3651        let src = r#"rule R {
3652    when: X()
3653    for each user in Users where user.active:
3654        ensures: Notified(user: user)
3655}"#;
3656        let r = parse_ok(src);
3657        assert!(
3658            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3659            "expected error for `for each` (not in spec), but parsed without errors"
3660        );
3661    }
3662
3663    // -- Finding 2: spec uses `=`, not `==` -----------------------------------
3664
3665    #[test]
3666    fn spec_reject_double_equals() {
3667        // The spec uses `=` for equality. `==` should not be accepted.
3668        let src = "rule R { when: X(a)\n    requires: a.status == active\n    ensures: Done() }";
3669        let r = parse_ok(src);
3670        assert!(
3671            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3672            "expected error for `==` (not in spec), but parsed without errors"
3673        );
3674    }
3675
3676    // -- Finding 3: `system` blocks are not in the spec -----------------------
3677
3678    #[test]
3679    fn spec_reject_system_block() {
3680        // `system` is not a declaration type in the V1 spec.
3681        let src = "system PaymentGateway {\n    timeout: 30.seconds\n}";
3682        let r = parse_ok(src);
3683        assert!(
3684            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3685            "expected error for `system` block (not in spec), but parsed without errors"
3686        );
3687    }
3688
3689    // -- Finding 6: `tags` clause is not in the spec --------------------------
3690
3691    #[test]
3692    fn spec_reject_tags_clause() {
3693        // `tags:` is not a clause keyword in the V1 spec.
3694        let src = r#"rule R {
3695    when: MigrationTriggered()
3696    tags: infrastructure, migration
3697    ensures: MigrationComplete()
3698}"#;
3699        let r = parse_ok(src);
3700        assert!(
3701            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3702            "expected error for `tags:` clause (not in spec), but parsed without errors"
3703        );
3704    }
3705
3706    // -- Finding 7: `includes`/`excludes` are not in the spec -----------------
3707
3708    #[test]
3709    fn spec_reject_includes_operator() {
3710        // The spec uses `x in collection`, not `collection includes x`.
3711        let src = r#"rule R {
3712    when: X(a, b)
3713    requires: a.items includes b
3714    ensures: Done()
3715}"#;
3716        let r = parse_ok(src);
3717        assert!(
3718            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3719            "expected error for `includes` operator (not in spec), but parsed without errors"
3720        );
3721    }
3722
3723    #[test]
3724    fn spec_reject_excludes_operator() {
3725        // The spec uses `x not in collection`, not `collection excludes x`.
3726        let src = r#"rule R {
3727    when: X(a, b)
3728    requires: a.items excludes b
3729    ensures: Done()
3730}"#;
3731        let r = parse_ok(src);
3732        assert!(
3733            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3734            "expected error for `excludes` operator (not in spec), but parsed without errors"
3735        );
3736    }
3737
3738    // -- Finding 8: range literals (`..`) are not in the spec -----------------
3739
3740    #[test]
3741    fn spec_reject_range_literal() {
3742        // The `..` range operator is not defined in the V1 spec.
3743        let src = r#"rule R {
3744    when: X(v)
3745    requires: v in [1..100]
3746    ensures: Done()
3747}"#;
3748        let r = parse_ok(src);
3749        assert!(
3750            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3751            "expected error for `..` range (not in spec), but parsed without errors"
3752        );
3753    }
3754
3755    // -- Finding 9: `within` is only for actors, not rules --------------------
3756
3757    #[test]
3758    fn spec_within_in_actor() {
3759        // The spec defines `within:` as an actor clause.
3760        let src = r#"actor WorkspaceAdmin {
3761    within: Workspace
3762    identified_by: User where role = admin
3763}"#;
3764        let r = parse_ok(src);
3765        assert_eq!(r.diagnostics.len(), 0, "within: in actor should parse cleanly");
3766    }
3767
3768    // -- Finding 10: `module` declaration is not in the spec ------------------
3769
3770    #[test]
3771    fn spec_reject_module_declaration() {
3772        // `module Name` is not a declaration in the V1 spec.
3773        let src = "module my_spec";
3774        let r = parse_ok(src);
3775        assert!(
3776            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3777            "expected error for `module` declaration (not in spec), but parsed without errors"
3778        );
3779    }
3780
3781    // -- Finding 11: `guidance` at module level is not in the spec ------------
3782
3783    #[test]
3784    fn spec_reject_module_level_guidance() {
3785        // The spec shows `guidance:` only as a surface clause.
3786        let src = r#"guidance: "All rules must be idempotent""#;
3787        let r = parse_ok(src);
3788        assert!(
3789            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3790            "expected error for module-level `guidance:` (not in spec), but parsed without errors"
3791        );
3792    }
3793
3794    // -- Finding 12: `guarantee`/`timeout` in rules is not in the spec --------
3795
3796    #[test]
3797    fn spec_guarantee_in_surface_migration() {
3798        // Old `guarantee:` colon form should emit a migration diagnostic
3799        let src = "-- allium: 1\nsurface S {\n    facing viewer: User\n    guarantee: DataIntegrity\n}";
3800        let r = parse(src);
3801        assert!(
3802            r.diagnostics.iter().any(|d| d.message.contains("`guarantee:` syntax was replaced")),
3803            "expected migration diagnostic, got: {:?}",
3804            r.diagnostics
3805        );
3806    }
3807
3808    #[test]
3809    fn spec_timeout_in_surface() {
3810        // The spec defines `timeout:` as a surface clause with rule name syntax.
3811        let src = r#"surface InvitationView {
3812    facing recipient: Candidate
3813    context invitation: ResourceInvitation where email = recipient.email
3814    timeout: InvitationExpires
3815}"#;
3816        let r = parse_ok(src);
3817        assert_eq!(r.diagnostics.len(), 0, "timeout: in surface should parse cleanly");
3818    }
3819
3820    #[test]
3821    fn spec_timeout_in_surface_with_when() {
3822        // The spec shows `timeout: RuleName when condition`.
3823        let src = r#"surface InvitationView {
3824    facing recipient: Candidate
3825    context invitation: ResourceInvitation where email = recipient.email
3826    timeout: InvitationExpires when invitation.expires_at <= now
3827}"#;
3828        let r = parse_ok(src);
3829        assert_eq!(r.diagnostics.len(), 0, "timeout: with when guard should parse cleanly");
3830    }
3831
3832    // -- Finding 15: suffix predicates are not in the spec --------------------
3833
3834    #[test]
3835    fn spec_reject_suffix_predicate() {
3836        // The spec does not define suffix predicate syntax like `starts_with`.
3837        let src = r#"rule R {
3838    when: X()
3839    requires: finding.code starts_with "allium."
3840    ensures: Done()
3841}"#;
3842        let r = parse_ok(src);
3843        assert!(
3844            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
3845            "expected error for suffix predicate (not in spec), but parsed without errors"
3846        );
3847    }
3848
3849    // -- Finding 17: `.add()`/`.remove()` are in the spec ---------------------
3850
3851    #[test]
3852    fn spec_add_remove_in_ensures() {
3853        // The spec documents `.add()` and `.remove()` as ensures-only mutations.
3854        // These parse as regular method calls, which is correct.
3855        let src = r#"rule R {
3856    when: AssignInterviewer(interview, new_interviewer)
3857    ensures:
3858        interview.interviewers.add(new_interviewer)
3859}"#;
3860        let r = parse_ok(src);
3861        assert_eq!(r.diagnostics.len(), 0, ".add() should parse cleanly");
3862    }
3863
3864    #[test]
3865    fn spec_remove_in_ensures() {
3866        let src = r#"rule R {
3867    when: RemoveInterviewer(interview, leaving)
3868    ensures:
3869        interview.interviewers.remove(leaving)
3870}"#;
3871        let r = parse_ok(src);
3872        assert_eq!(r.diagnostics.len(), 0, ".remove() should parse cleanly");
3873    }
3874
3875    // -- Finding 18: `.first`/`.last` are in the spec -------------------------
3876
3877    #[test]
3878    fn spec_first_last_access() {
3879        // The spec documents `.first` and `.last` for ordered collections.
3880        let src = "entity E { latest: attempts.last\n    earliest: attempts.first }";
3881        let r = parse_ok(src);
3882        assert_eq!(r.diagnostics.len(), 0, ".first/.last should parse cleanly");
3883    }
3884
3885    // -- Finding 19: set arithmetic is in the spec ----------------------------
3886
3887    #[test]
3888    fn spec_set_arithmetic() {
3889        // The spec documents `+` and `-` on collections as set arithmetic.
3890        let src = r#"entity Role {
3891    permissions: Set<String>
3892    inherited: Set<String>
3893    all_permissions: permissions + inherited
3894    removed: old_mentions - new_mentions
3895}"#;
3896        let r = parse_ok(src);
3897        assert_eq!(r.diagnostics.len(), 0, "set arithmetic should parse cleanly");
3898    }
3899
3900    // -- Finding 20: discard binding `_` is in the spec -----------------------
3901
3902    #[test]
3903    fn spec_discard_binding_in_trigger() {
3904        // The spec shows `when: _: LogProcessor.last_flush_check + ...`
3905        let src = r#"rule R {
3906    when: _: LogProcessor.last_flush_check <= now
3907    ensures: Flushed()
3908}"#;
3909        let r = parse_ok(src);
3910        assert_eq!(r.diagnostics.len(), 0, "discard binding _ in trigger should parse cleanly");
3911    }
3912
3913    #[test]
3914    fn spec_discard_in_trigger_params() {
3915        // The spec shows `when: SomeEvent(_, slot)`
3916        let src = r#"rule R {
3917    when: SomeEvent(_, slot)
3918    ensures: Processed(slot: slot)
3919}"#;
3920        let r = parse_ok(src);
3921        assert_eq!(r.diagnostics.len(), 0, "discard _ in trigger params should parse cleanly");
3922    }
3923
3924    #[test]
3925    fn spec_discard_in_for() {
3926        // The spec shows `for _ in items: Counted(batch)`
3927        let src = r#"rule R {
3928    when: X(items)
3929    ensures:
3930        for _ in items: Counted()
3931}"#;
3932        let r = parse_ok(src);
3933        assert_eq!(r.diagnostics.len(), 0, "discard _ in for should parse cleanly");
3934    }
3935
3936    // -- Finding 21: default with object literal is in the spec ---------------
3937
3938    #[test]
3939    fn spec_default_with_object_literal() {
3940        // The spec shows: default InterviewType all_in_one = { name: "All in one", duration: 75.minutes }
3941        let src = r#"default InterviewType all_in_one = { name: "All in one", duration: 75.minutes }"#;
3942        let r = parse_ok(src);
3943        assert_eq!(r.diagnostics.len(), 0, "default with object literal should parse cleanly");
3944    }
3945
3946    #[test]
3947    fn spec_default_multiline_object() {
3948        // The spec shows multi-line defaults with object literals.
3949        let src = r#"default Role viewer = {
3950    name: "viewer",
3951    permissions: { "documents.read" }
3952}"#;
3953        let r = parse_ok(src);
3954        assert_eq!(r.diagnostics.len(), 0, "multi-line default with object literal should parse cleanly");
3955    }
3956
3957    // -- Spec surface features: related, let, guarantee, timeout --------------
3958
3959    #[test]
3960    fn spec_surface_related_clause() {
3961        // The spec shows `related:` with surface references.
3962        let src = r#"surface InterviewerDashboard {
3963    facing viewer: Interviewer
3964    context assignment: SlotConfirmation where interviewer = viewer
3965    related: InterviewDetail(assignment.slot.interview) when assignment.slot.interview != null
3966}"#;
3967        let r = parse_ok(src);
3968        assert_eq!(r.diagnostics.len(), 0, "related: in surface should parse cleanly");
3969    }
3970
3971    #[test]
3972    fn spec_surface_let_binding() {
3973        // The spec shows `let` bindings inside surfaces.
3974        let src = r#"surface S {
3975    facing viewer: User
3976    let comments = Comments where parent = viewer
3977    exposes: CommentList
3978}"#;
3979        let r = parse_ok(src);
3980        assert_eq!(r.diagnostics.len(), 0, "let in surface should parse cleanly");
3981    }
3982
3983    #[test]
3984    fn spec_surface_multiline_context_where() {
3985        // The spec shows context with where on a continuation line.
3986        let src = r#"surface InterviewerPendingAssignments {
3987    facing viewer: Interviewer
3988    context assignment: InterviewAssignment
3989        where interviewer = viewer and status = pending
3990    exposes: AssignmentList
3991}"#;
3992        let r = parse_ok(src);
3993        assert_eq!(r.diagnostics.len(), 0, "multi-line context where should parse cleanly");
3994    }
3995
3996    // -- Spec: `for` inside surfaces ------------------------------------------
3997
3998    #[test]
3999    fn spec_for_in_surface_provides() {
4000        // The spec shows for iteration inside surface provides.
4001        let src = r#"surface TaskBoard {
4002    facing viewer: User
4003    for task in Task where task.assignee = viewer:
4004        provides: CompleteTask(viewer, task) when task.status = in_progress
4005    exposes: KanbanBoard
4006}"#;
4007        let r = parse_ok(src);
4008        assert_eq!(r.diagnostics.len(), 0, "for in surface provides should parse cleanly");
4009    }
4010
4011    // -- Spec: `use` without alias --------------------------------------------
4012
4013    #[test]
4014    fn spec_use_without_alias() {
4015        // The spec shows `use` both with and without `as alias`.
4016        let src = r#"use "github.com/specs/notifications/def456""#;
4017        let r = parse_ok(src);
4018        assert_eq!(r.diagnostics.len(), 0, "use without alias should parse cleanly");
4019    }
4020
4021    // -- Spec: empty external entity ------------------------------------------
4022
4023    #[test]
4024    fn spec_empty_external_entity() {
4025        // The spec shows external entities with empty bodies as type placeholders.
4026        let src = "external entity Commentable {}";
4027        let r = parse_ok(src);
4028        assert_eq!(r.diagnostics.len(), 0, "empty external entity should parse cleanly");
4029    }
4030
4031    // -- Spec: multi-line provides block in surface ---------------------------
4032
4033    #[test]
4034    fn spec_surface_multiline_provides() {
4035        // The spec shows provides as a multi-line block.
4036        let src = r#"surface ProjectDashboard {
4037    facing viewer: ProjectManager
4038    context project: Project where owner = viewer
4039    provides:
4040        CreateTask(viewer, project) when project.status = active
4041        ArchiveProject(viewer, project) when project.tasks.all(t => t.status = completed)
4042    exposes: TaskList
4043}"#;
4044        let r = parse_ok(src);
4045        assert_eq!(r.diagnostics.len(), 0, "multi-line provides should parse cleanly");
4046    }
4047
4048    // -- Spec: multi-line exposes block in surface ----------------------------
4049
4050    #[test]
4051    fn spec_surface_multiline_exposes() {
4052        // The spec shows exposes as a multi-line block.
4053        let src = r#"surface InterviewerDashboard {
4054    facing viewer: Interviewer
4055    context assignment: SlotConfirmation where interviewer = viewer
4056    exposes:
4057        assignment.slot.time
4058        assignment.status
4059}"#;
4060        let r = parse_ok(src);
4061        assert_eq!(r.diagnostics.len(), 0, "multi-line exposes should parse cleanly");
4062    }
4063
4064    // =====================================================================
4065    // COVERAGE GAP TESTS
4066    //
4067    // Dedicated unit tests for spec constructs that previously only had
4068    // fixture-file coverage.
4069    // =====================================================================
4070
4071    // -- Composite or-triggers ------------------------------------------------
4072
4073    #[test]
4074    fn composite_or_trigger() {
4075        let src = r#"rule R {
4076    when: EventA(x) or EventB(x) or EventC(x)
4077    ensures: Done()
4078}"#;
4079        let r = parse_ok(src);
4080        assert_eq!(r.diagnostics.len(), 0);
4081        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4082        let BlockItemKind::Clause { keyword, value } = &b.items[0].kind else { panic!() };
4083        assert_eq!(keyword, "when");
4084        // Top-level should be LogicalOp(Or) wrapping another Or
4085        let Expr::LogicalOp { op, left, .. } = value else {
4086            panic!("expected LogicalOp, got {value:?}");
4087        };
4088        assert_eq!(*op, LogicalOp::Or);
4089        assert!(matches!(left.as_ref(), Expr::LogicalOp { op: LogicalOp::Or, .. }));
4090    }
4091
4092    // -- Value type declaration -----------------------------------------------
4093
4094    #[test]
4095    fn value_type_declaration() {
4096        let src = r#"value TimeRange {
4097    start: Timestamp
4098    end: Timestamp
4099    duration: end - start
4100}"#;
4101        let r = parse_ok(src);
4102        assert_eq!(r.diagnostics.len(), 0);
4103        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4104        assert_eq!(b.kind, BlockKind::Value);
4105        assert_eq!(b.name.as_ref().unwrap().name, "TimeRange");
4106        assert_eq!(b.items.len(), 3);
4107    }
4108
4109    // -- Qualified config block -----------------------------------------------
4110
4111    #[test]
4112    fn qualified_config_block() {
4113        let src = r#"use "github.com/specs/oauth/abc123" as oauth
4114oauth/config {
4115    session_duration: Duration = 24.hours
4116}"#;
4117        let r = parse_ok(src);
4118        assert_eq!(r.diagnostics.len(), 0);
4119        assert_eq!(r.module.declarations.len(), 2);
4120    }
4121
4122    // -- String interpolation -------------------------------------------------
4123
4124    #[test]
4125    fn string_interpolation_parts() {
4126        let src = r#"rule R {
4127    when: X(name, action)
4128    ensures: Log.created(message: "User {name} did {action}")
4129}"#;
4130        let r = parse_ok(src);
4131        assert_eq!(r.diagnostics.len(), 0);
4132        // Dig into the message arg to verify interpolation parts
4133        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4134        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4135        let Expr::Call { args, .. } = value else { panic!() };
4136        let CallArg::Named(arg) = &args[0] else { panic!() };
4137        let Expr::StringLiteral(s) = &arg.value else { panic!() };
4138        assert_eq!(s.parts.len(), 4, "expected 4 string parts: text, interp, text, interp");
4139        assert!(matches!(&s.parts[0], StringPart::Text(t) if t == "User "));
4140        assert!(matches!(&s.parts[1], StringPart::Interpolation(id) if id.name == "name"));
4141        assert!(matches!(&s.parts[2], StringPart::Text(t) if t == " did "));
4142        assert!(matches!(&s.parts[3], StringPart::Interpolation(id) if id.name == "action"));
4143    }
4144
4145    // -- `this` keyword as expression -----------------------------------------
4146
4147    #[test]
4148    fn this_keyword_expression() {
4149        // `Item with parent = this` parses as With(Item, Eq(parent, this))
4150        // because `with` binds looser than `=`, capturing the full predicate.
4151        let src = "entity E { items: Item with parent = this }";
4152        let r = parse_ok(src);
4153        assert_eq!(r.diagnostics.len(), 0);
4154        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4155        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4156        let Expr::With { predicate, .. } = value else {
4157            panic!("expected With, got {value:?}");
4158        };
4159        let Expr::Comparison { op, right, .. } = predicate.as_ref() else {
4160            panic!("expected Comparison in with predicate, got {predicate:?}");
4161        };
4162        assert_eq!(*op, ComparisonOp::Eq);
4163        assert!(matches!(right.as_ref(), Expr::This { .. }));
4164    }
4165
4166    // -- `not` prefix operator (standalone) -----------------------------------
4167
4168    #[test]
4169    fn not_prefix_standalone() {
4170        let src = r#"rule R {
4171    when: X(user)
4172    requires: not user.is_locked
4173    ensures: Done()
4174}"#;
4175        let r = parse_ok(src);
4176        assert_eq!(r.diagnostics.len(), 0);
4177        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4178        let BlockItemKind::Clause { keyword, value } = &b.items[1].kind else { panic!() };
4179        assert_eq!(keyword, "requires");
4180        assert!(matches!(value, Expr::Not { .. }));
4181    }
4182
4183    // -- Unary minus ----------------------------------------------------------
4184
4185    #[test]
4186    fn unary_minus() {
4187        let src = "entity E { offset: -1 }";
4188        let r = parse_ok(src);
4189        assert_eq!(r.diagnostics.len(), 0);
4190        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4191        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4192        assert!(matches!(value, Expr::BinaryOp { op: BinaryOp::Sub, .. }
4193                        | Expr::NumberLiteral { .. }), "expected negation, got {value:?}");
4194    }
4195
4196    // -- Parenthesised expression grouping ------------------------------------
4197
4198    #[test]
4199    fn parenthesised_expression() {
4200        let src = "entity E { v: (a + b) * c }";
4201        let r = parse_ok(src);
4202        assert_eq!(r.diagnostics.len(), 0);
4203        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4204        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4205        // Top level should be Mul, with left being Add (grouped by parens)
4206        let Expr::BinaryOp { op, left, .. } = value else {
4207            panic!("expected BinaryOp, got {value:?}");
4208        };
4209        assert_eq!(*op, BinaryOp::Mul);
4210        assert!(matches!(left.as_ref(), Expr::BinaryOp { op: BinaryOp::Add, .. }));
4211    }
4212
4213    // -- Boolean literals -----------------------------------------------------
4214
4215    #[test]
4216    fn boolean_literals() {
4217        let src = r#"rule R {
4218    when: X(item)
4219    ensures:
4220        item.active = true
4221        item.deleted = false
4222}"#;
4223        let r = parse_ok(src);
4224        assert_eq!(r.diagnostics.len(), 0);
4225    }
4226
4227    // -- `null` literal -------------------------------------------------------
4228
4229    #[test]
4230    fn null_literal() {
4231        let src = "entity E { v: parent ?? null }";
4232        let r = parse_ok(src);
4233        assert_eq!(r.diagnostics.len(), 0);
4234        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4235        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4236        let Expr::NullCoalesce { right, .. } = value else { panic!() };
4237        assert!(matches!(right.as_ref(), Expr::Null { .. }));
4238    }
4239
4240    // -- Empty set literal ----------------------------------------------------
4241
4242    #[test]
4243    fn empty_set_literal() {
4244        let src = "entity E { tags: Set<String>\n    default_tags: {} }";
4245        let r = parse_ok(src);
4246        assert_eq!(r.diagnostics.len(), 0);
4247        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4248        let BlockItemKind::Assignment { value, .. } = &b.items[1].kind else { panic!() };
4249        let Expr::SetLiteral { elements, .. } = value else { panic!("expected SetLiteral, got {value:?}") };
4250        assert!(elements.is_empty());
4251    }
4252
4253    // =====================================================================
4254    // PRE-1.0 COVERAGE TESTS
4255    //
4256    // Additional tests addressing gaps identified during the pre-release
4257    // review: parameterised derived values, operator precedence matrix,
4258    // indentation-based multi-line detection, and set/object literal
4259    // disambiguation.
4260    // =====================================================================
4261
4262    // -- Parameterised derived values (ParamAssignment) --------------------
4263
4264    #[test]
4265    fn param_assignment_single() {
4266        let src = "entity Plan { can_use(feature): feature in features }";
4267        let r = parse_ok(src);
4268        assert_eq!(r.diagnostics.len(), 0);
4269        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4270        let BlockItemKind::ParamAssignment { name, params, value } = &b.items[0].kind else {
4271            panic!("expected ParamAssignment, got {:?}", b.items[0].kind);
4272        };
4273        assert_eq!(name.name, "can_use");
4274        assert_eq!(params.len(), 1);
4275        assert_eq!(params[0].name, "feature");
4276        assert!(matches!(value, Expr::In { .. }));
4277    }
4278
4279    #[test]
4280    fn param_assignment_multiple() {
4281        let src = "entity E { distance(x, y): (x * x + y * y) }";
4282        let r = parse_ok(src);
4283        assert_eq!(r.diagnostics.len(), 0);
4284        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4285        let BlockItemKind::ParamAssignment { name, params, .. } = &b.items[0].kind else {
4286            panic!("expected ParamAssignment, got {:?}", b.items[0].kind);
4287        };
4288        assert_eq!(name.name, "distance");
4289        assert_eq!(params.len(), 2);
4290        assert_eq!(params[0].name, "x");
4291        assert_eq!(params[1].name, "y");
4292    }
4293
4294    #[test]
4295    fn param_assignment_simple_expression() {
4296        let src = "entity Task { remaining_effort(total): total - effort }";
4297        let r = parse_ok(src);
4298        assert_eq!(r.diagnostics.len(), 0);
4299        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4300        let BlockItemKind::ParamAssignment { name, params, value } = &b.items[0].kind else {
4301            panic!("expected ParamAssignment, got {:?}", b.items[0].kind);
4302        };
4303        assert_eq!(name.name, "remaining_effort");
4304        assert_eq!(params.len(), 1);
4305        assert!(matches!(value, Expr::BinaryOp { op: BinaryOp::Sub, .. }));
4306    }
4307
4308    // -- Operator precedence matrix ----------------------------------------
4309
4310    #[test]
4311    fn precedence_logical_and_binds_tighter_than_or() {
4312        // `a or b and c` => Or(a, And(b, c))
4313        let src = "entity E { v: a or b and c }";
4314        let r = parse_ok(src);
4315        assert_eq!(r.diagnostics.len(), 0);
4316        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4317        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4318        let Expr::LogicalOp { op, right, .. } = value else {
4319            panic!("expected LogicalOp, got {value:?}");
4320        };
4321        assert_eq!(*op, LogicalOp::Or);
4322        assert!(matches!(right.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4323    }
4324
4325    #[test]
4326    fn precedence_comparison_binds_tighter_than_and() {
4327        // `a = b and c != d` => And(Eq(a, b), NotEq(c, d))
4328        let src = "entity E { v: a = b and c != d }";
4329        let r = parse_ok(src);
4330        assert_eq!(r.diagnostics.len(), 0);
4331        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4332        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4333        let Expr::LogicalOp { op, left, right, .. } = value else {
4334            panic!("expected LogicalOp, got {value:?}");
4335        };
4336        assert_eq!(*op, LogicalOp::And);
4337        assert!(matches!(left.as_ref(), Expr::Comparison { op: ComparisonOp::Eq, .. }));
4338        assert!(matches!(right.as_ref(), Expr::Comparison { op: ComparisonOp::NotEq, .. }));
4339    }
4340
4341    #[test]
4342    fn precedence_arithmetic_binds_tighter_than_comparison() {
4343        // `a + b > c * d` => Gt(Add(a, b), Mul(c, d))
4344        let src = "entity E { v: a + b > c * d }";
4345        let r = parse_ok(src);
4346        assert_eq!(r.diagnostics.len(), 0);
4347        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4348        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4349        let Expr::Comparison { op, left, right, .. } = value else {
4350            panic!("expected Comparison, got {value:?}");
4351        };
4352        assert_eq!(*op, ComparisonOp::Gt);
4353        assert!(matches!(left.as_ref(), Expr::BinaryOp { op: BinaryOp::Add, .. }));
4354        assert!(matches!(right.as_ref(), Expr::BinaryOp { op: BinaryOp::Mul, .. }));
4355    }
4356
4357    #[test]
4358    fn precedence_null_coalesce_binds_tighter_than_comparison() {
4359        // `a ?? b = c` => Eq(NullCoalesce(a, b), c)
4360        let src = "entity E { v: a ?? b = c }";
4361        let r = parse_ok(src);
4362        assert_eq!(r.diagnostics.len(), 0);
4363        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4364        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4365        let Expr::Comparison { op, left, .. } = value else {
4366            panic!("expected Comparison, got {value:?}");
4367        };
4368        assert_eq!(*op, ComparisonOp::Eq);
4369        assert!(matches!(left.as_ref(), Expr::NullCoalesce { .. }));
4370    }
4371
4372    #[test]
4373    fn precedence_not_binds_tighter_than_and() {
4374        // `not a and b` => And(Not(a), b)
4375        let src = r#"rule R {
4376    when: X(a, b)
4377    requires: not a and b
4378    ensures: Done()
4379}"#;
4380        let r = parse_ok(src);
4381        assert_eq!(r.diagnostics.len(), 0);
4382        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4383        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4384        let Expr::LogicalOp { op, left, .. } = value else {
4385            panic!("expected LogicalOp, got {value:?}");
4386        };
4387        assert_eq!(*op, LogicalOp::And);
4388        assert!(matches!(left.as_ref(), Expr::Not { .. }));
4389    }
4390
4391    #[test]
4392    fn precedence_where_captures_full_condition() {
4393        // `items where status = active` => Where(items, Eq(status, active))
4394        // where (BP 7) binds looser than comparison (BP 30), so the full
4395        // condition `status = active` is captured as the where predicate.
4396        let src = "entity E { v: items where status = active }";
4397        let r = parse_ok(src);
4398        assert_eq!(r.diagnostics.len(), 0);
4399        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4400        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4401        let Expr::Where { condition, .. } = value else {
4402            panic!("expected Where, got {value:?}");
4403        };
4404        assert!(matches!(condition.as_ref(), Expr::Comparison { op: ComparisonOp::Eq, .. }));
4405    }
4406
4407    #[test]
4408    fn precedence_where_captures_and_or_conditions() {
4409        // `items where status = active and count > 0` =>
4410        //   Where(items, And(Eq(status, active), Gt(count, 0)))
4411        let src = "entity E { v: items where status = active and count > 0 }";
4412        let r = parse_ok(src);
4413        assert_eq!(r.diagnostics.len(), 0);
4414        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4415        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4416        let Expr::Where { condition, .. } = value else {
4417            panic!("expected Where, got {value:?}");
4418        };
4419        assert!(matches!(condition.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4420    }
4421
4422    #[test]
4423    fn precedence_projection_applies_to_where_result() {
4424        // `items where status = confirmed -> interviewer` =>
4425        //   ProjectionMap(Where(items, Eq(status, confirmed)), interviewer)
4426        let src = "entity E { v: items where status = confirmed -> interviewer }";
4427        let r = parse_ok(src);
4428        assert_eq!(r.diagnostics.len(), 0);
4429        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4430        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4431        let Expr::ProjectionMap { source, field, .. } = value else {
4432            panic!("expected ProjectionMap, got {value:?}");
4433        };
4434        assert_eq!(field.name, "interviewer");
4435        assert!(matches!(source.as_ref(), Expr::Where { .. }));
4436    }
4437
4438    #[test]
4439    fn precedence_lambda_binds_loosest() {
4440        // `items.any(i => i.active and i.valid)` => Lambda(i, And(active, valid))
4441        let src = "entity E { v: items.any(i => i.active and i.valid) }";
4442        let r = parse_ok(src);
4443        assert_eq!(r.diagnostics.len(), 0);
4444        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4445        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4446        let Expr::Call { args, .. } = value else { panic!() };
4447        let CallArg::Positional(Expr::Lambda { body, .. }) = &args[0] else { panic!() };
4448        assert!(matches!(body.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4449    }
4450
4451    #[test]
4452    fn precedence_in_binds_at_comparison_level() {
4453        // `x in {a, b} and y not in {c}` => And(In(x, {a,b}), NotIn(y, {c}))
4454        let src = r#"rule R {
4455    when: X(x, y)
4456    requires: x in {a, b} and y not in {c}
4457    ensures: Done()
4458}"#;
4459        let r = parse_ok(src);
4460        assert_eq!(r.diagnostics.len(), 0);
4461        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4462        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4463        let Expr::LogicalOp { op, left, right, .. } = value else {
4464            panic!("expected LogicalOp, got {value:?}");
4465        };
4466        assert_eq!(*op, LogicalOp::And);
4467        assert!(matches!(left.as_ref(), Expr::In { .. }));
4468        assert!(matches!(right.as_ref(), Expr::NotIn { .. }));
4469    }
4470
4471    // -- Multi-line clause value detection ----------------------------------
4472
4473    #[test]
4474    fn multiline_ensures_block() {
4475        let src = r#"rule R {
4476    when: X(doc)
4477    ensures:
4478        doc.status = published
4479        Notification.created(to: doc.author)
4480}"#;
4481        let r = parse_ok(src);
4482        assert_eq!(r.diagnostics.len(), 0);
4483        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4484        let BlockItemKind::Clause { keyword, value } = &b.items[1].kind else { panic!() };
4485        assert_eq!(keyword, "ensures");
4486        let Expr::Block { items, .. } = value else {
4487            panic!("expected Block for multi-line ensures, got {value:?}");
4488        };
4489        assert_eq!(items.len(), 2);
4490    }
4491
4492    #[test]
4493    fn singleline_ensures_value() {
4494        let src = r#"rule R {
4495    when: X(doc)
4496    ensures: doc.status = published
4497}"#;
4498        let r = parse_ok(src);
4499        assert_eq!(r.diagnostics.len(), 0);
4500        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4501        let BlockItemKind::Clause { keyword, value } = &b.items[1].kind else { panic!() };
4502        assert_eq!(keyword, "ensures");
4503        // Single-line value should NOT be wrapped in a Block
4504        assert!(!matches!(value, Expr::Block { .. }), "single-line ensures should not be Block");
4505    }
4506
4507    #[test]
4508    fn multiline_requires_with_continuation() {
4509        let src = r#"rule R {
4510    when: X(a)
4511    requires:
4512        a.count >= 2
4513        or a.items.any(i => i.can_solo)
4514    ensures: Done()
4515}"#;
4516        let r = parse_ok(src);
4517        assert_eq!(r.diagnostics.len(), 0);
4518    }
4519
4520    // -- Set vs object literal disambiguation ------------------------------
4521
4522    #[test]
4523    fn object_literal_single_field() {
4524        let src = r#"rule R {
4525    when: X()
4526    ensures:
4527        let o = {name: "test"}
4528        Done()
4529}"#;
4530        let r = parse_ok(src);
4531        assert_eq!(r.diagnostics.len(), 0);
4532        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4533        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4534        let Expr::Block { items, .. } = value else { panic!() };
4535        let Expr::LetExpr { value: let_val, .. } = &items[0] else { panic!() };
4536        assert!(matches!(let_val.as_ref(), Expr::ObjectLiteral { .. }));
4537    }
4538
4539    #[test]
4540    fn set_literal_single_element() {
4541        let src = r#"rule R {
4542    when: X()
4543    ensures:
4544        let s = {active}
4545        Done()
4546}"#;
4547        let r = parse_ok(src);
4548        assert_eq!(r.diagnostics.len(), 0);
4549        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4550        let BlockItemKind::Clause { value, .. } = &b.items[1].kind else { panic!() };
4551        let Expr::Block { items, .. } = value else { panic!() };
4552        let Expr::LetExpr { value: let_val, .. } = &items[0] else { panic!() };
4553        assert!(matches!(let_val.as_ref(), Expr::SetLiteral { .. }),
4554            "bare {{ident}} should parse as set literal, got {:?}", let_val);
4555    }
4556
4557    // -- Lambda variations -------------------------------------------------
4558
4559    #[test]
4560    fn lambda_with_chained_access() {
4561        let src = "entity E { v: items.all(t => t.item.status = active) }";
4562        let r = parse_ok(src);
4563        assert_eq!(r.diagnostics.len(), 0);
4564    }
4565
4566    #[test]
4567    fn nested_lambda() {
4568        let src = "entity E { v: groups.any(g => g.items.all(i => i.valid)) }";
4569        let r = parse_ok(src);
4570        assert_eq!(r.diagnostics.len(), 0);
4571    }
4572
4573    // -- Qualified name variations -----------------------------------------
4574
4575    #[test]
4576    fn qualified_name_with_member_access() {
4577        let src = "entity E { v: shared/Validator.check }";
4578        let r = parse_ok(src);
4579        assert_eq!(r.diagnostics.len(), 0);
4580        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4581        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4582        let Expr::MemberAccess { object, field, .. } = value else {
4583            panic!("expected MemberAccess, got {value:?}");
4584        };
4585        assert!(matches!(object.as_ref(), Expr::QualifiedName(_)));
4586        assert_eq!(field.name, "check");
4587    }
4588
4589    #[test]
4590    fn qualified_name_in_call() {
4591        let src = r#"rule R {
4592    when: X(item)
4593    requires: shared/Validator.check(item: item)
4594    ensures: Done()
4595}"#;
4596        let r = parse_ok(src);
4597        assert_eq!(r.diagnostics.len(), 0);
4598    }
4599
4600    // -- Nested control flow -----------------------------------------------
4601
4602    #[test]
4603    fn nested_if_inside_for() {
4604        let src = r#"rule R {
4605    when: X()
4606    for user in Users where user.active:
4607        if user.role = admin:
4608            ensures: AdminNotified(user: user)
4609        else:
4610            ensures: UserNotified(user: user)
4611}"#;
4612        let r = parse_ok(src);
4613        assert_eq!(r.diagnostics.len(), 0);
4614        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4615        let BlockItemKind::ForBlock { items, .. } = &b.items[1].kind else { panic!() };
4616        assert!(matches!(items[0].kind, BlockItemKind::IfBlock { .. }));
4617    }
4618
4619    #[test]
4620    fn for_with_let_before_ensures() {
4621        let src = r#"rule R {
4622    when: schedule: DigestSchedule.next_run_at <= now
4623    for user in Users where user.active:
4624        let pending = user.tasks where status = pending
4625        ensures: DigestEmail.created(to: user.email, tasks: pending)
4626}"#;
4627        let r = parse_ok(src);
4628        assert_eq!(r.diagnostics.len(), 0);
4629        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4630        let BlockItemKind::ForBlock { items, .. } = &b.items[1].kind else { panic!() };
4631        assert_eq!(items.len(), 2, "for body should have let + ensures");
4632        assert!(matches!(items[0].kind, BlockItemKind::Let { .. }));
4633        assert!(matches!(items[1].kind, BlockItemKind::Clause { .. }));
4634    }
4635
4636    // -- Join lookup variations --------------------------------------------
4637
4638    #[test]
4639    fn join_lookup_all_unnamed() {
4640        let src = "entity E { match: Other{a, b, c} }";
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(), 3);
4647        assert!(fields.iter().all(|f| f.value.is_none()));
4648    }
4649
4650    #[test]
4651    fn join_lookup_all_named() {
4652        let src = "entity E { match: Membership{user: actor, workspace: ws} }";
4653        let r = parse_ok(src);
4654        assert_eq!(r.diagnostics.len(), 0);
4655        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4656        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4657        let Expr::JoinLookup { fields, .. } = value else { panic!() };
4658        assert_eq!(fields.len(), 2);
4659        assert!(fields.iter().all(|f| f.value.is_some()));
4660    }
4661
4662    #[test]
4663    fn join_lookup_in_requires() {
4664        let src = r#"rule R {
4665    when: X(user, workspace)
4666    requires: exists WorkspaceMembership{user: user, workspace: workspace}
4667    ensures: Done()
4668}"#;
4669        let r = parse_ok(src);
4670        assert_eq!(r.diagnostics.len(), 0);
4671    }
4672
4673    #[test]
4674    fn join_lookup_negated_in_requires() {
4675        let src = r#"rule R {
4676    when: X(email)
4677    requires: not exists User{email: email}
4678    ensures: Done()
4679}"#;
4680        let r = parse_ok(src);
4681        assert_eq!(r.diagnostics.len(), 0);
4682    }
4683
4684    // -----------------------------------------------------------------------
4685    // ALP-11: implies operator
4686    // -----------------------------------------------------------------------
4687
4688    #[test]
4689    fn implies_basic() {
4690        let src = "rule R { requires: a implies b }";
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::Clause { value, .. } = &b.items[0].kind else { panic!() };
4695        let Expr::LogicalOp { op, .. } = value else { panic!("expected LogicalOp, got {value:?}") };
4696        assert_eq!(*op, LogicalOp::Implies);
4697    }
4698
4699    #[test]
4700    fn implies_precedence_and_binds_tighter() {
4701        // `a and b implies c` → `(a and b) implies c`
4702        let src = "rule R { v: a and b implies c }";
4703        let r = parse_ok(src);
4704        assert_eq!(r.diagnostics.len(), 0);
4705        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4706        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4707        let Expr::LogicalOp { op, left, .. } = value else { panic!() };
4708        assert_eq!(*op, LogicalOp::Implies);
4709        assert!(matches!(left.as_ref(), Expr::LogicalOp { op: LogicalOp::And, .. }));
4710    }
4711
4712    #[test]
4713    fn implies_precedence_or_binds_tighter() {
4714        // `a or b implies c` → `(a or b) implies c`
4715        let src = "rule R { v: a or b implies c }";
4716        let r = parse_ok(src);
4717        assert_eq!(r.diagnostics.len(), 0);
4718        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4719        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4720        let Expr::LogicalOp { op, left, .. } = value else { panic!() };
4721        assert_eq!(*op, LogicalOp::Implies);
4722        assert!(matches!(left.as_ref(), Expr::LogicalOp { op: LogicalOp::Or, .. }));
4723    }
4724
4725    #[test]
4726    fn implies_precedence_implies_above_or() {
4727        // `a implies b or c` → `a implies (b or c)`
4728        let src = "rule R { v: a implies b or c }";
4729        let r = parse_ok(src);
4730        assert_eq!(r.diagnostics.len(), 0);
4731        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4732        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4733        let Expr::LogicalOp { op, right, .. } = value else { panic!() };
4734        assert_eq!(*op, LogicalOp::Implies);
4735        assert!(matches!(right.as_ref(), Expr::LogicalOp { op: LogicalOp::Or, .. }));
4736    }
4737
4738    #[test]
4739    fn implies_precedence_not_binds_tighter() {
4740        // `not a implies b` → `(not a) implies b`
4741        let src = "rule R { v: not a implies b }";
4742        let r = parse_ok(src);
4743        assert_eq!(r.diagnostics.len(), 0);
4744        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4745        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4746        let Expr::LogicalOp { op, left, .. } = value else { panic!() };
4747        assert_eq!(*op, LogicalOp::Implies);
4748        assert!(matches!(left.as_ref(), Expr::Not { .. }));
4749    }
4750
4751    #[test]
4752    fn implies_right_associative() {
4753        // `a implies b implies c` → `a implies (b implies c)`
4754        let src = "rule R { v: a implies b implies c }";
4755        let r = parse_ok(src);
4756        assert_eq!(r.diagnostics.len(), 0);
4757        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4758        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4759        let Expr::LogicalOp { op, right, .. } = value else { panic!() };
4760        assert_eq!(*op, LogicalOp::Implies);
4761        assert!(matches!(right.as_ref(), Expr::LogicalOp { op: LogicalOp::Implies, .. }));
4762    }
4763
4764    #[test]
4765    fn implies_is_keyword_parsed_as_operator() {
4766        // `implies` is a keyword — in infix position it's the operator, not an ident.
4767        // `a implies b` parses as LogicalOp, never as two adjacent identifiers.
4768        let src = "entity E { v: a implies b }";
4769        let r = parse_ok(src);
4770        assert_eq!(r.diagnostics.len(), 0);
4771        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4772        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4773        assert!(matches!(value, Expr::LogicalOp { op: LogicalOp::Implies, .. }));
4774    }
4775
4776    #[test]
4777    fn implies_in_ensures() {
4778        let src = r#"rule R {
4779    when: X()
4780    ensures: a implies b
4781}"#;
4782        let r = parse_ok(src);
4783        assert_eq!(r.diagnostics.len(), 0);
4784    }
4785
4786    #[test]
4787    fn implies_in_derived_value() {
4788        let src = "entity E { v: a implies b }";
4789        let r = parse_ok(src);
4790        assert_eq!(r.diagnostics.len(), 0);
4791        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4792        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
4793        assert!(matches!(value, Expr::LogicalOp { op: LogicalOp::Implies, .. }));
4794    }
4795
4796    // -----------------------------------------------------------------------
4797    // ALP-7: guidance clause ordering
4798    // -----------------------------------------------------------------------
4799
4800    #[test]
4801    fn guidance_ordering_tests_removed() {
4802        // Guidance ordering validation was moved to the structural validator.
4803        // The old `guidance:` colon form now emits migration diagnostics.
4804        // See guidance_colon_form_migration and annotation_guidance_in_rule tests.
4805    }
4806
4807    // -----------------------------------------------------------------------
4808    // ALP-9: contract declarations
4809    // -----------------------------------------------------------------------
4810
4811    #[test]
4812    fn contract_signatures_only() {
4813        let src = r#"contract Auditable {
4814    last_modified_by: Actor
4815    last_modified_at: Timestamp
4816}"#;
4817        let r = parse_ok(src);
4818        assert_eq!(r.diagnostics.len(), 0);
4819        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4820        assert_eq!(b.kind, BlockKind::Contract);
4821        assert_eq!(b.name.as_ref().unwrap().name, "Auditable");
4822        assert_eq!(b.items.len(), 2);
4823    }
4824
4825    #[test]
4826    fn contract_with_annotations() {
4827        let src = r#"contract Versioned {
4828    version: Integer
4829    @invariant Monotonic
4830        -- versions must increase
4831    @guidance
4832        -- use semantic versioning
4833}"#;
4834        let r = parse_ok(src);
4835        assert_eq!(r.diagnostics.len(), 0);
4836        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4837        assert_eq!(b.kind, BlockKind::Contract);
4838        assert_eq!(b.items.len(), 3);
4839    }
4840
4841    #[test]
4842    fn contract_with_any_type() {
4843        let src = r#"contract Identifiable {
4844    id: Any
4845}"#;
4846        let r = parse_ok(src);
4847        assert_eq!(r.diagnostics.len(), 0);
4848    }
4849
4850    #[test]
4851    fn contract_lowercase_name_rejected() {
4852        let src = "-- allium: 1\ncontract bad {}";
4853        let r = parse(src);
4854        assert!(
4855            r.diagnostics.iter().any(|d| d.message.contains("uppercase")),
4856            "expected uppercase error, got: {:?}",
4857            r.diagnostics
4858        );
4859    }
4860
4861    #[test]
4862    fn contract_colon_body_rejected() {
4863        let src = "-- allium: 1\ncontract Bad: something";
4864        let r = parse(src);
4865        assert!(
4866            r.diagnostics.iter().any(|d| d.message.contains("braces")),
4867            "expected braces error, got: {:?}",
4868            r.diagnostics
4869        );
4870    }
4871
4872    // -----------------------------------------------------------------------
4873    // ALP-15: contracts clause
4874    // -----------------------------------------------------------------------
4875
4876    #[test]
4877    fn contracts_clause_single_demands() {
4878        let src = "surface S {\n    contracts:\n        demands Auditable\n}";
4879        let r = parse_ok(src);
4880        assert_eq!(r.diagnostics.len(), 0);
4881        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4882        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4883            panic!("expected ContractsClause, got {:?}", b.items[0].kind)
4884        };
4885        assert_eq!(entries.len(), 1);
4886        assert!(matches!(entries[0].direction, ContractDirection::Demands));
4887        assert_eq!(entries[0].name.name, "Auditable");
4888    }
4889
4890    #[test]
4891    fn contracts_clause_single_fulfils() {
4892        let src = "surface S {\n    contracts:\n        fulfils EventSubmitter\n}";
4893        let r = parse_ok(src);
4894        assert_eq!(r.diagnostics.len(), 0);
4895        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4896        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4897            panic!("expected ContractsClause")
4898        };
4899        assert_eq!(entries.len(), 1);
4900        assert!(matches!(entries[0].direction, ContractDirection::Fulfils));
4901        assert_eq!(entries[0].name.name, "EventSubmitter");
4902    }
4903
4904    #[test]
4905    fn contracts_clause_qualified_fulfils() {
4906        let src = "surface S {\n    contracts:\n        fulfils base/MyContract\n}";
4907        let r = parse_ok(src);
4908        assert_eq!(r.diagnostics.len(), 0);
4909        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4910        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4911            panic!("expected ContractsClause, got {:?}", b.items[0].kind)
4912        };
4913        assert_eq!(entries.len(), 1);
4914        assert!(matches!(entries[0].direction, ContractDirection::Fulfils));
4915        assert_eq!(entries[0].qualifier.as_deref(), Some("base"));
4916        assert_eq!(entries[0].name.name, "MyContract");
4917    }
4918
4919    #[test]
4920    fn contracts_clause_qualified_demands() {
4921        let src = "surface S {\n    contracts:\n        demands base/MyContract\n        fulfils Local\n}";
4922        let r = parse_ok(src);
4923        assert_eq!(r.diagnostics.len(), 0);
4924        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4925        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4926            panic!("expected ContractsClause")
4927        };
4928        assert_eq!(entries.len(), 2);
4929        assert!(matches!(entries[0].direction, ContractDirection::Demands));
4930        assert_eq!(entries[0].qualifier.as_deref(), Some("base"));
4931        assert_eq!(entries[0].name.name, "MyContract");
4932        assert_eq!(entries[1].qualifier, None);
4933        assert_eq!(entries[1].name.name, "Local");
4934    }
4935
4936    #[test]
4937    fn contracts_clause_qualified_missing_name_errors() {
4938        let src = "surface S {\n    contracts:\n        fulfils base/\n}";
4939        let r = parse(src);
4940        assert!(
4941            r.diagnostics
4942                .iter()
4943                .any(|d| d.message.contains("contract name after '/'")),
4944            "expected an error about the missing name, got {:?}",
4945            r.diagnostics
4946        );
4947    }
4948
4949    #[test]
4950    fn contracts_clause_mixed() {
4951        let src = "surface S {\n    contracts:\n        demands Auditable\n        fulfils EventSubmitter\n}";
4952        let r = parse_ok(src);
4953        assert_eq!(r.diagnostics.len(), 0);
4954        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4955        let BlockItemKind::ContractsClause { entries } = &b.items[0].kind else {
4956            panic!("expected ContractsClause")
4957        };
4958        assert_eq!(entries.len(), 2);
4959        assert!(matches!(entries[0].direction, ContractDirection::Demands));
4960        assert!(matches!(entries[1].direction, ContractDirection::Fulfils));
4961    }
4962
4963    #[test]
4964    fn contracts_with_other_clauses() {
4965        let src = r#"surface S {
4966    facing user: User
4967    contracts:
4968        demands Auditable
4969    exposes:
4970        user.name
4971}"#;
4972        let r = parse_ok(src);
4973        assert_eq!(r.diagnostics.len(), 0);
4974        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4975        assert_eq!(b.items.len(), 3);
4976    }
4977
4978    #[test]
4979    fn contracts_only_surface() {
4980        let src = "surface S {\n    contracts:\n        demands Foo\n        fulfils Bar\n}";
4981        let r = parse_ok(src);
4982        assert_eq!(r.diagnostics.len(), 0);
4983        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
4984        assert_eq!(b.items.len(), 1);
4985    }
4986
4987    #[test]
4988    fn contracts_empty_rejected() {
4989        let src = "-- allium: 1\nsurface S {\n    contracts:\n}";
4990        let r = parse(src);
4991        assert!(
4992            r.diagnostics.iter().any(|d| d.message.contains("Empty `contracts:`")),
4993            "expected empty contracts error, got: {:?}",
4994            r.diagnostics
4995        );
4996    }
4997
4998    #[test]
4999    fn contracts_inline_block_rejected() {
5000        let src = "-- allium: 1\nsurface S {\n    contracts:\n        demands Foo {\n        }\n}";
5001        let r = parse(src);
5002        assert!(
5003            r.diagnostics.iter().any(|d| d.message.contains("Inline contract blocks")),
5004            "expected inline block error, got: {:?}",
5005            r.diagnostics
5006        );
5007    }
5008
5009    #[test]
5010    fn contracts_unknown_direction_rejected() {
5011        let src = "-- allium: 1\nsurface S {\n    contracts:\n        requires Foo\n}";
5012        let r = parse(src);
5013        assert!(
5014            r.diagnostics.iter().any(|d| d.message.contains("Unknown direction")),
5015            "expected unknown direction error, got: {:?}",
5016            r.diagnostics
5017        );
5018    }
5019
5020    // -----------------------------------------------------------------------
5021    // ALP-16: annotations
5022    // -----------------------------------------------------------------------
5023
5024    #[test]
5025    fn annotation_invariant() {
5026        let src = "contract C {\n    @invariant Determinism\n        -- all evaluations must be deterministic\n}";
5027        let r = parse_ok(src);
5028        assert_eq!(r.diagnostics.len(), 0);
5029        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5030        let BlockItemKind::Annotation(ann) = &b.items[0].kind else {
5031            panic!("expected Annotation, got {:?}", b.items[0].kind)
5032        };
5033        assert!(matches!(ann.kind, AnnotationKind::Invariant));
5034        assert_eq!(ann.name.as_ref().unwrap().name, "Determinism");
5035        assert_eq!(ann.body.len(), 1);
5036        assert_eq!(ann.body[0], "all evaluations must be deterministic");
5037    }
5038
5039    #[test]
5040    fn annotation_multiple_invariants() {
5041        let src = "contract C {\n    @invariant A\n        -- first\n    @invariant B\n        -- second\n}";
5042        let r = parse_ok(src);
5043        assert_eq!(r.diagnostics.len(), 0);
5044        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5045        assert_eq!(b.items.len(), 2);
5046        assert!(matches!(&b.items[0].kind, BlockItemKind::Annotation(_)));
5047        assert!(matches!(&b.items[1].kind, BlockItemKind::Annotation(_)));
5048    }
5049
5050    #[test]
5051    fn annotation_invariant_then_guidance() {
5052        let src = "contract C {\n    @invariant Safety\n        -- must be safe\n    @guidance\n        -- implementation notes\n}";
5053        let r = parse_ok(src);
5054        assert_eq!(r.diagnostics.len(), 0);
5055        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5056        assert_eq!(b.items.len(), 2);
5057    }
5058
5059    #[test]
5060    fn annotation_guidance_in_rule() {
5061        let src = "rule R {\n    when: Event.created\n    ensures: something\n    @guidance\n        -- do it this way\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 last = b.items.last().unwrap();
5066        let BlockItemKind::Annotation(ann) = &last.kind else { panic!() };
5067        assert!(matches!(ann.kind, AnnotationKind::Guidance));
5068        assert!(ann.name.is_none());
5069    }
5070
5071    #[test]
5072    fn annotation_guarantee() {
5073        let src = "surface S {\n    @guarantee ResponseTime\n        -- must respond within 100ms\n}";
5074        let r = parse_ok(src);
5075        assert_eq!(r.diagnostics.len(), 0);
5076        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5077        let BlockItemKind::Annotation(ann) = &b.items[0].kind else { panic!() };
5078        assert!(matches!(ann.kind, AnnotationKind::Guarantee));
5079        assert_eq!(ann.name.as_ref().unwrap().name, "ResponseTime");
5080    }
5081
5082    #[test]
5083    fn annotation_guarantee_then_guidance() {
5084        let src = "surface S {\n    @guarantee Fast\n        -- sub-second\n    @guidance\n        -- cache aggressively\n}";
5085        let r = parse_ok(src);
5086        assert_eq!(r.diagnostics.len(), 0);
5087        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5088        assert_eq!(b.items.len(), 2);
5089    }
5090
5091    #[test]
5092    fn annotation_contracts_guarantee_guidance() {
5093        let src = r#"surface S {
5094    contracts:
5095        demands Auditable
5096    @guarantee ResponseTime
5097        -- fast
5098    @guidance
5099        -- notes
5100}"#;
5101        let r = parse_ok(src);
5102        assert_eq!(r.diagnostics.len(), 0);
5103        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5104        assert_eq!(b.items.len(), 3);
5105    }
5106
5107    #[test]
5108    fn annotation_multiline_body() {
5109        let src = "contract C {\n    @invariant Multi\n        -- line one\n        -- line two\n        -- line three\n}";
5110        let r = parse_ok(src);
5111        assert_eq!(r.diagnostics.len(), 0);
5112        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5113        let BlockItemKind::Annotation(ann) = &b.items[0].kind else { panic!() };
5114        assert_eq!(ann.body.len(), 3);
5115        assert_eq!(ann.body[0], "line one");
5116        assert_eq!(ann.body[2], "line three");
5117    }
5118
5119    #[test]
5120    fn annotation_empty_body_rejected() {
5121        let src = "-- allium: 1\ncontract C {\n    @invariant NoBody\n}";
5122        let r = parse(src);
5123        assert!(
5124            r.diagnostics.iter().any(|d| d.message.contains("at least one indented comment line")),
5125            "expected empty body error, got: {:?}",
5126            r.diagnostics
5127        );
5128    }
5129
5130    #[test]
5131    fn annotation_unknown_keyword_rejected() {
5132        let src = "-- allium: 1\ncontract C {\n    @note Something\n        -- text\n}";
5133        let r = parse(src);
5134        assert!(
5135            r.diagnostics.iter().any(|d| d.message.contains("Unknown annotation")),
5136            "expected unknown annotation error, got: {:?}",
5137            r.diagnostics
5138        );
5139    }
5140
5141    #[test]
5142    fn expression_invariant_still_works() {
5143        let src = r#"entity E {
5144    status: pending | active
5145    invariant AllValid {
5146        this.status = active
5147    }
5148}"#;
5149        let r = parse_ok(src);
5150        assert_eq!(r.diagnostics.len(), 0);
5151        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5152        // Find the invariant block item
5153        let inv = b.items.iter().find(|i| matches!(&i.kind, BlockItemKind::InvariantBlock { .. }));
5154        assert!(inv.is_some(), "expression-bearing invariant should still parse");
5155    }
5156
5157    #[test]
5158    fn invariant_colon_form_migration() {
5159        let src = "-- allium: 1\ncontract C {\n    invariant: SomeName\n}";
5160        let r = parse(src);
5161        assert!(
5162            r.diagnostics.iter().any(|d| d.message.contains("`invariant:` syntax was replaced")),
5163            "expected migration diagnostic, got: {:?}",
5164            r.diagnostics
5165        );
5166    }
5167
5168    #[test]
5169    fn guidance_colon_form_migration() {
5170        let src = "-- allium: 1\nrule R {\n    when: Event.created\n    ensures: something\n    guidance: \"do it\"\n}";
5171        let r = parse(src);
5172        assert!(
5173            r.diagnostics.iter().any(|d| d.message.contains("`guidance:` syntax was replaced")),
5174            "expected migration diagnostic, got: {:?}",
5175            r.diagnostics
5176        );
5177    }
5178
5179    #[test]
5180    fn guarantee_colon_form_migration() {
5181        let src = "-- allium: 1\nsurface S {\n    guarantee: \"fast\"\n}";
5182        let r = parse(src);
5183        assert!(
5184            r.diagnostics.iter().any(|d| d.message.contains("`guarantee:` syntax was replaced")),
5185            "expected migration diagnostic, got: {:?}",
5186            r.diagnostics
5187        );
5188    }
5189
5190    #[test]
5191    fn annotation_guidance_with_name_rejected() {
5192        let src = "-- allium: 1\ncontract C {\n    @guidance Named\n        -- text\n}";
5193        let r = parse(src);
5194        assert!(
5195            r.diagnostics.iter().any(|d| d.message.contains("does not take a name")),
5196            "expected guidance name error, got: {:?}",
5197            r.diagnostics
5198        );
5199    }
5200
5201    // -----------------------------------------------------------------------
5202    // ALP-11 part 2: expression-bearing invariants
5203    // -----------------------------------------------------------------------
5204
5205    #[test]
5206    fn invariant_top_level_simple() {
5207        let src = r#"invariant PositiveBalance {
5208    this.balance > 0
5209}"#;
5210        let r = parse_ok(src);
5211        assert_eq!(r.diagnostics.len(), 0);
5212        let Decl::Invariant(inv) = &r.module.declarations[0] else {
5213            panic!("expected Invariant, got {:?}", r.module.declarations[0])
5214        };
5215        assert_eq!(inv.name.name, "PositiveBalance");
5216    }
5217
5218    #[test]
5219    fn invariant_top_level_for_quantifier() {
5220        let src = r#"invariant AllPositive {
5221    for item in items: item.value > 0
5222}"#;
5223        let r = parse_ok(src);
5224        assert_eq!(r.diagnostics.len(), 0);
5225        let Decl::Invariant(inv) = &r.module.declarations[0] else { panic!() };
5226        assert!(matches!(inv.body, Expr::For { .. }));
5227    }
5228
5229    #[test]
5230    fn invariant_top_level_nested_for() {
5231        let src = r#"invariant NestedFor {
5232    for a in items: for b in a.children: b.valid = true
5233}"#;
5234        let r = parse_ok(src);
5235        assert_eq!(r.diagnostics.len(), 0);
5236    }
5237
5238    #[test]
5239    fn invariant_top_level_implies() {
5240        let src = r#"invariant ImpliesTest {
5241    this.active implies this.balance > 0
5242}"#;
5243        let r = parse_ok(src);
5244        assert_eq!(r.diagnostics.len(), 0);
5245        let Decl::Invariant(inv) = &r.module.declarations[0] else { panic!() };
5246        assert!(matches!(inv.body, Expr::LogicalOp { op: LogicalOp::Implies, .. }));
5247    }
5248
5249    #[test]
5250    fn invariant_top_level_let_binding() {
5251        let src = r#"invariant WithLet {
5252    let total = this.items.count()
5253    total > 0
5254}"#;
5255        let r = parse_ok(src);
5256        assert_eq!(r.diagnostics.len(), 0);
5257    }
5258
5259    #[test]
5260    fn invariant_top_level_collection_ops() {
5261        let src = r#"invariant CollectionOps {
5262    this.items where active = true
5263}"#;
5264        let r = parse_ok(src);
5265        assert_eq!(r.diagnostics.len(), 0);
5266    }
5267
5268    #[test]
5269    fn invariant_top_level_exists() {
5270        let src = r#"invariant ExistsCheck {
5271    exists this.primary_contact
5272}"#;
5273        let r = parse_ok(src);
5274        assert_eq!(r.diagnostics.len(), 0);
5275    }
5276
5277    #[test]
5278    fn invariant_top_level_not_exists() {
5279        let src = r#"invariant NotExistsCheck {
5280    not exists this.deleted_at
5281}"#;
5282        let r = parse_ok(src);
5283        assert_eq!(r.diagnostics.len(), 0);
5284    }
5285
5286    #[test]
5287    fn invariant_top_level_optional_navigation() {
5288        let src = r#"invariant OptionalNav {
5289    this.owner?.email ?? "none" != "none"
5290}"#;
5291        let r = parse_ok(src);
5292        assert_eq!(r.diagnostics.len(), 0);
5293    }
5294
5295    #[test]
5296    fn invariant_top_level_lowercase_rejected() {
5297        let src = "-- allium: 1\ninvariant bad { true }";
5298        let r = parse(src);
5299        assert!(
5300            r.diagnostics.iter().any(|d| d.message.contains("uppercase")),
5301            "expected uppercase error, got: {:?}",
5302            r.diagnostics
5303        );
5304    }
5305
5306    #[test]
5307    fn invariant_entity_level() {
5308        let src = r#"entity Account {
5309    balance: Decimal
5310    invariant NonNegative { this.balance >= 0 }
5311}"#;
5312        let r = parse_ok(src);
5313        assert_eq!(r.diagnostics.len(), 0);
5314        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5315        let BlockItemKind::InvariantBlock { name, body: _ } = &b.items[1].kind else {
5316            panic!("expected InvariantBlock, got {:?}", b.items[1].kind)
5317        };
5318        assert_eq!(name.name, "NonNegative");
5319    }
5320
5321    #[test]
5322    fn invariant_entity_level_this_ref() {
5323        let src = r#"entity Order {
5324    total: Decimal
5325    invariant PositiveTotal { this.total > 0 }
5326}"#;
5327        let r = parse_ok(src);
5328        assert_eq!(r.diagnostics.len(), 0);
5329    }
5330
5331    #[test]
5332    fn invariant_entity_level_implies() {
5333        let src = r#"entity Subscription {
5334    active: Boolean
5335    balance: Decimal
5336    invariant ActiveMeansPositive { this.active implies this.balance > 0 }
5337}"#;
5338        let r = parse_ok(src);
5339        assert_eq!(r.diagnostics.len(), 0);
5340    }
5341
5342    #[test]
5343    fn invariant_entity_level_lowercase_rejected() {
5344        let src = "-- allium: 1\nentity E { invariant bad { true } }";
5345        let r = parse(src);
5346        assert!(
5347            r.diagnostics.iter().any(|d| d.message.contains("uppercase")),
5348            "expected uppercase error, got: {:?}",
5349            r.diagnostics
5350        );
5351    }
5352
5353    #[test]
5354    fn invariant_colon_form_in_entity_migration() {
5355        // Old `invariant:` colon form should emit migration diagnostic
5356        let src = "-- allium: 1\nentity E {\n    invariant: -- must be valid\n}";
5357        let r = parse(src);
5358        assert!(
5359            r.diagnostics.iter().any(|d| d.message.contains("`invariant:` syntax was replaced")),
5360            "expected migration diagnostic, got: {:?}",
5361            r.diagnostics
5362        );
5363    }
5364
5365    #[test]
5366    fn invariant_top_level_colon_rejected() {
5367        // Colon-delimited body at top level is wrong syntax
5368        let src = "-- allium: 1\ninvariant Bad: some text";
5369        let r = parse(src);
5370        assert!(
5371            r.diagnostics.iter().any(|d| d.severity == Severity::Error),
5372            "expected error for colon-delimited invariant at top level, got: {:?}",
5373            r.diagnostics
5374        );
5375    }
5376
5377    #[test]
5378    fn invariant_same_name_different_scopes() {
5379        // Top-level and entity-level invariants with same name are valid (parser doesn't check)
5380        let src = r#"invariant SameName { true }
5381entity E {
5382    invariant SameName { true }
5383}"#;
5384        let r = parse_ok(src);
5385        assert_eq!(r.diagnostics.len(), 0);
5386    }
5387
5388    // -----------------------------------------------------------------------
5389    // ALP-10: cross-module config references
5390    // -----------------------------------------------------------------------
5391
5392    #[test]
5393    fn config_qualified_reference() {
5394        // `core/config.max_batch_size` should parse as MemberAccess(QualifiedName, field)
5395        let src = r#"config {
5396    param: Integer = core/config.max_batch_size
5397}"#;
5398        let r = parse_ok(src);
5399        assert_eq!(r.diagnostics.len(), 0);
5400    }
5401
5402    #[test]
5403    fn config_multiple_qualified_refs() {
5404        let src = r#"config {
5405    param_a: Integer = core/config.max_batch_size
5406    param_b: Duration = core/config.default_delay
5407}"#;
5408        let r = parse_ok(src);
5409        assert_eq!(r.diagnostics.len(), 0);
5410    }
5411
5412    #[test]
5413    fn config_qualified_ref_with_type() {
5414        let src = r#"config {
5415    publish_delay: Duration = core/config.default_delay
5416}"#;
5417        let r = parse_ok(src);
5418        assert_eq!(r.diagnostics.len(), 0);
5419    }
5420
5421    #[test]
5422    fn config_qualified_chain() {
5423        // Parameter with qualified default that itself could have a qualified default
5424        let src = r#"config {
5425    first: Integer = core/config.base
5426    second: Integer = first
5427}"#;
5428        let r = parse_ok(src);
5429        assert_eq!(r.diagnostics.len(), 0);
5430    }
5431
5432    #[test]
5433    fn config_renamed_param_with_qualified_ref() {
5434        let src = r#"config {
5435    my_timeout: Duration = core/config.base_timeout
5436}"#;
5437        let r = parse_ok(src);
5438        assert_eq!(r.diagnostics.len(), 0);
5439    }
5440
5441    // -----------------------------------------------------------------------
5442    // ALP-13: expression-form config defaults
5443    // -----------------------------------------------------------------------
5444
5445    #[test]
5446    fn config_default_arithmetic() {
5447        let src = r#"config {
5448    param: Integer = other_param + 1
5449}"#;
5450        let r = parse_ok(src);
5451        assert_eq!(r.diagnostics.len(), 0);
5452    }
5453
5454    #[test]
5455    fn config_default_qualified_arithmetic() {
5456        let src = r#"config {
5457    param: Duration = core/config.timeout * 2
5458}"#;
5459        let r = parse_ok(src);
5460        assert_eq!(r.diagnostics.len(), 0);
5461    }
5462
5463    #[test]
5464    fn config_default_parenthesised() {
5465        let src = r#"config {
5466    param: Integer = (base + 1) * factor
5467}"#;
5468        let r = parse_ok(src);
5469        assert_eq!(r.diagnostics.len(), 0);
5470    }
5471
5472    #[test]
5473    fn config_default_two_qualified_refs() {
5474        let src = r#"config {
5475    param: Duration = core/config.a + core/config.b
5476}"#;
5477        let r = parse_ok(src);
5478        assert_eq!(r.diagnostics.len(), 0);
5479    }
5480
5481    #[test]
5482    fn config_default_literal_only() {
5483        let src = r#"config {
5484    param: Integer = 5
5485}"#;
5486        let r = parse_ok(src);
5487        assert_eq!(r.diagnostics.len(), 0);
5488    }
5489
5490    #[test]
5491    fn config_default_decimal_literal() {
5492        let src = r#"config {
5493    param: Decimal = price * 1.5
5494}"#;
5495        let r = parse_ok(src);
5496        assert_eq!(r.diagnostics.len(), 0);
5497    }
5498
5499    #[test]
5500    fn config_default_mixed_operators() {
5501        let src = r#"config {
5502    param: Duration = timeout * 2 + 1.minute
5503}"#;
5504        let r = parse_ok(src);
5505        assert_eq!(r.diagnostics.len(), 0);
5506    }
5507
5508    #[test]
5509    fn config_default_operator_precedence() {
5510        // a + b * c should be Add(a, Mul(b, c))
5511        let src = r#"config {
5512    param: Integer = a + b * c
5513}"#;
5514        let r = parse_ok(src);
5515        assert_eq!(r.diagnostics.len(), 0);
5516    }
5517
5518    // -----------------------------------------------------------------------
5519    // Version 2 marker
5520    // -----------------------------------------------------------------------
5521
5522    #[test]
5523    fn version_2_accepted() {
5524        let r = parse("-- allium: 2\nentity User {}");
5525        assert_eq!(r.module.version, Some(2));
5526        assert_eq!(r.diagnostics.len(), 0);
5527    }
5528
5529    #[test]
5530    fn version_99_still_rejected() {
5531        let r = parse("-- allium: 99\nentity User {}");
5532        assert!(r.diagnostics.iter().any(|d|
5533            d.severity == Severity::Error && d.message.contains("unsupported")
5534        ));
5535    }
5536
5537    #[test]
5538    fn contract_typed_signature() {
5539        let src = r#"contract Codec {
5540    serialize: (value: Any) -> ByteArray
5541}"#;
5542        let r = parse_ok(src);
5543        assert_eq!(r.diagnostics.len(), 0);
5544        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5545        assert_eq!(b.kind, BlockKind::Contract);
5546        let BlockItemKind::Assignment { name, value } = &b.items[0].kind else { panic!() };
5547        assert_eq!(name.name, "serialize");
5548        assert!(matches!(value, Expr::ProjectionMap { .. }));
5549    }
5550
5551    #[test]
5552    fn contract_multi_param_signature() {
5553        let src = r#"contract Codec {
5554    serialize: (value: Any, format: String) -> ByteArray
5555}"#;
5556        let r = parse_ok(src);
5557        assert_eq!(r.diagnostics.len(), 0);
5558    }
5559
5560    #[test]
5561    fn comma_separated_entity_fields() {
5562        let src = "entity Point { x: Decimal, y: Decimal }";
5563        let r = parse_ok(src);
5564        assert_eq!(r.diagnostics.len(), 0);
5565        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5566        assert_eq!(b.items.len(), 2);
5567        assert!(matches!(&b.items[0].kind, BlockItemKind::Assignment { name, .. } if name.name == "x"));
5568        assert!(matches!(&b.items[1].kind, BlockItemKind::Assignment { name, .. } if name.name == "y"));
5569    }
5570
5571    #[test]
5572    fn comma_separated_value_fields() {
5573        let src = "value Coord { x: Integer, y: Integer }";
5574        let r = parse_ok(src);
5575        assert_eq!(r.diagnostics.len(), 0);
5576        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5577        assert_eq!(b.items.len(), 2);
5578    }
5579
5580    // -----------------------------------------------------------------------
5581    // Version 3: transitions, produces, consumes
5582    // -----------------------------------------------------------------------
5583
5584    #[test]
5585    fn version_3_accepted() {
5586        let r = parse("-- allium: 3\nentity User {}");
5587        assert_eq!(r.module.version, Some(3));
5588        assert_eq!(r.diagnostics.len(), 0);
5589    }
5590
5591    #[test]
5592    fn transitions_block_basic() {
5593        let src = r#"-- allium: 3
5594entity Order {
5595    status: pending | confirmed | shipped | delivered | cancelled
5596
5597    transitions status {
5598        pending -> confirmed
5599        confirmed -> shipped
5600        shipped -> delivered
5601        pending -> cancelled
5602        confirmed -> cancelled
5603        terminal: delivered, cancelled
5604    }
5605}"#;
5606        let r = parse(src);
5607        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5608        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5609        // items[0] is the status field, items[1] is the transitions block
5610        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5611            panic!("expected TransitionsBlock, got {:?}", b.items[1].kind)
5612        };
5613        assert_eq!(graph.field.name, "status");
5614        assert_eq!(graph.edges.len(), 5);
5615        assert_eq!(graph.edges[0].from.name, "pending");
5616        assert_eq!(graph.edges[0].to.name, "confirmed");
5617        assert_eq!(graph.terminal.len(), 2);
5618        assert_eq!(graph.terminal[0].name, "delivered");
5619        assert_eq!(graph.terminal[1].name, "cancelled");
5620    }
5621
5622    #[test]
5623    fn transitions_block_no_terminal() {
5624        let src = r#"-- allium: 3
5625entity Task {
5626    status: open | closed
5627    transitions status {
5628        open -> closed
5629    }
5630}"#;
5631        let r = parse(src);
5632        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5633        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5634        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5635            panic!("expected TransitionsBlock")
5636        };
5637        assert_eq!(graph.edges.len(), 1);
5638        assert!(graph.terminal.is_empty());
5639    }
5640
5641    #[test]
5642    fn produces_emits_migration_warning() {
5643        let src = r#"-- allium: 3
5644rule ShipOrder {
5645    when: ShipOrder(order, tracking)
5646    requires: order.status = picking
5647    produces: tracking_number, shipped_at
5648    ensures: order.status = shipped
5649}"#;
5650        let r = parse(src);
5651        let warnings: Vec<_> = r.diagnostics.iter()
5652            .filter(|d| d.severity == Severity::Warning)
5653            .collect();
5654        assert!(
5655            warnings.iter().any(|d| d.message.contains("`produces:` clauses are removed")),
5656            "expected migration warning for produces, got: {:?}", warnings
5657        );
5658    }
5659
5660    #[test]
5661    fn consumes_emits_migration_warning() {
5662        let src = r#"-- allium: 3
5663rule ReadOrder {
5664    when: Check(order)
5665    consumes: warehouse_assignment
5666    ensures: order.verified = true
5667}"#;
5668        let r = parse(src);
5669        let warnings: Vec<_> = r.diagnostics.iter()
5670            .filter(|d| d.severity == Severity::Warning)
5671            .collect();
5672        assert!(
5673            warnings.iter().any(|d| d.message.contains("`consumes:` clauses are removed")),
5674            "expected migration warning for consumes, got: {:?}", warnings
5675        );
5676    }
5677
5678    #[test]
5679    fn when_clause_on_field() {
5680        let src = r#"-- allium: 3
5681entity Order {
5682    status: pending | shipped | delivered
5683    tracking_number: String when status = shipped | delivered
5684    transitions status {
5685        pending -> shipped
5686        shipped -> delivered
5687        terminal: delivered
5688    }
5689}"#;
5690        let r = parse(src);
5691        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5692        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5693        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5694        let field_with_when = b.items.iter().find(|i| matches!(&i.kind, BlockItemKind::FieldWithWhen { .. }));
5695        assert!(field_with_when.is_some(), "expected FieldWithWhen item");
5696        if let BlockItemKind::FieldWithWhen { name, when_clause, .. } = &field_with_when.unwrap().kind {
5697            assert_eq!(name.name, "tracking_number");
5698            assert_eq!(when_clause.status_field.name, "status");
5699            assert_eq!(when_clause.qualifying_states.len(), 2);
5700            assert_eq!(when_clause.qualifying_states[0].name, "shipped");
5701            assert_eq!(when_clause.qualifying_states[1].name, "delivered");
5702        }
5703    }
5704
5705    #[test]
5706    fn when_clause_single_state() {
5707        let src = r#"-- allium: 3
5708entity Order {
5709    status: active | cancelled
5710    cancelled_at: Timestamp when status = cancelled
5711    transitions status {
5712        active -> cancelled
5713        terminal: cancelled
5714    }
5715}"#;
5716        let r = parse(src);
5717        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5718        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5719        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5720        if let BlockItemKind::FieldWithWhen { name, when_clause, .. } = &b.items[1].kind {
5721            assert_eq!(name.name, "cancelled_at");
5722            assert_eq!(when_clause.qualifying_states.len(), 1);
5723            assert_eq!(when_clause.qualifying_states[0].name, "cancelled");
5724        } else {
5725            panic!("expected FieldWithWhen, got {:?}", b.items[1].kind);
5726        }
5727    }
5728
5729    #[test]
5730    fn when_clause_with_optional() {
5731        let src = r#"-- allium: 3
5732entity Order {
5733    status: active | cancelled
5734    notes: String? when status = cancelled
5735    transitions status {
5736        active -> cancelled
5737        terminal: cancelled
5738    }
5739}"#;
5740        let r = parse(src);
5741        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
5742        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
5743        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5744        if let BlockItemKind::FieldWithWhen { name, value, when_clause } = &b.items[1].kind {
5745            assert_eq!(name.name, "notes");
5746            assert!(matches!(value, Expr::TypeOptional { .. }), "expected TypeOptional");
5747            assert_eq!(when_clause.qualifying_states.len(), 1);
5748        } else {
5749            panic!("expected FieldWithWhen, got {:?}", b.items[1].kind);
5750        }
5751    }
5752
5753    #[test]
5754    fn transitions_in_json_output() {
5755        let src = r#"-- allium: 3
5756entity Order {
5757    status: pending | done
5758    transitions status {
5759        pending -> done
5760        terminal: done
5761    }
5762}"#;
5763        let r = parse(src);
5764        let json = serde_json::to_string(&r.module).unwrap();
5765        assert!(json.contains("TransitionsBlock"), "JSON should contain TransitionsBlock: {}", json);
5766        assert!(json.contains("pending"), "JSON should contain 'pending'");
5767    }
5768
5769    #[test]
5770    fn transitions_block_with_commas() {
5771        let src = r#"-- allium: 3
5772entity Order {
5773    status: a | b | c
5774    transitions status {
5775        a -> b,
5776        b -> c,
5777        terminal: c,
5778    }
5779}"#;
5780        let r = parse(src);
5781        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5782        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5783        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5784            panic!("expected TransitionsBlock")
5785        };
5786        assert_eq!(graph.edges.len(), 2);
5787        assert_eq!(graph.terminal.len(), 1);
5788    }
5789
5790    #[test]
5791    fn v3_full_entity_with_transitions_and_rule() {
5792        let src = r#"-- allium: 3
5793entity Order {
5794    status: pending | shipped | delivered
5795    tracking: String when status = shipped | delivered
5796    shipped_at: Timestamp when status = shipped | delivered
5797
5798    transitions status {
5799        pending -> shipped
5800        shipped -> delivered
5801        terminal: delivered
5802    }
5803}
5804
5805rule ShipOrder {
5806    when: ShipOrder(order, tracking)
5807    requires: order.status = pending
5808    ensures:
5809        order.status = shipped
5810        order.tracking = tracking
5811        order.shipped_at = now
5812}"#;
5813        let r = parse(src);
5814        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5815        assert_eq!(r.module.declarations.len(), 2);
5816    }
5817
5818    // -----------------------------------------------------------------------
5819    // Transition graph: edge cases
5820    // -----------------------------------------------------------------------
5821
5822    #[test]
5823    fn transitions_empty_block() {
5824        let src = "-- allium: 3\nentity E {\n    status: a | b\n    transitions status {}\n}";
5825        let r = parse(src);
5826        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5827        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5828        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5829            panic!("expected TransitionsBlock, got {:?}", b.items[1].kind)
5830        };
5831        assert!(graph.edges.is_empty());
5832        assert!(graph.terminal.is_empty());
5833    }
5834
5835    #[test]
5836    fn transitions_terminal_only() {
5837        let src = r#"-- allium: 3
5838entity E {
5839    status: done
5840    transitions status {
5841        terminal: done
5842    }
5843}"#;
5844        let r = parse(src);
5845        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5846        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5847        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5848        assert!(graph.edges.is_empty());
5849        assert_eq!(graph.terminal.len(), 1);
5850        assert_eq!(graph.terminal[0].name, "done");
5851    }
5852
5853    #[test]
5854    fn transitions_terminal_before_edges() {
5855        let src = r#"-- allium: 3
5856entity E {
5857    status: a | b | c
5858    transitions status {
5859        terminal: c
5860        a -> b
5861        b -> c
5862    }
5863}"#;
5864        let r = parse(src);
5865        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5866        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5867        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5868        assert_eq!(graph.edges.len(), 2);
5869        assert_eq!(graph.terminal.len(), 1);
5870        assert_eq!(graph.terminal[0].name, "c");
5871    }
5872
5873    #[test]
5874    fn transitions_self_loop() {
5875        let src = r#"-- allium: 3
5876entity E {
5877    status: running | stopped
5878    transitions status {
5879        running -> running
5880        running -> stopped
5881        terminal: stopped
5882    }
5883}"#;
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.edges.len(), 2);
5889        assert_eq!(graph.edges[0].from.name, "running");
5890        assert_eq!(graph.edges[0].to.name, "running");
5891    }
5892
5893    #[test]
5894    fn transitions_single_edge() {
5895        let src = "-- allium: 3\nentity E {\n    s: a | b\n    transitions s { a -> b }\n}";
5896        let r = parse(src);
5897        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5898        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5899        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5900        assert_eq!(graph.field.name, "s");
5901        assert_eq!(graph.edges.len(), 1);
5902    }
5903
5904    #[test]
5905    fn transitions_multiple_terminal_values() {
5906        let src = r#"-- allium: 3
5907entity E {
5908    status: a | b | c | d | e
5909    transitions status {
5910        a -> b
5911        b -> c
5912        terminal: c, d, e
5913    }
5914}"#;
5915        let r = parse(src);
5916        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5917        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5918        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5919        assert_eq!(graph.terminal.len(), 3);
5920    }
5921
5922    #[test]
5923    fn transitions_trailing_comma_in_terminal() {
5924        let src = "-- allium: 3\nentity E {\n    s: a | b\n    transitions s {\n        a -> b\n        terminal: b,\n    }\n}";
5925        let r = parse(src);
5926        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5927        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5928        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5929        assert_eq!(graph.terminal.len(), 1);
5930    }
5931
5932    #[test]
5933    fn transitions_among_other_entity_items() {
5934        // Transitions block between fields, relationships, invariants
5935        let src = r#"-- allium: 3
5936entity Order {
5937    status: pending | shipped | delivered
5938    customer: Customer
5939    tracking: String?
5940
5941    transitions status {
5942        pending -> shipped
5943        shipped -> delivered
5944        terminal: delivered
5945    }
5946
5947    active_items: items where status = active
5948    invariant Positive { this.total > 0 }
5949}"#;
5950        let r = parse(src);
5951        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5952        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5953        // Should have: status, customer, tracking, transitions, active_items, invariant
5954        assert_eq!(b.items.len(), 6);
5955        assert!(matches!(&b.items[3].kind, BlockItemKind::TransitionsBlock(_)));
5956        assert!(matches!(&b.items[5].kind, BlockItemKind::InvariantBlock { .. }));
5957    }
5958
5959    #[test]
5960    fn transitions_error_recovery_missing_arrow() {
5961        let src = r#"-- allium: 3
5962entity E {
5963    status: a | b | c
5964    transitions status {
5965        a b
5966        b -> c
5967    }
5968}"#;
5969        let r = parse(src);
5970        // Should get a diagnostic about missing `->`
5971        assert!(r.diagnostics.iter().any(|d| d.severity == Severity::Error),
5972            "expected error for missing arrow, got: {:?}", r.diagnostics);
5973        // But should still parse the valid edge
5974        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5975        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else {
5976            panic!("expected TransitionsBlock")
5977        };
5978        assert_eq!(graph.edges.len(), 1, "should recover and parse second edge");
5979        assert_eq!(graph.edges[0].from.name, "b");
5980    }
5981
5982    #[test]
5983    fn transitions_field_name_preserved() {
5984        let src = "-- allium: 3\nentity E {\n    phase: x | y\n    transitions phase { x -> y }\n}";
5985        let r = parse(src);
5986        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
5987        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
5988        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
5989        assert_eq!(graph.field.name, "phase");
5990    }
5991
5992    #[test]
5993    fn transitions_diamond_topology() {
5994        // Common pattern: multiple paths converge on a state
5995        let src = r#"-- allium: 3
5996entity E {
5997    status: new | path_a | path_b | done
5998    transitions status {
5999        new -> path_a
6000        new -> path_b
6001        path_a -> done
6002        path_b -> done
6003        terminal: done
6004    }
6005}"#;
6006        let r = parse(src);
6007        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6008        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6009        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
6010        assert_eq!(graph.edges.len(), 4);
6011    }
6012
6013    #[test]
6014    fn transitions_edge_span_is_from_to_range() {
6015        let src = "-- allium: 3\nentity E {\n    s: a | b\n    transitions s {\n        a -> b\n    }\n}";
6016        let r = parse(src);
6017        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6018        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6019        let BlockItemKind::TransitionsBlock(graph) = &b.items[1].kind else { panic!() };
6020        let edge = &graph.edges[0];
6021        // Span should cover from `a` through `b`, not include the arrow
6022        assert!(edge.span.start <= edge.from.span.start);
6023        assert!(edge.span.end >= edge.to.span.end);
6024    }
6025
6026    // -----------------------------------------------------------------------
6027    // When clause: edge cases
6028    // -----------------------------------------------------------------------
6029
6030    #[test]
6031    fn when_clause_multiple_fields() {
6032        let src = r#"-- allium: 3
6033entity Order {
6034    status: pending | shipped | delivered
6035    tracking: String when status = shipped | delivered
6036    shipped_at: Timestamp when status = shipped | delivered
6037    delivered_at: Timestamp when status = delivered
6038    transitions status {
6039        pending -> shipped
6040        shipped -> delivered
6041        terminal: delivered
6042    }
6043}"#;
6044        let r = parse(src);
6045        let errors: Vec<_> = r.diagnostics.iter().filter(|d| d.severity == Severity::Error).collect();
6046        assert_eq!(errors.len(), 0, "unexpected errors: {:?}", errors);
6047        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6048        let when_count = b.items.iter()
6049            .filter(|i| matches!(&i.kind, BlockItemKind::FieldWithWhen { .. }))
6050            .count();
6051        assert_eq!(when_count, 3);
6052    }
6053
6054    #[test]
6055    fn legacy_produces_consumes_skipped_with_warnings() {
6056        let src = r#"-- allium: 3
6057rule R {
6058    when: Go(x)
6059    produces: field_a
6060    consumes: field_b
6061    ensures: x.done = true
6062}"#;
6063        let r = parse(src);
6064        let warnings: Vec<_> = r.diagnostics.iter()
6065            .filter(|d| d.severity == Severity::Warning)
6066            .collect();
6067        assert!(warnings.len() >= 2, "expected at least 2 migration warnings, got {}", warnings.len());
6068        // The produces/consumes items should be dropped from the AST
6069        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6070        assert!(
6071            !b.items.iter().any(|i| matches!(&i.kind, BlockItemKind::FieldWithWhen { .. })),
6072            "legacy produces/consumes should not become FieldWithWhen"
6073        );
6074    }
6075
6076    // -----------------------------------------------------------------------
6077    // v3 interactions: transitions + produces/consumes + other constructs
6078    // -----------------------------------------------------------------------
6079
6080    #[test]
6081    fn v3_entity_with_transitions_and_invariant() {
6082        let src = r#"-- allium: 3
6083entity Account {
6084    status: open | frozen | closed
6085    balance: Decimal
6086
6087    transitions status {
6088        open -> frozen
6089        frozen -> open
6090        open -> closed
6091        frozen -> closed
6092        terminal: closed
6093    }
6094
6095    invariant NonNegative { this.balance >= 0 }
6096}"#;
6097        let r = parse(src);
6098        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6099        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6100        assert!(b.items.iter().any(|i| matches!(&i.kind, BlockItemKind::TransitionsBlock(_))));
6101        assert!(b.items.iter().any(|i| matches!(&i.kind, BlockItemKind::InvariantBlock { .. })));
6102    }
6103
6104    #[test]
6105    fn v3_rule_with_multiple_ensures() {
6106        let src = r#"-- allium: 3
6107rule CompleteOrder {
6108    when: Complete(order)
6109    requires: order.status = shipped
6110    ensures: order.status = delivered
6111    ensures: order.completed_at = now
6112    ensures: order.receipt_number = generate_receipt()
6113}"#;
6114        let r = parse(src);
6115        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6116        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6117        let ensures_count = b.items.iter()
6118            .filter(|i| matches!(&i.kind, BlockItemKind::Clause { keyword, .. } if keyword == "ensures"))
6119            .count();
6120        assert_eq!(ensures_count, 3);
6121    }
6122
6123    #[test]
6124    fn v3_rule_with_if_block() {
6125        let src = r#"-- allium: 3
6126rule Cancel {
6127    when: Cancel(order, reason)
6128    requires: order.status != delivered
6129    ensures:
6130        order.status = cancelled
6131        order.cancelled_at = now
6132        if reason = customer_request:
6133            order.cancelled_by = order.customer
6134}"#;
6135        let r = parse(src);
6136        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6137    }
6138
6139    #[test]
6140    fn v3_complete_lifecycle_spec() {
6141        let src = r#"-- allium: 3
6142
6143entity Subscription {
6144    status: trial | active | past_due | cancelled
6145    started_at: Timestamp when status = active | past_due | cancelled
6146    cancelled_at: Timestamp when status = cancelled
6147    balance: Decimal
6148
6149    transitions status {
6150        trial -> active
6151        active -> past_due
6152        past_due -> active
6153        active -> cancelled
6154        past_due -> cancelled
6155        terminal: cancelled
6156    }
6157
6158    invariant NonNegative { this.balance >= 0 }
6159}
6160
6161config {
6162    trial_period: Duration = 14.days
6163}
6164
6165rule ActivateSubscription {
6166    when: Activate(sub)
6167    requires: sub.status = trial
6168    ensures:
6169        sub.status = active
6170        sub.started_at = now
6171}
6172
6173rule CancelSubscription {
6174    when: Cancel(sub)
6175    requires: sub.status != cancelled
6176    ensures:
6177        sub.status = cancelled
6178        sub.cancelled_at = now
6179}
6180
6181invariant AllCancelledHaveTimestamp {
6182    for sub in Subscriptions where status = cancelled:
6183        sub.cancelled_at != null
6184}
6185
6186surface SubscriptionDashboard {
6187    facing user: User
6188    context sub: Subscription where owner = user
6189    exposes:
6190        sub.status
6191        sub.balance
6192    provides:
6193        Cancel(sub) when sub.status != cancelled
6194}
6195"#;
6196        let r = parse(src);
6197        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6198        // entity, config, 2 rules, invariant, surface = 6 declarations
6199        assert_eq!(r.module.declarations.len(), 6);
6200    }
6201
6202    #[test]
6203    fn v3_produces_consumes_are_field_names_in_entities() {
6204        // In non-rule blocks, `produces` and `consumes` are just field names
6205        let src = r#"-- allium: 3
6206entity Factory {
6207    produces: widget_a
6208    consumes: raw_material
6209}"#;
6210        let r = parse(src);
6211        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6212        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6213        assert!(matches!(&b.items[0].kind, BlockItemKind::Assignment { name, .. } if name.name == "produces"));
6214        assert!(matches!(&b.items[1].kind, BlockItemKind::Assignment { name, .. } if name.name == "consumes"));
6215    }
6216
6217    #[test]
6218    fn v3_legacy_produces_consumes_emit_warnings_in_rules() {
6219        let src = r#"-- allium: 3
6220rule Ship {
6221    when: Ship(order)
6222    produces: tracking_number
6223    consumes: warehouse
6224    ensures: order.status = shipped
6225}"#;
6226        let r = parse(src);
6227        let warnings: Vec<_> = r.diagnostics.iter()
6228            .filter(|d| d.severity == Severity::Warning)
6229            .collect();
6230        assert!(warnings.len() >= 2, "expected migration warnings, got {:?}", warnings);
6231    }
6232
6233    #[test]
6234    fn v3_version_preserved_in_module() {
6235        let src = "-- allium: 3\nentity E {}";
6236        let r = parse(src);
6237        assert_eq!(r.module.version, Some(3));
6238    }
6239
6240    #[test]
6241    fn v3_version_4_still_rejected() {
6242        let src = "-- allium: 4\nentity E {}";
6243        let r = parse(src);
6244        assert!(r.diagnostics.iter().any(|d| d.severity == Severity::Error
6245            && d.message.contains("unsupported")));
6246    }
6247
6248    // -----------------------------------------------------------------------
6249    // Backtick-quoted enum literals
6250    // -----------------------------------------------------------------------
6251
6252    #[test]
6253    fn backtick_in_named_enum() {
6254        let src = "-- allium: 3\nenum Locale { en | fr | `de-CH-1996` | `no-cache` }";
6255        let r = parse(src);
6256        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6257        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6258        assert_eq!(b.items.len(), 4);
6259        // First two are unquoted
6260        let BlockItemKind::EnumVariant { name, backtick_quoted } = &b.items[0].kind else { panic!() };
6261        assert_eq!(name.name, "en");
6262        assert!(!backtick_quoted);
6263        // Third is backtick-quoted
6264        let BlockItemKind::EnumVariant { name, backtick_quoted } = &b.items[2].kind else { panic!() };
6265        assert_eq!(name.name, "de-CH-1996");
6266        assert!(backtick_quoted);
6267        // Fourth is backtick-quoted
6268        let BlockItemKind::EnumVariant { name, backtick_quoted } = &b.items[3].kind else { panic!() };
6269        assert_eq!(name.name, "no-cache");
6270        assert!(backtick_quoted);
6271    }
6272
6273    #[test]
6274    fn backtick_in_inline_enum() {
6275        let src = "-- allium: 3\nentity E { cache: `no-cache` | `no-store` | `public` }";
6276        let r = parse(src);
6277        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6278        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6279        let BlockItemKind::Assignment { value, .. } = &b.items[0].kind else { panic!() };
6280        // Top-level should be Pipe chains of BacktickLiteral
6281        assert!(matches!(value, Expr::Pipe { .. }));
6282    }
6283
6284    #[test]
6285    fn backtick_in_comparison() {
6286        let src = r#"-- allium: 3
6287rule R {
6288    when: Check(item)
6289    requires: item.locale = `de-CH-1996`
6290    ensures: Done()
6291}"#;
6292        let r = parse(src);
6293        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6294    }
6295
6296    #[test]
6297    fn backtick_mixed_with_unquoted() {
6298        let src = "-- allium: 3\nenum CacheDirective { `no-cache` | `no-store` | public | private }";
6299        let r = parse(src);
6300        assert_eq!(r.diagnostics.len(), 0, "unexpected diagnostics: {:?}", r.diagnostics);
6301        let Decl::Block(b) = &r.module.declarations[0] else { panic!() };
6302        assert_eq!(b.items.len(), 4);
6303        let BlockItemKind::EnumVariant { backtick_quoted, .. } = &b.items[0].kind else { panic!() };
6304        assert!(backtick_quoted);
6305        let BlockItemKind::EnumVariant { backtick_quoted, .. } = &b.items[2].kind else { panic!() };
6306        assert!(!backtick_quoted);
6307    }
6308
6309    // -----------------------------------------------------------------------
6310    // V3 fixture file
6311    // -----------------------------------------------------------------------
6312
6313    #[test]
6314    fn v3_lifecycle_fixture() {
6315        let src = include_str!("../tests/fixtures/v3-lifecycle.allium");
6316        let r = parse(src);
6317        let errors: Vec<_> = r.diagnostics.iter()
6318            .filter(|d| d.severity == Severity::Error)
6319            .collect();
6320        assert_eq!(
6321            errors.len(),
6322            0,
6323            "expected no errors in v3 lifecycle fixture, got: {:?}",
6324            errors.iter().map(|d| &d.message).collect::<Vec<_>>(),
6325        );
6326    }
6327
6328    #[test]
6329    fn zero_arg_contract_signature_parens() {
6330        // `op: () -> ReturnType` declares a zero-argument contract operation.
6331        let r = parse_ok(
6332            "-- allium: 3\nvalue Foo { value: String }\n\
6333             contract Demo { list_things: () -> Set<Foo> }",
6334        );
6335        assert!(
6336            r.diagnostics.iter().all(|d| d.severity != Severity::Error),
6337            "zero-arg signature should parse without errors, got: {:?}",
6338            r.diagnostics
6339                .iter()
6340                .map(|d| &d.message)
6341                .collect::<Vec<_>>(),
6342        );
6343
6344        // The empty parens parse to an empty parameter list: the signature
6345        // value is `Set<Foo>` (a GenericType) whose name is a `() -> Set`
6346        // projection with an empty block as its source.
6347        let contract = r
6348            .module
6349            .declarations
6350            .iter()
6351            .find_map(|d| match d {
6352                Decl::Block(b) if b.kind == BlockKind::Contract => Some(b),
6353                _ => None,
6354            })
6355            .expect("contract decl");
6356        let BlockItemKind::Assignment { value, .. } = &contract.items[0].kind else {
6357            panic!("expected signature assignment, got {:?}", contract.items[0].kind);
6358        };
6359        let Expr::GenericType { name, .. } = value else {
6360            panic!("expected GenericType signature value, got {value:?}");
6361        };
6362        let Expr::ProjectionMap { source, .. } = name.as_ref() else {
6363            panic!("expected `params -> Return` projection, got {name:?}");
6364        };
6365        assert!(
6366            matches!(source.as_ref(), Expr::Block { items, .. } if items.is_empty()),
6367            "zero-arg params should be an empty block, got {source:?}",
6368        );
6369    }
6370}