Skip to main content

cljrs_value/
symbol.rs

1use std::cell::RefCell;
2use std::collections::HashMap;
3use std::hash::{BuildHasherDefault, Hasher};
4use std::sync::Arc;
5
6/// An interned Clojure symbol, optionally namespace-qualified, optionally
7/// pinned to a git commit via the `@<hash>` suffix syntax.
8///
9/// `"foo"`              → `Symbol { namespace: None,              name: "foo", version: None }`
10/// `"ns/name"`          → `Symbol { namespace: Some("ns"),        name: "name", version: None }`
11/// `"my-fn@abc1234"`    → `Symbol { namespace: None,              name: "my-fn", version: Some("abc1234") }`
12/// `"ns/fn@abc1234"`    → `Symbol { namespace: Some("ns"),        name: "fn", version: Some("abc1234") }`
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub struct Symbol {
15    pub namespace: Option<Arc<str>>,
16    pub name: Arc<str>,
17    /// Git commit hash suffix, present when the symbol was written as `name@hash`.
18    pub version: Option<Arc<str>>,
19}
20
21impl Symbol {
22    /// Unqualified, unversioned symbol.
23    pub fn simple(name: impl Into<Arc<str>>) -> Self {
24        Self {
25            namespace: None,
26            name: name.into(),
27            version: None,
28        }
29    }
30
31    /// Namespace-qualified, unversioned symbol.
32    pub fn qualified(ns: impl Into<Arc<str>>, name: impl Into<Arc<str>>) -> Self {
33        Self {
34            namespace: Some(ns.into()),
35            name: name.into(),
36            version: None,
37        }
38    }
39
40    /// Parse a symbol from a string of the form `"ns/name"`, `"name"`,
41    /// `"name@hash"`, or `"ns/name@hash"`.
42    ///
43    /// The `@` version suffix is detected in the *name* portion (after any `/`
44    /// split).  A bare `"/"` remains an unqualified symbol as before.
45    ///
46    /// Memoized per thread. Parsing is pure, but it allocates one `Arc<str>`
47    /// per component, and the callers that dominate are conversions that see
48    /// the *same* text over and over: `form_to_value` re-derives every symbol
49    /// in a form tree each time a quoted form is evaluated or a macro call is
50    /// expanded, and `macroexpand` runs the latter to a fixed point. A cache
51    /// hit is three refcount bumps against three allocations plus two scans.
52    pub fn parse(s: &str) -> Self {
53        INTERNED
54            .try_with(|table| {
55                if let Some(sym) = table.borrow().get(s).cloned() {
56                    return sym;
57                }
58                let sym = Self::parse_uncached(s);
59                let mut table = table.borrow_mut();
60                // `gensym` mints a fresh name per call, so an uncapped table
61                // would grow without bound in a long-running program. Past the
62                // cap parsing still answers, just uncached; the entries already
63                // in are the ones a program returns to.
64                if table.len() < INTERN_CAP {
65                    table.insert(Box::from(s), sym.clone());
66                }
67                sym
68            })
69            // During thread teardown the table is already gone.
70            .unwrap_or_else(|_| Self::parse_uncached(s))
71    }
72
73    /// [`Symbol::parse`] without the memo table.
74    fn parse_uncached(s: &str) -> Self {
75        // Split namespace qualifier on the first `/`.
76        let (ns_part, name_part) = match s.find('/') {
77            Some(idx) if idx > 0 && idx < s.len() - 1 => (Some(&s[..idx]), &s[idx + 1..]),
78            _ => (None, s),
79        };
80
81        // Split version suffix on the last `@` in the name portion, accepting
82        // only valid commit hashes (7–40 hex characters).
83        let (base_name, version) = split_version(name_part);
84
85        Symbol {
86            namespace: ns_part.map(Arc::from),
87            name: Arc::from(base_name),
88            version: version.map(Arc::from),
89        }
90    }
91
92    /// The unversioned, fully-qualified string: `"ns/name"` or `"name"`.
93    pub fn full_name(&self) -> String {
94        match &self.namespace {
95            Some(ns) => format!("{}/{}", ns, self.name),
96            None => self.name.to_string(),
97        }
98    }
99
100    /// The display string including the version suffix if present:
101    /// `"ns/name@hash"` or `"name@hash"` or `"name"`.
102    pub fn versioned_name(&self) -> String {
103        match (&self.namespace, &self.version) {
104            (Some(ns), Some(v)) => format!("{}/{}@{}", ns, self.name, v),
105            (Some(ns), None) => format!("{}/{}", ns, self.name),
106            (None, Some(v)) => format!("{}@{}", self.name, v),
107            (None, None) => self.name.to_string(),
108        }
109    }
110}
111
112/// Upper bound on memoized parses per thread. See [`Symbol::parse`].
113const INTERN_CAP: usize = 4096;
114
115thread_local! {
116    /// Memo table behind [`Symbol::parse`], keyed by the exact source text.
117    ///
118    /// Thread-local rather than shared: the table is written on nearly every
119    /// miss, so one lock would serialize symbol parsing across the tiering
120    /// threads to buy sharing that a per-thread table does not need.
121    static INTERNED: RefCell<HashMap<Box<str>, Symbol, BuildHasherDefault<FnvHasher>>> =
122        RefCell::new(HashMap::default());
123}
124
125/// FNV-1a, for the [`INTERNED`] table only.
126///
127/// Symbol text is short and this table is read on a hot path, where SipHash's
128/// per-call setup costs more than the collision resistance is worth: the keys
129/// are program identifiers, not adversarial input.
130#[derive(Debug)]
131pub struct FnvHasher(u64);
132
133impl Default for FnvHasher {
134    fn default() -> Self {
135        Self(0xcbf2_9ce4_8422_2325)
136    }
137}
138
139impl Hasher for FnvHasher {
140    fn write(&mut self, bytes: &[u8]) {
141        for b in bytes {
142            self.0 ^= u64::from(*b);
143            self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
144        }
145    }
146
147    fn finish(&self) -> u64 {
148        self.0
149    }
150}
151
152/// Split `name_part` into `(base, Some(hash))` if the last `@` is followed by
153/// a valid commit hash (7–40 hex chars), otherwise return `(name_part, None)`.
154pub fn split_version(name_part: &str) -> (&str, Option<&str>) {
155    if let Some(at_pos) = name_part.rfind('@') {
156        let candidate = &name_part[at_pos + 1..];
157        if is_commit_hash(candidate) {
158            return (&name_part[..at_pos], Some(candidate));
159        }
160    }
161    (name_part, None)
162}
163
164/// Returns `true` if `s` could be an abbreviated or full git commit hash
165/// (7–40 lowercase or uppercase hex characters).
166pub fn is_commit_hash(s: &str) -> bool {
167    (7..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
168}
169
170impl std::fmt::Display for Symbol {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        write!(f, "{}", self.versioned_name())
173    }
174}
175
176impl cljrs_gc::Trace for Symbol {
177    fn trace(&self, _: &mut cljrs_gc::MarkVisitor) {}
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_simple() {
186        let s = Symbol::simple("foo");
187        assert_eq!(s.name.as_ref(), "foo");
188        assert!(s.namespace.is_none());
189        assert!(s.version.is_none());
190        assert_eq!(s.full_name(), "foo");
191    }
192
193    #[test]
194    fn test_qualified() {
195        let s = Symbol::qualified("clojure.core", "map");
196        assert_eq!(s.full_name(), "clojure.core/map");
197        assert!(s.version.is_none());
198    }
199
200    #[test]
201    fn test_parse_unversioned() {
202        assert_eq!(Symbol::parse("foo"), Symbol::simple("foo"));
203        assert_eq!(Symbol::parse("a/b"), Symbol::qualified("a", "b"));
204        assert_eq!(Symbol::parse("/").namespace, None);
205    }
206
207    #[test]
208    fn test_parse_versioned_simple() {
209        let s = Symbol::parse("my-fn@abc1234");
210        assert_eq!(s.name.as_ref(), "my-fn");
211        assert!(s.namespace.is_none());
212        assert_eq!(s.version.as_deref(), Some("abc1234"));
213        assert_eq!(s.versioned_name(), "my-fn@abc1234");
214    }
215
216    #[test]
217    fn test_parse_versioned_qualified() {
218        let s = Symbol::parse("my.ns/my-fn@abc1234");
219        assert_eq!(s.namespace.as_deref(), Some("my.ns"));
220        assert_eq!(s.name.as_ref(), "my-fn");
221        assert_eq!(s.version.as_deref(), Some("abc1234"));
222    }
223
224    #[test]
225    fn test_at_without_valid_hash_is_part_of_name() {
226        // "@" followed by fewer than 7 hex chars is not a version.
227        let s = Symbol::parse("my-fn@abc");
228        assert_eq!(s.name.as_ref(), "my-fn@abc");
229        assert!(s.version.is_none());
230    }
231
232    #[test]
233    fn test_at_followed_by_non_hex_is_part_of_name() {
234        let s = Symbol::parse("my-fn@not-hex");
235        assert_eq!(s.name.as_ref(), "my-fn@not-hex");
236        assert!(s.version.is_none());
237    }
238
239    /// The memo table keys on the whole source text, so texts that share a
240    /// component must not answer for each other.
241    #[test]
242    fn memoized_parse_agrees_with_the_uncached_parse() {
243        for s in [
244            "foo",
245            "a/b",
246            "b",
247            "/",
248            "a/b@abc1234",
249            "b@abc1234",
250            "my-fn@abc",
251            "clojure.core/map",
252            "map",
253        ] {
254            // Twice, so the second answer is the cached one.
255            assert_eq!(Symbol::parse(s), Symbol::parse_uncached(s), "first {s}");
256            assert_eq!(Symbol::parse(s), Symbol::parse_uncached(s), "cached {s}");
257        }
258    }
259
260    /// Past the cap parsing still answers correctly, just without caching.
261    #[test]
262    fn parse_is_correct_beyond_the_intern_cap() {
263        for i in 0..(INTERN_CAP + 64) {
264            let text = format!("gen-{i}/sym-{i}@abc1234");
265            let sym = Symbol::parse(&text);
266            assert_eq!(sym, Symbol::parse_uncached(&text));
267            assert_eq!(sym.namespace.as_deref(), Some(format!("gen-{i}").as_str()));
268            assert_eq!(sym.version.as_deref(), Some("abc1234"));
269        }
270    }
271}