Skip to main content

strixonomy_owl/
render.rs

1//! Entity / Manchester rendering helpers (Protégé Wave 2 ports).
2//!
3//! Behavioral ports of `OWLEntityRendererImpl`, `RenderingEscapeUtils`,
4//! `IriSplitter`, and prefix expand/match/render helpers.
5
6use crate::patch::best_namespace_match;
7use crate::span::short_name_from_iri;
8use std::collections::BTreeMap;
9
10const OWL_NS: &str = "http://www.w3.org/2002/07/owl#";
11const RDFS_NS: &str = "http://www.w3.org/2000/01/rdf-schema#";
12const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
13const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema#";
14
15/// Split an IRI into `(namespace, local)` using `#` then `/` (Protégé IriSplitter-style).
16pub fn split_iri(iri: &str) -> (String, String) {
17    if let Some((ns, local)) = iri.rsplit_once('#') {
18        return (format!("{ns}#"), local.to_string());
19    }
20    if let Some((ns, local)) = iri.rsplit_once('/') {
21        return (format!("{ns}/"), local.to_string());
22    }
23    (String::new(), iri.to_string())
24}
25
26/// Render an entity IRI for display: prefer longest prefix CURIE, else short name, else bracketed IRI.
27pub fn render_entity_iri(iri: &str, namespaces: &BTreeMap<String, String>) -> String {
28    if let Some(curie) = render_as_curie(iri, namespaces) {
29        return curie;
30    }
31    let short = short_name_from_iri(iri);
32    if !short.is_empty() && short != iri {
33        return short;
34    }
35    format!("<{iri}>")
36}
37
38/// Prefer built-in vocabulary CURIEs (`owl:`, `rdfs:`, `rdf:`, `xsd:`) then workspace prefixes.
39pub fn render_as_curie(iri: &str, namespaces: &BTreeMap<String, String>) -> Option<String> {
40    let mut ns = namespaces.clone();
41    ns.entry("owl".into()).or_insert_with(|| OWL_NS.to_string());
42    ns.entry("rdfs".into()).or_insert_with(|| RDFS_NS.to_string());
43    ns.entry("rdf".into()).or_insert_with(|| RDF_NS.to_string());
44    ns.entry("xsd".into()).or_insert_with(|| XSD_NS.to_string());
45    best_namespace_match(iri, &ns).map(|(prefix, base)| {
46        let local = &iri[base.len()..];
47        format!("{prefix}:{local}")
48    })
49}
50
51/// Expand `prefix:local` using `namespaces`. Bare IRIs and `<iri>` pass through.
52pub fn expand_prefixed_iri(token: &str, namespaces: &BTreeMap<String, String>) -> Option<String> {
53    let token = token.trim();
54    if token.is_empty() {
55        return None;
56    }
57    if token.starts_with('<') && token.ends_with('>') && token.len() >= 2 {
58        return Some(token[1..token.len() - 1].to_string());
59    }
60    if token.contains("://") {
61        return Some(token.to_string());
62    }
63    let (prefix, local) = token.split_once(':')?;
64    let base = namespaces.get(prefix)?;
65    Some(format!("{base}{local}"))
66}
67
68/// Longest-prefix match: returns `(prefix, namespace_iri)` or `None`.
69pub fn match_prefix<'a>(
70    iri: &str,
71    namespaces: &'a BTreeMap<String, String>,
72) -> Option<(&'a str, &'a str)> {
73    best_namespace_match(iri, namespaces)
74}
75
76/// Manchester-style escaped rendering (Protégé `RenderingEscapeUtils.getEscapedRendering`).
77pub fn escape_manchester_rendering(original: &str) -> String {
78    let mut rendering = original.replace('\\', "\\\\");
79    rendering = rendering.replace('\'', "\\'");
80    rendering = rendering.replace('"', "\\\"");
81    let needs_quotes = original.chars().any(|c| {
82        matches!(
83            c,
84            ' ' | '\\' | ',' | '<' | '>' | '=' | '^' | '@' | '{' | '}' | '[' | ']' | '(' | ')'
85        )
86    });
87    if needs_quotes {
88        format!("'{rendering}'")
89    } else {
90        rendering
91    }
92}
93
94/// Inverse of [`escape_manchester_rendering`] (Protégé `RenderingEscapeUtils.unescape`).
95pub fn unescape_manchester_rendering(rendering: &str) -> String {
96    let mut s = rendering.to_string();
97    if s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2 {
98        s = s[1..s.len() - 1].to_string();
99    }
100    s = s.replace("\\\"", "\"");
101    s = s.replace("\\'", "'");
102    s = s.replace("\\\\", "\\");
103    s
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn ns() -> BTreeMap<String, String> {
111        BTreeMap::from([
112            ("ex".to_string(), "http://example.org/#".to_string()),
113            ("owl".to_string(), OWL_NS.to_string()),
114        ])
115    }
116
117    #[test]
118    fn split_hash_and_slash() {
119        assert_eq!(
120            split_iri("http://example.org#Person"),
121            ("http://example.org#".into(), "Person".into())
122        );
123        assert_eq!(
124            split_iri("http://example.org/obo/GO_1"),
125            ("http://example.org/obo/".into(), "GO_1".into())
126        );
127    }
128
129    #[test]
130    fn render_curie_and_builtin() {
131        assert_eq!(render_entity_iri("http://example.org/#Person", &ns()), "ex:Person");
132        assert_eq!(
133            render_entity_iri("http://www.w3.org/2002/07/owl#Thing", &BTreeMap::new()),
134            "owl:Thing"
135        );
136    }
137
138    #[test]
139    fn expand_and_match_prefix() {
140        let namespaces = ns();
141        assert_eq!(
142            expand_prefixed_iri("ex:Person", &namespaces).as_deref(),
143            Some("http://example.org/#Person")
144        );
145        let (p, base) = match_prefix("http://example.org/#Person", &namespaces).unwrap();
146        assert_eq!(p, "ex");
147        assert_eq!(base, "http://example.org/#");
148    }
149
150    #[test]
151    fn manchester_escape_roundtrip() {
152        assert_eq!(escape_manchester_rendering("Person"), "Person");
153        assert_eq!(escape_manchester_rendering("has part"), "'has part'");
154        assert_eq!(unescape_manchester_rendering(&escape_manchester_rendering("a'b")), "a'b");
155    }
156}