Skip to main content

molpack/script/
parser.rs

1//! Parser for molpack's line-oriented `.inp` script format.
2//!
3//! The format mirrors the keyword-driven input Packmol has used for two
4//! decades, but the parser and types here belong to molpack and are
5//! exposed as first-class library API. Unrecognised keywords are a
6//! hard error: silently dropping them risks wrong semantics (e.g., an
7//! ignored `pbc` directive that leaves the packer guessing a box size
8//! from the initial random placement, blowing the cell grid to
9//! gigabytes of RAM).
10
11use std::path::PathBuf;
12
13use super::error::ScriptError;
14
15// ──────────────────────────────────────────────────────────────────────────────
16// Public AST
17// ──────────────────────────────────────────────────────────────────────────────
18
19/// A fully parsed packing script.
20#[derive(Debug, Clone)]
21pub struct Script {
22    /// Minimum atom-atom distance tolerance (`tolerance`). Default 2.0 Å.
23    pub tolerance: f64,
24    /// Random seed (`seed` keyword).
25    pub seed: Option<u64>,
26    /// Global format hint (`filetype` keyword). When set, it overrides
27    /// per-file extension inference; otherwise each input file's format
28    /// is inferred from its extension.
29    pub filetype: Option<String>,
30    /// Output file path (`output` keyword). Required.
31    pub output: PathBuf,
32    /// Maximum outer-loop iterations (`nloop` keyword). When unset, Packmol's
33    /// default of `200 * ntype` is used (getinp.f90:537-539).
34    pub nloop: usize,
35    /// Whether to reject initial random placements that overlap a fixed
36    /// molecule (`avoid_overlap`, default on). Wired through `Script::build`
37    /// to `Molpack::with_avoid_overlap`.
38    pub avoid_overlap: bool,
39    /// Periodic-boundary box (`pbc` keyword). When set, it seeds the
40    /// packer's cell grid so the initial ±`sidemax` random placement
41    /// never drives `ncells` to anything astronomical.
42    pub pbc: Option<PbcSpec>,
43    pub structures: Vec<Structure>,
44}
45
46/// Periodic-boundary box declared at the top level via `pbc`.
47///
48/// Packmol accepts two forms (`getinp.f90`):
49/// - `pbc X Y Z`                           → `min = [0,0,0]`, `max = [X,Y,Z]`
50/// - `pbc X0 Y0 Z0  X1 Y1 Z1`              → `min = [X0,Y0,Z0]`, `max = [X1,Y1,Z1]`
51///
52/// Both forms declare periodicity on every axis.
53#[derive(Debug, Clone, Copy, PartialEq)]
54pub struct PbcSpec {
55    pub min: [f64; 3],
56    pub max: [f64; 3],
57}
58
59/// One `structure … end structure` block.
60#[derive(Debug, Clone)]
61pub struct Structure {
62    /// Template molecule file path, as written in the script (may be relative).
63    pub filepath: PathBuf,
64    /// Number of copies to pack.
65    pub number: usize,
66    /// Molecule-wide restraints (applied to every atom of every copy).
67    pub mol_restraints: Vec<RestraintSpec>,
68    /// Atom-subset restraints (`atoms … end atoms` blocks).
69    pub atom_groups: Vec<AtomGroup>,
70    /// Whether the `center` keyword was present.
71    pub center: bool,
72    /// Fixed placement: `(position [x,y,z], euler [ex,ey,ez])`.
73    pub fixed: Option<([f64; 3], [f64; 3])>,
74}
75
76/// One `atoms … end atoms` sub-block.
77#[derive(Debug, Clone)]
78pub struct AtomGroup {
79    /// Atom indices as written in the script (1-based).
80    pub atom_indices: Vec<usize>,
81    pub restraints: Vec<RestraintSpec>,
82}
83
84/// AtomRestraint as it appears in the script, before being mapped to a
85/// concrete `AtomRestraint` implementation.
86#[derive(Debug, Clone)]
87pub enum RestraintSpec {
88    InsideBox {
89        min: [f64; 3],
90        max: [f64; 3],
91    },
92    InsideSphere {
93        center: [f64; 3],
94        radius: f64,
95    },
96    OutsideSphere {
97        center: [f64; 3],
98        radius: f64,
99    },
100    /// `over plane` — atom must lie above the plane.
101    AbovePlane {
102        normal: [f64; 3],
103        distance: f64,
104    },
105    /// `below plane` — atom must lie below the plane.
106    BelowPlane {
107        normal: [f64; 3],
108        distance: f64,
109    },
110    /// `inside cube x0 y0 z0 d` — atom inside an axis-aligned cube.
111    InsideCube {
112        origin: [f64; 3],
113        side: f64,
114    },
115    /// `outside cube x0 y0 z0 d` — atom outside an axis-aligned cube.
116    OutsideCube {
117        origin: [f64; 3],
118        side: f64,
119    },
120    /// `outside box xmin ymin zmin xmax ymax zmax` — atom outside a box.
121    OutsideBox {
122        min: [f64; 3],
123        max: [f64; 3],
124    },
125    /// `inside ellipsoid a1 a2 a3 b1 b2 b3 c` — center, semi-axes, exponent.
126    InsideEllipsoid {
127        center: [f64; 3],
128        axes: [f64; 3],
129        exponent: f64,
130    },
131    /// `outside ellipsoid a1 a2 a3 b1 b2 b3 c` — center, semi-axes, exponent.
132    OutsideEllipsoid {
133        center: [f64; 3],
134        axes: [f64; 3],
135        exponent: f64,
136    },
137    /// `inside cylinder a1 a2 a3 d1 d2 d3 r l` — center, axis, radius, length.
138    InsideCylinder {
139        center: [f64; 3],
140        axis: [f64; 3],
141        radius: f64,
142        length: f64,
143    },
144    /// `outside cylinder a1 a2 a3 d1 d2 d3 r l` — center, axis, radius, length.
145    OutsideCylinder {
146        center: [f64; 3],
147        axis: [f64; 3],
148        radius: f64,
149        length: f64,
150    },
151}
152
153// ──────────────────────────────────────────────────────────────────────────────
154// Parser
155// ──────────────────────────────────────────────────────────────────────────────
156
157/// Parse a script from its textual source.
158pub fn parse(src: &str) -> Result<Script, ScriptError> {
159    let mut tolerance = 2.0_f64;
160    let mut seed: Option<u64> = None;
161    let mut filetype: Option<String> = None;
162    let mut output: Option<PathBuf> = None;
163    let mut nloop: Option<usize> = None;
164    let mut avoid_overlap = true;
165    let mut pbc: Option<PbcSpec> = None;
166    let mut structures: Vec<Structure> = Vec::new();
167
168    enum State {
169        TopLevel,
170        InStructure(Structure),
171        InAtoms {
172            structure: Structure,
173            group: AtomGroup,
174        },
175    }
176
177    let mut state = State::TopLevel;
178
179    for (lineno, raw) in src.lines().enumerate() {
180        let lineno = lineno + 1;
181
182        // Strip inline comments and surrounding whitespace.
183        let line = match raw.find('#') {
184            Some(pos) => &raw[..pos],
185            None => raw,
186        }
187        .trim();
188
189        if line.is_empty() {
190            continue;
191        }
192
193        let tokens: Vec<&str> = line.split_whitespace().collect();
194        let keyword = tokens[0].to_ascii_lowercase();
195
196        state = match state {
197            // ── Top-level keywords ──────────────────────────────────────────
198            State::TopLevel => match keyword.as_str() {
199                "tolerance" => {
200                    tolerance = parse_f64(&tokens, 1, "tolerance", lineno)?;
201                    State::TopLevel
202                }
203                "seed" => {
204                    seed = Some(parse_u64(&tokens, 1, "seed", lineno)?);
205                    State::TopLevel
206                }
207                "filetype" => {
208                    filetype = Some(
209                        tokens
210                            .get(1)
211                            .ok_or_else(|| parse_err(lineno, "missing filetype value"))?
212                            .to_ascii_lowercase(),
213                    );
214                    State::TopLevel
215                }
216                "output" => {
217                    output = Some(PathBuf::from(
218                        tokens
219                            .get(1)
220                            .ok_or_else(|| parse_err(lineno, "missing output path"))?,
221                    ));
222                    State::TopLevel
223                }
224                "nloop" => {
225                    nloop = Some(parse_usize(&tokens, 1, "nloop", lineno)?);
226                    State::TopLevel
227                }
228                "avoid_overlap" => {
229                    let val = tokens
230                        .get(1)
231                        .map(|s| s.to_ascii_lowercase())
232                        .unwrap_or_default();
233                    avoid_overlap = val != "no" && val != "false" && val != "0";
234                    State::TopLevel
235                }
236                "pbc" => {
237                    pbc = Some(parse_pbc(&tokens, lineno)?);
238                    State::TopLevel
239                }
240                "structure" => {
241                    let path = tokens
242                        .get(1)
243                        .ok_or_else(|| parse_err(lineno, "`structure` requires a file path"))?;
244                    State::InStructure(Structure {
245                        filepath: PathBuf::from(path),
246                        number: 0,
247                        mol_restraints: Vec::new(),
248                        atom_groups: Vec::new(),
249                        center: false,
250                        fixed: None,
251                    })
252                }
253                _ => {
254                    return Err(unknown_keyword(lineno, &keyword, "top-level"));
255                }
256            },
257
258            // ── Inside a structure block ────────────────────────────────────
259            State::InStructure(mut s) => match keyword.as_str() {
260                "end"
261                    if tokens.get(1).map(|t| t.to_ascii_lowercase()).as_deref()
262                        == Some("structure") =>
263                {
264                    structures.push(s);
265                    State::TopLevel
266                }
267                "number" => {
268                    s.number = parse_usize(&tokens, 1, "number", lineno)?;
269                    State::InStructure(s)
270                }
271                "center" | "centerofmass" => {
272                    s.center = true;
273                    State::InStructure(s)
274                }
275                "fixed" => {
276                    let pos = parse_vec3(&tokens, 1, "fixed position", lineno)?;
277                    let euler = parse_vec3(&tokens, 4, "fixed euler", lineno)?;
278                    s.fixed = Some((pos, euler));
279                    State::InStructure(s)
280                }
281                "inside" => {
282                    let r = parse_inside(&tokens, lineno)?;
283                    s.mol_restraints.push(r);
284                    State::InStructure(s)
285                }
286                "outside" => {
287                    let r = parse_outside(&tokens, lineno)?;
288                    s.mol_restraints.push(r);
289                    State::InStructure(s)
290                }
291                "over" | "above" => {
292                    let r = parse_plane_above(&tokens, lineno)?;
293                    s.mol_restraints.push(r);
294                    State::InStructure(s)
295                }
296                "below" => {
297                    let r = parse_plane_below(&tokens, lineno)?;
298                    s.mol_restraints.push(r);
299                    State::InStructure(s)
300                }
301                "atoms" => {
302                    let indices = tokens[1..]
303                        .iter()
304                        .map(|t| {
305                            t.parse::<usize>()
306                                .map_err(|_| parse_err(lineno, format!("invalid atom index `{t}`")))
307                        })
308                        .collect::<Result<Vec<_>, _>>()?;
309                    if indices.is_empty() {
310                        return Err(parse_err(lineno, "`atoms` requires at least one index"));
311                    }
312                    State::InAtoms {
313                        structure: s,
314                        group: AtomGroup {
315                            atom_indices: indices,
316                            restraints: Vec::new(),
317                        },
318                    }
319                }
320                _ => {
321                    return Err(unknown_keyword(lineno, &keyword, "structure block"));
322                }
323            },
324
325            // ── Inside an atoms sub-block ───────────────────────────────────
326            State::InAtoms {
327                structure: s,
328                mut group,
329            } => match keyword.as_str() {
330                "end"
331                    if tokens.get(1).map(|t| t.to_ascii_lowercase()).as_deref()
332                        == Some("atoms") =>
333                {
334                    let mut s = s;
335                    s.atom_groups.push(group);
336                    State::InStructure(s)
337                }
338                "inside" => {
339                    let r = parse_inside(&tokens, lineno)?;
340                    group.restraints.push(r);
341                    State::InAtoms {
342                        structure: s,
343                        group,
344                    }
345                }
346                "outside" => {
347                    let r = parse_outside(&tokens, lineno)?;
348                    group.restraints.push(r);
349                    State::InAtoms {
350                        structure: s,
351                        group,
352                    }
353                }
354                "over" | "above" => {
355                    let r = parse_plane_above(&tokens, lineno)?;
356                    group.restraints.push(r);
357                    State::InAtoms {
358                        structure: s,
359                        group,
360                    }
361                }
362                "below" => {
363                    let r = parse_plane_below(&tokens, lineno)?;
364                    group.restraints.push(r);
365                    State::InAtoms {
366                        structure: s,
367                        group,
368                    }
369                }
370                _ => {
371                    return Err(unknown_keyword(lineno, &keyword, "atoms block"));
372                }
373            },
374        };
375    }
376
377    match state {
378        State::InStructure(_) => return Err(ScriptError::UnclosedBlock("structure")),
379        State::InAtoms { .. } => return Err(ScriptError::UnclosedBlock("atoms")),
380        State::TopLevel => {}
381    }
382
383    Ok(Script {
384        tolerance,
385        seed,
386        filetype,
387        output: output.ok_or(ScriptError::MissingOutput)?,
388        // Packmol default (getinp.f90:537-539): unset `nloop` resolves to
389        // 200 * ntype, where ntype is the number of structure types.
390        nloop: nloop.unwrap_or(200 * structures.len()),
391        avoid_overlap,
392        pbc,
393        structures,
394    })
395}
396
397// ──────────────────────────────────────────────────────────────────────────────
398// AtomRestraint helpers
399// ──────────────────────────────────────────────────────────────────────────────
400
401fn parse_inside(tokens: &[&str], lineno: usize) -> Result<RestraintSpec, ScriptError> {
402    let shape = tokens
403        .get(1)
404        .map(|s| s.to_ascii_lowercase())
405        .ok_or_else(|| parse_err(lineno, "`inside` requires a shape keyword"))?;
406    match shape.as_str() {
407        "box" => {
408            let min = parse_vec3(tokens, 2, "inside box min", lineno)?;
409            let max = parse_vec3(tokens, 5, "inside box max", lineno)?;
410            Ok(RestraintSpec::InsideBox { min, max })
411        }
412        "cube" => {
413            let (origin, side) = parse_cube_args(tokens, lineno, "inside cube")?;
414            Ok(RestraintSpec::InsideCube { origin, side })
415        }
416        "sphere" => {
417            let center = parse_vec3(tokens, 2, "inside sphere center", lineno)?;
418            let radius = parse_f64(tokens, 5, "inside sphere radius", lineno)?;
419            Ok(RestraintSpec::InsideSphere { center, radius })
420        }
421        "ellipsoid" => {
422            let (center, axes, exponent) =
423                parse_ellipsoid_args(tokens, lineno, "inside ellipsoid")?;
424            Ok(RestraintSpec::InsideEllipsoid {
425                center,
426                axes,
427                exponent,
428            })
429        }
430        "cylinder" => {
431            let (center, axis, radius, length) =
432                parse_cylinder_args(tokens, lineno, "inside cylinder")?;
433            Ok(RestraintSpec::InsideCylinder {
434                center,
435                axis,
436                radius,
437                length,
438            })
439        }
440        _ => Err(parse_err(
441            lineno,
442            format!("unsupported `inside` shape `{shape}`"),
443        )),
444    }
445}
446
447fn parse_outside(tokens: &[&str], lineno: usize) -> Result<RestraintSpec, ScriptError> {
448    let shape = tokens
449        .get(1)
450        .map(|s| s.to_ascii_lowercase())
451        .ok_or_else(|| parse_err(lineno, "`outside` requires a shape keyword"))?;
452    match shape.as_str() {
453        "box" => {
454            let min = parse_vec3(tokens, 2, "outside box min", lineno)?;
455            let max = parse_vec3(tokens, 5, "outside box max", lineno)?;
456            Ok(RestraintSpec::OutsideBox { min, max })
457        }
458        "cube" => {
459            let (origin, side) = parse_cube_args(tokens, lineno, "outside cube")?;
460            Ok(RestraintSpec::OutsideCube { origin, side })
461        }
462        "sphere" => {
463            let center = parse_vec3(tokens, 2, "outside sphere center", lineno)?;
464            let radius = parse_f64(tokens, 5, "outside sphere radius", lineno)?;
465            Ok(RestraintSpec::OutsideSphere { center, radius })
466        }
467        "ellipsoid" => {
468            let (center, axes, exponent) =
469                parse_ellipsoid_args(tokens, lineno, "outside ellipsoid")?;
470            Ok(RestraintSpec::OutsideEllipsoid {
471                center,
472                axes,
473                exponent,
474            })
475        }
476        "cylinder" => {
477            let (center, axis, radius, length) =
478                parse_cylinder_args(tokens, lineno, "outside cylinder")?;
479            Ok(RestraintSpec::OutsideCylinder {
480                center,
481                axis,
482                radius,
483                length,
484            })
485        }
486        _ => Err(parse_err(
487            lineno,
488            format!("unsupported `outside` shape `{shape}`"),
489        )),
490    }
491}
492
493/// `<kw> cube x0 y0 z0 d` — origin corner + side length.
494fn parse_cube_args(
495    tokens: &[&str],
496    lineno: usize,
497    ctx: &str,
498) -> Result<([f64; 3], f64), ScriptError> {
499    let origin = parse_vec3(tokens, 2, ctx, lineno)?;
500    let side = parse_f64(tokens, 5, ctx, lineno)?;
501    Ok((origin, side))
502}
503
504/// `<kw> ellipsoid a1 a2 a3 b1 b2 b3 c` — center, semi-axes, exponent.
505fn parse_ellipsoid_args(
506    tokens: &[&str],
507    lineno: usize,
508    ctx: &str,
509) -> Result<([f64; 3], [f64; 3], f64), ScriptError> {
510    let center = parse_vec3(tokens, 2, ctx, lineno)?;
511    let axes = parse_vec3(tokens, 5, ctx, lineno)?;
512    if axes.iter().any(|&a| a <= 0.0) {
513        return Err(parse_err(
514            lineno,
515            format!("{ctx} semi-axes must be strictly positive, got {axes:?}"),
516        ));
517    }
518    let exponent = parse_f64(tokens, 8, ctx, lineno)?;
519    Ok((center, axes, exponent))
520}
521
522/// `<kw> cylinder a1 a2 a3 d1 d2 d3 r l` — center, axis, radius, length.
523fn parse_cylinder_args(
524    tokens: &[&str],
525    lineno: usize,
526    ctx: &str,
527) -> Result<([f64; 3], [f64; 3], f64, f64), ScriptError> {
528    let center = parse_vec3(tokens, 2, ctx, lineno)?;
529    let axis = parse_vec3(tokens, 5, ctx, lineno)?;
530    if axis == [0.0; 3] {
531        return Err(parse_err(
532            lineno,
533            format!("{ctx} axis must be a non-zero direction"),
534        ));
535    }
536    let radius = parse_f64(tokens, 8, ctx, lineno)?;
537    let length = parse_f64(tokens, 9, ctx, lineno)?;
538    Ok((center, axis, radius, length))
539}
540
541fn parse_plane_above(tokens: &[&str], lineno: usize) -> Result<RestraintSpec, ScriptError> {
542    let normal = parse_vec3(tokens, 2, "over plane normal", lineno)?;
543    let distance = parse_f64(tokens, 5, "over plane distance", lineno)?;
544    Ok(RestraintSpec::AbovePlane { normal, distance })
545}
546
547fn parse_plane_below(tokens: &[&str], lineno: usize) -> Result<RestraintSpec, ScriptError> {
548    let normal = parse_vec3(tokens, 2, "below plane normal", lineno)?;
549    let distance = parse_f64(tokens, 5, "below plane distance", lineno)?;
550    Ok(RestraintSpec::BelowPlane { normal, distance })
551}
552
553/// Parse `pbc` in either the 3-value (`pbc X Y Z`) or 6-value
554/// (`pbc X0 Y0 Z0  X1 Y1 Z1`) form — matching packmol `getinp.f90`
555/// lines 175-191.
556fn parse_pbc(tokens: &[&str], lineno: usize) -> Result<PbcSpec, ScriptError> {
557    // tokens[0] == "pbc"; the remaining tokens carry the numeric payload.
558    match tokens.len() - 1 {
559        3 => {
560            let size = parse_vec3(tokens, 1, "pbc box size", lineno)?;
561            if size.iter().any(|&v| v <= 0.0) {
562                return Err(parse_err(
563                    lineno,
564                    format!("`pbc` box length must be positive on every axis; got {size:?}"),
565                ));
566            }
567            Ok(PbcSpec {
568                min: [0.0; 3],
569                max: size,
570            })
571        }
572        6 => {
573            let min = parse_vec3(tokens, 1, "pbc min", lineno)?;
574            let max = parse_vec3(tokens, 4, "pbc max", lineno)?;
575            for k in 0..3 {
576                if max[k] <= min[k] {
577                    return Err(parse_err(
578                        lineno,
579                        format!(
580                            "`pbc` max must exceed min on every axis; axis {k}: min={}, max={}",
581                            min[k], max[k]
582                        ),
583                    ));
584                }
585            }
586            Ok(PbcSpec { min, max })
587        }
588        n => Err(parse_err(
589            lineno,
590            format!("`pbc` expects 3 or 6 numeric values, got {n}"),
591        )),
592    }
593}
594
595// ──────────────────────────────────────────────────────────────────────────────
596// Numeric helpers
597// ──────────────────────────────────────────────────────────────────────────────
598
599pub(super) fn parse_f64(
600    tokens: &[&str],
601    idx: usize,
602    ctx: &str,
603    lineno: usize,
604) -> Result<f64, ScriptError> {
605    let tok = tokens
606        .get(idx)
607        .ok_or_else(|| parse_err(lineno, format!("`{ctx}` — missing value at position {idx}")))?;
608    tok.parse::<f64>()
609        .map_err(|_| parse_err(lineno, format!("`{ctx}` — `{tok}` is not a valid number")))
610}
611
612fn parse_u64(tokens: &[&str], idx: usize, ctx: &str, lineno: usize) -> Result<u64, ScriptError> {
613    let tok = tokens
614        .get(idx)
615        .ok_or_else(|| parse_err(lineno, format!("`{ctx}` — missing value at position {idx}")))?;
616    tok.parse::<u64>()
617        .map_err(|_| parse_err(lineno, format!("`{ctx}` — `{tok}` is not a valid integer")))
618}
619
620fn parse_usize(
621    tokens: &[&str],
622    idx: usize,
623    ctx: &str,
624    lineno: usize,
625) -> Result<usize, ScriptError> {
626    let tok = tokens
627        .get(idx)
628        .ok_or_else(|| parse_err(lineno, format!("`{ctx}` — missing value at position {idx}")))?;
629    tok.parse::<usize>()
630        .map_err(|_| parse_err(lineno, format!("`{ctx}` — `{tok}` is not a valid integer")))
631}
632
633pub(super) fn parse_vec3(
634    tokens: &[&str],
635    start: usize,
636    ctx: &str,
637    lineno: usize,
638) -> Result<[f64; 3], ScriptError> {
639    Ok([
640        parse_f64(tokens, start, ctx, lineno)?,
641        parse_f64(tokens, start + 1, ctx, lineno)?,
642        parse_f64(tokens, start + 2, ctx, lineno)?,
643    ])
644}
645
646pub(super) fn parse_err(lineno: usize, message: impl Into<String>) -> ScriptError {
647    ScriptError::Parse {
648        line: lineno,
649        message: message.into(),
650    }
651}
652
653fn unknown_keyword(lineno: usize, keyword: &str, context: &'static str) -> ScriptError {
654    ScriptError::UnknownKeyword {
655        line: lineno,
656        keyword: keyword.to_string(),
657        context,
658    }
659}
660
661// ──────────────────────────────────────────────────────────────────────────────
662// Tests
663// ──────────────────────────────────────────────────────────────────────────────
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    #[test]
670    fn parse_mixture() {
671        let src = "\
672tolerance 2.0
673seed 1234567
674filetype pdb
675output mixture.xyz
676
677structure water.pdb
678  number 1000
679  inside box 0. 0. 0. 40. 40. 40.
680end structure
681
682structure urea.pdb
683  number 400
684  inside box 0. 0. 0. 40. 40. 40.
685end structure
686";
687        let inp = parse(src).expect("parse failed");
688        assert_eq!(inp.tolerance, 2.0);
689        assert_eq!(inp.seed, Some(1_234_567));
690        assert_eq!(inp.filetype.as_deref(), Some("pdb"));
691        assert_eq!(inp.output, PathBuf::from("mixture.xyz"));
692        assert_eq!(inp.structures.len(), 2);
693        assert_eq!(inp.structures[0].number, 1000);
694        assert_eq!(inp.structures[1].number, 400);
695        assert_eq!(inp.structures[0].mol_restraints.len(), 1);
696    }
697
698    #[test]
699    fn parse_bilayer_atoms_blocks() {
700        let src = "\
701tolerance 2.0
702seed 1234567
703filetype pdb
704output bilayer.xyz
705
706structure palmitoil.pdb
707  number 10
708  inside box 0. 0. 0. 40. 40. 14.
709  atoms 31 32
710    below plane 0. 0. 1. 2.
711  end atoms
712  atoms 1 2
713    over plane 0. 0. 1. 12.
714  end atoms
715end structure
716";
717        let inp = parse(src).expect("parse failed");
718        let s = &inp.structures[0];
719        assert_eq!(s.number, 10);
720        assert_eq!(s.mol_restraints.len(), 1);
721        assert_eq!(s.atom_groups.len(), 2);
722        assert_eq!(s.atom_groups[0].atom_indices, vec![31, 32]);
723        assert_eq!(s.atom_groups[1].atom_indices, vec![1, 2]);
724    }
725
726    #[test]
727    fn parse_interface_fixed_center() {
728        let src = "\
729tolerance 2.0
730seed 1234567
731filetype pdb
732output interface.xyz
733
734structure t3.pdb
735  number 1
736  center
737  fixed 0. 20. 20. 1.57 1.57 1.57
738end structure
739";
740        let inp = parse(src).expect("parse failed");
741        let s = &inp.structures[0];
742        assert!(s.center);
743        let (pos, euler) = s.fixed.unwrap();
744        assert_eq!(pos, [0.0, 20.0, 20.0]);
745        assert!((euler[0] - 1.57).abs() < 1e-9);
746    }
747
748    #[test]
749    fn parse_centerofmass_alias() {
750        // Official Packmol scripts (interface, solvprotein) spell the `center`
751        // keyword as `centerofmass`; the parser must treat them identically.
752        let src = "\
753tolerance 2.0
754filetype xyz
755output interface.xyz
756
757structure t3.xyz
758  number 1
759  centerofmass
760  fixed 0. 20. 20. 1.57 1.57 1.57
761end structure
762";
763        let inp = parse(src).expect("parse failed");
764        assert!(inp.structures[0].center);
765    }
766
767    #[test]
768    fn parse_comments_and_blank_lines() {
769        let src = "\
770# global settings
771tolerance 2.0
772# seed comment
773seed 42
774output out.pdb
775
776# molecule block
777structure mol.pdb
778  number 10 # trailing comment
779  inside sphere 0. 0. 0. 20.
780end structure
781";
782        let inp = parse(src).expect("parse failed");
783        assert_eq!(inp.seed, Some(42));
784        let s = &inp.structures[0];
785        assert_eq!(s.number, 10);
786        assert!(matches!(
787            s.mol_restraints[0],
788            RestraintSpec::InsideSphere { .. }
789        ));
790    }
791
792    #[test]
793    fn parse_avoid_overlap_no() {
794        let src = "\
795tolerance 2.0
796output out.pdb
797avoid_overlap no
798
799structure mol.pdb
800  number 1
801  inside sphere 0. 0. 0. 50.
802end structure
803";
804        let inp = parse(src).expect("parse failed");
805        assert!(!inp.avoid_overlap);
806    }
807
808    #[test]
809    fn parse_missing_output_errors() {
810        let src = "\
811tolerance 2.0
812
813structure mol.pdb
814  number 1
815  inside sphere 0. 0. 0. 50.
816end structure
817";
818        let err = parse(src).expect_err("should fail");
819        assert!(matches!(err, ScriptError::MissingOutput));
820    }
821
822    #[test]
823    fn parse_unclosed_structure_errors() {
824        let src = "\
825output out.pdb
826
827structure mol.pdb
828  number 1
829  inside sphere 0. 0. 0. 50.
830";
831        let err = parse(src).expect_err("should fail");
832        assert!(matches!(err, ScriptError::UnclosedBlock("structure")));
833    }
834
835    #[test]
836    fn parse_pbc_three_values() {
837        let src = "\
838tolerance 2.0
839output out.pdb
840pbc 100.0 80.0 60.0
841
842structure mol.pdb
843  number 1
844end structure
845";
846        let inp = parse(src).expect("parse failed");
847        let pbc = inp.pbc.expect("pbc should be set");
848        assert_eq!(pbc.min, [0.0, 0.0, 0.0]);
849        assert_eq!(pbc.max, [100.0, 80.0, 60.0]);
850    }
851
852    #[test]
853    fn parse_pbc_six_values() {
854        let src = "\
855tolerance 2.0
856output out.pdb
857pbc -50.0 -40.0 -30.0  50.0 40.0 30.0
858
859structure mol.pdb
860  number 1
861end structure
862";
863        let inp = parse(src).expect("parse failed");
864        let pbc = inp.pbc.expect("pbc should be set");
865        assert_eq!(pbc.min, [-50.0, -40.0, -30.0]);
866        assert_eq!(pbc.max, [50.0, 40.0, 30.0]);
867    }
868
869    #[test]
870    fn parse_pbc_rejects_bad_arity() {
871        let src = "\
872output out.pdb
873pbc 100.0 80.0
874
875structure mol.pdb
876  number 1
877end structure
878";
879        let err = parse(src).expect_err("should fail");
880        assert!(matches!(err, ScriptError::Parse { .. }));
881    }
882
883    #[test]
884    fn parse_pbc_rejects_non_positive_extent() {
885        let src = "\
886output out.pdb
887pbc 100.0 0.0 60.0
888
889structure mol.pdb
890  number 1
891end structure
892";
893        let err = parse(src).expect_err("should fail");
894        assert!(matches!(err, ScriptError::Parse { .. }));
895    }
896
897    #[test]
898    fn parse_unknown_top_level_keyword_errors() {
899        // Regression: parser used to silently drop unknown top-level
900        // keywords, which caused `pbc` to be dropped and drove the cell
901        // grid to ~10⁸ cells at pack time.
902        let src = "\
903output out.pdb
904wibble 1 2 3
905
906structure mol.pdb
907  number 1
908end structure
909";
910        let err = parse(src).expect_err("should fail");
911        match err {
912            ScriptError::UnknownKeyword {
913                keyword, context, ..
914            } => {
915                assert_eq!(keyword, "wibble");
916                assert_eq!(context, "top-level");
917            }
918            other => panic!("expected UnknownKeyword, got {other:?}"),
919        }
920    }
921
922    #[test]
923    fn parse_unknown_keyword_inside_structure_errors() {
924        let src = "\
925output out.pdb
926
927structure mol.pdb
928  number 1
929  nonsense foo
930end structure
931";
932        let err = parse(src).expect_err("should fail");
933        match err {
934            ScriptError::UnknownKeyword {
935                keyword, context, ..
936            } => {
937                assert_eq!(keyword, "nonsense");
938                assert_eq!(context, "structure block");
939            }
940            other => panic!("expected UnknownKeyword, got {other:?}"),
941        }
942    }
943
944    /// Parse a single restraint line inside a structure block and return the
945    /// parsed `RestraintSpec`.
946    fn parse_one_restraint(line: &str) -> RestraintSpec {
947        let src =
948            format!("output out.pdb\n\nstructure mol.pdb\n  number 1\n  {line}\nend structure\n");
949        let inp = parse(&src).expect("parse failed");
950        inp.structures[0].mol_restraints[0].clone()
951    }
952
953    #[test]
954    fn parse_inside_cube() {
955        assert!(matches!(
956            parse_one_restraint("inside cube 1. 2. 3. 10."),
957            RestraintSpec::InsideCube { origin, side }
958                if origin == [1.0, 2.0, 3.0] && side == 10.0
959        ));
960    }
961
962    #[test]
963    fn parse_outside_cube() {
964        assert!(matches!(
965            parse_one_restraint("outside cube 0. 0. 0. 5."),
966            RestraintSpec::OutsideCube { side, .. } if side == 5.0
967        ));
968    }
969
970    #[test]
971    fn parse_outside_box() {
972        assert!(matches!(
973            parse_one_restraint("outside box 0. 0. 0. 4. 5. 6."),
974            RestraintSpec::OutsideBox { min, max }
975                if min == [0.0, 0.0, 0.0] && max == [4.0, 5.0, 6.0]
976        ));
977    }
978
979    #[test]
980    fn parse_inside_ellipsoid() {
981        assert!(matches!(
982            parse_one_restraint("inside ellipsoid 0. 0. 0. 3. 2. 1.5 1."),
983            RestraintSpec::InsideEllipsoid { axes, exponent, .. }
984                if axes == [3.0, 2.0, 1.5] && exponent == 1.0
985        ));
986    }
987
988    #[test]
989    fn parse_outside_ellipsoid() {
990        assert!(matches!(
991            parse_one_restraint("outside ellipsoid 1. 1. 1. 2. 2. 2. 1."),
992            RestraintSpec::OutsideEllipsoid { center, .. } if center == [1.0, 1.0, 1.0]
993        ));
994    }
995
996    #[test]
997    fn parse_inside_cylinder() {
998        assert!(matches!(
999            parse_one_restraint("inside cylinder 0. 0. 0. 1. 0. 0. 2. 8."),
1000            RestraintSpec::InsideCylinder { axis, radius, length, .. }
1001                if axis == [1.0, 0.0, 0.0] && radius == 2.0 && length == 8.0
1002        ));
1003    }
1004
1005    #[test]
1006    fn parse_outside_cylinder() {
1007        assert!(matches!(
1008            parse_one_restraint("outside cylinder 0. 0. 0. 0. 0. 1. 3. 5."),
1009            RestraintSpec::OutsideCylinder { axis, .. } if axis == [0.0, 0.0, 1.0]
1010        ));
1011    }
1012
1013    #[test]
1014    fn parse_ellipsoid_rejects_zero_axis() {
1015        let src = "output out.pdb\n\nstructure mol.pdb\n  number 1\n  \
1016                   inside ellipsoid 0. 0. 0. 0. 2. 2. 1.\nend structure\n";
1017        assert!(parse(src).is_err(), "zero semi-axis must be rejected");
1018    }
1019
1020    #[test]
1021    fn parse_cylinder_rejects_zero_axis() {
1022        let src = "output out.pdb\n\nstructure mol.pdb\n  number 1\n  \
1023                   inside cylinder 0. 0. 0. 0. 0. 0. 2. 8.\nend structure\n";
1024        assert!(parse(src).is_err(), "zero cylinder axis must be rejected");
1025    }
1026}