use std::collections::HashMap;
use chematic_core::Molecule;
use chematic_smiles::parse as parse_smiles;
#[derive(Debug, Clone, PartialEq)]
pub enum CondensedError {
UnknownElement(String),
UnbalancedParens,
EmptyInput,
ParseError(String),
}
impl std::fmt::Display for CondensedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownElement(s) => write!(f, "unknown element: {}", s),
Self::UnbalancedParens => write!(f, "unbalanced parentheses"),
Self::EmptyInput => write!(f, "empty input"),
Self::ParseError(s) => write!(f, "SMILES parse error: {}", s),
}
}
}
impl std::error::Error for CondensedError {}
pub fn parse_condensed(input: &str) -> Result<Molecule, CondensedError> {
if input.trim().is_empty() {
return Err(CondensedError::EmptyInput);
}
let input = input.trim();
let smiles = condensed_to_smiles(input)?;
parse_smiles(&smiles).map_err(|e| CondensedError::ParseError(e.to_string()))
}
fn condensed_to_smiles(input: &str) -> Result<String, CondensedError> {
let tokens = tokenize(input)?;
substitute_functional_groups(&tokens)
}
fn tokenize(input: &str) -> Result<Vec<Token>, CondensedError> {
let mut tokens = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'(' || bytes[i] == b')' {
tokens.push(Token::Paren(bytes[i] as char));
i += 1;
} else if bytes[i].is_ascii_digit() {
let mut num = String::new();
while i < bytes.len() && bytes[i].is_ascii_digit() {
num.push(bytes[i] as char);
i += 1;
}
tokens.push(Token::Digit(num.parse::<u32>().unwrap_or(1)));
} else if bytes[i].is_ascii_uppercase() {
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_lowercase() {
let elem = std::str::from_utf8(&bytes[i..=i + 1]).unwrap();
if is_valid_element(elem) {
tokens.push(Token::Atom(elem.to_string()));
i += 2;
} else {
tokens.push(Token::Atom((bytes[i] as char).to_string()));
i += 1;
}
} else {
tokens.push(Token::Atom((bytes[i] as char).to_string()));
i += 1;
}
} else if bytes[i].is_ascii_lowercase() {
return Err(CondensedError::UnknownElement(format!(
"unexpected lowercase at position {}: {}",
i,
input
)));
} else {
return Err(CondensedError::UnknownElement(format!(
"unexpected character at position {}: {}",
i, bytes[i] as char
)));
}
}
Ok(tokens)
}
#[derive(Debug, Clone)]
enum Token {
Atom(String),
Digit(u32),
Paren(char),
}
fn is_valid_element(s: &str) -> bool {
matches!(
s,
"Cl" | "Br" | "Si" | "As" | "Se" | "Sn" | "Te" | "Pb" | "Bi" | "Po" | "At"
)
}
fn substitute_functional_groups(tokens: &[Token]) -> Result<String, CondensedError> {
let mut smiles = String::new();
let mut i = 0;
while i < tokens.len() {
match &tokens[i] {
Token::Paren(c) => {
smiles.push(*c);
i += 1;
}
Token::Digit(n) => {
if smiles.is_empty() {
return Err(CondensedError::ParseError("digit without preceding atom".into()));
}
let last_char = smiles.pop().unwrap();
for _ in 0..*n {
smiles.push(last_char);
}
i += 1;
}
Token::Atom(a) => {
if i + 1 < tokens.len() {
if let Token::Atom(b) = &tokens[i + 1] {
let key = format!("{}{}", a, b);
if let Some(replacement) = functional_groups(&key) {
smiles.push_str(replacement);
i += 2;
continue;
}
}
}
if i + 2 < tokens.len() {
if let (Token::Atom(b), Token::Atom(c)) = (&tokens[i + 1], &tokens[i + 2]) {
let key = format!("{}{}{}", a, b, c);
if let Some(replacement) = functional_groups(&key) {
smiles.push_str(replacement);
i += 3;
continue;
}
}
}
atom_to_smiles(&mut smiles, a)?;
i += 1;
}
}
}
let paren_balance: i32 = smiles.chars().map(|c| match c {
'(' => 1,
')' => -1,
_ => 0,
}).sum();
if paren_balance != 0 {
return Err(CondensedError::UnbalancedParens);
}
Ok(smiles)
}
fn functional_groups(key: &str) -> Option<&'static str> {
match key {
"COOH" => Some("C(=O)O"),
"CHO" => Some("C=O"),
"NHCO" => Some("NC(=O)"),
"COHN" => Some("C(=O)N"),
"NO2" => Some("[N+](=O)[O-]"),
"CN" => Some("C#N"),
"OH" => Some("O"),
"NH2" => Some("N"),
"SH" => Some("S"),
"PH2" => Some("P"),
_ => None,
}
}
fn atom_to_smiles(smiles: &mut String, atom: &str) -> Result<(), CondensedError> {
match atom {
"C" | "N" | "O" | "S" | "P" | "H" | "F" | "Cl" | "Br" | "I" | "Si" | "B" | "Se" | "As" => {
smiles.push_str(atom);
Ok(())
}
_ => Err(CondensedError::UnknownElement(atom.to_string())),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_methane() {
let mol = parse_condensed("C").expect("methane");
assert_eq!(mol.atom_count(), 1);
}
#[test]
fn test_ethane() {
let mol = parse_condensed("CC").expect("ethane");
assert_eq!(mol.atom_count(), 2);
}
#[test]
fn test_ammonia() {
let mol = parse_condensed("NH3").expect("ammonia");
assert!(mol.atom_count() >= 1);
}
#[test]
fn test_empty_input() {
let result = parse_condensed("");
assert!(matches!(result, Err(CondensedError::EmptyInput)));
}
#[test]
fn test_hexane_linear() {
let mol = parse_condensed("CCCCCC").expect("linear hexane");
assert_eq!(mol.atom_count(), 6);
}
#[test]
fn test_simple_branched() {
let mol = parse_condensed("CC").expect("ethane");
assert!(mol.atom_count() >= 2);
}
#[test]
fn test_with_functional_group() {
let mol = parse_condensed("CCOOH").expect("propionic acid");
assert!(mol.atom_count() >= 3);
}
#[test]
fn test_propane() {
let mol = parse_condensed("CCC").expect("propane");
assert!(mol.atom_count() >= 3);
}
#[test]
fn test_butane() {
let mol = parse_condensed("CCCC").expect("butane");
assert_eq!(mol.atom_count(), 4);
}
#[test]
fn test_unknown_element() {
let result = parse_condensed("CXC");
assert!(result.is_err());
}
}