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//! A comment is attached only if nothing but whitespace-on-the-same-line separates it from the
13//! following item — a blank line between an earlier comment and the item breaks the attachment,
14//! matching the common convention that a blank line ends a comment's association with what
15//! follows. `blank_line_before` is set 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//! A `//`/`/* */` comment ending or beginning on the same source line as an adjacent node's own
30//! first/last token — rather than on a fresh line of its own — attaches as that adjacent node's
31//! own `trailing_line_comment` instead of the following node's `leading_comment`; see
32//! [`analyze_gap`]'s `same_line` result and <https://github.com/stlab/cel-rs/issues/59>. This
33//! reuses the same node-to-node gaps described above, so it covers every sibling list and
34//! closing-brace gap this module already tracks, but goes no further: a comment written *inside*
35//! a CEL expression body (between two sub-expression tokens, with no adam-lang-tracked node
36//! boundary between them) is invisible to this whole gap-scanning mechanism and is silently
37//! dropped on reformat — see <https://github.com/stlab/cel-rs/issues/201>.
38
39use cel_parser::trivia::{line_column_to_byte, line_start_byte_offsets};
40use proc_macro2::LineColumn;
41
42use crate::ast::{
43    BindingDecl, CellDecl, ConditionalBranch, ConditionalDecl, ExprSpan, OutDecl, RelationshipDecl,
44    RequireBlock, RequirementDecl, Sheet, SourceDecl,
45};
46
47/// An AST node that can carry recovered leading trivia, attached by [`attach_gaps`].
48trait TriviaTarget {
49    fn span(&self) -> ExprSpan;
50    fn set_leading_comment(&mut self, comment: crate::ast::Comment);
51    /// Sets a `//`/`/* */` comment recovered on the same source line as this node's own last
52    /// token, immediately before the next sibling (or the enclosing container's own closing
53    /// `}`). See <https://github.com/stlab/cel-rs/issues/59>.
54    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment);
55    fn set_blank_line_before(&mut self, value: bool);
56}
57
58impl TriviaTarget for crate::ast::SheetItem {
59    fn span(&self) -> ExprSpan {
60        crate::ast::SheetItem::span(self)
61    }
62    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
63        crate::ast::SheetItem::set_leading_comment(self, comment)
64    }
65    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
66        crate::ast::SheetItem::set_trailing_line_comment(self, comment)
67    }
68    fn set_blank_line_before(&mut self, value: bool) {
69        crate::ast::SheetItem::set_blank_line_before(self, value)
70    }
71}
72
73impl TriviaTarget for BindingDecl {
74    fn span(&self) -> ExprSpan {
75        self.span
76    }
77    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
78        self.leading_comment = Some(comment);
79    }
80    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
81        self.trailing_line_comment = Some(comment);
82    }
83    fn set_blank_line_before(&mut self, value: bool) {
84        self.blank_line_before = value;
85    }
86}
87
88impl TriviaTarget for RelationshipDecl {
89    fn span(&self) -> ExprSpan {
90        self.span
91    }
92    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
93        self.leading_comment = Some(comment);
94    }
95    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
96        self.trailing_line_comment = Some(comment);
97    }
98    fn set_blank_line_before(&mut self, value: bool) {
99        self.blank_line_before = value;
100    }
101}
102
103impl TriviaTarget for ConditionalBranch {
104    fn span(&self) -> ExprSpan {
105        self.span
106    }
107    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
108        self.leading_comment = Some(comment);
109    }
110    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
111        self.trailing_line_comment = Some(comment);
112    }
113    fn set_blank_line_before(&mut self, value: bool) {
114        self.blank_line_before = value;
115    }
116}
117
118impl TriviaTarget for RequirementDecl {
119    fn span(&self) -> ExprSpan {
120        self.span
121    }
122    fn set_leading_comment(&mut self, comment: crate::ast::Comment) {
123        self.leading_comment = Some(comment);
124    }
125    fn set_trailing_line_comment(&mut self, comment: crate::ast::Comment) {
126        self.trailing_line_comment = Some(comment);
127    }
128    fn set_blank_line_before(&mut self, value: bool) {
129        self.blank_line_before = value;
130    }
131}
132
133/// A container whose own `{ ... }` block may carry trailing trivia — a comment or blank line
134/// between its last child and its own closing `}` — recovered by [`attach_trailing`]. See
135/// <https://github.com/stlab/cel-rs/issues/52>.
136trait TrailingTriviaTarget {
137    /// The span of this container's own opening `{`, used as the trailing gap's start when its
138    /// child list is empty.
139    fn open_brace_span(&self) -> proc_macro2::Span;
140    /// The span of this container's own closing `}`.
141    fn close_span(&self) -> proc_macro2::Span;
142    fn set_trailing_comment(&mut self, comment: crate::ast::Comment);
143    fn set_blank_line_before_close(&mut self, value: bool);
144}
145
146impl TrailingTriviaTarget for Sheet {
147    fn open_brace_span(&self) -> proc_macro2::Span {
148        self.open_brace_span.end
149    }
150    fn close_span(&self) -> proc_macro2::Span {
151        self.span.end
152    }
153    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
154        self.trailing_comment = Some(comment);
155    }
156    fn set_blank_line_before_close(&mut self, value: bool) {
157        self.blank_line_before_close = value;
158    }
159}
160
161impl TrailingTriviaTarget for RelationshipDecl {
162    fn open_brace_span(&self) -> proc_macro2::Span {
163        self.open_brace_span.end
164    }
165    fn close_span(&self) -> proc_macro2::Span {
166        self.span.end
167    }
168    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
169        self.trailing_comment = Some(comment);
170    }
171    fn set_blank_line_before_close(&mut self, value: bool) {
172        self.blank_line_before_close = value;
173    }
174}
175
176impl TrailingTriviaTarget for RequireBlock {
177    fn open_brace_span(&self) -> proc_macro2::Span {
178        self.open_brace_span.end
179    }
180    fn close_span(&self) -> proc_macro2::Span {
181        self.span.end
182    }
183    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
184        self.trailing_comment = Some(comment);
185    }
186    fn set_blank_line_before_close(&mut self, value: bool) {
187        self.blank_line_before_close = value;
188    }
189}
190
191impl TrailingTriviaTarget for ConditionalBranch {
192    fn open_brace_span(&self) -> proc_macro2::Span {
193        self.open_brace_span.end
194    }
195    fn close_span(&self) -> proc_macro2::Span {
196        self.span.end
197    }
198    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
199        self.trailing_comment = Some(comment);
200    }
201    fn set_blank_line_before_close(&mut self, value: bool) {
202        self.blank_line_before_close = value;
203    }
204}
205
206impl TrailingTriviaTarget for crate::ast::DefaultBranch {
207    fn open_brace_span(&self) -> proc_macro2::Span {
208        self.open_brace_span.end
209    }
210    fn close_span(&self) -> proc_macro2::Span {
211        self.span.end
212    }
213    fn set_trailing_comment(&mut self, comment: crate::ast::Comment) {
214        self.trailing_comment = Some(comment);
215    }
216    fn set_blank_line_before_close(&mut self, value: bool) {
217        self.blank_line_before_close = value;
218    }
219}
220
221/// Byte offset immediately after a container's own opening `{`.
222///
223/// `open_brace` is the flattened `OpenDelim` token's span, which `cel_parser::lex_lexer::LexLexer`
224/// sets to the *whole* delimited group's span (`proc_macro2::Group::span()`'s documented
225/// behavior) rather than to just the one-character `{` token — the same span value the matching
226/// `CloseDelim` token carries. So `open_brace.start()` is the position of the `{` character
227/// itself; advancing one byte (a brace is always exactly one ASCII byte) lands just past it.
228fn after_open_brace(source: &str, line_starts: &[usize], open_brace: proc_macro2::Span) -> usize {
229    line_column_to_byte(source, line_starts, open_brace.start()) + 1
230}
231
232/// Byte offset immediately before a container's own closing `}` — the mirror of
233/// [`after_open_brace`], exploiting the same whole-group-span quirk from the other end:
234/// `close_brace.end()` is the position just past the `}`; stepping back one byte lands just
235/// before it.
236fn before_close_brace(
237    source: &str,
238    line_starts: &[usize],
239    close_brace: proc_macro2::Span,
240) -> usize {
241    line_column_to_byte(source, line_starts, close_brace.end()) - 1
242}
243
244/// Recovers trailing trivia (a comment/blank line between the last child's end position —
245/// `last_child_end`, precomputed by the caller from its own child list before taking a mutable
246/// borrow of `container`, since `container` may be that same list's owner — and `container`'s
247/// own closing `}`, or between its opening `{` and closing `}` when `last_child_end` is `None`)
248/// and attaches it to `container`. See <https://github.com/stlab/cel-rs/issues/52>.
249///
250/// When the recovered comment's first line begins on the same source line as the last child's
251/// own last token, it belongs to that child's `trailing_line_comment` instead of `container`'s
252/// own `trailing_comment` (see <https://github.com/stlab/cel-rs/issues/59>) — but this function
253/// has no access to the child list itself (only its precomputed end position), so it returns the
254/// comment to the caller in that case instead of attaching it, leaving the caller (which does
255/// have the list) to set it on `items.last_mut()`. Returns `None` when there is no such same-line
256/// comment to route (either no comment was found, or it was a fresh leading-style comment and has
257/// already been attached directly to `container`).
258fn attach_trailing<C: TrailingTriviaTarget>(
259    source: &str,
260    line_starts: &[usize],
261    last_child_end: Option<LineColumn>,
262    container: &mut C,
263) -> Option<crate::ast::Comment> {
264    let start = match last_child_end {
265        Some(pos) => line_column_to_byte(source, line_starts, pos),
266        None => after_open_brace(source, line_starts, container.open_brace_span()),
267    };
268    let end = before_close_brace(source, line_starts, container.close_span());
269    if start >= end {
270        return None;
271    }
272    let gap_text = &source[start..end];
273    let (comment, blank_line_before_close, same_line) = analyze_gap(gap_text);
274    container.set_blank_line_before_close(blank_line_before_close);
275    match comment {
276        Some(comment) if same_line && last_child_end.is_some() => Some(comment),
277        Some(comment) => {
278            container.set_trailing_comment(comment);
279            None
280        }
281        None => None,
282    }
283}
284
285/// Recovers `ConditionalDecl`'s own trailing trivia — the gap before its outer closing `}`,
286/// after its default arm if present, else its last branch, else (an empty conditional) its own
287/// opening `{`. Handled specially because a `ConditionalDecl`'s "last child" isn't a single
288/// homogeneous list — it's whichever of `branches`/`default` came last in declaration order.
289///
290/// Same-line routing mirrors [`attach_trailing`]: returns the comment instead of attaching it to
291/// `cond` when it belongs on the same line as the default arm's (or last branch's) own last
292/// token, leaving the caller to attach it there. See <https://github.com/stlab/cel-rs/issues/59>.
293fn attach_conditional_trailing(
294    source: &str,
295    line_starts: &[usize],
296    cond: &mut ConditionalDecl,
297) -> Option<crate::ast::Comment> {
298    let has_last_child = cond.default.is_some() || !cond.branches.is_empty();
299    let start = if let Some(default) = &cond.default {
300        line_column_to_byte(source, line_starts, default.span.end.end())
301    } else if let Some(last_branch) = cond.branches.last() {
302        line_column_to_byte(source, line_starts, last_branch.span.end.end())
303    } else {
304        after_open_brace(source, line_starts, cond.open_brace_span.end)
305    };
306    let end = before_close_brace(source, line_starts, cond.span.end);
307    if start >= end {
308        return None;
309    }
310    let gap_text = &source[start..end];
311    let (comment, blank_line_before_close, same_line) = analyze_gap(gap_text);
312    cond.blank_line_before_close = blank_line_before_close;
313    match comment {
314        Some(comment) if same_line && has_last_child => Some(comment),
315        Some(comment) => {
316            cond.trailing_comment = Some(comment);
317            None
318        }
319        None => None,
320    }
321}
322
323/// Recovers comments/blank-lines from every gap in `sheet` — a leading comment before the
324/// `sheet` keyword itself, its own top-level items, and every nested `relationship`/`conditional`
325/// body — attaching each to the nearest following node.
326///
327/// - Precondition: `sheet` was parsed from exactly `source` (unmodified), so its items' spans'
328///   line/column positions resolve correctly against it.
329///
330/// - Complexity: O(n) in the length of `source` plus the number of nested lists — every gap's
331///   `LineColumn -> byte offset` conversion reuses the shared `line_starts` table computed once
332///   up front (see `line_start_byte_offsets`), rather than rescanning `source` per gap.
333pub fn attach_trivia(source: &str, sheet: &mut Sheet) {
334    let line_starts = line_start_byte_offsets(source);
335    let sheet_start = line_column_to_byte(source, &line_starts, sheet.span.start.start());
336    let (leading_comment, _, _) = analyze_gap(&source[..sheet_start]);
337    sheet.leading_comment = leading_comment;
338    attach_gaps(source, &line_starts, &mut sheet.items);
339    let last_child_end = sheet.items.last().map(|item| item.span().end.end());
340    if let Some(comment) = attach_trailing(source, &line_starts, last_child_end, sheet) {
341        sheet
342            .items
343            .last_mut()
344            .expect("same-line routing only returns Some when a last child exists")
345            .set_trailing_line_comment(comment);
346    }
347    for item in &mut sheet.items {
348        match item {
349            crate::ast::SheetItem::Relationship(rel) => {
350                attach_relationship(source, &line_starts, rel)
351            }
352            crate::ast::SheetItem::Conditional(cond) => {
353                attach_conditional(source, &line_starts, cond)
354            }
355            crate::ast::SheetItem::Out(out_decl) => attach_out(source, &line_starts, out_decl),
356            crate::ast::SheetItem::Cell(cell) => attach_cell(source, &line_starts, cell),
357            crate::ast::SheetItem::Source(source_decl) => {
358                attach_source(source, &line_starts, source_decl)
359            }
360            crate::ast::SheetItem::Error { .. } => {}
361        }
362    }
363}
364
365/// Recovers trivia for a relationship block's bindings.
366fn attach_relationship(source: &str, line_starts: &[usize], rel: &mut RelationshipDecl) {
367    attach_gaps(source, line_starts, &mut rel.bindings);
368    let last_child_end = rel.bindings.last().map(|b| b.span().end.end());
369    if let Some(comment) = attach_trailing(source, line_starts, last_child_end, rel) {
370        rel.bindings
371            .last_mut()
372            .expect("same-line routing only returns Some when a last child exists")
373            .set_trailing_line_comment(comment);
374    }
375}
376
377/// Recovers trivia for a conditional's branches, its default, and their nested relationships.
378fn attach_conditional(source: &str, line_starts: &[usize], cond: &mut ConditionalDecl) {
379    attach_gaps(source, line_starts, &mut cond.branches);
380    for branch in &mut cond.branches {
381        attach_gaps(source, line_starts, &mut branch.relationships);
382        let last_child_end = branch.relationships.last().map(|r| r.span().end.end());
383        if let Some(comment) = attach_trailing(source, line_starts, last_child_end, branch) {
384            branch
385                .relationships
386                .last_mut()
387                .expect("same-line routing only returns Some when a last child exists")
388                .set_trailing_line_comment(comment);
389        }
390        for rel in &mut branch.relationships {
391            attach_relationship(source, line_starts, rel);
392        }
393    }
394    if let Some(default) = &mut cond.default {
395        attach_gaps(source, line_starts, &mut default.relationships);
396        let last_child_end = default.relationships.last().map(|r| r.span().end.end());
397        if let Some(comment) = attach_trailing(source, line_starts, last_child_end, default) {
398            default
399                .relationships
400                .last_mut()
401                .expect("same-line routing only returns Some when a last child exists")
402                .set_trailing_line_comment(comment);
403        }
404        for rel in default.relationships.iter_mut() {
405            attach_relationship(source, line_starts, rel);
406        }
407    }
408    if let Some(comment) = attach_conditional_trailing(source, line_starts, cond) {
409        if let Some(default) = &mut cond.default {
410            default.trailing_line_comment = Some(comment);
411        } else if let Some(last_branch) = cond.branches.last_mut() {
412            last_branch.set_trailing_line_comment(comment);
413        }
414    }
415}
416
417/// Recovers trivia for a `require { ... }` block, if present — the gap before its own closing
418/// `}`, and gaps between its requirements. A `None` require block (a `cell`/`source`/`out` with
419/// no `require { ... }` clause) has nothing further to recover here: its `initializer`/`filter`
420/// expressions carry no trivia of their own. Shared by [`attach_cell`], [`attach_source`], and
421/// [`attach_out`], since a `require` block has the same trivia-recovery rules regardless of which
422/// cell kind it's attached to.
423fn attach_require_block(source: &str, line_starts: &[usize], require: Option<&mut RequireBlock>) {
424    let Some(require) = require else {
425        return;
426    };
427    attach_gaps(source, line_starts, &mut require.requirements);
428    let last_child_end = require.requirements.last().map(|r| r.span().end.end());
429    if let Some(comment) = attach_trailing(source, line_starts, last_child_end, require) {
430        require
431            .requirements
432            .last_mut()
433            .expect("same-line routing only returns Some when a last child exists")
434            .set_trailing_line_comment(comment);
435    }
436}
437
438/// Recovers trivia for an `out` declaration's `require` block, if present, via
439/// [`attach_require_block`].
440fn attach_out(source: &str, line_starts: &[usize], out_decl: &mut OutDecl) {
441    attach_require_block(source, line_starts, out_decl.require.as_mut());
442}
443
444/// Recovers trivia for a `cell` declaration's `require` block, if present, via
445/// [`attach_require_block`].
446fn attach_cell(source: &str, line_starts: &[usize], cell: &mut CellDecl) {
447    attach_require_block(source, line_starts, cell.require.as_mut());
448}
449
450/// Recovers trivia for a `source` declaration's `require` block, if present, via
451/// [`attach_require_block`].
452fn attach_source(source: &str, line_starts: &[usize], source_decl: &mut SourceDecl) {
453    attach_require_block(source, line_starts, source_decl.require.as_mut());
454}
455
456/// Recovers comments/blank-lines from the gaps between consecutive `items`, attaching each to the
457/// nearest following item — unless the comment's first line begins on the same source line as
458/// the *previous* item's own last token, in which case it attaches to that previous item's
459/// `trailing_line_comment` instead (see <https://github.com/stlab/cel-rs/issues/59>). The first
460/// item in `items` never gets a blank-line-before or leading comment — nothing in this list
461/// precedes it (a blank line or comment between it and this list's own enclosing `{` is a
462/// separate, untracked case; see the module doc's linked issue).
463fn attach_gaps<T: TriviaTarget>(source: &str, line_starts: &[usize], items: &mut [T]) {
464    if items.len() < 2 {
465        return;
466    }
467    for i in 1..items.len() {
468        let start = line_column_to_byte(source, line_starts, items[i - 1].span().end.end());
469        let end = line_column_to_byte(source, line_starts, items[i].span().start.start());
470        let gap_text = &source[start..end];
471        let (comment, blank_line_before, same_line) = analyze_gap(gap_text);
472        items[i].set_blank_line_before(blank_line_before);
473        if let Some(comment) = comment {
474            if same_line {
475                items[i - 1].set_trailing_line_comment(comment);
476            } else {
477                items[i].set_leading_comment(comment);
478            }
479        }
480    }
481}
482
483/// Analyzes one gap between two consecutive items: the maximal trailing run of `//` line
484/// comments, or a single `/* ... */` block comment (possibly spanning multiple lines),
485/// immediately preceding the next item, if any; whether that comment run's first line begins on
486/// the same source line as the gap's own start (i.e. it directly trails the *previous* item's
487/// last token, rather than leading the next item on a fresh line — see
488/// <https://github.com/stlab/cel-rs/issues/59>); and whether a blank line remains anywhere in
489/// what's left of the gap once that trailing run is accounted for (see the module doc for why
490/// the scan order matters).
491///
492/// - Complexity: O(n) in the length of `gap`.
493fn analyze_gap(gap: &str) -> (Option<crate::ast::Comment>, bool, bool) {
494    use crate::ast::Comment;
495    let mut lines: Vec<&str> = gap.lines().collect();
496    // `gap` ends exactly where the following item's first token begins. When that token isn't at
497    // column 0 and its own line is otherwise blank up to that point, `lines()`'s final entry is
498    // only leading whitespace, not a blank source line — drop that fragment before scanning for a
499    // trailing comment run so a real blank line (a genuine empty entry from `lines()`) still
500    // breaks the run. But when the gap has no newline at all (the previous item, an intervening
501    // comment, and the next item all share one physical line — see
502    // <https://github.com/stlab/cel-rs/pull/202>'s review), this same final entry is the *entire*
503    // gap, not just trailing indentation, so a non-whitespace fragment (a same-line `/* */`
504    // comment) must be kept for the scan below rather than discarded unseen.
505    if !gap.ends_with('\n') && lines.last().is_some_and(|line| line.trim().is_empty()) {
506        lines.pop();
507    }
508    let mut comment = None;
509    if let Some(last) = lines.last() {
510        let trimmed = last.trim();
511        if let Some(text) = trimmed.strip_prefix("//") {
512            let mut collected = vec![text.trim().to_string()];
513            lines.pop();
514            while let Some(line) = lines.last() {
515                let trimmed = line.trim();
516                if let Some(text) = trimmed.strip_prefix("//") {
517                    collected.push(text.trim().to_string());
518                    lines.pop();
519                } else {
520                    break;
521                }
522            }
523            collected.reverse();
524            comment = Some(Comment::Line(collected.join("\n")));
525        } else if let Some(inner) = trimmed
526            .strip_prefix("/*")
527            .and_then(|s| s.strip_suffix("*/"))
528        {
529            // A single-line block comment.
530            comment = Some(Comment::Block(inner.trim().to_string()));
531            lines.pop();
532        } else if trimmed.ends_with("*/") {
533            // The close of a block comment that opened on an earlier line — collect backwards
534            // until the line that opens it (see #105). A block comment is one unit; don't merge
535            // with an earlier `//` run.
536            let mut collected = Vec::new();
537            let closing_prefix = trimmed
538                .strip_suffix("*/")
539                .expect("checked ends_with(\"*/\") above")
540                .trim();
541            if !closing_prefix.is_empty() {
542                collected.push(closing_prefix.to_string());
543            }
544            lines.pop();
545            let mut found_open = false;
546            while let Some(line) = lines.last() {
547                let trimmed = line.trim();
548                lines.pop();
549                if let Some(text) = trimmed.strip_prefix("/*") {
550                    let opening_suffix = text.trim();
551                    if !opening_suffix.is_empty() {
552                        collected.push(opening_suffix.to_string());
553                    }
554                    found_open = true;
555                    break;
556                }
557                collected.push(trimmed.to_string());
558            }
559            // If the gap is exhausted without finding a matching `/*` (not expected for
560            // well-formed input), `found_open` stays false and no comment is fabricated.
561            if found_open {
562                collected.reverse();
563                comment = Some(Comment::Block(collected.join("\n")));
564            }
565        }
566    }
567    // If the comment run's backward scan consumed every remaining line, including `lines[0]`
568    // (the trailing remainder of the *previous* item's own source line), then the comment's
569    // first line began immediately after the previous item's last token — a same-line trailing
570    // comment, not a fresh leading comment on the next item. See
571    // <https://github.com/stlab/cel-rs/issues/59>.
572    let same_line = comment.is_some() && lines.is_empty();
573    // `lines[0]` is always the trailing remainder of the *previous* item's own source line (the
574    // fragment before the gap's first `\n`), which is empty whenever that item's last token is
575    // already at end-of-line — the common case. It must be excluded here: only a line strictly
576    // after it sits between two `\n`s in the original gap and can be a genuine blank line.
577    let blank_line_before = lines.len() > 1 && lines[1..].iter().any(|l| l.trim().is_empty());
578    (comment, blank_line_before, same_line)
579}
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584    use crate::AdamAstParser;
585
586    #[test]
587    fn attaches_a_line_comment_immediately_before_a_cell_decl() {
588        let source = "sheet s {\n    cell a: i32 = 1;\n    // the total\n    cell b: i32 = 2;\n}";
589        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
590        attach_trivia(source, &mut sheet);
591        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
592            panic!("expected Cell");
593        };
594        assert_eq!(
595            b.leading_comment,
596            Some(crate::ast::Comment::Line("the total".to_string()))
597        );
598    }
599
600    #[test]
601    fn attaches_a_multi_line_comment_block() {
602        let source = "sheet s {\n    cell a: i32 = 1;\n    // line one\n    // line two\n    cell b: i32 = 2;\n}";
603        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
604        attach_trivia(source, &mut sheet);
605        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
606            panic!("expected Cell");
607        };
608        assert_eq!(
609            b.leading_comment,
610            Some(crate::ast::Comment::Line("line one\nline two".to_string()))
611        );
612    }
613
614    #[test]
615    fn attaches_a_single_line_block_comment() {
616        let source =
617            "sheet s {\n    cell a: i32 = 1;\n    /* the total */\n    cell b: i32 = 2;\n}";
618        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
619        attach_trivia(source, &mut sheet);
620        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
621            panic!("expected Cell");
622        };
623        assert_eq!(
624            b.leading_comment,
625            Some(crate::ast::Comment::Block("the total".to_string()))
626        );
627    }
628
629    #[test]
630    fn attaches_a_multi_line_block_comment() {
631        let source = "sheet s {\n    cell a: i32 = 1;\n    /*\n        line one\n        line two\n    */\n    cell b: i32 = 2;\n}";
632        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
633        attach_trivia(source, &mut sheet);
634        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
635            panic!("expected Cell");
636        };
637        assert_eq!(
638            b.leading_comment,
639            Some(crate::ast::Comment::Block("line one\nline two".to_string()))
640        );
641    }
642
643    #[test]
644    fn attaches_the_issue_105_license_header_repro_as_a_block_comment() {
645        let source =
646            "/*\n    Copyright 2013 Adobe\n    ...\n*/\nsheet s {\n    cell a: i32 = 1;\n}";
647        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
648        attach_trivia(source, &mut sheet);
649        assert_eq!(
650            sheet.leading_comment,
651            Some(crate::ast::Comment::Block(
652                "Copyright 2013 Adobe\n...".to_string()
653            ))
654        );
655    }
656
657    #[test]
658    fn a_line_comment_is_recovered_as_comment_line() {
659        let source = "sheet s {\n    cell a: i32 = 1;\n    // the total\n    cell b: i32 = 2;\n}";
660        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
661        attach_trivia(source, &mut sheet);
662        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
663            panic!("expected Cell");
664        };
665        assert_eq!(
666            b.leading_comment,
667            Some(crate::ast::Comment::Line("the total".to_string()))
668        );
669    }
670
671    #[test]
672    fn does_not_attach_a_comment_separated_by_a_blank_line() {
673        let source =
674            "sheet s {\n    cell a: i32 = 1;\n    // stale comment\n\n    cell b: i32 = 2;\n}";
675        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
676        attach_trivia(source, &mut sheet);
677        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
678            panic!("expected Cell");
679        };
680        assert_eq!(b.leading_comment, None);
681    }
682
683    #[test]
684    fn no_comment_in_the_gap_leaves_leading_comment_none() {
685        let source = "sheet s {\n    cell a: i32 = 1;\n    cell b: i32 = 2;\n}";
686        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
687        attach_trivia(source, &mut sheet);
688        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
689            panic!("expected Cell");
690        };
691        assert_eq!(b.leading_comment, None);
692    }
693
694    #[test]
695    fn attaches_comments_correctly_across_more_than_one_gap() {
696        let source = "sheet s {\n    cell a: i32 = 1;\n    // first\n    cell b: i32 = 2;\n    // second\n    cell c: i32 = 3;\n}";
697        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
698        attach_trivia(source, &mut sheet);
699        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
700            panic!("expected Cell");
701        };
702        assert_eq!(
703            b.leading_comment,
704            Some(crate::ast::Comment::Line("first".to_string()))
705        );
706        let crate::ast::SheetItem::Cell(c) = &sheet.items[2] else {
707            panic!("expected Cell");
708        };
709        assert_eq!(
710            c.leading_comment,
711            Some(crate::ast::Comment::Line("second".to_string()))
712        );
713    }
714
715    #[test]
716    fn attaches_a_comment_preceding_a_recovered_error_item() {
717        let source = "sheet s {\n    cell a: i32 = 1;\n    // fix me\n    cell bad unknown_syntax\n    cell c: i32 = 2;\n}";
718        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
719        attach_trivia(source, &mut sheet);
720        let crate::ast::SheetItem::Error {
721            leading_comment, ..
722        } = &sheet.items[1]
723        else {
724            panic!("expected Error");
725        };
726        assert_eq!(
727            leading_comment,
728            &Some(crate::ast::Comment::Line("fix me".to_string()))
729        );
730    }
731
732    #[test]
733    fn recovery_span_that_abuts_the_next_keyword_does_not_invert_the_gap() {
734        let source = "sheet s { cell bad relationship { y := x; } }";
735        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
736        attach_trivia(source, &mut sheet); // must not panic
737        assert!(matches!(
738            sheet.items[0],
739            crate::ast::SheetItem::Error { .. }
740        ));
741        assert!(matches!(
742            sheet.items[1],
743            crate::ast::SheetItem::Relationship(_)
744        ));
745    }
746
747    #[test]
748    fn sets_blank_line_before_true_when_a_blank_line_separates_two_items() {
749        let source = "sheet s {\n    cell a: i32 = 1;\n\n    cell b: i32 = 2;\n}";
750        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
751        attach_trivia(source, &mut sheet);
752        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
753            panic!("expected Cell");
754        };
755        assert!(b.blank_line_before);
756    }
757
758    #[test]
759    fn sets_blank_line_before_false_when_items_are_packed_tight() {
760        let source = "sheet s {\n    cell a: i32 = 1;\n    cell b: i32 = 2;\n}";
761        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
762        attach_trivia(source, &mut sheet);
763        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
764            panic!("expected Cell");
765        };
766        assert!(!b.blank_line_before);
767    }
768
769    #[test]
770    fn a_run_of_several_blank_lines_still_just_sets_the_flag_true() {
771        let source = "sheet s {\n    cell a: i32 = 1;\n\n\n\n    cell b: i32 = 2;\n}";
772        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
773        attach_trivia(source, &mut sheet);
774        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
775            panic!("expected Cell");
776        };
777        assert!(b.blank_line_before);
778    }
779
780    #[test]
781    fn blank_line_before_an_attached_comment_still_sets_the_flag_true() {
782        // The blank line precedes the comment, not the item — the comment still attaches to b
783        // (nothing separates the comment itself from b), but the blank line further back in the
784        // gap still counts as separating a from (comment + b) as a group.
785        let source = "sheet s {\n    cell a: i32 = 1;\n\n    // c\n    cell b: i32 = 2;\n}";
786        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
787        attach_trivia(source, &mut sheet);
788        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
789            panic!("expected Cell");
790        };
791        assert_eq!(
792            b.leading_comment,
793            Some(crate::ast::Comment::Line("c".to_string()))
794        );
795        assert!(b.blank_line_before);
796    }
797
798    #[test]
799    fn attaches_a_comment_and_blank_line_to_a_binding_inside_a_relationship() {
800        let source = "sheet s {\n    relationship {\n        b := a;\n\n        // second\n        a := b;\n    }\n}";
801        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
802        attach_trivia(source, &mut sheet);
803        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
804            panic!("expected Relationship");
805        };
806        assert_eq!(
807            rel.bindings[1].leading_comment,
808            Some(crate::ast::Comment::Line("second".to_string()))
809        );
810        assert!(rel.bindings[1].blank_line_before);
811    }
812
813    #[test]
814    fn attaches_a_comment_to_a_conditional_branch() {
815        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } }\n        // one\n        1i32 => { relationship { b := a; } }\n    }\n}";
816        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
817        attach_trivia(source, &mut sheet);
818        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
819            panic!("expected Conditional");
820        };
821        assert_eq!(
822            cond.branches[1].leading_comment,
823            Some(crate::ast::Comment::Line("one".to_string()))
824        );
825    }
826
827    #[test]
828    fn attaches_a_comment_to_a_relationship_nested_inside_a_conditional_branch() {
829        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; }\n            // second\n            relationship { a := b; }\n        }\n    }\n}";
830        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
831        attach_trivia(source, &mut sheet);
832        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
833            panic!("expected Conditional");
834        };
835        assert_eq!(
836            cond.branches[0].relationships[1].leading_comment,
837            Some(crate::ast::Comment::Line("second".to_string()))
838        );
839    }
840
841    #[test]
842    fn attaches_a_comment_to_a_relationship_nested_inside_the_default_branch() {
843        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; }\n            // second\n            relationship { a := b; }\n        }\n    }\n}";
844        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
845        attach_trivia(source, &mut sheet);
846        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
847            panic!("expected Conditional");
848        };
849        let default = cond.default.as_ref().expect("default branch present");
850        assert_eq!(
851            default.relationships[1].leading_comment,
852            Some(crate::ast::Comment::Line("second".to_string()))
853        );
854    }
855
856    #[test]
857    fn attaches_a_leading_comment_before_the_sheet_itself() {
858        let source = "// file header\nsheet s {\n    cell a: i32 = 1;\n}";
859        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
860        attach_trivia(source, &mut sheet);
861        assert_eq!(
862            sheet.leading_comment,
863            Some(crate::ast::Comment::Line("file header".to_string()))
864        );
865    }
866
867    #[test]
868    fn attaches_a_multi_line_leading_comment_before_the_sheet_itself() {
869        let source = "// line one\n// line two\nsheet s {\n    cell a: i32 = 1;\n}";
870        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
871        attach_trivia(source, &mut sheet);
872        assert_eq!(
873            sheet.leading_comment,
874            Some(crate::ast::Comment::Line("line one\nline two".to_string()))
875        );
876    }
877
878    #[test]
879    fn no_leading_comment_before_the_sheet_leaves_it_none() {
880        let source = "sheet s {\n    cell a: i32 = 1;\n}";
881        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
882        attach_trivia(source, &mut sheet);
883        assert_eq!(sheet.leading_comment, None);
884    }
885
886    #[test]
887    fn attaches_a_comment_to_a_requirement_inside_an_out_declaration() {
888        // NOTE: deviates from a literal per-requirement translation of the old fixture, which
889        // put the commented requirement first. The old grammar could attach a leading comment to
890        // a require block's *first* requirement because `out_decl`'s writer method was a real,
891        // separately-spanned sibling `attach_out` computed a manual gap from; the new grammar
892        // flattens `out`'s writer into a bare `initializer` expression with no span-tracked
893        // sibling of its own, so `require.requirements[0]` is now subject to the same
894        // never-first-item limitation as every other sibling list (see the module doc's #52
895        // link). A second requirement here lands the comment in a tracked gap between two
896        // siblings instead, exercising the same attach-to-a-nested-requirement behavior.
897        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}";
898        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
899        attach_trivia(source, &mut sheet);
900        let crate::ast::SheetItem::Out(out) = &sheet.items[0] else {
901            panic!("expected Out");
902        };
903        let require = out.require.as_ref().expect("require block present");
904        assert_eq!(
905            require.requirements[1].leading_comment,
906            Some(crate::ast::Comment::Line("second".to_string()))
907        );
908    }
909
910    #[test]
911    fn recovers_a_trailing_comment_before_a_sheets_closing_brace() {
912        let source = "sheet s {\n    cell a: i32 = 1;\n    // trailing\n}";
913        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
914        attach_trivia(source, &mut sheet);
915        assert_eq!(
916            sheet.trailing_comment,
917            Some(crate::ast::Comment::Line("trailing".to_string()))
918        );
919    }
920
921    #[test]
922    fn recovers_a_trailing_comment_in_an_empty_relationship_block() {
923        let source = "sheet s {\n    relationship {\n        // only this\n    }\n}";
924        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
925        attach_trivia(source, &mut sheet);
926        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
927            panic!("expected Relationship");
928        };
929        assert_eq!(
930            rel.trailing_comment,
931            Some(crate::ast::Comment::Line("only this".to_string()))
932        );
933    }
934
935    #[test]
936    fn recovers_a_trailing_comment_before_a_relationships_closing_brace() {
937        let source =
938            "sheet s {\n    relationship {\n        b := a;\n        // trailing\n    }\n}";
939        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
940        attach_trivia(source, &mut sheet);
941        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
942            panic!("expected Relationship");
943        };
944        assert_eq!(
945            rel.trailing_comment,
946            Some(crate::ast::Comment::Line("trailing".to_string()))
947        );
948    }
949
950    #[test]
951    fn recovers_a_trailing_comment_before_a_conditional_branchs_closing_brace() {
952        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; }\n            // trailing\n        }\n    }\n}";
953        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
954        attach_trivia(source, &mut sheet);
955        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
956            panic!("expected Conditional");
957        };
958        assert_eq!(
959            cond.branches[0].trailing_comment,
960            Some(crate::ast::Comment::Line("trailing".to_string()))
961        );
962    }
963
964    #[test]
965    fn recovers_a_trailing_comment_in_a_default_arm() {
966        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; }\n            // trailing\n        }\n    }\n}";
967        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
968        attach_trivia(source, &mut sheet);
969        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
970            panic!("expected Conditional");
971        };
972        let default = cond.default.as_ref().expect("default branch present");
973        assert_eq!(
974            default.trailing_comment,
975            Some(crate::ast::Comment::Line("trailing".to_string()))
976        );
977    }
978
979    #[test]
980    fn recovers_a_trailing_comment_before_a_conditionals_own_closing_brace() {
981        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } }\n        // trailing\n    }\n}";
982        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
983        attach_trivia(source, &mut sheet);
984        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
985            panic!("expected Conditional");
986        };
987        assert_eq!(
988            cond.trailing_comment,
989            Some(crate::ast::Comment::Line("trailing".to_string()))
990        );
991    }
992
993    // NOTE: `recovers_a_trailing_comment_before_an_outs_closing_brace_with_no_conditions` (the old
994    // no-`require`-block case) is intentionally not carried forward: `OutDecl` no longer has a
995    // closing brace or a `trailing_comment` field of its own (the new grammar is flat and
996    // `;`-terminated), so there is nothing left to attach such a comment to when no `require`
997    // block is present — see the task brief's guidance to drop this case rather than invent one.
998
999    #[test]
1000    fn recovers_a_trailing_comment_before_a_requires_closing_brace() {
1001        let source = "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0;\n        // trailing\n    };\n}";
1002        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1003        attach_trivia(source, &mut sheet);
1004        let crate::ast::SheetItem::Out(out) = &sheet.items[0] else {
1005            panic!("expected Out");
1006        };
1007        let require = out.require.as_ref().expect("require block present");
1008        assert_eq!(
1009            require.trailing_comment,
1010            Some(crate::ast::Comment::Line("trailing".to_string()))
1011        );
1012    }
1013
1014    #[test]
1015    fn attaches_a_comment_to_a_requirement_inside_a_cell_declaration() {
1016        // Mirrors `attaches_a_comment_to_a_requirement_inside_an_out_declaration`, but for a
1017        // `cell`'s own `require` block — `CellDecl` gained `require` alongside `out`'s, and its
1018        // trivia recovery must not be dropped on the floor the way it was before this fix.
1019        let source = "sheet s {\n    cell a: i32 = 1 require {\n        @r1 a > 0;\n        // second\n        @r2 a < 10;\n    };\n}";
1020        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1021        attach_trivia(source, &mut sheet);
1022        let crate::ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1023            panic!("expected Cell");
1024        };
1025        let require = cell.require.as_ref().expect("require block present");
1026        assert_eq!(
1027            require.requirements[1].leading_comment,
1028            Some(crate::ast::Comment::Line("second".to_string()))
1029        );
1030    }
1031
1032    #[test]
1033    fn recovers_a_trailing_comment_before_a_cells_requires_closing_brace() {
1034        // Mirrors `recovers_a_trailing_comment_before_a_requires_closing_brace`, but for a
1035        // `cell`'s own `require` block.
1036        let source = "sheet s {\n    cell a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}";
1037        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1038        attach_trivia(source, &mut sheet);
1039        let crate::ast::SheetItem::Cell(cell) = &sheet.items[0] else {
1040            panic!("expected Cell");
1041        };
1042        let require = cell.require.as_ref().expect("require block present");
1043        assert_eq!(
1044            require.trailing_comment,
1045            Some(crate::ast::Comment::Line("trailing".to_string()))
1046        );
1047    }
1048
1049    #[test]
1050    fn attaches_a_comment_to_a_requirement_inside_a_source_declaration() {
1051        // Mirrors `attaches_a_comment_to_a_requirement_inside_an_out_declaration`, but for a
1052        // `source`'s own `require` block.
1053        let source = "sheet s {\n    source a: i32 = 1 require {\n        @r1 a > 0;\n        // second\n        @r2 a < 10;\n    };\n}";
1054        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1055        attach_trivia(source, &mut sheet);
1056        let crate::ast::SheetItem::Source(source_decl) = &sheet.items[0] else {
1057            panic!("expected Source");
1058        };
1059        let require = source_decl.require.as_ref().expect("require block present");
1060        assert_eq!(
1061            require.requirements[1].leading_comment,
1062            Some(crate::ast::Comment::Line("second".to_string()))
1063        );
1064    }
1065
1066    #[test]
1067    fn recovers_a_trailing_comment_before_a_sources_requires_closing_brace() {
1068        // Mirrors `recovers_a_trailing_comment_before_a_requires_closing_brace`, but for a
1069        // `source`'s own `require` block.
1070        let source = "sheet s {\n    source a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}";
1071        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1072        attach_trivia(source, &mut sheet);
1073        let crate::ast::SheetItem::Source(source_decl) = &sheet.items[0] else {
1074            panic!("expected Source");
1075        };
1076        let require = source_decl.require.as_ref().expect("require block present");
1077        assert_eq!(
1078            require.trailing_comment,
1079            Some(crate::ast::Comment::Line("trailing".to_string()))
1080        );
1081    }
1082
1083    #[test]
1084    fn sets_blank_line_before_close_when_a_blank_line_precedes_the_closing_brace() {
1085        let source = "sheet s {\n    cell a: i32 = 1;\n\n}";
1086        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1087        attach_trivia(source, &mut sheet);
1088        assert!(sheet.blank_line_before_close);
1089    }
1090
1091    #[test]
1092    fn no_trailing_comment_leaves_trailing_comment_none() {
1093        let source = "sheet s {\n    cell a: i32 = 1;\n}";
1094        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1095        attach_trivia(source, &mut sheet);
1096        assert_eq!(sheet.trailing_comment, None);
1097    }
1098
1099    // Issue #59: a `//` comment at the end of a declaration's own line must attach to that
1100    // declaration's own `trailing_line_comment`, not the following item's `leading_comment`.
1101
1102    #[test]
1103    fn a_same_line_trailing_line_comment_attaches_to_the_previous_cell_not_the_next() {
1104        let source =
1105            "sheet s {\n    cell a: f64 = 2.0; // end of line comment\n    cell b: f64 = 3.0;\n}";
1106        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1107        attach_trivia(source, &mut sheet);
1108        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1109            panic!("expected Cell");
1110        };
1111        assert_eq!(
1112            a.trailing_line_comment,
1113            Some(crate::ast::Comment::Line("end of line comment".to_string()))
1114        );
1115        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1116            panic!("expected Cell");
1117        };
1118        assert_eq!(b.leading_comment, None);
1119        assert!(!b.blank_line_before);
1120    }
1121
1122    #[test]
1123    fn a_same_line_trailing_block_comment_attaches_to_the_previous_cell_not_the_next() {
1124        let source = "sheet s {\n    cell a: f64 = 2.0; /* note */\n    cell b: f64 = 3.0;\n}";
1125        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1126        attach_trivia(source, &mut sheet);
1127        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1128            panic!("expected Cell");
1129        };
1130        assert_eq!(
1131            a.trailing_line_comment,
1132            Some(crate::ast::Comment::Block("note".to_string()))
1133        );
1134        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1135            panic!("expected Cell");
1136        };
1137        assert_eq!(b.leading_comment, None);
1138    }
1139
1140    #[test]
1141    fn a_comment_on_its_own_line_still_attaches_as_a_leading_comment() {
1142        // Regression guard: a comment that starts on a fresh line (not same-line as the previous
1143        // item) must keep attaching as the next item's leading_comment, exactly as before #59.
1144        let source = "sheet s {\n    cell a: i32 = 1;\n    // the total\n    cell b: i32 = 2;\n}";
1145        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1146        attach_trivia(source, &mut sheet);
1147        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1148            panic!("expected Cell");
1149        };
1150        assert_eq!(a.trailing_line_comment, None);
1151        let crate::ast::SheetItem::Cell(b) = &sheet.items[1] else {
1152            panic!("expected Cell");
1153        };
1154        assert_eq!(
1155            b.leading_comment,
1156            Some(crate::ast::Comment::Line("the total".to_string()))
1157        );
1158    }
1159
1160    #[test]
1161    fn a_same_line_trailing_comment_attaches_to_a_binding_inside_a_relationship() {
1162        let source =
1163            "sheet s {\n    relationship {\n        b := a; // note\n        a := b;\n    }\n}";
1164        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1165        attach_trivia(source, &mut sheet);
1166        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1167            panic!("expected Relationship");
1168        };
1169        assert_eq!(
1170            rel.bindings[0].trailing_line_comment,
1171            Some(crate::ast::Comment::Line("note".to_string()))
1172        );
1173        assert_eq!(rel.bindings[1].leading_comment, None);
1174    }
1175
1176    #[test]
1177    fn a_same_line_trailing_comment_before_a_sheets_closing_brace_attaches_to_the_last_item() {
1178        let source = "sheet s {\n    cell a: i32 = 1; // note\n}";
1179        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1180        attach_trivia(source, &mut sheet);
1181        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1182            panic!("expected Cell");
1183        };
1184        assert_eq!(
1185            a.trailing_line_comment,
1186            Some(crate::ast::Comment::Line("note".to_string()))
1187        );
1188        assert_eq!(sheet.trailing_comment, None);
1189    }
1190
1191    #[test]
1192    fn same_line_gap_with_no_newline_still_recovers_a_block_comment() {
1193        // PR #202 review: when both items and the comment share one physical line with no
1194        // newline anywhere in the gap, the old unconditional "drop the final fragment" logic
1195        // popped the whole gap (comment included) before the comment scanner ever ran.
1196        let source = "sheet s {\n    cell a: i32 = 1; /* note */ cell b: i32 = 2;\n}";
1197        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1198        attach_trivia(source, &mut sheet);
1199        let crate::ast::SheetItem::Cell(a) = &sheet.items[0] else {
1200            panic!("expected Cell");
1201        };
1202        assert_eq!(
1203            a.trailing_line_comment,
1204            Some(crate::ast::Comment::Block("note".to_string()))
1205        );
1206    }
1207
1208    #[test]
1209    fn a_same_line_trailing_comment_before_a_relationships_closing_brace_attaches_to_the_last_binding()
1210     {
1211        let source = "sheet s {\n    relationship {\n        b := a; // note\n    }\n}";
1212        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1213        attach_trivia(source, &mut sheet);
1214        let crate::ast::SheetItem::Relationship(rel) = &sheet.items[0] else {
1215            panic!("expected Relationship");
1216        };
1217        assert_eq!(
1218            rel.bindings[0].trailing_line_comment,
1219            Some(crate::ast::Comment::Line("note".to_string()))
1220        );
1221        assert_eq!(rel.trailing_comment, None);
1222    }
1223
1224    #[test]
1225    fn a_same_line_trailing_comment_before_a_conditional_branchs_own_closing_brace_attaches_to_the_last_relationship()
1226     {
1227        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; } // note\n        }\n    }\n}";
1228        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1229        attach_trivia(source, &mut sheet);
1230        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1231            panic!("expected Conditional");
1232        };
1233        assert_eq!(
1234            cond.branches[0].relationships[0].trailing_line_comment,
1235            Some(crate::ast::Comment::Line("note".to_string()))
1236        );
1237        assert_eq!(cond.branches[0].trailing_comment, None);
1238    }
1239
1240    #[test]
1241    fn a_same_line_trailing_comment_before_a_default_arms_own_closing_brace_attaches_to_the_last_relationship()
1242     {
1243        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; } // note\n        }\n    }\n}";
1244        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1245        attach_trivia(source, &mut sheet);
1246        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1247            panic!("expected Conditional");
1248        };
1249        let default = cond.default.as_ref().expect("default branch present");
1250        assert_eq!(
1251            default.relationships[0].trailing_line_comment,
1252            Some(crate::ast::Comment::Line("note".to_string()))
1253        );
1254        assert_eq!(default.trailing_comment, None);
1255    }
1256
1257    #[test]
1258    fn a_same_line_trailing_comment_before_a_conditionals_own_closing_brace_with_only_branches_attaches_to_the_last_branch()
1259     {
1260        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } } // note\n    }\n}";
1261        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1262        attach_trivia(source, &mut sheet);
1263        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1264            panic!("expected Conditional");
1265        };
1266        assert_eq!(
1267            cond.branches[0].trailing_line_comment,
1268            Some(crate::ast::Comment::Line("note".to_string()))
1269        );
1270        assert_eq!(cond.trailing_comment, None);
1271    }
1272
1273    #[test]
1274    fn a_same_line_trailing_comment_before_a_conditionals_own_closing_brace_with_a_default_attaches_to_the_default()
1275     {
1276        let source = "sheet s {\n    conditional m {\n        _ => { relationship { b := a; } } // note\n    }\n}";
1277        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1278        attach_trivia(source, &mut sheet);
1279        let crate::ast::SheetItem::Conditional(cond) = &sheet.items[0] else {
1280            panic!("expected Conditional");
1281        };
1282        let default = cond.default.as_ref().expect("default branch present");
1283        assert_eq!(
1284            default.trailing_line_comment,
1285            Some(crate::ast::Comment::Line("note".to_string()))
1286        );
1287        assert_eq!(cond.trailing_comment, None);
1288    }
1289
1290    #[test]
1291    fn a_same_line_trailing_comment_before_a_requires_closing_brace_attaches_to_the_last_requirement()
1292     {
1293        let source =
1294            "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0; // note\n    };\n}";
1295        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
1296        attach_trivia(source, &mut sheet);
1297        let crate::ast::SheetItem::Out(out) = &sheet.items[0] else {
1298            panic!("expected Out");
1299        };
1300        let require = out.require.as_ref().expect("require block present");
1301        assert_eq!(
1302            require.requirements[0].trailing_line_comment,
1303            Some(crate::ast::Comment::Line("note".to_string()))
1304        );
1305        assert_eq!(require.trailing_comment, None);
1306    }
1307}