Skip to main content

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}