Skip to main content

ontocore_parser/
rdf.rs

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