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