Skip to main content

cel_parser/
fmt.rs

1//! Pretty-prints a [`crate::Expr`] tree back to CEL source text: precedence-aware
2//! parenthesization (added only where required, not exhaustively), single-space-around-operator
3//! normalization, and a 100-column layout target. Short expressions stay inline; longer lists,
4//! records and closure bodies nest by four spaces, with leading binary continuation operators.
5//! Unbreakable tokens and comments may exceed the target. Literal leaves are re-emitted via
6//! [`proc_macro2::Span::source_text`] rather than synthesized from [`crate::Literal`], so exact
7//! original notation (`1920.0` vs `1920.0f64`, a byte literal's spelling) round-trips.
8//!
9//! Typed arrays round-trip with their postfix `: Type` ascriptions, including recursive array
10//! types such as `[]: [[i32]]` and reusable tuple syntax such as `[]: [(i32, f64)]`. The
11//! formatter preserves complete tuple, array, and `Fn((A,), R)` callable annotations,
12//! including the comma that distinguishes a singleton tuple from grouping.
13
14use crate::ast::{Expr, LogicalOp};
15use crate::trivia::{
16    Comment, GapPiece, line_column_to_byte, line_start_byte_offsets, scan_gap, take_block_comment,
17};
18
19/// An immutable Wadler document; construction never lays out a child expression.
20type Doc = pretty::RcDoc<'static, ()>;
21
22/// Preserves a lexical token's internal newlines and indentation independently of layout nesting.
23///
24/// - Complexity: O(n) in the token's text length.
25fn verbatim_doc(text: String) -> Doc {
26    let mut lines = text.split('\n');
27    let first = Doc::text(lines.next().unwrap_or_default().to_owned());
28    lines.fold(first, |doc, line| {
29        let line = Doc::text(line.to_owned());
30        doc.append(Doc::nesting(move |indent| {
31            let offset = isize::try_from(indent)
32                .expect("document indentation fits isize")
33                .checked_neg()
34                .expect("nonnegative indentation can be negated");
35            Doc::hardline().append(line.clone()).nest(offset)
36        }))
37    })
38}
39
40/// Lifts recovered trivia into a document with mandatory line-comment boundaries.
41///
42/// - Complexity: O(n) in the recovered text length.
43fn trivia_doc(text: String) -> Doc {
44    trivia_doc_at(text, 4)
45}
46
47/// Lifts recovered trivia with continuation nesting supplied by its grammar owner.
48///
49/// - Complexity: O(n) in the recovered text length.
50fn trivia_doc_at(text: String, continuation: isize) -> Doc {
51    let mut lines = text.split('\n');
52    let first = Doc::text(lines.next().unwrap_or_default().to_owned());
53    lines.fold(first, |doc, line| {
54        doc.append(
55            Doc::hardline()
56                .append(Doc::text(
57                    line.strip_prefix("    ").unwrap_or(line).to_owned(),
58                ))
59                .nest(continuation),
60        )
61    })
62}
63
64/// Builds a gap document, with a soft boundary before binary operators or after commas.
65///
66/// - Complexity: O(n) in the gap's text length.
67fn gap_doc(pieces: &[GapPiece], spacing: Spacing) -> Doc {
68    if pieces
69        .iter()
70        .any(|piece| matches!(piece, GapPiece::Comment(_)))
71        && let Some(index) = pieces
72            .iter()
73            .position(|piece| matches!(piece, GapPiece::Punct(_)))
74        && matches!(spacing, Spacing::Around | Spacing::CommaAfter)
75    {
76        let GapPiece::Punct(token) = pieces[index] else {
77            unreachable!()
78        };
79        let before = emit_gap(&pieces[..index], Spacing::None, 0);
80        let after = emit_gap(&pieces[index + 1..], Spacing::None, 0);
81        let after_empty = after.is_empty();
82        let before_line = matches!(
83            pieces.get(index.wrapping_sub(1)),
84            Some(GapPiece::Comment(Comment::Line(_)))
85        );
86        let after_line = matches!(pieces.last(), Some(GapPiece::Comment(Comment::Line(_))));
87        let prefix = if before_line {
88            trivia_doc_at(before, 0)
89        } else {
90            trivia_doc_at(before.trim_end().to_owned(), 0)
91        };
92        let suffix = if after_line {
93            trivia_doc_at(after, 0)
94        } else {
95            trivia_doc_at(after.trim_end().to_owned(), 0)
96        };
97        return match spacing {
98            Spacing::Around => prefix
99                .append(if before_line {
100                    Doc::nil()
101                } else {
102                    Doc::softline()
103                })
104                .append(token)
105                .append(if after_empty {
106                    Doc::space()
107                } else {
108                    suffix.append(if after_line { Doc::nil() } else { Doc::space() })
109                }),
110            Spacing::CommaAfter => prefix.append(token).append(if after_empty {
111                Doc::line()
112            } else {
113                suffix.append(if after_line { Doc::nil() } else { Doc::line() })
114            }),
115            Spacing::None => unreachable!(),
116        };
117    }
118    if pieces
119        .iter()
120        .all(|piece| matches!(piece, GapPiece::Punct(_)))
121    {
122        Doc::concat(pieces.iter().map(|piece| {
123            let GapPiece::Punct(token) = piece else {
124                unreachable!()
125            };
126            match spacing {
127                Spacing::Around => Doc::line().append(*token).append(" "),
128                Spacing::CommaAfter => Doc::text(*token).append(Doc::line()),
129                Spacing::None => Doc::text(*token),
130            }
131        }))
132    } else {
133        trivia_doc(emit_gap(pieces, spacing, 0))
134    }
135}
136
137/// Builds an indented leading-operator continuation without nesting the left operand.
138///
139/// - Complexity: O(n) in the gap's text length.
140fn binary_doc(lhs: Doc, gap: &[GapPiece], rhs: Doc) -> Doc {
141    lhs.append(gap_doc(gap, Spacing::Around).append(rhs).nest(4))
142        .group()
143}
144
145/// Ends an opening-brace gap with a soft boundary, preserving mandatory comment boundaries.
146///
147/// `trim_start` removes spacing already supplied by the preceding closing-brace boundary.
148///
149/// - Complexity: O(n) in the recovered gap's text length.
150fn body_gap_doc(pieces: &[GapPiece], trim_start: bool) -> Doc {
151    let text = emit_gap(pieces, Spacing::Around, 0);
152    let text = if trim_start { text.trim_start() } else { &text };
153    if matches!(pieces.last(), Some(GapPiece::Comment(Comment::Line(_)))) {
154        trivia_doc_at(text.to_owned(), 0)
155    } else {
156        trivia_doc_at(text.trim_end().to_owned(), 0).append(Doc::line())
157    }
158}
159
160/// Binding-strength level, loosest first, mirroring `lib.rs`'s grammar chain from
161/// `range_expression` (via `expression = range_expression`) through `primary_expression`.
162#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
163struct Level(u8);
164
165impl Level {
166    const RANGE: Level = Level(0);
167    const OR: Level = Level(1);
168    const AND: Level = Level(2);
169    const COMPARISON: Level = Level(3);
170    const BIT_OR: Level = Level(4);
171    const BIT_XOR: Level = Level(5);
172    const BIT_AND: Level = Level(6);
173    const SHIFT: Level = Level(7);
174    const ADDITIVE: Level = Level(8);
175    const MULTIPLICATIVE: Level = Level(9);
176    const CAST: Level = Level(10);
177    const UNARY: Level = Level(11);
178    const POSTFIX: Level = Level(12);
179    const PRIMARY: Level = Level(13);
180
181    /// The next level up (strictly tighter-binding than `self`).
182    fn tighter(self) -> Level {
183        Level(self.0 + 1)
184    }
185}
186
187/// 4 spaces per nesting level (mirrors `adam-lang::fmt::indent`).
188///
189/// - Complexity: O(depth).
190fn indent(depth: usize) -> String {
191    "    ".repeat(depth)
192}
193
194/// How a gap's expected `Punct` tokens are spaced against their operands.
195#[derive(Clone, Copy)]
196enum Spacing {
197    /// A single space on each side of the token: `a + b`.
198    Around,
199    /// No surrounding spaces: `a..b`, or a bare delimiter.
200    None,
201    /// The token then one space: `a, b`.
202    CommaAfter,
203}
204
205/// Renders the pieces of one scanned gap into the text that goes *between* two already-rendered
206/// operands. A `Comment::Block` is inlined space-separated; a `Comment::Line` ends its line and
207/// resumes at `indent(depth + 1)` (the wrap continuation), since a `//` comment runs to
208/// end-of-line. A `Punct` is spaced per `spacing`.
209///
210/// - Postcondition: the returned string starts and ends exactly as `spacing` dictates for the
211///   comment-free case (e.g. `Spacing::Around` with a lone `Punct` returns `" + "`), so a
212///   comment-free gap reprints identically to the pre-`scan_gap` formatter.
213///
214/// - Complexity: O(n) in `pieces.len()` plus their text lengths.
215fn emit_gap(pieces: &[GapPiece], spacing: Spacing, depth: usize) -> String {
216    let cont = indent(depth + 1);
217    let mut out = String::new();
218    for piece in pieces {
219        match piece {
220            GapPiece::Punct(tok) => {
221                match spacing {
222                    Spacing::Around => {
223                        // Skip the leading space only when the previous piece already left one
224                        // (a block comment's trailing `" */ "`, or a line comment's continuation
225                        // indent) — otherwise this token opens the gap and needs its own.
226                        if !out.ends_with(' ') {
227                            out.push(' ');
228                        }
229                        out.push_str(tok);
230                        out.push(' ');
231                    }
232                    Spacing::None => out.push_str(tok),
233                    Spacing::CommaAfter => {
234                        // Unlike `Around`, a comma never gets a leading space of its own -- it
235                        // glues directly to whatever precedes it, whether that's the element text
236                        // itself (the comment-free case) or a comment's trailing `" */ "` (a block
237                        // comment written between the element and its `,`, e.g.
238                        // `1i32 /* c */, 2i32`). A line comment's trailing continuation *indent*
239                        // is different: that space is meaningful (it's `indent(depth + 1)`, not
240                        // filler), so it must be left alone -- only the incidental single-space
241                        // filler a block comment or a prior `Around` punct leaves behind is
242                        // trimmed.
243                        if out.ends_with(' ') && !out.ends_with(&cont) {
244                            out.pop();
245                        }
246                        out.push_str(tok);
247                        out.push(' ');
248                    }
249                }
250            }
251            GapPiece::Comment(Comment::Block(text)) => {
252                // Separate from whatever came before (or, when this is the first piece, from the
253                // already-rendered operand this gap is appended to) with exactly one space,
254                // unless the previous piece already left a trailing space.
255                if !out.ends_with(' ') {
256                    out.push(' ');
257                }
258                out.push_str("/* ");
259                out.push_str(text);
260                out.push_str(" */ ");
261            }
262            GapPiece::Comment(Comment::Line(text)) => {
263                // A `//` comment runs to end of line, so anything after it must start on the next
264                // line; drop any trailing space this gap had accumulated before appending it.
265                let continuation = out
266                    .rsplit_once('\n')
267                    .is_some_and(|(_, tail)| tail.trim().is_empty());
268                if continuation {
269                    out.push_str("// ");
270                } else {
271                    let trimmed_len = out.trim_end().len();
272                    out.truncate(trimmed_len);
273                    out.push_str(" // ");
274                }
275                out.push_str(text);
276                out.push('\n');
277                out.push_str(&cont);
278            }
279        }
280    }
281    out
282}
283
284/// Returns the binding-strength level of a binary (two-operand) operator.
285///
286/// - Precondition: `name` is one of the binary operator tokens `lib.rs`'s grammar recognizes,
287///   excluding `"range"`/`"range_inclusive"` — those, and every other range-family operator
288///   (`"range_from"`/`"range_to"`/`"range_to_inclusive"`/`"range_full"`), are rendered by their
289///   own dedicated [`render`] match arms before any operator ever reaches this function, since
290///   their printed form (`x..y`, no surrounding spaces) doesn't fit this function's shared
291///   `"{lhs} {name} {rhs}"` shape the way every other binary operator here does.
292fn binary_op_level(name: &str) -> Level {
293    match name {
294        "|" => Level::BIT_OR,
295        "^" => Level::BIT_XOR,
296        "&" => Level::BIT_AND,
297        "<<" | ">>" => Level::SHIFT,
298        "+" | "-" => Level::ADDITIVE,
299        "*" | "/" | "%" => Level::MULTIPLICATIVE,
300        "==" | "!=" | "<" | ">" | "<=" | ">=" => Level::COMPARISON,
301        other => unreachable!("binary_op_level called with unknown operator `{other}`"),
302    }
303}
304
305/// Re-emits a literal's exact original text via its span, falling back to an empty string when
306/// none is recoverable (spans built without a live source file — never a real parse; see the
307/// module doc).
308fn render_literal(span: crate::ExprSpan) -> String {
309    span.start.source_text().unwrap_or_default()
310}
311
312/// Scans the source gap between two adjacent AST positions for comments interleaved with
313/// `expected` tokens. Returns just the expected tokens (no comments) when `source` is empty (a
314/// hand-built `Expr`), so a source-less format still works.
315///
316/// - Precondition: when `source` is non-empty, it is the exact text `expr` was parsed from —
317///   `prev_end`/`next_start` must resolve within it (checked by `line_column_to_byte`'s own
318///   `debug_assert!`).
319///
320/// - Complexity: O(n) in the gap's length.
321fn gap_between(
322    source: &str,
323    prev_end: proc_macro2::Span,
324    next_start: proc_macro2::Span,
325    expected: &[&'static str],
326) -> Vec<GapPiece> {
327    if source.is_empty() {
328        return scan_gap("", expected);
329    }
330    let line_starts = line_start_byte_offsets(source);
331    let start = line_column_to_byte(source, &line_starts, prev_end.end());
332    let end = line_column_to_byte(source, &line_starts, next_start.start());
333    let gap = source.get(start..end).unwrap_or("");
334    scan_gap(gap, expected)
335}
336
337/// Recovers member-dot comments across closing receiver groups omitted from the AST.
338///
339/// - Precondition: non-empty `source` is the receiver's original parsed text, and both spans
340///   resolve within it.
341/// - Postcondition: grouping delimiters are omitted; comments and the dot retain source order.
342/// - Complexity: O(n) in the source length plus the gap length.
343fn member_gap(
344    source: &str,
345    receiver_end: proc_macro2::Span,
346    name_start: proc_macro2::Span,
347) -> Vec<GapPiece> {
348    grouping_gap(source, receiver_end.end(), name_start.start(), &["."])
349}
350
351/// Renders a postfix base, retaining leading group comments inside one owning group.
352///
353/// - Precondition: `start` is the postfix node's original start in `source`, or source is empty.
354/// - Postcondition: the base's leading comments appear once, before its first rendered token.
355/// - Complexity: O(n) in the base's rendering cost plus source and gap lengths.
356fn format_postfix_base(
357    base: &Expr,
358    source: &str,
359    depth: usize,
360    start: proc_macro2::LineColumn,
361) -> Doc {
362    let leading = grouping_gap(source, start, base.span().start.start(), &[]);
363    if leading.is_empty() {
364        format_at(base, source, depth, Level::POSTFIX)
365    } else {
366        let (text, _) = render(base, source, depth);
367        let prefix = emit_gap(&leading, Spacing::None, 0);
368        Doc::text("(")
369            .append(trivia_doc(prefix.trim_start().to_owned()))
370            .append(text)
371            .append(")")
372    }
373}
374
375/// Renders a comma-separated element list with per-gap comment recovery: the gap before the first
376/// element (after `bounds.0`, the opening delimiter's position, e.g. a `(` or `[`), each
377/// inter-element `,` gap, and the gap after the last element (before `bounds.1`, the position
378/// right past the closing delimiter, e.g. a `)` or `]`). `delims` is `(open, close)`, the
379/// delimiter tokens. When `trailing_comma` is set (a 1-tuple), a `,` is emitted after the sole
380/// element.
381///
382/// `bounds` holds raw [`proc_macro2::LineColumn`] positions (not `Span`s, unlike [`gap_between`]'s
383/// `prev_end`/`next_start`) because `Expr::Apply`'s, `Expr::Tuple`'s, and `Expr::Array`'s own
384/// recorded delimiter span covers the *whole* `(...)`/`[...]` group rather than a single
385/// delimiter character: `AstContext::apply_op`'s `"()"` arm, `is_tuple_or_group`, and
386/// `is_array_expression` all capture `self.last_span` from a `Token::CloseDelim`, and
387/// `lex_lexer`'s flattening gives every `OpenDelim`/`CloseDelim` the *enclosing `Group`'s* span
388/// (see `LexLexer::next`), so a group span's `.start()` lands on the opening delimiter and its
389/// `.end()` lands just past the closing delimiter regardless of which delimiter "produced" it.
390/// Resolving which of `.start()`/`.end()` is the right boundary is the caller's job (it knows
391/// whether it's holding a whole-group span or an ordinary leaf span, e.g. a callee's own end);
392/// this helper just consumes the two positions it's given.
393///
394/// - Complexity: O(n) in the number of elements plus their gap lengths.
395fn emit_list(
396    source: &str,
397    depth: usize,
398    delims: (&'static str, &'static str),
399    bounds: (proc_macro2::LineColumn, proc_macro2::LineColumn),
400    elements: &[Expr],
401    trailing_comma: bool,
402) -> Doc {
403    let (open, close) = delims;
404    let (open_pos, close_pos) = bounds;
405    // Scans the raw gap between two already-resolved positions, mirroring `gap_between` but
406    // taking `LineColumn`s directly instead of deriving them from a fixed `prev_end.end()` /
407    // `next_start.start()` pairing (which doesn't fit `open_pos`/`close_pos`; see above).
408    let scan = |from: proc_macro2::LineColumn,
409                to: proc_macro2::LineColumn,
410                expected: &[&'static str]|
411     -> Vec<GapPiece> {
412        if source.is_empty() {
413            return scan_gap("", expected);
414        }
415        let line_starts = line_start_byte_offsets(source);
416        let start = line_column_to_byte(source, &line_starts, from);
417        let end = line_column_to_byte(source, &line_starts, to);
418        let gap = source.get(start..end).unwrap_or("");
419        scan_gap(gap, expected)
420    };
421
422    if elements.is_empty() {
423        // Whole gap between the delimiters: `open`, comments, `close`.
424        let pieces = scan(open_pos, close_pos, &[open, close]);
425        let whole = emit_gap(&pieces, Spacing::None, 0);
426        return trivia_doc(glue_before_close(&glue_after_open(&whole, open), close));
427    }
428    // Opening delimiter + any comment before the first element. `emit_gap`'s comment rendering
429    // always pads a leading space before a comment that opens a gap, which is right when the gap
430    // follows an already-rendered operand (`x /* c */ as i32`) but wrong right after a delimiter
431    // -- `glue_after_open` corrects it back to `f(/* a */ 1i32)`.
432    let mut pre = grouping_gap(source, open_pos, elements[0].span().start.start(), &[open]);
433    if open == "(" {
434        pre.insert(0, GapPiece::Punct("("));
435    }
436    let mut out = trivia_doc(glue_after_open(&emit_gap(&pre, Spacing::None, 0), open));
437    let mut body = Doc::line_();
438    for (i, el) in elements.iter().enumerate() {
439        body = body.append(format_at(el, source, depth, Level::OR));
440        if i + 1 < elements.len() {
441            let pieces = grouping_gap(
442                source,
443                el.span().end.end(),
444                elements[i + 1].span().start.start(),
445                &[","],
446            );
447            body = body.append(gap_doc(&pieces, Spacing::CommaAfter));
448        }
449    }
450    if trailing_comma {
451        body = body.append(",");
452    }
453    // Any comment before the closing delimiter, then the delimiter; symmetric to the opening gap
454    // above (`glue_before_close` drops the trailing space before `)` a comment would otherwise
455    // leave: `f(1i32 /* end */)`, not `f(1i32 /* end */ )`).
456    let mut post = grouping_gap(
457        source,
458        elements[elements.len() - 1].span().end.end(),
459        close_pos,
460        &[close],
461    );
462    if close == ")" {
463        post.push(GapPiece::Punct(")"));
464    }
465    out = out.append(body.nest(4));
466    let tail = glue_before_close(&emit_gap(&post, Spacing::None, 0), close);
467    out.append(Doc::line_()).append(trivia_doc(tail)).group()
468}
469
470/// Strips the one space [`emit_gap`] pads before a comment that immediately opens a gap, when
471/// that gap begins right after `open` — appropriate between an operand and an operator, but not
472/// right after an opening delimiter, which glues directly to a following comment.
473fn glue_after_open(gap: &str, open: &'static str) -> String {
474    match gap.strip_prefix(&format!("{open} ")) {
475        Some(rest) => format!("{open}{rest}"),
476        None => gap.to_string(),
477    }
478}
479
480/// Symmetric to [`glue_after_open`]: strips the one space [`emit_gap`] pads after a comment that
481/// immediately precedes `close`, so a trailing comment glues directly to the closing delimiter.
482fn glue_before_close(gap: &str, close: &'static str) -> String {
483    if let Some((_, last_line)) = gap.rsplit_once('\n') {
484        // `gap` wrapped after a `//` comment; `last_line` is the continuation line `emit_gap`
485        // started with its own indent. When that line holds nothing but indentation before
486        // `close` (no block comment glued onto the same line), those spaces are the
487        // continuation's own indent, not `emit_gap`'s one-space comment padding — leave them
488        // alone instead of stripping one as if it were the padding space.
489        if let Some(rest) = last_line.strip_suffix(close)
490            && rest.chars().all(|c| c == ' ')
491        {
492            return gap.to_string();
493        }
494    }
495    match gap.strip_suffix(&format!(" {close}")) {
496        Some(rest) => format!("{rest}{close}"),
497        None => gap.to_string(),
498    }
499}
500
501/// Returns the `'static` source spelling of a binary operator name, for `scan_gap`'s expected-token
502/// list (which needs `&'static str`). The spelling equals `name` for every binary operator this
503/// formatter's `binary_op_level` accepts.
504fn binary_op_token(name: &str) -> &'static str {
505    match name {
506        "|" => "|",
507        "^" => "^",
508        "&" => "&",
509        "<<" => "<<",
510        ">>" => ">>",
511        "+" => "+",
512        "-" => "-",
513        "*" => "*",
514        "/" => "/",
515        "%" => "%",
516        "==" => "==",
517        "!=" => "!=",
518        "<" => "<",
519        ">" => ">",
520        "<=" => "<=",
521        ">=" => ">=",
522        other => unreachable!("binary_op_token called with unknown operator `{other}`"),
523    }
524}
525
526/// Returns the `'static` source spelling of a unary operator name (`"-"` or `"!"`).
527fn unary_op_token(name: &str) -> &'static str {
528    match name {
529        "-" => "-",
530        "!" => "!",
531        other => unreachable!("unary_op_token called with unknown operator `{other}`"),
532    }
533}
534
535/// Renders one unresolved array-annotation type expression.
536///
537/// Named leaves are emitted verbatim, so built-in names and host-registered custom names format
538/// identically. Recursive arrays keep their bracket nesting, and tuple type expressions keep the
539/// same reusable syntax the parser accepts for future typed CEL surfaces.
540///
541/// - Complexity: O(n) in the number of nodes in `type_expr`.
542fn render_type_expr(type_expr: &crate::TypeExpr) -> String {
543    match type_expr {
544        crate::TypeExpr::Named { name, args, .. } => {
545            if args.is_empty() {
546                name.clone()
547            } else {
548                let inner = args
549                    .iter()
550                    .map(render_type_expr)
551                    .collect::<Vec<_>>()
552                    .join(", ");
553                format!("{name}({inner})")
554            }
555        }
556        crate::TypeExpr::Array { element, .. } => format!("[{}]", render_type_expr(element)),
557        crate::TypeExpr::Tuple { elements, .. } => {
558            let inner = elements
559                .iter()
560                .map(render_type_expr)
561                .collect::<Vec<_>>()
562                .join(", ");
563            match elements.len() {
564                0 => "()".to_string(),
565                1 => format!("({inner},)"),
566                _ => format!("({inner})"),
567            }
568        }
569    }
570}
571
572/// Builds `expr`'s document alongside its binding-strength level, so the
573/// caller ([`format_at`]) can decide whether the context it's being placed in requires parens.
574///
575/// `source` is the text `expr` was parsed from (or `""` for a hand-built `Expr`).
576/// Children contribute documents, never independently formatted strings.
577///
578/// - Complexity: O(n) document space plus existing source-gap scan costs.
579fn render(expr: &Expr, source: &str, depth: usize) -> (Doc, Level) {
580    match expr {
581        Expr::Struct {
582            name,
583            name_span,
584            fields,
585            span,
586        } => {
587            let mut out = Doc::text(name.clone());
588            let mut body = Doc::nil();
589            if let Some(first) = fields.first() {
590                let open = grouping_gap(
591                    source,
592                    name_span.end.end(),
593                    first.name_span.start.start(),
594                    &["{"],
595                );
596                body = body.append(body_gap_doc(&open, false));
597                for (index, field) in fields.iter().enumerate() {
598                    body = body.append(Doc::text(field.name.clone()));
599                    let colon = grouping_gap(
600                        source,
601                        field.name_span.end.end(),
602                        field.value.span().start.start(),
603                        &[":"],
604                    );
605                    body = body.append(trivia_doc(emit_gap(&colon, Spacing::CommaAfter, 0)));
606                    body = body.append(format_at(&field.value, source, depth, Level::RANGE));
607                    if let Some(next) = fields.get(index + 1) {
608                        let comma = grouping_gap(
609                            source,
610                            field.value.span().end.end(),
611                            next.name_span.start.start(),
612                            &[","],
613                        );
614                        body = body.append(gap_doc(&comma, Spacing::CommaAfter));
615                    }
616                }
617                let last = fields.last().expect("non-empty fields");
618                let mut tail = grouping_gap(
619                    source,
620                    last.value.span().end.end(),
621                    span.end.end(),
622                    &[",", "}"],
623                );
624                tail.retain(|piece| !matches!(piece, GapPiece::Punct(",")));
625                let closing = emit_gap(&tail, Spacing::Around, 0);
626                out = out
627                    .append(body.nest(4))
628                    .append(Doc::line())
629                    .append(trivia_doc(closing.trim().to_owned()))
630                    .group();
631            } else {
632                let tail = grouping_gap(source, name_span.end.end(), span.end.end(), &["{", "}"]);
633                out = out
634                    .append(" ")
635                    .append(trivia_doc(emit_gap(&tail, Spacing::None, 0)));
636            }
637            (out, Level::PRIMARY)
638        }
639        Expr::Literal { span, .. } => (verbatim_doc(render_literal(*span)), Level::PRIMARY),
640        // `AstContext::apply_op` records every arity-0 operator as a plain `Expr::Ident` (see
641        // its own test `apply_op_with_arity_zero_records_an_ident_node`) — `"range_full"` (`..`)
642        // is the only one this grammar has, so it's intercepted here rather than in the
643        // `Expr::Op` arms below, which a real parse never produces it as.
644        //
645        // Known gap (tracked as issue #155): this is indistinguishable from a genuine bare
646        // identifier reference literally named `range_full`, since `AstContext` deliberately
647        // does no scope resolution during parsing (unlike the runtime path, where
648        // `OpLookup::lookup` checks user scopes before builtins and so correctly lets a real
649        // variable named `range_full` shadow this operator). Narrow in practice — the other
650        // five range-family operators have no such collision, since a real function call is
651        // never represented as `Expr::Op`.
652        Expr::Ident { name, .. } if name == "range_full" => (Doc::text(".."), Level::RANGE),
653        Expr::Ident { name, .. } => (Doc::text(name.clone()), Level::PRIMARY),
654        Expr::Logical { op, lhs, rhs, .. } => {
655            let level = match op {
656                LogicalOp::Or => Level::OR,
657                LogicalOp::And => Level::AND,
658            };
659            let op_static: &'static str = match op {
660                LogicalOp::Or => "||",
661                LogicalOp::And => "&&",
662            };
663            let lhs_s = format_at(lhs, source, depth, level);
664            let rhs_s = format_at(rhs, source, depth, level.tighter());
665            let pieces = gap_between(source, lhs.span().end, rhs.span().start, &[op_static]);
666            (binary_doc(lhs_s, &pieces, rhs_s), level)
667        }
668        Expr::Op { operands, .. } if operands.is_empty() => {
669            // Defensive only: a real parse never produces an arity-0 `Expr::Op` (see the
670            // `Expr::Ident` arm above for how `"range_full"` actually arrives), but a hand-built
671            // tree using this representation still round-trips correctly, matching this file's
672            // existing convention of also handling non-parser-producible tree shapes (e.g.
673            // `nested_comparison_needs_parens_on_both_sides`).
674            (Doc::text(".."), Level::RANGE)
675        }
676        Expr::Op {
677            name,
678            operands,
679            span,
680        } if operands.len() == 1
681            && matches!(
682                name.as_str(),
683                "range_from" | "range_to" | "range_to_inclusive"
684            ) =>
685        {
686            // Range's own endpoints are always `or_expression`s (never chained further range
687            // expressions — see `is_range_expression`'s doc comment), so an operand only needs
688            // rendering strictly tighter than Range itself, the same non-chaining treatment
689            // `Level::COMPARISON` gets below.
690            let operand_s = format_at(&operands[0], source, depth, Level::RANGE.tighter());
691            let text = match name.as_str() {
692                "range_from" => {
693                    let pieces = gap_between(source, operands[0].span().end, span.end, &[".."]);
694                    operand_s.append(gap_doc(&pieces, Spacing::None))
695                }
696                "range_to" => {
697                    // `span.start` is the `".."` token's own span; a `gap_between` call always
698                    // scans strictly *after* its `prev_end` argument's end, so `".."` itself can
699                    // never appear inside the scanned gap here (unlike the operand-then-operator
700                    // arms below, where the operator genuinely sits inside the scanned range) —
701                    // it's rendered directly instead, and only a comment is scanned for.
702                    let pieces = gap_between(source, span.start, operands[0].span().start, &[]);
703                    Doc::text("..")
704                        .append(gap_doc(&pieces, Spacing::None))
705                        .append(operand_s)
706                }
707                "range_to_inclusive" => {
708                    let pieces = gap_between(source, span.start, operands[0].span().start, &[]);
709                    Doc::text("..=")
710                        .append(gap_doc(&pieces, Spacing::None))
711                        .append(operand_s)
712                }
713                _ => unreachable!("guarded by the outer match arm"),
714            };
715            (text, Level::RANGE)
716        }
717        Expr::Op {
718            name,
719            operands,
720            span,
721        } if operands.len() == 1 => {
722            let operand_s = format_at(&operands[0], source, depth, Level::UNARY);
723            let op_static = unary_op_token(name);
724            // Same reasoning as the `range_to`/`range_to_inclusive` arm above: the operator
725            // precedes the operand, so it can never appear inside the scanned gap; only a
726            // comment is scanned for, and the operator is rendered directly.
727            let pieces = gap_between(source, span.start, operands[0].span().start, &[]);
728            // Preserve the existing "- -1" disambiguating space when there are no comments.
729            let default_sep = if matches!(&operands[0], Expr::Op { name, operands, .. }
730                if operands.len() == 1 && matches!(name.as_str(), "-" | "!"))
731            {
732                " "
733            } else {
734                ""
735            };
736            let gap = if pieces.iter().any(|p| matches!(p, GapPiece::Comment(_))) {
737                emit_gap(&pieces, Spacing::None, 0)
738            } else {
739                default_sep.to_string()
740            };
741            (
742                Doc::text(op_static)
743                    .append(trivia_doc(gap))
744                    .append(operand_s),
745                Level::UNARY,
746            )
747        }
748        Expr::Op { name, operands, .. } if name == "range" || name == "range_inclusive" => {
749            // Same non-chaining reasoning as the arity-1 range arm above: both endpoints render
750            // strictly tighter than Range.
751            let lhs_s = format_at(&operands[0], source, depth, Level::RANGE.tighter());
752            let rhs_s = format_at(&operands[1], source, depth, Level::RANGE.tighter());
753            let op_static: &'static str = if name == "range_inclusive" {
754                "..="
755            } else {
756                ".."
757            };
758            let pieces = gap_between(
759                source,
760                operands[0].span().end,
761                operands[1].span().start,
762                &[op_static],
763            );
764            (
765                lhs_s
766                    .append(
767                        Doc::softline_()
768                            .append(gap_doc(&pieces, Spacing::None))
769                            .append(rhs_s)
770                            .nest(4),
771                    )
772                    .group(),
773                Level::RANGE,
774            )
775        }
776        Expr::Op { name, operands, .. } => {
777            let level = binary_op_level(name);
778            // Comparison can't chain — the grammar parses at most one per comparison_expression —
779            // so both operands must be strictly tighter than Comparison itself, unlike the other,
780            // left-associative (chaining) binary levels below, where only the right operand does.
781            let (lhs_min, rhs_min) = if level == Level::COMPARISON {
782                (level.tighter(), level.tighter())
783            } else {
784                (level, level.tighter())
785            };
786            let lhs_s = format_at(&operands[0], source, depth, lhs_min);
787            let rhs_s = format_at(&operands[1], source, depth, rhs_min);
788            // The operator token itself has no span; it lives in the gap between the two operands.
789            let op_static = binary_op_token(name);
790            let pieces = gap_between(
791                source,
792                operands[0].span().end,
793                operands[1].span().start,
794                &[op_static],
795            );
796            (binary_doc(lhs_s, &pieces, rhs_s), level)
797        }
798        Expr::Cast {
799            expr,
800            type_name,
801            span,
802        } => {
803            // Left-associative, like multiplicative/additive: the operand only needs to be at
804            // least as tight as Cast itself, so a chain like `x as i32 as f64` reprints without
805            // extra parens.
806            let expr_s = format_at(expr, source, depth, Level::CAST);
807            // The type name has no span of its own, so the gap scanned here runs from the operand's
808            // end through the whole cast node's end (which includes both `as` and the type name);
809            // only `"as"` is expected inside it, and the type name is appended afterward.
810            let pieces = gap_between(source, expr.span().end, span.end, &["as"]);
811            let text = binary_doc(expr_s, &pieces, Doc::text(type_name.clone()));
812            (text, Level::CAST)
813        }
814        Expr::Apply { callee, args, span } => {
815            let mut callee_s = format_postfix_base(callee, source, depth, span.start.start());
816            // Without parentheses a bare member followed by `()` becomes direct dispatch,
817            // rather than an ordinary call of the member's result.
818            if matches!(callee.as_ref(), Expr::Member { args: None, .. }) {
819                callee_s = Doc::text("(").append(callee_s).append(")");
820            }
821            let tail = grouping_gap(source, callee.span().end.end(), span.end.start(), &[]);
822            callee_s = callee_s.append(gap_doc(&tail, Spacing::None));
823            // The call's end span covers the complete argument group, so neither boundary
824            // includes closing callee groups omitted from the AST.
825            let list = emit_list(
826                source,
827                depth,
828                ("(", ")"),
829                (span.end.start(), span.end.end()),
830                args,
831                false,
832            );
833            (callee_s.append(list), Level::POSTFIX)
834        }
835        Expr::Member {
836            receiver,
837            name,
838            name_span,
839            args,
840            span,
841        } => {
842            let receiver_s = format_postfix_base(receiver, source, depth, span.start.start());
843            let gap = member_gap(source, receiver.span().end, name_span.start);
844            let member_gap = if gap.iter().all(|piece| matches!(piece, GapPiece::Punct(_))) {
845                Doc::line_().append(".")
846            } else if gap
847                .iter()
848                .any(|piece| matches!(piece, GapPiece::Comment(Comment::Line(_))))
849            {
850                trivia_doc_at(emit_gap(&gap, Spacing::None, 0), 0)
851            } else {
852                verbatim_doc(emit_gap(&gap, Spacing::None, 0))
853            };
854            let mut text = receiver_s
855                .append(member_gap.append(Doc::text(name.clone())).nest(4))
856                .group();
857            if let Some(args) = args {
858                text = text.append(emit_list(
859                    source,
860                    depth,
861                    ("(", ")"),
862                    (name_span.end.end(), span.end.end()),
863                    args,
864                    false,
865                ));
866            }
867            (text, Level::POSTFIX)
868        }
869        Expr::Tuple { elements, span } => {
870            // `span.start` and `span.end` are both the same whole-group span here (see
871            // `emit_list`'s doc comment): `.start()` is the position of `(`, `.end()` the
872            // position right after `)`.
873            let list = emit_list(
874                source,
875                depth,
876                ("(", ")"),
877                (span.start.start(), span.end.end()),
878                elements,
879                elements.len() == 1,
880            );
881            (list, Level::PRIMARY)
882        }
883        Expr::Array {
884            elements,
885            annotation_span,
886            type_annotation,
887            span,
888        } => {
889            // `span.start` is the `[` token. The closing `]` sits either at `span.end` for an
890            // unannotated array or immediately before `annotation_span` when a `: Type`
891            // ascription follows. Array literals never take a trailing comma (the grammar is
892            // LL(1) and rejects one outright), unlike the one-tuple special case above.
893            let close_pos = annotation_span
894                .map(|annotation_span| annotation_span.start.start())
895                .unwrap_or_else(|| span.end.end());
896            let mut list = emit_list(
897                source,
898                depth,
899                ("[", "]"),
900                (span.start.start(), close_pos),
901                elements,
902                false,
903            );
904            if let Some(type_annotation) = type_annotation {
905                list = list
906                    .append(": ")
907                    .append(Doc::text(render_type_expr(type_annotation)));
908            }
909            (list, Level::PRIMARY)
910        }
911        Expr::TupleIndex { base, index, span } => (
912            format_postfix_base(base, source, depth, span.start.start())
913                .append(Doc::text(format!(".{index}"))),
914            Level::POSTFIX,
915        ),
916        Expr::If {
917            cond,
918            then_branch,
919            else_branch,
920            span,
921        } => {
922            // `if` <cond> `{` <then_branch> `}` [ `else` ( `{` <else_branch> `}` | <else-if> ) ].
923            // None of `if`/`{`/`}`/`else` carry their own span (each is a fixed keyword/delimiter
924            // consumed structurally by the parser, never recorded as its own node), so every gap
925            // between two real spans is scanned for its expected fixed token(s) alongside any
926            // interleaved comment. `Spacing::Around` is used throughout (rather than `None`) so
927            // that a gap holding more than one fixed token (`"}"`, `"else"`, and — for a braced
928            // else — `"{"`) still gets a single space between each token and the next, matching
929            // the pre-`scan_gap` literal `" } else { "` spacing exactly in the comment-free case.
930            let cond_s = restricted_doc(
931                cond,
932                source,
933                depth,
934                crate::ExpressionRestriction::NoStructLiteral,
935            );
936            let then_s = format_at(then_branch, source, depth, Level::OR);
937            let if_gap = grouping_gap(source, span.start.start(), header_start(cond), &["if"]);
938            let open_gap = grouping_gap(
939                source,
940                header_end(cond),
941                then_branch.span().start.start(),
942                &["{"],
943            );
944            let mut text = Doc::nil();
945            // Only the very first gap is `trim_start`'d: it opens the whole node's text, so any
946            // leading space `Spacing::Around` would otherwise add before `"if"` must be dropped.
947            text = text.append(trivia_doc(
948                emit_gap(&if_gap, Spacing::Around, 0)
949                    .trim_start()
950                    .to_owned(),
951            ));
952            text = text.append(cond_s);
953            text = text.append(body_gap_doc(&open_gap, false).append(then_s).nest(4));
954            match else_branch {
955                None => {
956                    // No `else`: the gap between the then-branch and the node's own end holds
957                    // just the closing `}` (plus any comment before it).
958                    let close_gap = gap_between(source, then_branch.span().end, span.end, &["}"]);
959                    // `trim_end`'d because this is the node's last piece of text: a trailing
960                    // space here would otherwise survive into the caller's output (or, wrapped in
961                    // parens by `format_at`, land right before the close-paren).
962                    text = text.append(Doc::line()).append(trivia_doc(
963                        emit_gap(&close_gap, Spacing::Around, 0).trim().to_owned(),
964                    ));
965                }
966                Some(else_branch) => {
967                    let is_chain = matches!(else_branch.as_ref(), Expr::If { .. });
968                    // An `else if` chain has no braces of its own around the nested `if`; a plain
969                    // `else` block does.
970                    let expected: &[&'static str] = if is_chain {
971                        &["}", "else"]
972                    } else {
973                        &["}", "else", "{"]
974                    };
975                    // One scan across the whole then-branch-to-else-branch gap (rather than one
976                    // scan per token) so a comment sitting anywhere in it -- before `}`, between
977                    // `}` and `else`, or between `else` and `{` -- is recovered in source order.
978                    let else_gap = gap_between(
979                        source,
980                        then_branch.span().end,
981                        else_branch.span().start,
982                        expected,
983                    );
984                    text = text.append(Doc::line());
985                    if is_chain {
986                        // The nested `if` recurses through `render`, which lays out its own
987                        // `if`/`{`/`}`/`else` gaps (including its own trailing `trim_end`), so
988                        // nothing further is appended here.
989                        let (else_s, _) = render(else_branch, source, depth);
990                        text = text.append(body_gap_doc(&else_gap, true)).append(else_s);
991                    } else {
992                        let else_s = format_at(else_branch, source, depth, Level::OR);
993                        text = text.append(body_gap_doc(&else_gap, true).append(else_s).nest(4));
994                        let final_close_gap =
995                            gap_between(source, else_branch.span().end, span.end, &["}"]);
996                        text = text.append(Doc::line()).append(trivia_doc(
997                            emit_gap(&final_close_gap, Spacing::Around, 0)
998                                .trim()
999                                .to_owned(),
1000                        ));
1001                    }
1002                }
1003            }
1004            (text.group(), Level::PRIMARY)
1005        }
1006        Expr::Closure { params, body, span } => {
1007            let body_s = format_at(body, source, depth, Level::OR);
1008            let header = if params.is_empty() {
1009                Doc::text("||")
1010            } else {
1011                let leading = gap_between(source, span.start, params[0].name_span.start, &[]);
1012                let mut parameters =
1013                    trivia_doc(emit_gap(&leading, Spacing::None, 0).trim_start().to_owned());
1014                for (index, param) in params.iter().enumerate() {
1015                    if index > 0 {
1016                        let comma = gap_between(
1017                            source,
1018                            params[index - 1].type_expr.span().end,
1019                            param.name_span.start,
1020                            &[","],
1021                        );
1022                        parameters = parameters.append(gap_doc(&comma, Spacing::CommaAfter));
1023                    }
1024                    let colon = gap_between(
1025                        source,
1026                        param.name_span.end,
1027                        param.type_expr.span().start,
1028                        &[":"],
1029                    );
1030                    parameters = parameters
1031                        .append(Doc::text(param.name.clone()))
1032                        .append(trivia_doc(emit_gap(&colon, Spacing::CommaAfter, 0)))
1033                        .append(Doc::text(render_type_expr(&param.type_expr)));
1034                }
1035                Doc::text("|")
1036                    .append(Doc::line_().append(parameters).nest(4))
1037                    .append(Doc::line_())
1038                    .append("|")
1039                    .group()
1040            };
1041            let (tail_start, expected): (proc_macro2::Span, &[&'static str]) = match params.last() {
1042                Some(last) => (last.type_expr.span().end, &["|"]),
1043                None => (span.start, &[]),
1044            };
1045            let pieces = gap_between(source, tail_start, body.span().start, expected);
1046            let comments: Vec<GapPiece> = pieces
1047                .into_iter()
1048                .filter(|p| matches!(p, GapPiece::Comment(_)))
1049                .collect();
1050            let gap = emit_gap(&comments, Spacing::None, 0);
1051            // A `//` comment among `comments` made `emit_gap` end `gap` with a line wrap (`\n` +
1052            // continuation indent); the body must continue right there, so `gap` is used verbatim
1053            // (already correctly spaced/wrapped) rather than trimmed — trimming would collapse the
1054            // wrap and glue the body onto the comment's own line, commenting it out.
1055            let has_line_comment = comments
1056                .iter()
1057                .any(|p| matches!(p, GapPiece::Comment(Comment::Line(_))));
1058            let sep = if has_line_comment {
1059                trivia_doc_at(gap, 0)
1060            } else if gap.trim().is_empty() {
1061                Doc::line()
1062            } else {
1063                trivia_doc(format!(" {} ", gap.trim()))
1064            };
1065            // A closure's body greedily consumes expressions, including following postfixes.
1066            // Its primary-expression grammar position does not make it a tight-binding value.
1067            (
1068                header.append(sep.append(body_s).nest(4)).group(),
1069                Level::RANGE,
1070            )
1071        }
1072    }
1073}
1074
1075/// Returns the first rendered header token's position, excluding omitted operand groups.
1076///
1077/// Prefix/postfix operators and explicit expression delimiters retain their starting positions;
1078/// postfix rendering owns any leading receiver-group trivia.
1079///
1080/// - Complexity: O(h) in the visited leftmost AST path, with O(h) stack space.
1081fn header_start(expr: &Expr) -> proc_macro2::LineColumn {
1082    match expr {
1083        Expr::Op { name, operands, .. }
1084            if operands.len() == 2 || (name == "range_from" && operands.len() == 1) =>
1085        {
1086            header_start(&operands[0])
1087        }
1088        Expr::Logical { lhs, .. } => header_start(lhs),
1089        Expr::Cast { expr, .. } => header_start(expr),
1090        _ => expr.span().start.start(),
1091    }
1092}
1093
1094/// Returns the last rendered header token's position, excluding omitted operand/body groups.
1095///
1096/// Postfix operators and explicit expression delimiters retain their own ending positions.
1097///
1098/// - Complexity: O(h) in the visited rightmost AST path, with O(h) stack space.
1099fn header_end(expr: &Expr) -> proc_macro2::LineColumn {
1100    match expr {
1101        Expr::Op { name, operands, .. } if name != "range_from" && !operands.is_empty() => {
1102            header_end(operands.last().expect("non-empty operands"))
1103        }
1104        Expr::Logical { rhs, .. } => header_end(rhs),
1105        Expr::Closure { body, .. } => header_end(body),
1106        _ => expr.span().end.end(),
1107    }
1108}
1109
1110/// Recovers a field or header gap across grouping parentheses omitted from the AST.
1111///
1112/// - Precondition: positions refer to `source`, or `source` is empty.
1113/// - Postcondition: comments and expected tokens retain source order without grouping tokens.
1114/// - Complexity: O(n) in source length and gap size.
1115fn grouping_gap(
1116    source: &str,
1117    from: proc_macro2::LineColumn,
1118    to: proc_macro2::LineColumn,
1119    expected: &[&'static str],
1120) -> Vec<GapPiece> {
1121    let starts = line_start_byte_offsets(source);
1122    let gap = if source.is_empty() {
1123        ""
1124    } else {
1125        let from = line_column_to_byte(source, &starts, from);
1126        let to = line_column_to_byte(source, &starts, to);
1127        source.get(from..to).unwrap_or("")
1128    };
1129    let mut rest = gap;
1130    let mut tokens = Vec::new();
1131    let mut next = 0;
1132    loop {
1133        rest = rest.trim_start();
1134        if let Some(after) = rest.strip_prefix("//") {
1135            rest = &after[after.find('\n').unwrap_or(after.len())..];
1136        } else if let Some((_, after)) = take_block_comment(rest) {
1137            rest = after;
1138        } else if let Some(after) = rest.strip_prefix('(') {
1139            tokens.push("(");
1140            rest = after;
1141        } else if let Some(after) = rest.strip_prefix(')') {
1142            tokens.push(")");
1143            rest = after;
1144        } else if next < expected.len() && rest.starts_with(expected[next]) {
1145            tokens.push(expected[next]);
1146            rest = &rest[expected[next].len()..];
1147            next += 1;
1148        } else {
1149            break;
1150        }
1151    }
1152    tokens.extend_from_slice(&expected[next..]);
1153    scan_gap(gap, &tokens)
1154        .into_iter()
1155        .filter(|piece| !matches!(piece, GapPiece::Punct("(" | ")")))
1156        .collect()
1157}
1158
1159/// Detects record syntax reached without an explicit expression boundary in a header.
1160///
1161/// - Complexity: O(n) in the number of visited AST nodes.
1162fn header_contains_record(expr: &Expr) -> bool {
1163    match expr {
1164        Expr::Struct { .. } => true,
1165        Expr::Op { operands, .. } => operands.iter().any(header_contains_record),
1166        Expr::Logical { lhs, rhs, .. } => {
1167            header_contains_record(lhs) || header_contains_record(rhs)
1168        }
1169        Expr::Cast { expr, .. } | Expr::Closure { body: expr, .. } => header_contains_record(expr),
1170        Expr::Member { receiver, .. } => header_contains_record(receiver),
1171        Expr::TupleIndex { base, .. } => header_contains_record(base),
1172        Expr::Apply { callee, .. } => header_contains_record(callee),
1173        Expr::Literal { .. }
1174        | Expr::Ident { .. }
1175        | Expr::Tuple { .. }
1176        | Expr::Array { .. }
1177        | Expr::If { .. } => false,
1178    }
1179}
1180
1181/// Formats an embedded expression while preserving a header's brace reservation.
1182///
1183/// Groups the complete header when an otherwise unparenthesized record would be ambiguous.
1184/// Ordinary delimiters and nested branch bodies retain their normal formatting.
1185///
1186/// - Precondition: `source` is the parsed source, or empty for a hand-built AST.
1187/// - Complexity: O(n) document space plus layout and existing source-gap scans.
1188///
1189/// # Examples
1190/// ```
1191/// use cel_parser::{AstContext, ExpressionRestriction, OpLookup, Parser, format_expr_restricted};
1192/// let source = "(Point { x: 1.0 }).x";
1193/// let ast = Parser::<AstContext>::new(OpLookup::new()).parse_str_ast(source).unwrap();
1194/// let text = format_expr_restricted(&ast, source, 0, ExpressionRestriction::NoStructLiteral);
1195/// assert_eq!(text, "(Point { x: 1.0 }.x)");
1196/// ```
1197pub fn format_expr_restricted(
1198    expr: &Expr,
1199    source: &str,
1200    depth: usize,
1201    restriction: crate::ExpressionRestriction,
1202) -> String {
1203    render_document(
1204        restricted_doc(expr, source, depth, restriction),
1205        depth,
1206        depth * 4,
1207        100,
1208        0,
1209    )
1210}
1211
1212/// Builds a brace-reserving expression without independently laying out its children.
1213///
1214/// - Complexity: O(n) document construction plus existing source-gap scans.
1215fn restricted_doc(
1216    expr: &Expr,
1217    source: &str,
1218    depth: usize,
1219    restriction: crate::ExpressionRestriction,
1220) -> Doc {
1221    let text = format_at(expr, source, depth, Level::RANGE);
1222    if restriction == crate::ExpressionRestriction::NoStructLiteral && header_contains_record(expr)
1223    {
1224        Doc::text("(").append(text).append(")")
1225    } else {
1226        text
1227    }
1228}
1229
1230/// Formats a brace-reserving header and its surrounding comments within explicit boundaries.
1231///
1232/// `bounds` runs from just after the introducing keyword to just before the body brace.
1233/// Grouping parentheses omitted from the AST do not interrupt comment recovery.
1234///
1235/// - Precondition: `source` is the parsed source and `bounds` encloses `expr`, or source is empty.
1236/// - Postcondition: boundary comments appear exactly once in source order.
1237/// - Complexity: O(n) document space plus layout and existing source-gap scans.
1238///
1239/// # Examples
1240/// ```
1241/// use cel_parser::{AstContext, OpLookup, Parser, format_expr_header};
1242/// let source = "(Point { x: 1.0 }) /* keep */";
1243/// let expr = Parser::<AstContext>::new(OpLookup::new()).parse_str_ast(source).unwrap();
1244/// let text = format_expr_header(&expr, source, 0, (
1245///     proc_macro2::LineColumn { line: 1, column: 0 },
1246///     proc_macro2::LineColumn { line: 1, column: source.len() },
1247/// ));
1248/// assert_eq!(text, "(Point { x: 1.0 }) /* keep */");
1249/// ```
1250pub fn format_expr_header(
1251    expr: &Expr,
1252    source: &str,
1253    depth: usize,
1254    bounds: (proc_macro2::LineColumn, proc_macro2::LineColumn),
1255) -> String {
1256    format_expr_header_with_layout(expr, source, depth, bounds, depth * 4, 100, 0)
1257}
1258
1259/// Formats a brace-reserving header at `column`, reserving `suffix_columns` on its last line.
1260///
1261/// The target width includes the already-written prefix and following punctuation. Continuations
1262/// nest four spaces from `depth`; unbreakable tokens and comments may exceed `width`.
1263///
1264/// - Precondition: `source` is the parsed source and `bounds` encloses `expr`, or source is empty.
1265/// - Complexity: O(n) document space plus layout and existing source-gap scans.
1266///
1267/// # Examples
1268/// ```
1269/// use cel_parser::{AstContext, OpLookup, Parser, format_expr_header_with_layout, LineColumn};
1270/// let source = "value";
1271/// let expr = Parser::<AstContext>::new(OpLookup::new()).parse_str_ast(source).unwrap();
1272/// assert_eq!(format_expr_header_with_layout(&expr, source, 1,
1273///     (LineColumn { line: 1, column: 0 }, LineColumn { line: 1, column: 5 }),
1274///     16, 100, 2), "value");
1275/// ```
1276pub fn format_expr_header_with_layout(
1277    expr: &Expr,
1278    source: &str,
1279    depth: usize,
1280    bounds: (proc_macro2::LineColumn, proc_macro2::LineColumn),
1281    column: usize,
1282    width: usize,
1283    suffix_columns: usize,
1284) -> String {
1285    let leading = grouping_gap(source, bounds.0, header_start(expr), &[]);
1286    let trailing = grouping_gap(source, header_end(expr), bounds.1, &[]);
1287    let mut text = trivia_doc(emit_gap(&leading, Spacing::None, 0).trim_start().to_owned());
1288    text = text.append(restricted_doc(
1289        expr,
1290        source,
1291        depth,
1292        crate::ExpressionRestriction::NoStructLiteral,
1293    ));
1294    let tail = emit_gap(&trailing, Spacing::None, 0);
1295    if matches!(trailing.last(), Some(GapPiece::Comment(Comment::Line(_)))) {
1296        text = text.append(trivia_doc(tail));
1297    } else {
1298        text = text.append(trivia_doc(tail.trim_end().to_owned()));
1299    }
1300    render_document(text, depth, column, width, suffix_columns)
1301}
1302
1303/// Renders `expr`, wrapping it in parens if its own level is looser than `min_level` requires.
1304///
1305/// `source` and `depth` are forwarded to [`render`] unchanged; see its doc comment.
1306///
1307/// - Complexity: O(n) document space plus existing source-gap scan costs.
1308fn format_at(expr: &Expr, source: &str, depth: usize, min_level: Level) -> Doc {
1309    let (text, level) = render(expr, source, depth);
1310    if level < min_level {
1311        Doc::text("(").append(text).append(")").group()
1312    } else {
1313        text
1314    }
1315}
1316
1317/// Pretty-prints `expr` back to CEL source text — see the module doc for the printing rules.
1318///
1319/// `source` is the text `expr` was parsed from (or `""` for a hand-built `Expr`); `depth` supplies
1320/// the base indentation for wrapped continuations and the assumed initial output column.
1321/// Postfix bases retain leading group comments, and member-dot comments survive omitted groups.
1322/// Closure bases stay grouped so following postfixes cannot move into their bodies.
1323///
1324/// - Complexity: O(n) document space plus layout and existing source-gap scans.
1325///
1326/// # Examples
1327///
1328/// ```
1329/// use cel_parser::{AstContext, OpLookup, Parser, format_expr};
1330///
1331/// let mut parser = Parser::<AstContext>::new(OpLookup::new());
1332/// let expr = parser.parse_str_ast("(1i32 + 2i32) * 3i32").unwrap();
1333/// assert_eq!(format_expr(&expr, "(1i32 + 2i32) * 3i32", 0), "(1i32 + 2i32) * 3i32");
1334/// ```
1335pub fn format_expr(expr: &Expr, source: &str, depth: usize) -> String {
1336    format_expr_with_layout(expr, source, depth, depth * 4, 100, 0)
1337}
1338
1339/// Formats an expression at the actual output `column` with a target `width`.
1340///
1341/// Reserves `suffix_columns` for following punctuation on the final line. Short expressions remain
1342/// inline. Lists, records and closure bodies nest four spaces; binary continuations lead with their
1343/// operator. Unbreakable tokens and comments may exceed the target. The result excludes the prefix,
1344/// suffix and initial indentation; continuation indentation starts at `depth * 4`.
1345/// When an indented continuation has more room and the first fragment cannot fit after the prefix,
1346/// the expression starts on a new line indented four spaces beyond `depth`.
1347///
1348/// - Precondition: `source` is the expression's parsed source, or empty for a hand-built AST.
1349/// - Complexity: O(n) document space plus layout and existing source-gap scans.
1350///
1351/// # Examples
1352/// ```
1353/// use cel_parser::{AstContext, OpLookup, Parser, format_expr_with_layout};
1354/// let source = "first + second";
1355/// let expr = Parser::<AstContext>::new(OpLookup::new()).parse_str_ast(source).unwrap();
1356/// assert_eq!(format_expr_with_layout(&expr, source, 1, 12, 26, 1),
1357///     "first\n        + second");
1358/// ```
1359pub fn format_expr_with_layout(
1360    expr: &Expr,
1361    source: &str,
1362    depth: usize,
1363    column: usize,
1364    width: usize,
1365    suffix_columns: usize,
1366) -> String {
1367    render_document(
1368        format_at(expr, source, depth, Level::RANGE),
1369        depth,
1370        column,
1371        width,
1372        suffix_columns,
1373    )
1374}
1375
1376/// Lays out one complete expression document, accounting for both embedding boundaries.
1377///
1378/// - Complexity: O(n + column + suffix_columns) space; layout uses the document library's group
1379///   fit traversal and never renders subtrees to measure them.
1380fn render_document(
1381    doc: Doc,
1382    depth: usize,
1383    column: usize,
1384    width: usize,
1385    suffix_columns: usize,
1386) -> String {
1387    let prefix = " ".repeat(column);
1388    let suffix = " ".repeat(suffix_columns);
1389    let doc = if column > depth * 4 + 4 {
1390        // Group only the boundary: fitting inspects the following document in broken mode,
1391        // stopping at its first existing break instead of demanding a flat whole expression.
1392        Doc::hardline()
1393            .append("    ")
1394            .flat_alt(Doc::nil())
1395            .group()
1396            .append(Doc::column(move |current| {
1397                if current == column {
1398                    doc.clone()
1399                } else {
1400                    doc.clone().nest(4)
1401                }
1402            }))
1403    } else {
1404        doc
1405    };
1406    let document = Doc::text(prefix)
1407        .append(doc.nest((depth * 4) as isize))
1408        .append(Doc::text(suffix));
1409    let mut out = String::new();
1410    document
1411        .render_fmt(width, &mut out)
1412        .expect("writing to String is infallible");
1413    out.truncate(out.len() - suffix_columns);
1414    out.drain(..column);
1415    out
1416}
1417
1418#[cfg(test)]
1419mod tests {
1420    use super::*;
1421    use crate::{AstContext, OpLookup, Parser};
1422
1423    /// Long binary expressions wrap before their operators and reparse unchanged.
1424    #[test]
1425    fn wraps_long_expressions_with_leading_operators() {
1426        let source = std::iter::repeat_n("long_operand_name", 8)
1427            .collect::<Vec<_>>()
1428            .join(" + ");
1429        let text = fmt(&source);
1430        assert!(text.contains("\n    + "), "{text}");
1431        assert!(text.lines().all(|line| line.len() <= 100), "{text}");
1432        assert_eq!(fmt(&text), text);
1433    }
1434
1435    /// Binary ranges retain compact spelling and expose a zero-width leading-operator boundary.
1436    #[test]
1437    fn wraps_binary_ranges_before_their_operators() {
1438        for operator in ["..", "..="] {
1439            let source = format!("{}{}{}", "a".repeat(45), operator, "b".repeat(45));
1440            let expr = parse(&source);
1441            let text = format_expr_with_layout(&expr, &source, 1, 13, 100, 1);
1442            assert_eq!(
1443                text,
1444                format!("{}\n        {operator}{}", "a".repeat(45), "b".repeat(45))
1445            );
1446            assert_eq!(
1447                format_expr_with_layout(&parse(&text), &text, 1, 13, 100, 1),
1448                text
1449            );
1450            assert_eq!(
1451                fmt(&format!("left{operator}right")),
1452                format!("left{operator}right")
1453            );
1454            let call = format!("call({source})");
1455            let formatted = fmt(&call);
1456            assert!(
1457                formatted.lines().all(|line| line.len() <= 100),
1458                "{formatted}"
1459            );
1460            assert_eq!(fmt(&formatted), formatted);
1461        }
1462    }
1463
1464    /// An empty closure's header can move with its body when the embedding prefix fills the line.
1465    #[test]
1466    fn empty_closure_can_break_before_its_header() {
1467        let source = "|| predicate";
1468        let expr = parse(source);
1469        let text = format_expr_with_layout(&expr, source, 1, 100, 100, 1);
1470        assert_eq!(text, "\n        || predicate");
1471        assert_eq!(
1472            format_expr_with_layout(&parse(&text), &text, 1, 100, 100, 1),
1473            text
1474        );
1475    }
1476
1477    /// Nested grammar-owned lists break without introducing unsupported trailing commas.
1478    #[test]
1479    fn wraps_nested_calls_records_arrays_and_closures() {
1480        let name = "a_long_argument_name_that_occupies_thirty_columns";
1481        for source in [
1482            format!("outer(inner({name}, {name}), [{name}, {name}])"),
1483            format!("Record {{ first: ({name}, {name}), second: [{name}, {name}] }}"),
1484            format!("map([{name}, {name}], |value: i32| value + {name} + {name})"),
1485        ] {
1486            let text = fmt(&source);
1487            assert!(text.contains('\n'), "{text}");
1488            assert!(text.lines().all(|line| line.len() <= 100), "{text}");
1489            assert_eq!(fmt(&text), text);
1490        }
1491    }
1492
1493    /// Parameter lists wrap and retain comments on both sides of their separators.
1494    #[test]
1495    fn wraps_closure_parameters_without_losing_comments() {
1496        let source = "|/* first */ a_long_parameter: i32 /* second */, another_long_parameter: i32, third_long_parameter: i32, fourth_long_parameter: i32| a_long_parameter";
1497        let text = fmt(source);
1498        for label in ["first", "second"] {
1499            assert_eq!(text.matches(label).count(), 1, "{text}");
1500        }
1501        assert!(text.lines().all(|line| line.len() <= 100), "{text}");
1502        assert_eq!(fmt(&text), text);
1503    }
1504
1505    /// Block comments do not disable leading-operator break opportunities.
1506    #[test]
1507    fn wraps_commented_binary_gaps() {
1508        let operand = "a".repeat(45);
1509        let source = format!("{operand} /* keep */ + {operand} + {operand}");
1510        let text = fmt(&source);
1511        assert!(text.lines().all(|line| line.len() <= 100), "{text}");
1512        assert_eq!(text.matches("keep").count(), 1);
1513        assert_eq!(fmt(&text), text);
1514    }
1515
1516    /// Consecutive line comments keep separate mandatory line boundaries.
1517    #[test]
1518    fn consecutive_line_comments_remain_separate() {
1519        let text = fmt("1 + // first\n// second\n2");
1520        assert_eq!(text, "1 + // first\n    // second\n    2");
1521        assert_eq!(fmt(&text), text);
1522    }
1523
1524    /// A comment after an opening brace cannot consume the first field or branch expression.
1525    #[test]
1526    fn brace_opening_line_comments_keep_their_mandatory_breaks() {
1527        for source in [
1528            "Record { // first\n value: 1 }",
1529            "if condition { // first\n 1 } else { // second\n 2 }",
1530        ] {
1531            let text = fmt(source);
1532            assert!(text.contains("// first\n"), "{text}");
1533            assert_eq!(fmt(&text), text);
1534        }
1535    }
1536
1537    /// Width includes the actual prefix and suffix, not an independently reduced child budget.
1538    #[test]
1539    fn embedding_reserves_exact_prefix_and_suffix_columns() {
1540        let source = "first + second";
1541        let expr = parse(source);
1542        assert_eq!(format_expr_with_layout(&expr, source, 1, 12, 27, 1), source);
1543        assert_eq!(
1544            format_expr_with_layout(&expr, source, 1, 12, 26, 1),
1545            "first\n        + second"
1546        );
1547        let bounds = (
1548            proc_macro2::LineColumn { line: 1, column: 0 },
1549            proc_macro2::LineColumn {
1550                line: 1,
1551                column: source.len(),
1552            },
1553        );
1554        assert_eq!(
1555            format_expr_header_with_layout(&expr, source, 1, bounds, 12, 28, 2),
1556            source
1557        );
1558        assert_eq!(
1559            format_expr_header_with_layout(&expr, source, 1, bounds, 12, 27, 2),
1560            "first\n        + second"
1561        );
1562    }
1563
1564    /// Literal-internal newlines retain their spelling rather than inheriting expression nesting.
1565    #[test]
1566    fn multiline_literal_spelling_is_independent_of_layout_nesting() {
1567        let source = "call(r#\"first\n  second\"#, 0xffi32)";
1568        let expr = parse(source);
1569        let text = format_expr_with_layout(&expr, source, 2, 20, 30, 1);
1570        assert!(text.contains("r#\"first\n  second\"#"), "{text}");
1571        assert!(text.contains("0xffi32"));
1572        assert_eq!(
1573            format_expr_with_layout(&parse(&text), &text, 2, 20, 30, 1),
1574            text
1575        );
1576    }
1577
1578    /// Layout retains grammar, literal spelling, and precedence across every compound surface.
1579    #[test]
1580    fn narrow_layout_preserves_expression_semantics_and_reaches_a_fixed_point() {
1581        for source in [
1582            "-(first_long_name + second_long_name) as i32",
1583            "first_long_name - (second_long_name - third_long_name)",
1584            "(|value: i32| value + first_long_name)(second_long_name)",
1585            "(holder.long_member_name)(first_long_name, second_long_name)",
1586            "first_long_name..=second_long_name",
1587            "[first_long_name, second_long_name]: [i32]",
1588            "(first_long_name + second_long_name,)",
1589            "[(first_long_name, second_long_name), (third_long_name, fourth_long_name)]",
1590            "if (Record { value: first_long_name }).value { 0xffi32 } else { 1i32 }",
1591            "first_long_name /* keep\n block */ + second_long_name",
1592            "call(first_long_name, // keep line\n second_long_name)",
1593            "first_long_name + // first\n // second\n second_long_name",
1594        ] {
1595            let expr = parse(source);
1596            let text = format_expr_with_layout(&expr, source, 0, 0, 40, 0);
1597            let parsed = parse(&text);
1598            assert_eq!(
1599                format_expr_with_layout(&parsed, &text, 0, 0, 40, 0),
1600                text,
1601                "{source}"
1602            );
1603            assert_eq!(
1604                format_expr_with_layout(&parsed, &text, 0, 0, 10_000, 0),
1605                format_expr_with_layout(&expr, source, 0, 0, 10_000, 0),
1606                "{source} -> {text}"
1607            );
1608        }
1609    }
1610
1611    fn parse(source: &str) -> Expr {
1612        Parser::<AstContext>::new(OpLookup::new())
1613            .parse_str_ast(source)
1614            .unwrap()
1615    }
1616
1617    fn fmt(source: &str) -> String {
1618        format_expr(&parse(source), source, 0)
1619    }
1620
1621    #[test]
1622    fn additive_and_multiplicative_reprint_without_extra_parens() {
1623        assert_eq!(fmt("1i32 + 2i32 * 3i32"), "1i32 + 2i32 * 3i32");
1624    }
1625
1626    #[test]
1627    fn explicit_grouping_that_changes_precedence_keeps_its_parens() {
1628        assert_eq!(fmt("(1i32 + 2i32) * 3i32"), "(1i32 + 2i32) * 3i32");
1629    }
1630
1631    #[test]
1632    fn left_associative_chain_at_the_same_precedence_has_no_parens() {
1633        assert_eq!(fmt("1i32 - 2i32 - 3i32"), "1i32 - 2i32 - 3i32");
1634    }
1635
1636    #[test]
1637    fn a_right_leaning_tree_at_the_same_precedence_needs_parens() {
1638        // Not producible by real parsing (the grammar's additive_expression loop is always
1639        // left-associative) — built by hand to prove the printer round-trips a tree shape it
1640        // didn't itself produce. Uses Ident operands (rendered from `name`, not a span) so the
1641        // assertion reads as real text rather than the no-source-text fallback.
1642        fn ident(name: &str) -> Expr {
1643            Expr::Ident {
1644                name: name.to_string(),
1645                span: point(),
1646            }
1647        }
1648        fn point() -> crate::ExprSpan {
1649            crate::ExprSpan {
1650                start: proc_macro2::Span::call_site(),
1651                end: proc_macro2::Span::call_site(),
1652            }
1653        }
1654        let expr = Expr::Op {
1655            name: "-".to_string(),
1656            operands: vec![
1657                ident("a"),
1658                Expr::Op {
1659                    name: "-".to_string(),
1660                    operands: vec![ident("b"), ident("c")],
1661                    span: point(),
1662                },
1663            ],
1664            span: point(),
1665        };
1666        assert_eq!(format_expr(&expr, "", 0), "a - (b - c)");
1667    }
1668
1669    #[test]
1670    fn nested_comparison_needs_parens_on_both_sides() {
1671        // Also not producible by real parsing (comparison_expression allows at most one
1672        // comparison, never a nested one) — proves format_expr stays reparseable even for a
1673        // hand-built tree shape the grammar itself can't emit.
1674        fn ident(name: &str) -> Expr {
1675            Expr::Ident {
1676                name: name.to_string(),
1677                span: crate::ExprSpan {
1678                    start: proc_macro2::Span::call_site(),
1679                    end: proc_macro2::Span::call_site(),
1680                },
1681            }
1682        }
1683        let inner = Expr::Op {
1684            name: "==".to_string(),
1685            operands: vec![ident("a"), ident("b")],
1686            span: crate::ExprSpan {
1687                start: proc_macro2::Span::call_site(),
1688                end: proc_macro2::Span::call_site(),
1689            },
1690        };
1691        let expr = Expr::Op {
1692            name: "==".to_string(),
1693            operands: vec![inner, ident("c")],
1694            span: crate::ExprSpan {
1695                start: proc_macro2::Span::call_site(),
1696                end: proc_macro2::Span::call_site(),
1697            },
1698        };
1699        assert_eq!(format_expr(&expr, "", 0), "(a == b) == c");
1700    }
1701
1702    #[test]
1703    fn literal_notation_is_preserved_exactly() {
1704        assert_eq!(fmt("1920.0"), "1920.0");
1705        assert_eq!(fmt("1920.0f64"), "1920.0f64");
1706        assert_eq!(fmt("1i32"), "1i32");
1707    }
1708
1709    #[test]
1710    fn unary_minus_of_a_binary_expression_needs_parens() {
1711        assert_eq!(fmt("-(1i32 + 2i32)"), "-(1i32 + 2i32)");
1712    }
1713
1714    #[test]
1715    fn double_unary_minus_keeps_a_separating_space() {
1716        assert_eq!(fmt("- -1i32"), "- -1i32");
1717    }
1718
1719    #[test]
1720    fn cast_chain_reprints_without_extra_parens() {
1721        assert_eq!(fmt("x as i32 as f64"), "x as i32 as f64");
1722    }
1723
1724    #[test]
1725    fn unary_minus_before_a_cast_needs_no_parens() {
1726        // Matches Rust: `-x as f64` parses as `(-x) as f64` - unary already binds tighter than
1727        // Cast, so the printer doesn't need to add parens to preserve that grouping.
1728        assert_eq!(fmt("-x as f64"), "-x as f64");
1729    }
1730
1731    #[test]
1732    fn explicit_grouping_before_a_cast_keeps_its_parens() {
1733        // `as` binds tighter than `+`, so without parens `(a + b) as i32` would reprint as
1734        // `a + b as i32` - a different expression (`a + (b as i32)`). The parens must survive.
1735        assert_eq!(fmt("(a + b) as i32"), "(a + b) as i32");
1736    }
1737
1738    #[test]
1739    fn cast_operand_of_an_additive_expression_needs_no_parens() {
1740        // `a + b as i32` already parses as `a + (b as i32)` (`as` binds tighter than `+`), so no
1741        // parens are needed around the cast when reprinting.
1742        assert_eq!(fmt("a + b as i32"), "a + b as i32");
1743    }
1744
1745    #[test]
1746    fn explicit_grouping_around_a_cast_before_multiplicative_is_redundant_and_dropped() {
1747        // `(x as i32) * y` parses to the exact same tree as `x as i32 * y` (cast already binds
1748        // tighter than `*`), so the now-redundant parens are dropped on reprint - matching the
1749        // module doc's "parens added only where required, not exhaustively".
1750        assert_eq!(fmt("(x as i32) * y"), "x as i32 * y");
1751    }
1752
1753    #[test]
1754    fn one_tuple_keeps_its_trailing_comma() {
1755        assert_eq!(fmt("(1i32,)"), "(1i32,)");
1756    }
1757
1758    #[test]
1759    fn multi_element_tuple_has_no_trailing_comma() {
1760        assert_eq!(fmt("(1i32, 2i32)"), "(1i32, 2i32)");
1761    }
1762
1763    #[test]
1764    fn arrays_format_with_brackets_and_normalized_commas() {
1765        assert_eq!(fmt("[1i32,2i32]"), "[1i32, 2i32]");
1766        assert_eq!(fmt("[[1i32], [2i32]]"), "[[1i32], [2i32]]");
1767    }
1768
1769    #[test]
1770    fn typed_arrays_format_with_a_type_ascription() {
1771        assert_eq!(fmt("[1i32,2i32]:[i32]"), "[1i32, 2i32]: [i32]");
1772        assert_eq!(fmt("[]:[[i32]]"), "[]: [[i32]]");
1773    }
1774
1775    #[test]
1776    fn format_array_annotation_with_a_generic_builtin_element_type() {
1777        let source = "[]: [RangeInclusive(f64)]";
1778        let expr = parse(source);
1779        let formatted = format_expr(&expr, source, 0);
1780        assert_eq!(formatted, source);
1781    }
1782
1783    #[test]
1784    fn array_comments_remain_in_source_order() {
1785        assert_eq!(
1786            fmt("[1i32 /* first */, /* second */ 2i32]"),
1787            "[1i32 /* first */, /* second */ 2i32]"
1788        );
1789    }
1790
1791    #[test]
1792    fn comments_in_a_call_argument_list_are_preserved() {
1793        assert_eq!(
1794            fmt("f(/* a */ 1i32, /* b */ 2i32)"),
1795            "f(/* a */ 1i32, /* b */ 2i32)"
1796        );
1797    }
1798
1799    #[test]
1800    fn a_comment_before_a_closing_call_paren_is_preserved() {
1801        assert_eq!(fmt("f(1i32 /* end */)"), "f(1i32 /* end */)");
1802    }
1803
1804    #[test]
1805    fn comments_in_a_tuple_are_preserved() {
1806        assert_eq!(fmt("(1i32, /* mid */ 2i32)"), "(1i32, /* mid */ 2i32)");
1807    }
1808
1809    #[test]
1810    fn comment_free_calls_and_tuples_are_unchanged() {
1811        assert_eq!(fmt("f(1i32, 2i32)"), "f(1i32, 2i32)");
1812        assert_eq!(fmt("(1i32, 2i32)"), "(1i32, 2i32)");
1813        assert_eq!(fmt("(1i32,)"), "(1i32,)");
1814        assert_eq!(fmt("(1i32, 2i32).1"), "(1i32, 2i32).1");
1815    }
1816
1817    #[test]
1818    fn if_without_else_omits_the_else_clause() {
1819        assert_eq!(fmt("if true { 1i32 }"), "if true { 1i32 }");
1820    }
1821
1822    #[test]
1823    fn if_else_reprints_both_branches() {
1824        assert_eq!(
1825            fmt("if true { 1i32 } else { 2i32 }"),
1826            "if true { 1i32 } else { 2i32 }"
1827        );
1828    }
1829
1830    #[test]
1831    fn else_if_chain_has_no_braces_around_the_nested_if() {
1832        let source = "if true { 1i32 } else if false { 2i32 } else { 3i32 }";
1833        assert_eq!(fmt(source), source);
1834    }
1835
1836    #[test]
1837    fn a_comment_after_if_condition_is_preserved() {
1838        assert_eq!(fmt("if a /* c */ { 1i32 }"), "if a /* c */ { 1i32 }");
1839    }
1840
1841    #[test]
1842    fn a_comment_before_else_is_preserved() {
1843        assert_eq!(
1844            fmt("if a { 1i32 } /* e */ else { 2i32 }"),
1845            "if a { 1i32 } /* e */ else { 2i32 }"
1846        );
1847    }
1848
1849    #[test]
1850    fn comment_free_if_else_is_unchanged() {
1851        assert_eq!(
1852            fmt("if true { 1i32 } else { 2i32 }"),
1853            "if true { 1i32 } else { 2i32 }"
1854        );
1855        assert_eq!(fmt("if true { 1i32 }"), "if true { 1i32 }");
1856    }
1857
1858    #[test]
1859    fn logical_or_and_and_are_not_desugared_and_need_no_extra_parens() {
1860        assert_eq!(fmt("a || b && c"), "a || b && c");
1861    }
1862
1863    #[test]
1864    fn a_comment_around_a_logical_operator_is_preserved() {
1865        assert_eq!(fmt("a /* x */ || b"), "a /* x */ || b");
1866        assert_eq!(fmt("a && /* y */ b"), "a && /* y */ b");
1867    }
1868
1869    #[test]
1870    fn comment_free_logical_is_unchanged() {
1871        assert_eq!(fmt("a || b && c"), "a || b && c");
1872    }
1873
1874    #[test]
1875    fn format_is_idempotent_through_a_reparse() {
1876        let source = "(1i32 + 2i32) * 3i32 - -4i32";
1877        let once = fmt(source);
1878        let twice = format_expr(&parse(&once), &once, 0);
1879        assert_eq!(once, twice);
1880    }
1881
1882    #[test]
1883    fn typed_array_annotation_round_trips_through_reparse() {
1884        let source = "[1.0, 42.5]: [f64]";
1885        let once = fmt(source);
1886        let twice = format_expr(&parse(&once), &once, 0);
1887        assert_eq!(once, twice);
1888    }
1889
1890    #[test]
1891    fn closure_with_one_param_reprints_with_its_type() {
1892        assert_eq!(fmt("|x: i32| x + 1i32"), "|x: i32| x + 1i32");
1893    }
1894
1895    #[test]
1896    fn closure_with_no_params_reprints_with_double_pipe() {
1897        assert_eq!(fmt("|| 1i32"), "|| 1i32");
1898    }
1899
1900    #[test]
1901    fn closure_with_multiple_params_joins_them_with_commas() {
1902        assert_eq!(fmt("|x: i32, y: i32| x + y"), "|x: i32, y: i32| x + y");
1903    }
1904
1905    #[test]
1906    fn closure_with_a_tuple_typed_param_reprints_the_tuple_type() {
1907        assert_eq!(fmt("|x: (i32, f64)| x.0"), "|x: (i32, f64)| x.0");
1908    }
1909
1910    #[test]
1911    fn a_comment_before_a_closure_body_is_preserved() {
1912        assert_eq!(
1913            fmt("|x: i32| /* b */ x + 1i32"),
1914            "|x: i32| /* b */ x + 1i32"
1915        );
1916    }
1917
1918    #[test]
1919    fn comment_free_closures_are_unchanged() {
1920        assert_eq!(fmt("|x: i32| x + 1i32"), "|x: i32| x + 1i32");
1921        assert_eq!(fmt("|| 1i32"), "|| 1i32");
1922        assert_eq!(fmt("|x: i32, y: i32| x + y"), "|x: i32, y: i32| x + y");
1923    }
1924
1925    #[test]
1926    fn a_line_comment_before_a_closure_body_wraps_the_expression() {
1927        // A `//` comment forces a line wrap; the body must land on the continuation line, not get
1928        // glued onto the comment's own line (which would comment it out and make the output
1929        // non-reparseable).
1930        let source = "|x: i32| // c\n x + 1i32";
1931        let once = fmt(source);
1932        assert!(once.contains("// c"), "comment must survive: {once:?}");
1933        assert!(once.contains("x + 1i32"), "body must survive: {once:?}");
1934        let twice = format_expr(&parse(&once), &once, 0);
1935        assert_eq!(once, twice, "formatting must be idempotent");
1936    }
1937
1938    #[test]
1939    fn range_inclusive_reprints_without_spaces() {
1940        assert_eq!(fmt("1i32..=5i32"), "1i32..=5i32");
1941    }
1942
1943    #[test]
1944    fn range_reprints_without_spaces() {
1945        assert_eq!(fmt("1i32..5i32"), "1i32..5i32");
1946    }
1947
1948    #[test]
1949    fn range_from_reprints_without_spaces() {
1950        assert_eq!(fmt("1i32.."), "1i32..");
1951    }
1952
1953    #[test]
1954    fn range_to_reprints_without_spaces() {
1955        assert_eq!(fmt("..5i32"), "..5i32");
1956    }
1957
1958    #[test]
1959    fn range_to_inclusive_reprints_without_spaces() {
1960        assert_eq!(fmt("..=5i32"), "..=5i32");
1961    }
1962
1963    #[test]
1964    fn range_full_reprints_as_two_dots() {
1965        assert_eq!(fmt(".."), "..");
1966    }
1967
1968    #[test]
1969    fn range_endpoints_that_are_comparisons_need_no_parens() {
1970        // From `is_range_expression`'s own doc comment: `a == b..c == d` parses as
1971        // `(a == b)..(c == d)` -- comparison already binds tighter than range, so the printer
1972        // doesn't need parens to preserve that grouping.
1973        assert_eq!(fmt("a == b..c == d"), "a == b..c == d");
1974    }
1975
1976    #[test]
1977    fn range_endpoints_that_are_arithmetic_need_no_parens() {
1978        // From the same doc comment: `1 + 2..3 * 4` parses as `(1 + 2)..(3 * 4)` -- arithmetic
1979        // binds well inside range's own endpoints, so no parens are needed on reprint either.
1980        assert_eq!(fmt("1i32 + 2i32..3i32 * 4i32"), "1i32 + 2i32..3i32 * 4i32");
1981    }
1982
1983    #[test]
1984    fn emit_gap_around_a_plain_operator_uses_single_spaces() {
1985        let pieces = vec![GapPiece::Punct("+")];
1986        assert_eq!(emit_gap(&pieces, Spacing::Around, 0), " + ");
1987    }
1988
1989    #[test]
1990    fn emit_gap_none_spacing_glues_a_range_operator() {
1991        let pieces = vec![GapPiece::Punct("..")];
1992        assert_eq!(emit_gap(&pieces, Spacing::None, 0), "..");
1993    }
1994
1995    #[test]
1996    fn emit_gap_comma_after_puts_one_trailing_space() {
1997        let pieces = vec![GapPiece::Punct(",")];
1998        assert_eq!(emit_gap(&pieces, Spacing::CommaAfter, 0), ", ");
1999    }
2000
2001    #[test]
2002    fn emit_gap_inlines_a_block_comment_before_an_operator() {
2003        let pieces = vec![
2004            GapPiece::Comment(Comment::Block("a".to_string())),
2005            GapPiece::Punct("+"),
2006        ];
2007        assert_eq!(emit_gap(&pieces, Spacing::Around, 0), " /* a */ + ");
2008    }
2009
2010    #[test]
2011    fn emit_gap_inlines_a_block_comment_after_an_operator() {
2012        let pieces = vec![
2013            GapPiece::Punct("+"),
2014            GapPiece::Comment(Comment::Block("b".to_string())),
2015        ];
2016        assert_eq!(emit_gap(&pieces, Spacing::Around, 0), " + /* b */ ");
2017    }
2018
2019    #[test]
2020    fn emit_gap_wraps_after_a_line_comment_to_the_continuation_indent() {
2021        let pieces = vec![
2022            GapPiece::Punct("+"),
2023            GapPiece::Comment(Comment::Line("why".to_string())),
2024        ];
2025        // depth 0 => continuation at depth 1 (4 spaces). The operator keeps its leading space; the
2026        // line comment ends the line, and the next operand resumes at the continuation indent.
2027        assert_eq!(emit_gap(&pieces, Spacing::Around, 0), " + // why\n    ");
2028    }
2029
2030    #[test]
2031    fn emit_gap_comma_after_a_line_comment_keeps_the_full_continuation_indent() {
2032        // A comma following a line comment must NOT lose one space of the continuation indent --
2033        // that indent is meaningful (it's `indent(depth + 1)`), unlike the single incidental
2034        // space a block comment or an `Around` punct leaves before a comma.
2035        let pieces = vec![
2036            GapPiece::Comment(Comment::Line("why".to_string())),
2037            GapPiece::Punct(","),
2038        ];
2039        assert_eq!(emit_gap(&pieces, Spacing::CommaAfter, 0), " // why\n    , ");
2040    }
2041
2042    #[test]
2043    fn a_block_comment_before_a_binary_operator_is_preserved() {
2044        let source = "1i32 /* a */ + 2i32";
2045        assert_eq!(fmt(source), "1i32 /* a */ + 2i32");
2046    }
2047
2048    #[test]
2049    fn a_block_comment_after_a_binary_operator_is_preserved() {
2050        let source = "1i32 + /* b */ 2i32";
2051        assert_eq!(fmt(source), "1i32 + /* b */ 2i32");
2052    }
2053
2054    #[test]
2055    fn comments_on_both_sides_of_a_binary_operator_are_preserved() {
2056        let source = "1i32 /* a */ + /* b */ 2i32";
2057        assert_eq!(fmt(source), "1i32 /* a */ + /* b */ 2i32");
2058    }
2059
2060    #[test]
2061    fn a_line_comment_after_a_binary_operator_wraps_the_expression() {
2062        let source = "1i32 +\n    // why 2\n    2i32";
2063        assert_eq!(fmt(source), "1i32 + // why 2\n    2i32");
2064    }
2065
2066    #[test]
2067    fn a_comment_free_binary_op_still_prints_on_one_line() {
2068        assert_eq!(fmt("1i32 + 2i32 * 3i32"), "1i32 + 2i32 * 3i32");
2069    }
2070
2071    #[test]
2072    fn a_comment_after_unary_minus_is_preserved() {
2073        assert_eq!(fmt("- /* neg */ 1i32"), "- /* neg */ 1i32");
2074    }
2075
2076    #[test]
2077    fn a_comment_around_a_cast_operator_is_preserved() {
2078        assert_eq!(fmt("x /* c */ as i32"), "x /* c */ as i32");
2079    }
2080
2081    #[test]
2082    fn a_comment_inside_a_range_is_preserved() {
2083        assert_eq!(fmt("1i32 /* r */ ..5i32"), "1i32 /* r */ ..5i32");
2084    }
2085
2086    #[test]
2087    fn comment_free_unary_cast_and_range_are_unchanged() {
2088        assert_eq!(fmt("-1i32"), "-1i32");
2089        assert_eq!(fmt("x as i32"), "x as i32");
2090        assert_eq!(fmt("1i32..5i32"), "1i32..5i32");
2091    }
2092
2093    #[test]
2094    fn a_line_comment_after_a_cast_operator_wraps_the_expression() {
2095        // A `//` comment forces a line wrap; `i32` must land on the continuation line, not get
2096        // glued onto the comment's own line (which would comment it out and make the output
2097        // non-reparseable).
2098        let source = "x as // c\n i32";
2099        let once = fmt(source);
2100        assert!(once.contains("// c"), "comment must survive: {once:?}");
2101        assert!(once.contains("i32"), "type name must survive: {once:?}");
2102        let twice = format_expr(&parse(&once), &once, 0);
2103        assert_eq!(once, twice, "formatting must be idempotent");
2104    }
2105
2106    #[test]
2107    fn issue_201_line_comment_inside_an_expression_round_trips() {
2108        // The exact repro from stlab/cel-rs#201.
2109        let source = "1i32 +\n    // why 2\n    2i32";
2110        let once = fmt(source);
2111        assert!(once.contains("// why 2"), "comment must survive: {once:?}");
2112        let twice = format_expr(&parse(&once), &once, 0);
2113        assert_eq!(once, twice, "formatting must be idempotent");
2114    }
2115
2116    #[test]
2117    fn the_full_inline_comment_example_round_trips() {
2118        // From the design discussion: block comments on both operands and around the operator,
2119        // plus a trailing line comment. The leading `/* p */` and trailing `/* r */` sit outside
2120        // the expression's own span (before its first token, after its last), which the design
2121        // doc explicitly carves out of `format_expr`'s scope: "`Expr`'s own outer boundary ... is
2122        // not `format_expr`'s to own ... that boundary belongs to whatever embeds the expression"
2123        // (adam-lang's Part A2, not yet implemented) -- so only the two in-scope, inter-operand
2124        // comments are expected to survive here.
2125        let source = "/* p */ 1i32 /* q */ + // hello\n2i32 /* r */";
2126        let once = fmt(source);
2127        assert!(
2128            once.contains("/* q */") && once.contains("// hello"),
2129            "the in-scope operand/operator comments survive: {once:?}"
2130        );
2131        let twice = format_expr(&parse(&once), &once, 0);
2132        assert_eq!(once, twice);
2133    }
2134}