Skip to main content

cljrs_value/
symbol.rs

1use std::sync::Arc;
2
3/// An interned Clojure symbol, optionally namespace-qualified, optionally
4/// pinned to a git commit via the `@<hash>` suffix syntax.
5///
6/// `"foo"`              → `Symbol { namespace: None,              name: "foo", version: None }`
7/// `"ns/name"`          → `Symbol { namespace: Some("ns"),        name: "name", version: None }`
8/// `"my-fn@abc1234"`    → `Symbol { namespace: None,              name: "my-fn", version: Some("abc1234") }`
9/// `"ns/fn@abc1234"`    → `Symbol { namespace: Some("ns"),        name: "fn", version: Some("abc1234") }`
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct Symbol {
12    pub namespace: Option<Arc<str>>,
13    pub name: Arc<str>,
14    /// Git commit hash suffix, present when the symbol was written as `name@hash`.
15    pub version: Option<Arc<str>>,
16}
17
18impl Symbol {
19    /// Unqualified, unversioned symbol.
20    pub fn simple(name: impl Into<Arc<str>>) -> Self {
21        Self {
22            namespace: None,
23            name: name.into(),
24            version: None,
25        }
26    }
27
28    /// Namespace-qualified, unversioned symbol.
29    pub fn qualified(ns: impl Into<Arc<str>>, name: impl Into<Arc<str>>) -> Self {
30        Self {
31            namespace: Some(ns.into()),
32            name: name.into(),
33            version: None,
34        }
35    }
36
37    /// Parse a symbol from a string of the form `"ns/name"`, `"name"`,
38    /// `"name@hash"`, or `"ns/name@hash"`.
39    ///
40    /// The `@` version suffix is detected in the *name* portion (after any `/`
41    /// split).  A bare `"/"` remains an unqualified symbol as before.
42    pub fn parse(s: &str) -> Self {
43        // Split namespace qualifier on the first `/`.
44        let (ns_part, name_part) = match s.find('/') {
45            Some(idx) if idx > 0 && idx < s.len() - 1 => (Some(&s[..idx]), &s[idx + 1..]),
46            _ => (None, s),
47        };
48
49        // Split version suffix on the last `@` in the name portion, accepting
50        // only valid commit hashes (7–40 hex characters).
51        let (base_name, version) = split_version(name_part);
52
53        Symbol {
54            namespace: ns_part.map(Arc::from),
55            name: Arc::from(base_name),
56            version: version.map(Arc::from),
57        }
58    }
59
60    /// The unversioned, fully-qualified string: `"ns/name"` or `"name"`.
61    pub fn full_name(&self) -> String {
62        match &self.namespace {
63            Some(ns) => format!("{}/{}", ns, self.name),
64            None => self.name.to_string(),
65        }
66    }
67
68    /// The display string including the version suffix if present:
69    /// `"ns/name@hash"` or `"name@hash"` or `"name"`.
70    pub fn versioned_name(&self) -> String {
71        match (&self.namespace, &self.version) {
72            (Some(ns), Some(v)) => format!("{}/{}@{}", ns, self.name, v),
73            (Some(ns), None) => format!("{}/{}", ns, self.name),
74            (None, Some(v)) => format!("{}@{}", self.name, v),
75            (None, None) => self.name.to_string(),
76        }
77    }
78}
79
80/// Split `name_part` into `(base, Some(hash))` if the last `@` is followed by
81/// a valid commit hash (7–40 hex chars), otherwise return `(name_part, None)`.
82fn split_version(name_part: &str) -> (&str, Option<&str>) {
83    if let Some(at_pos) = name_part.rfind('@') {
84        let candidate = &name_part[at_pos + 1..];
85        if is_commit_hash(candidate) {
86            return (&name_part[..at_pos], Some(candidate));
87        }
88    }
89    (name_part, None)
90}
91
92/// Returns `true` if `s` could be an abbreviated or full git commit hash
93/// (7–40 lowercase or uppercase hex characters).
94pub fn is_commit_hash(s: &str) -> bool {
95    (7..=40).contains(&s.len()) && s.bytes().all(|b| b.is_ascii_hexdigit())
96}
97
98impl std::fmt::Display for Symbol {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        write!(f, "{}", self.versioned_name())
101    }
102}
103
104impl cljrs_gc::Trace for Symbol {
105    fn trace(&self, _: &mut cljrs_gc::MarkVisitor) {}
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_simple() {
114        let s = Symbol::simple("foo");
115        assert_eq!(s.name.as_ref(), "foo");
116        assert!(s.namespace.is_none());
117        assert!(s.version.is_none());
118        assert_eq!(s.full_name(), "foo");
119    }
120
121    #[test]
122    fn test_qualified() {
123        let s = Symbol::qualified("clojure.core", "map");
124        assert_eq!(s.full_name(), "clojure.core/map");
125        assert!(s.version.is_none());
126    }
127
128    #[test]
129    fn test_parse_unversioned() {
130        assert_eq!(Symbol::parse("foo"), Symbol::simple("foo"));
131        assert_eq!(Symbol::parse("a/b"), Symbol::qualified("a", "b"));
132        assert_eq!(Symbol::parse("/").namespace, None);
133    }
134
135    #[test]
136    fn test_parse_versioned_simple() {
137        let s = Symbol::parse("my-fn@abc1234");
138        assert_eq!(s.name.as_ref(), "my-fn");
139        assert!(s.namespace.is_none());
140        assert_eq!(s.version.as_deref(), Some("abc1234"));
141        assert_eq!(s.versioned_name(), "my-fn@abc1234");
142    }
143
144    #[test]
145    fn test_parse_versioned_qualified() {
146        let s = Symbol::parse("my.ns/my-fn@abc1234");
147        assert_eq!(s.namespace.as_deref(), Some("my.ns"));
148        assert_eq!(s.name.as_ref(), "my-fn");
149        assert_eq!(s.version.as_deref(), Some("abc1234"));
150    }
151
152    #[test]
153    fn test_at_without_valid_hash_is_part_of_name() {
154        // "@" followed by fewer than 7 hex chars is not a version.
155        let s = Symbol::parse("my-fn@abc");
156        assert_eq!(s.name.as_ref(), "my-fn@abc");
157        assert!(s.version.is_none());
158    }
159
160    #[test]
161    fn test_at_followed_by_non_hex_is_part_of_name() {
162        let s = Symbol::parse("my-fn@not-hex");
163        assert_eq!(s.name.as_ref(), "my-fn@not-hex");
164        assert!(s.version.is_none());
165    }
166}