fix(formatter): handle quoteProps: consistent for patterns and computed keys (#27216)

closes #27213, closes #27214

- Apply consistent quoting to object destructuring and assignment patterns
- Normalize quoted keys in object assignment patterns as well
- Ignore computed keys (and also TS enum) when deciding whether sibling keys need quotes
This commit is contained in:
leaysgur committed 2026-09-30 03:25:22 +00:00
1 parent 6fcd13529a
commit c9e1224348
11 files changed
+492 -39

No files matched your search

+4 -9
View File
@@ -49,15 +49,10 @@ use super::{
impl<'a> FormatWrite<'a> for AstNode<'a, ClassBody<'a>> {
fn write(&self, f: &mut JsFormatter<'_, 'a>) {
if f.options().quote_properties.is_consistent() {
let quote_needed = self.body.iter().any(|signature| {
let key = match signature {
ClassElement::PropertyDefinition(property) => &property.key,
ClassElement::AccessorProperty(property) => &property.key,
ClassElement::MethodDefinition(method) => &method.key,
_ => return false,
};
should_preserve_quote(key, f)
let quote_needed = self.body.iter().any(|element| {
element
.property_key()
.is_some_and(|key| should_preserve_quote(key, element.computed(), f))
});
f.context_mut().push_quote_needed(quote_needed);
}
+26 -25
View File
@@ -131,6 +131,8 @@ impl<'a> FormatWrite<'a> for AstNode<'a, IdentifierName<'a>> {
let is_property_key_parent = matches!(
self.parent(),
AstNodes::ObjectProperty(_)
| AstNodes::BindingProperty(_)
| AstNodes::AssignmentTargetPropertyProperty(_)
| AstNodes::TSPropertySignature(_)
| AstNodes::TSMethodSignature(_)
| AstNodes::MethodDefinition(_)
@@ -186,7 +188,9 @@ impl<'a> FormatWrite<'a> for AstNode<'a, ObjectExpression<'a>> {
fn write(&self, f: &mut JsFormatter<'_, 'a>) {
if f.options().quote_properties.is_consistent() {
let quote_needed = self.properties.iter().any(|kind| {
kind.as_property().is_some_and(|property| should_preserve_quote(&property.key, f))
kind.as_property().is_some_and(|property| {
should_preserve_quote(&property.key, property.computed, f)
})
});
f.context_mut().push_quote_needed(quote_needed);
}
@@ -433,13 +437,7 @@ impl<'a> FormatWrite<'a> for AstNode<'a, AssignmentTargetPropertyIdentifier<'a>>
impl<'a> FormatWrite<'a> for AstNode<'a, AssignmentTargetPropertyProperty<'a>> {
fn write(&self, f: &mut JsFormatter<'_, 'a>) {
if self.computed() {
write!(f, "[");
}
write!(f, self.name());
if self.computed() {
write!(f, "]");
}
format_property_key(self.name(), self.computed(), f);
write!(f, [":", space(), self.binding()]);
}
}
@@ -1165,15 +1163,19 @@ impl<'a> FormatWrite<'a> for AstNode<'a, NumericLiteral<'a>> {
// Check if this numeric literal is a property key (not a value) that should be quoted
// when quoteProps is "consistent" and another property requires quotes.
// We need to check that this literal's span matches the key's span, not just that the parent is a property.
let is_property_key = match self.parent() {
AstNodes::ObjectProperty(prop) => prop.key.span() == self.span(),
AstNodes::TSPropertySignature(prop) => prop.key.span() == self.span(),
AstNodes::TSMethodSignature(prop) => prop.key.span() == self.span(),
AstNodes::MethodDefinition(prop) => prop.key.span() == self.span(),
AstNodes::PropertyDefinition(prop) => prop.key.span() == self.span(),
AstNodes::AccessorProperty(prop) => prop.key.span() == self.span(),
_ => false,
// Computed keys like `[1]` are expressions, so they are never quoted.
let (key_span, computed) = match self.parent() {
AstNodes::ObjectProperty(prop) => (prop.key.span(), prop.computed),
AstNodes::BindingProperty(prop) => (prop.key.span(), prop.computed),
AstNodes::AssignmentTargetPropertyProperty(prop) => (prop.name.span(), prop.computed),
AstNodes::TSPropertySignature(prop) => (prop.key.span(), prop.computed),
AstNodes::TSMethodSignature(prop) => (prop.key.span(), prop.computed),
AstNodes::MethodDefinition(prop) => (prop.key.span(), prop.computed),
AstNodes::PropertyDefinition(prop) => (prop.key.span(), prop.computed),
AstNodes::AccessorProperty(prop) => (prop.key.span(), prop.computed),
_ => (Span::default(), true),
};
let is_property_key = !computed && key_span == self.span();
if is_property_key && f.context().is_quote_needed() {
// Get the formatted number text
@@ -1780,18 +1782,17 @@ impl<'a> Format<'a, JsFormatContext<'a>> for AstNode<'a, ArenaVec<'a, TSSignatur
fn fmt(&self, f: &mut JsFormatter<'_, 'a>) {
if f.options().quote_properties.is_consistent() {
let quote_needed = self.as_ref().iter().any(|signature| {
let key = match signature {
TSSignature::TSPropertySignature(property) => &property.key,
match signature {
TSSignature::TSPropertySignature(property) => {
should_preserve_quote(&property.key, property.computed, f)
}
TSSignature::TSMethodSignature(method) => {
// A quoted `new` method keeps its quotes, so it forces quoting the sibling members as well
if is_quoted_new_method_signature(method) {
return true;
}
&method.key
is_quoted_new_method_signature(method)
|| should_preserve_quote(&method.key, method.computed, f)
}
_ => return false,
};
should_preserve_quote(key, f)
_ => false,
}
});
f.context_mut().push_quote_needed(quote_needed);
}
@@ -9,6 +9,7 @@ use crate::{
prelude::{format_with, group, soft_block_indent_with_maybe_space},
trivia::format_dangling_comments,
},
utils::object::should_preserve_quote,
write,
};
@@ -122,12 +123,31 @@ impl<'a> ObjectPatternLike<'a, '_> {
}
fn write_properties(&self, f: &mut JsFormatter<'_, 'a>) {
let is_consistent = f.options().quote_properties.is_consistent();
if is_consistent {
let quote_needed = match self {
Self::ObjectPattern(o) => o
.properties
.iter()
.any(|property| should_preserve_quote(&property.key, property.computed, f)),
Self::ObjectAssignmentTarget(o) => o.properties.iter().any(|property| {
matches!(property, AssignmentTargetProperty::AssignmentTargetPropertyProperty(property)
if should_preserve_quote(&property.name, property.computed, f))
}),
};
f.context_mut().push_quote_needed(quote_needed);
}
match self {
Self::ObjectPattern(o) => BindingPropertyList::new(o.properties(), o.rest()).fmt(f),
Self::ObjectAssignmentTarget(o) => {
AssignmentTargetPropertyList::new(o.properties(), o.rest()).fmt(f);
}
}
if is_consistent {
f.context_mut().pop_quote_needed();
}
}
}
+9 -3
View File
@@ -107,9 +107,15 @@ pub fn should_preserve_string_quote(string: &StringLiteral<'_>, f: &JsFormatter<
!is_identifier_name_patched(quote_less_content)
}
/// Determine if the property key string literal should preserve its quotes
pub fn should_preserve_quote(key: &PropertyKey<'_>, f: &JsFormatter<'_, '_>) -> bool {
matches!(&key, PropertyKey::StringLiteral(string) if should_preserve_string_quote(string, f))
/// Determine if the property key string literal should preserve its quotes.
/// Computed keys are ignored.
pub fn should_preserve_quote(
key: &PropertyKey<'_>,
computed: bool,
f: &JsFormatter<'_, '_>,
) -> bool {
!computed
&& matches!(&key, PropertyKey::StringLiteral(string) if should_preserve_string_quote(string, f))
}
/// Determine if the enum member name string literal should preserve its quotes.
@@ -0,0 +1,11 @@
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ['meta.nonce']: 2 };
a = { 'color': 1, ['meta.nonce']: 2 };
a = { [1]: 1, 'a-b': 2 };
const { color, ['meta.nonce']: nonce } = props;
class A {
color = 1;
['meta.nonce'] = 2;
['a-b']() {}
}
@@ -0,0 +1,138 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ['meta.nonce']: 2 };
a = { 'color': 1, ['meta.nonce']: 2 };
a = { [1]: 1, 'a-b': 2 };
const { color, ['meta.nonce']: nonce } = props;
class A {
color = 1;
['meta.nonce'] = 2;
['a-b']() {}
}
==================== Output ====================
-------------------------------------------
{ printWidth: 80, quoteProps: "as-needed" }
-------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ["meta.nonce"]: 2 };
a = { color: 1, ["meta.nonce"]: 2 };
a = { [1]: 1, "a-b": 2 };
const { color, ["meta.nonce"]: nonce } = props;
class A {
color = 1;
["meta.nonce"] = 2;
["a-b"]() {}
}
--------------------------------------------
{ printWidth: 100, quoteProps: "as-needed" }
--------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ["meta.nonce"]: 2 };
a = { color: 1, ["meta.nonce"]: 2 };
a = { [1]: 1, "a-b": 2 };
const { color, ["meta.nonce"]: nonce } = props;
class A {
color = 1;
["meta.nonce"] = 2;
["a-b"]() {}
}
------------------------------------------
{ printWidth: 80, quoteProps: "preserve" }
------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ["meta.nonce"]: 2 };
a = { "color": 1, ["meta.nonce"]: 2 };
a = { [1]: 1, "a-b": 2 };
const { color, ["meta.nonce"]: nonce } = props;
class A {
color = 1;
["meta.nonce"] = 2;
["a-b"]() {}
}
-------------------------------------------
{ printWidth: 100, quoteProps: "preserve" }
-------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ["meta.nonce"]: 2 };
a = { "color": 1, ["meta.nonce"]: 2 };
a = { [1]: 1, "a-b": 2 };
const { color, ["meta.nonce"]: nonce } = props;
class A {
color = 1;
["meta.nonce"] = 2;
["a-b"]() {}
}
--------------------------------------------
{ printWidth: 80, quoteProps: "consistent" }
--------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ["meta.nonce"]: 2 };
a = { color: 1, ["meta.nonce"]: 2 };
a = { [1]: 1, "a-b": 2 };
const { color, ["meta.nonce"]: nonce } = props;
class A {
color = 1;
["meta.nonce"] = 2;
["a-b"]() {}
}
---------------------------------------------
{ printWidth: 100, quoteProps: "consistent" }
---------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ["meta.nonce"]: 2 };
a = { color: 1, ["meta.nonce"]: 2 };
a = { [1]: 1, "a-b": 2 };
const { color, ["meta.nonce"]: nonce } = props;
class A {
color = 1;
["meta.nonce"] = 2;
["a-b"]() {}
}
---------------------------------------------------------------
{ printWidth: 80, quoteProps: "consistent", singleQuote: true }
---------------------------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ['meta.nonce']: 2 };
a = { color: 1, ['meta.nonce']: 2 };
a = { [1]: 1, 'a-b': 2 };
const { color, ['meta.nonce']: nonce } = props;
class A {
color = 1;
['meta.nonce'] = 2;
['a-b']() {}
}
----------------------------------------------------------------
{ printWidth: 100, quoteProps: "consistent", singleQuote: true }
----------------------------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
a = { color: 1, ['meta.nonce']: 2 };
a = { color: 1, ['meta.nonce']: 2 };
a = { [1]: 1, 'a-b': 2 };
const { color, ['meta.nonce']: nonce } = props;
class A {
color = 1;
['meta.nonce'] = 2;
['a-b']() {}
}
===================== End =====================
@@ -0,0 +1,11 @@
const { text, 'class': c, 'data-tid': d } = props;
const { a, 'b': b } = props;
function f({ text, 'class': c, 'data-tid': d }) {}
({ text, 'class': c, 'data-tid': d } = props);
({ text, class: c, 'data-tid': d } = props);
const { 1: a, 'a-b': b } = p;
({ 1: a, 'a-b': b } = p);
// Nested patterns and objects are handled independently
const { 'data-tid': { inner }, x = { y: 1 } } = props;
const o = { 'a-b': 1, f({ z: w }) {} };
@@ -0,0 +1,162 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
const { text, 'class': c, 'data-tid': d } = props;
const { a, 'b': b } = props;
function f({ text, 'class': c, 'data-tid': d }) {}
({ text, 'class': c, 'data-tid': d } = props);
({ text, class: c, 'data-tid': d } = props);
const { 1: a, 'a-b': b } = p;
({ 1: a, 'a-b': b } = p);
// Nested patterns and objects are handled independently
const { 'data-tid': { inner }, x = { y: 1 } } = props;
const o = { 'a-b': 1, f({ z: w }) {} };
==================== Output ====================
-------------------------------------------
{ printWidth: 80, quoteProps: "as-needed" }
-------------------------------------------
const { text, class: c, "data-tid": d } = props;
const { a, b: b } = props;
function f({ text, class: c, "data-tid": d }) {}
({ text, class: c, "data-tid": d } = props);
({ text, class: c, "data-tid": d } = props);
const { 1: a, "a-b": b } = p;
({ 1: a, "a-b": b } = p);
// Nested patterns and objects are handled independently
const {
"data-tid": { inner },
x = { y: 1 },
} = props;
const o = { "a-b": 1, f({ z: w }) {} };
--------------------------------------------
{ printWidth: 100, quoteProps: "as-needed" }
--------------------------------------------
const { text, class: c, "data-tid": d } = props;
const { a, b: b } = props;
function f({ text, class: c, "data-tid": d }) {}
({ text, class: c, "data-tid": d } = props);
({ text, class: c, "data-tid": d } = props);
const { 1: a, "a-b": b } = p;
({ 1: a, "a-b": b } = p);
// Nested patterns and objects are handled independently
const {
"data-tid": { inner },
x = { y: 1 },
} = props;
const o = { "a-b": 1, f({ z: w }) {} };
------------------------------------------
{ printWidth: 80, quoteProps: "preserve" }
------------------------------------------
const { text, "class": c, "data-tid": d } = props;
const { a, "b": b } = props;
function f({ text, "class": c, "data-tid": d }) {}
({ text, "class": c, "data-tid": d } = props);
({ text, class: c, "data-tid": d } = props);
const { 1: a, "a-b": b } = p;
({ 1: a, "a-b": b } = p);
// Nested patterns and objects are handled independently
const {
"data-tid": { inner },
x = { y: 1 },
} = props;
const o = { "a-b": 1, f({ z: w }) {} };
-------------------------------------------
{ printWidth: 100, quoteProps: "preserve" }
-------------------------------------------
const { text, "class": c, "data-tid": d } = props;
const { a, "b": b } = props;
function f({ text, "class": c, "data-tid": d }) {}
({ text, "class": c, "data-tid": d } = props);
({ text, class: c, "data-tid": d } = props);
const { 1: a, "a-b": b } = p;
({ 1: a, "a-b": b } = p);
// Nested patterns and objects are handled independently
const {
"data-tid": { inner },
x = { y: 1 },
} = props;
const o = { "a-b": 1, f({ z: w }) {} };
--------------------------------------------
{ printWidth: 80, quoteProps: "consistent" }
--------------------------------------------
const { text, "class": c, "data-tid": d } = props;
const { a, b: b } = props;
function f({ text, "class": c, "data-tid": d }) {}
({ text, "class": c, "data-tid": d } = props);
({ text, "class": c, "data-tid": d } = props);
const { "1": a, "a-b": b } = p;
({ "1": a, "a-b": b } = p);
// Nested patterns and objects are handled independently
const {
"data-tid": { inner },
x = { y: 1 },
} = props;
const o = { "a-b": 1, "f"({ z: w }) {} };
---------------------------------------------
{ printWidth: 100, quoteProps: "consistent" }
---------------------------------------------
const { text, "class": c, "data-tid": d } = props;
const { a, b: b } = props;
function f({ text, "class": c, "data-tid": d }) {}
({ text, "class": c, "data-tid": d } = props);
({ text, "class": c, "data-tid": d } = props);
const { "1": a, "a-b": b } = p;
({ "1": a, "a-b": b } = p);
// Nested patterns and objects are handled independently
const {
"data-tid": { inner },
x = { y: 1 },
} = props;
const o = { "a-b": 1, "f"({ z: w }) {} };
---------------------------------------------------------------
{ printWidth: 80, quoteProps: "consistent", singleQuote: true }
---------------------------------------------------------------
const { text, 'class': c, 'data-tid': d } = props;
const { a, b: b } = props;
function f({ text, 'class': c, 'data-tid': d }) {}
({ text, 'class': c, 'data-tid': d } = props);
({ text, 'class': c, 'data-tid': d } = props);
const { '1': a, 'a-b': b } = p;
({ '1': a, 'a-b': b } = p);
// Nested patterns and objects are handled independently
const {
'data-tid': { inner },
x = { y: 1 },
} = props;
const o = { 'a-b': 1, 'f'({ z: w }) {} };
----------------------------------------------------------------
{ printWidth: 100, quoteProps: "consistent", singleQuote: true }
----------------------------------------------------------------
const { text, 'class': c, 'data-tid': d } = props;
const { a, b: b } = props;
function f({ text, 'class': c, 'data-tid': d }) {}
({ text, 'class': c, 'data-tid': d } = props);
({ text, 'class': c, 'data-tid': d } = props);
const { '1': a, 'a-b': b } = p;
({ '1': a, 'a-b': b } = p);
// Nested patterns and objects are handled independently
const {
'data-tid': { inner },
x = { y: 1 },
} = props;
const o = { 'a-b': 1, 'f'({ z: w }) {} };
===================== End =====================
@@ -0,0 +1,7 @@
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
['meta.nonce']: 2;
['a-b'](): void;
}
type T = { color: 1; ['meta.nonce']: 2 };
@@ -0,0 +1,102 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
['meta.nonce']: 2;
['a-b'](): void;
}
type T = { color: 1; ['meta.nonce']: 2 };
==================== Output ====================
-------------------------------------------
{ printWidth: 80, quoteProps: "as-needed" }
-------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
["meta.nonce"]: 2;
["a-b"](): void;
}
type T = { color: 1; ["meta.nonce"]: 2 };
--------------------------------------------
{ printWidth: 100, quoteProps: "as-needed" }
--------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
["meta.nonce"]: 2;
["a-b"](): void;
}
type T = { color: 1; ["meta.nonce"]: 2 };
------------------------------------------
{ printWidth: 80, quoteProps: "preserve" }
------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
["meta.nonce"]: 2;
["a-b"](): void;
}
type T = { color: 1; ["meta.nonce"]: 2 };
-------------------------------------------
{ printWidth: 100, quoteProps: "preserve" }
-------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
["meta.nonce"]: 2;
["a-b"](): void;
}
type T = { color: 1; ["meta.nonce"]: 2 };
--------------------------------------------
{ printWidth: 80, quoteProps: "consistent" }
--------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
["meta.nonce"]: 2;
["a-b"](): void;
}
type T = { color: 1; ["meta.nonce"]: 2 };
---------------------------------------------
{ printWidth: 100, quoteProps: "consistent" }
---------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
["meta.nonce"]: 2;
["a-b"](): void;
}
type T = { color: 1; ["meta.nonce"]: 2 };
---------------------------------------------------------------
{ printWidth: 80, quoteProps: "consistent", singleQuote: true }
---------------------------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
['meta.nonce']: 2;
['a-b'](): void;
}
type T = { color: 1; ['meta.nonce']: 2 };
----------------------------------------------------------------
{ printWidth: 100, quoteProps: "consistent", singleQuote: true }
----------------------------------------------------------------
// Computed keys are ignored when deciding whether quotes are needed
interface I {
color: 1;
['meta.nonce']: 2;
['a-b'](): void;
}
type T = { color: 1; ['meta.nonce']: 2 };
===================== End =====================
@@ -5,7 +5,7 @@ checker.ts:
sys deallocs: 19383
sys alloc bytes: 6767781 # 6.77 MB
sys peak growth: 2973674 # 2.97 MB
arena allocs: 1070124
arena allocs: 1070126
arena reallocs: 927
arena size: 90258720 # 90.26 MB
@@ -38,7 +38,7 @@ pdf.mjs:
sys deallocs: 9775
sys alloc bytes: 3141552 # 3.14 MB
sys peak growth: 1156432 # 1.16 MB
arena allocs: 432125
arena allocs: 432133
arena reallocs: 428
arena size: 29560832 # 29.56 MB