Skip to main content

sui_intern/
lib.rs

1//! String interning for attribute names and identifiers.
2//!
3//! Converts heap-allocated `String` comparisons into cheap `u32`
4//! comparisons. Every unique string is stored exactly once; lookups
5//! and comparisons use the [`Symbol`] handle (a `u32` index).
6//!
7//! # Performance Impact
8//!
9//! Attrset key operations (`GetAttr`, `HasAttr`, `MakeAttrs`, `UpdateAttrs`)
10//! go from O(n) string comparison to O(1) integer comparison. This is
11//! the single highest-ROI optimization for Nix evaluation because
12//! nixpkgs is dominated by attrset operations.
13//!
14//! # Storage
15//!
16//! Strings are stored as `Rc<str>`. The forward map's key and the reverse
17//! `strings` vector share the same allocation — no double-allocation on
18//! intern, and `resolve_rc` returns a cheap `Rc::clone` instead of a
19//! full `String::from` copy. Hashing uses `FxHashMap` (rustc-hash) —
20//! ~2x faster than SipHash for the short strings typical of Nix keys
21//! and identifiers.
22//!
23//! # Thread-Local Helpers
24//!
25//! For convenience in single-threaded evaluation, this crate provides
26//! [`intern()`] and [`resolve()`] free functions that operate on a
27//! thread-local [`Interner`] instance. [`prewarm()`] pre-interns a
28//! curated set of hot nixpkgs symbols so common names get low indices
29//! and the hashmap's initial resize cost is paid upfront.
30
31use std::cell::RefCell;
32use std::fmt;
33use std::rc::Rc;
34
35use rustc_hash::FxHashMap;
36
37/// `ContentMemo` + the `thread_local_content_memo!` macro — a byte-neutral,
38/// content-keyed memo of a pure function (the extracted shape hand-rolled at
39/// the NAR-hash / referenced-idents / overlay-flatten memo sites).
40pub mod memo;
41
42/// An interned string handle — a cheap, copyable, comparable token.
43///
44/// Two `Symbol`s are equal if and only if they refer to the same
45/// interned string. Comparison is a single `u32 ==` operation.
46#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
47pub struct Symbol(u32);
48
49impl Symbol {
50    /// Return the raw index for serialization / debugging.
51    #[must_use]
52    pub fn index(self) -> u32 {
53        self.0
54    }
55}
56
57impl fmt::Debug for Symbol {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(f, "Symbol({})", self.0)
60    }
61}
62
63impl fmt::Display for Symbol {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "#{}", self.0)
66    }
67}
68
69/// A string interner that maps strings to [`Symbol`] handles.
70///
71/// Thread-local, single-owner. For a Nix evaluator this is sufficient
72/// because evaluation is single-threaded.
73#[derive(Clone, Default)]
74pub struct Interner {
75    /// Forward map: string content -> symbol. The `Rc<str>` key is
76    /// shared with the reverse `strings` vector, so interning allocates
77    /// the UTF-8 bytes exactly once.
78    map: FxHashMap<Rc<str>, Symbol>,
79    /// Reverse map: symbol index -> string content.
80    strings: Vec<Rc<str>>,
81}
82
83impl Interner {
84    /// Create a new, empty interner.
85    #[must_use]
86    pub fn new() -> Self {
87        Self {
88            map: FxHashMap::default(),
89            strings: Vec::new(),
90        }
91    }
92
93    /// Create an interner with pre-allocated capacity. Use when you
94    /// know the approximate identifier count upfront — avoids hashmap
95    /// resizes during the hot path.
96    #[must_use]
97    pub fn with_capacity(cap: usize) -> Self {
98        Self {
99            map: FxHashMap::with_capacity_and_hasher(cap, rustc_hash::FxBuildHasher::default()),
100            strings: Vec::with_capacity(cap),
101        }
102    }
103
104    /// Intern a string, returning its symbol. If the string was already
105    /// interned, returns the existing symbol (O(1) amortized).
106    pub fn intern(&mut self, s: &str) -> Symbol {
107        if let Some(&sym) = self.map.get(s) {
108            return sym;
109        }
110        let rc: Rc<str> = Rc::from(s);
111        let sym = Symbol(u32::try_from(self.strings.len()).expect("interner overflow"));
112        self.strings.push(Rc::clone(&rc));
113        self.map.insert(rc, sym);
114        sym
115    }
116
117    /// Resolve a symbol back to its string content.
118    ///
119    /// # Panics
120    ///
121    /// Panics if the symbol was not produced by this interner.
122    #[must_use]
123    pub fn resolve(&self, sym: Symbol) -> &str {
124        &self.strings[sym.0 as usize]
125    }
126
127    /// Resolve a symbol to its shared `Rc<str>` handle. Cheap to clone
128    /// and pass around; prefer this over `resolve(...).to_string()` in
129    /// hot paths.
130    ///
131    /// # Panics
132    ///
133    /// Panics if the symbol was not produced by this interner.
134    #[must_use]
135    pub fn resolve_rc(&self, sym: Symbol) -> Rc<str> {
136        Rc::clone(&self.strings[sym.0 as usize])
137    }
138
139    /// Try to resolve a symbol, returning `None` if invalid.
140    #[must_use]
141    pub fn try_resolve(&self, sym: Symbol) -> Option<&str> {
142        self.strings.get(sym.0 as usize).map(AsRef::as_ref)
143    }
144
145    /// Look up a symbol for a string without interning it.
146    #[must_use]
147    pub fn lookup(&self, s: &str) -> Option<Symbol> {
148        self.map.get(s).copied()
149    }
150
151    /// Return the number of interned strings.
152    #[must_use]
153    pub fn len(&self) -> usize {
154        self.strings.len()
155    }
156
157    /// Whether the interner is empty.
158    #[must_use]
159    pub fn is_empty(&self) -> bool {
160        self.strings.is_empty()
161    }
162}
163
164impl fmt::Debug for Interner {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        write!(f, "Interner({} strings)", self.strings.len())
167    }
168}
169
170// -- Thread-local interner helpers --
171
172thread_local! {
173    static GLOBAL_INTERNER: RefCell<Interner> = RefCell::new(Interner::with_capacity(512));
174}
175
176/// Intern a string using the thread-local interner.
177pub fn intern(s: &str) -> Symbol {
178    GLOBAL_INTERNER.with(|i| i.borrow_mut().intern(s))
179}
180
181/// Resolve a symbol using the thread-local interner.
182///
183/// Allocates a fresh `String`. Prefer [`resolve_rc`] or [`with_resolved`]
184/// in hot paths — `Rc::clone` is ~20x cheaper than `String::from` for
185/// typical identifier lengths.
186#[must_use]
187pub fn resolve(sym: Symbol) -> String {
188    GLOBAL_INTERNER.with(|i| i.borrow().resolve(sym).to_string())
189}
190
191/// Resolve a symbol to a shared `Rc<str>` handle. Zero-copy.
192#[must_use]
193pub fn resolve_rc(sym: Symbol) -> Rc<str> {
194    GLOBAL_INTERNER.with(|i| i.borrow().resolve_rc(sym))
195}
196
197/// Borrow the resolved string inside a closure without allocating.
198/// The thread-local interner stays locked for the duration of `f`.
199pub fn with_resolved<F, R>(sym: Symbol, f: F) -> R
200where
201    F: FnOnce(&str) -> R,
202{
203    GLOBAL_INTERNER.with(|i| f(i.borrow().resolve(sym)))
204}
205
206/// Look up a symbol for a string in the thread-local interner without
207/// interning it.
208#[must_use]
209pub fn lookup(s: &str) -> Option<Symbol> {
210    GLOBAL_INTERNER.with(|i| i.borrow().lookup(s))
211}
212
213/// Intern the hot nixpkgs/flake/stdenv symbol set so they get low
214/// `Symbol` indices and the thread-local hashmap pays its first few
215/// resizes upfront instead of on the eval hot path.
216///
217/// Call this once per thread before the first eval. Idempotent —
218/// re-running is cheap because every symbol is a hashmap hit after
219/// the first pass.
220///
221/// The curated list covers the identifiers that dominate nixpkgs
222/// attribute access: derivation fields (`name`, `src`, `buildInputs`
223/// …), module-system glue (`config`, `options`, `mkOption`, `mkIf`
224/// …), flake schema (`inputs`, `outputs`, `description` …), meta
225/// (`meta`, `platforms`, `license` …), and the empty string used by
226/// string context machinery.
227pub fn prewarm() {
228    GLOBAL_INTERNER.with(|i| {
229        let mut guard = i.borrow_mut();
230        for s in HOT_SYMBOLS {
231            guard.intern(s);
232        }
233    });
234}
235
236/// Hot symbols pre-interned by [`prewarm`]. Order is load-bearing —
237/// the first entry gets Symbol(0), the second Symbol(1), etc.  Leave
238/// the empty string first so `Symbol(0)` means "empty" by convention.
239const HOT_SYMBOLS: &[&str] = &[
240    // Sentinels
241    "",
242    // Derivation fields
243    "name",
244    "pname",
245    "version",
246    "src",
247    "system",
248    "builder",
249    "args",
250    "outputs",
251    "out",
252    "dev",
253    "bin",
254    "man",
255    "doc",
256    "outputHash",
257    "outputHashAlgo",
258    "outputHashMode",
259    "passAsFile",
260    "preferLocalBuild",
261    "allowSubstitutes",
262    // Build dependencies
263    "buildInputs",
264    "nativeBuildInputs",
265    "propagatedBuildInputs",
266    "propagatedNativeBuildInputs",
267    "checkInputs",
268    "nativeCheckInputs",
269    "buildPhase",
270    "installPhase",
271    "configurePhase",
272    "patchPhase",
273    "unpackPhase",
274    // Module system
275    "config",
276    "options",
277    "imports",
278    "_module",
279    "mkOption",
280    "mkDefault",
281    "mkForce",
282    "mkIf",
283    "mkMerge",
284    "mkOverride",
285    "type",
286    "default",
287    "description",
288    "example",
289    "visible",
290    "internal",
291    "readOnly",
292    // Flake schema
293    "inputs",
294    "url",
295    "flake",
296    "follows",
297    "packages",
298    "devShells",
299    "apps",
300    "overlays",
301    "nixosModules",
302    "nixosConfigurations",
303    "darwinModules",
304    "darwinConfigurations",
305    "homeModules",
306    "homeConfigurations",
307    "templates",
308    "checks",
309    "formatter",
310    "legacyPackages",
311    // nixpkgs conventions
312    "nixpkgs",
313    "pkgs",
314    "stdenv",
315    "lib",
316    "hostPlatform",
317    "buildPlatform",
318    "targetPlatform",
319    "isDarwin",
320    "isLinux",
321    "x86_64-linux",
322    "aarch64-linux",
323    "x86_64-darwin",
324    "aarch64-darwin",
325    // Meta
326    "meta",
327    "platforms",
328    "homepage",
329    "license",
330    "maintainers",
331    "mainProgram",
332    "available",
333    "broken",
334    "insecure",
335    "unsupported",
336    // String context + laziness helpers
337    "recurseIntoAttrs",
338    "__functor",
339    "__toString",
340    "__ignoreNulls",
341    "outPath",
342    "drvPath",
343    "attrs",
344    "outputName",
345    // Common value identifiers
346    "true",
347    "false",
348    "null",
349];
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn intern_returns_same_symbol() {
357        let mut interner = Interner::new();
358        let s1 = interner.intern("hello");
359        let s2 = interner.intern("hello");
360        assert_eq!(s1, s2);
361    }
362
363    #[test]
364    fn different_strings_different_symbols() {
365        let mut interner = Interner::new();
366        let s1 = interner.intern("hello");
367        let s2 = interner.intern("world");
368        assert_ne!(s1, s2);
369    }
370
371    #[test]
372    fn resolve_roundtrip() {
373        let mut interner = Interner::new();
374        let sym = interner.intern("foo");
375        assert_eq!(interner.resolve(sym), "foo");
376    }
377
378    #[test]
379    fn resolve_rc_shares_allocation() {
380        let mut interner = Interner::new();
381        let sym = interner.intern("shared");
382        let a = interner.resolve_rc(sym);
383        let b = interner.resolve_rc(sym);
384        assert_eq!(&*a, "shared");
385        assert_eq!(&*b, "shared");
386        // Two handles point at the same allocation.
387        assert!(Rc::ptr_eq(&a, &b));
388    }
389
390    #[test]
391    fn lookup_existing() {
392        let mut interner = Interner::new();
393        let sym = interner.intern("bar");
394        assert_eq!(interner.lookup("bar"), Some(sym));
395    }
396
397    #[test]
398    fn lookup_missing() {
399        let interner = Interner::new();
400        assert_eq!(interner.lookup("missing"), None);
401    }
402
403    #[test]
404    fn len_and_empty() {
405        let mut interner = Interner::new();
406        assert!(interner.is_empty());
407        assert_eq!(interner.len(), 0);
408        interner.intern("a");
409        interner.intern("b");
410        interner.intern("a"); // duplicate
411        assert_eq!(interner.len(), 2);
412        assert!(!interner.is_empty());
413    }
414
415    #[test]
416    fn symbol_ordering() {
417        let mut interner = Interner::new();
418        let s1 = interner.intern("alpha");
419        let s2 = interner.intern("beta");
420        // Symbols are ordered by insertion order, not alphabetically.
421        assert!(s1 < s2);
422    }
423
424    #[test]
425    fn try_resolve_valid() {
426        let mut interner = Interner::new();
427        let sym = interner.intern("test");
428        assert_eq!(interner.try_resolve(sym), Some("test"));
429    }
430
431    #[test]
432    fn try_resolve_invalid() {
433        let interner = Interner::new();
434        assert_eq!(interner.try_resolve(Symbol(999)), None);
435    }
436
437    #[test]
438    fn clone_interner() {
439        let mut interner = Interner::new();
440        let s1 = interner.intern("hello");
441        let cloned = interner.clone();
442        assert_eq!(cloned.resolve(s1), "hello");
443        assert_eq!(cloned.len(), 1);
444    }
445
446    #[test]
447    fn with_capacity_preallocates() {
448        let interner = Interner::with_capacity(256);
449        assert!(interner.is_empty());
450        // capacity is not exposed on FxHashMap publicly but len should be 0
451        assert_eq!(interner.len(), 0);
452    }
453
454    // Thread-local helpers run inside `std::thread::spawn` so they
455    // can't collide with each other or with tests in sibling crates
456    // that already touched the global interner.
457    #[test]
458    fn thread_local_intern_resolve() {
459        std::thread::spawn(|| {
460            let sym = intern("thread_local_test");
461            let resolved = resolve(sym);
462            assert_eq!(resolved, "thread_local_test");
463        })
464        .join()
465        .unwrap();
466    }
467
468    #[test]
469    fn thread_local_intern_dedup() {
470        std::thread::spawn(|| {
471            let s1 = intern("dedup");
472            let s2 = intern("dedup");
473            assert_eq!(s1, s2);
474        })
475        .join()
476        .unwrap();
477    }
478
479    #[test]
480    fn thread_local_resolve_rc_zero_copy() {
481        std::thread::spawn(|| {
482            let sym = intern("tl_rc");
483            let a = resolve_rc(sym);
484            let b = resolve_rc(sym);
485            assert!(Rc::ptr_eq(&a, &b));
486        })
487        .join()
488        .unwrap();
489    }
490
491    #[test]
492    fn thread_local_with_resolved_no_alloc() {
493        std::thread::spawn(|| {
494            let sym = intern("borrowed");
495            let len = with_resolved(sym, str::len);
496            assert_eq!(len, "borrowed".len());
497        })
498        .join()
499        .unwrap();
500    }
501
502    #[test]
503    fn thread_local_lookup() {
504        std::thread::spawn(|| {
505            assert_eq!(lookup("never_interned_here"), None);
506            let sym = intern("findme");
507            assert_eq!(lookup("findme"), Some(sym));
508        })
509        .join()
510        .unwrap();
511    }
512
513    #[test]
514    fn prewarm_populates_hot_set() {
515        std::thread::spawn(|| {
516            // Fresh thread — interner starts empty, then prewarm fills it.
517            prewarm();
518            for &s in HOT_SYMBOLS {
519                assert!(
520                    lookup(s).is_some(),
521                    "prewarm should have interned {s:?}"
522                );
523            }
524            // Idempotent — second call shouldn't grow the interner.
525            let before = HOT_SYMBOLS.len();
526            prewarm();
527            // The thread-local state isn't directly inspectable here;
528            // instead, check that every hot symbol still resolves to
529            // the same index it got the first time.
530            let sym_first = lookup("name").expect("name interned by prewarm");
531            assert!(sym_first.index() < u32::try_from(before).unwrap());
532        })
533        .join()
534        .unwrap();
535    }
536
537    #[test]
538    fn hot_symbols_unique() {
539        // Sanity: no duplicates in the curated list, otherwise the
540        // "this symbol has the N-th index" reasoning breaks.
541        let mut seen = std::collections::HashSet::new();
542        for &s in HOT_SYMBOLS {
543            assert!(seen.insert(s), "HOT_SYMBOLS contains duplicate {s:?}");
544        }
545    }
546}