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