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