Skip to main content

submilli_engine/runtime/prelude/string/
transforms.rs

1//! UTF-16 case mapping and ECMAScript whitespace trimming.
2use std::sync::OnceLock;
3
4use regex_syntax::hir::{Class, ClassUnicode, HirKind};
5
6use super::{MAX_RESULT_UNITS, Str};
7use crate::runtime::host::{fatal_host_error, range_error};
8
9pub fn to_upper_case(input: &Str) -> wasmtime::Result<Str> {
10    let mut output = output_buffer(input.len().saturating_mul(3).min(MAX_RESULT_UNITS))?;
11    for decoded in char::decode_utf16(input.units().iter().copied()) {
12        match decoded {
13            Ok(ch) => {
14                for mapped in ch.to_uppercase() {
15                    push_char(&mut output, mapped)?;
16                }
17            }
18            Err(surrogate) => push_units(&mut output, &[surrogate.unpaired_surrogate()])?,
19        }
20    }
21    Ok(Str::from_units(output))
22}
23
24pub fn to_lower_case(input: &Str) -> wasmtime::Result<Str> {
25    let properties = case_properties()?;
26    let mut output = output_buffer(input.len().saturating_mul(3).min(MAX_RESULT_UNITS))?;
27    let mut preceded_by_cased = false;
28    let mut decoded = char::decode_utf16(input.units().iter().copied());
29    while let Some(unit) = decoded.next() {
30        let ch = match unit {
31            Ok(ch) => ch,
32            Err(surrogate) => {
33                push_units(&mut output, &[surrogate.unpaired_surrogate()])?;
34                preceded_by_cased = false;
35                continue;
36            }
37        };
38        if ch == 'Σ' && preceded_by_cased && !followed_by_cased(decoded.clone(), properties) {
39            push_units(&mut output, &['ς' as u16])?;
40        } else {
41            for mapped in ch.to_lowercase() {
42                push_char(&mut output, mapped)?;
43            }
44        }
45        // Case_Ignorable takes precedence when a character has both properties.
46        if !contains(&properties.ignorable, ch) {
47            preceded_by_cased = contains(&properties.cased, ch);
48        }
49    }
50    Ok(Str::from_units(output))
51}
52
53fn followed_by_cased(
54    following: impl Iterator<Item = std::result::Result<char, std::char::DecodeUtf16Error>>,
55    properties: &CaseProperties,
56) -> bool {
57    // Lookahead crosses only ignorable runs; distinct sigmas cannot rescan
58    // the same run, so all lookaheads together visit at most the input length.
59    for decoded in following {
60        let Ok(ch) = decoded else {
61            return false;
62        };
63        if !contains(&properties.ignorable, ch) {
64            return contains(&properties.cased, ch);
65        }
66    }
67    false
68}
69
70fn push_char(output: &mut Vec<u16>, ch: char) -> wasmtime::Result<()> {
71    let mut units = [0_u16; 2];
72    push_units(output, ch.encode_utf16(&mut units))
73}
74
75fn push_units(output: &mut Vec<u16>, units: &[u16]) -> wasmtime::Result<()> {
76    if units.len() > output.capacity().saturating_sub(output.len()) {
77        return Err(range_error("Invalid string length"));
78    }
79    output.extend_from_slice(units);
80    Ok(())
81}
82
83struct CaseProperties {
84    cased: ClassUnicode,
85    ignorable: ClassUnicode,
86}
87
88pub(super) fn prepare_case_properties() -> wasmtime::Result<()> {
89    case_properties().map(|_| ())
90}
91
92fn case_properties() -> wasmtime::Result<&'static CaseProperties> {
93    static PROPERTIES: OnceLock<std::result::Result<CaseProperties, &'static str>> =
94        OnceLock::new();
95    PROPERTIES
96        .get_or_init(|| {
97            Ok(CaseProperties {
98                cased: property_class(r"\p{Cased}")?,
99                ignorable: property_class(r"\p{Case_Ignorable}")?,
100            })
101        })
102        .as_ref()
103        .map_err(|message| fatal_host_error(*message))
104}
105
106fn property_class(pattern: &str) -> std::result::Result<ClassUnicode, &'static str> {
107    let hir = regex_syntax::Parser::new()
108        .parse(pattern)
109        .map_err(|_| "could not parse built-in Unicode case property")?;
110    match hir.into_kind() {
111        HirKind::Class(Class::Unicode(class)) => Ok(class),
112        _ => Err("built-in Unicode case property is not a character class"),
113    }
114}
115
116fn contains(class: &ClassUnicode, ch: char) -> bool {
117    let ranges = class.ranges();
118    let index = ranges.partition_point(|range| range.end() < ch);
119    ranges.get(index).is_some_and(|range| range.start() <= ch)
120}
121
122pub fn trim(input: &Str) -> wasmtime::Result<Str> {
123    trim_boundaries(input, true, true)
124}
125
126pub fn trim_start(input: &Str) -> wasmtime::Result<Str> {
127    trim_boundaries(input, true, false)
128}
129
130pub fn trim_end(input: &Str) -> wasmtime::Result<Str> {
131    trim_boundaries(input, false, true)
132}
133
134fn trim_boundaries(input: &Str, leading: bool, trailing: bool) -> wasmtime::Result<Str> {
135    let units = input.units();
136    let start = if leading {
137        units
138            .iter()
139            .position(|unit| !whitespace(*unit))
140            .unwrap_or(units.len())
141    } else {
142        0
143    };
144    let end = if trailing {
145        units
146            .get(start..)
147            .and_then(|rest| rest.iter().rposition(|unit| !whitespace(*unit)))
148            .map_or(start, |index| start + index + 1)
149    } else {
150        units.len()
151    };
152    let retained = units
153        .get(start..end)
154        .ok_or_else(|| fatal_host_error("invalid trim boundaries"))?;
155    let mut output = output_buffer(retained.len())?;
156    output.extend_from_slice(retained);
157    Ok(Str::from_units(output))
158}
159
160fn whitespace(unit: u16) -> bool {
161    matches!(unit, 0x0009..=0x000D | 0x0020 | 0x00A0 | 0x1680 | 0x2000..=0x200A | 0x2028 | 0x2029 | 0x202F | 0x205F | 0x3000 | 0xFEFF)
162}
163
164fn output_buffer(capacity: usize) -> wasmtime::Result<Vec<u16>> {
165    if capacity > MAX_RESULT_UNITS {
166        return Err(range_error("Invalid string length"));
167    }
168    let mut output = Vec::new();
169    output
170        .try_reserve_exact(capacity)
171        .map_err(fatal_host_error)?;
172    Ok(output)
173}