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 use install::{declare, install};
17pub(crate) use install::{declare_types, string_ctor_call};
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 `s[index]` reads, on the terms `arr[index]` has: a non-negative
225/// integer below the length (`-0` reads unit 0). Anything else throws.
226pub(super) fn string_index_unit(len: usize, index: f64) -> Option<usize> {
227    (index >= 0.0 && index < len as f64 && index == index.floor()).then_some(index as usize)
228}
229
230/// The unit `at` reads for `index`, a negative one counting from the end.
231pub(super) fn at_index(len: usize, index: f64) -> Option<usize> {
232    let len = len as i64;
233    let mut i = trunc_sat_i32(index) as i64;
234    if i < 0 {
235        i += len;
236    }
237    (i >= 0 && i < len).then_some(i as usize)
238}
239
240/// The code point `codePointAt` answers for the unit at an index and the one
241/// after it, when a surrogate pair starts there.
242pub(super) fn code_point(hi: u16, next: Option<u16>) -> f64 {
243    match next {
244        Some(lo) if (0xD800..=0xDBFF).contains(&hi) && (0xDC00..=0xDFFF).contains(&lo) => {
245            (0x10000 + (((hi as u32) - 0xD800) << 10) + ((lo as u32) - 0xDC00)) as f64
246        }
247        _ => hi as f64,
248    }
249}
250
251/// The units `slice(start, end)` keeps.
252pub(super) fn slice_range(len: usize, start: f64, end: f64) -> std::ops::Range<usize> {
253    let si = normalize_slice_index(trunc_sat_i32(start), len);
254    let ei = normalize_slice_index(trunc_sat_i32(end), len);
255    si..ei.max(si)
256}
257
258/// The units `substr(start, length)` keeps: a negative start counts from the
259/// end, and the length clamps into what remains.
260pub(super) fn substr_range(len: usize, start: f64, length: f64) -> std::ops::Range<usize> {
261    let from = normalize_slice_index(trunc_sat_i32(start), len);
262    let count = clamp_to_len(trunc_sat_i32(length), len - from);
263    from..from + count
264}
265
266/// The units `substring(start, end)` keeps: negatives clamp to 0 and the
267/// bounds swap when `start > end`.
268pub(super) fn substring_range(len: usize, start: f64, end: f64) -> std::ops::Range<usize> {
269    let a = clamp_to_len(trunc_sat_i32(start), len);
270    let b = clamp_to_len(trunc_sat_i32(end), len);
271    a.min(b)..a.max(b)
272}
273
274/// The window of a haystack `startsWith(needle, position)` compares, if the
275/// needle fits there.
276pub(super) fn starts_with_window(
277    len: usize,
278    needle_len: usize,
279    position: f64,
280) -> Option<std::ops::Range<usize>> {
281    let pos = clamp_to_len(trunc_sat_i32(position), len);
282    (pos + needle_len <= len).then_some(pos..pos + needle_len)
283}
284
285/// The window of a haystack `endsWith(needle, endPosition)` compares, if the
286/// needle fits there.
287pub(super) fn ends_with_window(
288    len: usize,
289    needle_len: usize,
290    end_position: f64,
291) -> Option<std::ops::Range<usize>> {
292    let end = clamp_to_len(trunc_sat_i32(end_position), len);
293    end.checked_sub(needle_len).map(|start| start..end)
294}
295
296/// Structural equality over code units — the `===`/`==` operator primitive.
297pub fn eq(a: &Str, b: &Str) -> bool {
298    a.units() == b.units()
299}
300
301/// Lexicographic compare over code units — the relational-operator primitive.
302/// Returns a sign: the first differing units' difference, else the length
303/// difference (consumers test the sign only).
304pub fn cmp(a: &Str, b: &Str) -> i32 {
305    for (&au, &bu) in a.units().iter().zip(b.units()) {
306        let diff = i32::from(au) - i32::from(bu);
307        if diff != 0 {
308            return diff;
309        }
310    }
311    a.len() as i32 - b.len() as i32
312}
313
314/// `concat`: the length of `left` followed by `right`.
315pub(crate) fn concat_length(left: usize, right: usize) -> wasmtime::Result<usize> {
316    left.checked_add(right)
317        .ok_or_else(|| fatal_host_error("string length overflow"))
318}
319
320/// `padStart` and `padEnd`: the padded length. Repetitions of `pad` fill the
321/// string up to `target_length`; it keeps its length when already long enough
322/// or when `pad` is empty.
323pub(crate) fn pad_length(len: usize, target_length: f64, pad_len: usize) -> Result<usize> {
324    let target = trunc_sat_i32(target_length);
325    if target <= 0 || target as usize <= len || pad_len == 0 {
326        return Ok(len);
327    }
328    checked_len(target as usize)
329}
330
331/// `isWellFormed`: `true` when the string contains no lone surrogate.
332pub fn is_well_formed(s: &Str) -> bool {
333    let u = s.units();
334    let mut i = 0;
335    while i < u.len() {
336        let c = u[i];
337        if (c & 0xFC00) == 0xD800 {
338            if i + 1 < u.len() && (u[i + 1] & 0xFC00) == 0xDC00 {
339                i += 2;
340            } else {
341                return false;
342            }
343        } else if (c & 0xFC00) == 0xDC00 {
344            return false;
345        } else {
346            i += 1;
347        }
348    }
349    true
350}
351
352/// `toWellFormed`: a copy with every lone surrogate replaced by U+FFFD.
353pub fn to_well_formed(s: &Str) -> Str {
354    let mut out = s.units().to_vec();
355    let len = out.len();
356    let mut i = 0;
357    while i < len {
358        let c = out[i];
359        if (c & 0xFC00) == 0xD800 {
360            if i + 1 < len && (out[i + 1] & 0xFC00) == 0xDC00 {
361                i += 2;
362            } else {
363                out[i] = 0xFFFD;
364                i += 1;
365            }
366        } else if (c & 0xFC00) == 0xDC00 {
367            out[i] = 0xFFFD;
368            i += 1;
369        } else {
370            i += 1;
371        }
372    }
373    Str::from_units(out)
374}
375
376/// `normalize`: Unicode normalization. `form` must be `"NFC"`/`"NFD"`/`"NFKC"`/
377/// `"NFKD"` (default `"NFC"`); any other value throws. A lone surrogate is
378/// kept: it neither decomposes nor composes, so the text on each side of it
379/// normalizes on its own.
380pub fn normalize(s: &Str, form: &Str) -> Result<Str> {
381    let form: fn(&str) -> String = match String::from_utf16_lossy(form.units()).as_str() {
382        "NFC" => |text| text.nfc().collect(),
383        "NFD" => |text| text.nfd().collect(),
384        "NFKC" => |text| text.nfkc().collect(),
385        "NFKD" => |text| text.nfkd().collect(),
386        _ => {
387            return Err(RangeError(
388                "String.normalize: invalid form — must be 'NFC', 'NFD', 'NFKC', or 'NFKD'",
389            ));
390        }
391    };
392    let mut out = Vec::with_capacity(s.units().len());
393    let mut run = String::new();
394    for scalar in char::decode_utf16(s.units().iter().copied()) {
395        match scalar {
396            Ok(c) => run.push(c),
397            Err(lone) => {
398                out.extend(form(&run).encode_utf16());
399                run.clear();
400                out.push(lone.unpaired_surrogate());
401            }
402        }
403    }
404    out.extend(form(&run).encode_utf16());
405    Ok(Str::from_units(out))
406}
407
408/// `String.fromCharCode(...codes)`: one UTF-16 code unit per argument, through
409/// JavaScript's `ToUint16`, so `-1` is U+FFFF.
410pub fn from_char_code(codes: &[f64]) -> Str {
411    Str::from_units(
412        codes
413            .iter()
414            .map(|&x| crate::runtime::number::to_uint16(x))
415            .collect(),
416    )
417}
418
419/// `String.fromCodePoint(...codePoints)`: astral code points encode as surrogate
420/// pairs; a negative, non-integer, or `> U+10FFFF` value throws.
421pub fn from_code_point(codes: &[f64]) -> Result<Str> {
422    let mut out = Vec::with_capacity(codes.len());
423    for &cpf in codes {
424        let cp = trunc_sat_i32(cpf);
425        if !(0..=0x10FFFF).contains(&cp) || cpf != cp as f64 {
426            return Err(RangeError("invalid code point"));
427        }
428        if cp < 0x10000 {
429            out.push(cp as u16);
430        } else {
431            let v = (cp - 0x10000) as u32;
432            out.push((0xD800 + (v >> 10)) as u16);
433            out.push((0xDC00 + (v & 0x3FF)) as u16);
434        }
435    }
436    Ok(Str::from_units(out))
437}
438
439#[cfg(test)]
440mod length_tests {
441    use super::*;
442
443    #[test]
444    fn length_validation_preserves_range_boundaries() {
445        assert_eq!(
446            repeat_length(1, MAX_RESULT_UNITS as f64).unwrap(),
447            MAX_RESULT_UNITS
448        );
449        for count in [
450            -1.0,
451            f64::NEG_INFINITY,
452            f64::INFINITY,
453            f64::MAX,
454            (MAX_RESULT_UNITS + 1) as f64,
455        ] {
456            assert!(repeat_length(1, count).is_err());
457        }
458        for count in [-0.5, 0.0, 0.5, f64::NAN] {
459            assert_eq!(repeat_length(1, count).unwrap(), 0);
460        }
461        assert_eq!(repeat_length(2, 2.9).unwrap(), 4);
462        assert_eq!(repeat_length(0, f64::INFINITY).unwrap(), 0);
463        assert!(repeat_length(usize::MAX, 2.0).is_err());
464        assert_eq!(
465            pad_length(1, MAX_RESULT_UNITS as f64, 1).unwrap(),
466            MAX_RESULT_UNITS
467        );
468        assert!(pad_length(1, (MAX_RESULT_UNITS + 1) as f64, 1).is_err());
469        assert_eq!(pad_length(2, f64::INFINITY, 0).unwrap(), 2);
470        assert_eq!(pad_length(2, f64::NAN, 1).unwrap(), 2);
471        assert_eq!(pad_length(2, -1.0, 1).unwrap(), 2);
472        assert_eq!(pad_length(2, 4.9, 1).unwrap(), 4);
473    }
474
475    #[test]
476    fn concat_length_overflow_is_fatal() {
477        assert_eq!(concat_length(2, 3).unwrap(), 5);
478        assert!(concat_length(usize::MAX, 1).is_err());
479    }
480}