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