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// SdfFileReader — streaming reader for file-backed SDF
250// ---------------------------------------------------------------------------
251
252/// Streaming SDF iterator over any [`std::io::BufRead`] source.
253///
254/// Unlike [`SdfRecordReader`] (which requires the entire file in memory as a
255/// `&str`), `SdfFileReader` reads one MOL block at a time, suitable for large
256/// SDF files.  IO errors are surfaced as [`MolParseError::Io`]; molecule parse
257/// errors are returned as `Err` items so the caller can decide to skip or stop.
258pub struct SdfFileReader<R: std::io::BufRead> {
259    reader: R,
260    done: bool,
261}
262
263impl<R: std::io::BufRead> SdfFileReader<R> {
264    /// Wrap any `BufRead` source (e.g. `BufReader<File>`).
265    pub fn new(reader: R) -> Self {
266        Self {
267            reader,
268            done: false,
269        }
270    }
271}
272
273impl<R: std::io::BufRead> Iterator for SdfFileReader<R> {
274    type Item = Result<SdfRecord, MolParseError>;
275
276    fn next(&mut self) -> Option<Self::Item> {
277        if self.done {
278            return None;
279        }
280
281        let mut block = String::with_capacity(2048);
282        let mut line = String::new();
283
284        loop {
285            line.clear();
286            match self.reader.read_line(&mut line) {
287                Err(e) => {
288                    self.done = true;
289                    return Some(Err(MolParseError::Io(e.to_string())));
290                }
291                Ok(0) => {
292                    // EOF
293                    self.done = true;
294                    if block.trim().is_empty() {
295                        return None;
296                    }
297                    // Trailing block without $$$$ delimiter — parse it.
298                    break;
299                }
300                Ok(_) => {
301                    let trimmed = line.trim_end_matches(['\r', '\n']);
302                    if trimmed == "$$$$" {
303                        break;
304                    }
305                    block.push_str(&line);
306                }
307            }
308        }
309
310        if block.trim().is_empty() {
311            // Empty block (e.g. two consecutive $$$$) — advance to next.
312            return self.next();
313        }
314
315        // Reuse the same parse path as SdfRecordReader.
316        let (mol, meta, coords) = match parse_mol_with_coords(&block) {
317            Ok(triple) => triple,
318            Err(e) => return Some(Err(e)),
319        };
320
321        let data_part = block
322            .find("M  END")
323            .map(|pos| &block[pos + 6..])
324            .unwrap_or("");
325        let properties: std::collections::HashMap<String, String> =
326            parse_sd_fields(data_part).into_iter().collect();
327
328        Some(Ok(SdfRecord {
329            mol,
330            meta,
331            coords,
332            properties,
333        }))
334    }
335}
336
337// ---------------------------------------------------------------------------
338// Tests
339// ---------------------------------------------------------------------------
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    const MOL_A: &str = "\
346mol_a
347  chematic
348
349  2  1  0  0  0  0  0  0  0  0  0 V2000
350    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
351    1.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
352  1  2  1  0
353M  END
354";
355
356    const MOL_B: &str = "\
357mol_b
358  chematic
359
360  3  2  0  0  0  0  0  0  0  0  0 V2000
361    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
362    1.0000    0.0000    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
363    2.0000    0.0000    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
364  1  2  1  0
365  2  3  2  0
366M  END
367";
368
369    fn two_mol_sdf() -> String {
370        format!("{MOL_A}$$$$\n{MOL_B}$$$$\n")
371    }
372
373    #[test]
374    fn test_sdf_reader_two_molecules() {
375        let sdf = two_mol_sdf();
376        let results: Vec<_> = SdfReader::new(&sdf).collect();
377        assert_eq!(results.len(), 2);
378        let (mol_a, meta_a) = results[0].as_ref().expect("mol_a parse");
379        let (mol_b, meta_b) = results[1].as_ref().expect("mol_b parse");
380        assert_eq!(mol_a.atom_count(), 2);
381        assert_eq!(mol_a.bond_count(), 1);
382        assert_eq!(meta_a.name, "mol_a");
383        assert_eq!(mol_b.atom_count(), 3);
384        assert_eq!(mol_b.bond_count(), 2);
385        assert_eq!(meta_b.name, "mol_b");
386    }
387
388    #[test]
389    fn test_parse_sdf_all() {
390        let sdf = two_mol_sdf();
391        let mols = parse_sdf(&sdf).expect("parse_sdf");
392        assert_eq!(mols.len(), 2);
393    }
394
395    #[test]
396    fn test_sdf_reader_single_molecule_no_delimiter() {
397        // An SDF with a single molecule that has no trailing $$$$ is still valid.
398        let results: Vec<_> = SdfReader::new(MOL_A).collect();
399        assert_eq!(results.len(), 1);
400        let (mol, _) = results[0].as_ref().expect("parse");
401        assert_eq!(mol.atom_count(), 2);
402    }
403
404    #[test]
405    fn test_sdf_reader_stops_on_error() {
406        // Second molecule has a bad counts line; parse_sdf should return Err.
407        let bad_sdf = format!("{MOL_A}$$$$\nbad\n  prog\n\n  X  Y\nM  END\n$$$$\n");
408        let result = parse_sdf(&bad_sdf);
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn test_sdf_reader_empty_input() {
414        let results: Vec<_> = SdfReader::new("").collect();
415        assert_eq!(results.len(), 0);
416    }
417
418    #[test]
419    fn test_sdf_reader_names_preserved() {
420        let sdf = two_mol_sdf();
421        let mols = parse_sdf(&sdf).expect("parse");
422        assert_eq!(mols[0].1.name, "mol_a");
423        assert_eq!(mols[1].1.name, "mol_b");
424    }
425
426    #[test]
427    fn test_sdf_with_data_fields() {
428        // SDF with data fields between M  END and $$$$ — should be ignored.
429        let sdf_with_data = format!("{MOL_A}> <MW>\n44.0\n\n$$$$\n");
430        let results: Vec<_> = SdfReader::new(&sdf_with_data).collect();
431        assert_eq!(results.len(), 1);
432        let (mol, _) = results[0].as_ref().expect("parse");
433        assert_eq!(mol.atom_count(), 2);
434    }
435
436    #[test]
437    fn test_sdf_reader_reports_truncated_large_count_record() {
438        let bad_sdf = "\
439max_atoms
440  chematic
441
442999  0  0  0  0  0  0  0  0  0  0 V2000
443$$$$
444";
445        let mut reader = SdfReader::new(bad_sdf);
446        assert!(matches!(
447            reader.next(),
448            Some(Err(MolParseError::UnexpectedEnd))
449        ));
450        assert!(reader.next().is_none());
451        assert!(matches!(
452            parse_sdf(bad_sdf),
453            Err(MolParseError::UnexpectedEnd)
454        ));
455    }
456
457    #[test]
458    fn test_sdf_file_reader_streaming() {
459        // Use Cursor<Vec<u8>> as an in-memory BufRead to avoid touching the filesystem.
460        use std::io::{BufReader, Cursor};
461        let sdf = two_mol_sdf();
462        let cursor = Cursor::new(sdf.into_bytes());
463        let records: Vec<_> = SdfFileReader::new(BufReader::new(cursor))
464            .filter_map(|r| r.ok())
465            .collect();
466        assert_eq!(records.len(), 2);
467        assert_eq!(records[0].mol.atom_count(), 2); // mol_a: 2 C atoms
468        assert_eq!(records[1].mol.atom_count(), 3); // mol_b: C, N, O
469    }
470
471    #[test]
472    fn test_sdf_file_reader_skips_empty_block() {
473        use std::io::{BufReader, Cursor};
474        let sdf = format!("$$$$\n{MOL_A}$$$$\n");
475        let cursor = Cursor::new(sdf.into_bytes());
476        let records: Vec<_> = SdfFileReader::new(BufReader::new(cursor))
477            .filter_map(|r| r.ok())
478            .collect();
479        assert_eq!(records.len(), 1);
480    }
481
482    #[test]
483    fn test_sdf_file_reader_malformed_record_yields_err() {
484        use std::io::{BufReader, Cursor};
485
486        // 3-record SDF: valid, malformed (bad atom count line), valid
487        let malformed = "broken\n  prog\n\n  NOTNUM  0  0 V2000\nM  END\n";
488        let sdf = format!("{MOL_A}$$$$\n{malformed}$$$$\n{MOL_B}$$$$\n");
489        let cursor = Cursor::new(sdf.into_bytes());
490        let results: Vec<_> = SdfFileReader::new(BufReader::new(cursor)).collect();
491
492        // Three items: Ok, Err, Ok
493        assert_eq!(results.len(), 3);
494        assert!(results[0].is_ok(), "first record should be ok");
495        assert!(
496            results[1].is_err(),
497            "second record should be err (malformed)"
498        );
499        assert!(results[2].is_ok(), "third record should be ok");
500    }
501}