Skip to main content

strixonomy_owl/
util.rs

1//! Portable presentation/util helpers (Protégé Wave 3 ports).
2//!
3//! Behavioral ports of `StringAbbreviator`, `ISO8601Formatter`,
4//! `LiteralLexicalValueReplacer`, `MarkdownRenderer`, and
5//! `AnnotationPropertyComparator` default ordering.
6//!
7//! Keep the annotation-property order in sync with
8//! `extension/webview-ui/src/utils/annotationOrder.ts`.
9
10use crate::render::unescape_manchester_rendering;
11use regex::Regex;
12use std::cmp::Ordering;
13
14/// Protégé `StringAbbreviator.ELLIPSIS` (U+2026).
15pub const ELLIPSIS: &str = "\u{2026}";
16
17/// Abbreviate `s` to at most `max_len` characters, appending an ellipsis when truncated.
18///
19/// Matches Protégé `StringAbbreviator.abbreviateString` edge cases:
20/// - empty input → empty
21/// - `max_len <= 0` → ellipsis only
22pub fn abbreviate_string(s: &str, max_len: usize) -> String {
23    if s.is_empty() {
24        return String::new();
25    }
26    if max_len == 0 {
27        return ELLIPSIS.to_string();
28    }
29    // Count Unicode scalar values so ellipsis behaviour matches Java `String.length`
30    // for BMP text used in Protégé tests (not grapheme clusters).
31    let chars: Vec<char> = s.chars().collect();
32    if chars.len() <= max_len {
33        return s.to_string();
34    }
35    chars.into_iter().take(max_len).collect::<String>() + ELLIPSIS
36}
37
38/// Format a UTC datetime as `yyyy-MM-dd'T'HH:mm:ss'Z'` (Protégé ISO8601Formatter).
39pub fn format_iso8601_utc(
40    year: u32,
41    month: u32,
42    day: u32,
43    hour: u32,
44    minute: u32,
45    second: u32,
46) -> String {
47    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
48}
49
50/// Result of a lexical value replacement (lang XOR datatype preserved).
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct LexicalLiteral {
53    pub lexical: String,
54    pub lang: Option<String>,
55    pub datatype: Option<String>,
56}
57
58/// Replace the lexical form via regex, preserving language tag or datatype IRI.
59pub fn replace_lexical_value(
60    lexical: &str,
61    lang: Option<&str>,
62    datatype: Option<&str>,
63    pattern: &Regex,
64    replacement: &str,
65) -> LexicalLiteral {
66    let new_lexical = pattern.replace_all(lexical, replacement).into_owned();
67    LexicalLiteral {
68        lexical: new_lexical,
69        lang: lang.map(str::to_string),
70        datatype: if lang.is_some() { None } else { datatype.map(str::to_string) },
71    }
72}
73
74/// Whole-string lexical replacement (Protégé `replaceLexicalValue` without pattern).
75pub fn replace_lexical_value_whole(
76    lexical: &str,
77    lang: Option<&str>,
78    datatype: Option<&str>,
79    replacement: &str,
80) -> LexicalLiteral {
81    let re = Regex::new(r"(?s)^.*$").expect("whole-string pattern");
82    replace_lexical_value(lexical, lang, datatype, &re, replacement)
83}
84
85/// Markdown entity link: `[unescaped display](iri)` (Protégé `MarkdownRenderer`).
86pub fn render_entity_markdown(display_name: &str, iri: &str) -> String {
87    let name = unescape_manchester_rendering(display_name);
88    format!("[{name}]({iri})")
89}
90
91/// Default annotation-property IRI ordering (Protégé `AnnotationPropertyComparator`).
92pub const DEFAULT_ANNOTATION_PROPERTY_ORDER: &[&str] = &[
93    // Labels
94    "http://www.w3.org/2000/01/rdf-schema#label",
95    "http://www.w3.org/2004/02/skos/core#prefLabel",
96    "http://purl.org/dc/elements/1.1/title",
97    // OBO identifiers / namespace
98    "http://www.geneontology.org/formats/oboInOwl#id",
99    "http://www.geneontology.org/formats/oboInOwl#hasAlternativeId",
100    "http://www.geneontology.org/formats/oboInOwl#hasOBONamespace",
101    // Definitions
102    "http://purl.obolibrary.org/obo/IAO_0000115",
103    "http://www.w3.org/2004/02/skos/core#definition",
104    "http://www.w3.org/2004/02/skos/core#note",
105    "http://purl.org/dc/elements/1.1/description",
106    // License / provenance
107    "http://purl.org/dc/elements/1.1/rights",
108    "http://purl.org/dc/terms/license",
109    "http://purl.org/dc/elements/1.1/publisher",
110    "http://purl.org/dc/elements/1.1/creator",
111    "http://purl.org/dc/elements/1.1/contributor",
112    "http://www.w3.org/2000/01/rdf-schema#comment",
113    // Other labels / seeAlso
114    "http://www.w3.org/2004/02/skos/core#altLabel",
115    "http://www.w3.org/2000/01/rdf-schema#seeAlso",
116    "http://www.w3.org/2000/01/rdf-schema#isDefinedBy",
117    // Synonyms
118    "http://www.geneontology.org/formats/oboInOwl#hasExactSynonym",
119    "http://www.geneontology.org/formats/oboInOwl#hasRelatedSynonym",
120    "http://www.geneontology.org/formats/oboInOwl#hasBroadSynonym",
121    "http://www.geneontology.org/formats/oboInOwl#hasNarrowSynonym",
122];
123
124fn annotation_order_index(iri: &str) -> Option<usize> {
125    DEFAULT_ANNOTATION_PROPERTY_ORDER.iter().position(|&known| known == iri)
126}
127
128/// Compare annotation-property IRIs using the Protégé default ordering.
129/// Unknown IRIs sort after known ones, then lexicographically.
130pub fn cmp_annotation_property_iri(a: &str, b: &str) -> Ordering {
131    match (annotation_order_index(a), annotation_order_index(b)) {
132        (Some(i), Some(j)) => i.cmp(&j).then_with(|| a.cmp(b)),
133        (Some(_), None) => Ordering::Less,
134        (None, Some(_)) => Ordering::Greater,
135        (None, None) => a.cmp(b),
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn abbreviate_edges() {
145        assert_eq!(abbreviate_string("", 5), "");
146        assert_eq!(abbreviate_string("abc", 0), ELLIPSIS);
147        assert_eq!(abbreviate_string("hello", 5), "hello");
148        assert_eq!(abbreviate_string("hello", 3), format!("hel{ELLIPSIS}"));
149    }
150
151    #[test]
152    fn iso8601_format() {
153        assert_eq!(format_iso8601_utc(2015, 11, 20, 12, 0, 0), "2015-11-20T12:00:00Z");
154    }
155
156    #[test]
157    fn lexical_replace_preserves_lang() {
158        let re = Regex::new("world").unwrap();
159        let lit = replace_lexical_value("hello world", Some("en"), None, &re, "there");
160        assert_eq!(lit.lexical, "hello there");
161        assert_eq!(lit.lang.as_deref(), Some("en"));
162        assert!(lit.datatype.is_none());
163    }
164
165    #[test]
166    fn markdown_unescapes_manchester() {
167        assert_eq!(
168            render_entity_markdown("'has part'", "http://ex.org#hp"),
169            "[has part](http://ex.org#hp)"
170        );
171    }
172
173    #[test]
174    fn annotation_order_labels_before_comments() {
175        assert_eq!(
176            cmp_annotation_property_iri(
177                "http://www.w3.org/2000/01/rdf-schema#label",
178                "http://www.w3.org/2000/01/rdf-schema#comment"
179            ),
180            Ordering::Less
181        );
182    }
183}