#![allow(clippy::print_stderr, clippy::print_stdout)]
use clap::Args;
use dampen_core::ir::layout::{Direction, Position};
use dampen_core::{
ir::{AttributeValue, EventKind, WidgetKind},
parser,
parser::style_parser,
};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
use walkdir::WalkDir;
#[derive(Error, Debug)]
pub enum CheckError {
#[error("Directory not found: {0}")]
DirectoryNotFound(PathBuf),
#[error("Parse error in {file}:{line}:{col}: {message}")]
ParseError {
file: PathBuf,
line: u32,
col: u32,
message: String,
},
#[error("XML validation error in {file}:{line}:{col}: {message}")]
XmlValidationError {
file: PathBuf,
line: u32,
col: u32,
message: String,
},
#[error("Invalid widget '{widget}' in {file}:{line}:{col}")]
InvalidWidget {
widget: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Unknown attribute '{attr}' for widget '{widget}' in {file}:{line}:{col}{suggestion}")]
UnknownAttribute {
attr: String,
widget: String,
file: PathBuf,
line: u32,
col: u32,
suggestion: String,
},
#[error("Unknown handler '{handler}' in {file}:{line}:{col}{suggestion}")]
UnknownHandler {
handler: String,
file: PathBuf,
line: u32,
col: u32,
suggestion: String,
},
#[error("Invalid binding field '{field}' in {file}:{line}:{col}")]
InvalidBinding {
field: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Invalid style attribute '{attr}' in {file}:{line}:{col}: {message}")]
InvalidStyleAttribute {
attr: String,
file: PathBuf,
line: u32,
col: u32,
message: String,
},
#[error("Invalid state prefix '{prefix}' in {file}:{line}:{col}")]
InvalidStatePrefix {
prefix: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Invalid style value for '{attr}' in {file}:{line}:{col}: {message}")]
InvalidStyleValue {
attr: String,
file: PathBuf,
line: u32,
col: u32,
message: String,
},
#[error("Invalid layout constraint in {file}:{line}:{col}: {message}")]
InvalidLayoutConstraint {
file: PathBuf,
line: u32,
col: u32,
message: String,
},
#[error("Unknown theme '{theme}' referenced in {file}:{line}:{col}")]
UnknownTheme {
theme: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Unknown style class '{class}' referenced in {file}:{line}:{col}")]
UnknownStyleClass {
class: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Invalid breakpoint attribute '{attr}' in {file}:{line}:{col}")]
InvalidBreakpoint {
attr: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Invalid state attribute '{attr}' in {file}:{line}:{col}")]
InvalidState {
attr: String,
file: PathBuf,
line: u32,
col: u32,
},
#[error("Failed to load handler registry from {path}: {source}")]
HandlerRegistryLoadError {
path: PathBuf,
source: serde_json::Error,
},
#[error("Failed to load model info from {path}: {source}")]
ModelInfoLoadError {
path: PathBuf,
source: serde_json::Error,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Args)]
pub struct CheckArgs {
#[arg(short, long)]
pub input: Option<String>,
#[arg(short, long)]
pub verbose: bool,
#[arg(long)]
pub handlers: Option<String>,
#[arg(long)]
pub model: Option<String>,
#[arg(long)]
pub custom_widgets: Option<String>,
#[arg(long)]
pub strict: bool,
#[arg(long)]
pub show_widget_versions: bool,
}
pub fn resolve_package_ui_path(package_name: &str) -> Option<PathBuf> {
let prefixes = ["examples", "crates", "."];
let suffixes = ["src/ui", "ui"];
for prefix in prefixes {
let package_root = Path::new(prefix).join(package_name);
if !package_root.exists() {
continue;
}
for suffix in suffixes {
let ui_path = package_root.join(suffix);
if ui_path.exists() {
return Some(ui_path);
}
}
}
None
}
pub fn resolve_ui_directory(explicit_input: Option<&str>) -> Result<PathBuf, String> {
if let Some(path) = explicit_input {
let path_buf = PathBuf::from(path);
if path_buf.exists() {
return Ok(path_buf);
} else {
return Err(format!("Specified UI directory does not exist: {}", path));
}
}
let src_ui = PathBuf::from("src/ui");
if src_ui.exists() && src_ui.is_dir() {
return Ok(src_ui);
}
let ui = PathBuf::from("ui");
if ui.exists() && ui.is_dir() {
return Ok(ui);
}
Err("No UI directory found. Please create one of:\n\
- src/ui/ (recommended for Rust projects)\n\
- ui/ (general purpose)\n\n\
Or specify a custom path with --input:\n\
dampen check --input path/to/ui"
.to_string())
}
fn resolve_optional_file(explicit_path: Option<&str>, filename: &str) -> Option<PathBuf> {
if let Some(path) = explicit_path {
let path_buf = PathBuf::from(path);
if path_buf.exists() {
return Some(path_buf);
}
return Some(path_buf);
}
let root_file = PathBuf::from(filename);
if root_file.exists() {
return Some(root_file);
}
let src_file = PathBuf::from("src").join(filename);
if src_file.exists() {
return Some(src_file);
}
None
}
fn display_widget_version_table() {
println!("Widget Version Requirements");
println!("===========================\n");
println!("{:<20} {:<10} Status", "Widget", "Min Version");
println!("{:-<20} {:-<10} {:-<30}", "", "", "");
let widgets = vec![
("column", WidgetKind::Column),
("row", WidgetKind::Row),
("container", WidgetKind::Container),
("scrollable", WidgetKind::Scrollable),
("stack", WidgetKind::Stack),
("text", WidgetKind::Text),
("image", WidgetKind::Image),
("svg", WidgetKind::Svg),
("button", WidgetKind::Button),
("text_input", WidgetKind::TextInput),
("checkbox", WidgetKind::Checkbox),
("slider", WidgetKind::Slider),
("pick_list", WidgetKind::PickList),
("toggler", WidgetKind::Toggler),
("radio", WidgetKind::Radio),
("space", WidgetKind::Space),
("rule", WidgetKind::Rule),
("progress_bar", WidgetKind::ProgressBar),
("combobox", WidgetKind::ComboBox),
("tooltip", WidgetKind::Tooltip),
("grid", WidgetKind::Grid),
("canvas", WidgetKind::Canvas),
("date_picker", WidgetKind::DatePicker),
("time_picker", WidgetKind::TimePicker),
("color_picker", WidgetKind::ColorPicker),
("menu", WidgetKind::Menu),
("menu_item", WidgetKind::MenuItem),
("menu_separator", WidgetKind::MenuSeparator),
("context_menu", WidgetKind::ContextMenu),
("float", WidgetKind::Float),
("data_table", WidgetKind::DataTable),
("data_column", WidgetKind::DataColumn),
];
for (name, widget) in widgets {
let min_version = widget.minimum_version();
let version_str = format!("{}.{}", min_version.major, min_version.minor);
let status = if min_version.minor > 0 {
"Experimental (not fully functional)"
} else {
"Stable"
};
println!("{:<20} {:<10} {}", name, version_str, status);
}
println!("\nNote: Widgets requiring v1.1+ are experimental and may not be fully functional.");
println!("Use 'dampen check' to validate your .dampen files for version compatibility.");
}
pub fn run_checks(input: Option<String>, strict: bool, verbose: bool) -> Result<(), CheckError> {
use crate::commands::check::handlers::HandlerRegistry;
let input_path = resolve_ui_directory(input.as_deref())
.map_err(|msg| CheckError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, msg)))?;
if verbose {
eprintln!("Using UI directory: {}", input_path.display());
}
let handlers_path = resolve_optional_file(None, "handlers.json");
if verbose && let Some(ref path) = handlers_path {
eprintln!("Using handler registry: {}", path.display());
}
let model_path = resolve_optional_file(None, "model.json");
if verbose && let Some(ref path) = model_path {
eprintln!("Using model info: {}", path.display());
}
let handler_registry = if let Some(path) = handlers_path {
let registry = HandlerRegistry::load_from_json(&path).map_err(|e| {
CheckError::HandlerRegistryLoadError {
path: path.clone(),
source: serde_json::Error::io(std::io::Error::other(e.to_string())),
}
})?;
Some(registry)
} else {
None
};
let model_info = if let Some(path) = model_path {
let model =
crate::commands::check::model::ModelInfo::load_from_json(&path).map_err(|e| {
CheckError::ModelInfoLoadError {
path: path.clone(),
source: serde_json::Error::io(std::io::Error::other(e.to_string())),
}
})?;
Some(model)
} else {
None
};
let mut errors = Vec::new();
let mut files_checked = 0;
for entry in WalkDir::new(input_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "dampen")
.unwrap_or(false)
})
{
let file_path = entry.path();
files_checked += 1;
if verbose {
eprintln!("Checking: {}", file_path.display());
}
let content = match fs::read_to_string(file_path) {
Ok(c) => c,
Err(e) => {
errors.push(CheckError::Io(e));
continue;
}
};
validate_xml_declaration(&content, file_path, &mut errors);
if !errors.is_empty() {
continue;
}
if file_path.file_name().is_some_and(|n| n == "theme.dampen") {
if let Err(theme_error) =
dampen_core::parser::theme_parser::parse_theme_document(&content)
{
errors.push(CheckError::XmlValidationError {
file: file_path.to_path_buf(),
line: 1,
col: 1,
message: format!("Theme validation error: {}", theme_error),
});
}
continue;
}
match parser::parse(&content) {
Ok(document) => {
validate_document(
&document,
file_path,
&handler_registry,
&model_info,
&mut errors,
);
validate_references(&document, file_path, &mut errors);
validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
let version_warnings = dampen_core::validate_widget_versions(&document);
if !version_warnings.is_empty() {
for warning in version_warnings {
eprintln!(
"Warning: {} in {}:{}:{}",
warning.format_message(),
file_path.display(),
warning.span.line,
warning.span.column
);
eprintln!(" Suggestion: {}", warning.suggestion());
eprintln!();
}
}
}
Err(parse_error) => {
errors.push(CheckError::ParseError {
file: file_path.to_path_buf(),
line: parse_error.span.line,
col: parse_error.span.column,
message: parse_error.to_string(),
});
}
}
}
if verbose {
eprintln!("Checked {} files", files_checked);
}
if !errors.is_empty() {
let error_label = "error(s)";
eprintln!("Found {} {}:", errors.len(), error_label);
for error in &errors {
let prefix = "ERROR";
eprintln!(" [{}] {}", prefix, error);
}
Err(errors.remove(0))
} else {
if verbose {
let status = if strict {
"✓ All files passed validation (strict mode)"
} else {
"✓ All files passed validation"
};
eprintln!("{}", status);
}
Ok(())
}
}
pub fn execute(args: &CheckArgs) -> Result<(), CheckError> {
if args.show_widget_versions {
display_widget_version_table();
return Ok(());
}
if args.handlers.is_some() || args.model.is_some() || args.custom_widgets.is_some() {
return run_checks_internal(
args.input.clone(),
args.strict,
args.verbose,
args.handlers.clone(),
args.model.clone(),
args.custom_widgets.clone(),
);
}
run_checks(args.input.clone(), args.strict, args.verbose)
}
fn run_checks_internal(
input: Option<String>,
strict: bool,
verbose: bool,
handlers: Option<String>,
model: Option<String>,
_custom_widgets: Option<String>,
) -> Result<(), CheckError> {
use crate::commands::check::handlers::HandlerRegistry;
let input_path = resolve_ui_directory(input.as_deref())
.map_err(|msg| CheckError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, msg)))?;
if verbose {
eprintln!("Using UI directory: {}", input_path.display());
}
let handlers_path = resolve_optional_file(handlers.as_deref(), "handlers.json");
if verbose && let Some(ref path) = handlers_path {
eprintln!("Using handler registry: {}", path.display());
}
let model_path = resolve_optional_file(model.as_deref(), "model.json");
if verbose && let Some(ref path) = model_path {
eprintln!("Using model info: {}", path.display());
}
let handler_registry = if let Some(path) = handlers_path {
let registry = HandlerRegistry::load_from_json(&path).map_err(|e| {
CheckError::HandlerRegistryLoadError {
path: path.clone(),
source: serde_json::Error::io(std::io::Error::other(e.to_string())),
}
})?;
Some(registry)
} else {
None
};
let model_info = if let Some(path) = model_path {
let model =
crate::commands::check::model::ModelInfo::load_from_json(&path).map_err(|e| {
CheckError::ModelInfoLoadError {
path: path.clone(),
source: serde_json::Error::io(std::io::Error::other(e.to_string())),
}
})?;
Some(model)
} else {
None
};
let mut errors = Vec::new();
let mut files_checked = 0;
for entry in WalkDir::new(input_path)
.follow_links(true)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.map(|ext| ext == "dampen")
.unwrap_or(false)
})
{
let file_path = entry.path();
files_checked += 1;
if verbose {
eprintln!("Checking: {}", file_path.display());
}
let content = fs::read_to_string(file_path)?;
validate_xml_declaration(&content, file_path, &mut errors);
if !errors.is_empty() {
continue;
}
if file_path.file_name().is_some_and(|n| n == "theme.dampen") {
if let Err(theme_error) =
dampen_core::parser::theme_parser::parse_theme_document(&content)
{
errors.push(CheckError::XmlValidationError {
file: file_path.to_path_buf(),
line: 1, col: 1,
message: format!("Theme validation error: {}", theme_error),
});
}
continue;
}
match parser::parse(&content) {
Ok(document) => {
validate_document(
&document,
file_path,
&handler_registry,
&model_info,
&mut errors,
);
validate_references(&document, file_path, &mut errors);
validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
let version_warnings = dampen_core::validate_widget_versions(&document);
if !version_warnings.is_empty() {
for warning in version_warnings {
eprintln!(
"Warning: {} in {}:{}:{}",
warning.format_message(),
file_path.display(),
warning.span.line,
warning.span.column
);
eprintln!(" Suggestion: {}", warning.suggestion());
eprintln!();
}
}
}
Err(parse_error) => {
errors.push(CheckError::ParseError {
file: file_path.to_path_buf(),
line: parse_error.span.line,
col: parse_error.span.column,
message: parse_error.to_string(),
});
}
}
}
if verbose {
eprintln!("Checked {} files", files_checked);
}
if !errors.is_empty() {
let error_label = "error(s)"; eprintln!("Found {} {}:", errors.len(), error_label);
for error in &errors {
let prefix = "ERROR"; eprintln!(" [{}] {}", prefix, error);
}
Err(errors.remove(0))
} else {
if verbose {
let status = if strict {
"✓ All files passed validation (strict mode)"
} else {
"✓ All files passed validation"
};
eprintln!("{}", status);
}
Ok(())
}
}
fn validate_xml_declaration(content: &str, file_path: &Path, errors: &mut Vec<CheckError>) {
let trimmed = content.trim_start();
if trimmed.starts_with("<?xml") && !trimmed.starts_with("<?xml version=\"1.0\"") {
errors.push(CheckError::XmlValidationError {
file: file_path.to_path_buf(),
line: 1,
col: 1,
message:
"Invalid XML declaration. Expected: <?xml version=\"1.0\" ... ?> or no declaration"
.to_string(),
});
}
}
fn validate_document(
document: &dampen_core::ir::DampenDocument,
file_path: &Path,
handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
model_info: &Option<crate::commands::check::model::ModelInfo>,
errors: &mut Vec<CheckError>,
) {
use crate::commands::check::cross_widget::RadioGroupValidator;
let valid_widgets: HashSet<String> = WidgetKind::all_variants()
.iter()
.map(|w| format!("{}", w).to_lowercase())
.collect();
let mut radio_validator = RadioGroupValidator::new();
validate_widget_node(
&document.root,
file_path,
&valid_widgets,
handler_registry,
model_info,
&mut radio_validator,
errors,
);
let radio_errors = radio_validator.validate();
for error in radio_errors {
match error {
crate::commands::check::errors::CheckError::DuplicateRadioValue {
value,
group,
file,
line,
col,
first_file,
first_line,
first_col,
} => {
errors.push(CheckError::XmlValidationError {
file: file.clone(),
line,
col,
message: format!(
"Duplicate radio value '{}' in group '{}'. First occurrence: {}:{}:{}",
value,
group,
first_file.display(),
first_line,
first_col
),
});
}
crate::commands::check::errors::CheckError::InconsistentRadioHandlers {
group,
file,
line,
col,
handlers,
} => {
errors.push(CheckError::XmlValidationError {
file: file.clone(),
line,
col,
message: format!(
"Radio group '{}' has inconsistent on_select handlers. Found handlers: {}",
group, handlers
),
});
}
_ => {}
}
}
validate_tree_views(&document.root, file_path, errors);
}
fn validate_tree_views(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
errors: &mut Vec<CheckError>,
) {
use crate::commands::check::tree_view::TreeViewValidator;
if matches!(node.kind, WidgetKind::TreeView) {
let mut validator = TreeViewValidator::new();
validator.set_file(file_path.to_path_buf());
validator.validate_tree_view(node);
for error in validator.errors() {
match error {
crate::commands::check::errors::CheckError::DuplicateTreeNodeId {
id,
file,
line,
col,
first_file,
first_line,
first_col,
} => {
errors.push(CheckError::XmlValidationError {
file: file.clone(),
line: *line,
col: *col,
message: format!(
"Duplicate tree node ID '{}'. First occurrence: {}:{}:{}",
id,
first_file.display(),
first_line,
first_col
),
});
}
crate::commands::check::errors::CheckError::MissingRequiredAttribute {
attr,
widget,
file,
line,
col,
} => {
errors.push(CheckError::XmlValidationError {
file: file.clone(),
line: *line,
col: *col,
message: format!(
"Missing required attribute '{}' for widget '{}'",
attr, widget
),
});
}
_ => {}
}
}
}
for child in &node.children {
validate_tree_views(child, file_path, errors);
}
}
fn validate_widget_node(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
valid_widgets: &HashSet<String>,
handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
model_info: &Option<crate::commands::check::model::ModelInfo>,
radio_validator: &mut crate::commands::check::cross_widget::RadioGroupValidator,
errors: &mut Vec<CheckError>,
) {
use crate::commands::check::attributes;
use crate::commands::check::suggestions;
let widget_name = format!("{}", node.kind).to_lowercase();
if !valid_widgets.contains(&widget_name) && !matches!(node.kind, WidgetKind::Custom(_)) {
errors.push(CheckError::InvalidWidget {
widget: widget_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
});
}
let mut attr_names: Vec<String> = node.attributes.keys().map(|s| s.to_string()).collect();
if node.id.is_some() {
attr_names.push("id".to_string());
}
let unknown_attrs = attributes::validate_widget_attributes(&node.kind, &attr_names);
for (attr, _suggestion_opt) in unknown_attrs {
let schema = attributes::WidgetAttributeSchema::for_widget(&node.kind);
let all_valid = schema.all_valid_names();
let suggestion = suggestions::suggest(&attr, &all_valid, 3);
errors.push(CheckError::UnknownAttribute {
attr,
widget: widget_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
suggestion,
});
}
let missing_required = attributes::validate_required_attributes(&node.kind, &attr_names);
for missing_attr in missing_required {
errors.push(CheckError::XmlValidationError {
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: format!(
"Missing required attribute '{}' for widget '{}'",
missing_attr, widget_name
),
});
}
if let Some(registry) = handler_registry {
for event_binding in &node.events {
if !registry.contains(&event_binding.handler) {
let all_handler_names = registry.all_names();
let handler_refs: Vec<&str> =
all_handler_names.iter().map(|s| s.as_str()).collect();
let suggestion = suggestions::suggest(&event_binding.handler, &handler_refs, 3);
errors.push(CheckError::UnknownHandler {
handler: event_binding.handler.clone(),
file: file_path.to_path_buf(),
line: event_binding.span.line,
col: event_binding.span.column,
suggestion,
});
}
}
} else {
for event_binding in &node.events {
if event_binding.handler.is_empty() {
errors.push(CheckError::UnknownHandler {
handler: "<empty>".to_string(),
file: file_path.to_path_buf(),
line: event_binding.span.line,
col: event_binding.span.column,
suggestion: String::new(),
});
}
}
}
if let Some(model) = model_info {
for (attr_name, attr_value) in &node.attributes {
validate_attribute_bindings(
attr_name,
attr_value,
file_path,
node.span.line,
node.span.column,
model,
errors,
);
}
}
for attr_value in node.attributes.values() {
validate_attribute_value(
attr_value,
file_path,
node.span.line,
node.span.column,
errors,
);
}
if matches!(node.kind, WidgetKind::Radio) {
let group_id = node.id.as_deref().unwrap_or("default");
let value = node
.attributes
.get("value")
.and_then(|v| match v {
AttributeValue::Static(s) => Some(s.as_str()),
_ => None,
})
.unwrap_or("");
let handler = node
.events
.iter()
.find(|e| e.event == EventKind::Select)
.map(|e| e.handler.clone());
radio_validator.add_radio(
group_id,
value,
file_path.to_str().unwrap_or("unknown"),
node.span.line,
node.span.column,
handler,
);
}
for child in &node.children {
validate_widget_node(
child,
file_path,
valid_widgets,
handler_registry,
model_info,
radio_validator,
errors,
);
}
}
fn validate_attribute_bindings(
_attr_name: &str,
value: &dampen_core::ir::AttributeValue,
file_path: &Path,
line: u32,
col: u32,
model: &crate::commands::check::model::ModelInfo,
errors: &mut Vec<CheckError>,
) {
if let dampen_core::ir::AttributeValue::Binding(binding_expr) = value {
validate_expr_fields(&binding_expr.expr, file_path, line, col, model, errors);
}
}
fn validate_expr_fields(
expr: &dampen_core::expr::Expr,
file_path: &Path,
line: u32,
col: u32,
model: &crate::commands::check::model::ModelInfo,
errors: &mut Vec<CheckError>,
) {
match expr {
dampen_core::expr::Expr::FieldAccess(field_access) => {
let field_parts: Vec<&str> = field_access.path.iter().map(|s| s.as_str()).collect();
if !model.contains_field(&field_parts) {
let all_paths = model.all_field_paths();
let available = if all_paths.len() > 5 {
format!("{} ({} total)", &all_paths[..5].join(", "), all_paths.len())
} else {
all_paths.join(", ")
};
let field_path = field_access.path.join(".");
errors.push(CheckError::InvalidBinding {
field: field_path,
file: file_path.to_path_buf(),
line,
col,
});
eprintln!(" Available fields: {}", available);
}
}
dampen_core::expr::Expr::MethodCall(method_call) => {
validate_expr_fields(&method_call.receiver, file_path, line, col, model, errors);
for arg in &method_call.args {
validate_expr_fields(arg, file_path, line, col, model, errors);
}
}
dampen_core::expr::Expr::BinaryOp(binary_op) => {
validate_expr_fields(&binary_op.left, file_path, line, col, model, errors);
validate_expr_fields(&binary_op.right, file_path, line, col, model, errors);
}
dampen_core::expr::Expr::UnaryOp(unary_op) => {
validate_expr_fields(&unary_op.operand, file_path, line, col, model, errors);
}
dampen_core::expr::Expr::Conditional(conditional) => {
validate_expr_fields(&conditional.condition, file_path, line, col, model, errors);
validate_expr_fields(
&conditional.then_branch,
file_path,
line,
col,
model,
errors,
);
validate_expr_fields(
&conditional.else_branch,
file_path,
line,
col,
model,
errors,
);
}
dampen_core::expr::Expr::Literal(_) => {
}
dampen_core::expr::Expr::SharedFieldAccess(shared_access) => {
if shared_access.path.is_empty() || shared_access.path.iter().any(|f| f.is_empty()) {
errors.push(CheckError::InvalidBinding {
field: "shared.<empty>".to_string(),
file: file_path.to_path_buf(),
line,
col,
});
}
}
}
}
fn validate_attribute_value(
value: &dampen_core::ir::AttributeValue,
file_path: &Path,
line: u32,
col: u32,
errors: &mut Vec<CheckError>,
) {
match value {
dampen_core::ir::AttributeValue::Static(_) => {
}
dampen_core::ir::AttributeValue::Binding(binding_expr) => {
validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
}
dampen_core::ir::AttributeValue::Interpolated(parts) => {
for part in parts {
match part {
dampen_core::ir::InterpolatedPart::Literal(_) => {
}
dampen_core::ir::InterpolatedPart::Binding(binding_expr) => {
validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
}
}
}
}
}
}
fn validate_binding_expr(
expr: &dampen_core::expr::Expr,
file_path: &Path,
line: u32,
col: u32,
errors: &mut Vec<CheckError>,
) {
match expr {
dampen_core::expr::Expr::FieldAccess(field_access) => {
if field_access.path.is_empty() || field_access.path.iter().any(|f| f.is_empty()) {
errors.push(CheckError::InvalidBinding {
field: "<empty>".to_string(),
file: file_path.to_path_buf(),
line,
col,
});
}
}
dampen_core::expr::Expr::MethodCall(_) => {
}
dampen_core::expr::Expr::BinaryOp(_) => {
}
dampen_core::expr::Expr::UnaryOp(_) => {
}
dampen_core::expr::Expr::Conditional(_) => {
}
dampen_core::expr::Expr::Literal(_) => {
}
dampen_core::expr::Expr::SharedFieldAccess(_) => {
}
}
}
trait WidgetKindExt {
fn all_variants() -> Vec<WidgetKind>;
}
impl WidgetKindExt for WidgetKind {
fn all_variants() -> Vec<WidgetKind> {
vec![
WidgetKind::Column,
WidgetKind::Row,
WidgetKind::Container,
WidgetKind::Scrollable,
WidgetKind::Stack,
WidgetKind::Text,
WidgetKind::Image,
WidgetKind::Svg,
WidgetKind::Button,
WidgetKind::TextInput,
WidgetKind::Checkbox,
WidgetKind::Slider,
WidgetKind::PickList,
WidgetKind::Toggler,
WidgetKind::Space,
WidgetKind::Rule,
WidgetKind::Radio,
WidgetKind::ComboBox,
WidgetKind::ProgressBar,
WidgetKind::Tooltip,
WidgetKind::Grid,
WidgetKind::Canvas,
WidgetKind::CanvasRect,
WidgetKind::CanvasCircle,
WidgetKind::CanvasLine,
WidgetKind::CanvasText,
WidgetKind::CanvasGroup,
WidgetKind::DatePicker,
WidgetKind::TimePicker,
WidgetKind::ColorPicker,
WidgetKind::Menu,
WidgetKind::MenuItem,
WidgetKind::MenuSeparator,
WidgetKind::ContextMenu,
WidgetKind::Float,
WidgetKind::DataTable,
WidgetKind::DataColumn,
WidgetKind::TreeView,
WidgetKind::TreeNode,
WidgetKind::TabBar,
WidgetKind::Tab,
WidgetKind::For,
WidgetKind::If,
]
}
}
fn validate_references(
document: &dampen_core::ir::DampenDocument,
file_path: &Path,
errors: &mut Vec<CheckError>,
) {
if let Some(global_theme) = &document.global_theme
&& !document.themes.contains_key(global_theme)
{
errors.push(CheckError::UnknownTheme {
theme: global_theme.clone(),
file: file_path.to_path_buf(),
line: 1,
col: 1,
});
}
for (name, theme) in &document.themes {
if let Err(msg) = theme.validate(false) {
if msg.contains("circular") || msg.contains("Circular") {
errors.push(CheckError::XmlValidationError {
file: file_path.to_path_buf(),
line: 1,
col: 1,
message: format!("Theme '{}' validation error: {}", name, msg),
});
} else {
errors.push(CheckError::InvalidStyleValue {
attr: format!("theme '{}'", name),
file: file_path.to_path_buf(),
line: 1,
col: 1,
message: msg,
});
}
}
}
for (name, class) in &document.style_classes {
if let Err(msg) = class.validate(&document.style_classes) {
if msg.contains("circular") || msg.contains("Circular") {
errors.push(CheckError::XmlValidationError {
file: file_path.to_path_buf(),
line: 1,
col: 1,
message: format!("Style class '{}' has circular dependency: {}", name, msg),
});
} else {
errors.push(CheckError::InvalidStyleValue {
attr: format!("class '{}'", name),
file: file_path.to_path_buf(),
line: 1,
col: 1,
message: msg,
});
}
}
}
}
fn validate_widget_with_styles(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
document: &dampen_core::ir::DampenDocument,
errors: &mut Vec<CheckError>,
) {
if let Some(style) = &node.style
&& let Err(msg) = style.validate()
{
errors.push(CheckError::InvalidStyleValue {
attr: "structured style".to_string(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
if let Some(layout) = &node.layout
&& let Err(msg) = layout.validate()
{
errors.push(CheckError::InvalidLayoutConstraint {
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
for class_name in &node.classes {
if !document.style_classes.contains_key(class_name) {
errors.push(CheckError::UnknownStyleClass {
class: class_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
});
}
}
if let Some(theme_ref) = &node.theme_ref {
match theme_ref {
AttributeValue::Static(theme_name) => {
if !document.themes.contains_key(theme_name) {
errors.push(CheckError::UnknownTheme {
theme: theme_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
});
}
}
AttributeValue::Binding(_) | AttributeValue::Interpolated(_) => {
}
}
}
validate_style_attributes(node, file_path, errors);
validate_layout_attributes(node, file_path, errors);
validate_breakpoint_attributes(node, file_path, errors);
validate_state_attributes(node, file_path, errors);
for child in &node.children {
validate_widget_with_styles(child, file_path, document, errors);
}
}
fn validate_style_attributes(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
errors: &mut Vec<CheckError>,
) {
for (attr_name, attr_value) in &node.attributes {
match attr_name.as_str() {
"background" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_background_attr(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"color" | "border_color" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_color_attr(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"border_width" | "opacity" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_float_attr(value, attr_name)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"border_radius" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_border_radius(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"border_style" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_border_style(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"shadow" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_shadow_attr(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"transform" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_transform(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
_ => {} }
}
}
fn validate_layout_attributes(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
errors: &mut Vec<CheckError>,
) {
for (attr_name, attr_value) in &node.attributes {
match attr_name.as_str() {
"width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_length_attr(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"padding" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_padding_attr(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"spacing" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_spacing(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"align_items" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_alignment(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"justify_content" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_justification(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"direction" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = Direction::parse(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"position" => {
if !matches!(node.kind, WidgetKind::Tooltip)
&& let AttributeValue::Static(value) = attr_value
&& let Err(msg) = Position::parse(value)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"top" | "right" | "bottom" | "left" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_float_attr(value, attr_name)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
"z_index" => {
if let AttributeValue::Static(value) = attr_value
&& let Err(msg) = style_parser::parse_int_attr(value, attr_name)
{
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
_ => {} }
}
}
fn validate_breakpoint_attributes(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
errors: &mut Vec<CheckError>,
) {
for (breakpoint, attrs) in &node.breakpoint_attributes {
for (attr_name, attr_value) in attrs {
let base_attr = attr_name.as_str();
let full_attr = format!("{:?}:{}", breakpoint, base_attr);
let is_style_attr = matches!(
base_attr,
"background"
| "color"
| "border_width"
| "border_color"
| "border_radius"
| "border_style"
| "shadow"
| "opacity"
| "transform"
);
let is_layout_attr = matches!(
base_attr,
"width"
| "height"
| "min_width"
| "max_width"
| "min_height"
| "max_height"
| "padding"
| "spacing"
| "align_items"
| "justify_content"
| "direction"
| "position"
| "top"
| "right"
| "bottom"
| "left"
| "z_index"
);
if !is_style_attr && !is_layout_attr {
errors.push(CheckError::InvalidBreakpoint {
attr: full_attr,
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
});
continue;
}
if let AttributeValue::Static(value) = attr_value {
let result: Result<(), String> = match base_attr {
"background" => style_parser::parse_background_attr(value).map(|_| ()),
"color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
"border_width" | "opacity" => {
style_parser::parse_float_attr(value, base_attr).map(|_| ())
}
"border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
"border_style" => style_parser::parse_border_style(value).map(|_| ()),
"shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
"transform" => style_parser::parse_transform(value).map(|_| ()),
"width" | "height" | "min_width" | "max_width" | "min_height"
| "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
"padding" => style_parser::parse_padding_attr(value).map(|_| ()),
"spacing" => style_parser::parse_spacing(value).map(|_| ()),
"align_items" => style_parser::parse_alignment(value).map(|_| ()),
"justify_content" => style_parser::parse_justification(value).map(|_| ()),
"direction" => Direction::parse(value).map(|_| ()),
"position" => Position::parse(value).map(|_| ()),
"top" | "right" | "bottom" | "left" => {
style_parser::parse_float_attr(value, base_attr).map(|_| ())
}
"z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
_ => Ok(()),
};
if let Err(msg) = result {
errors.push(CheckError::InvalidStyleValue {
attr: full_attr,
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
}
}
}
fn validate_state_attributes(
node: &dampen_core::ir::WidgetNode,
file_path: &Path,
errors: &mut Vec<CheckError>,
) {
for (attr_name, attr_value) in &node.attributes {
if attr_name.contains(':') {
let parts: Vec<&str> = attr_name.split(':').collect();
if parts.len() >= 2 {
let prefix = parts[0];
let base_attr = parts[1];
if !["hover", "focus", "active", "disabled"].contains(&prefix) {
errors.push(CheckError::InvalidState {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
});
continue;
}
let is_valid_attr = matches!(
base_attr,
"background"
| "color"
| "border_width"
| "border_color"
| "border_radius"
| "border_style"
| "shadow"
| "opacity"
| "transform"
| "width"
| "height"
| "min_width"
| "max_width"
| "min_height"
| "max_height"
| "padding"
| "spacing"
| "align_items"
| "justify_content"
| "direction"
| "position"
| "top"
| "right"
| "bottom"
| "left"
| "z_index"
);
if !is_valid_attr {
errors.push(CheckError::InvalidState {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
});
continue;
}
if let AttributeValue::Static(value) = attr_value {
let result: Result<(), String> = match base_attr {
"background" => style_parser::parse_background_attr(value).map(|_| ()),
"color" | "border_color" => {
style_parser::parse_color_attr(value).map(|_| ())
}
"border_width" | "opacity" => {
style_parser::parse_float_attr(value, base_attr).map(|_| ())
}
"border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
"border_style" => style_parser::parse_border_style(value).map(|_| ()),
"shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
"transform" => style_parser::parse_transform(value).map(|_| ()),
"width" | "height" | "min_width" | "max_width" | "min_height"
| "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
"padding" => style_parser::parse_padding_attr(value).map(|_| ()),
"spacing" => style_parser::parse_spacing(value).map(|_| ()),
"align_items" => style_parser::parse_alignment(value).map(|_| ()),
"justify_content" => style_parser::parse_justification(value).map(|_| ()),
"direction" => Direction::parse(value).map(|_| ()),
"position" => Position::parse(value).map(|_| ()),
"top" | "right" | "bottom" | "left" => {
style_parser::parse_float_attr(value, base_attr).map(|_| ())
}
"z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
_ => Ok(()),
};
if let Err(msg) = result {
errors.push(CheckError::InvalidStyleValue {
attr: attr_name.clone(),
file: file_path.to_path_buf(),
line: node.span.line,
col: node.span.column,
message: msg,
});
}
}
}
}
}
}