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};
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 parsed SDF record including the molecule, its name, and SD data fields.
102pub struct SdfRecord {
103    /// Parsed molecule.
104    pub mol: Molecule,
105    /// Molecule name from MOL header line 1.
106    pub name: String,
107    /// SD data fields in file order.  Each entry is `(field_name, value)`.
108    /// Multi-line values are joined with `\n`.
109    pub properties: Vec<(String, String)>,
110}
111
112/// Iterator over SDF records that also captures SD data fields.
113///
114/// Unlike [`SdfReader`], this iterator yields [`SdfRecord`] values so that
115/// callers can access per-molecule properties (e.g. activity values, MW, etc.).
116pub struct SdfRecordReader<'a> {
117    remaining: &'a str,
118}
119
120impl<'a> SdfRecordReader<'a> {
121    /// Create a new reader over the given SDF string.
122    pub fn new(input: &'a str) -> Self {
123        Self { remaining: input }
124    }
125}
126
127impl<'a> Iterator for SdfRecordReader<'a> {
128    type Item = Result<SdfRecord, MolParseError>;
129
130    fn next(&mut self) -> Option<Self::Item> {
131        // Skip leading blank lines.
132        while let Some(rest) = self
133            .remaining
134            .strip_prefix("\r\n")
135            .or_else(|| self.remaining.strip_prefix('\n'))
136        {
137            self.remaining = rest;
138        }
139
140        if self.remaining.is_empty() {
141            return None;
142        }
143
144        // Scan to find the $$$$ delimiter (line-by-line to avoid false matches).
145        let mut byte_offset = 0usize;
146        let (end_byte, after_delim) = loop {
147            let rest = &self.remaining[byte_offset..];
148            match rest.find('\n') {
149                Some(nl) => {
150                    let line = rest[..nl].trim_end_matches('\r');
151                    if line == "$$$$" {
152                        break (byte_offset, &self.remaining[byte_offset + nl + 1..]);
153                    }
154                    byte_offset += nl + 1;
155                }
156                None => {
157                    if rest.trim_end_matches('\r') == "$$$$" {
158                        break (byte_offset, "");
159                    }
160                    break (self.remaining.len(), "");
161                }
162            }
163        };
164
165        let block = &self.remaining[..end_byte];
166        self.remaining = after_delim;
167
168        if block.trim().is_empty() {
169            return self.next();
170        }
171
172        // Pass the full block (including any data fields) to the V2000 parser,
173        // matching the behaviour of SdfReader — parse_mol ignores content after
174        // the "M  END" line.
175        let (mol, meta) = match parse_mol(block) {
176            Ok(pair) => pair,
177            Err(e) => return Some(Err(e)),
178        };
179
180        // Extract data fields from the part after "M  END".
181        let data_part = block
182            .find("M  END")
183            .map(|pos| &block[pos + 6..]) // 6 == len("M  END")
184            .unwrap_or("");
185        let properties = parse_sd_fields(data_part);
186
187        Some(Ok(SdfRecord { mol, name: meta.name, properties }))
188    }
189}
190
191/// Parse SD data fields from the section after `M  END`.
192///
193/// Each field starts with `> <FieldName>` on its own line.  The value is
194/// everything on subsequent lines until a blank line (or end of input).
195fn parse_sd_fields(data: &str) -> Vec<(String, String)> {
196    let mut fields = Vec::new();
197    let mut current_key: Option<String> = None;
198    let mut current_value_lines: Vec<&str> = Vec::new();
199
200    for raw_line in data.lines() {
201        let line = raw_line.trim_end_matches('\r');
202
203        if let Some(key) = parse_sd_field_header(line) {
204            // Flush previous field.
205            if let Some(k) = current_key.take() {
206                fields.push((k, current_value_lines.join("\n")));
207                current_value_lines.clear();
208            }
209            current_key = Some(key);
210        } else if line.is_empty() {
211            // Blank line ends the current field's value.
212            if let Some(k) = current_key.take() {
213                fields.push((k, current_value_lines.join("\n")));
214                current_value_lines.clear();
215            }
216        } else if current_key.is_some() {
217            current_value_lines.push(line);
218        }
219    }
220    // Flush trailing field with no blank line.
221    if let Some(k) = current_key {
222        fields.push((k, current_value_lines.join("\n")));
223    }
224
225    fields
226}
227
228/// Parse `> <FieldName>` header lines, returning the field name or `None`.
229fn parse_sd_field_header(line: &str) -> Option<String> {
230    // SDF spec: field headers start with "> " and contain the name in `<...>`.
231    // We accept "> <Name>" and also ">  <Name>" (extra spaces).
232    let rest = line.strip_prefix('>')?;
233    let rest = rest.trim();
234    let inner = rest.strip_prefix('<')?.strip_suffix('>')?;
235    Some(inner.to_string())
236}
237
238// ---------------------------------------------------------------------------
239// Tests
240// ---------------------------------------------------------------------------
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    const MOL_A: &str = "\
247mol_a
248  chematic
249
250  2  1  0  0  0  0  0  0  0  0  0 V2000
251    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
252    1.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
253  1  2  1  0
254M  END
255";
256
257    const MOL_B: &str = "\
258mol_b
259  chematic
260
261  3  2  0  0  0  0  0  0  0  0  0 V2000
262    0.0000    0.0000    0.0000 C   0  0  0  0  0  0  0  0  0  0  0  0
263    1.0000    0.0000    0.0000 N   0  0  0  0  0  0  0  0  0  0  0  0
264    2.0000    0.0000    0.0000 O   0  0  0  0  0  0  0  0  0  0  0  0
265  1  2  1  0
266  2  3  2  0
267M  END
268";
269
270    fn two_mol_sdf() -> String {
271        format!("{MOL_A}$$$$\n{MOL_B}$$$$\n")
272    }
273
274    #[test]
275    fn test_sdf_reader_two_molecules() {
276        let sdf = two_mol_sdf();
277        let results: Vec<_> = SdfReader::new(&sdf).collect();
278        assert_eq!(results.len(), 2);
279        let (mol_a, meta_a) = results[0].as_ref().expect("mol_a parse");
280        let (mol_b, meta_b) = results[1].as_ref().expect("mol_b parse");
281        assert_eq!(mol_a.atom_count(), 2);
282        assert_eq!(mol_a.bond_count(), 1);
283        assert_eq!(meta_a.name, "mol_a");
284        assert_eq!(mol_b.atom_count(), 3);
285        assert_eq!(mol_b.bond_count(), 2);
286        assert_eq!(meta_b.name, "mol_b");
287    }
288
289    #[test]
290    fn test_parse_sdf_all() {
291        let sdf = two_mol_sdf();
292        let mols = parse_sdf(&sdf).expect("parse_sdf");
293        assert_eq!(mols.len(), 2);
294    }
295
296    #[test]
297    fn test_sdf_reader_single_molecule_no_delimiter() {
298        // An SDF with a single molecule that has no trailing $$$$ is still valid.
299        let results: Vec<_> = SdfReader::new(MOL_A).collect();
300        assert_eq!(results.len(), 1);
301        let (mol, _) = results[0].as_ref().expect("parse");
302        assert_eq!(mol.atom_count(), 2);
303    }
304
305    #[test]
306    fn test_sdf_reader_stops_on_error() {
307        // Second molecule has a bad counts line; parse_sdf should return Err.
308        let bad_sdf = format!("{MOL_A}$$$$\nbad\n  prog\n\n  X  Y\nM  END\n$$$$\n");
309        let result = parse_sdf(&bad_sdf);
310        assert!(result.is_err());
311    }
312
313    #[test]
314    fn test_sdf_reader_empty_input() {
315        let results: Vec<_> = SdfReader::new("").collect();
316        assert_eq!(results.len(), 0);
317    }
318
319    #[test]
320    fn test_sdf_reader_names_preserved() {
321        let sdf = two_mol_sdf();
322        let mols = parse_sdf(&sdf).expect("parse");
323        assert_eq!(mols[0].1.name, "mol_a");
324        assert_eq!(mols[1].1.name, "mol_b");
325    }
326
327    #[test]
328    fn test_sdf_with_data_fields() {
329        // SDF with data fields between M  END and $$$$ — should be ignored.
330        let sdf_with_data = format!(
331            "{MOL_A}> <MW>\n44.0\n\n$$$$\n"
332        );
333        let results: Vec<_> = SdfReader::new(&sdf_with_data).collect();
334        assert_eq!(results.len(), 1);
335        let (mol, _) = results[0].as_ref().expect("parse");
336        assert_eq!(mol.atom_count(), 2);
337    }
338}