readcon-core 0.14.7

An oxidized single and multiple CON file reader and writer with FFI bindings for ergonomic C/C++ usage.
Documentation
//! Formal CON/convel surface grammar ([Pest](https://pest.rs) PEG).
//!
//! Enable with `--features grammar`. The production parser in [`crate::parser`]
//! remains the hot path; this module exists so the repo ships a machine-checkable
//! PEG next to the prose specification (`docs/orgmode/spec.org`, source file
//! `grammar/readcon.pest`).
//!
//! Semantic constraints (per-type atom counts, component indices, JSON metadata)
//! are **not** fully encoded in the PEG — see comments in `grammar/readcon.pest`.

#![cfg(feature = "grammar")]

use pest::Parser;
use pest_derive::Parser;

/// Pest-generated parser for [`Rule`].
#[derive(Parser)]
#[grammar = "../grammar/readcon.pest"]
pub struct ConGrammar;

/// Parse `input` as a full CON/convel multi-frame buffer under the surface PEG.
///
/// Returns `Ok(())` when the grammar accepts the input. Does not construct
/// [`crate::types::ConFrame`] values.
pub fn parse_surface(input: &str) -> Result<(), pest::error::Error<Rule>> {
    ConGrammar::parse(Rule::file, input).map(|_| ())
}

/// Whether `input` is accepted by the surface grammar (convenience for tests).
pub fn accepts(input: &str) -> bool {
    parse_surface(input).is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::PathBuf;

    fn fixture(name: &str) -> String {
        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test")
            .join(name);
        fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()))
    }

    #[test]
    fn grammar_accepts_core_fixtures() {
        for name in [
            "tiny_cuh2.con",
            "cuh2.con",
            "sulfolene.con",
            "tiny_multi_cuh2.con",
            "tiny_cuh2_vel_forces.con",
            "tiny_cuh2_forces.con",
            "tiny_cuh2.convel",
            "tiny_multi_cuh2.convel",
            "tiny_cuh2_charges_spins_magmoms.con",
        ] {
            let text = fixture(name);
            assert!(
                accepts(&text),
                "grammar rejected fixture {name}: {:?}",
                parse_surface(&text).err()
            );
        }
    }

    #[test]
    fn grammar_lockstep_hand_parser_accepts_same_fixtures() {
        use crate::iterators::ConFrameIterator;
        for name in [
            "tiny_cuh2.con",
            "tiny_cuh2_vel_forces.con",
            "tiny_cuh2_charges_spins_magmoms.con",
            "tiny_multi_cuh2.con",
        ] {
            let text = fixture(name);
            assert!(accepts(&text), "PEG reject {name}");
            let frames: Result<Vec<_>, _> = ConFrameIterator::new(&text).collect();
            assert!(
                frames.as_ref().map(|f| !f.is_empty()).unwrap_or(false),
                "hand parser failed {name}: {frames:?}"
            );
        }
    }

    #[test]
    fn grammar_rejects_obvious_garbage() {
        assert!(!accepts(""));
        assert!(!accepts("not a con file\n"));
        assert!(!accepts("only one line"));
    }

    #[test]
    fn grammar_file_ships_six_section_kinds_and_scalar_rows() {
        let p = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("grammar/readcon.pest");
        assert!(p.is_file(), "expected shipped grammar at {}", p.display());
        let body = fs::read_to_string(&p).unwrap();
        assert!(body.contains("file = {"), "grammar should define file rule");
        assert!(body.contains("Coordinates of Component"));
        for kind in [
            "Velocities",
            "Forces",
            "Energies",
            "Charges",
            "Spins",
            "Magmoms",
        ] {
            assert!(
                body.contains(&format!("\"{kind}\"")),
                "grammar missing section kind {kind}"
            );
        }
        assert!(
            body.contains("scalar_atom_line"),
            "grammar must define scalar section rows"
        );
        assert!(
            body.contains("vector_atom_line"),
            "grammar must define vector section rows"
        );
    }

    #[test]
    fn grammar_accepts_scalar_and_vector_kind_snippets() {
        // Minimal synthetic: header + one type + charges scalar section
        let scalar = "\
Generated by test
{\"con_spec_version\":2,\"sections\":[\"charges\"]}
10.0 10.0 10.0
90.0 90.0 90.0
0 0
0 0
1
1
1.0
H
Coordinates of Component 1
0.0 0.0 0.0 0 0

H
Charges of Component 1
0.5 0 0
";
        assert!(
            accepts(scalar),
            "scalar charges: {:?}",
            parse_surface(scalar).err()
        );

        let vector = "\
Generated by test
{\"con_spec_version\":2,\"sections\":[\"magmoms\"]}
10.0 10.0 10.0
90.0 90.0 90.0
0 0
0 0
1
1
1.0
H
Coordinates of Component 1
0.0 0.0 0.0 0 0

H
Magmoms of Component 1
0.0 0.0 1.0 0 0
";
        assert!(
            accepts(vector),
            "vector magmoms: {:?}",
            parse_surface(vector).err()
        );
    }
}