Skip to main content

ontocore_parser/
obo.rs

1//! OBO Format 1.4 parser via [`fastobo`] → OntoCore catalog model.
2
3use fastobo::ast::{HeaderClause, 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/";
16
17pub fn parse_obo_text(path: &Path, ontology_id: &str, source_text: &str) -> Result<ParsedOntology> {
18    let doc = fastobo::from_str(source_text)
19        .map_err(|e| ParseError::Rdf(format!("OBO parse error in {}: {e}", path.display())))?;
20
21    let mut namespaces = BTreeMap::new();
22    for clause in doc.header().iter() {
23        if let HeaderClause::Idspace(prefix, url, _) = clause {
24            namespaces.insert(prefix.to_string(), url.to_string());
25        }
26    }
27
28    let mut entities = Vec::new();
29    let mut annotations = Vec::new();
30    let mut axioms = Vec::new();
31    let mut axiom_counter = 0usize;
32
33    for entity in doc.entities() {
34        let Some(term) = entity.as_term() else {
35            continue;
36        };
37        let obo_id = ident_to_string(term.id().as_inner());
38        let iri = obo_id_to_iri(&obo_id, &namespaces);
39        let short_name = obo_id.split(':').next_back().unwrap_or(&obo_id).to_string();
40
41        let mut labels = Vec::new();
42        let mut comments = Vec::new();
43        let mut deprecated = false;
44
45        for clause in term.clauses() {
46            match clause.as_inner() {
47                TermClause::Name(name) => labels.push(name.to_string()),
48                TermClause::Comment(comment) => comments.push(comment.to_string()),
49                TermClause::Def(def) => {
50                    let def_text = def.text().to_string();
51                    comments.push(def_text.clone());
52                    annotations.push(Annotation {
53                        subject: iri.clone(),
54                        predicate: "obo:IAO_0000115".to_string(),
55                        object: def_text,
56                        ontology_id: ontology_id.to_string(),
57                        source_location: SourceLocation::default(),
58                    });
59                }
60                TermClause::IsObsolete(true) => deprecated = true,
61                TermClause::IsA(parent) => {
62                    axiom_counter += 1;
63                    let parent_id = ident_to_string(parent.as_ref());
64                    axioms.push(Axiom {
65                        id: format!("{ontology_id}#axiom-{axiom_counter}"),
66                        ontology_id: ontology_id.to_string(),
67                        subject: iri.clone(),
68                        predicate: "rdfs:subClassOf".to_string(),
69                        object: obo_id_to_iri(&parent_id, &namespaces),
70                        axiom_kind: AXIOM_KIND_SUB_CLASS_OF.to_string(),
71                        source_location: SourceLocation::default(),
72                    });
73                }
74                TermClause::Synonym(syn) => {
75                    annotations.push(Annotation {
76                        subject: iri.clone(),
77                        predicate: format!("obo:has{}Synonym", scope_label(syn.scope())),
78                        object: syn.description().to_string(),
79                        ontology_id: ontology_id.to_string(),
80                        source_location: SourceLocation::default(),
81                    });
82                }
83                TermClause::Xref(xref) => {
84                    annotations.push(Annotation {
85                        subject: iri.clone(),
86                        predicate: "obo:hasDbXref".to_string(),
87                        object: xref.as_ref().to_string(),
88                        ontology_id: ontology_id.to_string(),
89                        source_location: SourceLocation::default(),
90                    });
91                }
92                TermClause::PropertyValue(pv) => {
93                    annotations.push(Annotation {
94                        subject: iri.clone(),
95                        predicate: pv.property().to_string(),
96                        object: pv.to_string(),
97                        ontology_id: ontology_id.to_string(),
98                        source_location: SourceLocation::default(),
99                    });
100                }
101                _ => {}
102            }
103        }
104
105        entities.push(Entity {
106            iri,
107            short_name,
108            kind: EntityKind::Class,
109            ontology_id: ontology_id.to_string(),
110            source_location: SourceLocation::default(),
111            labels,
112            comments,
113            deprecated,
114            obo_id: Some(obo_id),
115        });
116
117        if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
118            return Err(ParseError::LimitExceeded(format!(
119                "OBO file exceeds entity/axiom limit: {}",
120                path.display()
121            )));
122        }
123    }
124
125    if !namespaces.contains_key("") {
126        namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
127    }
128
129    Ok(assemble_parsed_ontology(
130        ontology_id,
131        Some(DEFAULT_OBO_BASE.to_string()),
132        namespaces,
133        entities,
134        annotations,
135        axioms,
136    ))
137}
138
139fn ident_to_string<T: std::fmt::Display>(ident: &T) -> String {
140    ident.to_string()
141}
142
143fn scope_label(scope: &fastobo::ast::SynonymScope) -> &'static str {
144    use fastobo::ast::SynonymScope;
145    match scope {
146        SynonymScope::Exact => "Exact",
147        SynonymScope::Broad => "Broad",
148        SynonymScope::Narrow => "Narrow",
149        SynonymScope::Related => "Related",
150    }
151}
152
153/// Map an OBO ID to an IRI using `idspace:` expansions when present.
154fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
155    if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
156        return obo_id.to_string();
157    }
158    if let Some((prefix, local)) = obo_id.split_once(':') {
159        if let Some(ns) = namespaces.get(prefix) {
160            return format!("{ns}{local}");
161        }
162    }
163    let normalized = obo_id.replace(':', "_");
164    format!("{DEFAULT_OBO_BASE}{normalized}")
165}
166
167pub fn parse_obo_file(
168    path: &Path,
169    ontology_id: &str,
170    _content_hash: &str,
171    _modified_time: u64,
172) -> Result<ParsedOntology> {
173    let content = read_to_string_capped(path, MAX_FILE_BYTES)
174        .map_err(|e| ParseError::LimitExceeded(format!("{}: {e}", path.display())))?;
175    parse_obo_text(path, ontology_id, &content)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use std::io::Write;
182    use tempfile::NamedTempFile;
183
184    #[test]
185    fn parses_minimal_obo() {
186        let mut file = NamedTempFile::new().unwrap();
187        writeln!(
188            file,
189            "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
190        )
191        .unwrap();
192        let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
193        assert_eq!(parsed.entities.len(), 1);
194        assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
195        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
196        assert_eq!(parsed.entities[0].labels, vec!["example term"]);
197        assert_eq!(parsed.axioms.len(), 1);
198        assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
199        assert!(parsed.triple_count > 0);
200        assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
201    }
202
203    #[test]
204    fn materializes_all_synonym_scopes_and_definition_in_sparql_quads() {
205        let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
206id: TEST:0000001\n\
207name: test term\n\
208synonym: \"exact syn\" EXACT []\n\
209synonym: \"broad syn\" BROAD []\n\
210synonym: \"narrow syn\" NARROW []\n\
211synonym: \"related syn\" RELATED []\n\
212def: \"A definition.\" []\n";
213        let parsed = parse_obo_text(Path::new("syn.obo"), "doc-1", text).unwrap();
214        let predicates: std::collections::BTreeSet<_> =
215            parsed.quads().iter().map(|q| q.predicate.as_str().to_string()).collect();
216        assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasExactSynonym"));
217        assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasBroadSynonym"));
218        assert!(
219            predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasNarrowSynonym")
220        );
221        assert!(
222            predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasRelatedSynonym")
223        );
224        assert!(predicates.contains("http://purl.obolibrary.org/obo/IAO_0000115"));
225    }
226
227    #[test]
228    fn parses_def_and_synonym_via_fastobo() {
229        let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
230id: TEST:0000001\n\
231name: example\n\
232def: \"A definition.\" []\n\
233synonym: \"alt label\" EXACT []\n";
234        let parsed = parse_obo_text(Path::new("t.obo"), "doc-1", text).unwrap();
235        assert!(parsed.entities[0].comments.iter().any(|c| c.contains("definition")));
236        assert!(parsed.annotations.iter().any(|a| a.predicate.contains("Synonym")));
237    }
238
239    #[test]
240    fn idspace_overrides_default_base() {
241        let text = "format-version: 1.2\n\
242idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
243[Term]\n\
244id: GO:0000001\n\
245name: mitochondrion\n";
246        let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
247        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
248    }
249
250    #[test]
251    fn rejects_oversized_obo_source_text() {
252        let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
253        let err = crate::rdf::parse_ontology_text(
254            Path::new("big.obo"),
255            ontocore_core::OntologyFormat::Obo,
256            "doc-1",
257            &huge,
258            huge.as_bytes(),
259        )
260        .unwrap_err();
261        assert!(err.to_string().contains("exceeds"));
262    }
263}