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
This commit is contained in:
leaysgur committed 2026-10-16 10:17:38 +00:00
1 parent 9261df700b
commit 52ee29d1be
17 files changed
+355 -12

No files matched your search

Generated
+2 -2
View File
@@ -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",
]
+1 -1
View File
@@ -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
+17
View File
@@ -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.
+51
View File
@@ -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").
@@ -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, "%");
@@ -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))
{
@@ -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.
///
+20 -5
View File
@@ -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();
}
@@ -0,0 +1,2 @@
? a
: m
@@ -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 =====================
@@ -0,0 +1,4 @@
--- ---
--- ... x
--- --- a
b
@@ -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 =====================
@@ -0,0 +1,4 @@
a: 1
? --- x
: m
? ... y
@@ -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 =====================
@@ -0,0 +1 @@
[{}, { "proseWrap": "always", "printWidth": 10 }]
@@ -0,0 +1,8 @@
aaaa bbbb --- cccc
---
"aaaa
--- x"
---
aaaa
... x
@@ -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 =====================