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