readcon-core 0.17.1

An oxidized single and multiple CON file reader and writer with FFI bindings for ergonomic C/C++ usage. Implements CON spec v3.
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",
            "tiny_cuh2_displacements.con",
            "tiny_cuh2_spreads.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_cuh2_displacements.con",
            "tiny_cuh2_spreads.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_eight_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",
            "Displacements",
            "Spreads",
        ] {
            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()
        );

        let displacement = "\
Generated by test
{\"con_spec_version\":2,\"sections\":[\"displacements\"]}
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
Displacements of Component 1
0.1 -0.2 0.3 0 0
";
        assert!(
            accepts(displacement),
            "vector displacements: {:?}",
            parse_surface(displacement).err()
        );

        let spread = "\
Generated by test
{\"con_spec_version\":2,\"sections\":[\"spreads\"]}
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
Spreads of Component 1
0.1 0.2 0.3 0 0
";
        assert!(
            accepts(spread),
            "vector spreads: {:?}",
            parse_surface(spread).err()
        );
    }

    fn hand_class(text: &str) -> String {
        use crate::iterators::ConFrameIterator;
        match ConFrameIterator::new(text).next() {
            Some(Ok(_)) => "ok".into(),
            Some(Err(err)) => err.class_name().into(),
            None => "empty".into(),
        }
    }

    /// Syntactic cases the PEG and the hand parser both accept or both reject.
    /// Non-finite tokens and unknown sections are semantic: the PEG accepts
    /// the shape and the hand parser names the error (CON-token.4).
    #[test]
    fn grammar_and_hand_parser_share_an_accept_reject_list() {
        let ok_float = "\
PRE
{\"con_spec_version\":2}
10 10 10
90 90 90
A
B
1
1
1.0
H
coordinates of component   1
+1.5 .25 1e-1 0 3
   \n";
        assert!(accepts(ok_float), "{:?}", parse_surface(ok_float).err());
        assert_eq!(hand_class(ok_float), "ok");

        let cases = [
            (
                "\u{feff}PRE\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\nA\nB\n1\n1\n1\nH\nCoordinates of Component 1\n0 0 0 0 0\n",
                false,
            ),
            (
                "PRE\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\nA\nB\n1\n1\n1\nH\nCoordinates of Component 1\n0 0 0 -1 0\n",
                false,
            ),
            (
                "PRE\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\nA\nB\n1\n1\n1\nH\nCoordinates of Component 1\n0 0 0 0.0 0\n",
                false,
            ),
            (
                "PRE\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\nA\nB\n0\n\n",
                true,
            ),
            (
                "PRE\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\nA\nB\n0\n1\n1\n",
                false,
            ),
            (
                "PRE\n{\"con_spec_version\":2}\n10 10 10\n90 90 90\nA\nB\n-1\n1\n1\n",
                false,
            ),
            ("", false),
        ];
        for (text, want) in cases {
            assert_eq!(accepts(text), want, "PEG {text:?}");
            assert_eq!(hand_class(text) == "ok", want, "hand {text:?}");
        }

        let nan = "\
PRE
{\"con_spec_version\":2}
10 10 10
90 90 90
A
B
1
1
1.0
H
Coordinates of Component 1
nan 0 0 0 0
";
        assert!(
            accepts(nan),
            "PEG keeps the non-finite token for the semantic layer"
        );
        assert_eq!(hand_class(nan), "NonFiniteValue");

        let unknown = "\
PRE
{\"con_spec_version\":3,\"con_spec_minor\":1,\"units\":{\"length\":\"angstrom\",\"energy\":\"eV\"},\"sections\":[\"widgets\"]}
10 10 10
90 90 90
A
B
1
1
1.0
H
Coordinates of Component 1
0 0 0 0 0

H
Widgets of Component 1
1 2 3 0 0
";
        assert!(accepts(unknown), "{:?}", parse_surface(unknown).err());
        assert_eq!(hand_class(unknown), "UnknownSection");
    }
}