fix(formatter_json): preserve key and literal value for json-stringify (#23996)

Part of #23923

Do not transform object key, string and number values with `json-stringify`.
This commit is contained in:
leaysgur committed 2026-07-03 07:22:00 +00:00
1 parent 7d663d3fd0
commit 0a6b16c424
7 files changed
+63 -100

No files matched your search

@@ -44,6 +44,10 @@ pub fn write_quoted_str<'a>(f: &mut JsonFormatter<'_, 'a>, quote_byte: u8, body:
/// JS stringifies the parsed value into a different shape (`"16"` for `0x10`, `"NaN"` for `1_2`),
/// Rust simply fails to parse, so `Err` mapping to `false` matches.
pub fn number_string_round_trips(s: &str) -> bool {
// PERF: Use `dragonbox_ecma` directly instead of `oxc_syntax::ToJsString`
// The latter returns `String` (heap alloc),
// while `Buffer::format` returns `&str` borrowed from a stack buffer.
// This is only a `==` comparison on a hot path (every numeric key), so avoid the alloc.
s.parse::<f64>()
.is_ok_and(|value| value.is_finite() && dragonbox_ecma::Buffer::new().format(value) == s)
}
@@ -25,10 +25,6 @@ use super::{
number_string_round_trips, write_quoted_str,
};
// NOTE: This is a temporary compatibility flag for Prettier's pre-#18405 behavior,
// which should be removed after updating `prettier@3.9.0`.
const COMPAT_PRE_18405: bool = true;
/// Top-level wrapper around an [`Expression`] for the `json-stringify` variant.
/// The counterpart of [`super::FmtJsonValue`]; recursion stays within this type.
pub struct FmtJsonStringifyValue<'a, 'b> {
@@ -42,7 +38,7 @@ impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonStringifyValue<'a, '_> {
Expression::BooleanLiteral(lit) => {
write!(f, if lit.value { "true" } else { "false" });
}
Expression::NumericLiteral(lit) => write_number_value(lit, f),
Expression::NumericLiteral(lit) => write_number(lit, f),
Expression::StringLiteral(lit) => write_string(lit, f),
Expression::ArrayExpression(array) => write_array(array, f),
Expression::ObjectExpression(object) => write_object(object, f),
@@ -70,31 +66,14 @@ impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonStringifyValue<'a, '_> {
}
}
fn write_number_value<'a>(lit: &NumericLiteral<'a>, f: &mut JsonFormatter<'_, 'a>) {
if COMPAT_PRE_18405 {
// The parser can only produce non-negative values; overflow yields +Infinity.
if lit.value.is_finite() {
// Stack-format then copy once into the arena (same as `oxc_codegen`),
// avoiding `ToJsString`'s intermediate heap `String`.
let mut buffer = dragonbox_ecma::Buffer::new();
write!(f, text(f.allocator().alloc_str(buffer.format(lit.value))));
} else {
write!(f, "null");
}
} else {
let raw = lit.raw.as_ref().unwrap_or_else(|| unreachable!("parser always sets `raw`"));
write!(f, text(raw.as_str()));
}
fn write_number<'a>(lit: &NumericLiteral<'a>, f: &mut JsonFormatter<'_, 'a>) {
let raw = lit.raw.as_ref().unwrap_or_else(|| unreachable!("parser always sets `raw`"));
write!(f, text(raw.as_str()));
}
fn write_string<'a>(lit: &StringLiteral<'a>, f: &mut JsonFormatter<'_, 'a>) {
if COMPAT_PRE_18405 {
let body = json_stringify_escape(lit.value.as_str(), lit.lone_surrogates);
write_quoted_str(f, b'"', arena_cow_str(&body, f));
} else {
// `preferred_quote` already pins `json-stringify` to `"`.
FmtJsonString { lit }.fmt(f);
}
// `preferred_quote` already pins `json-stringify` to `"`.
FmtJsonString { lit }.fmt(f);
}
/// The always-expanded layout shared by arrays and objects: a [`block_indent`]
@@ -165,8 +144,7 @@ fn write_object<'a>(object: &ObjectExpression<'a>, f: &mut JsonFormatter<'_, 'a>
/// - Identifier key (`a:`, `undefined:`): always double-quoted
/// (identifier names cannot contain characters `JSON.stringify` would escape)
/// - Numeric key: see the module table for the mode split; in compat mode `String()`
/// (unlike value position's `JSON.stringify()`) has no `null` fallback,
/// so non-finite quotes as `"Infinity"`
/// (unlike value position's `JSON.stringify()`) has no `null` fallback, so non-finite quotes as `"Infinity"`
fn write_object_key<'a>(key: &PropertyKey<'a>, f: &mut JsonFormatter<'_, 'a>) {
match key {
PropertyKey::StringLiteral(lit) => write_string(lit, f),
@@ -174,23 +152,12 @@ fn write_object_key<'a>(key: &PropertyKey<'a>, f: &mut JsonFormatter<'_, 'a>) {
write_quoted_str(f, b'"', ident.name.as_str());
}
PropertyKey::NumericLiteral(lit) => {
if COMPAT_PRE_18405 {
let printed = if lit.value.is_finite() {
let mut buffer = dragonbox_ecma::Buffer::new();
f.allocator().alloc_str(buffer.format(lit.value))
} else {
"Infinity"
};
write_quoted_str(f, b'"', printed);
let raw = lit.raw.as_ref().unwrap_or_else(|| unreachable!("parser always sets `raw`"));
let raw = raw.as_str();
if number_string_round_trips(raw) {
write_quoted_str(f, b'"', raw);
} else {
let raw =
lit.raw.as_ref().unwrap_or_else(|| unreachable!("parser always sets `raw`"));
let raw = raw.as_str();
if number_string_round_trips(raw) {
write_quoted_str(f, b'"', raw);
} else {
write!(f, text(raw));
}
write!(f, text(raw));
}
}
_ => write!(f, FormatInvalidJson(key.span())),
@@ -285,8 +252,6 @@ fn json_stringify_escape(content: &str, lone_surrogates: bool) -> Cow<'_, str> {
mod tests {
use super::{json_stringify_escape, number_string_round_trips};
/// Covers the prettier@3.9 behavior even while `COMPAT_PRE_18405` keeps it inactive,
/// so the path doesn't rot before the switch is flipped.
#[test]
fn key_round_trip() {
// Quoted: survives `String(Number(raw))` unchanged
@@ -19,8 +19,8 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
1,
2,
"3",
10000000000,
0.001
1e10,
1e-3
]
},
"empty_arr": [],
@@ -41,8 +41,8 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
1,
2,
"3",
10000000000,
0.001
1e10,
1e-3
]
},
"empty_arr": [],
@@ -15,18 +15,18 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
---------------------------------------------
[
0.5,
0.8675309,
8675309,
912559,
1,
2,
2,
1,
1,
-9876.54321,
1.23456789e+34,
2.3456789012e+76,
123456,
.8675309,
8675309.,
0xdecaf,
1e00,
2e+00,
2e-00,
1.0,
1.00000,
-9876.543210,
1.234567890E+34,
23456789012E66,
123_456,
1,
-1,
0.5,
@@ -42,18 +42,18 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
----------------------------------------------
[
0.5,
0.8675309,
8675309,
912559,
1,
2,
2,
1,
1,
-9876.54321,
1.23456789e+34,
2.3456789012e+76,
123456,
.8675309,
8675309.,
0xdecaf,
1e00,
2e+00,
2e-00,
1.0,
1.00000,
-9876.543210,
1.234567890E+34,
23456789012E66,
123_456,
1,
-1,
0.5,
@@ -31,15 +31,15 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
"undefined": "",
"string": "",
"0": "",
"100": "",
"100": "",
1e2: "",
1.0e+2: "",
.1: "",
"0.1": "",
"0.1": "",
"1": "",
"1": "",
"16": "",
"1e+30": "",
"123": ""
1.0: "",
1.00000: "",
0x10: "",
999999999999999999999999999999: "",
1_2_3: ""
}
----------------------------------------------
@@ -52,15 +52,15 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
"undefined": "",
"string": "",
"0": "",
"100": "",
"100": "",
1e2: "",
1.0e+2: "",
.1: "",
"0.1": "",
"0.1": "",
"1": "",
"1": "",
"16": "",
"1e+30": "",
"123": ""
1.0: "",
1.00000: "",
0x10: "",
999999999999999999999999999999: "",
1_2_3: ""
}
===================== End =====================
@@ -23,7 +23,7 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
"single",
"double",
"I can use \"double quotes\" here",
"ģ䕧 ÿ \u0000 / /",
"ģ䕧 \xFF \0 / \/",
"",
"foo",
"\n multiple-line\n",
@@ -37,7 +37,7 @@ source: crates/oxc_formatter_json/tests/fixtures/mod.rs
"single",
"double",
"I can use \"double quotes\" here",
"ģ䕧 ÿ \u0000 / /",
"ģ䕧 \xFF \0 / \/",
"",
"foo",
"\n multiple-line\n",
@@ -1,12 +1,6 @@
json-stringify compatibility: 8/14 (57.14%), 0 files skipped
json-stringify compatibility: 14/14 (100.00%), 0 files skipped
# Failed
| Spec path | Failed or Passed | Match ratio |
| :-------- | :--------------: | :---------: |
| json/json/json5.json | 💥 | 82.35% |
| json/json/json6.json | 💥 | 77.91% |
| json/json/multi-line.json | 💥 | 87.50% |
| json/json/pass1.json | 💥 | 81.33% |
| json/json/propertyKey.json | 💥 | 33.33% |
| json/json/single-line.json | 💥 | 87.50% |