Skip to main content

intern_lang/
interner.rs

1//! The single-threaded [`Interner`]: a contiguous string store with a
2//! deduplicating index.
3
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::error::InternError;
9use crate::symbol::Symbol;
10
11/// Initial number of slots in the dedup index. A power of two so the hash maps to
12/// a slot with a single mask. Sixteen keeps an empty interner cheap while still
13/// avoiding an immediate resize for small inputs.
14const INITIAL_CAPACITY: usize = 16;
15
16/// Where one interned string lives inside the backing buffer. `start` and `len`
17/// are byte offsets into [`Interner::buf`]; the pair is the symbol's permanent
18/// coordinates, and because the buffer only ever appends, they never change once
19/// assigned — that is what keeps a symbol resolving to the same bytes after the
20/// store grows.
21#[derive(Clone, Copy)]
22struct Span {
23    start: usize,
24    len: usize,
25}
26
27/// One slot in the open-addressing dedup index. `id` is the 1-based symbol id, or
28/// `0` for an empty slot (symbol ids start at one, so zero is a free sentinel).
29/// `hash` caches the low 32 bits of the string's hash so a probe can reject a
30/// non-match without touching the backing buffer at all.
31#[derive(Clone, Copy)]
32struct Slot {
33    hash: u32,
34    id: u32,
35}
36
37impl Slot {
38    const EMPTY: Slot = Slot { hash: 0, id: 0 };
39
40    #[inline]
41    fn is_empty(self) -> bool {
42        self.id == 0
43    }
44}
45
46/// A single-threaded string interner.
47///
48/// `Interner` maps each distinct string to a small [`Symbol`], stores the bytes
49/// exactly once in a contiguous buffer, and hands back integer handles. Interning
50/// a string it has already seen is a hash lookup with no allocation and no copy;
51/// resolving a symbol borrows the original bytes straight out of the buffer.
52///
53/// # Design
54///
55/// Bytes live once, appended end to end in a single `String`. A symbol is an
56/// index into a side table of `(start, len)` spans into that buffer, so a symbol
57/// is four bytes regardless of how long its string is. Deduplication runs through
58/// an open-addressing hash index that stores symbol ids, not strings, so it adds
59/// no second copy of the bytes. The buffer only ever appends and the span table
60/// only ever grows, so a symbol issued early keeps resolving to the same string
61/// for the interner's whole lifetime, including after either structure
62/// reallocates — [`resolve`](Interner::resolve) recomputes the slice from the
63/// current buffer on each call rather than holding a borrowed pointer, so growth
64/// can never dangle a previously issued symbol.
65///
66/// # Capacity
67///
68/// Symbol ids span `1..=u32::MAX`, so an interner holds up to `u32::MAX` distinct
69/// strings. Reaching that bound requires interning over four billion *distinct*
70/// strings, which exhausts memory long before the id space — the span table alone
71/// would need tens of gigabytes. A defined, non-panicking exhaustion result is
72/// scheduled for a later release; until then the boundary is unreachable for any
73/// input that fits in memory.
74///
75/// # Examples
76///
77/// ```
78/// use intern_lang::Interner;
79///
80/// let mut interner = Interner::new();
81///
82/// let print = interner.intern("print");
83/// let again = interner.intern("print");
84/// let read = interner.intern("read");
85///
86/// // Deduplication: the same string always yields the same symbol.
87/// assert_eq!(print, again);
88/// assert_ne!(print, read);
89///
90/// // Resolution borrows the stored bytes back out.
91/// assert_eq!(interner.resolve(print), Some("print"));
92/// assert_eq!(interner.len(), 2);
93/// ```
94pub struct Interner {
95    /// Contiguous backing store. Every interned string's bytes are appended here
96    /// once and never moved relative to their span.
97    buf: String,
98    /// Span per symbol, indexed by the symbol's 0-based [`Symbol::index`]. Push
99    /// order is interning order, so `spans.len()` is also the next 1-based id.
100    spans: Vec<Span>,
101    /// Open-addressing dedup index. Length is a power of two; `mask` is
102    /// `len - 1`. Empty until the first insert.
103    table: Vec<Slot>,
104    /// `table.len() - 1`, for mapping a hash to a slot with a single `&`.
105    mask: usize,
106    /// Size of the symbol space: the most distinct strings this interner will
107    /// issue symbols for. Always `u32::MAX` in normal use — the constructors set
108    /// it there and nothing lowers it — so it is unreachable before memory runs
109    /// out. It exists so [`try_intern`](Interner::try_intern) has a defined,
110    /// testable exhaustion boundary.
111    max_symbols: u32,
112}
113
114impl Interner {
115    /// Creates an empty interner.
116    ///
117    /// No allocation happens until the first string is interned, so an interner
118    /// that is created but never used costs nothing.
119    ///
120    /// # Examples
121    ///
122    /// ```
123    /// use intern_lang::Interner;
124    ///
125    /// let interner = Interner::new();
126    /// assert!(interner.is_empty());
127    /// ```
128    #[inline]
129    #[must_use]
130    pub fn new() -> Self {
131        Self {
132            buf: String::new(),
133            spans: Vec::new(),
134            table: Vec::new(),
135            mask: 0,
136            max_symbols: u32::MAX,
137        }
138    }
139
140    /// Creates an empty interner sized to hold about `capacity` distinct strings
141    /// before the dedup index has to grow.
142    ///
143    /// This pre-allocates the span table and the hash index. The backing byte
144    /// buffer is left to grow on demand, since the total byte length cannot be
145    /// predicted from a string count. Use this when the rough number of distinct
146    /// identifiers is known ahead of time — for example, sizing from a previous
147    /// compilation — to avoid a series of reallocations during warm-up.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use intern_lang::Interner;
153    ///
154    /// let mut interner = Interner::with_capacity(1_024);
155    /// let sym = interner.intern("identifier");
156    /// assert_eq!(interner.resolve(sym), Some("identifier"));
157    /// ```
158    #[must_use]
159    pub fn with_capacity(capacity: usize) -> Self {
160        let mut interner = Self::new();
161        if capacity > 0 {
162            interner.spans.reserve(capacity);
163            let table_cap = table_capacity_for(capacity);
164            interner.resize_table(table_cap);
165        }
166        interner
167    }
168
169    /// Interns `s`, returning its [`Symbol`].
170    ///
171    /// If `s` has been interned before, the existing symbol is returned and
172    /// nothing is allocated or copied. Otherwise the bytes are appended to the
173    /// backing store, a fresh symbol is assigned, and that symbol is returned.
174    /// Either way the result round-trips: `interner.resolve(interner.intern(s))`
175    /// is always `Some(s)`.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use intern_lang::Interner;
181    ///
182    /// let mut interner = Interner::new();
183    /// let a = interner.intern("while");
184    /// let b = interner.intern("while");
185    /// let c = interner.intern("until");
186    ///
187    /// assert_eq!(a, b);            // deduplicated
188    /// assert_ne!(a, c);            // distinct strings, distinct symbols
189    /// assert_eq!(interner.resolve(a), Some("while"));
190    /// ```
191    ///
192    /// # Symbol-space bound
193    ///
194    /// An interner issues at most `u32::MAX` distinct symbols. `intern` is the
195    /// infallible path for the overwhelming common case where that bound is never
196    /// approached; at the bound it saturates — returning the highest symbol
197    /// without adding the string — rather than panicking. Use
198    /// [`try_intern`](Interner::try_intern) when you need the exhaustion reported
199    /// as a [`Result`] instead.
200    pub fn intern(&mut self, s: &str) -> Symbol {
201        let hash = hash_bytes(s.as_bytes());
202        if let Some(symbol) = self.lookup(s, hash) {
203            return symbol;
204        }
205        if self.is_full() {
206            // Symbol space exhausted: saturate at the highest symbol rather than
207            // panic. Unreachable in normal use (the bound is `u32::MAX`).
208            return Symbol::from_raw(self.max_symbols);
209        }
210        self.insert_new(s, hash)
211    }
212
213    /// Interns `s`, returning its [`Symbol`], or an error if the symbol space is
214    /// exhausted.
215    ///
216    /// This is the fallible counterpart to [`intern`](Interner::intern). It
217    /// behaves identically — deduplicating, allocation-free on a repeat hit —
218    /// except that interning a *new* string when the symbol space is full returns
219    /// [`InternError::SymbolSpaceExhausted`] instead of saturating. Interning a
220    /// string that already exists never fails, even at the bound.
221    ///
222    /// # Errors
223    ///
224    /// Returns [`InternError::SymbolSpaceExhausted`] when `s` is new and the
225    /// interner has already issued all of its symbols. This is unreachable for any
226    /// input that fits in memory; the method exists so a caller that must account
227    /// for the boundary can do so explicitly.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use intern_lang::Interner;
233    ///
234    /// let mut interner = Interner::new();
235    /// let sym = interner.try_intern("identifier").expect("space available");
236    /// assert_eq!(interner.resolve(sym), Some("identifier"));
237    ///
238    /// // Re-interning the same string yields the same symbol and never errors.
239    /// assert_eq!(interner.try_intern("identifier"), Ok(sym));
240    /// ```
241    pub fn try_intern(&mut self, s: &str) -> Result<Symbol, InternError> {
242        let hash = hash_bytes(s.as_bytes());
243        if let Some(symbol) = self.lookup(s, hash) {
244            return Ok(symbol);
245        }
246        if self.is_full() {
247            return Err(InternError::SymbolSpaceExhausted);
248        }
249        Ok(self.insert_new(s, hash))
250    }
251
252    /// Whether the symbol space is exhausted — no new string can be assigned a
253    /// symbol.
254    #[inline]
255    fn is_full(&self) -> bool {
256        self.spans.len() >= self.max_symbols as usize
257    }
258
259    /// Looks up `s` without interning it, returning its [`Symbol`] if it is
260    /// already present.
261    ///
262    /// Unlike [`intern`](Interner::intern), this never mutates the interner: a
263    /// miss returns `None` rather than allocating a new symbol. Use it to ask
264    /// "has this name been seen?" without growing the symbol space.
265    ///
266    /// # Examples
267    ///
268    /// ```
269    /// use intern_lang::Interner;
270    ///
271    /// let mut interner = Interner::new();
272    /// let sym = interner.intern("declared");
273    ///
274    /// assert_eq!(interner.get("declared"), Some(sym));
275    /// assert_eq!(interner.get("undeclared"), None);
276    /// ```
277    #[must_use]
278    pub fn get(&self, s: &str) -> Option<Symbol> {
279        self.lookup(s, hash_bytes(s.as_bytes()))
280    }
281
282    /// Resolves `symbol` back to the string it names, borrowing the bytes from the
283    /// backing store.
284    ///
285    /// Returns `Some(&str)` for any symbol this interner issued, and `None` for a
286    /// symbol whose id is out of range — most often one issued by a different
287    /// interner. A symbol from another interner whose id happens to fall in range
288    /// resolves to *this* interner's string at that id; symbols are only
289    /// meaningful with the interner that produced them.
290    ///
291    /// # Examples
292    ///
293    /// ```
294    /// use intern_lang::Interner;
295    ///
296    /// let mut interner = Interner::new();
297    /// let sym = interner.intern("resolved");
298    /// assert_eq!(interner.resolve(sym), Some("resolved"));
299    ///
300    /// // A symbol from an interner that issued more symbols is out of range here.
301    /// let mut other = Interner::new();
302    /// let _ = other.intern("a");
303    /// let high = other.intern("b");
304    /// assert_eq!(interner.resolve(high), None);
305    /// ```
306    #[must_use]
307    pub fn resolve(&self, symbol: Symbol) -> Option<&str> {
308        let span = self.spans.get(symbol.index())?;
309        Some(&self.buf[span.start..span.start + span.len])
310    }
311
312    /// Runs `f` against the string `symbol` names, returning its result, or `None`
313    /// if `symbol` is out of range.
314    ///
315    /// This is the [`Lookup`](crate::Lookup) trait's resolution form. For the
316    /// single-threaded interner it is a thin wrapper over
317    /// [`resolve`](Interner::resolve) — prefer `resolve` here, which hands back the
318    /// borrowed slice directly. The closure form exists so the same generic code
319    /// works against the [`ConcurrentInterner`](crate::ConcurrentInterner), where
320    /// the borrow cannot outlive the read lock.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use intern_lang::Interner;
326    ///
327    /// let mut interner = Interner::new();
328    /// let sym = interner.intern("identifier");
329    /// assert_eq!(interner.resolve_with(sym, str::len), Some(10));
330    /// ```
331    pub fn resolve_with<R, F>(&self, symbol: Symbol, f: F) -> Option<R>
332    where
333        F: FnOnce(&str) -> R,
334    {
335        self.resolve(symbol).map(f)
336    }
337
338    /// Returns the number of distinct strings interned so far.
339    ///
340    /// This is also the id that the next newly interned string will receive.
341    ///
342    /// # Examples
343    ///
344    /// ```
345    /// use intern_lang::Interner;
346    ///
347    /// let mut interner = Interner::new();
348    /// assert_eq!(interner.len(), 0);
349    /// let _ = interner.intern("x");
350    /// let _ = interner.intern("x"); // duplicate, not counted again
351    /// let _ = interner.intern("y");
352    /// assert_eq!(interner.len(), 2);
353    /// ```
354    #[inline]
355    #[must_use]
356    pub fn len(&self) -> usize {
357        self.spans.len()
358    }
359
360    /// Returns `true` if no strings have been interned.
361    ///
362    /// # Examples
363    ///
364    /// ```
365    /// use intern_lang::Interner;
366    ///
367    /// let mut interner = Interner::new();
368    /// assert!(interner.is_empty());
369    /// let _ = interner.intern("x");
370    /// assert!(!interner.is_empty());
371    /// ```
372    #[inline]
373    #[must_use]
374    pub fn is_empty(&self) -> bool {
375        self.spans.is_empty()
376    }
377
378    /// Probes the dedup index for `s`. Returns its symbol if present.
379    fn lookup(&self, s: &str, hash: u64) -> Option<Symbol> {
380        if self.table.is_empty() {
381            return None;
382        }
383        let fingerprint = hash as u32;
384        let mut idx = (hash as usize) & self.mask;
385        loop {
386            let slot = self.table[idx];
387            if slot.is_empty() {
388                return None;
389            }
390            if slot.hash == fingerprint && self.span_str(slot.id) == s {
391                return Some(Symbol::from_raw(slot.id));
392            }
393            idx = (idx + 1) & self.mask;
394        }
395    }
396
397    /// Appends `s` to the backing store, assigns it a fresh symbol, and records it
398    /// in the dedup index. The caller has already established that `s` is not
399    /// present and computed its `hash`.
400    fn insert_new(&mut self, s: &str, hash: u64) -> Symbol {
401        self.reserve_one();
402
403        let span = Span {
404            start: self.buf.len(),
405            len: s.len(),
406        };
407        self.buf.push_str(s);
408        self.spans.push(span);
409
410        // The 1-based id equals the new length of the span table.
411        let id = id_for(self.spans.len());
412        self.insert_slot(Slot {
413            hash: hash as u32,
414            id,
415        });
416        Symbol::from_raw(id)
417    }
418
419    /// Places `slot` at its first empty probe position. The table is guaranteed to
420    /// have room because [`reserve_one`](Interner::reserve_one) ran first.
421    fn insert_slot(&mut self, slot: Slot) {
422        let mut idx = (slot.hash as usize) & self.mask;
423        while !self.table[idx].is_empty() {
424            idx = (idx + 1) & self.mask;
425        }
426        self.table[idx] = slot;
427    }
428
429    /// Ensures the dedup index has room for one more entry under a 0.75 load
430    /// factor, allocating or doubling the table as needed.
431    fn reserve_one(&mut self) {
432        let occupied_after = self.spans.len() + 1;
433        if self.table.is_empty() {
434            self.resize_table(INITIAL_CAPACITY);
435        } else if occupied_after * 4 > self.table.len() * 3 {
436            self.resize_table(self.table.len() * 2);
437        }
438    }
439
440    /// Reallocates the dedup index to `new_cap` slots (a power of two) and
441    /// re-inserts every existing symbol. The backing buffer and span table are
442    /// untouched, so no symbol changes identity.
443    fn resize_table(&mut self, new_cap: usize) {
444        let mut table = Vec::new();
445        table.resize(new_cap, Slot::EMPTY);
446        let mask = new_cap - 1;
447
448        for (i, span) in self.spans.iter().enumerate() {
449            let s = &self.buf[span.start..span.start + span.len];
450            let hash = hash_bytes(s.as_bytes());
451            let id = id_for(i + 1);
452            let mut idx = (hash as usize) & mask;
453            while !table[idx].is_empty() {
454                idx = (idx + 1) & mask;
455            }
456            table[idx] = Slot {
457                hash: hash as u32,
458                id,
459            };
460        }
461
462        self.table = table;
463        self.mask = mask;
464    }
465
466    /// Returns the string for a 1-based symbol id. Only called with ids the
467    /// interner issued, so the span always exists.
468    #[inline]
469    fn span_str(&self, id: u32) -> &str {
470        let span = self.spans[id as usize - 1];
471        &self.buf[span.start..span.start + span.len]
472    }
473}
474
475impl Default for Interner {
476    #[inline]
477    fn default() -> Self {
478        Self::new()
479    }
480}
481
482impl crate::Lookup for Interner {
483    #[inline]
484    fn get(&self, s: &str) -> Option<Symbol> {
485        Interner::get(self, s)
486    }
487
488    #[inline]
489    fn resolve_with<R, F>(&self, symbol: Symbol, f: F) -> Option<R>
490    where
491        F: FnOnce(&str) -> R,
492    {
493        Interner::resolve_with(self, symbol, f)
494    }
495
496    #[inline]
497    fn len(&self) -> usize {
498        Interner::len(self)
499    }
500
501    #[inline]
502    fn is_empty(&self) -> bool {
503        Interner::is_empty(self)
504    }
505}
506
507impl fmt::Debug for Interner {
508    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509        f.debug_struct("Interner")
510            .field("strings", &self.spans.len())
511            .field("bytes", &self.buf.len())
512            .finish_non_exhaustive()
513    }
514}
515
516/// Converts a span-table length into a 1-based symbol id.
517///
518/// `len` is bounded by available memory — each interned string costs a span, a
519/// table slot, and at least one byte — so it stays within `u32` long before the
520/// cast could lose information. The saturating fallback keeps the conversion free
521/// of `unwrap`/`expect` and panics, and is unreachable for any in-memory input.
522#[inline]
523fn id_for(len: usize) -> u32 {
524    u32::try_from(len).unwrap_or(u32::MAX)
525}
526
527/// Rounds a desired distinct-string count up to a power-of-two table capacity that
528/// holds it under a 0.75 load factor, never below [`INITIAL_CAPACITY`].
529#[inline]
530fn table_capacity_for(strings: usize) -> usize {
531    let target = strings.saturating_mul(4) / 3 + 1;
532    target.max(INITIAL_CAPACITY).next_power_of_two()
533}
534
535/// Hashes `bytes` with an FxHash-style multiply-rotate over 64-bit words.
536///
537/// The string length seeds the state so that strings differing only in trailing
538/// content within a word boundary (for example `"ab"` versus `"ab\0"`) do not
539/// collide on the fast fingerprint. This is a non-cryptographic hash chosen for
540/// throughput on short identifiers; correctness never depends on it, since the
541/// dedup index always confirms a candidate with a full byte comparison.
542#[inline]
543fn hash_bytes(bytes: &[u8]) -> u64 {
544    const K: u64 = 0x517c_c1b7_2722_0a95;
545
546    let mut hash = bytes.len() as u64;
547    let mut chunks = bytes.chunks_exact(8);
548    for chunk in chunks.by_ref() {
549        // `chunks_exact(8)` always yields eight bytes, so the conversion holds;
550        // the fallback is dead and only keeps this free of `unwrap`.
551        let word = u64::from_le_bytes(<[u8; 8]>::try_from(chunk).unwrap_or([0; 8]));
552        hash = (hash.rotate_left(5) ^ word).wrapping_mul(K);
553    }
554
555    let remainder = chunks.remainder();
556    if !remainder.is_empty() {
557        let mut tail = [0u8; 8];
558        tail[..remainder.len()].copy_from_slice(remainder);
559        let word = u64::from_le_bytes(tail);
560        hash = (hash.rotate_left(5) ^ word).wrapping_mul(K);
561    }
562
563    hash
564}
565
566#[cfg(test)]
567mod tests {
568    // Unwrapping is acceptable in tests where an error cannot be meaningfully
569    // handled and a failure should fail the test.
570    #![allow(clippy::unwrap_used, clippy::expect_used)]
571
572    use proptest::prelude::*;
573
574    use super::*;
575
576    #[test]
577    fn test_intern_same_string_returns_same_symbol() {
578        let mut interner = Interner::new();
579        let a = interner.intern("name");
580        let b = interner.intern("name");
581        assert_eq!(a, b);
582        assert_eq!(interner.len(), 1);
583    }
584
585    #[test]
586    fn test_intern_distinct_strings_return_distinct_symbols() {
587        let mut interner = Interner::new();
588        let a = interner.intern("one");
589        let b = interner.intern("two");
590        assert_ne!(a, b);
591        assert_eq!(interner.len(), 2);
592    }
593
594    #[test]
595    fn test_resolve_roundtrips() {
596        let mut interner = Interner::new();
597        for s in ["", "a", "alpha", "a longer identifier with spaces"] {
598            let sym = interner.intern(s);
599            assert_eq!(interner.resolve(sym), Some(s));
600        }
601    }
602
603    #[test]
604    fn test_resolve_out_of_range_symbol_is_none() {
605        let mut issuer = Interner::new();
606        let _ = issuer.intern("a");
607        let high = issuer.intern("b");
608
609        let empty = Interner::new();
610        assert_eq!(empty.resolve(high), None);
611    }
612
613    #[test]
614    fn test_get_does_not_intern() {
615        let mut interner = Interner::new();
616        assert_eq!(interner.get("absent"), None);
617        assert_eq!(interner.len(), 0);
618        let sym = interner.intern("absent");
619        assert_eq!(interner.get("absent"), Some(sym));
620    }
621
622    #[test]
623    fn test_ids_are_sequential_from_one() {
624        let mut interner = Interner::new();
625        assert_eq!(interner.intern("a").as_u32(), 1);
626        assert_eq!(interner.intern("b").as_u32(), 2);
627        assert_eq!(interner.intern("a").as_u32(), 1);
628        assert_eq!(interner.intern("c").as_u32(), 3);
629    }
630
631    #[test]
632    fn test_growth_preserves_earlier_symbols() {
633        let mut interner = Interner::new();
634        let mut remembered = alloc::vec::Vec::new();
635        // Enough distinct strings to force several table resizes and buffer
636        // reallocations.
637        for i in 0..10_000 {
638            let s = alloc::format!("symbol_{i}");
639            remembered.push((interner.intern(&s), s));
640        }
641        for (sym, s) in &remembered {
642            assert_eq!(interner.resolve(*sym), Some(s.as_str()));
643        }
644    }
645
646    #[test]
647    fn test_empty_string_is_interned() {
648        let mut interner = Interner::new();
649        let empty = interner.intern("");
650        assert_eq!(interner.resolve(empty), Some(""));
651        assert_eq!(interner.intern(""), empty);
652    }
653
654    #[test]
655    fn test_unicode_roundtrips() {
656        let mut interner = Interner::new();
657        for s in ["café", "naïve", "日本語", "emoji 🦀", "Ωμέγα"] {
658            let sym = interner.intern(s);
659            assert_eq!(interner.resolve(sym), Some(s));
660        }
661    }
662
663    #[test]
664    fn test_with_capacity_behaves_like_new() {
665        let mut interner = Interner::with_capacity(64);
666        let sym = interner.intern("preallocated");
667        assert_eq!(interner.resolve(sym), Some("preallocated"));
668        assert_eq!(interner.len(), 1);
669    }
670
671    #[test]
672    fn test_strings_differing_only_in_trailing_byte_are_distinct() {
673        let mut interner = Interner::new();
674        let a = interner.intern("ab");
675        let b = interner.intern("ab\0");
676        assert_ne!(a, b);
677        assert_eq!(interner.resolve(a), Some("ab"));
678        assert_eq!(interner.resolve(b), Some("ab\0"));
679    }
680
681    #[test]
682    fn test_default_is_empty() {
683        let interner = Interner::default();
684        assert!(interner.is_empty());
685    }
686
687    #[test]
688    fn test_table_capacity_for_is_power_of_two_and_fits() {
689        for n in [0usize, 1, 12, 13, 100, 1000] {
690            let cap = table_capacity_for(n);
691            assert!(cap.is_power_of_two());
692            assert!(cap >= INITIAL_CAPACITY);
693            assert!(cap * 3 >= n.saturating_mul(4));
694        }
695    }
696
697    #[test]
698    fn test_try_intern_succeeds_below_the_bound() {
699        let mut interner = Interner::new();
700        let sym = interner.try_intern("ok").expect("space available");
701        assert_eq!(interner.resolve(sym), Some("ok"));
702        assert_eq!(interner.try_intern("ok"), Ok(sym));
703    }
704
705    #[test]
706    fn test_intern_saturates_at_the_bound() {
707        // Lower the symbol-space bound so the boundary is reachable in a test.
708        let mut interner = Interner::new();
709        interner.max_symbols = 2;
710        let a = interner.intern("a");
711        let b = interner.intern("b");
712        // The space is now full; a new string saturates to the highest symbol
713        // and is not stored.
714        let saturated = interner.intern("c");
715        assert_eq!(saturated.as_u32(), 2);
716        assert_eq!(interner.len(), 2);
717        // Existing strings still resolve and dedup correctly.
718        assert_eq!(interner.resolve(a), Some("a"));
719        assert_eq!(interner.intern("b"), b);
720    }
721
722    proptest! {
723        /// At the symbol-space boundary, `try_intern` reports exhaustion for a new
724        /// string while still accepting strings it already holds.
725        #[test]
726        fn try_intern_reports_exhaustion_at_the_boundary(limit in 1u32..=64) {
727            let mut interner = Interner::new();
728            interner.max_symbols = limit;
729
730            // Fill exactly to the bound with distinct strings.
731            for i in 0..limit {
732                let s = alloc::format!("s{i}");
733                prop_assert!(interner.try_intern(&s).is_ok());
734            }
735            prop_assert_eq!(interner.len(), limit as usize);
736
737            // A new distinct string is now rejected with the defined error...
738            prop_assert_eq!(
739                interner.try_intern("overflow"),
740                Err(InternError::SymbolSpaceExhausted)
741            );
742            // ...but an already-interned string still succeeds (dedup, no growth).
743            prop_assert!(interner.try_intern("s0").is_ok());
744            prop_assert_eq!(interner.len(), limit as usize);
745        }
746    }
747}