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}