use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq)]
pub enum FormulaParseError {
EmptyFormula,
UnknownElement(String),
UnbalancedParentheses,
InvalidCount(String),
}
impl std::fmt::Display for FormulaParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyFormula => write!(f, "formula string is empty"),
Self::UnknownElement(s) => write!(f, "unknown element symbol: {s}"),
Self::UnbalancedParentheses => write!(f, "unbalanced parentheses in formula"),
Self::InvalidCount(s) => write!(f, "invalid atom count: {s}"),
}
}
}
impl std::error::Error for FormulaParseError {}
pub fn parse_formula(formula: &str) -> Result<HashMap<String, u32>, FormulaParseError> {
let formula = formula.trim();
if formula.is_empty() {
return Err(FormulaParseError::EmptyFormula);
}
let formula = strip_brackets(formula);
let formula = strip_charge(&formula);
let mut counts: HashMap<String, u32> = HashMap::new();
let chars: Vec<char> = formula.chars().collect();
parse_segment(&chars, 0, &mut counts)?;
Ok(counts)
}
fn strip_brackets(s: &str) -> String {
s.chars().filter(|&c| c != '[' && c != ']').collect()
}
fn strip_charge(s: &str) -> &str {
let bytes = s.as_bytes();
let mut end = bytes.len();
while end > 0 && (bytes[end - 1] == b'+' || bytes[end - 1] == b'-') {
end -= 1;
}
&s[..end]
}
fn parse_segment(
chars: &[char],
start: usize,
counts: &mut HashMap<String, u32>,
) -> Result<usize, FormulaParseError> {
let mut i = start;
while i < chars.len() {
let c = chars[i];
if c == '(' {
let mut sub_counts: HashMap<String, u32> = HashMap::new();
let end = parse_segment(chars, i + 1, &mut sub_counts)?;
let (mult, next) = parse_count(chars, end + 1); for (elem, cnt) in sub_counts {
*counts.entry(elem).or_insert(0) += cnt * mult;
}
i = next;
} else if c == ')' {
return Ok(i);
} else if c.is_uppercase() {
let mut sym = String::new();
sym.push(c);
i += 1;
while i < chars.len() && chars[i].is_lowercase() {
sym.push(chars[i]);
i += 1;
}
let (cnt, next) = parse_count(chars, i);
*counts.entry(sym).or_insert(0) += cnt;
i = next;
} else if c.is_ascii_digit() {
i += 1;
} else if c == '+' || c == '-' {
i += 1;
} else {
return Err(FormulaParseError::UnknownElement(c.to_string()));
}
}
Ok(i)
}
fn parse_count(chars: &[char], pos: usize) -> (u32, usize) {
let mut i = pos;
let mut n_str = String::new();
while i < chars.len() && chars[i].is_ascii_digit() {
n_str.push(chars[i]);
i += 1;
}
if n_str.is_empty() {
(1, i)
} else {
(n_str.parse().unwrap_or(1), i)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn counts(formula: &str) -> HashMap<String, u32> {
parse_formula(formula).unwrap_or_else(|e| panic!("parse {formula:?}: {e}"))
}
#[test]
fn test_empty_formula_error() {
assert!(matches!(
parse_formula(""),
Err(FormulaParseError::EmptyFormula)
));
assert!(matches!(
parse_formula(" "),
Err(FormulaParseError::EmptyFormula)
));
}
#[test]
fn test_water() {
let m = counts("H2O");
assert_eq!(m["H"], 2);
assert_eq!(m["O"], 1);
}
#[test]
fn test_glucose() {
let m = counts("C6H12O6");
assert_eq!(m["C"], 6);
assert_eq!(m["H"], 12);
assert_eq!(m["O"], 6);
}
#[test]
fn test_calcium_hydroxide() {
let m = counts("Ca(OH)2");
assert_eq!(m["Ca"], 1);
assert_eq!(m["O"], 2);
assert_eq!(m["H"], 2);
}
#[test]
fn test_ammonium_ion_bracket() {
let m = counts("[NH4]+");
assert_eq!(m["N"], 1);
assert_eq!(m["H"], 4);
assert!(!m.contains_key("+"));
}
#[test]
fn test_sulfuric_acid() {
let m = counts("H2SO4");
assert_eq!(m["H"], 2);
assert_eq!(m["S"], 1);
assert_eq!(m["O"], 4);
}
#[test]
fn test_ethanol() {
let m = counts("C2H5OH");
assert_eq!(m["C"], 2);
assert_eq!(m["H"], 6); assert_eq!(m["O"], 1);
}
#[test]
fn test_iron_iii_chloride() {
let m = counts("FeCl3");
assert_eq!(m["Fe"], 1);
assert_eq!(m["Cl"], 3);
}
#[test]
fn test_nested_parens() {
let m = counts("Al2(SO4)3");
assert_eq!(m["Al"], 2);
assert_eq!(m["S"], 3);
assert_eq!(m["O"], 12);
}
#[test]
fn test_trailing_charge_sign_stripped() {
let m = counts("NH4+");
assert_eq!(m["N"], 1);
assert_eq!(m["H"], 4);
let m2 = counts("Cl-");
assert_eq!(m2["Cl"], 1);
}
#[test]
fn test_single_atom() {
let m = counts("Fe");
assert_eq!(m["Fe"], 1);
assert_eq!(m.len(), 1);
}
#[test]
fn test_methane() {
let m = counts("CH4");
assert_eq!(m["C"], 1);
assert_eq!(m["H"], 4);
}
#[test]
fn test_benzene() {
let m = counts("C6H6");
assert_eq!(m["C"], 6);
assert_eq!(m["H"], 6);
}
#[test]
fn test_formula_roundtrip_consistency() {
use chematic_smiles::parse;
let mol = parse("CC(=O)Oc1ccccc1C(=O)O").unwrap(); let formula = crate::calc_mol_formula(&mol);
let parsed = counts(&formula);
assert_eq!(parsed["C"], 9);
assert_eq!(parsed["H"], 8);
assert_eq!(parsed["O"], 4);
}
}