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