Skip to main content

chematic_mol/
cml.rs

1//! CML (Chemical Markup Language) parser and writer.
2//!
3//! CML is an open XML-based format for chemical structures.  This module
4//! implements a minimal subset: `<molecule>`, `<atomArray>`, `<bondArray>`,
5//! `<atom>`, and `<bond>` elements with the attributes needed to represent
6//! 2D chemical structures.
7//!
8//! No external XML library is used; attributes are extracted with a simple
9//! key="value" scanner.
10//!
11//! # Supported attributes
12//!
13//! `<atom>`: `id`, `elementType`, `x2`, `y2`, `formalCharge`, `isotope`,
14//!           `hydrogenCount`, `isotopeNumber`
15//!
16//! `<bond>`: `atomRefs2` (two atom ids separated by a space), `order`
17//!           ("1" / "2" / "3" / "A" / "S" / "D" / "T")
18//!
19//! # Limitations
20//!
21//! - 3D coordinates (x3/y3/z3) are parsed but not returned (only 2D x2/y2).
22//! - Aromatic bonds ("A") are stored as alternating single/double bonds (no
23//!   special aromatic bond order in the core model).
24//! - Stereochemistry attributes are ignored.
25//! - The CML namespace declaration is accepted but not validated.
26
27use std::collections::HashMap;
28
29use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
30
31// ---------------------------------------------------------------------------
32// Error type
33// ---------------------------------------------------------------------------
34
35/// Error returned when parsing a CML document fails.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum CmlError {
38    /// An atom referenced `elementType` that is not a known element symbol.
39    UnknownElement(String),
40    /// A `<bond>` element referenced an atom id that was not defined.
41    UnknownAtomRef(String),
42    /// A `<bond>` element's `atomRefs2` attribute was not two space-separated ids.
43    InvalidAtomRefs2(String),
44    /// A `<bond>` element's `order` attribute could not be interpreted.
45    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// ---------------------------------------------------------------------------
60// XML attribute scanner (no external dependency)
61// ---------------------------------------------------------------------------
62
63/// Extract all `key="value"` pairs from a single XML tag line.
64#[doc(hidden)]
65///
66/// Handles the most common cases:
67/// - Double-quoted values
68/// - Basic XML entities: `&amp;` `&lt;` `&gt;` `&quot;` `&apos;`
69/// - Ignores self-closing slash and angle brackets
70pub(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        // Skip whitespace and punctuation between attributes.
78        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        // Read attribute key: stop at '=' or whitespace.
84        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        // Expect '='
92        while i < len && bytes[i].is_ascii_whitespace() { i += 1; }
93        if i >= len || bytes[i] != b'=' { continue; }
94        i += 1;
95
96        // Expect opening '"' or '\''
97        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        // Read until closing quote.
104        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; } // consume closing quote
109
110        if !key.is_empty() {
111            map.insert(key, value);
112        }
113    }
114    map
115}
116
117/// Decode basic XML character entities.
118fn unescape_xml(s: &str) -> String {
119    if !s.contains('&') {
120        return s.to_string();
121    }
122    s.replace("&amp;", "&")
123     .replace("&lt;", "<")
124     .replace("&gt;", ">")
125     .replace("&quot;", "\"")
126     .replace("&apos;", "'")
127}
128
129// ---------------------------------------------------------------------------
130// CML bond order decoding
131// ---------------------------------------------------------------------------
132
133fn 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        // Aromatic bonds: store as single (aromaticity is perceived from topology)
139        "A" | "aromatic"       => Ok(BondOrder::Single),
140        other => Err(CmlError::InvalidBondOrder(other.to_string())),
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Parser
146// ---------------------------------------------------------------------------
147
148/// Intermediate atom data before building the `Molecule`.
149struct 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
160/// Parse a CML document into a `(Molecule, 2D-coords)` pair.
161///
162/// The second element of the tuple contains one `(x, y)` entry per atom in
163/// atom-insertion order.  If a molecule has no `x2`/`y2` attributes, the
164/// coordinate pairs default to `(0.0, 0.0)`.
165pub fn parse_cml(input: &str) -> Result<(Molecule, Vec<(f64, f64)>), CmlError> {
166    // --- Pass 1: collect atoms and bonds from the CML -----------------------
167
168    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    // --- Pass 2: mark aromatic atoms, then build Molecule -------------------
226
227    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
259/// True if `line` starts the opening tag for the given element name
260/// (case-insensitive on the element name, as some writers vary case).
261fn 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
275// ---------------------------------------------------------------------------
276// Writer
277// ---------------------------------------------------------------------------
278
279/// Serialise a molecule to a CML string.
280///
281/// `coords` — optional slice of `(x, y)` pairs in atom-insertion order.
282/// If `None`, no coordinate attributes are written.
283pub 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// ---------------------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------------------
337
338#[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}