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