Skip to main content

aam_core/commands/
schema.rs

1//! `@schema` directive — defines a named struct-like schema with typed fields.
2//!
3//! # Syntax
4//! ```text
5//! @schema Name { field1: type1, field2*: type2, ... }
6//! ```
7//!
8//! A field name ending with `*` is **optional** — it is not required to be present
9//! in the data map, but if it *is* present, the value must satisfy the declared type.
10//!
11//! # Semantics
12//! After a schema is registered, any `key = value` assignment whose key matches
13//! a schema field is automatically validated against the declared type.
14//! Use [`AAML::apply_schema`] to validate a complete data map programmatically.
15
16use crate::aaml::AAML;
17use crate::commands::Command;
18use crate::error::AamlError;
19use std::collections::HashMap;
20use std::collections::HashSet;
21
22/// A parsed schema definition: maps field names to their declared type strings.
23///
24/// Type strings can be primitives (`i32`, `f64`, `string`, `bool`, `color`),
25/// built-in module paths (`math::vector3`, `physics::kilogram`, `time::datetime`),
26/// or custom aliases registered via `@type`.
27///
28/// Fields listed in `optional_fields` do not have to be present in the data map,
29/// but if they *are* press not, their values are still validated.
30#[derive(Clone, Debug)]
31#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
32pub struct SchemaDef {
33    /// Map of `field_name → type_name`.
34    pub fields: HashMap<String, String>,
35    /// Set of field names that are optional (declared with `*` suffix).
36    pub optional_fields: HashSet<String>,
37}
38
39impl SchemaDef {
40    /// Returns `true` when `field` was declared with `*` (optional).
41    #[must_use]
42    pub fn is_optional(&self, field: &str) -> bool {
43        self.optional_fields.contains(field)
44    }
45}
46
47/// Command handler for the `@schema` directive.
48#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
49pub struct SchemaCommand;
50
51impl SchemaCommand {
52    /// Splits `args` into the schema name and the raw body between `{` and `}`.
53    fn parse_header(args: &str) -> Result<(&str, &str), AamlError> {
54        let (name_part, body_part) =
55            args.split_once('{')
56                .ok_or_else(|| AamlError::DirectiveError {
57                    directive: "schema".to_string(),
58                    message: "Expected '{' after schema name".to_string(),
59                    diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
60                        "Invalid @schema syntax",
61                        "Schema definition must start with '{' after the name",
62                        "Use format: @schema Name { field: type, ... }",
63                    ))),
64                })?;
65
66        let name = name_part.trim();
67        if name.is_empty() {
68            return Err(AamlError::DirectiveError {
69                directive: "schema".to_string(),
70                message: "Schema name is empty".to_string(),
71                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
72                    "Missing schema name",
73                    "Schema directive must have a name before '{'",
74                    "Use format: @schema SchemaName { ... }",
75                ))),
76            });
77        }
78
79        let body = body_part
80            .rsplit_once('}')
81            .ok_or_else(|| AamlError::DirectiveError {
82                directive: "schema".to_string(),
83                message: "Expected '}' to close schema body".to_string(),
84                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
85                    "Unclosed schema definition",
86                    "Schema body must be closed with '}'",
87                    "Ensure your @schema has matching braces: { ... }",
88                ))),
89            })?
90            .0;
91
92        Ok((name, body))
93    }
94
95    /// Parses a single `field:type` or `field*:type` token pair.
96    ///
97    /// Returns `(field_name, type_name, is_optional)`.
98    /// A field name ending with `*` is optional — the `*` is stripped from
99    /// the stored name and `is_optional` is set to `true`.
100    fn parse_field<'a>(
101        token: &'a str,
102        tokens: &mut impl Iterator<Item = &'a str>,
103    ) -> Result<(String, String, bool), AamlError> {
104        let (field_raw, ty) = token
105            .split_once(':')
106            .ok_or_else(|| AamlError::DirectiveError {
107                directive: "schema".to_string(),
108                message: format!("Bad field: '{token}' — missing ':' separator"),
109                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
110                    "Invalid field definition",
111                    format!("Field '{token}' must use format: field:type"),
112                    "Use ':' to separate field name from type, e.g., x:f64",
113                ))),
114            })?;
115
116        // "field:type" or "field:" — type may follow as the next token.
117        let ty = if ty.is_empty() {
118            tokens.next().ok_or_else(|| AamlError::DirectiveError {
119                directive: "schema".to_string(),
120                message: format!("Field '{field_raw}:' has no type specified"),
121                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
122                    "Missing field type",
123                    format!("Field '{field_raw}' must have a type after ':'"),
124                    "Provide a type: field:i32, field:string, etc.",
125                ))),
126            })?
127        } else {
128            ty
129        };
130
131        let is_optional = field_raw.ends_with('*');
132        let field = if is_optional {
133            field_raw.trim_end_matches('*')
134        } else {
135            field_raw
136        };
137
138        if field.is_empty() || ty.is_empty() {
139            return Err(AamlError::DirectiveError {
140                directive: "schema".to_string(),
141                message: format!("Bad field definition: '{field}:{ty}' — empty name or type"),
142                diagnostics: Some(Box::new(crate::error::ErrorDiagnostics::new(
143                    "Empty field or type",
144                    "Field name and type cannot be empty",
145                    "Use format: fieldName:typeName",
146                ))),
147            });
148        }
149
150        Ok((field.to_string(), ty.to_string(), is_optional))
151    }
152
153    /// Parses the raw argument string into a `(name, SchemaDef)` pair.
154    ///
155    /// Expected format: `Name { field: type, field*: type, ... }`
156    fn parse(args: &str) -> Result<(String, SchemaDef), AamlError> {
157        let (name, body) = Self::parse_header(args.trim())?;
158
159        // Normalize: commas and whitespace are both valid field separators.
160        // Replace commas with spaces so we can use split_whitespace uniformly.
161        let normalized = body.replace(',', " ");
162        let mut tokens = normalized.split_whitespace();
163        let mut fields = HashMap::new();
164        let mut optional_fields = HashSet::new();
165
166        while let Some(token) = tokens.next() {
167            let (field, ty, is_optional) = Self::parse_field(token, &mut tokens)?;
168            if is_optional {
169                optional_fields.insert(field.clone());
170            }
171            fields.insert(field, ty);
172        }
173
174        Ok((
175            name.to_string(),
176            SchemaDef {
177                fields,
178                optional_fields,
179            },
180        ))
181    }
182}
183
184impl Command for SchemaCommand {
185    fn name(&self) -> &'static str {
186        "schema"
187    }
188
189    /// Parses the schema definition and registers it in the current [`AAML`] instance.
190    ///
191    /// If a schema with the same name already exists, it is **replaced**.
192    fn execute(&self, aaml: &mut AAML, args: &str) -> Result<(), AamlError> {
193        let (name, schema) = Self::parse(args)?;
194        aaml.get_schemas_mut().insert(name, schema);
195        Ok(())
196    }
197}