Skip to main content

adam_lang/
trivia.rs

1//! Recovers comments and blank-line-before flags discarded/erased by `proc_macro2`'s tokenizer,
2//! re-slicing the gap between two consecutive AST nodes' spans (the same technique `rustfmt` uses
3//! for the identical problem — see `cel-parser/src/lex_lexer.rs`'s `test_span_preservation`), and
4//! attaches each to the nearest following node. Applied recursively to every sibling list in the
5//! tree — `Sheet.items`, a `RelationshipDecl`'s `bindings`, a `ConditionalDecl`'s `branches` and
6//! `default`, each `ConditionalBranch`'s `relationships`, and a `CellDecl`/`SourceDecl`/`OutDecl`'s
7//! `require` block's `requirements` — not just the top level. Also
8//! recovers a comment preceding the `sheet` keyword itself (e.g. a file header) into
9//! `Sheet.leading_comment` — the one gap with no enclosing sibling list to attach via, so it's
10//! handled directly against the start of `source` rather than through [`attach_gaps`].
11//!
12//! Gap comments remain in source order even when blank lines detach them from an item.
13//! A single comment retains its style; multiple detached or mixed runs normalize to line-comment
14//! text because each ordinary AST attachment stores one comment. `blank_line_before` records separation
15//! independently: it reflects whether *any* blank line
16//! remained in the gap once the (possibly absent) attached trailing comment run was accounted
17//! for, so `cell a;\n\n// c\ncell b;`'s blank line (before the comment, not after it) still marks
18//! `b.blank_line_before` true even though the comment still attaches to `b`.
19//!
20//! A comment or blank line in the gap between a block's *last* item and that block's own closing
21//! `}` (or, for a child-empty block, between its opening `{` and closing `}`) has nothing
22//! following it for the above machinery to attach to, so it is recovered separately, into each
23//! container's own `trailing_comment`/`blank_line_before_close` fields, by [`attach_trailing`]
24//! and its special-cased sibling [`attach_conditional_trailing`] (a `CellDecl`/`SourceDecl`/
25//! `OutDecl`'s `require` block, when present, uses the standard [`attach_trailing`] path
26//! directly, like any other container).
27//! See <https://github.com/stlab/cel-rs/issues/52>.
28//!
29//! Attachment costs below use `v` for visited AST nodes, `g` for the sum of scanned
30//! gap bytes, `c` for the sum of character-column prefixes traversed by coordinate
31//! conversions, and `h` for attachment call depth. Repeated scans count repeatedly.
32//! A shared line-start table avoids rebuilding the index, but conversions still
33//! traverse each line's character prefix; many items on one long line can make
34//! `c` quadratic in that line's length. Recovered comment storage is included in
35//! the space bounds.
36//!
37//! A `//`/`/* */` comment ending or beginning on the same source line as an adjacent node's own
38//! first/last token — rather than on a fresh line of its own — attaches as that adjacent node's
39//! own `trailing_line_comment` instead of the following node's `leading_comment`; see
40//! [`analyze_gap`]'s `same_line` result and <https://github.com/stlab/cel-rs/issues/59>. This
41//! reuses the same node-to-node gaps described above, so it covers every sibling list and
42//! closing-brace gap this module already tracks, but goes no further: a comment written *inside*
43//! a CEL expression body (between two sub-expression tokens, with no adam-lang-tracked node
44//! boundary between them) is invisible to this whole gap-scanning mechanism and is silently
45//! dropped on reformat — see <https://github.com/stlab/cel-rs/issues/201>.
46
47use cel_parser::trivia::{line_column_to_byte, line_start_byte_offsets};
48use proc_macro2::LineColumn;
49
50use crate::ast::{
51    BindingDecl, CellDecl, ConditionalBranch, ConditionalDecl, ExprSpan, OutDecl, RelationshipDecl,
52    RequireBlock, RequirementDecl, Sheet, SourceDecl,
53};
54
55/// An AST node that can carry recovered leading trivia, attached by [`attach_gaps`].
56trait TriviaTarget {
57    /// Returns storage for complete opening-gap comments when this node supports it.
58    fn opening_comments_mut(&mut self) -> Option<&mut Vec<crate::ast::Comment>> {
59        None
60    }
61
62    /// Returns this node's source range.
63    fn span(&self) -> ExprSpan;
64    /// Replaces this node's leading comment.
65    fn set_leading_comment(&mut self, comment: crate::ast::Comment);
66    /// Sets a `//`/`/* */` comment recovered on the same source line as this node's own last
67    /// token, immediately before the next sibling (or the enclosing container's own closing
68    /// `}`). See <https://github.com/stlab/cel-rs/issues/59>.
69    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment);
70    /// Records whether a blank line precedes this node.
71    fn set_blank_line_before(&mut self, value: bool);
72}
73
74impl TriviaTarget for crate::ast::SheetItem {
75    /// Returns the complete opening-comment sequence for a named relationship use.
76    fn opening_comments_mut(&mut self) -> Option<&mut Vec<crate::ast::Comment>> {
77        match self {
78            Self::RelationshipUse(call) => Some(&mut call.opening_comments),
79            _ => None,
80        }
81    }
82
83    /// Returns the selected declaration's source range.
84    fn span(&self) -> ExprSpan {
85        crate::ast::SheetItem::span(self)
86    }
87    /// Replaces the selected declaration's leading comment.
88    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
89        crate::ast::SheetItem::set_leading_comment(self, comment)
90    }
91    /// Replaces the selected declaration's same-line trailing comment.
92    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
93        crate::ast::SheetItem::set_trailing_line_comment(self, comment)
94    }
95    /// Records whether a blank line precedes the selected declaration.
96    fn set_blank_line_before(&mut self, value: bool) {
97        crate::ast::SheetItem::set_blank_line_before(self, value)
98    }
99}
100
101impl TriviaTarget for BindingDecl {
102    /// Returns the binding's source range.
103    fn span(&self) -> ExprSpan {
104        self.span
105    }
106    /// Replaces the binding's leading comment.
107    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
108        self.leading_comment = Some(comment);
109    }
110    /// Replaces the binding's same-line trailing comment.
111    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
112        self.trailing_line_comment = Some(comment);
113    }
114    /// Records whether a blank line precedes the binding.
115    fn set_blank_line_before(&mut self, value: bool) {
116        self.blank_line_before = value;
117    }
118}
119
120impl TriviaTarget for RelationshipDecl {
121    /// Returns the relationship's source range.
122    fn span(&self) -> ExprSpan {
123        self.span
124    }
125    /// Replaces the relationship's leading comment.
126    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
127        self.leading_comment = Some(comment);
128    }
129    /// Replaces the relationship's same-line trailing comment.
130    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
131        self.trailing_line_comment = Some(comment);
132    }
133    /// Records whether a blank line precedes the relationship.
134    fn set_blank_line_before(&mut self, value: bool) {
135        self.blank_line_before = value;
136    }
137}
138
139impl TriviaTarget for crate::ast::BranchItem {
140    /// Returns the complete opening-comment sequence for a named relationship use.
141    fn opening_comments_mut(&mut self) -> Option<&mut Vec<crate::ast::Comment>> {
142        match self {
143            Self::RelationshipUse(call) => Some(&mut call.opening_comments),
144            _ => None,
145        }
146    }
147
148    /// Returns the inline relationship or component use span.
149    fn span(&self) -> ExprSpan {
150        match self {
151            Self::Relationship(rel) => rel.span,
152            Self::RelationshipUse(call) => call.span,
153        }
154    }
155
156    /// Attaches a leading comment to this branch item.
157    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
158        match self {
159            Self::Relationship(rel) => rel.leading_comment = Some(comment),
160            Self::RelationshipUse(call) => call.leading_comment = Some(comment),
161        }
162    }
163
164    /// Attaches a same-line comment to this branch item.
165    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
166        match self {
167            Self::Relationship(rel) => rel.trailing_line_comment = Some(comment),
168            Self::RelationshipUse(call) => call.trailing_line_comment = Some(comment),
169        }
170    }
171
172    /// Records a preceding blank line.
173    fn set_blank_line_before(&mut self, value: bool) {
174        match self {
175            Self::Relationship(rel) => rel.blank_line_before = value,
176            Self::RelationshipUse(call) => call.blank_line_before = value,
177        }
178    }
179}
180
181impl TriviaTarget for ConditionalBranch {
182    /// Returns the named branch's source range.
183    fn span(&self) -> ExprSpan {
184        self.span
185    }
186    /// Replaces the named branch's leading comment.
187    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
188        self.leading_comment = Some(comment);
189    }
190    /// Replaces the named branch's same-line trailing comment.
191    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
192        self.trailing_line_comment = Some(comment);
193    }
194    /// Records whether a blank line precedes the named branch.
195    fn set_blank_line_before(&mut self, value: bool) {
196        self.blank_line_before = value;
197    }
198}
199
200impl TriviaTarget for RequirementDecl {
201    /// Returns the requirement's source range.
202    fn span(&self) -> ExprSpan {
203        self.span
204    }
205    /// Replaces the requirement's leading comment.
206    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
207        self.leading_comment = Some(comment);
208    }
209    /// Replaces the requirement's same-line trailing comment.
210    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
211        self.trailing_line_comment = Some(comment);
212    }
213    /// Records whether a blank line precedes the requirement.
214    fn set_blank_line_before(&mut self, value: bool) {
215        self.blank_line_before = value;
216    }
217}
218
219/// A container whose own `{ ... }` block may carry trailing trivia — a comment or blank line
220/// between its last child and its own closing `}` — recovered by [`attach_trailing`]. See
221/// <https://github.com/stlab/cel-rs/issues/52>.
222trait TrailingTriviaTarget {
223    /// The span of this container's own opening `{`, used as the trailing gap's start when its
224    /// child list is empty.
225    fn open_brace_span(&self) -> proc_macro2::Span;
226    /// The span of this container's own closing `}`.
227    fn close_span(&self) -> proc_macro2::Span;
228    /// Replaces the comment immediately before the closing brace.
229    fn set_trailing_comment(&mut self, comment: crate::ast::Comment);
230    /// Records whether a blank line precedes the closing brace.
231    fn set_blank_line_before_close(&mut self, value: bool);
232}
233
234impl TrailingTriviaTarget for Sheet {
235    /// Returns the sheet's opening brace span.
236    fn open_brace_span(&self) -> proc_macro2::Span {
237        self.open_brace_span.end
238    }
239    /// Returns the sheet's closing brace span.
240    fn close_span(&self) -> proc_macro2::Span {
241        self.span.end
242    }
243    /// Replaces the sheet's trailing interior comment.
244    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
245        self.trailing_comment = Some(comment);
246    }
247    /// Records whether a blank line precedes the sheet's closing brace.
248    fn set_blank_line_before_close(&mut self, value: bool) {
249        self.blank_line_before_close = value;
250    }
251}
252
253impl TrailingTriviaTarget for RelationshipDecl {
254    /// Returns the relationship's opening brace span.
255    fn open_brace_span(&self) -> proc_macro2::Span {
256        self.open_brace_span.end
257    }
258    /// Returns the relationship's closing brace span.
259    fn close_span(&self) -> proc_macro2::Span {
260        self.span.end
261    }
262    /// Replaces the relationship's trailing interior comment.
263    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
264        self.trailing_comment = Some(comment);
265    }
266    /// Records whether a blank line precedes the relationship's closing brace.
267    fn set_blank_line_before_close(&mut self, value: bool) {
268        self.blank_line_before_close = value;
269    }
270}
271
272impl TrailingTriviaTarget for RequireBlock {
273    /// Returns the requirement block's opening brace span.
274    fn open_brace_span(&self) -> proc_macro2::Span {
275        self.open_brace_span.end
276    }
277    /// Returns the requirement block's closing brace span.
278    fn close_span(&self) -> proc_macro2::Span {
279        self.span.end
280    }
281    /// Replaces the requirement block's trailing interior comment.
282    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
283        self.trailing_comment = Some(comment);
284    }
285    /// Records whether a blank line precedes the requirement block's closing brace.
286    fn set_blank_line_before_close(&mut self, value: bool) {
287        self.blank_line_before_close = value;
288    }
289}
290
291impl TrailingTriviaTarget for ConditionalBranch {
292    /// Returns the named branch's opening brace span.
293    fn open_brace_span(&self) -> proc_macro2::Span {
294        self.open_brace_span.end
295    }
296    /// Returns the named branch's closing brace span.
297    fn close_span(&self) -> proc_macro2::Span {
298        self.span.end
299    }
300    /// Replaces the named branch's trailing interior comment.
301    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
302        self.trailing_comment = Some(comment);
303    }
304    /// Records whether a blank line precedes the named branch's closing brace.
305    fn set_blank_line_before_close(&mut self, value: bool) {
306        self.blank_line_before_close = value;
307    }
308}
309
310impl TrailingTriviaTarget for crate::ast::DefaultBranch {
311    /// Returns the default arm's opening brace span.
312    fn open_brace_span(&self) -> proc_macro2::Span {
313        self.open_brace_span.end
314    }
315    /// Returns the default arm's closing brace span.
316    fn close_span(&self) -> proc_macro2::Span {
317        self.span.end
318    }
319    /// Replaces the default arm's trailing interior comment.
320    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
321        self.trailing_comment = Some(comment);
322    }
323    /// Records whether a blank line precedes the default arm's closing brace.
324    fn set_blank_line_before_close(&mut self, value: bool) {
325        self.blank_line_before_close = value;
326    }
327}
328
329/// Byte offset immediately after a container's own opening `{`.
330///
331/// `open_brace` is the flattened `OpenDelim` token's span, which `cel_parser::lex_lexer::LexLexer`
332/// sets to the *whole* delimited group's span (`proc_macro2::Group::span()`'s documented
333/// behavior) rather than to just the one-character `{` token — the same span value the matching
334/// `CloseDelim` token carries. So `open_brace.start()` is the position of the `{` character
335/// itself; advancing one byte (a brace is always exactly one ASCII byte) lands just past it.
336///
337/// - Complexity: O(c) time for the opening position's character column; O(1) space.
338fn after_open_brace(source: &str, line_starts: &[usize], open_brace: proc_macro2::Span) -> usize {
339    line_column_to_byte(source, line_starts, open_brace.start()) + 1
340}
341
342/// Byte offset immediately before a container's own closing `}` — the mirror of
343/// [`after_open_brace`], exploiting the same whole-group-span quirk from the other end:
344/// `close_brace.end()` is the position just past the `}`; stepping back one byte lands just
345/// before it.
346///
347/// - Complexity: O(c) time for the closing position's character column; O(1) space.
348fn before_close_brace(
349    source: &str,
350    line_starts: &[usize],
351    close_brace: proc_macro2::Span,
352) -> usize {
353    line_column_to_byte(source, line_starts, close_brace.end()) - 1
354}
355
356/// Recovers trailing trivia (a comment/blank line between the last child's end position —
357/// `last_child_end`, precomputed by the caller from its own child list before taking a mutable
358/// borrow of `container`, since `container` may be that same list's owner — and `container`'s
359/// own closing `}`, or between its opening `{` and closing `}` when `last_child_end` is `None`)
360/// and attaches it to `container`. See <https://github.com/stlab/cel-rs/issues/52>.
361///
362/// When the recovered comment's first line begins on the same source line as the last child's
363/// own last token, it belongs to that child's `trailing_line_comment` instead of `container`'s
364/// own `trailing_comment` (see <https://github.com/stlab/cel-rs/issues/59>) — but this function
365/// has no access to the child list itself (only its precomputed end position), so it returns the
366/// comment to the caller in that case instead of attaching it, leaving the caller (which does
367/// have the list) to set it on `items.last_mut()`. Returns `None` when there is no such same-line
368/// comment to route (either no comment was found, or it was a fresh leading-style comment and has
369/// already been attached directly to `container`).
370///
371/// - Complexity: O(g + c) time and O(g) space for scanned gap bytes and converted prefixes.
372fn attach_trailing<C: TrailingTriviaTarget>(
373    source: &str,
374    line_starts: &[usize],
375    last_child_end: Option<LineColumn>,
376    container: &mut C,
377) -> Option<crate::ast::Comment> {
378    let start = match last_child_end {
379        Some(pos) => line_column_to_byte(source, line_starts, pos),
380        None => after_open_brace(source, line_starts, container.open_brace_span()),
381    };
382    let end = before_close_brace(source, line_starts, container.close_span());
383    if start >= end {
384        return None;
385    }
386    let gap_text = &source[start..end];
387    let (comment, blank_line_before_close, same_line) = analyze_gap(gap_text);
388    container.set_blank_line_before_close(blank_line_before_close);
389    match comment {
390        Some(comment) if same_line && last_child_end.is_some() => Some(comment),
391        Some(comment) => {
392            container.set_trailing_comment(comment);
393            None
394        }
395        None => None,
396    }
397}
398
399/// Recovers `ConditionalDecl`'s own trailing trivia — the gap before its outer closing `}`,
400/// after its default arm if present, else its last branch, else (an empty conditional) its own
401/// opening `{`. Handled specially because a `ConditionalDecl`'s "last child" isn't a single
402/// homogeneous list — it's whichever of `branches`/`default` came last in declaration order.
403///
404/// Same-line routing mirrors [`attach_trailing`]: returns the comment instead of attaching it to
405/// `cond` when it belongs on the same line as the default arm's (or last branch's) own last
406/// token, leaving the caller to attach it there. See <https://github.com/stlab/cel-rs/issues/59>.
407///
408/// - Complexity: O(g + c) time and O(g) space for scanned gap bytes and converted prefixes.
409fn attach_conditional_trailing(
410    source: &str,
411    line_starts: &[usize],
412    cond: &mut ConditionalDecl,
413) -> Option<crate::ast::Comment> {
414    let has_last_child = cond.default.is_some() || !cond.branches.is_empty();
415    let start = if let Some(default) = &cond.default {
416        line_column_to_byte(source, line_starts, default.span.end.end())
417    } else if let Some(last_branch) = cond.branches.last() {
418        line_column_to_byte(source, line_starts, last_branch.span.end.end())
419    } else {
420        after_open_brace(source, line_starts, cond.open_brace_span.end)
421    };
422    let end = before_close_brace(source, line_starts, cond.span.end);
423    if start >= end {
424        return None;
425    }
426    let gap_text = &source[start..end];
427    let (comment, blank_line_before_close, same_line) = analyze_gap(gap_text);
428    cond.blank_line_before_close = blank_line_before_close;
429    match comment {
430        Some(comment) if same_line && has_last_child => Some(comment),
431        Some(comment) => {
432            cond.trailing_comment = Some(comment);
433            None
434        }
435        None => None,
436    }
437}
438
439/// Recovers comments/blank-lines from every gap in `sheet` — a leading comment before the
440/// `sheet` keyword itself, its own top-level items, and every nested `relationship`/`conditional`
441/// body — attaching each to the nearest following node.
442///
443/// - Precondition: `sheet` was parsed from exactly `source` (unmodified), so its items' spans'
444///   line/column positions resolve correctly against it.
445/// - Postcondition: preserves comment payload order. Multiple mixed or detached runs
446///   sharing one ordinary AST attachment normalize to line-comment text; a single
447///   comment retains its style.
448///
449/// - Complexity: O(n + v + g + c) time for source bytes n, visited AST nodes v,
450///   summed scanned gap bytes g, and summed converted character-column prefixes c.
451///   O(l + g + h) space for source lines l, recovered/scanned gaps, and call depth h.
452///   Sharing line starts does not eliminate repeated prefix scans; c can be quadratic
453///   for many declarations on one long line.
454///
455/// # Examples
456///
457/// See [`crate::format_sheet`] for recovering trivia before formatting a parsed sheet.
458pub fn attach_trivia(source: &str, sheet: &mut Sheet) {
459    let line_starts = line_start_byte_offsets(source);
460    let sheet_start = line_column_to_byte(source, &line_starts, sheet.span.start.start());
461    let (leading_comment, _, _) = analyze_gap(&source[..sheet_start]);
462    sheet.leading_comment = leading_comment;
463    attach_sheet_body(source, &line_starts, sheet);
464}
465
466/// Attaches nested sheet trivia using the caller's shared source-offset table.
467///
468/// - Precondition: spans and offsets refer to `source`.
469/// - Complexity: O(v + g + c) time and O(g + h) space for the sheet and its nested
470///   lists, using the module's attachment cost dimensions.
471fn attach_sheet_body(source: &str, line_starts: &[usize], sheet: &mut Sheet) {
472    attach_opening(source, line_starts, sheet.open_brace_span, &mut sheet.items);
473    attach_gaps(source, line_starts, &mut sheet.items);
474    let last_child_end = sheet.items.last().map(|item| item.span().end.end());
475    if let Some(comment) = attach_trailing(source, line_starts, last_child_end, sheet) {
476        sheet
477            .items
478            .last_mut()
479            .expect("same-line routing only returns Some when a last child exists")
480            .set_trailing_line_comment(comment);
481    }
482    for item in &mut sheet.items {
483        match item {
484            crate::ast::SheetItem::Relationship(rel) => {
485                attach_relationship(source, line_starts, rel)
486            }
487            crate::ast::SheetItem::Conditional(cond) => {
488                attach_conditional(source, line_starts, cond)
489            }
490            crate::ast::SheetItem::Out(out_decl) => attach_out(source, line_starts, out_decl),
491            crate::ast::SheetItem::Cell(cell) => attach_cell(source, line_starts, cell),
492            crate::ast::SheetItem::Source(source_decl) => {
493                attach_source(source, line_starts, source_decl)
494            }
495            crate::ast::SheetItem::RelationshipUse(_) | crate::ast::SheetItem::Error { .. } => {}
496        }
497    }
498}
499
500/// Attaches every file-boundary comment and each sheet's existing nested trivia.
501///
502/// - Precondition: `module` was parsed from exactly `source`.
503/// - Postcondition: each file-boundary comment belongs to exactly one gap.
504/// - Complexity: O(n + v + g + c) time for source bytes n, visited AST nodes v,
505///   summed scanned gap bytes g, and summed converted character-column prefixes c,
506///   including every nested attachment. O(l + g + h) space for source lines l,
507///   recovered/scanned gaps, and call depth h. Repeated prefix scans can be quadratic
508///   for many declarations on one long line.
509///
510/// # Examples
511///
512/// ```
513/// let source = "// header\nsheet root {}\n// end\n";
514/// let mut module = adam_lang::AdamAstParser::new().parse_module_str(source).unwrap();
515/// adam_lang::attach_module_trivia(source, &mut module);
516/// assert_eq!(module.comments.len(), 2);
517/// ```
518pub fn attach_module_trivia(source: &str, module: &mut crate::ast::Module) {
519    use cel_parser::trivia::{GapPiece, scan_gap};
520    let line_starts = line_start_byte_offsets(source);
521    module.comments.clear();
522    let mut previous_end = 0;
523    for item in &mut module.items {
524        let span = item.span();
525        let start = line_column_to_byte(source, &line_starts, span.start.start());
526        module.comments.push(
527            scan_gap(source.get(previous_end..start).unwrap_or(""), &[])
528                .into_iter()
529                .filter_map(|piece| match piece {
530                    GapPiece::Comment(comment) => Some(comment),
531                    GapPiece::Punct(_) => None,
532                })
533                .collect(),
534        );
535        previous_end = line_column_to_byte(source, &line_starts, span.end.end());
536        if let crate::ast::ModuleItem::Sheet(sheet) = item {
537            attach_sheet_body(source, &line_starts, sheet);
538            sheet.leading_comment = None;
539        }
540    }
541    module.comments.push(
542        scan_gap(&source[previous_end..], &[])
543            .into_iter()
544            .filter_map(|piece| match piece {
545                GapPiece::Comment(comment) => Some(comment),
546                GapPiece::Punct(_) => None,
547            })
548            .collect(),
549    );
550}
551
552/// Recovers trivia for a relationship block's bindings.
553///
554/// - Complexity: O(v + g + c) time and O(g) space for its bindings, scanned gaps,
555///   and converted prefixes.
556fn attach_relationship(source: &str, line_starts: &[usize], rel: &mut RelationshipDecl) {
557    attach_opening(source, line_starts, rel.open_brace_span, &mut rel.bindings);
558    attach_gaps(source, line_starts, &mut rel.bindings);
559    let last_child_end = rel.bindings.last().map(|b| b.span().end.end());
560    if let Some(comment) = attach_trailing(source, line_starts, last_child_end, rel) {
561        rel.bindings
562            .last_mut()
563            .expect("same-line routing only returns Some when a last child exists")
564            .set_trailing_line_comment(comment);
565    }
566}
567
568/// Recovers trivia for a conditional's branches, its default, and their nested relationships.
569///
570/// - Complexity: O(v + g + c) time and O(g + h) space for every visited descendant,
571///   using the module's attachment cost dimensions.
572fn attach_conditional(source: &str, line_starts: &[usize], cond: &mut ConditionalDecl) {
573    attach_opening(
574        source,
575        line_starts,
576        cond.open_brace_span,
577        &mut cond.branches,
578    );
579    attach_gaps(source, line_starts, &mut cond.branches);
580    for branch in &mut cond.branches {
581        attach_opening(
582            source,
583            line_starts,
584            branch.open_brace_span,
585            &mut branch.relationships,
586        );
587        attach_gaps(source, line_starts, &mut branch.relationships);
588        let last_child_end = branch.relationships.last().map(|r| r.span().end.end());
589        if let Some(comment) = attach_trailing(source, line_starts, last_child_end, branch) {
590            branch
591                .relationships
592                .last_mut()
593                .expect("same-line routing only returns Some when a last child exists")
594                .set_trailing_line_comment(comment);
595        }
596        for rel in &mut branch.relationships {
597            if let crate::ast::BranchItem::Relationship(rel) = rel {
598                attach_relationship(source, line_starts, rel);
599            }
600        }
601    }
602    if let Some(default) = &mut cond.default {
603        let start = cond.branches.last().map_or_else(
604            || after_open_brace(source, line_starts, cond.open_brace_span.end),
605            |branch| line_column_to_byte(source, line_starts, branch.span.end.end()),
606        );
607        let end = line_column_to_byte(source, line_starts, default.span.start.start());
608        let (comment, blank, same_line) = analyze_gap(source.get(start..end).unwrap_or(""));
609        default.blank_line_before = blank;
610        if same_line && let Some(previous) = cond.branches.last_mut() {
611            previous.trailing_line_comment = comment;
612        } else {
613            default.leading_comment = comment;
614        }
615        attach_opening(
616            source,
617            line_starts,
618            default.open_brace_span,
619            &mut default.relationships,
620        );
621        attach_gaps(source, line_starts, &mut default.relationships);
622        let last_child_end = default.relationships.last().map(|r| r.span().end.end());
623        if let Some(comment) = attach_trailing(source, line_starts, last_child_end, default) {
624            default
625                .relationships
626                .last_mut()
627                .expect("same-line routing only returns Some when a last child exists")
628                .set_trailing_line_comment(comment);
629        }
630        for rel in default.relationships.iter_mut() {
631            if let crate::ast::BranchItem::Relationship(rel) = rel {
632                attach_relationship(source, line_starts, rel);
633            }
634        }
635    }
636    if let Some(comment) = attach_conditional_trailing(source, line_starts, cond) {
637        if let Some(default) = &mut cond.default {
638            default.trailing_line_comment = Some(comment);
639        } else if let Some(last_branch) = cond.branches.last_mut() {
640            last_branch.set_trailing_line_comment(comment);
641        }
642    }
643}
644
645/// Recovers trivia for a `require { ... }` block, if present — the gap before its own closing
646/// `}`, and gaps between its requirements. A `None` require block (a `cell`/`source`/`out` with
647/// no `require { ... }` clause) has nothing further to recover here: its `initializer`/`filter`
648/// expressions carry no trivia of their own. Shared by [`attach_cell`], [`attach_source`], and
649/// [`attach_out`], since a `require` block has the same trivia-recovery rules regardless of which
650/// cell kind it's attached to.
651///
652/// - Complexity: O(v + g + c) time and O(g) space for requirements, scanned gaps,
653///   and converted prefixes; O(1) when no block exists.
654fn attach_require_block(source: &str, line_starts: &[usize], require: Option<&mut RequireBlock>) {
655    let Some(require) = require else {
656        return;
657    };
658    attach_opening(
659        source,
660        line_starts,
661        require.open_brace_span,
662        &mut require.requirements,
663    );
664    attach_gaps(source, line_starts, &mut require.requirements);
665    let last_child_end = require.requirements.last().map(|r| r.span().end.end());
666    if let Some(comment) = attach_trailing(source, line_starts, last_child_end, require) {
667        require
668            .requirements
669            .last_mut()
670            .expect("same-line routing only returns Some when a last child exists")
671            .set_trailing_line_comment(comment);
672    }
673}
674
675/// Recovers trivia for an `out` declaration's `require` block, if present, via
676/// [`attach_require_block`].
677///
678/// - Complexity: the time and space of [`attach_require_block`].
679fn attach_out(source: &str, line_starts: &[usize], out_decl: &mut OutDecl) {
680    attach_require_block(source, line_starts, out_decl.require.as_mut());
681}
682
683/// Recovers trivia for a `cell` declaration's `require` block, if present, via
684/// [`attach_require_block`].
685///
686/// - Complexity: the time and space of [`attach_require_block`].
687fn attach_cell(source: &str, line_starts: &[usize], cell: &mut CellDecl) {
688    attach_require_block(source, line_starts, cell.require.as_mut());
689}
690
691/// Recovers trivia for a `source` declaration's `require` block, if present, via
692/// [`attach_require_block`].
693///
694/// - Complexity: the time and space of [`attach_require_block`].
695fn attach_source(source: &str, line_starts: &[usize], source_decl: &mut SourceDecl) {
696    attach_require_block(source, line_starts, source_decl.require.as_mut());
697}
698
699/// Attaches trivia between a container's opening brace and its first child.
700///
701/// - Precondition: spans and offsets refer to `source`.
702/// - Complexity: O(g + c) time and O(g) space for opening-gap bytes and converted prefixes.
703fn attach_opening<T: TriviaTarget>(
704    source: &str,
705    line_starts: &[usize],
706    open: ExprSpan,
707    items: &mut [T],
708) {
709    if let Some(first) = items.first_mut() {
710        let start = after_open_brace(source, line_starts, open.end);
711        let end = line_column_to_byte(source, line_starts, first.span().start.start());
712        let gap = source.get(start..end).unwrap_or("");
713        if let Some(comments) = first.opening_comments_mut() {
714            use cel_parser::trivia::{GapPiece, scan_gap};
715            *comments = scan_gap(gap, &[])
716                .into_iter()
717                .filter_map(|piece| match piece {
718                    GapPiece::Comment(comment) => Some(comment),
719                    GapPiece::Punct(_) => None,
720                })
721                .collect();
722            return;
723        }
724        let (comment, blank, _) = analyze_gap(gap);
725        if let Some(comment) = comment {
726            first.set_leading_comment(comment);
727        }
728        first.set_blank_line_before(blank);
729    }
730}
731
732/// Recovers comments/blank-lines from the gaps between consecutive `items`, attaching each to the
733/// nearest following item — unless the comment's first line begins on the same source line as
734/// the *previous* item's own last token, in which case it attaches to that previous item's
735/// `trailing_line_comment` instead (see <https://github.com/stlab/cel-rs/issues/59>). The first
736/// item's opening-brace gap belongs to [`attach_opening`], not this sibling-gap pass.
737///
738/// - Complexity: O(v + g + c) time for sibling count v, summed scanned gap bytes g,
739///   and summed converted character-column prefixes c; O(g) space for recovered
740///   comments and scanning. The line index does not cache character-prefix conversions.
741fn attach_gaps<T: TriviaTarget>(source: &str, line_starts: &[usize], items: &mut [T]) {
742    if items.len() < 2 {
743        return;
744    }
745    for i in 1..items.len() {
746        let start = line_column_to_byte(source, line_starts, items[i - 1].span().end.end());
747        let end = line_column_to_byte(source, line_starts, items[i].span().start.start());
748        let gap_text = &source[start..end];
749        let (comment, blank_line_before, same_line) = analyze_gap(gap_text);
750        items[i].set_blank_line_before(blank_line_before);
751        if let Some(comment) = comment {
752            if same_line {
753                items[i - 1].set_trailing_line_comment(comment);
754            } else {
755                items[i].set_leading_comment(comment);
756            }
757        }
758    }
759}
760
761/// Detects a complete blank line between a region's initial and final partial lines.
762///
763/// - Complexity: O(n) time for n region bytes; O(1) additional space.
764fn has_blank_line(region: &str) -> bool {
765    region
766        .split_inclusive('\n')
767        .skip(1)
768        .any(|line| line.ends_with('\n') && line.trim().is_empty())
769}
770
771/// Recovers every comment payload, blank-line spacing, and same-line attachment from one gap.
772///
773/// A single comment retains its style. Multiple runs normalize into one line-comment
774/// attachment in source order. The first comment determines same-line routing.
775///
776/// - Complexity: O(n) time and space in gap bytes, including normalized comment text.
777fn analyze_gap(gap: &str) -> (Option<crate::ast::Comment>, bool, bool) {
778    use crate::ast::Comment;
779    use cel_parser::trivia::{GapPiece, scan_gap};
780    let mut comments = scan_gap(gap, &[])
781        .into_iter()
782        .filter_map(|piece| match piece {
783            GapPiece::Comment(comment) => Some(comment),
784            GapPiece::Punct(_) => None,
785        });
786    let first = comments.next();
787    let prefix = &gap[..gap.len() - gap.trim_start().len()];
788    let same_line = first.is_some() && !prefix.contains('\n');
789    if let Some(second) = comments.next() {
790        // A single AST attachment stores mixed/detached runs as safe line-comment text.
791        let text = first
792            .into_iter()
793            .chain(std::iter::once(second))
794            .chain(comments)
795            .enumerate()
796            .fold(String::new(), |mut joined, (index, comment)| {
797                if index != 0 {
798                    joined.push('\n');
799                }
800                match comment {
801                    Comment::Line(text) | Comment::Block(text) => joined.push_str(&text),
802                }
803                joined
804            });
805        return (Some(Comment::Line(text)), has_blank_line(gap), same_line);
806    }
807    let blank_line_before = if first.is_some() {
808        let suffix = &gap[gap.trim_end().len()..];
809        has_blank_line(prefix) || has_blank_line(suffix)
810    } else {
811        has_blank_line(gap)
812    };
813    (first, blank_line_before, same_line)
814}
815
816#[cfg(test)]
817mod tests {
818    use super::*;
819    use crate::AdamAstParser;
820
821    /// Retains an empty first comment when normalizing a mixed-style run.
822    #[test]
823    fn mixed_comment_runs_preserve_empty_first_payload() {
824        let (comment, _, _) = analyze_gap("\n//\n/* second */\n");
825        assert_eq!(comment, Some(crate::ast::Comment::Line("\nsecond".into())));
826    }
827
828    /// Recognizes whitespace-only blank lines between mixed runs under LF and CRLF.
829    #[test]
830    fn mixed_comment_blank_lines_allow_whitespace_and_crlf() {
831        for separator in ["\n \t\n", "\r\n \t\r\n"] {
832            let gap = format!("\n// first{separator}/* second */\n    ");
833            let (comment, blank, same_line) = analyze_gap(&gap);
834            assert!(blank, "{gap:?}");
835            assert!(!same_line);
836            assert_eq!(
837                comment,
838                Some(crate::ast::Comment::Line("first\nsecond".into()))
839            );
840        }
841        let (_, blank, _) = analyze_gap("\n// first\n/* second */\n    ");
842        assert!(!blank, "next-item indentation is not a blank line");
843    }
844
845    /// Joins consecutive leading line comments in source order.
846    #[test]
847    fn attaches_a_multi_line_comment_block() {
848        let source = "sheet s {\n    cell a: i32 = 1;\n    // line one\n    // line two\n    cell b: i32 = 2;\n}";
849        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
850        attach_trivia(source, &mut sheet);
851        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
852            panic!("expected Cell");
853        };
854        assert_eq!(
855            b.leading_comment,
856            Some(crate::ast::Comment::Line("line one\nline two".to_string()))
857        );
858    }
859
860    /// Preserves a single-line block comment's style and text.
861    #[test]
862    fn attaches_a_single_line_block_comment() {
863        let source =
864            "sheet s {\n    cell a: i32 = 1;\n    /* the total */\n    cell b: i32 = 2;\n}";
865        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
866        attach_trivia(source, &mut sheet);
867        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
868            panic!("expected Cell");
869        };
870        assert_eq!(
871            b.leading_comment,
872            Some(crate::ast::Comment::Block("the total".to_string()))
873        );
874    }
875
876    /// Preserves a multiline block comment's style and text.
877    #[test]
878    fn attaches_a_multi_line_block_comment() {
879        let source = "sheet s {\n    cell a: i32 = 1;\n    /*\n        line one\n        line two\n    */\n    cell b: i32 = 2;\n}";
880        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
881        attach_trivia(source, &mut sheet);
882        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
883            panic!("expected Cell");
884        };
885        assert_eq!(
886            b.leading_comment,
887            Some(crate::ast::Comment::Block("line one\nline two".to_string()))
888        );
889    }
890
891    /// Retains the license-header regression fixture as a block comment.
892    #[test]
893    fn attaches_the_issue_105_license_header_repro_as_a_block_comment() {
894        let source =
895            "/*\n    Copyright 2013 Adobe\n    ...\n*/\nsheet s {\n    cell a: i32 = 1;\n}";
896        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
897        attach_trivia(source, &mut sheet);
898        assert_eq!(
899            sheet.leading_comment,
900            Some(crate::ast::Comment::Block(
901                "Copyright 2013 Adobe\n...".to_string()
902            ))
903        );
904    }
905
906    /// Retains a detached comment rather than dropping its text.
907    #[test]
908    fn preserves_a_comment_separated_by_a_blank_line() {
909        let source =
910            "sheet s {\n    cell a: i32 = 1;\n    // stale comment\n\n    cell b: i32 = 2;\n}";
911        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
912        attach_trivia(source, &mut sheet);
913        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
914            panic!("expected Cell");
915        };
916        assert_eq!(
917            b.leading_comment,
918            Some(crate::ast::Comment::Line("stale comment".into()))
919        );
920    }
921
922    /// Leaves the leading comment absent when the gap contains no comment.
923    #[test]
924    fn no_comment_in_the_gap_leaves_leading_comment_none() {
925        let source = "sheet s {\n    cell a: i32 = 1;\n    cell b: i32 = 2;\n}";
926        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
927        attach_trivia(source, &mut sheet);
928        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
929            panic!("expected Cell");
930        };
931        assert_eq!(b.leading_comment, None);
932    }
933
934    /// Routes comments independently across successive declaration gaps.
935    #[test]
936    fn attaches_comments_correctly_across_more_than_one_gap() {
937        let source = "sheet s {\n    cell a: i32 = 1;\n    // first\n    cell b: i32 = 2;\n    // second\n    cell c: i32 = 3;\n}";
938        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
939        attach_trivia(source, &mut sheet);
940        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
941            panic!("expected Cell");
942        };
943        assert_eq!(
944            b.leading_comment,
945            Some(crate::ast::Comment::Line("first".to_string()))
946        );
947        let crate::ast::SheetItem::Cell(c) = &sheet.items[2] else {
948            panic!("expected Cell");
949        };
950        assert_eq!(
951            c.leading_comment,
952            Some(crate::ast::Comment::Line("second".to_string()))
953        );
954    }
955
956    /// Attaches leading trivia to a syntax-recovery placeholder.
957    #[test]
958    fn attaches_a_comment_preceding_a_recovered_error_item() {
959        let source = "sheet s {\n    cell a: i32 = 1;\n    // fix me\n    cell bad unknown_syntax\n    cell c: i32 = 2;\n}";
960        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
961        attach_trivia(source, &mut sheet);
962        let crate::ast::SheetItem::Error {
963            leading_comment, ..
964        } = &sheet.items[1]
965        else {
966            panic!("expected Error");
967        };
968        assert_eq!(
969            leading_comment,
970            &Some(crate::ast::Comment::Line("fix me".to_string()))
971        );
972    }
973
974    /// Keeps recovery-adjacent gaps valid when the error ends at the next keyword.
975    #[test]
976    fn recovery_span_that_abuts_the_next_keyword_does_not_invert_the_gap() {
977        let source = "sheet s { cell bad relationship { y := x; } }";
978        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
979        attach_trivia(source, &mut sheet); // must not panic
980        assert!(matches!(
981            sheet.items[0],
982            crate::ast::SheetItem::Error { .. }
983        ));
984        assert!(matches!(
985            sheet.items[1],
986            crate::ast::SheetItem::Relationship(_)
987        ));
988    }
989
990    /// Marks a blank line separating declarations.
991    #[test]
992    fn sets_blank_line_before_true_when_a_blank_line_separates_two_items() {
993        let source = "sheet s {\n    cell a: i32 = 1;\n\n    cell b: i32 = 2;\n}";
994        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
995        attach_trivia(source, &mut sheet);
996        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
997            panic!("expected Cell");
998        };
999        assert!(b.blank_line_before);
1000    }
1001
1002    /// Leaves the blank-line flag clear for adjacent declarations.
1003    #[test]
1004    fn sets_blank_line_before_false_when_items_are_packed_tight() {
1005        let source = "sheet s {\n    cell a: i32 = 1;\n    cell b: i32 = 2;\n}";
1006        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1007        attach_trivia(source, &mut sheet);
1008        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1009            panic!("expected Cell");
1010        };
1011        assert!(!b.blank_line_before);
1012    }
1013
1014    /// Collapses multiple blank lines into one spacing flag.
1015    #[test]
1016    fn a_run_of_several_blank_lines_still_just_sets_the_flag_true() {
1017        let source = "sheet s {\n    cell a: i32 = 1;\n\n\n\n    cell b: i32 = 2;\n}";
1018        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1019        attach_trivia(source, &mut sheet);
1020        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1021            panic!("expected Cell");
1022        };
1023        assert!(b.blank_line_before);
1024    }
1025
1026    /// Retains blank-line spacing before an attached comment.
1027    #[test]
1028    fn blank_line_before_an_attached_comment_still_sets_the_flag_true() {
1029        // The blank line precedes the comment, not the item — the comment still attaches to b
1030        // (nothing separates the comment itself from b), but the blank line further back in the
1031        // gap still counts as separating a from (comment + b) as a group.
1032        let source = "sheet s {\n    cell a: i32 = 1;\n\n    // c\n    cell b: i32 = 2;\n}";
1033        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1034        attach_trivia(source, &mut sheet);
1035        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1036            panic!("expected Cell");
1037        };
1038        assert_eq!(
1039            b.leading_comment,
1040            Some(crate::ast::Comment::Line("c".to_string()))
1041        );
1042        assert!(b.blank_line_before);
1043    }
1044
1045    /// Attaches leading comments and spacing to nested bindings.
1046    #[test]
1047    fn attaches_a_comment_and_blank_line_to_a_binding_inside_a_relationship() {
1048        let source = "sheet s {\n    relationship {\n        b := a;\n\n        // second\n        a := b;\n    }\n}";
1049        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1050        attach_trivia(source, &mut sheet);
1051        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1052            panic!("expected Relationship");
1053        };
1054        assert_eq!(
1055            rel.bindings[1].leading_comment,
1056            Some(crate::ast::Comment::Line("second".to_string()))
1057        );
1058        assert!(rel.bindings[1].blank_line_before);
1059    }
1060
1061    /// Attaches leading comments to named conditional branches.
1062    #[test]
1063    fn attaches_a_comment_to_a_conditional_branch() {
1064        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } }\n        // one\n        1i32 => { relationship { b := a; } }\n    }\n}";
1065        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1066        attach_trivia(source, &mut sheet);
1067        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1068            panic!("expected Conditional");
1069        };
1070        assert_eq!(
1071            cond.branches[1].leading_comment,
1072            Some(crate::ast::Comment::Line("one".to_string()))
1073        );
1074    }
1075
1076    /// Attaches comments to relationships within a named branch.
1077    #[test]
1078    fn attaches_a_comment_to_a_relationship_nested_inside_a_conditional_branch() {
1079        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; }\n            // second\n            relationship { a := b; }\n        }\n    }\n}";
1080        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1081        attach_trivia(source, &mut sheet);
1082        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1083            panic!("expected Conditional");
1084        };
1085        assert_eq!(
1086            match &cond.branches[0].relationships[1] {
1087                crate::ast::BranchItem::Relationship(rel) => &rel.leading_comment,
1088                crate::ast::BranchItem::RelationshipUse(call) => &call.leading_comment,
1089            },
1090            &Some(crate::ast::Comment::Line("second".to_string()))
1091        );
1092    }
1093
1094    /// Attaches comments to relationships within the default arm.
1095    #[test]
1096    fn attaches_a_comment_to_a_relationship_nested_inside_the_default_branch() {
1097        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; }\n            // second\n            relationship { a := b; }\n        }\n    }\n}";
1098        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1099        attach_trivia(source, &mut sheet);
1100        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1101            panic!("expected Conditional");
1102        };
1103        let default = cond.default.as_ref().expect("default branch present");
1104        assert_eq!(
1105            match &default.relationships[1] {
1106                crate::ast::BranchItem::Relationship(rel) => &rel.leading_comment,
1107                crate::ast::BranchItem::RelationshipUse(call) => &call.leading_comment,
1108            },
1109            &Some(crate::ast::Comment::Line("second".to_string()))
1110        );
1111    }
1112
1113    /// Attaches a leading file comment to the sheet.
1114    #[test]
1115    fn attaches_a_leading_comment_before_the_sheet_itself() {
1116        let source = "// file header\nsheet s {\n    cell a: i32 = 1;\n}";
1117        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1118        attach_trivia(source, &mut sheet);
1119        assert_eq!(
1120            sheet.leading_comment,
1121            Some(crate::ast::Comment::Line("file header".to_string()))
1122        );
1123    }
1124
1125    /// Joins multiple leading file-comment lines on the sheet.
1126    #[test]
1127    fn attaches_a_multi_line_leading_comment_before_the_sheet_itself() {
1128        let source = "// line one\n// line two\nsheet s {\n    cell a: i32 = 1;\n}";
1129        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1130        attach_trivia(source, &mut sheet);
1131        assert_eq!(
1132            sheet.leading_comment,
1133            Some(crate::ast::Comment::Line("line one\nline two".to_string()))
1134        );
1135    }
1136
1137    /// Leaves sheet header trivia absent when no leading comment exists.
1138    #[test]
1139    fn no_leading_comment_before_the_sheet_leaves_it_none() {
1140        let source = "sheet s {\n    cell a: i32 = 1;\n}";
1141        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1142        attach_trivia(source, &mut sheet);
1143        assert_eq!(sheet.leading_comment, None);
1144    }
1145
1146    /// Attaches comments to requirements within an output.
1147    #[test]
1148    fn attaches_a_comment_to_a_requirement_inside_an_out_declaration() {
1149        // NOTE: deviates from a literal per-requirement translation of the old fixture, which
1150        // put the commented requirement first. The old grammar could attach a leading comment to
1151        // a require block's *first* requirement because `out_decl`'s writer method was a real,
1152        // separately-spanned sibling `attach_out` computed a manual gap from; the new grammar
1153        // flattens `out`'s writer into a bare `initializer` expression with no span-tracked
1154        // sibling of its own, so `require.requirements[0]` is now subject to the same
1155        // never-first-item limitation as every other sibling list (see the module doc's #52
1156        // link). A second requirement here lands the comment in a tracked gap between two
1157        // siblings instead, exercising the same attach-to-a-nested-requirement behavior.
1158        let source = "sheet s {\n    out area: f64 := width * height require {\n        @max_area width * height <= max_area;\n        // second\n        @c width <= 10.0;\n    };\n}";
1159        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1160        attach_trivia(source, &mut sheet);
1161        let crate::ast::SheetItem::Out(out) = &sheet.items[0] else {
1162            panic!("expected Out");
1163        };
1164        let require = out.require.as_ref().expect("require block present");
1165        assert_eq!(
1166            require.requirements[1].leading_comment,
1167            Some(crate::ast::Comment::Line("second".to_string()))
1168        );
1169    }
1170
1171    /// Retains a sheet's trailing interior comment.
1172    #[test]
1173    fn recovers_a_trailing_comment_before_a_sheets_closing_brace() {
1174        let source = "sheet s {\n    cell a: i32 = 1;\n    // trailing\n}";
1175        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1176        attach_trivia(source, &mut sheet);
1177        assert_eq!(
1178            sheet.trailing_comment,
1179            Some(crate::ast::Comment::Line("trailing".to_string()))
1180        );
1181    }
1182
1183    /// Retains a trailing comment in an empty relationship.
1184    #[test]
1185    fn recovers_a_trailing_comment_in_an_empty_relationship_block() {
1186        let source = "sheet s {\n    relationship {\n        // only this\n    }\n}";
1187        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1188        attach_trivia(source, &mut sheet);
1189        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1190            panic!("expected Relationship");
1191        };
1192        assert_eq!(
1193            rel.trailing_comment,
1194            Some(crate::ast::Comment::Line("only this".to_string()))
1195        );
1196    }
1197
1198    /// Retains a relationship's trailing interior comment.
1199    #[test]
1200    fn recovers_a_trailing_comment_before_a_relationships_closing_brace() {
1201        let source =
1202            "sheet s {\n    relationship {\n        b := a;\n        // trailing\n    }\n}";
1203        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1204        attach_trivia(source, &mut sheet);
1205        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1206            panic!("expected Relationship");
1207        };
1208        assert_eq!(
1209            rel.trailing_comment,
1210            Some(crate::ast::Comment::Line("trailing".to_string()))
1211        );
1212    }
1213
1214    /// Retains a named branch's trailing interior comment.
1215    #[test]
1216    fn recovers_a_trailing_comment_before_a_conditional_branchs_closing_brace() {
1217        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; }\n            // trailing\n        }\n    }\n}";
1218        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1219        attach_trivia(source, &mut sheet);
1220        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1221            panic!("expected Conditional");
1222        };
1223        assert_eq!(
1224            cond.branches[0].trailing_comment,
1225            Some(crate::ast::Comment::Line("trailing".to_string()))
1226        );
1227    }
1228
1229    /// Retains the default arm's trailing interior comment.
1230    #[test]
1231    fn recovers_a_trailing_comment_in_a_default_arm() {
1232        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; }\n            // trailing\n        }\n    }\n}";
1233        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1234        attach_trivia(source, &mut sheet);
1235        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1236            panic!("expected Conditional");
1237        };
1238        let default = cond.default.as_ref().expect("default branch present");
1239        assert_eq!(
1240            default.trailing_comment,
1241            Some(crate::ast::Comment::Line("trailing".to_string()))
1242        );
1243    }
1244
1245    /// Retains a conditional's trailing interior comment.
1246    #[test]
1247    fn recovers_a_trailing_comment_before_a_conditionals_own_closing_brace() {
1248        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } }\n        // trailing\n    }\n}";
1249        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1250        attach_trivia(source, &mut sheet);
1251        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1252            panic!("expected Conditional");
1253        };
1254        assert_eq!(
1255            cond.trailing_comment,
1256            Some(crate::ast::Comment::Line("trailing".to_string()))
1257        );
1258    }
1259
1260    // NOTE: `recovers_a_trailing_comment_before_an_outs_closing_brace_with_no_conditions` (the old
1261    // no-`require`-block case) is intentionally not carried forward: `OutDecl` no longer has a
1262    // closing brace or a `trailing_comment` field of its own (the new grammar is flat and
1263    // `;`-terminated), so there is nothing left to attach such a comment to when no `require`
1264    // block is present — see the task brief's guidance to drop this case rather than invent one.
1265
1266    /// Retains an output requirement block's trailing comment.
1267    #[test]
1268    fn recovers_a_trailing_comment_before_a_requires_closing_brace() {
1269        let source = "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0;\n        // trailing\n    };\n}";
1270        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1271        attach_trivia(source, &mut sheet);
1272        let crate::ast::SheetItem::Out(out) = &sheet.items[0] else {
1273            panic!("expected Out");
1274        };
1275        let require = out.require.as_ref().expect("require block present");
1276        assert_eq!(
1277            require.trailing_comment,
1278            Some(crate::ast::Comment::Line("trailing".to_string()))
1279        );
1280    }
1281
1282    /// Attaches comments to requirements within an ordinary cell.
1283    #[test]
1284    fn attaches_a_comment_to_a_requirement_inside_a_cell_declaration() {
1285        // Mirrors `attaches_a_comment_to_a_requirement_inside_an_out_declaration`, but for a
1286        // `cell`'s own `require` block — `CellDecl` gained `require` alongside `out`'s, and its
1287        // trivia recovery must not be dropped on the floor the way it was before this fix.
1288        let source = "sheet s {\n    cell a: i32 = 1 require {\n        @r1 a > 0;\n        // second\n        @r2 a < 10;\n    };\n}";
1289        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1290        attach_trivia(source, &mut sheet);
1291        let crate::ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1292            panic!("expected Cell");
1293        };
1294        let require = cell.require.as_ref().expect("require block present");
1295        assert_eq!(
1296            require.requirements[1].leading_comment,
1297            Some(crate::ast::Comment::Line("second".to_string()))
1298        );
1299    }
1300
1301    /// Retains an ordinary cell requirement block's trailing comment.
1302    #[test]
1303    fn recovers_a_trailing_comment_before_a_cells_requires_closing_brace() {
1304        // Mirrors `recovers_a_trailing_comment_before_a_requires_closing_brace`, but for a
1305        // `cell`'s own `require` block.
1306        let source = "sheet s {\n    cell a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}";
1307        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1308        attach_trivia(source, &mut sheet);
1309        let crate::ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1310            panic!("expected Cell");
1311        };
1312        let require = cell.require.as_ref().expect("require block present");
1313        assert_eq!(
1314            require.trailing_comment,
1315            Some(crate::ast::Comment::Line("trailing".to_string()))
1316        );
1317    }
1318
1319    /// Attaches comments to requirements within a source cell.
1320    #[test]
1321    fn attaches_a_comment_to_a_requirement_inside_a_source_declaration() {
1322        // Mirrors `attaches_a_comment_to_a_requirement_inside_an_out_declaration`, but for a
1323        // `source`'s own `require` block.
1324        let source = "sheet s {\n    source a: i32 = 1 require {\n        @r1 a > 0;\n        // second\n        @r2 a < 10;\n    };\n}";
1325        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1326        attach_trivia(source, &mut sheet);
1327        let crate::ast::SheetItem::Source(source_decl) = &sheet.items[0] else {
1328            panic!("expected Source");
1329        };
1330        let require = source_decl.require.as_ref().expect("require block present");
1331        assert_eq!(
1332            require.requirements[1].leading_comment,
1333            Some(crate::ast::Comment::Line("second".to_string()))
1334        );
1335    }
1336
1337    /// Retains a source requirement block's trailing comment.
1338    #[test]
1339    fn recovers_a_trailing_comment_before_a_sources_requires_closing_brace() {
1340        // Mirrors `recovers_a_trailing_comment_before_a_requires_closing_brace`, but for a
1341        // `source`'s own `require` block.
1342        let source = "sheet s {\n    source a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}";
1343        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1344        attach_trivia(source, &mut sheet);
1345        let crate::ast::SheetItem::Source(source_decl) = &sheet.items[0] else {
1346            panic!("expected Source");
1347        };
1348        let require = source_decl.require.as_ref().expect("require block present");
1349        assert_eq!(
1350            require.trailing_comment,
1351            Some(crate::ast::Comment::Line("trailing".to_string()))
1352        );
1353    }
1354
1355    /// Marks blank-line spacing before a container closes.
1356    #[test]
1357    fn sets_blank_line_before_close_when_a_blank_line_precedes_the_closing_brace() {
1358        let source = "sheet s {\n    cell a: i32 = 1;\n\n}";
1359        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1360        attach_trivia(source, &mut sheet);
1361        assert!(sheet.blank_line_before_close);
1362    }
1363
1364    /// Leaves trailing trivia absent when the closing gap contains no comment.
1365    #[test]
1366    fn no_trailing_comment_leaves_trailing_comment_none() {
1367        let source = "sheet s {\n    cell a: i32 = 1;\n}";
1368        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1369        attach_trivia(source, &mut sheet);
1370        assert_eq!(sheet.trailing_comment, None);
1371    }
1372
1373    // Issue #59: a `//` comment at the end of a declaration's own line must attach to that
1374    // declaration's own `trailing_line_comment`, not the following item's `leading_comment`.
1375
1376    /// Routes a same-line line comment to the preceding cell only.
1377    #[test]
1378    fn a_same_line_trailing_line_comment_attaches_to_the_previous_cell_not_the_next() {
1379        let source =
1380            "sheet s {\n    cell a: f64 = 2.0; // end of line comment\n    cell b: f64 = 3.0;\n}";
1381        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1382        attach_trivia(source, &mut sheet);
1383        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1384            panic!("expected Cell");
1385        };
1386        assert_eq!(
1387            a.trailing_line_comment,
1388            Some(crate::ast::Comment::Line("end of line comment".to_string()))
1389        );
1390        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1391            panic!("expected Cell");
1392        };
1393        assert_eq!(b.leading_comment, None);
1394        assert!(!b.blank_line_before);
1395    }
1396
1397    /// Routes a same-line block comment to the preceding cell only.
1398    #[test]
1399    fn a_same_line_trailing_block_comment_attaches_to_the_previous_cell_not_the_next() {
1400        let source = "sheet s {\n    cell a: f64 = 2.0; /* note */\n    cell b: f64 = 3.0;\n}";
1401        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1402        attach_trivia(source, &mut sheet);
1403        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1404            panic!("expected Cell");
1405        };
1406        assert_eq!(
1407            a.trailing_line_comment,
1408            Some(crate::ast::Comment::Block("note".to_string()))
1409        );
1410        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1411            panic!("expected Cell");
1412        };
1413        assert_eq!(b.leading_comment, None);
1414    }
1415
1416    /// Routes an own-line comment to the following declaration only.
1417    #[test]
1418    fn a_comment_on_its_own_line_still_attaches_as_a_leading_comment() {
1419        // Regression guard: a comment that starts on a fresh line (not same-line as the previous
1420        // item) must keep attaching as the next item's leading_comment, exactly as before #59.
1421        let source = "sheet s {\n    cell a: i32 = 1;\n    // the total\n    cell b: i32 = 2;\n}";
1422        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1423        attach_trivia(source, &mut sheet);
1424        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1425            panic!("expected Cell");
1426        };
1427        assert_eq!(a.trailing_line_comment, None);
1428        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1429            panic!("expected Cell");
1430        };
1431        assert_eq!(
1432            b.leading_comment,
1433            Some(crate::ast::Comment::Line("the total".to_string()))
1434        );
1435    }
1436
1437    /// Routes a same-line trailing comment to its binding.
1438    #[test]
1439    fn a_same_line_trailing_comment_attaches_to_a_binding_inside_a_relationship() {
1440        let source =
1441            "sheet s {\n    relationship {\n        b := a; // note\n        a := b;\n    }\n}";
1442        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1443        attach_trivia(source, &mut sheet);
1444        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1445            panic!("expected Relationship");
1446        };
1447        assert_eq!(
1448            rel.bindings[0].trailing_line_comment,
1449            Some(crate::ast::Comment::Line("note".to_string()))
1450        );
1451        assert_eq!(rel.bindings[1].leading_comment, None);
1452    }
1453
1454    /// Routes a final same-line comment to the last sheet item.
1455    #[test]
1456    fn a_same_line_trailing_comment_before_a_sheets_closing_brace_attaches_to_the_last_item() {
1457        let source = "sheet s {\n    cell a: i32 = 1; // note\n}";
1458        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1459        attach_trivia(source, &mut sheet);
1460        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1461            panic!("expected Cell");
1462        };
1463        assert_eq!(
1464            a.trailing_line_comment,
1465            Some(crate::ast::Comment::Line("note".to_string()))
1466        );
1467        assert_eq!(sheet.trailing_comment, None);
1468    }
1469
1470    /// Recovers a block comment from a gap without any newline.
1471    #[test]
1472    fn same_line_gap_with_no_newline_still_recovers_a_block_comment() {
1473        // PR #202 review: when both items and the comment share one physical line with no
1474        // newline anywhere in the gap, the old unconditional "drop the final fragment" logic
1475        // popped the whole gap (comment included) before the comment scanner ever ran.
1476        let source = "sheet s {\n    cell a: i32 = 1; /* note */ cell b: i32 = 2;\n}";
1477        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1478        attach_trivia(source, &mut sheet);
1479        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1480            panic!("expected Cell");
1481        };
1482        assert_eq!(
1483            a.trailing_line_comment,
1484            Some(crate::ast::Comment::Block("note".to_string()))
1485        );
1486    }
1487
1488    /// Routes a final same-line comment to the last relationship binding.
1489    #[test]
1490    fn a_same_line_trailing_comment_before_a_relationships_closing_brace_attaches_to_the_last_binding()
1491     {
1492        let source = "sheet s {\n    relationship {\n        b := a; // note\n    }\n}";
1493        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1494        attach_trivia(source, &mut sheet);
1495        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1496            panic!("expected Relationship");
1497        };
1498        assert_eq!(
1499            rel.bindings[0].trailing_line_comment,
1500            Some(crate::ast::Comment::Line("note".to_string()))
1501        );
1502        assert_eq!(rel.trailing_comment, None);
1503    }
1504
1505    /// Routes a final same-line comment to the last relationship of a named branch.
1506    #[test]
1507    fn a_same_line_trailing_comment_before_a_conditional_branchs_own_closing_brace_attaches_to_the_last_relationship()
1508     {
1509        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; } // note\n        }\n    }\n}";
1510        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1511        attach_trivia(source, &mut sheet);
1512        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1513            panic!("expected Conditional");
1514        };
1515        assert_eq!(
1516            match &cond.branches[0].relationships[0] {
1517                crate::ast::BranchItem::Relationship(rel) => &rel.trailing_line_comment,
1518                crate::ast::BranchItem::RelationshipUse(call) => &call.trailing_line_comment,
1519            },
1520            &Some(crate::ast::Comment::Line("note".to_string()))
1521        );
1522        assert_eq!(cond.branches[0].trailing_comment, None);
1523    }
1524
1525    /// Routes a final same-line comment to the last relationship of the default arm.
1526    #[test]
1527    fn a_same_line_trailing_comment_before_a_default_arms_own_closing_brace_attaches_to_the_last_relationship()
1528     {
1529        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; } // note\n        }\n    }\n}";
1530        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1531        attach_trivia(source, &mut sheet);
1532        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1533            panic!("expected Conditional");
1534        };
1535        let default = cond.default.as_ref().expect("default branch present");
1536        assert_eq!(
1537            match &default.relationships[0] {
1538                crate::ast::BranchItem::Relationship(rel) => &rel.trailing_line_comment,
1539                crate::ast::BranchItem::RelationshipUse(call) => &call.trailing_line_comment,
1540            },
1541            &Some(crate::ast::Comment::Line("note".to_string()))
1542        );
1543        assert_eq!(default.trailing_comment, None);
1544    }
1545
1546    /// Routes a final same-line comment to the last named branch.
1547    #[test]
1548    fn a_same_line_trailing_comment_before_a_conditionals_own_closing_brace_with_only_branches_attaches_to_the_last_branch()
1549     {
1550        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } } // note\n    }\n}";
1551        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1552        attach_trivia(source, &mut sheet);
1553        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1554            panic!("expected Conditional");
1555        };
1556        assert_eq!(
1557            cond.branches[0].trailing_line_comment,
1558            Some(crate::ast::Comment::Line("note".to_string()))
1559        );
1560        assert_eq!(cond.trailing_comment, None);
1561    }
1562
1563    /// Routes a final same-line comment to the default arm.
1564    #[test]
1565    fn a_same_line_trailing_comment_before_a_conditionals_own_closing_brace_with_a_default_attaches_to_the_default()
1566     {
1567        let source = "sheet s {\n    conditional m {\n        _ => { relationship { b := a; } } // note\n    }\n}";
1568        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1569        attach_trivia(source, &mut sheet);
1570        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1571            panic!("expected Conditional");
1572        };
1573        let default = cond.default.as_ref().expect("default branch present");
1574        assert_eq!(
1575            default.trailing_line_comment,
1576            Some(crate::ast::Comment::Line("note".to_string()))
1577        );
1578        assert_eq!(cond.trailing_comment, None);
1579    }
1580
1581    /// Routes a final same-line comment to the last requirement.
1582    #[test]
1583    fn a_same_line_trailing_comment_before_a_requires_closing_brace_attaches_to_the_last_requirement()
1584     {
1585        let source =
1586            "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0; // note\n    };\n}";
1587        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1588        attach_trivia(source, &mut sheet);
1589        let crate::ast::SheetItem::Out(out) = &sheet.items[0] else {
1590            panic!("expected Out");
1591        };
1592        let require = out.require.as_ref().expect("require block present");
1593        assert_eq!(
1594            require.requirements[0].trailing_line_comment,
1595            Some(crate::ast::Comment::Line("note".to_string()))
1596        );
1597        assert_eq!(require.trailing_comment, None);
1598    }
1599}