Skip to main content

chematic_mol/
mol2000.rs

1//! MOL V2000 (Ctab) parser and writer.
2//!
3//! Reference format:
4//!   Line 1  — molecule name (may be blank)
5//!   Line 2  — program/date info (may be blank)
6//!   Line 3  — comment (may be blank)
7//!   Line 4  — counts line: fixed-width fields for atom count, bond count, version tag
8//!   Lines 5..5+natoms — atom block (one line per atom)
9//!   Lines 5+natoms..5+natoms+nbonds — bond block (one line per bond)
10//!   "M  END" — molecule terminator
11
12use chematic_core::{Atom, AtomIdx, BondOrder, Element, Molecule, MoleculeBuilder};
13
14use crate::error::MolParseError;
15
16/// Metadata extracted from the three-line MOL header.
17#[derive(Debug, Clone, PartialEq, Eq, Default)]
18pub struct MolMetadata {
19    /// Molecule name from header line 1.
20    pub name: String,
21    /// Comment string from header line 3.
22    pub comment: String,
23}
24
25// ---------------------------------------------------------------------------
26// Charge encoding table (V2000 ccc field → formal charge)
27// ---------------------------------------------------------------------------
28
29/// Decode a V2000 charge code into a formal charge value.
30fn decode_charge(code: i8) -> i8 {
31    match code {
32        1 => 3,
33        2 => 2,
34        3 => 1,
35        4 => 0, // doublet radical — treated as neutral
36        5 => -1,
37        6 => -2,
38        7 => -3,
39        _ => 0,
40    }
41}
42
43/// Encode a formal charge into a V2000 charge code.
44fn encode_charge(charge: i8) -> u8 {
45    match charge {
46        3 => 1,
47        2 => 2,
48        1 => 3,
49        -1 => 5,
50        -2 => 6,
51        -3 => 7,
52        _ => 0,
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Parser
58// ---------------------------------------------------------------------------
59
60/// Parse a fixed-width 3-character integer field from a string slice.
61///
62/// Returns an error using `make_err` when the slice is missing or the text
63/// cannot be parsed as an integer.
64fn parse_field3(
65    line: &str,
66    start: usize,
67    line_num: usize,
68    make_err: impl Fn(usize, String) -> MolParseError,
69) -> Result<usize, MolParseError> {
70    let field = line.get(start..start + 3).ok_or_else(|| {
71        make_err(line_num, format!("line too short at column {start}"))
72    })?;
73    field.trim().parse::<usize>().map_err(|_| {
74        make_err(line_num, format!("cannot parse integer from '{field}'"))
75    })
76}
77
78/// Parse a MOL V2000 string into a `(Molecule, MolMetadata, coords)` triple.
79///
80/// The parser follows the MDL/CTfile fixed-width column layout.
81/// `coords[i]` is the `(x, y)` position for atom `i` extracted from the
82/// atom block.  Z-coordinates are discarded.
83pub fn parse_mol_with_coords(input: &str) -> Result<(Molecule, MolMetadata, Vec<(f64, f64)>), MolParseError> {
84    // Yields (1-based line number, line text); short-circuits on EOF.
85    let mut lines = input
86        .lines()
87        .enumerate()
88        .map(|(i, l)| (i + 1, l));
89    let mut next_line = || lines.next().ok_or(MolParseError::UnexpectedEnd);
90
91    // -- Header block: lines 1–3 -------------------------------------------
92
93    let name = next_line()?.1.to_string();
94    next_line()?; // line 2: program/date — discarded
95    let comment = next_line()?.1.to_string();
96
97    let metadata = MolMetadata { name, comment };
98
99    // -- Counts line (line 4) -----------------------------------------------
100
101    let (counts_lineno, counts_line) = next_line()?;
102
103    // Be lenient with shorter lines — just check the V2000 tag exists.
104    if !counts_line.contains("V2000") {
105        return Err(MolParseError::InvalidCountLine {
106            line: counts_lineno,
107            detail: "missing V2000 version tag".to_string(),
108        });
109    }
110
111    let make_count_err = |ln: usize, d: String| MolParseError::InvalidCountLine { line: ln, detail: d };
112
113    let natoms = parse_field3(counts_line, 0, counts_lineno, make_count_err)?;
114    let nbonds = parse_field3(counts_line, 3, counts_lineno, make_count_err)?;
115
116    // -- Atom block ---------------------------------------------------------
117
118    let mut builder = MoleculeBuilder::new();
119    let mut coords: Vec<(f64, f64)> = Vec::with_capacity(natoms);
120    let make_atom_err = |ln: usize, d: String| MolParseError::InvalidAtomLine { line: ln, detail: d };
121
122    for atom_i in 0..natoms {
123        let (raw_lineno, atom_line) = next_line()?;
124
125        // Coordinates: bytes 0–9 (x), 10–19 (y), 20–29 (z) — each 10 chars.
126        let x: f64 = atom_line.get(0..10).and_then(|s| s.trim().parse().ok()).unwrap_or(0.0);
127        let y: f64 = atom_line.get(10..20).and_then(|s| s.trim().parse().ok()).unwrap_or(0.0);
128        coords.push((x, y));
129
130        // Element symbol: bytes 31–33 (3 chars, left-padded with a space in
131        // the spec, but writers vary; trim both ends).
132        let sym = atom_line.get(31..34).ok_or_else(|| {
133            make_atom_err(raw_lineno, format!("atom line {atom_i} too short for element field"))
134        })?.trim();
135
136        let element = Element::from_symbol(sym).ok_or_else(|| MolParseError::UnknownElement {
137            symbol: sym.to_string(),
138            line: raw_lineno,
139        })?;
140
141        // Charge code: bytes 36–38 (3 chars).
142        let charge = atom_line
143            .get(36..39)
144            .map(|ccc| decode_charge(ccc.trim().parse().unwrap_or(0)))
145            .unwrap_or(0);
146
147        let mut atom = Atom::new(element);
148        atom.charge = charge;
149        builder.add_atom(atom);
150    }
151
152    // -- Bond block ---------------------------------------------------------
153
154    let make_bond_err = |ln: usize, d: String| MolParseError::InvalidBondLine { line: ln, detail: d };
155
156    for bond_i in 0..nbonds {
157        let (raw_lineno, bond_line) = next_line()?;
158
159        let a1_raw = parse_field3(bond_line, 0, raw_lineno, make_bond_err)?;
160        let a2_raw = parse_field3(bond_line, 3, raw_lineno, make_bond_err)?;
161        let btype_raw = parse_field3(bond_line, 6, raw_lineno, make_bond_err)?;
162
163        if a1_raw == 0 || a2_raw == 0 {
164            return Err(MolParseError::InvalidBondLine {
165                line: raw_lineno,
166                detail: format!("bond {bond_i}: atom indices are 1-based; got {a1_raw}/{a2_raw}"),
167            });
168        }
169
170        let a1 = AtomIdx((a1_raw - 1) as u32);
171        let a2 = AtomIdx((a2_raw - 1) as u32);
172
173        // Stereo field (columns 9-11, 0-indexed): only meaningful for single bonds.
174        let stereo_raw: usize = if bond_line.len() >= 12 {
175            parse_field3(bond_line, 9, raw_lineno, make_bond_err).unwrap_or(0)
176        } else {
177            0
178        };
179
180        // Bond types 5/6/7/8 (query bonds) fall back to Single since they
181        // are not representable in BondOrder.
182        let order = match btype_raw {
183            1 => match stereo_raw {
184                1 | 4 => BondOrder::Up,
185                6 => BondOrder::Down,
186                _ => BondOrder::Single,
187            },
188            2 => BondOrder::Double,
189            3 => BondOrder::Triple,
190            4 => BondOrder::Aromatic,
191            _ => BondOrder::Single,
192        };
193
194        builder.add_bond(a1, a2, order).map_err(|e| MolParseError::InvalidBondLine {
195            line: raw_lineno,
196            detail: format!("bond {bond_i}: {e}"),
197        })?;
198    }
199
200    // Skip property lines until "M  END" (or EOF if absent).
201    for (_, l) in lines.by_ref() {
202        if l.trim_start().starts_with("M  END") {
203            break;
204        }
205    }
206
207    Ok((builder.build(), metadata, coords))
208}
209
210/// Parse a MOL V2000 string into a `(Molecule, MolMetadata)` pair.
211///
212/// This is a convenience wrapper around [`parse_mol_with_coords`] that discards
213/// the 2D coordinate data.
214pub fn parse_mol(input: &str) -> Result<(Molecule, MolMetadata), MolParseError> {
215    parse_mol_with_coords(input).map(|(mol, meta, _coords)| (mol, meta))
216}
217
218/// Parse all molecules from an SDF string, returning 2D coordinates.
219///
220/// Each entry contains the molecule, its metadata, and a `Vec<(x, y)>` of
221/// 2D coordinates in atom-insertion order (the same order as `.atoms()`).
222///
223/// Stops and returns an error on the first parse failure.
224pub fn parse_sdf_with_coords(
225    input: &str,
226) -> Result<Vec<(Molecule, MolMetadata, Vec<(f64, f64)>)>, MolParseError> {
227    use crate::sdf::SdfReader;
228    // Re-use the SDF record splitter by borrowing its block-splitting logic,
229    // but call parse_mol_with_coords on each block instead of parse_mol.
230    let mut result = Vec::new();
231    let mut remaining = input;
232    loop {
233        // Skip leading blank lines.
234        while let Some(rest) = remaining.strip_prefix("\r\n").or_else(|| remaining.strip_prefix('\n')) {
235            remaining = rest;
236        }
237        if remaining.is_empty() { break; }
238
239        // Find the $$$$ delimiter (line-by-line to avoid false matches inside data).
240        let mut byte_offset = 0usize;
241        let (end_byte, after_delim) = loop {
242            let rest = &remaining[byte_offset..];
243            match rest.find('\n') {
244                Some(nl) => {
245                    let line = rest[..nl].trim_end_matches('\r');
246                    if line == "$$$$" {
247                        break (byte_offset, &remaining[byte_offset + nl + 1..]);
248                    }
249                    byte_offset += nl + 1;
250                }
251                None => {
252                    if rest.trim_end_matches('\r') == "$$$$" {
253                        break (byte_offset, "");
254                    }
255                    break (remaining.len(), "");
256                }
257            }
258        };
259
260        let block = &remaining[..end_byte];
261        remaining = after_delim;
262        if block.trim().is_empty() { continue; }
263
264        let (mol, meta, coords) = parse_mol_with_coords(block)?;
265        result.push((mol, meta, coords));
266    }
267    Ok(result)
268}
269
270// ---------------------------------------------------------------------------
271// Writer
272// ---------------------------------------------------------------------------
273
274/// Write a `Molecule` to MOL V2000 format.
275///
276/// Coordinates are written as 0.0 because the core `Molecule` type does not
277/// store 2D/3D coordinates.  All other atom and bond fields are derived from
278/// the molecule graph.
279pub fn write_mol(mol: &Molecule, metadata: &MolMetadata) -> String {
280    write_mol_with_coords(mol, metadata, &[])
281}
282
283/// Serialize `mol` to a V2000 MOL block, using `coords` for atom positions.
284///
285/// `coords[i]` is the `(x, y)` position in Ångström for atom index `i`.
286/// Atoms beyond `coords.len()` receive `(0.0, 0.0, 0.0)`.
287pub fn write_mol_with_coords(
288    mol: &Molecule,
289    metadata: &MolMetadata,
290    coords: &[(f64, f64)],
291) -> String {
292    let mut out = String::new();
293
294    // Header lines 1–3
295    out.push_str(&metadata.name);
296    out.push('\n');
297    out.push_str("  chematic\n");
298    out.push_str(&metadata.comment);
299    out.push('\n');
300
301    // Counts line (line 4)
302    let natoms = mol.atom_count();
303    let nbonds = mol.bond_count();
304    out.push_str(&format!(
305        "{:>3}{:>3}  0  0  0  0  0  0  0  0999 V2000\n",
306        natoms, nbonds
307    ));
308
309    // Atom block
310    for (idx, atom) in mol.atoms() {
311        let sym = atom.element.symbol();
312        let charge_code = encode_charge(atom.charge);
313        let (x, y) = coords.get(idx.0 as usize).copied().unwrap_or((0.0, 0.0));
314        out.push_str(&format!(
315            "{:>10.4}{:>10.4}{:>10.4} {:<3} 0{:>3}  0  0  0  0  0  0  0  0  0\n",
316            x, y, 0.0_f64,
317            sym,
318            charge_code,
319        ));
320    }
321
322    // Bond block
323    for (_idx, bond) in mol.bonds() {
324        let a1 = bond.atom1.0 + 1; // convert to 1-based
325        let a2 = bond.atom2.0 + 1;
326        let btype = match bond.order {
327            BondOrder::Aromatic => 4,
328            _ => bond.order.order_int(),
329        };
330        out.push_str(&format!(
331            "{:>3}{:>3}{:>3}  0\n",
332            a1, a2, btype
333        ));
334    }
335
336    // Terminator
337    out.push_str("M  END\n");
338
339    out
340}
341
342// ---------------------------------------------------------------------------
343// SDF writer
344// ---------------------------------------------------------------------------
345
346/// Serialise one or more molecules to SDF format.
347///
348/// `records` — slice of `(molecule, metadata, coords)` tuples.
349/// `coords` is optional; pass an empty slice to write zero coordinates.
350/// Each molecule block is terminated with `$$$$`.
351pub fn write_sdf(records: &[(&Molecule, &MolMetadata, &[(f64, f64)])]) -> String {
352    let mut out = String::new();
353    for (mol, meta, coords) in records {
354        out.push_str(&write_mol_with_coords(mol, meta, coords));
355        out.push_str("$$$$\n");
356    }
357    out
358}
359
360// ---------------------------------------------------------------------------
361// Tests
362// ---------------------------------------------------------------------------
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    /// Minimal ethanol MOL V2000 block (CCO, 3 atoms, 2 bonds).
369    const ETHANOL_MOL: &str = "\
370ethanol
371  chematic
372
373  3  2  0  0  0  0  0  0  0  0  0 V2000
374    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
375    1.5000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
376    3.0000    0.0000    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
377  1  2  1  0
378  2  3  1  0
379M  END
380";
381
382    #[test]
383    fn test_parse_ethanol_counts() {
384        let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse should succeed");
385        assert_eq!(mol.atom_count(), 3);
386        assert_eq!(mol.bond_count(), 2);
387        assert_eq!(meta.name, "ethanol");
388    }
389
390    #[test]
391    fn test_parse_elements() {
392        let (mol, _) = parse_mol(ETHANOL_MOL).expect("parse should succeed");
393        let atoms: Vec<_> = mol.atoms().collect();
394        assert_eq!(atoms[0].1.element, Element::C);
395        assert_eq!(atoms[1].1.element, Element::C);
396        assert_eq!(atoms[2].1.element, Element::O);
397    }
398
399    #[test]
400    fn test_parse_bond_types() {
401        // Two carbons: single, double, triple, aromatic bonds.
402        let mol_str = "\
403test
404  chematic
405
406  8  4  0  0  0  0  0  0  0  0  0 V2000
407    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
408    1.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
409    2.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
410    3.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
411    4.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
412    5.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
413    6.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
414    7.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
415  1  2  1  0
416  3  4  2  0
417  5  6  3  0
418  7  8  4  0
419M  END
420";
421        let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
422        let bonds: Vec<_> = mol.bonds().collect();
423        assert_eq!(bonds[0].1.order, BondOrder::Single);
424        assert_eq!(bonds[1].1.order, BondOrder::Double);
425        assert_eq!(bonds[2].1.order, BondOrder::Triple);
426        assert_eq!(bonds[3].1.order, BondOrder::Aromatic);
427    }
428
429    #[test]
430    fn test_parse_charge() {
431        // Nitrogen with charge code 3 (+1 formal charge).
432        let mol_str = "\
433charged
434  chematic
435
436  1  0  0  0  0  0  0  0  0  0  0 V2000
437    0.0000    0.0000    0.0000 N   0  3  0  0  0  0  0  0  0  0  0  0
438M  END
439";
440        let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
441        assert_eq!(mol.atom(AtomIdx(0)).charge, 1);
442    }
443
444    #[test]
445    fn test_parse_negative_charge() {
446        // Oxygen with charge code 5 (-1 formal charge).
447        let mol_str = "\
448negcharge
449  chematic
450
451  1  0  0  0  0  0  0  0  0  0  0 V2000
452    0.0000    0.0000    0.0000 O   0  5  0  0  0  0  0  0  0  0  0  0
453M  END
454";
455        let (mol, _) = parse_mol(mol_str).expect("parse should succeed");
456        assert_eq!(mol.atom(AtomIdx(0)).charge, -1);
457    }
458
459    #[test]
460    fn test_round_trip() {
461        // Parse → write → parse again; atom and bond counts must match.
462        let (mol1, meta1) = parse_mol(ETHANOL_MOL).expect("first parse");
463        let written = write_mol(&mol1, &meta1);
464        let (mol2, _meta2) = parse_mol(&written).expect("second parse");
465        assert_eq!(mol1.atom_count(), mol2.atom_count());
466        assert_eq!(mol1.bond_count(), mol2.bond_count());
467    }
468
469    #[test]
470    fn test_round_trip_elements_preserved() {
471        let (mol1, meta1) = parse_mol(ETHANOL_MOL).expect("first parse");
472        let written = write_mol(&mol1, &meta1);
473        let (mol2, _) = parse_mol(&written).expect("second parse");
474        for ((_, a1), (_, a2)) in mol1.atoms().zip(mol2.atoms()) {
475            assert_eq!(a1.element, a2.element);
476        }
477    }
478
479    #[test]
480    fn test_error_missing_v2000() {
481        let bad = "\
482bad
483  prog
484
485  3  2  0  0  0  0  0  0  0  0  0 V3000
486    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
487M  END
488";
489        assert!(matches!(
490            parse_mol(bad),
491            Err(MolParseError::InvalidCountLine { .. })
492        ));
493    }
494
495    #[test]
496    fn test_error_truncated_input() {
497        // Counts line says 3 atoms but only 1 is provided.
498        let bad = "\
499trunc
500  prog
501
502  3  0  0  0  0  0  0  0  0  0  0 V2000
503    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
504";
505        assert!(matches!(parse_mol(bad), Err(MolParseError::UnexpectedEnd)));
506    }
507
508    #[test]
509    fn test_error_invalid_counts_line() {
510        // Counts line too short (no V2000 tag at all).
511        let bad = "\
512mol
513  prog
514
515  X  Y
516M  END
517";
518        assert!(matches!(
519            parse_mol(bad),
520            Err(MolParseError::InvalidCountLine { .. })
521        ));
522    }
523
524    #[test]
525    fn test_write_contains_m_end() {
526        let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
527        let written = write_mol(&mol, &meta);
528        assert!(written.contains("M  END"));
529    }
530
531    #[test]
532    fn test_write_contains_v2000() {
533        let (mol, meta) = parse_mol(ETHANOL_MOL).expect("parse");
534        let written = write_mol(&mol, &meta);
535        assert!(written.contains("V2000"));
536    }
537
538    #[test]
539    fn test_parse_stereo_up_bond() {
540        // MOL V2000 with a stereo=1 (Up) bond
541        let mol_str = "\n\n\n  2  1  0  0  0  0            999 V2000\n    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0\n    1.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0\n  1  2  1  1  0  0  0\nM  END\n";
542        let (mol, _) = crate::parse_mol(mol_str).unwrap();
543        let bond = mol.bond(chematic_core::BondIdx(0));
544        assert_eq!(bond.order, chematic_core::BondOrder::Up);
545    }
546
547    #[test]
548    fn test_parse_stereo_down_bond() {
549        let mol_str = "\n\n\n  2  1  0  0  0  0            999 V2000\n    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0\n    1.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0\n  1  2  1  6  0  0  0\nM  END\n";
550        let (mol, _) = crate::parse_mol(mol_str).unwrap();
551        let bond = mol.bond(chematic_core::BondIdx(0));
552        assert_eq!(bond.order, chematic_core::BondOrder::Down);
553    }
554}