fix(formatter): measure a short key by its display width (#28026)

`js/object-prop-break-in/short-keys.js` is in the "Not idempotent" list
of the Prettier conformance report. The cause is how a property key's
width is measured for the short-key check (`width < indent_width +
MIN_OVERLAP_FOR_BREAK`):

- a quoted key goes through `FormatLiteralStringToken::width()`, its
display width (`'古体诗'` -> 6)
- an unquoted key goes through `SourceText::span_width()`, its `char`
count (`古体诗` -> 3)

So the first pass breaks after `'古体诗':` like Prettier, unquotes the key,
and the second pass sees a 3-wide key and joins the line again:

```js
// pass 1 (same as Prettier)
  古体诗:
    "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
// pass 2
  古体诗: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
```

An unquoted wide key (`古体诗: ...` in the input) never breaks at all,
unlike Prettier, whose `isObjectPropertyWithShortKey` uses
`getStringWidth`.

This makes `span_width` return the display width
(`UnicodeWidthStr::width`). Its only callers are these key-width checks
(object/binding/assignment-target properties, plain and computed), so
nothing else changes.

Tests:
- New fixture `js/unicode/wide-char-short-key.js` (unquoted, quoted and
computed wide keys, destructuring); it fails on `main`, and its output
is identical to Prettier 3.9.10 (the pinned version) at both option sets
and stable on a second pass.
- `short-keys.js` drops out of the "Not idempotent" list in
`conformance__prettier-js.snap`; no other conformance change.
- `cargo test -p oxc_formatter -p oxc_formatter_core --all-features`,
`cargo fmt --check` and `cargo clippy --all-targets --all-features` on
both crates pass.

AI disclosure: this PR (code, tests and description) was written with
Claude Code (Claude Opus 5.5); the verification above is what was run.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Yuji Sugiura <6259812+leaysgur@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-22 06:42:39 +00:00
1 parent 7e9aa449c5
commit 999496e4ed
4 files changed
+73 -3

No files matched your search

@@ -0,0 +1,11 @@
// A key is short by its display width (wide characters count as 2), quoted or not.
// `古体诗` is 6 wide, so like `abcdef` it is not short, and the value moves to the next line.
x = {
古体诗: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
"古体诗": "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
[古体]: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
abcdef: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
古: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about-xxxxxx",
};
var { 古体诗: 古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned" } = x;
({ 古体诗: 古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned" } = x);
@@ -0,0 +1,58 @@
---
source: crates/oxc_formatter/tests/fixtures/mod.rs
---
==================== Input ====================
// A key is short by its display width (wide characters count as 2), quoted or not.
// `古体诗` is 6 wide, so like `abcdef` it is not short, and the value moves to the next line.
x = {
古体诗: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
"古体诗": "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
[古体]: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
abcdef: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
古: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about-xxxxxx",
};
var { 古体诗: 古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned" } = x;
({ 古体诗: 古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned" } = x);
==================== Output ====================
------------------
{ printWidth: 80 }
------------------
// A key is short by its display width (wide characters count as 2), quoted or not.
// `古体诗` is 6 wide, so like `abcdef` it is not short, and the value moves to the next line.
x = {
古体诗:
"https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
古体诗:
"https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
[古体]:
"https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
abcdef:
"https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
古: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about-xxxxxx",
};
var {
古体诗:
古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned",
} = x;
({
古体诗:
古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned",
} = x);
-------------------
{ printWidth: 100 }
-------------------
// A key is short by its display width (wide characters count as 2), quoted or not.
// `古体诗` is 6 wide, so like `abcdef` it is not short, and the value moves to the next line.
x = {
古体诗: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
古体诗: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
[古体]: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
abcdef: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about",
古: "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned-about-xxxxxx",
};
var { 古体诗: 古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned" } = x;
({ 古体诗: 古 = "https://prettier.io/docs/en/rationale.html#what-prettier-is-concerned" } = x);
===================== End =====================
@@ -92,6 +92,5 @@ js compatibility: 1033/1123 (91.99%), 23 files skipped
- js/comments/first-argument/first-argument.js
- js/for/continue-and-break-comment-without-blocks.js
- js/function/issue-12967.js
- js/object-prop-break-in/short-keys.js
- jsx/comments/in-attributes.js
- jsx/spread/child.js
+4 -2
View File
@@ -1,5 +1,7 @@
use std::ops::Deref;
use unicode_width::UnicodeWidthStr;
use oxc_span::{GetSpan, Span};
/// Source text wrapper providing mechanical byte/offset access for the formatter.
@@ -110,8 +112,8 @@ impl<'a> SourceText<'a> {
}
// Utility methods
/// Get character count of span
/// Get the display width of span, the same measure as a printed string's width (wide characters count as 2)
pub fn span_width(&self, span: Span) -> usize {
self.text_for(&span).chars().count()
self.text_for(&span).width()
}
}