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