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