Skip to main content

ontocore_parser/
obo.rs

1//! OBO Format 1.4 parser via [`fastobo`] → OntoCore catalog model.
2
3use fastobo::ast::{HeaderClause, PropertyValue, TermClause};
4use ontocore_core::{
5    limits::{MAX_FILE_BYTES, MAX_TRIPLES_PER_FILE},
6    read_to_string_capped, Annotation, Axiom, Entity, EntityKind, SourceLocation,
7    AXIOM_KIND_SUB_CLASS_OF,
8};
9use std::collections::BTreeMap;
10use std::path::Path;
11
12use crate::rdf::{assemble_parsed_ontology, ParseError, ParsedOntology, Result};
13
14/// Default OBO PURL prefix (terms use `GO:0000001` → `…/obo/GO_0000001`).
15const DEFAULT_OBO_BASE: &str = "http://purl.obolibrary.org/obo/";
16const RDFS_SUBCLASS_OF: &str = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
17const IAO_DEFINITION: &str = "http://purl.obolibrary.org/obo/IAO_0000115";
18const OBO_INOWL_NS: &str = "http://www.geneontology.org/formats/oboInOwl#";
19
20pub fn parse_obo_text(path: &Path, ontology_id: &str, source_text: &str) -> Result<ParsedOntology> {
21    let doc = fastobo::from_str(source_text)
22        .map_err(|e| ParseError::Rdf(format!("OBO parse error in {}: {e}", path.display())))?;
23
24    let mut namespaces = BTreeMap::new();
25    for clause in doc.header().iter() {
26        if let HeaderClause::Idspace(prefix, url, _) = clause {
27            namespaces.insert(prefix.to_string(), url.to_string());
28        }
29    }
30
31    let mut entities = Vec::new();
32    let mut annotations = Vec::new();
33    let mut axioms = Vec::new();
34    let mut axiom_counter = 0usize;
35
36    for entity in doc.entities() {
37        let Some(term) = entity.as_term() else {
38            continue;
39        };
40        let obo_id = ident_to_string(term.id().as_inner());
41        let iri = obo_id_to_iri(&obo_id, &namespaces);
42        let short_name = obo_id.split(':').next_back().unwrap_or(&obo_id).to_string();
43
44        let mut labels = Vec::new();
45        let mut comments = Vec::new();
46        let mut deprecated = false;
47
48        for clause in term.clauses() {
49            match clause.as_inner() {
50                TermClause::Name(name) => labels.push(name.to_string()),
51                TermClause::Comment(comment) => comments.push(comment.to_string()),
52                TermClause::Def(def) => {
53                    // Definition is IAO_0000115 only — do not also emit rdfs:comment (#308).
54                    annotations.push(Annotation {
55                        subject: iri.clone(),
56                        predicate: IAO_DEFINITION.to_string(),
57                        object: def.text().to_string(),
58                        ontology_id: ontology_id.to_string(),
59                        source_location: SourceLocation::default(),
60                    });
61                }
62                TermClause::IsObsolete(true) => deprecated = true,
63                TermClause::IsA(parent) => {
64                    axiom_counter += 1;
65                    let parent_id = ident_to_string(parent.as_ref());
66                    axioms.push(Axiom {
67                        id: format!("{ontology_id}#axiom-{axiom_counter}"),
68                        ontology_id: ontology_id.to_string(),
69                        subject: iri.clone(),
70                        // Absolute IRIs so catalog SQL / SPARQL / diff agree (#305).
71                        predicate: RDFS_SUBCLASS_OF.to_string(),
72                        object: obo_id_to_iri(&parent_id, &namespaces),
73                        axiom_kind: AXIOM_KIND_SUB_CLASS_OF.to_string(),
74                        source_location: SourceLocation::default(),
75                        annotations: Vec::new(),
76                    });
77                }
78                TermClause::Synonym(syn) => {
79                    annotations.push(Annotation {
80                        subject: iri.clone(),
81                        predicate: format!("{OBO_INOWL_NS}has{}Synonym", scope_label(syn.scope())),
82                        object: syn.description().to_string(),
83                        ontology_id: ontology_id.to_string(),
84                        source_location: SourceLocation::default(),
85                    });
86                }
87                TermClause::Xref(xref) => {
88                    annotations.push(Annotation {
89                        subject: iri.clone(),
90                        predicate: format!("{OBO_INOWL_NS}hasDbXref"),
91                        object: xref.as_ref().to_string(),
92                        ontology_id: ontology_id.to_string(),
93                        source_location: SourceLocation::default(),
94                    });
95                }
96                TermClause::PropertyValue(pv) => {
97                    let (predicate, object) = property_value_parts(pv, &namespaces);
98                    annotations.push(Annotation {
99                        subject: iri.clone(),
100                        predicate,
101                        object,
102                        ontology_id: ontology_id.to_string(),
103                        source_location: SourceLocation::default(),
104                    });
105                }
106                _ => {}
107            }
108        }
109
110        entities.push(Entity {
111            iri,
112            short_name,
113            kind: EntityKind::Class,
114            ontology_id: ontology_id.to_string(),
115            source_location: SourceLocation::default(),
116            labels,
117            comments,
118            deprecated,
119            obo_id: Some(obo_id),
120            characteristics: Default::default(),
121        });
122
123        if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
124            return Err(ParseError::LimitExceeded(format!(
125                "OBO file exceeds entity/axiom limit: {}",
126                path.display()
127            )));
128        }
129    }
130
131    if !namespaces.contains_key("") {
132        namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
133    }
134
135    Ok(assemble_parsed_ontology(
136        ontology_id,
137        Some(DEFAULT_OBO_BASE.to_string()),
138        namespaces,
139        entities,
140        annotations,
141        axioms,
142    ))
143}
144
145fn ident_to_string<T: std::fmt::Display>(ident: &T) -> String {
146    ident.to_string()
147}
148
149fn scope_label(scope: &fastobo::ast::SynonymScope) -> &'static str {
150    use fastobo::ast::SynonymScope;
151    match scope {
152        SynonymScope::Exact => "Exact",
153        SynonymScope::Broad => "Broad",
154        SynonymScope::Narrow => "Narrow",
155        SynonymScope::Related => "Related",
156    }
157}
158
159/// Split property_value into predicate IRI + object (not the full Display form) (#306).
160fn property_value_parts(
161    pv: &PropertyValue,
162    namespaces: &BTreeMap<String, String>,
163) -> (String, String) {
164    let predicate = obo_id_to_iri(&ident_to_string(pv.property()), namespaces);
165    let object = match pv {
166        PropertyValue::Resource(r) => obo_id_to_iri(&ident_to_string(r.target()), namespaces),
167        PropertyValue::Literal(l) => l.literal().as_str().to_string(),
168    };
169    (predicate, object)
170}
171
172/// Map an OBO ID to an IRI using `idspace:` expansions when present.
173fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
174    if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
175        return obo_id.to_string();
176    }
177    if let Some((prefix, local)) = obo_id.split_once(':') {
178        if let Some(ns) = namespaces.get(prefix) {
179            // When the idspace URL already ends with `PREFIX_` (e.g. `…/obo/GO_`), append
180            // only the local segment. Otherwise append `PREFIX_LOCAL` per OBO PURL rules.
181            if ns.ends_with(&format!("{prefix}_")) {
182                return format!("{ns}{local}");
183            }
184            return format!("{ns}{prefix}_{local}");
185        }
186    }
187    let normalized = obo_id.replace(':', "_");
188    format!("{DEFAULT_OBO_BASE}{normalized}")
189}
190
191pub fn parse_obo_file(
192    path: &Path,
193    ontology_id: &str,
194    _content_hash: &str,
195    _modified_time: u64,
196) -> Result<ParsedOntology> {
197    let content = read_to_string_capped(path, MAX_FILE_BYTES)
198        .map_err(|e| ParseError::LimitExceeded(format!("{}: {e}", path.display())))?;
199    parse_obo_text(path, ontology_id, &content)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::io::Write;
206    use tempfile::NamedTempFile;
207
208    #[test]
209    fn parses_minimal_obo() {
210        let mut file = NamedTempFile::new().unwrap();
211        writeln!(
212            file,
213            "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
214        )
215        .unwrap();
216        let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
217        assert_eq!(parsed.entities.len(), 1);
218        assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
219        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
220        assert_eq!(parsed.entities[0].labels, vec!["example term"]);
221        assert_eq!(parsed.axioms.len(), 1);
222        assert_eq!(parsed.axioms[0].predicate, "http://www.w3.org/2000/01/rdf-schema#subClassOf");
223        assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
224        assert!(parsed.triple_count > 0);
225        assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
226    }
227
228    #[test]
229    fn materializes_all_synonym_scopes_and_definition_in_sparql_quads() {
230        let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
231id: TEST:0000001\n\
232name: test term\n\
233synonym: \"exact syn\" EXACT []\n\
234synonym: \"broad syn\" BROAD []\n\
235synonym: \"narrow syn\" NARROW []\n\
236synonym: \"related syn\" RELATED []\n\
237def: \"A definition.\" []\n";
238        let parsed = parse_obo_text(Path::new("syn.obo"), "doc-1", text).unwrap();
239        let predicates: std::collections::BTreeSet<_> =
240            parsed.quads().iter().map(|q| q.predicate.as_str().to_string()).collect();
241        assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasExactSynonym"));
242        assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasBroadSynonym"));
243        assert!(
244            predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasNarrowSynonym")
245        );
246        assert!(
247            predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasRelatedSynonym")
248        );
249        assert!(predicates.contains("http://purl.obolibrary.org/obo/IAO_0000115"));
250        assert!(
251            !predicates.contains("http://www.w3.org/2000/01/rdf-schema#comment"),
252            "def: must not also emit rdfs:comment (#308)"
253        );
254        assert!(parsed.annotations.iter().all(|a| a.predicate.contains("://")));
255        assert!(parsed.axioms.iter().all(|a| a.predicate.contains("://")));
256    }
257
258    #[test]
259    fn parses_def_and_synonym_via_fastobo() {
260        let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
261id: TEST:0000001\n\
262name: example\n\
263def: \"A definition.\" []\n\
264synonym: \"alt label\" EXACT []\n\
265comment: \"A true comment.\"\n";
266        let parsed = parse_obo_text(Path::new("t.obo"), "doc-1", text).unwrap();
267        assert!(
268            !parsed.entities[0].comments.iter().any(|c| c.contains("definition")),
269            "def text must not land in entity.comments (#308)"
270        );
271        assert!(parsed.entities[0].comments.iter().any(|c| c.contains("true comment")));
272        assert!(parsed.annotations.iter().any(|a| {
273            a.predicate == "http://purl.obolibrary.org/obo/IAO_0000115"
274                && a.object.contains("definition")
275        }));
276        assert!(parsed.annotations.iter().any(|a| {
277            a.predicate == "http://www.geneontology.org/formats/oboInOwl#hasExactSynonym"
278        }));
279    }
280
281    #[test]
282    fn property_value_stores_target_not_display() {
283        let text = "format-version: 1.2\n\
284idspace: dc http://purl.org/dc/elements/1.1/\n\
285idspace: ORCID http://orcid.org/\n\n\
286[Term]\n\
287id: TEST:0000001\n\
288name: example\n\
289property_value: dc:creator ORCID:0000-0002-3947-4444\n\
290property_value: shoe_size \"8\" xsd:positiveInteger\n";
291        let parsed = parse_obo_text(Path::new("pv.obo"), "doc-1", text).unwrap();
292        let resource = parsed
293            .annotations
294            .iter()
295            .find(|a| {
296                a.predicate == "http://purl.org/dc/elements/1.1/dc_creator"
297                    || a.predicate == "http://purl.org/dc/elements/1.1/creator"
298            })
299            .expect("dc:creator annotation");
300        assert!(
301            !resource.object.contains("dc:creator"),
302            "object must not duplicate the property Display: {}",
303            resource.object
304        );
305        assert!(
306            resource.object.contains("0000-0002-3947-4444"),
307            "object should be the ORCID target: {}",
308            resource.object
309        );
310        let literal =
311            parsed.annotations.iter().find(|a| a.object == "8").expect("shoe_size literal");
312        assert!(!literal.object.contains("shoe_size"));
313        let predicates: std::collections::BTreeSet<_> =
314            parsed.quads().iter().map(|q| q.predicate.as_str().to_string()).collect();
315        assert!(
316            predicates.iter().any(|p| p.contains("creator") || p.contains("shoe_size")),
317            "property_value must appear in SPARQL quads"
318        );
319    }
320
321    #[test]
322    fn idspace_overrides_default_base() {
323        let text = "format-version: 1.2\n\
324idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
325[Term]\n\
326id: GO:0000001\n\
327name: mitochondrion\n";
328        let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
329        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
330    }
331
332    #[test]
333    fn idspace_standard_obo_foundry_base_normalizes_colon_to_underscore() {
334        let text = "format-version: 1.2\n\
335idspace: GO http://purl.obolibrary.org/obo/\n\n\
336[Term]\n\
337id: GO:0000001\n\
338name: mitochondrion\n\
339is_a: GO:0000002 ! parent\n";
340        let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
341        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
342        assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/GO_0000002");
343    }
344
345    #[test]
346    fn rejects_oversized_obo_source_text() {
347        let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
348        let err = crate::rdf::parse_ontology_text(
349            Path::new("big.obo"),
350            ontocore_core::OntologyFormat::Obo,
351            "doc-1",
352            &huge,
353            huge.as_bytes(),
354        )
355        .unwrap_err();
356        assert!(err.to_string().contains("exceeds"));
357    }
358}