rustyfi_lang/symbol.rs
1//! Interned identifiers: [`SymbolStore`] (an append-only unique-string
2//! registry) and [`Symbol`] (a `Copy`, `u32`-sized handle into one).
3//!
4//! The brand covers the *compile side* only (`Ast` → elaborate →
5//! typecheck); the runtime side never sees a `Symbol`, because names are
6//! resolved away at the compile membrane (`compile.rs`).
7//!
8//! # Why a lifetime brand
9//!
10//! A `Symbol<'s>` is just an index, so on its own it would be meaninglessly
11//! interchangeable between stores and would outlive the store it names. The
12//! `PhantomData<&'s SymbolStore>` ties every symbol to the borrow of the store
13//! that minted it, which is what lets the AST hold bare `u32`s while the
14//! compiler still guarantees a live store is available wherever a symbol is
15//! read back as text. The brand costs nothing at run time — and it is
16//! *deliberately* confined to the front half of the pipeline: letting it reach
17//! `Value` would cascade a lifetime through all 172 `prim_*` functions for
18//! zero speed.
19//!
20//! # Interning is not enough — `resolve` must be cheap
21//!
22//! The port models namespacing with flat mangled string keys (`"M.x"`,
23//! `"\cmd"`, `"$M.atan2"`, `"%cmd_arg0"`), and several consumers *inspect that
24//! text*: the command-sigil test, `Scope::names_with_prefix`, `open_module`'s
25//! prefix scan, and — critically for byte-identity — the **lexicographic
26//! sorting** of optional-argument label rows and record kinds. A `Symbol`'s
27//! index order is *insertion* order, never lexicographic, so all of those must
28//! keep working on resolved text. [`SymbolStore::resolve`] therefore returns a
29//! borrowed `&str` (no allocation, no refcount bump) rather than an owned or
30//! reference-counted string.
31
32use std::cell::RefCell;
33use std::collections::HashMap;
34use std::marker::PhantomData;
35
36/// An interned identifier: an index into the [`SymbolStore`] that minted it,
37/// branded with that store's borrow lifetime.
38///
39/// `Copy` + `Eq` + `Hash` in one `u32`, so scope stacks, type environments and
40/// the compiler's lexical frames compare and hash identifiers by integer
41/// instead of by string content.
42///
43/// Equality is index equality, which is *exactly* string equality for symbols
44/// from the same store (the store deduplicates on intern). Comparing symbols
45/// from two different stores is meaningless but not unsound — see
46/// [`SymbolStore::resolve`].
47///
48/// [`Ord`] is **index order (insertion order), not lexicographic order.** It
49/// exists only so symbols can key a `BTreeMap`/`BTreeSet` for deterministic
50/// iteration within one run. Anywhere the *output* depends on ordering (type
51/// error text, record kinds, optional-label rows), sort by
52/// [`SymbolStore::resolve`] text instead — see this module's header.
53#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct Symbol<'s>(u32, PhantomData<&'s SymbolStore>);
55
56impl Symbol<'_> {
57 /// The raw index. Useful for dense side-tables keyed by symbol; carries no
58 /// meaning without the store that minted it.
59 #[inline]
60 pub fn index(self) -> u32 {
61 self.0
62 }
63}
64
65/// Prints the index, not the text — a `Symbol` is a bare `u32` plus a
66/// zero-sized brand, so it has no way to reach its store from here.
67///
68/// This is a deliberate choice: golden tests must diff
69/// *resolved* strings produced at their format site, never `Debug`-of-AST.
70impl std::fmt::Debug for Symbol<'_> {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 write!(f, "Symbol({})", self.0)
73 }
74}
75
76/// An append-only, deduplicating registry of identifier strings.
77///
78/// Interning takes `&self`, not `&mut self`: elaboration and typechecking both
79/// *mint new derived names mid-pass* (`qualify_key`'s `"M.x"`, `$`-mangled
80/// module keys, `"%patbind…"`, fresh desugar names like `"%cmd_arg0"`,
81/// qualified constructor lookup keys), so the store must stay interned-into
82/// while the tree it brands is being walked. Hence the interior mutability.
83///
84/// Entries are never removed or mutated, which is what makes [`resolve`]
85/// able to hand out `&str`s that live as long as the store borrow.
86///
87/// [`resolve`]: SymbolStore::resolve
88#[derive(Default)]
89pub struct SymbolStore {
90 inner: RefCell<StoreInner>,
91}
92
93#[derive(Default)]
94struct StoreInner {
95 /// Index → text. `Box<str>` (not `String`) because the pointed-to bytes
96 /// must never move once pushed: growing this `Vec` relocates the *boxes*,
97 /// not the string data they own, which is the invariant `resolve`'s
98 /// lifetime extension rests on.
99 texts: Vec<Box<str>>,
100 /// Text → index, for dedup. Keys alias `texts`' contents conceptually but
101 /// are stored separately (a second `Box<str>`) to keep this safe code.
102 index: HashMap<Box<str>, u32>,
103}
104
105impl SymbolStore {
106 pub fn new() -> SymbolStore {
107 SymbolStore::default()
108 }
109
110 /// Intern `text`, returning its symbol. Interning the same text twice
111 /// returns the same symbol.
112 pub fn intern<'s>(&'s self, text: &str) -> Symbol<'s> {
113 let mut inner = self.inner.borrow_mut();
114 if let Some(&i) = inner.index.get(text) {
115 return Symbol(i, PhantomData);
116 }
117 let i = u32::try_from(inner.texts.len())
118 .expect("SymbolStore overflow: more than u32::MAX distinct identifiers");
119 let boxed: Box<str> = text.into();
120 inner.index.insert(boxed.clone(), i);
121 inner.texts.push(boxed);
122 Symbol(i, PhantomData)
123 }
124
125 /// The text `sym` was interned from.
126 ///
127 /// # Panics
128 ///
129 /// If `sym` was minted by a *different* store that happened to share this
130 /// one's borrow lifetime and holds more entries than this one. The brand
131 /// makes that hard to write by accident and it is not unsound — the bounds
132 /// check below turns it into a panic rather than a bogus read — but a
133 /// program should still keep one store per pipeline run.
134 pub fn resolve<'s>(&'s self, sym: Symbol<'s>) -> &'s str {
135 let inner = self.inner.borrow();
136 let s: &str = inner
137 .texts
138 .get(sym.0 as usize)
139 .unwrap_or_else(|| panic!("Symbol({}) does not belong to this SymbolStore", sym.0));
140 // SAFETY: `s` points into the heap allocation owned by a `Box<str>`
141 // that lives in `inner.texts`. That allocation's address is fixed for
142 // as long as the box exists, and:
143 // * entries are only ever *pushed* — never removed, replaced, or
144 // mutated (the only writer is `intern`, which pushes);
145 // * growing `texts` relocates the `Box` pointers, not the `str`
146 // bytes they point at;
147 // * the data outlives the `RefCell` borrow guard and is dropped only
148 // with `self`,
149 // so widening the guard-scoped borrow to `&'s self`'s lifetime does
150 // not create a dangling or aliasing-mutable reference. This is the
151 // standard interner pattern; it is needed because a `RefCell` cannot
152 // otherwise lend out data past the guard.
153 unsafe { &*(s as *const str) }
154 }
155
156 /// How many distinct strings have been interned. (Also the index the next
157 /// new symbol will get — useful for sizing dense side-tables.)
158 pub fn len(&self) -> usize {
159 self.inner.borrow().texts.len()
160 }
161
162 pub fn is_empty(&self) -> bool {
163 self.len() == 0
164 }
165}
166
167impl std::fmt::Debug for SymbolStore {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 write!(f, "SymbolStore({} symbols)", self.len())
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use std::collections::HashSet;
177
178 #[test]
179 fn intern_dedups_and_resolves() {
180 let store = SymbolStore::new();
181 let a = store.intern("foo");
182 let b = store.intern("bar");
183 let a2 = store.intern("foo");
184 assert_eq!(a, a2);
185 assert_ne!(a, b);
186 assert_eq!(store.resolve(a), "foo");
187 assert_eq!(store.resolve(b), "bar");
188 assert_eq!(store.len(), 2);
189 }
190
191 #[test]
192 fn indices_are_dense_and_insertion_ordered() {
193 let store = SymbolStore::new();
194 assert!(store.is_empty());
195 let syms: Vec<_> = ["z", "y", "x"].iter().map(|s| store.intern(s)).collect();
196 assert_eq!(
197 syms.iter().map(|s| s.index()).collect::<Vec<_>>(),
198 vec![0, 1, 2]
199 );
200 let mut sorted = syms.clone();
201 sorted.sort();
202 assert_eq!(sorted, syms);
203 let mut by_text: Vec<&str> = syms.iter().map(|&s| store.resolve(s)).collect();
204 by_text.sort_unstable();
205 assert_eq!(by_text, vec!["x", "y", "z"]);
206 }
207
208 #[test]
209 fn resolved_borrows_survive_further_interning() {
210 // resolve()'s borrow must survive further interning (elaborate/
211 // typecheck mint derived names mid-walk).
212 let store = SymbolStore::new();
213 let first = store.resolve(store.intern("first"));
214 for i in 0..10_000 {
215 store.intern(&format!("derived%{i}"));
216 }
217 assert_eq!(first, "first");
218 assert_eq!(store.len(), 10_001);
219 }
220
221 #[test]
222 fn mangled_keys_round_trip_verbatim() {
223 // The port's identifiers are mangled composites; nothing about them is
224 // normalized on the way through.
225 let store = SymbolStore::new();
226 for key in ["M.x", "\\cmd", "+p", "M.\\cmd", "$M.atan2", "%context"] {
227 assert_eq!(store.resolve(store.intern(key)), key);
228 }
229 assert_eq!(store.len(), 6);
230 }
231
232 #[test]
233 fn symbols_are_hashable_keys() {
234 let store = SymbolStore::new();
235 let set: HashSet<Symbol<'_>> = ["a", "b", "a", "c"]
236 .iter()
237 .map(|s| store.intern(s))
238 .collect();
239 assert_eq!(set.len(), 3);
240 assert_eq!(std::mem::size_of::<Symbol<'_>>(), 4);
241 }
242}