1use std::collections::HashMap;
28
29use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
30
31#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum CmlError {
38 UnknownElement(String),
40 UnknownAtomRef(String),
42 InvalidAtomRefs2(String),
44 InvalidBondOrder(String),
46}
47
48impl std::fmt::Display for CmlError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 CmlError::UnknownElement(s) => write!(f, "unknown element symbol: {s}"),
52 CmlError::UnknownAtomRef(s) => write!(f, "unknown atom ref: {s}"),
53 CmlError::InvalidAtomRefs2(s) => write!(f, "invalid atomRefs2: {s}"),
54 CmlError::InvalidBondOrder(s) => write!(f, "invalid bond order: {s}"),
55 }
56 }
57}
58
59#[doc(hidden)]
65pub(crate) fn parse_xml_attrs(line: &str) -> HashMap<String, String> {
71 let mut map = HashMap::new();
72 let bytes = line.as_bytes();
73 let len = bytes.len();
74 let mut i = 0usize;
75
76 while i < len {
77 while i < len && (bytes[i].is_ascii_whitespace() || bytes[i] == b'/' || bytes[i] == b'>') {
79 i += 1;
80 }
81 if i >= len { break; }
82
83 let key_start = i;
85 while i < len && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
86 i += 1;
87 }
88 let key = line[key_start..i].trim().to_string();
89 if key.is_empty() { i += 1; continue; }
90
91 while i < len && bytes[i].is_ascii_whitespace() { i += 1; }
93 if i >= len || bytes[i] != b'=' { continue; }
94 i += 1;
95
96 while i < len && bytes[i].is_ascii_whitespace() { i += 1; }
98 if i >= len { break; }
99 let quote = bytes[i];
100 if quote != b'"' && quote != b'\'' { i += 1; continue; }
101 i += 1;
102
103 let value_start = i;
105 while i < len && bytes[i] != quote { i += 1; }
106 let raw_value = &line[value_start..i];
107 let value = unescape_xml(raw_value);
108 if i < len { i += 1; } if !key.is_empty() {
111 map.insert(key, value);
112 }
113 }
114 map
115}
116
117fn unescape_xml(s: &str) -> String {
119 if !s.contains('&') {
120 return s.to_string();
121 }
122 s.replace("&", "&")
123 .replace("<", "<")
124 .replace(">", ">")
125 .replace(""", "\"")
126 .replace("'", "'")
127}
128
129fn parse_bond_order(s: &str) -> Result<BondOrder, CmlError> {
134 match s.trim() {
135 "1" | "S" | "single" => Ok(BondOrder::Single),
136 "2" | "D" | "double" => Ok(BondOrder::Double),
137 "3" | "T" | "triple" => Ok(BondOrder::Triple),
138 "A" | "aromatic" => Ok(BondOrder::Single),
140 other => Err(CmlError::InvalidBondOrder(other.to_string())),
141 }
142}
143
144struct CmlAtomData {
150 id: String,
151 element: Element,
152 charge: i8,
153 isotope: Option<u16>,
154 hydrogen_count: Option<u8>,
155 x2: f64,
156 y2: f64,
157 aromatic: bool,
158}
159
160pub fn parse_cml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CmlError> {
166 let mut atom_data: Vec<CmlAtomData> = Vec::new();
169 let mut atom_id_to_pos: HashMap<String, usize> = HashMap::new();
170
171 struct BondData { a1: usize, a2: usize, order: BondOrder, aromatic: bool }
172 let mut bond_data: Vec<BondData> = Vec::new();
173
174 for raw_line in input.lines() {
175 let line = raw_line.trim();
176 if line.is_empty() { continue; }
177
178 if is_element_tag(line, "atom") {
179 let attrs = parse_xml_attrs(line);
180 let id = attrs.get("id").cloned().unwrap_or_default();
181
182 let element_sym = attrs.get("elementType")
183 .map(String::as_str)
184 .unwrap_or("C");
185 let element = Element::from_symbol(element_sym)
186 .ok_or_else(|| CmlError::UnknownElement(element_sym.to_string()))?;
187
188 let charge = attrs.get("formalCharge")
189 .and_then(|s| s.trim().parse::<i8>().ok())
190 .unwrap_or(0);
191 let isotope = attrs.get("isotope")
192 .or_else(|| attrs.get("isotopeNumber"))
193 .and_then(|s| s.trim().parse::<u16>().ok())
194 .filter(|&v| v > 0);
195 let hydrogen_count = attrs.get("hydrogenCount")
196 .and_then(|s| s.trim().parse::<u8>().ok());
197 let x2 = attrs.get("x2").and_then(|s| s.trim().parse::<f64>().ok()).unwrap_or(0.0);
198 let y2 = attrs.get("y2").and_then(|s| s.trim().parse::<f64>().ok()).unwrap_or(0.0);
199
200 let pos = atom_data.len();
201 if !id.is_empty() { atom_id_to_pos.insert(id.clone(), pos); }
202 atom_data.push(CmlAtomData { id, element, charge, isotope, hydrogen_count, x2, y2, aromatic: false });
203 continue;
204 }
205
206 if is_element_tag(line, "bond") {
207 let attrs = parse_xml_attrs(line);
208 let refs = attrs.get("atomRefs2")
209 .ok_or_else(|| CmlError::InvalidAtomRefs2("missing atomRefs2".to_string()))?;
210 let parts: Vec<&str> = refs.split_whitespace().collect();
211 if parts.len() < 2 {
212 return Err(CmlError::InvalidAtomRefs2(refs.clone()));
213 }
214 let a1 = *atom_id_to_pos.get(parts[0])
215 .ok_or_else(|| CmlError::UnknownAtomRef(parts[0].to_string()))?;
216 let a2 = *atom_id_to_pos.get(parts[1])
217 .ok_or_else(|| CmlError::UnknownAtomRef(parts[1].to_string()))?;
218 let order_s = attrs.get("order").map(String::as_str).unwrap_or("1");
219 let is_aromatic = matches!(order_s.trim(), "A" | "aromatic");
220 let order = parse_bond_order(order_s)?;
221 bond_data.push(BondData { a1, a2, order, aromatic: is_aromatic });
222 }
223 }
224
225 for bd in &bond_data {
228 if bd.aromatic {
229 if let Some(a) = atom_data.get_mut(bd.a1) { a.aromatic = true; }
230 if let Some(a) = atom_data.get_mut(bd.a2) { a.aromatic = true; }
231 }
232 }
233
234 let mut builder = MoleculeBuilder::new();
235 let mut idx_by_pos: Vec<AtomIdx> = Vec::new();
236 let mut coords: Vec<(f64, f64)> = Vec::new();
237
238 for ad in &atom_data {
239 let mut atom = Atom::new(ad.element);
240 atom.charge = ad.charge;
241 atom.isotope = ad.isotope;
242 atom.hydrogen_count = ad.hydrogen_count;
243 atom.aromatic = ad.aromatic;
244 let idx = builder.add_atom(atom);
245 idx_by_pos.push(idx);
246 coords.push((ad.x2, ad.y2));
247 }
248
249 for bd in &bond_data {
250 let a1 = idx_by_pos[bd.a1];
251 let a2 = idx_by_pos[bd.a2];
252 builder.add_bond(a1, a2, bd.order)
253 .map_err(|_| CmlError::InvalidAtomRefs2(format!("{} {}", bd.a1, bd.a2)))?;
254 }
255
256 Ok((builder.build(), coords))
257}
258
259fn is_element_tag(line: &str, name: &str) -> bool {
262 let check = |prefix: &str| -> bool {
263 line.starts_with(prefix) && {
264 let rest = &line[prefix.len()..];
265 rest.is_empty()
266 || rest.starts_with(' ')
267 || rest.starts_with('/')
268 || rest.starts_with('>')
269 || rest.starts_with('\t')
270 }
271 };
272 check(&format!("<{name}")) || check(&format!("<{}", name.to_uppercase()))
273}
274
275pub fn write_cml(mol: &Molecule, coords: Option<&[(f64, f64)]>) -> String {
284 let mut out = String::from(
285 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
286 <molecule xmlns=\"http://www.xml-cml.org/schema\">\n <atomArray>\n",
287 );
288
289 for (i, (idx, atom)) in mol.atoms().enumerate() {
290 let sym = atom.element.symbol();
291 let mut parts = vec![
292 format!("id=\"a{}\"", idx.0 + 1),
293 format!("elementType=\"{sym}\""),
294 ];
295 if let Some(cs) = coords {
296 if let Some(&(x, y)) = cs.get(i) {
297 parts.push(format!("x2=\"{x:.4}\""));
298 parts.push(format!("y2=\"{y:.4}\""));
299 }
300 }
301 if atom.charge != 0 {
302 parts.push(format!("formalCharge=\"{}\"", atom.charge));
303 }
304 if let Some(iso) = atom.isotope {
305 parts.push(format!("isotopeNumber=\"{iso}\""));
306 }
307 if let Some(h) = atom.hydrogen_count {
308 parts.push(format!("hydrogenCount=\"{h}\""));
309 }
310 out.push_str(&format!(" <atom {}/>\n", parts.join(" ")));
311 }
312
313 out.push_str(" </atomArray>\n <bondArray>\n");
314
315 for (_, bond) in mol.bonds() {
316 let order_str = match bond.order {
317 BondOrder::Single | BondOrder::Up | BondOrder::Down => "1",
318 BondOrder::Double => "2",
319 BondOrder::Triple => "3",
320 BondOrder::Aromatic => "A",
321 BondOrder::Quadruple => "4",
322 };
323 let a1_id = bond.atom1.0 + 1;
324 let a2_id = bond.atom2.0 + 1;
325 out.push_str(&format!(
326 " <bond atomRefs2=\"a{a1_id} a{a2_id}\" order=\"{order_str}\"/>\n"
327 ));
328 }
329
330 out.push_str(" </bondArray>\n</molecule>\n");
331 out
332}
333
334#[cfg(test)]
339mod tests {
340 use super::*;
341
342 const ETHANOL_CML: &str = r#"<?xml version="1.0"?>
343<molecule xmlns="http://www.xml-cml.org/schema">
344 <atomArray>
345 <atom id="a1" elementType="C" x2="0.0" y2="0.0"/>
346 <atom id="a2" elementType="C" x2="1.5" y2="0.0"/>
347 <atom id="a3" elementType="O" x2="3.0" y2="0.0"/>
348 </atomArray>
349 <bondArray>
350 <bond atomRefs2="a1 a2" order="1"/>
351 <bond atomRefs2="a2 a3" order="1"/>
352 </bondArray>
353</molecule>"#;
354
355 #[test]
356 fn parse_cml_ethanol_atom_count() {
357 let (mol, coords) = parse_cml(ETHANOL_CML).unwrap();
358 assert_eq!(mol.atom_count(), 3, "ethanol has 3 heavy atoms");
359 assert_eq!(mol.bond_count(), 2, "ethanol has 2 bonds");
360 assert_eq!(coords.len(), 3, "one coord per atom");
361 }
362
363 #[test]
364 fn parse_cml_ethanol_elements() {
365 let (mol, _) = parse_cml(ETHANOL_CML).unwrap();
366 let elems: Vec<&str> = mol.atoms().map(|(_, a)| a.element.symbol()).collect();
367 assert!(elems.contains(&"C"), "should have C");
368 assert!(elems.contains(&"O"), "should have O");
369 }
370
371 #[test]
372 fn parse_cml_ethanol_coords() {
373 let (_, coords) = parse_cml(ETHANOL_CML).unwrap();
374 assert!((coords[0].0 - 0.0).abs() < 0.001, "a1 x=0.0");
375 assert!((coords[1].0 - 1.5).abs() < 0.001, "a2 x=1.5");
376 assert!((coords[2].0 - 3.0).abs() < 0.001, "a3 x=3.0");
377 }
378
379 #[test]
380 fn parse_cml_formal_charge() {
381 let cml = r#"<molecule>
382 <atomArray>
383 <atom id="a1" elementType="N" formalCharge="1"/>
384 </atomArray>
385 <bondArray/>
386</molecule>"#;
387 let (mol, _) = parse_cml(cml).unwrap();
388 let atom = mol.atom(chematic_core::AtomIdx(0));
389 assert_eq!(atom.charge, 1, "N+ should have charge=1");
390 }
391
392 #[test]
393 fn parse_cml_unknown_element_returns_err() {
394 let cml = r#"<molecule>
395 <atomArray>
396 <atom id="a1" elementType="Xx"/>
397 </atomArray>
398</molecule>"#;
399 let result = parse_cml(cml);
400 assert!(
401 matches!(result, Err(CmlError::UnknownElement(_))),
402 "unknown element should return Err"
403 );
404 }
405
406 #[test]
407 fn write_cml_roundtrip() {
408 let (mol, coords) = parse_cml(ETHANOL_CML).unwrap();
409 let written = write_cml(&mol, Some(&coords));
410 let (mol2, coords2) = parse_cml(&written).unwrap();
411
412 assert_eq!(mol.atom_count(), mol2.atom_count(), "atom count preserved");
413 assert_eq!(mol.bond_count(), mol2.bond_count(), "bond count preserved");
414 assert!((coords[1].0 - coords2[1].0).abs() < 0.001, "x coord preserved");
415 }
416
417 #[test]
418 fn write_cml_no_coords() {
419 use chematic_core::MoleculeBuilder;
420 let mut b = MoleculeBuilder::new();
421 b.add_atom(Atom::new(Element::from_symbol("C").unwrap()));
422 b.add_atom(Atom::new(Element::from_symbol("O").unwrap()));
423 let mol = b.build();
424 let cml = write_cml(&mol, None);
425 assert!(cml.contains("elementType=\"C\""), "C atom in output");
426 assert!(cml.contains("elementType=\"O\""), "O atom in output");
427 assert!(!cml.contains("x2="), "no x2 when no coords");
428 }
429
430 #[test]
431 fn parse_cml_double_bond() {
432 let cml = r#"<molecule>
433 <atomArray>
434 <atom id="a1" elementType="C"/>
435 <atom id="a2" elementType="O"/>
436 </atomArray>
437 <bondArray>
438 <bond atomRefs2="a1 a2" order="2"/>
439 </bondArray>
440</molecule>"#;
441 let (mol, _) = parse_cml(cml).unwrap();
442 let bond = mol.bond(chematic_core::BondIdx(0));
443 assert_eq!(bond.order, BondOrder::Double, "order=2 should give Double");
444 }
445}