Skip to main content

ontocore_catalog/
entity_api.rs

1use crate::OntologyCatalog;
2use ontocore_core::{
3    document_matches_entity, read_to_string_capped, Entity, EntityKind, PropertyCharacteristics,
4    AXIOM_KIND_CLASS_ASSERTION, AXIOM_KIND_DATA_PROPERTY_ASSERTION, AXIOM_KIND_DISJOINT_CLASS,
5    AXIOM_KIND_DOMAIN, AXIOM_KIND_EQUIVALENT_CLASS, AXIOM_KIND_OBJECT_PROPERTY_ASSERTION,
6    AXIOM_KIND_PROPERTY_CHAIN, AXIOM_KIND_RANGE, AXIOM_KIND_SUB_CLASS_OF, MAX_FILE_BYTES,
7};
8use ontocore_diagnostics::{entity_needles, find_in_source};
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::PathBuf;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SubclassEdge {
15    pub child: String,
16    pub parent: String,
17}
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20pub struct ClassHierarchy {
21    pub edges: Vec<SubclassEdge>,
22    pub parents: BTreeMap<String, Vec<String>>,
23    pub children: BTreeMap<String, Vec<String>>,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct SourceHint {
28    pub path: PathBuf,
29    pub line: u64,
30    pub column: u64,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub struct EntityAxiomSummary {
35    pub kind: String,
36    pub display: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub manchester: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub parent_iri: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub other_iri: Option<String>,
43    pub editable: bool,
44}
45
46#[derive(Debug, Clone, Serialize)]
47pub struct EntityAnnotationSummary {
48    pub predicate: String,
49    pub value: String,
50    pub editable: bool,
51}
52
53#[derive(Debug, Clone, Serialize)]
54pub struct EntityDetail {
55    pub entity: Entity,
56    pub parents: Vec<String>,
57    pub children: Vec<String>,
58    pub axioms: Vec<EntityAxiomSummary>,
59    pub annotations: Vec<EntityAnnotationSummary>,
60    #[serde(skip_serializing_if = "PropertyCharacteristics::is_empty")]
61    pub characteristics: PropertyCharacteristics,
62    pub source: Option<SourceHint>,
63    pub editable: bool,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub document_path: Option<String>,
66}
67
68impl OntologyCatalog {
69    pub fn find_entity(&self, iri: &str) -> Option<&Entity> {
70        self.data().entities.iter().find(|e| e.iri == iri)
71    }
72
73    pub fn entity_document(&self, iri: &str) -> Option<&ontocore_core::OntologyDocument> {
74        if let Some(&doc_idx) = self.entity_to_document.get(iri) {
75            return self.data().documents.get(doc_idx);
76        }
77
78        let entity = self.find_entity(iri)?;
79        self.data().documents.iter().find(|d| document_matches_entity(entity, d))
80    }
81
82    pub fn class_hierarchy(&self) -> ClassHierarchy {
83        let mut edges = Vec::new();
84        let mut parents: BTreeMap<String, Vec<String>> = BTreeMap::new();
85        let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
86
87        let class_iris: BTreeSet<&str> = self
88            .data()
89            .entities
90            .iter()
91            .filter(|e| e.kind == EntityKind::Class)
92            .map(|e| e.iri.as_str())
93            .collect();
94
95        for axiom in &self.data().axioms {
96            if axiom.axiom_kind != AXIOM_KIND_SUB_CLASS_OF {
97                continue;
98            }
99            // Keep edges when the child is a known class, even if the parent is external
100            // (common for OBO is_a and imports).
101            if !class_iris.contains(axiom.subject.as_str()) {
102                continue;
103            }
104            let edge = SubclassEdge { child: axiom.subject.clone(), parent: axiom.object.clone() };
105            edges.push(edge.clone());
106            parents.entry(edge.child.clone()).or_default().push(edge.parent.clone());
107            children.entry(edge.parent.clone()).or_default().push(edge.child.clone());
108        }
109
110        for list in parents.values_mut().chain(children.values_mut()) {
111            list.sort();
112            list.dedup();
113        }
114
115        ClassHierarchy { edges, parents, children }
116    }
117
118    pub fn entity_detail(&self, iri: &str) -> Option<EntityDetail> {
119        let hierarchy = self.class_hierarchy();
120        self.entity_detail_with_hierarchy(iri, &hierarchy)
121    }
122
123    pub fn entity_detail_with_hierarchy(
124        &self,
125        iri: &str,
126        hierarchy: &ClassHierarchy,
127    ) -> Option<EntityDetail> {
128        let entity = self.find_entity(iri)?.clone();
129
130        let parents = hierarchy.parents.get(iri).cloned().unwrap_or_default();
131        let children = hierarchy.children.get(iri).cloned().unwrap_or_default();
132
133        let source = self.find_source_location(iri);
134        let doc = self.entity_document(iri);
135        let editable = doc.is_some_and(|d| {
136            matches!(
137                d.format,
138                ontocore_core::OntologyFormat::Turtle | ontocore_core::OntologyFormat::Obo
139            ) && d.parse_status == ontocore_core::ParseStatus::Ok
140        });
141        let turtle_axioms = doc.is_some_and(|d| {
142            d.format == ontocore_core::OntologyFormat::Turtle
143                && d.parse_status == ontocore_core::ParseStatus::Ok
144        });
145        let document_path = doc.map(|d| d.path.display().to_string());
146
147        let axioms: Vec<EntityAxiomSummary> = self
148            .data()
149            .axioms
150            .iter()
151            .filter(|a| a.subject == iri)
152            .map(|a| axiom_summary(a, turtle_axioms))
153            .collect();
154
155        const PROMOTED: &[&str] = &[
156            "http://www.w3.org/2000/01/rdf-schema#label",
157            "http://www.w3.org/2000/01/rdf-schema#comment",
158            "http://www.w3.org/2002/07/owl#deprecated",
159        ];
160        let annotations: Vec<EntityAnnotationSummary> = self
161            .data()
162            .annotations
163            .iter()
164            .filter(|a| a.subject == iri && !PROMOTED.contains(&a.predicate.as_str()))
165            .map(|a| EntityAnnotationSummary {
166                predicate: a.predicate.clone(),
167                value: a.object.clone(),
168                editable,
169            })
170            .collect();
171
172        Some(EntityDetail {
173            entity: entity.clone(),
174            parents,
175            children,
176            axioms,
177            annotations,
178            characteristics: entity.characteristics.clone(),
179            source,
180            editable,
181            document_path,
182        })
183    }
184
185    pub fn find_source_location(&self, iri: &str) -> Option<SourceHint> {
186        let entity = self.find_entity(iri)?;
187        let doc = self.entity_document(iri)?;
188
189        if let Some(loc) = entity.source_location.line {
190            return Some(SourceHint {
191                path: doc.path.clone(),
192                line: loc,
193                column: entity.source_location.column.unwrap_or(0),
194            });
195        }
196
197        scan_file_for_iri(&doc.path, iri, &entity.short_name, &doc.namespaces)
198    }
199
200    pub fn entities_in_document(&self, doc_path: &std::path::Path) -> Vec<&Entity> {
201        let doc_path = doc_path.canonicalize().unwrap_or_else(|_| doc_path.to_path_buf());
202        let Some(doc_idx) = self
203            .data()
204            .documents
205            .iter()
206            .position(|d| d.path.canonicalize().unwrap_or_else(|_| d.path.clone()) == doc_path)
207        else {
208            return Vec::new();
209        };
210        self.document_entity_iris
211            .get(doc_idx)
212            .into_iter()
213            .flatten()
214            .filter_map(|iri| self.find_entity(iri))
215            .collect()
216    }
217}
218
219fn axiom_summary(a: &ontocore_core::Axiom, editable: bool) -> EntityAxiomSummary {
220    let is_named_iri = a.object.starts_with("http://") || a.object.starts_with("https://");
221    let manchester = if is_named_iri { None } else { Some(a.object.clone()) };
222    let parent_iri = if (a.axiom_kind == AXIOM_KIND_SUB_CLASS_OF
223        || a.axiom_kind == AXIOM_KIND_CLASS_ASSERTION)
224        && is_named_iri
225    {
226        Some(a.object.clone())
227    } else {
228        None
229    };
230    let other_iri = if a.axiom_kind == AXIOM_KIND_DISJOINT_CLASS && is_named_iri {
231        Some(a.object.clone())
232    } else {
233        None
234    };
235    let kind_label = match a.axiom_kind.as_str() {
236        AXIOM_KIND_EQUIVALENT_CLASS => "EquivalentClasses",
237        AXIOM_KIND_DISJOINT_CLASS => "DisjointClasses",
238        AXIOM_KIND_DOMAIN => "Domain",
239        AXIOM_KIND_RANGE => "Range",
240        AXIOM_KIND_PROPERTY_CHAIN => "PropertyChain",
241        AXIOM_KIND_CLASS_ASSERTION => "ClassAssertion",
242        AXIOM_KIND_OBJECT_PROPERTY_ASSERTION => "ObjectPropertyAssertion",
243        AXIOM_KIND_DATA_PROPERTY_ASSERTION => "DataPropertyAssertion",
244        _ => "SubClassOf",
245    };
246    let axiom_editable = editable;
247    EntityAxiomSummary {
248        kind: a.axiom_kind.clone(),
249        display: format!("{} {}", kind_label, a.object),
250        manchester,
251        parent_iri,
252        other_iri,
253        editable: axiom_editable,
254    }
255}
256
257fn scan_file_for_iri(
258    path: &std::path::Path,
259    iri: &str,
260    short_name: &str,
261    namespaces: &BTreeMap<String, String>,
262) -> Option<SourceHint> {
263    if path.symlink_metadata().ok()?.file_type().is_symlink() {
264        return None;
265    }
266    let content = read_to_string_capped(path, MAX_FILE_BYTES).ok()?;
267    let loc = find_in_source(&content, &entity_needles(iri, short_name, namespaces));
268    loc.line.map(|line| SourceHint {
269        path: path.to_path_buf(),
270        line,
271        column: loc.column.unwrap_or(0),
272    })
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::IndexBuilder;
279    use std::path::PathBuf;
280
281    fn fixture_workspace() -> PathBuf {
282        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
283    }
284
285    fn fixture_catalog() -> OntologyCatalog {
286        IndexBuilder::new().workspace(fixture_workspace()).build().expect("build catalog")
287    }
288
289    #[test]
290    fn find_entity_by_iri() {
291        let catalog = fixture_catalog();
292        let entity =
293            catalog.find_entity("http://example.org/people#Person").expect("Person entity");
294        assert_eq!(entity.short_name, "Person");
295        assert_eq!(entity.kind, EntityKind::Class);
296    }
297
298    #[test]
299    fn class_hierarchy_includes_subclass_axiom() {
300        let catalog = fixture_catalog();
301        let hierarchy = catalog.class_hierarchy();
302        assert!(!hierarchy.edges.is_empty());
303        assert!(hierarchy
304            .parents
305            .get("http://example.org/people#Person")
306            .is_some_and(|p| p.contains(&"http://example.org/people#Thing".to_string())));
307    }
308
309    #[test]
310    fn entity_detail_includes_labels_and_parents() {
311        let catalog = fixture_catalog();
312        let detail =
313            catalog.entity_detail("http://example.org/people#Person").expect("Person detail");
314        assert!(!detail.entity.labels.is_empty());
315        assert!(!detail.parents.is_empty());
316    }
317
318    #[test]
319    fn find_source_location_in_fixture() {
320        let catalog = fixture_catalog();
321        let source = catalog
322            .find_source_location("http://example.org/people#Person")
323            .expect("source location");
324        assert!(source.path.ends_with("example.ttl"));
325        assert!(source.line > 0);
326    }
327
328    #[test]
329    fn entities_in_document_uses_build_time_index() {
330        let catalog = fixture_catalog();
331        let doc_path = fixture_workspace().join("example.ttl");
332        let entities = catalog.entities_in_document(&doc_path);
333        assert!(entities.iter().any(|e| e.short_name == "Person"));
334    }
335
336    #[test]
337    fn find_source_location_skips_person_colon_in_comments() {
338        let dir = tempfile::tempdir().expect("tempdir");
339        let ttl_path = dir.path().join("test.ttl");
340        std::fs::write(
341            &ttl_path,
342            concat!(
343                "@prefix ex: <http://ex#> .\n",
344                "@prefix owl: <http://www.w3.org/2002/07/owl#> .\n\n",
345                "# Note: Person: see documentation\n\n",
346                "ex:Person a owl:Class .\n"
347            ),
348        )
349        .expect("write ttl");
350
351        let catalog = IndexBuilder::new().workspace(dir.path()).build().expect("build catalog");
352        let source = catalog.find_source_location("http://ex#Person").expect("source location");
353        let entity_line = std::fs::read_to_string(&ttl_path)
354            .expect("read ttl")
355            .lines()
356            .position(|line| line.contains("ex:Person"))
357            .expect("entity line")
358            + 1;
359        assert_eq!(source.line, entity_line as u64);
360        assert_eq!(source.column, 0);
361    }
362}