Skip to main content

forgedb_parser/
validate.rs

1//! Positioned, reusable schema validation — the single semantic-diagnostics
2//! authority for a parsed [`Schema`].
3//!
4//! # Why this lives in `forgedb-parser`
5//!
6//! Schema validation logically belongs next to the *diagnostic vocabulary*
7//! (`ValidationError` / `Position`) in [`forgedb-validation`], and epic #173
8//! (WS2b) originally proposed extending that crate. But `forgedb-validation`
9//! cannot see the `Schema` AST: `forgedb-parser` already depends on
10//! `forgedb-validation` (for `Position`/`ValidationError`), so a reverse
11//! dependency would be a cycle. The AST lives here, so the walk that validates
12//! it lives here too — reusing the diagnostic types and naming predicates from
13//! `forgedb-validation`. The split is: **validation crate = diagnostic types +
14//! reusable predicates; parser = the AST and the walk that applies them.**
15//!
16//! # The single authority
17//!
18//! [`validate_schema`] is the one implementation of ForgeDB's schema-level
19//! semantic rules. It is consumed by:
20//! - the parser itself ([`crate::Parser::parse`] runs it fail-fast to preserve
21//!   its `Result<Schema, String>` contract), and
22//! - the CLI `forgedb validate` command and the LSP, which call it directly on a
23//!   [`Parser::parse_unvalidated`](crate::Parser::parse_unvalidated) AST to
24//!   surface **all** diagnostics with positions instead of just the first.
25//!
26//! Everything it reports is a property of the assembled `Schema` (names,
27//! duplicates, cross-references, type constraints). Purely *syntactic* errors
28//! (unexpected tokens, malformed directives, empty models, composite-index
29//! arity) remain fatal in the parser's structural pass and never reach here.
30//!
31//! Callers that also need **filesystem** checks (component-file existence) or
32//! **advisory** lints (no id / no timestamp) layer those on top — those are not
33//! pure-schema diagnostics and stay in the CLI.
34
35use crate::ast::{FieldType, RelationType, Schema};
36use forgedb_validation::{is_pascal_case, validate_field_name, validate_model_name, ValidationError};
37
38/// Run the full positioned semantic validation of a parsed schema and return
39/// **every** diagnostic (naming + structural). Does not fail fast.
40///
41/// This is the entry point for the CLI and the LSP. The parser runs the same
42/// checks internally (see [`collect_naming_errors`] / [`collect_structure_errors`])
43/// but reports only the first, to keep its `Result<Schema, String>` contract.
44pub fn validate_schema(schema: &Schema) -> Vec<ValidationError> {
45    let mut errors = Vec::new();
46    collect_naming_errors(schema, &mut errors);
47    collect_structure_errors(schema, &mut errors);
48    errors
49}
50
51/// Naming-convention diagnostics: PascalCase model/struct/enum names and enum
52/// variants, snake_case field names.
53///
54/// Separated from [`collect_structure_errors`] because the parser gates *these*
55/// behind its `use_validation` flag (see `Parser::new_with_validation`), while
56/// structural checks always run. `validate_schema` runs both.
57pub fn collect_naming_errors(schema: &Schema, errors: &mut Vec<ValidationError>) {
58    for model in &schema.models {
59        if let Err(e) = validate_model_name(&model.name, model.position) {
60            errors.push(e);
61        }
62        for field in &model.fields {
63            if let Err(e) = validate_field_name(&field.name, field.position) {
64                errors.push(e);
65            }
66        }
67    }
68
69    for struct_def in &schema.structs {
70        if let Err(e) = validate_model_name(&struct_def.name, struct_def.position) {
71            errors.push(e);
72        }
73        for field in &struct_def.fields {
74            if let Err(e) = validate_field_name(&field.name, field.position) {
75                errors.push(e);
76            }
77        }
78    }
79
80    for enum_def in &schema.enums {
81        if let Err(e) = validate_model_name(&enum_def.name, enum_def.position) {
82            errors.push(e);
83        }
84        for variant in &enum_def.variants {
85            // Variants follow the model-name rule (leading uppercase, alphanumerics).
86            if !is_pascal_case(variant) {
87                let mut err = ValidationError::new(format!(
88                    "Enum '{}' variant '{}' must be PascalCase",
89                    enum_def.name, variant
90                ));
91                if let Some(pos) = enum_def.position {
92                    err = err.with_position(pos);
93                }
94                errors.push(err);
95            }
96        }
97    }
98}
99
100/// Structural / reference diagnostics: duplicate names (model/struct/enum,
101/// fields within a container, enum variants), relation targets resolve,
102/// struct/enum type references resolve, structs contain only fixed-size types,
103/// and composite-index / projection field references exist.
104///
105/// These are always run (the parser does not gate them behind `use_validation`).
106pub fn collect_structure_errors(schema: &Schema, errors: &mut Vec<ValidationError>) {
107    // Duplicate top-level names.
108    check_duplicate_names(
109        schema.models.iter().map(|m| (m.name.as_str(), m.position)),
110        "model",
111        errors,
112    );
113    check_duplicate_names(
114        schema.structs.iter().map(|s| (s.name.as_str(), s.position)),
115        "struct",
116        errors,
117    );
118    check_duplicate_names(
119        schema.enums.iter().map(|e| (e.name.as_str(), e.position)),
120        "enum",
121        errors,
122    );
123
124    // Structs: fixed-size fields only + duplicate field names.
125    for struct_def in &schema.structs {
126        let mut seen = std::collections::HashSet::new();
127        for field in &struct_def.fields {
128            if !seen.insert(field.name.as_str()) {
129                errors.push(positioned(
130                    format!(
131                        "Duplicate field name '{}' in struct '{}'",
132                        field.name, struct_def.name
133                    ),
134                    field.position,
135                ));
136            }
137            if !field.field_type.is_fixed_size() {
138                errors.push(positioned(
139                    format!(
140                        "Struct '{}' field '{}' contains variable-length type. Structs can only contain fixed-size types.",
141                        struct_def.name, field.name
142                    ),
143                    field.position,
144                ));
145            }
146        }
147    }
148
149    // Enums: duplicate variants.
150    for enum_def in &schema.enums {
151        let mut seen = std::collections::HashSet::new();
152        for variant in &enum_def.variants {
153            if !seen.insert(variant.as_str()) {
154                errors.push(positioned(
155                    format!(
156                        "Duplicate variant '{}' in enum '{}'",
157                        variant, enum_def.name
158                    ),
159                    enum_def.position,
160                ));
161            }
162        }
163    }
164
165    // Models: duplicate fields, relation/type references, composite-index and
166    // projection field references.
167    for model in &schema.models {
168        let field_names: std::collections::HashSet<&str> =
169            model.fields.iter().map(|f| f.name.as_str()).collect();
170
171        let mut seen = std::collections::HashSet::new();
172        for field in &model.fields {
173            if !seen.insert(field.name.as_str()) {
174                errors.push(positioned(
175                    format!(
176                        "Duplicate field name '{}' in model '{}'",
177                        field.name, model.name
178                    ),
179                    field.position,
180                ));
181            }
182
183            // Relation targets must reference a declared model.
184            if let FieldType::Relation(rel) = &field.field_type {
185                let target = match rel {
186                    RelationType::OneToMany(t)
187                    | RelationType::RequiredReference(t)
188                    | RelationType::OptionalReference(t)
189                    | RelationType::ManyToMany(t) => t,
190                };
191                if schema.find_model(target).is_none() {
192                    errors.push(positioned(
193                        format!(
194                            "Model '{}' field '{}' references undefined model '{}'",
195                            model.name, field.name, target
196                        ),
197                        field.position,
198                    ));
199                }
200            }
201
202            // A remaining bare-identifier `StructType` (enum resolution has already
203            // rewritten enum references) must name a declared struct or enum.
204            if let Some(named) = field.field_type.struct_name()
205                && schema.find_struct(named).is_none()
206                && schema.find_enum(named).is_none()
207            {
208                errors.push(positioned(
209                    format!(
210                        "Model '{}' field '{}' references unknown type '{}' (no such struct or enum)",
211                        model.name, field.name, named
212                    ),
213                    field.position,
214                ));
215            }
216        }
217
218        // Composite indexes reference existing fields.
219        for comp_idx in &model.composite_indexes {
220            for field_name in &comp_idx.fields {
221                if !field_names.contains(field_name.as_str()) {
222                    errors.push(positioned(
223                        format!(
224                            "Composite index in model '{}' references undefined field '{}'",
225                            model.name, field_name
226                        ),
227                        model.position,
228                    ));
229                }
230            }
231        }
232
233        // Projections: unique names + referenced fields exist.
234        let mut projection_names = std::collections::HashSet::new();
235        for proj in &model.projections {
236            if !projection_names.insert(proj.name.as_str()) {
237                errors.push(positioned(
238                    format!(
239                        "Duplicate @projection name '{}' in model '{}'",
240                        proj.name, model.name
241                    ),
242                    model.position,
243                ));
244            }
245            for field_name in &proj.fields {
246                if !field_names.contains(field_name.as_str()) {
247                    errors.push(positioned(
248                        format!(
249                            "@projection '{}' in model '{}' references undefined field '{}'",
250                            proj.name, model.name, field_name
251                        ),
252                        model.position,
253                    ));
254                }
255            }
256        }
257    }
258}
259
260/// Build a `ValidationError` with an optional position attached.
261fn positioned(message: String, pos: Option<forgedb_validation::Position>) -> ValidationError {
262    let err = ValidationError::new(message);
263    match pos {
264        Some(p) => err.with_position(p),
265        None => err,
266    }
267}
268
269/// Report every name that appears more than once (after the first), attaching the
270/// duplicate occurrence's position.
271fn check_duplicate_names<'a>(
272    names: impl Iterator<Item = (&'a str, Option<forgedb_validation::Position>)>,
273    kind: &str,
274    errors: &mut Vec<ValidationError>,
275) {
276    let mut seen = std::collections::HashSet::new();
277    for (name, pos) in names {
278        if !seen.insert(name) {
279            errors.push(positioned(
280                format!("Duplicate {} name '{}'", kind, name),
281                pos,
282            ));
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::Parser;
291
292    fn ast(input: &str) -> Schema {
293        Parser::new(input)
294            .unwrap()
295            .parse_unvalidated()
296            .expect("structural parse should succeed")
297    }
298
299    /// The defining capability of the consolidated validator: unlike the parser's
300    /// fail-fast `Result<_, String>`, `validate_schema` collects *every* semantic
301    /// diagnostic in one pass, each carrying a source position for the LSP.
302    #[test]
303    fn validate_schema_collects_all_errors_with_positions() {
304        // Two independent defects in distinct categories: a snake_case violation
305        // (naming) and a dangling relation target (structure).
306        let schema = ast("User {\n  id: +uuid\n  BadField: string\n  friend: *Ghost\n}\n");
307
308        let errors = validate_schema(&schema);
309        assert_eq!(errors.len(), 2, "both defects reported, not just the first: {errors:?}");
310
311        let naming = errors
312            .iter()
313            .find(|e| e.message.contains("snake_case"))
314            .expect("field-naming diagnostic present");
315        assert_eq!(
316            naming.position.map(|p| p.line),
317            Some(3),
318            "naming error points at the offending field's line"
319        );
320
321        let dangling = errors
322            .iter()
323            .find(|e| e.message.contains("references undefined model 'Ghost'"))
324            .expect("dangling-relation diagnostic present");
325        assert_eq!(
326            dangling.position.map(|p| p.line),
327            Some(4),
328            "relation error points at the offending field's line"
329        );
330    }
331
332    /// A fully valid schema yields no diagnostics.
333    #[test]
334    fn validate_schema_accepts_a_valid_schema() {
335        let schema = ast("User {\n  id: +uuid\n  email: string\n}\n");
336        assert!(validate_schema(&schema).is_empty());
337    }
338
339    /// Naming vs. structural split: `collect_structure_errors` alone ignores a
340    /// casing violation (that is the parser's `use_validation`-gated concern),
341    /// but still reports a dangling relation.
342    #[test]
343    fn structure_pass_ignores_naming_but_catches_references() {
344        let schema = ast("User {\n  id: +uuid\n  BadField: string\n  friend: *Ghost\n}\n");
345        let mut errors = Vec::new();
346        collect_structure_errors(&schema, &mut errors);
347        assert_eq!(errors.len(), 1, "only the structural defect: {errors:?}");
348        assert!(errors[0].message.contains("undefined model 'Ghost'"));
349    }
350}