Skip to main content

bamts_bytecode/
string.rs

1use std::error::Error;
2use std::fmt;
3use std::fmt::Write as _;
4use std::ops::Range;
5use std::sync::Arc;
6
7/// An immutable ECMAScript string represented exactly as UTF-16 code units.
8///
9/// Unlike Rust's [`str`], this type preserves every `u16` sequence, including
10/// unpaired surrogate code units. Converting to UTF-8 is therefore explicit.
11#[derive(Clone, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
12pub struct EcmaString(Arc<[u16]>);
13
14impl EcmaString {
15    /// Encodes a well-formed UTF-8 string as ECMAScript UTF-16 code units.
16    #[must_use]
17    pub fn from_utf8(value: &str) -> Self {
18        Self(Arc::from(value.encode_utf16().collect::<Vec<_>>()))
19    }
20
21    /// Copies exact UTF-16 code units, including unpaired surrogates.
22    #[must_use]
23    pub fn from_units(units: &[u16]) -> Self {
24        Self(Arc::from(units))
25    }
26
27    /// Decodes little-endian UTF-16 code units from a checked wire slice.
28    #[must_use]
29    pub(crate) fn from_le_bytes(bytes: &[u8]) -> Self {
30        debug_assert!(bytes.len().is_multiple_of(2));
31        Self(
32            bytes
33                .chunks_exact(2)
34                .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
35                .collect::<Arc<[u16]>>(),
36        )
37    }
38
39    /// Returns the exact UTF-16 code units.
40    #[must_use]
41    pub fn as_units(&self) -> &[u16] {
42        &self.0
43    }
44
45    /// Returns the number of UTF-16 code units.
46    #[must_use]
47    pub fn len_units(&self) -> usize {
48        self.0.len()
49    }
50
51    /// Returns whether this string has no UTF-16 code units.
52    #[must_use]
53    pub fn is_empty(&self) -> bool {
54        self.0.is_empty()
55    }
56
57    /// Returns a code unit at `offset`, if present.
58    #[must_use]
59    pub fn unit_at(&self, offset: usize) -> Option<u16> {
60        self.0.get(offset).copied()
61    }
62
63    /// Returns a copy of the requested code-unit range.
64    ///
65    /// This deliberately permits ranges that split a surrogate pair, matching
66    /// ECMAScript's code-unit indexing semantics.
67    #[must_use]
68    pub fn slice_units(&self, range: Range<usize>) -> Self {
69        Self::from_units(&self.0[range])
70    }
71
72    /// Returns whether every surrogate code unit is part of a valid pair.
73    #[must_use]
74    pub fn is_well_formed(&self) -> bool {
75        self.first_ill_formed_offset().is_none()
76    }
77
78    /// Compares with an ASCII string without allocating.
79    #[must_use]
80    pub fn eq_ascii(&self, value: &str) -> bool {
81        value.is_ascii()
82            && self.0.len() == value.len()
83            && self
84                .0
85                .iter()
86                .zip(value.bytes())
87                .all(|(&unit, byte)| unit == u16::from(byte))
88    }
89
90    /// Iterates decoded code points with their UTF-16 code-unit offsets.
91    ///
92    /// Valid surrogate pairs yield one supplementary code point. Unpaired
93    /// surrogates yield their raw code-unit values.
94    pub fn code_points(&self) -> impl Iterator<Item = (usize, u32)> + '_ {
95        let mut offset = 0;
96        std::iter::from_fn(move || {
97            let unit = *self.0.get(offset)?;
98            let current_offset = offset;
99            offset += 1;
100            if is_high_surrogate(unit)
101                && let Some(&low) = self.0.get(offset)
102                && is_low_surrogate(low)
103            {
104                offset += 1;
105                Some((
106                    current_offset,
107                    0x1_0000 + ((u32::from(unit) - 0xD800) << 10) + (u32::from(low) - 0xDC00),
108                ))
109            } else {
110                Some((current_offset, u32::from(unit)))
111            }
112        })
113    }
114
115    /// Converts to UTF-8, rejecting the first unpaired surrogate.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`IllFormedUtf16`] with the offset of the first unpaired
120    /// surrogate code unit.
121    pub fn to_utf8_strict(&self) -> Result<String, IllFormedUtf16> {
122        if let Some(unit_offset) = self.first_ill_formed_offset() {
123            return Err(IllFormedUtf16 { unit_offset });
124        }
125        String::from_utf16(&self.0).map_err(|_| unreachable!("UTF-16 was validated"))
126    }
127
128    /// Converts to UTF-8, replacing each unpaired surrogate with U+FFFD.
129    #[must_use]
130    pub fn to_utf8_lossy(&self) -> String {
131        String::from_utf16_lossy(&self.0)
132    }
133
134    fn first_ill_formed_offset(&self) -> Option<usize> {
135        let mut offset = 0;
136        while let Some(&unit) = self.0.get(offset) {
137            if is_high_surrogate(unit) {
138                if self
139                    .0
140                    .get(offset + 1)
141                    .is_some_and(|&next| is_low_surrogate(next))
142                {
143                    offset += 2;
144                } else {
145                    return Some(offset);
146                }
147            } else if is_low_surrogate(unit) {
148                return Some(offset);
149            } else {
150                offset += 1;
151            }
152        }
153        None
154    }
155}
156
157impl fmt::Debug for EcmaString {
158    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
159        formatter.write_str("EcmaString(\"")?;
160        for (_, code_point) in self.code_points() {
161            if let Some(character) = char::from_u32(code_point) {
162                for escaped in character.escape_debug() {
163                    formatter.write_char(escaped)?;
164                }
165            } else {
166                write!(formatter, "\\u{code_point:04X}")?;
167            }
168        }
169        formatter.write_str("\")")
170    }
171}
172
173/// The first unpaired surrogate encountered while validating UTF-16.
174#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
175pub struct IllFormedUtf16 {
176    /// Offset, in UTF-16 code units, of the unpaired surrogate.
177    pub unit_offset: usize,
178}
179
180impl fmt::Display for IllFormedUtf16 {
181    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
182        write!(
183            formatter,
184            "ill-formed UTF-16: unpaired surrogate at code-unit offset {}",
185            self.unit_offset
186        )
187    }
188}
189
190impl Error for IllFormedUtf16 {}
191
192/// A code point outside the Unicode scalar-value range.
193#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
194pub struct InvalidCodePoint {
195    /// The rejected code point.
196    pub value: u32,
197}
198
199impl fmt::Display for InvalidCodePoint {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        write!(formatter, "invalid Unicode code point U+{:X}", self.value)
202    }
203}
204
205impl Error for InvalidCodePoint {}
206
207/// The owned accumulation path for exact ECMAScript strings.
208#[derive(Default)]
209pub struct EcmaStringBuilder(Vec<u16>);
210
211impl EcmaStringBuilder {
212    /// Creates an empty builder.
213    #[must_use]
214    pub const fn new() -> Self {
215        Self(Vec::new())
216    }
217
218    /// Creates an empty builder with room for `capacity` code units.
219    #[must_use]
220    pub fn with_capacity(capacity: usize) -> Self {
221        Self(Vec::with_capacity(capacity))
222    }
223
224    /// Appends one exact UTF-16 code unit.
225    pub fn push_unit(&mut self, unit: u16) {
226        self.0.push(unit);
227    }
228
229    /// Encodes and appends a UTF-8 string as UTF-16 code units.
230    pub fn push_utf8(&mut self, value: &str) {
231        self.0.extend(value.encode_utf16());
232    }
233
234    /// Appends one Unicode code point as UTF-16 code units.
235    ///
236    /// Values in the BMP, including surrogate values, append one code unit;
237    /// supplementary scalar values append a surrogate pair.
238    ///
239    /// # Errors
240    ///
241    /// Returns [`InvalidCodePoint`] for values greater than U+10FFFF rather
242    /// than truncating them to a `u16`.
243    pub fn push_code_point(&mut self, code_point: u32) -> Result<(), InvalidCodePoint> {
244        if code_point > 0x10_FFFF {
245            return Err(InvalidCodePoint { value: code_point });
246        }
247        if code_point <= 0xFFFF {
248            self.0.push(code_point as u16);
249        } else {
250            let supplementary = code_point - 0x1_0000;
251            self.0.push(0xD800 | ((supplementary >> 10) as u16));
252            self.0.push(0xDC00 | ((supplementary as u16) & 0x03FF));
253        }
254        Ok(())
255    }
256
257    /// Returns the number of accumulated UTF-16 code units.
258    #[must_use]
259    pub fn len_units(&self) -> usize {
260        self.0.len()
261    }
262
263    /// Finishes this builder into an immutable string.
264    #[must_use]
265    pub fn finish(self) -> EcmaString {
266        EcmaString(Arc::from(self.0))
267    }
268}
269
270const fn is_high_surrogate(unit: u16) -> bool {
271    unit >= 0xD800 && unit <= 0xDBFF
272}
273
274const fn is_low_surrogate(unit: u16) -> bool {
275    unit >= 0xDC00 && unit <= 0xDFFF
276}
277
278#[cfg(test)]
279mod tests {
280    use super::{EcmaString, EcmaStringBuilder};
281    use std::collections::hash_map::DefaultHasher;
282    use std::hash::{Hash, Hasher};
283
284    #[test]
285    fn little_endian_wire_units_are_exact() {
286        let string = EcmaString::from_le_bytes(&[0x61, 0, 0, 0xD8, 0, 0xDC]);
287        assert_eq!(string.as_units(), &[0x0061, 0xD800, 0xDC00]);
288    }
289
290    #[test]
291    fn lexical_order_is_by_code_units() {
292        let high = EcmaString::from_units(&[0xD800]);
293        let low = EcmaString::from_units(&[0xDFFF]);
294        let supplementary = EcmaString::from_units(&[0xD800, 0xDC00]);
295
296        assert!(high < low);
297        assert!(high < supplementary);
298        assert!(supplementary < low);
299    }
300
301    #[test]
302    fn cloned_strings_are_equal_and_hash_equally() {
303        let original = EcmaString::from_units(&[0x61, 0xD800]);
304        let clone = original.clone();
305        let mut original_hasher = DefaultHasher::new();
306        let mut clone_hasher = DefaultHasher::new();
307        original.hash(&mut original_hasher);
308        clone.hash(&mut clone_hasher);
309
310        assert_eq!(original, clone);
311        assert_eq!(original_hasher.finish(), clone_hasher.finish());
312    }
313
314    #[test]
315    fn strict_utf8_reports_the_first_unpaired_surrogate() {
316        let string = EcmaString::from_units(&[0xD800, 0x61, 0xDC00]);
317
318        assert_eq!(string.to_utf8_strict().unwrap_err().unit_offset, 0);
319        assert_eq!(
320            EcmaString::from_units(&[0x61, 0xDC00])
321                .to_utf8_strict()
322                .unwrap_err()
323                .unit_offset,
324            1
325        );
326    }
327
328    #[test]
329    fn lossy_utf8_replaces_only_unpaired_surrogates() {
330        let string = EcmaString::from_units(&[0xD800, 0xDC00, 0xD800, 0x61, 0xDC00]);
331
332        assert_eq!(string.to_utf8_lossy(), "𐀀�a�");
333    }
334
335    #[test]
336    fn code_points_keep_code_unit_offsets_and_raw_surrogates() {
337        let string = EcmaString::from_units(&[0x61, 0xD800, 0xDC00, 0xDC00, 0xD800]);
338
339        assert_eq!(
340            string.code_points().collect::<Vec<_>>(),
341            vec![(0, 0x61), (1, 0x1_0000), (3, 0xDC00), (4, 0xD800)]
342        );
343    }
344
345    #[test]
346    fn slices_may_split_surrogate_pairs() {
347        let string = EcmaString::from_units(&[0xD800, 0xDC00]);
348
349        assert_eq!(string.slice_units(0..1).as_units(), &[0xD800]);
350        assert_eq!(string.slice_units(1..2).as_units(), &[0xDC00]);
351    }
352
353    #[test]
354    fn builder_preserves_supplementary_and_surrogate_code_points() {
355        let mut builder = EcmaStringBuilder::new();
356        builder.push_code_point(0x1F600).unwrap();
357        builder.push_code_point(0xD800).unwrap();
358
359        assert_eq!(builder.len_units(), 3);
360        assert_eq!(builder.finish().as_units(), &[0xD83D, 0xDE00, 0xD800]);
361    }
362
363    #[test]
364    fn builder_rejects_out_of_range_code_points() {
365        let mut builder = EcmaStringBuilder::new();
366
367        assert_eq!(
368            builder.push_code_point(0x11_0000).unwrap_err().value,
369            0x11_0000
370        );
371        assert!(builder.finish().is_empty());
372    }
373
374    #[test]
375    fn ascii_comparison_rejects_non_ascii_values() {
376        let ascii = EcmaString::from_utf8("ascii");
377        let non_ascii = EcmaString::from_utf8("é");
378
379        assert!(ascii.eq_ascii("ascii"));
380        assert!(!ascii.eq_ascii("ASCII"));
381        assert!(!ascii.eq_ascii("é"));
382        assert!(!non_ascii.eq_ascii("é"));
383    }
384
385    #[test]
386    fn debug_renders_lone_surrogates_visibly() {
387        let string = EcmaString::from_units(&[0x61, 0xD800, 0xDC00, 0xDC00]);
388
389        assert_eq!(format!("{string:?}"), "EcmaString(\"a𐀀\\uDC00\")");
390    }
391}