fix(runtime): string </sort compare by UTF-16 code unit, not code point#6152
Conversation
…oint
ECMAScript string relational comparison (`<` `>` `<=` `>=`) and the default
`Array.prototype.sort` order compare by UTF-16 code unit. Perry compared raw
UTF-8 bytes (= code-point order), which diverges once an astral character
(code point > U+FFFF) is involved: its UTF-16 surrogate pair leads with
0xD800–0xDBFF and sorts before BMP characters in 0xE000–0xFFFF, whereas
code-point order sorts it after.
"\u{1F600}" < "。" // was false, now true (matches Node)
["。","\u{1F600}"].sort() // was [😀,。], now [。,😀]
Add `string::utf16_cmp_bytes` (decodes UTF-8 → `encode_utf16().cmp()`, with a
raw-byte fallback for WTF-8 lone surrogates) and route the three lexicographic
string-order sites through it: the `<`/`>` operator (value/equality.rs),
`js_string_compare` (string/compare.rs), and the default `sort` comparator's
two key-comparison sites (array/sort.rs). BMP-only strings (ASCII, accents,
etc.) are unaffected; `localeCompare` (locale collation) is untouched.
📝 WalkthroughWalkthroughAdds a ChangesUTF-16 Comparison Fix
Estimated code review effort: 2 (Simple) | ~10 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/perry-runtime/src/array/sort.rs (1)
162-162: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winSort keys are always valid UTF-8 — redundant
from_utf8validation on every comparison.
sort_key_stringreturns a RustString(valid UTF-8 viaunwrap_or("")), soutf16_cmp_bytesalways takes theOkbranch here. Thestd::str::from_utf8validation runs on every one of the O(n log n) comparisons but can never fail. For large arrays with long strings, this adds measurable overhead.Two options, in order of effort:
- Skip validation (low effort): compare
encode_utf16()iterators directly since the bytes are already known-valid UTF-8.- Pre-encode once (medium effort): change
pairsfromVec<(String, f64)>toVec<(Vec<u16>, f64)>, encoding each key to UTF-16 once upfront, then compare&[u16]slices — eliminates both per-comparison validation and re-encoding.Option 1: Use
encode_utf16().cmp()directly- pairs.sort_by(|a, b| crate::string::utf16_cmp_bytes(a.0.as_bytes(), b.0.as_bytes())); + pairs.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));Applied at both line 162 and line 629.
This is a nice-to-have; the shared
utf16_cmp_byteshelper is the right call for consistency in the direct comparison paths (js_string_compare,js_jsvalue_compare) where raw bytes may be WTF-8.Also applies to: 629-629
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-runtime/src/array/sort.rs` at line 162, The `pairs.sort_by` path in `sort.rs` is doing redundant UTF-8 validation through `utf16_cmp_bytes` even though `sort_key_string` always produces a valid `String`. Update the sorting logic in the affected compare path(s) to avoid calling `std::str::from_utf8` on every comparison, either by comparing `encode_utf16()` results directly or by pre-encoding the keys once before sorting. Keep `utf16_cmp_bytes` for the raw-byte comparison helpers like `js_string_compare` and `js_jsvalue_compare`, but not for the `pairs` sort path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/perry-runtime/src/array/sort.rs`:
- Line 162: The `pairs.sort_by` path in `sort.rs` is doing redundant UTF-8
validation through `utf16_cmp_bytes` even though `sort_key_string` always
produces a valid `String`. Update the sorting logic in the affected compare
path(s) to avoid calling `std::str::from_utf8` on every comparison, either by
comparing `encode_utf16()` results directly or by pre-encoding the keys once
before sorting. Keep `utf16_cmp_bytes` for the raw-byte comparison helpers like
`js_string_compare` and `js_jsvalue_compare`, but not for the `pairs` sort path.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: aecde187-f7c0-4dc7-8976-026cc2d68ad8
📒 Files selected for processing (4)
crates/perry-runtime/src/array/sort.rscrates/perry-runtime/src/string/compare.rscrates/perry-runtime/src/string/mod.rscrates/perry-runtime/src/value/equality.rs
Fixes #6151.
Summary
ECMAScript compares strings by UTF-16 code unit — for
<><=>=andthe default
Array.prototype.sortorder. Perry compared raw UTF-8 bytes(= code-point order), which diverges once an astral character (code point
An astral char's UTF-16 encoding is a surrogate pair leading with 0xD800–0xDBFF,
which sorts before BMP characters in 0xE000–0xFFFF; code-point order sorts it
after.
What it does
string::utf16_cmp_bytes(a, b)— decodes UTF-8 and compares viaencode_utf16().cmp(), falling back to raw byte order if either side is notvalid UTF-8 (WTF-8 lone surrogates, a known categorical gap).
</>/<=/>=operator (value/equality.rs)js_string_compare(string/compare.rs)sortcomparator, both key-comparison sites (array/sort.rs)BMP-only strings (ASCII, Latin accents, etc.) are unchanged — byte order and
UTF-16 order agree there.
localeCompare(locale-sensitive collation) is adifferent operation and is untouched.
Validation
Byte-for-byte vs
node --experimental-strip-types: astral</>/<=/>=,two-astral, mixed BMP+astral, surrogate-vs-high-BMP; default sort of
astral/emoji/accents/ASCII words/numbers/mixed types/empty+dupes; comparator
sort and
localeCompareunaffected.Summary by CodeRabbit