Skip to content

Commit fac08cd

Browse files
committed
Add StringInterner
1 parent 7807b0c commit fac08cd

3 files changed

Lines changed: 222 additions & 0 deletions

File tree

rust/ruby-rbs/src/ids.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ impl<T> std::fmt::Debug for Id<T> {
118118
}
119119
}
120120

121+
/// Tag for IDs produced by the string interner.
122+
pub enum SymbolTag {}
123+
124+
/// Identifier for an interned string. Content-addressed: `xxh3_64` of the
125+
/// string bytes.
126+
pub type SymbolId = Id<SymbolTag>;
127+
121128
#[cfg(test)]
122129
mod tests {
123130
use super::*;

rust/ruby-rbs/src/interner.rs

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
//! Content-addressed string interner producing [`SymbolId`]s.
2+
//!
3+
//! Each [`SymbolId`] is the `xxh3_64` hash of the interned bytes, so the
4+
//! same string always produces the same `SymbolId` — regardless of which
5+
//! [`StringInterner`] (and therefore which thread) it was interned in. To merge
6+
//! per-thread interners into one, just take the union of their backing
7+
//! maps; no `Remap` walk is needed.
8+
//!
9+
//! ```
10+
//! use ruby_rbs::interner::StringInterner;
11+
//!
12+
//! let mut a = StringInterner::new();
13+
//! let mut b = StringInterner::new();
14+
//! let a_string = a.intern("String");
15+
//! let a_int = a.intern("Integer");
16+
//! let b_string = b.intern("String");
17+
//! let b_array = b.intern("Array");
18+
//!
19+
//! // Same content ⇒ same id across independent interners.
20+
//! assert_eq!(a_string, b_string);
21+
//!
22+
//! let mut global = StringInterner::new();
23+
//! global.merge(a);
24+
//! global.merge(b);
25+
//!
26+
//! assert_eq!(global.resolve(a_int), "Integer");
27+
//! assert_eq!(global.resolve(b_array), "Array");
28+
//! ```
29+
30+
use crate::ids::SymbolId;
31+
use std::collections::HashMap;
32+
use xxhash_rust::xxh3::xxh3_64;
33+
34+
/// Interns strings and assigns each the content-addressed [`SymbolId`]
35+
/// `xxh3_64(s.as_bytes())`.
36+
///
37+
/// One `StringInterner` per thread during parallel work, then [`merge`] them all
38+
/// into a single destination `StringInterner` for the final shared view.
39+
///
40+
/// [`merge`]: Self::merge
41+
#[derive(Default)]
42+
pub struct StringInterner {
43+
map: HashMap<SymbolId, Box<str>>,
44+
}
45+
46+
impl StringInterner {
47+
#[must_use]
48+
pub fn new() -> Self {
49+
Self::default()
50+
}
51+
52+
/// Returns the content-addressed [`SymbolId`] for `s`, allocating
53+
/// storage only when `s` is new to this interner.
54+
pub fn intern(&mut self, s: &str) -> SymbolId {
55+
let id = SymbolId::from_hash(xxh3_64(s.as_bytes()));
56+
self.map.entry(id).or_insert_with(|| Box::<str>::from(s));
57+
id
58+
}
59+
60+
/// Returns the string previously interned for `id`.
61+
///
62+
/// # Panics
63+
/// If `id` was not issued by this interner (or one merged into it).
64+
#[must_use]
65+
pub fn resolve(&self, id: SymbolId) -> &str {
66+
&self.map[&id]
67+
}
68+
69+
/// Returns the string for `id`, or `None` if it was never interned here.
70+
#[must_use]
71+
pub fn try_resolve(&self, id: SymbolId) -> Option<&str> {
72+
self.map.get(&id).map(|s| &**s)
73+
}
74+
75+
/// Returns the number of interned strings.
76+
#[must_use]
77+
pub fn len(&self) -> usize {
78+
self.map.len()
79+
}
80+
81+
#[must_use]
82+
pub fn is_empty(&self) -> bool {
83+
self.map.is_empty()
84+
}
85+
86+
/// Move every entry from `other` into `self`. Because IDs are
87+
/// content-addressed, entries already present in `self` are kept; new
88+
/// ones are absorbed without reallocating their `Box<str>` storage.
89+
pub fn merge(&mut self, other: StringInterner) {
90+
for (id, boxed) in other.map {
91+
self.map.entry(id).or_insert(boxed);
92+
}
93+
}
94+
}
95+
96+
#[cfg(test)]
97+
mod tests {
98+
use super::*;
99+
100+
#[test]
101+
fn intern_is_stable_within_one_interner() {
102+
let mut i = StringInterner::new();
103+
let a1 = i.intern("String");
104+
let a2 = i.intern("String");
105+
let b = i.intern("Integer");
106+
assert_eq!(a1, a2);
107+
assert_ne!(a1, b);
108+
assert_eq!(i.resolve(a1), "String");
109+
assert_eq!(i.resolve(b), "Integer");
110+
assert_eq!(i.len(), 2);
111+
}
112+
113+
#[test]
114+
fn same_string_yields_same_id_across_interners() {
115+
let mut a = StringInterner::new();
116+
let mut b = StringInterner::new();
117+
assert_eq!(a.intern("String"), b.intern("String"));
118+
assert_eq!(a.intern(""), b.intern(""));
119+
}
120+
121+
#[test]
122+
fn merge_unions_entries() {
123+
let mut local = StringInterner::new();
124+
let l_string = local.intern("String");
125+
let l_array = local.intern("Array");
126+
127+
let mut global = StringInterner::new();
128+
let g_string = global.intern("String");
129+
let g_int = global.intern("Integer");
130+
131+
// Same content ⇒ same id, even before merge.
132+
assert_eq!(l_string, g_string);
133+
134+
global.merge(local);
135+
136+
assert_eq!(global.resolve(l_string), "String");
137+
assert_eq!(global.resolve(l_array), "Array");
138+
assert_eq!(global.resolve(g_int), "Integer");
139+
assert_eq!(global.len(), 3);
140+
}
141+
142+
#[test]
143+
fn merge_into_empty_global() {
144+
let mut local = StringInterner::new();
145+
let a = local.intern("Foo");
146+
let b = local.intern("Bar");
147+
148+
let mut global = StringInterner::new();
149+
global.merge(local);
150+
151+
assert_eq!(global.resolve(a), "Foo");
152+
assert_eq!(global.resolve(b), "Bar");
153+
assert_eq!(global.len(), 2);
154+
}
155+
156+
#[test]
157+
fn interner_is_send() {
158+
fn assert_send<T: Send>() {}
159+
assert_send::<StringInterner>();
160+
}
161+
162+
#[test]
163+
fn parallel_intern_then_merge() {
164+
// Each thread interns some strings with overlap. Because IDs are
165+
// content-addressed, no remap step is needed.
166+
let inputs: Vec<Vec<&'static str>> = vec![
167+
vec!["String", "Integer", "Foo"],
168+
vec!["String", "Array", "Bar"],
169+
vec!["Integer", "Array", "Baz"],
170+
];
171+
172+
let handles: Vec<_> = inputs
173+
.into_iter()
174+
.map(|words| {
175+
std::thread::spawn(move || {
176+
let mut interner = StringInterner::new();
177+
let ids: Vec<SymbolId> = words.iter().map(|w| interner.intern(w)).collect();
178+
(interner, ids, words)
179+
})
180+
})
181+
.collect();
182+
183+
let per_thread: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
184+
185+
let mut global = StringInterner::new();
186+
let mut translated: Vec<(Vec<SymbolId>, Vec<&'static str>)> = Vec::new();
187+
for (local, ids, words) in per_thread {
188+
global.merge(local);
189+
translated.push((ids, words));
190+
}
191+
192+
// Every id resolves to its original string in the global interner.
193+
for (ids, words) in &translated {
194+
for (id, word) in ids.iter().zip(words.iter()) {
195+
assert_eq!(global.resolve(*id), *word);
196+
}
197+
}
198+
199+
// The same string always yields the same SymbolId across threads.
200+
let mut expected = std::collections::HashMap::<&str, SymbolId>::new();
201+
for (ids, words) in &translated {
202+
for (id, word) in ids.iter().zip(words.iter()) {
203+
if let Some(&prev) = expected.get(*word) {
204+
assert_eq!(prev, *id, "{word} got different ids");
205+
} else {
206+
expected.insert(*word, *id);
207+
}
208+
}
209+
}
210+
211+
// Unique strings across all threads: String, Integer, Foo, Array, Bar, Baz = 6.
212+
assert_eq!(global.len(), 6);
213+
}
214+
}

rust/ruby-rbs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
pub mod ids;
2+
pub mod interner;
23
pub mod node;

0 commit comments

Comments
 (0)