use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{self, BufRead, BufReader};
#[derive(Debug)]
pub struct MmcifParser {
categories: HashMap<String, Category>,
single_value_categories: HashSet<String>,
}
#[derive(Debug)]
pub struct Category {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
}
impl Default for MmcifParser {
fn default() -> Self {
Self::new()
}
}
impl MmcifParser {
pub fn new() -> Self {
Self {
categories: HashMap::new(),
single_value_categories: HashSet::new(),
}
}
pub fn parse_file(&mut self, path: &str) -> io::Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
self.parse_reader(reader)
}
pub fn parse_reader<R: BufRead>(&mut self, reader: R) -> io::Result<()> {
let mut current_category: Option<String> = None;
let mut current_headers: Vec<String> = Vec::new();
let mut in_loop = false;
let mut loop_has_rows = false;
let mut pending_item: Option<(String, String)> = None;
let mut text_field: Option<(String, String, String)> = None;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if text_field.is_some() {
if line.starts_with(';') {
let (category, field, value) = text_field.take().unwrap();
self.set_item(&category, &field, value);
} else if let Some((_, _, value)) = text_field.as_mut() {
if !value.is_empty() {
value.push(' ');
}
value.push_str(trimmed);
}
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((category, field)) = pending_item.take() {
if line.starts_with(';') {
text_field = Some((category, field, trimmed[1..].trim().to_string()));
continue;
}
if !trimmed.starts_with('_') && !is_reserved_word(trimmed) {
self.set_item(&category, &field, first_value(trimmed).to_string());
continue;
}
}
if trimmed.starts_with("data_") {
continue;
}
if trimmed.starts_with("loop_") {
in_loop = true;
loop_has_rows = false;
current_category = None;
current_headers.clear();
continue;
}
if trimmed.starts_with('_') {
let parts: Vec<&str> = trimmed.splitn(2, '.').collect();
if parts.len() != 2 {
continue;
}
let category_name = parts[0][1..].to_string(); let mut rest = parts[1].splitn(2, char::is_whitespace);
let field_name = rest.next().unwrap_or("").to_string();
let value = rest.next().unwrap_or("").trim();
if in_loop && !loop_has_rows {
if current_category.is_none() {
current_category = Some(category_name);
}
current_headers.push(field_name);
} else {
in_loop = false;
if value.is_empty() {
pending_item = Some((category_name, field_name));
} else {
self.set_item(&category_name, &field_name, first_value(value).to_string());
}
}
continue;
}
if in_loop && !current_headers.is_empty() {
if let Some(category_name) = ¤t_category {
loop_has_rows = true;
let values = parse_values(trimmed);
let category =
self.categories
.entry(category_name.clone())
.or_insert(Category {
headers: current_headers.clone(),
rows: Vec::new(),
});
category.rows.push(values);
}
}
}
if let Some((category, field, value)) = text_field.take() {
self.set_item(&category, &field, value);
}
Ok(())
}
fn set_item(&mut self, category_name: &str, field: &str, value: String) {
if !self.single_value_categories.contains(category_name)
&& self.categories.contains_key(category_name)
{
return; }
self.single_value_categories
.insert(category_name.to_string());
let category = self
.categories
.entry(category_name.to_string())
.or_insert_with(|| Category {
headers: Vec::new(),
rows: vec![Vec::new()],
});
match category.headers.iter().position(|name| name == field) {
Some(index) => category.rows[0][index] = value,
None => {
category.headers.push(field.to_string());
category.rows[0].push(value);
}
}
}
pub fn get_category(&self, name: &str) -> Option<&Category> {
self.categories.get(name)
}
}
fn first_value(text: &str) -> &str {
let mut chars = text.chars();
match chars.next() {
Some(quote @ ('\'' | '"')) => {
let rest = &text[quote.len_utf8()..];
match rest.find(quote) {
Some(end) => &rest[..end],
None => rest,
}
}
_ => text.split_whitespace().next().unwrap_or(""),
}
}
fn is_reserved_word(text: &str) -> bool {
let lowercase = text.to_ascii_lowercase();
["data_", "loop_", "save_", "global_", "stop_"]
.iter()
.any(|word| lowercase.starts_with(word))
}
impl Category {
pub fn get_column(&self, header: &str) -> Option<Vec<&str>> {
let index = self.headers.iter().position(|h| h == header)?;
Some(
self.rows
.iter()
.filter_map(|row| row.get(index).map(|s| s.as_str()))
.collect(),
)
}
pub fn get_row(&self, index: usize) -> Option<HashMap<&str, &str>> {
self.rows.get(index).map(|row| {
self.headers
.iter()
.enumerate()
.filter_map(|(i, h)| row.get(i).map(|v| (h.as_str(), v.as_str())))
.collect()
})
}
}
fn parse_values(line: &str) -> Vec<String> {
let mut values = Vec::new();
let mut current_value = String::new();
let mut in_quotes = false;
for c in line.chars() {
match c {
'"' => {
in_quotes = !in_quotes;
if !in_quotes {
values.push(current_value.clone());
current_value.clear();
}
}
' ' | '\t' if !in_quotes => {
if !current_value.is_empty() {
values.push(current_value.clone());
current_value.clear();
}
}
_ => current_value.push(c),
}
}
if !current_value.is_empty() {
values.push(current_value);
}
values
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn test_parse_simple_mmcif() {
let data = r#"data_test
_entry.id 1ABC
loop_
_atom_site.group_PDB
_atom_site.id
_atom_site.type_symbol
ATOM 1 C
ATOM 2 N
"#;
let mut parser = MmcifParser::new();
parser.parse_reader(Cursor::new(data)).unwrap();
let atom_site = parser.get_category("atom_site").unwrap();
assert_eq!(atom_site.headers.len(), 3);
assert_eq!(atom_site.rows.len(), 2);
assert_eq!(
atom_site.get_column("group_PDB").unwrap(),
vec!["ATOM", "ATOM"]
);
}
#[test]
fn test_parse_quoted_values() {
let data = r#"_citation.title "Some quoted title with spaces"
loop_
_entity.type
_entity.description
"polymer" "First chain"
"non-polymer" "Second chain""#;
let mut parser = MmcifParser::new();
parser.parse_reader(Cursor::new(data)).unwrap();
let entity = parser.get_category("entity").unwrap();
assert_eq!(entity.rows.len(), 2);
assert_eq!(
entity.get_column("type").unwrap(),
vec!["polymer", "non-polymer"]
);
}
}