Skip to main content

adam_lang/
ast_parser.rs

1//! [`AdamAstParser`]: parses adam-lang source into [`crate::ast::Sheet`] instead of executing into a
2//! live `adam_rs::Sheet`. Shares [`crate::token_cursor::TokenCursor`] with
3//! [`crate::AdamParser`] for pure tokenizing; the two parsers' grammar-production functions are
4//! separate because what each one builds is genuinely different (see this plan's Architecture
5//! section).
6
7use cel_parser::{AstContext, OpLookup, Parser as CelParser};
8
9use crate::ast;
10use crate::token_cursor::TokenCursor;
11
12/// Parser result type, matching `cel_parser::ParseError`.
13type Result<T> = std::result::Result<T, cel_parser::ParseError>;
14
15/// Parses adam-lang source strings into [`ast::Sheet`] trees, instead of executing into a live
16/// `adam_rs::Sheet` (see [`crate::AdamParser`] for that path).
17///
18/// # Example
19///
20/// ```rust
21/// use adam_lang::AdamAstParser;
22///
23/// let sheet = AdamAstParser::new().parse_str("sheet s { cell x: i32 = 0; }").unwrap();
24/// assert_eq!(sheet.name, "s");
25/// ```
26pub struct AdamAstParser {
27    cel: CelParser<AstContext>,
28}
29
30impl Default for AdamAstParser {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl AdamAstParser {
37    /// Creates a new AST-building parser.
38    ///
39    /// Unlike [`crate::AdamParser::new`], this takes no `TypeRegistry`/`OpLookup` — `AstContext`
40    /// resolves no identifiers and validates nothing during parsing (see this plan's Architecture
41    /// section), so there is nothing for either to configure.
42    #[must_use]
43    pub fn new() -> Self {
44        AdamAstParser {
45            cel: CelParser::new(OpLookup::new()),
46        }
47    }
48
49    /// Parses an adam-lang source string into an [`ast::Sheet`].
50    ///
51    /// A syntax error inside one `cell`/`relationship`/`conditional` item is recorded in
52    /// `Sheet.errors` and replaced by a `SheetItem::Error` placeholder covering the skipped
53    /// tokens; parsing resumes at the next sheet item instead of aborting (see
54    /// `TokenCursor::skip_to_recovery_point`). This recovery is declaration-level only: a
55    /// malformed `binding` inside a `relationship`/`conditional` block causes the whole
56    /// enclosing item to become one `SheetItem::Error`.
57    ///
58    /// Recovery is reliable for syntax errors adam-lang's own grammar detects directly (malformed
59    /// `cell` declarations; `relationship`/`conditional`/`binding` structure outside their CEL
60    /// expression bodies, including a malformed `type_expr`'s own dangling `(`/`)`) and for CEL
61    /// expression errors, including one that leaves a dangling, unmatched delimiter of a kind CEL
62    /// reuses for its own internal grouping — e.g. an `if`/`else` expression's braces, the same
63    /// `Delimiter::Brace` kind adam-lang uses for its own `relationship`/`conditional` blocks
64    /// (`if a { }` is one such case), or a tuple/group literal's or call's parens, the same
65    /// `Delimiter::Parenthesis` kind `type_expr` uses (`(+)` is one such case): see
66    /// `TokenCursor::absorb_unbalanced_delimiters`.
67    ///
68    /// # Errors
69    ///
70    /// Returns `Err` for structural errors outside any sheet item (e.g. a missing `sheet`
71    /// keyword, missing sheet name, missing top-level braces, or trailing tokens after the
72    /// sheet closes) — these can't be attributed to a single recoverable item.
73    pub fn parse_str(&mut self, source: &str) -> Result<ast::Sheet> {
74        use std::str::FromStr;
75        let stream = proc_macro2::TokenStream::from_str(source)
76            .map_err(|e| cel_parser::ParseError::from_lex_error(source, e))?;
77        let mut cursor =
78            TokenCursor::new(cel_parser::lex_lexer::LexLexer::new(stream.into_iter()).peekable());
79        let doc_comment = cursor.consume_doc_comment_run(true);
80        let mut sheet = self.parse_sheet(&mut cursor)?;
81        if let Some((text, span)) = doc_comment {
82            sheet.doc_comment = Some(text);
83            sheet.span.start = span;
84        }
85        if let Some(tok) = cursor.peek_token() {
86            use cel_parser::lex_lexer::HasSpan;
87            return Err(cel_parser::ParseError::new("unexpected token", tok.span()));
88        }
89        Ok(sheet)
90    }
91
92    /// `sheet = "sheet" identifier "{" { sheet_item } "}".`
93    fn parse_sheet(&mut self, cursor: &mut TokenCursor) -> Result<ast::Sheet> {
94        let sheet_start = cursor.peek_span();
95        if !cursor.is_keyword("sheet") {
96            return Err(cursor.err_at("expected `sheet`"));
97        }
98        let (name, name_span) = cursor.consume_ident()?;
99        let open_span = cursor.expect_open_brace()?;
100        let mut items = Vec::new();
101        let mut errors = Vec::new();
102        while !cursor.at_close_brace() {
103            let doc = cursor.consume_doc_comment_run(false);
104            let item_start = doc
105                .as_ref()
106                .map(|(_, span)| *span)
107                .unwrap_or_else(|| cursor.peek_span());
108            cursor.set_last_span(item_start);
109            let target_depth = cursor.depth();
110            match self.parse_sheet_item(cursor) {
111                Ok(mut item) => {
112                    if let Some((text, doc_span)) = &doc {
113                        item.set_doc_comment(text.clone(), *doc_span);
114                    }
115                    items.push(item);
116                }
117                Err(e) => {
118                    errors.push(e);
119                    let recovery_fallback = cursor.last_span();
120                    let item_end = cursor.skip_to_recovery_point(target_depth, recovery_fallback);
121                    items.push(ast::SheetItem::Error {
122                        span: ast::ExprSpan {
123                            start: item_start,
124                            end: item_end,
125                        },
126                        leading_comment: None,
127                        doc_comment: doc.map(|(text, _)| text),
128                        trailing_line_comment: None,
129                        blank_line_before: false,
130                    });
131                }
132            }
133        }
134        let close_span = cursor.expect_close_brace()?;
135        Ok(ast::Sheet {
136            name,
137            name_span: point(name_span),
138            items,
139            leading_comment: None,
140            doc_comment: None,
141            trailing_comment: None,
142            blank_line_before_close: false,
143            open_brace_span: point(open_span),
144            span: ast::ExprSpan {
145                start: sheet_start,
146                end: close_span,
147            },
148            errors,
149        })
150    }
151
152    /// `sheet_item = cell_decl | relationship_decl | conditional_decl | out_decl | source_decl.`
153    fn parse_sheet_item(&mut self, cursor: &mut TokenCursor) -> Result<ast::SheetItem> {
154        use cel_parser::lex_lexer::{HasSpan, Token};
155        match cursor.peek_token() {
156            Some(Token::Identifier(id)) if id == "cell" => {
157                self.parse_cell_decl(cursor).map(ast::SheetItem::Cell)
158            }
159            Some(Token::Identifier(id)) if id == "relationship" => self
160                .parse_relationship_decl(cursor)
161                .map(ast::SheetItem::Relationship),
162            Some(Token::Identifier(id)) if id == "conditional" => self
163                .parse_conditional_decl(cursor)
164                .map(ast::SheetItem::Conditional),
165            Some(Token::Identifier(id)) if id == "out" => {
166                self.parse_out_decl(cursor).map(ast::SheetItem::Out)
167            }
168            Some(Token::Identifier(id)) if id == "source" => {
169                self.parse_source_decl(cursor).map(ast::SheetItem::Source)
170            }
171            Some(tok) => Err(cel_parser::ParseError::new(
172                "expected `cell`, `relationship`, `conditional`, `out`, or `source`",
173                tok.span(),
174            )),
175            None => Err(cel_parser::ParseError::new(
176                "unexpected end of input",
177                proc_macro2::Span::call_site(),
178            )),
179        }
180    }
181
182    /// `cell_decl = "cell" identifier cell_type_init [ cell_filter ] [ "require" "{" {
183    /// requirement } "}" ] ";".`
184    fn parse_cell_decl(&mut self, cursor: &mut TokenCursor) -> Result<ast::CellDecl> {
185        let decl_start = cursor.peek_span();
186        cursor.is_keyword("cell");
187        let (name, name_span) = cursor.consume_ident()?;
188        let (type_name, initializer) = if cursor.consume_punct(":") {
189            let type_name = self.parse_type_expr(cursor)?;
190            let initializer = if cursor.consume_punct("=") {
191                Some(self.parse_cel_expression(cursor)?)
192            } else {
193                None
194            };
195            (Some(type_name), initializer)
196        } else if cursor.consume_punct("=") {
197            (None, Some(self.parse_cel_expression(cursor)?))
198        } else {
199            return Err(cursor.err_at("expected `:` or `=` in cell declaration"));
200        };
201        let filter = if cursor.is_keyword("filter") {
202            let filter_start = cursor.last_span();
203            Some(self.parse_cell_filter(cursor, filter_start)?)
204        } else {
205            None
206        };
207        let require = if cursor.is_keyword("require") {
208            Some(self.parse_require_block(cursor)?)
209        } else {
210            None
211        };
212        let semi_span = cursor.expect_punct(";")?;
213        Ok(ast::CellDecl {
214            name,
215            name_span: point(name_span),
216            type_name,
217            initializer,
218            filter,
219            require,
220            leading_comment: None,
221            doc_comment: None,
222            trailing_line_comment: None,
223            blank_line_before: false,
224            span: ast::ExprSpan {
225                start: decl_start,
226                end: semi_span,
227            },
228        })
229    }
230
231    /// `source_decl = "source" identifier cell_type_init [ cell_filter ] [ "require" "{" {
232    /// requirement } "}" ] ";".`
233    ///
234    /// Mirrors [`Self::parse_cell_decl`] exactly.
235    fn parse_source_decl(&mut self, cursor: &mut TokenCursor) -> Result<ast::SourceDecl> {
236        let decl_start = cursor.peek_span();
237        cursor.is_keyword("source");
238        let (name, name_span) = cursor.consume_ident()?;
239        let (type_name, initializer) = if cursor.consume_punct(":") {
240            let type_name = self.parse_type_expr(cursor)?;
241            let initializer = if cursor.consume_punct("=") {
242                Some(self.parse_cel_expression(cursor)?)
243            } else {
244                None
245            };
246            (Some(type_name), initializer)
247        } else if cursor.consume_punct("=") {
248            (None, Some(self.parse_cel_expression(cursor)?))
249        } else {
250            return Err(cursor.err_at("expected `:` or `=` in source declaration"));
251        };
252        let filter = if cursor.is_keyword("filter") {
253            let filter_start = cursor.last_span();
254            Some(self.parse_cell_filter(cursor, filter_start)?)
255        } else {
256            None
257        };
258        let require = if cursor.is_keyword("require") {
259            Some(self.parse_require_block(cursor)?)
260        } else {
261            None
262        };
263        let semi_span = cursor.expect_punct(";")?;
264        Ok(ast::SourceDecl {
265            name,
266            name_span: point(name_span),
267            type_name,
268            initializer,
269            filter,
270            require,
271            leading_comment: None,
272            doc_comment: None,
273            trailing_line_comment: None,
274            blank_line_before: false,
275            span: ast::ExprSpan {
276                start: decl_start,
277                end: semi_span,
278            },
279        })
280    }
281
282    /// `cell_filter = "filter" expression.`
283    ///
284    /// - Precondition: the `filter` keyword has already been consumed by the caller; `filter_start`
285    ///   is its span.
286    fn parse_cell_filter(
287        &mut self,
288        cursor: &mut TokenCursor,
289        filter_start: proc_macro2::Span,
290    ) -> Result<ast::CellFilter> {
291        let body = self.parse_cel_expression(cursor)?;
292        let body_end = body.span().end;
293        Ok(ast::CellFilter {
294            body,
295            span: ast::ExprSpan {
296                start: filter_start,
297                end: body_end,
298            },
299        })
300    }
301
302    /// Delegates one `type_expr` to `cel_parser::Parser<AstContext>`, sharing the token stream
303    /// (the same take/set-tokens handoff `parse_cel_expression` uses).
304    fn parse_type_expr(&mut self, cursor: &mut TokenCursor) -> Result<ast::TypeExpr> {
305        let tokens = cursor.take_tokens().expect("tokens present");
306        self.cel.set_lex_tokens(tokens);
307        let result = self.cel.parse_type_expression();
308        cursor.set_tokens(self.cel.take_lex_tokens().expect("tokens set"));
309        cursor.absorb_unbalanced_delimiters(self.cel.unbalanced_delimiter_count());
310        result
311    }
312
313    /// `relationship_decl = "relationship" "{" { binding } "}".`
314    fn parse_relationship_decl(
315        &mut self,
316        cursor: &mut TokenCursor,
317    ) -> Result<ast::RelationshipDecl> {
318        let decl_start = cursor.peek_span();
319        cursor.is_keyword("relationship");
320        let open_span = cursor.expect_open_brace()?;
321        let mut bindings = Vec::new();
322        while !cursor.at_close_brace() {
323            bindings.push(self.parse_binding(cursor)?);
324        }
325        let close_span = cursor.expect_close_brace()?;
326        Ok(ast::RelationshipDecl {
327            bindings,
328            leading_comment: None,
329            doc_comment: None,
330            trailing_line_comment: None,
331            blank_line_before: false,
332            trailing_comment: None,
333            blank_line_before_close: false,
334            open_brace_span: point(open_span),
335            span: ast::ExprSpan {
336                start: decl_start,
337                end: close_span,
338            },
339        })
340    }
341
342    /// `binding = binding_target ":=" expression ";".`
343    fn parse_binding(&mut self, cursor: &mut TokenCursor) -> Result<ast::BindingDecl> {
344        let decl_start = cursor.peek_span();
345        let (outputs, destructure) = parse_binding_target(cursor)?;
346        cursor.expect_punct(":=")?;
347        let body = self.parse_cel_expression(cursor)?;
348        let semi_span = cursor.expect_punct(";")?;
349        Ok(ast::BindingDecl {
350            outputs,
351            destructure,
352            body,
353            leading_comment: None,
354            trailing_line_comment: None,
355            blank_line_before: false,
356            span: ast::ExprSpan {
357                start: decl_start,
358                end: semi_span,
359            },
360        })
361    }
362
363    /// `conditional_decl = "conditional" expression "{" { conditional_branch } "}".`
364    fn parse_conditional_decl(&mut self, cursor: &mut TokenCursor) -> Result<ast::ConditionalDecl> {
365        use cel_parser::lex_lexer::Token;
366        let decl_start = cursor.peek_span();
367        cursor.is_keyword("conditional");
368        let match_expr = self.parse_cel_expression_restricted(
369            cursor,
370            cel_parser::ExpressionRestriction::NoStructLiteral,
371        )?;
372        let outer_open = cursor.expect_open_brace()?;
373        let mut branches = Vec::new();
374        let mut default = None;
375        while !cursor.at_close_brace() {
376            if matches!(cursor.peek_token(), Some(Token::Identifier(id)) if id == "_") {
377                let underscore_span = cursor.peek_span();
378                cursor.advance();
379                cursor.expect_punct("=>")?;
380                let branch_open = cursor.expect_open_brace()?;
381                let relationships = self.parse_branch_relationships(cursor)?;
382                let close = cursor.expect_close_brace()?;
383                cursor.consume_punct(",");
384                default = Some(ast::DefaultBranch {
385                    relationships,
386                    trailing_comment: None,
387                    trailing_line_comment: None,
388                    blank_line_before_close: false,
389                    open_brace_span: point(branch_open),
390                    span: ast::ExprSpan {
391                        start: underscore_span,
392                        end: close,
393                    },
394                });
395                break; // default branch is always last
396            }
397            let (negated, lit, pattern_start, lit_span) = cursor.consume_literal_pattern()?;
398            cursor.expect_punct("=>")?;
399            let branch_open = cursor.expect_open_brace()?;
400            let relationships = self.parse_branch_relationships(cursor)?;
401            let close = cursor.expect_close_brace()?;
402            cursor.consume_punct(",");
403            branches.push(ast::ConditionalBranch {
404                literal: lit,
405                negated,
406                literal_span: point(lit_span),
407                relationships,
408                leading_comment: None,
409                trailing_line_comment: None,
410                blank_line_before: false,
411                trailing_comment: None,
412                blank_line_before_close: false,
413                open_brace_span: point(branch_open),
414                span: ast::ExprSpan {
415                    start: pattern_start,
416                    end: close,
417                },
418            });
419        }
420        let close_span = cursor.expect_close_brace()?;
421        Ok(ast::ConditionalDecl {
422            match_expr,
423            branches,
424            default,
425            leading_comment: None,
426            doc_comment: None,
427            trailing_line_comment: None,
428            blank_line_before: false,
429            trailing_comment: None,
430            blank_line_before_close: false,
431            open_brace_span: point(outer_open),
432            span: ast::ExprSpan {
433                start: decl_start,
434                end: close_span,
435            },
436        })
437    }
438
439    /// Parses one `conditional_branch`/`default_branch`'s shared body: `"{" { relationship_decl }
440    /// "}"`, up to (not including) the closing `}`.
441    fn parse_branch_relationships(
442        &mut self,
443        cursor: &mut TokenCursor,
444    ) -> Result<Vec<ast::RelationshipDecl>> {
445        use cel_parser::lex_lexer::Token;
446        let mut relationships = Vec::new();
447        while !cursor.at_close_brace() {
448            if !matches!(cursor.peek_token(), Some(Token::Identifier(id)) if id == "relationship") {
449                return Err(cursor.err_at("expected `relationship`"));
450            }
451            relationships.push(self.parse_relationship_decl(cursor)?);
452        }
453        Ok(relationships)
454    }
455
456    /// `out_decl = "out" identifier [ ":" type_name ] ":=" expression [ cell_filter ] [ "require"
457    /// "{" { requirement } "}" ] ";".`
458    fn parse_out_decl(&mut self, cursor: &mut TokenCursor) -> Result<ast::OutDecl> {
459        let decl_start = cursor.peek_span();
460        cursor.is_keyword("out");
461        let (name, name_span) = cursor.consume_ident()?;
462        let type_name = if cursor.consume_punct(":") {
463            Some(self.parse_type_expr(cursor)?)
464        } else {
465            None
466        };
467        cursor.expect_punct(":=")?;
468        let initializer = self.parse_cel_expression(cursor)?;
469        let filter = if cursor.is_keyword("filter") {
470            let filter_start = cursor.last_span();
471            Some(self.parse_cell_filter(cursor, filter_start)?)
472        } else {
473            None
474        };
475        let require = if cursor.is_keyword("require") {
476            Some(self.parse_require_block(cursor)?)
477        } else {
478            None
479        };
480        let semi_span = cursor.expect_punct(";")?;
481        Ok(ast::OutDecl {
482            name,
483            name_span: point(name_span),
484            type_name,
485            initializer,
486            filter,
487            require,
488            leading_comment: None,
489            doc_comment: None,
490            trailing_line_comment: None,
491            blank_line_before: false,
492            span: ast::ExprSpan {
493                start: decl_start,
494                end: semi_span,
495            },
496        })
497    }
498
499    /// `require_block = "require" "{" { requirement } "}".`
500    ///
501    /// - Precondition: the `require` keyword has already been consumed by the caller.
502    fn parse_require_block(&mut self, cursor: &mut TokenCursor) -> Result<ast::RequireBlock> {
503        let open_span = cursor.expect_open_brace()?;
504        let mut requirements = Vec::new();
505        while !cursor.at_close_brace() {
506            requirements.push(self.parse_requirement(cursor)?);
507        }
508        let close_span = cursor.expect_close_brace()?;
509        Ok(ast::RequireBlock {
510            requirements,
511            trailing_comment: None,
512            blank_line_before_close: false,
513            open_brace_span: point(open_span),
514            span: ast::ExprSpan {
515                start: open_span,
516                end: close_span,
517            },
518        })
519    }
520
521    /// `requirement = [ "@" identifier ] expression ";".`
522    fn parse_requirement(&mut self, cursor: &mut TokenCursor) -> Result<ast::RequirementDecl> {
523        let decl_start = cursor.peek_span();
524        let (name, name_span) = if cursor.consume_punct("@") {
525            let (name, span) = cursor.consume_ident()?;
526            (Some(name), Some(point(span)))
527        } else {
528            (None, None)
529        };
530        let body = self.parse_cel_expression(cursor)?;
531        let semi_span = cursor.expect_punct(";")?;
532        Ok(ast::RequirementDecl {
533            name,
534            name_span,
535            body,
536            leading_comment: None,
537            trailing_line_comment: None,
538            blank_line_before: false,
539            span: ast::ExprSpan {
540                start: decl_start,
541                end: semi_span,
542            },
543        })
544    }
545
546    /// Delegates one `expression` to `cel_parser::Parser<AstContext>`, sharing the token
547    /// stream (the same take/set-tokens handoff `crate::AdamParser` uses for the `DynSegment`
548    /// path).
549    fn parse_cel_expression(&mut self, cursor: &mut TokenCursor) -> Result<cel_parser::Expr> {
550        self.parse_cel_expression_restricted(cursor, cel_parser::ExpressionRestriction::None)
551    }
552
553    /// Parses an embedded expression with a temporary header brace reservation.
554    ///
555    /// - Postcondition: tokens and unmatched delimiter depth return to the cursor on every result.
556    /// - Complexity: O(n) in expression tokens.
557    ///
558    /// # Errors
559    /// Returns the embedded expression's syntax error.
560    fn parse_cel_expression_restricted(
561        &mut self,
562        cursor: &mut TokenCursor,
563        restriction: cel_parser::ExpressionRestriction,
564    ) -> Result<cel_parser::Expr> {
565        let tokens = cursor.take_tokens().expect("tokens present");
566        self.cel.set_lex_tokens(tokens);
567        let result = self
568            .cel
569            .parse_expression_ctx_restricted(restriction)
570            .map(cel_parser::AstContext::into_expr);
571        cursor.set_tokens(self.cel.take_lex_tokens().expect("tokens set"));
572        cursor.absorb_unbalanced_delimiters(self.cel.unbalanced_delimiter_count());
573        result
574    }
575}
576
577/// A single-token `ExprSpan` where start and end coincide.
578fn point(span: proc_macro2::Span) -> ast::ExprSpan {
579    ast::ExprSpan {
580        start: span,
581        end: span,
582    }
583}
584
585/// `binding_target = identifier | "(" identifier { "," identifier } [ "," ] ")".`
586///
587/// Returns the output names in declaration order alongside whether the left-hand side requests
588/// destructuring: `false` for a bare identifier or a single parenthesized identifier with no
589/// comma (mere grouping, matching Rust's `(a)` pattern); `true` for `(a,)` (a 1-tuple pattern,
590/// trailing comma mandatory) or `(a, b, ...)`.
591fn parse_binding_target(cursor: &mut TokenCursor) -> Result<(Vec<(String, ast::ExprSpan)>, bool)> {
592    if !cursor.at_open_paren() {
593        let (name, span) = cursor.consume_ident()?;
594        return Ok((vec![(name, point(span))], false));
595    }
596
597    cursor.expect_open_paren()?;
598    let (first_name, first_span) = cursor.consume_ident()?;
599    if cursor.at_close_paren() {
600        // Grouping: exactly one identifier, no comma -- same as the bare form.
601        cursor.expect_close_paren()?;
602        return Ok((vec![(first_name, point(first_span))], false));
603    }
604    if !cursor.consume_punct(",") {
605        return Err(cursor.err_at("expected ',' or closing parenthesis"));
606    }
607    if cursor.at_close_paren() {
608        // Single identifier + trailing comma: destructures a 1-tuple.
609        cursor.expect_close_paren()?;
610        return Ok((vec![(first_name, point(first_span))], true));
611    }
612    let mut outputs = vec![(first_name, point(first_span))];
613    loop {
614        let (name, span) = cursor.consume_ident()?;
615        outputs.push((name, point(span)));
616        if cursor.at_close_paren() {
617            break;
618        }
619        if !cursor.consume_punct(",") {
620            return Err(cursor.err_at("expected ',' or closing parenthesis"));
621        }
622    }
623    cursor.expect_close_paren()?;
624    Ok((outputs, true))
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use cel_parser::Expr;
631
632    #[test]
633    fn parse_empty_sheet_has_no_items() {
634        let sheet = AdamAstParser::new().parse_str("sheet empty {}").unwrap();
635        assert_eq!(sheet.name, "empty");
636        assert!(sheet.items.is_empty());
637        assert!(sheet.errors.is_empty());
638    }
639
640    #[test]
641    fn parse_cell_with_annotation_and_initializer() {
642        let sheet = AdamAstParser::new()
643            .parse_str("sheet s { cell width: f64 = 1920.0; }")
644            .unwrap();
645        assert_eq!(sheet.items.len(), 1);
646        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
647            panic!("expected Cell");
648        };
649        assert_eq!(cell.name, "width");
650        assert!(matches!(
651            cell.type_name.as_ref().unwrap(),
652            ast::TypeExpr::Named { name: n, .. } if n == "f64"
653        ));
654        assert!(cell.initializer.is_some());
655    }
656
657    #[test]
658    fn parse_cell_annotation_only_has_no_initializer() {
659        let sheet = AdamAstParser::new()
660            .parse_str("sheet s { cell area: f64; }")
661            .unwrap();
662        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
663            panic!("expected Cell");
664        };
665        assert!(cell.type_name.is_some());
666        assert!(cell.initializer.is_none());
667    }
668
669    #[test]
670    fn parse_cell_initializer_only_has_no_type_name() {
671        let sheet = AdamAstParser::new()
672            .parse_str("sheet s { cell mode = 0i32; }")
673            .unwrap();
674        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
675            panic!("expected Cell");
676        };
677        assert!(cell.type_name.is_none());
678        assert!(cell.initializer.is_some());
679    }
680
681    #[test]
682    fn parse_source_decl_produces_a_source_decl_sheet_item() {
683        let sheet = AdamAstParser::new()
684            .parse_str("sheet s { source width: i32 = 4; }")
685            .unwrap();
686        assert!(matches!(sheet.items[0], ast::SheetItem::Source(_)));
687    }
688
689    #[test]
690    fn parse_source_with_a_filter() {
691        let sheet = AdamAstParser::new()
692            .parse_str("sheet s { source a: i32 = 1 filter _; }")
693            .unwrap();
694        let ast::SheetItem::Source(source) = &sheet.items[0] else {
695            panic!("expected Source");
696        };
697        let filter = source.filter.as_ref().expect("filter present");
698        assert!(matches!(&filter.body, Expr::Ident { name, .. } if name == "_"));
699    }
700
701    #[test]
702    fn parse_source_without_a_filter_leaves_it_none() {
703        let sheet = AdamAstParser::new()
704            .parse_str("sheet s { source a: i32 = 1; }")
705            .unwrap();
706        let ast::SheetItem::Source(source) = &sheet.items[0] else {
707            panic!("expected Source");
708        };
709        assert!(source.filter.is_none());
710    }
711
712    #[test]
713    fn parse_relationship_records_bindings_in_order() {
714        let sheet = AdamAstParser::new()
715            .parse_str(
716                r#"
717                sheet s {
718                    relationship {
719                        area := width * height;
720                        width := area / height;
721                    }
722                }
723            "#,
724            )
725            .unwrap();
726        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
727            panic!("expected Relationship");
728        };
729        assert_eq!(rel.bindings.len(), 2);
730        assert_eq!(rel.bindings[0].outputs[0].0, "area");
731        assert!(matches!(rel.bindings[0].body, Expr::Op { ref name, .. } if name == "*"));
732    }
733
734    #[test]
735    fn parse_binding_bare_identifier_target_is_not_a_destructure() {
736        let sheet = AdamAstParser::new()
737            .parse_str("sheet s { relationship { x := a; } }")
738            .unwrap();
739        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
740            panic!("expected Relationship");
741        };
742        assert!(!rel.bindings[0].destructure);
743        assert_eq!(rel.bindings[0].outputs.len(), 1);
744    }
745
746    #[test]
747    fn parse_binding_grouped_single_identifier_target_is_not_a_destructure() {
748        let sheet = AdamAstParser::new()
749            .parse_str("sheet s { relationship { (x) := a; } }")
750            .unwrap();
751        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
752            panic!("expected Relationship");
753        };
754        assert!(!rel.bindings[0].destructure);
755        assert_eq!(rel.bindings[0].outputs.len(), 1);
756    }
757
758    #[test]
759    fn parse_binding_single_element_tuple_target_is_a_destructure() {
760        let sheet = AdamAstParser::new()
761            .parse_str("sheet s { relationship { (x,) := (a,); } }")
762            .unwrap();
763        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
764            panic!("expected Relationship");
765        };
766        assert!(rel.bindings[0].destructure);
767        assert_eq!(rel.bindings[0].outputs.len(), 1);
768        assert_eq!(rel.bindings[0].outputs[0].0, "x");
769    }
770
771    #[test]
772    fn parse_binding_multi_identifier_target_is_a_destructure() {
773        let sheet = AdamAstParser::new()
774            .parse_str("sheet s { relationship { (x, y) := (a, b); } }")
775            .unwrap();
776        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
777            panic!("expected Relationship");
778        };
779        assert!(rel.bindings[0].destructure);
780        assert_eq!(
781            rel.bindings[0]
782                .outputs
783                .iter()
784                .map(|(name, _)| name.as_str())
785                .collect::<Vec<_>>(),
786            vec!["x", "y"]
787        );
788    }
789
790    #[test]
791    fn multi_output_binding_without_parens_recovers_at_sheet_item_level() {
792        let sheet = AdamAstParser::new()
793            .parse_str(
794                r#"
795                sheet s {
796                    cell good_before: i32 = 1;
797                    relationship {
798                        x, y := (a, b);
799                    }
800                    cell good_after: i32 = 2;
801                }
802            "#,
803            )
804            .unwrap();
805        assert_eq!(sheet.errors.len(), 1);
806        assert_eq!(sheet.items.len(), 3);
807        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
808        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
809        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
810    }
811
812    #[test]
813    fn parse_conditional_records_branches_and_default() {
814        let sheet = AdamAstParser::new()
815            .parse_str(
816                r#"
817                sheet s {
818                    conditional mode {
819                        0i32 => { relationship { height := width; } },
820                        _ => { relationship { height := width; } },
821                    }
822                }
823            "#,
824            )
825            .unwrap();
826        let ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
827            panic!("expected Conditional");
828        };
829        assert!(matches!(&cond.match_expr, Expr::Ident { name, .. } if name == "mode"));
830        assert_eq!(cond.branches.len(), 1);
831        assert!(!cond.branches[0].negated);
832        assert!(cond.default.is_some());
833    }
834
835    #[test]
836    fn parse_conditional_records_a_negated_literal_branch_key() {
837        let sheet = AdamAstParser::new()
838            .parse_str(
839                r#"
840                sheet s {
841                    conditional mode {
842                        -1i32 => { relationship { height := width; } },
843                        _ => { relationship { height := width; } },
844                    }
845                }
846            "#,
847            )
848            .unwrap();
849        let ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
850            panic!("expected Conditional");
851        };
852        assert_eq!(cond.branches.len(), 1);
853        assert!(cond.branches[0].negated);
854    }
855
856    #[test]
857    fn parse_conditional_branch_dash_not_followed_by_a_literal_is_error() {
858        let sheet = AdamAstParser::new()
859            .parse_str(
860                r#"
861                sheet s {
862                    conditional mode {
863                        -width => { relationship { height := width; } },
864                    }
865                }
866            "#,
867            )
868            .unwrap();
869        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
870    }
871
872    #[test]
873    fn parse_conditional_branch_literal_with_invalid_suffix_is_error() {
874        // `10xyz` isn't a `cel_parser`-recognized literal (unrecognized integer suffix) — must
875        // be rejected here exactly as the runtime parser rejects it, not silently accepted into
876        // the CST and later round-tripped by `fmt`.
877        let sheet = AdamAstParser::new()
878            .parse_str(
879                r#"
880                sheet s {
881                    conditional mode {
882                        10xyz => { relationship { height := width; } },
883                    }
884                }
885            "#,
886            )
887            .unwrap();
888        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
889    }
890
891    #[test]
892    fn parse_conditional_branch_negating_an_unsigned_literal_is_error() {
893        // `-1u32` has no `cel_parser` unary `-` overload — must be rejected here exactly as the
894        // runtime parser rejects it, not silently accepted into the CST.
895        let sheet = AdamAstParser::new()
896            .parse_str(
897                r#"
898                sheet s {
899                    conditional mode {
900                        -1u32 => { relationship { height := width; } },
901                    }
902                }
903            "#,
904            )
905            .unwrap();
906        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
907    }
908
909    #[test]
910    fn parse_conditional_records_an_expression_match_subject() {
911        let sheet = AdamAstParser::new()
912            .parse_str(
913                r#"
914                sheet s {
915                    conditional a && b {
916                        _ => { relationship { height := width; } },
917                    }
918                }
919            "#,
920            )
921            .unwrap();
922        let ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
923            panic!("expected Conditional");
924        };
925        assert!(matches!(
926            &cond.match_expr,
927            Expr::Logical {
928                op: cel_parser::LogicalOp::And,
929                ..
930            }
931        ));
932    }
933
934    /// Verifies embedded CEL conditions and conditional subjects reserve their body braces.
935    #[test]
936    fn adam_condition_and_match_headers_reserve_braces() {
937        for source in [
938            "sheet s { conditional flag { _ => { relationship { x := if flag { 1 } else { 2 }; } } } }",
939            "sheet s { conditional (TestPoint { x: 0.0, y: 1.0 }).x { _ => { relationship { x := TestPoint { x: 2.0, y: 3.0 }; } } } }",
940        ] {
941            let sheet = AdamAstParser::new().parse_str(source).unwrap();
942            assert!(sheet.errors.is_empty(), "{:?}", sheet.errors);
943            assert!(matches!(sheet.items[0], ast::SheetItem::Conditional(_)));
944            let formatted = crate::fmt::format_sheet(&sheet, source);
945            let reparsed = AdamAstParser::new().parse_str(&formatted).unwrap();
946            assert!(
947                reparsed.errors.is_empty(),
948                "{formatted}\n{:?}",
949                reparsed.errors
950            );
951            assert_eq!(crate::fmt::format_sheet(&reparsed, &formatted), formatted);
952        }
953    }
954
955    #[test]
956    fn parse_conditional_branch_records_multiple_relationships() {
957        let sheet = AdamAstParser::new()
958            .parse_str(
959                r#"
960                sheet s {
961                    conditional mode {
962                        0i32 => {
963                            relationship { b := a; }
964                            relationship { d := c; }
965                        },
966                    }
967                }
968            "#,
969            )
970            .unwrap();
971        let ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
972            panic!("expected Conditional");
973        };
974        assert_eq!(cond.branches[0].relationships.len(), 2);
975    }
976
977    #[test]
978    fn parse_conditional_default_branch_records_multiple_relationships() {
979        let sheet = AdamAstParser::new()
980            .parse_str(
981                r#"
982                sheet s {
983                    conditional mode {
984                        _ => {
985                            relationship { b := a; }
986                            relationship { d := c; }
987                        },
988                    }
989                }
990            "#,
991            )
992            .unwrap();
993        let ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
994            panic!("expected Conditional");
995        };
996        assert_eq!(
997            cond.default.as_ref().map(|d| d.relationships.len()),
998            Some(2)
999        );
1000    }
1001
1002    #[test]
1003    fn conditional_branch_bare_binding_without_relationship_wrapper_recovers() {
1004        // A branch body is now `{ relationship_decl }`, not `{ binding }` directly — a bare binding is
1005        // a syntax error, recovered at the enclosing conditional_decl's sheet-item level (see
1006        // `recovery_malformed_conditional_item_recovers`).
1007        let sheet = AdamAstParser::new()
1008            .parse_str(
1009                r#"
1010                sheet s {
1011                    cell good_before: i32 = 1;
1012                    conditional mode { 0i32 => { b := a; } }
1013                    cell good_after: i32 = 2;
1014                }
1015            "#,
1016            )
1017            .unwrap();
1018        assert_eq!(sheet.errors.len(), 1);
1019        assert_eq!(sheet.items.len(), 3);
1020        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1021        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1022        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1023    }
1024
1025    #[test]
1026    fn parse_binding_body_is_a_cel_expr_tree() {
1027        let sheet = AdamAstParser::new()
1028            .parse_str("sheet s { relationship { c := (a + b, a - b); } }")
1029            .unwrap();
1030        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1031            panic!("expected Relationship");
1032        };
1033        assert!(matches!(rel.bindings[0].body, Expr::Tuple { .. }));
1034    }
1035
1036    #[test]
1037    fn parse_unknown_sheet_item_is_recorded_as_an_error_item() {
1038        let sheet = AdamAstParser::new()
1039            .parse_str("sheet s { bogus x; }")
1040            .unwrap();
1041        assert_eq!(sheet.errors.len(), 1);
1042        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
1043    }
1044
1045    #[test]
1046    fn parse_malformed_cell_is_recorded_as_an_error_item() {
1047        let sheet = AdamAstParser::new()
1048            .parse_str("sheet s { cell x unknown_syntax }")
1049            .unwrap();
1050        assert_eq!(sheet.errors.len(), 1);
1051        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
1052    }
1053
1054    #[test]
1055    fn recovery_records_an_error_item_and_continues_parsing() {
1056        let sheet = AdamAstParser::new()
1057            .parse_str(
1058                r#"
1059                sheet s {
1060                    cell good_before: i32 = 1;
1061                    cell bad unknown_syntax
1062                    cell good_after: i32 = 2;
1063                }
1064            "#,
1065            )
1066            .unwrap();
1067        assert_eq!(sheet.items.len(), 3);
1068        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1069        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1070        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1071        assert_eq!(sheet.errors.len(), 1);
1072    }
1073
1074    #[test]
1075    fn recovery_error_span_covers_tokens_actually_consumed_by_the_failed_item() {
1076        // When recovery stops immediately (the very next token is already the sibling
1077        // keyword, so skip_to_recovery_point consumes nothing itself), the Error item's span
1078        // must still cover whatever the failed production consumed before giving up (here,
1079        // "cell bad" -- the name it read before failing the ':'/'=' check) rather than
1080        // collapsing to just its first token ("cell").
1081        let source = "sheet s { cell bad relationship { y := x; } }";
1082        let sheet = AdamAstParser::new().parse_str(source).unwrap();
1083        let ast::SheetItem::Error { span, .. } = &sheet.items[0] else {
1084            panic!("expected Error");
1085        };
1086        let range = cel_parser::SourceSpan {
1087            start: span.start.start(),
1088            end: span.end.end(),
1089        }
1090        .to_byte_range(source);
1091        assert_eq!(&source[range], "cell bad");
1092    }
1093
1094    #[test]
1095    fn recovery_collects_multiple_errors_from_multiple_malformed_items() {
1096        let sheet = AdamAstParser::new()
1097            .parse_str(
1098                r#"
1099                sheet s {
1100                    cell bad1 unknown_syntax;
1101                    cell bad2 unknown_syntax;
1102                    cell good: i32 = 1;
1103                }
1104            "#,
1105            )
1106            .unwrap();
1107        assert_eq!(sheet.errors.len(), 2);
1108        assert!(matches!(sheet.items.last(), Some(ast::SheetItem::Cell(_))));
1109    }
1110
1111    #[test]
1112    fn well_formed_input_has_empty_errors() {
1113        let sheet = AdamAstParser::new()
1114            .parse_str("sheet s { cell x: i32 = 1; }")
1115            .unwrap();
1116        assert!(sheet.errors.is_empty());
1117    }
1118
1119    /// Regression test for a bug where `skip_to_recovery_point` tracked nesting depth with a
1120    /// fresh local counter starting at 0 on every call, instead of the cursor's actual running
1121    /// depth. A malformed `relationship { .. }` item opens its own `{` before the inner error (on
1122    /// `bad`, which isn't a valid binding) is detected, so recovery begins already one delimiter
1123    /// deep; the old code treated the relationship's own closing `}` as if it were back at
1124    /// sheet-item level, leaving it and everything after unconsumed and causing the whole parse
1125    /// to abort with `Err` instead of recovering just this one item.
1126    #[test]
1127    fn recovery_malformed_relationship_item_recovers() {
1128        let sheet = AdamAstParser::new()
1129            .parse_str(
1130                r#"
1131                sheet s {
1132                    cell good_before: i32 = 1;
1133                    relationship { bad }
1134                    cell good_after: i32 = 2;
1135                }
1136            "#,
1137            )
1138            .unwrap();
1139        assert_eq!(sheet.errors.len(), 1);
1140        assert_eq!(sheet.items.len(), 3);
1141        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1142        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1143        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1144    }
1145
1146    /// Same regression as `recovery_malformed_relationship_item_recovers`, but for a malformed
1147    /// `conditional` item, which — like `relationship` — unconditionally opens its own `{`
1148    /// before any inner error can occur.
1149    #[test]
1150    fn recovery_malformed_conditional_item_recovers() {
1151        let sheet = AdamAstParser::new()
1152            .parse_str(
1153                r#"
1154                sheet s {
1155                    cell good_before: i32 = 1;
1156                    conditional m { bad }
1157                    cell good_after: i32 = 2;
1158                }
1159            "#,
1160            )
1161            .unwrap();
1162        assert_eq!(sheet.errors.len(), 1);
1163        assert_eq!(sheet.items.len(), 3);
1164        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1165        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1166        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1167    }
1168
1169    /// Deeper regression case: the syntax error occurs inside a binding's own body expression (an
1170    /// incomplete CEL expression), one delimiter below the sheet-item level (the relationship's own
1171    /// `{`) — unlike the old `method_decl` grammar, a binding's body has no brace of its own, so
1172    /// there is one less delimiter to unwind through than before. Recovery must still land at
1173    /// sheet-item level rather than aborting the whole parse.
1174    #[test]
1175    fn recovery_malformed_binding_body_recovers_at_sheet_item_level() {
1176        let sheet = AdamAstParser::new()
1177            .parse_str(
1178                r#"
1179                sheet s {
1180                    cell good_before: i32 = 1;
1181                    relationship {
1182                        b := a + ;
1183                    }
1184                    cell good_after: i32 = 2;
1185                }
1186            "#,
1187            )
1188            .unwrap();
1189        assert_eq!(sheet.errors.len(), 1);
1190        assert_eq!(sheet.items.len(), 3);
1191        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1192        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1193        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1194    }
1195
1196    /// Regression test for <https://github.com/stlab/cel-rs/issues/43>: a malformed CEL
1197    /// expression like `(+)` causes the embedded CEL sub-parser to consume the opening `(` (via
1198    /// `is_tuple_or_group`) but fail before consuming the matching `)`, since it never went
1199    /// through `TokenCursor` (see `TokenCursor::depth`'s own docs). Previously this dangling `)`
1200    /// was indistinguishable, by delimiter kind alone, from the enclosing `relationship`'s own
1201    /// paren-tracked nesting, so `skip_to_recovery_point` mis-stopped one delimiter early and
1202    /// aborted the whole parse with `Err` instead of isolating just this one malformed item. Now
1203    /// `cel_parser::Parser::unbalanced_delimiter_count` reports the dangling `)` back to
1204    /// `TokenCursor::absorb_unbalanced_delimiters`, so recovery correctly unwinds through it and
1205    /// isolates just the malformed `relationship` item.
1206    #[test]
1207    fn recovery_cel_dangling_paren_recovers_at_sheet_item_level() {
1208        let sheet = AdamAstParser::new()
1209            .parse_str(
1210                r#"
1211                sheet s {
1212                    cell good_before: i32 = 1;
1213                    relationship { b := (+); }
1214                    cell good_after: i32 = 2;
1215                }
1216            "#,
1217            )
1218            .unwrap();
1219        assert_eq!(sheet.errors.len(), 1);
1220        assert_eq!(sheet.items.len(), 3);
1221        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1222        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1223        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1224    }
1225
1226    /// Regression test for <https://github.com/stlab/cel-rs/issues/43>: a dangling `}` left by a
1227    /// failed CEL `if`-expression. `is_if_expression` consumes the then-branch's opening `{`
1228    /// directly (bypassing `TokenCursor`, exactly like the paren case above) but fails before
1229    /// consuming the matching `}` when the then-branch itself fails to parse (here, an empty
1230    /// `{ }`). Because CEL's `if`/`else` grammar reuses `Delimiter::Brace` — the same kind
1231    /// adam-lang's own `relationship`/`conditional` blocks use — this dangling `}` was previously
1232    /// mistaken for the `relationship`'s own closing brace, one delimiter early. Now
1233    /// `TokenCursor::absorb_unbalanced_delimiters` accounts for it, so recovery isolates just the
1234    /// malformed `relationship` item.
1235    #[test]
1236    fn recovery_if_expr_dangling_brace_recovers_at_sheet_item_level() {
1237        let sheet = AdamAstParser::new()
1238            .parse_str(
1239                r#"
1240                sheet s {
1241                    cell good_before: i32 = 1;
1242                    relationship { b := if a { }; }
1243                    cell good_after: i32 = 2;
1244                }
1245            "#,
1246            )
1247            .unwrap();
1248        assert_eq!(sheet.errors.len(), 1);
1249        assert_eq!(sheet.items.len(), 3);
1250        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1251        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1252        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1253    }
1254
1255    /// A variant of `recovery_if_expr_dangling_brace_recovers_at_sheet_item_level`: here the CEL
1256    /// sub-expression fails on a bare `+` (no valid expression follows it), which the failed
1257    /// `is_or_expression` call doesn't consume — so the very next token `skip_to_recovery_point`
1258    /// sees is a keyword-shaped identifier (`cell`) written just after it. Because that
1259    /// identifier is encountered while `depth` is still elevated (still inside the
1260    /// `relationship`'s own brace, not yet unwound, plus the dangling brace's now-absorbed depth),
1261    /// the `at_or_below_target` guard on the `cell`/`relationship`/`conditional` stopping check
1262    /// doesn't fire for it, so it's swallowed as ordinary garbage rather than treated as a
1263    /// boundary — recovery then continues past it and still isolates just the malformed
1264    /// `relationship` item once it reaches the (now correctly tracked) dangling `}`.
1265    #[test]
1266    fn recovery_keyword_shaped_garbage_inside_dangling_brace_recovers_cleanly() {
1267        let sheet = AdamAstParser::new()
1268            .parse_str(
1269                "sheet s { relationship { b := if a { + cell good: i32 = 1; }; } cell trailing: i32 = 2; }",
1270            )
1271            .unwrap();
1272        assert_eq!(sheet.errors.len(), 1);
1273        assert_eq!(sheet.items.len(), 2);
1274        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
1275        assert!(matches!(sheet.items[1], ast::SheetItem::Cell(_)));
1276    }
1277
1278    #[test]
1279    fn parse_out_with_explicit_type_and_no_requirements() {
1280        let sheet = AdamAstParser::new()
1281            .parse_str(
1282                r#"
1283            sheet s {
1284                cell width: f64 = 4.0;
1285                cell height: f64 = 3.0;
1286                out area: f64 := width * height;
1287            }
1288        "#,
1289            )
1290            .unwrap();
1291        assert!(sheet.errors.is_empty());
1292        let ast::SheetItem::Out(out) = &sheet.items[2] else {
1293            panic!("expected Out");
1294        };
1295        assert_eq!(out.name, "area");
1296        assert!(matches!(
1297            out.type_name.as_ref().unwrap(),
1298            ast::TypeExpr::Named { name: n, .. } if n == "f64"
1299        ));
1300        assert!(matches!(out.initializer, Expr::Op { ref name, .. } if name == "*"));
1301        assert!(out.require.is_none());
1302    }
1303
1304    #[test]
1305    fn parse_out_with_no_type_annotation() {
1306        let sheet = AdamAstParser::new()
1307            .parse_str("sheet s { out area := width; }")
1308            .unwrap();
1309        let ast::SheetItem::Out(out) = &sheet.items[0] else {
1310            panic!("expected Out");
1311        };
1312        assert!(out.type_name.is_none());
1313    }
1314
1315    #[test]
1316    fn parse_out_with_requirements_in_declaration_order() {
1317        let sheet = AdamAstParser::new()
1318            .parse_str(
1319                r#"
1320            sheet s {
1321                out area: f64 := width * height require {
1322                    @max_area width * height <= max_area;
1323                    @max_width width <= max_width;
1324                };
1325            }
1326        "#,
1327            )
1328            .unwrap();
1329        let ast::SheetItem::Out(out) = &sheet.items[0] else {
1330            panic!("expected Out");
1331        };
1332        let require = out.require.as_ref().expect("require block present");
1333        assert_eq!(require.requirements.len(), 2);
1334        assert_eq!(require.requirements[0].name.as_deref(), Some("max_area"));
1335        assert_eq!(require.requirements[1].name.as_deref(), Some("max_width"));
1336    }
1337
1338    #[test]
1339    fn parse_requirement_without_a_label_leaves_name_none() {
1340        let sheet = AdamAstParser::new()
1341            .parse_str(
1342                r#"
1343            sheet s {
1344                out area: f64 := width * height require {
1345                    width * height <= max_area;
1346                };
1347            }
1348        "#,
1349            )
1350            .unwrap();
1351        let ast::SheetItem::Out(out) = &sheet.items[0] else {
1352            panic!("expected Out");
1353        };
1354        let require = out.require.as_ref().expect("require block present");
1355        assert_eq!(require.requirements[0].name, None);
1356    }
1357
1358    #[test]
1359    fn parse_malformed_out_is_recorded_as_an_error_item() {
1360        let sheet = AdamAstParser::new()
1361            .parse_str(
1362                r#"
1363            sheet s {
1364                cell good_before: i32 = 1;
1365                out area { bad }
1366                cell good_after: i32 = 2;
1367            }
1368        "#,
1369            )
1370            .unwrap();
1371        assert_eq!(sheet.errors.len(), 1);
1372        assert_eq!(sheet.items.len(), 3);
1373        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1374        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1375        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1376    }
1377
1378    #[test]
1379    fn parse_cell_with_explicit_tuple_type() {
1380        let sheet = AdamAstParser::new()
1381            .parse_str("sheet s { cell a: (i32, f64); }")
1382            .unwrap();
1383        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1384            panic!("expected Cell");
1385        };
1386        match cell.type_name.as_ref().unwrap() {
1387            ast::TypeExpr::Tuple { elements, .. } => {
1388                assert_eq!(elements.len(), 2);
1389                assert!(matches!(&elements[0], ast::TypeExpr::Named { name: n, .. } if n == "i32"));
1390                assert!(matches!(&elements[1], ast::TypeExpr::Named { name: n, .. } if n == "f64"));
1391            }
1392            other => panic!("expected Tuple, got {other:?}"),
1393        }
1394    }
1395
1396    #[test]
1397    fn parse_cell_with_a_filter() {
1398        let sheet = AdamAstParser::new()
1399            .parse_str("sheet s { cell a: i32 = 1 filter _; }")
1400            .unwrap();
1401        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1402            panic!("expected Cell");
1403        };
1404        let filter = cell.filter.as_ref().expect("filter present");
1405        assert!(matches!(&filter.body, Expr::Ident { name, .. } if name == "_"));
1406    }
1407
1408    #[test]
1409    fn parse_cell_with_a_filter_referencing_a_cell() {
1410        let sheet = AdamAstParser::new()
1411            .parse_str("sheet s { cell hi: i32 = 100; cell a: i32 = 1 filter _ + hi; }")
1412            .unwrap();
1413        let ast::SheetItem::Cell(cell) = &sheet.items[1] else {
1414            panic!("expected Cell");
1415        };
1416        let filter = cell.filter.as_ref().expect("filter present");
1417        match &filter.body {
1418            Expr::Op { name, operands, .. } => {
1419                assert_eq!(name, "+");
1420                assert!(matches!(&operands[0], Expr::Ident { name, .. } if name == "_"));
1421                assert!(matches!(&operands[1], Expr::Ident { name, .. } if name == "hi"));
1422            }
1423            other => panic!("expected Op, got {other:?}"),
1424        }
1425    }
1426
1427    #[test]
1428    fn parse_cell_without_a_filter_leaves_it_none() {
1429        let sheet = AdamAstParser::new()
1430            .parse_str("sheet s { cell a: i32 = 1; }")
1431            .unwrap();
1432        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1433            panic!("expected Cell");
1434        };
1435        assert!(cell.filter.is_none());
1436    }
1437
1438    #[test]
1439    fn recovery_malformed_filter_recovers_at_the_next_sheet_item() {
1440        let sheet = AdamAstParser::new()
1441            .parse_str(
1442                r#"
1443                sheet s {
1444                    cell good_before: i32 = 1;
1445                    cell bad: i32 = 1 filter |x: i32|;
1446                    cell good_after: i32 = 2;
1447                }
1448            "#,
1449            )
1450            .unwrap();
1451        assert_eq!(sheet.errors.len(), 1);
1452        assert_eq!(sheet.items.len(), 3);
1453        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1454        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1455        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1456    }
1457
1458    #[test]
1459    fn parse_cell_with_nested_tuple_type() {
1460        let sheet = AdamAstParser::new()
1461            .parse_str("sheet s { cell a: (i32, (f64, String)); }")
1462            .unwrap();
1463        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1464            panic!("expected Cell");
1465        };
1466        let ast::TypeExpr::Tuple { elements, .. } = cell.type_name.as_ref().unwrap() else {
1467            panic!("expected top-level Tuple");
1468        };
1469        assert_eq!(elements.len(), 2);
1470        assert!(matches!(&elements[0], ast::TypeExpr::Named { name: n, .. } if n == "i32"));
1471        match &elements[1] {
1472            ast::TypeExpr::Tuple {
1473                elements: inner, ..
1474            } => assert_eq!(inner.len(), 2),
1475            other => panic!("expected nested Tuple, got {other:?}"),
1476        }
1477    }
1478
1479    #[test]
1480    fn parse_cell_with_empty_tuple_type() {
1481        let sheet = AdamAstParser::new()
1482            .parse_str("sheet s { cell a: (); }")
1483            .unwrap();
1484        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1485            panic!("expected Cell");
1486        };
1487        match cell.type_name.as_ref().unwrap() {
1488            ast::TypeExpr::Tuple { elements, .. } => assert!(elements.is_empty()),
1489            other => panic!("expected empty Tuple, got {other:?}"),
1490        }
1491    }
1492
1493    #[test]
1494    fn parse_cell_with_parenthesized_type_is_grouping_not_a_1_tuple() {
1495        let sheet = AdamAstParser::new()
1496            .parse_str("sheet s { cell a: (i32); }")
1497            .unwrap();
1498        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1499            panic!("expected Cell");
1500        };
1501        assert!(matches!(
1502            cell.type_name.as_ref().unwrap(),
1503            ast::TypeExpr::Named { name: n, .. } if n == "i32"
1504        ));
1505    }
1506
1507    #[test]
1508    fn parse_cell_with_1_tuple_type_requires_trailing_comma() {
1509        let sheet = AdamAstParser::new()
1510            .parse_str("sheet s { cell a: (i32,); }")
1511            .unwrap();
1512        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1513            panic!("expected Cell");
1514        };
1515        match cell.type_name.as_ref().unwrap() {
1516            ast::TypeExpr::Tuple { elements, .. } => assert_eq!(elements.len(), 1),
1517            other => panic!("expected 1-Tuple, got {other:?}"),
1518        }
1519    }
1520
1521    #[test]
1522    fn parse_cell_initializer_is_a_tuple_expr() {
1523        let sheet = AdamAstParser::new()
1524            .parse_str("sheet s { cell a = (1, 2.5); }")
1525            .unwrap();
1526        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1527            panic!("expected Cell");
1528        };
1529        assert!(matches!(cell.initializer, Some(Expr::Tuple { .. })));
1530    }
1531
1532    #[test]
1533    fn parse_out_with_explicit_tuple_type() {
1534        let sheet = AdamAstParser::new()
1535            .parse_str("sheet s { out a: (i32, f64) := (x, x); }")
1536            .unwrap();
1537        let ast::SheetItem::Out(out) = &sheet.items[0] else {
1538            panic!("expected Out");
1539        };
1540        assert!(matches!(
1541            out.type_name.as_ref().unwrap(),
1542            ast::TypeExpr::Tuple { elements, .. } if elements.len() == 2
1543        ));
1544    }
1545
1546    #[test]
1547    fn malformed_tuple_type_recovers_at_the_next_sheet_item() {
1548        // Note: unlike a typical "forgot the closing delimiter" typo, the `(`/`)` pair here must
1549        // stay balanced overall — `proc_macro2::TokenStream::from_str` tokenizes delimiters as a
1550        // matched tree up front, so a source string with a truly unmatched paren anywhere fails
1551        // to tokenize at all (`LexError`), before adam-lang-level parsing (and its recovery) ever
1552        // runs. The malformed part is the missing `,` between the two type names, not the parens.
1553        let sheet = AdamAstParser::new()
1554            .parse_str("sheet s { cell good_before: i32 = 1; cell bad: (i32 i32); cell good_after: i32 = 2; }")
1555            .unwrap();
1556        assert_eq!(sheet.errors.len(), 1);
1557        assert_eq!(sheet.items.len(), 3);
1558        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1559        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1560        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1561    }
1562
1563    #[test]
1564    fn malformed_generic_type_argument_list_recovers_at_the_next_sheet_item() {
1565        // Same balanced-delimiter caveat as the tuple-type test above: the missing `,` between
1566        // the two type arguments, not an unmatched paren, is what's malformed here.
1567        let sheet = AdamAstParser::new()
1568            .parse_str(
1569                "sheet s { cell good_before: i32 = 1; cell bad: Pair(i32 i32); cell good_after: i32 = 2; }",
1570            )
1571            .unwrap();
1572        assert_eq!(sheet.errors.len(), 1);
1573        assert_eq!(sheet.items.len(), 3);
1574        assert!(matches!(sheet.items[0], ast::SheetItem::Cell(_)));
1575        assert!(matches!(sheet.items[1], ast::SheetItem::Error { .. }));
1576        assert!(matches!(sheet.items[2], ast::SheetItem::Cell(_)));
1577    }
1578
1579    #[test]
1580    fn attaches_an_outer_doc_comment_to_a_cell() {
1581        let sheet = AdamAstParser::new()
1582            .parse_str("sheet s {\n    /// the total\n    cell x: i32 = 1;\n}")
1583            .unwrap();
1584        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1585            panic!("expected Cell");
1586        };
1587        assert_eq!(cell.doc_comment.as_deref(), Some(" the total"));
1588    }
1589
1590    #[test]
1591    fn attaches_an_outer_doc_comment_to_a_relationship() {
1592        let sheet = AdamAstParser::new()
1593            .parse_str("sheet s {\n    /// docs\n    relationship { b := a; }\n}")
1594            .unwrap();
1595        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1596            panic!("expected Relationship");
1597        };
1598        assert_eq!(rel.doc_comment.as_deref(), Some(" docs"));
1599    }
1600
1601    #[test]
1602    fn attaches_an_outer_doc_comment_to_a_conditional() {
1603        let sheet = AdamAstParser::new()
1604            .parse_str(
1605                "sheet s {\n    cell p: i32 = 0;\n    /// docs\n    conditional p {\n        _ => { relationship { b := a; } }\n    }\n}",
1606            )
1607            .unwrap();
1608        let ast::SheetItem::Conditional(cond) = &sheet.items[1] else {
1609            panic!("expected Conditional");
1610        };
1611        assert_eq!(cond.doc_comment.as_deref(), Some(" docs"));
1612    }
1613
1614    #[test]
1615    fn attaches_an_outer_doc_comment_to_an_out_decl() {
1616        let sheet = AdamAstParser::new()
1617            .parse_str("sheet s {\n    /// docs\n    out area: f64 := w;\n}")
1618            .unwrap();
1619        let ast::SheetItem::Out(out) = &sheet.items[0] else {
1620            panic!("expected Out");
1621        };
1622        assert_eq!(out.doc_comment.as_deref(), Some(" docs"));
1623    }
1624
1625    #[test]
1626    fn attaches_a_sheet_level_inner_doc_comment() {
1627        let sheet = AdamAstParser::new()
1628            .parse_str("//! module docs\nsheet s {\n    cell x: i32 = 1;\n}")
1629            .unwrap();
1630        assert_eq!(sheet.doc_comment.as_deref(), Some(" module docs"));
1631    }
1632
1633    #[test]
1634    fn joins_consecutive_outer_doc_comment_lines() {
1635        let sheet = AdamAstParser::new()
1636            .parse_str("sheet s {\n    /// line one\n    /// line two\n    cell x: i32 = 1;\n}")
1637            .unwrap();
1638        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1639            panic!("expected Cell");
1640        };
1641        assert_eq!(cell.doc_comment.as_deref(), Some(" line one\n line two"));
1642    }
1643
1644    #[test]
1645    fn a_doc_comment_binds_forward_across_a_blank_line() {
1646        let sheet = AdamAstParser::new()
1647            .parse_str("sheet s {\n    /// docs\n\n    cell x: i32 = 1;\n}")
1648            .unwrap();
1649        let ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1650            panic!("expected Cell");
1651        };
1652        assert_eq!(cell.doc_comment.as_deref(), Some(" docs"));
1653    }
1654
1655    #[test]
1656    fn a_plain_comment_and_a_doc_comment_coexist_in_source_order() {
1657        // NOTE: deviates from the task brief's literal source string, which places `cell x` as
1658        // the sheet's *first* item. `trivia::attach_gaps` never attaches a leading comment to a
1659        // list's first element (nothing precedes it but the enclosing `{` — an out-of-scope,
1660        // pre-existing gap tracked by issue #52's "trailing" counterpart in trivia.rs, unrelated
1661        // to doc-comment parsing; see this task's report for the full diagnosis), so the literal
1662        // brief source can never satisfy the `leading_comment` assertion below regardless of this
1663        // task's changes. A leading sibling item here exercises the exact same doc-comment/
1664        // plain-comment interaction the brief intends (span-widening must stop the plain-comment
1665        // gap scan before the doc comment's own source text) via `attach_gaps`'s normal,
1666        // already-working non-first-item path instead.
1667        let source =
1668            "sheet s {\n    cell w: i32 = 0;\n    // TODO\n    /// docs\n    cell x: i32 = 1;\n}";
1669        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1670        crate::attach_trivia(source, &mut sheet);
1671        let ast::SheetItem::Cell(cell) = &sheet.items[1] else {
1672            panic!("expected Cell");
1673        };
1674        assert_eq!(cell.doc_comment.as_deref(), Some(" docs"));
1675        assert_eq!(
1676            cell.leading_comment,
1677            Some(ast::Comment::Line("TODO".to_string()))
1678        );
1679    }
1680
1681    #[test]
1682    fn a_doc_comment_before_a_binding_recovers_as_a_declaration_level_error() {
1683        let sheet = AdamAstParser::new()
1684            .parse_str(
1685                "sheet s {\n    relationship {\n        /// not allowed here\n        b := a;\n    }\n}",
1686            )
1687            .unwrap();
1688        assert!(!sheet.errors.is_empty());
1689        assert!(matches!(sheet.items[0], ast::SheetItem::Error { .. }));
1690    }
1691
1692    #[test]
1693    fn range_syntax_is_reachable_from_a_relationship_binding() {
1694        let sheet = AdamAstParser::new()
1695            .parse_str("sheet s { relationship { x := 1..5; } }")
1696            .unwrap();
1697        let ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1698            panic!("expected a Relationship item, got {:?}", sheet.items[0]);
1699        };
1700        assert!(
1701            matches!(&rel.bindings[0].body, cel_parser::Expr::Op { name, .. } if name == "range"),
1702            "expected the binding body to be a range Expr::Op, got {:?}",
1703            rel.bindings[0].body
1704        );
1705    }
1706}