1use 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
14const 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 characteristics: Default::default(),
116 });
117
118 if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
119 return Err(ParseError::LimitExceeded(format!(
120 "OBO file exceeds entity/axiom limit: {}",
121 path.display()
122 )));
123 }
124 }
125
126 if !namespaces.contains_key("") {
127 namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
128 }
129
130 Ok(assemble_parsed_ontology(
131 ontology_id,
132 Some(DEFAULT_OBO_BASE.to_string()),
133 namespaces,
134 entities,
135 annotations,
136 axioms,
137 ))
138}
139
140fn ident_to_string<T: std::fmt::Display>(ident: &T) -> String {
141 ident.to_string()
142}
143
144fn scope_label(scope: &fastobo::ast::SynonymScope) -> &'static str {
145 use fastobo::ast::SynonymScope;
146 match scope {
147 SynonymScope::Exact => "Exact",
148 SynonymScope::Broad => "Broad",
149 SynonymScope::Narrow => "Narrow",
150 SynonymScope::Related => "Related",
151 }
152}
153
154fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
156 if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
157 return obo_id.to_string();
158 }
159 if let Some((prefix, local)) = obo_id.split_once(':') {
160 if let Some(ns) = namespaces.get(prefix) {
161 if ns.ends_with(&format!("{prefix}_")) {
164 return format!("{ns}{local}");
165 }
166 return format!("{ns}{prefix}_{local}");
167 }
168 }
169 let normalized = obo_id.replace(':', "_");
170 format!("{DEFAULT_OBO_BASE}{normalized}")
171}
172
173pub fn parse_obo_file(
174 path: &Path,
175 ontology_id: &str,
176 _content_hash: &str,
177 _modified_time: u64,
178) -> Result<ParsedOntology> {
179 let content = read_to_string_capped(path, MAX_FILE_BYTES)
180 .map_err(|e| ParseError::LimitExceeded(format!("{}: {e}", path.display())))?;
181 parse_obo_text(path, ontology_id, &content)
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::io::Write;
188 use tempfile::NamedTempFile;
189
190 #[test]
191 fn parses_minimal_obo() {
192 let mut file = NamedTempFile::new().unwrap();
193 writeln!(
194 file,
195 "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
196 )
197 .unwrap();
198 let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
199 assert_eq!(parsed.entities.len(), 1);
200 assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
201 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
202 assert_eq!(parsed.entities[0].labels, vec!["example term"]);
203 assert_eq!(parsed.axioms.len(), 1);
204 assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
205 assert!(parsed.triple_count > 0);
206 assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
207 }
208
209 #[test]
210 fn materializes_all_synonym_scopes_and_definition_in_sparql_quads() {
211 let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
212id: TEST:0000001\n\
213name: test term\n\
214synonym: \"exact syn\" EXACT []\n\
215synonym: \"broad syn\" BROAD []\n\
216synonym: \"narrow syn\" NARROW []\n\
217synonym: \"related syn\" RELATED []\n\
218def: \"A definition.\" []\n";
219 let parsed = parse_obo_text(Path::new("syn.obo"), "doc-1", text).unwrap();
220 let predicates: std::collections::BTreeSet<_> =
221 parsed.quads().iter().map(|q| q.predicate.as_str().to_string()).collect();
222 assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasExactSynonym"));
223 assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasBroadSynonym"));
224 assert!(
225 predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasNarrowSynonym")
226 );
227 assert!(
228 predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasRelatedSynonym")
229 );
230 assert!(predicates.contains("http://purl.obolibrary.org/obo/IAO_0000115"));
231 }
232
233 #[test]
234 fn parses_def_and_synonym_via_fastobo() {
235 let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
236id: TEST:0000001\n\
237name: example\n\
238def: \"A definition.\" []\n\
239synonym: \"alt label\" EXACT []\n";
240 let parsed = parse_obo_text(Path::new("t.obo"), "doc-1", text).unwrap();
241 assert!(parsed.entities[0].comments.iter().any(|c| c.contains("definition")));
242 assert!(parsed.annotations.iter().any(|a| a.predicate.contains("Synonym")));
243 }
244
245 #[test]
246 fn idspace_overrides_default_base() {
247 let text = "format-version: 1.2\n\
248idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
249[Term]\n\
250id: GO:0000001\n\
251name: mitochondrion\n";
252 let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
253 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
254 }
255
256 #[test]
257 fn idspace_standard_obo_foundry_base_normalizes_colon_to_underscore() {
258 let text = "format-version: 1.2\n\
259idspace: GO http://purl.obolibrary.org/obo/\n\n\
260[Term]\n\
261id: GO:0000001\n\
262name: mitochondrion\n\
263is_a: GO:0000002 ! parent\n";
264 let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
265 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
266 assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/GO_0000002");
267 }
268
269 #[test]
270 fn rejects_oversized_obo_source_text() {
271 let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
272 let err = crate::rdf::parse_ontology_text(
273 Path::new("big.obo"),
274 ontocore_core::OntologyFormat::Obo,
275 "doc-1",
276 &huge,
277 huge.as_bytes(),
278 )
279 .unwrap_err();
280 assert!(err.to_string().contains("exceeds"));
281 }
282}