aam-rs 2.0.0

A Rust implementation of the Abstract Alias Mapping (AAM) framework for aliasing and maping aam files.
Documentation
//! `@schema` directive — defines a named struct-like schema with typed fields.
//!
//! # Syntax
//! ```text
//! @schema Name { field1: type1, field2*: type2, ... }
//! ```
//!
//! A field name ending with `*` is **optional** — it is not required to be present
//! in the data map, but if it *is* present the value must satisfy the declared type.
//!
//! # Semantics
//! After a schema is registered any `key = value` assignment whose key matches
//! a schema field is automatically validated against the declared type.
//! Use [`AAML::apply_schema`] to validate a complete data map programmatically.

use crate::aaml::AAML;
use crate::commands::Command;
use crate::error::AamlError;
use std::collections::HashMap;
use std::collections::HashSet;

/// A parsed schema definition: maps field names to their declared type strings.
///
/// Type strings can be primitives (`i32`, `f64`, `string`, `bool`, `color`),
/// built-in module paths (`math::vector3`, `physics::kilogram`, `time::datetime`),
/// or custom aliases registered via `@type`.
///
/// Fields listed in `optional_fields` do not have to be present in the data map,
/// but if they *are* present their values are still validated.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SchemaDef {
    /// Map of `field_name → type_name`.
    pub fields: HashMap<String, String>,
    /// Set of field names that are optional (declared with `*` suffix).
    pub optional_fields: HashSet<String>,
}

impl SchemaDef {
    /// Returns `true` when `field` was declared with `*` (optional).
    pub fn is_optional(&self, field: &str) -> bool {
        self.optional_fields.contains(field)
    }
}

/// Command handler for the `@schema` directive.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SchemaCommand;

impl SchemaCommand {
    /// Splits `args` into the schema name and the raw body between `{` and `}`.
    fn parse_header(args: &str) -> Result<(&str, &str), AamlError> {
        let (name_part, body_part) =
            args.split_once('{')
                .ok_or_else(|| AamlError::DirectiveError {
                    directive: "schema".to_string(),
                    message: "Expected '{' after schema name".to_string(),
                    diagnostics: Some(crate::error::ErrorDiagnostics::new(
                        "Invalid @schema syntax",
                        "Schema definition must start with '{' after the name",
                        "Use format: @schema Name { field: type, ... }",
                    )),
                })?;

        let name = name_part.trim();
        if name.is_empty() {
            return Err(AamlError::DirectiveError {
                directive: "schema".to_string(),
                message: "Schema name is empty".to_string(),
                diagnostics: Some(crate::error::ErrorDiagnostics::new(
                    "Missing schema name",
                    "Schema directive must have a name before '{'",
                    "Use format: @schema SchemaName { ... }",
                )),
            });
        }

        let body = body_part
            .rsplit_once('}')
            .ok_or_else(|| AamlError::DirectiveError {
                directive: "schema".to_string(),
                message: "Expected '}' to close schema body".to_string(),
                diagnostics: Some(crate::error::ErrorDiagnostics::new(
                    "Unclosed schema definition",
                    "Schema body must be closed with '}'",
                    "Ensure your @schema has matching braces: { ... }",
                )),
            })?
            .0;

        Ok((name, body))
    }

    /// Parses a single `field:type` or `field*:type` token pair.
    ///
    /// Returns `(field_name, type_name, is_optional)`.
    /// A field name ending with `*` is optional — the `*` is stripped from
    /// the stored name and `is_optional` is set to `true`.
    fn parse_field<'a>(
        token: &'a str,
        tokens: &mut impl Iterator<Item = &'a str>,
    ) -> Result<(String, String, bool), AamlError> {
        let (field_raw, ty) = token
            .split_once(':')
            .ok_or_else(|| AamlError::DirectiveError {
                directive: "schema".to_string(),
                message: format!("Bad field: '{}' — missing ':' separator", token),
                diagnostics: Some(crate::error::ErrorDiagnostics::new(
                    "Invalid field definition",
                    format!("Field '{}' must use format: field:type", token),
                    "Use ':' to separate field name from type, e.g., x:f64",
                )),
            })?;

        // "field:type" or "field:" — type may follow as the next token.
        let ty = if ty.is_empty() {
            tokens.next().ok_or_else(|| AamlError::DirectiveError {
                directive: "schema".to_string(),
                message: format!("Field '{}:' has no type specified", field_raw),
                diagnostics: Some(crate::error::ErrorDiagnostics::new(
                    "Missing field type",
                    format!("Field '{}' must have a type after ':'", field_raw),
                    "Provide a type: field:i32, field:string, etc.",
                )),
            })?
        } else {
            ty
        };

        let is_optional = field_raw.ends_with('*');
        let field = if is_optional {
            field_raw.trim_end_matches('*')
        } else {
            field_raw
        };

        if field.is_empty() || ty.is_empty() {
            return Err(AamlError::DirectiveError {
                directive: "schema".to_string(),
                message: format!(
                    "Bad field definition: '{}:{}' — empty name or type",
                    field, ty
                ),
                diagnostics: Some(crate::error::ErrorDiagnostics::new(
                    "Empty field or type",
                    "Field name and type cannot be empty",
                    "Use format: fieldName:typeName",
                )),
            });
        }

        Ok((field.to_string(), ty.to_string(), is_optional))
    }

    /// Parses the raw argument string into a `(name, SchemaDef)` pair.
    ///
    /// Expected format: `Name { field: type, field*: type, ... }`
    fn parse(args: &str) -> Result<(String, SchemaDef), AamlError> {
        let (name, body) = Self::parse_header(args.trim())?;

        // Normalize: commas and whitespace are both valid field separators.
        // Replace commas with spaces so we can use split_whitespace uniformly.
        let normalized = body.replace(',', " ");
        let mut tokens = normalized.split_whitespace();
        let mut fields = HashMap::new();
        let mut optional_fields = HashSet::new();

        while let Some(token) = tokens.next() {
            let (field, ty, is_optional) = Self::parse_field(token, &mut tokens)?;
            if is_optional {
                optional_fields.insert(field.clone());
            }
            fields.insert(field, ty);
        }

        Ok((
            name.to_string(),
            SchemaDef {
                fields,
                optional_fields,
            },
        ))
    }
}

impl Command for SchemaCommand {
    fn name(&self) -> &str {
        "schema"
    }

    /// Parses the schema definition and registers it in the current [`AAML`] instance.
    ///
    /// If a schema with the same name already exists it is **replaced**.
    fn execute(&self, aaml: &mut AAML, args: &str) -> Result<(), AamlError> {
        let (name, schema) = Self::parse(args)?;
        aaml.get_schemas_mut().insert(name, schema);
        Ok(())
    }
}