Skip to main content

vtcode_commons/
interner.rs

1#![expect(
2    clippy::cast_possible_truncation,
3    reason = "Arena offsets are bounded by the compact interner representation and every handle is range-checked in `get_bytes`."
4)]
5
6//! Arena-based string interner for memory-efficient string deduplication.
7//!
8//! Stores all strings in a single contiguous buffer to minimize allocations
9//! and improve cache locality. Uses a hash-based lookup for O(1) interning.
10//!
11//! # Example
12//!
13//! ```
14//! use vtcode_commons::interner::StringInterner;
15//!
16//! let mut interner = StringInterner::new();
17//! let id1 = interner.intern("src/lib.rs");
18//! let id2 = interner.intern("src/lib.rs");
19//! assert_eq!(id1, id2);
20//! assert_eq!(interner.get(id1), Some("src/lib.rs"));
21//! ```
22
23use std::hash::{Hash, Hasher};
24
25use hashbrown::HashMap;
26use rustc_hash::FxHasher;
27use serde::{Deserialize, Serialize};
28use smallvec::SmallVec;
29
30/// Type alias for HashMap with u64 keys that are already hashed.
31type U64NoHashMap<V> = HashMap<u64, V, rustc_hash::FxBuildHasher>;
32
33/// A compact identifier for an interned string.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
35pub struct StringId(u32);
36
37impl StringId {
38    /// Create a new StringId from a raw u32 value.
39    #[inline]
40    const fn new(id: u32) -> Self {
41        Self(id)
42    }
43
44    /// Get the raw u32 value.
45    #[inline]
46    pub const fn as_u32(self) -> u32 {
47        self.0
48    }
49}
50
51/// Arena-based string interner for efficient string deduplication.
52#[derive(Debug, Clone, Default)]
53pub struct StringInterner {
54    arena: Vec<u8>,
55    lookup: U64NoHashMap<SmallVec<[StringId; 1]>>,
56    offsets: Vec<(u32, u32)>,
57}
58
59impl StringInterner {
60    /// Create a new empty interner.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Create an interner with pre-allocated capacity.
67    #[must_use]
68    pub fn with_capacity(string_bytes: usize, num_strings: usize) -> Self {
69        Self {
70            arena: Vec::with_capacity(string_bytes),
71            lookup: U64NoHashMap::with_capacity_and_hasher(num_strings, rustc_hash::FxBuildHasher),
72            offsets: Vec::with_capacity(num_strings),
73        }
74    }
75
76    /// Intern a byte string, returning its StringId.
77    fn intern_bytes(&mut self, s: &[u8]) -> StringId {
78        let hash = Self::hash_bytes(s);
79
80        if let Some(ids) = self.lookup.get(&hash) {
81            for &id in ids {
82                if self.get_bytes(id) == Some(s) {
83                    return id;
84                }
85            }
86        }
87
88        let start = self.arena.len() as u32;
89        let len = s.len() as u32;
90        self.arena.extend_from_slice(s);
91        let id = StringId::new(self.offsets.len() as u32);
92        self.offsets.push((start, len));
93        self.lookup.entry(hash).or_default().push(id);
94        id
95    }
96
97    /// Intern a UTF-8 string. Convenience wrapper around `intern_bytes`.
98    #[inline]
99    pub fn intern(&mut self, s: &str) -> StringId {
100        self.intern_bytes(s.as_bytes())
101    }
102
103    /// Get the StringId for a byte string without interning it.
104    fn get_bytes_id(&self, s: &[u8]) -> Option<StringId> {
105        let hash = Self::hash_bytes(s);
106        if let Some(ids) = self.lookup.get(&hash) {
107            for &id in ids {
108                if self.get_bytes(id) == Some(s) {
109                    return Some(id);
110                }
111            }
112        }
113        None
114    }
115
116    /// Get the StringId for a UTF-8 string without interning it.
117    #[inline]
118    fn get_id(&self, s: &str) -> Option<StringId> {
119        self.get_bytes_id(s.as_bytes())
120    }
121
122    /// Get the raw bytes for a StringId.
123    ///
124    /// The range check lives here and not only at interning time: `get` is
125    /// public and returns `Option`, so a stale or foreign handle must be a
126    /// recoverable miss. Indexing `offsets` directly would panic on a handle
127    /// this interner never issued, and would abort the caller instead of
128    /// letting it drop the entry.
129    fn get_bytes(&self, id: StringId) -> Option<&[u8]> {
130        let (start, len) = *self.offsets.get(usize::try_from(id.0).ok()?)?;
131        self.arena.get(start as usize..(start as usize + len as usize))
132    }
133
134    /// Get the string for a StringId, if it's valid UTF-8.
135    pub fn get(&self, id: StringId) -> Option<&str> {
136        self.get_bytes(id).and_then(|b| std::str::from_utf8(b).ok())
137    }
138
139    /// Number of interned strings.
140    #[inline]
141    fn len(&self) -> usize {
142        self.offsets.len()
143    }
144
145    /// Check if the interner is empty.
146    #[inline]
147    pub fn is_empty(&self) -> bool {
148        self.offsets.is_empty()
149    }
150
151    /// Compute FxHash of a byte slice.
152    #[inline]
153    fn hash_bytes(s: &[u8]) -> u64 {
154        let mut hasher = FxHasher::default();
155        s.hash(&mut hasher);
156        hasher.finish()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn test_basic_interning() {
166        let mut interner = StringInterner::new();
167        let id1 = interner.intern("src");
168        let id2 = interner.intern("lib");
169        let id3 = interner.intern("src");
170        assert_eq!(id1, id3);
171        assert_ne!(id1, id2);
172        assert_eq!(interner.get(id1), Some("src"));
173        assert_eq!(interner.len(), 2);
174    }
175
176    #[test]
177    fn test_bytes_interning() {
178        let mut interner = StringInterner::new();
179        let id1 = interner.intern_bytes(b"hello");
180        assert_eq!(interner.get(id1), Some("hello"));
181    }
182
183    #[test]
184    fn test_get_id() {
185        let mut interner = StringInterner::new();
186        let id_src = interner.intern("src");
187        assert_eq!(interner.get_id("src"), Some(id_src));
188        assert_eq!(interner.get_id("nonexistent"), None);
189    }
190
191    #[test]
192    fn stale_handle_is_a_miss_not_a_panic() {
193        // `get` is public and returns `Option`, so a handle this interner
194        // never issued must resolve to `None`. Indexing the offset table
195        // directly panicked here instead of reporting the miss.
196        let interner = StringInterner::new();
197        let never_issued = StringId::new(u32::MAX);
198        assert_eq!(interner.get(never_issued), None);
199
200        let mut populated = StringInterner::new();
201        let issued = populated.intern("src");
202        let beyond_len = StringId::new(issued.as_u32() + 1);
203        assert_eq!(populated.get(beyond_len), None, "handle past the offset table must miss");
204        assert_eq!(populated.get(issued), Some("src"), "live handle must still resolve");
205    }
206}