hermes_atom_table/lib.rs
1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8//! Ported from juno atom_table for the Hermes Rust lexer; carries encapsulated
9//! unsafe; adds a byte/WTF-8 intern path.
10
11use std::cell::Cell;
12use std::cell::UnsafeCell;
13use std::collections::HashMap;
14use std::fmt::Formatter;
15use std::ptr::null;
16
17/// Type used to hold a string index internally.
18type NumIndex = u32;
19
20/// A string uniquing table - only one copy of a string is stored and all attempts
21/// to add the same string again return the same atom. This table is intended to
22/// be easily shareable, so it utilizes interior mutability. UnsafeCell<> is safe
23/// because we never allow reference to it to escape.
24#[derive(Debug, Default)]
25pub struct AtomTable(UnsafeCell<Inner>);
26
27/// A string uniquing table - only one copy of a string is stored and all attempts
28/// to add the same string again return the same atom.
29#[derive(Default)]
30struct Inner {
31 /// Strings are added here and never removed or mutated.
32 strings: Vec<String>,
33 /// Maps from a reference inside [`Inner::strings`] to the index in [`Inner::strings`].
34 /// Since strings are never removed or modified, the lifetime of the key
35 /// is effectively static.
36 map: HashMap<&'static str, NumIndex>,
37
38 /// Strings are added here and never removed or mutated.
39 strings_u16: Vec<Vec<u16>>,
40 /// Maps from a reference inside [`Inner::strings_u16`] to the index in [`Inner::strings_u16`].
41 /// Since strings are never removed or modified, the lifetime of the key
42 /// is effectively static.
43 map_u16: HashMap<&'static [u16], NumIndex>,
44
45 /// Byte strings are added here and never removed or mutated.
46 /// The bytes need not be valid UTF-8 (they may be WTF-8 or arbitrary byte
47 /// sequences, e.g. JS string literals containing lone surrogates).
48 strings_bytes: Vec<Vec<u8>>,
49 /// Maps from a reference inside [`Inner::strings_bytes`] to the index in
50 /// [`Inner::strings_bytes`]. Since strings are never removed or modified,
51 /// the lifetime of the key is effectively static.
52 map_bytes: HashMap<&'static [u8], NumIndex>,
53
54 /// String renderings of byte atoms that are *not* valid UTF-8, built on
55 /// demand. This is a lifetime anchor, not a cache: the conversion has to
56 /// put its newly built string somewhere in order for
57 /// [`AtomTable::bytes_str_lossy`] and [`AtomTable::try_bytes_str`] to hand
58 /// out a `&str`. Both share one entry per atom, so a given atom is
59 /// converted at most once. Atoms whose bytes are already valid UTF-8 never
60 /// reach it — they are borrowed straight out of [`Inner::strings_bytes`] —
61 /// so it stays empty unless a string literal (or a hand-built atom) holds
62 /// surrogates; identifiers never can, because the lexer rejects unpaired
63 /// surrogates there. Entries are never removed or mutated, so a returned
64 /// `&str` stays valid (rehashing moves the `String` structs, never their
65 /// heap buffers) — the same argument as [`Inner::strings_bytes`].
66 converted_bytes: HashMap<AtomBytes, Converted>,
67}
68
69/// This represents a unique string index in the table.
70#[derive(Copy, Clone, Eq, PartialEq, Hash)]
71pub struct Atom(NumIndex);
72
73/// This represents a unique string index in the table.
74#[derive(Copy, Clone, Eq, PartialEq, Hash)]
75pub struct AtomU16(NumIndex);
76
77/// This represents a unique byte-string index in the table.
78/// The bytes need not be valid UTF-8.
79#[derive(Copy, Clone, Eq, PartialEq, Hash)]
80pub struct AtomBytes(NumIndex);
81
82thread_local! {
83 /// Stores the active table used for debug formatting.
84 static DEBUG_TABLE: Cell<* const AtomTable> = Cell::new(null());
85}
86
87// An implementation of Debug which optionally obtains the Atom value from the
88// active debug map.
89impl std::fmt::Debug for Atom {
90 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
91 let mut t = f.debug_tuple("Atom");
92 t.field(&self.0);
93
94 // If the debug table is set and the atom is valid in it, add the value
95 DEBUG_TABLE.with(|debug_table| {
96 let p = debug_table.get();
97 if let Some(r) = unsafe { p.as_ref() } {
98 if let Some(value) = r.try_str(*self) {
99 t.field(&value);
100 }
101 }
102 });
103 t.finish()
104 }
105}
106
107// An implementation of Debug which optionally obtains the Atom value from the
108// active debug map.
109impl std::fmt::Debug for AtomU16 {
110 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
111 let mut t = f.debug_tuple("Atom");
112 t.field(&self.0);
113
114 // If the debug table is set and the atom is valid in it, add the value
115 DEBUG_TABLE.with(|debug_table| {
116 let p = debug_table.get();
117 if let Some(r) = unsafe { p.as_ref() } {
118 if let Some(value) = r.try_str_u16(*self) {
119 t.field(&value);
120 }
121 }
122 });
123 t.finish()
124 }
125}
126
127// An implementation of Debug which optionally obtains the AtomBytes value from
128// the active debug map.
129impl std::fmt::Debug for AtomBytes {
130 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
131 let mut t = f.debug_tuple("AtomBytes");
132 t.field(&self.0);
133
134 // If the debug table is set and the atom is valid in it, add the value
135 DEBUG_TABLE.with(|debug_table| {
136 let p = debug_table.get();
137 if let Some(r) = unsafe { p.as_ref() } {
138 if let Some(value) = r.try_bytes(*self) {
139 t.field(&value);
140 }
141 }
142 });
143 t.finish()
144 }
145}
146
147/// A special value reserved for the invalid atom.
148pub const INVALID_ATOM: Atom = Atom(NumIndex::MAX);
149
150/// A special value reserved for the invalid atom bytes.
151pub const INVALID_ATOM_BYTES: AtomBytes = AtomBytes(NumIndex::MAX);
152
153impl Inner {
154 /// Add a string to the table and return its atom index. The same
155 /// string always returns the same index.
156 fn add_atom<V: Into<String> + AsRef<str>>(&mut self, value: V) -> Atom {
157 if let Some(index) = self.map.get(value.as_ref()) {
158 return Atom(*index);
159 }
160 self.add(value.into())
161 }
162
163 /// Perform the actual addition of the owned string.
164 fn add(&mut self, owned: String) -> Atom {
165 // Remember the index of the new element.
166 let index = self.strings.len();
167 assert!(index < INVALID_ATOM.0 as usize, "More than 4GB atoms?");
168
169 // Obtain a reference to the existing string on the heap. That reference
170 // is valid while `self` is valid.
171 let key: *const str = owned.as_str();
172
173 // Push the new string.
174 self.strings.push(owned);
175
176 self.map.insert(unsafe { &*key }, index as NumIndex);
177 Atom(index as NumIndex)
178 }
179
180 /// Return the contents of the specified atom.
181 #[inline]
182 fn str(&self, ident: Atom) -> &str {
183 self.strings[ident.0 as usize].as_str()
184 }
185
186 fn try_str(&self, ident: Atom) -> Option<&str> {
187 if (ident.0 as usize) < self.strings.len() {
188 Some(self.str(ident))
189 } else {
190 None
191 }
192 }
193
194 /// Add a string to the table and return its atom index. The same
195 /// string always returns the same index.
196 fn add_atom_u16<V: Into<Vec<u16>> + AsRef<[u16]>>(&mut self, value: V) -> AtomU16 {
197 if let Some(index) = self.map_u16.get(value.as_ref()) {
198 return AtomU16(*index);
199 }
200 self.add_u16(value.into())
201 }
202
203 /// Perform the actual addition of the owned string.
204 fn add_u16(&mut self, owned: Vec<u16>) -> AtomU16 {
205 // Remember the index of the new element.
206 let index = self.strings_u16.len();
207 assert!(index < INVALID_ATOM.0 as usize, "More than 4GB atoms?");
208
209 // Obtain a reference to the existing string on the heap. That reference
210 // is valid while `self` is valid.
211 let key: *const [u16] = owned.as_slice();
212
213 // Push the new string.
214 self.strings_u16.push(owned);
215
216 self.map_u16.insert(unsafe { &*key }, index as NumIndex);
217 AtomU16(index as NumIndex)
218 }
219
220 /// Return the contents of the specified atom.
221 #[inline]
222 fn str_u16(&self, ident: AtomU16) -> &[u16] {
223 self.strings_u16[ident.0 as usize].as_slice()
224 }
225
226 fn try_str_u16(&self, ident: AtomU16) -> Option<&[u16]> {
227 if (ident.0 as usize) < self.strings_u16.len() {
228 Some(self.str_u16(ident))
229 } else {
230 None
231 }
232 }
233
234 /// Add a byte string to the table and return its atom index. The same
235 /// byte sequence always returns the same index. The bytes need not be
236 /// valid UTF-8.
237 fn add_atom_bytes<V: Into<Vec<u8>> + AsRef<[u8]>>(&mut self, value: V) -> AtomBytes {
238 if let Some(index) = self.map_bytes.get(value.as_ref()) {
239 return AtomBytes(*index);
240 }
241 self.add_bytes(value.into())
242 }
243
244 /// Perform the actual addition of the owned byte string.
245 fn add_bytes(&mut self, owned: Vec<u8>) -> AtomBytes {
246 // Remember the index of the new element.
247 let index = self.strings_bytes.len();
248 assert!(index < INVALID_ATOM_BYTES.0 as usize, "More than 4GB atoms?");
249
250 // Obtain a reference to the existing bytes on the heap. That reference
251 // is valid while `self` is valid. Pushing an owned Vec into the outer
252 // Vec moves only the Vec struct, never its heap buffer — so a
253 // *const [u8] captured from owned.as_slice() before the push stays
254 // valid.
255 let key: *const [u8] = owned.as_slice();
256
257 // Push the new byte string.
258 self.strings_bytes.push(owned);
259
260 self.map_bytes.insert(unsafe { &*key }, index as NumIndex);
261 AtomBytes(index as NumIndex)
262 }
263
264 /// Return the contents of the specified atom bytes.
265 #[inline]
266 fn bytes(&self, ident: AtomBytes) -> &[u8] {
267 self.strings_bytes[ident.0 as usize].as_slice()
268 }
269
270 fn try_bytes(&self, ident: AtomBytes) -> Option<&[u8]> {
271 if (ident.0 as usize) < self.strings_bytes.len() {
272 Some(self.bytes(ident))
273 } else {
274 None
275 }
276 }
277
278 /// Convert `ident` and anchor the result unless that has already been
279 /// done. Only ever called for atoms whose bytes failed UTF-8 validation.
280 fn ensure_converted(&mut self, ident: AtomBytes) {
281 if !self.converted_bytes.contains_key(&ident) {
282 let converted = convert_wtf8(self.bytes(ident));
283 self.converted_bytes.insert(ident, converted);
284 }
285 }
286
287 /// Return the conversion of `ident`, which must already have been built by
288 /// [`Inner::ensure_converted`].
289 #[inline]
290 fn converted(&self, ident: AtomBytes) -> &Converted {
291 &self.converted_bytes[&ident]
292 }
293}
294
295/// A byte atom rendered as a Rust string, owned by the table.
296struct Converted {
297 /// The rendering: surrogate pairs folded into the character they encode,
298 /// each unpaired surrogate and each other ill-formed subsequence replaced
299 /// by one U+FFFD.
300 text: String,
301 /// Whether anything was *replaced* — as opposed to merely folded. When
302 /// this is false, `text` is a faithful, lossless rendering of the atom's
303 /// bytes and [`AtomTable::try_bytes_str`] may hand it out; when it is
304 /// true, the atom holds something no `&str` can represent.
305 replaced: bool,
306}
307
308/// If `bytes` starts with the WTF-8 encoding of a surrogate, return that
309/// surrogate's code point. Such an encoding is always three bytes: `ED`, then
310/// a continuation byte in `A0..=BF` (`A0..=AF` for the high surrogates
311/// `U+D800..=U+DBFF`, `B0..=BF` for the low surrogates `U+DC00..=U+DFFF`),
312/// then any continuation byte. Well-formed UTF-8 never produces this shape, so
313/// the check is only ever reached on bytes `str::from_utf8` already rejected.
314#[inline]
315fn surrogate_at(bytes: &[u8]) -> Option<u32> {
316 match bytes {
317 [0xED, b1 @ 0xA0..=0xBF, b2 @ 0x80..=0xBF, ..] => {
318 Some(0xD000 | ((*b1 as u32 & 0x3F) << 6) | (*b2 as u32 & 0x3F))
319 }
320 _ => None,
321 }
322}
323
324/// Render `bytes` — WTF-8, or arbitrary bytes — as a valid Rust `String`,
325/// reporting whether anything had to be replaced.
326///
327/// A WTF-8 surrogate *pair* is **folded** back into the supplementary-plane
328/// character it encodes, which loses nothing: the pair and the character are
329/// two encodings of the same string, so the result is exact and
330/// [`Converted::replaced`] stays false. An **unpaired** surrogate has no UTF-8
331/// form at all and becomes exactly one U+FFFD, as does every other maximal
332/// ill-formed subsequence; either sets [`Converted::replaced`].
333///
334/// This mirrors the C++ `convertSurrogatesInString` pipeline
335/// (`JSLexer.cpp:2486-2495`), which the lexer applies when its
336/// `convert_surrogates` option is on — and which it does *not* apply by
337/// default, so an astral character in a string literal is stored here in its
338/// surrogate-pair form and must be folded back rather than replaced.
339/// `convertToCodePointAt` (UTF8.cpp:77-96) pairs a high surrogate only with an
340/// immediately following low one, so a high surrogate at end of input, a high
341/// followed by another high, and a low followed by a high are each unpaired;
342/// the arms below reproduce that, one U+FFFD per surrogate.
343///
344/// Deliberately not `String::from_utf8_lossy`: std has no notion of WTF-8, so
345/// it renders a lone surrogate's three bytes as three separate U+FFFD and an
346/// encoded astral character as six.
347fn convert_wtf8(bytes: &[u8]) -> Converted {
348 let mut out = String::with_capacity(bytes.len());
349 let mut replaced = false;
350 let mut rest = bytes;
351 loop {
352 // std validates the bulk of the input; only what it rejects is
353 // examined by hand below.
354 let err = match std::str::from_utf8(rest) {
355 Ok(valid) => {
356 out.push_str(valid);
357 return Converted {
358 text: out,
359 replaced,
360 };
361 }
362 Err(err) => err,
363 };
364 let (valid, invalid) = rest.split_at(err.valid_up_to());
365 // `valid` is by definition the prefix `from_utf8` accepted.
366 out.push_str(std::str::from_utf8(valid).unwrap());
367
368 rest = match surrogate_at(invalid) {
369 // A high surrogate directly followed by a low one is the WTF-8
370 // encoding of a single supplementary-plane character. Folding it
371 // back is exact, so `replaced` is left alone.
372 Some(high) if high < 0xDC00 => match surrogate_at(&invalid[3..]) {
373 Some(low) if low >= 0xDC00 => {
374 let cp = 0x10000 + ((high - 0xD800) << 10) + (low - 0xDC00);
375 // `cp` is in 0x10000..=0x10FFFF by construction, so the
376 // fallback is unreachable.
377 out.push(char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER));
378 &invalid[6..]
379 }
380 // A high surrogate with no low one after it: unpaired.
381 _ => {
382 out.push(char::REPLACEMENT_CHARACTER);
383 replaced = true;
384 &invalid[3..]
385 }
386 },
387 // An unpaired low surrogate. Note this arm also takes a low
388 // surrogate that happens to precede a high one, which C++ likewise
389 // treats as two unpaired surrogates rather than a reversed pair.
390 Some(_) => {
391 out.push(char::REPLACEMENT_CHARACTER);
392 replaced = true;
393 &invalid[3..]
394 }
395 // Not a surrogate: one U+FFFD for the maximal ill-formed
396 // subsequence std identified. `error_len() == None` means the
397 // input simply ends mid-sequence, so everything left is consumed.
398 None => {
399 out.push(char::REPLACEMENT_CHARACTER);
400 replaced = true;
401 &invalid[err.error_len().unwrap_or(invalid.len())..]
402 }
403 };
404 }
405}
406
407impl AtomTable {
408 /// Create a new empty atom table.
409 pub fn new() -> AtomTable {
410 Default::default()
411 }
412
413 /// Add a string to the table and return its atom index. The same
414 /// string always returns the same index.
415 pub fn atom<V: Into<String> + AsRef<str>>(&self, value: V) -> Atom {
416 unsafe { &mut *self.0.get() }.add_atom(value)
417 }
418
419 /// Return the contents of the specified atom.
420 #[inline]
421 pub fn str(&self, ident: Atom) -> &str {
422 unsafe { &*self.0.get() }.str(ident)
423 }
424
425 #[inline]
426 pub fn try_str(&self, ident: Atom) -> Option<&str> {
427 unsafe { &*self.0.get() }.try_str(ident)
428 }
429
430 /// Add a string to the table and return its atom index. The same
431 /// string always returns the same index.
432 pub fn atom_u16<V: Into<Vec<u16>> + AsRef<[u16]>>(&self, value: V) -> AtomU16 {
433 unsafe { &mut *self.0.get() }.add_atom_u16(value)
434 }
435
436 /// Return the contents of the specified atom.
437 #[inline]
438 pub fn str_u16(&self, ident: AtomU16) -> &[u16] {
439 unsafe { &*self.0.get() }.str_u16(ident)
440 }
441
442 #[inline]
443 pub fn try_str_u16(&self, ident: AtomU16) -> Option<&[u16]> {
444 unsafe { &*self.0.get() }.try_str_u16(ident)
445 }
446
447 /// Add a byte string to the table and return its atom index. The same
448 /// byte sequence always returns the same index. The bytes need not be
449 /// valid UTF-8 (e.g., WTF-8 sequences encoding lone surrogates are
450 /// accepted).
451 pub fn atom_bytes<V: Into<Vec<u8>> + AsRef<[u8]>>(&self, value: V) -> AtomBytes {
452 unsafe { &mut *self.0.get() }.add_atom_bytes(value)
453 }
454
455 /// Return the contents of the specified atom bytes.
456 #[inline]
457 pub fn bytes(&self, ident: AtomBytes) -> &[u8] {
458 unsafe { &*self.0.get() }.bytes(ident)
459 }
460
461 #[inline]
462 pub fn try_bytes(&self, ident: AtomBytes) -> Option<&[u8]> {
463 unsafe { &*self.0.get() }.try_bytes(ident)
464 }
465
466 /// Return the contents of the specified atom bytes as a string,
467 /// substituting U+FFFD for anything that cannot be represented.
468 ///
469 /// The atom's bytes are WTF-8, so a surrogate *pair* is folded back into
470 /// the supplementary-plane character it encodes — that is exact, and it is
471 /// the common case, because with the lexer's default settings an astral
472 /// character in a string literal is stored in surrogate-pair form. An
473 /// *unpaired* surrogate has no UTF-8 form and becomes exactly one U+FFFD,
474 /// as does any other ill-formed sequence.
475 ///
476 /// When the bytes are already valid UTF-8, which is always the case for
477 /// identifiers, the result borrows them directly with no allocation and no
478 /// lookup. Otherwise the converted string is built once and owned by the
479 /// table, so the returned reference stays valid for as long as the table
480 /// does, whatever is interned afterwards.
481 ///
482 /// Use [`AtomTable::bytes`] when the exact bytes matter, and
483 /// [`AtomTable::try_bytes_str`] when substitution would be data loss —
484 /// notably for JS string-literal values, where an unpaired surrogate is a
485 /// legal value rather than malformed data.
486 ///
487 /// # Panics
488 ///
489 /// Panics if `ident` is not a valid atom of this table, like
490 /// [`AtomTable::bytes`].
491 #[inline]
492 pub fn bytes_str_lossy(&self, ident: AtomBytes) -> &str {
493 // Validate first: on the overwhelmingly common valid path this borrows
494 // the atom's own bytes and never touches `converted_bytes`.
495 if let Ok(s) = std::str::from_utf8(unsafe { &*self.0.get() }.bytes(ident)) {
496 return s;
497 }
498 unsafe { &mut *self.0.get() }.ensure_converted(ident);
499 unsafe { &*self.0.get() }.converted(ident).text.as_str()
500 }
501
502 /// Return the contents of the specified atom bytes as a string whenever
503 /// they can be represented as one exactly, and `None` when they cannot.
504 ///
505 /// `None` means the atom holds an **unpaired surrogate** — a legal JS
506 /// string value with no UTF-8 form — or bytes that are not WTF-8 at all.
507 /// It does *not* mean merely "the bytes are not literally valid UTF-8":
508 /// a surrogate *pair* is folded back into the character it encodes and
509 /// returned as `Some`, since the pair and that character are two encodings
510 /// of the same string. An emoji in a string literal is stored in
511 /// surrogate-pair form and therefore comes back as `Some`, not `None`.
512 ///
513 /// Valid UTF-8 borrows the atom's bytes with no allocation; the folding
514 /// path builds the string once and anchors it in the table, sharing the
515 /// one entry with [`AtomTable::bytes_str_lossy`].
516 ///
517 /// Unlike [`AtomTable::bytes_str_lossy`] this never substitutes, so it is
518 /// the right accessor for JS string-literal values, where replacing an
519 /// unpaired surrogate with U+FFFD would silently corrupt the program's
520 /// data.
521 ///
522 /// Also returns `None` if `ident` is not a valid atom of this table, so
523 /// that — like [`AtomTable::try_bytes`] — it never panics.
524 #[inline]
525 pub fn try_bytes_str(&self, ident: AtomBytes) -> Option<&str> {
526 // Validate first: valid UTF-8 borrows the atom's own bytes and never
527 // touches `converted_bytes`.
528 let bytes = unsafe { &*self.0.get() }.try_bytes(ident)?;
529 if let Ok(s) = std::str::from_utf8(bytes) {
530 return Some(s);
531 }
532 unsafe { &mut *self.0.get() }.ensure_converted(ident);
533 let converted = unsafe { &*self.0.get() }.converted(ident);
534 if converted.replaced {
535 None
536 } else {
537 Some(converted.text.as_str())
538 }
539 }
540
541 /// Execute the callback in a context where this table is used for debug
542 /// printing of atoms.
543 pub fn in_debug_context<R, F: FnOnce() -> R>(&self, f: F) -> R {
544 DEBUG_TABLE.with(|debug_table| {
545 let prev_table = debug_table.replace(self);
546 let res = f();
547 debug_assert!(
548 debug_table.get() == self,
549 "debug context unexpectedly changed"
550 );
551 debug_table.set(prev_table);
552 res
553 })
554 }
555
556 /// Set a table or nullptr as the Atom debug context. If non-null, debug
557 /// printing of atoms will use it. Return the previous debug context.
558 ///
559 /// # Safety
560 /// The table must not be destroyed or moved while it is set.
561 pub unsafe fn unsafe_set_debug_context(ptr: *const Self) -> *const Self {
562 DEBUG_TABLE.with(|debug_table| debug_table.replace(ptr))
563 }
564}
565
566impl std::ops::Index<Atom> for AtomTable {
567 type Output = str;
568
569 fn index(&self, index: Atom) -> &Self::Output {
570 self.str(index)
571 }
572}
573
574impl std::ops::Index<AtomU16> for AtomTable {
575 type Output = [u16];
576
577 fn index(&self, index: AtomU16) -> &Self::Output {
578 self.str_u16(index)
579 }
580}
581
582impl std::ops::Index<AtomBytes> for AtomTable {
583 type Output = [u8];
584
585 fn index(&self, index: AtomBytes) -> &Self::Output {
586 self.bytes(index)
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn test_tab() {
596 let idtab = AtomTable::new();
597
598 let id_foo = idtab.atom("foo");
599 let p_foo: *const str = idtab.str(id_foo);
600 let id_bar = idtab.atom("bar");
601 assert_ne!(id_foo, id_bar);
602
603 assert_eq!(idtab.atom("foo"), id_foo);
604 assert_eq!(idtab.atom("bar"), id_bar);
605
606 assert_eq!(idtab.atom(String::from("foo")), id_foo);
607 assert_eq!(idtab.atom(String::from("bar")), id_bar);
608
609 assert_eq!(idtab.str(id_foo), "foo");
610 assert_eq!(idtab.str(id_bar), "bar");
611
612 assert_eq!(idtab.str(id_foo) as *const str, p_foo);
613 }
614
615 #[test]
616 fn test_bytes() {
617 let tab = AtomTable::new();
618 let foo = tab.atom_bytes(b"foo".as_slice());
619 let bar = tab.atom_bytes(b"bar".as_slice());
620 assert_ne!(foo, bar);
621 assert_eq!(tab.atom_bytes(b"foo".as_slice()), foo);
622 assert_eq!(tab.atom_bytes(Vec::from(*b"bar")), bar);
623 assert_eq!(tab.bytes(foo), b"foo");
624 assert_eq!(&tab[bar], b"bar");
625 let p_foo: *const [u8] = tab.bytes(foo);
626 let _ = tab.atom_bytes(b"baz".as_slice());
627 assert_eq!(tab.bytes(foo) as *const [u8], p_foo);
628 }
629
630 #[test]
631 fn test_bytes_ill_formed_utf8() {
632 let tab = AtomTable::new();
633 let lone_surrogate: &[u8] = &[0xed, 0xa0, 0x80];
634 let a = tab.atom_bytes(lone_surrogate);
635 assert_eq!(tab.bytes(a), lone_surrogate);
636 assert_eq!(tab.atom_bytes(lone_surrogate), a);
637 let s = tab.atom("foo");
638 let b = tab.atom_bytes(b"foo".as_slice());
639 assert_eq!(tab.str(s), "foo");
640 assert_eq!(tab.bytes(b), b"foo");
641 }
642
643 #[test]
644 fn test_bytes_try_and_invalid() {
645 let tab = AtomTable::new();
646 let a = tab.atom_bytes(b"x".as_slice());
647 assert_eq!(tab.try_bytes(a), Some(b"x".as_slice()));
648 assert_eq!(tab.try_bytes(INVALID_ATOM_BYTES), None);
649 }
650
651 #[test]
652 fn lone_surrogate_becomes_exactly_one_replacement_char() {
653 let t = AtomTable::new();
654 // WTF-8 for U+D800, i.e. `"\uD800"` as Hermes stores it.
655 let a = t.atom_bytes(vec![0xED, 0xA0, 0x80]);
656 assert_eq!(t.try_bytes_str(a), None);
657 let s = t.bytes_str_lossy(a);
658 assert_eq!(
659 s.chars().filter(|c| *c == '\u{FFFD}').count(),
660 1,
661 "std::from_utf8_lossy would give 3 here; we must be WTF-8 aware"
662 );
663 assert_eq!(s, "\u{FFFD}");
664 }
665
666 #[test]
667 fn valid_utf8_is_borrowed_unchanged() {
668 let t = AtomTable::new();
669 let a = t.atom_bytes("greet".as_bytes().to_vec());
670 assert_eq!(t.try_bytes_str(a), Some("greet"));
671 assert_eq!(t.bytes_str_lossy(a), "greet");
672 // Zero-copy: the returned str points into the table's own bytes.
673 assert_eq!(t.bytes_str_lossy(a).as_ptr(), t.bytes(a).as_ptr());
674 }
675
676 #[test]
677 fn surrogates_mixed_with_text_replace_only_the_surrogate() {
678 let t = AtomTable::new();
679 let mut v = b"a".to_vec();
680 v.extend_from_slice(&[0xED, 0xA0, 0x80]);
681 v.extend_from_slice("b".as_bytes());
682 let a = t.atom_bytes(v);
683 assert_eq!(t.bytes_str_lossy(a), "a\u{FFFD}b");
684 }
685
686 /// How many atoms are anchored in the conversion map. Tests use it to pin
687 /// that the valid-UTF-8 path never touches it.
688 fn anchored(t: &AtomTable) -> usize {
689 unsafe { &*t.0.get() }.converted_bytes.len()
690 }
691
692 #[test]
693 fn surrogate_pair_folds_into_the_astral_char() {
694 let t = AtomTable::new();
695 // How the lexer stores `"\u{1F600}"` by default: a WTF-8 surrogate
696 // pair, which is *not* valid UTF-8 (see the `convert_surrogates` test
697 // in the parser's lexer).
698 let a = t.atom_bytes(vec![0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80]);
699 // A pair IS representable: folding it back loses nothing, so this is
700 // `Some`, not `None`. Returning `None` here would tell a
701 // correctness-sensitive caller that a perfectly ordinary emoji is
702 // corrupt.
703 assert_eq!(t.try_bytes_str(a), Some("\u{1F600}"));
704 assert_eq!(t.bytes_str_lossy(a), "\u{1F600}");
705 // Both accessors share the one anchored string: converted once.
706 assert_eq!(
707 t.try_bytes_str(a).unwrap().as_ptr(),
708 t.bytes_str_lossy(a).as_ptr()
709 );
710 assert_eq!(anchored(&t), 1);
711 }
712
713 #[test]
714 fn a_pair_beside_an_unpaired_surrogate_is_not_representable() {
715 let t = AtomTable::new();
716 let mut v = vec![0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80]; // U+1F600
717 v.extend_from_slice(&[0xED, 0xA0, 0x80]); // unpaired U+D800
718 v.extend_from_slice(b"!");
719 let a = t.atom_bytes(v);
720 // The unpaired surrogate has no UTF-8 form, so nothing exact exists.
721 assert_eq!(t.try_bytes_str(a), None);
722 // But the emoji still survives the lossy rendering intact, and only
723 // the unpaired surrogate is replaced.
724 let s = t.bytes_str_lossy(a);
725 assert_eq!(s, "\u{1F600}\u{FFFD}!");
726 assert_eq!(s.chars().filter(|c| *c == '\u{FFFD}').count(), 1);
727 }
728
729 #[test]
730 fn the_valid_path_never_anchors() {
731 let t = AtomTable::new();
732 let a = t.atom_bytes(b"plain".as_slice());
733 // Zero-copy from both accessors, and the map is never touched.
734 assert_eq!(t.try_bytes_str(a), Some("plain"));
735 assert_eq!(t.try_bytes_str(a).unwrap().as_ptr(), t.bytes(a).as_ptr());
736 assert_eq!(t.bytes_str_lossy(a).as_ptr(), t.bytes(a).as_ptr());
737 assert_eq!(anchored(&t), 0);
738 }
739
740 #[test]
741 fn the_folded_result_is_stable_across_calls() {
742 let t = AtomTable::new();
743 let a = t.atom_bytes(vec![0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80]);
744 // Held live across everything below.
745 let first: &str = t.try_bytes_str(a).unwrap();
746 for i in 0..100u8 {
747 let b = t.atom_bytes(vec![0xED, 0xA0, 0xBD, 0xED, 0xB8, 0x80, i]);
748 assert!(t.try_bytes_str(b).unwrap().starts_with('\u{1F600}'));
749 }
750 assert_eq!(first, "\u{1F600}");
751 assert_eq!(t.try_bytes_str(a).unwrap().as_ptr(), first.as_ptr());
752 }
753
754 /// The pairing edge cases, matching `convertToCodePointAt`
755 /// (UTF8.cpp:77-96): a high surrogate pairs *only* with an immediately
756 /// following low one. Everything else is unpaired — one U+FFFD each, and
757 /// never representable.
758 #[test]
759 fn unpaired_surrogates_are_one_replacement_each() {
760 let t = AtomTable::new();
761 let cases: &[(&[u8], &str)] = &[
762 // A high surrogate at end of input (C++ `cur + 1 == end`).
763 (&[0xED, 0xA0, 0x80], "\u{FFFD}"),
764 // Two high surrogates in a row: neither pairs.
765 (&[0xED, 0xA0, 0x80, 0xED, 0xA0, 0x80], "\u{FFFD}\u{FFFD}"),
766 // A low surrogate followed by a high one: NOT a reversed pair,
767 // two unpaired surrogates.
768 (&[0xED, 0xB0, 0x80, 0xED, 0xA0, 0x80], "\u{FFFD}\u{FFFD}"),
769 // Two low surrogates in a row.
770 (&[0xED, 0xB0, 0x80, 0xED, 0xB0, 0x80], "\u{FFFD}\u{FFFD}"),
771 // A lone low surrogate followed by text.
772 (&[0xED, 0xB0, 0x80, b'z'], "\u{FFFD}z"),
773 // A high surrogate whose successor is text, not a low surrogate.
774 (&[0xED, 0xA0, 0x80, b'z'], "\u{FFFD}z"),
775 ];
776 for (bytes, expected) in cases {
777 let a = t.atom_bytes(*bytes);
778 assert_eq!(t.bytes_str_lossy(a), *expected, "bytes {bytes:02X?}");
779 assert_eq!(t.try_bytes_str(a), None, "bytes {bytes:02X?}");
780 }
781 }
782
783 #[test]
784 fn non_surrogate_garbage_is_replaced_per_ill_formed_sequence() {
785 let t = AtomTable::new();
786 // Two invalid lead bytes.
787 let a = t.atom_bytes(vec![0xFF, 0xFE]);
788 assert_eq!(t.bytes_str_lossy(a), "\u{FFFD}\u{FFFD}");
789 // A truncated 3-byte sequence at the very end (error_len() == None).
790 let b = t.atom_bytes(vec![b'x', 0xE2, 0x82]);
791 assert_eq!(t.bytes_str_lossy(b), "x\u{FFFD}");
792 // Valid text on both sides of a bad byte.
793 let c = t.atom_bytes(vec![b'o', 0x80, b'k']);
794 assert_eq!(t.bytes_str_lossy(c), "o\u{FFFD}k");
795 }
796
797 #[test]
798 fn an_earlier_lossy_str_survives_later_conversions() {
799 let t = AtomTable::new();
800 let a = t.atom_bytes(vec![0xED, 0xA0, 0x80]);
801 // Held across everything below: this is the anchoring claim.
802 let first: &str = t.bytes_str_lossy(a);
803 // Grow and rehash the anchor map with 100 further lossy conversions.
804 for i in 0..100u8 {
805 let b = t.atom_bytes(vec![0xED, 0xA0, 0x80, b'a' + i % 26, i]);
806 assert!(t.bytes_str_lossy(b).starts_with('\u{FFFD}'));
807 }
808 assert_eq!(first, "\u{FFFD}");
809 }
810
811 #[test]
812 fn try_bytes_str_rejects_invalid_atoms() {
813 let t = AtomTable::new();
814 assert_eq!(t.try_bytes_str(INVALID_ATOM_BYTES), None);
815 }
816
817 #[test]
818 fn the_lossy_result_is_stable_across_calls() {
819 let t = AtomTable::new();
820 let a = t.atom_bytes(vec![0xED, 0xA0, 0x80]);
821 let p1 = t.bytes_str_lossy(a).as_ptr();
822 // Interning more atoms must not invalidate an earlier result.
823 for i in 0..1000 {
824 t.atom_bytes(format!("filler{i}").into_bytes());
825 }
826 let p2 = t.bytes_str_lossy(a).as_ptr();
827 assert_eq!(p1, p2, "the anchored String must not be rebuilt or moved");
828 }
829}