1use 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
14const 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 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 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 });
76 }
77 TermClause::Synonym(syn) => {
78 annotations.push(Annotation {
79 subject: iri.clone(),
80 predicate: format!("{OBO_INOWL_NS}has{}Synonym", scope_label(syn.scope())),
81 object: syn.description().to_string(),
82 ontology_id: ontology_id.to_string(),
83 source_location: SourceLocation::default(),
84 });
85 }
86 TermClause::Xref(xref) => {
87 annotations.push(Annotation {
88 subject: iri.clone(),
89 predicate: format!("{OBO_INOWL_NS}hasDbXref"),
90 object: xref.as_ref().to_string(),
91 ontology_id: ontology_id.to_string(),
92 source_location: SourceLocation::default(),
93 });
94 }
95 TermClause::PropertyValue(pv) => {
96 let (predicate, object) = property_value_parts(pv, &namespaces);
97 annotations.push(Annotation {
98 subject: iri.clone(),
99 predicate,
100 object,
101 ontology_id: ontology_id.to_string(),
102 source_location: SourceLocation::default(),
103 });
104 }
105 _ => {}
106 }
107 }
108
109 entities.push(Entity {
110 iri,
111 short_name,
112 kind: EntityKind::Class,
113 ontology_id: ontology_id.to_string(),
114 source_location: SourceLocation::default(),
115 labels,
116 comments,
117 deprecated,
118 obo_id: Some(obo_id),
119 characteristics: Default::default(),
120 });
121
122 if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
123 return Err(ParseError::LimitExceeded(format!(
124 "OBO file exceeds entity/axiom limit: {}",
125 path.display()
126 )));
127 }
128 }
129
130 if !namespaces.contains_key("") {
131 namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
132 }
133
134 Ok(assemble_parsed_ontology(
135 ontology_id,
136 Some(DEFAULT_OBO_BASE.to_string()),
137 namespaces,
138 entities,
139 annotations,
140 axioms,
141 ))
142}
143
144fn ident_to_string<T: std::fmt::Display>(ident: &T) -> String {
145 ident.to_string()
146}
147
148fn scope_label(scope: &fastobo::ast::SynonymScope) -> &'static str {
149 use fastobo::ast::SynonymScope;
150 match scope {
151 SynonymScope::Exact => "Exact",
152 SynonymScope::Broad => "Broad",
153 SynonymScope::Narrow => "Narrow",
154 SynonymScope::Related => "Related",
155 }
156}
157
158fn property_value_parts(
160 pv: &PropertyValue,
161 namespaces: &BTreeMap<String, String>,
162) -> (String, String) {
163 let predicate = obo_id_to_iri(&ident_to_string(pv.property()), namespaces);
164 let object = match pv {
165 PropertyValue::Resource(r) => obo_id_to_iri(&ident_to_string(r.target()), namespaces),
166 PropertyValue::Literal(l) => l.literal().as_str().to_string(),
167 };
168 (predicate, object)
169}
170
171fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
173 if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
174 return obo_id.to_string();
175 }
176 if let Some((prefix, local)) = obo_id.split_once(':') {
177 if let Some(ns) = namespaces.get(prefix) {
178 if ns.ends_with(&format!("{prefix}_")) {
181 return format!("{ns}{local}");
182 }
183 return format!("{ns}{prefix}_{local}");
184 }
185 }
186 let normalized = obo_id.replace(':', "_");
187 format!("{DEFAULT_OBO_BASE}{normalized}")
188}
189
190pub fn parse_obo_file(
191 path: &Path,
192 ontology_id: &str,
193 _content_hash: &str,
194 _modified_time: u64,
195) -> Result<ParsedOntology> {
196 let content = read_to_string_capped(path, MAX_FILE_BYTES)
197 .map_err(|e| ParseError::LimitExceeded(format!("{}: {e}", path.display())))?;
198 parse_obo_text(path, ontology_id, &content)
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::io::Write;
205 use tempfile::NamedTempFile;
206
207 #[test]
208 fn parses_minimal_obo() {
209 let mut file = NamedTempFile::new().unwrap();
210 writeln!(
211 file,
212 "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
213 )
214 .unwrap();
215 let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
216 assert_eq!(parsed.entities.len(), 1);
217 assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
218 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
219 assert_eq!(parsed.entities[0].labels, vec!["example term"]);
220 assert_eq!(parsed.axioms.len(), 1);
221 assert_eq!(parsed.axioms[0].predicate, "http://www.w3.org/2000/01/rdf-schema#subClassOf");
222 assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
223 assert!(parsed.triple_count > 0);
224 assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
225 }
226
227 #[test]
228 fn materializes_all_synonym_scopes_and_definition_in_sparql_quads() {
229 let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
230id: TEST:0000001\n\
231name: test term\n\
232synonym: \"exact syn\" EXACT []\n\
233synonym: \"broad syn\" BROAD []\n\
234synonym: \"narrow syn\" NARROW []\n\
235synonym: \"related syn\" RELATED []\n\
236def: \"A definition.\" []\n";
237 let parsed = parse_obo_text(Path::new("syn.obo"), "doc-1", text).unwrap();
238 let predicates: std::collections::BTreeSet<_> =
239 parsed.quads().iter().map(|q| q.predicate.as_str().to_string()).collect();
240 assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasExactSynonym"));
241 assert!(predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasBroadSynonym"));
242 assert!(
243 predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasNarrowSynonym")
244 );
245 assert!(
246 predicates.contains("http://www.geneontology.org/formats/oboInOwl#hasRelatedSynonym")
247 );
248 assert!(predicates.contains("http://purl.obolibrary.org/obo/IAO_0000115"));
249 assert!(
250 !predicates.contains("http://www.w3.org/2000/01/rdf-schema#comment"),
251 "def: must not also emit rdfs:comment (#308)"
252 );
253 assert!(parsed.annotations.iter().all(|a| a.predicate.contains("://")));
254 assert!(parsed.axioms.iter().all(|a| a.predicate.contains("://")));
255 }
256
257 #[test]
258 fn parses_def_and_synonym_via_fastobo() {
259 let text = "format-version: 1.2\nontology: test\n\n[Term]\n\
260id: TEST:0000001\n\
261name: example\n\
262def: \"A definition.\" []\n\
263synonym: \"alt label\" EXACT []\n\
264comment: \"A true comment.\"\n";
265 let parsed = parse_obo_text(Path::new("t.obo"), "doc-1", text).unwrap();
266 assert!(
267 !parsed.entities[0].comments.iter().any(|c| c.contains("definition")),
268 "def text must not land in entity.comments (#308)"
269 );
270 assert!(parsed.entities[0].comments.iter().any(|c| c.contains("true comment")));
271 assert!(parsed.annotations.iter().any(|a| {
272 a.predicate == "http://purl.obolibrary.org/obo/IAO_0000115"
273 && a.object.contains("definition")
274 }));
275 assert!(parsed.annotations.iter().any(|a| {
276 a.predicate == "http://www.geneontology.org/formats/oboInOwl#hasExactSynonym"
277 }));
278 }
279
280 #[test]
281 fn property_value_stores_target_not_display() {
282 let text = "format-version: 1.2\n\
283idspace: dc http://purl.org/dc/elements/1.1/\n\
284idspace: ORCID http://orcid.org/\n\n\
285[Term]\n\
286id: TEST:0000001\n\
287name: example\n\
288property_value: dc:creator ORCID:0000-0002-3947-4444\n\
289property_value: shoe_size \"8\" xsd:positiveInteger\n";
290 let parsed = parse_obo_text(Path::new("pv.obo"), "doc-1", text).unwrap();
291 let resource = parsed
292 .annotations
293 .iter()
294 .find(|a| {
295 a.predicate == "http://purl.org/dc/elements/1.1/dc_creator"
296 || a.predicate == "http://purl.org/dc/elements/1.1/creator"
297 })
298 .expect("dc:creator annotation");
299 assert!(
300 !resource.object.contains("dc:creator"),
301 "object must not duplicate the property Display: {}",
302 resource.object
303 );
304 assert!(
305 resource.object.contains("0000-0002-3947-4444"),
306 "object should be the ORCID target: {}",
307 resource.object
308 );
309 let literal =
310 parsed.annotations.iter().find(|a| a.object == "8").expect("shoe_size literal");
311 assert!(!literal.object.contains("shoe_size"));
312 let predicates: std::collections::BTreeSet<_> =
313 parsed.quads().iter().map(|q| q.predicate.as_str().to_string()).collect();
314 assert!(
315 predicates.iter().any(|p| p.contains("creator") || p.contains("shoe_size")),
316 "property_value must appear in SPARQL quads"
317 );
318 }
319
320 #[test]
321 fn idspace_overrides_default_base() {
322 let text = "format-version: 1.2\n\
323idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
324[Term]\n\
325id: GO:0000001\n\
326name: mitochondrion\n";
327 let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
328 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
329 }
330
331 #[test]
332 fn idspace_standard_obo_foundry_base_normalizes_colon_to_underscore() {
333 let text = "format-version: 1.2\n\
334idspace: GO http://purl.obolibrary.org/obo/\n\n\
335[Term]\n\
336id: GO:0000001\n\
337name: mitochondrion\n\
338is_a: GO:0000002 ! parent\n";
339 let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
340 assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
341 assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/GO_0000002");
342 }
343
344 #[test]
345 fn rejects_oversized_obo_source_text() {
346 let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
347 let err = crate::rdf::parse_ontology_text(
348 Path::new("big.obo"),
349 ontocore_core::OntologyFormat::Obo,
350 "doc-1",
351 &huge,
352 huge.as_bytes(),
353 )
354 .unwrap_err();
355 assert!(err.to_string().contains("exceeds"));
356 }
357}