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