use crate::error::{Error, Result};
use crate::parser::ast::{Block, ConfigBlock, EndpointBlock, GoldFile, QueryBlock, SchemaBlock};
use std::collections::{HashMap, HashSet};
pub struct AstValidator {
strict: bool,
}
impl AstValidator {
pub fn new() -> Self {
Self { strict: false }
}
pub fn strict() -> Self {
Self { strict: true }
}
pub fn validate(&self, file: &GoldFile) -> Result<AstValidationReport> {
let mut report = AstValidationReport::new();
let schema_names = self.collect_schema_names(file);
let query_names = self.collect_query_names(file);
for block in &file.blocks {
self.validate_block(block, &schema_names, &query_names, &mut report);
}
self.check_duplicate_schemas(file, &mut report);
self.check_duplicate_queries(file, &mut report);
self.check_duplicate_endpoints(file, &mut report);
if self.strict && !report.warnings.is_empty() {
for warning in &report.warnings {
report.errors.push(warning.clone());
}
report.warnings.clear();
}
if !report.is_valid() {
return Err(Error::Validation {
message: format!("{} validation error(s) found", report.errors.len()),
context: Some("AST validation".to_string()),
});
}
Ok(report)
}
fn validate_block(
&self,
block: &Block,
schema_names: &HashSet<&str>,
query_names: &HashSet<&str>,
report: &mut AstValidationReport,
) {
match block {
Block::Schema(schema) => self.validate_schema(schema, report),
Block::Query(query) => self.validate_query(query, schema_names, report),
Block::Config(config) => self.validate_config(config, report),
Block::Endpoint(endpoint) => self.validate_endpoint(endpoint, query_names, report),
Block::Data(_) => {} Block::Custom(_) => {} }
}
fn validate_schema(&self, schema: &SchemaBlock, report: &mut AstValidationReport) {
if schema.name.is_empty() {
report.add_error("Schema name cannot be empty");
}
if schema.fields.is_empty() && schema.content.is_none() {
report.add_warning(&format!(
"Schema '{}' has no fields or content",
schema.name
));
}
for field in &schema.fields {
if field.name.is_empty() {
report.add_error(&format!("Field in schema '{}' has empty name", schema.name));
}
if field.field_type.is_empty() {
report.add_error(&format!(
"Field '{}' in schema '{}' has empty type",
field.name, schema.name
));
}
}
let mut field_names = HashSet::new();
for field in &schema.fields {
if !field_names.insert(&field.name) {
report.add_error(&format!(
"Duplicate field '{}' in schema '{}'",
field.name, schema.name
));
}
}
}
fn validate_query(
&self,
query: &QueryBlock,
schema_names: &HashSet<&str>,
report: &mut AstValidationReport,
) {
if query.name.is_empty() {
report.add_error("Query name cannot be empty");
}
if query.source.is_empty() {
report.add_error(&format!("Query '{}' has empty source", query.name));
}
if query.language.is_empty() {
report.add_warning(&format!(
"Query '{}' has no language specified (will default to 'hyperql')",
query.name
));
}
let mut param_names = HashSet::new();
for param in &query.params {
if param.name.is_empty() {
report.add_error(&format!(
"Parameter in query '{}' has empty name",
query.name
));
}
if !param_names.insert(¶m.name) {
report.add_error(&format!(
"Duplicate parameter '{}' in query '{}'",
param.name, query.name
));
}
if param.param_type.is_empty() {
report.add_error(&format!(
"Parameter '{}' in query '{}' has empty type",
param.name, query.name
));
}
}
if !query.return_type.is_empty() {
let base_type = extract_base_type(&query.return_type);
if !is_primitive_type(base_type) && !schema_names.contains(base_type) {
report.add_warning(&format!(
"Query '{}' return type '{}' may reference undefined schema",
query.name, base_type
));
}
}
}
fn validate_config(&self, config: &ConfigBlock, report: &mut AstValidationReport) {
if config.name.is_empty() {
report.add_error("Config block name cannot be empty");
}
if config.attributes.is_empty() {
report.add_warning(&format!("Config '{}' has no attributes", config.name));
}
}
fn validate_endpoint(
&self,
endpoint: &EndpointBlock,
query_names: &HashSet<&str>,
report: &mut AstValidationReport,
) {
if endpoint.method.is_empty() {
report.add_error(&format!("Endpoint '{}' has empty method", endpoint.path));
}
if endpoint.path.is_empty() {
report.add_error("Endpoint has empty path");
}
let valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
if !valid_methods.contains(&endpoint.method.as_str()) {
report.add_warning(&format!(
"Endpoint '{}' has unusual HTTP method '{}'",
endpoint.path, endpoint.method
));
}
if !endpoint.query.is_empty() && !query_names.contains(endpoint.query.as_str()) {
report.add_error(&format!(
"Endpoint '{}' references undefined query '{}'",
endpoint.path, endpoint.query
));
}
if !endpoint.path.starts_with('/') {
report.add_warning(&format!(
"Endpoint path '{}' should start with '/'",
endpoint.path
));
}
}
fn collect_schema_names<'a>(&self, file: &'a GoldFile) -> HashSet<&'a str> {
file.blocks
.iter()
.filter_map(|block| {
if let Block::Schema(schema) = block {
Some(schema.name.as_str())
} else {
None
}
})
.collect()
}
fn collect_query_names<'a>(&self, file: &'a GoldFile) -> HashSet<&'a str> {
file.blocks
.iter()
.filter_map(|block| {
if let Block::Query(query) = block {
Some(query.name.as_str())
} else {
None
}
})
.collect()
}
fn check_duplicate_schemas(&self, file: &GoldFile, report: &mut AstValidationReport) {
let mut seen = HashMap::new();
for block in &file.blocks {
if let Block::Schema(schema) = block {
if let Some(_) = seen.insert(&schema.name, ()) {
report.add_error(&format!("Duplicate schema name '{}'", schema.name));
}
}
}
}
fn check_duplicate_queries(&self, file: &GoldFile, report: &mut AstValidationReport) {
let mut seen = HashMap::new();
for block in &file.blocks {
if let Block::Query(query) = block {
if let Some(_) = seen.insert(&query.name, ()) {
report.add_error(&format!("Duplicate query name '{}'", query.name));
}
}
}
}
fn check_duplicate_endpoints(&self, file: &GoldFile, report: &mut AstValidationReport) {
let mut seen = HashSet::new();
for block in &file.blocks {
if let Block::Endpoint(endpoint) = block {
let key = format!("{} {}", endpoint.method, endpoint.path);
if !seen.insert(key.clone()) {
report.add_error(&format!("Duplicate endpoint '{}'", key));
}
}
}
}
}
impl Default for AstValidator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct AstValidationReport {
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl AstValidationReport {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn add_error(&mut self, message: &str) {
self.errors.push(message.to_string());
}
pub fn add_warning(&mut self, message: &str) {
self.warnings.push(message.to_string());
}
pub fn total_issues(&self) -> usize {
self.errors.len() + self.warnings.len()
}
}
impl Default for AstValidationReport {
fn default() -> Self {
Self::new()
}
}
fn extract_base_type(type_str: &str) -> &str {
if let Some(start) = type_str.find('<') {
if let Some(end) = type_str.rfind('>') {
return type_str[start + 1..end].trim();
}
}
type_str
}
fn is_primitive_type(type_name: &str) -> bool {
matches!(
type_name,
"String"
| "i8"
| "i16"
| "i32"
| "i64"
| "i128"
| "u8"
| "u16"
| "u32"
| "u64"
| "u128"
| "f32"
| "f64"
| "bool"
| "char"
| "EntityId"
| "Timestamp"
| "Integer"
| "Float"
| "Boolean"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::ast::{EndpointBlock, Parameter, QueryBlock, SchemaField};
use std::collections::HashMap;
#[test]
fn test_validator_creation() {
let validator = AstValidator::new();
assert!(!validator.strict);
let strict = AstValidator::strict();
assert!(strict.strict);
}
#[test]
fn test_validation_report() {
let mut report = AstValidationReport::new();
assert!(report.is_valid());
assert_eq!(report.total_issues(), 0);
report.add_error("test error");
assert!(!report.is_valid());
assert_eq!(report.errors.len(), 1);
report.add_warning("test warning");
assert_eq!(report.warnings.len(), 1);
assert_eq!(report.total_issues(), 2);
}
#[test]
fn test_validate_empty_schema_name() {
let validator = AstValidator::new();
let mut file = GoldFile::new();
file.add_block(Block::Schema(SchemaBlock {
name: String::new(),
format: None,
fields: Vec::new(),
content: None,
}));
let result = validator.validate(&file);
assert!(result.is_err());
}
#[test]
fn test_validate_duplicate_schemas() {
let validator = AstValidator::new();
let mut file = GoldFile::new();
file.add_block(Block::Schema(SchemaBlock {
name: "User".to_string(),
format: None,
fields: vec![SchemaField {
name: "id".to_string(),
field_type: "EntityId".to_string(),
nullable: false,
default: None,
embedding_annotation: None,
}],
content: None,
}));
file.add_block(Block::Schema(SchemaBlock {
name: "User".to_string(),
format: None,
fields: vec![SchemaField {
name: "name".to_string(),
field_type: "String".to_string(),
nullable: false,
default: None,
embedding_annotation: None,
}],
content: None,
}));
let result = validator.validate(&file);
assert!(result.is_err());
}
#[test]
fn test_validate_duplicate_field_names() {
let validator = AstValidator::new();
let mut file = GoldFile::new();
file.add_block(Block::Schema(SchemaBlock {
name: "User".to_string(),
format: None,
fields: vec![
SchemaField {
name: "id".to_string(),
field_type: "EntityId".to_string(),
nullable: false,
default: None,
embedding_annotation: None,
},
SchemaField {
name: "id".to_string(),
field_type: "String".to_string(),
nullable: false,
default: None,
embedding_annotation: None,
},
],
content: None,
}));
let result = validator.validate(&file);
assert!(result.is_err());
}
#[test]
fn test_validate_query_references_undefined_schema() {
let validator = AstValidator::new();
let mut file = GoldFile::new();
file.add_block(Block::Query(QueryBlock {
name: "get_user".to_string(),
params: vec![Parameter {
name: "id".to_string(),
param_type: "EntityId".to_string(),
}],
return_type: "User".to_string(),
language: "hyperql".to_string(),
source: "SELECT * FROM users".to_string(),
}));
let result = validator.validate(&file);
assert!(result.is_ok());
let report = result.unwrap();
assert!(!report.warnings.is_empty());
}
#[test]
fn test_validate_endpoint_references_undefined_query() {
let validator = AstValidator::new();
let mut file = GoldFile::new();
file.add_block(Block::Endpoint(EndpointBlock {
method: "GET".to_string(),
path: "/api/users".to_string(),
query: "get_users".to_string(),
auth: false,
params: HashMap::new(),
}));
let result = validator.validate(&file);
assert!(result.is_err());
}
#[test]
fn test_validate_valid_file() {
let validator = AstValidator::new();
let mut file = GoldFile::new();
file.add_block(Block::Schema(SchemaBlock {
name: "User".to_string(),
format: None,
fields: vec![SchemaField {
name: "id".to_string(),
field_type: "EntityId".to_string(),
nullable: false,
default: None,
embedding_annotation: None,
}],
content: None,
}));
file.add_block(Block::Query(QueryBlock {
name: "get_user".to_string(),
params: vec![Parameter {
name: "id".to_string(),
param_type: "EntityId".to_string(),
}],
return_type: "User".to_string(),
language: "hyperql".to_string(),
source: "SELECT * FROM users WHERE id = :id".to_string(),
}));
file.add_block(Block::Endpoint(EndpointBlock {
method: "GET".to_string(),
path: "/api/users/:id".to_string(),
query: "get_user".to_string(),
auth: false,
params: HashMap::new(),
}));
let result = validator.validate(&file);
assert!(result.is_ok());
let report = result.unwrap();
assert!(report.is_valid());
}
#[test]
fn test_extract_base_type() {
assert_eq!(extract_base_type("User"), "User");
assert_eq!(extract_base_type("Vec<User>"), "User");
assert_eq!(extract_base_type("Option<String>"), "String");
}
#[test]
fn test_is_primitive_type() {
assert!(is_primitive_type("String"));
assert!(is_primitive_type("i64"));
assert!(is_primitive_type("EntityId"));
assert!(!is_primitive_type("User"));
assert!(!is_primitive_type("CustomType"));
}
}