Skip to main content

haddock_restraints/core/
tbl_parser.rs

1//! Parses HADDOCK `.tbl` AIR restraint files (the *inverse* of
2//! `Interactor::create_block`, see `src/core/interactor.rs`).
3//!
4//! Only `resid <N> ... segid <chain>` selections are extracted from each
5//! `assign` block — that's all pml rendering (`tbl2pml`) needs. Extra
6//! clauses like `and name CA` or `and attr z gt 42.00` are ignored.
7
8/// A single residue selection extracted from a `.tbl` restraint block.
9#[derive(Debug, Clone, PartialEq)]
10pub struct ResSelection {
11    pub resid: i16,
12    pub chain: String,
13}
14
15/// One `assign` block: one active residue restrained against a list of
16/// (possibly OR'd) partner residues.
17///
18/// ```text
19/// assign ( resid 1 and segid A )
20///        (
21///         ( resid 2 and segid B )
22///      or
23///         ( resid 3 and segid B )
24///        ) 2.0 2.0 0.0
25/// ```
26/// parses into `active = {1, A}`, `partners = [{2, B}, {3, B}]`.
27#[derive(Debug, Clone, PartialEq)]
28pub struct ParsedRestraint {
29    pub active: ResSelection,
30    pub partners: Vec<ResSelection>,
31}
32
33/// Parses a HADDOCK `.tbl` restraints file into `ParsedRestraint`s.
34///
35/// `assign` blocks that don't resolve to any `resid`/`segid` selection
36/// (dihedral, hbond, and other non-AIR CNS restraint types share the same
37/// `assign` keyword but a different body) are skipped rather than treated
38/// as errors, so one such block doesn't discard every restraint already
39/// parsed from the rest of the file.
40///
41/// # Errors
42/// Returns `Err` if a `resid`/`segid` clause is present but malformed (a
43/// residue number that doesn't parse, or a `segid` with no chain after it),
44/// or if the file contains no usable restraint at all.
45pub fn parse_tbl(content: &str) -> Result<Vec<ParsedRestraint>, String> {
46    let cleaned = strip_comments(content);
47    let mut restraints = Vec::new();
48    let mut saw_assign_block = false;
49
50    for chunk in cleaned.split("assign").skip(1) {
51        saw_assign_block = true;
52
53        let mut selections = extract_selections(chunk)?.into_iter();
54
55        let Some(active) = selections.next() else {
56            continue;
57        };
58
59        restraints.push(ParsedRestraint {
60            active,
61            partners: selections.collect(),
62        });
63    }
64
65    if saw_assign_block && restraints.is_empty() {
66        return Err(
67            "no resid/segid restraints found in .tbl (only unsupported restraint types?)"
68                .to_string(),
69        );
70    }
71
72    Ok(restraints)
73}
74
75/// Strips `!`-prefixed CNS comments (rest-of-line) from tbl content.
76fn strip_comments(content: &str) -> String {
77    content
78        .lines()
79        .map(|line| line.split('!').next().unwrap_or(""))
80        .collect::<Vec<_>>()
81        .join("\n")
82}
83
84/// Splits `chunk` into whitespace-separated tokens, treating `(` and `)` as
85/// their own single-character tokens regardless of surrounding whitespace
86/// (so `(resid` and `A)` still tokenize as `(`, `resid` / `A`, `)`).
87fn tokenize(chunk: &str) -> Vec<&str> {
88    let mut tokens = Vec::new();
89    let mut chars = chunk.char_indices().peekable();
90
91    while let Some(&(start, c)) = chars.peek() {
92        if c.is_whitespace() {
93            chars.next();
94            continue;
95        }
96        if c == '(' || c == ')' {
97            tokens.push(&chunk[start..start + c.len_utf8()]);
98            chars.next();
99            continue;
100        }
101        let mut end = start + c.len_utf8();
102        chars.next();
103        while let Some(&(idx, c2)) = chars.peek() {
104            if c2.is_whitespace() || c2 == '(' || c2 == ')' {
105                break;
106            }
107            end = idx + c2.len_utf8();
108            chars.next();
109        }
110        tokens.push(&chunk[start..end]);
111    }
112
113    tokens
114}
115
116/// Extracts every residue selection found in `chunk`'s innermost `( ... )`
117/// groups — the paren-balanced units with no further parens nested inside
118/// them, which is exactly what a single `resid`/`segid` selection is (the
119/// OR-wrapping around a partner list is always one paren level up). Within
120/// each such group, `resid` and `segid` are located independently, so
121/// either order (`resid ... segid ...` or `segid ... resid ...`) and any
122/// interleaved clause (`and name CA`, `and attr ...`) works the same way.
123///
124/// Groups that don't contain both a `resid` and a `segid` (dihedral/hbond
125/// clauses, the outer OR-wrapper itself, ...) are skipped, not errored —
126/// they're just not residue selections. A `resid`/`segid` keyword that
127/// *is* present but missing its value is a genuine parse error, though.
128fn extract_selections(chunk: &str) -> Result<Vec<ResSelection>, String> {
129    let tokens = tokenize(chunk);
130
131    // Stack of (index just after this group's `(`, has a nested `(` inside).
132    let mut stack: Vec<(usize, bool)> = Vec::new();
133    let mut selections = Vec::new();
134
135    for (idx, &tok) in tokens.iter().enumerate() {
136        match tok {
137            "(" => {
138                if let Some(parent) = stack.last_mut() {
139                    parent.1 = true;
140                }
141                stack.push((idx + 1, false));
142            }
143            ")" => {
144                if let Some((start, has_nested)) = stack.pop()
145                    && !has_nested
146                    && let Some(selection) = parse_selection(&tokens[start..idx])?
147                {
148                    selections.push(selection);
149                }
150            }
151            _ => {}
152        }
153    }
154
155    Ok(selections)
156}
157
158/// Parses a single innermost selection's tokens (with the surrounding
159/// parens already stripped) into a `ResSelection`, if it has both a
160/// `resid` and a `segid` clause.
161fn parse_selection(tokens: &[&str]) -> Result<Option<ResSelection>, String> {
162    let mut resid = None;
163    let mut chain = None;
164
165    let mut i = 0;
166    while i < tokens.len() {
167        match tokens[i] {
168            "resid" => {
169                let value = tokens.get(i + 1).ok_or_else(|| {
170                    "\"resid\" keyword with no residue number after it".to_string()
171                })?;
172                resid = Some(
173                    value
174                        .parse::<i16>()
175                        .map_err(|_| format!("invalid residue number \"{}\"", value))?,
176                );
177                i += 1;
178            }
179            "segid" => {
180                let value = tokens.get(i + 1).ok_or_else(|| {
181                    "\"segid\" keyword with no chain identifier after it".to_string()
182                })?;
183                chain = Some((*value).to_string());
184                i += 1;
185            }
186            _ => {}
187        }
188        i += 1;
189    }
190
191    Ok(resid
192        .zip(chain)
193        .map(|(resid, chain)| ResSelection { resid, chain }))
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_parse_oneline() {
202        let tbl = "assign ( resid 1 and segid A ) ( resid 2 and segid B ) 2.0 2.0 0.0\n\n";
203        let restraints = parse_tbl(tbl).unwrap();
204
205        assert_eq!(
206            restraints,
207            vec![ParsedRestraint {
208                active: ResSelection {
209                    resid: 1,
210                    chain: "A".to_string()
211                },
212                partners: vec![ResSelection {
213                    resid: 2,
214                    chain: "B".to_string()
215                }],
216            }]
217        );
218    }
219
220    #[test]
221    fn test_parse_multiline_or() {
222        let tbl = "assign ( resid 1 and segid A )\n       (\n        ( resid 2 and segid B )\n     or\n        ( resid 3 and segid B )\n       ) 2.0 2.0 0.0\n\n";
223        let restraints = parse_tbl(tbl).unwrap();
224
225        assert_eq!(
226            restraints,
227            vec![ParsedRestraint {
228                active: ResSelection {
229                    resid: 1,
230                    chain: "A".to_string()
231                },
232                partners: vec![
233                    ResSelection {
234                        resid: 2,
235                        chain: "B".to_string()
236                    },
237                    ResSelection {
238                        resid: 3,
239                        chain: "B".to_string()
240                    },
241                ],
242            }]
243        );
244    }
245
246    #[test]
247    fn test_parse_ignores_atom_clauses() {
248        let tbl = "assign ( resid 1 and segid A and name CA ) ( resid 2 and segid B and name CB ) 2.0 2.0 0.0\n\n";
249        let restraints = parse_tbl(tbl).unwrap();
250
251        assert_eq!(restraints[0].active.resid, 1);
252        assert_eq!(restraints[0].active.chain, "A");
253        assert_eq!(restraints[0].partners[0].resid, 2);
254        assert_eq!(restraints[0].partners[0].chain, "B");
255    }
256
257    #[test]
258    fn test_parse_multiple_blocks() {
259        let tbl = "assign ( resid 1 and segid A ) ( resid 2 and segid B ) 2.0 2.0 0.0\n\n\
260                   assign ( resid 5 and segid A ) ( resid 9 and segid B ) 2.0 2.0 0.0\n\n";
261        let restraints = parse_tbl(tbl).unwrap();
262
263        assert_eq!(restraints.len(), 2);
264        assert_eq!(restraints[1].active.resid, 5);
265    }
266
267    #[test]
268    fn test_parse_ignores_comments() {
269        let tbl = "! this is a comment\nassign ( resid 1 and segid A ) ( resid 2 and segid B ) 2.0 2.0 0.0 ! inline comment\n\n";
270        let restraints = parse_tbl(tbl).unwrap();
271
272        assert_eq!(restraints[0].active.resid, 1);
273    }
274
275    #[test]
276    fn test_parse_atom_clause_before_segid() {
277        // `and name CA` sits between `resid` and `segid` here.
278        let tbl =
279            "assign ( resid 1 and name CA and segid A ) ( resid 2 and segid B ) 2.0 2.0 0.0\n\n";
280        let restraints = parse_tbl(tbl).unwrap();
281
282        assert_eq!(restraints[0].active.resid, 1);
283        assert_eq!(restraints[0].active.chain, "A");
284    }
285
286    #[test]
287    fn test_parse_segid_before_resid() {
288        let tbl = "assign ( segid A and resid 1 ) ( resid 2 and segid B ) 2.0 2.0 0.0\n\n";
289        let restraints = parse_tbl(tbl).unwrap();
290
291        assert_eq!(restraints[0].active.resid, 1);
292        assert_eq!(restraints[0].active.chain, "A");
293    }
294
295    #[test]
296    fn test_parse_skips_non_air_block_without_erroring() {
297        // Not every `assign` block is a resid/segid AIR distance
298        // restraint — some CNS restraint types select purely by `attr`.
299        // One such block shouldn't discard the valid restraint that
300        // follows it.
301        let tbl = "assign ( attr store1 ) ( attr store2 ) 5.0 0.0 0.0\n\n\
302                   assign ( resid 5 and segid A ) ( resid 9 and segid B ) 2.0 2.0 0.0\n\n";
303        let restraints = parse_tbl(tbl).unwrap();
304
305        assert_eq!(restraints.len(), 1);
306        assert_eq!(restraints[0].active.resid, 5);
307    }
308
309    #[test]
310    fn test_parse_invalid_resid_errors() {
311        let tbl = "assign ( resid 99999999 and segid A ) ( resid 2 and segid B ) 2.0 2.0 0.0\n\n";
312        assert!(parse_tbl(tbl).is_err());
313    }
314
315    #[test]
316    fn test_parse_segid_without_chain_errors() {
317        let tbl = "assign ( resid 1 and segid ) ( resid 2 and segid B ) 2.0 2.0 0.0\n\n";
318        assert!(parse_tbl(tbl).is_err());
319    }
320
321    #[test]
322    fn test_parse_empty_file_yields_no_restraints() {
323        assert!(parse_tbl("").unwrap().is_empty());
324        assert!(parse_tbl("! just a comment\n").unwrap().is_empty());
325    }
326
327    #[test]
328    fn test_parse_only_non_air_blocks_errors() {
329        // Every assign block present resolves to zero selections, so
330        // there's nothing to visualize and this should be reported rather
331        // than silently returning an empty restraint list.
332        let tbl = "assign ( attr store1 ) ( attr store2 ) 5.0 0.0 0.0\n\n";
333        assert!(parse_tbl(tbl).is_err());
334    }
335
336    #[test]
337    fn test_extract_selections_no_surrounding_whitespace() {
338        let selections = extract_selections("(resid 1 and segid A)(resid 2 and segid B)").unwrap();
339        assert_eq!(
340            selections,
341            vec![
342                ResSelection {
343                    resid: 1,
344                    chain: "A".to_string()
345                },
346                ResSelection {
347                    resid: 2,
348                    chain: "B".to_string()
349                },
350            ]
351        );
352    }
353
354    #[test]
355    fn test_extract_selections() {
356        let selections =
357            extract_selections("( resid 1 and segid A ) ( resid 2 and segid B )").unwrap();
358        assert_eq!(
359            selections,
360            vec![
361                ResSelection {
362                    resid: 1,
363                    chain: "A".to_string()
364                },
365                ResSelection {
366                    resid: 2,
367                    chain: "B".to_string()
368                },
369            ]
370        );
371    }
372}