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::For,
1285 WidgetKind::If,
1286 ]
1287 }
1288}
1289
1290fn validate_references(
1292 document: &dampen_core::ir::DampenDocument,
1293 file_path: &Path,
1294 errors: &mut Vec<CheckError>,
1295) {
1296 if let Some(global_theme) = &document.global_theme
1298 && !document.themes.contains_key(global_theme)
1299 {
1300 errors.push(CheckError::UnknownTheme {
1301 theme: global_theme.clone(),
1302 file: file_path.to_path_buf(),
1303 line: 1,
1304 col: 1,
1305 });
1306 }
1307
1308 for (name, theme) in &document.themes {
1310 if let Err(msg) = theme.validate(false) {
1311 if msg.contains("circular") || msg.contains("Circular") {
1313 errors.push(CheckError::XmlValidationError {
1314 file: file_path.to_path_buf(),
1315 line: 1,
1316 col: 1,
1317 message: format!("Theme '{}' validation error: {}", name, msg),
1318 });
1319 } else {
1320 errors.push(CheckError::InvalidStyleValue {
1321 attr: format!("theme '{}'", name),
1322 file: file_path.to_path_buf(),
1323 line: 1,
1324 col: 1,
1325 message: msg,
1326 });
1327 }
1328 }
1329 }
1330
1331 for (name, class) in &document.style_classes {
1333 if let Err(msg) = class.validate(&document.style_classes) {
1334 if msg.contains("circular") || msg.contains("Circular") {
1336 errors.push(CheckError::XmlValidationError {
1337 file: file_path.to_path_buf(),
1338 line: 1,
1339 col: 1,
1340 message: format!("Style class '{}' has circular dependency: {}", name, msg),
1341 });
1342 } else {
1343 errors.push(CheckError::InvalidStyleValue {
1344 attr: format!("class '{}'", name),
1345 file: file_path.to_path_buf(),
1346 line: 1,
1347 col: 1,
1348 message: msg,
1349 });
1350 }
1351 }
1352 }
1353}
1354
1355fn validate_widget_with_styles(
1357 node: &dampen_core::ir::WidgetNode,
1358 file_path: &Path,
1359 document: &dampen_core::ir::DampenDocument,
1360 errors: &mut Vec<CheckError>,
1361) {
1362 if let Some(style) = &node.style
1364 && let Err(msg) = style.validate()
1365 {
1366 errors.push(CheckError::InvalidStyleValue {
1367 attr: "structured style".to_string(),
1368 file: file_path.to_path_buf(),
1369 line: node.span.line,
1370 col: node.span.column,
1371 message: msg,
1372 });
1373 }
1374
1375 if let Some(layout) = &node.layout
1377 && let Err(msg) = layout.validate()
1378 {
1379 errors.push(CheckError::InvalidLayoutConstraint {
1380 file: file_path.to_path_buf(),
1381 line: node.span.line,
1382 col: node.span.column,
1383 message: msg,
1384 });
1385 }
1386
1387 for class_name in &node.classes {
1389 if !document.style_classes.contains_key(class_name) {
1390 errors.push(CheckError::UnknownStyleClass {
1391 class: class_name.clone(),
1392 file: file_path.to_path_buf(),
1393 line: node.span.line,
1394 col: node.span.column,
1395 });
1396 }
1397 }
1398
1399 if let Some(theme_ref) = &node.theme_ref {
1401 match theme_ref {
1402 AttributeValue::Static(theme_name) => {
1403 if !document.themes.contains_key(theme_name) {
1404 errors.push(CheckError::UnknownTheme {
1405 theme: theme_name.clone(),
1406 file: file_path.to_path_buf(),
1407 line: node.span.line,
1408 col: node.span.column,
1409 });
1410 }
1411 }
1412 AttributeValue::Binding(_) | AttributeValue::Interpolated(_) => {
1413 }
1416 }
1417 }
1418
1419 validate_style_attributes(node, file_path, errors);
1421
1422 validate_layout_attributes(node, file_path, errors);
1424
1425 validate_breakpoint_attributes(node, file_path, errors);
1427
1428 validate_state_attributes(node, file_path, errors);
1430
1431 for child in &node.children {
1433 validate_widget_with_styles(child, file_path, document, errors);
1434 }
1435}
1436
1437fn validate_style_attributes(
1439 node: &dampen_core::ir::WidgetNode,
1440 file_path: &Path,
1441 errors: &mut Vec<CheckError>,
1442) {
1443 for (attr_name, attr_value) in &node.attributes {
1444 match attr_name.as_str() {
1445 "background" => {
1446 if let AttributeValue::Static(value) = attr_value
1447 && let Err(msg) = style_parser::parse_background_attr(value)
1448 {
1449 errors.push(CheckError::InvalidStyleValue {
1450 attr: attr_name.clone(),
1451 file: file_path.to_path_buf(),
1452 line: node.span.line,
1453 col: node.span.column,
1454 message: msg,
1455 });
1456 }
1457 }
1458 "color" | "border_color" => {
1459 if let AttributeValue::Static(value) = attr_value
1460 && let Err(msg) = style_parser::parse_color_attr(value)
1461 {
1462 errors.push(CheckError::InvalidStyleValue {
1463 attr: attr_name.clone(),
1464 file: file_path.to_path_buf(),
1465 line: node.span.line,
1466 col: node.span.column,
1467 message: msg,
1468 });
1469 }
1470 }
1471 "border_width" | "opacity" => {
1472 if let AttributeValue::Static(value) = attr_value
1473 && let Err(msg) = style_parser::parse_float_attr(value, attr_name)
1474 {
1475 errors.push(CheckError::InvalidStyleValue {
1476 attr: attr_name.clone(),
1477 file: file_path.to_path_buf(),
1478 line: node.span.line,
1479 col: node.span.column,
1480 message: msg,
1481 });
1482 }
1483 }
1484 "border_radius" => {
1485 if let AttributeValue::Static(value) = attr_value
1486 && let Err(msg) = style_parser::parse_border_radius(value)
1487 {
1488 errors.push(CheckError::InvalidStyleValue {
1489 attr: attr_name.clone(),
1490 file: file_path.to_path_buf(),
1491 line: node.span.line,
1492 col: node.span.column,
1493 message: msg,
1494 });
1495 }
1496 }
1497 "border_style" => {
1498 if let AttributeValue::Static(value) = attr_value
1499 && let Err(msg) = style_parser::parse_border_style(value)
1500 {
1501 errors.push(CheckError::InvalidStyleValue {
1502 attr: attr_name.clone(),
1503 file: file_path.to_path_buf(),
1504 line: node.span.line,
1505 col: node.span.column,
1506 message: msg,
1507 });
1508 }
1509 }
1510 "shadow" => {
1511 if let AttributeValue::Static(value) = attr_value
1512 && let Err(msg) = style_parser::parse_shadow_attr(value)
1513 {
1514 errors.push(CheckError::InvalidStyleValue {
1515 attr: attr_name.clone(),
1516 file: file_path.to_path_buf(),
1517 line: node.span.line,
1518 col: node.span.column,
1519 message: msg,
1520 });
1521 }
1522 }
1523 "transform" => {
1524 if let AttributeValue::Static(value) = attr_value
1525 && let Err(msg) = style_parser::parse_transform(value)
1526 {
1527 errors.push(CheckError::InvalidStyleValue {
1528 attr: attr_name.clone(),
1529 file: file_path.to_path_buf(),
1530 line: node.span.line,
1531 col: node.span.column,
1532 message: msg,
1533 });
1534 }
1535 }
1536 _ => {} }
1538 }
1539}
1540
1541fn validate_layout_attributes(
1543 node: &dampen_core::ir::WidgetNode,
1544 file_path: &Path,
1545 errors: &mut Vec<CheckError>,
1546) {
1547 for (attr_name, attr_value) in &node.attributes {
1548 match attr_name.as_str() {
1549 "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1550 if let AttributeValue::Static(value) = attr_value
1551 && let Err(msg) = style_parser::parse_length_attr(value)
1552 {
1553 errors.push(CheckError::InvalidStyleValue {
1554 attr: attr_name.clone(),
1555 file: file_path.to_path_buf(),
1556 line: node.span.line,
1557 col: node.span.column,
1558 message: msg,
1559 });
1560 }
1561 }
1562 "padding" => {
1563 if let AttributeValue::Static(value) = attr_value
1564 && let Err(msg) = style_parser::parse_padding_attr(value)
1565 {
1566 errors.push(CheckError::InvalidStyleValue {
1567 attr: attr_name.clone(),
1568 file: file_path.to_path_buf(),
1569 line: node.span.line,
1570 col: node.span.column,
1571 message: msg,
1572 });
1573 }
1574 }
1575 "spacing" => {
1576 if let AttributeValue::Static(value) = attr_value
1577 && let Err(msg) = style_parser::parse_spacing(value)
1578 {
1579 errors.push(CheckError::InvalidStyleValue {
1580 attr: attr_name.clone(),
1581 file: file_path.to_path_buf(),
1582 line: node.span.line,
1583 col: node.span.column,
1584 message: msg,
1585 });
1586 }
1587 }
1588 "align_items" => {
1589 if let AttributeValue::Static(value) = attr_value
1590 && let Err(msg) = style_parser::parse_alignment(value)
1591 {
1592 errors.push(CheckError::InvalidStyleValue {
1593 attr: attr_name.clone(),
1594 file: file_path.to_path_buf(),
1595 line: node.span.line,
1596 col: node.span.column,
1597 message: msg,
1598 });
1599 }
1600 }
1601 "justify_content" => {
1602 if let AttributeValue::Static(value) = attr_value
1603 && let Err(msg) = style_parser::parse_justification(value)
1604 {
1605 errors.push(CheckError::InvalidStyleValue {
1606 attr: attr_name.clone(),
1607 file: file_path.to_path_buf(),
1608 line: node.span.line,
1609 col: node.span.column,
1610 message: msg,
1611 });
1612 }
1613 }
1614 "direction" => {
1615 if let AttributeValue::Static(value) = attr_value
1616 && let Err(msg) = Direction::parse(value)
1617 {
1618 errors.push(CheckError::InvalidStyleValue {
1619 attr: attr_name.clone(),
1620 file: file_path.to_path_buf(),
1621 line: node.span.line,
1622 col: node.span.column,
1623 message: msg,
1624 });
1625 }
1626 }
1627 "position" => {
1628 if !matches!(node.kind, WidgetKind::Tooltip)
1629 && let AttributeValue::Static(value) = attr_value
1630 && let Err(msg) = Position::parse(value)
1631 {
1632 errors.push(CheckError::InvalidStyleValue {
1633 attr: attr_name.clone(),
1634 file: file_path.to_path_buf(),
1635 line: node.span.line,
1636 col: node.span.column,
1637 message: msg,
1638 });
1639 }
1640 }
1641 "top" | "right" | "bottom" | "left" => {
1642 if let AttributeValue::Static(value) = attr_value
1643 && let Err(msg) = style_parser::parse_float_attr(value, attr_name)
1644 {
1645 errors.push(CheckError::InvalidStyleValue {
1646 attr: attr_name.clone(),
1647 file: file_path.to_path_buf(),
1648 line: node.span.line,
1649 col: node.span.column,
1650 message: msg,
1651 });
1652 }
1653 }
1654 "z_index" => {
1655 if let AttributeValue::Static(value) = attr_value
1656 && let Err(msg) = style_parser::parse_int_attr(value, attr_name)
1657 {
1658 errors.push(CheckError::InvalidStyleValue {
1659 attr: attr_name.clone(),
1660 file: file_path.to_path_buf(),
1661 line: node.span.line,
1662 col: node.span.column,
1663 message: msg,
1664 });
1665 }
1666 }
1667 _ => {} }
1669 }
1670}
1671
1672fn validate_breakpoint_attributes(
1674 node: &dampen_core::ir::WidgetNode,
1675 file_path: &Path,
1676 errors: &mut Vec<CheckError>,
1677) {
1678 for (breakpoint, attrs) in &node.breakpoint_attributes {
1679 for (attr_name, attr_value) in attrs {
1680 let base_attr = attr_name.as_str();
1682 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1683
1684 let is_style_attr = matches!(
1686 base_attr,
1687 "background"
1688 | "color"
1689 | "border_width"
1690 | "border_color"
1691 | "border_radius"
1692 | "border_style"
1693 | "shadow"
1694 | "opacity"
1695 | "transform"
1696 );
1697
1698 let is_layout_attr = matches!(
1699 base_attr,
1700 "width"
1701 | "height"
1702 | "min_width"
1703 | "max_width"
1704 | "min_height"
1705 | "max_height"
1706 | "padding"
1707 | "spacing"
1708 | "align_items"
1709 | "justify_content"
1710 | "direction"
1711 | "position"
1712 | "top"
1713 | "right"
1714 | "bottom"
1715 | "left"
1716 | "z_index"
1717 );
1718
1719 if !is_style_attr && !is_layout_attr {
1720 errors.push(CheckError::InvalidBreakpoint {
1721 attr: full_attr,
1722 file: file_path.to_path_buf(),
1723 line: node.span.line,
1724 col: node.span.column,
1725 });
1726 continue;
1727 }
1728
1729 if let AttributeValue::Static(value) = attr_value {
1731 let result: Result<(), String> = match base_attr {
1732 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1733 "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1734 "border_width" | "opacity" => {
1735 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1736 }
1737 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1738 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1739 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1740 "transform" => style_parser::parse_transform(value).map(|_| ()),
1741 "width" | "height" | "min_width" | "max_width" | "min_height"
1742 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1743 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1744 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1745 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1746 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1747 "direction" => Direction::parse(value).map(|_| ()),
1748 "position" => Position::parse(value).map(|_| ()),
1749 "top" | "right" | "bottom" | "left" => {
1750 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1751 }
1752 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1753 _ => Ok(()),
1754 };
1755
1756 if let Err(msg) = result {
1757 errors.push(CheckError::InvalidStyleValue {
1758 attr: full_attr,
1759 file: file_path.to_path_buf(),
1760 line: node.span.line,
1761 col: node.span.column,
1762 message: msg,
1763 });
1764 }
1765 }
1766 }
1767 }
1768}
1769
1770fn validate_state_attributes(
1772 node: &dampen_core::ir::WidgetNode,
1773 file_path: &Path,
1774 errors: &mut Vec<CheckError>,
1775) {
1776 for (attr_name, attr_value) in &node.attributes {
1777 if attr_name.contains(':') {
1778 let parts: Vec<&str> = attr_name.split(':').collect();
1779 if parts.len() >= 2 {
1780 let prefix = parts[0];
1781 let base_attr = parts[1];
1782
1783 if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1785 errors.push(CheckError::InvalidState {
1786 attr: attr_name.clone(),
1787 file: file_path.to_path_buf(),
1788 line: node.span.line,
1789 col: node.span.column,
1790 });
1791 continue;
1792 }
1793
1794 let is_valid_attr = matches!(
1796 base_attr,
1797 "background"
1798 | "color"
1799 | "border_width"
1800 | "border_color"
1801 | "border_radius"
1802 | "border_style"
1803 | "shadow"
1804 | "opacity"
1805 | "transform"
1806 | "width"
1807 | "height"
1808 | "min_width"
1809 | "max_width"
1810 | "min_height"
1811 | "max_height"
1812 | "padding"
1813 | "spacing"
1814 | "align_items"
1815 | "justify_content"
1816 | "direction"
1817 | "position"
1818 | "top"
1819 | "right"
1820 | "bottom"
1821 | "left"
1822 | "z_index"
1823 );
1824
1825 if !is_valid_attr {
1826 errors.push(CheckError::InvalidState {
1827 attr: attr_name.clone(),
1828 file: file_path.to_path_buf(),
1829 line: node.span.line,
1830 col: node.span.column,
1831 });
1832 continue;
1833 }
1834
1835 if let AttributeValue::Static(value) = attr_value {
1837 let result: Result<(), String> = match base_attr {
1838 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1839 "color" | "border_color" => {
1840 style_parser::parse_color_attr(value).map(|_| ())
1841 }
1842 "border_width" | "opacity" => {
1843 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1844 }
1845 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1846 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1847 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1848 "transform" => style_parser::parse_transform(value).map(|_| ()),
1849 "width" | "height" | "min_width" | "max_width" | "min_height"
1850 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1851 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1852 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1853 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1854 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1855 "direction" => Direction::parse(value).map(|_| ()),
1856 "position" => Position::parse(value).map(|_| ()),
1857 "top" | "right" | "bottom" | "left" => {
1858 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1859 }
1860 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1861 _ => Ok(()),
1862 };
1863
1864 if let Err(msg) = result {
1865 errors.push(CheckError::InvalidStyleValue {
1866 attr: attr_name.clone(),
1867 file: file_path.to_path_buf(),
1868 line: node.span.line,
1869 col: node.span.column,
1870 message: msg,
1871 });
1872 }
1873 }
1874 }
1875 }
1876 }
1877}