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
+}