From 52ee29d1beac9f007fbb6ea77344c94417fbfc60 Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:17:38 +0000 Subject: [PATCH] fix(formatter_yaml): never start col-0 line with document marker or BOM (#27818) - Bump `oxc-yaml-parser` to 0.0.7 - handles NUL as content, BOM only outside a document, markers only at column 0 - Keep `---`/`...` content on the `---` line, keep `?` on a root key starting with a marker, and indent a flow scalar continuation line starting with a marker - Add `---` before a document whose first text is U+FEFF --- Cargo.lock | 4 +- Cargo.toml | 2 +- crates/oxc_formatter_yaml/AGENTS.md | 17 +++++ crates/oxc_formatter_yaml/DIVERGENCES.md | 51 +++++++++++++ .../oxc_formatter_yaml/src/print/document.rs | 28 ++++++- .../src/print/mapping_item.rs | 14 +++- crates/oxc_formatter_yaml/src/print/mod.rs | 12 +++ crates/oxc_formatter_yaml/src/print/scalar.rs | 25 +++++-- .../tests/fixtures/yaml/bom-content.yaml | 2 + .../tests/fixtures/yaml/bom-content.yaml.snap | 21 ++++++ .../document-marker-content/document.yaml | 4 + .../document.yaml.snap | 49 ++++++++++++ .../yaml/document-marker-content/key.yaml | 4 + .../document-marker-content/key.yaml.snap | 51 +++++++++++++ .../yaml/document-marker-content/options.json | 1 + .../yaml/document-marker-content/scalar.yaml | 8 ++ .../document-marker-content/scalar.yaml.snap | 74 +++++++++++++++++++ 17 files changed, 355 insertions(+), 12 deletions(-) create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml.snap create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml.snap create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml.snap create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/options.json create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml create mode 100644 crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml.snap diff --git a/Cargo.lock b/Cargo.lock index be235aa7e5..8999d1fd97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1734,9 +1734,9 @@ checksum = "f321c22ccee109a6fe0aef08d1521af693e4b178dcb7259137ff08f8ce5388d8" [[package]] name = "oxc-yaml-parser" -version = "0.0.6" +version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "097919ec3fb73bb7ec22ceea30171c0af1f14fa67d03bd410a15dc3b6b8eff1b" +checksum = "41ca5cfdf8b83491f53dd1fe583f43923100eacf76d3b8e6bce9d24dec93356c" dependencies = [ "oxc_allocator", ] diff --git a/Cargo.toml b/Cargo.toml index 3362e57902..6a73cc3c53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -244,7 +244,7 @@ oxc-css-parser = "0.0.17" # CSS/SCSS/Less parser (raffia 0.12.3 fork: adds `temp oxc-graphql-parser = "0.0.9" # GraphQL parser (direct AST; apollo-parser 0.8.6 fork aligned with graphql-js 17 syntax) oxc-markdown-parser = "0.0.3" # CommonMark + GFM parser (span-faithful, style-preserving typed AST) oxc-toml = "0.15.1" # TOML parser and formatter -oxc-yaml-parser = "0.0.6" # YAML 1.2 parser (comment-preserving typed AST, node naming from `yaml-unist-parser`) +oxc-yaml-parser = "0.0.7" # YAML 1.2 parser (comment-preserving typed AST, node naming from `yaml-unist-parser`) papaya = "0.2.4" # Concurrent hash map percent-encoding = "2.3.2" # URL encoding petgraph = { version = "0.8.3", default-features = false } # Graph data structures diff --git a/crates/oxc_formatter_yaml/AGENTS.md b/crates/oxc_formatter_yaml/AGENTS.md index 64a2c61742..d5c2d0e995 100644 --- a/crates/oxc_formatter_yaml/AGENTS.md +++ b/crates/oxc_formatter_yaml/AGENTS.md @@ -36,6 +36,23 @@ The printer re-emits the configured `end_of_line` at the final stage. A leading BOM is stripped before parsing and re-emitted by `format()`. +### Line starts + +A line the printer starts at column 0 must not read back as stream-level syntax (see the parser README "Input acceptance"): + +- `---`/`...` followed by a blank or the end is a document marker +- U+FEFF outside a document is a BOM + +Column 0 is reached at these sites, each guarded without changing the value: + +- The document's first text (`print/document.rs`): content after `---` stays on its line; a document without `---` whose first text is U+FEFF gets one +- A root mapping key (`print/mapping_item.rs`): a key starting with a marker keeps its `?` +- A flow scalar's continuation line outside block collections (`print/scalar.rs`): a line starting with a marker is indented + - This also covers entries of a root flow collection, already indented, where the extra indent is harmless + +Elsewhere lines start at column 2 or more and Prettier's layout is matched. +See DIVERGENCES.md#document-marker-content and DIVERGENCES.md#bom-content. + ### Comments Positional cursor (`Comments` in `src/comments.rs`), same approach as graphql/json, yaml-unist-parser's attach algorithm is NOT ported. diff --git a/crates/oxc_formatter_yaml/DIVERGENCES.md b/crates/oxc_formatter_yaml/DIVERGENCES.md index be784fd8b4..569bfe5a02 100644 --- a/crates/oxc_formatter_yaml/DIVERGENCES.md +++ b/crates/oxc_formatter_yaml/DIVERGENCES.md @@ -293,3 +293,54 @@ run: A same-line trailing comment never counts toward the `fits` measurement, the treatment Prettier itself gives JS/JSON line comments and YAML flow collections, but not the block scalar header. Same rule as CSS `trailing-line-comment-print-width`. The KEY does count: a long key overflowing on `key: |` alone breaks the pair exactly like Prettier. + +## document-marker-content + +- Why: semantics +- Pin: `tests/fixtures/yaml/document-marker-content/document.yaml`, `tests/fixtures/yaml/document-marker-content/key.yaml`, `tests/fixtures/yaml/document-marker-content/scalar.yaml` + +```yaml +# input +--- --- +? --- x +: m +# (proseWrap: always, printWidth: 10) +aaaa bbbb --- cccc + +# ours: no line starts with the marker at column 0 +--- --- +? --- x +: m +aaaa bbbb + --- cccc + +# prettier: the content becomes a marker +--- +--- +--- x: m +aaaa bbbb +--- cccc +``` + +Text starting with `---` or `...` never starts a line at column 0, where it is read as a document marker (`"---"` becomes an empty document, the rest fails to parse). +The guarded sites are listed in AGENTS.md "Line starts". + +## bom-content + +- Why: semantics +- Pin: `tests/fixtures/yaml/bom-content.yaml` + +```yaml +# input ("␢" marks U+FEFF) +? ␢a +: m + +# ours: a `---` keeps the key inside a document +--- +␢a: m + +# prettier: the key leads the stream, read back as a BOM, the key becomes "a" +␢a: m +``` + +A U+FEFF never starts the text of a document without `---`, where it is read as a BOM: the document gets a `---` (AGENTS.md "Line starts"). diff --git a/crates/oxc_formatter_yaml/src/print/document.rs b/crates/oxc_formatter_yaml/src/print/document.rs index 85b0cba893..dbdb343965 100644 --- a/crates/oxc_formatter_yaml/src/print/document.rs +++ b/crates/oxc_formatter_yaml/src/print/document.rs @@ -2,7 +2,7 @@ use oxc_formatter_core::{ builders::{hard_line_break, space, text}, write, }; -use oxc_yaml_parser::ast::{Directive, Document, Root}; +use oxc_yaml_parser::ast::{Content, Directive, Document, Node, Root}; use crate::{ comments::{ @@ -13,7 +13,7 @@ use crate::{ print::{ YamlFormatter, block::{ends_with_keep_chomped_block, item_gap_anchor, last_descendant_block_scalar}, - to_span, write_node_or_suppressed, + source_starts_with_document_marker, to_span, write_node_or_suppressed, }, }; @@ -137,6 +137,20 @@ fn write_document<'a>(document: &'a Document<'a>, f: &mut YamlFormatter<'_, 'a>) } if let Some(node) = &document.body.content { + let source = f.context().source_text(); + if let Some(marker) = document.directives_end_marker { + // Content starting with `---`/`...` stays on the `---` line: on a line of its own it would become a marker + if source.all_bytes_match(marker.end, node.span.start, |b| matches!(b, b' ' | b'\t')) + && source_starts_with_document_marker(node.span.start, f) + { + needs_line = false; + write!(f, space()); + } + } else if source.as_str()[first_key_start(node) as usize..].starts_with('\u{FEFF}') { + // Content starting with U+FEFF needs a `---`: outside a document it would be read as a BOM + write!(f, "---"); + needs_line = true; + } if needs_line { write!(f, hard_line_break()); } @@ -156,6 +170,16 @@ fn write_document<'a>(document: &'a Document<'a>, f: &mut YamlFormatter<'_, 'a>) } } +/// Where a block mapping's first key starts: a one-line explicit key loses its `?` (`print/mapping_item.rs`), +/// so the key text may lead the output. +fn first_key_start(node: &Node<'_>) -> u32 { + let key = match &node.content { + Content::Mapping(mapping) => mapping.children.first().and_then(|item| item.key_content()), + _ => None, + }; + key.map_or(node.span.start, |key| key.span.start) +} + /// Re-joins directive words with single spaces (whitespace runs between them are normalized). fn write_directive<'a>(directive: &'a Directive<'a>, f: &mut YamlFormatter<'_, 'a>) { write!(f, "%"); diff --git a/crates/oxc_formatter_yaml/src/print/mapping_item.rs b/crates/oxc_formatter_yaml/src/print/mapping_item.rs index 0962bbe338..b753ab1636 100644 --- a/crates/oxc_formatter_yaml/src/print/mapping_item.rs +++ b/crates/oxc_formatter_yaml/src/print/mapping_item.rs @@ -14,7 +14,10 @@ use crate::{ }, context::YamlFormatContext, options::ProseWrap, - print::{YamlFormatter, column_of, format_with, to_span, write_node, write_node_or_suppressed}, + print::{ + YamlFormatter, column_of, format_with, source_starts_with_document_marker, to_span, + write_node, write_node_or_suppressed, + }, }; /// Where a mapping item lives; decides the empty-value layout @@ -61,6 +64,10 @@ pub fn write_mapping_item<'a>( } let space_before_colon = key_content.is_some_and(|k| matches!(k.content, Content::Alias(_))); + // A root mapping key starting with a document marker keeps its `?`: without it the key starts a line at column 0 + let key_starts_with_marker = in_flow == FlowParent::No + && f.context().collection_depth().get() == 1 + && key_content.is_some_and(|k| source_starts_with_document_marker(k.span.start, f)); if is_empty_value { if in_flow == FlowParent::Mapping { @@ -79,6 +86,7 @@ pub fn write_mapping_item<'a>( && is_absolutely_single_line(key_content, f) && !parent_tag_is_set && !key_content_trailing_comment + && !key_starts_with_marker { write_key(item, f); if space_before_colon { @@ -106,12 +114,14 @@ pub fn write_mapping_item<'a>( let value_start = value_node.span.start; // Force explicit key: - // the key isn't an inline node, or the source was already explicit with a comment between `?` and the key, + // the key isn't an inline node or starts with a document marker, + // or the source was already explicit with a comment between `?` and the key, // or between the key and `:` (an implicit `key:` with a comment above the value keeps the implicit form, // the comment becomes the value's leading comment instead). let explicit_comment_before_key = key.explicit && f.context().comments().peek().is_some_and(|c| c.span.end <= key_node.span.start); if !is_inline(key_content) + || key_starts_with_marker || explicit_comment_before_key || (key.explicit && has_own_line_comment_before_value(value_node, f)) { diff --git a/crates/oxc_formatter_yaml/src/print/mod.rs b/crates/oxc_formatter_yaml/src/print/mod.rs index dc5860ec2d..1288c4170a 100644 --- a/crates/oxc_formatter_yaml/src/print/mod.rs +++ b/crates/oxc_formatter_yaml/src/print/mod.rs @@ -59,6 +59,18 @@ pub fn column_of(source: &str, offset: u32) -> u32 { u32::try_from(count).unwrap_or(u32::MAX) } +/// Whether `text` starts with a document marker: `---`/`...` followed by a blank or the end. +/// At column 0 it would be read as one, see AGENTS.md "Line starts". +pub fn starts_with_document_marker(text: &str) -> bool { + (text.starts_with("---") || text.starts_with("...")) + && text.as_bytes().get(3).is_none_or(|b| matches!(b, b' ' | b'\t' | b'\n')) +} + +/// [starts_with_document_marker] for the source text from `offset`. +pub fn source_starts_with_document_marker(offset: u32, f: &YamlFormatter<'_, '_>) -> bool { + starts_with_document_marker(&f.context().source_text().as_str()[offset as usize..]) +} + /// Emits a node's properties (anchor/tag) in their source order. /// NOTE: Prettier 3.9.5 normalizes to `tag anchor` order; we keep the source order instead. /// diff --git a/crates/oxc_formatter_yaml/src/print/scalar.rs b/crates/oxc_formatter_yaml/src/print/scalar.rs index 30e68fc1c8..7e0f295b3f 100644 --- a/crates/oxc_formatter_yaml/src/print/scalar.rs +++ b/crates/oxc_formatter_yaml/src/print/scalar.rs @@ -4,12 +4,15 @@ use cow_utils::CowUtils; use oxc_formatter_core::{ arena_cow_str, - builders::{exact_line_breaks, hard_line_break, soft_line_break_or_space, text}, + builders::{Line, exact_line_breaks, hard_line_break, indent, soft_line_break_or_space, text}, write, }; use oxc_span::Span; -use crate::{options::ProseWrap, print::YamlFormatter}; +use crate::{ + options::ProseWrap, + print::{YamlFormatter, format_with, starts_with_document_marker}, +}; /// Which flow scalar shape is being folded (affects word-merge rules and quoting). #[derive(Clone, Copy, Eq, PartialEq)] @@ -117,6 +120,18 @@ pub fn write_flow_scalar_content<'a>( }) .collect(); + // Outside block collections, continuation lines start at column 0: + // a line starting with a document marker is indented instead (see AGENTS.md "Line starts") + let guards_line_start = f.context().collection_depth().get() == 0; + let line_before = |line: Line, word: &'a str| { + format_with(move |f| { + if guards_line_start && starts_with_document_marker(word) { + write!(f, indent(&line)); + } else { + write!(f, line); + } + }) + }; let mut blanks = 0usize; let mut wrote_any = false; for words in &line_groups { @@ -127,15 +142,15 @@ pub fn write_flow_scalar_content<'a>( if blanks > 0 { // N blank groups = N separators before this group // (plus the one separating it from the previous non-empty group, if any). - write!(f, exact_line_breaks(blanks + usize::from(wrote_any))); + write!(f, line_before(exact_line_breaks(blanks + usize::from(wrote_any)), words[0])); } else if wrote_any { - write!(f, hard_line_break()); + write!(f, line_before(hard_line_break(), words[0])); } blanks = 0; wrote_any = true; let mut fill = f.fill(); for &word in words { - fill.entry(&soft_line_break_or_space(), &text(word)); + fill.entry(&line_before(soft_line_break_or_space(), word), &text(word)); } fill.finish(); } diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml b/crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml new file mode 100644 index 0000000000..062f199152 --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml @@ -0,0 +1,2 @@ +? a +: m diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml.snap b/crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml.snap new file mode 100644 index 0000000000..43dbabfbcf --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/bom-content.yaml.snap @@ -0,0 +1,21 @@ +--- +source: crates/oxc_formatter_yaml/tests/fixtures/mod.rs +--- +==================== Input ==================== +? a +: m + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +--- +a: m + +------------------- +{ printWidth: 100 } +------------------- +--- +a: m + +===================== End ===================== diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml new file mode 100644 index 0000000000..4b17f98bf2 --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml @@ -0,0 +1,4 @@ +--- --- +--- ... x +--- --- a + b diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml.snap b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml.snap new file mode 100644 index 0000000000..53c9b4dce0 --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/document.yaml.snap @@ -0,0 +1,49 @@ +--- +source: crates/oxc_formatter_yaml/tests/fixtures/mod.rs +--- +==================== Input ==================== +--- --- +--- ... x +--- --- a + b + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +--- --- +--- ... x +--- --- a +b + +------------------- +{ printWidth: 100 } +------------------- +--- --- +--- ... x +--- --- a +b + +--------------------------------------- +{ printWidth: 10, proseWrap: "always" } +--------------------------------------- +--- --- +--- ... x +--- --- a +b + +--------------------------------------- +{ printWidth: 80, proseWrap: "always" } +--------------------------------------- +--- --- +--- ... x +--- --- a b + +---------------------------------------- +{ printWidth: 100, proseWrap: "always" } +---------------------------------------- +--- --- +--- ... x +--- --- a b + +===================== End ===================== diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml new file mode 100644 index 0000000000..f737daff29 --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml @@ -0,0 +1,4 @@ +a: 1 +? --- x +: m +? ... y diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml.snap b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml.snap new file mode 100644 index 0000000000..c64d53e259 --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/key.yaml.snap @@ -0,0 +1,51 @@ +--- +source: crates/oxc_formatter_yaml/tests/fixtures/mod.rs +--- +==================== Input ==================== +a: 1 +? --- x +: m +? ... y + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +a: 1 +? --- x +: m +? ... y + +------------------- +{ printWidth: 100 } +------------------- +a: 1 +? --- x +: m +? ... y + +--------------------------------------- +{ printWidth: 10, proseWrap: "always" } +--------------------------------------- +a: 1 +? --- x +: m +? ... y + +--------------------------------------- +{ printWidth: 80, proseWrap: "always" } +--------------------------------------- +a: 1 +? --- x +: m +? ... y + +---------------------------------------- +{ printWidth: 100, proseWrap: "always" } +---------------------------------------- +a: 1 +? --- x +: m +? ... y + +===================== End ===================== diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/options.json b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/options.json new file mode 100644 index 0000000000..ffb985f4ac --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/options.json @@ -0,0 +1 @@ +[{}, { "proseWrap": "always", "printWidth": 10 }] diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml new file mode 100644 index 0000000000..bfa712bb32 --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml @@ -0,0 +1,8 @@ +aaaa bbbb --- cccc +--- +"aaaa + --- x" +--- +aaaa + + ... x diff --git a/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml.snap b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml.snap new file mode 100644 index 0000000000..e991d2edcf --- /dev/null +++ b/crates/oxc_formatter_yaml/tests/fixtures/yaml/document-marker-content/scalar.yaml.snap @@ -0,0 +1,74 @@ +--- +source: crates/oxc_formatter_yaml/tests/fixtures/mod.rs +--- +==================== Input ==================== +aaaa bbbb --- cccc +--- +"aaaa + --- x" +--- +aaaa + + ... x + +==================== Output ==================== +------------------ +{ printWidth: 80 } +------------------ +aaaa bbbb --- cccc +--- +"aaaa + --- x" +--- +aaaa + + ... x + +------------------- +{ printWidth: 100 } +------------------- +aaaa bbbb --- cccc +--- +"aaaa + --- x" +--- +aaaa + + ... x + +--------------------------------------- +{ printWidth: 10, proseWrap: "always" } +--------------------------------------- +aaaa bbbb + --- cccc +--- +"aaaa --- +x" +--- +aaaa + + ... x + +--------------------------------------- +{ printWidth: 80, proseWrap: "always" } +--------------------------------------- +aaaa bbbb --- cccc +--- +"aaaa --- x" +--- +aaaa + + ... x + +---------------------------------------- +{ printWidth: 100, proseWrap: "always" } +---------------------------------------- +aaaa bbbb --- cccc +--- +"aaaa --- x" +--- +aaaa + + ... x + +===================== End =====================