nodejs/utf16.rs
1//! The UTF-8 ⇄ UTF-16 boundary for JS string indices.
2//!
3//! A JS `String` is a sequence of UTF-16 code units, and *every* index-bearing
4//! `String.prototype` operation counts in those units: `length`, `charAt`,
5//! `charCodeAt`, `codePointAt`, `at`, `indexOf`/`lastIndexOf`, `slice`,
6//! `substring`, `substr`, `split`, `padStart`/`padEnd`, `s[i]`, and a RegExp's
7//! `.index`/`lastIndex`.
8//!
9//! Indices are not the only place the unit sequence is observable. Relational
10//! comparison (`< <= > >=`, 7.2.13 IsLessThan) and the default `Array.prototype
11//! .sort` comparator order strings by *code unit* too, which is a different
12//! order than Rust's `str: Ord` (code point / UTF-8 byte order) for any pair
13//! that straddles `U+E000`: a surrogate is `0xD800..0xE000`, so every astral
14//! character sorts BELOW every BMP character from `U+E000` up. See [`cmp_units`].
15//!
16//! node-js stores a JS string as a Rust `String` (UTF-8): `fusevm::Value::Str`
17//! and the host heap's `JsObj::Str` are both `String`, and `fusevm` is a pinned
18//! external dependency, so the storage type is not ours to change. Every
19//! JS-visible index therefore has to be translated, and this module is the one
20//! place that translation happens. Indices are code-unit counts (`U16Index`);
21//! Rust's own string offsets are byte counts; the two are only equal on ASCII,
22//! and the newtype exists so a function that has both in scope cannot silently
23//! pass one where the other belongs.
24//!
25//! # The lone-surrogate boundary
26//!
27//! Rust `String` cannot hold an unpaired surrogate — `char` excludes
28//! `U+D800..=U+DFFF` — so an operation that *cuts a surrogate pair in half*
29//! cannot reproduce node's result exactly. `"𝒳".charAt(0)` is the lone
30//! surrogate `\ud835` in node; here it is `U+FFFD`. This is deliberately the
31//! narrowest possible gap:
32//!
33//! * Index *arithmetic* is exact — a cut at a surrogate boundary still happens
34//! at the right place, still yields a 1-unit string, and every surrounding
35//! index still lines up. `"𝒳".length` is `2` and `"𝒳".charCodeAt(0)` is
36//! `55349`, read from the intact original.
37//! * Printing is byte-identical: node itself writes `ef bf bd` (U+FFFD) when a
38//! lone surrogate reaches stdout, verified with
39//! `node -e 'process.stdout.write("𝒳".charAt(0))' | xxd`.
40//! * Only *re-inspecting an extracted half* differs — `"𝒳".charAt(0).charCodeAt(0)`
41//! (65533 here, 55349 in node), `JSON.stringify("𝒳".charAt(0))`, and
42//! re-joining two halves back into the original astral character.
43//!
44//! Closing that last gap means replacing `String` with a WTF-8 buffer
45//! throughout `fusevm` and all 47 stdlib modules, which the pinned dependency
46//! forbids.
47
48/// An index into a JS string, counted in UTF-16 code units.
49///
50/// Distinct from a Rust byte offset on purpose: the regex path holds both at
51/// once (a match's byte offsets, a `lastIndex` in code units) and mixing them
52/// is the exact bug this module exists to prevent.
53#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
54pub struct U16Index(usize);
55
56impl U16Index {
57 pub const ZERO: U16Index = U16Index(0);
58
59 pub fn new(i: usize) -> Self {
60 U16Index(i)
61 }
62
63 pub fn get(self) -> usize {
64 self.0
65 }
66}
67
68/// The length of `s` in UTF-16 code units — the value of `s.length` in JS.
69pub fn len(s: &str) -> usize {
70 s.chars().map(char::len_utf16).sum()
71}
72
73/// A JS string decoded into its UTF-16 code units, so that index arithmetic can
74/// be done directly on the units JS counts.
75pub struct Units(Vec<u16>);
76
77impl Units {
78 pub fn of(s: &str) -> Self {
79 Units(s.encode_utf16().collect())
80 }
81
82 pub fn len(&self) -> usize {
83 self.0.len()
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.0.is_empty()
88 }
89
90 pub fn as_slice(&self) -> &[u16] {
91 &self.0
92 }
93
94 /// The code unit at `i` — the value `charCodeAt(i)` reports.
95 pub fn unit(&self, i: usize) -> Option<u16> {
96 self.0.get(i).copied()
97 }
98
99 /// The code *point* starting at `i`: a full astral scalar when `i` is the
100 /// leading half of a surrogate pair, otherwise the bare unit. This is
101 /// `codePointAt`, which — unlike `charCodeAt` — looks ahead one unit.
102 pub fn code_point(&self, i: usize) -> Option<u32> {
103 let hi = self.unit(i)? as u32;
104 if (0xD800..0xDC00).contains(&hi) {
105 if let Some(lo) = self.unit(i + 1).map(u32::from) {
106 if (0xDC00..0xE000).contains(&lo) {
107 return Some(0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00));
108 }
109 }
110 }
111 Some(hi)
112 }
113
114 /// The substring `[lo, hi)` in code units. An index that splits a surrogate
115 /// pair yields `U+FFFD` for the orphaned half — see the module docs.
116 pub fn slice(&self, lo: usize, hi: usize) -> String {
117 let lo = lo.min(self.0.len());
118 let hi = hi.clamp(lo, self.0.len());
119 to_string_lossy(&self.0[lo..hi])
120 }
121
122 /// The single code unit at `i` as a string — `charAt(i)` / `s[i]`.
123 pub fn unit_str(&self, i: usize) -> Option<String> {
124 self.unit(i).map(|u| to_string_lossy(&[u]))
125 }
126}
127
128/// Decode UTF-16 code units back to a Rust `String`, mapping any unpaired
129/// surrogate to `U+FFFD` — the same replacement node performs when a lone
130/// surrogate is written to stdout.
131pub fn to_string_lossy(units: &[u16]) -> String {
132 String::from_utf16_lossy(units)
133}
134
135/// `ToUint16(n)` — the modulo-2^16 wrap `String.fromCharCode` applies to each
136/// argument, so `fromCharCode(0x1D4B3)` produces U+D4B3 and not U+1D4B3.
137pub fn to_uint16(n: f64) -> u16 {
138 if !n.is_finite() {
139 return 0;
140 }
141 (n.trunc().rem_euclid(65536.0)) as u16
142}
143
144/// Lexicographic order over UTF-16 code units — the order JS's `<`/`<=`/`>`/`>=`
145/// and the default `sort` comparator use (7.2.13 IsLessThan step 3.d compares
146/// "the code unit at index k").
147///
148/// Rust's `str: Ord` compares UTF-8 bytes, which is code-point order. The two
149/// disagree exactly when one string reaches an astral character where the other
150/// has a BMP character at or above `U+E000`: `"\u{1D4B3}" < "\u{FFFF}"` is
151/// `true` in JS (leading surrogate `0xD835` < `0xFFFF`) and `false` by code
152/// point (`0x1D4B3` > `0xFFFF`).
153///
154/// Decoding is lazy per unit, so the common all-BMP case never allocates.
155pub fn cmp_units(a: &str, b: &str) -> std::cmp::Ordering {
156 a.encode_utf16().cmp(b.encode_utf16())
157}
158
159/// The UTF-16 index corresponding to a UTF-8 *byte* offset into `s`.
160pub fn index_of_byte(s: &str, byte: usize) -> U16Index {
161 let byte = byte.min(s.len());
162 // Round a non-boundary byte down so the slice below is always valid.
163 let mut b = byte;
164 while b > 0 && !s.is_char_boundary(b) {
165 b -= 1;
166 }
167 U16Index(len(&s[..b]))
168}
169
170/// The UTF-8 byte offset corresponding to a UTF-16 index into `s`. An index
171/// that falls *inside* a surrogate pair rounds down to the start of that code
172/// point, so the result is always a valid `str` boundary.
173pub fn byte_of_index(s: &str, idx: U16Index) -> usize {
174 let target = idx.get();
175 let mut units = 0usize;
176 for (b, c) in s.char_indices() {
177 if units + c.len_utf16() > target {
178 return b;
179 }
180 units += c.len_utf16();
181 }
182 s.len()
183}
184
185/// ECMA-262 `WhiteSpace` (11.2) + `LineTerminator` (11.3): the exact character
186/// set `String.prototype.trim`, `ToNumber(string)`, `parseInt` and `parseFloat`
187/// skip.
188///
189/// Written out rather than delegated to `char::is_whitespace`, which follows the
190/// Unicode `White_Space` property. The two sets are NOT the same and they differ
191/// in BOTH directions:
192///
193/// * `U+FEFF` (ZWNBSP/BOM) is JS whitespace and is not Unicode `White_Space`, so
194/// `"\u{FEFF} x".trim()` kept the BOM and `Number("\u{FEFF}1")` was `NaN`.
195/// * `U+0085` (NEL) is Unicode `White_Space` and is NOT JS whitespace, so
196/// `"\u{85}x".trim()` stripped a character node keeps.
197///
198/// `U+180E` is in neither set (Unicode 6.3 dropped it from `White_Space`,
199/// ES2016 dropped it from `WhiteSpace`), which both engines already agreed on.
200pub fn is_js_whitespace(c: char) -> bool {
201 matches!(
202 c,
203 // WhiteSpace: TAB, VT, FF, SP, NBSP, ZWNBSP
204 '\u{9}' | '\u{B}' | '\u{C}' | '\u{20}' | '\u{A0}' | '\u{FEFF}'
205 // WhiteSpace: the rest of general category Zs
206 | '\u{1680}' | '\u{2000}'
207 ..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}'
208 // LineTerminator: LF, CR, LS, PS
209 | '\u{A}' | '\u{D}' | '\u{2028}' | '\u{2029}'
210 )
211}
212
213/// `s` with leading and trailing JS whitespace removed.
214pub fn js_trim(s: &str) -> &str {
215 s.trim_matches(is_js_whitespace)
216}
217
218/// `s` with leading JS whitespace removed.
219pub fn js_trim_start(s: &str) -> &str {
220 s.trim_start_matches(is_js_whitespace)
221}
222
223/// `s` with trailing JS whitespace removed.
224pub fn js_trim_end(s: &str) -> &str {
225 s.trim_end_matches(is_js_whitespace)
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 /// The measured node values for `"𝒳"` (U+1D4B3, one code point, two units)
233 /// and `"ab𝒳cd"`, from `node v26.7.0`.
234 #[test]
235 fn astral_lengths_and_units() {
236 assert_eq!(len("𝒳"), 2);
237 assert_eq!(len("ab𝒳cd"), 6);
238 assert_eq!(len("😀🎉"), 4);
239 assert_eq!(len("abc"), 3);
240
241 let u = Units::of("𝒳");
242 assert_eq!(u.len(), 2);
243 assert_eq!(u.unit(0), Some(55349));
244 assert_eq!(u.unit(1), Some(56499));
245 // codePointAt looks ahead; charCodeAt does not.
246 assert_eq!(u.code_point(0), Some(119987));
247 assert_eq!(u.code_point(1), Some(56499));
248 assert_eq!(u.unit(2), None);
249 }
250
251 #[test]
252 fn slicing_a_pair_in_half_keeps_the_unit_count() {
253 let u = Units::of("𝒳");
254 // node yields a lone surrogate here; we yield U+FFFD, which is still
255 // exactly one code unit, so every downstream index still lines up.
256 assert_eq!(len(&u.slice(0, 1)), 1);
257 assert_eq!(len(&u.slice(1, 2)), 1);
258 assert_eq!(u.slice(0, 2), "𝒳");
259 assert_eq!(u.slice(3, 9), "");
260 }
261
262 /// A code-unit index and a byte offset must round-trip through each other.
263 #[test]
264 fn byte_and_index_round_trip() {
265 let s = "ab𝒳cd";
266 // 'c' is at byte 6 and at UTF-16 index 4 (node: "ab𝒳cd".indexOf("c") === 4).
267 assert_eq!(index_of_byte(s, 6), U16Index::new(4));
268 assert_eq!(byte_of_index(s, U16Index::new(4)), 6);
269 assert_eq!(index_of_byte(s, 0), U16Index::ZERO);
270 assert_eq!(byte_of_index(s, U16Index::new(0)), 0);
271 assert_eq!(byte_of_index(s, U16Index::new(99)), s.len());
272 // Index 3 splits the surrogate pair: round down to the pair's start.
273 assert_eq!(byte_of_index(s, U16Index::new(3)), 2);
274 }
275
276 /// Code-unit order, not code-point order. Measured on node v26.7.0:
277 /// `["","\u{1D4B3}","","a"].sort()` → `["a","𝒳","",""]`.
278 #[test]
279 fn relational_order_is_by_code_unit() {
280 use std::cmp::Ordering;
281 // The pair that separates the two orders: an astral char vs a high BMP
282 // char. Rust's own `str` comparison gets this backwards.
283 assert_eq!(cmp_units("𝒳", "\u{FFFF}"), Ordering::Less);
284 assert_eq!("𝒳".cmp("\u{FFFF}"), Ordering::Greater);
285 assert_eq!(cmp_units("𝒳", "\u{E000}"), Ordering::Less);
286 assert_eq!(cmp_units("\u{10FFFF}", "\u{E000}"), Ordering::Less);
287 // Below U+E000 the two orders agree, and equality/prefixes are ordinary.
288 assert_eq!(cmp_units("a", "b"), Ordering::Less);
289 // node: `"café" < "cafz"` is false, `"café" < "cagz"` is true — 'é' is
290 // U+00E9, above 'z', so the tie breaks on the fourth unit either way.
291 assert_eq!(cmp_units("café", "cafz"), Ordering::Greater);
292 assert_eq!(cmp_units("café", "cagz"), Ordering::Less);
293 assert_eq!(cmp_units("ab", "ab"), Ordering::Equal);
294 assert_eq!(cmp_units("ab", "abc"), Ordering::Less);
295 assert_eq!(cmp_units("", "a"), Ordering::Less);
296 }
297
298 #[test]
299 fn index_of_byte_tolerates_a_non_boundary_offset() {
300 let s = "ab𝒳cd";
301 // Bytes 3..5 are continuation bytes of the astral char; all round down
302 // to the char start, which is UTF-16 index 2.
303 for b in 2..=5 {
304 assert_eq!(index_of_byte(s, b), U16Index::new(2), "byte {b}");
305 }
306 }
307}