Skip to main content

ontocore_parser/
obo.rs

1//! Minimal OBO Format 1.4 parser → OntoCore catalog model.
2
3use ontocore_core::{
4    limits::MAX_TRIPLES_PER_FILE, Annotation, Axiom, Entity, EntityKind, SourceLocation,
5    AXIOM_KIND_SUB_CLASS_OF,
6};
7use std::collections::BTreeMap;
8use std::path::Path;
9
10use crate::rdf::{assemble_parsed_ontology, ParseError, ParsedOntology, Result};
11
12/// Default OBO PURL prefix (terms use `GO:0000001` → `…/obo/GO_0000001`).
13const DEFAULT_OBO_BASE: &str = "http://purl.obolibrary.org/obo/";
14
15pub fn parse_obo_text(path: &Path, ontology_id: &str, source_text: &str) -> Result<ParsedOntology> {
16    let mut namespaces = BTreeMap::new();
17    let mut entities = Vec::new();
18    let mut annotations = Vec::new();
19    let mut axioms = Vec::new();
20    let mut axiom_counter = 0usize;
21
22    let mut in_term = false;
23    let mut current_id: Option<String> = None;
24    let mut current_iri: Option<String> = None;
25    let mut labels = Vec::new();
26    let mut comments = Vec::new();
27    let mut deprecated = false;
28
29    let flush_term = |entities: &mut Vec<Entity>,
30                      current_id: &mut Option<String>,
31                      current_iri: &mut Option<String>,
32                      labels: &mut Vec<String>,
33                      comments: &mut Vec<String>,
34                      deprecated: &mut bool| {
35        if let (Some(obo_id), Some(iri)) = (current_id.take(), current_iri.take()) {
36            let short_name = obo_id.split(':').next_back().unwrap_or(&obo_id).to_string();
37            entities.push(Entity {
38                iri,
39                short_name,
40                kind: EntityKind::Class,
41                ontology_id: ontology_id.to_string(),
42                source_location: SourceLocation::default(),
43                labels: std::mem::take(labels),
44                comments: std::mem::take(comments),
45                deprecated: *deprecated,
46                obo_id: Some(obo_id),
47            });
48            *deprecated = false;
49        }
50    };
51
52    for line in source_text.lines() {
53        let line = line.trim();
54        if line.is_empty() || line.starts_with('!') {
55            continue;
56        }
57        if line.starts_with('[') && line.ends_with(']') {
58            flush_term(
59                &mut entities,
60                &mut current_id,
61                &mut current_iri,
62                &mut labels,
63                &mut comments,
64                &mut deprecated,
65            );
66            in_term = line == "[Term]";
67            continue;
68        }
69        if !in_term {
70            // ontology: is metadata only — do not rewrite the term IRI base.
71            if let Some(rest) = line.strip_prefix("idspace:") {
72                let mut parts = rest.split_whitespace();
73                if let (Some(prefix), Some(url)) = (parts.next(), parts.next()) {
74                    namespaces.insert(prefix.to_string(), url.to_string());
75                }
76            }
77            continue;
78        }
79
80        if let Some(value) = line.strip_prefix("id:") {
81            flush_term(
82                &mut entities,
83                &mut current_id,
84                &mut current_iri,
85                &mut labels,
86                &mut comments,
87                &mut deprecated,
88            );
89            let obo_id = value.split('!').next().unwrap_or(value).trim().to_string();
90            current_iri = Some(obo_id_to_iri(&obo_id, &namespaces));
91            current_id = Some(obo_id);
92            in_term = true;
93            continue;
94        }
95
96        let Some(iri) = current_iri.clone() else {
97            continue;
98        };
99
100        if let Some(value) = line.strip_prefix("name:") {
101            labels.push(value.trim().to_string());
102        } else if let Some(value) = line.strip_prefix("comment:") {
103            comments.push(value.trim().to_string());
104        } else if line == "is_obsolete: true" {
105            deprecated = true;
106        } else if let Some(value) = line.strip_prefix("is_a:") {
107            let parent_id = value.split('!').next().unwrap_or(value).trim().to_string();
108            axiom_counter += 1;
109            axioms.push(Axiom {
110                id: format!("{ontology_id}#axiom-{axiom_counter}"),
111                ontology_id: ontology_id.to_string(),
112                subject: iri.clone(),
113                predicate: "rdfs:subClassOf".to_string(),
114                object: obo_id_to_iri(&parent_id, &namespaces),
115                axiom_kind: AXIOM_KIND_SUB_CLASS_OF.to_string(),
116                source_location: SourceLocation::default(),
117            });
118        } else if let Some(value) = line.strip_prefix("synonym:") {
119            annotations.push(Annotation {
120                subject: iri.clone(),
121                predicate: "obo:hasExactSynonym".to_string(),
122                object: value.trim().trim_matches('"').to_string(),
123                ontology_id: ontology_id.to_string(),
124                source_location: SourceLocation::default(),
125            });
126        } else if let Some(value) = line.strip_prefix("xref:") {
127            annotations.push(Annotation {
128                subject: iri.clone(),
129                predicate: "obo:hasDbXref".to_string(),
130                object: value.trim().to_string(),
131                ontology_id: ontology_id.to_string(),
132                source_location: SourceLocation::default(),
133            });
134        } else if let Some(value) = line.strip_prefix("property_value:") {
135            let mut parts = value.split_whitespace();
136            if let (Some(prop), Some(val)) = (parts.next(), parts.next()) {
137                annotations.push(Annotation {
138                    subject: iri,
139                    predicate: prop.to_string(),
140                    object: val.to_string(),
141                    ontology_id: ontology_id.to_string(),
142                    source_location: SourceLocation::default(),
143                });
144            }
145        }
146        if entities.len() + annotations.len() + axioms.len() > MAX_TRIPLES_PER_FILE {
147            return Err(ParseError::LimitExceeded(format!(
148                "OBO file exceeds entity/axiom limit: {}",
149                path.display()
150            )));
151        }
152    }
153
154    flush_term(
155        &mut entities,
156        &mut current_id,
157        &mut current_iri,
158        &mut labels,
159        &mut comments,
160        &mut deprecated,
161    );
162
163    let total = entities.len() + annotations.len() + axioms.len();
164    if entities.len() > MAX_TRIPLES_PER_FILE || total > MAX_TRIPLES_PER_FILE {
165        return Err(ParseError::LimitExceeded(format!(
166            "OBO file exceeds entity/axiom limit: {}",
167            path.display()
168        )));
169    }
170
171    // Ensure default OBO namespace is visible for consumers.
172    if !namespaces.contains_key("") {
173        namespaces.insert(String::new(), DEFAULT_OBO_BASE.to_string());
174    }
175
176    Ok(assemble_parsed_ontology(
177        ontology_id,
178        Some(DEFAULT_OBO_BASE.to_string()),
179        namespaces,
180        entities,
181        annotations,
182        axioms,
183    ))
184}
185
186/// Map an OBO ID to an IRI using `idspace:` expansions when present.
187///
188/// Default: `GO:0000001` → `http://purl.obolibrary.org/obo/GO_0000001`.
189fn obo_id_to_iri(obo_id: &str, namespaces: &BTreeMap<String, String>) -> String {
190    if obo_id.starts_with("http://") || obo_id.starts_with("https://") {
191        return obo_id.to_string();
192    }
193    if let Some((prefix, local)) = obo_id.split_once(':') {
194        if let Some(ns) = namespaces.get(prefix) {
195            return format!("{ns}{local}");
196        }
197    }
198    let normalized = obo_id.replace(':', "_");
199    format!("{DEFAULT_OBO_BASE}{normalized}")
200}
201
202pub fn parse_obo_file(
203    path: &Path,
204    ontology_id: &str,
205    _content_hash: &str,
206    _modified_time: u64,
207) -> Result<ParsedOntology> {
208    let metadata = std::fs::metadata(path).map_err(ParseError::Io)?;
209    if metadata.len() > ontocore_core::MAX_FILE_BYTES {
210        return Err(ParseError::Io(std::io::Error::new(
211            std::io::ErrorKind::InvalidData,
212            format!("file exceeds maximum size of {} bytes", ontocore_core::MAX_FILE_BYTES),
213        )));
214    }
215    let content = std::fs::read_to_string(path).map_err(ParseError::Io)?;
216    parse_obo_text(path, ontology_id, &content)
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use std::io::Write;
223    use tempfile::NamedTempFile;
224
225    #[test]
226    fn parses_minimal_obo() {
227        let mut file = NamedTempFile::new().unwrap();
228        writeln!(
229            file,
230            "format-version: 1.2\nontology: test\n\n[Term]\nid: TEST:0000001\nname: example term\nis_a: TEST:0000002 ! parent\n"
231        )
232        .unwrap();
233        let parsed = parse_obo_file(file.path(), "doc-1", "hash", 0).unwrap();
234        assert_eq!(parsed.entities.len(), 1);
235        assert_eq!(parsed.entities[0].obo_id.as_deref(), Some("TEST:0000001"));
236        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/TEST_0000001");
237        assert_eq!(parsed.entities[0].labels, vec!["example term"]);
238        assert_eq!(parsed.axioms.len(), 1);
239        assert_eq!(parsed.axioms[0].object, "http://purl.obolibrary.org/obo/TEST_0000002");
240        assert!(parsed.triple_count > 0);
241        assert!(!parsed.quads().is_empty(), "OBO must materialize RDF quads");
242    }
243
244    #[test]
245    fn idspace_overrides_default_base() {
246        let text = "format-version: 1.2\n\
247idspace: GO http://purl.obolibrary.org/obo/GO_\n\n\
248[Term]\n\
249id: GO:0000001\n\
250name: mitochondrion\n";
251        let parsed = parse_obo_text(Path::new("go.obo"), "doc-1", text).unwrap();
252        assert_eq!(parsed.entities[0].iri, "http://purl.obolibrary.org/obo/GO_0000001");
253    }
254
255    #[test]
256    fn rejects_oversized_obo_source_text() {
257        let huge = "x".repeat((ontocore_core::MAX_FILE_BYTES as usize) + 1);
258        let err = crate::rdf::parse_ontology_text(
259            Path::new("big.obo"),
260            ontocore_core::OntologyFormat::Obo,
261            "doc-1",
262            &huge,
263            huge.as_bytes(),
264        )
265        .unwrap_err();
266        assert!(err.to_string().contains("exceeds"));
267    }
268}