use std::collections::HashMap;
use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CmlError {
UnknownElement(String),
UnknownAtomRef(String),
InvalidAtomRefs2(String),
InvalidBondOrder(String),
}
impl std::fmt::Display for CmlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CmlError::UnknownElement(s) => write!(f, "unknown element symbol: {s}"),
CmlError::UnknownAtomRef(s) => write!(f, "unknown atom ref: {s}"),
CmlError::InvalidAtomRefs2(s) => write!(f, "invalid atomRefs2: {s}"),
CmlError::InvalidBondOrder(s) => write!(f, "invalid bond order: {s}"),
}
}
}
impl std::error::Error for CmlError {}
#[doc(hidden)]
pub(crate) fn parse_xml_attrs(line: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0usize;
while i < len {
while i < len && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/' || bytes[i] == b'>') {
i += 1;
}
if i >= len {
break;
}
let key_start = i;
while i < len && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
i += 1;
}
let key = line[key_start..i].trim().to_string();
if key.is_empty() {
i += 1;
continue;
}
while i < len && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= len || bytes[i] != b'=' {
continue;
}
i += 1;
while i < len && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= len {
break;
}
let quote = bytes[i];
if quote != b'"' && quote != b'\'' {
i += 1;
continue;
}
i += 1;
let value_start = i;
while i < len && bytes[i] != quote {
i += 1;
}
let raw_value = &line[value_start..i];
let value = unescape_xml(raw_value);
if i < len {
i += 1;
}
if !key.is_empty() {
map.insert(key, value);
}
}
map
}
fn unescape_xml(s: &str) -> String {
if !s.contains('&') {
return s.to_string();
}
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
}
fn parse_bond_order(s: &str) -> Result<BondOrder, CmlError> {
match s.trim() {
"0" | "zero" => Ok(BondOrder::Zero),
"1" | "S" | "single" => Ok(BondOrder::Single),
"2" | "D" | "double" => Ok(BondOrder::Double),
"3" | "T" | "triple" => Ok(BondOrder::Triple),
"A" | "aromatic" => Ok(BondOrder::Aromatic),
"any" => Ok(BondOrder::QueryAny),
"S/D" => Ok(BondOrder::QuerySingleOrDouble),
"S/A" => Ok(BondOrder::QuerySingleOrAromatic),
"D/A" => Ok(BondOrder::QueryDoubleOrAromatic),
other => Err(CmlError::InvalidBondOrder(other.to_string())),
}
}
struct CmlAtomData {
element: Element,
charge: i8,
isotope: Option<u16>,
hydrogen_count: Option<u8>,
x2: f64,
y2: f64,
aromatic: bool,
}
pub fn parse_cml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CmlError> {
let mut atom_data: Vec<CmlAtomData> = Vec::new();
let mut atom_id_to_pos: HashMap<String, usize> = HashMap::new();
struct BondData {
a1: usize,
a2: usize,
order: BondOrder,
aromatic: bool,
}
let mut bond_data: Vec<BondData> = Vec::new();
for raw_line in input.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if is_element_tag(line, "atom") {
let attrs = parse_xml_attrs(line);
let id = attrs.get("id").cloned().unwrap_or_default();
let element_sym = attrs.get("elementType").map(String::as_str).unwrap_or("C");
let element = Element::from_symbol(element_sym)
.ok_or_else(|| CmlError::UnknownElement(element_sym.to_string()))?;
let charge = attrs
.get("formalCharge")
.and_then(|s| s.trim().parse::<i8>().ok())
.unwrap_or(0);
let isotope = attrs
.get("isotope")
.or_else(|| attrs.get("isotopeNumber"))
.and_then(|s| s.trim().parse::<u16>().ok())
.filter(|&v| v > 0);
let hydrogen_count = attrs
.get("hydrogenCount")
.and_then(|s| s.trim().parse::<u8>().ok());
let x2 = attrs
.get("x2")
.and_then(|s| s.trim().parse::<f64>().ok())
.unwrap_or(0.0);
let y2 = attrs
.get("y2")
.and_then(|s| s.trim().parse::<f64>().ok())
.unwrap_or(0.0);
let pos = atom_data.len();
if !id.is_empty() {
atom_id_to_pos.insert(id.clone(), pos);
}
atom_data.push(CmlAtomData {
element,
charge,
isotope,
hydrogen_count,
x2,
y2,
aromatic: false,
});
continue;
}
if is_element_tag(line, "bond") {
let attrs = parse_xml_attrs(line);
let refs = attrs
.get("atomRefs2")
.ok_or_else(|| CmlError::InvalidAtomRefs2("missing atomRefs2".to_string()))?;
let parts: Vec<&str> = refs.split_whitespace().collect();
if parts.len() < 2 {
return Err(CmlError::InvalidAtomRefs2(refs.clone()));
}
let a1 = *atom_id_to_pos
.get(parts[0])
.ok_or_else(|| CmlError::UnknownAtomRef(parts[0].to_string()))?;
let a2 = *atom_id_to_pos
.get(parts[1])
.ok_or_else(|| CmlError::UnknownAtomRef(parts[1].to_string()))?;
let order_s = attrs.get("order").map(String::as_str).unwrap_or("1");
let is_aromatic = matches!(order_s.trim(), "A" | "aromatic");
let order = parse_bond_order(order_s)?;
bond_data.push(BondData {
a1,
a2,
order,
aromatic: is_aromatic,
});
}
}
for bd in &bond_data {
if bd.aromatic {
if let Some(a) = atom_data.get_mut(bd.a1) {
a.aromatic = true;
}
if let Some(a) = atom_data.get_mut(bd.a2) {
a.aromatic = true;
}
}
}
let mut builder = MoleculeBuilder::new();
let mut idx_by_pos: Vec<AtomIdx> = Vec::new();
let mut coords: Vec<(f64, f64)> = Vec::new();
for ad in &atom_data {
let mut atom = Atom::new(ad.element);
atom.charge = ad.charge;
atom.isotope = ad.isotope;
atom.hydrogen_count = ad.hydrogen_count;
atom.aromatic = ad.aromatic;
let idx = builder.add_atom(atom);
idx_by_pos.push(idx);
coords.push((ad.x2, ad.y2));
}
for bd in &bond_data {
let a1 = idx_by_pos[bd.a1];
let a2 = idx_by_pos[bd.a2];
builder
.add_bond(a1, a2, bd.order)
.map_err(|_| CmlError::InvalidAtomRefs2(format!("{} {}", bd.a1, bd.a2)))?;
}
Ok((builder.build(), coords))
}
fn is_element_tag(line: &str, name: &str) -> bool {
let check = |prefix: &str| -> bool {
line.starts_with(prefix) && {
let rest = &line[prefix.len()..];
rest.is_empty()
|| rest.starts_with(' ')
|| rest.starts_with('/')
|| rest.starts_with('>')
|| rest.starts_with('\t')
}
};
check(&format!("<{name}")) || check(&format!("<{}", name.to_uppercase()))
}
pub fn write_cml(mol: &Molecule, coords: Option<&[(f64, f64)]>) -> String {
let mut out = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<molecule xmlns=\"http://www.xml-cml.org/schema\">\n <atomArray>\n",
);
for (i, (idx, atom)) in mol.atoms().enumerate() {
let sym = atom.element.symbol();
let mut parts = vec![
format!("id=\"a{}\"", idx.0 + 1),
format!("elementType=\"{sym}\""),
];
if let Some(cs) = coords
&& let Some(&(x, y)) = cs.get(i)
{
parts.push(format!("x2=\"{x:.4}\""));
parts.push(format!("y2=\"{y:.4}\""));
}
if atom.charge != 0 {
parts.push(format!("formalCharge=\"{}\"", atom.charge));
}
if let Some(iso) = atom.isotope {
parts.push(format!("isotopeNumber=\"{iso}\""));
}
if let Some(h) = atom.hydrogen_count {
parts.push(format!("hydrogenCount=\"{h}\""));
}
out.push_str(&format!(" <atom {}/>\n", parts.join(" ")));
}
out.push_str(" </atomArray>\n <bondArray>\n");
for (_, bond) in mol.bonds() {
let order_str = match bond.order {
BondOrder::Zero => "0",
BondOrder::Single | BondOrder::Up | BondOrder::Down | BondOrder::Dative => "1",
BondOrder::Double => "2",
BondOrder::Triple => "3",
BondOrder::Aromatic => "A",
BondOrder::Quadruple => "4",
BondOrder::QueryAny => "any",
BondOrder::QuerySingleOrDouble => "S/D",
BondOrder::QuerySingleOrAromatic => "S/A",
BondOrder::QueryDoubleOrAromatic => "D/A",
};
let a1_id = bond.atom1.0 + 1;
let a2_id = bond.atom2.0 + 1;
out.push_str(&format!(
" <bond atomRefs2=\"a{a1_id} a{a2_id}\" order=\"{order_str}\"/>\n"
));
}
out.push_str(" </bondArray>\n</molecule>\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
const ETHANOL_CML: &str = r#"<?xml version="1.0"?>
<molecule xmlns="http://www.xml-cml.org/schema">
<atomArray>
<atom id="a1" elementType="C" x2="0.0" y2="0.0"/>
<atom id="a2" elementType="C" x2="1.5" y2="0.0"/>
<atom id="a3" elementType="O" x2="3.0" y2="0.0"/>
</atomArray>
<bondArray>
<bond atomRefs2="a1 a2" order="1"/>
<bond atomRefs2="a2 a3" order="1"/>
</bondArray>
</molecule>"#;
#[test]
fn parse_cml_ethanol_atom_count() {
let (mol, coords) = parse_cml(ETHANOL_CML).unwrap();
assert_eq!(mol.atom_count(), 3, "ethanol has 3 heavy atoms");
assert_eq!(mol.bond_count(), 2, "ethanol has 2 bonds");
assert_eq!(coords.len(), 3, "one coord per atom");
}
#[test]
fn parse_cml_ethanol_elements() {
let (mol, _) = parse_cml(ETHANOL_CML).unwrap();
let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
assert!(elems.contains(&"C"), "should have C");
assert!(elems.contains(&"O"), "should have O");
}
#[test]
fn parse_cml_ethanol_coords() {
let (_, coords) = parse_cml(ETHANOL_CML).unwrap();
assert!((coords[0].0 - 0.0).abs() < 0.001, "a1 x=0.0");
assert!((coords[1].0 - 1.5).abs() < 0.001, "a2 x=1.5");
assert!((coords[2].0 - 3.0).abs() < 0.001, "a3 x=3.0");
}
#[test]
fn parse_cml_formal_charge() {
let cml = r#"<molecule>
<atomArray>
<atom id="a1" elementType="N" formalCharge="1"/>
</atomArray>
<bondArray/>
</molecule>"#;
let (mol, _) = parse_cml(cml).unwrap();
let atom = mol.atom(chematic_core::AtomIdx(0));
assert_eq!(atom.charge, 1, "N+ should have charge=1");
}
#[test]
fn parse_cml_unknown_element_returns_err() {
let cml = r#"<molecule>
<atomArray>
<atom id="a1" elementType="Xx"/>
</atomArray>
</molecule>"#;
let result = parse_cml(cml);
assert!(
matches!(result, Err(CmlError::UnknownElement(_))),
"unknown element should return Err"
);
}
#[test]
fn write_cml_roundtrip() {
let (mol, coords) = parse_cml(ETHANOL_CML).unwrap();
let written = write_cml(&mol, Some(&coords));
let (mol2, coords2) = parse_cml(&written).unwrap();
assert_eq!(mol.atom_count(), mol2.atom_count(), "atom count preserved");
assert_eq!(mol.bond_count(), mol2.bond_count(), "bond count preserved");
assert!(
(coords[1].0 - coords2[1].0).abs() < 0.001,
"x coord preserved"
);
}
#[test]
fn write_cml_no_coords() {
use chematic_core::MoleculeBuilder;
let mut b = MoleculeBuilder::new();
b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
b.add_atom(Atom::new(Element::from_symbol("O").unwrap()));
let mol = b.build();
let cml = write_cml(&mol, None);
assert!(cml.contains("elementType=\"C\""), "C atom in output");
assert!(cml.contains("elementType=\"O\""), "O atom in output");
assert!(!cml.contains("x2="), "no x2 when no coords");
}
#[test]
fn parse_cml_double_bond() {
let cml = r#"<molecule>
<atomArray>
<atom id="a1" elementType="C"/>
<atom id="a2" elementType="O"/>
</atomArray>
<bondArray>
<bond atomRefs2="a1 a2" order="2"/>
</bondArray>
</molecule>"#;
let (mol, _) = parse_cml(cml).unwrap();
let bond = mol.bond(chematic_core::BondIdx(0));
assert_eq!(bond.order, BondOrder::Double, "order=2 should give Double");
}
}