dampen_cli/commands/check/
main_command.rs

1#![allow(clippy::print_stderr, clippy::print_stdout)]
2
3//! Check command - validates Dampen UI files
4
5use clap::Args;
6use dampen_core::ir::layout::{Direction, Position};
7use dampen_core::{
8    ir::{AttributeValue, EventKind, WidgetKind},
9    parser,
10    parser::style_parser,
11};
12use std::collections::HashSet;
13use std::fs;
14use std::path::{Path, PathBuf};
15use thiserror::Error;
16use walkdir::WalkDir;
17
18#[derive(Error, Debug)]
19pub enum CheckError {
20    #[error("Directory not found: {0}")]
21    DirectoryNotFound(PathBuf),
22
23    #[error("Parse error in {file}:{line}:{col}: {message}")]
24    ParseError {
25        file: PathBuf,
26        line: u32,
27        col: u32,
28        message: String,
29    },
30
31    #[error("XML validation error in {file}:{line}:{col}: {message}")]
32    XmlValidationError {
33        file: PathBuf,
34        line: u32,
35        col: u32,
36        message: String,
37    },
38
39    #[error("Invalid widget '{widget}' in {file}:{line}:{col}")]
40    InvalidWidget {
41        widget: String,
42        file: PathBuf,
43        line: u32,
44        col: u32,
45    },
46
47    #[error("Unknown attribute '{attr}' for widget '{widget}' in {file}:{line}:{col}{suggestion}")]
48    UnknownAttribute {
49        attr: String,
50        widget: String,
51        file: PathBuf,
52        line: u32,
53        col: u32,
54        suggestion: String,
55    },
56
57    #[error("Unknown handler '{handler}' in {file}:{line}:{col}{suggestion}")]
58    UnknownHandler {
59        handler: String,
60        file: PathBuf,
61        line: u32,
62        col: u32,
63        suggestion: String,
64    },
65
66    #[error("Invalid binding field '{field}' in {file}:{line}:{col}")]
67    InvalidBinding {
68        field: String,
69        file: PathBuf,
70        line: u32,
71        col: u32,
72    },
73
74    #[error("Invalid style attribute '{attr}' in {file}:{line}:{col}: {message}")]
75    InvalidStyleAttribute {
76        attr: String,
77        file: PathBuf,
78        line: u32,
79        col: u32,
80        message: String,
81    },
82
83    #[error("Invalid state prefix '{prefix}' in {file}:{line}:{col}")]
84    InvalidStatePrefix {
85        prefix: String,
86        file: PathBuf,
87        line: u32,
88        col: u32,
89    },
90
91    #[error("Invalid style value for '{attr}' in {file}:{line}:{col}: {message}")]
92    InvalidStyleValue {
93        attr: String,
94        file: PathBuf,
95        line: u32,
96        col: u32,
97        message: String,
98    },
99
100    #[error("Invalid layout constraint in {file}:{line}:{col}: {message}")]
101    InvalidLayoutConstraint {
102        file: PathBuf,
103        line: u32,
104        col: u32,
105        message: String,
106    },
107
108    #[error("Unknown theme '{theme}' referenced in {file}:{line}:{col}")]
109    UnknownTheme {
110        theme: String,
111        file: PathBuf,
112        line: u32,
113        col: u32,
114    },
115
116    #[error("Unknown style class '{class}' referenced in {file}:{line}:{col}")]
117    UnknownStyleClass {
118        class: String,
119        file: PathBuf,
120        line: u32,
121        col: u32,
122    },
123
124    #[error("Invalid breakpoint attribute '{attr}' in {file}:{line}:{col}")]
125    InvalidBreakpoint {
126        attr: String,
127        file: PathBuf,
128        line: u32,
129        col: u32,
130    },
131
132    #[error("Invalid state attribute '{attr}' in {file}:{line}:{col}")]
133    InvalidState {
134        attr: String,
135        file: PathBuf,
136        line: u32,
137        col: u32,
138    },
139
140    #[error("Failed to load handler registry from {path}: {source}")]
141    HandlerRegistryLoadError {
142        path: PathBuf,
143        source: serde_json::Error,
144    },
145
146    #[error("Failed to load model info from {path}: {source}")]
147    ModelInfoLoadError {
148        path: PathBuf,
149        source: serde_json::Error,
150    },
151
152    #[error("IO error: {0}")]
153    Io(#[from] std::io::Error),
154}
155
156#[derive(Args)]
157pub struct CheckArgs {
158    /// Directory containing .dampen files (default: auto-detect src/ui or ui)
159    #[arg(short, long)]
160    pub input: Option<String>,
161
162    /// Enable verbose output
163    #[arg(short, long)]
164    pub verbose: bool,
165
166    /// Path to handler registry JSON (default: auto-discover handlers.json)
167    #[arg(long)]
168    pub handlers: Option<String>,
169
170    /// Path to model info JSON (default: auto-discover model.json)
171    #[arg(long)]
172    pub model: Option<String>,
173
174    /// Path to custom widget configuration JSON file
175    #[arg(long)]
176    pub custom_widgets: Option<String>,
177
178    /// Treat warnings as errors (strict mode for CI/CD)
179    #[arg(long)]
180    pub strict: bool,
181}
182
183/// Resolves the UI directory path using smart detection
184fn resolve_ui_directory(explicit_input: Option<&str>) -> Result<PathBuf, String> {
185    // If explicitly provided, use it
186    if let Some(path) = explicit_input {
187        let path_buf = PathBuf::from(path);
188        if path_buf.exists() {
189            return Ok(path_buf);
190        } else {
191            return Err(format!("Specified UI directory does not exist: {}", path));
192        }
193    }
194
195    // Try src/ui/ (Rust convention)
196    let src_ui = PathBuf::from("src/ui");
197    if src_ui.exists() && src_ui.is_dir() {
198        return Ok(src_ui);
199    }
200
201    // Try ui/ (fallback)
202    let ui = PathBuf::from("ui");
203    if ui.exists() && ui.is_dir() {
204        return Ok(ui);
205    }
206
207    // None found
208    Err("No UI directory found. Please create one of:\n\
209         - src/ui/ (recommended for Rust projects)\n\
210         - ui/ (general purpose)\n\n\
211         Or specify a custom path with --input:\n\
212         dampen check --input path/to/ui"
213        .to_string())
214}
215
216/// Resolves optional file paths with auto-discovery
217fn resolve_optional_file(explicit_path: Option<&str>, filename: &str) -> Option<PathBuf> {
218    // If explicitly provided, use it
219    if let Some(path) = explicit_path {
220        let path_buf = PathBuf::from(path);
221        if path_buf.exists() {
222            return Some(path_buf);
223        }
224        // Note: If explicit path doesn't exist, we'll let the caller handle the error
225        return Some(path_buf);
226    }
227
228    // Try project root
229    let root_file = PathBuf::from(filename);
230    if root_file.exists() {
231        return Some(root_file);
232    }
233
234    // Try src/ directory
235    let src_file = PathBuf::from("src").join(filename);
236    if src_file.exists() {
237        return Some(src_file);
238    }
239
240    // Not found - this is OK for optional files
241    None
242}
243
244pub fn execute(args: &CheckArgs) -> Result<(), CheckError> {
245    use crate::commands::check::handlers::HandlerRegistry;
246
247    // Resolve UI directory
248    let input_path = resolve_ui_directory(args.input.as_deref())
249        .map_err(|msg| CheckError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, msg)))?;
250
251    if args.verbose {
252        eprintln!("Using UI directory: {}", input_path.display());
253    }
254
255    // Resolve optional files
256    let handlers_path = resolve_optional_file(args.handlers.as_deref(), "handlers.json");
257    if args.verbose {
258        if let Some(ref path) = handlers_path {
259            eprintln!("Using handler registry: {}", path.display());
260        }
261    }
262
263    let model_path = resolve_optional_file(args.model.as_deref(), "model.json");
264    if args.verbose {
265        if let Some(ref path) = model_path {
266            eprintln!("Using model info: {}", path.display());
267        }
268    }
269
270    // Load handler registry if provided or auto-discovered (US2: Handler Registry Validation)
271    let handler_registry = if let Some(path) = handlers_path {
272        let registry = HandlerRegistry::load_from_json(&path).map_err(|e| {
273            CheckError::HandlerRegistryLoadError {
274                path: path.clone(),
275                source: serde_json::Error::io(std::io::Error::other(e.to_string())),
276            }
277        })?;
278        Some(registry)
279    } else {
280        None
281    };
282
283    // Load model info if provided or auto-discovered (US3: Binding Validation Against Model)
284    let model_info = if let Some(path) = model_path {
285        let model =
286            crate::commands::check::model::ModelInfo::load_from_json(&path).map_err(|e| {
287                CheckError::ModelInfoLoadError {
288                    path: path.clone(),
289                    source: serde_json::Error::io(std::io::Error::other(e.to_string())),
290                }
291            })?;
292        Some(model)
293    } else {
294        None
295    };
296
297    let mut errors = Vec::new();
298    let mut files_checked = 0;
299
300    // Find all .dampen files
301    for entry in WalkDir::new(input_path)
302        .follow_links(true)
303        .into_iter()
304        .filter_map(|e| e.ok())
305        .filter(|e| {
306            e.path()
307                .extension()
308                .map(|ext| ext == "dampen")
309                .unwrap_or(false)
310        })
311    {
312        let file_path = entry.path();
313        files_checked += 1;
314
315        if args.verbose {
316            eprintln!("Checking: {}", file_path.display());
317        }
318
319        // Read and parse the file
320        let content = fs::read_to_string(file_path)?;
321
322        // First check for XML declaration
323        validate_xml_declaration(&content, file_path, &mut errors);
324
325        // Only proceed to parse if XML declaration is valid
326        if !errors.is_empty() {
327            continue;
328        }
329
330        match parser::parse(&content) {
331            Ok(document) => {
332                // Validate the document structure
333                validate_document(
334                    &document,
335                    file_path,
336                    &handler_registry,
337                    &model_info,
338                    &mut errors,
339                );
340
341                // Validate references (themes, classes)
342                validate_references(&document, file_path, &mut errors);
343
344                // Validate widgets with styles, layout, breakpoints, and states
345                validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
346            }
347            Err(parse_error) => {
348                errors.push(CheckError::ParseError {
349                    file: file_path.to_path_buf(),
350                    line: parse_error.span.line,
351                    col: parse_error.span.column,
352                    message: parse_error.to_string(),
353                });
354            }
355        }
356    }
357
358    if args.verbose {
359        eprintln!("Checked {} files", files_checked);
360    }
361
362    // Report errors
363    if !errors.is_empty() {
364        // T048: Strict mode logic - in strict mode, all validation issues are errors
365        // (Currently all validation issues are already treated as errors, so this is
366        // primarily for future extensibility when we might add warnings)
367        let error_label = "error(s)"; // TODO: differentiate warnings in non-strict mode
368        eprintln!("Found {} {}:", errors.len(), error_label);
369
370        for error in &errors {
371            // T049: Error formatting - distinguish warnings from errors in strict mode
372            let prefix = "ERROR"; // TODO: use "WARNING" for warnings in non-strict mode
373            eprintln!("  [{}] {}", prefix, error);
374        }
375
376        // In strict mode, exit with code 1 on any error
377        // (This is already the default behavior)
378        Err(errors.remove(0))
379    } else {
380        if args.verbose {
381            let status = if args.strict {
382                "✓ All files passed validation (strict mode)"
383            } else {
384                "✓ All files passed validation"
385            };
386            eprintln!("{}", status);
387        }
388        Ok(())
389    }
390}
391
392fn validate_xml_declaration(content: &str, file_path: &Path, errors: &mut Vec<CheckError>) {
393    // Check if content starts with proper XML declaration
394    let trimmed = content.trim_start();
395    if !trimmed.starts_with("<?xml version=\"1.0\"") {
396        errors.push(CheckError::XmlValidationError {
397            file: file_path.to_path_buf(),
398            line: 1,
399            col: 1,
400            message: "Missing or invalid XML declaration. Expected: <?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
401        });
402    }
403}
404
405fn validate_document(
406    document: &dampen_core::ir::DampenDocument,
407    file_path: &Path,
408    handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
409    model_info: &Option<crate::commands::check::model::ModelInfo>,
410    errors: &mut Vec<CheckError>,
411) {
412    use crate::commands::check::cross_widget::RadioGroupValidator;
413
414    // Get all valid widget names
415    let valid_widgets: HashSet<String> = WidgetKind::all_variants()
416        .iter()
417        .map(|w| format!("{:?}", w).to_lowercase())
418        .collect();
419
420    // Create radio group validator to collect radio buttons across the tree
421    let mut radio_validator = RadioGroupValidator::new();
422
423    // Validate each widget in the tree
424    validate_widget_node(
425        &document.root,
426        file_path,
427        &valid_widgets,
428        handler_registry,
429        model_info,
430        &mut radio_validator,
431        errors,
432    );
433
434    // After all widgets are validated, check radio groups for consistency
435    let radio_errors = radio_validator.validate();
436    for error in radio_errors {
437        // Convert cross_widget::CheckError to main_command::CheckError
438        match error {
439            crate::commands::check::errors::CheckError::DuplicateRadioValue {
440                value,
441                group,
442                file,
443                line,
444                col,
445                first_file,
446                first_line,
447                first_col,
448            } => {
449                errors.push(CheckError::XmlValidationError {
450                    file: file.clone(),
451                    line,
452                    col,
453                    message: format!(
454                        "Duplicate radio value '{}' in group '{}'. First occurrence: {}:{}:{}",
455                        value,
456                        group,
457                        first_file.display(),
458                        first_line,
459                        first_col
460                    ),
461                });
462            }
463            crate::commands::check::errors::CheckError::InconsistentRadioHandlers {
464                group,
465                file,
466                line,
467                col,
468                handlers,
469            } => {
470                errors.push(CheckError::XmlValidationError {
471                    file: file.clone(),
472                    line,
473                    col,
474                    message: format!(
475                        "Radio group '{}' has inconsistent on_select handlers. Found handlers: {}",
476                        group, handlers
477                    ),
478                });
479            }
480            _ => {}
481        }
482    }
483}
484
485fn validate_widget_node(
486    node: &dampen_core::ir::WidgetNode,
487    file_path: &Path,
488    valid_widgets: &HashSet<String>,
489    handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
490    model_info: &Option<crate::commands::check::model::ModelInfo>,
491    radio_validator: &mut crate::commands::check::cross_widget::RadioGroupValidator,
492    errors: &mut Vec<CheckError>,
493) {
494    use crate::commands::check::attributes;
495    use crate::commands::check::suggestions;
496
497    // Check if widget kind is valid
498    let widget_name = format!("{:?}", node.kind).to_lowercase();
499    if !valid_widgets.contains(&widget_name) && !matches!(node.kind, WidgetKind::Custom(_)) {
500        errors.push(CheckError::InvalidWidget {
501            widget: widget_name.clone(),
502            file: file_path.to_path_buf(),
503            line: node.span.line,
504            col: node.span.column,
505        });
506    }
507
508    // Validate widget attributes (US1: Unknown Attribute Detection)
509    let attr_names: Vec<String> = node.attributes.keys().map(|s| s.to_string()).collect();
510    let unknown_attrs = attributes::validate_widget_attributes(&node.kind, &attr_names);
511
512    for (attr, _suggestion_opt) in unknown_attrs {
513        let schema = attributes::WidgetAttributeSchema::for_widget(&node.kind);
514        let all_valid = schema.all_valid_names();
515        let suggestion = suggestions::suggest(&attr, &all_valid, 3);
516
517        errors.push(CheckError::UnknownAttribute {
518            attr,
519            widget: widget_name.clone(),
520            file: file_path.to_path_buf(),
521            line: node.span.line,
522            col: node.span.column,
523            suggestion,
524        });
525    }
526
527    // Validate required attributes (US7: Required Attribute Validation)
528    let missing_required = attributes::validate_required_attributes(&node.kind, &attr_names);
529    for missing_attr in missing_required {
530        errors.push(CheckError::XmlValidationError {
531            file: file_path.to_path_buf(),
532            line: node.span.line,
533            col: node.span.column,
534            message: format!(
535                "Missing required attribute '{}' for widget '{}'",
536                missing_attr, widget_name
537            ),
538        });
539    }
540
541    // Validate event handlers (US2: Handler Registry Validation)
542    if let Some(registry) = handler_registry {
543        for event_binding in &node.events {
544            if !registry.contains(&event_binding.handler) {
545                // Generate suggestion using Levenshtein distance
546                let all_handler_names = registry.all_names();
547                let handler_refs: Vec<&str> =
548                    all_handler_names.iter().map(|s| s.as_str()).collect();
549                let suggestion = suggestions::suggest(&event_binding.handler, &handler_refs, 3);
550
551                errors.push(CheckError::UnknownHandler {
552                    handler: event_binding.handler.clone(),
553                    file: file_path.to_path_buf(),
554                    line: event_binding.span.line,
555                    col: event_binding.span.column,
556                    suggestion,
557                });
558            }
559        }
560    } else {
561        // If no registry provided, only check for empty handlers
562        for event_binding in &node.events {
563            if event_binding.handler.is_empty() {
564                errors.push(CheckError::UnknownHandler {
565                    handler: "<empty>".to_string(),
566                    file: file_path.to_path_buf(),
567                    line: event_binding.span.line,
568                    col: event_binding.span.column,
569                    suggestion: String::new(),
570                });
571            }
572        }
573    }
574
575    // Validate attribute bindings (US3: Binding Validation Against Model)
576    if let Some(model) = model_info {
577        for (attr_name, attr_value) in &node.attributes {
578            validate_attribute_bindings(
579                attr_name,
580                attr_value,
581                file_path,
582                node.span.line,
583                node.span.column,
584                model,
585                errors,
586            );
587        }
588    }
589
590    // Validate attribute values (style, layout, etc.)
591    for attr_value in node.attributes.values() {
592        validate_attribute_value(
593            attr_value,
594            file_path,
595            node.span.line,
596            node.span.column,
597            errors,
598        );
599    }
600
601    // Collect radio button information for cross-widget validation (US4: Radio Group Validation)
602    if matches!(node.kind, WidgetKind::Radio) {
603        // Extract radio button attributes
604        let group_id = node
605            .attributes
606            .get("id")
607            .and_then(|v| match v {
608                AttributeValue::Static(s) => Some(s.as_str()),
609                _ => None,
610            })
611            .unwrap_or("default");
612
613        let value = node
614            .attributes
615            .get("value")
616            .and_then(|v| match v {
617                AttributeValue::Static(s) => Some(s.as_str()),
618                _ => None,
619            })
620            .unwrap_or("");
621
622        // Find on_select handler
623        let handler = node
624            .events
625            .iter()
626            .find(|e| e.event == EventKind::Select)
627            .map(|e| e.handler.clone());
628
629        radio_validator.add_radio(
630            group_id,
631            value,
632            file_path.to_str().unwrap_or("unknown"),
633            node.span.line,
634            node.span.column,
635            handler,
636        );
637    }
638
639    // Recursively validate children
640    for child in &node.children {
641        validate_widget_node(
642            child,
643            file_path,
644            valid_widgets,
645            handler_registry,
646            model_info,
647            radio_validator,
648            errors,
649        );
650    }
651}
652
653fn validate_attribute_bindings(
654    _attr_name: &str,
655    value: &dampen_core::ir::AttributeValue,
656    file_path: &Path,
657    line: u32,
658    col: u32,
659    model: &crate::commands::check::model::ModelInfo,
660    errors: &mut Vec<CheckError>,
661) {
662    // Only validate binding expressions
663    if let dampen_core::ir::AttributeValue::Binding(binding_expr) = value {
664        // Validate field access in the expression
665        validate_expr_fields(&binding_expr.expr, file_path, line, col, model, errors);
666    }
667}
668
669fn validate_expr_fields(
670    expr: &dampen_core::expr::Expr,
671    file_path: &Path,
672    line: u32,
673    col: u32,
674    model: &crate::commands::check::model::ModelInfo,
675    errors: &mut Vec<CheckError>,
676) {
677    match expr {
678        dampen_core::expr::Expr::FieldAccess(field_access) => {
679            // Convert Vec<String> to Vec<&str>
680            let field_parts: Vec<&str> = field_access.path.iter().map(|s| s.as_str()).collect();
681
682            if !model.contains_field(&field_parts) {
683                // Generate available fields list
684                let all_paths = model.all_field_paths();
685                let available = if all_paths.len() > 5 {
686                    format!("{} ({} total)", &all_paths[..5].join(", "), all_paths.len())
687                } else {
688                    all_paths.join(", ")
689                };
690
691                let field_path = field_access.path.join(".");
692
693                errors.push(CheckError::InvalidBinding {
694                    field: field_path,
695                    file: file_path.to_path_buf(),
696                    line,
697                    col,
698                });
699
700                // Add more detailed error with available fields
701                eprintln!("  Available fields: {}", available);
702            }
703        }
704        dampen_core::expr::Expr::MethodCall(method_call) => {
705            // Validate the receiver expression
706            validate_expr_fields(&method_call.receiver, file_path, line, col, model, errors);
707            // Validate arguments
708            for arg in &method_call.args {
709                validate_expr_fields(arg, file_path, line, col, model, errors);
710            }
711        }
712        dampen_core::expr::Expr::BinaryOp(binary_op) => {
713            // Validate both sides of the binary operation
714            validate_expr_fields(&binary_op.left, file_path, line, col, model, errors);
715            validate_expr_fields(&binary_op.right, file_path, line, col, model, errors);
716        }
717        dampen_core::expr::Expr::UnaryOp(unary_op) => {
718            // Validate the operand
719            validate_expr_fields(&unary_op.operand, file_path, line, col, model, errors);
720        }
721        dampen_core::expr::Expr::Conditional(conditional) => {
722            // Validate all parts of the conditional
723            validate_expr_fields(&conditional.condition, file_path, line, col, model, errors);
724            validate_expr_fields(
725                &conditional.then_branch,
726                file_path,
727                line,
728                col,
729                model,
730                errors,
731            );
732            validate_expr_fields(
733                &conditional.else_branch,
734                file_path,
735                line,
736                col,
737                model,
738                errors,
739            );
740        }
741        dampen_core::expr::Expr::Literal(_) => {
742            // Literals don't reference fields, nothing to validate
743        }
744    }
745}
746
747fn validate_attribute_value(
748    value: &dampen_core::ir::AttributeValue,
749    file_path: &Path,
750    line: u32,
751    col: u32,
752    errors: &mut Vec<CheckError>,
753) {
754    match value {
755        dampen_core::ir::AttributeValue::Static(_) => {
756            // Static values are always valid
757        }
758        dampen_core::ir::AttributeValue::Binding(binding_expr) => {
759            // For now, we'll do basic validation of the binding expression
760            // In a real implementation, we'd check against the model fields
761            validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
762        }
763        dampen_core::ir::AttributeValue::Interpolated(parts) => {
764            for part in parts {
765                match part {
766                    dampen_core::ir::InterpolatedPart::Literal(_) => {
767                        // Literals are always valid
768                    }
769                    dampen_core::ir::InterpolatedPart::Binding(binding_expr) => {
770                        validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
771                    }
772                }
773            }
774        }
775    }
776}
777
778fn validate_binding_expr(
779    expr: &dampen_core::expr::Expr,
780    file_path: &Path,
781    line: u32,
782    col: u32,
783    errors: &mut Vec<CheckError>,
784) {
785    match expr {
786        dampen_core::expr::Expr::FieldAccess(field_access) => {
787            // For now, we'll assume any field name is valid
788            // In a real implementation, we'd check against the model fields
789            if field_access.path.is_empty() || field_access.path.iter().any(|f| f.is_empty()) {
790                errors.push(CheckError::InvalidBinding {
791                    field: "<empty>".to_string(),
792                    file: file_path.to_path_buf(),
793                    line,
794                    col,
795                });
796            }
797        }
798        dampen_core::expr::Expr::MethodCall(_) => {
799            // Method calls are generally valid if the method exists
800            // For now, we'll assume they're valid
801        }
802        dampen_core::expr::Expr::BinaryOp(_) => {
803            // Binary operations are valid if both operands are valid
804            // We'd need to recursively validate the operands
805        }
806        dampen_core::expr::Expr::UnaryOp(_) => {
807            // Unary operations are valid if the operand is valid
808        }
809        dampen_core::expr::Expr::Conditional(_) => {
810            // Conditionals are valid if all parts are valid
811        }
812        dampen_core::expr::Expr::Literal(_) => {
813            // Literals are always valid
814        }
815    }
816}
817
818// Helper extension to get all widget variants
819trait WidgetKindExt {
820    fn all_variants() -> Vec<WidgetKind>;
821}
822
823impl WidgetKindExt for WidgetKind {
824    fn all_variants() -> Vec<WidgetKind> {
825        vec![
826            WidgetKind::Column,
827            WidgetKind::Row,
828            WidgetKind::Container,
829            WidgetKind::Scrollable,
830            WidgetKind::Stack,
831            WidgetKind::Text,
832            WidgetKind::Image,
833            WidgetKind::Svg,
834            WidgetKind::Button,
835            WidgetKind::TextInput,
836            WidgetKind::Checkbox,
837            WidgetKind::Slider,
838            WidgetKind::PickList,
839            WidgetKind::Toggler,
840            WidgetKind::Space,
841            WidgetKind::Rule,
842            WidgetKind::Radio,
843            WidgetKind::ComboBox,
844            WidgetKind::ProgressBar,
845            WidgetKind::Tooltip,
846            WidgetKind::Grid,
847            WidgetKind::Canvas,
848            WidgetKind::Float,
849            WidgetKind::For,
850        ]
851    }
852}
853
854/// Validate all references (themes, classes) in the document
855fn validate_references(
856    document: &dampen_core::ir::DampenDocument,
857    file_path: &Path,
858    errors: &mut Vec<CheckError>,
859) {
860    // Validate global theme reference
861    if let Some(global_theme) = &document.global_theme {
862        if !document.themes.contains_key(global_theme) {
863            errors.push(CheckError::UnknownTheme {
864                theme: global_theme.clone(),
865                file: file_path.to_path_buf(),
866                line: 1,
867                col: 1,
868            });
869        }
870    }
871
872    // Validate each theme definition (US5: Theme Property Validation)
873    for (name, theme) in &document.themes {
874        if let Err(msg) = theme.validate() {
875            // Check if it's a circular dependency error
876            if msg.contains("circular") || msg.contains("Circular") {
877                errors.push(CheckError::XmlValidationError {
878                    file: file_path.to_path_buf(),
879                    line: 1,
880                    col: 1,
881                    message: format!("Theme '{}' validation error: {}", name, msg),
882                });
883            } else {
884                errors.push(CheckError::InvalidStyleValue {
885                    attr: format!("theme '{}'", name),
886                    file: file_path.to_path_buf(),
887                    line: 1,
888                    col: 1,
889                    message: msg,
890                });
891            }
892        }
893    }
894
895    // Validate each style class definition (US5: Circular Dependency Detection)
896    for (name, class) in &document.style_classes {
897        if let Err(msg) = class.validate(&document.style_classes) {
898            // Check if it's a circular dependency error
899            if msg.contains("circular") || msg.contains("Circular") {
900                errors.push(CheckError::XmlValidationError {
901                    file: file_path.to_path_buf(),
902                    line: 1,
903                    col: 1,
904                    message: format!("Style class '{}' has circular dependency: {}", name, msg),
905                });
906            } else {
907                errors.push(CheckError::InvalidStyleValue {
908                    attr: format!("class '{}'", name),
909                    file: file_path.to_path_buf(),
910                    line: 1,
911                    col: 1,
912                    message: msg,
913                });
914            }
915        }
916    }
917}
918
919/// Validate a widget node with all its styles, layout, and references
920fn validate_widget_with_styles(
921    node: &dampen_core::ir::WidgetNode,
922    file_path: &Path,
923    document: &dampen_core::ir::DampenDocument,
924    errors: &mut Vec<CheckError>,
925) {
926    // Validate structured style properties
927    if let Some(style) = &node.style {
928        if let Err(msg) = style.validate() {
929            errors.push(CheckError::InvalidStyleValue {
930                attr: "structured style".to_string(),
931                file: file_path.to_path_buf(),
932                line: node.span.line,
933                col: node.span.column,
934                message: msg,
935            });
936        }
937    }
938
939    // Validate structured layout constraints
940    if let Some(layout) = &node.layout {
941        if let Err(msg) = layout.validate() {
942            errors.push(CheckError::InvalidLayoutConstraint {
943                file: file_path.to_path_buf(),
944                line: node.span.line,
945                col: node.span.column,
946                message: msg,
947            });
948        }
949    }
950
951    // Validate style class references
952    for class_name in &node.classes {
953        if !document.style_classes.contains_key(class_name) {
954            errors.push(CheckError::UnknownStyleClass {
955                class: class_name.clone(),
956                file: file_path.to_path_buf(),
957                line: node.span.line,
958                col: node.span.column,
959            });
960        }
961    }
962
963    // Validate theme reference
964    if let Some(theme_ref) = &node.theme_ref {
965        if !document.themes.contains_key(theme_ref) {
966            errors.push(CheckError::UnknownTheme {
967                theme: theme_ref.clone(),
968                file: file_path.to_path_buf(),
969                line: node.span.line,
970                col: node.span.column,
971            });
972        }
973    }
974
975    // Validate inline style attributes
976    validate_style_attributes(node, file_path, errors);
977
978    // Validate inline layout attributes
979    validate_layout_attributes(node, file_path, errors);
980
981    // Validate breakpoint attributes
982    validate_breakpoint_attributes(node, file_path, errors);
983
984    // Validate state attributes
985    validate_state_attributes(node, file_path, errors);
986
987    // Recursively validate children
988    for child in &node.children {
989        validate_widget_with_styles(child, file_path, document, errors);
990    }
991}
992
993/// Validate inline style attributes
994fn validate_style_attributes(
995    node: &dampen_core::ir::WidgetNode,
996    file_path: &Path,
997    errors: &mut Vec<CheckError>,
998) {
999    for (attr_name, attr_value) in &node.attributes {
1000        match attr_name.as_str() {
1001            "background" => {
1002                if let AttributeValue::Static(value) = attr_value {
1003                    if let Err(msg) = style_parser::parse_background_attr(value) {
1004                        errors.push(CheckError::InvalidStyleValue {
1005                            attr: attr_name.clone(),
1006                            file: file_path.to_path_buf(),
1007                            line: node.span.line,
1008                            col: node.span.column,
1009                            message: msg,
1010                        });
1011                    }
1012                }
1013            }
1014            "color" | "border_color" => {
1015                if let AttributeValue::Static(value) = attr_value {
1016                    if let Err(msg) = style_parser::parse_color_attr(value) {
1017                        errors.push(CheckError::InvalidStyleValue {
1018                            attr: attr_name.clone(),
1019                            file: file_path.to_path_buf(),
1020                            line: node.span.line,
1021                            col: node.span.column,
1022                            message: msg,
1023                        });
1024                    }
1025                }
1026            }
1027            "border_width" | "opacity" => {
1028                if let AttributeValue::Static(value) = attr_value {
1029                    if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1030                        errors.push(CheckError::InvalidStyleValue {
1031                            attr: attr_name.clone(),
1032                            file: file_path.to_path_buf(),
1033                            line: node.span.line,
1034                            col: node.span.column,
1035                            message: msg,
1036                        });
1037                    }
1038                }
1039            }
1040            "border_radius" => {
1041                if let AttributeValue::Static(value) = attr_value {
1042                    if let Err(msg) = style_parser::parse_border_radius(value) {
1043                        errors.push(CheckError::InvalidStyleValue {
1044                            attr: attr_name.clone(),
1045                            file: file_path.to_path_buf(),
1046                            line: node.span.line,
1047                            col: node.span.column,
1048                            message: msg,
1049                        });
1050                    }
1051                }
1052            }
1053            "border_style" => {
1054                if let AttributeValue::Static(value) = attr_value {
1055                    if let Err(msg) = style_parser::parse_border_style(value) {
1056                        errors.push(CheckError::InvalidStyleValue {
1057                            attr: attr_name.clone(),
1058                            file: file_path.to_path_buf(),
1059                            line: node.span.line,
1060                            col: node.span.column,
1061                            message: msg,
1062                        });
1063                    }
1064                }
1065            }
1066            "shadow" => {
1067                if let AttributeValue::Static(value) = attr_value {
1068                    if let Err(msg) = style_parser::parse_shadow_attr(value) {
1069                        errors.push(CheckError::InvalidStyleValue {
1070                            attr: attr_name.clone(),
1071                            file: file_path.to_path_buf(),
1072                            line: node.span.line,
1073                            col: node.span.column,
1074                            message: msg,
1075                        });
1076                    }
1077                }
1078            }
1079            "transform" => {
1080                if let AttributeValue::Static(value) = attr_value {
1081                    if let Err(msg) = style_parser::parse_transform(value) {
1082                        errors.push(CheckError::InvalidStyleValue {
1083                            attr: attr_name.clone(),
1084                            file: file_path.to_path_buf(),
1085                            line: node.span.line,
1086                            col: node.span.column,
1087                            message: msg,
1088                        });
1089                    }
1090                }
1091            }
1092            _ => {} // Autres attributs gérés ailleurs
1093        }
1094    }
1095}
1096
1097/// Validate inline layout attributes
1098fn validate_layout_attributes(
1099    node: &dampen_core::ir::WidgetNode,
1100    file_path: &Path,
1101    errors: &mut Vec<CheckError>,
1102) {
1103    for (attr_name, attr_value) in &node.attributes {
1104        match attr_name.as_str() {
1105            "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1106                if let AttributeValue::Static(value) = attr_value {
1107                    if let Err(msg) = style_parser::parse_length_attr(value) {
1108                        errors.push(CheckError::InvalidStyleValue {
1109                            attr: attr_name.clone(),
1110                            file: file_path.to_path_buf(),
1111                            line: node.span.line,
1112                            col: node.span.column,
1113                            message: msg,
1114                        });
1115                    }
1116                }
1117            }
1118            "padding" => {
1119                if let AttributeValue::Static(value) = attr_value {
1120                    if let Err(msg) = style_parser::parse_padding_attr(value) {
1121                        errors.push(CheckError::InvalidStyleValue {
1122                            attr: attr_name.clone(),
1123                            file: file_path.to_path_buf(),
1124                            line: node.span.line,
1125                            col: node.span.column,
1126                            message: msg,
1127                        });
1128                    }
1129                }
1130            }
1131            "spacing" => {
1132                if let AttributeValue::Static(value) = attr_value {
1133                    if let Err(msg) = style_parser::parse_spacing(value) {
1134                        errors.push(CheckError::InvalidStyleValue {
1135                            attr: attr_name.clone(),
1136                            file: file_path.to_path_buf(),
1137                            line: node.span.line,
1138                            col: node.span.column,
1139                            message: msg,
1140                        });
1141                    }
1142                }
1143            }
1144            "align_items" => {
1145                if let AttributeValue::Static(value) = attr_value {
1146                    if let Err(msg) = style_parser::parse_alignment(value) {
1147                        errors.push(CheckError::InvalidStyleValue {
1148                            attr: attr_name.clone(),
1149                            file: file_path.to_path_buf(),
1150                            line: node.span.line,
1151                            col: node.span.column,
1152                            message: msg,
1153                        });
1154                    }
1155                }
1156            }
1157            "justify_content" => {
1158                if let AttributeValue::Static(value) = attr_value {
1159                    if let Err(msg) = style_parser::parse_justification(value) {
1160                        errors.push(CheckError::InvalidStyleValue {
1161                            attr: attr_name.clone(),
1162                            file: file_path.to_path_buf(),
1163                            line: node.span.line,
1164                            col: node.span.column,
1165                            message: msg,
1166                        });
1167                    }
1168                }
1169            }
1170            "direction" => {
1171                if let AttributeValue::Static(value) = attr_value {
1172                    if let Err(msg) = Direction::parse(value) {
1173                        errors.push(CheckError::InvalidStyleValue {
1174                            attr: attr_name.clone(),
1175                            file: file_path.to_path_buf(),
1176                            line: node.span.line,
1177                            col: node.span.column,
1178                            message: msg,
1179                        });
1180                    }
1181                }
1182            }
1183            "position" => {
1184                if matches!(node.kind, WidgetKind::Tooltip) {
1185                } else if let AttributeValue::Static(value) = attr_value {
1186                    if let Err(msg) = Position::parse(value) {
1187                        errors.push(CheckError::InvalidStyleValue {
1188                            attr: attr_name.clone(),
1189                            file: file_path.to_path_buf(),
1190                            line: node.span.line,
1191                            col: node.span.column,
1192                            message: msg,
1193                        });
1194                    }
1195                }
1196            }
1197            "top" | "right" | "bottom" | "left" => {
1198                if let AttributeValue::Static(value) = attr_value {
1199                    if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1200                        errors.push(CheckError::InvalidStyleValue {
1201                            attr: attr_name.clone(),
1202                            file: file_path.to_path_buf(),
1203                            line: node.span.line,
1204                            col: node.span.column,
1205                            message: msg,
1206                        });
1207                    }
1208                }
1209            }
1210            "z_index" => {
1211                if let AttributeValue::Static(value) = attr_value {
1212                    if let Err(msg) = style_parser::parse_int_attr(value, attr_name) {
1213                        errors.push(CheckError::InvalidStyleValue {
1214                            attr: attr_name.clone(),
1215                            file: file_path.to_path_buf(),
1216                            line: node.span.line,
1217                            col: node.span.column,
1218                            message: msg,
1219                        });
1220                    }
1221                }
1222            }
1223            _ => {} // Autres attributs gérés ailleurs
1224        }
1225    }
1226}
1227
1228/// Validate breakpoint attributes (mobile:, tablet:, desktop:)
1229fn validate_breakpoint_attributes(
1230    node: &dampen_core::ir::WidgetNode,
1231    file_path: &Path,
1232    errors: &mut Vec<CheckError>,
1233) {
1234    for (breakpoint, attrs) in &node.breakpoint_attributes {
1235        for (attr_name, attr_value) in attrs {
1236            // Valider que l'attribut de base est valide
1237            let base_attr = attr_name.as_str();
1238            let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1239
1240            // Utiliser les mêmes validateurs que pour les attributs normaux
1241            let is_style_attr = matches!(
1242                base_attr,
1243                "background"
1244                    | "color"
1245                    | "border_width"
1246                    | "border_color"
1247                    | "border_radius"
1248                    | "border_style"
1249                    | "shadow"
1250                    | "opacity"
1251                    | "transform"
1252            );
1253
1254            let is_layout_attr = matches!(
1255                base_attr,
1256                "width"
1257                    | "height"
1258                    | "min_width"
1259                    | "max_width"
1260                    | "min_height"
1261                    | "max_height"
1262                    | "padding"
1263                    | "spacing"
1264                    | "align_items"
1265                    | "justify_content"
1266                    | "direction"
1267                    | "position"
1268                    | "top"
1269                    | "right"
1270                    | "bottom"
1271                    | "left"
1272                    | "z_index"
1273            );
1274
1275            if !is_style_attr && !is_layout_attr {
1276                errors.push(CheckError::InvalidBreakpoint {
1277                    attr: full_attr,
1278                    file: file_path.to_path_buf(),
1279                    line: node.span.line,
1280                    col: node.span.column,
1281                });
1282                continue;
1283            }
1284
1285            // Valider la valeur selon le type d'attribut
1286            if let AttributeValue::Static(value) = attr_value {
1287                let result: Result<(), String> = match base_attr {
1288                    "background" => style_parser::parse_background_attr(value).map(|_| ()),
1289                    "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1290                    "border_width" | "opacity" => {
1291                        style_parser::parse_float_attr(value, base_attr).map(|_| ())
1292                    }
1293                    "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1294                    "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1295                    "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1296                    "transform" => style_parser::parse_transform(value).map(|_| ()),
1297                    "width" | "height" | "min_width" | "max_width" | "min_height"
1298                    | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1299                    "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1300                    "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1301                    "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1302                    "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1303                    "direction" => Direction::parse(value).map(|_| ()),
1304                    "position" => Position::parse(value).map(|_| ()),
1305                    "top" | "right" | "bottom" | "left" => {
1306                        style_parser::parse_float_attr(value, base_attr).map(|_| ())
1307                    }
1308                    "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1309                    _ => Ok(()),
1310                };
1311
1312                if let Err(msg) = result {
1313                    errors.push(CheckError::InvalidStyleValue {
1314                        attr: full_attr,
1315                        file: file_path.to_path_buf(),
1316                        line: node.span.line,
1317                        col: node.span.column,
1318                        message: msg,
1319                    });
1320                }
1321            }
1322        }
1323    }
1324}
1325
1326/// Validate state attributes (hover:, focus:, active:, disabled:)
1327fn validate_state_attributes(
1328    node: &dampen_core::ir::WidgetNode,
1329    file_path: &Path,
1330    errors: &mut Vec<CheckError>,
1331) {
1332    for (attr_name, attr_value) in &node.attributes {
1333        if attr_name.contains(':') {
1334            let parts: Vec<&str> = attr_name.split(':').collect();
1335            if parts.len() >= 2 {
1336                let prefix = parts[0];
1337                let base_attr = parts[1];
1338
1339                // Valider le préfixe d'état
1340                if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1341                    errors.push(CheckError::InvalidState {
1342                        attr: attr_name.clone(),
1343                        file: file_path.to_path_buf(),
1344                        line: node.span.line,
1345                        col: node.span.column,
1346                    });
1347                    continue;
1348                }
1349
1350                // Valider que l'attribut de base est valide
1351                let is_valid_attr = matches!(
1352                    base_attr,
1353                    "background"
1354                        | "color"
1355                        | "border_width"
1356                        | "border_color"
1357                        | "border_radius"
1358                        | "border_style"
1359                        | "shadow"
1360                        | "opacity"
1361                        | "transform"
1362                        | "width"
1363                        | "height"
1364                        | "min_width"
1365                        | "max_width"
1366                        | "min_height"
1367                        | "max_height"
1368                        | "padding"
1369                        | "spacing"
1370                        | "align_items"
1371                        | "justify_content"
1372                        | "direction"
1373                        | "position"
1374                        | "top"
1375                        | "right"
1376                        | "bottom"
1377                        | "left"
1378                        | "z_index"
1379                );
1380
1381                if !is_valid_attr {
1382                    errors.push(CheckError::InvalidState {
1383                        attr: attr_name.clone(),
1384                        file: file_path.to_path_buf(),
1385                        line: node.span.line,
1386                        col: node.span.column,
1387                    });
1388                    continue;
1389                }
1390
1391                // Valider la valeur
1392                if let AttributeValue::Static(value) = attr_value {
1393                    let result: Result<(), String> = match base_attr {
1394                        "background" => style_parser::parse_background_attr(value).map(|_| ()),
1395                        "color" | "border_color" => {
1396                            style_parser::parse_color_attr(value).map(|_| ())
1397                        }
1398                        "border_width" | "opacity" => {
1399                            style_parser::parse_float_attr(value, base_attr).map(|_| ())
1400                        }
1401                        "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1402                        "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1403                        "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1404                        "transform" => style_parser::parse_transform(value).map(|_| ()),
1405                        "width" | "height" | "min_width" | "max_width" | "min_height"
1406                        | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1407                        "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1408                        "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1409                        "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1410                        "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1411                        "direction" => Direction::parse(value).map(|_| ()),
1412                        "position" => Position::parse(value).map(|_| ()),
1413                        "top" | "right" | "bottom" | "left" => {
1414                            style_parser::parse_float_attr(value, base_attr).map(|_| ())
1415                        }
1416                        "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1417                        _ => Ok(()),
1418                    };
1419
1420                    if let Err(msg) = result {
1421                        errors.push(CheckError::InvalidStyleValue {
1422                            attr: attr_name.clone(),
1423                            file: file_path.to_path_buf(),
1424                            line: node.span.line,
1425                            col: node.span.column,
1426                            message: msg,
1427                        });
1428                    }
1429                }
1430            }
1431        }
1432    }
1433}