Skip to main content

submilli_engine/runtime/prelude/string/
mod.rs

1//! The submilli string and its operations — the Rust port of the prelude's
2//! `String` methods.
3//!
4//! Everything here works in UTF-16 *code-unit* space (`[u16]`), the
5//! representation `$string` stores and the one JS string semantics are defined
6//! over. Operations never decode to a Rust `String`: that would round-trip
7//! through UTF-8 (lossy on lone surrogates, and an allocation on every call).
8//! The `$string` ↔ [`Str`] marshalling and host-fn registration live in
9//! [`install`]; this file is just the strings.
10
11mod gc_text;
12mod install;
13pub(crate) mod search;
14mod transforms;
15
16pub(crate) use install::declare_types;
17pub use install::{declare, install};
18
19use unicode_normalization::UnicodeNormalization;
20
21use crate::runtime::host::fatal_host_error;
22
23/// A submilli string: its UTF-16 code units. Cheap to slice and concatenate, and
24/// faithful to JS — indices, `length`, and surrogate handling are all code-unit
25/// based.
26pub struct Str(Vec<u16>);
27
28impl Str {
29    pub fn from_units(units: Vec<u16>) -> Self {
30        Self(units)
31    }
32
33    pub fn units(&self) -> &[u16] {
34        &self.0
35    }
36
37    pub fn len(&self) -> usize {
38        self.0.len()
39    }
40
41    pub fn is_empty(&self) -> bool {
42        self.0.is_empty()
43    }
44}
45
46/// A string operation rejected its input the way JS would (a `RangeError`).
47/// [`install`] raises it as a catchable guest `RangeError`.
48#[derive(Debug)]
49pub struct RangeError(&'static str);
50
51impl RangeError {
52    pub fn message(&self) -> &'static str {
53        self.0
54    }
55}
56
57pub type Result<T> = std::result::Result<T, RangeError>;
58
59/// Caps the result of `repeat`, the pads and the case maps, whose length a
60/// small input can multiply. This language-level ceiling complements the memory cap; the result
61/// is still allocated under the GC limiter.
62const MAX_RESULT_UNITS: usize = 32 * 1024 * 1024;
63
64/// `String.prototype.repeat`. `ToInteger(count)` truncates toward zero, leaving
65/// three bands — and the guard order below encodes them, so `NaN` must fall
66/// through the negative check to the zero case:
67///   - `count <= -1` → throws (negative repeat)
68///   - `-1 < count < 1`, or `NaN` → `""` (zero repeats)
69///   - `count >= 1` → repeat, capped so an overflowing or `+Infinity` count
70///     throws instead of allocating (see [`MAX_RESULT_UNITS`]).
71///
72/// Returns the result's length in units: the receiver repeated that many times.
73pub(crate) fn repeat_length(len: usize, count: f64) -> Result<usize> {
74    let n = count.trunc();
75    if n <= -1.0 {
76        return Err(RangeError("Invalid count value"));
77    }
78    if n.is_nan() || n < 1.0 || len == 0 {
79        return Ok(0);
80    }
81    // The saturating cast makes positive infinity fail the checked bound.
82    len.checked_mul(n as usize)
83        .filter(|&total| total <= MAX_RESULT_UNITS)
84        .ok_or(RangeError("Invalid count value"))
85}
86
87/// Trunc-sat `f64`→`i32`, matching the prelude's `i32.trunc_sat_f64_s`: `NaN`→0,
88/// out-of-range saturates to `i32::MIN`/`MAX`. Rust's `as` cast is already
89/// saturating, so the index methods agree with the Wasm bodies they replace.
90fn trunc_sat_i32(x: f64) -> i32 {
91    x as i32
92}
93
94/// Rejects a builder result that would exceed [`MAX_RESULT_UNITS`], with the
95/// `RangeError` ("Invalid string length") JS throws.
96fn checked_len(n: usize) -> Result<usize> {
97    if n > MAX_RESULT_UNITS {
98        Err(RangeError("Invalid string length"))
99    } else {
100        Ok(n)
101    }
102}
103
104/// Clamp an index into `[0, len]` (negatives to 0, overshoot to `len`).
105fn clamp_to_len(x: i32, len: usize) -> usize {
106    if x < 0 { 0 } else { (x as usize).min(len) }
107}
108
109/// JS slice-style index normalization: a negative index counts from the end,
110/// then the result is clamped into `[0, len]`.
111fn normalize_slice_index(x: i32, len: usize) -> usize {
112    if x < 0 {
113        let from_end = x as i64 + len as i64;
114        if from_end < 0 {
115            0
116        } else {
117            (from_end as usize).min(len)
118        }
119    } else {
120        (x as usize).min(len)
121    }
122}
123
124/// First index `>= from` at which `needle` occurs in `haystack`. An empty
125/// `needle` matches at `min(from, len)` — JS `indexOf`/`includes` search order.
126fn raw_index_of(haystack: &[u16], needle: &[u16], from: usize) -> Option<usize> {
127    search::Search::new(needle, false).find(haystack, from.min(haystack.len()))
128}
129
130/// `charAt`: the single code unit at `index`, or `""` out of range.
131pub fn char_at(s: &Str, index: f64) -> Str {
132    let units = s.units();
133    match unit_index(units.len(), index) {
134        Some(i) => Str::from_units(vec![units[i]]),
135        None => Str::from_units(Vec::new()),
136    }
137}
138
139/// `at`: like `charAt`, but a negative `index` counts from the end. `None` on
140/// OOB — `Array#at` and `Uint8Array#at` both answer `undefined` there, and `""` is
141/// indistinguishable from a legitimate empty read.
142pub fn at(s: &Str, index: f64) -> Option<Str> {
143    let units = s.units();
144    at_index(units.len(), index).map(|i| Str::from_units(vec![units[i]]))
145}
146
147/// `charCodeAt`: the UTF-16 code unit at `index` as `f64`, or `NaN` out of range.
148pub fn char_code_at(s: &Str, index: f64) -> f64 {
149    let units = s.units();
150    unit_index(units.len(), index).map_or(f64::NAN, |i| units[i] as f64)
151}
152
153/// `slice`: negatives count from the end; empty when `start >= end` after
154/// normalization.
155pub fn slice(s: &Str, start: f64, end: f64) -> Str {
156    let units = s.units();
157    Str::from_units(units[slice_range(units.len(), start, end)].to_vec())
158}
159
160/// `substring`: negatives clamp to 0 (not from the end), and the arguments swap
161/// when `start > end`.
162pub fn substring(s: &Str, start: f64, end: f64) -> Str {
163    let units = s.units();
164    Str::from_units(units[substring_range(units.len(), start, end)].to_vec())
165}
166
167/// `indexOf`: first match at or after `fromIndex`, else `-1`.
168pub fn index_of(s: &Str, search: &Str, from: f64) -> f64 {
169    let haystack = s.units();
170    let from = clamp_to_len(trunc_sat_i32(from), haystack.len());
171    match raw_index_of(haystack, search.units(), from) {
172        Some(i) => i as f64,
173        None => -1.0,
174    }
175}
176
177/// `lastIndexOf`: last match at or before `fromIndex` (default end), else `-1`.
178pub fn last_index_of(s: &Str, search: &Str, from: f64) -> f64 {
179    let haystack = s.units();
180    let needle = search.units();
181    if needle.len() > haystack.len() {
182        return -1.0;
183    }
184    let max_start = haystack.len() - needle.len();
185    let from = clamp_to_len(trunc_sat_i32(from), max_start);
186    if needle.is_empty() {
187        return from as f64;
188    }
189    let end = from + needle.len();
190    search::Search::new(needle, true)
191        .find(&haystack[..end], 0)
192        .map_or(-1.0, |i| (end - i - needle.len()) as f64)
193}
194
195/// `includes`: whether `search` occurs at or after `fromIndex`.
196pub fn includes(s: &Str, search: &Str, from: f64) -> bool {
197    let haystack = s.units();
198    let from = clamp_to_len(trunc_sat_i32(from), haystack.len());
199    raw_index_of(haystack, search.units(), from).is_some()
200}
201
202/// `startsWith`: whether `search` sits at `position`.
203pub fn starts_with(s: &Str, search: &Str, position: f64) -> bool {
204    let haystack = s.units();
205    let needle = search.units();
206    starts_with_window(haystack.len(), needle.len(), position)
207        .is_some_and(|window| &haystack[window] == needle)
208}
209
210/// `endsWith`: whether `search` ends at `endPosition` (default end).
211pub fn ends_with(s: &Str, search: &Str, end_position: f64) -> bool {
212    let haystack = s.units();
213    let needle = search.units();
214    ends_with_window(haystack.len(), needle.len(), end_position)
215        .is_some_and(|window| &haystack[window] == needle)
216}
217
218/// The unit `charAt`/`charCodeAt` read for `index`, if it is in range.
219pub(super) fn unit_index(len: usize, index: f64) -> Option<usize> {
220    let i = trunc_sat_i32(index);
221    (i >= 0 && (i as usize) < len).then_some(i as usize)
222}
223
224/// The unit `at` reads for `index`, a negative one counting from the end.
225pub(super) fn at_index(len: usize, index: f64) -> Option<usize> {
226    let len = len as i64;
227    let mut i = trunc_sat_i32(index) as i64;
228    if i < 0 {
229        i += len;
230    }
231    (i >= 0 && i < len).then_some(i as usize)
232}
233
234/// The code point `codePointAt` answers for the unit at an index and the one
235/// after it, when a surrogate pair starts there.
236pub(super) fn code_point(hi: u16, next: Option<u16>) -> f64 {
237    match next {
238        Some(lo) if (0xD800..=0xDBFF).contains(&hi) && (0xDC00..=0xDFFF).contains(&lo) => {
239            (0x10000 + (((hi as u32) - 0xD800) << 10) + ((lo as u32) - 0xDC00)) as f64
240        }
241        _ => hi as f64,
242    }
243}
244
245/// The units `slice(start, end)` keeps.
246pub(super) fn slice_range(len: usize, start: f64, end: f64) -> std::ops::Range<usize> {
247    let si = normalize_slice_index(trunc_sat_i32(start), len);
248    let ei = normalize_slice_index(trunc_sat_i32(end), len);
249    si..ei.max(si)
250}
251
252/// The units `substring(start, end)` keeps: negatives clamp to 0 and the
253/// bounds swap when `start > end`.
254pub(super) fn substring_range(len: usize, start: f64, end: f64) -> std::ops::Range<usize> {
255    let a = clamp_to_len(trunc_sat_i32(start), len);
256    let b = clamp_to_len(trunc_sat_i32(end), len);
257    a.min(b)..a.max(b)
258}
259
260/// The window of a haystack `startsWith(needle, position)` compares, if the
261/// needle fits there.
262pub(super) fn starts_with_window(
263    len: usize,
264    needle_len: usize,
265    position: f64,
266) -> Option<std::ops::Range<usize>> {
267    let pos = clamp_to_len(trunc_sat_i32(position), len);
268    (pos + needle_len <= len).then_some(pos..pos + needle_len)
269}
270
271/// The window of a haystack `endsWith(needle, endPosition)` compares, if the
272/// needle fits there.
273pub(super) fn ends_with_window(
274    len: usize,
275    needle_len: usize,
276    end_position: f64,
277) -> Option<std::ops::Range<usize>> {
278    let end = clamp_to_len(trunc_sat_i32(end_position), len);
279    end.checked_sub(needle_len).map(|start| start..end)
280}
281
282/// Structural equality over code units — the `===`/`==` operator primitive.
283pub fn eq(a: &Str, b: &Str) -> bool {
284    a.units() == b.units()
285}
286
287/// Lexicographic compare over code units — the relational-operator primitive.
288/// Returns a sign: the first differing units' difference, else the length
289/// difference (consumers test the sign only).
290pub fn cmp(a: &Str, b: &Str) -> i32 {
291    for (&au, &bu) in a.units().iter().zip(b.units()) {
292        let diff = i32::from(au) - i32::from(bu);
293        if diff != 0 {
294            return diff;
295        }
296    }
297    a.len() as i32 - b.len() as i32
298}
299
300/// `concat`: the length of `left` followed by `right`.
301pub(crate) fn concat_length(left: usize, right: usize) -> wasmtime::Result<usize> {
302    left.checked_add(right)
303        .ok_or_else(|| fatal_host_error("string length overflow"))
304}
305
306/// `padStart` and `padEnd`: the padded length. Repetitions of `pad` fill the
307/// string up to `target_length`; it keeps its length when already long enough
308/// or when `pad` is empty.
309pub(crate) fn pad_length(len: usize, target_length: f64, pad_len: usize) -> Result<usize> {
310    let target = trunc_sat_i32(target_length);
311    if target <= 0 || target as usize <= len || pad_len == 0 {
312        return Ok(len);
313    }
314    checked_len(target as usize)
315}
316
317/// `isWellFormed`: `true` when the string contains no lone surrogate.
318pub fn is_well_formed(s: &Str) -> bool {
319    let u = s.units();
320    let mut i = 0;
321    while i < u.len() {
322        let c = u[i];
323        if (c & 0xFC00) == 0xD800 {
324            if i + 1 < u.len() && (u[i + 1] & 0xFC00) == 0xDC00 {
325                i += 2;
326            } else {
327                return false;
328            }
329        } else if (c & 0xFC00) == 0xDC00 {
330            return false;
331        } else {
332            i += 1;
333        }
334    }
335    true
336}
337
338/// `toWellFormed`: a copy with every lone surrogate replaced by U+FFFD.
339pub fn to_well_formed(s: &Str) -> Str {
340    let mut out = s.units().to_vec();
341    let len = out.len();
342    let mut i = 0;
343    while i < len {
344        let c = out[i];
345        if (c & 0xFC00) == 0xD800 {
346            if i + 1 < len && (out[i + 1] & 0xFC00) == 0xDC00 {
347                i += 2;
348            } else {
349                out[i] = 0xFFFD;
350                i += 1;
351            }
352        } else if (c & 0xFC00) == 0xDC00 {
353            out[i] = 0xFFFD;
354            i += 1;
355        } else {
356            i += 1;
357        }
358    }
359    Str::from_units(out)
360}
361
362/// `normalize`: Unicode normalization. `form` must be `"NFC"`/`"NFD"`/`"NFKC"`/
363/// `"NFKD"` (default `"NFC"`); any other value throws. A lone surrogate is
364/// kept: it neither decomposes nor composes, so the text on each side of it
365/// normalizes on its own.
366pub fn normalize(s: &Str, form: &Str) -> Result<Str> {
367    let form: fn(&str) -> String = match String::from_utf16_lossy(form.units()).as_str() {
368        "NFC" => |text| text.nfc().collect(),
369        "NFD" => |text| text.nfd().collect(),
370        "NFKC" => |text| text.nfkc().collect(),
371        "NFKD" => |text| text.nfkd().collect(),
372        _ => {
373            return Err(RangeError(
374                "String.normalize: invalid form — must be 'NFC', 'NFD', 'NFKC', or 'NFKD'",
375            ));
376        }
377    };
378    let mut out = Vec::with_capacity(s.units().len());
379    let mut run = String::new();
380    for scalar in char::decode_utf16(s.units().iter().copied()) {
381        match scalar {
382            Ok(c) => run.push(c),
383            Err(lone) => {
384                out.extend(form(&run).encode_utf16());
385                run.clear();
386                out.push(lone.unpaired_surrogate());
387            }
388        }
389    }
390    out.extend(form(&run).encode_utf16());
391    Ok(Str::from_units(out))
392}
393
394/// `String.fromCharCode(...codes)`: one UTF-16 code unit per argument, through
395/// JavaScript's `ToUint16`, so `-1` is U+FFFF.
396pub fn from_char_code(codes: &[f64]) -> Str {
397    Str::from_units(
398        codes
399            .iter()
400            .map(|&x| crate::runtime::number::to_uint16(x))
401            .collect(),
402    )
403}
404
405/// `String.fromCodePoint(...codePoints)`: astral code points encode as surrogate
406/// pairs; a negative, non-integer, or `> U+10FFFF` value throws.
407pub fn from_code_point(codes: &[f64]) -> Result<Str> {
408    let mut out = Vec::with_capacity(codes.len());
409    for &cpf in codes {
410        let cp = trunc_sat_i32(cpf);
411        if !(0..=0x10FFFF).contains(&cp) || cpf != cp as f64 {
412            return Err(RangeError("invalid code point"));
413        }
414        if cp < 0x10000 {
415            out.push(cp as u16);
416        } else {
417            let v = (cp - 0x10000) as u32;
418            out.push((0xD800 + (v >> 10)) as u16);
419            out.push((0xDC00 + (v & 0x3FF)) as u16);
420        }
421    }
422    Ok(Str::from_units(out))
423}
424
425#[cfg(test)]
426mod length_tests {
427    use super::*;
428
429    #[test]
430    fn length_validation_preserves_range_boundaries() {
431        assert_eq!(
432            repeat_length(1, MAX_RESULT_UNITS as f64).unwrap(),
433            MAX_RESULT_UNITS
434        );
435        for count in [
436            -1.0,
437            f64::NEG_INFINITY,
438            f64::INFINITY,
439            f64::MAX,
440            (MAX_RESULT_UNITS + 1) as f64,
441        ] {
442            assert!(repeat_length(1, count).is_err());
443        }
444        for count in [-0.5, 0.0, 0.5, f64::NAN] {
445            assert_eq!(repeat_length(1, count).unwrap(), 0);
446        }
447        assert_eq!(repeat_length(2, 2.9).unwrap(), 4);
448        assert_eq!(repeat_length(0, f64::INFINITY).unwrap(), 0);
449        assert!(repeat_length(usize::MAX, 2.0).is_err());
450        assert_eq!(
451            pad_length(1, MAX_RESULT_UNITS as f64, 1).unwrap(),
452            MAX_RESULT_UNITS
453        );
454        assert!(pad_length(1, (MAX_RESULT_UNITS + 1) as f64, 1).is_err());
455        assert_eq!(pad_length(2, f64::INFINITY, 0).unwrap(), 2);
456        assert_eq!(pad_length(2, f64::NAN, 1).unwrap(), 2);
457        assert_eq!(pad_length(2, -1.0, 1).unwrap(), 2);
458        assert_eq!(pad_length(2, 4.9, 1).unwrap(), 4);
459    }
460
461    #[test]
462    fn concat_length_overflow_is_fatal() {
463        assert_eq!(concat_length(2, 3).unwrap(), 5);
464        assert!(concat_length(usize::MAX, 1).is_err());
465    }
466}