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
187fn resolve_ui_directory(explicit_input: Option<&str>) -> Result<PathBuf, String> {
189 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 let src_ui = PathBuf::from("src/ui");
201 if src_ui.exists() && src_ui.is_dir() {
202 return Ok(src_ui);
203 }
204
205 let ui = PathBuf::from("ui");
207 if ui.exists() && ui.is_dir() {
208 return Ok(ui);
209 }
210
211 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
220fn resolve_optional_file(explicit_path: Option<&str>, filename: &str) -> Option<PathBuf> {
222 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 return Some(path_buf);
230 }
231
232 let root_file = PathBuf::from(filename);
234 if root_file.exists() {
235 return Some(root_file);
236 }
237
238 let src_file = PathBuf::from("src").join(filename);
240 if src_file.exists() {
241 return Some(src_file);
242 }
243
244 None
246}
247
248fn 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 args.show_widget_versions {
301 display_widget_version_table();
302 return Ok(());
303 }
304
305 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 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 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 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 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 let content = fs::read_to_string(file_path)?;
379
380 validate_xml_declaration(&content, file_path, &mut errors);
382
383 if !errors.is_empty() {
385 continue;
386 }
387
388 match parser::parse(&content) {
389 Ok(document) => {
390 validate_document(
392 &document,
393 file_path,
394 &handler_registry,
395 &model_info,
396 &mut errors,
397 );
398
399 validate_references(&document, file_path, &mut errors);
401
402 validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
404
405 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 if !errors.is_empty() {
439 let error_label = "error(s)"; eprintln!("Found {} {}:", errors.len(), error_label);
444
445 for error in &errors {
446 let prefix = "ERROR"; eprintln!(" [{}] {}", prefix, error);
449 }
450
451 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 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 let valid_widgets: HashSet<String> = WidgetKind::all_variants()
491 .iter()
492 .map(|w| format!("{:?}", w).to_lowercase())
493 .collect();
494
495 let mut radio_validator = RadioGroupValidator::new();
497
498 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 let radio_errors = radio_validator.validate();
511 for error in radio_errors {
512 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 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 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 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 if let Some(registry) = handler_registry {
618 for event_binding in &node.events {
619 if !registry.contains(&event_binding.handler) {
620 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 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 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 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 if matches!(node.kind, WidgetKind::Radio) {
678 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 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 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 if let dampen_core::ir::AttributeValue::Binding(binding_expr) = value {
739 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 let field_parts: Vec<&str> = field_access.path.iter().map(|s| s.as_str()).collect();
756
757 if !model.contains_field(&field_parts) {
758 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 eprintln!(" Available fields: {}", available);
777 }
778 }
779 dampen_core::expr::Expr::MethodCall(method_call) => {
780 validate_expr_fields(&method_call.receiver, file_path, line, col, model, errors);
782 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_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_expr_fields(&unary_op.operand, file_path, line, col, model, errors);
795 }
796 dampen_core::expr::Expr::Conditional(conditional) => {
797 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 }
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 }
833 dampen_core::ir::AttributeValue::Binding(binding_expr) => {
834 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 }
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 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 }
877 dampen_core::expr::Expr::BinaryOp(_) => {
878 }
881 dampen_core::expr::Expr::UnaryOp(_) => {
882 }
884 dampen_core::expr::Expr::Conditional(_) => {
885 }
887 dampen_core::expr::Expr::Literal(_) => {
888 }
890 }
891}
892
893trait 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
929fn validate_references(
931 document: &dampen_core::ir::DampenDocument,
932 file_path: &Path,
933 errors: &mut Vec<CheckError>,
934) {
935 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 for (name, theme) in &document.themes {
949 if let Err(msg) = theme.validate() {
950 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 for (name, class) in &document.style_classes {
972 if let Err(msg) = class.validate(&document.style_classes) {
973 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
994fn 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 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 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 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 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_style_attributes(node, file_path, errors);
1052
1053 validate_layout_attributes(node, file_path, errors);
1055
1056 validate_breakpoint_attributes(node, file_path, errors);
1058
1059 validate_state_attributes(node, file_path, errors);
1061
1062 for child in &node.children {
1064 validate_widget_with_styles(child, file_path, document, errors);
1065 }
1066}
1067
1068fn 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 _ => {} }
1169 }
1170}
1171
1172fn 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 _ => {} }
1300 }
1301}
1302
1303fn 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 let base_attr = attr_name.as_str();
1313 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1314
1315 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 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
1401fn 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 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 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 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}