fix(formatter_json): reject unary expressions other than signed numbers (#27728)

Should reject invalid JSON like `{ "foo": -true }`.
This commit is contained in:
leaysgur committed 2026-10-16 00:36:34 +00:00
1 parent 22cd4cdaee
commit 99077ef835
3 files changed
+40 -12

No files matched your search

+18 -2
View File
@@ -1,4 +1,4 @@
use oxc_ast::ast::Expression;
use oxc_ast::ast::{Expression, UnaryExpression};
use oxc_formatter_core::{Format, Formatter, builders::FormatWith, builders::text, write};
use oxc_span::{GetSpan, Span};
@@ -98,7 +98,7 @@ impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonValue<'a, '_> {
// Prettier's `json` parser routes through the JS estree printer,
// which keeps both `+` and `-` operators while recursing into the argument
// so the inner number is normalized (`-1.0e+2` → `-1.0e2`).
Expression::UnaryExpression(unary) => {
Expression::UnaryExpression(unary) if is_signed_number(unary) => {
write!(f, unary.operator.as_str());
FmtJsonValue { expression: &unary.argument }.fmt(f);
}
@@ -124,9 +124,25 @@ impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonValue<'a, '_> {
}
}
/// Returns `true` for `+` / `-` before a number, `Infinity` or `NaN`, the only unary forms Prettier accepts.
/// Others are invalid, and printing them would change or break the source (`typeof x` -> `typeofx`, `-(-1)` -> `--1`).
fn is_signed_number(unary: &UnaryExpression<'_>) -> bool {
unary.operator.is_arithmetic()
&& match &unary.argument {
Expression::NumericLiteral(_) => true,
Expression::Identifier(ident) => matches!(ident.name.as_str(), "Infinity" | "NaN"),
_ => false,
}
}
/// `Format` adapter for JSON-invalid AST nodes: records a diagnostic via
/// [`JsonFormatContext::report_invalid_json`] and emits the node's source verbatim
/// via [`FormatSuppressedNode`] so IR construction can still complete.
///
/// NOTE: Validation happens at format time, so a node under `prettier-ignore` / `oxfmt-ignore` is never checked
/// (e.g. `// prettier-ignore\n-true` is accepted, Prettier rejects it at parse time).
/// Accepted since it is rare and the source is kept as-is;
/// validating at parse time would need another AST walk.
pub struct FormatInvalidJson(pub Span);
impl<'a> Format<'a, JsonFormatContext<'a>> for FormatInvalidJson {
@@ -22,7 +22,7 @@ use oxc_syntax::operator::UnaryOperator;
use crate::context::JsonFormatContext;
use super::{
FormatInvalidJson, JsonFormatter, format_with, literal::FmtJsonString,
FormatInvalidJson, JsonFormatter, format_with, is_signed_number, literal::FmtJsonString,
number_string_round_trips, write_quoted_str,
};
@@ -43,17 +43,13 @@ impl<'a> Format<'a, JsonFormatContext<'a>> for FmtJsonStringifyValue<'a, '_> {
Expression::StringLiteral(lit) => write_string(lit, f),
Expression::ArrayExpression(array) => write_array(array, f),
Expression::ObjectExpression(object) => write_object(object, f),
Expression::UnaryExpression(unary) => match unary.operator {
Expression::UnaryExpression(unary) if is_signed_number(unary) => {
// `JSON.parse` rejects a leading `+`, so Prettier drops it.
UnaryOperator::UnaryPlus => {
Self { expression: &unary.argument }.fmt(f);
}
UnaryOperator::UnaryNegation => {
if unary.operator == UnaryOperator::UnaryNegation {
write!(f, "-");
Self { expression: &unary.argument }.fmt(f);
}
_ => write!(f, FormatInvalidJson(unary.span)),
},
Self { expression: &unary.argument }.fmt(f);
}
// JSON5 `Infinity` / `NaN`, JSON6 `undefined`; anything else is invalid
// (Prettier rejects other identifiers at parse time, we report at format time).
Expression::Identifier(ident)
+17 -1
View File
@@ -1,7 +1,7 @@
use std::path::Path;
use oxc_allocator::Allocator;
use oxc_formatter_json::{JsonFormatOptions, format, parse_for_format};
use oxc_formatter_json::{JsonFormatOptions, JsonVariant, format, parse_for_format};
use oxc_formatter_tests::{FixtureFormatter, OptionSet, build_fixture_snapshot};
mod options;
@@ -57,3 +57,19 @@ fn test_file(path: &Path) {
// Include auto-generated test functions from build.rs
include!(concat!(env!("OUT_DIR"), "/generated_tests.rs"));
// ---
/// JSON-invalid syntax the lenient JS parse accepts must surface as `Err` (the oxfmt fallback trigger).
#[test]
fn invalid_json_is_err() {
// `json` / `jsonc` / `json5` share one printer, `json-stringify` has its own
for variant in [JsonVariant::Json, JsonVariant::JsonStringify] {
let options = JsonFormatOptions { variant, ..Default::default() };
// Unary other than `+` / `-` before a number, `Infinity` or `NaN`
for source in ["!true", "typeof Infinity", "-true", "-undefined", "-(-1)", "+ +1"] {
let allocator = Allocator::default();
assert!(format(&allocator, source, options).is_err(), "{variant:?}: {source:?}");
}
}
}