Skip to main content

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}