1#![allow(clippy::print_stderr, clippy::print_stdout)]
2
3use 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 #[arg(short, long)]
160 pub input: Option<String>,
161
162 #[arg(short, long)]
164 pub verbose: bool,
165
166 #[arg(long)]
168 pub handlers: Option<String>,
169
170 #[arg(long)]
172 pub model: Option<String>,
173
174 #[arg(long)]
176 pub custom_widgets: Option<String>,
177
178 #[arg(long)]
180 pub strict: bool,
181
182 #[arg(long)]
184 pub show_widget_versions: bool,
185}
186
187pub fn resolve_package_ui_path(package_name: &str) -> Option<PathBuf> {
195 let prefixes = ["examples", "crates", "."];
196 let suffixes = ["src/ui", "ui"];
197
198 for prefix in prefixes {
199 let package_root = Path::new(prefix).join(package_name);
200 if !package_root.exists() {
201 continue;
202 }
203
204 for suffix in suffixes {
205 let ui_path = package_root.join(suffix);
206 if ui_path.exists() {
207 return Some(ui_path);
208 }
209 }
210 }
211 None
212}
213
214pub fn resolve_ui_directory(explicit_input: Option<&str>) -> Result<PathBuf, String> {
216 if let Some(path) = explicit_input {
218 let path_buf = PathBuf::from(path);
219 if path_buf.exists() {
220 return Ok(path_buf);
221 } else {
222 return Err(format!("Specified UI directory does not exist: {}", path));
223 }
224 }
225
226 let src_ui = PathBuf::from("src/ui");
228 if src_ui.exists() && src_ui.is_dir() {
229 return Ok(src_ui);
230 }
231
232 let ui = PathBuf::from("ui");
234 if ui.exists() && ui.is_dir() {
235 return Ok(ui);
236 }
237
238 Err("No UI directory found. Please create one of:\n\
240 - src/ui/ (recommended for Rust projects)\n\
241 - ui/ (general purpose)\n\n\
242 Or specify a custom path with --input:\n\
243 dampen check --input path/to/ui"
244 .to_string())
245}
246
247fn resolve_optional_file(explicit_path: Option<&str>, filename: &str) -> Option<PathBuf> {
249 if let Some(path) = explicit_path {
251 let path_buf = PathBuf::from(path);
252 if path_buf.exists() {
253 return Some(path_buf);
254 }
255 return Some(path_buf);
257 }
258
259 let root_file = PathBuf::from(filename);
261 if root_file.exists() {
262 return Some(root_file);
263 }
264
265 let src_file = PathBuf::from("src").join(filename);
267 if src_file.exists() {
268 return Some(src_file);
269 }
270
271 None
273}
274
275fn display_widget_version_table() {
277 println!("Widget Version Requirements");
278 println!("===========================\n");
279 println!("{:<20} {:<10} Status", "Widget", "Min Version");
280 println!("{:-<20} {:-<10} {:-<30}", "", "", "");
281
282 let widgets = vec![
283 ("column", WidgetKind::Column),
284 ("row", WidgetKind::Row),
285 ("container", WidgetKind::Container),
286 ("scrollable", WidgetKind::Scrollable),
287 ("stack", WidgetKind::Stack),
288 ("text", WidgetKind::Text),
289 ("image", WidgetKind::Image),
290 ("svg", WidgetKind::Svg),
291 ("button", WidgetKind::Button),
292 ("text_input", WidgetKind::TextInput),
293 ("checkbox", WidgetKind::Checkbox),
294 ("slider", WidgetKind::Slider),
295 ("pick_list", WidgetKind::PickList),
296 ("toggler", WidgetKind::Toggler),
297 ("radio", WidgetKind::Radio),
298 ("space", WidgetKind::Space),
299 ("rule", WidgetKind::Rule),
300 ("progress_bar", WidgetKind::ProgressBar),
301 ("combobox", WidgetKind::ComboBox),
302 ("tooltip", WidgetKind::Tooltip),
303 ("grid", WidgetKind::Grid),
304 ("canvas", WidgetKind::Canvas),
305 ("date_picker", WidgetKind::DatePicker),
306 ("time_picker", WidgetKind::TimePicker),
307 ("color_picker", WidgetKind::ColorPicker),
308 ("menu", WidgetKind::Menu),
309 ("menu_item", WidgetKind::MenuItem),
310 ("menu_separator", WidgetKind::MenuSeparator),
311 ("context_menu", WidgetKind::ContextMenu),
312 ("float", WidgetKind::Float),
313 ("data_table", WidgetKind::DataTable),
314 ("data_column", WidgetKind::DataColumn),
315 ];
316
317 for (name, widget) in widgets {
318 let min_version = widget.minimum_version();
319 let version_str = format!("{}.{}", min_version.major, min_version.minor);
320 let status = if min_version.minor > 0 {
321 "Experimental (not fully functional)"
322 } else {
323 "Stable"
324 };
325 println!("{:<20} {:<10} {}", name, version_str, status);
326 }
327
328 println!("\nNote: Widgets requiring v1.1+ are experimental and may not be fully functional.");
329 println!("Use 'dampen check' to validate your .dampen files for version compatibility.");
330}
331
332pub fn run_checks(input: Option<String>, strict: bool, verbose: bool) -> Result<(), CheckError> {
333 use crate::commands::check::handlers::HandlerRegistry;
334
335 let input_path = resolve_ui_directory(input.as_deref())
337 .map_err(|msg| CheckError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, msg)))?;
338
339 if verbose {
340 eprintln!("Using UI directory: {}", input_path.display());
341 }
342
343 let handlers_path = resolve_optional_file(None, "handlers.json");
347 if verbose && let Some(ref path) = handlers_path {
348 eprintln!("Using handler registry: {}", path.display());
349 }
350
351 let model_path = resolve_optional_file(None, "model.json");
352 if verbose && let Some(ref path) = model_path {
353 eprintln!("Using model info: {}", path.display());
354 }
355
356 let handler_registry = if let Some(path) = handlers_path {
358 let registry = HandlerRegistry::load_from_json(&path).map_err(|e| {
359 CheckError::HandlerRegistryLoadError {
360 path: path.clone(),
361 source: serde_json::Error::io(std::io::Error::other(e.to_string())),
362 }
363 })?;
364 Some(registry)
365 } else {
366 None
367 };
368
369 let model_info = if let Some(path) = model_path {
371 let model =
372 crate::commands::check::model::ModelInfo::load_from_json(&path).map_err(|e| {
373 CheckError::ModelInfoLoadError {
374 path: path.clone(),
375 source: serde_json::Error::io(std::io::Error::other(e.to_string())),
376 }
377 })?;
378 Some(model)
379 } else {
380 None
381 };
382
383 let mut errors = Vec::new();
384 let mut files_checked = 0;
385
386 for entry in WalkDir::new(input_path)
388 .follow_links(true)
389 .into_iter()
390 .filter_map(|e| e.ok())
391 .filter(|e| {
392 e.path()
393 .extension()
394 .map(|ext| ext == "dampen")
395 .unwrap_or(false)
396 })
397 {
398 let file_path = entry.path();
399 files_checked += 1;
400
401 if verbose {
402 eprintln!("Checking: {}", file_path.display());
403 }
404
405 let content = match fs::read_to_string(file_path) {
407 Ok(c) => c,
408 Err(e) => {
409 errors.push(CheckError::Io(e));
410 continue;
411 }
412 };
413
414 validate_xml_declaration(&content, file_path, &mut errors);
416
417 if !errors.is_empty() {
419 continue;
421 }
422
423 if file_path.file_name().is_some_and(|n| n == "theme.dampen") {
425 if let Err(theme_error) =
426 dampen_core::parser::theme_parser::parse_theme_document(&content)
427 {
428 errors.push(CheckError::XmlValidationError {
429 file: file_path.to_path_buf(),
430 line: 1,
431 col: 1,
432 message: format!("Theme validation error: {}", theme_error),
433 });
434 }
435 continue;
436 }
437
438 match parser::parse(&content) {
439 Ok(document) => {
440 validate_document(
442 &document,
443 file_path,
444 &handler_registry,
445 &model_info,
446 &mut errors,
447 );
448
449 validate_references(&document, file_path, &mut errors);
451
452 validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
454
455 let version_warnings = dampen_core::validate_widget_versions(&document);
457 if !version_warnings.is_empty() {
458 for warning in version_warnings {
459 eprintln!(
460 "Warning: {} in {}:{}:{}",
461 warning.format_message(),
462 file_path.display(),
463 warning.span.line,
464 warning.span.column
465 );
466 eprintln!(" Suggestion: {}", warning.suggestion());
467 eprintln!();
468 }
469 }
470 }
471 Err(parse_error) => {
472 errors.push(CheckError::ParseError {
473 file: file_path.to_path_buf(),
474 line: parse_error.span.line,
475 col: parse_error.span.column,
476 message: parse_error.to_string(),
477 });
478 }
479 }
480 }
481
482 if verbose {
483 eprintln!("Checked {} files", files_checked);
484 }
485
486 if !errors.is_empty() {
488 let error_label = "error(s)";
489 eprintln!("Found {} {}:", errors.len(), error_label);
490
491 for error in &errors {
492 let prefix = "ERROR";
493 eprintln!(" [{}] {}", prefix, error);
494 }
495
496 Err(errors.remove(0))
497 } else {
498 if verbose {
499 let status = if strict {
500 "✓ All files passed validation (strict mode)"
501 } else {
502 "✓ All files passed validation"
503 };
504 eprintln!("{}", status);
505 }
506 Ok(())
507 }
508}
509
510pub fn execute(args: &CheckArgs) -> Result<(), CheckError> {
511 if args.show_widget_versions {
513 display_widget_version_table();
514 return Ok(());
515 }
516
517 if args.handlers.is_some() || args.model.is_some() || args.custom_widgets.is_some() {
520 return run_checks_internal(
530 args.input.clone(),
531 args.strict,
532 args.verbose,
533 args.handlers.clone(),
534 args.model.clone(),
535 args.custom_widgets.clone(),
536 );
537 }
538
539 run_checks(args.input.clone(), args.strict, args.verbose)
540}
541
542fn run_checks_internal(
544 input: Option<String>,
545 strict: bool,
546 verbose: bool,
547 handlers: Option<String>,
548 model: Option<String>,
549 _custom_widgets: Option<String>,
550) -> Result<(), CheckError> {
551 use crate::commands::check::handlers::HandlerRegistry;
552
553 let input_path = resolve_ui_directory(input.as_deref())
555 .map_err(|msg| CheckError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, msg)))?;
556
557 if verbose {
558 eprintln!("Using UI directory: {}", input_path.display());
559 }
560
561 let handlers_path = resolve_optional_file(handlers.as_deref(), "handlers.json");
563 if verbose && let Some(ref path) = handlers_path {
564 eprintln!("Using handler registry: {}", path.display());
565 }
566
567 let model_path = resolve_optional_file(model.as_deref(), "model.json");
568 if verbose && let Some(ref path) = model_path {
569 eprintln!("Using model info: {}", path.display());
570 }
571
572 let handler_registry = if let Some(path) = handlers_path {
574 let registry = HandlerRegistry::load_from_json(&path).map_err(|e| {
575 CheckError::HandlerRegistryLoadError {
576 path: path.clone(),
577 source: serde_json::Error::io(std::io::Error::other(e.to_string())),
578 }
579 })?;
580 Some(registry)
581 } else {
582 None
583 };
584
585 let model_info = if let Some(path) = model_path {
587 let model =
588 crate::commands::check::model::ModelInfo::load_from_json(&path).map_err(|e| {
589 CheckError::ModelInfoLoadError {
590 path: path.clone(),
591 source: serde_json::Error::io(std::io::Error::other(e.to_string())),
592 }
593 })?;
594 Some(model)
595 } else {
596 None
597 };
598
599 let mut errors = Vec::new();
600 let mut files_checked = 0;
601
602 for entry in WalkDir::new(input_path)
604 .follow_links(true)
605 .into_iter()
606 .filter_map(|e| e.ok())
607 .filter(|e| {
608 e.path()
609 .extension()
610 .map(|ext| ext == "dampen")
611 .unwrap_or(false)
612 })
613 {
614 let file_path = entry.path();
615 files_checked += 1;
616
617 if verbose {
618 eprintln!("Checking: {}", file_path.display());
619 }
620
621 let content = fs::read_to_string(file_path)?;
623
624 validate_xml_declaration(&content, file_path, &mut errors);
626
627 if !errors.is_empty() {
629 continue;
630 }
631
632 if file_path.file_name().is_some_and(|n| n == "theme.dampen") {
634 if let Err(theme_error) =
635 dampen_core::parser::theme_parser::parse_theme_document(&content)
636 {
637 errors.push(CheckError::XmlValidationError {
638 file: file_path.to_path_buf(),
639 line: 1, col: 1,
641 message: format!("Theme validation error: {}", theme_error),
642 });
643 }
644 continue;
645 }
646
647 match parser::parse(&content) {
648 Ok(document) => {
649 validate_document(
651 &document,
652 file_path,
653 &handler_registry,
654 &model_info,
655 &mut errors,
656 );
657
658 validate_references(&document, file_path, &mut errors);
660
661 validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
663
664 let version_warnings = dampen_core::validate_widget_versions(&document);
667 if !version_warnings.is_empty() {
668 for warning in version_warnings {
669 eprintln!(
670 "Warning: {} in {}:{}:{}",
671 warning.format_message(),
672 file_path.display(),
673 warning.span.line,
674 warning.span.column
675 );
676 eprintln!(" Suggestion: {}", warning.suggestion());
677 eprintln!();
678 }
679 }
680 }
681 Err(parse_error) => {
682 errors.push(CheckError::ParseError {
683 file: file_path.to_path_buf(),
684 line: parse_error.span.line,
685 col: parse_error.span.column,
686 message: parse_error.to_string(),
687 });
688 }
689 }
690 }
691
692 if verbose {
693 eprintln!("Checked {} files", files_checked);
694 }
695
696 if !errors.is_empty() {
698 let error_label = "error(s)"; eprintln!("Found {} {}:", errors.len(), error_label);
703
704 for error in &errors {
705 let prefix = "ERROR"; eprintln!(" [{}] {}", prefix, error);
708 }
709
710 Err(errors.remove(0))
713 } else {
714 if verbose {
715 let status = if strict {
716 "✓ All files passed validation (strict mode)"
717 } else {
718 "✓ All files passed validation"
719 };
720 eprintln!("{}", status);
721 }
722 Ok(())
723 }
724}
725
726fn validate_xml_declaration(content: &str, file_path: &Path, errors: &mut Vec<CheckError>) {
727 let trimmed = content.trim_start();
730 if trimmed.starts_with("<?xml") && !trimmed.starts_with("<?xml version=\"1.0\"") {
731 errors.push(CheckError::XmlValidationError {
732 file: file_path.to_path_buf(),
733 line: 1,
734 col: 1,
735 message:
736 "Invalid XML declaration. Expected: <?xml version=\"1.0\" ... ?> or no declaration"
737 .to_string(),
738 });
739 }
740}
741
742fn validate_document(
743 document: &dampen_core::ir::DampenDocument,
744 file_path: &Path,
745 handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
746 model_info: &Option<crate::commands::check::model::ModelInfo>,
747 errors: &mut Vec<CheckError>,
748) {
749 use crate::commands::check::cross_widget::RadioGroupValidator;
750
751 let valid_widgets: HashSet<String> = WidgetKind::all_variants()
753 .iter()
754 .map(|w| format!("{}", w).to_lowercase())
755 .collect();
756
757 let mut radio_validator = RadioGroupValidator::new();
759
760 validate_widget_node(
762 &document.root,
763 file_path,
764 &valid_widgets,
765 handler_registry,
766 model_info,
767 &mut radio_validator,
768 errors,
769 );
770
771 let radio_errors = radio_validator.validate();
773 for error in radio_errors {
774 match error {
776 crate::commands::check::errors::CheckError::DuplicateRadioValue {
777 value,
778 group,
779 file,
780 line,
781 col,
782 first_file,
783 first_line,
784 first_col,
785 } => {
786 errors.push(CheckError::XmlValidationError {
787 file: file.clone(),
788 line,
789 col,
790 message: format!(
791 "Duplicate radio value '{}' in group '{}'. First occurrence: {}:{}:{}",
792 value,
793 group,
794 first_file.display(),
795 first_line,
796 first_col
797 ),
798 });
799 }
800 crate::commands::check::errors::CheckError::InconsistentRadioHandlers {
801 group,
802 file,
803 line,
804 col,
805 handlers,
806 } => {
807 errors.push(CheckError::XmlValidationError {
808 file: file.clone(),
809 line,
810 col,
811 message: format!(
812 "Radio group '{}' has inconsistent on_select handlers. Found handlers: {}",
813 group, handlers
814 ),
815 });
816 }
817 _ => {}
818 }
819 }
820
821 validate_tree_views(&document.root, file_path, errors);
823}
824
825fn validate_tree_views(
827 node: &dampen_core::ir::WidgetNode,
828 file_path: &Path,
829 errors: &mut Vec<CheckError>,
830) {
831 use crate::commands::check::tree_view::TreeViewValidator;
832
833 if matches!(node.kind, WidgetKind::TreeView) {
835 let mut validator = TreeViewValidator::new();
836 validator.set_file(file_path.to_path_buf());
837 validator.validate_tree_view(node);
838
839 for error in validator.errors() {
841 match error {
842 crate::commands::check::errors::CheckError::DuplicateTreeNodeId {
843 id,
844 file,
845 line,
846 col,
847 first_file,
848 first_line,
849 first_col,
850 } => {
851 errors.push(CheckError::XmlValidationError {
852 file: file.clone(),
853 line: *line,
854 col: *col,
855 message: format!(
856 "Duplicate tree node ID '{}'. First occurrence: {}:{}:{}",
857 id,
858 first_file.display(),
859 first_line,
860 first_col
861 ),
862 });
863 }
864 crate::commands::check::errors::CheckError::MissingRequiredAttribute {
865 attr,
866 widget,
867 file,
868 line,
869 col,
870 } => {
871 errors.push(CheckError::XmlValidationError {
872 file: file.clone(),
873 line: *line,
874 col: *col,
875 message: format!(
876 "Missing required attribute '{}' for widget '{}'",
877 attr, widget
878 ),
879 });
880 }
881 _ => {}
882 }
883 }
884 }
885
886 for child in &node.children {
888 validate_tree_views(child, file_path, errors);
889 }
890}
891
892fn validate_widget_node(
893 node: &dampen_core::ir::WidgetNode,
894 file_path: &Path,
895 valid_widgets: &HashSet<String>,
896 handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
897 model_info: &Option<crate::commands::check::model::ModelInfo>,
898 radio_validator: &mut crate::commands::check::cross_widget::RadioGroupValidator,
899 errors: &mut Vec<CheckError>,
900) {
901 use crate::commands::check::attributes;
902 use crate::commands::check::suggestions;
903
904 let widget_name = format!("{}", node.kind).to_lowercase();
906 if !valid_widgets.contains(&widget_name) && !matches!(node.kind, WidgetKind::Custom(_)) {
907 errors.push(CheckError::InvalidWidget {
908 widget: widget_name.clone(),
909 file: file_path.to_path_buf(),
910 line: node.span.line,
911 col: node.span.column,
912 });
913 }
914
915 let mut attr_names: Vec<String> = node.attributes.keys().map(|s| s.to_string()).collect();
917 if node.id.is_some() {
918 attr_names.push("id".to_string());
919 }
920 let unknown_attrs = attributes::validate_widget_attributes(&node.kind, &attr_names);
921
922 for (attr, _suggestion_opt) in unknown_attrs {
923 let schema = attributes::WidgetAttributeSchema::for_widget(&node.kind);
924 let all_valid = schema.all_valid_names();
925 let suggestion = suggestions::suggest(&attr, &all_valid, 3);
926
927 errors.push(CheckError::UnknownAttribute {
928 attr,
929 widget: widget_name.clone(),
930 file: file_path.to_path_buf(),
931 line: node.span.line,
932 col: node.span.column,
933 suggestion,
934 });
935 }
936
937 let missing_required = attributes::validate_required_attributes(&node.kind, &attr_names);
939 for missing_attr in missing_required {
940 errors.push(CheckError::XmlValidationError {
941 file: file_path.to_path_buf(),
942 line: node.span.line,
943 col: node.span.column,
944 message: format!(
945 "Missing required attribute '{}' for widget '{}'",
946 missing_attr, widget_name
947 ),
948 });
949 }
950
951 if let Some(registry) = handler_registry {
953 for event_binding in &node.events {
954 if !registry.contains(&event_binding.handler) {
955 let all_handler_names = registry.all_names();
957 let handler_refs: Vec<&str> =
958 all_handler_names.iter().map(|s| s.as_str()).collect();
959 let suggestion = suggestions::suggest(&event_binding.handler, &handler_refs, 3);
960
961 errors.push(CheckError::UnknownHandler {
962 handler: event_binding.handler.clone(),
963 file: file_path.to_path_buf(),
964 line: event_binding.span.line,
965 col: event_binding.span.column,
966 suggestion,
967 });
968 }
969 }
970 } else {
971 for event_binding in &node.events {
973 if event_binding.handler.is_empty() {
974 errors.push(CheckError::UnknownHandler {
975 handler: "<empty>".to_string(),
976 file: file_path.to_path_buf(),
977 line: event_binding.span.line,
978 col: event_binding.span.column,
979 suggestion: String::new(),
980 });
981 }
982 }
983 }
984
985 if let Some(model) = model_info {
987 for (attr_name, attr_value) in &node.attributes {
988 validate_attribute_bindings(
989 attr_name,
990 attr_value,
991 file_path,
992 node.span.line,
993 node.span.column,
994 model,
995 errors,
996 );
997 }
998 }
999
1000 for attr_value in node.attributes.values() {
1002 validate_attribute_value(
1003 attr_value,
1004 file_path,
1005 node.span.line,
1006 node.span.column,
1007 errors,
1008 );
1009 }
1010
1011 if matches!(node.kind, WidgetKind::Radio) {
1013 let group_id = node.id.as_deref().unwrap_or("default");
1016
1017 let value = node
1018 .attributes
1019 .get("value")
1020 .and_then(|v| match v {
1021 AttributeValue::Static(s) => Some(s.as_str()),
1022 _ => None,
1023 })
1024 .unwrap_or("");
1025
1026 let handler = node
1028 .events
1029 .iter()
1030 .find(|e| e.event == EventKind::Select)
1031 .map(|e| e.handler.clone());
1032
1033 radio_validator.add_radio(
1034 group_id,
1035 value,
1036 file_path.to_str().unwrap_or("unknown"),
1037 node.span.line,
1038 node.span.column,
1039 handler,
1040 );
1041 }
1042
1043 for child in &node.children {
1045 validate_widget_node(
1046 child,
1047 file_path,
1048 valid_widgets,
1049 handler_registry,
1050 model_info,
1051 radio_validator,
1052 errors,
1053 );
1054 }
1055}
1056
1057fn validate_attribute_bindings(
1058 _attr_name: &str,
1059 value: &dampen_core::ir::AttributeValue,
1060 file_path: &Path,
1061 line: u32,
1062 col: u32,
1063 model: &crate::commands::check::model::ModelInfo,
1064 errors: &mut Vec<CheckError>,
1065) {
1066 if let dampen_core::ir::AttributeValue::Binding(binding_expr) = value {
1068 validate_expr_fields(&binding_expr.expr, file_path, line, col, model, errors);
1070 }
1071}
1072
1073fn validate_expr_fields(
1074 expr: &dampen_core::expr::Expr,
1075 file_path: &Path,
1076 line: u32,
1077 col: u32,
1078 model: &crate::commands::check::model::ModelInfo,
1079 errors: &mut Vec<CheckError>,
1080) {
1081 match expr {
1082 dampen_core::expr::Expr::FieldAccess(field_access) => {
1083 let field_parts: Vec<&str> = field_access.path.iter().map(|s| s.as_str()).collect();
1085
1086 if !model.contains_field(&field_parts) {
1087 let all_paths = model.all_field_paths();
1089 let available = if all_paths.len() > 5 {
1090 format!("{} ({} total)", &all_paths[..5].join(", "), all_paths.len())
1091 } else {
1092 all_paths.join(", ")
1093 };
1094
1095 let field_path = field_access.path.join(".");
1096
1097 errors.push(CheckError::InvalidBinding {
1098 field: field_path,
1099 file: file_path.to_path_buf(),
1100 line,
1101 col,
1102 });
1103
1104 eprintln!(" Available fields: {}", available);
1106 }
1107 }
1108 dampen_core::expr::Expr::MethodCall(method_call) => {
1109 validate_expr_fields(&method_call.receiver, file_path, line, col, model, errors);
1111 for arg in &method_call.args {
1113 validate_expr_fields(arg, file_path, line, col, model, errors);
1114 }
1115 }
1116 dampen_core::expr::Expr::BinaryOp(binary_op) => {
1117 validate_expr_fields(&binary_op.left, file_path, line, col, model, errors);
1119 validate_expr_fields(&binary_op.right, file_path, line, col, model, errors);
1120 }
1121 dampen_core::expr::Expr::UnaryOp(unary_op) => {
1122 validate_expr_fields(&unary_op.operand, file_path, line, col, model, errors);
1124 }
1125 dampen_core::expr::Expr::Conditional(conditional) => {
1126 validate_expr_fields(&conditional.condition, file_path, line, col, model, errors);
1128 validate_expr_fields(
1129 &conditional.then_branch,
1130 file_path,
1131 line,
1132 col,
1133 model,
1134 errors,
1135 );
1136 validate_expr_fields(
1137 &conditional.else_branch,
1138 file_path,
1139 line,
1140 col,
1141 model,
1142 errors,
1143 );
1144 }
1145 dampen_core::expr::Expr::Literal(_) => {
1146 }
1148 dampen_core::expr::Expr::SharedFieldAccess(shared_access) => {
1149 if shared_access.path.is_empty() || shared_access.path.iter().any(|f| f.is_empty()) {
1151 errors.push(CheckError::InvalidBinding {
1152 field: "shared.<empty>".to_string(),
1153 file: file_path.to_path_buf(),
1154 line,
1155 col,
1156 });
1157 }
1158 }
1159 }
1160}
1161
1162fn validate_attribute_value(
1163 value: &dampen_core::ir::AttributeValue,
1164 file_path: &Path,
1165 line: u32,
1166 col: u32,
1167 errors: &mut Vec<CheckError>,
1168) {
1169 match value {
1170 dampen_core::ir::AttributeValue::Static(_) => {
1171 }
1173 dampen_core::ir::AttributeValue::Binding(binding_expr) => {
1174 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
1177 }
1178 dampen_core::ir::AttributeValue::Interpolated(parts) => {
1179 for part in parts {
1180 match part {
1181 dampen_core::ir::InterpolatedPart::Literal(_) => {
1182 }
1184 dampen_core::ir::InterpolatedPart::Binding(binding_expr) => {
1185 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
1186 }
1187 }
1188 }
1189 }
1190 }
1191}
1192
1193fn validate_binding_expr(
1194 expr: &dampen_core::expr::Expr,
1195 file_path: &Path,
1196 line: u32,
1197 col: u32,
1198 errors: &mut Vec<CheckError>,
1199) {
1200 match expr {
1201 dampen_core::expr::Expr::FieldAccess(field_access) => {
1202 if field_access.path.is_empty() || field_access.path.iter().any(|f| f.is_empty()) {
1205 errors.push(CheckError::InvalidBinding {
1206 field: "<empty>".to_string(),
1207 file: file_path.to_path_buf(),
1208 line,
1209 col,
1210 });
1211 }
1212 }
1213 dampen_core::expr::Expr::MethodCall(_) => {
1214 }
1217 dampen_core::expr::Expr::BinaryOp(_) => {
1218 }
1221 dampen_core::expr::Expr::UnaryOp(_) => {
1222 }
1224 dampen_core::expr::Expr::Conditional(_) => {
1225 }
1227 dampen_core::expr::Expr::Literal(_) => {
1228 }
1230 dampen_core::expr::Expr::SharedFieldAccess(_) => {
1231 }
1234 }
1235}
1236
1237trait WidgetKindExt {
1239 fn all_variants() -> Vec<WidgetKind>;
1240}
1241
1242impl WidgetKindExt for WidgetKind {
1243 fn all_variants() -> Vec<WidgetKind> {
1244 vec![
1245 WidgetKind::Column,
1246 WidgetKind::Row,
1247 WidgetKind::Container,
1248 WidgetKind::Scrollable,
1249 WidgetKind::Stack,
1250 WidgetKind::Text,
1251 WidgetKind::Image,
1252 WidgetKind::Svg,
1253 WidgetKind::Button,
1254 WidgetKind::TextInput,
1255 WidgetKind::Checkbox,
1256 WidgetKind::Slider,
1257 WidgetKind::PickList,
1258 WidgetKind::Toggler,
1259 WidgetKind::Space,
1260 WidgetKind::Rule,
1261 WidgetKind::Radio,
1262 WidgetKind::ComboBox,
1263 WidgetKind::ProgressBar,
1264 WidgetKind::Tooltip,
1265 WidgetKind::Grid,
1266 WidgetKind::Canvas,
1267 WidgetKind::CanvasRect,
1268 WidgetKind::CanvasCircle,
1269 WidgetKind::CanvasLine,
1270 WidgetKind::CanvasText,
1271 WidgetKind::CanvasGroup,
1272 WidgetKind::DatePicker,
1273 WidgetKind::TimePicker,
1274 WidgetKind::ColorPicker,
1275 WidgetKind::Menu,
1276 WidgetKind::MenuItem,
1277 WidgetKind::MenuSeparator,
1278 WidgetKind::ContextMenu,
1279 WidgetKind::Float,
1280 WidgetKind::DataTable,
1281 WidgetKind::DataColumn,
1282 WidgetKind::TreeView,
1283 WidgetKind::TreeNode,
1284 WidgetKind::TabBar,
1285 WidgetKind::Tab,
1286 WidgetKind::For,
1287 WidgetKind::If,
1288 ]
1289 }
1290}
1291
1292fn validate_references(
1294 document: &dampen_core::ir::DampenDocument,
1295 file_path: &Path,
1296 errors: &mut Vec<CheckError>,
1297) {
1298 if let Some(global_theme) = &document.global_theme
1300 && !document.themes.contains_key(global_theme)
1301 {
1302 errors.push(CheckError::UnknownTheme {
1303 theme: global_theme.clone(),
1304 file: file_path.to_path_buf(),
1305 line: 1,
1306 col: 1,
1307 });
1308 }
1309
1310 for (name, theme) in &document.themes {
1312 if let Err(msg) = theme.validate(false) {
1313 if msg.contains("circular") || msg.contains("Circular") {
1315 errors.push(CheckError::XmlValidationError {
1316 file: file_path.to_path_buf(),
1317 line: 1,
1318 col: 1,
1319 message: format!("Theme '{}' validation error: {}", name, msg),
1320 });
1321 } else {
1322 errors.push(CheckError::InvalidStyleValue {
1323 attr: format!("theme '{}'", name),
1324 file: file_path.to_path_buf(),
1325 line: 1,
1326 col: 1,
1327 message: msg,
1328 });
1329 }
1330 }
1331 }
1332
1333 for (name, class) in &document.style_classes {
1335 if let Err(msg) = class.validate(&document.style_classes) {
1336 if msg.contains("circular") || msg.contains("Circular") {
1338 errors.push(CheckError::XmlValidationError {
1339 file: file_path.to_path_buf(),
1340 line: 1,
1341 col: 1,
1342 message: format!("Style class '{}' has circular dependency: {}", name, msg),
1343 });
1344 } else {
1345 errors.push(CheckError::InvalidStyleValue {
1346 attr: format!("class '{}'", name),
1347 file: file_path.to_path_buf(),
1348 line: 1,
1349 col: 1,
1350 message: msg,
1351 });
1352 }
1353 }
1354 }
1355}
1356
1357fn validate_widget_with_styles(
1359 node: &dampen_core::ir::WidgetNode,
1360 file_path: &Path,
1361 document: &dampen_core::ir::DampenDocument,
1362 errors: &mut Vec<CheckError>,
1363) {
1364 if let Some(style) = &node.style
1366 && let Err(msg) = style.validate()
1367 {
1368 errors.push(CheckError::InvalidStyleValue {
1369 attr: "structured style".to_string(),
1370 file: file_path.to_path_buf(),
1371 line: node.span.line,
1372 col: node.span.column,
1373 message: msg,
1374 });
1375 }
1376
1377 if let Some(layout) = &node.layout
1379 && let Err(msg) = layout.validate()
1380 {
1381 errors.push(CheckError::InvalidLayoutConstraint {
1382 file: file_path.to_path_buf(),
1383 line: node.span.line,
1384 col: node.span.column,
1385 message: msg,
1386 });
1387 }
1388
1389 for class_name in &node.classes {
1391 if !document.style_classes.contains_key(class_name) {
1392 errors.push(CheckError::UnknownStyleClass {
1393 class: class_name.clone(),
1394 file: file_path.to_path_buf(),
1395 line: node.span.line,
1396 col: node.span.column,
1397 });
1398 }
1399 }
1400
1401 if let Some(theme_ref) = &node.theme_ref {
1403 match theme_ref {
1404 AttributeValue::Static(theme_name) => {
1405 if !document.themes.contains_key(theme_name) {
1406 errors.push(CheckError::UnknownTheme {
1407 theme: theme_name.clone(),
1408 file: file_path.to_path_buf(),
1409 line: node.span.line,
1410 col: node.span.column,
1411 });
1412 }
1413 }
1414 AttributeValue::Binding(_) | AttributeValue::Interpolated(_) => {
1415 }
1418 }
1419 }
1420
1421 validate_style_attributes(node, file_path, errors);
1423
1424 validate_layout_attributes(node, file_path, errors);
1426
1427 validate_breakpoint_attributes(node, file_path, errors);
1429
1430 validate_state_attributes(node, file_path, errors);
1432
1433 for child in &node.children {
1435 validate_widget_with_styles(child, file_path, document, errors);
1436 }
1437}
1438
1439fn validate_style_attributes(
1441 node: &dampen_core::ir::WidgetNode,
1442 file_path: &Path,
1443 errors: &mut Vec<CheckError>,
1444) {
1445 for (attr_name, attr_value) in &node.attributes {
1446 match attr_name.as_str() {
1447 "background" => {
1448 if let AttributeValue::Static(value) = attr_value
1449 && let Err(msg) = style_parser::parse_background_attr(value)
1450 {
1451 errors.push(CheckError::InvalidStyleValue {
1452 attr: attr_name.clone(),
1453 file: file_path.to_path_buf(),
1454 line: node.span.line,
1455 col: node.span.column,
1456 message: msg,
1457 });
1458 }
1459 }
1460 "color" | "border_color" => {
1461 if let AttributeValue::Static(value) = attr_value
1462 && let Err(msg) = style_parser::parse_color_attr(value)
1463 {
1464 errors.push(CheckError::InvalidStyleValue {
1465 attr: attr_name.clone(),
1466 file: file_path.to_path_buf(),
1467 line: node.span.line,
1468 col: node.span.column,
1469 message: msg,
1470 });
1471 }
1472 }
1473 "border_width" | "opacity" => {
1474 if let AttributeValue::Static(value) = attr_value
1475 && let Err(msg) = style_parser::parse_float_attr(value, attr_name)
1476 {
1477 errors.push(CheckError::InvalidStyleValue {
1478 attr: attr_name.clone(),
1479 file: file_path.to_path_buf(),
1480 line: node.span.line,
1481 col: node.span.column,
1482 message: msg,
1483 });
1484 }
1485 }
1486 "border_radius" => {
1487 if let AttributeValue::Static(value) = attr_value
1488 && let Err(msg) = style_parser::parse_border_radius(value)
1489 {
1490 errors.push(CheckError::InvalidStyleValue {
1491 attr: attr_name.clone(),
1492 file: file_path.to_path_buf(),
1493 line: node.span.line,
1494 col: node.span.column,
1495 message: msg,
1496 });
1497 }
1498 }
1499 "border_style" => {
1500 if let AttributeValue::Static(value) = attr_value
1501 && let Err(msg) = style_parser::parse_border_style(value)
1502 {
1503 errors.push(CheckError::InvalidStyleValue {
1504 attr: attr_name.clone(),
1505 file: file_path.to_path_buf(),
1506 line: node.span.line,
1507 col: node.span.column,
1508 message: msg,
1509 });
1510 }
1511 }
1512 "shadow" => {
1513 if let AttributeValue::Static(value) = attr_value
1514 && let Err(msg) = style_parser::parse_shadow_attr(value)
1515 {
1516 errors.push(CheckError::InvalidStyleValue {
1517 attr: attr_name.clone(),
1518 file: file_path.to_path_buf(),
1519 line: node.span.line,
1520 col: node.span.column,
1521 message: msg,
1522 });
1523 }
1524 }
1525 "transform" => {
1526 if let AttributeValue::Static(value) = attr_value
1527 && let Err(msg) = style_parser::parse_transform(value)
1528 {
1529 errors.push(CheckError::InvalidStyleValue {
1530 attr: attr_name.clone(),
1531 file: file_path.to_path_buf(),
1532 line: node.span.line,
1533 col: node.span.column,
1534 message: msg,
1535 });
1536 }
1537 }
1538 _ => {} }
1540 }
1541}
1542
1543fn validate_layout_attributes(
1545 node: &dampen_core::ir::WidgetNode,
1546 file_path: &Path,
1547 errors: &mut Vec<CheckError>,
1548) {
1549 for (attr_name, attr_value) in &node.attributes {
1550 match attr_name.as_str() {
1551 "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1552 if let AttributeValue::Static(value) = attr_value
1553 && let Err(msg) = style_parser::parse_length_attr(value)
1554 {
1555 errors.push(CheckError::InvalidStyleValue {
1556 attr: attr_name.clone(),
1557 file: file_path.to_path_buf(),
1558 line: node.span.line,
1559 col: node.span.column,
1560 message: msg,
1561 });
1562 }
1563 }
1564 "padding" => {
1565 if let AttributeValue::Static(value) = attr_value
1566 && let Err(msg) = style_parser::parse_padding_attr(value)
1567 {
1568 errors.push(CheckError::InvalidStyleValue {
1569 attr: attr_name.clone(),
1570 file: file_path.to_path_buf(),
1571 line: node.span.line,
1572 col: node.span.column,
1573 message: msg,
1574 });
1575 }
1576 }
1577 "spacing" => {
1578 if let AttributeValue::Static(value) = attr_value
1579 && let Err(msg) = style_parser::parse_spacing(value)
1580 {
1581 errors.push(CheckError::InvalidStyleValue {
1582 attr: attr_name.clone(),
1583 file: file_path.to_path_buf(),
1584 line: node.span.line,
1585 col: node.span.column,
1586 message: msg,
1587 });
1588 }
1589 }
1590 "align_items" => {
1591 if let AttributeValue::Static(value) = attr_value
1592 && let Err(msg) = style_parser::parse_alignment(value)
1593 {
1594 errors.push(CheckError::InvalidStyleValue {
1595 attr: attr_name.clone(),
1596 file: file_path.to_path_buf(),
1597 line: node.span.line,
1598 col: node.span.column,
1599 message: msg,
1600 });
1601 }
1602 }
1603 "justify_content" => {
1604 if let AttributeValue::Static(value) = attr_value
1605 && let Err(msg) = style_parser::parse_justification(value)
1606 {
1607 errors.push(CheckError::InvalidStyleValue {
1608 attr: attr_name.clone(),
1609 file: file_path.to_path_buf(),
1610 line: node.span.line,
1611 col: node.span.column,
1612 message: msg,
1613 });
1614 }
1615 }
1616 "direction" => {
1617 if let AttributeValue::Static(value) = attr_value
1618 && let Err(msg) = Direction::parse(value)
1619 {
1620 errors.push(CheckError::InvalidStyleValue {
1621 attr: attr_name.clone(),
1622 file: file_path.to_path_buf(),
1623 line: node.span.line,
1624 col: node.span.column,
1625 message: msg,
1626 });
1627 }
1628 }
1629 "position" => {
1630 if !matches!(node.kind, WidgetKind::Tooltip)
1631 && let AttributeValue::Static(value) = attr_value
1632 && let Err(msg) = Position::parse(value)
1633 {
1634 errors.push(CheckError::InvalidStyleValue {
1635 attr: attr_name.clone(),
1636 file: file_path.to_path_buf(),
1637 line: node.span.line,
1638 col: node.span.column,
1639 message: msg,
1640 });
1641 }
1642 }
1643 "top" | "right" | "bottom" | "left" => {
1644 if let AttributeValue::Static(value) = attr_value
1645 && let Err(msg) = style_parser::parse_float_attr(value, attr_name)
1646 {
1647 errors.push(CheckError::InvalidStyleValue {
1648 attr: attr_name.clone(),
1649 file: file_path.to_path_buf(),
1650 line: node.span.line,
1651 col: node.span.column,
1652 message: msg,
1653 });
1654 }
1655 }
1656 "z_index" => {
1657 if let AttributeValue::Static(value) = attr_value
1658 && let Err(msg) = style_parser::parse_int_attr(value, attr_name)
1659 {
1660 errors.push(CheckError::InvalidStyleValue {
1661 attr: attr_name.clone(),
1662 file: file_path.to_path_buf(),
1663 line: node.span.line,
1664 col: node.span.column,
1665 message: msg,
1666 });
1667 }
1668 }
1669 _ => {} }
1671 }
1672}
1673
1674fn validate_breakpoint_attributes(
1676 node: &dampen_core::ir::WidgetNode,
1677 file_path: &Path,
1678 errors: &mut Vec<CheckError>,
1679) {
1680 for (breakpoint, attrs) in &node.breakpoint_attributes {
1681 for (attr_name, attr_value) in attrs {
1682 let base_attr = attr_name.as_str();
1684 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1685
1686 let is_style_attr = matches!(
1688 base_attr,
1689 "background"
1690 | "color"
1691 | "border_width"
1692 | "border_color"
1693 | "border_radius"
1694 | "border_style"
1695 | "shadow"
1696 | "opacity"
1697 | "transform"
1698 );
1699
1700 let is_layout_attr = matches!(
1701 base_attr,
1702 "width"
1703 | "height"
1704 | "min_width"
1705 | "max_width"
1706 | "min_height"
1707 | "max_height"
1708 | "padding"
1709 | "spacing"
1710 | "align_items"
1711 | "justify_content"
1712 | "direction"
1713 | "position"
1714 | "top"
1715 | "right"
1716 | "bottom"
1717 | "left"
1718 | "z_index"
1719 );
1720
1721 if !is_style_attr && !is_layout_attr {
1722 errors.push(CheckError::InvalidBreakpoint {
1723 attr: full_attr,
1724 file: file_path.to_path_buf(),
1725 line: node.span.line,
1726 col: node.span.column,
1727 });
1728 continue;
1729 }
1730
1731 if let AttributeValue::Static(value) = attr_value {
1733 let result: Result<(), String> = match base_attr {
1734 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1735 "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1736 "border_width" | "opacity" => {
1737 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1738 }
1739 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1740 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1741 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1742 "transform" => style_parser::parse_transform(value).map(|_| ()),
1743 "width" | "height" | "min_width" | "max_width" | "min_height"
1744 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1745 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1746 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1747 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1748 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1749 "direction" => Direction::parse(value).map(|_| ()),
1750 "position" => Position::parse(value).map(|_| ()),
1751 "top" | "right" | "bottom" | "left" => {
1752 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1753 }
1754 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1755 _ => Ok(()),
1756 };
1757
1758 if let Err(msg) = result {
1759 errors.push(CheckError::InvalidStyleValue {
1760 attr: full_attr,
1761 file: file_path.to_path_buf(),
1762 line: node.span.line,
1763 col: node.span.column,
1764 message: msg,
1765 });
1766 }
1767 }
1768 }
1769 }
1770}
1771
1772fn validate_state_attributes(
1774 node: &dampen_core::ir::WidgetNode,
1775 file_path: &Path,
1776 errors: &mut Vec<CheckError>,
1777) {
1778 for (attr_name, attr_value) in &node.attributes {
1779 if attr_name.contains(':') {
1780 let parts: Vec<&str> = attr_name.split(':').collect();
1781 if parts.len() >= 2 {
1782 let prefix = parts[0];
1783 let base_attr = parts[1];
1784
1785 if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1787 errors.push(CheckError::InvalidState {
1788 attr: attr_name.clone(),
1789 file: file_path.to_path_buf(),
1790 line: node.span.line,
1791 col: node.span.column,
1792 });
1793 continue;
1794 }
1795
1796 let is_valid_attr = matches!(
1798 base_attr,
1799 "background"
1800 | "color"
1801 | "border_width"
1802 | "border_color"
1803 | "border_radius"
1804 | "border_style"
1805 | "shadow"
1806 | "opacity"
1807 | "transform"
1808 | "width"
1809 | "height"
1810 | "min_width"
1811 | "max_width"
1812 | "min_height"
1813 | "max_height"
1814 | "padding"
1815 | "spacing"
1816 | "align_items"
1817 | "justify_content"
1818 | "direction"
1819 | "position"
1820 | "top"
1821 | "right"
1822 | "bottom"
1823 | "left"
1824 | "z_index"
1825 );
1826
1827 if !is_valid_attr {
1828 errors.push(CheckError::InvalidState {
1829 attr: attr_name.clone(),
1830 file: file_path.to_path_buf(),
1831 line: node.span.line,
1832 col: node.span.column,
1833 });
1834 continue;
1835 }
1836
1837 if let AttributeValue::Static(value) = attr_value {
1839 let result: Result<(), String> = match base_attr {
1840 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1841 "color" | "border_color" => {
1842 style_parser::parse_color_attr(value).map(|_| ())
1843 }
1844 "border_width" | "opacity" => {
1845 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1846 }
1847 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1848 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1849 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1850 "transform" => style_parser::parse_transform(value).map(|_| ()),
1851 "width" | "height" | "min_width" | "max_width" | "min_height"
1852 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1853 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1854 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1855 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1856 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1857 "direction" => Direction::parse(value).map(|_| ()),
1858 "position" => Position::parse(value).map(|_| ()),
1859 "top" | "right" | "bottom" | "left" => {
1860 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1861 }
1862 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1863 _ => Ok(()),
1864 };
1865
1866 if let Err(msg) = result {
1867 errors.push(CheckError::InvalidStyleValue {
1868 attr: attr_name.clone(),
1869 file: file_path.to_path_buf(),
1870 line: node.span.line,
1871 col: node.span.column,
1872 message: msg,
1873 });
1874 }
1875 }
1876 }
1877 }
1878 }
1879}