Skip to main content

adam_lang/
fmt.rs

1//! Pretty-prints an [`crate::ast::Sheet`] back to adam-lang source text: 4-space indentation,
2//! opening braces on the same line, `leading_comment`/`blank_line_before` reproduced exactly as
3//! [`crate::trivia::attach_trivia`] recovered them (including a file-header-style comment
4//! preceding the `sheet` keyword itself, `Sheet.leading_comment`), and binding bodies/cell
5//! initializers/requirement bodies delegated to [`cel_parser::format_expr_with_layout`].
6//! Expression layout targets 100 columns including declaration prefixes and punctuation. Nested
7//! lists, records and closure bodies use four spaces; binary continuations lead with operators.
8//! Type annotations are re-emitted via `Span::source_text()` directly
9//! via `TypeExpr::span()`, and branch-match literals via `ConditionalBranch::literal_span` — see
10//! the design doc for why no `Literal` value is needed. Conditional branches omit the grammar's
11//! optional trailing `,`, matching `begin/assets/demo.adm2`'s existing style.
12//!
13//! Never called on a sheet with any recorded syntax errors — see `adam-lsp`'s
14//! `textDocument/formatting` handler, which refuses to format in that case.
15
16use crate::ast;
17
18/// Returns the current output column in Unicode scalar values.
19///
20/// - Complexity: O(k) in the final line's length.
21fn output_column(out: &str) -> usize {
22    out.rsplit('\n').next().unwrap_or_default().chars().count()
23}
24
25/// Appends one expression using the complete emitted prefix and following punctuation budget.
26///
27/// - Complexity: O(n) document space plus layout, final-line measurement and source-gap scans.
28fn write_expression(
29    out: &mut String,
30    source: &str,
31    expr: &cel_parser::Expr,
32    depth: usize,
33    suffix_columns: usize,
34) {
35    let text = cel_parser::format_expr_with_layout(
36        expr,
37        source,
38        depth,
39        output_column(out),
40        100,
41        suffix_columns,
42    );
43    append_expression_layout(out, &text);
44}
45
46/// Appends expression layout, removing prefix padding when layout starts on a new line.
47///
48/// - Complexity: O(n + k) in the expression text and the prefix's trailing padding.
49fn append_expression_layout(out: &mut String, text: &str) {
50    if text.starts_with('\n') {
51        out.truncate(out.trim_end_matches(' ').len());
52    }
53    out.push_str(text);
54}
55
56/// Reserves punctuation before the next clause, or the declaration's semicolon.
57fn cell_clause_suffix(
58    filter: Option<&ast::CellFilter>,
59    require: Option<&ast::RequireBlock>,
60) -> usize {
61    if filter.is_some() {
62        8
63    } else if require.is_some() {
64        10
65    } else {
66        1
67    }
68}
69
70/// 4 spaces per nesting level.
71fn indent(depth: usize) -> String {
72    "    ".repeat(depth)
73}
74
75/// Writes `comment`'s text starting at the current write position — its first line gets no
76/// leading indent or prefix of its own, so the caller positions the cursor first (either after
77/// its own `indent(depth)`, for a comment on its own line, or inline after a declaration's own
78/// terminator) — continuing any further lines at `depth`'s indentation, and always ending with a
79/// newline: a `Comment::Line` as one `// ` line per stored line; a `Comment::Block` as
80/// `/* text */` on one line when its text has no internal `\n`, or a multi-line `/*`/`*/`-
81/// delimited block (one line per stored line, indented one level past `depth`) when it does.
82/// Shared by [`write_comment`] and [`write_line_end`].
83fn write_comment_inline(out: &mut String, comment: &ast::Comment, depth: usize) {
84    match comment {
85        ast::Comment::Line(text) => {
86            let mut lines = text.split('\n');
87            if let Some(first) = lines.next() {
88                out.push_str("// ");
89                out.push_str(first);
90                out.push('\n');
91            }
92            for line in lines {
93                out.push_str(&indent(depth));
94                out.push_str("// ");
95                out.push_str(line);
96                out.push('\n');
97            }
98        }
99        ast::Comment::Block(text) => {
100            if text.contains('\n') {
101                out.push_str("/*\n");
102                for line in text.split('\n') {
103                    out.push_str(&indent(depth + 1));
104                    out.push_str(line);
105                    out.push('\n');
106                }
107                out.push_str(&indent(depth));
108                out.push_str("*/\n");
109            } else {
110                out.push_str("/* ");
111                out.push_str(text);
112                out.push_str(" */\n");
113            }
114        }
115    }
116}
117
118/// Writes one recovered `Comment` on its own line at `depth`'s indentation, via
119/// [`write_comment_inline`].
120fn write_comment(out: &mut String, comment: &ast::Comment, depth: usize) {
121    out.push_str(&indent(depth));
122    write_comment_inline(out, comment, depth);
123}
124
125/// Writes `doc_comment`'s lines (if present) as one `marker` line per stored line, at `depth`'s
126/// indentation. `marker` is `"///"` for a declaration's own doc comment or `"//!"` for the
127/// sheet's own (each stored line already includes the space rustdoc puts after the marker, e.g.
128/// `" the total"` for `/// the total`, so no extra space is inserted here).
129fn write_doc_comment(out: &mut String, marker: &str, doc_comment: Option<&str>, depth: usize) {
130    if let Some(doc) = doc_comment {
131        for line in doc.split('\n') {
132            out.push_str(&indent(depth));
133            out.push_str(marker);
134            out.push_str(line);
135            out.push('\n');
136        }
137    }
138}
139
140/// Emits `blank_line_before`/`leading_comment` ahead of an item, if either is present.
141fn write_trivia(
142    out: &mut String,
143    blank_line_before: bool,
144    leading_comment: Option<&ast::Comment>,
145    depth: usize,
146) {
147    if blank_line_before {
148        out.push('\n');
149    }
150    if let Some(comment) = leading_comment {
151        write_comment(out, comment, depth);
152    }
153}
154
155/// Emits a container's trailing comment (honoring `blank_line_before_close`) immediately before
156/// its closing `}` is written, reusing [`write_comment`] so block-vs-line style is preserved
157/// exactly as for leading comments. See <https://github.com/stlab/cel-rs/issues/52>.
158fn write_trailing_trivia(
159    out: &mut String,
160    blank_line_before_close: bool,
161    trailing_comment: Option<&ast::Comment>,
162    depth: usize,
163) {
164    if blank_line_before_close {
165        out.push('\n');
166    }
167    if let Some(comment) = trailing_comment {
168        write_comment(out, comment, depth);
169    }
170}
171
172/// Terminates a declaration's own output line with `terminator` (`;` or `}`), appending its
173/// same-line `trailing_line_comment` inline first when present — a `//` comment always ends the
174/// line it's on, and a `/* */` comment stays inline with what precedes it, so both are written
175/// before the newline rather than after it. See <https://github.com/stlab/cel-rs/issues/59>.
176fn write_line_end(
177    out: &mut String,
178    terminator: &str,
179    trailing_line_comment: Option<&ast::Comment>,
180    depth: usize,
181) {
182    out.push_str(terminator);
183    match trailing_line_comment {
184        Some(comment) => {
185            out.push(' ');
186            write_comment_inline(out, comment, depth);
187        }
188        None => out.push('\n'),
189    }
190}
191
192/// Re-emits a literal's exact original text via its span, falling back to an empty string when
193/// none is recoverable — mirrors `cel_parser::fmt`'s identical fallback (see the module doc for
194/// why no `Literal` value is needed here).
195fn source_text_or_empty(span: ast::ExprSpan) -> String {
196    span.start.source_text().unwrap_or_default()
197}
198
199/// A single-point `ExprSpan` (start and end coincide), for a gap endpoint with no spanned AST
200/// node of its own — e.g. the terminating `;` following a `CellDecl`'s initializer, which has
201/// only `CellDecl.span.end` (the `;` token's own span) available, not a full node.
202fn point_span(span: proc_macro2::Span) -> ast::ExprSpan {
203    ast::ExprSpan {
204        start: span,
205        end: span,
206    }
207}
208
209/// Renders the source gap between two adam-lang declaration positions — any comments it holds,
210/// interleaved *in source order* with `expected`, the fixed literal tokens (e.g. `"="`, `":="`)
211/// the grammar guarantees appear there. Passing the operator through [`cel_parser::trivia::scan_gap`]
212/// (rather than having the caller append it afterward) is what preserves ordering: a comment that
213/// follows the `=` in `cell a: i32 = /* p */ 1;` stays after it, and one on each side of it
214/// (`cell a: i32 /* before */ = /* after */ 1;`) keeps its position relative to the operator.
215///
216/// Spacing mirrors `cel_parser::fmt::emit_gap`'s `Around` convention: an `expected` token is
217/// rendered space-separated, a `Comment::Block` stays inline, and a `Comment::Line` (which runs to
218/// end of line) wraps — ending the gap with a newline plus `indent(depth + 1)` so whatever the
219/// caller appends next starts on a fresh line rather than being swallowed into the comment.
220///
221/// - Postcondition: an *operator* gap (`expected` non-empty) always emits every `expected` token,
222///   even when `source` is empty or the positions don't resolve (a hand-built declaration with
223///   synthetic spans still gets its `=`/`:=`), and ends with a separating space so the caller's
224///   next token (the initializer) is not glued on. A *comment-only* gap (`expected` empty) returns
225///   `""` when it holds no comments, and never adds a trailing space, so the caller appends its own
226///   terminator (`;`) or keyword directly.
227///
228/// `depth` is the caller's own indent depth (the declaration's, not the gap's).
229fn emit_decl_gap(
230    source: &str,
231    prev_end: ast::ExprSpan,
232    next_start: ast::ExprSpan,
233    expected: &[&'static str],
234    depth: usize,
235) -> String {
236    use cel_parser::trivia::{GapPiece, line_column_to_byte, line_start_byte_offsets, scan_gap};
237    // `scan_gap` re-synthesizes every `expected` token even on an empty or non-resolving gap, so a
238    // hand-built declaration (empty `source`) still renders its operator.
239    let pieces = if source.is_empty() {
240        scan_gap("", expected)
241    } else {
242        let line_starts = line_start_byte_offsets(source);
243        let start = line_column_to_byte(source, &line_starts, prev_end.end.end());
244        let end = line_column_to_byte(source, &line_starts, next_start.start.start());
245        match source.get(start..end) {
246            Some(gap) => scan_gap(gap, expected),
247            None => scan_gap("", expected),
248        }
249    };
250    let mut out = String::new();
251    for piece in pieces {
252        match piece {
253            GapPiece::Punct(tok) => {
254                if !out.ends_with(' ') {
255                    out.push(' ');
256                }
257                out.push_str(tok);
258                out.push(' ');
259            }
260            GapPiece::Comment(cel_parser::Comment::Block(text)) => {
261                if !out.ends_with(' ') {
262                    out.push(' ');
263                }
264                out.push_str("/* ");
265                out.push_str(&text);
266                out.push_str(" */");
267            }
268            GapPiece::Comment(cel_parser::Comment::Line(text)) => {
269                if !out.ends_with(' ') {
270                    out.push(' ');
271                }
272                out.push_str("// ");
273                out.push_str(&text);
274                out.push('\n');
275                out.push_str(&indent(depth + 1));
276            }
277        }
278    }
279    // An operator gap must end separated from the initializer the caller appends next; a
280    // comment-only gap must not (its terminator/keyword is appended with its own leading space).
281    if !expected.is_empty() && !out.is_empty() && !out.ends_with(' ') {
282        out.push(' ');
283    }
284    out
285}
286
287/// Writes one `a := ...;` / `(a, b) := ...;` binding, delegating its body to
288/// [`cel_parser::format_expr`].
289///
290/// - Complexity: O(n) output/document space plus layout and source-gap scans.
291fn write_binding(out: &mut String, source: &str, binding: &ast::BindingDecl, depth: usize) {
292    write_trivia(
293        out,
294        binding.blank_line_before,
295        binding.leading_comment.as_ref(),
296        depth,
297    );
298    out.push_str(&indent(depth));
299    if binding.destructure {
300        out.push('(');
301        for (i, (name, _)) in binding.outputs.iter().enumerate() {
302            if i > 0 {
303                out.push_str(", ");
304            }
305            out.push_str(name);
306        }
307        if binding.outputs.len() == 1 {
308            out.push(',');
309        }
310        out.push(')');
311    } else {
312        debug_assert_eq!(
313            binding.outputs.len(),
314            1,
315            "a non-destructuring binding names exactly one output"
316        );
317        out.push_str(&binding.outputs[0].0);
318    }
319    // `:=` gap, rendered in-position so a comment on either side of it keeps its order.
320    let op_start = binding
321        .outputs
322        .last()
323        .map(|(_, span)| *span)
324        .unwrap_or(binding.span);
325    out.push_str(&emit_decl_gap(
326        source,
327        op_start,
328        binding.body.span(),
329        &[":="],
330        depth,
331    ));
332    write_expression(out, source, &binding.body, depth, 1);
333    // Comment between the binding's body and its terminating `;`.
334    out.push_str(&emit_decl_gap(
335        source,
336        binding.body.span(),
337        point_span(binding.span.end),
338        &[],
339        depth,
340    ));
341    write_line_end(out, ";", binding.trailing_line_comment.as_ref(), depth);
342}
343
344/// Writes one `relationship { ... }` declaration and its bindings, in declaration order.
345fn write_relationship(out: &mut String, source: &str, rel: &ast::RelationshipDecl, depth: usize) {
346    write_trivia(
347        out,
348        rel.blank_line_before,
349        rel.leading_comment.as_ref(),
350        depth,
351    );
352    write_doc_comment(out, "///", rel.doc_comment.as_deref(), depth);
353    out.push_str(&indent(depth));
354    out.push_str("relationship");
355    // Comment between the `relationship` keyword and its `{` (the keyword's own span starts at
356    // `rel.span.start`; scan from just past it to the block's `{`).
357    out.push_str(&emit_decl_gap(
358        source,
359        point_span(rel.span.start),
360        rel.open_brace_span,
361        &[],
362        depth,
363    ));
364    out.push_str(" {\n");
365    for binding in &rel.bindings {
366        write_binding(out, source, binding, depth + 1);
367    }
368    write_trailing_trivia(
369        out,
370        rel.blank_line_before_close,
371        rel.trailing_comment.as_ref(),
372        depth + 1,
373    );
374    out.push_str(&indent(depth));
375    write_line_end(out, "}", rel.trailing_line_comment.as_ref(), depth);
376}
377
378/// Writes a `{ ... }` block of relationships, shared by both a named conditional branch and the
379/// default (`_ =>`) arm.
380fn write_branch_relationships(
381    out: &mut String,
382    source: &str,
383    relationships: &[ast::RelationshipDecl],
384    trailing_comment: Option<&ast::Comment>,
385    blank_line_before_close: bool,
386    line_trailing_comment: Option<&ast::Comment>,
387    depth: usize,
388) {
389    out.push_str("{\n");
390    for rel in relationships {
391        write_relationship(out, source, rel, depth + 1);
392    }
393    write_trailing_trivia(out, blank_line_before_close, trailing_comment, depth + 1);
394    out.push_str(&indent(depth));
395    write_line_end(out, "}", line_trailing_comment, depth);
396}
397
398/// Writes one `literal_pattern => { ... }` conditional branch, re-emitting the match literal via
399/// its span rather than the (unused) `Literal` value, with a leading `-` when
400/// [`ast::ConditionalBranch::negated`].
401fn write_branch(out: &mut String, source: &str, branch: &ast::ConditionalBranch, depth: usize) {
402    write_trivia(
403        out,
404        branch.blank_line_before,
405        branch.leading_comment.as_ref(),
406        depth,
407    );
408    out.push_str(&indent(depth));
409    if branch.negated {
410        out.push('-');
411    }
412    out.push_str(&source_text_or_empty(branch.literal_span));
413    // One gap from the literal through the `=>` to the branch body's `{`, recovering comments on
414    // either side of `=>` in source order.
415    out.push_str(&emit_decl_gap(
416        source,
417        branch.literal_span,
418        branch.open_brace_span,
419        &["=>"],
420        depth,
421    ));
422    write_branch_relationships(
423        out,
424        source,
425        &branch.relationships,
426        branch.trailing_comment.as_ref(),
427        branch.blank_line_before_close,
428        branch.trailing_line_comment.as_ref(),
429        depth,
430    );
431}
432
433/// Writes one `conditional <expr> { ... }` declaration: its branches in declaration
434/// order (dispatching on the match-subject expression), followed by its optional `_ => { ... }` default arm.
435///
436/// Preserves header-boundary comments across omitted groups while reserving the body brace.
437///
438/// - Complexity: O(n) output/document space plus layout and source-gap scans.
439fn write_conditional(out: &mut String, source: &str, cond: &ast::ConditionalDecl, depth: usize) {
440    write_trivia(
441        out,
442        cond.blank_line_before,
443        cond.leading_comment.as_ref(),
444        depth,
445    );
446    write_doc_comment(out, "///", cond.doc_comment.as_deref(), depth);
447    out.push_str(&indent(depth));
448    out.push_str("conditional ");
449    let header = cel_parser::format_expr_header_with_layout(
450        &cond.match_expr,
451        source,
452        depth,
453        (cond.span.start.end(), cond.open_brace_span.start.start()),
454        output_column(out),
455        100,
456        2,
457    );
458    append_expression_layout(out, &header);
459    out.push_str(" {\n");
460    for branch in &cond.branches {
461        write_branch(out, source, branch, depth + 1);
462    }
463    if let Some(default) = &cond.default {
464        out.push_str(&indent(depth + 1));
465        out.push('_');
466        // One gap from `_` through `=>` to the default body's `{`, recovering comments around `=>`.
467        out.push_str(&emit_decl_gap(
468            source,
469            point_span(default.span.start),
470            default.open_brace_span,
471            &["=>"],
472            depth + 1,
473        ));
474        write_branch_relationships(
475            out,
476            source,
477            &default.relationships,
478            default.trailing_comment.as_ref(),
479            default.blank_line_before_close,
480            default.trailing_line_comment.as_ref(),
481            depth + 1,
482        );
483    }
484    write_trailing_trivia(
485        out,
486        cond.blank_line_before_close,
487        cond.trailing_comment.as_ref(),
488        depth + 1,
489    );
490    out.push_str(&indent(depth));
491    write_line_end(out, "}", cond.trailing_line_comment.as_ref(), depth);
492}
493
494/// Writes one `cell name[: type][ = initializer][ filter body][ require { ... }];` declaration,
495/// delegating its type annotation to [`source_text_or_empty`] via `TypeExpr::span()` and its
496/// initializer/filter body to [`cel_parser::format_expr`].
497///
498/// - Complexity: O(n) output/document space plus layout and source-gap scans.
499fn write_cell(out: &mut String, source: &str, cell: &ast::CellDecl, depth: usize) {
500    write_trivia(
501        out,
502        cell.blank_line_before,
503        cell.leading_comment.as_ref(),
504        depth,
505    );
506    write_doc_comment(out, "///", cell.doc_comment.as_deref(), depth);
507    out.push_str(&indent(depth));
508    out.push_str("cell ");
509    out.push_str(&cell.name);
510    if let Some(type_expr) = &cell.type_name {
511        out.push_str(": ");
512        out.push_str(&source_text_or_empty(type_expr.span()));
513    }
514    // `=` gap, rendered in-position so comments keep their order relative to the operator.
515    if let Some(expr) = &cell.initializer {
516        let op_start = cell
517            .type_name
518            .as_ref()
519            .map(ast::TypeExpr::span)
520            .unwrap_or(cell.name_span);
521        out.push_str(&emit_decl_gap(source, op_start, expr.span(), &["="], depth));
522        write_expression(
523            out,
524            source,
525            expr,
526            depth,
527            cell_clause_suffix(cell.filter.as_ref(), cell.require.as_ref()),
528        );
529    }
530    write_cell_clauses(
531        out,
532        source,
533        depth,
534        cell.initializer.as_ref().map(cel_parser::Expr::span),
535        cell.type_name.as_ref().map(ast::TypeExpr::span),
536        cell.name_span,
537        cell.filter.as_ref(),
538        cell.require.as_ref(),
539        cell.span.end,
540    );
541    write_line_end(out, ";", cell.trailing_line_comment.as_ref(), depth);
542}
543
544/// Writes a declaration's optional `filter`/`require` clauses and recovers any comment in each gap
545/// between adjacent segments (initializer → `filter` → `require` → `;`), shared by [`write_cell`],
546/// [`write_source`], and [`write_out`]. `prev_*` give the end span of whichever of
547/// initializer/type/name precedes the clauses; `terminator` is the declaration's own `;` span.
548///
549/// - Complexity: O(n) output/document space plus layout and source-gap scans.
550#[allow(clippy::too_many_arguments)]
551fn write_cell_clauses(
552    out: &mut String,
553    source: &str,
554    depth: usize,
555    initializer_span: Option<ast::ExprSpan>,
556    type_span: Option<ast::ExprSpan>,
557    name_span: ast::ExprSpan,
558    filter: Option<&ast::CellFilter>,
559    require: Option<&ast::RequireBlock>,
560    terminator: proc_macro2::Span,
561) {
562    let mut prev = initializer_span.or(type_span).unwrap_or(name_span);
563    if let Some(filter) = filter {
564        // One gap from the previous segment through the `filter` keyword to the filter body,
565        // recovering comments on either side of `filter` in source order.
566        out.push_str(&emit_decl_gap(
567            source,
568            prev,
569            filter.body.span(),
570            &["filter"],
571            depth,
572        ));
573        write_expression(
574            out,
575            source,
576            &filter.body,
577            depth,
578            if require.is_some() { 10 } else { 1 },
579        );
580        prev = filter.body.span();
581    }
582    if let Some(require) = require {
583        // One gap from the previous segment through the `require` keyword to the block's `{`,
584        // recovering comments on either side of `require` (the keyword itself has no stored span,
585        // but it lies inside this gap).
586        out.push_str(&emit_decl_gap(
587            source,
588            prev,
589            require.open_brace_span,
590            &["require"],
591            depth,
592        ));
593        write_require_block(out, source, require, depth);
594        prev = require.span;
595    }
596    // Comment between the last segment and the terminating `;`.
597    out.push_str(&emit_decl_gap(
598        source,
599        prev,
600        point_span(terminator),
601        &[],
602        depth,
603    ));
604}
605
606/// Writes one `[ "@" identifier " " ] ...;` requirement.
607///
608/// - Complexity: O(n) output/document space plus layout and source-gap scans.
609fn write_requirement(out: &mut String, source: &str, req: &ast::RequirementDecl, depth: usize) {
610    write_trivia(
611        out,
612        req.blank_line_before,
613        req.leading_comment.as_ref(),
614        depth,
615    );
616    out.push_str(&indent(depth));
617    if let Some(name) = &req.name {
618        out.push('@');
619        out.push_str(name);
620        // Recover any comment between the `@label` and the body (e.g. `@c /* keep */ w <= 10`).
621        if let Some(name_span) = req.name_span {
622            out.push_str(&emit_decl_gap(
623                source,
624                name_span,
625                req.body.span(),
626                &[],
627                depth,
628            ));
629        }
630        out.push(' ');
631    }
632    write_expression(out, source, &req.body, depth, 1);
633    write_line_end(out, ";", req.trailing_line_comment.as_ref(), depth);
634}
635
636/// Writes one `require` block's `{ ... }` body (no `require` keyword, no trailing `;`) — the
637/// caller emits the `require` keyword (and any comments around it) via [`emit_decl_gap`] so a
638/// comment between `require` and its `{` is preserved. Shared by [`write_cell`], [`write_source`],
639/// and [`write_out`].
640fn write_require_block(out: &mut String, source: &str, require: &ast::RequireBlock, depth: usize) {
641    out.push_str("{\n");
642    for req in &require.requirements {
643        write_requirement(out, source, req, depth + 1);
644    }
645    write_trailing_trivia(
646        out,
647        require.blank_line_before_close,
648        require.trailing_comment.as_ref(),
649        depth + 1,
650    );
651    out.push_str(&indent(depth));
652    out.push('}');
653}
654
655/// Writes one `out name[: type] := ...[ filter body][ require { ... } ];` declaration.
656///
657/// - Complexity: O(n) output/document space plus layout and source-gap scans.
658fn write_out(out: &mut String, source: &str, decl: &ast::OutDecl, depth: usize) {
659    write_trivia(
660        out,
661        decl.blank_line_before,
662        decl.leading_comment.as_ref(),
663        depth,
664    );
665    write_doc_comment(out, "///", decl.doc_comment.as_deref(), depth);
666    out.push_str(&indent(depth));
667    out.push_str("out ");
668    out.push_str(&decl.name);
669    if let Some(type_expr) = &decl.type_name {
670        out.push_str(": ");
671        out.push_str(&source_text_or_empty(type_expr.span()));
672    }
673    // `:=` gap, rendered in-position so comments keep their order relative to the operator.
674    let op_start = decl
675        .type_name
676        .as_ref()
677        .map(ast::TypeExpr::span)
678        .unwrap_or(decl.name_span);
679    out.push_str(&emit_decl_gap(
680        source,
681        op_start,
682        decl.initializer.span(),
683        &[":="],
684        depth,
685    ));
686    write_expression(
687        out,
688        source,
689        &decl.initializer,
690        depth,
691        cell_clause_suffix(decl.filter.as_ref(), decl.require.as_ref()),
692    );
693    write_cell_clauses(
694        out,
695        source,
696        depth,
697        Some(decl.initializer.span()),
698        decl.type_name.as_ref().map(ast::TypeExpr::span),
699        decl.name_span,
700        decl.filter.as_ref(),
701        decl.require.as_ref(),
702        decl.span.end,
703    );
704    write_line_end(out, ";", decl.trailing_line_comment.as_ref(), depth);
705}
706
707/// Writes one `source name[: type][ = initializer][ filter body][ require { ... }];` declaration.
708/// Mirrors [`write_cell`] exactly.
709///
710/// - Complexity: O(n) output/document space plus layout and source-gap scans.
711fn write_source(out: &mut String, source: &str, decl: &ast::SourceDecl, depth: usize) {
712    write_trivia(
713        out,
714        decl.blank_line_before,
715        decl.leading_comment.as_ref(),
716        depth,
717    );
718    write_doc_comment(out, "///", decl.doc_comment.as_deref(), depth);
719    out.push_str(&indent(depth));
720    out.push_str("source ");
721    out.push_str(&decl.name);
722    if let Some(type_expr) = &decl.type_name {
723        out.push_str(": ");
724        out.push_str(&source_text_or_empty(type_expr.span()));
725    }
726    // `=` gap, rendered in-position so comments keep their order relative to the operator.
727    if let Some(expr) = &decl.initializer {
728        let op_start = decl
729            .type_name
730            .as_ref()
731            .map(ast::TypeExpr::span)
732            .unwrap_or(decl.name_span);
733        out.push_str(&emit_decl_gap(source, op_start, expr.span(), &["="], depth));
734        write_expression(
735            out,
736            source,
737            expr,
738            depth,
739            cell_clause_suffix(decl.filter.as_ref(), decl.require.as_ref()),
740        );
741    }
742    write_cell_clauses(
743        out,
744        source,
745        depth,
746        decl.initializer.as_ref().map(cel_parser::Expr::span),
747        decl.type_name.as_ref().map(ast::TypeExpr::span),
748        decl.name_span,
749        decl.filter.as_ref(),
750        decl.require.as_ref(),
751        decl.span.end,
752    );
753    write_line_end(out, ";", decl.trailing_line_comment.as_ref(), depth);
754}
755
756/// Dispatches to the writer for one top-level sheet item.
757///
758/// - Precondition: `item` is not `SheetItem::Error` — [`format_sheet`]'s own precondition
759///   (`sheet.errors.is_empty()`) guarantees no `Error` item ever reaches this function.
760fn write_sheet_item(out: &mut String, source: &str, item: &ast::SheetItem, depth: usize) {
761    match item {
762        ast::SheetItem::Cell(cell) => write_cell(out, source, cell, depth),
763        ast::SheetItem::Relationship(rel) => write_relationship(out, source, rel, depth),
764        ast::SheetItem::Conditional(cond) => write_conditional(out, source, cond, depth),
765        ast::SheetItem::Out(out_decl) => write_out(out, source, out_decl, depth),
766        ast::SheetItem::Source(decl) => write_source(out, source, decl, depth),
767        ast::SheetItem::Error { .. } => {
768            unreachable!("format_sheet is only called on a sheet with no recorded syntax errors")
769        }
770    }
771}
772
773/// Pretty-prints `sheet` back to adam-lang source text — see the module doc for the printing
774/// rules.
775///
776/// `source` is the text `sheet` was parsed from (or `""` for a programmatically built `Sheet`);
777/// it's forwarded to [`cel_parser::format_expr`] for every embedded expression.
778///
779/// - Precondition: `sheet` has no recorded syntax errors (`sheet.errors.is_empty()`) — a sheet
780///   with a `SheetItem::Error` placeholder cannot be printed back to valid source.
781///
782/// # Examples
783///
784/// ```
785/// use adam_lang::{AdamAstParser, attach_trivia, format_sheet};
786///
787/// let source = "sheet s { cell x: i32 = 1; }";
788/// let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
789/// attach_trivia(source, &mut sheet);
790/// assert_eq!(format_sheet(&sheet, source), "sheet s {\n    cell x: i32 = 1;\n}\n");
791/// ```
792pub fn format_sheet(sheet: &ast::Sheet, source: &str) -> String {
793    debug_assert!(
794        sheet.errors.is_empty(),
795        "format_sheet's precondition: no recorded syntax errors"
796    );
797    let mut out = String::new();
798    write_trivia(&mut out, false, sheet.leading_comment.as_ref(), 0);
799    write_doc_comment(&mut out, "//!", sheet.doc_comment.as_deref(), 0);
800    out.push_str(&format!("sheet {} {{\n", sheet.name));
801    for item in &sheet.items {
802        write_sheet_item(&mut out, source, item, 1);
803    }
804    write_trailing_trivia(
805        &mut out,
806        sheet.blank_line_before_close,
807        sheet.trailing_comment.as_ref(),
808        1,
809    );
810    out.push_str("}\n");
811    out
812}
813
814/// Error returned by [`format_source`] when `source` can't be safely formatted.
815#[derive(Debug)]
816pub enum FormatSourceError {
817    /// `source` failed to parse at all.
818    Parse(cel_parser::ParseError),
819    /// `source` parsed but recovered one or more syntax errors inside individual sheet items.
820    Recovered(Vec<cel_parser::ParseError>),
821}
822
823impl std::fmt::Display for FormatSourceError {
824    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
825        match self {
826            FormatSourceError::Parse(error) => write!(f, "{error}"),
827            FormatSourceError::Recovered(errors) => {
828                for (i, error) in errors.iter().enumerate() {
829                    if i > 0 {
830                        writeln!(f)?;
831                    }
832                    write!(f, "{error}")?;
833                }
834                Ok(())
835            }
836        }
837    }
838}
839
840impl std::error::Error for FormatSourceError {}
841
842/// Parses and formats `source` as a complete adam-lang sheet, matching `adam-lsp`'s
843/// `textDocument/formatting` handler exactly.
844///
845/// # Errors
846///
847/// Returns [`FormatSourceError::Parse`] if `source` fails to parse at all
848/// ([`crate::AdamAstParser::parse_str`] returns `Err`), or [`FormatSourceError::Recovered`] if it
849/// parses with one or more recovered syntax errors (`Sheet.errors` non-empty) — refusing to
850/// format code it can't fully understand, matching `rustfmt`.
851///
852/// # Examples
853///
854/// ```
855/// use adam_lang::format_source;
856///
857/// assert_eq!(
858///     format_source("sheet   s{cell x:i32=1;}").unwrap(),
859///     "sheet s {\n    cell x: i32 = 1;\n}\n"
860/// );
861/// assert!(format_source("not a sheet at all").is_err());
862/// ```
863pub fn format_source(source: &str) -> Result<String, FormatSourceError> {
864    let mut sheet = crate::AdamAstParser::new()
865        .parse_str(source)
866        .map_err(FormatSourceError::Parse)?;
867    if !sheet.errors.is_empty() {
868        return Err(FormatSourceError::Recovered(sheet.errors));
869    }
870    crate::attach_trivia(source, &mut sheet);
871    Ok(format_sheet(&sheet, source))
872}
873
874#[cfg(test)]
875mod tests {
876    /// Measures the active line, counting Unicode scalars rather than UTF-8 bytes.
877    #[test]
878    fn output_column_tracks_the_active_line() {
879        for (text, expected) in [
880            ("", 0),
881            ("cell é = ", 9),
882            ("old prefix\n    := ", 7),
883            ("line\n", 0),
884        ] {
885            assert_eq!(super::output_column(text), expected);
886        }
887    }
888
889    /// Verifies grouped restricted subjects retain boundary comments exactly once and in order.
890    #[test]
891    fn grouped_conditional_headers_preserve_comments() {
892        for header in [
893            "(Sample { value: 1.0 }) /* first */",
894            "/* first */ (Sample { value: 1.0 })",
895            "(/* first */ Sample { value: 1.0 } /* second */) /* third */",
896            "(/* first */ (/* second */ Sample { value: 1.0 }) /* third */) /* fourth */",
897            "(Sample { value: 1.0 }) /* first /* nested ) */ end */",
898            "(/* first /* nested ( */ end */ Sample { value: 1.0 }) /* second */",
899            "((Sample { value: 1.0 }) /* first */) /* second */",
900            "(// first\n Sample { value: 1.0 } // second\n) // third\n",
901            "// first\n (/* second */ Sample { value: 1.0 }) // third\n",
902            "(/* first */ p) /* second */",
903            "(/* first */ (Sample { value: 1.0 }).value) /* second */",
904        ] {
905            let source = format!("sheet s {{ conditional {header} {{ _ => {{}} }} }}");
906            let formatted = super::format_source(&source).unwrap();
907            let mut previous = 0;
908            for label in ["first", "second", "third", "fourth"] {
909                if source.contains(label) {
910                    assert_eq!(
911                        formatted.matches(label).count(),
912                        1,
913                        "{source} -> {formatted}"
914                    );
915                    let position = formatted.find(label).unwrap();
916                    assert!(position >= previous, "{formatted}");
917                    previous = position;
918                }
919            }
920            assert_eq!(super::format_source(&formatted).unwrap(), formatted);
921        }
922    }
923
924    use super::*;
925    use crate::AdamAstParser;
926
927    fn format(source: &str) -> String {
928        let mut sheet = AdamAstParser::new().parse_str(source).unwrap();
929        crate::attach_trivia(source, &mut sheet);
930        format_sheet(&sheet, source)
931    }
932
933    #[test]
934    fn formats_an_empty_sheet() {
935        assert_eq!(format("sheet s {}"), "sheet s {\n}\n");
936    }
937
938    #[test]
939    fn preserves_a_leading_comment_before_the_sheet_itself() {
940        assert_eq!(
941            format("// file header\nsheet s { cell x: i32 = 1; }"),
942            "// file header\nsheet s {\n    cell x: i32 = 1;\n}\n"
943        );
944    }
945
946    #[test]
947    fn no_leading_comment_before_the_sheet_emits_nothing_extra() {
948        assert_eq!(
949            format("sheet s { cell x: i32 = 1; }"),
950            "sheet s {\n    cell x: i32 = 1;\n}\n"
951        );
952    }
953
954    #[test]
955    fn formats_a_cell_with_type_and_initializer() {
956        assert_eq!(
957            format("sheet s { cell width: f64 = 1920.0; }"),
958            "sheet s {\n    cell width: f64 = 1920.0;\n}\n"
959        );
960    }
961
962    #[test]
963    fn formats_a_source_decl() {
964        assert_eq!(
965            format("sheet s { source width: i32 = 4; }"),
966            "sheet s {\n    source width: i32 = 4;\n}\n"
967        );
968    }
969
970    #[test]
971    fn formats_a_cell_with_only_a_type_annotation() {
972        assert_eq!(
973            format("sheet s { cell area: f64; }"),
974            "sheet s {\n    cell area: f64;\n}\n"
975        );
976    }
977
978    #[test]
979    fn formats_a_cell_with_only_an_initializer() {
980        assert_eq!(
981            format("sheet s { cell mode = 0i32; }"),
982            "sheet s {\n    cell mode = 0i32;\n}\n"
983        );
984    }
985
986    #[test]
987    fn packed_cells_stay_packed_and_a_blank_line_before_a_relationship_is_preserved() {
988        let source = "sheet s {\n    cell a: i32 = 1;\n    cell b: i32 = 2;\n\n    relationship { b := a; }\n}";
989        let expected = "sheet s {\n    cell a: i32 = 1;\n    cell b: i32 = 2;\n\n    relationship {\n        b := a;\n    }\n}\n";
990        assert_eq!(format(source), expected);
991    }
992
993    #[test]
994    fn a_run_of_blank_lines_collapses_to_one() {
995        let source = "sheet s {\n    cell a: i32 = 1;\n\n\n\n    cell b: i32 = 2;\n}";
996        let expected = "sheet s {\n    cell a: i32 = 1;\n\n    cell b: i32 = 2;\n}\n";
997        assert_eq!(format(source), expected);
998    }
999
1000    #[test]
1001    fn formats_a_relationship_with_multiple_bindings() {
1002        let source = "sheet s {\n    relationship {\n        area := width * height;\n        width := area / height;\n    }\n}";
1003        let expected = "sheet s {\n    relationship {\n        area := width * height;\n        width := area / height;\n    }\n}\n";
1004        assert_eq!(format(source), expected);
1005    }
1006
1007    #[test]
1008    fn formats_a_multi_output_destructuring_binding_with_parens() {
1009        let source =
1010            "sheet s {\n    relationship {\n        (sum, diff) := (a + b, a - b);\n    }\n}";
1011        let expected =
1012            "sheet s {\n    relationship {\n        (sum, diff) := (a + b, a - b);\n    }\n}\n";
1013        assert_eq!(format(source), expected);
1014    }
1015
1016    #[test]
1017    fn formats_a_single_element_tuple_destructuring_binding_with_a_trailing_comma() {
1018        let source = "sheet s {\n    relationship {\n        (x,) := (a,);\n    }\n}";
1019        let expected = "sheet s {\n    relationship {\n        (x,) := (a,);\n    }\n}\n";
1020        assert_eq!(format(source), expected);
1021    }
1022
1023    #[test]
1024    fn drops_redundant_grouping_parens_from_a_non_destructuring_binding() {
1025        let source = "sheet s {\n    relationship {\n        (x) := a;\n    }\n}";
1026        let expected = "sheet s {\n    relationship {\n        x := a;\n    }\n}\n";
1027        assert_eq!(format(source), expected);
1028    }
1029
1030    #[test]
1031    fn preserves_a_comment_on_a_nested_binding() {
1032        let source = "sheet s {\n    relationship {\n        b := a;\n\n        // second\n        a := b;\n    }\n}";
1033        let expected = "sheet s {\n    relationship {\n        b := a;\n\n        // second\n        a := b;\n    }\n}\n";
1034        assert_eq!(format(source), expected);
1035    }
1036
1037    #[test]
1038    fn formats_a_conditional_with_branches_and_a_default_and_no_trailing_commas() {
1039        let source = "sheet s {\n    conditional p {\n        0i32 => { relationship { b := a; } },\n        _ => { relationship { a := b; } },\n    }\n}";
1040        let expected = "sheet s {\n    conditional p {\n        0i32 => {\n            relationship {\n                b := a;\n            }\n        }\n        _ => {\n            relationship {\n                a := b;\n            }\n        }\n    }\n}\n";
1041        assert_eq!(format(source), expected);
1042    }
1043
1044    #[test]
1045    fn formats_a_conditional_branch_with_a_negated_literal_key() {
1046        let source = "sheet s {\n    conditional p {\n        -1i32 => { relationship { b := a; } },\n        _ => { relationship { a := b; } },\n    }\n}";
1047        let expected = "sheet s {\n    conditional p {\n        -1i32 => {\n            relationship {\n                b := a;\n            }\n        }\n        _ => {\n            relationship {\n                a := b;\n            }\n        }\n    }\n}\n";
1048        assert_eq!(format(source), expected);
1049    }
1050
1051    #[test]
1052    fn formats_a_conditional_with_an_expression_match_subject() {
1053        let source = "sheet s {\n    conditional a && b {\n        _ => { relationship { d := c; } },\n    }\n}";
1054        let expected = "sheet s {\n    conditional a && b {\n        _ => {\n            relationship {\n                d := c;\n            }\n        }\n    }\n}\n";
1055        assert_eq!(format(source), expected);
1056    }
1057
1058    #[test]
1059    fn preserves_a_comment_on_a_conditional_branch() {
1060        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } }\n        // one\n        1i32 => { relationship { b := a; } }\n    }\n}";
1061        let expected = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship {\n                b := a;\n            }\n        }\n        // one\n        1i32 => {\n            relationship {\n                b := a;\n            }\n        }\n    }\n}\n";
1062        assert_eq!(format(source), expected);
1063    }
1064
1065    #[test]
1066    fn binding_body_delegates_precedence_aware_parenthesization_to_cel_parser() {
1067        let source = "sheet s { relationship { c := (a + b) * 2i32; } }";
1068        let expected = "sheet s {\n    relationship {\n        c := (a + b) * 2i32;\n    }\n}\n";
1069        assert_eq!(format(source), expected);
1070    }
1071
1072    #[test]
1073    fn format_is_idempotent_through_a_reparse() {
1074        let source = "sheet demo {\n    cell a: f64 = 2.0;\n    cell b: f64 = 3.0;\n\n    relationship {\n        c := a * b;\n    }\n}";
1075        let once = format(source);
1076        let twice = format(&once);
1077        assert_eq!(once, twice);
1078    }
1079
1080    #[test]
1081    fn format_is_idempotent_through_a_reparse_with_a_conditional() {
1082        let source = "sheet demo {\n    cell p: i32 = 0;\n    cell c: f64;\n\n    conditional p {\n        0i32 => {\n            relationship {\n                c := c;\n            }\n        }\n        _ => {\n            relationship {\n                c := c;\n            }\n        }\n    }\n}";
1083        let once = format(source);
1084        let twice = format(&once);
1085        assert_eq!(once, twice);
1086    }
1087
1088    #[test]
1089    fn formats_an_out_with_explicit_type_and_no_requirements() {
1090        let source = "sheet s {\n    out area: f64 := width * height;\n}";
1091        let expected = "sheet s {\n    out area: f64 := width * height;\n}\n";
1092        assert_eq!(format(source), expected);
1093    }
1094
1095    #[test]
1096    fn formats_an_out_with_no_type_annotation() {
1097        let source = "sheet s {\n    out area := width;\n}";
1098        let expected = "sheet s {\n    out area := width;\n}\n";
1099        assert_eq!(format(source), expected);
1100    }
1101
1102    #[test]
1103    fn formats_an_out_with_requirements_in_declaration_order() {
1104        let source = "sheet s {\n    out area: f64 := width * height require {\n        @max_area width * height <= max_area;\n    };\n}";
1105        let expected = "sheet s {\n    out area: f64 := width * height require {\n        @max_area width * height <= max_area;\n    };\n}\n";
1106        assert_eq!(format(source), expected);
1107    }
1108
1109    #[test]
1110    fn formats_a_cell_with_a_require_block() {
1111        let source =
1112            "sheet s {\n    cell x: i32 = 5 require {\n        @positive x > 0;\n    };\n}";
1113        assert_eq!(format(source), format!("{source}\n"));
1114    }
1115
1116    #[test]
1117    fn formats_a_source_with_a_require_block() {
1118        let source =
1119            "sheet s {\n    source x: i32 = 5 require {\n        @positive x > 0;\n    };\n}";
1120        assert_eq!(format(source), format!("{source}\n"));
1121    }
1122
1123    #[test]
1124    fn formats_a_source_with_a_filter_clause() {
1125        let source = "sheet s {\n    source x: i32 = 5 filter 0..=10;\n}";
1126        assert_eq!(format(source), format!("{source}\n"));
1127    }
1128
1129    #[test]
1130    fn formats_an_out_with_a_filter_clause() {
1131        let source =
1132            "sheet s {\n    cell width: i32 = 4;\n    out area := width filter 0..=100;\n}";
1133        assert_eq!(format(source), format!("{source}\n"));
1134    }
1135
1136    #[test]
1137    fn formats_a_cell_with_an_explicit_tuple_type() {
1138        assert_eq!(
1139            format("sheet s { cell a: (i32, f64) = (1, 2.5); }"),
1140            "sheet s {\n    cell a: (i32, f64) = (1, 2.5);\n}\n"
1141        );
1142    }
1143
1144    #[test]
1145    fn formats_a_cell_with_a_nested_tuple_type() {
1146        assert_eq!(
1147            format("sheet s { cell a: (i32, (f64, String)); }"),
1148            "sheet s {\n    cell a: (i32, (f64, String));\n}\n"
1149        );
1150    }
1151
1152    #[test]
1153    fn formats_an_out_with_an_explicit_tuple_type() {
1154        assert_eq!(
1155            format("sheet s { out a: (i32, i32) := (x, x); }"),
1156            "sheet s {\n    out a: (i32, i32) := (x, x);\n}\n"
1157        );
1158    }
1159
1160    #[test]
1161    fn format_is_idempotent_through_a_reparse_with_a_tuple_cell() {
1162        let source = "sheet s {\n    cell a: (i32, f64) = (1, 2.5);\n}";
1163        let once = format(source);
1164        let twice = format(&once);
1165        assert_eq!(once, twice);
1166    }
1167
1168    #[test]
1169    fn formats_a_single_line_block_comment_preserving_its_style() {
1170        // A comment immediately after the sheet's opening `{` (before its first item) falls into
1171        // the untracked gap from issue #52 (see trivia.rs's module doc) — unrelated to this
1172        // change — so a preceding cell is included here to land the comment in a tracked gap.
1173        // The comment is on its own source line so it attaches as `x`'s leading comment, not `a`'s
1174        // same-line trailing comment (see issue #59) — what this test is actually checking is that
1175        // a `/* */` leading comment round-trips as `/* */`, not `//`.
1176        let source =
1177            "sheet s {\n    cell a: i32 = 1;\n    /* the total */\n    cell x: i32 = 1;\n}";
1178        let expected =
1179            "sheet s {\n    cell a: i32 = 1;\n    /* the total */\n    cell x: i32 = 1;\n}\n";
1180        assert_eq!(format(source), expected);
1181    }
1182
1183    #[test]
1184    fn formats_a_multi_line_block_comment_preserving_its_style() {
1185        // See the comment on `formats_a_single_line_block_comment_preserving_its_style` for why
1186        // a preceding cell is included.
1187        let source = "sheet s {\n    cell a: i32 = 1;\n    /*\n        line one\n        line two\n    */\n    cell x: i32 = 1;\n}";
1188        let expected = "sheet s {\n    cell a: i32 = 1;\n    /*\n        line one\n        line two\n    */\n    cell x: i32 = 1;\n}\n";
1189        assert_eq!(format(source), expected);
1190    }
1191
1192    #[test]
1193    fn formats_the_issue_105_license_header_repro_without_dropping_it() {
1194        let source =
1195            "/*\n    Copyright 2013 Adobe\n    ...\n*/\nsheet s {\n    cell a: i32 = 1;\n}";
1196        let expected =
1197            "/*\n    Copyright 2013 Adobe\n    ...\n*/\nsheet s {\n    cell a: i32 = 1;\n}\n";
1198        assert_eq!(format(source), expected);
1199    }
1200
1201    #[test]
1202    fn block_comment_formatting_is_idempotent_through_a_reparse() {
1203        // See the comment on `formats_a_single_line_block_comment_preserving_its_style` for why
1204        // a preceding cell is included: without it, the comment lands in issue #52's untracked
1205        // gap and is dropped on the *first* format, making `once == twice` trivially true
1206        // regardless of whether the comment round-trips.
1207        let source = "sheet s {\n    cell a: i32 = 1;\n    /*\n        line one\n        line two\n    */\n    cell x: i32 = 1;\n}";
1208        let once = format(source);
1209        let twice = format(&once);
1210        assert_eq!(once, twice);
1211    }
1212
1213    #[test]
1214    fn formats_a_cell_with_a_doc_comment() {
1215        assert_eq!(
1216            format("sheet s { /// the total\n cell x: i32 = 1; }"),
1217            "sheet s {\n    /// the total\n    cell x: i32 = 1;\n}\n"
1218        );
1219    }
1220
1221    #[test]
1222    fn formats_a_sheet_level_doc_comment() {
1223        assert_eq!(
1224            format("//! module docs\nsheet s { cell x: i32 = 1; }"),
1225            "//! module docs\nsheet s {\n    cell x: i32 = 1;\n}\n"
1226        );
1227    }
1228
1229    #[test]
1230    fn formats_a_plain_comment_and_doc_comment_together_in_source_order() {
1231        // Two items, not one: `trivia::attach_gaps` never attaches a leading plain comment to a
1232        // list's first element (a pre-existing, out-of-scope #52-adjacent limitation unrelated to
1233        // doc comments — see Tasks 2/3's identical fixture fix), so `x` needs a preceding sibling
1234        // for its `// TODO` to actually attach via the normal gap-scanning path.
1235        let source =
1236            "sheet s {\n    cell w: i32 = 0;\n    // TODO\n    /// docs\n    cell x: i32 = 1;\n}";
1237        let expected =
1238            "sheet s {\n    cell w: i32 = 0;\n    // TODO\n    /// docs\n    cell x: i32 = 1;\n}\n";
1239        assert_eq!(format(source), expected);
1240    }
1241
1242    #[test]
1243    fn formats_doc_comments_on_a_relationship_conditional_and_out() {
1244        let source = "sheet s {\n    /// r\n    relationship { b := a; }\n\n    /// o\n    out area: f64 := w;\n}";
1245        let expected = "sheet s {\n    /// r\n    relationship {\n        b := a;\n    }\n\n    /// o\n    out area: f64 := w;\n}\n";
1246        assert_eq!(format(source), expected);
1247    }
1248
1249    #[test]
1250    fn doc_comment_formatting_is_idempotent_through_a_reparse() {
1251        let source = "sheet s {\n    /// the total\n    cell x: i32 = 1;\n}";
1252        let once = format(source);
1253        let twice = format(&once);
1254        assert_eq!(once, twice);
1255    }
1256
1257    #[test]
1258    fn formats_a_trailing_comment_before_the_sheets_closing_brace() {
1259        assert_eq!(
1260            format("sheet s {\n    cell x: i32 = 1;\n    // trailing\n}"),
1261            "sheet s {\n    cell x: i32 = 1;\n    // trailing\n}\n"
1262        );
1263    }
1264
1265    #[test]
1266    fn formats_a_trailing_comment_in_an_empty_relationship() {
1267        assert_eq!(
1268            format("sheet s {\n    relationship {\n        // only this\n    }\n}"),
1269            "sheet s {\n    relationship {\n        // only this\n    }\n}\n"
1270        );
1271    }
1272
1273    #[test]
1274    fn formats_a_trailing_comment_before_a_relationships_closing_brace() {
1275        let source =
1276            "sheet s {\n    relationship {\n        b := a;\n        // trailing\n    }\n}";
1277        let expected =
1278            "sheet s {\n    relationship {\n        b := a;\n        // trailing\n    }\n}\n";
1279        assert_eq!(format(source), expected);
1280    }
1281
1282    #[test]
1283    fn formats_a_trailing_comment_before_a_conditionals_own_closing_brace() {
1284        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } }\n        // trailing\n    }\n}";
1285        let expected = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship {\n                b := a;\n            }\n        }\n        // trailing\n    }\n}\n";
1286        assert_eq!(format(source), expected);
1287    }
1288
1289    #[test]
1290    fn formats_a_trailing_comment_in_a_default_arm() {
1291        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; }\n            // trailing\n        }\n    }\n}";
1292        let expected = "sheet s {\n    conditional m {\n        _ => {\n            relationship {\n                b := a;\n            }\n            // trailing\n        }\n    }\n}\n";
1293        assert_eq!(format(source), expected);
1294    }
1295
1296    #[test]
1297    fn formats_a_trailing_comment_before_a_requires_closing_brace() {
1298        let source = "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0;\n        // trailing\n    };\n}";
1299        let expected = "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0;\n        // trailing\n    };\n}\n";
1300        assert_eq!(format(source), expected);
1301    }
1302
1303    #[test]
1304    fn formats_a_trailing_comment_before_a_cells_requires_closing_brace() {
1305        // Mirrors `formats_a_trailing_comment_before_a_requires_closing_brace`, but for a
1306        // `cell`'s own `require` block — regression test for the comment being silently dropped
1307        // (never attached, so never written back out) because `attach_trivia` only recovered
1308        // require-block trivia for `SheetItem::Out`, not `SheetItem::Cell`/`SheetItem::Source`.
1309        let source = "sheet s {\n    cell a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}";
1310        let expected = "sheet s {\n    cell a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}\n";
1311        assert_eq!(format(source), expected);
1312    }
1313
1314    #[test]
1315    fn formats_a_trailing_comment_before_a_sources_requires_closing_brace() {
1316        // Mirrors `formats_a_trailing_comment_before_a_requires_closing_brace`, but for a
1317        // `source`'s own `require` block.
1318        let source = "sheet s {\n    source a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}";
1319        let expected = "sheet s {\n    source a: i32 = 1 require {\n        @r a > 0;\n        // trailing\n    };\n}\n";
1320        assert_eq!(format(source), expected);
1321    }
1322
1323    #[test]
1324    fn trailing_trivia_formatting_is_idempotent_through_a_reparse() {
1325        let source = "sheet s {\n    cell a: i32 = 1;\n    // trailing\n}";
1326        let once = format(source);
1327        let twice = format(&once);
1328        assert_eq!(once, twice);
1329    }
1330
1331    #[test]
1332    fn formats_a_cell_with_a_filter() {
1333        assert_eq!(
1334            format("sheet s { cell a: i32 = 1 filter _; }"),
1335            "sheet s {\n    cell a: i32 = 1 filter _;\n}\n"
1336        );
1337    }
1338
1339    #[test]
1340    fn formats_a_cell_with_a_filter_referencing_a_cell() {
1341        assert_eq!(
1342            format("sheet s { cell hi: i32 = 100; cell a: i32 = 1 filter min(_, hi); }"),
1343            "sheet s {\n    cell hi: i32 = 100;\n    cell a: i32 = 1 filter min(_, hi);\n}\n"
1344        );
1345    }
1346
1347    #[test]
1348    fn format_is_idempotent_through_a_reparse_with_a_filter() {
1349        let source = "sheet s {\n    cell a: i32 = 1 filter _;\n}";
1350        let once = format(source);
1351        let twice = format(&once);
1352        assert_eq!(once, twice);
1353    }
1354
1355    #[test]
1356    fn formats_a_filter() {
1357        let source = "sheet s { cell x: i32 = 0 filter 0..=10; }";
1358        let sheet = AdamAstParser::new().parse_str(source).unwrap();
1359        assert_eq!(
1360            format_sheet(&sheet, source),
1361            "sheet s {\n    cell x: i32 = 0 filter 0..=10;\n}\n"
1362        );
1363    }
1364
1365    #[test]
1366    fn formats_a_requirement_with_no_label() {
1367        let source = "sheet s {\n    cell x: i32 = 5 require {\n        x > 0;\n    };\n}";
1368        assert_eq!(format(source), format!("{source}\n"));
1369    }
1370
1371    #[test]
1372    fn format_source_formats_valid_source() {
1373        assert_eq!(
1374            format_source("sheet   s{cell x:i32=1;}").unwrap(),
1375            "sheet s {\n    cell x: i32 = 1;\n}\n"
1376        );
1377    }
1378
1379    #[test]
1380    fn format_source_errors_on_source_that_fails_to_parse() {
1381        assert!(matches!(
1382            format_source("not a sheet at all"),
1383            Err(FormatSourceError::Parse(_))
1384        ));
1385    }
1386
1387    #[test]
1388    fn format_source_errors_on_a_recovered_syntax_error() {
1389        assert!(matches!(
1390            format_source("sheet s { cell x unknown_syntax }"),
1391            Err(FormatSourceError::Recovered(_))
1392        ));
1393    }
1394
1395    // Issue #59: a same-line trailing `//`/`/* */` comment stays on the declaration it trails,
1396    // instead of migrating onto its own line before the next declaration.
1397
1398    #[test]
1399    fn formats_a_same_line_trailing_line_comment_on_a_cell() {
1400        let source =
1401            "sheet s {\n    cell a: f64 = 2.0; // end of line comment\n    cell b: f64 = 3.0;\n}";
1402        let expected =
1403            "sheet s {\n    cell a: f64 = 2.0; // end of line comment\n    cell b: f64 = 3.0;\n}\n";
1404        assert_eq!(format(source), expected);
1405    }
1406
1407    #[test]
1408    fn formats_a_same_line_trailing_block_comment_on_a_cell() {
1409        let source = "sheet s {\n    cell a: f64 = 2.0; /* note */\n    cell b: f64 = 3.0;\n}";
1410        let expected = "sheet s {\n    cell a: f64 = 2.0; /* note */\n    cell b: f64 = 3.0;\n}\n";
1411        assert_eq!(format(source), expected);
1412    }
1413
1414    #[test]
1415    fn formats_a_same_line_trailing_comment_on_the_last_item_before_the_sheets_closing_brace() {
1416        let source = "sheet s {\n    cell a: i32 = 1; // note\n}";
1417        let expected = "sheet s {\n    cell a: i32 = 1; // note\n}\n";
1418        assert_eq!(format(source), expected);
1419    }
1420
1421    #[test]
1422    fn formats_a_same_line_trailing_comment_on_a_binding() {
1423        let source =
1424            "sheet s {\n    relationship {\n        b := a; // note\n        a := b;\n    }\n}";
1425        let expected =
1426            "sheet s {\n    relationship {\n        b := a; // note\n        a := b;\n    }\n}\n";
1427        assert_eq!(format(source), expected);
1428    }
1429
1430    #[test]
1431    fn formats_a_same_line_block_comment_when_the_gap_has_no_newline_at_all() {
1432        // PR #202 review: previously the trailing `/* note */` was silently dropped, since
1433        // items+comment sharing one physical line with no `\n` anywhere in the gap fell into
1434        // the "drop the final fragment" path before the comment scanner ever ran.
1435        let source = "sheet s {\n    cell a: i32 = 1; /* note */ cell b: i32 = 2;\n}";
1436        let expected = "sheet s {\n    cell a: i32 = 1; /* note */\n    cell b: i32 = 2;\n}\n";
1437        assert_eq!(format(source), expected);
1438    }
1439
1440    #[test]
1441    fn formats_a_same_line_trailing_comment_before_a_conditional_branchs_own_closing_brace() {
1442        let source = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship { b := a; } // note\n        }\n    }\n}";
1443        let expected = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship {\n                b := a;\n            } // note\n        }\n    }\n}\n";
1444        assert_eq!(format(source), expected);
1445    }
1446
1447    #[test]
1448    fn formats_a_same_line_trailing_comment_before_a_default_arms_own_closing_brace() {
1449        let source = "sheet s {\n    conditional m {\n        _ => {\n            relationship { b := a; } // note\n        }\n    }\n}";
1450        let expected = "sheet s {\n    conditional m {\n        _ => {\n            relationship {\n                b := a;\n            } // note\n        }\n    }\n}\n";
1451        assert_eq!(format(source), expected);
1452    }
1453
1454    #[test]
1455    fn formats_a_same_line_trailing_comment_before_a_conditionals_own_closing_brace_with_only_branches()
1456     {
1457        let source = "sheet s {\n    conditional m {\n        0i32 => { relationship { b := a; } } // note\n    }\n}";
1458        let expected = "sheet s {\n    conditional m {\n        0i32 => {\n            relationship {\n                b := a;\n            }\n        } // note\n    }\n}\n";
1459        assert_eq!(format(source), expected);
1460    }
1461
1462    #[test]
1463    fn formats_a_same_line_trailing_comment_before_a_conditionals_own_closing_brace_with_a_default()
1464    {
1465        let source = "sheet s {\n    conditional m {\n        _ => { relationship { b := a; } } // note\n    }\n}";
1466        let expected = "sheet s {\n    conditional m {\n        _ => {\n            relationship {\n                b := a;\n            }\n        } // note\n    }\n}\n";
1467        assert_eq!(format(source), expected);
1468    }
1469
1470    #[test]
1471    fn formats_a_same_line_trailing_comment_before_a_requires_closing_brace() {
1472        let source =
1473            "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0; // note\n    };\n}";
1474        let expected = "sheet s {\n    out area: f64 := w require {\n        @c w <= 10.0; // note\n    };\n}\n";
1475        assert_eq!(format(source), expected);
1476    }
1477
1478    #[test]
1479    fn same_line_trailing_comment_formatting_is_idempotent_through_a_reparse() {
1480        let source =
1481            "sheet s {\n    cell a: f64 = 2.0; // end of line comment\n    cell b: f64 = 3.0;\n}";
1482        let once = format(source);
1483        let twice = format(&once);
1484        assert_eq!(once, twice);
1485    }
1486
1487    // Issue #201: comments in gaps *within* one declaration's own tokens (as opposed to gaps
1488    // between sibling declarations, or before a container's closing brace — both already handled
1489    // by `trivia.rs`).
1490
1491    #[test]
1492    fn preserves_a_block_comment_between_a_cell_initializer_and_its_semicolon() {
1493        let source = "sheet s {\n    cell a: i32 = 1 /* trailing */;\n}";
1494        assert_eq!(
1495            format(source),
1496            "sheet s {\n    cell a: i32 = 1 /* trailing */;\n}\n"
1497        );
1498    }
1499
1500    #[test]
1501    fn preserves_a_block_comment_between_a_type_and_the_equals() {
1502        let source = "sheet s {\n    cell a: i32 /* t */ = 1;\n}";
1503        assert_eq!(
1504            format(source),
1505            "sheet s {\n    cell a: i32 /* t */ = 1;\n}\n"
1506        );
1507    }
1508
1509    #[test]
1510    fn a_declaration_with_no_intra_token_comments_is_unchanged() {
1511        assert_eq!(
1512            format("sheet s { cell a: i32 = 1; }"),
1513            "sheet s {\n    cell a: i32 = 1;\n}\n"
1514        );
1515    }
1516
1517    #[test]
1518    fn a_cell_initializer_with_leading_inline_and_trailing_comments_round_trips_losslessly() {
1519        let source = "sheet s {\n    cell a: i32 = /* p */ 1 /* q */ + 2 /* r */;\n}";
1520        let once = format_source(source).unwrap();
1521        // Every comment survives (none silently dropped).
1522        for needle in ["/* p */", "/* q */", "/* r */"] {
1523            assert!(once.contains(needle), "comment {needle} lost: {once:?}");
1524        }
1525        // And formatting is idempotent.
1526        let twice = format_source(&once).unwrap();
1527        assert_eq!(once, twice);
1528    }
1529
1530    #[test]
1531    fn preserves_a_line_comment_between_a_cell_initializer_and_its_semicolon() {
1532        // A `//` comment forces a line wrap; the terminating `;` must land on the continuation
1533        // line, not get glued onto the comment's own line, which would comment the `;` out and
1534        // make the output fail to reparse.
1535        let source = "sheet s {\n    cell a: i32 = 1 // c\n    ;\n}";
1536        let once = format_source(source).unwrap();
1537        assert!(once.contains("// c"), "comment must survive: {once:?}");
1538        assert!(
1539            once.trim_end().ends_with('}'),
1540            "the sheet's own closing brace must not have been commented out: {once:?}"
1541        );
1542        // Must still reparse (a stray `;` swallowed into the comment would fail this).
1543        let twice = format_source(&once).unwrap();
1544        assert_eq!(once, twice, "formatting must be idempotent");
1545    }
1546
1547    #[test]
1548    fn preserves_a_line_comment_between_a_type_and_the_equals() {
1549        let source = "sheet s {\n    cell a: i32 // c\n    = 1;\n}";
1550        let once = format_source(source).unwrap();
1551        assert!(once.contains("// c"), "comment must survive: {once:?}");
1552        let twice = format_source(&once).unwrap();
1553        assert_eq!(once, twice, "formatting must be idempotent");
1554    }
1555
1556    #[test]
1557    fn preserves_comment_order_after_the_equals() {
1558        let source = "sheet s {\n    cell a: i32 = /* after */ 1;\n}";
1559        let once = format_source(source).unwrap();
1560        let eq = once.find('=').expect("has =");
1561        let c = once.find("/* after */").expect("comment survives");
1562        assert!(c > eq, "comment must stay AFTER the =, got: {once:?}");
1563    }
1564
1565    #[test]
1566    fn preserves_comment_order_around_the_equals() {
1567        let source = "sheet s {\n    cell a: i32 /* before */ = /* after */ 1;\n}";
1568        let once = format_source(source).unwrap();
1569        let before = once.find("/* before */").expect("before survives");
1570        let eq = once.find('=').expect("has =");
1571        let after = once.find("/* after */").expect("after survives");
1572        assert!(before < eq && eq < after, "order lost: {once:?}");
1573    }
1574
1575    #[test]
1576    fn preserves_a_comment_before_the_filter_keyword() {
1577        let source = "sheet s {\n    cell a: i32 = 1 /* keep */ filter 0..=10;\n}";
1578        let once = format_source(source).unwrap();
1579        assert!(once.contains("/* keep */"), "comment dropped: {once:?}");
1580    }
1581
1582    #[test]
1583    fn preserves_a_comment_after_the_filter_body() {
1584        let source = "sheet s {\n    cell a: i32 = 1 filter 0..=10 /* keep */;\n}";
1585        let once = format_source(source).unwrap();
1586        assert!(once.contains("/* keep */"), "comment dropped: {once:?}");
1587    }
1588
1589    #[test]
1590    fn preserves_a_comment_before_the_require_keyword() {
1591        let source = "sheet s {\n    out area: f64 := w /* keep */ require {\n        @c w <= 10.0;\n    };\n}";
1592        let once = format_source(source).unwrap();
1593        assert!(once.contains("/* keep */"), "comment dropped: {once:?}");
1594    }
1595
1596    /// Every keyword-to-body gap must preserve a comment and stay idempotent — one case per
1597    /// hardcoded token the declaration formatter reprints (`filter`, `require`, `=>`, the `@label`,
1598    /// the conditional subject's `{`, and the `relationship` keyword's `{`). See PR #207 review.
1599    fn assert_comment_survives_and_is_idempotent(source: &str) {
1600        let once = format_source(source).unwrap();
1601        assert!(once.contains("/* keep */"), "comment dropped: {once:?}");
1602        let twice = format_source(&once).unwrap();
1603        assert_eq!(once, twice, "formatting must be idempotent: {once:?}");
1604    }
1605
1606    #[test]
1607    fn preserves_a_comment_between_the_filter_keyword_and_body() {
1608        assert_comment_survives_and_is_idempotent(
1609            "sheet s {\n    cell a: i32 = 1 filter /* keep */ 0..=10;\n}",
1610        );
1611    }
1612
1613    #[test]
1614    fn preserves_a_comment_between_the_require_keyword_and_brace() {
1615        assert_comment_survives_and_is_idempotent(
1616            "sheet s {\n    out area: f64 := w require /* keep */ {\n        @c w <= 10.0;\n    };\n}",
1617        );
1618    }
1619
1620    #[test]
1621    fn preserves_a_comment_between_a_branch_literal_and_the_fat_arrow() {
1622        assert_comment_survives_and_is_idempotent(
1623            "sheet s {\n    conditional p {\n        0i32 /* keep */ => { relationship { b := a; } }\n    }\n}",
1624        );
1625    }
1626
1627    #[test]
1628    fn preserves_a_comment_between_the_fat_arrow_and_a_branch_body() {
1629        assert_comment_survives_and_is_idempotent(
1630            "sheet s {\n    conditional p {\n        0i32 => /* keep */ { relationship { b := a; } }\n    }\n}",
1631        );
1632    }
1633
1634    #[test]
1635    fn preserves_a_comment_between_a_requirement_label_and_body() {
1636        assert_comment_survives_and_is_idempotent(
1637            "sheet s {\n    out area: f64 := w require {\n        @c /* keep */ w <= 10.0;\n    };\n}",
1638        );
1639    }
1640
1641    #[test]
1642    fn preserves_a_comment_between_a_conditional_subject_and_its_brace() {
1643        assert_comment_survives_and_is_idempotent(
1644            "sheet s {\n    conditional p /* keep */ {\n        0i32 => { relationship { b := a; } }\n    }\n}",
1645        );
1646    }
1647
1648    #[test]
1649    fn preserves_a_comment_between_the_relationship_keyword_and_its_brace() {
1650        assert_comment_survives_and_is_idempotent(
1651            "sheet s {\n    relationship /* keep */ {\n        b := a;\n    }\n}",
1652        );
1653    }
1654}