uni_plugin/qname.rs
1//! Qualified plugin item names — `namespace.local`.
2//!
3//! Every plugin-registered extension is identified by a [`QName`]: the
4//! plugin's owning namespace (reverse-DNS, e.g. `ai.dragonscale.geo`) plus a
5//! local name (e.g. `haversine`). Stored case-sensitively; matched
6//! case-insensitively at Cypher call sites, case-sensitively at Locy call
7//! sites.
8
9use std::fmt;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13use smol_str::SmolStr;
14
15use crate::errors::PluginError;
16
17/// Reserved single-token plugin ids that are exempt from the reverse-DNS
18/// id-format requirement.
19///
20/// Third-party plugins must use reverse-DNS ids (e.g. `ai.example.geo`).
21/// The framework ships a handful of single-token ids for its own
22/// built-ins and migration aids; conformance probes accept these as
23/// valid id shapes.
24pub const RESERVED_PLUGIN_IDS: &[&str] = &["builtin", "apoc-core", "custom", "user.legacy"];
25
26/// Returns `true` if `id` is one of the framework-reserved single-token
27/// plugin ids exempt from the reverse-DNS requirement.
28#[must_use]
29pub fn is_reserved_plugin_id(id: &str) -> bool {
30 RESERVED_PLUGIN_IDS.contains(&id)
31}
32
33/// Qualified plugin item name — `namespace.local`.
34///
35/// `QName` is the address every plugin-registered extension is looked up by.
36/// The namespace is the registering plugin's id; the local is the per-plugin
37/// item name. Built-ins use the reserved namespace [`QName::BUILTIN_NS`].
38///
39/// # Examples
40///
41/// ```
42/// use uni_plugin::QName;
43/// let q = QName::parse("ai.dragonscale.geo.haversine").unwrap();
44/// assert_eq!(q.namespace(), "ai.dragonscale.geo");
45/// assert_eq!(q.local(), "haversine");
46/// ```
47///
48/// # Errors
49///
50/// [`QName::parse`] returns [`PluginError::InvalidQName`] if the input does
51/// not contain at least one `.` separating namespace from local, or if either
52/// side is empty.
53#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub struct QName {
55 namespace: SmolStr,
56 local: SmolStr,
57}
58
59impl QName {
60 /// Reserved namespace for uni-db built-in extensions.
61 ///
62 /// Built-ins registered by `uni-plugin-builtin` use this namespace so
63 /// they are distinguishable from third-party plugins at the registry
64 /// level. The user-facing Cypher / Locy syntax does not require the
65 /// namespace prefix for built-ins — `RETURN toUpper(s)` resolves to
66 /// `builtin.toUpper` through Cypher's case-insensitive matching.
67 pub const BUILTIN_NS: &'static str = "builtin";
68
69 /// Construct a `QName` from already-validated parts.
70 ///
71 /// # Panics
72 ///
73 /// Panics if `namespace` or `local` is empty, since this is a programming
74 /// error rather than a fallible parse — use [`QName::parse`] to validate
75 /// untrusted input.
76 #[must_use]
77 pub fn new(namespace: impl Into<SmolStr>, local: impl Into<SmolStr>) -> Self {
78 let namespace = namespace.into();
79 let local = local.into();
80 assert!(!namespace.is_empty(), "QName namespace must not be empty");
81 assert!(!local.is_empty(), "QName local must not be empty");
82 Self { namespace, local }
83 }
84
85 /// Construct a `QName` in the [`QName::BUILTIN_NS`] namespace.
86 ///
87 /// Shorthand for built-in registrations.
88 ///
89 /// # Examples
90 ///
91 /// ```
92 /// use uni_plugin::QName;
93 /// let q = QName::builtin("MIN");
94 /// assert_eq!(q.namespace(), "builtin");
95 /// assert_eq!(q.local(), "MIN");
96 /// ```
97 #[must_use]
98 pub fn builtin(local: impl Into<SmolStr>) -> Self {
99 Self::new(Self::BUILTIN_NS, local)
100 }
101
102 /// Parse a fully-qualified name like `"ai.dragonscale.geo.haversine"`.
103 ///
104 /// The last segment (after the final `.`) is taken as the local name; the
105 /// preceding segments are joined back as the namespace. A namespace with
106 /// no `.` (e.g. `"builtin.MIN"`) is also accepted.
107 ///
108 /// # Errors
109 ///
110 /// Returns [`PluginError::InvalidQName`] if the input lacks a `.`, or if
111 /// either side of the final `.` is empty.
112 pub fn parse(s: impl AsRef<str>) -> Result<Self, PluginError> {
113 let s = s.as_ref();
114 let (ns, local) = s
115 .rsplit_once('.')
116 .ok_or_else(|| PluginError::InvalidQName(s.to_owned()))?;
117 if ns.is_empty() || local.is_empty() {
118 return Err(PluginError::InvalidQName(s.to_owned()));
119 }
120 Ok(Self {
121 namespace: SmolStr::new(ns),
122 local: SmolStr::new(local),
123 })
124 }
125
126 /// Every way to split a dotted name into `(namespace, local)`, yielded
127 /// from the **first** dot to the **last** dot.
128 ///
129 /// Resolution must be convention-agnostic because two registration
130 /// conventions coexist: dynamic loaders register the *whole* (possibly
131 /// dotted) plugin id as the namespace (`ai.example` + `myfn` ⇒
132 /// `("ai.example", "myfn")`, a last-dot split), while the M9-declared and
133 /// builtin/apoc paths use a first-dot split (`apoc-core` + `bitwise.and`,
134 /// `uni` + `plugin.declareAggregate`). Neither a pure first-dot nor a pure
135 /// last-dot split resolves both. A caller looks up each candidate against
136 /// the registry (exact `QName` keyed) and takes the first hit.
137 ///
138 /// First-dot is yielded first so that, in the (vanishingly unlikely) event
139 /// two registrations would both match, resolution stays identical to the
140 /// historical `split_once('.')` behavior.
141 ///
142 /// A name with no `.` (or with an empty side at every split) yields nothing.
143 ///
144 /// ```
145 /// # use uni_plugin::QName;
146 /// let cands: Vec<_> = QName::candidate_splits("a.b.c").collect();
147 /// assert_eq!(cands, vec![QName::new("a", "b.c"), QName::new("a.b", "c")]);
148 /// assert_eq!(QName::candidate_splits("bare").count(), 0);
149 /// ```
150 pub fn candidate_splits(name: &str) -> impl Iterator<Item = QName> + '_ {
151 name.match_indices('.').filter_map(move |(i, _)| {
152 let (ns, local) = (&name[..i], &name[i + 1..]);
153 if ns.is_empty() || local.is_empty() {
154 None
155 } else {
156 Some(QName::new(ns, local))
157 }
158 })
159 }
160
161 /// Returns the namespace portion (the plugin id).
162 #[must_use]
163 pub fn namespace(&self) -> &str {
164 &self.namespace
165 }
166
167 /// Returns the local portion (the per-plugin item name).
168 #[must_use]
169 pub fn local(&self) -> &str {
170 &self.local
171 }
172
173 /// Returns `true` if this name is in the reserved built-in namespace.
174 #[must_use]
175 pub fn is_builtin(&self) -> bool {
176 self.namespace == Self::BUILTIN_NS
177 }
178
179 /// Cypher-style case-insensitive equality.
180 ///
181 /// Cypher function-call sites compare names case-insensitively
182 /// (`toUpper` and `TOUPPER` resolve identically). Locy uses
183 /// [`PartialEq`] (case-sensitive) directly.
184 #[must_use]
185 pub fn matches_cypher(&self, other: &Self) -> bool {
186 self.namespace.eq_ignore_ascii_case(&other.namespace)
187 && self.local.eq_ignore_ascii_case(&other.local)
188 }
189}
190
191impl fmt::Display for QName {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 write!(f, "{}.{}", self.namespace, self.local)
194 }
195}
196
197impl FromStr for QName {
198 type Err = PluginError;
199
200 fn from_str(s: &str) -> Result<Self, Self::Err> {
201 Self::parse(s)
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn parse_simple() {
211 let q = QName::parse("foo.bar").unwrap();
212 assert_eq!(q.namespace(), "foo");
213 assert_eq!(q.local(), "bar");
214 }
215
216 #[test]
217 fn parse_nested_namespace() {
218 let q = QName::parse("ai.dragonscale.geo.haversine").unwrap();
219 assert_eq!(q.namespace(), "ai.dragonscale.geo");
220 assert_eq!(q.local(), "haversine");
221 }
222
223 #[test]
224 fn parse_rejects_empty_local() {
225 assert!(matches!(
226 QName::parse("foo."),
227 Err(PluginError::InvalidQName(_))
228 ));
229 }
230
231 #[test]
232 fn parse_rejects_empty_namespace() {
233 assert!(matches!(
234 QName::parse(".bar"),
235 Err(PluginError::InvalidQName(_))
236 ));
237 }
238
239 #[test]
240 fn parse_rejects_no_dot() {
241 assert!(matches!(
242 QName::parse("nodothere"),
243 Err(PluginError::InvalidQName(_))
244 ));
245 }
246
247 #[test]
248 fn builtin_helper() {
249 let q = QName::builtin("MIN");
250 assert!(q.is_builtin());
251 assert_eq!(q.local(), "MIN");
252 }
253
254 #[test]
255 fn candidate_splits_orders_first_dot_to_last() {
256 let cands: Vec<_> = QName::candidate_splits("a.b.c").collect();
257 assert_eq!(
258 cands,
259 vec![QName::new("a", "b.c"), QName::new("a.b", "c")],
260 "candidates must run first-dot → last-dot"
261 );
262 }
263
264 #[test]
265 fn candidate_splits_single_dot() {
266 let cands: Vec<_> = QName::candidate_splits("mycorp.fn").collect();
267 assert_eq!(cands, vec![QName::new("mycorp", "fn")]);
268 }
269
270 #[test]
271 fn candidate_splits_covers_both_registration_conventions() {
272 // Dotted-id loader plugin registers ("ai.example", "agg") — last-dot;
273 // M9 declared registers ("ai", "example.agg") — first-dot. Both forms
274 // must appear among the candidates so resolution finds whichever the
275 // registry actually holds.
276 let cands: Vec<_> = QName::candidate_splits("ai.example.agg").collect();
277 assert!(cands.contains(&QName::new("ai", "example.agg")));
278 assert!(cands.contains(&QName::new("ai.example", "agg")));
279 }
280
281 #[test]
282 fn candidate_splits_skips_empty_sides_and_bare_names() {
283 assert_eq!(QName::candidate_splits("bare").count(), 0);
284 assert_eq!(QName::candidate_splits(".bar").count(), 0);
285 assert_eq!(QName::candidate_splits("foo.").count(), 0);
286 // The interior split of "a..b" has an empty side on each adjacent dot.
287 let cands: Vec<_> = QName::candidate_splits("a..b").collect();
288 assert_eq!(cands, vec![QName::new("a", ".b"), QName::new("a.", "b")]);
289 }
290
291 #[test]
292 fn cypher_match_case_insensitive() {
293 let a = QName::builtin("toUpper");
294 let b = QName::builtin("TOUPPER");
295 assert!(a.matches_cypher(&b));
296 assert_ne!(a, b);
297 }
298
299 #[test]
300 fn display_round_trip() {
301 let q = QName::new("foo.bar", "baz");
302 assert_eq!(q.to_string(), "foo.bar.baz");
303 let parsed: QName = "foo.bar.baz".parse().unwrap();
304 assert_eq!(q, parsed);
305 }
306}