Skip to main content

sim_lib_lang_javascript/
text.rs

1//! UTF-16 code-unit face for ECMAScript strings.
2
3use sim_text::{CodeUnitOffset, CodeUnitRange, CodeUnitString, CodeUnitStringError};
4
5/// Error crossing from the code-unit face to canonical scalar SIM text.
6#[derive(Clone, Debug, Eq, PartialEq)]
7pub enum JavascriptTextError {
8    /// The unit sequence contains an unpaired surrogate.
9    LoneSurrogate {
10        /// Code-unit index of the first invalid surrogate.
11        index: usize,
12        /// Invalid code unit.
13        unit: u16,
14    },
15}
16
17/// An ECMAScript String as exact UTF-16 code units.
18///
19/// This face admits lone surrogates. Canonical SIM text remains scalar Unicode;
20/// conversion to it is explicit and fails rather than replacing data.
21#[derive(Clone, Debug, Default, Eq, PartialEq)]
22pub struct JavascriptCodeUnitString(CodeUnitString);
23impl JavascriptCodeUnitString {
24    /// Encode canonical scalar text into the JavaScript face.
25    pub fn from_scalar(text: &str) -> Self {
26        Self(CodeUnitString::from_scalar(text))
27    }
28    /// Preserve an exact sequence including lone surrogates.
29    pub fn from_code_units(units: Vec<u16>) -> Self {
30        Self(CodeUnitString::from_code_units(units))
31    }
32    /// ECMAScript `length` in code units.
33    pub fn len(&self) -> usize {
34        self.0.len()
35    }
36    /// Whether the string has no code units.
37    pub fn is_empty(&self) -> bool {
38        self.0.is_empty()
39    }
40    /// Index one code unit.
41    pub fn code_unit_at(&self, index: usize) -> Option<u16> {
42        self.0.code_unit_at(CodeUnitOffset::new(index))
43    }
44    /// Slice by code-unit indices, clamped as `String.prototype.slice` does for nonnegative indices.
45    pub fn slice(&self, start: usize, end: usize) -> Self {
46        Self(self.0.slice(CodeUnitRange::new(
47            CodeUnitOffset::new(start),
48            CodeUnitOffset::new(end),
49        )))
50    }
51    /// Iterate exact code units (the indexing face).
52    pub fn code_units(&self) -> impl Iterator<Item = u16> + '_ {
53        self.0.code_units()
54    }
55    /// Iterate ECMAScript string iterator chunks: paired surrogates together, lone units alone.
56    pub fn iter_strings(&self) -> JavascriptStringIterator<'_> {
57        JavascriptStringIterator(self.0.iter_code_points())
58    }
59    /// Convert only well-formed UTF-16 to canonical scalar SIM text.
60    pub fn to_scalar(&self) -> Result<String, JavascriptTextError> {
61        self.0.to_scalar().map_err(|error| match error {
62            CodeUnitStringError::LoneSurrogate(invalid) => JavascriptTextError::LoneSurrogate {
63                index: invalid.offset.get(),
64                unit: invalid.unit,
65            },
66            CodeUnitStringError::TooLong { .. } => {
67                unreachable!("an existing code-unit string already satisfies the allocation limit")
68            }
69        })
70    }
71}
72/// Iterator over ECMAScript code-point chunks represented as exact code-unit strings.
73pub struct JavascriptStringIterator<'a>(sim_text::CodePointIter<'a>);
74impl Iterator for JavascriptStringIterator<'_> {
75    type Item = JavascriptCodeUnitString;
76    fn next(&mut self) -> Option<Self::Item> {
77        self.0.next().map(JavascriptCodeUnitString)
78    }
79}
80
81#[cfg(test)]
82mod law_fixtures {
83    use super::*;
84    use std::sync::Arc;
85
86    use sim_kernel::{Cx, Datum, DefaultFactory, NoopEvalPolicy, Ref, Symbol};
87    use sim_lib_standard_core::{
88        BoundedLane, CanonicalObservation, CanonicalOutcome, CharacterizationCapture,
89        ScenarioLimits, ScenarioObservationLane, ScenarioSpec, publish_characterization_capture,
90    };
91
92    fn units_datum(units: impl IntoIterator<Item = u16>) -> Datum {
93        Datum::Vector(
94            units
95                .into_iter()
96                .map(|unit| Datum::String(format!("{unit:04x}")))
97                .collect(),
98        )
99    }
100
101    fn characterized_case(name: &str, value: JavascriptCodeUnitString) -> Datum {
102        Datum::Node {
103            tag: Symbol::qualified("javascript-utf16", "case/v1"),
104            fields: vec![
105                (Symbol::new("name"), Datum::String(name.to_owned())),
106                (
107                    Symbol::new("length"),
108                    Datum::String(value.len().to_string()),
109                ),
110                (Symbol::new("code-units"), units_datum(value.code_units())),
111                (
112                    Symbol::new("string-iterator"),
113                    Datum::Vector(
114                        value
115                            .iter_strings()
116                            .map(|chunk| units_datum(chunk.code_units()))
117                            .collect(),
118                    ),
119                ),
120            ],
121        }
122    }
123
124    fn characterization_capture() -> (ScenarioSpec, CharacterizationCapture) {
125        let astral = JavascriptCodeUnitString::from_scalar("😀");
126        let cases = vec![
127            characterized_case("empty", JavascriptCodeUnitString::default()),
128            characterized_case("astral-pair", astral.clone()),
129            characterized_case(
130                "lone-high-start",
131                JavascriptCodeUnitString::from_code_units(vec![0xd800, 0x0061]),
132            ),
133            characterized_case(
134                "lone-high-middle",
135                JavascriptCodeUnitString::from_code_units(vec![0x0061, 0xd800, 0x0062]),
136            ),
137            characterized_case(
138                "lone-high-end",
139                JavascriptCodeUnitString::from_code_units(vec![0x0061, 0xd800]),
140            ),
141            characterized_case(
142                "lone-low",
143                JavascriptCodeUnitString::from_code_units(vec![0xdc00]),
144            ),
145            characterized_case("nul", JavascriptCodeUnitString::from_scalar("\0")),
146            characterized_case("slice-across-pair-high", astral.slice(0, 1)),
147            characterized_case("slice-across-pair-low", astral.slice(1, 2)),
148        ];
149        let scenario = ScenarioSpec::new(
150            Symbol::qualified("javascript", "characterize-utf16/v1"),
151            Symbol::qualified("javascript", "text-current/v1"),
152        )
153        .with_limits(ScenarioLimits::new(0, 1))
154        .observing(ScenarioObservationLane::ValueOrFailure);
155        let capture = CharacterizationCapture::new(
156            Symbol::qualified("javascript", "utf16-code-units/v1"),
157            CanonicalObservation {
158                outcome: Some(CanonicalOutcome::Success(Datum::Vector(cases))),
159                events: BoundedLane::Absent,
160                receipts: BoundedLane::Absent,
161                browse: BoundedLane::Absent,
162            },
163        );
164        (scenario, capture)
165    }
166
167    fn test_cx() -> Cx {
168        Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory))
169    }
170
171    #[test]
172    fn characterize_1_current_code_unit_behavior_has_stable_content_identity() {
173        let (scenario, capture) = characterization_capture();
174        let first = publish_characterization_capture(&mut test_cx(), &scenario, &capture).unwrap();
175        let second = publish_characterization_capture(&mut test_cx(), &scenario, &capture).unwrap();
176        assert!(matches!(first, Ref::Content(_)));
177        assert_eq!(first, second);
178    }
179
180    #[test]
181    fn length_index_slice_and_code_unit_iteration_are_utf16() {
182        let s = JavascriptCodeUnitString::from_scalar("A😀B");
183        assert_eq!(s.len(), 4);
184        assert_eq!(s.code_unit_at(1), Some(0xd83d));
185        assert_eq!(
186            s.slice(1, 3).code_units().collect::<Vec<_>>(),
187            vec![0xd83d, 0xde00]
188        );
189        assert_eq!(s.code_units().count(), 4);
190    }
191    #[test]
192    fn scalar_conversion_and_paired_iteration_are_exact() {
193        let s = JavascriptCodeUnitString::from_code_units(vec![0xd83d, 0xde00]);
194        assert_eq!(s.to_scalar().unwrap(), "😀");
195        assert_eq!(s.iter_strings().next().unwrap().len(), 2);
196    }
197    #[test]
198    fn lone_high_surrogate_is_preserved_and_rejected_by_scalar_face() {
199        let s = JavascriptCodeUnitString::from_code_units(vec![0xd800]);
200        assert_eq!(s.code_unit_at(0), Some(0xd800));
201        assert_eq!(
202            s.iter_strings()
203                .next()
204                .unwrap()
205                .code_units()
206                .collect::<Vec<_>>(),
207            vec![0xd800]
208        );
209        assert_eq!(
210            s.to_scalar(),
211            Err(JavascriptTextError::LoneSurrogate {
212                index: 0,
213                unit: 0xd800
214            })
215        );
216    }
217    #[test]
218    fn lone_low_surrogate_is_preserved_and_rejected_by_scalar_face() {
219        let s = JavascriptCodeUnitString::from_code_units(vec![0xdc00]);
220        assert_eq!(s.slice(0, 1), s);
221        assert_eq!(
222            s.to_scalar(),
223            Err(JavascriptTextError::LoneSurrogate {
224                index: 0,
225                unit: 0xdc00
226            })
227        );
228    }
229
230    #[test]
231    fn scalar_error_preserves_javascript_code_unit_position() {
232        let s = JavascriptCodeUnitString::from_code_units(vec![0x0061, 0xd800, 0x0062]);
233        assert_eq!(
234            s.to_scalar(),
235            Err(JavascriptTextError::LoneSurrogate {
236                index: 1,
237                unit: 0xd800,
238            })
239        );
240    }
241}