1use crate::vocab::{Rdf, Rdfs, OWL};
2use ontocore_core::{
3 limits::{MAX_FILE_BYTES, MAX_TRIPLES_PER_FILE},
4 read_file_capped, Annotation, Axiom, Entity, EntityKind, Import, Namespace, OntologyFormat,
5 ParseStatus, SourceLocation, AXIOM_KIND_SUB_CLASS_OF,
6};
7use oxigraph::io::{RdfFormat, RdfParseError, RdfParser, RdfSerializer};
8use oxigraph::model::{GraphName, Literal, NamedNode, Quad, Subject, Term};
9use std::collections::{BTreeMap, BTreeSet, HashMap};
10use std::path::Path;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14pub enum ParseError {
15 #[error("IO error: {0}")]
16 Io(#[from] std::io::Error),
17
18 #[error("RDF parse error: {0}")]
19 Rdf(String),
20
21 #[error("unsupported format: {0}")]
22 UnsupportedFormat(String),
23
24 #[error("limit exceeded: {0}")]
25 LimitExceeded(String),
26
27 #[error("invalid UTF-8: {0}")]
28 InvalidUtf8(String),
29}
30
31pub type Result<T> = std::result::Result<T, ParseError>;
32
33#[derive(Debug, Clone)]
34pub struct ParsedOntology {
35 pub ontology_id: String,
36 pub base_iri: Option<String>,
37 pub version_iri: Option<String>,
38 pub imports: Vec<String>,
39 pub namespaces: BTreeMap<String, String>,
40 pub entities: Vec<Entity>,
41 pub annotations: Vec<Annotation>,
42 pub axioms: Vec<Axiom>,
43 pub namespace_rows: Vec<Namespace>,
44 pub import_rows: Vec<Import>,
45 pub parse_status: ParseStatus,
46 pub parse_message: Option<String>,
47 pub parse_error_location: Option<SourceLocation>,
48 pub triple_count: usize,
49 quads: Vec<Quad>,
50}
51
52impl ParsedOntology {
53 #[doc(hidden)]
55 pub fn quads(&self) -> &[Quad] {
56 &self.quads
57 }
58}
59
60pub fn parse_ontology_file(
61 path: &Path,
62 format: OntologyFormat,
63 ontology_id: &str,
64 content_hash: &str,
65 modified_time: u64,
66) -> Result<ParsedOntology> {
67 if format == OntologyFormat::Obo {
68 return crate::obo::parse_obo_file(path, ontology_id, content_hash, modified_time);
69 }
70 if format == OntologyFormat::OwlXml {
71 let content = read_file_capped(path, MAX_FILE_BYTES)
72 .map_err(|e| ParseError::LimitExceeded(e.to_string()))?;
73 let _source_text = String::from_utf8(content).map_err(|e| {
74 ParseError::InvalidUtf8(format!("invalid UTF-8 in {}: {e}", path.display()))
75 })?;
76 return Ok(empty_parsed_ontology(ontology_id));
77 }
78 let _ = (content_hash, modified_time);
79 let content = read_file_capped(path, MAX_FILE_BYTES)
80 .map_err(|e| ParseError::LimitExceeded(e.to_string()))?;
81 let source_text = String::from_utf8(content).map_err(|e| {
82 ParseError::InvalidUtf8(format!("invalid UTF-8 in {}: {e}", path.display()))
83 })?;
84 parse_ontology_text(path, format, ontology_id, &source_text, source_text.as_bytes())
85}
86
87pub fn parse_ontology_text(
89 path: &Path,
90 format: OntologyFormat,
91 ontology_id: &str,
92 source_text: &str,
93 raw_bytes: &[u8],
94) -> Result<ParsedOntology> {
95 if source_text.len() as u64 > MAX_FILE_BYTES || raw_bytes.len() as u64 > MAX_FILE_BYTES {
96 return Err(ParseError::LimitExceeded(format!(
97 "source exceeds {MAX_FILE_BYTES} bytes: {}",
98 path.display()
99 )));
100 }
101 if format == OntologyFormat::Obo {
102 return crate::obo::parse_obo_text(path, ontology_id, source_text);
103 }
104 if format == OntologyFormat::OwlXml {
105 return Ok(empty_parsed_ontology(ontology_id));
106 }
107 let rdf_format = to_rdf_format(format, path)?;
108
109 let mut quads = Vec::new();
110 let mut parse_message = None;
111 let mut parse_error_location = None;
112 let mut parse_status = ParseStatus::Ok;
113
114 let parser = RdfParser::from_format(rdf_format);
115 for quad in parser.for_reader(raw_bytes) {
116 match quad {
117 Ok(q) => {
118 if quads.len() >= MAX_TRIPLES_PER_FILE {
119 return Err(ParseError::LimitExceeded(format!(
120 "file exceeds {MAX_TRIPLES_PER_FILE} triples: {}",
121 path.display()
122 )));
123 }
124 quads.push(q);
125 }
126 Err(e) => {
127 parse_status = ParseStatus::Error;
128 parse_message = Some(format_parse_error(&e));
129 parse_error_location = extract_parse_error_location(&e, source_text);
130 break;
131 }
132 }
133 }
134
135 if parse_status == ParseStatus::Error && quads.is_empty() {
137 return Ok(empty_result(
138 ontology_id,
139 parse_status,
140 parse_message,
141 parse_error_location,
142 BTreeMap::new(),
143 ));
144 }
145
146 let mut namespaces =
147 if format == OntologyFormat::TriG { extract_prefixes(&quads) } else { BTreeMap::new() };
148 namespaces.extend(extract_declared_prefixes(source_text, format));
149 if namespaces.is_empty() {
150 namespaces.insert("".to_string(), default_base_iri(path));
151 }
152
153 let mut builder = OntologyBuilder::new(ontology_id.to_string(), namespaces.clone());
154 for quad in &quads {
155 builder.ingest_quad(quad);
156 }
157 builder.finish(parse_status, parse_message, parse_error_location, source_text, quads)
158}
159
160fn empty_parsed_ontology(ontology_id: &str) -> ParsedOntology {
161 ParsedOntology {
162 ontology_id: ontology_id.to_string(),
163 base_iri: None,
164 version_iri: None,
165 imports: Vec::new(),
166 namespaces: BTreeMap::new(),
167 entities: Vec::new(),
168 annotations: Vec::new(),
169 axioms: Vec::new(),
170 namespace_rows: Vec::new(),
171 import_rows: Vec::new(),
172 parse_status: ParseStatus::Ok,
173 parse_message: None,
174 parse_error_location: None,
175 triple_count: 0,
176 quads: Vec::new(),
177 }
178}
179
180fn to_rdf_format(format: OntologyFormat, path: &Path) -> Result<RdfFormat> {
181 match format {
182 OntologyFormat::Turtle => Ok(RdfFormat::Turtle),
183 OntologyFormat::RdfXml | OntologyFormat::Owl => Ok(RdfFormat::RdfXml),
184 OntologyFormat::JsonLd => Ok(RdfFormat::JsonLd { profile: Default::default() }),
185 OntologyFormat::NTriples => Ok(RdfFormat::NTriples),
186 OntologyFormat::NQuads => Ok(RdfFormat::NQuads),
187 OntologyFormat::TriG => Ok(RdfFormat::TriG),
188 OntologyFormat::Obo => {
189 Err(ParseError::UnsupportedFormat("OBO format must use parse_obo_text".to_string()))
190 }
191 OntologyFormat::OwlXml => {
192 Err(ParseError::UnsupportedFormat("OWL/XML must use parse_ontology_text".to_string()))
193 }
194 OntologyFormat::Unknown => Err(ParseError::UnsupportedFormat(path.display().to_string())),
195 }
196}
197
198fn format_parse_error(error: &RdfParseError) -> String {
199 error.to_string()
200}
201
202fn extract_parse_error_location(
203 error: &RdfParseError,
204 _source_text: &str,
205) -> Option<SourceLocation> {
206 let msg = error.to_string();
207 let line = msg.split_whitespace().collect::<Vec<_>>().windows(2).find_map(|w| {
208 if w[0].eq_ignore_ascii_case("line") {
209 w[1].trim_end_matches(':').parse().ok()
210 } else {
211 None
212 }
213 });
214 let column = msg.split_whitespace().collect::<Vec<_>>().windows(2).find_map(|w| {
215 if w[0].eq_ignore_ascii_case("column") || w[0].eq_ignore_ascii_case("col") {
216 w[1].trim_end_matches(':').parse().ok()
217 } else {
218 None
219 }
220 });
221 if line.is_some() || column.is_some() {
222 Some(SourceLocation { line, column, ..Default::default() })
223 } else {
224 None
225 }
226}
227
228fn default_base_iri(path: &Path) -> String {
229 ontocore_core::file_uri_for_path(path)
230}
231
232fn extract_declared_prefixes(
233 source_text: &str,
234 format: OntologyFormat,
235) -> BTreeMap<String, String> {
236 let mut prefixes = BTreeMap::new();
237
238 if matches!(format, OntologyFormat::Turtle | OntologyFormat::TriG) {
239 for line in source_text.lines() {
240 let trimmed = line.trim();
241 let rest = trimmed
242 .strip_prefix("@prefix ")
243 .or_else(|| trimmed.strip_prefix("@PREFIX "))
244 .or_else(|| trimmed.strip_prefix("PREFIX "));
245 let Some(rest) = rest else {
246 continue;
247 };
248 let Some((prefix_part, iri_part)) = rest.split_once('<') else {
249 continue;
250 };
251 let prefix = prefix_part.trim().trim_end_matches(':');
252 let Some(iri) = iri_part.split('>').next() else {
253 continue;
254 };
255 prefixes.insert(prefix.to_string(), iri.to_string());
256 }
257 return prefixes;
258 }
259
260 if matches!(format, OntologyFormat::RdfXml | OntologyFormat::Owl) {
261 static XMLNS_ATTR: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
262 regex::Regex::new(r#"xmlns(?::([A-Za-z][\w-]*))?="([^"]+)""#).expect("xmlns regex")
263 });
264 for cap in XMLNS_ATTR.captures_iter(source_text) {
265 let prefix = cap.get(1).map(|m| m.as_str()).unwrap_or("");
266 let iri = cap.get(2).map(|m| m.as_str()).unwrap_or("");
267 if !iri.is_empty() {
268 prefixes.insert(prefix.to_string(), iri.to_string());
269 }
270 }
271 }
272
273 prefixes
274}
275
276fn extract_prefixes(quads: &[Quad]) -> BTreeMap<String, String> {
277 let mut prefixes = BTreeMap::new();
278 for quad in quads {
279 if let oxigraph::model::GraphNameRef::NamedNode(graph) = quad.graph_name.as_ref() {
280 let iri = graph.as_str();
281 if let Some((prefix, _)) = iri.rsplit_once('#') {
282 if let Some((p, _)) = prefix.rsplit_once('/') {
283 prefixes
284 .entry(short_name_from_iri(p))
285 .or_insert_with(|| format!("{}/", prefix.trim_end_matches('#')));
286 }
287 }
288 }
289 }
290 prefixes
291}
292
293pub(crate) fn assemble_parsed_ontology(
294 ontology_id: &str,
295 base_iri: Option<String>,
296 namespaces: BTreeMap<String, String>,
297 entities: Vec<Entity>,
298 annotations: Vec<Annotation>,
299 axioms: Vec<Axiom>,
300) -> ParsedOntology {
301 let namespace_rows: Vec<Namespace> = namespaces
302 .iter()
303 .map(|(prefix, iri)| Namespace {
304 prefix: prefix.clone(),
305 iri: iri.clone(),
306 ontology_id: ontology_id.to_string(),
307 })
308 .collect();
309 let quads = materialize_catalog_quads(&entities, &annotations, &axioms);
310 let triple_count = quads.len().max(entities.len() + annotations.len() + axioms.len());
311 ParsedOntology {
312 ontology_id: ontology_id.to_string(),
313 base_iri,
314 version_iri: None,
315 imports: Vec::new(),
316 namespaces: namespaces.clone(),
317 entities,
318 annotations,
319 axioms,
320 namespace_rows,
321 import_rows: Vec::new(),
322 parse_status: ParseStatus::Ok,
323 parse_message: None,
324 parse_error_location: None,
325 triple_count,
326 quads,
327 }
328}
329
330fn materialize_catalog_quads(
332 entities: &[Entity],
333 annotations: &[Annotation],
334 axioms: &[Axiom],
335) -> Vec<Quad> {
336 let mut quads = Vec::new();
337 for entity in entities {
338 let Some(subject) = named_node(&entity.iri) else {
339 continue;
340 };
341 let type_iri = match entity.kind {
342 EntityKind::Class => OWL::class(),
343 EntityKind::ObjectProperty => OWL::object_property(),
344 EntityKind::DataProperty => OWL::datatype_property(),
345 EntityKind::AnnotationProperty => OWL::annotation_property(),
346 EntityKind::Individual => OWL::named_individual(),
347 EntityKind::Datatype => Rdfs::datatype(),
348 EntityKind::Ontology => OWL::ontology(),
349 EntityKind::Other => continue,
350 };
351 quads.push(Quad::new(
352 subject.clone(),
353 Rdf::type_().into_owned(),
354 type_iri.into_owned(),
355 GraphName::DefaultGraph,
356 ));
357 for label in &entity.labels {
358 quads.push(Quad::new(
359 subject.clone(),
360 Rdfs::label().into_owned(),
361 Literal::new_simple_literal(label),
362 GraphName::DefaultGraph,
363 ));
364 }
365 for comment in &entity.comments {
366 quads.push(Quad::new(
367 subject.clone(),
368 Rdfs::comment().into_owned(),
369 Literal::new_simple_literal(comment),
370 GraphName::DefaultGraph,
371 ));
372 }
373 if entity.deprecated {
374 quads.push(Quad::new(
375 subject,
376 OWL::deprecated().into_owned(),
377 Literal::from(true),
378 GraphName::DefaultGraph,
379 ));
380 }
381 }
382 for axiom in axioms {
383 if axiom.axiom_kind != AXIOM_KIND_SUB_CLASS_OF {
384 continue;
385 }
386 let (Some(s), Some(o)) = (named_node(&axiom.subject), named_node(&axiom.object)) else {
387 continue;
388 };
389 quads.push(Quad::new(s, Rdfs::sub_class_of().into_owned(), o, GraphName::DefaultGraph));
390 }
391 for ann in annotations {
392 let Some(s) = named_node(&ann.subject) else {
393 continue;
394 };
395 let pred_iri = expand_annotation_predicate(&ann.predicate);
396 let Some(p) = pred_iri.as_deref().and_then(named_node) else {
397 continue;
398 };
399 if let Some(o) = named_node(&ann.object) {
400 quads.push(Quad::new(s, p, o, GraphName::DefaultGraph));
401 } else {
402 quads.push(Quad::new(
403 s,
404 p,
405 Literal::new_simple_literal(&ann.object),
406 GraphName::DefaultGraph,
407 ));
408 }
409 }
410 quads
411}
412
413fn named_node(iri: &str) -> Option<NamedNode> {
414 NamedNode::new(iri).ok()
415}
416
417const OBO_INOWL_NS: &str = "http://www.geneontology.org/formats/oboInOwl#";
418const OBO_PURL_NS: &str = "http://purl.obolibrary.org/obo/";
419
420fn expand_annotation_predicate(pred: &str) -> Option<String> {
422 if pred.contains("://") {
423 return Some(pred.to_string());
424 }
425 let (prefix, local) = pred.split_once(':')?;
426 match prefix {
427 "obo" => Some(expand_obo_curie(local)),
428 _ => None,
429 }
430}
431
432fn expand_obo_curie(local: &str) -> String {
433 match local {
434 "hasExactSynonym" | "hasBroadSynonym" | "hasNarrowSynonym" | "hasRelatedSynonym"
435 | "hasDbXref" => format!("{OBO_INOWL_NS}{local}"),
436 _ if local.contains('_') => format!("{OBO_PURL_NS}{local}"),
438 _ => format!("{OBO_INOWL_NS}{local}"),
439 }
440}
441
442pub fn serialize_quads_turtle(quads: &[Quad]) -> Result<String> {
444 let mut serializer = RdfSerializer::from_format(RdfFormat::Turtle).for_writer(Vec::new());
445 for quad in quads {
446 serializer.serialize_quad(quad).map_err(|e| ParseError::Rdf(e.to_string()))?;
447 }
448 let bytes = serializer.finish().map_err(|e| ParseError::Rdf(e.to_string()))?;
449 String::from_utf8(bytes).map_err(|e| ParseError::Rdf(e.to_string()))
450}
451
452fn empty_result(
453 ontology_id: &str,
454 parse_status: ParseStatus,
455 parse_message: Option<String>,
456 parse_error_location: Option<SourceLocation>,
457 namespaces: BTreeMap<String, String>,
458) -> ParsedOntology {
459 ParsedOntology {
460 ontology_id: ontology_id.to_string(),
461 base_iri: namespaces.values().next().cloned(),
462 version_iri: None,
463 imports: Vec::new(),
464 namespaces: namespaces.clone(),
465 entities: Vec::new(),
466 annotations: Vec::new(),
467 axioms: Vec::new(),
468 namespace_rows: namespaces
469 .into_iter()
470 .map(|(prefix, iri)| Namespace { prefix, iri, ontology_id: ontology_id.to_string() })
471 .collect(),
472 import_rows: Vec::new(),
473 parse_status,
474 parse_message,
475 parse_error_location,
476 triple_count: 0,
477 quads: Vec::new(),
478 }
479}
480
481struct EntityState {
482 kind: EntityKind,
483 labels: Vec<String>,
484 comments: Vec<String>,
485 deprecated: bool,
486 types: BTreeSet<String>,
487}
488
489struct OntologyBuilder {
490 ontology_id: String,
491 namespaces: BTreeMap<String, String>,
492 entities: HashMap<String, EntityState>,
493 annotations: Vec<Annotation>,
494 axioms: Vec<Axiom>,
495 imports: BTreeSet<String>,
496 ontology_iris: BTreeSet<String>,
497 version_iris: BTreeSet<String>,
498 triple_count: usize,
499 axiom_counter: usize,
500}
501
502impl OntologyBuilder {
503 fn new(ontology_id: String, namespaces: BTreeMap<String, String>) -> Self {
504 Self {
505 ontology_id,
506 namespaces,
507 entities: HashMap::new(),
508 annotations: Vec::new(),
509 axioms: Vec::new(),
510 imports: BTreeSet::new(),
511 ontology_iris: BTreeSet::new(),
512 version_iris: BTreeSet::new(),
513 triple_count: 0,
514 axiom_counter: 0,
515 }
516 }
517
518 fn ingest_quad(&mut self, quad: &Quad) {
519 self.triple_count += 1;
520 let subject = subject_to_string(&quad.subject);
521 let predicate = quad.predicate.as_str().to_string();
522 let object = term_to_string(&quad.object);
523
524 if quad.predicate == Rdf::type_() {
525 if let Term::NamedNode(node) = &quad.object {
526 let type_iri = node.as_str();
527 if type_iri == OWL::ontology().as_str() {
528 self.ontology_iris.insert(subject.clone());
529 }
530 let kind = entity_kind_for_type(type_iri);
531 if kind != EntityKind::Other {
532 let entry =
533 self.entities.entry(subject.clone()).or_insert_with(|| EntityState {
534 kind,
535 labels: Vec::new(),
536 comments: Vec::new(),
537 deprecated: false,
538 types: BTreeSet::new(),
539 });
540 entry.types.insert(type_iri.to_string());
541 if entry.kind == EntityKind::Other
542 || kind_priority(kind) > kind_priority(entry.kind)
543 {
544 entry.kind = kind;
545 }
546 }
547 }
548 return;
549 }
550
551 if quad.predicate == OWL::imports() {
552 self.imports.insert(object.clone());
553 return;
554 }
555
556 if quad.predicate == OWL::version_iri() {
557 self.version_iris.insert(object.clone());
558 return;
559 }
560
561 if quad.predicate == Rdfs::label() {
562 if let Some(entity) = self.entities.get_mut(&subject) {
563 entity.labels.push(object.clone());
564 } else {
565 self.entities.entry(subject.clone()).or_insert_with(|| EntityState {
566 kind: EntityKind::Other,
567 labels: vec![object.clone()],
568 comments: Vec::new(),
569 deprecated: false,
570 types: BTreeSet::new(),
571 });
572 }
573 self.annotations.push(Annotation {
574 subject: subject.clone(),
575 predicate: predicate.clone(),
576 object: object.clone(),
577 ontology_id: self.ontology_id.clone(),
578 source_location: SourceLocation::default(),
579 });
580 return;
581 }
582
583 if quad.predicate == Rdfs::comment() {
584 if let Some(entity) = self.entities.get_mut(&subject) {
585 entity.comments.push(object.clone());
586 } else {
587 self.entities.entry(subject.clone()).or_insert_with(|| EntityState {
588 kind: EntityKind::Other,
589 labels: Vec::new(),
590 comments: vec![object.clone()],
591 deprecated: false,
592 types: BTreeSet::new(),
593 });
594 }
595 self.annotations.push(Annotation {
596 subject: subject.clone(),
597 predicate: predicate.clone(),
598 object: object.clone(),
599 ontology_id: self.ontology_id.clone(),
600 source_location: SourceLocation::default(),
601 });
602 return;
603 }
604
605 if quad.predicate == OWL::deprecated() {
606 let entry = self.entities.entry(subject.clone()).or_insert_with(|| EntityState {
607 kind: EntityKind::Other,
608 labels: Vec::new(),
609 comments: Vec::new(),
610 deprecated: false,
611 types: BTreeSet::new(),
612 });
613 entry.deprecated = ontocore_core::parse_boolean_literal(&object).unwrap_or(false);
614 return;
615 }
616
617 if quad.predicate == OWL::same_as() {
618 self.annotations.push(Annotation {
619 subject: subject.clone(),
620 predicate: predicate.clone(),
621 object: object.clone(),
622 ontology_id: self.ontology_id.clone(),
623 source_location: SourceLocation::default(),
624 });
625 return;
626 }
627
628 if quad.predicate == Rdfs::sub_class_of() {
629 self.axiom_counter += 1;
630 self.axioms.push(Axiom {
631 id: format!("{}#axiom-{}", self.ontology_id, self.axiom_counter),
632 ontology_id: self.ontology_id.clone(),
633 subject: subject.clone(),
634 predicate: predicate.clone(),
635 object: object.clone(),
636 axiom_kind: AXIOM_KIND_SUB_CLASS_OF.to_string(),
637 source_location: SourceLocation::default(),
638 annotations: Vec::new(),
639 });
640 }
641 }
642
643 fn finish(
644 self,
645 parse_status: ParseStatus,
646 parse_message: Option<String>,
647 parse_error_location: Option<SourceLocation>,
648 source_text: &str,
649 quads: Vec<Quad>,
650 ) -> Result<ParsedOntology> {
651 let base_iri = self
652 .ontology_iris
653 .iter()
654 .next()
655 .cloned()
656 .or_else(|| self.namespaces.get("").cloned())
657 .or_else(|| self.namespaces.values().next().cloned());
658
659 let version_iri = self.version_iris.iter().next().cloned();
660
661 let ontology_id = if let Some(iri) = self.ontology_iris.iter().next() {
662 iri.clone()
663 } else {
664 self.ontology_id.clone()
665 };
666
667 let mut entities = Vec::new();
668 for (iri, state) in &self.entities {
669 if state.kind == EntityKind::Other {
670 continue;
671 }
672 let short_name = short_name_from_iri(iri);
673 entities.push(Entity {
674 iri: iri.clone(),
675 short_name: short_name.clone(),
676 kind: state.kind,
677 ontology_id: ontology_id.clone(),
678 source_location: find_entity_source_location(
679 source_text,
680 iri,
681 &short_name,
682 &self.namespaces,
683 ),
684 labels: state.labels.clone(),
685 comments: state.comments.clone(),
686 deprecated: state.deprecated,
687 obo_id: None,
688 characteristics: Default::default(),
689 });
690 }
691 entities.sort_by(|a, b| a.iri.cmp(&b.iri));
692
693 let namespace_rows = self
694 .namespaces
695 .iter()
696 .map(|(prefix, iri)| Namespace {
697 prefix: prefix.clone(),
698 iri: iri.clone(),
699 ontology_id: ontology_id.clone(),
700 })
701 .collect();
702
703 let import_rows = self
704 .imports
705 .iter()
706 .map(|import_iri| Import {
707 ontology_id: ontology_id.clone(),
708 import_iri: import_iri.clone(),
709 })
710 .collect();
711
712 let mut annotations = self.annotations;
713 for ann in &mut annotations {
714 ann.ontology_id = ontology_id.clone();
715 }
716 let mut axioms = self.axioms;
717 for axiom in &mut axioms {
718 axiom.ontology_id = ontology_id.clone();
719 }
720
721 Ok(ParsedOntology {
722 ontology_id,
723 base_iri,
724 version_iri,
725 imports: self.imports.into_iter().collect(),
726 namespaces: self.namespaces.clone(),
727 entities,
728 annotations,
729 axioms,
730 namespace_rows,
731 import_rows,
732 parse_status,
733 parse_message,
734 parse_error_location,
735 triple_count: self.triple_count,
736 quads,
737 })
738 }
739}
740
741fn entity_kind_for_type(type_iri: &str) -> EntityKind {
742 match type_iri {
743 t if t == OWL::class().as_str() || t == Rdfs::class().as_str() => EntityKind::Class,
744 t if t == OWL::object_property().as_str() => EntityKind::ObjectProperty,
745 t if t == OWL::datatype_property().as_str() => EntityKind::DataProperty,
746 t if t == OWL::annotation_property().as_str() => EntityKind::AnnotationProperty,
747 t if t == OWL::named_individual().as_str() => EntityKind::Individual,
748 t if t == Rdfs::datatype().as_str() => EntityKind::Datatype,
749 t if t == OWL::ontology().as_str() => EntityKind::Ontology,
750 _ => EntityKind::Other,
751 }
752}
753
754fn kind_priority(kind: EntityKind) -> u8 {
755 match kind {
756 EntityKind::Class => 5,
757 EntityKind::ObjectProperty => 5,
758 EntityKind::DataProperty => 5,
759 EntityKind::AnnotationProperty => 5,
760 EntityKind::Individual => 4,
761 EntityKind::Datatype => 4,
762 EntityKind::Ontology => 3,
763 EntityKind::Other => 0,
764 }
765}
766
767fn subject_to_string(subject: &Subject) -> String {
768 match subject {
769 Subject::NamedNode(node) => node.as_str().to_string(),
770 Subject::BlankNode(node) => format!("_:{}", node.as_str()),
771 #[allow(unreachable_patterns)]
772 _ => subject.to_string(),
773 }
774}
775
776fn term_to_string(term: &Term) -> String {
777 match term {
778 Term::NamedNode(node) => node.as_str().to_string(),
779 Term::BlankNode(node) => format!("_:{}", node.as_str()),
780 Term::Literal(lit) => lit.value().to_string(),
781 #[allow(unreachable_patterns)]
782 _ => term.to_string(),
783 }
784}
785
786fn find_entity_source_location(
787 source_text: &str,
788 iri: &str,
789 short_name: &str,
790 namespaces: &BTreeMap<String, String>,
791) -> SourceLocation {
792 let mut needles = vec![iri.to_string(), format!("<{iri}>"), format!(":{short_name}")];
793 for (prefix, ns) in namespaces {
794 if iri.starts_with(ns) && !prefix.is_empty() {
795 needles.push(format!("{prefix}:{short_name}"));
796 }
797 }
798 for line in source_text.lines() {
799 let trimmed = line.trim();
800 if !(trimmed.starts_with("@prefix")
801 || trimmed.starts_with("@PREFIX")
802 || trimmed.starts_with("PREFIX "))
803 {
804 continue;
805 }
806 let prefix_kw_len = if trimmed.to_ascii_lowercase().starts_with("@prefix ") {
807 "@prefix ".len()
808 } else if trimmed.to_ascii_lowercase().starts_with("prefix ") {
809 "PREFIX ".len()
810 } else {
811 continue;
812 };
813 if let Some(colon) = trimmed.find(':') {
814 if colon < prefix_kw_len {
815 continue;
816 }
817 let prefix = trimmed[prefix_kw_len..colon].trim();
818 let prefix = prefix.trim_start_matches('@');
819 if let (Some(start), Some(end)) = (line.find('<'), line.find('>')) {
820 if start < end {
821 let ns = &line[start + 1..end];
822 if iri.starts_with(ns) && !prefix.is_empty() {
823 needles.push(format!("{prefix}:{short_name}"));
824 }
825 }
826 }
827 }
828 }
829
830 for (line_idx, line) in source_text.lines().enumerate() {
831 for needle in &needles {
832 if let Some(col) = line.find(needle) {
833 return SourceLocation {
834 line: Some((line_idx + 1) as u64),
835 column: Some(col as u64),
836 start_byte: None,
837 end_byte: None,
838 };
839 }
840 }
841 }
842
843 SourceLocation::default()
844}
845
846fn short_name_from_iri(iri: &str) -> String {
847 if let Some((_, name)) = iri.rsplit_once('#') {
848 return name.to_string();
849 }
850 if let Some((_, name)) = iri.rsplit_once('/') {
851 return name.to_string();
852 }
853 iri.to_string()
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use std::fs;
860 use std::io::Write;
861
862 #[test]
863 fn rejects_oversized_source_text() {
864 let dir = tempfile::tempdir().unwrap();
865 let path = dir.path().join("huge.ttl");
866 let oversized = "x".repeat((MAX_FILE_BYTES + 1) as usize);
867 let err = parse_ontology_text(
868 &path,
869 OntologyFormat::Turtle,
870 "doc-1",
871 &oversized,
872 oversized.as_bytes(),
873 )
874 .unwrap_err();
875 assert!(matches!(err, ParseError::LimitExceeded(_)));
876 }
877
878 #[test]
879 fn rejects_invalid_utf8_ontology_file() {
880 let dir = tempfile::tempdir().unwrap();
881 let path = dir.path().join("bad.ttl");
882 std::fs::write(&path, b"@prefix ex: <http://ex/> .\n\xff\xfe\n").unwrap();
883 let err = parse_ontology_file(&path, OntologyFormat::Turtle, "doc-1", "h", 0).unwrap_err();
884 assert!(matches!(err, ParseError::InvalidUtf8(_)));
885 }
886
887 #[test]
888 fn parses_simple_turtle_ontology() {
889 let dir = tempfile::tempdir().unwrap();
890 let path = dir.path().join("test.ttl");
891 let mut f = fs::File::create(&path).unwrap();
892 writeln!(
893 f,
894 r#"@prefix ex: <http://example.org/test#> .
895@prefix owl: <http://www.w3.org/2002/07/owl#> .
896@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
897
898<http://example.org/test> a owl:Ontology .
899
900ex:Person a owl:Class ;
901 rdfs:label "Person" ;
902 rdfs:comment "A human being" .
903
904ex:knows a owl:ObjectProperty ;
905 rdfs:label "knows" .
906"#
907 )
908 .unwrap();
909
910 let parsed =
911 parse_ontology_file(&path, OntologyFormat::Turtle, "doc-1", "hash", 0).unwrap();
912
913 assert_eq!(parsed.parse_status, ParseStatus::Ok);
914
915 let person = parsed
916 .entities
917 .iter()
918 .find(|e| e.iri == "http://example.org/test#Person")
919 .expect("Person entity");
920 assert_eq!(person.kind, EntityKind::Class);
921 assert_eq!(person.labels, vec!["Person".to_string()]);
922 assert!(person.source_location.line.is_some());
923
924 let knows = parsed
925 .entities
926 .iter()
927 .find(|e| e.iri == "http://example.org/test#knows")
928 .expect("knows property");
929 assert_eq!(knows.kind, EntityKind::ObjectProperty);
930 assert_eq!(knows.labels, vec!["knows".to_string()]);
931 }
932
933 #[test]
934 fn extracts_turtle_prefix_declarations() {
935 let dir = tempfile::tempdir().unwrap();
936 let path = dir.path().join("test.ttl");
937 let mut f = fs::File::create(&path).unwrap();
938 writeln!(
939 f,
940 r#"@prefix ex: <http://example.org/test#> .
941@prefix owl: <http://www.w3.org/2002/07/owl#> .
942
943<http://example.org/test> a owl:Ontology .
944ex:Person a owl:Class .
945"#
946 )
947 .unwrap();
948
949 let parsed =
950 parse_ontology_file(&path, OntologyFormat::Turtle, "doc-1", "hash", 0).unwrap();
951
952 assert_eq!(parsed.base_iri.as_deref(), Some("http://example.org/test"));
953 assert_eq!(
954 parsed.namespaces.get("ex").map(String::as_str),
955 Some("http://example.org/test#")
956 );
957 }
958
959 #[test]
960 fn trailing_parse_error_keeps_prior_entities() {
961 let ttl = r#"@prefix ex: <http://example.org/test#> .
962@prefix owl: <http://www.w3.org/2002/07/owl#> .
963
964ex:Person a owl:Class ;
965 rdfs:label "Person" .
966
967ex:Broken a owl:Class ; this is not valid turtle
968"#;
969 let parsed = parse_ontology_text(
970 Path::new("partial.ttl"),
971 OntologyFormat::Turtle,
972 "doc-1",
973 ttl,
974 ttl.as_bytes(),
975 )
976 .expect("partial parse");
977 assert_eq!(parsed.parse_status, ParseStatus::Error);
978 assert!(
979 parsed.entities.iter().any(|e| e.iri == "http://example.org/test#Person"),
980 "entities parsed before the fault must be retained"
981 );
982 assert!(!parsed.quads().is_empty());
983 }
984
985 #[test]
986 fn default_base_iri_uses_file_uri_helper() {
987 let dir = tempfile::tempdir().unwrap();
988 let path = dir.path().join("bare.ttl");
989 fs::write(&path, "<> a <http://www.w3.org/2002/07/owl#Ontology> .\n").unwrap();
990 let uri = default_base_iri(&path);
991 assert!(uri.starts_with("file://"), "got {uri}");
992 assert!(!uri.contains('\\'), "raw backslash must not appear: {uri}");
993 assert_eq!(uri, ontocore_core::file_uri_for_path(&path));
994 }
995}