main
rs 335 lines 12.3 KB
Raw
1 //! A JavaScript string value. JS strings are sequences of UTF-16 code units
2 //! with no validity requirement, so a value can contain unpaired surrogate
3 //! halves that Rust's `String` cannot represent. `JsString` keeps the common
4 //! valid case as UTF-8 and falls back to code units only when the value is
5 //! ill-formed, so the compiler computes on true program values instead of
6 //! replacement characters or escape hatches.
7 //!
8 //! Wire format: the babel bridge transports lone surrogates as
9 //! `__SURROGATE_XXXX__` markers (see `sanitizeJsonSurrogates` in bridge.ts),
10 //! because serde_json can neither parse nor emit a lone `\uXXXX` escape.
11 //! Serde for `JsString` decodes and re-emits that marker form, which keeps the
12 //! JS side of the bridge unchanged.
13
14 use std::fmt;
15
16 use serde::Deserialize;
17 use serde::Serialize;
18
19 /// Invariant: `Repr::Utf8` holds every well-formed value and `Repr::Wtf16`
20 /// only ill-formed ones (at least one unpaired surrogate). The derived
21 /// `PartialEq`/`Hash` are only sound under this invariant: a well-formed
22 /// value smuggled into `Wtf16` would compare unequal to its `Utf8` twin. The
23 /// representation is private so the invariant holds by construction; match on
24 /// [`JsString::as_ref`] to branch on well-formedness.
25 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
26 pub struct JsString(Repr);
27
28 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
29 enum Repr {
30 /// A well-formed string (no unpaired surrogates), stored as UTF-8.
31 Utf8(String),
32 /// An ill-formed string, stored as UTF-16 code units.
33 Wtf16(Vec<u16>),
34 }
35
36 /// Borrowed view of a [`JsString`] for callers that need to branch on
37 /// well-formedness.
38 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
39 pub enum JsStringRef<'a> {
40 Utf8(&'a str),
41 Wtf16(&'a [u16]),
42 }
43
44 impl JsString {
45 /// Build from UTF-16 code units, normalizing to UTF-8 when well-formed.
46 pub fn from_code_units(units: Vec<u16>) -> Self {
47 match String::from_utf16(&units) {
48 Ok(s) => JsString(Repr::Utf8(s)),
49 Err(_) => JsString(Repr::Wtf16(units)),
50 }
51 }
52
53 pub fn as_ref(&self) -> JsStringRef<'_> {
54 match &self.0 {
55 Repr::Utf8(s) => JsStringRef::Utf8(s),
56 Repr::Wtf16(units) => JsStringRef::Wtf16(units),
57 }
58 }
59
60 /// The UTF-8 view, when the value is well-formed.
61 pub fn as_str(&self) -> Option<&str> {
62 match &self.0 {
63 Repr::Utf8(s) => Some(s),
64 Repr::Wtf16(_) => None,
65 }
66 }
67
68 pub fn code_units(&self) -> Vec<u16> {
69 match &self.0 {
70 Repr::Utf8(s) => s.encode_utf16().collect(),
71 Repr::Wtf16(units) => units.clone(),
72 }
73 }
74
75 /// Length in UTF-16 code units (JS `String.prototype.length`).
76 pub fn len_utf16(&self) -> usize {
77 match &self.0 {
78 Repr::Utf8(s) => s.encode_utf16().count(),
79 Repr::Wtf16(units) => units.len(),
80 }
81 }
82
83 /// The value with unpaired surrogates replaced by U+FFFD, for consumers
84 /// whose string type cannot represent ill-formed values.
85 pub fn to_string_lossy(&self) -> String {
86 match &self.0 {
87 Repr::Utf8(s) => s.clone(),
88 Repr::Wtf16(units) => String::from_utf16_lossy(units),
89 }
90 }
91
92 /// Decode the bridge wire form: a UTF-8 string in which lone surrogates
93 /// appear as `__SURROGATE_XXXX__` markers (uppercase hex, mirroring what
94 /// `sanitizeJsonSurrogates` emits and `restoreJsonSurrogates` accepts).
95 ///
96 /// All scanning is byte-wise: a marker is 18 ASCII bytes, so byte-slice
97 /// comparisons cannot land on a UTF-8 char boundary the way `str` range
98 /// indexing can when multibyte text follows the prefix.
99 pub fn from_marker_string(s: &str) -> Self {
100 const PREFIX: &[u8] = b"__SURROGATE_";
101 const MARKER_LEN: usize = 18;
102 if !s.contains("__SURROGATE_") {
103 return JsString(Repr::Utf8(s.to_string()));
104 }
105 let bytes = s.as_bytes();
106 let mut units: Vec<u16> = Vec::with_capacity(s.len());
107 let mut pos = 0;
108 let mut segment_start = 0;
109 while let Some(found) = s[pos..].find("__SURROGATE_") {
110 let idx = pos + found;
111 let tail = &bytes[idx..];
112 let well_formed = tail.len() >= MARKER_LEN
113 && &tail[MARKER_LEN - 2..MARKER_LEN] == b"__"
114 && tail[PREFIX.len()..PREFIX.len() + 4]
115 .iter()
116 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_lowercase());
117 if well_formed {
118 let hex = std::str::from_utf8(&tail[PREFIX.len()..PREFIX.len() + 4])
119 .expect("ascii hex is valid utf8");
120 let unit = u16::from_str_radix(hex, 16).expect("validated hex digits");
121 units.extend(s[segment_start..idx].encode_utf16());
122 units.push(unit);
123 pos = idx + MARKER_LEN;
124 segment_start = pos;
125 } else {
126 // Not a well-formed marker: keep the literal text and continue
127 // scanning after the prefix.
128 pos = idx + PREFIX.len();
129 }
130 }
131 units.extend(s[segment_start..].encode_utf16());
132 JsString::from_code_units(units)
133 }
134
135 /// Encode to the bridge wire form (markers for unpaired surrogates).
136 pub fn to_marker_string(&self) -> String {
137 match &self.0 {
138 Repr::Utf8(s) => s.clone(),
139 Repr::Wtf16(units) => {
140 let mut out = String::with_capacity(units.len() * 2);
141 let mut iter = units.iter().copied().peekable();
142 while let Some(unit) = iter.next() {
143 match unit {
144 0xD800..=0xDBFF => {
145 if let Some(&next) = iter.peek() {
146 if (0xDC00..=0xDFFF).contains(&next) {
147 iter.next();
148 let cp = 0x10000
149 + ((unit as u32 - 0xD800) << 10)
150 + (next as u32 - 0xDC00);
151 out.push(char::from_u32(cp).expect("valid supplementary"));
152 continue;
153 }
154 }
155 out.push_str(&format!("__SURROGATE_{unit:04X}__"));
156 }
157 0xDC00..=0xDFFF => {
158 out.push_str(&format!("__SURROGATE_{unit:04X}__"));
159 }
160 _ => {
161 out.push(
162 char::from_u32(unit as u32).expect("BMP non-surrogate is a char"),
163 );
164 }
165 }
166 }
167 out
168 }
169 }
170 }
171
172 /// Render as JS-source-style escaped text, matching the form TS's debug
173 /// printer produces via JSON.stringify: unpaired surrogates print as
174 /// lowercase `\udXXX` escapes inside the otherwise UTF-8 text.
175 pub fn to_escaped_string(&self) -> String {
176 match &self.0 {
177 Repr::Utf8(s) => s.clone(),
178 Repr::Wtf16(units) => {
179 let mut out = String::with_capacity(units.len() * 2);
180 let mut iter = units.iter().copied().peekable();
181 while let Some(unit) = iter.next() {
182 match unit {
183 0xD800..=0xDBFF => {
184 if let Some(&next) = iter.peek() {
185 if (0xDC00..=0xDFFF).contains(&next) {
186 iter.next();
187 let cp = 0x10000
188 + ((unit as u32 - 0xD800) << 10)
189 + (next as u32 - 0xDC00);
190 out.push(char::from_u32(cp).expect("valid supplementary"));
191 continue;
192 }
193 }
194 out.push_str(&format!("\\u{unit:04x}"));
195 }
196 0xDC00..=0xDFFF => {
197 out.push_str(&format!("\\u{unit:04x}"));
198 }
199 _ => {
200 out.push(
201 char::from_u32(unit as u32).expect("BMP non-surrogate is a char"),
202 );
203 }
204 }
205 }
206 out
207 }
208 }
209 }
210 }
211
212 impl From<String> for JsString {
213 fn from(s: String) -> Self {
214 // A Rust String is valid UTF-8 and so cannot contain an unpaired
215 // surrogate; constructing Utf8 directly preserves the invariant.
216 JsString(Repr::Utf8(s))
217 }
218 }
219
220 impl From<&str> for JsString {
221 fn from(s: &str) -> Self {
222 JsString(Repr::Utf8(s.to_string()))
223 }
224 }
225
226 impl PartialEq<str> for JsString {
227 fn eq(&self, other: &str) -> bool {
228 self.as_str() == Some(other)
229 }
230 }
231
232 impl PartialEq<&str> for JsString {
233 fn eq(&self, other: &&str) -> bool {
234 self.as_str() == Some(*other)
235 }
236 }
237
238 impl fmt::Display for JsString {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 f.write_str(&self.to_escaped_string())
241 }
242 }
243
244 impl Serialize for JsString {
245 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
246 serializer.serialize_str(&self.to_marker_string())
247 }
248 }
249
250 impl<'de> Deserialize<'de> for JsString {
251 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
252 let s = String::deserialize(deserializer)?;
253 Ok(JsString::from_marker_string(&s))
254 }
255 }
256
257 #[cfg(test)]
258 mod tests {
259 use super::JsString;
260 use super::JsStringRef;
261
262 #[test]
263 fn as_ref_views_match_well_formedness() {
264 assert!(matches!(
265 JsString::from("plain").as_ref(),
266 JsStringRef::Utf8("plain")
267 ));
268 assert!(matches!(
269 JsString::from_code_units(vec![0xD83E]).as_ref(),
270 JsStringRef::Wtf16(&[0xD83E])
271 ));
272 // Well-formed code units normalize to the Utf8 representation, so
273 // equal logical strings are equal values regardless of how they
274 // were constructed.
275 assert_eq!(
276 JsString::from_code_units("plain".encode_utf16().collect()),
277 JsString::from("plain")
278 );
279 }
280
281 #[test]
282 fn marker_round_trip_preserves_lone_surrogates() {
283 let js = JsString::from_marker_string("__SURROGATE_D83E__");
284 assert_eq!(js.code_units(), vec![0xD83E]);
285 assert_eq!(js.to_marker_string(), "__SURROGATE_D83E__");
286 assert_eq!(js.to_escaped_string(), "\\ud83e");
287 }
288
289 #[test]
290 fn paired_halves_render_as_the_supplementary_character() {
291 let js = JsString::from_code_units(vec![0xD83E, 0xDD21]);
292 assert_eq!(js.as_str(), Some("\u{1F921}"));
293 }
294
295 #[test]
296 fn plain_strings_stay_utf8_and_compare_with_str() {
297 let js = JsString::from("use memo");
298 assert!(js == "use memo");
299 assert_eq!(js.to_marker_string(), "use memo");
300 }
301
302 #[test]
303 fn malformed_marker_text_is_kept_literally() {
304 let js = JsString::from_marker_string("__SURROGATE_XYZ__");
305 assert_eq!(js.as_str(), Some("__SURROGATE_XYZ__"));
306 }
307
308 #[test]
309 fn multibyte_text_after_marker_prefix_does_not_panic() {
310 let input = "__SURROGATE_\u{20AC}\u{20AC}";
311 let js = JsString::from_marker_string(input);
312 assert_eq!(js.as_str(), Some(input));
313
314 let truncated = "__SURROGATE_D8";
315 assert_eq!(
316 JsString::from_marker_string(truncated).as_str(),
317 Some(truncated)
318 );
319
320 let mixed = "a\u{20AC}__SURROGATE_D83E__b\u{20AC}";
321 let js = JsString::from_marker_string(mixed);
322 let mut expected: Vec<u16> = "a\u{20AC}".encode_utf16().collect();
323 expected.push(0xD83E);
324 expected.extend("b\u{20AC}".encode_utf16());
325 assert_eq!(js.code_units(), expected);
326 }
327
328 #[test]
329 fn lowercase_hex_markers_are_not_decoded() {
330 // The bridge emits uppercase hex only; lowercase marker-shaped text is
331 // user text and must survive verbatim.
332 let input = "__SURROGATE_d83e__";
333 assert_eq!(JsString::from_marker_string(input).as_str(), Some(input));
334 }
335 }