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 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}