Skip to main content

chematic_mol/
sdf.rs

1//! SDF (Structure-Data File) reader.
2//!
3//! An SDF file contains one or more MOL V2000 blocks separated by `$$$$`
4//! delimiter lines.  Data-field sections between `M  END` and `$$$$` are
5//! accepted but ignored.
6
7use chematic_core::Molecule;
8
9use crate::error::MolParseError;
10use crate::mol2000::{MolMetadata, parse_mol, parse_mol_with_coords};
11
12/// Iterator over molecules in an SDF string.
13///
14/// Each call to `next()` returns the next `(Molecule, MolMetadata)` pair
15/// parsed from the string, or the first `MolParseError` encountered.
16/// Returns `None` when the entire input has been consumed.
17pub struct SdfReader<'a> {
18    remaining: &'a str,
19    current_mol_num: usize,
20}
21
22impl<'a> SdfReader<'a> {
23    /// Create a new `SdfReader` over the given SDF string.
24    pub fn new(input: &'a str) -> Self {
25        Self {
26            remaining: input,
27            current_mol_num: 0,
28        }
29    }
30}
31
32impl<'a> Iterator for SdfReader<'a> {
33    type Item = Result<(Molecule, MolMetadata), MolParseError>;
34
35    fn next(&mut self) -> Option<Self::Item> {
36        // Skip leading blank lines between records (defensive; well-formed SDF
37        // should not have them, but some writers emit a trailing blank).
38        while let Some(rest) = self
39            .remaining
40            .strip_prefix("\r\n")
41            .or_else(|| self.remaining.strip_prefix('\n'))
42        {
43            self.remaining = rest;
44        }
45
46        if self.remaining.is_empty() {
47            return None;
48        }
49
50        self.current_mol_num += 1;
51
52        // Scan line by line so that a `$$$$` substring inside a data value
53        // does not trigger a false match.  When the delimiter is found, the
54        // mol block runs up to (but excluding) it, and the rest continues
55        // after the delimiter line.  When EOF is reached without a delimiter,
56        // the entire remainder is treated as a single mol block.
57        let mut byte_offset = 0usize;
58        let (end_byte, after_delim) = loop {
59            let rest = &self.remaining[byte_offset..];
60            match rest.find('\n') {
61                Some(nl) => {
62                    let line = rest[..nl].trim_end_matches('\r');
63                    if line == "$$$$" {
64                        break (byte_offset, &self.remaining[byte_offset + nl + 1..]);
65                    }
66                    byte_offset += nl + 1;
67                }
68                None => {
69                    // Last line, no trailing newline.
70                    if rest.trim_end_matches('\r') == "$$$$" {
71                        break (byte_offset, "");
72                    }
73                    break (self.remaining.len(), "");
74                }
75            }
76        };
77
78        let mol_block = &self.remaining[..end_byte];
79        self.remaining = after_delim;
80
81        if mol_block.trim().is_empty() {
82            // Empty block between two `$$$$` lines — skip and try next.
83            return self.next();
84        }
85
86        Some(parse_mol(mol_block))
87    }
88}
89
90/// Parse all molecules from an SDF string.
91///
92/// Stops and returns an error on the first parse failure.
93pub fn parse_sdf(input: &str) -> Result<Vec<(Molecule, MolMetadata)>, MolParseError> {
94    SdfReader::new(input).collect()
95}
96
97// ---------------------------------------------------------------------------
98// SdfRecord — molecule + SD data fields
99// ---------------------------------------------------------------------------
100
101/// A fully-parsed SDF record: molecule, metadata, 2D coordinates, and SD properties.
102///
103/// `SdfRecordReader` yields one `SdfRecord` per molecule in an SDF string.
104/// The `properties` map corresponds to `> <FieldName>` data blocks.
105pub struct SdfRecord {
106    /// Parsed molecule (heavy atoms only, no explicit H).
107    pub mol: Molecule,
108    /// Metadata from the three-line MOL header (name, comment).
109    pub meta: MolMetadata,
110    /// 2D atom coordinates in Å, indexed by atom position.
111    /// Empty when the MOL block contains no coordinate data.
112    pub coords: Vec<(f64, f64)>,
113    /// SD data fields.  Keys are field names; values are field content.
114    /// Multi-line values are joined with `\n`.
115    pub properties: std::collections::HashMap<String, String>,
116}
117
118/// Iterator over SDF records that also captures SD data fields.
119///
120/// Unlike [`SdfReader`], this iterator yields [`SdfRecord`] values so that
121/// callers can access per-molecule properties (e.g. activity values, MW, etc.).
122pub struct SdfRecordReader<'a> {
123    remaining: &'a str,
124}
125
126impl<'a> SdfRecordReader<'a> {
127    /// Create a new reader over the given SDF string.
128    pub fn new(input: &'a str) -> Self {
129        Self { remaining: input }
130    }
131}
132
133impl<'a> Iterator for SdfRecordReader<'a> {
134    type Item = Result<SdfRecord, MolParseError>;
135
136    fn next(&mut self) -> Option<Self::Item> {
137        // Skip leading blank lines.
138        while let Some(rest) = self
139            .remaining
140            .strip_prefix("\r\n")
141            .or_else(|| self.remaining.strip_prefix('\n'))
142        {
143            self.remaining = rest;
144        }
145
146        if self.remaining.is_empty() {
147            return None;
148        }
149
150        // Scan to find the $$$$ delimiter (line-by-line to avoid false matches).
151        let mut byte_offset = 0usize;
152        let (end_byte, after_delim) = loop {
153            let rest = &self.remaining[byte_offset..];
154            match rest.find('\n') {
155                Some(nl) => {
156                    let line = rest[..nl].trim_end_matches('\r');
157                    if line == "$$$$" {
158                        break (byte_offset, &self.remaining[byte_offset + nl + 1..]);
159                    }
160                    byte_offset += nl + 1;
161                }
162                None => {
163                    if rest.trim_end_matches('\r') == "$$$$" {
164                        break (byte_offset, "");
165                    }
166                    break (self.remaining.len(), "");
167                }
168            }
169        };
170
171        let block = &self.remaining[..end_byte];
172        self.remaining = after_delim;
173
174        if block.trim().is_empty() {
175            return self.next();
176        }
177
178        // Parse molecule + 2D coordinates.
179        let (mol, meta, coords) = match parse_mol_with_coords(block) {
180            Ok(triple) => triple,
181            Err(e) => return Some(Err(e)),
182        };
183
184        // Extract data fields from the part after "M  END".
185        let data_part = block
186            .find("M  END")
187            .map(|pos| &block[pos + 6..])
188            .unwrap_or("");
189        let properties: std::collections::HashMap<String, String> =
190            parse_sd_fields(data_part).into_iter().collect();
191
192        Some(Ok(SdfRecord {
193            mol,
194            meta,
195            coords,
196            properties,
197        }))
198    }
199}
200
201/// Parse SD data fields from the section after `M  END`.
202///
203/// Each field starts with `> <FieldName>` on its own line.  The value is
204/// everything on subsequent lines until a blank line (or end of input).
205fn parse_sd_fields(data: &str) -> Vec<(String, String)> {
206    let mut fields = Vec::new();
207    let mut current_key: Option<String> = None;
208    let mut current_value_lines: Vec<&str> = Vec::new();
209
210    for raw_line in data.lines() {
211        let line = raw_line.trim_end_matches('\r');
212
213        if let Some(key) = parse_sd_field_header(line) {
214            // Flush previous field.
215            if let Some(k) = current_key.take() {
216                fields.push((k, current_value_lines.join("\n")));
217                current_value_lines.clear();
218            }
219            current_key = Some(key);
220        } else if line.is_empty() {
221            // Blank line ends the current field's value.
222            if let Some(k) = current_key.take() {
223                fields.push((k, current_value_lines.join("\n")));
224                current_value_lines.clear();
225            }
226        } else if current_key.is_some() {
227            current_value_lines.push(line);
228        }
229    }
230    // Flush trailing field with no blank line.
231    if let Some(k) = current_key {
232        fields.push((k, current_value_lines.join("\n")));
233    }
234
235    fields
236}
237
238/// Parse `> <FieldName>` header lines, returning the field name or `None`.
239fn parse_sd_field_header(line: &str) -> Option<String> {
240    // SDF spec: field headers start with "> " and contain the name in `<...>`.
241    // We accept "> <Name>" and also ">  <Name>" (extra spaces).
242    let rest = line.strip_prefix('>')?;
243    let rest = rest.trim();
244    let inner = rest.strip_prefix('<')?.strip_suffix('>')?;
245    Some(inner.to_string())
246}
247
248// ---------------------------------------------------------------------------
249// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    const MOL_A: &str = "\
257mol_a
258  chematic
259
260  2  1  0  0  0  0  0  0  0  0  0 V2000
261    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
262    1.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
263  1  2  1  0
264M  END
265";
266
267    const MOL_B: &str = "\
268mol_b
269  chematic
270
271  3  2  0  0  0  0  0  0  0  0  0 V2000
272    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
273    1.0000    0.0000    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
274    2.0000    0.0000    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
275  1  2  1  0
276  2  3  2  0
277M  END
278";
279
280    fn two_mol_sdf() -> String {
281        format!("{MOL_A}$$$$\n{MOL_B}$$$$\n")
282    }
283
284    #[test]
285    fn test_sdf_reader_two_molecules() {
286        let sdf = two_mol_sdf();
287        let results: Vec<_> = SdfReader::new(&sdf).collect();
288        assert_eq!(results.len(), 2);
289        let (mol_a, meta_a) = results[0].as_ref().expect("mol_a parse");
290        let (mol_b, meta_b) = results[1].as_ref().expect("mol_b parse");
291        assert_eq!(mol_a.atom_count(), 2);
292        assert_eq!(mol_a.bond_count(), 1);
293        assert_eq!(meta_a.name, "mol_a");
294        assert_eq!(mol_b.atom_count(), 3);
295        assert_eq!(mol_b.bond_count(), 2);
296        assert_eq!(meta_b.name, "mol_b");
297    }
298
299    #[test]
300    fn test_parse_sdf_all() {
301        let sdf = two_mol_sdf();
302        let mols = parse_sdf(&sdf).expect("parse_sdf");
303        assert_eq!(mols.len(), 2);
304    }
305
306    #[test]
307    fn test_sdf_reader_single_molecule_no_delimiter() {
308        // An SDF with a single molecule that has no trailing $$$$ is still valid.
309        let results: Vec<_> = SdfReader::new(MOL_A).collect();
310        assert_eq!(results.len(), 1);
311        let (mol, _) = results[0].as_ref().expect("parse");
312        assert_eq!(mol.atom_count(), 2);
313    }
314
315    #[test]
316    fn test_sdf_reader_stops_on_error() {
317        // Second molecule has a bad counts line; parse_sdf should return Err.
318        let bad_sdf = format!("{MOL_A}$$$$\nbad\n  prog\n\n  X  Y\nM  END\n$$$$\n");
319        let result = parse_sdf(&bad_sdf);
320        assert!(result.is_err());
321    }
322
323    #[test]
324    fn test_sdf_reader_empty_input() {
325        let results: Vec<_> = SdfReader::new("").collect();
326        assert_eq!(results.len(), 0);
327    }
328
329    #[test]
330    fn test_sdf_reader_names_preserved() {
331        let sdf = two_mol_sdf();
332        let mols = parse_sdf(&sdf).expect("parse");
333        assert_eq!(mols[0].1.name, "mol_a");
334        assert_eq!(mols[1].1.name, "mol_b");
335    }
336
337    #[test]
338    fn test_sdf_with_data_fields() {
339        // SDF with data fields between M  END and $$$$ — should be ignored.
340        let sdf_with_data = format!("{MOL_A}> <MW>\n44.0\n\n$$$$\n");
341        let results: Vec<_> = SdfReader::new(&sdf_with_data).collect();
342        assert_eq!(results.len(), 1);
343        let (mol, _) = results[0].as_ref().expect("parse");
344        assert_eq!(mol.atom_count(), 2);
345    }
346
347    #[test]
348    fn test_sdf_reader_reports_truncated_large_count_record() {
349        let bad_sdf = "\
350max_atoms
351  chematic
352
353999  0  0  0  0  0  0  0  0  0  0 V2000
354$$$$
355";
356        let mut reader = SdfReader::new(bad_sdf);
357        assert!(matches!(
358            reader.next(),
359            Some(Err(MolParseError::UnexpectedEnd))
360        ));
361        assert!(reader.next().is_none());
362        assert!(matches!(
363            parse_sdf(bad_sdf),
364            Err(MolParseError::UnexpectedEnd)
365        ));
366    }
367}