Skip to main content

bynk_fmt/
fmt.rs

1//! Bynk source formatter.
2//!
3//! Re-parses the source into an AST and re-prints it in canonical form per
4//! the style rules in `design/bynk-lsp-spec.md` §3.5:
5//!
6//! - Tabs by default (one tab per nesting level).
7//! - K&R brace style: opening brace on the same line as the construct header.
8//! - Trailing commas in multi-line record / sum / parameter / argument lists.
9//! - One blank line between top-level declarations.
10//! - No blank lines between fields within a record or arms within a match.
11//! - Doc blocks immediately above their declaration, no blank line between.
12//! - One space around binary operators, after commas, no space inside parens.
13//! - Soft 100-column line width — see below.
14//!
15//! Line width (#963). Every fit test measures the *whole* line: the column the
16//! construct starts at, its own width, and the width of whatever the caller
17//! will still emit after it (a `-> Ret {` signature tail, a closing `)`, a
18//! match arm's `,`). A construct that does not fit is re-emitted vertically,
19//! breaking only where the grammar tolerates a newline:
20//!
21//! - Record constructions, list literals, `exports`/`enum` bodies, and agent
22//!   scheme configuration take one entry per line, with a trailing comma.
23//! - Parameter and argument lists take one entry per line **without** a
24//!   trailing comma — the grammar rejects one there.
25//! - A `&&` / `||` / `implies` run breaks before each operator. Arithmetic and
26//!   comparison operators never break: a continuation line opening with `+`
27//!   does not re-attach to the line above on re-parse.
28//! - A `.`-chain of two or more calls breaks before each call, unless the
29//!   overflow belongs to a trailing argument that can open its body on the
30//!   chain's own line (`xs.fold(init, (acc, x) => match acc {`).
31//! - An `if` sends both branches vertical; a block sends its statements
32//!   vertical.
33//!
34//! The target is soft: a construct with no break point inside it — a long
35//! string literal, a `Matches("…")` regex — is left over-long rather than
36//! mangled. Every layout choice is a function of the AST and the current
37//! column, so the result is stable under re-formatting.
38//!
39//! The formatter is idempotent: format → format yields the same text.
40//!
41//! Comments (v1.1): line comments are preserved through the lexer-to-parser
42//! trivia pipeline (lexer emits `Comment` tokens, parser attaches them to
43//! AST declarations and statements). The formatter re-emits leading
44//! comments above each node and a trailing comment, if any, on the same
45//! line as the node's last token. Comments inside expression sub-trees
46//! are not yet attached to individual operands; they are folded into the
47//! enclosing statement's leading trivia. When even that would lose a
48//! comment, [`format_source`] refuses with a `bynk.fmt.comment_loss`
49//! diagnostic instead of dropping user text (#523) — the file is left
50//! unchanged. See `design/bynk-lsp-spec.md` §3.5 for the canonical
51//! comment-placement rules.
52
53use bynk_syntax::ast::*;
54use bynk_syntax::error::CompileError;
55use bynk_syntax::lexer::{Token, TokenKind, tokenize};
56use bynk_syntax::parser::{parse_units, parse_units_with_drain_check};
57use bynk_syntax::span::Span;
58
59/// Indentation style: tabs or spaces. Mirrors the LSP spec's `[fmt].indent`
60/// setting.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum IndentStyle {
63    #[default]
64    Tab,
65    Spaces(u8),
66}
67
68/// Formatter options. All fields have spec-defined defaults.
69///
70/// `Copy` (#972): three scalar fields, and the config layering passes them by
71/// value through per-file resolution — a `clone()` at each hop would be noise.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct FormatOptions {
74    pub indent: IndentStyle,
75    pub max_line_width: u32,
76    pub trailing_comma: bool,
77}
78
79impl Default for FormatOptions {
80    fn default() -> Self {
81        Self {
82            indent: IndentStyle::Tab,
83            max_line_width: 100,
84            trailing_comma: true,
85        }
86    }
87}
88
89/// Error returned when formatting fails. The formatter cannot format code
90/// that does not parse, so all failure modes here surface as parse errors.
91#[derive(Debug, Clone)]
92pub struct FormatError {
93    pub errors: Vec<CompileError>,
94}
95
96/// Format a Bynk source string. On parse failure, returns the original
97/// source unchanged is *not* this function's responsibility — callers (LSP,
98/// CLI) decide how to handle parse failure. Here we surface the errors so
99/// the caller can do so.
100pub fn format_source(source: &str, opts: &FormatOptions) -> Result<String, FormatError> {
101    // #1763: line endings are not a formatting difference. A CRLF file formats
102    // to the LF canonical form, CRs inside `---` blocks included (their text
103    // was kept verbatim, so a CR survived into the output). A caller rendering
104    // this function's errors should render them against the normalised text.
105    let source = &*normalize_line_endings(source);
106    let tokens = tokenize(source).map_err(|e| FormatError { errors: vec![e] })?;
107    // v0.113: a file may hold more than one top-level unit (an atomic
108    // `commons` + `suite` file, DECISION S). Format each and join with a blank
109    // line. Each unit's output already ends in exactly one newline, so joining
110    // with `"\n"` inserts one blank line between units and leaves a single-unit
111    // file byte-identical.
112    let (units, _warnings, _) =
113        parse_units_with_drain_check(&tokens, source).map_err(|errors| FormatError { errors })?;
114    let output = render_units(&units, opts);
115    // #523/#66 guard: trivia is only attached at declaration/statement
116    // granularity, so a comment inside an expression subtree can be silently
117    // dropped. Losing user text is worse than leaving a file unformatted — when
118    // the output holds fewer comments than the input, refuse with a diagnostic
119    // pointing at the first comment that would vanish. It runs on every format:
120    // the parse's own `fully_drained` flag only says each comment was harvested
121    // from the trivia table, not that it reached the AST, and #1756 found
122    // comments harvested then dropped (at the end of a service, agent or
123    // capability body, and inside a `cors`/`security`/`limits` policy) that the
124    // formatter deleted with no refusal. Both places keep their comments now
125    // (#1756, #1786); the guard stays for whatever the next gap is.
126    if let Some(error) = comment_loss(source, &tokens, &output) {
127        return Err(FormatError {
128            errors: vec![error],
129        });
130    }
131    // #1664: doc blocks never enter the trivia table, so `fully_drained` says
132    // nothing about them, and the guard above counts `--` comments only. Check
133    // them separately, on every run. Since #1756 an orphan (a block separated
134    // from the next declaration by a blank line, or with none to follow) is
135    // kept as a `Comment::OrphanDoc` and printed in place; this is the backstop
136    // for a block some printer path still loses.
137    if let Some(error) = doc_block_loss(source, &tokens, &output) {
138        return Err(FormatError {
139            errors: vec![error],
140        });
141    }
142    // #735 guard: the printer is hand-written and dodges several parse traps by
143    // convention (a tail `()` re-attaching as a call, a trailing comma making a
144    // param list unparseable). A shape the corpus misses that the printer
145    // mis-renders would otherwise be written straight over the user's file with
146    // exit 0. Before returning, re-parse the output and compare its *code*
147    // structure — every comment stripped from both sides, so trivia re-flow is
148    // ignored — against the input. When the output fails to re-parse, or a
149    // shape round-trips to a different AST, refuse rather than corrupt.
150    if let Some(error) = roundtrip_divergence(&tokens, source, &output, opts) {
151        return Err(FormatError {
152            errors: vec![error],
153        });
154    }
155    Ok(output)
156}
157
158/// #1763: `source` with every line ending made LF, borrowed when there is
159/// nothing to change. The canonical form uses LF, and comparing a file against
160/// it modulo line endings is what keeps a Windows checkout
161/// (`core.autocrlf=true`) of a canonical file canonical.
162///
163/// A run of CRs before an LF (`\r\n`, `\r\r\n`, …) is one line ending, so
164/// this is a fixed point: normalising its own output changes nothing (#1775
165/// review; a single `replace` turned `\r\r\n` into a `\r\n` a second pass
166/// would change again). A CR not before an LF is left alone; the lexer treats
167/// it as whitespace. Normalising can't change a program value, since a string
168/// literal can't span a line (`bynk.lex.unterminated_string`).
169pub fn normalize_line_endings(source: &str) -> std::borrow::Cow<'_, str> {
170    if !source.contains("\r\n") {
171        return std::borrow::Cow::Borrowed(source);
172    }
173    let mut out = String::with_capacity(source.len());
174    let mut pending_crs = 0usize;
175    for c in source.chars() {
176        match c {
177            '\r' => pending_crs += 1,
178            '\n' => {
179                pending_crs = 0;
180                out.push('\n');
181            }
182            _ => {
183                out.extend(std::iter::repeat_n('\r', pending_crs));
184                pending_crs = 0;
185                out.push(c);
186            }
187        }
188    }
189    out.extend(std::iter::repeat_n('\r', pending_crs));
190    std::borrow::Cow::Owned(out)
191}
192
193/// Format every top-level unit and join with a blank line. A file may hold more
194/// than one top-level unit (v0.113, an atomic `commons` + `suite` file,
195/// DECISION S). Each unit's output already ends in exactly one newline, so
196/// joining with `"\n"` inserts one blank line between units and leaves a
197/// single-unit file byte-identical.
198fn render_units(units: &[SourceUnit], opts: &FormatOptions) -> String {
199    let parts: Vec<String> = units
200        .iter()
201        .map(|unit| {
202            let mut f = Formatter::new(opts);
203            f.format_unit(unit);
204            f.finish()
205        })
206        .collect();
207    parts.join("\n")
208}
209
210/// #735: the comment-free canonical rendering of `source` — every `Comment`
211/// token dropped before parsing, so the result carries no trivia and reflects
212/// only the code structure. Because the parser strips comments the same way
213/// ([`split_trivia`]), pre-filtering them changes nothing structural; it just
214/// leaves the trivia fields empty so the formatter emits pure code. Returns the
215/// parse errors when `source` does not tokenize or parse.
216fn code_only_canonical(source: &str, opts: &FormatOptions) -> Result<String, Vec<CompileError>> {
217    let tokens = tokenize(source).map_err(|e| vec![e])?;
218    code_only_canonical_from_tokens(&tokens, source, opts)
219}
220
221/// [`code_only_canonical`], given an already-tokenized `source` — finding #66:
222/// `format_source` tokenizes `source` once up front for the real render;
223/// `roundtrip_divergence` reuses that token list here instead of tokenizing
224/// `source` from scratch a second time.
225fn code_only_canonical_from_tokens(
226    tokens: &[Token],
227    source: &str,
228    opts: &FormatOptions,
229) -> Result<String, Vec<CompileError>> {
230    let code: Vec<Token> = tokens
231        .iter()
232        .filter(|t| t.kind != TokenKind::Comment)
233        .cloned()
234        .collect();
235    let units = parse_units(&code, source)?;
236    Ok(render_units(&units, opts))
237}
238
239/// #735: refuse when the formatter's `output` does not round-trip to the same
240/// code as `source`. Both sides are reduced to their comment-free canonical
241/// form ([`code_only_canonical`]) and compared: the formatter only re-flows
242/// whitespace and trivia, so for a faithful render the two canonical strings
243/// are byte-identical. A mismatch means either the output no longer parses (the
244/// data-loss vector) or the printer altered the AST. `None` when the output is
245/// safe to write.
246///
247/// Note: this guard assumes the formatter is idempotent on comment-free code —
248/// i.e. `render(parse(strip(x)))` is a stable canonical form. That invariant is
249/// held by the corpus/property idempotency tests. Were a future formatter
250/// change to break it on some shape, this guard would *refuse* an otherwise
251/// valid file rather than corrupt it — it fails safe (file unchanged + a
252/// diagnostic), but the surprise would be a formatter bug to fix upstream.
253fn roundtrip_divergence(
254    tokens: &[Token],
255    source: &str,
256    output: &str,
257    opts: &FormatOptions,
258) -> Option<CompileError> {
259    // The output MUST re-parse to the same structure; a failure here is the
260    // core corruption vector this guard exists to stop.
261    let canon_out = match code_only_canonical(output, opts) {
262        Ok(canon) => canon,
263        Err(_) => {
264            return Some(roundtrip_error(
265                "the formatter produced output that no longer parses",
266            ));
267        }
268    };
269    // The input already tokenized (and parsed) in `format_source` — `tokens` is
270    // that same token list, reused rather than tokenizing `source` a second
271    // time (finding #66). If its canonical form unexpectedly fails to compute,
272    // do not block a valid format on our own guard failing — leave the file
273    // writable.
274    let canon_in = code_only_canonical_from_tokens(tokens, source, opts).ok()?;
275    (canon_in != canon_out)
276        .then(|| roundtrip_error("the formatter's output does not round-trip to the same code"))
277}
278
279/// Build the `bynk.fmt.roundtrip` diagnostic shared by both failure modes of
280/// [`roundtrip_divergence`]. The span is deliberately `Span::default()` (the
281/// start of the file): the message points at neither the source nor the
282/// output — it is a generic "this is a formatter bug" — and the failing branch
283/// carries an *output*-relative span, which the caller renders against the
284/// *source* string. When the mis-rendered output is longer than the source,
285/// that span is out of range for the buffer ariadne is given (a misplaced
286/// caret, or a byte-index panic in the very formatter-bug path this guard
287/// exists to handle gracefully). A zero span is always in range and buys the
288/// message nothing to lose.
289fn roundtrip_error(what: &str) -> CompileError {
290    CompileError {
291        category: "bynk.fmt.roundtrip",
292        span: Span::default(),
293        message: format!("{what} — the file was left unchanged"),
294        labels: Vec::new(),
295        notes: vec![
296            "this is a formatter bug, not a problem with your source; please report it \
297             with the file that triggered it"
298                .to_string(),
299        ],
300        suggestions: Vec::new(),
301    }
302}
303
304/// #523: compare the comment population of `source` (already tokenized as
305/// `tokens`) against `output`. Returns a `bynk.fmt.comment_loss` error naming
306/// the first lost comment when the output would hold fewer comments, `None`
307/// when every comment survives. Comments may legitimately *move* (expression
308/// trivia folds into the enclosing statement's leading block), so the
309/// comparison is by body multiset, not position.
310fn comment_loss(source: &str, tokens: &[Token], output: &str) -> Option<CompileError> {
311    use bynk_syntax::lexer::comment_body;
312    let in_comments: Vec<Span> = tokens
313        .iter()
314        .filter(|t| t.kind == TokenKind::Comment)
315        .map(|t| t.span)
316        .collect();
317    if in_comments.is_empty() {
318        return None;
319    }
320    // The formatter's own output must tokenize; treat a failure as "all
321    // comments lost" rather than silently accepting the write.
322    let mut out_bodies: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
323    if let Ok(out_tokens) = tokenize(output) {
324        for t in &out_tokens {
325            if t.kind == TokenKind::Comment {
326                *out_bodies
327                    .entry(comment_body(output, t.span).trim().to_string())
328                    .or_insert(0) += 1;
329            }
330        }
331    }
332    let mut lost = 0usize;
333    let mut first_lost: Option<Span> = None;
334    for span in &in_comments {
335        let body = comment_body(source, *span).trim().to_string();
336        match out_bodies.get_mut(&body) {
337            Some(n) if *n > 0 => *n -= 1,
338            _ => {
339                lost += 1;
340                first_lost.get_or_insert(*span);
341            }
342        }
343    }
344    let span = first_lost?;
345    Some(CompileError {
346        category: "bynk.fmt.comment_loss",
347        span,
348        message: format!(
349            "formatting would lose {lost} comment{} — the file was left unchanged",
350            if lost == 1 { "" } else { "s" }
351        ),
352        labels: vec![(
353            span,
354            "this comment sits where the formatter cannot yet re-attach it".to_string(),
355        )],
356        notes: vec![
357            "comments inside expression subtrees are not yet preserved; move the comment onto \
358             its own line before the enclosing statement to format this file"
359                .to_string(),
360        ],
361        suggestions: Vec::new(),
362    })
363}
364
365/// #1664: the `---` counterpart of [`comment_loss`]. Returns a
366/// `bynk.fmt.comment_loss` error naming the first doc block of `source` (already
367/// tokenized as `tokens`) that has no counterpart in `output`, compared by
368/// content multiset. Attached and orphaned (#1756) blocks are both re-rendered
369/// with their content intact, so this fires only on a printer gap.
370///
371/// An `output` that does not tokenize returns `None`: that is a formatter bug
372/// the round-trip guard reports accurately ("no longer parses"), and counting
373/// every block as lost would point the user at an innocent one instead.
374fn doc_block_loss(source: &str, tokens: &[Token], output: &str) -> Option<CompileError> {
375    use bynk_syntax::lexer::doc_block_content;
376    let in_docs: Vec<Span> = tokens
377        .iter()
378        .filter(|t| t.kind == TokenKind::DocBlock)
379        .map(|t| t.span)
380        .collect();
381    if in_docs.is_empty() {
382        return None;
383    }
384    // Compare content line by line with surrounding whitespace removed, so the
385    // formatter's re-indentation of an attached block is not mistaken for loss.
386    let normalise = |content: String| {
387        content
388            .lines()
389            .map(str::trim)
390            .collect::<Vec<_>>()
391            .join("\n")
392    };
393    let out_tokens = tokenize(output).ok()?;
394    let mut out_docs: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
395    for t in &out_tokens {
396        if t.kind == TokenKind::DocBlock {
397            *out_docs
398                .entry(normalise(doc_block_content(output, t.span)))
399                .or_insert(0) += 1;
400        }
401    }
402    let mut lost = 0usize;
403    let mut first_lost: Option<Span> = None;
404    for span in &in_docs {
405        match out_docs.get_mut(&normalise(doc_block_content(source, *span))) {
406            Some(n) if *n > 0 => *n -= 1,
407            _ => {
408                lost += 1;
409                first_lost.get_or_insert(*span);
410            }
411        }
412    }
413    let span = first_lost?;
414    Some(CompileError {
415        category: "bynk.fmt.comment_loss",
416        span,
417        message: format!(
418            "formatting would lose {lost} documentation block{} — the file was left unchanged",
419            if lost == 1 { "" } else { "s" }
420        ),
421        labels: vec![(
422            span,
423            "this documentation block has no counterpart in the formatted output".to_string(),
424        )],
425        notes: vec![
426            "a `---` block attaches to the declaration directly below it; one separated from \
427             it by a blank line, or with no declaration after it, attaches to nothing. Remove \
428             the blank line to attach it, or make it a `--` comment if it documents nothing"
429                .to_string(),
430            "if the block is already directly above a declaration, this is a formatter bug; \
431             please report it with the file that triggered it"
432                .to_string(),
433        ],
434        suggestions: Vec::new(),
435    })
436}
437
438// -- Internal formatter state --
439
440struct Formatter<'a> {
441    opts: &'a FormatOptions,
442    out: String,
443    indent_level: u32,
444    /// True when the formatter has just emitted a newline and is at the
445    /// start of a fresh line. Used to gate indent emission.
446    at_line_start: bool,
447}
448
449impl<'a> Formatter<'a> {
450    fn new(opts: &'a FormatOptions) -> Self {
451        Self {
452            opts,
453            out: String::new(),
454            indent_level: 0,
455            at_line_start: true,
456        }
457    }
458
459    fn finish(mut self) -> String {
460        // Single trailing newline.
461        while self.out.ends_with("\n\n") {
462            self.out.pop();
463        }
464        if !self.out.ends_with('\n') {
465            self.out.push('\n');
466        }
467        self.out
468    }
469
470    fn indent_unit(&self) -> String {
471        match self.opts.indent {
472            IndentStyle::Tab => "\t".to_string(),
473            IndentStyle::Spaces(n) => " ".repeat(n as usize),
474        }
475    }
476
477    fn emit_indent(&mut self) {
478        let unit = self.indent_unit();
479        for _ in 0..self.indent_level {
480            self.out.push_str(&unit);
481        }
482    }
483
484    fn push(&mut self, s: &str) {
485        if self.at_line_start && !s.starts_with('\n') {
486            self.emit_indent();
487            self.at_line_start = false;
488        }
489        if s.contains('\n') {
490            self.push_reindented(s);
491        } else {
492            self.out.push_str(s);
493        }
494    }
495
496    /// Append a multi-line string, re-applying the current indent to every
497    /// continuation line. Multi-line strings come from the single-line
498    /// expression renderers (`expr_to_string` and friends), which build their
499    /// internal structure assuming column zero — they embed `\n` plus relative
500    /// tabs but know nothing about the current nesting depth. Without this an
501    /// argument-position `match` (or any embedded multi-line expression) would
502    /// print its arms and trailing brace at column one regardless of how deeply
503    /// it is nested. The first line is emitted as-is (its indent, if any, was
504    /// handled by `push`); blank lines are left empty rather than padded.
505    fn push_reindented(&mut self, s: &str) {
506        let prefix = self.indent_unit().repeat(self.indent_level as usize);
507        for (i, line) in s.split('\n').enumerate() {
508            if i > 0 {
509                self.out.push('\n');
510                if !line.is_empty() {
511                    self.out.push_str(&prefix);
512                }
513            }
514            self.out.push_str(line);
515        }
516    }
517
518    fn newline(&mut self) {
519        self.out.push('\n');
520        self.at_line_start = true;
521    }
522
523    #[allow(dead_code)]
524    fn blank_line(&mut self) {
525        if !self.out.ends_with('\n') {
526            self.out.push('\n');
527        }
528        if !self.out.ends_with("\n\n") {
529            self.out.push('\n');
530        }
531        self.at_line_start = true;
532    }
533
534    fn indented<F: FnOnce(&mut Self)>(&mut self, f: F) {
535        self.indent_level += 1;
536        f(self);
537        self.indent_level -= 1;
538    }
539
540    /// Render `body` into a detached formatter positioned at this one's exact
541    /// column and indent, and commit it only if every line it produces stays
542    /// inside the budget (the last line counting `reserve` further columns for
543    /// whatever the caller will append). Returns whether it was committed.
544    ///
545    /// A candidate layout is often only judgeable once rendered: whether a
546    /// `.`-chain needs breaking at its dots depends on how its arguments wrap,
547    /// which depends on the column they start at. Measuring a real rendering
548    /// beats predicting one (#963).
549    fn try_layout<F: FnOnce(&mut Self)>(&mut self, reserve: usize, body: F) -> bool {
550        self.try_layout_if(reserve, body, || true)
551    }
552
553    /// [`Self::try_layout`] with a second condition, evaluated after the
554    /// rendering, for a caller that judges the candidate on more than its
555    /// width — a chain that fits but only by exploding an argument list.
556    fn try_layout_if<F: FnOnce(&mut Self), A: FnOnce() -> bool>(
557        &mut self,
558        reserve: usize,
559        body: F,
560        accept: A,
561    ) -> bool {
562        let line_start = self.out.rfind('\n').map_or(0, |i| i + 1);
563        let mut sub = Formatter {
564            opts: self.opts,
565            out: self.out[line_start..].to_string(),
566            indent_level: self.indent_level,
567            at_line_start: self.at_line_start,
568        };
569        let produced_from = sub.out.len();
570        body(&mut sub);
571        if !sub.every_line_within_budget(reserve) || !accept() {
572            return false;
573        }
574        // Splice the raw text: `sub` already emitted its own indentation, so
575        // going through `push` would double it.
576        self.out.push_str(&sub.out[produced_from..]);
577        self.at_line_start = sub.at_line_start;
578        true
579    }
580
581    /// Does every line of this (detached) formatter's buffer fit, with
582    /// `reserve` columns still free on the last one?
583    fn every_line_within_budget(&self, reserve: usize) -> bool {
584        let tab = self.indent_width();
585        let mut lines = self.out.split('\n').peekable();
586        let mut last = "";
587        while let Some(line) = lines.next() {
588            if lines.peek().is_none() {
589                last = line;
590                break;
591            }
592            if display_width(line, tab) > self.opts.max_line_width as usize {
593                return false;
594            }
595        }
596        let last_width = if self.at_line_start {
597            // The trailing indent is not in the buffer yet; `push` adds it.
598            self.indent_level as usize * tab
599        } else {
600            display_width(last, tab)
601        };
602        last_width + reserve <= self.opts.max_line_width as usize
603    }
604
605    // -- Doc block --
606
607    /// Emit a doc block immediately above a declaration. The content is
608    /// already normalised (common leading indent stripped) when stored in
609    /// the AST; we re-emit with the current indent applied per line.
610    fn emit_doc(&mut self, doc: &str) {
611        self.push("---");
612        self.newline();
613        for line in doc.lines() {
614            if line.is_empty() {
615                self.newline();
616            } else {
617                self.push(line);
618                self.newline();
619            }
620        }
621        self.push("---");
622        self.newline();
623    }
624
625    // -- Line-comment trivia (v1.1) --
626
627    /// Emit a sequence of leading comments, each on its own line at the
628    /// current indent. `--` lines have no blank lines between them; an orphaned
629    /// doc block (#1756) prints as a doc block and is followed by a blank line,
630    /// which is what keeps it from attaching to the declaration below.
631    fn emit_leading_comments(&mut self, comments: &[Comment]) {
632        self.emit_comments(comments, true);
633    }
634
635    /// Emit the comments that close a body or file. As
636    /// [`Self::emit_leading_comments`], except that an orphaned doc block that
637    /// is the last entry needs no blank line: nothing follows it to attach to.
638    fn emit_trailing_comments(&mut self, comments: &[Comment]) {
639        self.emit_comments(comments, false);
640    }
641
642    fn emit_comments(&mut self, comments: &[Comment], blank_after_last_orphan: bool) {
643        for (i, comment) in comments.iter().enumerate() {
644            match comment {
645                Comment::Line(body) => {
646                    self.push("--");
647                    self.push(body);
648                    self.newline();
649                }
650                Comment::OrphanDoc(doc) => {
651                    self.emit_doc(doc);
652                    if i + 1 < comments.len() || blank_after_last_orphan {
653                        self.newline();
654                    }
655                }
656            }
657        }
658    }
659
660    /// Emit a trailing comment on the same line as the just-emitted token.
661    /// The spec uses two spaces between code and comment for readability.
662    fn emit_trailing_comment(&mut self, body: Option<&str>) {
663        if let Some(body) = body {
664            // Ensure we're on the same line as the preceding tokens —
665            // strip any newline we just emitted.
666            while self.out.ends_with('\n') {
667                self.out.pop();
668            }
669            self.out.push_str("  --");
670            self.out.push_str(body);
671            self.newline();
672        }
673    }
674
675    /// End the current line: with the trailing comment when there is one
676    /// (which [`Self::emit_trailing_comment`] closes with its own newline),
677    /// otherwise with a bare newline. A site that splices the comment onto a
678    /// line something else already ended calls `emit_trailing_comment` alone.
679    fn emit_trailing_comment_or_newline(&mut self, trailing: Option<&str>) {
680        if trailing.is_some() {
681            self.emit_trailing_comment(trailing);
682        } else {
683            self.newline();
684        }
685    }
686
687    // -- Top level --
688
689    fn format_unit(&mut self, unit: &SourceUnit) {
690        match unit {
691            SourceUnit::Commons(c) => self.format_commons(c),
692            SourceUnit::Context(c) => self.format_context(c),
693            SourceUnit::Suite(t) => self.format_test(t),
694            SourceUnit::Adapter(a) => self.format_adapter(a),
695        }
696    }
697
698    fn format_adapter(&mut self, a: &AdapterDecl) {
699        self.emit_leading_comments(&a.trivia.leading);
700        if let Some(doc) = &a.documentation {
701            self.emit_doc(doc);
702        }
703        let header = format!("adapter {}", a.name.joined());
704        match a.form {
705            CommonsForm::Brace => {
706                self.push(&header);
707                self.push(" {");
708                self.newline();
709                self.indented(|f| {
710                    f.format_adapter_body(a);
711                });
712                self.push("}");
713                self.newline();
714            }
715            CommonsForm::Fragment => {
716                self.push(&header);
717                self.newline();
718                self.newline();
719                self.format_adapter_body(a);
720            }
721        }
722    }
723
724    fn format_adapter_body(&mut self, a: &AdapterDecl) {
725        let mut any_header = false;
726        if let Some(b) = &a.binding {
727            self.emit_leading_comments(&b.trivia.leading);
728            self.push(&format!("binding {:?}", b.module));
729            if !b.requires.is_empty() {
730                let entries: Vec<String> = b
731                    .requires
732                    .iter()
733                    .map(|r| format!("{:?}: {:?}", r.package, r.range))
734                    .collect();
735                self.push(&format!(" requires {{ {} }}", entries.join(", ")));
736            }
737            self.emit_trailing_comment_or_newline(b.trivia.trailing.as_deref());
738            any_header = true;
739        }
740        for u in &a.uses {
741            self.emit_leading_comments(&u.trivia.leading);
742            self.push(&format!("uses {}", u.target.joined()));
743            self.emit_trailing_comment_or_newline(u.trivia.trailing.as_deref());
744            any_header = true;
745        }
746        for c in &a.consumes {
747            self.format_consumes(c);
748            any_header = true;
749        }
750        for e in &a.exports {
751            self.emit_leading_comments(&e.trivia.leading);
752            self.format_exports(e);
753            if e.trivia.trailing.is_some() {
754                self.emit_trailing_comment(e.trivia.trailing.as_deref());
755            }
756            any_header = true;
757        }
758        if any_header && !a.items.is_empty() {
759            self.newline();
760        }
761        let mut first = true;
762        for item in &a.items {
763            if !first {
764                self.newline();
765            }
766            self.format_item(item);
767            first = false;
768        }
769        if !a.trailing_comments.is_empty() {
770            if !a.items.is_empty() || any_header {
771                self.newline();
772            }
773            self.emit_trailing_comments(&a.trailing_comments);
774        }
775    }
776
777    fn format_test(&mut self, t: &SuiteDecl) {
778        self.emit_leading_comments(&t.trivia.leading);
779        if let Some(doc) = &t.documentation {
780            self.emit_doc(doc);
781        }
782        let mut header = format!("suite {}", t.target.joined());
783        if let Some(tier) = t.tier {
784            header.push_str(&format!(" as {}", tier.as_str()));
785        }
786        match t.form {
787            CommonsForm::Brace => {
788                self.push(&header);
789                self.push(" {");
790                self.newline();
791                self.indented(|f| {
792                    f.format_test_body(
793                        &t.uses,
794                        &t.stubs,
795                        &t.cases,
796                        &t.properties,
797                        &t.trailing_comments,
798                    );
799                });
800                self.push("}");
801                self.newline();
802            }
803            CommonsForm::Fragment => {
804                self.push(&header);
805                self.newline();
806                self.format_test_body(
807                    &t.uses,
808                    &t.stubs,
809                    &t.cases,
810                    &t.properties,
811                    &t.trailing_comments,
812                );
813            }
814        }
815    }
816
817    fn format_test_body(
818        &mut self,
819        uses: &[UsesDecl],
820        stubs: &[StubClause],
821        cases: &[Case],
822        properties: &[PropertyDecl],
823        trailing_comments: &[Comment],
824    ) {
825        let mut first = true;
826        for u in uses {
827            if !first {
828                self.newline();
829            }
830            self.emit_leading_comments(&u.trivia.leading);
831            self.push(&format!("uses {}", u.target.joined()));
832            self.emit_trailing_comment(u.trivia.trailing.as_deref());
833            self.newline();
834            first = false;
835        }
836        for pv in stubs {
837            if !first {
838                self.newline();
839            }
840            self.format_stub_clause(pv);
841            first = false;
842        }
843        for c in cases {
844            if !first {
845                self.newline();
846            }
847            self.emit_leading_comments(&c.trivia.leading);
848            if let Some(doc) = &c.documentation {
849                self.emit_doc(doc);
850            }
851            let mut ch = format!("case \"{}\"", escape_string(&c.name));
852            if let Some(tier) = c.tier {
853                ch.push_str(&format!(" as {}", tier.as_str()));
854            }
855            ch.push(' ');
856            self.push(&ch);
857            self.format_case_block(&c.body, &c.stubs);
858            self.newline();
859            first = false;
860        }
861        for p in properties {
862            if !first {
863                self.newline();
864            }
865            self.emit_leading_comments(&p.trivia.leading);
866            if let Some(doc) = &p.documentation {
867                self.emit_doc(doc);
868            }
869            self.push(&format!("property \"{}\" {{", escape_string(&p.name)));
870            self.newline();
871            self.indented(|f| f.format_for_all(&p.forall));
872            self.push("}");
873            self.newline();
874            first = false;
875        }
876        self.emit_trailing_comments(trailing_comments);
877    }
878
879    /// v0.118: format a `stub` clause as a suite- or case-body line, with
880    /// its leading comments / doc and a terminating newline (testing track
881    /// slice 6).
882    fn format_stub_clause(&mut self, pv: &StubClause) {
883        self.emit_leading_comments(&pv.trivia.leading);
884        if let Some(doc) = &pv.documentation {
885            self.emit_doc(doc);
886        }
887        self.push(&stub_clause_to_string(pv));
888        self.emit_trailing_comment_or_newline(pv.trivia.trailing.as_deref());
889    }
890
891    /// v0.118: format a `case` body, emitting its case-scoped `stub` clauses
892    /// as the leading lines inside the block, before the statements and tail.
893    /// With no case-scoped `stub` this is exactly [`Self::format_block`].
894    fn format_case_block(&mut self, b: &Block, stubs: &[StubClause]) {
895        if stubs.is_empty() {
896            self.format_block(b);
897            return;
898        }
899        self.push("{");
900        self.newline();
901        self.indented(|f| {
902            for pv in stubs {
903                f.format_stub_clause(pv);
904            }
905            for stmt in &b.statements {
906                let trivia = statement_trivia(stmt);
907                f.emit_leading_comments(&trivia.leading);
908                f.format_statement(stmt);
909                f.emit_trailing_comment_or_newline(trivia.trailing.as_deref());
910            }
911            f.emit_leading_comments(&b.tail_leading_comments);
912            // See `format_block` / #981: any `()` tail is omitted, not just an
913            // implicit one.
914            if !omit_unit_tail(b) {
915                f.format_expr(&b.tail);
916                f.newline();
917            }
918        });
919        self.push("}");
920    }
921
922    /// v0.114: format a `for all <bindings> [where <pred>] { … }` binder — the
923    /// sole body of a `property`.
924    fn format_for_all(&mut self, fa: &ForAll) {
925        let bindings = fa
926            .bindings
927            .iter()
928            .map(|b| format!("{}: {}", b.name.name, type_ref_to_string(&b.type_ref)))
929            .collect::<Vec<_>>()
930            .join(", ");
931        let mut header = format!("for all {bindings}");
932        if let Some(w) = &fa.where_pred {
933            header.push_str(&format!(" where {}", expr_to_string(w)));
934        }
935        self.push(&format!("{header} "));
936        self.format_block(&fa.body);
937        self.newline();
938    }
939
940    fn format_commons(&mut self, c: &Commons) {
941        self.emit_leading_comments(&c.trivia.leading);
942        if let Some(doc) = &c.documentation {
943            self.emit_doc(doc);
944        }
945        let header = format!("commons {}", c.name.joined());
946        match c.form {
947            CommonsForm::Brace => {
948                self.push(&header);
949                self.push(" {");
950                self.newline();
951                self.indented(|f| {
952                    f.format_commons_body(&c.uses, &c.items, &c.trailing_comments);
953                });
954                self.push("}");
955                self.newline();
956            }
957            CommonsForm::Fragment => {
958                self.push(&header);
959                self.newline();
960                self.newline();
961                self.format_commons_body(&c.uses, &c.items, &c.trailing_comments);
962            }
963        }
964    }
965
966    fn format_commons_body(
967        &mut self,
968        uses: &[UsesDecl],
969        items: &[CommonsItem],
970        trailing_comments: &[Comment],
971    ) {
972        let mut any_uses = false;
973        for u in uses {
974            self.emit_leading_comments(&u.trivia.leading);
975            self.push(&format!("uses {}", u.target.joined()));
976            self.emit_trailing_comment_or_newline(u.trivia.trailing.as_deref());
977            any_uses = true;
978        }
979        if any_uses && !items.is_empty() {
980            self.newline();
981        }
982        let mut first = true;
983        for item in items {
984            if !first {
985                self.newline();
986            }
987            self.format_item(item);
988            first = false;
989        }
990        if !trailing_comments.is_empty() {
991            // One blank line before trailing-file comments if anything
992            // came before them.
993            if !items.is_empty() || any_uses {
994                self.newline();
995            }
996            self.emit_trailing_comments(trailing_comments);
997        }
998    }
999
1000    fn format_context(&mut self, c: &Context) {
1001        self.emit_leading_comments(&c.trivia.leading);
1002        if let Some(doc) = &c.documentation {
1003            self.emit_doc(doc);
1004        }
1005        let header = format!("context {}", c.name.joined());
1006        match c.form {
1007            CommonsForm::Brace => {
1008                self.push(&header);
1009                self.push(" {");
1010                self.newline();
1011                self.indented(|f| {
1012                    f.format_context_body(
1013                        &c.uses,
1014                        &c.consumes,
1015                        &c.exports,
1016                        &c.items,
1017                        &c.trailing_comments,
1018                    );
1019                });
1020                self.push("}");
1021                self.newline();
1022            }
1023            CommonsForm::Fragment => {
1024                self.push(&header);
1025                self.newline();
1026                self.newline();
1027                self.format_context_body(
1028                    &c.uses,
1029                    &c.consumes,
1030                    &c.exports,
1031                    &c.items,
1032                    &c.trailing_comments,
1033                );
1034            }
1035        }
1036    }
1037
1038    /// Print one `consumes` clause in any of its three forms: whole-unit,
1039    /// aliased, or braced capability selection (v0.17 §3.3 — previously the
1040    /// braced form was silently dropped, a semantic-changing format).
1041    fn format_consumes(&mut self, c: &ConsumesDecl) {
1042        self.emit_leading_comments(&c.trivia.leading);
1043        match (&c.alias, &c.selected) {
1044            (Some(alias), _) => {
1045                self.push(&format!("consumes {} as {}", c.target.joined(), alias.name))
1046            }
1047            (None, Some(selected)) if selected.is_empty() => {
1048                self.push(&format!("consumes {} {{ }}", c.target.joined()));
1049            }
1050            (None, Some(selected)) => {
1051                let names: Vec<&str> = selected.iter().map(|i| i.name.as_str()).collect();
1052                self.push(&format!(
1053                    "consumes {} {{ {} }}",
1054                    c.target.joined(),
1055                    names.join(", ")
1056                ));
1057            }
1058            (None, None) => self.push(&format!("consumes {}", c.target.joined())),
1059        }
1060        self.emit_trailing_comment_or_newline(c.trivia.trailing.as_deref());
1061    }
1062
1063    fn format_context_body(
1064        &mut self,
1065        uses: &[UsesDecl],
1066        consumes: &[ConsumesDecl],
1067        exports: &[ExportsDecl],
1068        items: &[CommonsItem],
1069        trailing_comments: &[Comment],
1070    ) {
1071        let mut any_header = false;
1072        for u in uses {
1073            self.emit_leading_comments(&u.trivia.leading);
1074            self.push(&format!("uses {}", u.target.joined()));
1075            self.emit_trailing_comment_or_newline(u.trivia.trailing.as_deref());
1076            any_header = true;
1077        }
1078        for c in consumes {
1079            self.format_consumes(c);
1080            any_header = true;
1081        }
1082        for e in exports {
1083            self.emit_leading_comments(&e.trivia.leading);
1084            self.format_exports(e);
1085            // exports may emit multi-line — the trailing comment goes on
1086            // its last line. Since format_exports already terminates with
1087            // a newline, splice the comment before it if present.
1088            if e.trivia.trailing.is_some() {
1089                self.emit_trailing_comment(e.trivia.trailing.as_deref());
1090            }
1091            any_header = true;
1092        }
1093        if any_header && !items.is_empty() {
1094            self.newline();
1095        }
1096        let mut first = true;
1097        for item in items {
1098            if !first {
1099                self.newline();
1100            }
1101            self.format_item(item);
1102            first = false;
1103        }
1104        if !trailing_comments.is_empty() {
1105            if !items.is_empty() || any_header {
1106                self.newline();
1107            }
1108            self.emit_trailing_comments(trailing_comments);
1109        }
1110    }
1111
1112    fn format_exports(&mut self, e: &ExportsDecl) {
1113        let vis = match e.kind {
1114            ExportKind::Type(Visibility::Opaque) => "opaque",
1115            ExportKind::Type(Visibility::Transparent) => "transparent",
1116            ExportKind::Capability => "capability",
1117        };
1118        // #1797: a comment anywhere in the list forces the multi-line form.
1119        let has_comments = !e.trailing_comments.is_empty()
1120            || e.names
1121                .iter()
1122                .any(|n| !n.trivia.leading.is_empty() || n.trivia.trailing.is_some());
1123        if e.names.is_empty() && !has_comments {
1124            self.push(&format!("exports {} {{}}", vis));
1125            self.newline();
1126            return;
1127        }
1128        // Single-line form if it fits.
1129        if !has_comments {
1130            let oneline = format!(
1131                "exports {} {{ {} }}",
1132                vis,
1133                e.names
1134                    .iter()
1135                    .map(|n| n.name.name.as_str())
1136                    .collect::<Vec<_>>()
1137                    .join(", ")
1138            );
1139            if self.fits(&oneline, 0) {
1140                self.push(&oneline);
1141                self.newline();
1142                return;
1143            }
1144        }
1145        // Multi-line form.
1146        self.push(&format!("exports {} {{", vis));
1147        self.newline();
1148        self.indented(|f| {
1149            for (i, n) in e.names.iter().enumerate() {
1150                f.emit_leading_comments(&n.trivia.leading);
1151                f.push(&n.name.name);
1152                if i + 1 < e.names.len() || f.opts.trailing_comma {
1153                    f.push(",");
1154                }
1155                f.emit_trailing_comment(n.trivia.trailing.as_deref());
1156                if n.trivia.trailing.is_none() {
1157                    f.newline();
1158                }
1159            }
1160            f.emit_trailing_comments(&e.trailing_comments);
1161        });
1162        self.push("}");
1163        self.newline();
1164    }
1165
1166    /// The rendered width of one indent level. A tab is counted as four
1167    /// columns for width estimation (the file stores one byte; editors render
1168    /// it at the reader's chosen width, so any fixed number is an estimate).
1169    fn indent_width(&self) -> usize {
1170        match self.opts.indent {
1171            IndentStyle::Tab => 4,
1172            IndentStyle::Spaces(n) => n as usize,
1173        }
1174    }
1175
1176    /// The column the next character pushed would land on. Everything already
1177    /// emitted on the current line counts — #963: measuring only
1178    /// `indent_level` (as this did before) made every fit test blind to the
1179    /// prefix its caller had already printed, so a body measured as "fits"
1180    /// while sitting behind `fn name(params) -> Ret ` routinely overflowed.
1181    fn current_column(&self) -> usize {
1182        if self.at_line_start {
1183            // `push` has yet to emit this line's indent; account for it here.
1184            return self.indent_level as usize * self.indent_width();
1185        }
1186        let line = match self.out.rfind('\n') {
1187            Some(i) => &self.out[i + 1..],
1188            None => self.out.as_str(),
1189        };
1190        display_width(line, self.indent_width())
1191    }
1192
1193    /// Does `candidate`, emitted at the current column, leave `reserve`
1194    /// further columns inside the line budget? `reserve` is the width of text
1195    /// the caller knows will follow on the same line — a closing `)`, a
1196    /// `-> Ret {` suffix, an arm's `,`. A multi-line candidate never "fits":
1197    /// its own line breaks are the decision the caller is trying to make.
1198    fn fits(&self, candidate: &str, reserve: usize) -> bool {
1199        if candidate.contains('\n') {
1200            return false;
1201        }
1202        let column =
1203            self.current_column() + display_width(candidate, self.indent_width()) + reserve;
1204        column <= self.opts.max_line_width as usize
1205    }
1206
1207    fn format_item(&mut self, item: &CommonsItem) {
1208        match item {
1209            CommonsItem::Type(t) => self.format_type_decl(t),
1210            CommonsItem::Fn(f) => self.format_fn_decl(f),
1211            CommonsItem::Capability(c) => self.format_capability(c),
1212            CommonsItem::Provider(p) => self.format_provider(p),
1213            CommonsItem::Service(s) => self.format_service(s),
1214            CommonsItem::Agent(a) => self.format_agent(a),
1215            CommonsItem::Actor(a) => self.format_actor(a),
1216            CommonsItem::Messages(m) => self.format_messages(m),
1217            CommonsItem::Event(e) => self.format_event_decl(e),
1218        }
1219    }
1220
1221    fn format_event_decl(&mut self, e: &EventDecl) {
1222        self.emit_leading_comments(&e.trivia.leading);
1223        if let Some(doc) = &e.documentation {
1224            self.emit_doc(doc);
1225        }
1226        self.push(&format!("event {}", e.name.name));
1227        // Events slice 3b (#978): an optional `@schema(N)` (and any other
1228        // future event annotation) — same loop as `format_messages`'s.
1229        for ann in &e.annotations {
1230            self.push(" ");
1231            self.push(&annotation_to_string(ann));
1232        }
1233        self.push(" = ");
1234        self.format_record_body(&e.body);
1235        self.emit_trailing_comment_or_newline(e.trivia.trailing.as_deref());
1236    }
1237
1238    fn format_messages(&mut self, m: &MessagesDecl) {
1239        self.emit_leading_comments(&m.trivia.leading);
1240        if let Some(doc) = &m.documentation {
1241            self.emit_doc(doc);
1242        }
1243        self.push(&format!("messages \"{}\"", escape_string(&m.tag)));
1244        for ann in &m.annotations {
1245            self.push(" ");
1246            self.push(&annotation_to_string(ann));
1247        }
1248        self.push(" {");
1249        self.newline();
1250        self.indented(|f| {
1251            for entry in &m.entries {
1252                f.push(&format!(
1253                    "\"{}\" => \"{}\"",
1254                    escape_string(&entry.code),
1255                    escape_string(&entry.template)
1256                ));
1257                f.newline();
1258            }
1259        });
1260        self.push("}");
1261        self.emit_trailing_comment_or_newline(m.trivia.trailing.as_deref());
1262    }
1263
1264    // -- Type declarations --
1265
1266    fn format_type_decl(&mut self, t: &TypeDecl) {
1267        self.emit_leading_comments(&t.trivia.leading);
1268        if let Some(doc) = &t.documentation {
1269            self.emit_doc(doc);
1270        }
1271        // v0.157 (ADR 0183): `[A, B]` type parameters, spelled as on a function.
1272        let params = if t.type_params.is_empty() {
1273            String::new()
1274        } else {
1275            let names: Vec<&str> = t
1276                .type_params
1277                .iter()
1278                .map(|tp| tp.name.name.as_str())
1279                .collect();
1280            format!("[{}]", names.join(", "))
1281        };
1282        self.push(&format!("type {}{} = ", t.name.name, params));
1283        self.format_type_body(&t.body);
1284        self.emit_trailing_comment_or_newline(t.trivia.trailing.as_deref());
1285    }
1286
1287    fn format_type_body(&mut self, body: &TypeBody) {
1288        match body {
1289            TypeBody::Refined {
1290                base, refinement, ..
1291            } => {
1292                self.push(base.name());
1293                if let Some(r) = refinement {
1294                    self.push(" where ");
1295                    self.format_refinement(r);
1296                }
1297            }
1298            TypeBody::Opaque {
1299                base, refinement, ..
1300            } => {
1301                self.push("opaque ");
1302                self.push(base.name());
1303                if let Some(r) = refinement {
1304                    self.push(" where ");
1305                    self.format_refinement(r);
1306                }
1307            }
1308            TypeBody::Record(r) => self.format_record_body(r),
1309            TypeBody::Sum(s) => self.format_sum_body(s),
1310        }
1311    }
1312
1313    fn format_refinement(&mut self, r: &Refinement) {
1314        for (i, p) in r.predicates.iter().enumerate() {
1315            if i > 0 {
1316                self.push(" && ");
1317            }
1318            self.format_pred(p);
1319        }
1320    }
1321
1322    fn format_pred(&mut self, p: &RefinementPred) {
1323        match &p.kind {
1324            PredKind::Matches(re) => self.push(&format!("Matches(\"{}\")", escape_string(re))),
1325            PredKind::InRange(a, b) => self.push(&format!("InRange({}, {})", a.value, b.value)),
1326            PredKind::InRangeF(a, b) => self.push(&format!("InRange({}, {})", a.lexeme, b.lexeme)),
1327            PredKind::MinLength(n) => self.push(&format!("MinLength({n})")),
1328            PredKind::MaxLength(n) => self.push(&format!("MaxLength({n})")),
1329            PredKind::Length(n) => self.push(&format!("Length({n})")),
1330            PredKind::NonNegative => self.push("NonNegative"),
1331            PredKind::Positive => self.push("Positive"),
1332            PredKind::NonEmpty => self.push("NonEmpty"),
1333        }
1334    }
1335
1336    fn format_record_body(&mut self, r: &RecordBody) {
1337        let has_comments = !r.trailing_comments.is_empty()
1338            || r.fields
1339                .iter()
1340                .any(|f| !f.trivia.leading.is_empty() || f.trivia.trailing.is_some());
1341        if r.fields.is_empty() && !has_comments {
1342            self.push("{}");
1343            return;
1344        }
1345        // Try single-line first; a comment forces the multi-line form.
1346        if !has_comments {
1347            let oneline_fields: Vec<String> = r
1348                .fields
1349                .iter()
1350                .map(|f| self.format_record_field_oneline(f))
1351                .collect();
1352            let oneline = format!("{{ {} }}", oneline_fields.join(", "));
1353            if self.fits(&oneline, 0) {
1354                self.push(&oneline);
1355                return;
1356            }
1357        }
1358        // Multi-line.
1359        self.push("{");
1360        self.newline();
1361        self.indented(|f| {
1362            for (i, field) in r.fields.iter().enumerate() {
1363                f.emit_leading_comments(&field.trivia.leading);
1364                f.format_record_field(field);
1365                if i + 1 < r.fields.len() || f.opts.trailing_comma {
1366                    f.push(",");
1367                }
1368                f.emit_trailing_comment_or_newline(field.trivia.trailing.as_deref());
1369            }
1370            f.emit_trailing_comments(&r.trailing_comments);
1371        });
1372        self.push("}");
1373    }
1374
1375    fn format_record_field(&mut self, field: &RecordField) {
1376        self.push(&format!("{}: ", field.name.name));
1377        self.format_type_ref(&field.type_ref);
1378        if let Some(r) = &field.refinement {
1379            self.push(" where ");
1380            self.format_refinement(r);
1381        }
1382        if let Some(init) = &field.init {
1383            self.push(" = ");
1384            self.format_expr(init);
1385        }
1386    }
1387
1388    fn format_record_field_oneline(&self, field: &RecordField) -> String {
1389        let mut out = format!("{}: ", field.name.name);
1390        out.push_str(&type_ref_to_string(&field.type_ref));
1391        if let Some(r) = &field.refinement {
1392            out.push_str(" where ");
1393            out.push_str(&refinement_to_string(r));
1394        }
1395        if let Some(init) = &field.init {
1396            out.push_str(" = ");
1397            out.push_str(&expr_to_string(init));
1398        }
1399        out
1400    }
1401
1402    fn format_sum_body(&mut self, s: &SumBody) {
1403        // Two surface forms exist; we render the pipe form (clearest for both
1404        // variants with and without payload). enum form is only meaningful for
1405        // payloadless variants — round-trip preserves semantics either way.
1406        let any_payload = s.variants.iter().any(|v| !v.payload.is_empty());
1407        if !any_payload {
1408            // Enum-style. #1794: a comment forces the multi-line form, as in
1409            // a record body.
1410            let has_comments = !s.trailing_comments.is_empty()
1411                || s.variants
1412                    .iter()
1413                    .any(|v| !v.trivia.leading.is_empty() || v.trivia.trailing.is_some());
1414            if !has_comments {
1415                let names: Vec<&str> = s.variants.iter().map(|v| v.name.name.as_str()).collect();
1416                let oneline = format!("enum {{ {} }}", names.join(", "));
1417                if self.fits(&oneline, 0) {
1418                    self.push(&oneline);
1419                    return;
1420                }
1421            }
1422            self.push("enum {");
1423            self.newline();
1424            self.indented(|f| {
1425                for (i, v) in s.variants.iter().enumerate() {
1426                    f.emit_leading_comments(&v.trivia.leading);
1427                    f.push(&v.name.name);
1428                    if i + 1 < s.variants.len() || f.opts.trailing_comma {
1429                        f.push(",");
1430                    }
1431                    f.emit_trailing_comment(v.trivia.trailing.as_deref());
1432                    if v.trivia.trailing.is_none() {
1433                        f.newline();
1434                    }
1435                }
1436                f.emit_trailing_comments(&s.trailing_comments);
1437            });
1438            self.push("}");
1439            return;
1440        }
1441        // Pipe form, multi-line. #1794: a variant's end-of-line comment ends
1442        // its line, so the next variant needs no newline of its own.
1443        for (i, v) in s.variants.iter().enumerate() {
1444            if i == 0 && !v.trivia.leading.is_empty() {
1445                // A comment printed after `type S = ` would trail the `=`:
1446                // break the line after it instead.
1447                while self.out.ends_with(' ') {
1448                    self.out.pop();
1449                }
1450                self.newline();
1451            } else if i > 0 && s.variants[i - 1].trivia.trailing.is_none() {
1452                self.newline();
1453            }
1454            self.emit_leading_comments(&v.trivia.leading);
1455            self.push("| ");
1456            self.push(&v.name.name);
1457            if !v.payload.is_empty() {
1458                self.push("(");
1459                let parts: Vec<String> = v
1460                    .payload
1461                    .iter()
1462                    .map(|p| format!("{}: {}", p.name.name, type_ref_to_string(&p.type_ref)))
1463                    .collect();
1464                self.push(&parts.join(", "));
1465                self.push(")");
1466            }
1467            self.emit_trailing_comment(v.trivia.trailing.as_deref());
1468        }
1469        // v0.154 (ADR 0178): the trailing `embeds E as V, …` clause, on its own
1470        // line under the variants and the comments above it (#1794). Only a
1471        // variant followed by `embeds` can be last with a comment on its line.
1472        if !s.embeds.is_empty() {
1473            if s.variants
1474                .last()
1475                .is_none_or(|v| v.trivia.trailing.is_none())
1476            {
1477                self.newline();
1478            }
1479            self.emit_trailing_comments(&s.trailing_comments);
1480            let parts: Vec<String> = s
1481                .embeds
1482                .iter()
1483                .map(|e| {
1484                    format!(
1485                        "{} as {}",
1486                        type_ref_to_string(&e.source_type),
1487                        e.variant.name
1488                    )
1489                })
1490                .collect();
1491            self.push(&format!("embeds {}", parts.join(", ")));
1492        }
1493    }
1494
1495    fn format_type_ref(&mut self, t: &TypeRef) {
1496        self.push(&type_ref_to_string(t));
1497    }
1498
1499    // -- Function declarations --
1500
1501    fn format_fn_decl(&mut self, f: &FnDecl) {
1502        self.emit_leading_comments(&f.trivia.leading);
1503        if let Some(doc) = &f.documentation {
1504            self.emit_doc(doc);
1505        }
1506        self.push("fn ");
1507        self.push(&f.name.display());
1508        // v0.20a: `[A, B]` type parameters.
1509        if !f.type_params.is_empty() {
1510            let names: Vec<&str> = f
1511                .type_params
1512                .iter()
1513                .map(|tp| tp.name.name.as_str())
1514                .collect();
1515            self.push(&format!("[{}]", names.join(", ")));
1516        }
1517        // The signature tail that shares the parameter list's line: ` -> Ret`
1518        // plus the body's ` {` (a contract clause moves the body to its own
1519        // line, so only the return type counts then).
1520        let tail = format!(" -> {}", type_ref_to_string(&f.return_type));
1521        let reserve = if f.requires.is_empty() && f.ensures.is_empty() {
1522            tail.chars().count() + " {".len()
1523        } else {
1524            tail.chars().count()
1525        };
1526        self.format_params(&f.params, f.has_self, reserve);
1527        self.push(" -> ");
1528        self.format_type_ref(&f.return_type);
1529        // v0.115: contract clauses on their own indented lines between the
1530        // return type and the body (`requires`/`ensures <name>: <pred>`).
1531        if f.requires.is_empty() && f.ensures.is_empty() {
1532            self.push(" ");
1533        } else {
1534            self.newline();
1535            self.indented(|f2| {
1536                for c in &f.requires {
1537                    f2.push(&format!(
1538                        "requires {}: {}",
1539                        c.name.name,
1540                        expr_to_string(&c.predicate)
1541                    ));
1542                    f2.newline();
1543                }
1544                for c in &f.ensures {
1545                    f2.push(&format!(
1546                        "ensures {}: {}",
1547                        c.name.name,
1548                        expr_to_string(&c.predicate)
1549                    ));
1550                    f2.newline();
1551                }
1552            });
1553        }
1554        self.format_block(&f.body);
1555        self.emit_trailing_comment_or_newline(f.trivia.trailing.as_deref());
1556    }
1557
1558    /// Emit a parameter list. `reserve` is the width of the signature tail that
1559    /// will follow it on the same line — `-> Ret`, any `by`/`given` clauses,
1560    /// and the body's opening brace — so a list that only "fits" by ignoring
1561    /// what comes after it wraps instead (#963).
1562    fn format_params(&mut self, params: &[Param], has_self: bool, reserve: usize) {
1563        let mut rendered: Vec<String> = Vec::new();
1564        if has_self {
1565            rendered.push("self".to_string());
1566        }
1567        // `params` never includes `self` — it is tracked separately via the
1568        // `has_self` flag (see parser.rs parse_fn_decl).
1569        for p in params {
1570            rendered.push(format!(
1571                "{}: {}",
1572                p.name.name,
1573                type_ref_to_string(&p.type_ref)
1574            ));
1575        }
1576        let oneline = format!("({})", rendered.join(", "));
1577        // An empty list has nothing to wrap onto; `()` always stays put.
1578        if rendered.is_empty() || self.fits(&oneline, reserve) {
1579            self.push(&oneline);
1580            return;
1581        }
1582        // Wrapping moves the reserved tail onto the `)` line. When that line
1583        // would overflow anyway — a `given` list long enough on its own — the
1584        // wrap costs lines and buys nothing, so keep the single-line form.
1585        let closing_line = self.indent_level as usize * self.indent_width() + 1 + reserve;
1586        if closing_line > self.opts.max_line_width as usize {
1587            self.push(&oneline);
1588            return;
1589        }
1590        // Multi-line params.
1591        self.push("(");
1592        self.newline();
1593        self.indented(|f| {
1594            for (i, r) in rendered.iter().enumerate() {
1595                f.push(r);
1596                // Parameter lists — unlike records, enum/sum variants, agent
1597                // state fields and exports — do NOT accept a trailing comma in
1598                // the grammar, so never emit one here regardless of the
1599                // `trailing_comma` option, or the wrapped output fails to
1600                // re-parse.
1601                if i + 1 < rendered.len() {
1602                    f.push(",");
1603                }
1604                f.newline();
1605            }
1606        });
1607        self.push(")");
1608    }
1609
1610    // -- Capability / provider / service / agent (v0.5) --
1611
1612    fn format_capability(&mut self, c: &CapabilityDecl) {
1613        self.emit_leading_comments(&c.trivia.leading);
1614        if let Some(doc) = &c.documentation {
1615            self.emit_doc(doc);
1616        }
1617        self.push(&format!("capability {} {{", c.name.name));
1618        self.newline();
1619        self.indented(|f| {
1620            for op in &c.ops {
1621                f.emit_leading_comments(&op.trivia.leading);
1622                if let Some(doc) = &op.documentation {
1623                    f.emit_doc(doc);
1624                }
1625                f.push("fn ");
1626                f.push(&op.name.name);
1627                // #926: `[T, …]` type parameters on the op itself.
1628                if !op.type_params.is_empty() {
1629                    let names: Vec<&str> = op
1630                        .type_params
1631                        .iter()
1632                        .map(|tp| tp.name.name.as_str())
1633                        .collect();
1634                    f.push(&format!("[{}]", names.join(", ")));
1635                }
1636                let reserve = 4 + type_ref_to_string(&op.return_type).chars().count();
1637                f.format_params(&op.params, false, reserve);
1638                f.push(" -> ");
1639                f.format_type_ref(&op.return_type);
1640                f.emit_trailing_comment_or_newline(op.trivia.trailing.as_deref());
1641            }
1642            // #1756: the comments before the closing `}`, after a blank line.
1643            if !c.trailing_comments.is_empty() {
1644                if !c.ops.is_empty() {
1645                    f.newline();
1646                }
1647                f.emit_trailing_comments(&c.trailing_comments);
1648            }
1649        });
1650        self.push("}");
1651        self.emit_trailing_comment_or_newline(c.trivia.trailing.as_deref());
1652    }
1653
1654    fn format_provider(&mut self, p: &ProviderDecl) {
1655        self.emit_leading_comments(&p.trivia.leading);
1656        if let Some(doc) = &p.documentation {
1657            self.emit_doc(doc);
1658        }
1659        self.push(&format!(
1660            "provides {} = {}",
1661            p.capability.name, p.provider_name.name
1662        ));
1663        if !p.given.is_empty() {
1664            self.push(" given ");
1665            let names: Vec<String> = p.given.iter().map(cap_ref_src).collect();
1666            self.push(&names.join(", "));
1667        }
1668        // v0.17: an external provider (inside an adapter) has no body.
1669        if p.external {
1670            self.emit_trailing_comment_or_newline(p.trivia.trailing.as_deref());
1671            return;
1672        }
1673        self.push(" {");
1674        self.newline();
1675        self.indented(|f| {
1676            for (i, op) in p.ops.iter().enumerate() {
1677                if i > 0 {
1678                    f.newline();
1679                }
1680                f.emit_leading_comments(&op.trivia.leading);
1681                f.push("fn ");
1682                f.push(&op.name.name);
1683                let reserve = 4 + type_ref_to_string(&op.return_type).chars().count() + 2;
1684                f.format_params(&op.params, false, reserve);
1685                f.push(" -> ");
1686                f.format_type_ref(&op.return_type);
1687                f.push(" ");
1688                f.format_block(&op.body);
1689                f.emit_trailing_comment_or_newline(op.trivia.trailing.as_deref());
1690            }
1691        });
1692        self.push("}");
1693        self.emit_trailing_comment_or_newline(p.trivia.trailing.as_deref());
1694    }
1695
1696    fn format_service(&mut self, s: &ServiceDecl) {
1697        self.emit_leading_comments(&s.trivia.leading);
1698        if let Some(doc) = &s.documentation {
1699            self.emit_doc(doc);
1700        }
1701        let from = match &s.protocol {
1702            ServiceProtocol::Call => String::new(),
1703            ServiceProtocol::Http => " from http".to_string(),
1704            ServiceProtocol::Cron => " from cron".to_string(),
1705            ServiceProtocol::Queue { name } => {
1706                format!(" from queue(\"{}\")", escape_string(name))
1707            }
1708            ServiceProtocol::WebSocket { in_type, out_type } => {
1709                format!(
1710                    " from websocket(in: {}, out: {})",
1711                    type_ref_to_string(in_type),
1712                    type_ref_to_string(out_type)
1713                )
1714            }
1715            ServiceProtocol::Events {
1716                event_type,
1717                pattern,
1718                schema_dispatch,
1719            } => {
1720                let header = match pattern {
1721                    Some(p) => format!(
1722                        " from Events({} {})",
1723                        type_ref_to_string(event_type),
1724                        event_pattern_src(p)
1725                    ),
1726                    None => format!(" from Events({})", type_ref_to_string(event_type)),
1727                };
1728                match schema_dispatch {
1729                    Some(d) => format!("{header} {}", schema_dispatch_src(d)),
1730                    None => header,
1731                }
1732            }
1733        };
1734        // v0.155: the optional service-level `by`/`given` defaults follow the
1735        // protocol on the header, `by` first — the ambient contract every handler
1736        // inherits unless it declares its own.
1737        let mut header = format!("service {}{}", s.name.name, from);
1738        if let Some(by) = &s.default_by {
1739            header.push_str(&format!(" {}", by_clause_src(by)));
1740        }
1741        if !s.default_given.is_empty() {
1742            let names: Vec<String> = s.default_given.iter().map(cap_ref_src).collect();
1743            header.push_str(&format!(" given {}", names.join(", ")));
1744        }
1745        self.push(&format!("{header} {{"));
1746        self.newline();
1747        self.indented(|f| {
1748            // v0.131/v0.141/v0.142: the CORS, security, and limits policies are
1749            // header-position sections, before the handlers (mirroring the agent
1750            // phase order). A canonical order — `cors`, then `security`, then
1751            // `limits` — with a blank line between each section.
1752            if let Some(cors) = &s.cors {
1753                f.format_cors_policy(cors);
1754                if s.security.is_some() || s.limits.is_some() || !s.handlers.is_empty() {
1755                    f.newline();
1756                }
1757            }
1758            if let Some(security) = &s.security {
1759                f.format_security_policy(security);
1760                if s.limits.is_some() || !s.handlers.is_empty() {
1761                    f.newline();
1762                }
1763            }
1764            if let Some(limits) = &s.limits {
1765                f.format_limits_policy(limits);
1766                if !s.handlers.is_empty() {
1767                    f.newline();
1768                }
1769            }
1770            for (i, h) in s.handlers.iter().enumerate() {
1771                if i > 0 {
1772                    f.newline();
1773                }
1774                f.format_handler(h);
1775            }
1776            // #1756: the comments before the closing `}`, after a blank line.
1777            if !s.trailing_comments.is_empty() {
1778                if !s.handlers.is_empty()
1779                    || s.cors.is_some()
1780                    || s.security.is_some()
1781                    || s.limits.is_some()
1782                {
1783                    f.newline();
1784                }
1785                f.emit_trailing_comments(&s.trailing_comments);
1786            }
1787        });
1788        self.push("}");
1789        self.emit_trailing_comment_or_newline(s.trivia.trailing.as_deref());
1790    }
1791
1792    /// Format a `cors { }` policy section (v0.131). One `name: value` field per
1793    /// line, with a trailing comma, mirroring a record construction.
1794    fn format_cors_policy(&mut self, cors: &CorsPolicy) {
1795        let fields: Vec<_> = cors
1796            .fields
1797            .iter()
1798            .map(|f| (&f.name, &f.value, &f.trivia))
1799            .collect();
1800        self.format_policy("cors", &cors.trivia, &fields, &cors.trailing_comments);
1801    }
1802
1803    /// Format a `security { }` policy section (v0.141), as `format_cors_policy`.
1804    fn format_security_policy(&mut self, security: &SecurityPolicy) {
1805        let fields: Vec<_> = security
1806            .fields
1807            .iter()
1808            .map(|f| (&f.name, &f.value, &f.trivia))
1809            .collect();
1810        self.format_policy(
1811            "security",
1812            &security.trivia,
1813            &fields,
1814            &security.trailing_comments,
1815        );
1816    }
1817
1818    /// Format a `limits { }` policy section (v0.142), as `format_cors_policy`. A
1819    /// `maxBody` value keeps its as-written `_` digit separators (the `IntLit`
1820    /// lexeme).
1821    fn format_limits_policy(&mut self, limits: &LimitsPolicy) {
1822        let fields: Vec<_> = limits
1823            .fields
1824            .iter()
1825            .map(|f| (&f.name, &f.value, &f.trivia))
1826            .collect();
1827        self.format_policy("limits", &limits.trivia, &fields, &limits.trailing_comments);
1828    }
1829
1830    /// A `cors`/`security`/`limits` policy: `keyword {`, one `name: value,` field
1831    /// per line, `}`. #1786: each field's comments, the comments before `}`, and
1832    /// the comment after it are printed in place.
1833    fn format_policy(
1834        &mut self,
1835        keyword: &str,
1836        trivia: &Trivia,
1837        fields: &[(&Ident, &Expr, &Trivia)],
1838        trailing_comments: &[Comment],
1839    ) {
1840        self.emit_leading_comments(&trivia.leading);
1841        self.push(&format!("{keyword} {{"));
1842        self.newline();
1843        self.indented(|f| {
1844            for (name, value, field_trivia) in fields {
1845                f.emit_leading_comments(&field_trivia.leading);
1846                f.push(&format!("{}: ", name.name));
1847                f.format_expr_at(value, 0, 1);
1848                f.push(",");
1849                f.emit_trailing_comment_or_newline(field_trivia.trailing.as_deref());
1850            }
1851            f.emit_trailing_comments(trailing_comments);
1852        });
1853        self.push("}");
1854        self.emit_trailing_comment_or_newline(trivia.trailing.as_deref());
1855    }
1856
1857    fn format_agent(&mut self, a: &AgentDecl) {
1858        self.emit_leading_comments(&a.trivia.leading);
1859        if let Some(doc) = &a.documentation {
1860            self.emit_doc(doc);
1861        }
1862        self.push(&format!("agent {} {{", a.name.name));
1863        self.newline();
1864        self.indented(|f| {
1865            // key
1866            f.emit_leading_comments(&a.key_trivia.leading);
1867            f.push(&format!(
1868                "key {}: {}",
1869                a.key_name.name,
1870                type_ref_to_string(&a.key_type)
1871            ));
1872            f.emit_trailing_comment_or_newline(a.key_trivia.trailing.as_deref());
1873            f.newline();
1874            // storage (v0.81, storage track): the agent's `store` fields.
1875            for sf in &a.store_fields {
1876                f.format_store_field(sf);
1877                f.newline();
1878            }
1879            // v0.80: invariants form a phase between the storage fields and the
1880            // handlers.
1881            for inv in &a.invariants {
1882                f.newline();
1883                f.format_invariant(inv);
1884            }
1885            // v0.116: step invariants form part of the same phase, beside the
1886            // snapshot invariants.
1887            for tr in &a.transitions {
1888                f.newline();
1889                f.format_transition(tr);
1890            }
1891            // handlers
1892            for h in &a.handlers {
1893                f.newline();
1894                f.format_handler(h);
1895            }
1896            // #1756: the comments before the closing `}`, after a blank line.
1897            if !a.trailing_comments.is_empty() {
1898                f.newline();
1899                f.emit_trailing_comments(&a.trailing_comments);
1900            }
1901        });
1902        self.push("}");
1903        self.emit_trailing_comment_or_newline(a.trivia.trailing.as_deref());
1904    }
1905
1906    /// Format a `store` field (v0.81): `store <name>: <Kind> [= <init>]`, with
1907    /// its leading comments / doc and trailing comment. The enclosing loop adds
1908    /// the line break.
1909    fn format_store_field(&mut self, sf: &StoreField) {
1910        self.emit_leading_comments(&sf.trivia.leading);
1911        if let Some(doc) = &sf.documentation {
1912            self.emit_doc(doc);
1913        }
1914        self.push(&format!(
1915            "store {}: {}",
1916            sf.name.name,
1917            store_kind_to_string(&sf.kind)
1918        ));
1919        // v0.85 (ADR 0111): annotations follow the kind, one space-separated each.
1920        for ann in &sf.annotations {
1921            self.push(&format!(" {}", annotation_to_string(ann)));
1922        }
1923        if let Some(init) = &sf.init {
1924            self.push(" = ");
1925            self.format_expr(init);
1926        }
1927        self.emit_trailing_comment(sf.trivia.trailing.as_deref());
1928    }
1929
1930    /// Format an agent invariant (v0.80): the name on one line, the predicate
1931    /// indented beneath, matching the §14 worked examples.
1932    fn format_invariant(&mut self, inv: &Invariant) {
1933        self.emit_leading_comments(&inv.trivia.leading);
1934        if let Some(doc) = &inv.documentation {
1935            self.emit_doc(doc);
1936        }
1937        self.push(&format!("invariant {}:", inv.name.name));
1938        self.newline();
1939        self.indented(|f| {
1940            f.format_expr(&inv.predicate);
1941        });
1942        self.emit_trailing_comment_or_newline(inv.trivia.trailing.as_deref());
1943    }
1944
1945    /// Format an agent step invariant (v0.116): `transition <name>:` with the
1946    /// `old`/`new` predicate indented beneath, mirroring [`format_invariant`].
1947    fn format_transition(&mut self, tr: &Transition) {
1948        self.emit_leading_comments(&tr.trivia.leading);
1949        if let Some(doc) = &tr.documentation {
1950            self.emit_doc(doc);
1951        }
1952        self.push(&format!("transition {}:", tr.name.name));
1953        self.newline();
1954        self.indented(|f| {
1955            f.format_expr(&tr.predicate);
1956        });
1957        self.emit_trailing_comment_or_newline(tr.trivia.trailing.as_deref());
1958    }
1959
1960    fn format_actor(&mut self, a: &ActorDecl) {
1961        self.emit_leading_comments(&a.trivia.leading);
1962        if let Some(doc) = &a.documentation {
1963            self.emit_doc(doc);
1964        }
1965        if let Some(r) = &a.refinement {
1966            // Reserved refinement form: `actor Name = Base where <predicate>`.
1967            self.push(&format!(
1968                "actor {} = {} where {}",
1969                a.name.name,
1970                r.base.name,
1971                expr_to_string(&r.predicate)
1972            ));
1973        } else {
1974            // Normal form: `actor Name { auth = Scheme(, identity = Type)? }`.
1975            let auth = a.auth.as_ref().map(|i| i.name.as_str()).unwrap_or("None");
1976            let args: Vec<String> = a
1977                .auth_config
1978                .iter()
1979                .map(|arg| match &arg.value {
1980                    bynk_syntax::ast::SchemeArgValue::Str(s) => {
1981                        format!("{} = \"{}\"", arg.key.name, escape_string(s))
1982                    }
1983                    bynk_syntax::ast::SchemeArgValue::Int(n) => {
1984                        format!("{} = {n}", arg.key.name)
1985                    }
1986                })
1987                .collect();
1988            let config = if args.is_empty() {
1989                String::new()
1990            } else {
1991                format!("({})", args.join(", "))
1992            };
1993            let identity = a.identity.as_ref().map(type_ref_to_string);
1994            let oneline = format!(
1995                "actor {} {{ auth = {auth}{config}{} }}",
1996                a.name.name,
1997                identity
1998                    .as_ref()
1999                    .map(|id| format!(", identity = {id}"))
2000                    .unwrap_or_default()
2001            );
2002            // An OIDC-style scheme carries issuer / audience / JWKS URLs that
2003            // blow past any line budget on one line (#963): open the braces and
2004            // give each scheme argument its own line. The test ignores comments,
2005            // so commenting an actor never changes how its arguments break.
2006            let break_args = !args.is_empty() && !self.fits(&oneline, 0);
2007            // #1797: a comment anywhere in the body forces the multi-line form.
2008            let has_comments = !a.trailing_comments.is_empty()
2009                || [&a.auth_trivia, &a.identity_trivia]
2010                    .iter()
2011                    .any(|t| !t.leading.is_empty() || t.trailing.is_some());
2012            if !break_args && !has_comments {
2013                self.push(&oneline);
2014            } else {
2015                self.push(&format!("actor {} {{", a.name.name));
2016                self.newline();
2017                self.indented(|f| {
2018                    f.emit_leading_comments(&a.auth_trivia.leading);
2019                    if break_args {
2020                        f.push(&format!("auth = {auth}("));
2021                        f.newline();
2022                        f.indented(|f2| {
2023                            for (i, arg) in args.iter().enumerate() {
2024                                f2.push(arg);
2025                                if i + 1 < args.len() {
2026                                    f2.push(",");
2027                                }
2028                                f2.newline();
2029                            }
2030                        });
2031                        f.push(")");
2032                    } else {
2033                        f.push(&format!("auth = {auth}{config}"));
2034                    }
2035                    if let Some(id) = &identity {
2036                        // `identity` is a sibling of `auth`, so its comma stays
2037                        // with `auth`'s line and it starts a new line.
2038                        f.push(",");
2039                        f.emit_trailing_comment(a.auth_trivia.trailing.as_deref());
2040                        if a.auth_trivia.trailing.is_none() {
2041                            f.newline();
2042                        }
2043                        f.emit_leading_comments(&a.identity_trivia.leading);
2044                        f.push(&format!("identity = {id}"));
2045                        f.emit_trailing_comment(a.identity_trivia.trailing.as_deref());
2046                        if a.identity_trivia.trailing.is_none() {
2047                            f.newline();
2048                        }
2049                    } else {
2050                        f.emit_trailing_comment(a.auth_trivia.trailing.as_deref());
2051                        if a.auth_trivia.trailing.is_none() {
2052                            f.newline();
2053                        }
2054                    }
2055                    f.emit_trailing_comments(&a.trailing_comments);
2056                });
2057                self.push("}");
2058            }
2059        }
2060        self.emit_trailing_comment_or_newline(a.trivia.trailing.as_deref());
2061    }
2062
2063    fn format_handler(&mut self, h: &Handler) {
2064        self.emit_leading_comments(&h.trivia.leading);
2065        if let Some(doc) = &h.documentation {
2066            self.emit_doc(doc);
2067        }
2068        // v0.140 (ADR 0163): handler-position annotations (`@cache(…)`) print one
2069        // per line above the `on`, mirroring how decorators read in source. Each is
2070        // rendered by the shared `annotation_to_string` used for `store` fields.
2071        for ann in &h.annotations {
2072            self.push(&annotation_to_string(ann));
2073            self.newline();
2074        }
2075        // The handler kind prefix: `on call`, `on http METHOD "path"`, or
2076        // `on cron("expr")`. Agent `on call` handlers carry a method name.
2077        match &h.kind {
2078            HandlerKind::Call => {
2079                self.push("on call");
2080                if let Some(m) = &h.method_name {
2081                    self.push(&format!(" {}", m.name));
2082                }
2083            }
2084            HandlerKind::Http { method, path } => {
2085                // Trailing space: the path string is followed by the param list,
2086                // which reads better separated (`… "/path" (params)`).
2087                self.push(&format!(
2088                    "on {}(\"{}\") ",
2089                    method.as_str(),
2090                    escape_string(path)
2091                ));
2092            }
2093            HandlerKind::Cron { expr } => {
2094                self.push(&format!("on schedule(\"{}\") ", escape_string(expr)));
2095            }
2096            HandlerKind::Message => {
2097                self.push("on message");
2098            }
2099            HandlerKind::Open => {
2100                self.push("on open");
2101            }
2102            HandlerKind::Close => {
2103                self.push("on close");
2104            }
2105            HandlerKind::Event => {
2106                self.push("on event");
2107            }
2108        }
2109        // The param list follows the kind prefix directly — `on call(params)`,
2110        // `on open(params)` — while the Http/Cron prefixes already emit a trailing
2111        // space (`on GET("/x") (params)`). (v0.155: the `by` clause no longer sits
2112        // here, so no separating space is needed.)
2113        // Everything from `-> Ret` to the body's `{` shares the parameter
2114        // list's line and none of it can wrap (the `given` list in particular
2115        // is newline-sensitive), so the whole tail is reserved up front and the
2116        // parameters are what gives (#963).
2117        let mut tail = format!(" -> {}", type_ref_to_string(&h.return_type));
2118        if let Some(by) = &h.by_clause {
2119            tail.push_str(&format!(" {}", by_clause_src(by)));
2120        }
2121        if !h.given.is_empty() {
2122            let names: Vec<String> = h.given.iter().map(cap_ref_src).collect();
2123            tail.push_str(&format!(" given {}", names.join(", ")));
2124        }
2125        self.format_params(&h.params, false, tail.chars().count() + " {".len());
2126        self.push(&tail);
2127        self.push(" ");
2128        self.format_block(&h.body);
2129        self.emit_trailing_comment_or_newline(h.trivia.trailing.as_deref());
2130    }
2131
2132    // -- Blocks, statements, expressions --
2133
2134    fn format_block(&mut self, b: &Block) {
2135        self.format_block_with_reserve(b, 0);
2136    }
2137
2138    /// Format a block, knowing that `reserve` columns of text will follow its
2139    /// closing brace on the same line (` else {` on an `if`'s then-branch, an
2140    /// arm's `,`). Only a block that fits *including* that tail stays inline.
2141    fn format_block_with_reserve(&mut self, b: &Block, reserve: usize) {
2142        // A block with no statements, no trivia, and a simple tail
2143        // expression can be emitted inline if it fits; otherwise multi-line.
2144        let tail_oneline = expr_to_string(&b.tail);
2145        let any_stmt_trivia = b.statements.iter().any(|s| !statement_trivia(s).is_empty());
2146        if b.statements.is_empty()
2147            && b.tail_leading_comments.is_empty()
2148            && !any_stmt_trivia
2149            && self.fits(&format!("{{ {tail_oneline} }}"), reserve)
2150        {
2151            self.push("{ ");
2152            self.push(&tail_oneline);
2153            self.push(" }");
2154            return;
2155        }
2156        self.format_block_multiline(b);
2157    }
2158
2159    /// The multi-line block form: brace, one statement per indented line, the
2160    /// tail expression, closing brace. Split out of [`Self::format_block`] so
2161    /// the wrapped-expression printer can force it (#963) for an `if`/lambda
2162    /// body whose single-line form would overflow.
2163    fn format_block_multiline(&mut self, b: &Block) {
2164        self.push("{");
2165        self.newline();
2166        self.indented(|f| {
2167            for stmt in &b.statements {
2168                let trivia = statement_trivia(stmt);
2169                f.emit_leading_comments(&trivia.leading);
2170                f.format_statement(stmt);
2171                f.emit_trailing_comment_or_newline(trivia.trailing.as_deref());
2172            }
2173            f.emit_leading_comments(&b.tail_leading_comments);
2174            if !omit_unit_tail(b) {
2175                f.format_expr(&b.tail);
2176                f.newline();
2177            }
2178        });
2179        self.push("}");
2180    }
2181
2182    fn format_statement(&mut self, s: &Statement) {
2183        match s {
2184            Statement::Let(l) => {
2185                self.push("let ");
2186                self.push(&l.name.name);
2187                if let Some(t) = &l.type_annot {
2188                    self.push(": ");
2189                    self.format_type_ref(t);
2190                }
2191                self.push(" = ");
2192                self.format_expr(&l.value);
2193            }
2194            Statement::EffectLet(l) => {
2195                self.push("let ");
2196                self.push(&l.name.name);
2197                if let Some(t) = &l.type_annot {
2198                    self.push(": ");
2199                    self.format_type_ref(t);
2200                }
2201                self.push(" <- ");
2202                // The `by <Actor>` clause trails the value on the same line.
2203                let principal = l
2204                    .principal
2205                    .as_ref()
2206                    .map(|p| format!(" {}", call_site_actor_src(p)));
2207                let reserve = principal.as_deref().map_or(0, |p| p.chars().count());
2208                self.format_expr_at(&l.value, 0, reserve);
2209                if let Some(principal) = principal {
2210                    self.push(&principal);
2211                }
2212            }
2213            Statement::Expect(a) => {
2214                self.push("expect ");
2215                self.format_expr(&a.value);
2216            }
2217            Statement::Send(s) => {
2218                self.push("~> ");
2219                self.format_expr(&s.value);
2220            }
2221            Statement::Do(d) => {
2222                self.push("do ");
2223                self.format_expr(&d.value);
2224            }
2225            Statement::Assign(a) => {
2226                self.push(&a.target.name);
2227                self.push(" := ");
2228                self.format_expr(&a.value);
2229            }
2230        }
2231    }
2232
2233    fn format_expr(&mut self, e: &Expr) {
2234        self.format_expr_at(e, 0, 0);
2235    }
2236
2237    /// Emit `e` at the current column, breaking it across lines when its
2238    /// single-line form would overrun the line budget (#963).
2239    ///
2240    /// `parent_prec` is the enclosing operator's precedence, exactly as in
2241    /// [`expr_with_prec`] — it decides parenthesisation. `reserve` is the width
2242    /// of text the caller will emit after this expression on the same line (a
2243    /// closing `)`, an arm's `,`, ` else {`), so a sub-expression is not judged
2244    /// to fit on the strength of a line it does not in fact end.
2245    ///
2246    /// The flat form always wins when it fits: this only ever *adds* line
2247    /// breaks, and only at points the grammar accepts (verified by the
2248    /// round-trip guard in [`format_source`]).
2249    fn format_expr_at(&mut self, e: &Expr, parent_prec: u8, reserve: usize) {
2250        // `match` renders multi-line unconditionally, so it must go through the
2251        // indent-aware emitter rather than `expr_to_string` — the latter builds
2252        // a flat string with hardcoded single-tab arms that ignores the current
2253        // nesting depth (the closing brace and every arm would land at column
2254        // one regardless of how deeply the `match` is nested).
2255        if let ExprKind::Match { discriminant, arms } = &e.kind {
2256            self.format_match(discriminant, arms);
2257            return;
2258        }
2259        let flat = expr_with_prec(e, parent_prec);
2260        if self.fits(&flat, reserve) {
2261            self.push(&flat);
2262            return;
2263        }
2264        // Too wide. Re-emit broken across lines — inside the parentheses the
2265        // flat form would have added, if precedence calls for them.
2266        if needs_parens(e, parent_prec) {
2267            self.push("(");
2268            self.format_expr_broken(e, reserve + 1);
2269            self.push(")");
2270        } else {
2271            self.format_expr_broken(e, reserve);
2272        }
2273    }
2274
2275    /// The multi-line rendering of an expression that does not fit. Each arm
2276    /// breaks at a point the grammar tolerates a newline; anything with no such
2277    /// point (a long string literal, an identifier) falls through to the flat
2278    /// form, which simply overruns — the 100-column target is soft.
2279    fn format_expr_broken(&mut self, e: &Expr, reserve: usize) {
2280        match &e.kind {
2281            // `T { field: value, … }` — one field per line.
2282            ExprKind::RecordConstruction { type_name, fields } if !fields.is_empty() => {
2283                self.push(&format!("{} {{", type_name.name));
2284                self.format_field_inits(fields.iter(), None);
2285            }
2286            // `T { ...base, field: value, … }` — the spread first, then the
2287            // overrides, one per line.
2288            ExprKind::RecordSpread {
2289                type_name,
2290                base,
2291                overrides,
2292            } => {
2293                match type_name {
2294                    Some(tn) => self.push(&format!("{} {{", tn.name)),
2295                    None => self.push("{"),
2296                }
2297                let spread = format!("...{}", expr_with_prec(base, 0));
2298                self.format_field_inits(overrides.iter(), Some(&spread));
2299            }
2300            // A call's arguments, one per line. Unlike a record body an
2301            // argument list does NOT accept a trailing comma (the grammar
2302            // rejects it), so the wrapped form never emits one.
2303            ExprKind::Call {
2304                name,
2305                type_args,
2306                args,
2307            } if !args.is_empty() => {
2308                self.push(&format!("{}{}(", name.name, type_args_src(type_args)));
2309                self.format_arg_list(args, reserve);
2310            }
2311            ExprKind::ConstructorCall {
2312                type_name,
2313                method,
2314                args,
2315            } if !args.is_empty() => {
2316                self.push(&format!("{}.{}(", type_name.name, method.name));
2317                self.format_arg_list(args, reserve);
2318            }
2319            ExprKind::Val { type_ref, args } if !args.is_empty() => {
2320                self.push(&format!("Val[{}](", type_ref_to_string(type_ref)));
2321                self.format_arg_list(args, reserve);
2322            }
2323            // A `.`-chain: `receiver.a().b()` — kept on one line where the
2324            // overflow is an argument's, broken before each call where it is
2325            // the chain's own (see `format_chain`).
2326            ExprKind::MethodCall { .. } | ExprKind::FieldAccess { .. } => {
2327                self.format_chain(e, reserve);
2328            }
2329            // `[a, b, c]` — one element per line.
2330            ExprKind::ListLit(elems) if !elems.is_empty() => {
2331                self.push("[");
2332                self.newline();
2333                self.indented(|f| {
2334                    for (i, elem) in elems.iter().enumerate() {
2335                        let last = i + 1 == elems.len();
2336                        f.format_expr_at(elem, 0, if last { 0 } else { 1 });
2337                        if !last || f.opts.trailing_comma {
2338                            f.push(",");
2339                        }
2340                        f.newline();
2341                    }
2342                });
2343                self.push("]");
2344            }
2345            // A run of `&&` / `||` / `implies` breaks before each operator, per
2346            // the spec's "wraps at `&&`/`||` boundaries". Only these: a
2347            // continuation line starting with an arithmetic or comparison
2348            // operator does not re-attach to the line above on re-parse.
2349            ExprKind::BinOp(op, ..) if is_logical(*op) => {
2350                let prec = binop_prec(*op);
2351                let mut operands = Vec::new();
2352                flatten_binop(e, *op, &mut operands);
2353                self.format_expr_at(operands[0], prec, 0);
2354                self.indented(|f| {
2355                    for (i, operand) in operands.iter().enumerate().skip(1) {
2356                        f.newline();
2357                        f.push(&format!("{} ", op.name()));
2358                        let last = i + 1 == operands.len();
2359                        f.format_expr_at(operand, prec + 1, if last { reserve } else { 0 });
2360                    }
2361                });
2362            }
2363            // Any other binary operator stays on one line, but its operands may
2364            // still break internally (a record or call on either side).
2365            ExprKind::BinOp(op, lhs, rhs) => {
2366                let prec = binop_prec(*op);
2367                let tail = format!(" {} {}", op.name(), expr_with_prec(rhs, prec + 1));
2368                // The right-hand side shares the operator's line whenever it is
2369                // itself unbroken, so charge it to the left-hand side's budget.
2370                let lhs_reserve = if tail.contains('\n') {
2371                    0
2372                } else {
2373                    tail.chars().count() + reserve
2374                };
2375                self.format_expr_at(lhs, prec, lhs_reserve);
2376                self.push(&format!(" {} ", op.name()));
2377                self.format_expr_at(rhs, prec + 1, reserve);
2378            }
2379            ExprKind::Is { value, pattern } => {
2380                let pat = format!(" is {}", pattern_to_string(pattern));
2381                self.format_expr_at(value, 4, pat.chars().count() + reserve);
2382                self.push(&pat);
2383            }
2384            // `if cond { … } else { … }` — both branches go vertical. Once the
2385            // one-line form is over budget, splitting only one branch leaves a
2386            // lopsided line that is no easier to read.
2387            ExprKind::If {
2388                cond,
2389                then_block,
2390                else_block,
2391            } => {
2392                self.push("if ");
2393                self.format_expr_at(cond, 0, 2);
2394                self.push(" ");
2395                self.format_block_multiline(then_block);
2396                // v0.146 (ADR 0170): an `if` with no `else` carries a
2397                // synthesised unit else-branch — omit it, as the flat form does.
2398                if !else_block.is_synth_unit() {
2399                    self.push(" else ");
2400                    self.format_block_multiline(else_block);
2401                }
2402            }
2403            ExprKind::Block(b) => self.format_block_multiline(b),
2404            ExprKind::Lambda(lambda) => {
2405                let params: Vec<String> = lambda
2406                    .params
2407                    .iter()
2408                    .map(|p| match &p.type_ref {
2409                        Some(tr) => format!("{}: {}", p.name.name, type_ref_to_string(tr)),
2410                        None => p.name.name.clone(),
2411                    })
2412                    .collect();
2413                self.push(&format!("({}) => ", params.join(", ")));
2414                self.format_expr_at(&lambda.body, 0, reserve);
2415            }
2416            // Single-argument wrappers: nothing to break at the wrapper itself,
2417            // so recurse and let the payload wrap inside the parentheses.
2418            ExprKind::Ok(v) => self.wrap_call("Ok(", v, reserve),
2419            ExprKind::Err(v) => self.wrap_call("Err(", v, reserve),
2420            ExprKind::Some(v) => self.wrap_call("Some(", v, reserve),
2421            ExprKind::EffectPure(v) => self.wrap_call("Effect.pure(", v, reserve),
2422            ExprKind::Wire(v) => self.wrap_call("Wire(", v, reserve),
2423            ExprKind::Paren(v) => self.wrap_call("(", v, reserve),
2424            ExprKind::Question(v) => {
2425                self.format_expr_at(v, 8, reserve + 1);
2426                self.push("?");
2427            }
2428            ExprKind::Expect(v) => {
2429                self.push("expect ");
2430                self.format_expr_at(v, 0, reserve);
2431            }
2432            // Nothing breakable — a literal, an identifier, an interpolated
2433            // string. Emit it as-is and overrun.
2434            _ => self.push(&expr_with_prec(e, 0)),
2435        }
2436    }
2437
2438    /// `<head><inner>)` where `inner` wraps inside the parentheses.
2439    fn wrap_call(&mut self, head: &str, inner: &Expr, reserve: usize) {
2440        self.push(head);
2441        self.format_expr_at(inner, 0, reserve + 1);
2442        self.push(")");
2443    }
2444
2445    /// The body of a wrapped record construction or spread: one `name: value`
2446    /// per indented line, then the closing brace. `spread` is the leading
2447    /// `...base` entry, when there is one. The brace and any type name are the
2448    /// caller's to emit.
2449    fn format_field_inits<'f, I>(&mut self, fields: I, spread: Option<&str>)
2450    where
2451        I: ExactSizeIterator<Item = &'f FieldInit>,
2452    {
2453        let total = fields.len() + usize::from(spread.is_some());
2454        self.newline();
2455        self.indented(|f| {
2456            let mut emitted = 0usize;
2457            if let Some(spread) = spread {
2458                f.push(spread);
2459                emitted += 1;
2460                if emitted < total || f.opts.trailing_comma {
2461                    f.push(",");
2462                }
2463                f.newline();
2464            }
2465            for field in fields {
2466                f.push(&field.name.name);
2467                if let Some(v) = &field.value {
2468                    f.push(": ");
2469                    f.format_expr_at(v, 0, 1);
2470                }
2471                emitted += 1;
2472                if emitted < total || f.opts.trailing_comma {
2473                    f.push(",");
2474                }
2475                f.newline();
2476            }
2477        });
2478        self.push("}");
2479    }
2480
2481    /// The arguments of a wrapped call plus its closing `)` — the caller has
2482    /// already emitted the `name(` head.
2483    ///
2484    /// A trailing argument hugs the call — the earlier arguments stay on the
2485    /// call's line and it opens its own body there, so
2486    /// `xs.fold(init, (acc, x) => match acc {` reads as one construct instead
2487    /// of being pushed down a level. Hugging is attempted for a sole argument
2488    /// (there is no sibling for it to misalign against) and, past that, only
2489    /// for a trailing lambda / record / block / `match` / `if`, whose opening
2490    /// line is short. It is taken only if every line it produces fits;
2491    /// otherwise each argument goes on its own indented line.
2492    ///
2493    /// Never a trailing comma — the grammar rejects one in an argument list,
2494    /// and the wrapped output has to re-parse.
2495    fn format_arg_list(&mut self, args: &[Expr], reserve: usize) -> bool {
2496        if let Some((last, leading)) = args.split_last()
2497            && (leading.is_empty() || is_block_like(last))
2498            && self.try_layout(reserve, |f| {
2499                for arg in leading {
2500                    f.push(&expr_with_prec(arg, 0));
2501                    f.push(", ");
2502                }
2503                f.format_expr_at(last, 0, 1);
2504                f.push(")");
2505            })
2506        {
2507            return false;
2508        }
2509        self.newline();
2510        self.indented(|f| {
2511            for (i, arg) in args.iter().enumerate() {
2512                let last = i + 1 == args.len();
2513                f.format_expr_at(arg, 0, if last { 0 } else { 1 });
2514                if !last {
2515                    f.push(",");
2516                }
2517                f.newline();
2518            }
2519        });
2520        self.push(")");
2521        true
2522    }
2523
2524    /// Emit a `.`-chain (`receiver.a(…).b(…).c`) broken across lines.
2525    ///
2526    /// A single-call chain never breaks at its `.` — there is no pipeline to
2527    /// read and the overflow belongs to the argument list. A multi-call chain
2528    /// prefers to stay on one line too, and breaks before each call only when
2529    /// staying inline would strand an exploded argument list mid-chain.
2530    fn format_chain(&mut self, e: &Expr, reserve: usize) {
2531        let (base, links) = flatten_chain(e);
2532        let calls = links
2533            .iter()
2534            .filter(|l| matches!(l, ChainLink::Method { .. }))
2535            .count();
2536        if calls < 2 {
2537            self.format_chain_inline(base, &links, reserve);
2538            return;
2539        }
2540        // Keeping a multi-call chain intact is preferable when the overflow
2541        // belongs to an argument rather than to the chain — the
2542        // `xs.fold(init, (acc, x) => match acc {` shape, where a body opens on
2543        // the chain's own line. That reading survives only while every step
2544        // either fits or *hugs*. A step forced to put its arguments one per
2545        // line strands a bare `)` mid-chain, at which point the chain itself is
2546        // what is too long, and breaking at its dots reads better.
2547        let exploded = std::cell::Cell::new(false);
2548        if self.try_layout_if(
2549            reserve,
2550            |f| exploded.set(f.format_chain_inline(base, &links, reserve)),
2551            || !exploded.get(),
2552        ) {
2553            return;
2554        }
2555        // Break before each *call*, not before each `.`. A field access is part
2556        // of whatever it qualifies: a leading `msg.params` belongs to the
2557        // receiver, and a `.rows.count()` reads as one step, so a line never
2558        // opens with a bare `.field`.
2559        let first_call = links
2560            .iter()
2561            .position(|l| matches!(l, ChainLink::Method { .. }))
2562            .expect("a chain with two calls has one");
2563        self.format_expr_at(base, 8, 0);
2564        for link in &links[..first_call] {
2565            self.format_chain_link(link, 0);
2566        }
2567        self.indented(|f| {
2568            let mut i = first_call;
2569            while i < links.len() {
2570                let mut end = i;
2571                while end < links.len() && matches!(links[end], ChainLink::Field(_)) {
2572                    end += 1;
2573                }
2574                // …and the call those field accesses qualify, if any.
2575                if end < links.len() {
2576                    end += 1;
2577                }
2578                f.newline();
2579                for (offset, link) in links[i..end].iter().enumerate() {
2580                    let is_last = end == links.len() && i + offset + 1 == links.len();
2581                    f.format_chain_link(link, if is_last { reserve } else { 0 });
2582                }
2583                i = end;
2584            }
2585        });
2586    }
2587
2588    /// The chain on one line: the receiver, then every `.`-step in place. Any
2589    /// step whose arguments do not fit wraps them, but no break is introduced
2590    /// at a `.`. Reports whether any step had to put its arguments one per
2591    /// line, which is what tells [`Self::format_chain`] this layout is a poor
2592    /// fit for the chain.
2593    fn format_chain_inline(
2594        &mut self,
2595        base: &Expr,
2596        links: &[ChainLink<'_>],
2597        reserve: usize,
2598    ) -> bool {
2599        self.format_expr_at(base, 8, 0);
2600        let mut exploded = false;
2601        for (i, link) in links.iter().enumerate() {
2602            exploded |=
2603                self.format_chain_link(link, if i + 1 == links.len() { reserve } else { 0 });
2604        }
2605        exploded
2606    }
2607
2608    /// One `.field` or `.method(args)` step of a chain, wrapping the argument
2609    /// list when the step does not fit on the current line. Reports whether
2610    /// that wrapping was the one-argument-per-line form.
2611    fn format_chain_link(&mut self, link: &ChainLink<'_>, reserve: usize) -> bool {
2612        match link {
2613            ChainLink::Field(name) => {
2614                self.push(&format!(".{name}"));
2615                false
2616            }
2617            ChainLink::Method {
2618                method,
2619                type_args,
2620                args,
2621            } => {
2622                let head = format!(".{}{}", method, type_args_src(type_args));
2623                let flat = format!(
2624                    "{head}({})",
2625                    args.iter()
2626                        .map(|a| expr_with_prec(a, 0))
2627                        .collect::<Vec<_>>()
2628                        .join(", ")
2629                );
2630                if args.is_empty() || self.fits(&flat, reserve) {
2631                    self.push(&flat);
2632                    return false;
2633                }
2634                self.push(&head);
2635                self.push("(");
2636                self.format_arg_list(args, reserve)
2637            }
2638        }
2639    }
2640
2641    /// Emit a `match` expression at the current indent level. Arms sit one
2642    /// level deeper than the `match`/`}`; block-bodied arms recurse through
2643    /// `format_block` so their statements indent correctly in turn.
2644    fn format_match(&mut self, discriminant: &Expr, arms: &[MatchArm]) {
2645        self.push("match ");
2646        self.format_expr_at(discriminant, 0, " {".len());
2647        self.push(" {");
2648        self.newline();
2649        self.indented(|f| {
2650            for arm in arms {
2651                f.push(&pattern_to_string(&arm.pattern));
2652                // ADR 0169: render an optional `if <guard>` before `=>`.
2653                if let Some(guard) = &arm.guard {
2654                    f.push(" if ");
2655                    f.format_expr_at(guard, 0, " => ".len());
2656                }
2657                f.push(" => ");
2658                // Every arm ends in a `,`, which counts against its budget.
2659                match &arm.body {
2660                    MatchBody::Expr(e) => f.format_expr_at(e, 0, 1),
2661                    MatchBody::Block(b) => f.format_block_with_reserve(b, 1),
2662                }
2663                f.push(",");
2664                f.newline();
2665            }
2666        });
2667        self.push("}");
2668    }
2669}
2670
2671/// One step of a `.`-chain, as collected by [`flatten_chain`].
2672enum ChainLink<'e> {
2673    Field(&'e str),
2674    Method {
2675        method: &'e str,
2676        type_args: &'e [TypeRef],
2677        args: &'e [Expr],
2678    },
2679}
2680
2681/// Split `receiver.a(…).b.c(…)` into its innermost receiver and the `.`-steps
2682/// applied to it, outermost last. A non-chain expression yields itself and an
2683/// empty list.
2684fn flatten_chain(e: &Expr) -> (&Expr, Vec<ChainLink<'_>>) {
2685    let mut links = Vec::new();
2686    let mut cur = e;
2687    loop {
2688        match &cur.kind {
2689            ExprKind::FieldAccess { receiver, field } => {
2690                links.push(ChainLink::Field(field.name.as_str()));
2691                cur = receiver;
2692            }
2693            ExprKind::MethodCall {
2694                receiver,
2695                method,
2696                type_args,
2697                args,
2698            } => {
2699                links.push(ChainLink::Method {
2700                    method: method.name.as_str(),
2701                    type_args,
2702                    args,
2703                });
2704                cur = receiver;
2705            }
2706            _ => break,
2707        }
2708    }
2709    links.reverse();
2710    (cur, links)
2711}
2712
2713/// The `[T, U]` type-argument suffix on a call, or the empty string.
2714fn type_args_src(type_args: &[TypeRef]) -> String {
2715    if type_args.is_empty() {
2716        return String::new();
2717    }
2718    format!(
2719        "[{}]",
2720        type_args
2721            .iter()
2722            .map(type_ref_to_string)
2723            .collect::<Vec<_>>()
2724            .join(", ")
2725    )
2726}
2727
2728/// The operators a wrapped expression may break *before*. A continuation line
2729/// opening with `&&`, `||`, or `implies` re-attaches to the line above on
2730/// re-parse; one opening with `+` or `==` does not.
2731fn is_logical(op: BinOp) -> bool {
2732    matches!(op, BinOp::And | BinOp::Or | BinOp::Implies)
2733}
2734
2735/// Collect the operands of a left-nested run of the same operator, so
2736/// `a && b && c` breaks into three lines rather than nesting two levels deep.
2737fn flatten_binop<'e>(e: &'e Expr, op: BinOp, out: &mut Vec<&'e Expr>) {
2738    if let ExprKind::BinOp(inner_op, lhs, rhs) = &e.kind
2739        && *inner_op == op
2740    {
2741        flatten_binop(lhs, op, out);
2742        out.push(rhs);
2743        return;
2744    }
2745    out.push(e);
2746}
2747
2748/// An expression whose wrapped form opens with a short header and a brace, so
2749/// it reads correctly as the sole argument of a call it shares a line with —
2750/// `xs.forEach((x) => {`, `Fetch.send(Request {`. Excludes anything whose first
2751/// wrapped line is as long as the construct itself (a call, a `.`-chain), which
2752/// would just move the overflow rather than remove it.
2753fn is_block_like(e: &Expr) -> bool {
2754    matches!(
2755        e.kind,
2756        ExprKind::Lambda(_)
2757            | ExprKind::Block(_)
2758            | ExprKind::RecordConstruction { .. }
2759            | ExprKind::RecordSpread { .. }
2760            | ExprKind::Match { .. }
2761            | ExprKind::If { .. }
2762    )
2763}
2764
2765/// Whether [`expr_with_prec`] would parenthesise `e` in a `parent_prec`
2766/// context. The wrapped printer emits those parentheses itself, since it
2767/// bypasses the flat renderer that would otherwise add them.
2768fn needs_parens(e: &Expr, parent_prec: u8) -> bool {
2769    match &e.kind {
2770        ExprKind::BinOp(op, ..) => binop_prec(*op) < parent_prec,
2771        ExprKind::UnaryOp(..) => parent_prec > 7,
2772        _ => false,
2773    }
2774}
2775
2776/// Borrow the trivia attached to a statement variant.
2777/// Render a `given`-clause capability reference back to source: a bare name
2778/// for a local capability, or `prefix.Name` for a cross-context one (v0.15).
2779fn cap_ref_src(c: &CapRef) -> String {
2780    match &c.context {
2781        Some(prefix) => format!("{}.{}", prefix.joined(), c.name.name),
2782        None => c.name.name.clone(),
2783    }
2784}
2785
2786/// Render a `by` clause back to source: `by <Actor>` (binder-less), `by <b>: <Actor>`
2787/// (captured identity), or an ordered sum `by <b>: A | B` (v0.52). Shared by handler
2788/// and service-header (v0.155) formatting.
2789fn by_clause_src(by: &ByClause) -> String {
2790    let actors = by
2791        .actors
2792        .iter()
2793        .map(|a| a.name.as_str())
2794        .collect::<Vec<_>>()
2795        .join(" | ");
2796    match &by.binder {
2797        Some(b) => format!("by {}: {actors}", b.name),
2798        None => format!("by {actors}"),
2799    }
2800}
2801
2802/// Render an events subscription pattern (Events track slice 1, spine
2803/// #936): `{ field: value, .. }`. The trailing `..` is mandatory whenever any
2804/// field is listed, so it always renders — there is no pattern-less `Some`
2805/// case to omit it for (a pattern-less subscription is `pattern: None` on
2806/// `ServiceProtocol::Events`, handled by the caller before this is reached).
2807fn event_pattern_src(p: &EventPattern) -> String {
2808    let fields: Vec<String> = p
2809        .fields
2810        .iter()
2811        .map(|f| format!("{}: {}", f.name.name, event_pattern_value_src(&f.value)))
2812        .collect();
2813    format!("{{ {}, .. }}", fields.join(", "))
2814}
2815
2816fn event_pattern_value_src(v: &EventPatternValue) -> String {
2817    match v {
2818        EventPatternValue::Literal { value, .. } => match value {
2819            LiteralValue::Int(n) => n.to_string(),
2820            LiteralValue::Str(s) => format!("\"{}\"", escape_string(s)),
2821            LiteralValue::Bool(b) => b.to_string(),
2822        },
2823        EventPatternValue::Variant {
2824            type_name, variant, ..
2825        } => match type_name {
2826            Some(t) => format!("{}.{}", t.name, variant.name),
2827            None => variant.name.clone(),
2828        },
2829    }
2830}
2831
2832/// Render a `via schema(N)` dispatch clause (Events track slice 4, spine
2833/// #936), written after the `from Events(...)` header's closing `)`.
2834fn schema_dispatch_src(d: &SchemaDispatch) -> String {
2835    match &d.pattern {
2836        SchemaVersionPattern::Literal(n) => format!("via schema({n})"),
2837    }
2838}
2839
2840/// v0.182 (#664): render a call-site actor clause — `by User("bob")` or the
2841/// unit-identity `by Visitor`.
2842fn call_site_actor_src(p: &CallSiteActor) -> String {
2843    match &p.identity {
2844        Some(id) => format!("by {}({})", p.actor.name, expr_with_prec(id, 0)),
2845        None => format!("by {}", p.actor.name),
2846    }
2847}
2848
2849/// Render a storage kind: `Cell[Int]`, `Map[K, V]`, or a bare head (v0.81).
2850fn store_kind_to_string(k: &StoreKind) -> String {
2851    if k.args.is_empty() {
2852        k.head.name.clone()
2853    } else {
2854        format!(
2855            "{}[{}]",
2856            k.head.name,
2857            k.args
2858                .iter()
2859                .map(type_ref_to_string)
2860                .collect::<Vec<_>>()
2861                .join(", ")
2862        )
2863    }
2864}
2865
2866/// Render a storage annotation (v0.85; ADR 0111): `@name`, or `@name(arg, …)`
2867/// where each argument is an optional `label: ` then the value expression.
2868/// Render a storage annotation as a single source-syntax token: `@indexed(by:
2869/// id)`, `@bounded(10000)`, `@ttl(5.minutes)`, or a bare `@retain`. Public so
2870/// the LSP's agent-state hover (ADR 0161) can render a `store` field's
2871/// annotations without re-deriving them.
2872pub fn annotation_to_string(ann: &Annotation) -> String {
2873    if ann.args.is_empty() {
2874        return format!("@{}", ann.name.name);
2875    }
2876    let args = ann
2877        .args
2878        .iter()
2879        .map(|a| match &a.label {
2880            Some(l) => format!("{}: {}", l.name, expr_with_prec(&a.value, 0)),
2881            None => expr_with_prec(&a.value, 0),
2882        })
2883        .collect::<Vec<_>>()
2884        .join(", ");
2885    format!("@{}({})", ann.name.name, args)
2886}
2887
2888/// v0.118: render a `stub` clause head-to-tail as a single source line:
2889/// `stub <capability>.<method>(<args>) <rhs>` (testing track slice 6).
2890fn stub_clause_to_string(pv: &StubClause) -> String {
2891    let args = pv
2892        .args
2893        .iter()
2894        .map(|a| match a {
2895            ArgPattern::Any(_) => "_".to_string(),
2896            ArgPattern::Value(e) => expr_to_string(e),
2897        })
2898        .collect::<Vec<_>>()
2899        .join(", ");
2900    let rhs = match &pv.rhs {
2901        StubRhs::Returns(e) => format!("returns {}", expr_to_string(e)),
2902        StubRhs::Fails(_) => "fails".to_string(),
2903        StubRhs::ReturnsEach(outcomes, _) => {
2904            let items = outcomes
2905                .iter()
2906                .map(|o| match o {
2907                    SeqOutcome::Value(e) => expr_to_string(e),
2908                    SeqOutcome::Fails(_) => "fails".to_string(),
2909                })
2910                .collect::<Vec<_>>()
2911                .join(", ");
2912            format!("returns each [{items}]")
2913        }
2914    };
2915    format!(
2916        "stub {}.{}({}) {}",
2917        pv.capability.name, pv.method.name, args, rhs
2918    )
2919}
2920
2921fn statement_trivia(s: &Statement) -> &Trivia {
2922    match s {
2923        Statement::Let(l) | Statement::EffectLet(l) => &l.trivia,
2924        Statement::Expect(a) => &a.trivia,
2925        Statement::Send(s) => &s.trivia,
2926        Statement::Do(d) => &d.trivia,
2927        Statement::Assign(a) => &a.trivia,
2928    }
2929}
2930
2931// -- String-rendering helpers (used by inline single-line emission) --
2932
2933/// The rendered width of a single line: one column per character, except a
2934/// tab, which advances to the next multiple of `tab`. Counting `char`s rather
2935/// than bytes keeps a non-ASCII identifier or string literal from being
2936/// over-measured and wrapped for no reason.
2937fn display_width(line: &str, tab: usize) -> usize {
2938    let mut col = 0usize;
2939    for ch in line.chars() {
2940        if ch == '\t' {
2941            col += tab - (col % tab);
2942        } else {
2943            col += 1;
2944        }
2945    }
2946    col
2947}
2948
2949fn type_ref_to_string(t: &TypeRef) -> String {
2950    match t {
2951        TypeRef::Base(b, _) => b.name().to_string(),
2952        TypeRef::Named(id) => id.name.clone(),
2953        TypeRef::Result(a, b, _) => format!(
2954            "Result[{}, {}]",
2955            type_ref_to_string(a),
2956            type_ref_to_string(b)
2957        ),
2958        TypeRef::Option(t, _) => format!("Option[{}]", type_ref_to_string(t)),
2959        TypeRef::Effect(t, _) => format!("Effect[{}]", type_ref_to_string(t)),
2960        TypeRef::HttpResult(t, _) => format!("HttpResult[{}]", type_ref_to_string(t)),
2961        TypeRef::QueueResult(_) => "QueueResult".to_string(),
2962        TypeRef::List(t, _) => format!("List[{}]", type_ref_to_string(t)),
2963        TypeRef::Query(t, _) => format!("Query[{}]", type_ref_to_string(t)),
2964        TypeRef::Stream(t, _) => format!("Stream[{}]", type_ref_to_string(t)),
2965        TypeRef::Connection(t, _) => format!("Connection[{}]", type_ref_to_string(t)),
2966        TypeRef::History(t, _) => format!("History[{}]", type_ref_to_string(t)),
2967        TypeRef::Map(k, v, _) => {
2968            format!("Map[{}, {}]", type_ref_to_string(k), type_ref_to_string(v))
2969        }
2970        TypeRef::ValidationError(_) => "ValidationError".to_string(),
2971        TypeRef::JsonError(_) => "JsonError".to_string(),
2972        TypeRef::Unit(_) => "()".to_string(),
2973        // v0.157 (ADR 0183): a user generic-type application, as written.
2974        TypeRef::App { name, args, .. } => format!(
2975            "{}[{}]",
2976            name.name,
2977            args.iter()
2978                .map(type_ref_to_string)
2979                .collect::<Vec<_>>()
2980                .join(", ")
2981        ),
2982        TypeRef::Fn(params, ret, _) => {
2983            let lhs = match params.len() {
2984                0 => "()".to_string(),
2985                1 if !matches!(params[0], TypeRef::Fn(..)) => type_ref_to_string(&params[0]),
2986                _ => format!(
2987                    "({})",
2988                    params
2989                        .iter()
2990                        .map(type_ref_to_string)
2991                        .collect::<Vec<_>>()
2992                        .join(", ")
2993                ),
2994            };
2995            format!("{lhs} -> {}", type_ref_to_string(ret))
2996        }
2997    }
2998}
2999
3000pub fn refinement_to_string(r: &Refinement) -> String {
3001    let mut s = String::new();
3002    for (i, p) in r.predicates.iter().enumerate() {
3003        if i > 0 {
3004            s.push_str(" && ");
3005        }
3006        s.push_str(&pred_to_string(p));
3007    }
3008    s
3009}
3010
3011fn pred_to_string(p: &RefinementPred) -> String {
3012    match &p.kind {
3013        PredKind::Matches(re) => format!("Matches(\"{}\")", escape_string(re)),
3014        PredKind::InRange(a, b) => format!("InRange({}, {})", a.value, b.value),
3015        PredKind::InRangeF(a, b) => format!("InRange({}, {})", a.lexeme, b.lexeme),
3016        PredKind::MinLength(n) => format!("MinLength({n})"),
3017        PredKind::MaxLength(n) => format!("MaxLength({n})"),
3018        PredKind::Length(n) => format!("Length({n})"),
3019        PredKind::NonNegative => "NonNegative".to_string(),
3020        PredKind::Positive => "Positive".to_string(),
3021        PredKind::NonEmpty => "NonEmpty".to_string(),
3022    }
3023}
3024
3025pub fn escape_string(s: &str) -> String {
3026    let mut out = String::with_capacity(s.len());
3027    for ch in s.chars() {
3028        match ch {
3029            '\\' => out.push_str("\\\\"),
3030            '"' => out.push_str("\\\""),
3031            '\n' => out.push_str("\\n"),
3032            '\t' => out.push_str("\\t"),
3033            c => out.push(c),
3034        }
3035    }
3036    out
3037}
3038
3039pub fn expr_to_string(e: &Expr) -> String {
3040    expr_with_prec(e, 0)
3041}
3042
3043// Operator precedences (smaller = binds looser):
3044//   1: || 2: && 3: == != 4: < <= > >= 5: + - 6: * / 7: unary ! - 8: postfix . () ?
3045fn binop_prec(op: BinOp) -> u8 {
3046    match op {
3047        // v0.80: `implies` is the lowest-precedence binary operator (below `||`).
3048        BinOp::Implies => 0,
3049        BinOp::Or => 1,
3050        BinOp::And => 2,
3051        BinOp::Eq | BinOp::NotEq => 3,
3052        BinOp::Lt | BinOp::LtEq | BinOp::Gt | BinOp::GtEq => 4,
3053        BinOp::Add | BinOp::Sub => 5,
3054        BinOp::Mul | BinOp::Div => 6,
3055    }
3056}
3057
3058fn expr_with_prec(e: &Expr, parent_prec: u8) -> String {
3059    match &e.kind {
3060        // v0.142 (ADR 0166): the stored lexeme verbatim — formatting must not
3061        // normalise away the author's `_` digit separators.
3062        ExprKind::IntLit { lexeme, .. } => lexeme.clone(),
3063        // v0.21: the stored lexeme verbatim — formatting must not normalise.
3064        ExprKind::FloatLit { lexeme, .. } => lexeme.clone(),
3065        // v0.86 (ADR 0112): a duration literal `<value>.<unit>`.
3066        ExprKind::DurationLit { value, unit, .. } => format!("{value}.{}", unit.name()),
3067        ExprKind::StrLit(s) => format!("\"{}\"", escape_string(s)),
3068        // v0.43: re-emit the interpolated string — chunks re-escaped, each
3069        // hole as `\(expr)`. Re-escaping a chunk's literal `\` to `\\` keeps a
3070        // source `\\(` (an escaped `\(`) round-tripping as text, not a hole.
3071        ExprKind::InterpStr(parts) => {
3072            let mut out = String::from("\"");
3073            for part in parts {
3074                match part {
3075                    InterpPart::Chunk(text) => out.push_str(&escape_string(text)),
3076                    InterpPart::Hole(hole) => {
3077                        out.push_str(&format!("\\({})", expr_with_prec(hole, 0)));
3078                    }
3079                }
3080            }
3081            out.push('"');
3082            out
3083        }
3084        ExprKind::BoolLit(b) => b.to_string(),
3085        ExprKind::UnitLit => "()".to_string(),
3086        ExprKind::Ident(id) => id.name.clone(),
3087        ExprKind::ListLit(elems) => format!(
3088            "[{}]",
3089            elems
3090                .iter()
3091                .map(expr_to_string)
3092                .collect::<Vec<_>>()
3093                .join(", ")
3094        ),
3095        ExprKind::Call {
3096            name,
3097            type_args,
3098            args,
3099        } => {
3100            let targs = if type_args.is_empty() {
3101                String::new()
3102            } else {
3103                format!(
3104                    "[{}]",
3105                    type_args
3106                        .iter()
3107                        .map(type_ref_to_string)
3108                        .collect::<Vec<_>>()
3109                        .join(", ")
3110                )
3111            };
3112            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
3113            format!("{}{}({})", name.name, targs, parts.join(", "))
3114        }
3115        ExprKind::BinOp(op, l, r) => {
3116            let prec = binop_prec(*op);
3117            let inner = format!(
3118                "{} {} {}",
3119                expr_with_prec(l, prec),
3120                op.name(),
3121                expr_with_prec(r, prec + 1)
3122            );
3123            if prec < parent_prec {
3124                format!("({inner})")
3125            } else {
3126                inner
3127            }
3128        }
3129        ExprKind::UnaryOp(op, inner) => {
3130            // Unary binds tightly (prec 7).
3131            let s = format!("{}{}", op.name(), expr_with_prec(inner, 7));
3132            if parent_prec > 7 { format!("({s})") } else { s }
3133        }
3134        ExprKind::Paren(inner) => format!("({})", expr_with_prec(inner, 0)),
3135        // v0.20a: a lambda prints as `(params) => body`.
3136        ExprKind::Lambda(lambda) => {
3137            let params: Vec<String> = lambda
3138                .params
3139                .iter()
3140                .map(|p| match &p.type_ref {
3141                    Some(tr) => format!("{}: {}", p.name.name, type_ref_to_string(tr)),
3142                    None => p.name.name.clone(),
3143                })
3144                .collect();
3145            let body = match &lambda.body.kind {
3146                ExprKind::Block(b) => format_block_oneline(b),
3147                _ => expr_with_prec(&lambda.body, 0),
3148            };
3149            format!("({}) => {}", params.join(", "), body)
3150        }
3151        ExprKind::Block(b) => format_block_oneline(b),
3152        ExprKind::If {
3153            cond,
3154            then_block,
3155            else_block,
3156        } => {
3157            // v0.146 (ADR 0170): an `if` with no `else` carries a synthesised
3158            // unit else-branch — omit it so the else-less form round-trips.
3159            if else_block.is_synth_unit() {
3160                format!(
3161                    "if {} {}",
3162                    expr_with_prec(cond, 0),
3163                    format_block_oneline(then_block),
3164                )
3165            } else {
3166                format!(
3167                    "if {} {} else {}",
3168                    expr_with_prec(cond, 0),
3169                    format_block_oneline(then_block),
3170                    format_block_oneline(else_block),
3171                )
3172            }
3173        }
3174        ExprKind::Ok(v) => format!("Ok({})", expr_with_prec(v, 0)),
3175        ExprKind::Err(v) => format!("Err({})", expr_with_prec(v, 0)),
3176        ExprKind::Some(v) => format!("Some({})", expr_with_prec(v, 0)),
3177        ExprKind::None => "None".to_string(),
3178        ExprKind::Question(v) => format!("{}?", expr_with_prec(v, 8)),
3179        ExprKind::ConstructorCall {
3180            type_name,
3181            method,
3182            args,
3183        } => {
3184            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
3185            format!("{}.{}({})", type_name.name, method.name, parts.join(", "))
3186        }
3187        ExprKind::RecordConstruction { type_name, fields } => {
3188            let parts: Vec<String> = fields
3189                .iter()
3190                .map(|f| match &f.value {
3191                    Some(v) => format!("{}: {}", f.name.name, expr_with_prec(v, 0)),
3192                    None => f.name.name.clone(),
3193                })
3194                .collect();
3195            if parts.is_empty() {
3196                format!("{} {{}}", type_name.name)
3197            } else {
3198                format!("{} {{ {} }}", type_name.name, parts.join(", "))
3199            }
3200        }
3201        ExprKind::FieldAccess { receiver, field } => {
3202            format!("{}.{}", expr_with_prec(receiver, 8), field.name)
3203        }
3204        ExprKind::MethodCall {
3205            receiver,
3206            method,
3207            type_args,
3208            args,
3209        } => {
3210            let targs = if type_args.is_empty() {
3211                String::new()
3212            } else {
3213                format!(
3214                    "[{}]",
3215                    type_args
3216                        .iter()
3217                        .map(type_ref_to_string)
3218                        .collect::<Vec<_>>()
3219                        .join(", ")
3220                )
3221            };
3222            let parts: Vec<String> = args.iter().map(|a| expr_with_prec(a, 0)).collect();
3223            format!(
3224                "{}.{}{targs}({})",
3225                expr_with_prec(receiver, 8),
3226                method.name,
3227                parts.join(", ")
3228            )
3229        }
3230        ExprKind::Match { discriminant, arms } => {
3231            let mut out = String::new();
3232            out.push_str("match ");
3233            out.push_str(&expr_with_prec(discriminant, 0));
3234            out.push_str(" {\n");
3235            for arm in arms {
3236                out.push('\t');
3237                out.push_str(&pattern_to_string(&arm.pattern));
3238                if let Some(guard) = &arm.guard {
3239                    out.push_str(" if ");
3240                    out.push_str(&expr_with_prec(guard, 0));
3241                }
3242                out.push_str(" => ");
3243                match &arm.body {
3244                    MatchBody::Expr(e) => out.push_str(&expr_with_prec(e, 0)),
3245                    MatchBody::Block(b) => out.push_str(&format_block_oneline(b)),
3246                }
3247                out.push_str(",\n");
3248            }
3249            out.push('}');
3250            out
3251        }
3252        ExprKind::Is { value, pattern } => {
3253            format!(
3254                "{} is {}",
3255                expr_with_prec(value, 4),
3256                pattern_to_string(pattern)
3257            )
3258        }
3259        ExprKind::RecordSpread {
3260            type_name,
3261            base,
3262            overrides,
3263        } => {
3264            let mut parts = vec![format!("...{}", expr_with_prec(base, 0))];
3265            for f in overrides {
3266                if let Some(v) = &f.value {
3267                    parts.push(format!("{}: {}", f.name.name, expr_with_prec(v, 0)));
3268                } else {
3269                    parts.push(f.name.name.clone());
3270                }
3271            }
3272            let body = parts.join(", ");
3273            match type_name {
3274                Some(tn) => format!("{} {{ {} }}", tn.name, body),
3275                None => format!("{{ {} }}", body),
3276            }
3277        }
3278        ExprKind::EffectPure(v) => format!("Effect.pure({})", expr_with_prec(v, 0)),
3279        ExprKind::Expect(v) => format!("expect {}", expr_with_prec(v, 0)),
3280        ExprKind::Val { type_ref, args } => {
3281            let t = type_ref_to_string(type_ref);
3282            if args.is_empty() {
3283                format!("Val[{t}]")
3284            } else {
3285                let a = args
3286                    .iter()
3287                    .map(|x| expr_with_prec(x, 0))
3288                    .collect::<Vec<_>>()
3289                    .join(", ");
3290                format!("Val[{t}]({a})")
3291            }
3292        }
3293        ExprKind::Wire(inner) => format!("Wire({})", expr_with_prec(inner, 0)),
3294        ExprKind::Trace { cap, op } => format!("trace({}.{})", cap.name, op.name),
3295        // #1706: the fault claim — the call, then the contextual `faults`.
3296        ExprKind::Faults(call) => format!("{} faults", expr_with_prec(call, 0)),
3297        ExprKind::Observation(o) => {
3298            let subject = format!("{}.{}", o.cap.name, o.op.name);
3299            match &o.matcher {
3300                ObservationMatcher::NeverCalled => format!("{subject} never called"),
3301                ObservationMatcher::Before { cap, op } => {
3302                    format!("{subject} before {}.{}", cap.name, op.name)
3303                }
3304                ObservationMatcher::Called { count, with_pred } => {
3305                    let mut s = format!("{subject} called");
3306                    if let Some(c) = count {
3307                        if matches!(c.kind, ExprKind::IntLit { value: 1, .. }) {
3308                            s.push_str(" once");
3309                        } else {
3310                            s.push_str(&format!(" {} times", expr_with_prec(c, 0)));
3311                        }
3312                    }
3313                    if let Some(p) = with_pred {
3314                        s.push_str(&format!(" with {}", expr_with_prec(p, 0)));
3315                    }
3316                    s
3317                }
3318            }
3319        }
3320    }
3321}
3322
3323fn pattern_to_string(p: &Pattern) -> String {
3324    match p {
3325        Pattern::Wildcard(_) => "_".to_string(),
3326        // ADR 0169: a bare name binding renders as its identifier.
3327        Pattern::Binding(id) => id.name.clone(),
3328        // v0.130: literal patterns render as their source literal.
3329        Pattern::Literal { value, .. } => match value {
3330            LiteralValue::Int(n) => n.to_string(),
3331            LiteralValue::Str(s) => format!("\"{}\"", escape_string(s)),
3332            LiteralValue::Bool(b) => b.to_string(),
3333        },
3334        // #472: `p where predicate` — the inner pattern, then the predicate
3335        // list rendered the same way a `type X = Base where P` refinement is.
3336        Pattern::Refined {
3337            inner, predicate, ..
3338        } => format!(
3339            "{} where {}",
3340            pattern_to_string(inner),
3341            refinement_to_string(predicate)
3342        ),
3343        Pattern::Variant {
3344            type_name,
3345            variant,
3346            bindings,
3347            ..
3348        } => {
3349            let name_part = match type_name {
3350                Some(t) => format!("{}.{}", t.name, variant.name),
3351                None => variant.name.clone(),
3352            };
3353            if bindings.is_empty() {
3354                name_part
3355            } else {
3356                // ADR 0169: each payload binding is a full sub-pattern.
3357                let parts: Vec<String> = bindings
3358                    .iter()
3359                    .map(|b| match &b.kind {
3360                        PatternBindingKind::Positional { pattern } => pattern_to_string(pattern),
3361                        PatternBindingKind::Named { field, pattern } => {
3362                            format!("{}: {}", field.name, pattern_to_string(pattern))
3363                        }
3364                    })
3365                    .collect();
3366                format!("{}({})", name_part, parts.join(", "))
3367            }
3368        }
3369        // #474: an or-pattern renders as its alternatives joined by `|`.
3370        Pattern::Or(alts, _) => alts
3371            .iter()
3372            .map(pattern_to_string)
3373            .collect::<Vec<_>>()
3374            .join(" | "),
3375    }
3376}
3377
3378/// #981: whether a block's `()` tail should be omitted rather than printed.
3379///
3380/// A `()` tail — whether the parser synthesised it ([`Block::implicit_tail`])
3381/// or the user wrote it out explicitly — is exactly the block's default
3382/// value, so dropping it is loss-free: the parser re-derives the same
3383/// implicit unit tail either way (v0.7 / v0.146, ADR 0170).
3384///
3385/// Omitting it is not just an idempotency nicety, it is required for
3386/// correctness whenever anything precedes the tail (a statement, a `case`'s
3387/// `stub` clause): Bynk has no statement terminator, so a printed `()`
3388/// immediately after a preceding line's last token re-attaches to it as a
3389/// zero-arg call on re-parse (`x` / `()` → `x()`) rather than staying two
3390/// separate constructs. #735 only special-cased the *implicit*-tail shape;
3391/// #981 found the identical corruption for an *explicit* `()` tail (e.g. the
3392/// last statement of a `match` arm's block), which is exactly as dangerous
3393/// once anything comes before it. So this covers both, structurally, rather
3394/// than special-casing another syntactic position.
3395fn omit_unit_tail(b: &Block) -> bool {
3396    matches!(b.tail.kind, ExprKind::UnitLit) && b.tail_leading_comments.is_empty()
3397}
3398
3399fn format_block_oneline(b: &Block) -> String {
3400    if b.statements.is_empty() {
3401        // v0.146 (ADR 0170): an empty block with a synthesised `()` tail prints
3402        // as `{}` — printing `{ () }` would not round-trip against the parser's
3403        // implicit-tail synthesis.
3404        if b.implicit_tail {
3405            "{}".to_string()
3406        } else {
3407            format!("{{ {} }}", expr_with_prec(&b.tail, 0))
3408        }
3409    } else {
3410        // Multi-line block — render with newlines and tab indentation.
3411        let mut out = String::from("{\n");
3412        for stmt in &b.statements {
3413            out.push('\t');
3414            out.push_str(&stmt_to_string(stmt));
3415            out.push('\n');
3416        }
3417        if !omit_unit_tail(b) {
3418            out.push('\t');
3419            out.push_str(&expr_with_prec(&b.tail, 0));
3420            out.push('\n');
3421        }
3422        out.push('}');
3423        out
3424    }
3425}
3426
3427fn stmt_to_string(s: &Statement) -> String {
3428    match s {
3429        Statement::Let(l) => {
3430            let mut out = format!("let {}", l.name.name);
3431            if let Some(t) = &l.type_annot {
3432                out.push_str(&format!(": {}", type_ref_to_string(t)));
3433            }
3434            out.push_str(&format!(" = {}", expr_with_prec(&l.value, 0)));
3435            out
3436        }
3437        Statement::EffectLet(l) => {
3438            let mut out = format!("let {}", l.name.name);
3439            if let Some(t) = &l.type_annot {
3440                out.push_str(&format!(": {}", type_ref_to_string(t)));
3441            }
3442            out.push_str(&format!(" <- {}", expr_with_prec(&l.value, 0)));
3443            if let Some(p) = &l.principal {
3444                out.push_str(&format!(" {}", call_site_actor_src(p)));
3445            }
3446            out
3447        }
3448        Statement::Expect(a) => format!("expect {}", expr_with_prec(&a.value, 0)),
3449        Statement::Send(s) => format!("~> {}", expr_with_prec(&s.value, 0)),
3450        Statement::Do(d) => format!("do {}", expr_with_prec(&d.value, 0)),
3451        Statement::Assign(a) => format!("{} := {}", a.target.name, expr_with_prec(&a.value, 0)),
3452    }
3453}
3454
3455#[cfg(test)]
3456mod tests {
3457    use super::*;
3458
3459    /// #1763: a CRLF copy of a canonical file formats to the LF canonical form,
3460    /// and a doc block's lines lose their CR too (its text was kept verbatim).
3461    #[test]
3462    fn crlf_formats_to_the_lf_canonical_form() {
3463        let lf = "context greeting\n\n---\nA greeting for a name.\n---\nfn greet(name: String) -> String { name }\n";
3464        assert_eq!(format_source(lf, &FormatOptions::default()).unwrap(), lf);
3465        let crlf = lf.replace('\n', "\r\n");
3466        let formatted = format_source(&crlf, &FormatOptions::default()).unwrap();
3467        assert_eq!(formatted, lf);
3468        assert!(!formatted.contains('\r'));
3469    }
3470
3471    #[test]
3472    fn normalize_line_endings_borrows_when_there_is_nothing_to_do() {
3473        assert!(matches!(
3474            normalize_line_endings("a\nb"),
3475            std::borrow::Cow::Borrowed(_)
3476        ));
3477        assert_eq!(normalize_line_endings("a\r\nb\r\n"), "a\nb\n");
3478    }
3479
3480    /// #1775 review: a run of CRs before an LF is one line ending, so the
3481    /// normaliser is a fixed point; a CR not before an LF is kept.
3482    #[test]
3483    fn normalize_line_endings_is_a_fixed_point() {
3484        for input in ["a\r\r\nb", "a\r\nb\rc\r\n", "\r\r\r\n", "x\r", "a\r\n\rb"] {
3485            let once = normalize_line_endings(input).into_owned();
3486            assert_eq!(normalize_line_endings(&once), once, "{input:?}");
3487            assert!(!once.contains("\r\n"), "{input:?} -> {once:?}");
3488        }
3489        assert_eq!(normalize_line_endings("a\r\r\nb"), "a\nb");
3490        assert_eq!(normalize_line_endings("a\r\nb\rc"), "a\nb\rc");
3491    }
3492
3493    /// #1755 review: output that does not even tokenize is the round-trip
3494    /// guard's to report ("no longer parses"). The doc-block guard runs first,
3495    /// so it must stay silent rather than blame every doc block as lost.
3496    #[test]
3497    fn doc_block_loss_leaves_an_untokenizable_output_to_the_roundtrip_guard() {
3498        let source = "commons d\n\n---\nattached\n---\nfn f() -> Int { 1 }\n";
3499        let tokens = tokenize(source).unwrap();
3500        assert!(tokenize("commons d\n\"unterminated").is_err());
3501        assert!(
3502            doc_block_loss(source, &tokens, "commons d\n\"unterminated").is_none(),
3503            "an untokenizable output was reported as doc-block loss"
3504        );
3505    }
3506
3507    fn fmt(src: &str) -> String {
3508        format_source(src, &FormatOptions::default()).expect("format failed")
3509    }
3510
3511    #[test]
3512    fn formats_minimal_commons() {
3513        let src = "commons fitness.units {}";
3514        let out = fmt(src);
3515        assert!(out.starts_with("commons fitness.units"));
3516        // Idempotency.
3517        let out2 = fmt(&out);
3518        assert_eq!(out, out2);
3519    }
3520
3521    #[test]
3522    fn formats_refined_type() {
3523        let src = "commons x { type Metres = Int where NonNegative }";
3524        let out = fmt(src);
3525        assert!(out.contains("type Metres = Int where NonNegative"));
3526        let out2 = fmt(&out);
3527        assert_eq!(out, out2);
3528    }
3529
3530    #[test]
3531    fn formats_function_decl() {
3532        let src = "commons x { fn add(a: Int, b: Int) -> Int { a + b } }";
3533        let out = fmt(src);
3534        assert!(out.contains("fn add(a: Int, b: Int) -> Int"));
3535        let out2 = fmt(&out);
3536        assert_eq!(out, out2);
3537    }
3538
3539    #[test]
3540    fn formats_record() {
3541        let src = "commons x { type Pt = { x: Int, y: Int } }";
3542        let out = fmt(src);
3543        let out2 = fmt(&out);
3544        assert_eq!(out, out2, "formatter not idempotent: {out}");
3545    }
3546
3547    #[test]
3548    fn formats_doc_block() {
3549        let src = "commons x {\n---\nA descriptive doc.\n---\ntype T = Int where Positive\n}";
3550        let out = fmt(src);
3551        assert!(out.contains("A descriptive doc."));
3552        let out2 = fmt(&out);
3553        assert_eq!(out, out2);
3554    }
3555
3556    // -- v1.1 comment preservation --
3557
3558    #[test]
3559    fn preserves_leading_line_comment_on_decl() {
3560        let src = "commons x {\n-- explain T\ntype T = Int where NonNegative\n}";
3561        let out = fmt(src);
3562        assert!(out.contains("-- explain T"), "comment dropped: {out}");
3563        // Idempotent.
3564        assert_eq!(out, fmt(&out));
3565    }
3566
3567    #[test]
3568    fn preserves_trailing_line_comment_on_decl() {
3569        let src = "commons x {\ntype T = Int where NonNegative  -- short\n}";
3570        let out = fmt(src);
3571        assert!(out.contains("-- short"));
3572        // The trailing comment must remain on the same line as the decl.
3573        assert!(
3574            out.lines()
3575                .any(|l| l.contains("type T") && l.contains("-- short")),
3576            "trailing comment not on same line: {out}"
3577        );
3578        assert_eq!(out, fmt(&out));
3579    }
3580
3581    #[test]
3582    fn preserves_grouped_leading_comments() {
3583        let src = "commons x {\n-- one\n-- two\ntype T = Int where Positive\n}";
3584        let out = fmt(src);
3585        assert!(out.contains("-- one"));
3586        assert!(out.contains("-- two"));
3587        // Adjacent — no blank line between the comments.
3588        let i1 = out.find("-- one").unwrap();
3589        let i2 = out.find("-- two").unwrap();
3590        let between = &out[i1..i2];
3591        assert_eq!(
3592            between.matches('\n').count(),
3593            1,
3594            "blank line inserted: {out}"
3595        );
3596        assert_eq!(out, fmt(&out));
3597    }
3598
3599    #[test]
3600    fn preserves_comment_before_block_tail() {
3601        let src = "commons x {\nfn f(n: Int) -> Int {\nlet y = n + 1\n-- result\ny\n}\n}";
3602        let out = fmt(src);
3603        assert!(out.contains("-- result"), "tail comment dropped: {out}");
3604        assert_eq!(out, fmt(&out));
3605    }
3606
3607    #[test]
3608    fn preserves_comment_with_doc_block_above_decl() {
3609        let src = "commons x {\n-- TODO: rename\n---\nThe canonical T.\n---\ntype T = Int where Positive\n}";
3610        let out = fmt(src);
3611        assert!(out.contains("-- TODO: rename"));
3612        assert!(out.contains("The canonical T."));
3613        // Spec layout: comment, then doc block, then declaration.
3614        let ic = out.find("-- TODO: rename").unwrap();
3615        let id = out.find("The canonical T.").unwrap();
3616        let it = out.find("type T").unwrap();
3617        assert!(ic < id && id < it, "ordering wrong: {out}");
3618        assert_eq!(out, fmt(&out));
3619    }
3620
3621    #[test]
3622    fn preserves_trailing_file_comment() {
3623        let src = "commons x.y\n\ntype T = Int where Positive\n-- TODO\n";
3624        let out = fmt(src);
3625        assert!(out.contains("-- TODO"));
3626        assert_eq!(out, fmt(&out));
3627    }
3628
3629    // -- Finding #66: the drain-check fast path must not weaken the
3630    // comment-loss guard --
3631
3632    /// A comment strictly inside an expression subtree (here, a binop chain)
3633    /// is the one shape `TriviaTable` genuinely cannot drain — `fully_drained`
3634    /// must be `false` for it, so `format_source`'s fast path does not skip
3635    /// `comment_loss` and the file is still refused rather than silently
3636    /// losing the comment.
3637    #[test]
3638    fn expression_interior_comment_is_not_fully_drained_and_still_refused() {
3639        let src = "commons x {\n  fn f() -> Int {\n    1 + -- note\n    2\n  }\n}\n";
3640        let tokens = tokenize(src).unwrap();
3641        let (_, _, fully_drained) = parse_units_with_drain_check(&tokens, src)
3642            .expect("a comment inside an expression is still a valid parse");
3643        assert!(
3644            !fully_drained,
3645            "an expression-interior comment must leave the trivia table undrained"
3646        );
3647        let err = format_source(src, &FormatOptions::default())
3648            .expect_err("formatting must still refuse rather than lose the comment");
3649        assert_eq!(err.errors[0].category, "bynk.fmt.comment_loss");
3650    }
3651
3652    /// The mirror image: an ordinary file with no expression-interior comment
3653    /// (every comment sits before a declaration/statement, or trailing one) is
3654    /// `fully_drained`, so `format_source`'s fast path is what actually runs —
3655    /// and formatting must still succeed and preserve the comment.
3656    #[test]
3657    fn ordinary_comment_is_fully_drained_and_formats_normally() {
3658        let src = "commons x {\n-- note\ntype T = Int where Positive\n}\n";
3659        let tokens = tokenize(src).unwrap();
3660        let (_, _, fully_drained) =
3661            parse_units_with_drain_check(&tokens, src).expect("should parse");
3662        assert!(
3663            fully_drained,
3664            "a declaration-leading comment must be fully drained"
3665        );
3666        let out = fmt(src);
3667        assert!(out.contains("-- note"));
3668    }
3669
3670    // -- #981: a bare-identifier statement + trailing `()` must not merge
3671    // into a call expression.
3672
3673    #[test]
3674    fn match_arm_block_tail_unit_after_assign_does_not_reattach_as_a_call() {
3675        // The exact shape from #981: an Assign statement whose value is a
3676        // bare (capitalised, enum-variant-shaped) identifier, immediately
3677        // followed by the block's own explicit `()` tail. The formatter must
3678        // not print these adjacently — that reparses as `status := Paid()`,
3679        // a call, rather than the original two constructs.
3680        let src = "commons x { fn f(status: T) -> T {\n  match status {\n    Draft => {\n      status := Paid\n      ()\n    }\n    Paid => (),\n  }\n} }";
3681        let out = fmt(src);
3682        assert!(
3683            !out.contains("Paid()"),
3684            "the `()` tail must not re-attach to `Paid` as a call:\n{out}"
3685        );
3686        assert!(
3687            out.contains("status := Paid"),
3688            "the assignment must survive unmangled:\n{out}"
3689        );
3690        assert_eq!(out, fmt(&out), "must be idempotent");
3691    }
3692
3693    #[test]
3694    fn explicit_unit_tail_after_a_statement_is_omitted_like_an_implicit_one() {
3695        // Not just the implicit-tail shape #735 special-cased — an
3696        // *explicit* `()` written by the user right after a statement is
3697        // exactly as dangerous to print, so it is omitted the same way.
3698        let src = "commons x { fn f() -> Effect[()] {\n  let a = 1\n  ()\n} }";
3699        let out = fmt(src);
3700        let expected = "commons x {\n\tfn f() -> Effect[()] {\n\t\tlet a = 1\n\t}\n}\n";
3701        assert_eq!(
3702            out, expected,
3703            "an explicit unit tail after a statement must be omitted"
3704        );
3705        assert_eq!(out, fmt(&out), "must be idempotent");
3706    }
3707
3708    // -- #735 round-trip guard --
3709
3710    #[test]
3711    fn code_only_canonical_ignores_comments() {
3712        // Two sources whose only difference is comments must reduce to the same
3713        // comment-free canonical form — this is what lets the round-trip guard
3714        // compare structure while the formatter re-flows trivia freely.
3715        let opts = FormatOptions::default();
3716        let bare = "commons x { type T = Int where Positive }";
3717        let commented = "commons x {\n-- a note\ntype T = Int where Positive  -- trailing\n}";
3718        assert_eq!(
3719            code_only_canonical(bare, &opts).unwrap(),
3720            code_only_canonical(commented, &opts).unwrap(),
3721        );
3722    }
3723
3724    #[test]
3725    fn roundtrip_guard_accepts_faithful_output() {
3726        // The formatter's own output over a real source must round-trip.
3727        let opts = FormatOptions::default();
3728        let src = "commons x { fn add(a: Int, b: Int) -> Int { a + b } }";
3729        let out = format_source(src, &opts).unwrap();
3730        let tokens = tokenize(src).unwrap();
3731        assert!(roundtrip_divergence(&tokens, src, &out, &opts).is_none());
3732    }
3733
3734    #[test]
3735    fn roundtrip_guard_rejects_non_parsing_output() {
3736        // Simulate a printer that emitted garbage: the output no longer parses,
3737        // so the guard must fire rather than let it be written. The output here
3738        // is *longer* than the source and its parse error lands near its end —
3739        // the error's span must nonetheless stay within the source, because the
3740        // caller renders it against `source`, not the output (an out-of-range
3741        // primary span misplaces the caret or panics ariadne). See
3742        // `roundtrip_error`.
3743        let opts = FormatOptions::default();
3744        let src = "commons x { type T = Int where Positive }";
3745        let corrupt = "commons x { type T = Int where Positive } fn f(a: Int) -> Int { a +";
3746        assert!(
3747            corrupt.len() > src.len(),
3748            "output must be the longer buffer"
3749        );
3750        let tokens = tokenize(src).unwrap();
3751        let err = roundtrip_divergence(&tokens, src, corrupt, &opts)
3752            .expect("must reject non-parsing output");
3753        assert_eq!(err.category, "bynk.fmt.roundtrip");
3754        assert!(
3755            err.span.end <= src.len(),
3756            "roundtrip error span {:?} escapes the source it is rendered against (len {})",
3757            err.span,
3758            src.len(),
3759        );
3760    }
3761
3762    #[test]
3763    fn roundtrip_guard_rejects_structural_divergence() {
3764        // Simulate a printer that emitted parseable-but-wrong code: the output
3765        // parses, but to a different AST than the input. The guard must catch it.
3766        let opts = FormatOptions::default();
3767        let src = "commons x { type T = Int where Positive }";
3768        let wrong = "commons x { type T = Bool }";
3769        let tokens = tokenize(src).unwrap();
3770        let err = roundtrip_divergence(&tokens, src, wrong, &opts)
3771            .expect("must reject structural divergence");
3772        assert_eq!(err.category, "bynk.fmt.roundtrip");
3773        assert!(err.span.end <= src.len(), "span escapes the source buffer");
3774    }
3775
3776    #[test]
3777    fn roundtrip_error_renders_against_source_without_panicking() {
3778        // The guard's error is rendered against the *source* (`run_fmt` calls
3779        // `print_errors(&e.errors, &source, …)`). ariadne uses a primary span
3780        // unconditionally, so a span outside the source buffer misplaces the
3781        // caret or panics — exactly in the formatter-bug path this guard must
3782        // handle gracefully. Render the real diagnostic against a short source
3783        // and assert it produces output without panicking.
3784        let err = roundtrip_error("the formatter produced output that no longer parses");
3785        let source = "commons x {}";
3786        let rendered = bynk_render::render_errors(std::slice::from_ref(&err), source, "<test>");
3787        assert!(
3788            rendered.contains("bynk.fmt.roundtrip"),
3789            "diagnostic did not render: {rendered}"
3790        );
3791    }
3792
3793    #[test]
3794    fn unchanged_files_without_comments_format_identically() {
3795        let src = "commons x { type T = Int where NonNegative }";
3796        let out = fmt(src);
3797        // Sanity: the formatter still produces the canonical output for
3798        // existing fixtures (no spurious comment rendering).
3799        assert!(!out.contains("--"), "unexpected comment in output: {out}");
3800    }
3801
3802    // -- v0.81 storage track: `store` fields and the `:=` write --
3803
3804    #[test]
3805    fn formats_store_field_and_cell_write() {
3806        let src = "context shop {\nagent Counter {\nkey id: String\nstore count: Cell[Int] = 0\non call bump() -> Effect[()] {\ncount := count + 1\n()\n}\n}\n}";
3807        let out = fmt(src);
3808        assert!(
3809            out.contains("store count: Cell[Int] = 0"),
3810            "store field not formatted: {out}"
3811        );
3812        assert!(
3813            out.contains("count := count + 1"),
3814            "cell write not formatted: {out}"
3815        );
3816        assert_eq!(out, fmt(&out), "formatter not idempotent: {out}");
3817    }
3818
3819    #[test]
3820    fn formats_store_only_agent_without_state_block() {
3821        let src = "context shop {\nagent Counter {\nkey id: String\nstore count: Cell[Int] = 0\non call get() -> Effect[Int] {\ncount\n}\n}\n}";
3822        let out = fmt(src);
3823        // A `store`-only agent emits no empty `state { }` block.
3824        assert!(!out.contains("state {"), "spurious state block: {out}");
3825        assert!(out.contains("store count: Cell[Int] = 0"), "{out}");
3826        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3827    }
3828
3829    // -- #963: line-width-driven wrapping --
3830
3831    /// Every line of `out`, measured the way the formatter measures them.
3832    fn widths(out: &str) -> Vec<usize> {
3833        out.lines().map(|l| display_width(l, 4)).collect()
3834    }
3835
3836    fn assert_within_budget(out: &str) {
3837        let over: Vec<&str> = out.lines().filter(|l| display_width(l, 4) > 100).collect();
3838        assert!(over.is_empty(), "lines over 100 columns: {over:?}\n{out}");
3839    }
3840
3841    #[test]
3842    fn fit_test_counts_the_prefix_already_on_the_line() {
3843        // The body fits on its own but not behind the signature, which is what
3844        // the pre-#963 fit test measured.
3845        let src = "commons x {\nfn authorise(amount: Int, ceiling: Int, floor: Int) -> Result[Int, Error] { if amount > ceiling { Err(Declined) } else { Ok(amount) } }\n}";
3846        let out = fmt(src);
3847        assert_within_budget(&out);
3848        assert!(
3849            out.contains("-> Result[Int, Error] {\n"),
3850            "body not broken out: {out}"
3851        );
3852        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3853    }
3854
3855    #[test]
3856    fn wraps_a_long_record_construction_one_field_per_line() {
3857        let src = "commons x {\nfn make() -> R { R { alpha: \"first value here\", beta: \"second value here\", gamma: \"third value here\", delta: \"fourth value\" } }\n}";
3858        let out = fmt(src);
3859        assert_within_budget(&out);
3860        assert!(
3861            out.contains("\t\talpha: \"first value here\",\n"),
3862            "fields not one per line: {out}"
3863        );
3864        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3865    }
3866
3867    #[test]
3868    fn wraps_a_long_argument_list_without_a_trailing_comma() {
3869        // A parameter/argument list rejects a trailing comma in the grammar, so
3870        // the wrapped form must not emit one — `format_source` would refuse the
3871        // output on the round-trip guard if it did.
3872        let src = "commons x {\nfn go() -> Int { combine(firstOperandValue, secondOperandValue, thirdOperandValue, fourthOperandValue) }\n}";
3873        let out = fmt(src);
3874        assert_within_budget(&out);
3875        assert!(
3876            !out.contains(",\n\t\t)"),
3877            "trailing comma in an argument list: {out}"
3878        );
3879        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3880    }
3881
3882    #[test]
3883    fn wraps_long_parameter_lists_behind_the_return_type() {
3884        let src = "context x {\nservice api from http {\non POST(\"/reservations/confirm\") (identifier: String, body: Reservation) -> Effect[HttpResult[Reservation]] by Visitor { Ok(body) }\n}\n}";
3885        let out = fmt(src);
3886        assert_within_budget(&out);
3887        assert!(
3888            out.contains("\t\t\tidentifier: String,\n"),
3889            "params not wrapped: {out}"
3890        );
3891        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3892    }
3893
3894    #[test]
3895    fn breaks_a_long_chain_at_its_dots() {
3896        let src = "commons x {\nfn go(rows: List[Row]) -> List[Int] { rows.filterOnlyTheInteresting((r) => r.nights > 0).mapEachOntoItsValue((r) => r.nights * r.rate).collect() }\n}";
3897        let out = fmt(src);
3898        assert_within_budget(&out);
3899        assert!(
3900            out.contains("\n\t\t\t.collect()"),
3901            "chain not broken at the dots: {out}"
3902        );
3903        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3904    }
3905
3906    #[test]
3907    fn keeps_a_chain_intact_when_only_an_argument_needs_wrapping() {
3908        // The chain itself is short; the `match` inside is what spans lines.
3909        // Breaking at the dots here would be noise, so the chain stays put.
3910        let src = "commons x {\nfn join(parts: List[String]) -> String {\nlet init: Option[String] = None\nparts.fold(init, (acc, p) => match acc {\nSome(s) => Some(s.concat(p)),\nNone => Some(p),\n}).getOrElse(\"\")\n}\n}";
3911        let out = fmt(src);
3912        assert_within_budget(&out);
3913        assert!(
3914            out.contains("parts.fold(init, (acc, p) => match acc {"),
3915            "chain broken needlessly: {out}"
3916        );
3917        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3918    }
3919
3920    #[test]
3921    fn breaks_a_long_conjunction_before_each_operator() {
3922        let src = "commons x {\nfn ok(a: Int, b: Int, c: Int, d: Int) -> Bool { aSufficientlyLongPredicateName(a) && anotherRatherLongPredicate(b) && yetAnotherLongishPredicate(c) && theFinalPredicateHere(d) }\n}";
3923        let out = fmt(src);
3924        assert_within_budget(&out);
3925        assert!(
3926            out.lines().any(|l| l.trim_start().starts_with("&& ")),
3927            "conjunction not broken at the operators: {out}"
3928        );
3929        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3930    }
3931
3932    #[test]
3933    fn never_breaks_before_an_arithmetic_operator() {
3934        // A continuation line opening with `+` does not re-attach on re-parse,
3935        // so an arithmetic run stays on one line however long it gets.
3936        let src = "commons x {\nfn total(a: Int, b: Int, c: Int, d: Int) -> Int { someLongFunctionName(a) + anotherLongFunction(b) + aThirdLongFunction(c) + lastOne(d) }\n}";
3937        let out = fmt(src);
3938        assert!(
3939            !out.lines().any(|l| l.trim_start().starts_with("+ ")),
3940            "broke before `+`, which does not re-parse: {out}"
3941        );
3942        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3943    }
3944
3945    #[test]
3946    fn wraps_an_over_long_actor_auth_config() {
3947        let src = "context x {\nactor Partner { auth = Oidc(issuer = \"https://issuer.example.test\", audience = \"reservations-api\", jwks = \"https://issuer.example.test/jwks.json\"), identity = PartnerId }\n}";
3948        let out = fmt(src);
3949        assert_within_budget(&out);
3950        assert!(
3951            out.contains("\t\tissuer = "),
3952            "scheme args not wrapped: {out}"
3953        );
3954        assert!(out.contains("identity = PartnerId"), "identity lost: {out}");
3955        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3956    }
3957
3958    #[test]
3959    fn a_lone_block_like_argument_hugs_its_call() {
3960        let src = "commons x {\nfn go(items: List[Item]) -> Effect[()] { items.forEachInTurnAndOrder((item: Item) => { let _ <- store.put(item.identifier, item) }) }\n}";
3961        let out = fmt(src);
3962        assert_within_budget(&out);
3963        assert!(
3964            out.contains("((item: Item) => {"),
3965            "sole lambda argument did not hug its call: {out}"
3966        );
3967        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3968    }
3969
3970    #[test]
3971    fn an_unbreakable_line_is_left_long_rather_than_mangled() {
3972        // No break point exists inside a string literal; the 100-column target
3973        // is soft, so the line simply overruns.
3974        let long = "x".repeat(140);
3975        let src = format!("commons x {{\nfn go() -> String {{ \"{long}\" }}\n}}");
3976        let out = fmt(&src);
3977        assert!(
3978            widths(&out).iter().any(|w| *w > 100),
3979            "expected an over-long line: {out}"
3980        );
3981        assert_eq!(out, fmt(&out), "not idempotent: {out}");
3982    }
3983
3984    #[test]
3985    fn wrapping_is_idempotent_and_structure_preserving_across_widths() {
3986        // The round-trip guard inside `format_source` already refuses output
3987        // that re-parses differently, so a successful format at each width is
3988        // itself the structural assertion.
3989        let src = "context x {\ntype R = { id: String, name: String, size: Int }\nfn build(id: String, name: String, size: Int) -> R { R { id: id, name: name, size: size } }\nfn pick(rows: List[R]) -> List[String] { rows.filter((r) => r.size > 0).map((r) => r.name).collect() }\n}";
3990        for width in [40u32, 60, 80, 100, 120] {
3991            let opts = FormatOptions {
3992                max_line_width: width,
3993                ..FormatOptions::default()
3994            };
3995            let out = format_source(src, &opts).unwrap_or_else(|e| {
3996                panic!("width {width}: format refused ({} errors)", e.errors.len())
3997            });
3998            let again = format_source(&out, &opts).unwrap_or_else(|e| {
3999                panic!(
4000                    "width {width}: reformat refused ({} errors)",
4001                    e.errors.len()
4002                )
4003            });
4004            assert_eq!(out, again, "width {width}: not idempotent:\n{out}");
4005        }
4006    }
4007
4008    // Events slice 3b (#978): `@schema(N)` on an event round-trips the same
4009    // way `messages "en" @reference { ... }`'s annotation already does.
4010    #[test]
4011    fn event_schema_annotation_formats_and_is_idempotent() {
4012        let src = "context commerce.order {\nevent PaymentConfirmed @schema(2) = {\norderId: String,\n}\n}";
4013        let out = fmt(src);
4014        assert!(
4015            out.contains("event PaymentConfirmed @schema(2) = {"),
4016            "{out}"
4017        );
4018        assert_eq!(out, fmt(&out), "not idempotent: {out}");
4019    }
4020
4021    // An event with no annotation formats exactly as it did before this
4022    // slice — no stray space before `=`.
4023    #[test]
4024    fn event_with_no_annotation_formats_unchanged() {
4025        let src = "context commerce.order {\nevent PaymentConfirmed = {\norderId: String,\n}\n}";
4026        let out = fmt(src);
4027        assert!(out.contains("event PaymentConfirmed = {"), "{out}");
4028        assert_eq!(out, fmt(&out), "not idempotent: {out}");
4029    }
4030
4031    // Events slice 4 (#985): `via schema(N)` on a subscription header
4032    // round-trips after the `from Events(...)` header's closing `)`.
4033    #[test]
4034    fn via_schema_dispatch_formats_and_is_idempotent() {
4035        let src = "context commerce.order {\nservice OnPayment from Events(PaymentConfirmed) via schema(2) {\non event(e: PaymentConfirmed) -> Effect[()] {\nEffect.pure(())\n}\n}\n}";
4036        let out = fmt(src);
4037        assert!(
4038            out.contains("from Events(PaymentConfirmed) via schema(2)"),
4039            "{out}"
4040        );
4041        assert_eq!(out, fmt(&out), "not idempotent: {out}");
4042    }
4043
4044    // A payload pattern and a `via schema(...)` clause are independent and
4045    // combine on one header — neither one's presence should perturb the
4046    // other's rendering.
4047    #[test]
4048    fn via_schema_dispatch_combines_with_a_payload_pattern() {
4049        let src = "context commerce.order {\nservice OnPayment from Events(PaymentConfirmed { region: Domestic, .. }) via schema(2) {\non event(e: PaymentConfirmed) -> Effect[()] {\nEffect.pure(())\n}\n}\n}";
4050        let out = fmt(src);
4051        assert!(
4052            out.contains("from Events(PaymentConfirmed { region: Domestic, .. }) via schema(2)"),
4053            "{out}"
4054        );
4055        assert_eq!(out, fmt(&out), "not idempotent: {out}");
4056    }
4057}