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