ronin_core/formatter.rs
1//! The deterministic, lossless-by-semantics RON formatter (E005 Wave 1).
2//!
3//! The formatter turns a [`CstDocument`] (or a single [`SyntaxNode`] subtree) into
4//! a **canonically laid-out** RON string: one element per line for multi-line
5//! collections, canonical indentation per nesting depth, normalized spacing, and a
6//! deterministic trailing-comma rule. It is the formatter half of RONin's "smart
7//! authoring" epic and lives in `ronin-core` so every surface (desktop, future LSP)
8//! shares one engine (project-instructions §II, "One Core, Many Surfaces").
9//!
10//! # Hard invariants (project-instructions §I, "Never Corrupt User Data")
11//!
12//! The formatter ONLY changes whitespace / layout. It MUST:
13//!
14//! * **preserve every comment** — line `//` and block `/* */`, including comments
15//! at construct boundaries (before `)`, `]`, `}`) and dangling comments inside
16//! otherwise-empty collections — re-emitted attached to the same node (T007);
17//! * **preserve order** — fields, variants, list elements, map entries, tuple
18//! elements stay in source order (never sorted / reordered);
19//! * **preserve every name and value** — struct/variant names and all scalar
20//! tokens are emitted verbatim (never normalized / re-escaped);
21//! * **be idempotent** — `format(format(x)) == format(x)` (T019);
22//! * **be a no-op on failure** — unparseable / in-progress input, or any internal
23//! inconsistency, returns [`FormatResult::NoOp`] with the document byte-unchanged
24//! (T011), and the candidate output is verified to be semantically identical to
25//! the input before it is ever returned (T012, AD-008 verify-before-replace).
26//!
27//! # WASM-clean (project-instructions §II, INV-9)
28//!
29//! This module adds **no** filesystem / UI / async / native dependency — it uses
30//! only `std` and `ronin-core`'s own CST types, so the `wasm32` build of `ronin-core`
31//! stays green.
32//!
33//! # Canonical style
34//!
35//! * **Indent** — `indent_width` spaces per nesting depth (clamped to `1..=16`,
36//! default `4`).
37//! * **Element-per-line** — a collection that spans more than one line in the
38//! source (or that contains a comment) is laid out one element per line; a
39//! collection that fits on a single source line and has no comments stays on one
40//! line.
41//! * **Trailing comma** — a multi-line collection gets a trailing comma after
42//! every element including the last; a single-line collection gets none (T008).
43//! * **Spacing** — `name: value` (one space after `:`), `key: value` in maps, one
44//! space after a struct/variant name has no gap before its `(`/`{`, etc.
45//! * **Blank lines** — [`BlankLinePolicy::Collapse`] (default) collapses any run of
46//! blank lines to at most one; [`BlankLinePolicy::Preserve`] keeps the original
47//! blank-line count between elements.
48//!
49//! # Deferred seams
50//!
51//! The formatter is **structural only** — it lays out the CST it is given and never
52//! consults type information. The intelligence that layers on top is deferred to
53//! later epics and attaches around (never inside) this pure-CST engine:
54//!
55//! * **type-aware formatting / completion** (e.g. canonical layout choices keyed to
56//! an expected type) → **E006** (schema-optional type model);
57//! * **semantic / CST-backed undo-redo** of a format apply → **E007** (the format
58//! command's buffer replacement is the seam an undo stack records against);
59//! * **tree / table structured editing** that would re-emit through this formatter
60//! → **E008**;
61//! * **Bevy-registry-aware** formatting (component-aware layout) → **E009**;
62//! * **RON⇄JSON interop / `derive`-driven canonicalization** → **E010** (interop
63//! lives outside this CST formatter; the `ron` crate is used there, never here).
64
65use crate::parser::{parse, CstDocument};
66use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken};
67
68/// How the formatter treats runs of blank lines between elements / fields.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum BlankLinePolicy {
71 /// Collapse any run of blank lines to at most one (the default). A blank line
72 /// the user inserted between two fields is kept (a single blank), but a run of
73 /// several is reduced to one.
74 Collapse,
75 /// Preserve the original number of blank lines between elements verbatim.
76 Preserve,
77}
78
79impl Default for BlankLinePolicy {
80 #[inline]
81 fn default() -> Self {
82 Self::Collapse
83 }
84}
85
86/// Formatter configuration (the formatter-side mirror of `ronin-app`'s
87/// `FormattingConfig`). A pure value type with no I/O — WASM-clean.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct FormatConfig {
90 /// Spaces of indent per nesting depth. Constructed values are clamped to
91 /// [`FormatConfig::MIN_INDENT`]`..=`[`FormatConfig::MAX_INDENT`] via
92 /// [`FormatConfig::new`] / [`FormatConfig::with_indent_width`]; read it back
93 /// through [`FormatConfig::indent_width`].
94 indent_width: u32,
95 /// How runs of blank lines are treated.
96 blank_line_policy: BlankLinePolicy,
97}
98
99impl FormatConfig {
100 /// The smallest permitted indent width (1 space).
101 pub const MIN_INDENT: u32 = 1;
102 /// The largest permitted indent width (16 spaces).
103 pub const MAX_INDENT: u32 = 16;
104 /// The default indent width (4 spaces).
105 pub const DEFAULT_INDENT: u32 = 4;
106
107 /// Build a config, clamping `indent_width` to the sane range
108 /// [`MIN_INDENT`](Self::MIN_INDENT)`..=`[`MAX_INDENT`](Self::MAX_INDENT).
109 #[must_use]
110 pub fn new(indent_width: u32, blank_line_policy: BlankLinePolicy) -> Self {
111 Self {
112 indent_width: indent_width.clamp(Self::MIN_INDENT, Self::MAX_INDENT),
113 blank_line_policy,
114 }
115 }
116
117 /// Builder-style override of the indent width (clamped to the sane range).
118 #[must_use]
119 pub fn with_indent_width(mut self, indent_width: u32) -> Self {
120 self.indent_width = indent_width.clamp(Self::MIN_INDENT, Self::MAX_INDENT);
121 self
122 }
123
124 /// Builder-style override of the blank-line policy.
125 #[must_use]
126 pub fn with_blank_line_policy(mut self, policy: BlankLinePolicy) -> Self {
127 self.blank_line_policy = policy;
128 self
129 }
130
131 /// The (already-clamped) indent width in spaces.
132 #[must_use]
133 pub fn indent_width(self) -> u32 {
134 self.indent_width
135 }
136
137 /// The blank-line policy.
138 #[must_use]
139 pub fn blank_line_policy(self) -> BlankLinePolicy {
140 self.blank_line_policy
141 }
142}
143
144impl Default for FormatConfig {
145 #[inline]
146 fn default() -> Self {
147 Self {
148 indent_width: Self::DEFAULT_INDENT,
149 blank_line_policy: BlankLinePolicy::default(),
150 }
151 }
152}
153
154/// The outcome of a format request.
155///
156/// On success it carries the canonically-formatted text. On any failure path —
157/// unparseable input, error-recovered tree, no clean subtree boundary, or a
158/// verify-before-replace mismatch — it carries a human-readable reason and the
159/// **caller's original bytes are left unchanged** (the formatter never performs a
160/// partial rewrite, project-instructions §I).
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub enum FormatResult {
163 /// Formatting succeeded; the canonical text is enclosed.
164 Formatted(String),
165 /// Formatting was declined; the document is unchanged. `reason` explains why
166 /// (suitable for a non-blocking notice).
167 NoOp {
168 /// Why the formatter declined (e.g. "input has parse errors").
169 reason: String,
170 },
171}
172
173impl FormatResult {
174 /// A [`FormatResult::NoOp`] carrying `reason`.
175 fn no_op(reason: impl Into<String>) -> Self {
176 Self::NoOp {
177 reason: reason.into(),
178 }
179 }
180
181 /// The formatted text, if formatting succeeded.
182 #[must_use]
183 pub fn formatted(&self) -> Option<&str> {
184 match self {
185 Self::Formatted(s) => Some(s),
186 Self::NoOp { .. } => None,
187 }
188 }
189
190 /// `true` if formatting was declined (a no-op).
191 #[must_use]
192 pub fn is_no_op(&self) -> bool {
193 matches!(self, Self::NoOp { .. })
194 }
195}
196
197/// Format the whole document into canonical RON (T009).
198///
199/// Returns [`FormatResult::Formatted`] with the canonical text, or
200/// [`FormatResult::NoOp`] (input unchanged) when the input does not parse cleanly
201/// (error-recovered tree, T011) or the candidate output fails the
202/// verify-before-replace semantic check (T012).
203#[must_use]
204pub fn format(doc: &CstDocument, config: &FormatConfig) -> FormatResult {
205 // T011 — no-op on unparseable / in-progress input: a tree that triggered error
206 // recovery is never reflowed (a partial / wrong reflow would corrupt data).
207 if !doc.diagnostics().is_empty() {
208 return FormatResult::no_op("input has parse errors; formatting skipped");
209 }
210 let root = doc.root();
211 format_subtree(&root, config, FormatScope::WholeDocument)
212}
213
214/// Format the smallest enclosing CST subtree for "Format Selection" (T010).
215///
216/// `node` is expected to be a value-position node (struct / tuple / list / map /
217/// enum-variant / unit / literal) or the document root. For any other node — i.e.
218/// no clean subtree boundary — this returns [`FormatResult::NoOp`]. The rest of the
219/// document is the caller's responsibility (the caller splices the returned text in
220/// place); this function only produces the canonical text for `node`'s span.
221#[must_use]
222pub fn format_node(node: &SyntaxNode, config: &FormatConfig) -> FormatResult {
223 // T011 — refuse to format any subtree that contains an error-recovery node:
224 // reflowing partially-parsed input risks corruption.
225 if subtree_has_errors(node) {
226 return FormatResult::no_op("selection contains parse errors; formatting skipped");
227 }
228 // A clean subtree boundary is a value node or the root. Anything else (a bare
229 // StructField, MapEntry, a token, the ExtensionAttr, an Error node) has no
230 // standalone canonical form here → no-op (T010).
231 let scope = match node.kind() {
232 SyntaxKind::Root => FormatScope::WholeDocument,
233 SyntaxKind::Struct
234 | SyntaxKind::Tuple
235 | SyntaxKind::List
236 | SyntaxKind::Map
237 | SyntaxKind::EnumVariant
238 | SyntaxKind::Unit
239 | SyntaxKind::Literal => FormatScope::Subtree,
240 _ => {
241 return FormatResult::no_op(
242 "no clean subtree boundary at the selection; formatting skipped",
243 )
244 }
245 };
246 format_subtree(node, config, scope)
247}
248
249/// Whether a format pass covers the whole document (so it owns leading extension
250/// attributes + a final trailing newline) or a single embedded subtree.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252enum FormatScope {
253 WholeDocument,
254 Subtree,
255}
256
257/// Shared implementation behind [`format`] and [`format_node`].
258///
259/// Emits the canonical text for `node`, then runs verify-before-replace (T012)
260/// before returning it. Any internal inconsistency degrades to a no-op (T011), so
261/// the formatter is never the source of a partial / corrupting rewrite.
262fn format_subtree(node: &SyntaxNode, config: &FormatConfig, scope: FormatScope) -> FormatResult {
263 let original = node.text();
264
265 let mut writer = Writer::new(config);
266 match scope {
267 FormatScope::WholeDocument => emit_root(node, &mut writer, config),
268 FormatScope::Subtree => emit_value_like(node, &mut writer, config),
269 }
270 let candidate = writer.finish(scope, &original);
271
272 // T012 — verify-before-replace (AD-008): re-parse the candidate and confirm it
273 // is SEMANTICALLY identical to the input (same names / order / values /
274 // comments, ignoring only whitespace layout). On ANY mismatch, no-op.
275 match scope {
276 FormatScope::WholeDocument => {
277 let reparsed = parse(&candidate);
278 if !reparsed.diagnostics().is_empty() {
279 return FormatResult::no_op(
280 "internal: formatted output did not re-parse cleanly; left unchanged",
281 );
282 }
283 if !semantically_equal(&original, &candidate) {
284 return FormatResult::no_op(
285 "internal: semantic verification failed; document left unchanged",
286 );
287 }
288 }
289 FormatScope::Subtree => {
290 // A subtree's candidate is verified against the original subtree text by
291 // comparing significant + comment token streams directly (the candidate
292 // may not be a stand-alone whole document, e.g. a bare literal).
293 if !semantic_tokens_equal(&original, &candidate) {
294 return FormatResult::no_op(
295 "internal: semantic verification failed; selection left unchanged",
296 );
297 }
298 }
299 }
300
301 // Idempotence guard: if the input was already canonical, the candidate equals
302 // the original — still a successful Formatted (the caller can compare to detect
303 // a no-change result if it cares).
304 FormatResult::Formatted(candidate)
305}
306
307// =============================================================================
308// Trivia model (T007): classify and re-emit comments attached to the right node.
309// =============================================================================
310
311/// A comment captured from the CST, with its leading-blank context.
312#[derive(Debug, Clone)]
313struct Comment {
314 /// Verbatim comment text (e.g. `// foo` or `/* bar */`), never normalized.
315 text: String,
316 /// Number of blank lines that preceded this comment in the source (already
317 /// resolved against the blank-line policy by the caller).
318 blanks_before: usize,
319 /// `true` if this comment began on the same source line as the preceding
320 /// significant token (an inline trailing comment, e.g. `1, // note`).
321 same_line_as_prev: bool,
322}
323
324/// The trivia found between two significant tokens (or around a construct), split
325/// into the comments it carries and the blank-line run that trails it.
326#[derive(Debug, Clone, Default)]
327struct TriviaRun {
328 /// Comments in source order.
329 comments: Vec<Comment>,
330 /// Blank lines after the last comment (or in an all-whitespace run), already
331 /// resolved against the policy.
332 trailing_blanks: usize,
333 /// `true` if the run contained at least one newline (used to decide whether a
334 /// following inline comment really is "same line").
335 has_newline: bool,
336}
337
338impl TriviaRun {
339 fn is_empty(&self) -> bool {
340 self.comments.is_empty()
341 }
342}
343
344/// Count the blank lines represented by a run of whitespace text.
345///
346/// A "blank line" is a newline beyond the first: `"\n"` (end of one line) is zero
347/// blanks; `"\n\n"` is one blank line; `"\n\n\n"` is two. CRLF is handled by
348/// counting `\n` only.
349fn count_blank_lines(ws: &str) -> usize {
350 let newlines = ws.bytes().filter(|&b| b == b'\n').count();
351 newlines.saturating_sub(1)
352}
353
354/// Resolve a raw blank-line count against the policy.
355fn resolve_blanks(raw: usize, policy: BlankLinePolicy) -> usize {
356 match policy {
357 BlankLinePolicy::Collapse => raw.min(1),
358 BlankLinePolicy::Preserve => raw,
359 }
360}
361
362// =============================================================================
363// Emit: the canonical layout walk.
364// =============================================================================
365
366/// A small indent-tracking string writer.
367struct Writer {
368 out: String,
369 indent_level: usize,
370 indent_width: usize,
371 /// `true` when the current line has no content yet (so indentation is pending).
372 at_line_start: bool,
373}
374
375impl Writer {
376 fn new(config: &FormatConfig) -> Self {
377 Self {
378 out: String::new(),
379 indent_level: 0,
380 indent_width: config.indent_width() as usize,
381 at_line_start: true,
382 }
383 }
384
385 fn indent(&mut self) {
386 self.indent_level += 1;
387 }
388
389 fn dedent(&mut self) {
390 self.indent_level = self.indent_level.saturating_sub(1);
391 }
392
393 /// Write `s` (no newlines expected inside) at the current position, emitting
394 /// pending indentation first if at line start.
395 fn write(&mut self, s: &str) {
396 if s.is_empty() {
397 return;
398 }
399 if self.at_line_start {
400 for _ in 0..(self.indent_level * self.indent_width) {
401 self.out.push(' ');
402 }
403 self.at_line_start = false;
404 }
405 self.out.push_str(s);
406 }
407
408 /// End the current line.
409 fn newline(&mut self) {
410 // Trim trailing spaces on the line we are closing (no trailing whitespace).
411 while self.out.ends_with(' ') {
412 self.out.pop();
413 }
414 self.out.push('\n');
415 self.at_line_start = true;
416 }
417
418 /// Emit `count` blank lines (each an empty line).
419 fn blank_lines(&mut self, count: usize) {
420 for _ in 0..count {
421 // A blank line is just a newline with no content.
422 while self.out.ends_with(' ') {
423 self.out.pop();
424 }
425 self.out.push('\n');
426 self.at_line_start = true;
427 }
428 }
429
430 /// Finish the formatted text, normalizing the document-final newline.
431 fn finish(mut self, scope: FormatScope, original: &str) -> String {
432 // Trim trailing spaces on the final line.
433 while self.out.ends_with(' ') {
434 self.out.pop();
435 }
436 match scope {
437 FormatScope::WholeDocument => {
438 // Canonical whole-document output ends in exactly one newline when
439 // there is content; an empty/trivia-only doc keeps its emptiness.
440 while self.out.ends_with('\n') {
441 self.out.pop();
442 }
443 // Preserve a leading BOM if the original carried one.
444 if original.starts_with('\u{FEFF}') && !self.out.starts_with('\u{FEFF}') {
445 self.out.insert(0, '\u{FEFF}');
446 }
447 let body_is_empty =
448 self.out.is_empty() || self.out == "\u{FEFF}" || self.out.trim().is_empty();
449 if !body_is_empty {
450 self.out.push('\n');
451 }
452 self.out
453 }
454 FormatScope::Subtree => {
455 // A subtree carries no document-final newline; strip any trailing
456 // newline we may have emitted so the splice site stays exact.
457 while self.out.ends_with('\n') {
458 self.out.pop();
459 }
460 self.out
461 }
462 }
463 }
464}
465
466/// Emit the canonical layout for the document root.
467///
468/// Walks the root's children in source order, emitting each significant child
469/// (extension attributes, the single top-level value) on its own line(s) and
470/// threading every comment between them so nothing is lost (T007). A leading BOM is
471/// re-inserted by [`Writer::finish`], so it is skipped here.
472fn emit_root(root: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
473 let policy = config.blank_line_policy();
474 let children: Vec<SyntaxElement> = root.children_with_tokens().collect();
475
476 // Pending trivia buffer between significant children.
477 let mut pending: Vec<SyntaxToken> = Vec::new();
478 // Whether we have emitted any significant content yet (so we know when to start
479 // a fresh line before the next item).
480 let mut emitted_any = false;
481 // Whether the last thing emitted was a significant item on the current line
482 // (so an inline trailing comment can attach to it).
483 let mut last_was_item = false;
484
485 for el in &children {
486 match el {
487 SyntaxElement::Token(t) if t.is_trivia() => {
488 // BOM is layout metadata re-emitted by `finish`; skip it here.
489 if t.kind() != SyntaxKind::Bom {
490 pending.push(t.clone());
491 }
492 }
493 SyntaxElement::Node(n) if n.kind() == SyntaxKind::ExtensionAttr => {
494 emit_root_pending(&pending, w, policy, last_was_item, &mut emitted_any);
495 pending.clear();
496 if emitted_any {
497 w.newline();
498 }
499 emit_extension_attr(n, w);
500 emitted_any = true;
501 last_was_item = true;
502 }
503 SyntaxElement::Node(n) if is_value_kind(n.kind()) => {
504 emit_root_pending(&pending, w, policy, last_was_item, &mut emitted_any);
505 pending.clear();
506 if emitted_any {
507 w.newline();
508 }
509 emit_value_like(n, w, config);
510 emitted_any = true;
511 last_was_item = true;
512 }
513 // Any other node (Error etc.) — emit its verbatim trimmed text so bytes
514 // are never dropped; verification will catch a real problem.
515 SyntaxElement::Node(n) => {
516 emit_root_pending(&pending, w, policy, last_was_item, &mut emitted_any);
517 pending.clear();
518 if emitted_any {
519 w.newline();
520 }
521 w.write(n.text().trim());
522 emitted_any = true;
523 last_was_item = true;
524 }
525 // A stray significant token at root level (recovery): keep it verbatim.
526 SyntaxElement::Token(t) => {
527 emit_root_pending(&pending, w, policy, last_was_item, &mut emitted_any);
528 pending.clear();
529 if emitted_any {
530 w.newline();
531 }
532 w.write(t.text());
533 emitted_any = true;
534 last_was_item = true;
535 }
536 }
537 }
538
539 // Trailing comments bound to the root (after the last significant child).
540 emit_root_pending(&pending, w, policy, last_was_item, &mut emitted_any);
541}
542
543/// Emit the buffered root-level trivia (comments) between significant children.
544///
545/// Comments on their own line are emitted at column 0; a comment that shared the
546/// line with the previous item is emitted inline (one space after it). Updates
547/// `emitted_any` when it emits content.
548fn emit_root_pending(
549 pending: &[SyntaxToken],
550 w: &mut Writer,
551 policy: BlankLinePolicy,
552 last_was_item: bool,
553 emitted_any: &mut bool,
554) {
555 if pending.is_empty() {
556 return;
557 }
558 let (inline, leading) = split_pending_trivia(pending);
559
560 // Inline comment(s) on the same line as the previous item.
561 let inline_run = build_trivia_run(&inline, policy, last_was_item);
562 for c in &inline_run.comments {
563 if c.same_line_as_prev && *emitted_any {
564 w.write(" ");
565 w.write(&c.text);
566 } else {
567 if *emitted_any {
568 w.newline();
569 w.blank_lines(c.blanks_before);
570 }
571 w.write(&c.text);
572 *emitted_any = true;
573 }
574 }
575
576 // Own-line leading comments.
577 let leading_run = build_trivia_run(&leading, policy, false);
578 for c in &leading_run.comments {
579 if *emitted_any {
580 w.newline();
581 w.blank_lines(c.blanks_before);
582 }
583 w.write(&c.text);
584 *emitted_any = true;
585 }
586}
587
588/// Emit an extension attribute node verbatim (significant tokens with single
589/// spaces, comments preserved). Extension attrs are rare and structurally fixed
590/// (`#![enable(a, b)]`), so we emit a conservative canonical form.
591fn emit_extension_attr(attr: &SyntaxNode, w: &mut Writer) {
592 // Re-emit the significant tokens with no internal reflow other than trimming —
593 // the canonical form of `#![enable(implicit_some)]` is itself. We rebuild it
594 // from significant tokens to drop any odd internal whitespace, preserving any
595 // comments inline.
596 let mut first = true;
597 let mut prev: Option<SyntaxKind> = None;
598 for el in attr.children_with_tokens() {
599 if let SyntaxElement::Token(t) = el {
600 if t.is_trivia() {
601 if matches!(t.kind(), SyntaxKind::LineComment | SyntaxKind::BlockComment) {
602 w.write(" ");
603 w.write(t.text());
604 }
605 continue;
606 }
607 let k = t.kind();
608 if !first {
609 // Space rule inside an extension attr: a space before `enable`-ident
610 // after `[`, and `, ` between idents; none around `#`, `!`, `(`, `)`,
611 // `[`, `]`.
612 if needs_space_in_ext_attr(prev, k) {
613 w.write(" ");
614 }
615 }
616 w.write(t.text());
617 first = false;
618 prev = Some(k);
619 }
620 }
621}
622
623/// Spacing rule for the two adjacent significant tokens inside an extension attr.
624fn needs_space_in_ext_attr(prev: Option<SyntaxKind>, cur: SyntaxKind) -> bool {
625 match (prev, cur) {
626 // `, ident` → space after comma.
627 (Some(SyntaxKind::Comma), _) => true,
628 _ => false,
629 }
630}
631
632/// Whether `kind` is a value-position node kind.
633fn is_value_kind(kind: SyntaxKind) -> bool {
634 matches!(
635 kind,
636 SyntaxKind::Struct
637 | SyntaxKind::Tuple
638 | SyntaxKind::List
639 | SyntaxKind::Map
640 | SyntaxKind::EnumVariant
641 | SyntaxKind::Unit
642 | SyntaxKind::Literal
643 )
644}
645
646/// Emit any value-like node (dispatch by kind).
647fn emit_value_like(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
648 match node.kind() {
649 SyntaxKind::Literal => emit_literal(node, w),
650 SyntaxKind::Unit => emit_unit(node, w, config),
651 SyntaxKind::Struct => emit_struct(node, w, config),
652 SyntaxKind::Tuple => emit_tuple(node, w, config),
653 SyntaxKind::List => emit_list(node, w, config),
654 SyntaxKind::Map => emit_map(node, w, config),
655 SyntaxKind::EnumVariant => emit_enum_variant(node, w, config),
656 SyntaxKind::Root => emit_root(node, w, config),
657 // Any other node (Error, etc.) should have been rejected earlier; emit its
658 // verbatim text as a last-resort so we never drop bytes (verification will
659 // then decide).
660 _ => w.write(node.text().trim()),
661 }
662}
663
664/// Emit a scalar literal verbatim (its single token text).
665fn emit_literal(node: &SyntaxNode, w: &mut Writer) {
666 if let Some(tok) = node
667 .children_with_tokens()
668 .filter_map(|el| el.as_token().cloned())
669 .find(|t| !t.is_trivia())
670 {
671 w.write(tok.text());
672 }
673}
674
675/// Emit the unit value `()` (or a named empty `Foo()`), preserving any dangling
676/// comment inside the parens (T007).
677///
678/// The parser classifies `Foo()` and `()` — and `Foo(/* c */)` / `(/* c */)` —
679/// as [`SyntaxKind::Unit`]; this emitter therefore handles a possible leading
680/// name and an interior dangling comment so neither the name nor the comment is
681/// ever dropped.
682fn emit_unit(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
683 if let Some(name) = leading_name_token(node) {
684 w.write(name.text());
685 }
686 // No elements; reuse the collection emitter so a dangling comment is threaded.
687 emit_paren_collection(node, &[], w, config, EntryKind::Value);
688}
689
690/// Emit a bare enum variant (`Ident`) or struct-like variant (`Ident { .. }`).
691fn emit_enum_variant(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
692 // The variant name token.
693 if let Some(name) = node.first_token_of(SyntaxKind::Ident) {
694 w.write(name.text());
695 }
696 // A struct-like payload `{ .. }` if present (an LBrace child token).
697 if node
698 .children_with_tokens()
699 .any(|el| el.kind() == SyntaxKind::LBrace)
700 {
701 let entries: Vec<SyntaxNode> = node
702 .children()
703 .filter(|n| n.kind() == SyntaxKind::MapEntry)
704 .collect();
705 emit_brace_collection(node, &entries, w, config, Delim::Brace, EntryKind::MapEntry);
706 }
707}
708
709/// Emit a named or anonymous struct.
710fn emit_struct(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
711 if let Some(name) = node.first_token_of(SyntaxKind::Ident) {
712 w.write(name.text());
713 }
714 let fields: Vec<SyntaxNode> = node
715 .children()
716 .filter(|n| n.kind() == SyntaxKind::StructField)
717 .collect();
718 emit_paren_collection(node, &fields, w, config, EntryKind::StructField);
719}
720
721/// Emit a positional tuple, including a leading name for a tuple-struct /
722/// newtype-variant payload such as `Some(5)` or `Foo(1, 2)`.
723fn emit_tuple(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
724 // A named tuple (tuple struct / variant payload) carries a leading `Ident`
725 // token before its `(`; emit it verbatim so the name is never dropped.
726 if let Some(name) = leading_name_token(node) {
727 w.write(name.text());
728 }
729 let items: Vec<SyntaxNode> = node
730 .children()
731 .filter(|n| is_value_kind(n.kind()))
732 .collect();
733 emit_paren_collection(node, &items, w, config, EntryKind::Value);
734}
735
736/// The leading name `Ident` token of a node (before its first delimiter), if any.
737///
738/// Distinguishes a tuple-struct / named struct (`Foo(..)`) from an anonymous one.
739/// Returns the `Ident` only when it appears before the first `(`/`{`/`[` delimiter
740/// (a name), never a stray ident inside the body.
741fn leading_name_token(node: &SyntaxNode) -> Option<SyntaxToken> {
742 for el in node.children_with_tokens() {
743 match el {
744 SyntaxElement::Token(t) if t.is_trivia() => continue,
745 SyntaxElement::Token(t) if t.kind() == SyntaxKind::Ident => return Some(t),
746 // First non-trivia, non-ident element (a delimiter or a value node):
747 // there is no leading name.
748 _ => return None,
749 }
750 }
751 None
752}
753
754/// Emit a list `[ .. ]`.
755fn emit_list(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
756 let items: Vec<SyntaxNode> = node
757 .children()
758 .filter(|n| is_value_kind(n.kind()))
759 .collect();
760 emit_bracket_collection(node, &items, w, config, EntryKind::Value);
761}
762
763/// Emit a map `{ .. }`.
764fn emit_map(node: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
765 let entries: Vec<SyntaxNode> = node
766 .children()
767 .filter(|n| n.kind() == SyntaxKind::MapEntry)
768 .collect();
769 emit_brace_collection(node, &entries, w, config, Delim::Brace, EntryKind::MapEntry);
770}
771
772/// The delimiter family of a collection.
773#[derive(Debug, Clone, Copy, PartialEq, Eq)]
774enum Delim {
775 Paren,
776 Bracket,
777 Brace,
778}
779
780impl Delim {
781 fn open(self) -> &'static str {
782 match self {
783 Delim::Paren => "(",
784 Delim::Bracket => "[",
785 Delim::Brace => "{",
786 }
787 }
788 fn close(self) -> &'static str {
789 match self {
790 Delim::Paren => ")",
791 Delim::Bracket => "]",
792 Delim::Brace => "}",
793 }
794 }
795 fn close_kind(self) -> SyntaxKind {
796 match self {
797 Delim::Paren => SyntaxKind::RParen,
798 Delim::Bracket => SyntaxKind::RBracket,
799 Delim::Brace => SyntaxKind::RBrace,
800 }
801 }
802}
803
804/// The kind of element inside a collection (controls how it is emitted).
805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
806enum EntryKind {
807 /// A `field: value` struct field.
808 StructField,
809 /// A `key: value` map entry.
810 MapEntry,
811 /// A bare positional value (list/tuple element).
812 Value,
813}
814
815fn emit_paren_collection(
816 node: &SyntaxNode,
817 elements: &[SyntaxNode],
818 w: &mut Writer,
819 config: &FormatConfig,
820 entry: EntryKind,
821) {
822 emit_collection(node, elements, w, config, Delim::Paren, entry);
823}
824
825fn emit_bracket_collection(
826 node: &SyntaxNode,
827 elements: &[SyntaxNode],
828 w: &mut Writer,
829 config: &FormatConfig,
830 entry: EntryKind,
831) {
832 emit_collection(node, elements, w, config, Delim::Bracket, entry);
833}
834
835fn emit_brace_collection(
836 node: &SyntaxNode,
837 elements: &[SyntaxNode],
838 w: &mut Writer,
839 config: &FormatConfig,
840 delim: Delim,
841 entry: EntryKind,
842) {
843 emit_collection(node, elements, w, config, delim, entry);
844}
845
846/// The core collection emitter: decide single- vs multi-line, then lay out the
847/// elements, threading comments (T007) and applying the trailing-comma oracle
848/// (T008).
849fn emit_collection(
850 node: &SyntaxNode,
851 elements: &[SyntaxNode],
852 w: &mut Writer,
853 config: &FormatConfig,
854 delim: Delim,
855 entry: EntryKind,
856) {
857 // Gather the interior trivia segments around / between elements.
858 let layout = collect_collection_layout(node, elements, config, delim);
859
860 let multiline = decide_multiline(node, &layout, elements.len());
861
862 w.write(delim.open());
863
864 if elements.is_empty() {
865 // Possibly a dangling comment inside an empty collection (T007).
866 emit_empty_collection_body(&layout, w, multiline);
867 w.write(delim.close());
868 return;
869 }
870
871 if multiline {
872 w.indent();
873 for (i, el) in elements.iter().enumerate() {
874 let seg = &layout.before[i];
875 // Newline to start this element's line, plus any leading comments.
876 w.newline();
877 w.blank_lines(if i == 0 { 0 } else { seg_blank(seg) });
878 emit_leading_comments_block(seg, w);
879 emit_entry(el, w, config, entry);
880 // Trailing comma (oracle: multi-line ⇒ every element, incl. last).
881 w.write(",");
882 // An inline trailing comment after this element (e.g. `1, // note`).
883 emit_inline_trailing_comment(&layout.after[i], w);
884 }
885 // Comments that sit just before the closing delimiter (boundary comments).
886 emit_pre_close_comments(&layout.pre_close, w);
887 w.dedent();
888 w.newline();
889 w.write(delim.close());
890 } else {
891 // Single line: `( a, b, c )`-style with no trailing comma.
892 for (i, el) in elements.iter().enumerate() {
893 if i > 0 {
894 w.write(", ");
895 }
896 emit_entry(el, w, config, entry);
897 }
898 w.write(delim.close());
899 }
900}
901
902/// Blank-lines to emit before an element, derived from its leading trivia segment.
903fn seg_blank(seg: &TriviaRun) -> usize {
904 seg.trailing_blanks
905}
906
907/// Emit an element's leading comments, each on its own line (multi-line layout).
908fn emit_leading_comments_block(seg: &TriviaRun, w: &mut Writer) {
909 for c in &seg.comments {
910 w.write(&c.text);
911 w.newline();
912 w.blank_lines(c.blanks_before_or(0));
913 }
914}
915
916impl Comment {
917 /// Blank lines before this comment, or `default` when none recorded.
918 fn blanks_before_or(&self, _default: usize) -> usize {
919 self.blanks_before
920 }
921}
922
923/// Emit an inline trailing comment (same source line as the element) after the
924/// element + comma, e.g. `1, // note`. If the comment was on its own line it is
925/// emitted as a leading comment of the *next* element instead, so here we only
926/// handle the genuinely-inline case.
927fn emit_inline_trailing_comment(seg: &TriviaRun, w: &mut Writer) {
928 for c in &seg.comments {
929 if c.same_line_as_prev {
930 w.write(" ");
931 w.write(&c.text);
932 } else {
933 // Own-line comment trailing the element: put it on its own line.
934 w.newline();
935 w.blank_lines(c.blanks_before);
936 w.write(&c.text);
937 }
938 }
939}
940
941/// Emit comments that sit between the last element and the closing delimiter.
942fn emit_pre_close_comments(seg: &TriviaRun, w: &mut Writer) {
943 for c in &seg.comments {
944 w.newline();
945 w.blank_lines(c.blanks_before);
946 w.write(&c.text);
947 }
948}
949
950/// Emit the body of an empty collection — possibly a dangling comment (T007).
951fn emit_empty_collection_body(layout: &CollectionLayout, w: &mut Writer, multiline: bool) {
952 if layout.pre_close.is_empty() {
953 return;
954 }
955 if multiline {
956 w.indent();
957 for c in &layout.pre_close.comments {
958 w.newline();
959 w.blank_lines(c.blanks_before);
960 w.write(&c.text);
961 }
962 w.dedent();
963 w.newline();
964 } else {
965 // Single-line dangling comment: keep it inline with spaces.
966 for c in &layout.pre_close.comments {
967 w.write(" ");
968 w.write(&c.text);
969 w.write(" ");
970 }
971 }
972}
973
974/// Emit one collection element by kind.
975fn emit_entry(el: &SyntaxNode, w: &mut Writer, config: &FormatConfig, kind: EntryKind) {
976 match kind {
977 EntryKind::StructField => emit_struct_field(el, w, config),
978 EntryKind::MapEntry => emit_map_entry(el, w, config),
979 EntryKind::Value => emit_value_like(el, w, config),
980 }
981}
982
983/// Emit `name: value` for a struct field.
984fn emit_struct_field(field: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
985 if let Some(name) = field.first_token_of(SyntaxKind::Ident) {
986 w.write(name.text());
987 }
988 w.write(":");
989 if let Some(value) = field.children().find(|n| is_value_kind(n.kind())) {
990 w.write(" ");
991 emit_value_like(&value, w, config);
992 }
993}
994
995/// Emit `key: value` for a map entry (the key can be any value).
996fn emit_map_entry(entry: &SyntaxNode, w: &mut Writer, config: &FormatConfig) {
997 let values: Vec<SyntaxNode> = entry
998 .children()
999 .filter(|n| is_value_kind(n.kind()))
1000 .collect();
1001 if let Some(key) = values.first() {
1002 emit_value_like(key, w, config);
1003 }
1004 w.write(":");
1005 if let Some(value) = values.get(1) {
1006 w.write(" ");
1007 emit_value_like(value, w, config);
1008 }
1009}
1010
1011// =============================================================================
1012// Collection-layout extraction: split a collection's interior trivia into the
1013// segments the emitter consumes (before each element, after each element, and
1014// before the close delimiter).
1015// =============================================================================
1016
1017/// The trivia layout of a collection, indexed alongside its elements.
1018#[derive(Debug, Default)]
1019struct CollectionLayout {
1020 /// Trivia immediately before element `i` (leading comments + blank context).
1021 before: Vec<TriviaRun>,
1022 /// Trivia immediately after element `i` up to the next separator/element
1023 /// (an inline trailing comment lives here).
1024 after: Vec<TriviaRun>,
1025 /// Trivia between the last element (or the open delim, if empty) and the close
1026 /// delimiter — boundary / dangling comments (T007).
1027 pre_close: TriviaRun,
1028 /// `true` if the open delimiter and close delimiter were on different source
1029 /// lines in the input (a primary multi-line signal).
1030 spans_multiple_lines: bool,
1031}
1032
1033/// Walk a collection node's `children_with_tokens` and bucket trivia into
1034/// per-element segments. Elements are identified by their node identity (kind +
1035/// range) in `elements`.
1036fn collect_collection_layout(
1037 node: &SyntaxNode,
1038 elements: &[SyntaxNode],
1039 config: &FormatConfig,
1040 delim: Delim,
1041) -> CollectionLayout {
1042 let policy = config.blank_line_policy();
1043 let mut layout = CollectionLayout {
1044 before: vec![TriviaRun::default(); elements.len()],
1045 after: vec![TriviaRun::default(); elements.len()],
1046 pre_close: TriviaRun::default(),
1047 spans_multiple_lines: false,
1048 };
1049
1050 // Build a flat, ordered list of this node's *direct* children (tokens + nodes),
1051 // so we can scan from the open delimiter to the close delimiter.
1052 let children: Vec<SyntaxElement> = node.children_with_tokens().collect();
1053
1054 // Locate the open delimiter index and the close delimiter index (last matching
1055 // close token at this level).
1056 let open_kind = match delim {
1057 Delim::Paren => SyntaxKind::LParen,
1058 Delim::Bracket => SyntaxKind::LBracket,
1059 Delim::Brace => SyntaxKind::LBrace,
1060 };
1061 let close_kind = delim.close_kind();
1062
1063 let open_idx = children
1064 .iter()
1065 .position(|el| el.kind() == open_kind && el.as_token().is_some());
1066 let close_idx = children
1067 .iter()
1068 .rposition(|el| el.kind() == close_kind && el.as_token().is_some());
1069
1070 let (open_idx, close_idx) = match (open_idx, close_idx) {
1071 (Some(o), Some(c)) if c > o => (o, c),
1072 // Degenerate (recovery) shape: no clean delimiters; treat as single-line
1073 // with no captured trivia. Verification will then decide.
1074 _ => return layout,
1075 };
1076
1077 // Determine element node order: a slice over the interior children that are
1078 // element nodes (in source order).
1079 let element_ranges: Vec<(usize, usize)> = elements
1080 .iter()
1081 .map(|n| {
1082 let r = n.text_range();
1083 (r.start(), r.end())
1084 })
1085 .collect();
1086
1087 // Track newline span between open and close for the multi-line signal.
1088 let mut saw_newline_between = false;
1089
1090 // Pending trivia buffer (whitespace text + comments) accumulating between
1091 // significant items.
1092 let mut pending: Vec<SyntaxToken> = Vec::new();
1093
1094 // The index of the element we last emitted (so a trailing-comment after it can
1095 // be bucketed into `after[that]`). `None` before the first element.
1096 let mut last_element: Option<usize> = None;
1097
1098 // Helper to find which element (if any) a node child corresponds to.
1099 let element_index_of = |start: usize, end: usize| -> Option<usize> {
1100 element_ranges
1101 .iter()
1102 .position(|&(s, e)| s == start && e == end)
1103 };
1104
1105 for el in &children[(open_idx + 1)..close_idx] {
1106 match el {
1107 SyntaxElement::Token(t) if t.is_trivia() => {
1108 if t.text().contains('\n') {
1109 saw_newline_between = true;
1110 }
1111 pending.push(t.clone());
1112 }
1113 SyntaxElement::Token(t) if t.kind() == SyntaxKind::Comma => {
1114 // A separator: flush pending trivia. Comments before a comma that
1115 // are on the same line as the previous element are inline-trailing
1116 // for that element; otherwise they lead the next element. We attach
1117 // pending here as `after[last_element]`.
1118 let run = build_trivia_run(&pending, policy, /*after_element=*/ true);
1119 if let Some(idx) = last_element {
1120 merge_run(&mut layout.after[idx], run);
1121 } else {
1122 // Comma with no preceding element (recovery) — drop into
1123 // pre_close as a fallback so comments survive.
1124 merge_run(&mut layout.pre_close, run);
1125 }
1126 pending.clear();
1127 }
1128 SyntaxElement::Node(n) => {
1129 let r = n.text_range();
1130 if let Some(idx) = element_index_of(r.start(), r.end()) {
1131 // Pending trivia precedes this element. Split it: comments on the
1132 // same line as the previous element/comma (before the first
1133 // newline) are inline-trailing for `last_element`; the rest lead
1134 // this element (T007).
1135 let (inline, leading) = split_pending_trivia(&pending);
1136 if let Some(prev) = last_element {
1137 let inline_run = build_trivia_run(&inline, policy, true);
1138 merge_run(&mut layout.after[prev], inline_run);
1139 } else if !inline.is_empty() {
1140 // No previous element: fold it into this element's leading.
1141 let inline_run = build_trivia_run(&inline, policy, false);
1142 merge_run(&mut layout.before[idx], inline_run);
1143 }
1144 let leading_run = build_trivia_run(&leading, policy, false);
1145 merge_run(&mut layout.before[idx], leading_run);
1146 pending.clear();
1147 last_element = Some(idx);
1148 } else {
1149 // A nested non-element node (shouldn't happen for clean trees);
1150 // keep its preceding trivia bucketed conservatively.
1151 let run = build_trivia_run(&pending, policy, false);
1152 merge_run(&mut layout.pre_close, run);
1153 pending.clear();
1154 }
1155 }
1156 // Any other significant token inside the group (recovery): flush.
1157 SyntaxElement::Token(_) => {
1158 let run = build_trivia_run(&pending, policy, false);
1159 merge_run(&mut layout.pre_close, run);
1160 pending.clear();
1161 }
1162 }
1163 }
1164
1165 // Whatever trivia remains before the close delimiter. Split it: a comment on
1166 // the same line as the last element/comma is inline-trailing for that element;
1167 // the rest are pre-close (boundary / dangling) comments (T007).
1168 let (inline, boundary) = split_pending_trivia(&pending);
1169 if let Some(prev) = last_element {
1170 let inline_run = build_trivia_run(&inline, policy, true);
1171 merge_run(&mut layout.after[prev], inline_run);
1172 } else {
1173 let inline_run = build_trivia_run(&inline, policy, false);
1174 merge_run(&mut layout.pre_close, inline_run);
1175 }
1176 let boundary_run = build_trivia_run(&boundary, policy, false);
1177 merge_run(&mut layout.pre_close, boundary_run);
1178
1179 layout.spans_multiple_lines = saw_newline_between;
1180 layout
1181}
1182
1183/// Split a buffer of trivia tokens at the first line break.
1184///
1185/// Returns `(inline, rest)` where `inline` is the prefix up to and including the
1186/// first whitespace token that contains a newline (so any comment on the same line
1187/// as the preceding significant token stays in `inline`, an inline-trailing
1188/// comment), and `rest` is everything after that newline (leading the next
1189/// element). If there is no newline at all, everything is `inline`.
1190fn split_pending_trivia(tokens: &[SyntaxToken]) -> (Vec<SyntaxToken>, Vec<SyntaxToken>) {
1191 for (i, t) in tokens.iter().enumerate() {
1192 if t.kind() == SyntaxKind::Whitespace && t.text().contains('\n') {
1193 // `inline` = tokens[0..i] (the comments/ws before the first newline);
1194 // `rest` = tokens[i..] (the newline-bearing ws and everything after).
1195 return (tokens[..i].to_vec(), tokens[i..].to_vec());
1196 }
1197 }
1198 (tokens.to_vec(), Vec::new())
1199}
1200
1201/// Merge `src` into `dst` (append comments, take the max blank context).
1202fn merge_run(dst: &mut TriviaRun, src: TriviaRun) {
1203 if src.has_newline {
1204 dst.has_newline = true;
1205 }
1206 dst.trailing_blanks = dst.trailing_blanks.max(src.trailing_blanks);
1207 dst.comments.extend(src.comments);
1208}
1209
1210/// Build a [`TriviaRun`] from a buffer of trivia tokens.
1211///
1212/// `after_element` hints whether the first comment, if it shares the line with the
1213/// preceding significant token (no newline before it), is an inline trailing
1214/// comment.
1215fn build_trivia_run(
1216 tokens: &[SyntaxToken],
1217 policy: BlankLinePolicy,
1218 after_element: bool,
1219) -> TriviaRun {
1220 let mut run = TriviaRun::default();
1221 let mut blanks_acc = 0usize; // raw blank lines accumulated before the next comment
1222 let mut seen_newline = false;
1223 let mut first_comment = true;
1224
1225 for t in tokens {
1226 match t.kind() {
1227 SyntaxKind::Whitespace => {
1228 if t.text().contains('\n') {
1229 seen_newline = true;
1230 run.has_newline = true;
1231 }
1232 blanks_acc += count_blank_lines(t.text());
1233 }
1234 SyntaxKind::Bom => {
1235 // BOM only appears at document start; ignore inside collections.
1236 }
1237 SyntaxKind::LineComment | SyntaxKind::BlockComment => {
1238 let same_line = first_comment && after_element && !seen_newline;
1239 run.comments.push(Comment {
1240 text: t.text().to_string(),
1241 blanks_before: resolve_blanks(blanks_acc, policy),
1242 same_line_as_prev: same_line,
1243 });
1244 blanks_acc = 0;
1245 first_comment = false;
1246 // After a line comment the line necessarily ends; after a block
1247 // comment it may not, but treat subsequent comments as own-line.
1248 seen_newline = true;
1249 }
1250 _ => {}
1251 }
1252 }
1253
1254 run.trailing_blanks = resolve_blanks(blanks_acc, policy);
1255 run
1256}
1257
1258/// Decide whether a collection lays out multi-line.
1259///
1260/// Rules (deterministic, T009):
1261/// * empty collection → single-line (unless it carries a dangling comment that
1262/// itself forces multi-line, handled by the comment presence test);
1263/// * a collection whose open/close delimiters were on different source lines →
1264/// multi-line;
1265/// * a collection that contains ANY comment → multi-line (so comments get their
1266/// own clean lines and are never lost);
1267/// * otherwise → single-line.
1268fn decide_multiline(_node: &SyntaxNode, layout: &CollectionLayout, element_count: usize) -> bool {
1269 if layout.spans_multiple_lines {
1270 return true;
1271 }
1272 // Any comment anywhere in the collection forces multi-line so we never have to
1273 // jam a comment onto a crowded single line (and never drop one).
1274 let has_comments = layout.before.iter().any(|r| !r.is_empty())
1275 || layout.after.iter().any(|r| !r.is_empty())
1276 || !layout.pre_close.is_empty();
1277 if has_comments {
1278 return true;
1279 }
1280 let _ = element_count;
1281 false
1282}
1283
1284// =============================================================================
1285// Error detection + semantic verification (T011, T012).
1286// =============================================================================
1287
1288/// `true` if `node` (or any descendant) is an `Error` recovery node.
1289fn subtree_has_errors(node: &SyntaxNode) -> bool {
1290 if node.kind() == SyntaxKind::Error {
1291 return true;
1292 }
1293 node.children().any(|c| subtree_has_errors(&c))
1294}
1295
1296/// Verify two whole-document strings are semantically identical: same significant
1297/// tokens (verbatim) in the same order, and same comments (verbatim) in the same
1298/// order — ignoring only whitespace layout (T012).
1299fn semantically_equal(a: &str, b: &str) -> bool {
1300 semantic_tokens_equal(a, b)
1301}
1302
1303/// Compare the semantic token streams of two RON fragments: significant tokens and
1304/// comments, both verbatim and in order; whitespace and BOM are ignored.
1305///
1306/// This is the verify-before-replace oracle (AD-008): names, ordering, values, and
1307/// comments must be byte-identical (modulo layout). Re-lexing via [`parse`] reuses
1308/// the single engine tokenizer, so no second lexer is introduced.
1309fn semantic_tokens_equal(a: &str, b: &str) -> bool {
1310 let ta = semantic_token_stream(a);
1311 let tb = semantic_token_stream(b);
1312 ta == tb
1313}
1314
1315/// The ordered list of semantically-significant token texts (significant tokens +
1316/// comments, verbatim), with layout-only tokens dropped.
1317///
1318/// Dropped tokens (layout, not data):
1319/// * `Whitespace` / `Bom` — pure layout;
1320/// * `Comma` — a separator whose presence/absence is the formatter's
1321/// trailing-comma canonicalization (T008), never a data change. Element ORDER is
1322/// still verified because the elements' own tokens stay in order between the
1323/// commas.
1324///
1325/// Kept tokens carry the data the formatter MUST preserve: struct/variant names
1326/// (`Ident`), all scalar values, delimiters (so structure cannot silently change),
1327/// and every comment (verbatim).
1328fn semantic_token_stream(src: &str) -> Vec<(SyntaxKind, String)> {
1329 let doc = parse(src);
1330 doc.root()
1331 .descendant_tokens()
1332 .filter(|t| {
1333 !matches!(
1334 t.kind(),
1335 SyntaxKind::Whitespace | SyntaxKind::Bom | SyntaxKind::Comma
1336 )
1337 })
1338 .map(|t| (t.kind(), t.text().to_string()))
1339 .collect()
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344 use super::*;
1345
1346 fn fmt(src: &str) -> String {
1347 let doc = parse(src);
1348 match format(&doc, &FormatConfig::default()) {
1349 FormatResult::Formatted(s) => s,
1350 FormatResult::NoOp { reason } => panic!("unexpected no-op for {src:?}: {reason}"),
1351 }
1352 }
1353
1354 #[test]
1355 fn config_clamps_indent() {
1356 assert_eq!(
1357 FormatConfig::new(0, BlankLinePolicy::Collapse).indent_width(),
1358 1
1359 );
1360 assert_eq!(
1361 FormatConfig::new(99, BlankLinePolicy::Collapse).indent_width(),
1362 16
1363 );
1364 assert_eq!(FormatConfig::default().indent_width(), 4);
1365 }
1366
1367 #[test]
1368 fn single_line_collection_stays_single_line() {
1369 assert_eq!(fmt("[1, 2, 3]"), "[1, 2, 3]\n");
1370 assert_eq!(fmt("[1,2,3]"), "[1, 2, 3]\n");
1371 assert_eq!(fmt("(1, 2)"), "(1, 2)\n");
1372 assert_eq!(fmt("Foo(x: 1, y: 2)"), "Foo(x: 1, y: 2)\n");
1373 }
1374
1375 #[test]
1376 fn single_line_drops_trailing_comma() {
1377 assert_eq!(fmt("[1, 2, 3,]"), "[1, 2, 3]\n");
1378 }
1379
1380 #[test]
1381 fn multiline_gets_trailing_comma_on_every_element() {
1382 let out = fmt("[\n1,\n2,\n3\n]");
1383 assert_eq!(out, "[\n 1,\n 2,\n 3,\n]\n");
1384 }
1385
1386 #[test]
1387 fn multiline_struct_canonical_indent() {
1388 let out = fmt("Foo(\nx: 1,\ny: 2\n)");
1389 assert_eq!(out, "Foo(\n x: 1,\n y: 2,\n)\n");
1390 }
1391
1392 #[test]
1393 fn nested_indentation() {
1394 let out = fmt("Foo(\na: [\n1,\n2\n]\n)");
1395 assert_eq!(out, "Foo(\n a: [\n 1,\n 2,\n ],\n)\n");
1396 }
1397
1398 #[test]
1399 fn literal_passthrough() {
1400 assert_eq!(fmt("42"), "42\n");
1401 assert_eq!(fmt(" 42 "), "42\n");
1402 assert_eq!(fmt("\"hi\""), "\"hi\"\n");
1403 assert_eq!(fmt("true"), "true\n");
1404 }
1405
1406 #[test]
1407 fn unit_value() {
1408 assert_eq!(fmt("()"), "()\n");
1409 }
1410
1411 #[test]
1412 fn comment_forces_multiline_and_is_preserved() {
1413 let out = fmt("[1, 2] // trailing");
1414 // The comment trails the value (root-level), preserved.
1415 assert!(out.contains("// trailing"), "comment lost: {out:?}");
1416 }
1417
1418 #[test]
1419 fn leading_comment_preserved() {
1420 let out = fmt("// header\n42");
1421 assert_eq!(out, "// header\n42\n");
1422 }
1423
1424 #[test]
1425 fn inline_field_comment_preserved() {
1426 let out = fmt("Foo(\nx: 1, // note\ny: 2\n)");
1427 assert!(out.contains("// note"), "inline comment lost: {out:?}");
1428 assert!(
1429 out.contains("x: 1, // note"),
1430 "inline comment misplaced: {out:?}"
1431 );
1432 }
1433
1434 #[test]
1435 fn dangling_comment_in_empty_collection_preserved() {
1436 let out = fmt("[\n// empty\n]");
1437 assert!(out.contains("// empty"), "dangling comment lost: {out:?}");
1438 }
1439
1440 #[test]
1441 fn boundary_comment_before_close_preserved() {
1442 let out = fmt("[\n1,\n// last\n]");
1443 assert!(out.contains("// last"), "boundary comment lost: {out:?}");
1444 }
1445
1446 #[test]
1447 fn idempotent_on_corpus_samples() {
1448 for src in [
1449 "[1, 2, 3]",
1450 "Foo(\nx: 1,\ny: 2\n)",
1451 "// header\n42\n",
1452 "{ \"a\": 1, \"b\": 2 }",
1453 "Foo(\na: [\n1,\n2\n]\n)",
1454 ] {
1455 let once = fmt(src);
1456 let twice = fmt(&once);
1457 assert_eq!(once, twice, "not idempotent for {src:?}");
1458 }
1459 }
1460
1461 #[test]
1462 fn no_op_on_parse_errors() {
1463 let doc = parse("[1, 2");
1464 assert!(format(&doc, &FormatConfig::default()).is_no_op());
1465 }
1466
1467 #[test]
1468 fn extension_attr_preserved() {
1469 let out = fmt("#![enable(implicit_some)]\nSome(5)");
1470 assert!(
1471 out.contains("#![enable(implicit_some)]"),
1472 "ext attr lost: {out:?}"
1473 );
1474 assert!(out.contains("Some(5)"));
1475 }
1476
1477 #[test]
1478 fn map_canonical() {
1479 assert_eq!(fmt("{\"a\":1,\"b\":2}"), "{\"a\": 1, \"b\": 2}\n");
1480 }
1481
1482 #[test]
1483 fn format_node_subtree() {
1484 let doc = parse("Foo(\nx: 1,\ny: 2\n)");
1485 let value = doc
1486 .root()
1487 .children()
1488 .find(|n| n.kind() == SyntaxKind::Struct)
1489 .unwrap();
1490 let res = format_node(&value, &FormatConfig::default());
1491 match res {
1492 FormatResult::Formatted(s) => assert_eq!(s, "Foo(\n x: 1,\n y: 2,\n)"),
1493 FormatResult::NoOp { reason } => panic!("subtree no-op: {reason}"),
1494 }
1495 }
1496
1497 #[test]
1498 fn format_node_rejects_non_value_node() {
1499 let doc = parse("Foo(x: 1)");
1500 // A bare StructField is not a clean value-position subtree boundary, so
1501 // Format Selection on it must no-op (T010).
1502 let field = find_kind(&doc.root(), SyntaxKind::StructField).expect("has a field");
1503 assert!(format_node(&field, &FormatConfig::default()).is_no_op());
1504 }
1505
1506 /// Find the first descendant node of `kind` (depth-first), if any.
1507 fn find_kind(node: &SyntaxNode, kind: SyntaxKind) -> Option<SyntaxNode> {
1508 if node.kind() == kind {
1509 return Some(node.clone());
1510 }
1511 for c in node.children() {
1512 if let Some(found) = find_kind(&c, kind) {
1513 return Some(found);
1514 }
1515 }
1516 None
1517 }
1518
1519 #[test]
1520 fn empty_document_stays_empty() {
1521 assert_eq!(fmt(""), "");
1522 assert_eq!(fmt(" "), "");
1523 }
1524
1525 #[test]
1526 fn blank_line_collapse_default() {
1527 let out = fmt("Foo(\nx: 1,\n\n\n\ny: 2\n)");
1528 // Collapse: at most one blank between fields.
1529 assert_eq!(out, "Foo(\n x: 1,\n\n y: 2,\n)\n");
1530 }
1531
1532 #[test]
1533 fn blank_line_preserve() {
1534 let doc = parse("Foo(\nx: 1,\n\n\ny: 2\n)");
1535 let cfg = FormatConfig::new(4, BlankLinePolicy::Preserve);
1536 let FormatResult::Formatted(out) = format(&doc, &cfg) else {
1537 panic!("no-op");
1538 };
1539 assert_eq!(out, "Foo(\n x: 1,\n\n\n y: 2,\n)\n");
1540 }
1541}