Skip to main content

noyalib/cst/
format.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Formatter for YAML CST.
5
6use crate::cst::document::{Document, parse_stream_inner};
7use crate::cst::green::{GreenChild, GreenNode};
8use crate::cst::syntax::SyntaxKind;
9use crate::cst::{YAML_BLANK, is_yaml_blank};
10use crate::de::ParserConfig;
11use crate::error::{Error, Result};
12use crate::parser::ParseConfig;
13use crate::prelude::*;
14
15/// Configuration for the formatter.
16#[derive(Debug, Clone, Copy)]
17pub struct FormatConfig {
18    /// Number of spaces per indentation level. Defaults to 2.
19    pub indent_size: usize,
20}
21
22impl Default for FormatConfig {
23    fn default() -> Self {
24        Self { indent_size: 2 }
25    }
26}
27
28/// Auto-formats a messy YAML file into a canonical style based on the CST.
29///
30/// This uses the default configuration (2 spaces indentation).
31///
32/// The output always parses to the same values as the input. A stream
33/// the formatter cannot re-layout without changing its meaning (for
34/// example a multi-line flow collection whose continuation lines would
35/// end up at or left of a re-indented key) is refused with an error
36/// rather than rewritten.
37///
38/// # Errors
39///
40/// Returns the parse error of an invalid input, a budget error under
41/// the default [`ParserConfig`], or an error when the input cannot be
42/// re-laid out without changing its meaning.
43pub fn format(input: &str) -> Result<String> {
44    format_with_config(input, &FormatConfig::default())
45}
46
47/// Auto-formats a messy YAML file into a canonical style based on the CST,
48/// using the provided configuration.
49///
50/// The input is parsed under [`ParserConfig::default`]; use
51/// [`format_with_parser_config`] to choose the limits.
52pub fn format_with_config(input: &str, config: &FormatConfig) -> Result<String> {
53    format_with_parser_config(input, config, &ParserConfig::default())
54}
55
56/// Auto-formats a YAML stream under the given formatter and parser
57/// configuration.
58///
59/// The input is parsed as by [`crate::cst::parse_stream_with_config`]:
60/// `max_stream_bytes`, `max_documents`, `max_document_length` and the
61/// other limits of `parser` apply, so a formatter that runs on
62/// untrusted input can bound the work it does.
63///
64/// # Errors
65///
66/// Returns the parse or budget error the input trips under `parser`,
67/// or an error when the input cannot be re-laid out without changing
68/// its meaning (see [`format()`]).
69///
70/// # Examples
71///
72/// ```
73/// use noyalib::cst::{FormatConfig, format_with_parser_config};
74/// use noyalib::ParserConfig;
75///
76/// let strict = ParserConfig::strict();
77/// let out = format_with_parser_config("a:   1\n", &FormatConfig::default(), &strict).unwrap();
78/// assert_eq!(out, "a: 1\n");
79///
80/// let one_doc = ParserConfig::new().max_documents(1);
81/// let stream = "--- 1\n--- 2\n";
82/// assert!(format_with_parser_config(stream, &FormatConfig::default(), &one_doc).is_err());
83/// ```
84pub fn format_with_parser_config(
85    input: &str,
86    config: &FormatConfig,
87    parser: &ParserConfig,
88) -> Result<String> {
89    if is_yaml_blank(input) {
90        return Ok(String::new());
91    }
92    let parse_config = ParseConfig::from(parser);
93    let docs = parse_stream_inner(input, &parse_config, parser.max_stream_bytes)?;
94    let mut output = String::with_capacity(input.len());
95    for doc in &docs {
96        let mut formatter = Formatter::new(doc.source(), config);
97        formatter.format_node(doc.syntax(), 0)?;
98        output.push_str(&formatter.finish());
99    }
100    ensure_same_meaning(&docs, &output, &parse_config, parser.max_stream_bytes)?;
101    Ok(output)
102}
103
104/// Refuse `output` unless it parses, under the same configuration, to
105/// the values of `docs`.
106///
107/// The formatter re-lays out text token by token and does not model
108/// every YAML layout rule, so this check is what makes its contract
109/// hold: the output means what the input meant, or `format` returns an
110/// error and the caller keeps the input.
111fn ensure_same_meaning(
112    docs: &[Document],
113    output: &str,
114    config: &ParseConfig,
115    max_stream_bytes: usize,
116) -> Result<()> {
117    let refused = || {
118        Error::Parse(
119            "format: the input cannot be re-laid out without changing its meaning; \
120             it was left unformatted"
121                .into(),
122        )
123    };
124    let after = parse_stream_inner(output, config, max_stream_bytes).map_err(|_| refused())?;
125    let same = after.len() == docs.len()
126        && after
127            .iter()
128            .zip(docs)
129            .all(|(a, b)| *a.as_value() == *b.as_value());
130    if same { Ok(()) } else { Err(refused()) }
131}
132
133struct Formatter<'a> {
134    source: &'a str,
135    config: &'a FormatConfig,
136    out: String,
137    indent_level: usize,
138    at_line_start: bool,
139    /// Whether the last thing written was a space.
140    last_was_space: bool,
141    /// The last token written, ignoring whitespace. A tag, anchor or
142    /// alias must be separated from what follows it: `!foo "bar"`
143    /// written as `!foo"bar"` is one tag and an empty scalar, and
144    /// `*a :` written as `*a:` is an alias named `a:`.
145    last_token: Option<SyntaxKind>,
146}
147
148impl<'a> Formatter<'a> {
149    fn new(source: &'a str, config: &'a FormatConfig) -> Self {
150        Self {
151            source,
152            config,
153            out: String::with_capacity(source.len()),
154            indent_level: 0,
155            at_line_start: true,
156            last_was_space: false,
157            last_token: None,
158        }
159    }
160
161    fn finish(self) -> String {
162        let mut out = self.out;
163        if !out.ends_with('\n') && !out.is_empty() {
164            out.push('\n');
165        }
166        out
167    }
168
169    fn indent(&mut self) {
170        if self.at_line_start {
171            for _ in 0..(self.indent_level * self.config.indent_size) {
172                self.out.push(' ');
173            }
174            self.at_line_start = false;
175            self.last_was_space = self.indent_level > 0;
176        }
177    }
178
179    fn newline(&mut self) {
180        if self.at_line_start {
181            return;
182        }
183        // A block scalar's token carries the indentation of the line
184        // that follows it, so after writing one the cursor sits on a
185        // line holding nothing but spaces. Ending that line would add a
186        // blank line, and a keep-chomped scalar counts blank lines as
187        // content, so the value would gain a newline. Drop the spaces
188        // and we are already at the start of a line (found by
189        // formatting the spec-torture corpus).
190        let trimmed = self.out.trim_end_matches([' ', '\t']).len();
191        let line_is_only_space =
192            trimmed < self.out.len() && (trimmed == 0 || self.out.as_bytes()[trimmed - 1] == b'\n');
193        if line_is_only_space {
194            self.out.truncate(trimmed);
195        } else {
196            self.out.push('\n');
197        }
198        self.at_line_start = true;
199        self.last_was_space = false;
200    }
201
202    fn write_raw(&mut self, text: &str) {
203        if text.is_empty() {
204            return;
205        }
206        // Indentation is written eagerly, so writing it before a token
207        // that opens with a line break leaves a line of nothing but
208        // spaces. Inside a keep-chomped block scalar that line is
209        // content and silently adds a newline to the value (found by
210        // formatting the spec-torture corpus).
211        if !text.starts_with(['\n', '\r']) {
212            self.indent();
213        }
214        self.out.push_str(text);
215        if let Some(last_newline) = text.rfind('\n') {
216            let trailing = &text[last_newline + 1..];
217            self.at_line_start = trailing.is_empty();
218            self.last_was_space = trailing.ends_with(' ');
219        } else {
220            self.at_line_start = false;
221            self.last_was_space = text.ends_with(' ');
222        }
223    }
224
225    fn ensure_space(&mut self) {
226        if !self.at_line_start && !self.last_was_space {
227            self.out.push(' ');
228            self.last_was_space = true;
229        }
230    }
231
232    fn format_node(&mut self, node: &GreenNode, base: usize) -> Result<()> {
233        match node.kind() {
234            SyntaxKind::Document | SyntaxKind::Stream => {
235                self.format_children(node, base)?;
236            }
237            SyntaxKind::BlockMapping => {
238                self.format_block_mapping(node, base)?;
239            }
240            SyntaxKind::BlockSequence => {
241                self.format_block_sequence(node, base)?;
242            }
243            SyntaxKind::MappingEntry => {
244                self.format_mapping_entry(node, base)?;
245            }
246            SyntaxKind::SequenceItem => {
247                self.format_sequence_item(node, base)?;
248            }
249            SyntaxKind::FlowMapping | SyntaxKind::FlowSequence => {
250                self.write_verbatim(node, base);
251            }
252            _ => {
253                self.format_children(node, base)?;
254            }
255        }
256        Ok(())
257    }
258
259    fn format_children(&mut self, node: &GreenNode, base: usize) -> Result<()> {
260        let mut pos = base;
261        for child in node.children() {
262            match child {
263                GreenChild::Node(inner) => {
264                    self.format_node(inner, pos)?;
265                }
266                GreenChild::Token { kind, len } => {
267                    self.handle_token(*kind, &self.source[pos..pos + *len as usize]);
268                }
269            }
270            pos += child.text_len();
271        }
272        Ok(())
273    }
274
275    fn handle_token(&mut self, kind: SyntaxKind, text: &str) {
276        match kind {
277            SyntaxKind::Comment => {
278                self.ensure_space();
279                self.write_raw(text.trim_end_matches(YAML_BLANK));
280                self.newline();
281            }
282            SyntaxKind::Newline => {
283                self.newline();
284            }
285            SyntaxKind::Whitespace | SyntaxKind::Bom => {
286                // Skip
287            }
288            SyntaxKind::DocStart | SyntaxKind::DocEnd => {
289                // A marker is only a marker at the start of a line:
290                // `Document...` is a plain scalar.
291                self.newline();
292                self.write_raw(if kind == SyntaxKind::DocStart {
293                    "---"
294                } else {
295                    "..."
296                });
297                self.newline();
298            }
299            SyntaxKind::ColonIndicator => {
300                if self.after_node_property() {
301                    self.ensure_space();
302                }
303                self.write_raw(":");
304            }
305            SyntaxKind::DashIndicator => {
306                if !self.at_line_start {
307                    self.newline();
308                }
309                self.write_raw("-");
310            }
311            _ if kind.is_token() => {
312                let trimmed = if matches!(kind, SyntaxKind::PlainScalar) {
313                    text.trim_matches(YAML_BLANK)
314                } else {
315                    text
316                };
317                self.separate_from_property();
318                self.write_raw(trimmed);
319                // A scalar token can carry the line break that ends
320                // it (`: a` / `: b` under an empty key); keep it.
321                if trimmed.len() < text.len() && text.trim_end_matches([' ', '\t']).ends_with('\n')
322                {
323                    self.newline();
324                }
325            }
326            _ => {}
327        }
328        if kind != SyntaxKind::Whitespace {
329            self.last_token = Some(kind);
330        }
331    }
332
333    /// Whether the last token was a tag, anchor or alias.
334    fn after_node_property(&self) -> bool {
335        matches!(
336            self.last_token,
337            Some(SyntaxKind::TagMark | SyntaxKind::AnchorMark | SyntaxKind::AliasMark)
338        )
339    }
340
341    /// Keep the space between a tag, anchor or indicator and the
342    /// content that follows it (`: a` is a value, `:a` a plain scalar).
343    fn separate_from_property(&mut self) {
344        if matches!(
345            self.last_token,
346            Some(
347                SyntaxKind::TagMark
348                    | SyntaxKind::AnchorMark
349                    | SyntaxKind::ColonIndicator
350                    | SyntaxKind::DashIndicator
351                    | SyntaxKind::QuestionIndicator
352            )
353        ) {
354            self.ensure_space();
355        }
356    }
357
358    fn format_block_mapping(&mut self, node: &GreenNode, base: usize) -> Result<()> {
359        let mut pos = base;
360        for child in node.children() {
361            match child {
362                GreenChild::Node(inner) if inner.kind() == SyntaxKind::MappingEntry => {
363                    self.format_mapping_entry(inner, pos)?;
364                }
365                GreenChild::Token { kind, len } => {
366                    self.handle_token(*kind, &self.source[pos..pos + *len as usize]);
367                }
368                GreenChild::Node(inner) => {
369                    self.format_node(inner, pos)?;
370                }
371            }
372            pos += child.text_len();
373        }
374        Ok(())
375    }
376
377    fn format_block_sequence(&mut self, node: &GreenNode, base: usize) -> Result<()> {
378        let mut pos = base;
379        for child in node.children() {
380            match child {
381                GreenChild::Node(inner) if inner.kind() == SyntaxKind::SequenceItem => {
382                    self.format_sequence_item(inner, pos)?;
383                }
384                GreenChild::Token { kind, len } => {
385                    self.handle_token(*kind, &self.source[pos..pos + *len as usize]);
386                }
387                GreenChild::Node(inner) => {
388                    self.format_node(inner, pos)?;
389                }
390            }
391            pos += child.text_len();
392        }
393        Ok(())
394    }
395
396    fn format_mapping_entry(&mut self, node: &GreenNode, base: usize) -> Result<()> {
397        // A value whose tag or anchor sits alone on the line after the
398        // colon (`k:` / `  !!pairs` / `  - a`) is written as `k: !!pairs`:
399        // the line break is dropped and the property joins the key line,
400        // where it needs no indentation of its own (found by the
401        // ultra-complex fixture, which lost the property's indent).
402        let children: Vec<&GreenChild> = node.children().collect();
403        let drop_newline_at = property_on_next_line(&children);
404        let mut state = EntryState::default();
405        let mut pos = base;
406        for (index, child) in children.iter().enumerate() {
407            match child {
408                GreenChild::Token { kind, len } if drop_newline_at != Some(index) => {
409                    let text = &self.source[pos..pos + *len as usize];
410                    self.entry_token(&mut state, *kind, text);
411                }
412                GreenChild::Token { .. } => {}
413                GreenChild::Node(inner) => self.entry_node(&mut state, inner, pos)?,
414            }
415            pos += child.text_len();
416        }
417        self.end_entry(&state);
418        Ok(())
419    }
420
421    /// Write one token of a mapping entry.
422    fn entry_token(&mut self, state: &mut EntryState, kind: SyntaxKind, text: &str) {
423        // An explicit key (`? key`) needs the space after its indicator
424        // as much as a sequence item needs the one after `-`: `?[a, b]`
425        // is a plain scalar, not an explicit key (found by the
426        // ultra-complex fixture).
427        if (state.saw_colon || state.saw_question) && !is_layout_token(kind) {
428            if state.saw_colon {
429                self.indent_value_on_next_line(state);
430            }
431            self.ensure_space();
432        }
433        match kind {
434            SyntaxKind::ColonIndicator => {
435                // An explicit key's value indicator starts its own
436                // line: `? a` / `: b`. On the key's line it would
437                // turn the key into a mapping (`? a: b`).
438                if state.saw_question && !self.at_line_start {
439                    self.newline();
440                }
441                state.saw_colon = true;
442                state.saw_question = false;
443            }
444            SyntaxKind::QuestionIndicator => state.saw_question = true,
445            SyntaxKind::Newline | SyntaxKind::Comment if state.saw_colon => {
446                state.value_on_next_line = true;
447            }
448            _ => {}
449        }
450        self.handle_token(kind, text);
451    }
452
453    /// Write one child node of a mapping entry.
454    fn entry_node(&mut self, state: &mut EntryState, inner: &GreenNode, pos: usize) -> Result<()> {
455        let block = matches!(
456            inner.kind(),
457            SyntaxKind::BlockMapping | SyntaxKind::BlockSequence
458        );
459        if state.saw_question && !state.saw_colon {
460            self.ensure_space();
461            // A mapping used as an explicit key continues on
462            // the lines below the `?`, and those lines must
463            // be indented past it or they become entries of
464            // the surrounding mapping instead. Without this
465            // the key is torn apart and the value is lost
466            // (found by formatting the spec-torture corpus).
467            let nested = inner.kind() == SyntaxKind::BlockMapping;
468            return self.format_nested(inner, pos, nested);
469        }
470        if !state.saw_colon {
471            return self.format_node(inner, pos);
472        }
473        if block {
474            self.newline();
475            return self.format_nested(inner, pos, true);
476        }
477        self.indent_value_on_next_line(state);
478        self.ensure_space();
479        self.format_node(inner, pos)
480    }
481
482    /// A scalar or flow value that starts on the line below its key
483    /// (`key:` / `  value`) must stay indented past the key, or it
484    /// becomes a key of its own.
485    fn indent_value_on_next_line(&mut self, state: &mut EntryState) {
486        if state.value_on_next_line && !state.value_indented {
487            self.indent_level += 1;
488            state.value_indented = true;
489        }
490    }
491
492    fn end_entry(&mut self, state: &EntryState) {
493        if state.value_indented {
494            self.indent_level -= 1;
495        }
496        self.newline();
497    }
498
499    /// Format `inner`, one level deeper when `deeper` is set.
500    fn format_nested(&mut self, inner: &GreenNode, pos: usize, deeper: bool) -> Result<()> {
501        if !deeper {
502            return self.format_node(inner, pos);
503        }
504        self.indent_level += 1;
505        let result = self.format_node(inner, pos);
506        self.indent_level -= 1;
507        result
508    }
509
510    fn format_sequence_item(&mut self, node: &GreenNode, base: usize) -> Result<()> {
511        let mut pos = base;
512        let mut state = EntryState::default();
513
514        for child in node.children() {
515            match child {
516                GreenChild::Token { kind, len } => {
517                    let text = &self.source[pos..pos + *len as usize];
518                    self.item_token(&mut state, *kind, text);
519                }
520                GreenChild::Node(inner) if state.saw_colon => {
521                    // `saw_colon` stands for "saw the dash" here.
522                    let block = matches!(
523                        inner.kind(),
524                        SyntaxKind::BlockMapping | SyntaxKind::BlockSequence
525                    );
526                    if !block {
527                        self.indent_value_on_next_line(&mut state);
528                    }
529                    self.ensure_space();
530                    self.format_nested(inner, pos, block)?;
531                }
532                GreenChild::Node(inner) => self.format_node(inner, pos)?,
533            }
534            pos += child.text_len();
535        }
536        self.end_entry(&state);
537        Ok(())
538    }
539
540    /// Write one token of a sequence item.
541    fn item_token(&mut self, state: &mut EntryState, kind: SyntaxKind, text: &str) {
542        if state.saw_colon && !is_layout_token(kind) && kind != SyntaxKind::DashIndicator {
543            self.indent_value_on_next_line(state);
544            self.ensure_space();
545        }
546        match kind {
547            SyntaxKind::DashIndicator => state.saw_colon = true,
548            SyntaxKind::Newline | SyntaxKind::Comment if state.saw_colon => {
549                state.value_on_next_line = true;
550            }
551            _ => {}
552        }
553        self.handle_token(kind, text);
554    }
555
556    fn write_verbatim(&mut self, node: &GreenNode, base: usize) {
557        self.separate_from_property();
558        self.indent();
559        let text = node.text(&self.source[base..base + node.text_len()]);
560        self.out.push_str(&text);
561        self.at_line_start = text.ends_with('\n');
562        self.last_was_space = text.ends_with(' ');
563        self.last_token = Some(node.kind());
564    }
565}
566
567/// Where a mapping entry is in its layout.
568#[derive(Default)]
569struct EntryState {
570    /// The value indicator (or, for a sequence item, the dash) has
571    /// been written.
572    saw_colon: bool,
573    /// An explicit-key indicator has been written and its value
574    /// indicator has not.
575    saw_question: bool,
576    /// A line break or comment followed the value indicator.
577    value_on_next_line: bool,
578    /// The value was indented one level for being on its own line.
579    value_indented: bool,
580}
581
582/// Tokens that carry layout only and never need a separating space.
583fn is_layout_token(kind: SyntaxKind) -> bool {
584    matches!(
585        kind,
586        SyntaxKind::ColonIndicator
587            | SyntaxKind::Newline
588            | SyntaxKind::Whitespace
589            | SyntaxKind::Comment
590    )
591}
592
593/// The index of the line break after an entry's colon when the next
594/// thing on the following line is a tag or anchor (`k:` / `  !!pairs`).
595fn property_on_next_line(children: &[&GreenChild]) -> Option<usize> {
596    let is = |c: &GreenChild, kinds: &[SyntaxKind]| matches!(c, GreenChild::Token { kind, .. } if kinds.contains(kind));
597    let colon = children
598        .iter()
599        .position(|c| is(c, &[SyntaxKind::ColonIndicator]))?;
600    let mut rest = children[colon + 1..]
601        .iter()
602        .enumerate()
603        .filter(|(_, c)| !is(c, &[SyntaxKind::Whitespace]));
604    let (offset, newline) = rest.next()?;
605    if !is(newline, &[SyntaxKind::Newline]) {
606        return None;
607    }
608    let (_, next) = rest.next()?;
609    is(next, &[SyntaxKind::TagMark, SyntaxKind::AnchorMark]).then_some(colon + 1 + offset)
610}