Skip to main content

oxirs_ttl/
prefix_resolver.rs

1/// Prefix/CURIE resolver for Turtle and TriG documents.
2///
3/// Manages `@prefix` and `@base` declarations and resolves both CURIE
4/// (prefixed name) and relative IRI references to full absolute IRIs.
5use std::collections::HashMap;
6
7/// A single prefix declaration with source location.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct PrefixDeclaration {
10    /// The prefix label (e.g. "ex", "rdf").
11    pub prefix: String,
12    /// The namespace IRI (e.g. `"http://www.w3.org/1999/02/22-rdf-syntax-ns#"`).
13    pub namespace: String,
14    /// The line number where the declaration appeared (1-based).
15    pub line: usize,
16}
17
18/// How an IRI was resolved.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum ResolveSource {
21    /// Resolved via a prefix declaration (carries the prefix label).
22    Prefix(String),
23    /// Resolved against the base IRI.
24    Base,
25    /// The term was already an absolute IRI.
26    Absolute,
27}
28
29/// The outcome of resolving a term.
30#[derive(Debug, Clone)]
31pub struct ResolveResult {
32    /// The fully resolved IRI.
33    pub iri: String,
34    /// How it was resolved.
35    pub source: ResolveSource,
36}
37
38/// Errors produced by prefix resolution.
39#[derive(Debug, Clone)]
40pub enum ResolveError {
41    /// The prefix is not declared.
42    UnknownPrefix(String),
43    /// No base IRI is set and a relative IRI was encountered.
44    NoBaseIri,
45    /// The CURIE syntax is invalid (e.g. missing colon).
46    InvalidCurie(String),
47    /// The IRI structure is invalid.
48    InvalidIri(String),
49}
50
51impl std::fmt::Display for ResolveError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            Self::UnknownPrefix(p) => write!(f, "Unknown prefix: {p}"),
55            Self::NoBaseIri => write!(f, "No base IRI set"),
56            Self::InvalidCurie(c) => write!(f, "Invalid CURIE: {c}"),
57            Self::InvalidIri(i) => write!(f, "Invalid IRI: {i}"),
58        }
59    }
60}
61
62impl std::error::Error for ResolveError {}
63
64/// A prefix/CURIE resolver for Turtle documents.
65#[derive(Debug, Default)]
66pub struct PrefixResolver {
67    prefixes: HashMap<String, String>,
68    base_iri: Option<String>,
69    declarations: Vec<PrefixDeclaration>,
70}
71
72impl PrefixResolver {
73    /// Create an empty resolver.
74    pub fn new() -> Self {
75        Self {
76            prefixes: HashMap::new(),
77            base_iri: None,
78            declarations: Vec::new(),
79        }
80    }
81
82    /// Create a resolver with a pre-set base IRI.
83    pub fn with_base(base_iri: impl Into<String>) -> Self {
84        let base = base_iri.into();
85        Self {
86            prefixes: HashMap::new(),
87            base_iri: Some(base),
88            declarations: Vec::new(),
89        }
90    }
91
92    /// Add a prefix declaration.
93    ///
94    /// Does not treat re-declarations as errors; the latest wins.
95    pub fn add_prefix(
96        &mut self,
97        prefix: impl Into<String>,
98        namespace: impl Into<String>,
99        line: usize,
100    ) -> Result<(), ResolveError> {
101        let prefix = prefix.into();
102        let namespace = namespace.into();
103        self.prefixes.insert(prefix.clone(), namespace.clone());
104        self.declarations.push(PrefixDeclaration {
105            prefix,
106            namespace,
107            line,
108        });
109        Ok(())
110    }
111
112    /// Update (or set) the base IRI.
113    pub fn set_base(&mut self, base_iri: impl Into<String>) {
114        self.base_iri = Some(base_iri.into());
115    }
116
117    /// Resolve a term that is either a CURIE (`prefix:local`) or a relative IRI.
118    ///
119    /// Absolute IRIs (with a scheme like `http://`) are passed through unchanged.
120    /// Registered prefix names are resolved via their namespace.
121    /// Unregistered prefix-like names are treated as unknown prefix errors.
122    pub fn resolve(&self, term: &str) -> Result<ResolveResult, ResolveError> {
123        // Check for CURIE pattern first (prefix:local where prefix is registered).
124        if let Some(colon) = term.find(':') {
125            let prefix = &term[..colon];
126            let after_colon = &term[colon + 1..];
127
128            // A registered prefix always wins.
129            if !prefix.is_empty() && self.prefixes.contains_key(prefix) {
130                let ns = &self.prefixes[prefix];
131                return Ok(ResolveResult {
132                    iri: format!("{ns}{after_colon}"),
133                    source: ResolveSource::Prefix(prefix.to_string()),
134                });
135            }
136
137            // If the part after ':' starts with '//', this is an absolute IRI (http://, ftp://, etc.)
138            if after_colon.starts_with("//") {
139                return Ok(ResolveResult {
140                    iri: term.to_string(),
141                    source: ResolveSource::Absolute,
142                });
143            }
144
145            // Known absolute URI schemes without authority (urn:, mailto:, data:, file:, etc.)
146            if !prefix.is_empty() && Self::is_known_scheme(prefix) {
147                return Ok(ResolveResult {
148                    iri: term.to_string(),
149                    source: ResolveSource::Absolute,
150                });
151            }
152
153            // Non-empty, non-registered prefix — report as unknown.
154            if !prefix.is_empty() {
155                return Err(ResolveError::UnknownPrefix(prefix.to_string()));
156            }
157        } else if Self::is_absolute_iri(term) {
158            return Ok(ResolveResult {
159                iri: term.to_string(),
160                source: ResolveSource::Absolute,
161            });
162        }
163
164        // Treat as relative IRI.
165        self.resolve_relative(term).map(|iri| ResolveResult {
166            iri,
167            source: ResolveSource::Base,
168        })
169    }
170
171    /// Resolve a CURIE string (`prefix:local`) into a full IRI.
172    pub fn resolve_curie(&self, curie: &str) -> Result<String, ResolveError> {
173        let colon = curie
174            .find(':')
175            .ok_or_else(|| ResolveError::InvalidCurie(curie.to_string()))?;
176        let prefix = &curie[..colon];
177        let local = &curie[colon + 1..];
178        let ns = self
179            .prefixes
180            .get(prefix)
181            .ok_or_else(|| ResolveError::UnknownPrefix(prefix.to_string()))?;
182        Ok(format!("{ns}{local}"))
183    }
184
185    /// Resolve a relative IRI against the current base IRI.
186    pub fn resolve_relative(&self, relative: &str) -> Result<String, ResolveError> {
187        let base = self.base_iri.as_deref().ok_or(ResolveError::NoBaseIri)?;
188
189        if relative.is_empty() {
190            return Ok(base.to_string());
191        }
192
193        // Absolute fragment reference
194        if relative.starts_with('#') {
195            return Ok(format!("{base}{relative}"));
196        }
197
198        // Strip base to its document root (last '/') then join
199        let base_path = if let Some(idx) = base.rfind('/') {
200            &base[..=idx]
201        } else {
202            base
203        };
204        Ok(format!("{base_path}{relative}"))
205    }
206
207    /// Try to abbreviate a full IRI back to `prefix:local` using the registered prefixes.
208    /// Returns `None` if no matching prefix is found.
209    pub fn abbreviate(&self, iri: &str) -> Option<String> {
210        // Find the longest matching namespace
211        let mut best: Option<(&str, &str)> = None;
212        for (prefix, ns) in &self.prefixes {
213            if iri.starts_with(ns.as_str())
214                && best.map_or(true, |(_, best_ns)| ns.len() > best_ns.len())
215            {
216                best = Some((prefix.as_str(), ns.as_str()));
217            }
218        }
219        best.map(|(prefix, ns)| {
220            let local = &iri[ns.len()..];
221            format!("{prefix}:{local}")
222        })
223    }
224
225    /// Return the number of declared prefixes (most recent per label).
226    pub fn prefix_count(&self) -> usize {
227        self.prefixes.len()
228    }
229
230    /// Return true if a base IRI is set.
231    pub fn has_base(&self) -> bool {
232        self.base_iri.is_some()
233    }
234
235    /// Return all declarations in declaration order (may include historical re-declarations).
236    pub fn declarations(&self) -> &[PrefixDeclaration] {
237        &self.declarations
238    }
239
240    /// Return true if the string has a URI scheme (e.g. `http://`, `https://`, `urn:`, `ftp://`).
241    ///
242    /// Uses heuristics: the scheme must be followed by `//` (hierarchy) or be a known
243    /// hierarchical/non-hierarchical scheme. CURIEs like `ex:Foo` are NOT absolute IRIs.
244    pub fn is_absolute_iri(s: &str) -> bool {
245        if s.contains(' ') {
246            return false;
247        }
248        if let Some(colon_pos) = s.find(':') {
249            let scheme = &s[..colon_pos];
250            let after = &s[colon_pos + 1..];
251            if scheme.is_empty() {
252                return false;
253            }
254            let scheme_chars_valid = scheme
255                .chars()
256                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
257            if !scheme_chars_valid {
258                return false;
259            }
260            // Authority-based IRIs (http://, ftp://, etc.)
261            if after.starts_with("//") {
262                return true;
263            }
264            // Known schemeless-authority schemes
265            Self::is_known_scheme(scheme)
266        } else {
267            false
268        }
269    }
270
271    /// Return true if `scheme` is a well-known absolute URI scheme that does not use `://`.
272    fn is_known_scheme(scheme: &str) -> bool {
273        matches!(
274            scheme.to_ascii_lowercase().as_str(),
275            "urn"
276                | "mailto"
277                | "data"
278                | "file"
279                | "tel"
280                | "fax"
281                | "news"
282                | "http"
283                | "https"
284                | "ftp"
285                | "ftps"
286                | "ldap"
287                | "ldaps"
288                | "irc"
289                | "ircs"
290                | "xmpp"
291                | "sip"
292                | "sips"
293                | "coap"
294                | "coaps"
295                | "ws"
296                | "wss"
297                | "urn:ietf"
298                | "tag"
299        )
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    // --- add_prefix / resolve_curie ---
308
309    #[test]
310    fn test_add_prefix_and_resolve_curie() {
311        let mut r = PrefixResolver::new();
312        r.add_prefix("ex", "http://example.org/", 1)
313            .expect("should succeed");
314        assert_eq!(
315            r.resolve_curie("ex:Person").expect("should succeed"),
316            "http://example.org/Person"
317        );
318    }
319
320    #[test]
321    fn test_resolve_curie_unknown_prefix() {
322        let r = PrefixResolver::new();
323        let err = r.resolve_curie("ex:Thing");
324        assert!(matches!(err, Err(ResolveError::UnknownPrefix(_))));
325    }
326
327    #[test]
328    fn test_resolve_curie_no_colon() {
329        let r = PrefixResolver::new();
330        let err = r.resolve_curie("nocolon");
331        assert!(matches!(err, Err(ResolveError::InvalidCurie(_))));
332    }
333
334    #[test]
335    fn test_resolve_curie_empty_local() {
336        let mut r = PrefixResolver::new();
337        r.add_prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#", 1)
338            .expect("should succeed");
339        assert_eq!(
340            r.resolve_curie("rdf:").expect("should succeed"),
341            "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
342        );
343    }
344
345    // --- resolve ---
346
347    #[test]
348    fn test_resolve_absolute_iri_passthrough() {
349        let r = PrefixResolver::new();
350        let result = r.resolve("http://example.org/foo").expect("should succeed");
351        assert_eq!(result.iri, "http://example.org/foo");
352        assert_eq!(result.source, ResolveSource::Absolute);
353    }
354
355    #[test]
356    fn test_resolve_curie_via_resolve() {
357        let mut r = PrefixResolver::new();
358        r.add_prefix("ex", "http://example.org/", 1)
359            .expect("should succeed");
360        let result = r.resolve("ex:Cat").expect("should succeed");
361        assert_eq!(result.iri, "http://example.org/Cat");
362        assert!(matches!(result.source, ResolveSource::Prefix(_)));
363    }
364
365    #[test]
366    fn test_resolve_relative_via_resolve() {
367        let r = PrefixResolver::with_base("http://example.org/doc/page.ttl");
368        let result = r.resolve("other.ttl").expect("should succeed");
369        assert_eq!(result.iri, "http://example.org/doc/other.ttl");
370        assert_eq!(result.source, ResolveSource::Base);
371    }
372
373    // --- resolve_relative ---
374
375    #[test]
376    fn test_resolve_relative_simple() {
377        let r = PrefixResolver::with_base("http://example.org/base/");
378        let iri = r.resolve_relative("foo").expect("should succeed");
379        assert_eq!(iri, "http://example.org/base/foo");
380    }
381
382    #[test]
383    fn test_resolve_relative_no_base() {
384        let r = PrefixResolver::new();
385        let err = r.resolve_relative("foo");
386        assert!(matches!(err, Err(ResolveError::NoBaseIri)));
387    }
388
389    #[test]
390    fn test_resolve_relative_fragment() {
391        let r = PrefixResolver::with_base("http://example.org/ont");
392        let iri = r.resolve_relative("#Alice").expect("should succeed");
393        assert_eq!(iri, "http://example.org/ont#Alice");
394    }
395
396    #[test]
397    fn test_resolve_relative_empty_string_returns_base() {
398        let r = PrefixResolver::with_base("http://example.org/doc");
399        let iri = r.resolve_relative("").expect("should succeed");
400        assert_eq!(iri, "http://example.org/doc");
401    }
402
403    // --- set_base ---
404
405    #[test]
406    fn test_set_base_updates() {
407        let mut r = PrefixResolver::new();
408        r.set_base("http://first.org/");
409        r.set_base("http://second.org/");
410        let iri = r.resolve_relative("x").expect("should succeed");
411        assert!(iri.contains("second.org"));
412    }
413
414    #[test]
415    fn test_has_base_false() {
416        let r = PrefixResolver::new();
417        assert!(!r.has_base());
418    }
419
420    #[test]
421    fn test_has_base_true() {
422        let r = PrefixResolver::with_base("http://base.org/");
423        assert!(r.has_base());
424    }
425
426    // --- abbreviate ---
427
428    #[test]
429    fn test_abbreviate_success() {
430        let mut r = PrefixResolver::new();
431        r.add_prefix("ex", "http://example.org/", 1)
432            .expect("should succeed");
433        let abbrev = r.abbreviate("http://example.org/Person");
434        assert_eq!(abbrev.as_deref(), Some("ex:Person"));
435    }
436
437    #[test]
438    fn test_abbreviate_no_match() {
439        let r = PrefixResolver::new();
440        assert!(r.abbreviate("http://unknown.org/foo").is_none());
441    }
442
443    #[test]
444    fn test_abbreviate_prefers_longest_prefix() {
445        let mut r = PrefixResolver::new();
446        r.add_prefix("ex", "http://example.org/", 1)
447            .expect("should succeed");
448        r.add_prefix("exv", "http://example.org/vocab/", 2)
449            .expect("should succeed");
450        let abbrev = r
451            .abbreviate("http://example.org/vocab/Foo")
452            .expect("should succeed");
453        assert_eq!(abbrev, "exv:Foo");
454    }
455
456    // --- prefix_count ---
457
458    #[test]
459    fn test_prefix_count_zero() {
460        let r = PrefixResolver::new();
461        assert_eq!(r.prefix_count(), 0);
462    }
463
464    #[test]
465    fn test_prefix_count_after_adds() {
466        let mut r = PrefixResolver::new();
467        r.add_prefix("ex", "http://example.org/", 1)
468            .expect("should succeed");
469        r.add_prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#", 2)
470            .expect("should succeed");
471        assert_eq!(r.prefix_count(), 2);
472    }
473
474    #[test]
475    fn test_prefix_count_redeclaration_same() {
476        let mut r = PrefixResolver::new();
477        r.add_prefix("ex", "http://a.org/", 1)
478            .expect("should succeed");
479        r.add_prefix("ex", "http://b.org/", 2)
480            .expect("should succeed");
481        // HashMap replaces; count stays 1
482        assert_eq!(r.prefix_count(), 1);
483    }
484
485    // --- declarations ---
486
487    #[test]
488    fn test_declarations_list() {
489        let mut r = PrefixResolver::new();
490        r.add_prefix("ex", "http://example.org/", 5)
491            .expect("should succeed");
492        let decls = r.declarations();
493        assert_eq!(decls.len(), 1);
494        assert_eq!(decls[0].prefix, "ex");
495        assert_eq!(decls[0].line, 5);
496    }
497
498    #[test]
499    fn test_declarations_order() {
500        let mut r = PrefixResolver::new();
501        r.add_prefix("a", "http://a.org/", 1)
502            .expect("should succeed");
503        r.add_prefix("b", "http://b.org/", 2)
504            .expect("should succeed");
505        r.add_prefix("c", "http://c.org/", 3)
506            .expect("should succeed");
507        assert_eq!(r.declarations().len(), 3);
508        assert_eq!(r.declarations()[0].prefix, "a");
509        assert_eq!(r.declarations()[2].prefix, "c");
510    }
511
512    // --- is_absolute_iri ---
513
514    #[test]
515    fn test_is_absolute_http() {
516        assert!(PrefixResolver::is_absolute_iri("http://example.org/foo"));
517    }
518
519    #[test]
520    fn test_is_absolute_https() {
521        assert!(PrefixResolver::is_absolute_iri("https://example.org/"));
522    }
523
524    #[test]
525    fn test_is_absolute_urn() {
526        assert!(PrefixResolver::is_absolute_iri("urn:example:a123,z456"));
527    }
528
529    #[test]
530    fn test_is_absolute_relative() {
531        assert!(!PrefixResolver::is_absolute_iri("foo/bar"));
532    }
533
534    #[test]
535    fn test_is_absolute_no_scheme() {
536        assert!(!PrefixResolver::is_absolute_iri("no-colon-here"));
537    }
538
539    // --- ResolveSource variants ---
540
541    #[test]
542    fn test_resolve_source_absolute_variant() {
543        let r = PrefixResolver::new();
544        let result = r.resolve("http://x.org/").expect("should succeed");
545        assert_eq!(result.source, ResolveSource::Absolute);
546    }
547
548    #[test]
549    fn test_resolve_source_prefix_variant() {
550        let mut r = PrefixResolver::new();
551        r.add_prefix("x", "http://x.org/", 1)
552            .expect("should succeed");
553        let result = r.resolve("x:Foo").expect("should succeed");
554        assert!(matches!(result.source, ResolveSource::Prefix(_)));
555    }
556
557    #[test]
558    fn test_resolve_source_base_variant() {
559        let r = PrefixResolver::with_base("http://base.org/");
560        let result = r.resolve("relative").expect("should succeed");
561        assert_eq!(result.source, ResolveSource::Base);
562    }
563
564    // --- error display ---
565
566    #[test]
567    fn test_unknown_prefix_display() {
568        let e = ResolveError::UnknownPrefix("xyz".to_string());
569        assert!(format!("{e}").contains("xyz"));
570    }
571
572    #[test]
573    fn test_no_base_iri_display() {
574        let e = ResolveError::NoBaseIri;
575        assert!(format!("{e}").contains("base"));
576    }
577
578    #[test]
579    fn test_invalid_curie_display() {
580        let e = ResolveError::InvalidCurie("bad".to_string());
581        assert!(format!("{e}").contains("bad"));
582    }
583
584    #[test]
585    fn test_resolver_default() {
586        let r = PrefixResolver::default();
587        assert_eq!(r.prefix_count(), 0);
588        assert!(!r.has_base());
589    }
590
591    #[test]
592    fn test_resolve_unknown_curie_prefix() {
593        let r = PrefixResolver::new();
594        // "ex:Foo" has no registered prefix
595        let err = r.resolve("ex:Foo");
596        assert!(matches!(err, Err(ResolveError::UnknownPrefix(_))));
597    }
598}