mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
fix(formatter_core): unify leading-BOM handlings (#25340)
Follow up on #25333.
This commit is contained in:
1 parent
2de4ec24ab
commit
2eaede9b0b
7 files changed
+99
-80
No files matched your search
@@ -2,22 +2,27 @@ import { describe, expect, it } from "vitest";
|
||||
import { format } from "../../dist/index.js";
|
||||
|
||||
// Pins the physical-file BOM contract (`oxc_formatter_core::spec::split_bom`):
|
||||
// each Rust formatter strips the BOM before parsing and re-emits it exactly once at byte 0 of the output.
|
||||
// each Rust formatter strips the whole leading BOM run before parsing and
|
||||
// re-emits a single BOM at byte 0 of the output.
|
||||
describe("BOM handling", () => {
|
||||
it("preserves a leading BOM across Tier 1 languages", async () => {
|
||||
const cases: [string, string, string][] = [
|
||||
["a.js", "\uFEFFlet a = 1", "\uFEFFlet a = 1;\n"],
|
||||
["a.css", "\uFEFFa { color: red }", "\uFEFFa {\n color: red;\n}\n"],
|
||||
["a.yaml", "\uFEFFkey: value", "\uFEFFkey: value\n"],
|
||||
["a.graphql", "\uFEFF{ a }", "\uFEFF{\n a\n}\n"],
|
||||
["a.json", '\uFEFF{"a":1}', '\uFEFF{ "a": 1 }\n'],
|
||||
];
|
||||
for (const [filename, source, expected] of cases) {
|
||||
// oxlint-disable-next-line no-await-in-loop
|
||||
const result = await format(filename, source);
|
||||
expect(result.errors).toStrictEqual([]);
|
||||
expect(result.code).toBe(expected);
|
||||
}
|
||||
const cases: [string, string, string][] = [
|
||||
["a.js", "\uFEFFlet a = 1", "\uFEFFlet a = 1;\n"],
|
||||
["a.css", "\uFEFFa { color: red }", "\uFEFFa {\n color: red;\n}\n"],
|
||||
["a.yaml", "\uFEFFkey: value", "\uFEFFkey: value\n"],
|
||||
["a.graphql", "\uFEFF{ a }", "\uFEFF{\n a\n}\n"],
|
||||
["a.json", '\uFEFF{"a":1}', '\uFEFF{ "a": 1 }\n'],
|
||||
];
|
||||
|
||||
it.each(cases)("preserves a leading BOM in %s", async (filename, source, expected) => {
|
||||
const result = await format(filename, source);
|
||||
expect(result.errors).toStrictEqual([]);
|
||||
expect(result.code).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(cases)("swallows a doubled BOM in %s down to one", async (filename, source, expected) => {
|
||||
const result = await format(filename, "\uFEFF" + source);
|
||||
expect(result.errors).toStrictEqual([]);
|
||||
expect(result.code).toBe(expected);
|
||||
});
|
||||
|
||||
it("keeps a BOM-only file as-is", async () => {
|
||||
@@ -26,15 +31,19 @@ describe("BOM handling", () => {
|
||||
expect(result.code).toBe("\uFEFF");
|
||||
});
|
||||
|
||||
it("pins the doubled-BOM behavior (only the first is the BOM; the second reaches the parser)", async () => {
|
||||
// Both parsers treat the second U+FEFF as ordinary whitespace,
|
||||
// so it is swallowed and a single BOM comes back out.
|
||||
const js = await format("a.js", "\uFEFF\uFEFFlet a = 1");
|
||||
expect(js.errors).toStrictEqual([]);
|
||||
expect(js.code).toBe("\uFEFFlet a = 1;\n");
|
||||
// In an embedded position U+FEFF is content, not a BOM:
|
||||
// `FormatSession::dispatch` answers `PreserveOriginal`,
|
||||
// so the part stays verbatim instead of losing the character.
|
||||
it("preserves a BOM-headed embedded part verbatim (css-in-js)", async () => {
|
||||
const source = "const s = css`\uFEFFa { color : red }`;\n";
|
||||
const result = await format("a.js", source);
|
||||
expect(result.errors).toStrictEqual([]);
|
||||
expect(result.code).toBe(source);
|
||||
});
|
||||
|
||||
const yaml = await format("a.yaml", "\uFEFF\uFEFFkey: value");
|
||||
expect(yaml.errors).toStrictEqual([]);
|
||||
expect(yaml.code).toBe("\uFEFFkey: value\n");
|
||||
it("preserves a BOM-headed front matter verbatim while the css body still formats", async () => {
|
||||
const result = await format("a.css", "---\n\uFEFFkey: value\n---\na { color : red }\n");
|
||||
expect(result.errors).toStrictEqual([]);
|
||||
expect(result.code).toBe("---\n\uFEFFkey: value\n---\n\na {\n color: red;\n}\n");
|
||||
});
|
||||
});
|
||||
@@ -77,9 +77,14 @@ impl<'a> FormatSession<'a> {
|
||||
|
||||
/// Formats one embedded-language request on a child session derived from this one.
|
||||
///
|
||||
/// See [`DispatchOutcome`] for the outcome-vs-`Err` contract; this method adds two rules:
|
||||
/// no dispatcher installed (plain standalone run) counts as the deliberate
|
||||
/// `Ok(DispatchOutcome::PreserveOriginal)`, and reaching `MAX_DISPATCH_DEPTH` is an `Err`.
|
||||
/// See [`DispatchOutcome`] for the outcome-vs-`Err` contract; this method adds three rules:
|
||||
/// - no dispatcher installed (plain standalone run) counts as the deliberat `Ok(DispatchOutcome::PreserveOriginal)`
|
||||
/// - a request text starting with U+FEFF is deliberately preserved
|
||||
/// - in an embedded position it is content, not a BOM, formatting must not swallow it
|
||||
/// - and no child entry handles it
|
||||
/// - reaching `MAX_DISPATCH_DEPTH`
|
||||
///
|
||||
/// is an `Err`.
|
||||
///
|
||||
/// The callback receives the derived child session;
|
||||
/// it must not create another `GroupId` space or bump the depth again.
|
||||
@@ -90,11 +95,15 @@ impl<'a> FormatSession<'a> {
|
||||
let Some(dispatcher) = &self.dispatcher else {
|
||||
return Ok(DispatchOutcome::PreserveOriginal);
|
||||
};
|
||||
if request.texts.iter().any(|text| text.starts_with('\u{feff}')) {
|
||||
return Ok(DispatchOutcome::PreserveOriginal);
|
||||
}
|
||||
if self.dispatch_depth >= MAX_DISPATCH_DEPTH {
|
||||
return Err(format!(
|
||||
"embedded dispatch exceeded the recursion limit ({MAX_DISPATCH_DEPTH})"
|
||||
));
|
||||
}
|
||||
|
||||
let child = self.derive_child(request.input_kind);
|
||||
dispatcher(&child, request)
|
||||
}
|
||||
@@ -148,6 +157,25 @@ mod tests {
|
||||
assert!(matches!(session.dispatch(request()), Ok(DispatchOutcome::PreserveOriginal)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_preserves_a_bom_headed_text() {
|
||||
let allocator = Allocator::default();
|
||||
let dispatcher: FormatDispatcher = Arc::new(|_ctx, _request| {
|
||||
Ok(DispatchOutcome::Formatted(DispatchResult {
|
||||
docs: Vec::new(),
|
||||
tailwind_classes: Vec::new(),
|
||||
meta: None,
|
||||
}))
|
||||
});
|
||||
let session = FormatSession::new(&allocator, InputKind::PhysicalFile, Some(dispatcher));
|
||||
|
||||
let bom_request = DispatchRequest { texts: &["a: 1", "\u{feff}a: 1"], ..request() };
|
||||
assert!(matches!(session.dispatch(bom_request), Ok(DispatchOutcome::PreserveOriginal)));
|
||||
// A non-leading U+FEFF is ordinary content and formats normally
|
||||
let inner_request = DispatchRequest { texts: &["a: \u{feff}1"], ..request() };
|
||||
assert!(matches!(session.dispatch(inner_request), Ok(DispatchOutcome::Formatted(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_stops_at_the_depth_limit() {
|
||||
let allocator = Allocator::default();
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
//! Byte-order-mark handling shared by every formatter's file entries.
|
||||
|
||||
/// Splits one leading U+FEFF off `source`, returning whether it was present and the remainder.
|
||||
/// Splits the entire leading run of U+FEFF off `source`,
|
||||
/// returning whether any was present and the remainder.
|
||||
///
|
||||
/// The contract every formatter follows: ENTRIES own the strip, parse layers
|
||||
/// assume BOM-free input (spans and gap scans never see it).
|
||||
/// The contract every formatter follows:
|
||||
/// ENTRIES own the strip, parse layers assume BOM-free input (spans and gap scans never see it).
|
||||
/// A physical root entry keeps the flag and re-emits the BOM exactly once at byte 0 of its IR;
|
||||
/// an embedded entry (`format_to_ir`) strips as input hygiene and never re-emits.
|
||||
/// The one exception is JS: its AST-in entry (`format_program`) means the formatter
|
||||
/// never owns pre-parse text — oxc_parser lexes U+FEFF as whitespace itself,
|
||||
/// so the root printer detects at print time instead.
|
||||
/// an embedded entry (`format_to_ir`) never sees a leading U+FEFF at all:
|
||||
/// in an embedded position it is content, not a BOM,
|
||||
/// so `FormatSession::dispatch` answers `PreserveOriginal` for BOM-headed inputs before any child entry runs.
|
||||
/// The one exception is JS:
|
||||
/// its AST-in entry (`format_program`) means the formatter never owns pre-parse text,
|
||||
/// `oxc_parser` lexes U+FEFF as whitespace itself, so the root printer detects at print time instead.
|
||||
///
|
||||
/// Exactly ONE BOM is split: a doubled BOM keeps its second copy in the remainder
|
||||
/// and reaches the parser as ordinary input. (matching Prettier, which strips only `charAt(0)`)
|
||||
/// The WHOLE leading run is split, not just the first,
|
||||
/// so the remainder is BOM-free by construction and a doubled BOM comes back out as one BOM.
|
||||
/// (Prettier strips only `charAt(0)` and lets its parsers swallow the rest; the observable output is the same.)
|
||||
pub fn split_bom(source: &str) -> (bool, &str) {
|
||||
match source.strip_prefix('\u{feff}') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, source),
|
||||
}
|
||||
(source.starts_with('\u{feff}'), source.trim_start_matches('\u{feff}'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -24,23 +25,17 @@ mod tests {
|
||||
use super::split_bom;
|
||||
|
||||
#[test]
|
||||
fn no_bom_passes_through() {
|
||||
assert_eq!(split_bom("a: 1"), (false, "a: 1"));
|
||||
assert_eq!(split_bom(""), (false, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_bom_is_split() {
|
||||
assert_eq!(split_bom("\u{feff}a"), (true, "a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bom_only_leaves_an_empty_remainder() {
|
||||
assert_eq!(split_bom("\u{feff}"), (true, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_bom_keeps_the_second_copy() {
|
||||
assert_eq!(split_bom("\u{feff}\u{feff}a"), (true, "\u{feff}a"));
|
||||
fn splits_the_whole_leading_bom_run() {
|
||||
for (input, expected) in [
|
||||
("a: 1", (false, "a: 1")),
|
||||
("", (false, "")),
|
||||
("\u{feff}a", (true, "a")),
|
||||
("\u{feff}", (true, "")),
|
||||
("\u{feff}\u{feff}a", (true, "a")),
|
||||
// A non-leading U+FEFF is content and stays
|
||||
("\u{feff}a\u{feff}", (true, "a\u{feff}")),
|
||||
] {
|
||||
assert_eq!(split_bom(input), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,15 +135,6 @@ pub fn format_to_ir<'a>(
|
||||
let allocator = session.allocator();
|
||||
let input_kind = session.input_kind();
|
||||
|
||||
// A BOM marks a document this entry does not own: physical roots handle
|
||||
// BOMs at `format_with_session`; an embedded input carrying one is refused
|
||||
// wholesale (the host degrades to `PreserveOriginal`, keeping the part as-is).
|
||||
if oxc_formatter_core::spec::split_bom(source_text).0 {
|
||||
return Err(OxcDiagnostic::error(
|
||||
"Embedded CSS input starts with a BOM; the part is preserved as-is",
|
||||
));
|
||||
}
|
||||
|
||||
let prepared = prepare_source(allocator, source_text);
|
||||
if prepared.front_matter.is_some() && input_kind != InputKind::VirtualDocument {
|
||||
// A fragment (css-in-js, JSDoc fence) never acquires file envelope semantics:
|
||||
@@ -208,15 +199,19 @@ fn prepare_source<'a>(allocator: &'a Allocator, source_text: &str) -> PreparedSo
|
||||
/// Parse the (already normalized, front-matter-blanked) source into an AST
|
||||
/// and collect comments, bailing out on any error.
|
||||
///
|
||||
/// `parse_source` comes from [`prepare_source`]; entries own the BOM strip,
|
||||
/// and a leading `\u{feff}` still reaching this point is content
|
||||
/// (e.g. a doubled BOM's second copy) and is the parser's to judge.
|
||||
/// `parse_source` comes from [`prepare_source`]; a leading `\u{feff}` can never
|
||||
/// arrive here (physical entries split the whole BOM run, `FormatSession::dispatch`
|
||||
/// preserves BOM-headed embedded inputs), and this parser needs that guarantee:
|
||||
/// `oxc-css-parser` strips a leading `\u{feff}` from the source itself, which
|
||||
/// would desync every span from the arena copy the printer slices.
|
||||
fn parse_stylesheet<'a>(
|
||||
allocator: &'a Allocator,
|
||||
parse_source: &'a str,
|
||||
options: CssFormatOptions,
|
||||
tolerate_placeholders: bool,
|
||||
) -> Result<(Stylesheet<'a>, &'a [CssComment]), OxcDiagnostic> {
|
||||
debug_assert!(!parse_source.starts_with('\u{feff}'), "callers must never pass a leading BOM");
|
||||
|
||||
let mut parser = ParserBuilder::new(allocator, parse_source)
|
||||
.syntax(options.variant.to_css_syntax())
|
||||
.options(ParserOptions {
|
||||
|
||||
@@ -62,8 +62,6 @@ pub fn format_to_ir<'a>(
|
||||
options: GraphqlFormatOptions,
|
||||
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
|
||||
let allocator = session.allocator();
|
||||
// Input hygiene: embedded sources may carry a BOM; strip it, never re-emit.
|
||||
let (_, source_text) = oxc_formatter_core::spec::split_bom(source_text);
|
||||
let (document, source, comments) = parse_document(allocator, source_text)?;
|
||||
|
||||
let context = GraphqlFormatContext::new(options, source, comments);
|
||||
@@ -80,8 +78,7 @@ pub fn format_to_ir<'a>(
|
||||
/// bailing out on any parse error.
|
||||
///
|
||||
/// Copies the source into the arena so every slice taken from it carries `'a`.
|
||||
/// Entries own the BOM strip; a leading `\u{feff}` still reaching this point is content
|
||||
/// (e.g. a doubled BOM's second copy) and is the parser's to judge.
|
||||
/// Entries own the BOM strip; this layer assumes BOM-free (see [`oxc_formatter_core::spec::split_bom`]).
|
||||
fn parse_document<'a>(
|
||||
allocator: &'a Allocator,
|
||||
source_text: &str,
|
||||
|
||||
@@ -72,8 +72,6 @@ pub fn format_to_ir<'a>(
|
||||
options: JsonFormatOptions,
|
||||
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
|
||||
let allocator = session.allocator();
|
||||
// Input hygiene: embedded sources may carry a BOM; strip it, never re-emit.
|
||||
let (_, source_text) = oxc_formatter_core::spec::split_bom(source_text);
|
||||
let parsed = parse_json(allocator, source_text, options.variant)?;
|
||||
|
||||
let context = JsonFormatContext::new(
|
||||
|
||||
@@ -60,8 +60,6 @@ pub fn format_to_ir<'a>(
|
||||
options: YamlFormatOptions,
|
||||
) -> Result<EmbeddedIr<'a>, OxcDiagnostic> {
|
||||
let allocator = session.allocator();
|
||||
// Input hygiene: embedded sources may carry a BOM; strip it, never re-emit.
|
||||
let (_, source_text) = oxc_formatter_core::spec::split_bom(source_text);
|
||||
let (root, source, comments) = parse_root(allocator, source_text)?;
|
||||
|
||||
let context =
|
||||
@@ -79,8 +77,7 @@ pub fn format_to_ir<'a>(
|
||||
/// bailing out on any parse error.
|
||||
///
|
||||
/// Copies the source into the arena so every slice taken from it carries `'a`.
|
||||
/// Entries own the BOM strip; a leading `\u{feff}` still reaching this point is content
|
||||
/// (e.g. a doubled BOM's second copy) and is the parser's to judge.
|
||||
/// Entries own the BOM strip; this layer assumes BOM-free input (see [`oxc_formatter_core::spec::split_bom`]).
|
||||
fn parse_root<'a>(
|
||||
allocator: &'a Allocator,
|
||||
source_text: &str,
|
||||
|
||||
Reference in new issue
Block a user