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