From a5314a1c8a01e06ed6717902121ecbd76d54902c Mon Sep 17 00:00:00 2001 From: leaysgur <6259812+leaysgur@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:43:53 +0000 Subject: [PATCH] fix(formatter): keep `;` before `in` and `instanceof` class members (#28166) Closes #28065 Prettier's `shouldPrintSemicolonAfterClassProperty` (and also biome) already has this rule, but our port missed it. - Treat an `in` / `instanceof` key like a computed key in `needs_semicolon`, with the same modifier exclusions - Check a string key by its value, since `"in"` may be printed unquoted - Prettier unquotes it and drops the `;`, producing invalid code - DIVERGENCES.md#string-binary-keyword-class-key --- crates/oxc_formatter/DIVERGENCES.md | 29 ++++ crates/oxc_formatter/src/print/class.rs | 27 +++- .../js/semicolons/binary-keyword-class-key.js | 22 +++ .../binary-keyword-class-key.js.snap | 133 ++++++++++++++++++ .../binary-keyword-class-key-quoted.ts | 5 + .../binary-keyword-class-key-quoted.ts.snap | 48 +++++++ tasks/coverage/snapshots/formatter_babel.snap | 6 +- 7 files changed, 261 insertions(+), 9 deletions(-) create mode 100644 crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js create mode 100644 crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js.snap create mode 100644 crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts create mode 100644 crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts.snap diff --git a/crates/oxc_formatter/DIVERGENCES.md b/crates/oxc_formatter/DIVERGENCES.md index ff0b581db0..e8ab86276e 100644 --- a/crates/oxc_formatter/DIVERGENCES.md +++ b/crates/oxc_formatter/DIVERGENCES.md @@ -1050,3 +1050,32 @@ const --> comment A closing HTML comment (`-->`, Annex B) is a comment only at the start of a line. Prettier inlines it after `const` or `var`, producing code rejected by `node --check`, and misparses the `let` case as an expression statement. We always print a line break before `-->`, which also splits a block comment preceding it on the same line, where Prettier's output stays valid. + +## string-binary-keyword-class-key + +- Why: semantics +- Pin: `tests/fixtures/js/semicolons/binary-keyword-class-key.js`, `tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts` + +```js +// input (semi: false) +class A { + a = 1; + "in"; +} + +// ours +class A { + a = 1; + in +} + +// prettier +class A { + a = 1 + in +} +``` + +A class member named `in` or `instanceof` would continue the previous property's value as a binary operator, so the `;` before it is kept. +Prettier checks only an identifier key, but also prints the string key `"in"` unquoted, producing code rejected by `node --check`. +We check a string key by its value, which also keeps the `;` before a key that stays quoted (TypeScript, `quoteProps: "preserve"`), where Prettier's output stays valid. diff --git a/crates/oxc_formatter/src/print/class.rs b/crates/oxc_formatter/src/print/class.rs index c94faab07f..1699b0258a 100644 --- a/crates/oxc_formatter/src/print/class.rs +++ b/crates/oxc_formatter/src/print/class.rs @@ -571,10 +571,15 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> { let Some(next_element) = next_element else { return false }; + // When the name starts with `[`, `in` or `instanceof`. + // NOTE: These only continue a previous property with a value (`x = a` + `in`, `x = a` + `[b]`), + // but kept regardless of it like Prettier, an unneeded `;` is harmless. + let starts_with_hazard = next_element.computed() + || next_element.property_key().is_some_and(is_binary_keyword_key); match next_element.as_ref() { - // When the name starts with the generator token or `[` + // Or the generator token ClassElement::MethodDefinition(def) if !def.value.r#async => { - (def.computed + (starts_with_hazard && !(def.kind.is_accessor() || def.r#static || def.accessibility.is_some() @@ -582,7 +587,7 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> { || def.value.generator } ClassElement::PropertyDefinition(def) => { - def.computed + starts_with_hazard && !(def.accessibility.is_some() || def.r#static || def.declare @@ -590,7 +595,8 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> { || def.readonly) } ClassElement::AccessorProperty(def) => { - def.computed && !(def.accessibility.is_some() || def.r#static || def.r#override) + starts_with_hazard + && !(def.accessibility.is_some() || def.r#static || def.r#override) } ClassElement::TSIndexSignature(_) => true, _ => false, @@ -598,6 +604,19 @@ impl<'a, 'b> FormatClassElementWithSemicolon<'a, 'b> { } } +/// `in` or `instanceof` would continue the previous property's value as a binary operator. +/// A string key may be printed unquoted, so it is checked by its value. +/// See DIVERGENCES.md#string-binary-keyword-class-key +fn is_binary_keyword_key(key: &PropertyKey<'_>) -> bool { + match key { + PropertyKey::StaticIdentifier(ident) => matches!(ident.name.as_str(), "in" | "instanceof"), + PropertyKey::StringLiteral(literal) => { + literal.value == "in" || literal.value == "instanceof" + } + _ => false, + } +} + impl FormatClassElementWithSemicolon<'_, '_> { /// Where the element's content ends and its `;` takes over, for the kinds that take one: /// a property's value (the chain leaf, like an expression statement), type annotation or key, diff --git a/crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js b/crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js new file mode 100644 index 0000000000..27cbf08ef3 --- /dev/null +++ b/crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js @@ -0,0 +1,22 @@ +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + in; + b = 1; + instanceof() {} + c; + in; + d = 1; + "in"; + e = 1; + "instanceof"() {} +} + +class B { + a = 1; + static in; + b = 1; + get in() {} + c = 1; + async in() {} +} diff --git a/crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js.snap b/crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js.snap new file mode 100644 index 0000000000..b37dfd0bf8 --- /dev/null +++ b/crates/oxc_formatter/tests/fixtures/js/semicolons/binary-keyword-class-key.js.snap @@ -0,0 +1,133 @@ +--- +source: crates/oxc_formatter/tests/fixtures/mod.rs +--- +==================== Input ==================== +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + in; + b = 1; + instanceof() {} + c; + in; + d = 1; + "in"; + e = 1; + "instanceof"() {} +} + +class B { + a = 1; + static in; + b = 1; + get in() {} + c = 1; + async in() {} +} + +==================== Output ==================== +------------------------------ +{ printWidth: 80, semi: true } +------------------------------ +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + in; + b = 1; + instanceof() {} + c; + in; + d = 1; + in; + e = 1; + instanceof() {} +} + +class B { + a = 1; + static in; + b = 1; + get in() {} + c = 1; + async in() {} +} + +------------------------------- +{ printWidth: 100, semi: true } +------------------------------- +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + in; + b = 1; + instanceof() {} + c; + in; + d = 1; + in; + e = 1; + instanceof() {} +} + +class B { + a = 1; + static in; + b = 1; + get in() {} + c = 1; + async in() {} +} + +------------------------------- +{ printWidth: 80, semi: false } +------------------------------- +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + in + b = 1; + instanceof() {} + c; + in + d = 1; + in + e = 1; + instanceof() {} +} + +class B { + a = 1 + static in + b = 1 + get in() {} + c = 1 + async in() {} +} + +-------------------------------- +{ printWidth: 100, semi: false } +-------------------------------- +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + in + b = 1; + instanceof() {} + c; + in + d = 1; + in + e = 1; + instanceof() {} +} + +class B { + a = 1 + static in + b = 1 + get in() {} + c = 1 + async in() {} +} + +===================== End ===================== diff --git a/crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts b/crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts new file mode 100644 index 0000000000..98ef199e21 --- /dev/null +++ b/crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts @@ -0,0 +1,5 @@ +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + "in": number; +} diff --git a/crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts.snap b/crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts.snap new file mode 100644 index 0000000000..df1b2d8626 --- /dev/null +++ b/crates/oxc_formatter/tests/fixtures/ts/semicolons/binary-keyword-class-key-quoted.ts.snap @@ -0,0 +1,48 @@ +--- +source: crates/oxc_formatter/tests/fixtures/mod.rs +--- +==================== Input ==================== +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + "in": number; +} + +==================== Output ==================== +------------------------------ +{ printWidth: 80, semi: true } +------------------------------ +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + "in": number; +} + +------------------------------- +{ printWidth: 100, semi: true } +------------------------------- +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + "in": number; +} + +------------------------------- +{ printWidth: 80, semi: false } +------------------------------- +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + "in": number +} + +-------------------------------- +{ printWidth: 100, semi: false } +-------------------------------- +// DIVERGENCES.md#string-binary-keyword-class-key +class A { + a = 1; + "in": number +} + +===================== End ===================== diff --git a/tasks/coverage/snapshots/formatter_babel.snap b/tasks/coverage/snapshots/formatter_babel.snap index 33027eeecb..c8c9702a72 100644 --- a/tasks/coverage/snapshots/formatter_babel.snap +++ b/tasks/coverage/snapshots/formatter_babel.snap @@ -2,7 +2,7 @@ commit: a56fe4a4 formatter_babel Summary: AST Parsed : 2280/2280 (100.00%) -Positive Passed: 2272/2280 (99.65%) +Positive Passed: 2274/2280 (99.74%) Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basic/class-method/input.js Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basic/class-private-method/input.js @@ -15,7 +15,3 @@ Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basi Mismatch: tasks/coverage/babel/packages/babel-parser/test/fixtures/comments/basic/sequence-expression/input.js -Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/es2022/class-properties/escaped-in-after-initializer/input.js -Cannot assign to this expression -Expect to Parse: tasks/coverage/babel/packages/babel-parser/test/fixtures/typescript/class/escaped-in-after-initializer/input.ts -Cannot assign to this expression