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 && let Some(ref path) = handlers_path
317 {
318 eprintln!("Using handler registry: {}", path.display());
319 }
320
321 let model_path = resolve_optional_file(args.model.as_deref(), "model.json");
322 if args.verbose
323 && let Some(ref path) = model_path
324 {
325 eprintln!("Using model info: {}", path.display());
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 if file_path.file_name().is_some_and(|n| n == "theme.dampen") {
390 if let Err(theme_error) =
391 dampen_core::parser::theme_parser::parse_theme_document(&content)
392 {
393 errors.push(CheckError::XmlValidationError {
394 file: file_path.to_path_buf(),
395 line: 1, col: 1,
397 message: format!("Theme validation error: {}", theme_error),
398 });
399 }
400 continue;
401 }
402
403 match parser::parse(&content) {
404 Ok(document) => {
405 validate_document(
407 &document,
408 file_path,
409 &handler_registry,
410 &model_info,
411 &mut errors,
412 );
413
414 validate_references(&document, file_path, &mut errors);
416
417 validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
419
420 let version_warnings = dampen_core::validate_widget_versions(&document);
423 if !version_warnings.is_empty() {
424 for warning in version_warnings {
425 eprintln!(
426 "Warning: {} in {}:{}:{}",
427 warning.format_message(),
428 file_path.display(),
429 warning.span.line,
430 warning.span.column
431 );
432 eprintln!(" Suggestion: {}", warning.suggestion());
433 eprintln!();
434 }
435 }
436 }
437 Err(parse_error) => {
438 errors.push(CheckError::ParseError {
439 file: file_path.to_path_buf(),
440 line: parse_error.span.line,
441 col: parse_error.span.column,
442 message: parse_error.to_string(),
443 });
444 }
445 }
446 }
447
448 if args.verbose {
449 eprintln!("Checked {} files", files_checked);
450 }
451
452 if !errors.is_empty() {
454 let error_label = "error(s)"; eprintln!("Found {} {}:", errors.len(), error_label);
459
460 for error in &errors {
461 let prefix = "ERROR"; eprintln!(" [{}] {}", prefix, error);
464 }
465
466 Err(errors.remove(0))
469 } else {
470 if args.verbose {
471 let status = if args.strict {
472 "✓ All files passed validation (strict mode)"
473 } else {
474 "✓ All files passed validation"
475 };
476 eprintln!("{}", status);
477 }
478 Ok(())
479 }
480}
481
482fn validate_xml_declaration(content: &str, file_path: &Path, errors: &mut Vec<CheckError>) {
483 let trimmed = content.trim_start();
485 if !trimmed.starts_with("<?xml version=\"1.0\"") {
486 errors.push(CheckError::XmlValidationError {
487 file: file_path.to_path_buf(),
488 line: 1,
489 col: 1,
490 message: "Missing or invalid XML declaration. Expected: <?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
491 });
492 }
493}
494
495fn validate_document(
496 document: &dampen_core::ir::DampenDocument,
497 file_path: &Path,
498 handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
499 model_info: &Option<crate::commands::check::model::ModelInfo>,
500 errors: &mut Vec<CheckError>,
501) {
502 use crate::commands::check::cross_widget::RadioGroupValidator;
503
504 let valid_widgets: HashSet<String> = WidgetKind::all_variants()
506 .iter()
507 .map(|w| format!("{:?}", w).to_lowercase())
508 .collect();
509
510 let mut radio_validator = RadioGroupValidator::new();
512
513 validate_widget_node(
515 &document.root,
516 file_path,
517 &valid_widgets,
518 handler_registry,
519 model_info,
520 &mut radio_validator,
521 errors,
522 );
523
524 let radio_errors = radio_validator.validate();
526 for error in radio_errors {
527 match error {
529 crate::commands::check::errors::CheckError::DuplicateRadioValue {
530 value,
531 group,
532 file,
533 line,
534 col,
535 first_file,
536 first_line,
537 first_col,
538 } => {
539 errors.push(CheckError::XmlValidationError {
540 file: file.clone(),
541 line,
542 col,
543 message: format!(
544 "Duplicate radio value '{}' in group '{}'. First occurrence: {}:{}:{}",
545 value,
546 group,
547 first_file.display(),
548 first_line,
549 first_col
550 ),
551 });
552 }
553 crate::commands::check::errors::CheckError::InconsistentRadioHandlers {
554 group,
555 file,
556 line,
557 col,
558 handlers,
559 } => {
560 errors.push(CheckError::XmlValidationError {
561 file: file.clone(),
562 line,
563 col,
564 message: format!(
565 "Radio group '{}' has inconsistent on_select handlers. Found handlers: {}",
566 group, handlers
567 ),
568 });
569 }
570 _ => {}
571 }
572 }
573}
574
575fn validate_widget_node(
576 node: &dampen_core::ir::WidgetNode,
577 file_path: &Path,
578 valid_widgets: &HashSet<String>,
579 handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
580 model_info: &Option<crate::commands::check::model::ModelInfo>,
581 radio_validator: &mut crate::commands::check::cross_widget::RadioGroupValidator,
582 errors: &mut Vec<CheckError>,
583) {
584 use crate::commands::check::attributes;
585 use crate::commands::check::suggestions;
586
587 let widget_name = format!("{:?}", node.kind).to_lowercase();
589 if !valid_widgets.contains(&widget_name) && !matches!(node.kind, WidgetKind::Custom(_)) {
590 errors.push(CheckError::InvalidWidget {
591 widget: widget_name.clone(),
592 file: file_path.to_path_buf(),
593 line: node.span.line,
594 col: node.span.column,
595 });
596 }
597
598 let attr_names: Vec<String> = node.attributes.keys().map(|s| s.to_string()).collect();
600 let unknown_attrs = attributes::validate_widget_attributes(&node.kind, &attr_names);
601
602 for (attr, _suggestion_opt) in unknown_attrs {
603 let schema = attributes::WidgetAttributeSchema::for_widget(&node.kind);
604 let all_valid = schema.all_valid_names();
605 let suggestion = suggestions::suggest(&attr, &all_valid, 3);
606
607 errors.push(CheckError::UnknownAttribute {
608 attr,
609 widget: widget_name.clone(),
610 file: file_path.to_path_buf(),
611 line: node.span.line,
612 col: node.span.column,
613 suggestion,
614 });
615 }
616
617 let missing_required = attributes::validate_required_attributes(&node.kind, &attr_names);
619 for missing_attr in missing_required {
620 errors.push(CheckError::XmlValidationError {
621 file: file_path.to_path_buf(),
622 line: node.span.line,
623 col: node.span.column,
624 message: format!(
625 "Missing required attribute '{}' for widget '{}'",
626 missing_attr, widget_name
627 ),
628 });
629 }
630
631 if let Some(registry) = handler_registry {
633 for event_binding in &node.events {
634 if !registry.contains(&event_binding.handler) {
635 let all_handler_names = registry.all_names();
637 let handler_refs: Vec<&str> =
638 all_handler_names.iter().map(|s| s.as_str()).collect();
639 let suggestion = suggestions::suggest(&event_binding.handler, &handler_refs, 3);
640
641 errors.push(CheckError::UnknownHandler {
642 handler: event_binding.handler.clone(),
643 file: file_path.to_path_buf(),
644 line: event_binding.span.line,
645 col: event_binding.span.column,
646 suggestion,
647 });
648 }
649 }
650 } else {
651 for event_binding in &node.events {
653 if event_binding.handler.is_empty() {
654 errors.push(CheckError::UnknownHandler {
655 handler: "<empty>".to_string(),
656 file: file_path.to_path_buf(),
657 line: event_binding.span.line,
658 col: event_binding.span.column,
659 suggestion: String::new(),
660 });
661 }
662 }
663 }
664
665 if let Some(model) = model_info {
667 for (attr_name, attr_value) in &node.attributes {
668 validate_attribute_bindings(
669 attr_name,
670 attr_value,
671 file_path,
672 node.span.line,
673 node.span.column,
674 model,
675 errors,
676 );
677 }
678 }
679
680 for attr_value in node.attributes.values() {
682 validate_attribute_value(
683 attr_value,
684 file_path,
685 node.span.line,
686 node.span.column,
687 errors,
688 );
689 }
690
691 if matches!(node.kind, WidgetKind::Radio) {
693 let group_id = node
695 .attributes
696 .get("id")
697 .and_then(|v| match v {
698 AttributeValue::Static(s) => Some(s.as_str()),
699 _ => None,
700 })
701 .unwrap_or("default");
702
703 let value = node
704 .attributes
705 .get("value")
706 .and_then(|v| match v {
707 AttributeValue::Static(s) => Some(s.as_str()),
708 _ => None,
709 })
710 .unwrap_or("");
711
712 let handler = node
714 .events
715 .iter()
716 .find(|e| e.event == EventKind::Select)
717 .map(|e| e.handler.clone());
718
719 radio_validator.add_radio(
720 group_id,
721 value,
722 file_path.to_str().unwrap_or("unknown"),
723 node.span.line,
724 node.span.column,
725 handler,
726 );
727 }
728
729 for child in &node.children {
731 validate_widget_node(
732 child,
733 file_path,
734 valid_widgets,
735 handler_registry,
736 model_info,
737 radio_validator,
738 errors,
739 );
740 }
741}
742
743fn validate_attribute_bindings(
744 _attr_name: &str,
745 value: &dampen_core::ir::AttributeValue,
746 file_path: &Path,
747 line: u32,
748 col: u32,
749 model: &crate::commands::check::model::ModelInfo,
750 errors: &mut Vec<CheckError>,
751) {
752 if let dampen_core::ir::AttributeValue::Binding(binding_expr) = value {
754 validate_expr_fields(&binding_expr.expr, file_path, line, col, model, errors);
756 }
757}
758
759fn validate_expr_fields(
760 expr: &dampen_core::expr::Expr,
761 file_path: &Path,
762 line: u32,
763 col: u32,
764 model: &crate::commands::check::model::ModelInfo,
765 errors: &mut Vec<CheckError>,
766) {
767 match expr {
768 dampen_core::expr::Expr::FieldAccess(field_access) => {
769 let field_parts: Vec<&str> = field_access.path.iter().map(|s| s.as_str()).collect();
771
772 if !model.contains_field(&field_parts) {
773 let all_paths = model.all_field_paths();
775 let available = if all_paths.len() > 5 {
776 format!("{} ({} total)", &all_paths[..5].join(", "), all_paths.len())
777 } else {
778 all_paths.join(", ")
779 };
780
781 let field_path = field_access.path.join(".");
782
783 errors.push(CheckError::InvalidBinding {
784 field: field_path,
785 file: file_path.to_path_buf(),
786 line,
787 col,
788 });
789
790 eprintln!(" Available fields: {}", available);
792 }
793 }
794 dampen_core::expr::Expr::MethodCall(method_call) => {
795 validate_expr_fields(&method_call.receiver, file_path, line, col, model, errors);
797 for arg in &method_call.args {
799 validate_expr_fields(arg, file_path, line, col, model, errors);
800 }
801 }
802 dampen_core::expr::Expr::BinaryOp(binary_op) => {
803 validate_expr_fields(&binary_op.left, file_path, line, col, model, errors);
805 validate_expr_fields(&binary_op.right, file_path, line, col, model, errors);
806 }
807 dampen_core::expr::Expr::UnaryOp(unary_op) => {
808 validate_expr_fields(&unary_op.operand, file_path, line, col, model, errors);
810 }
811 dampen_core::expr::Expr::Conditional(conditional) => {
812 validate_expr_fields(&conditional.condition, file_path, line, col, model, errors);
814 validate_expr_fields(
815 &conditional.then_branch,
816 file_path,
817 line,
818 col,
819 model,
820 errors,
821 );
822 validate_expr_fields(
823 &conditional.else_branch,
824 file_path,
825 line,
826 col,
827 model,
828 errors,
829 );
830 }
831 dampen_core::expr::Expr::Literal(_) => {
832 }
834 dampen_core::expr::Expr::SharedFieldAccess(shared_access) => {
835 if shared_access.path.is_empty() || shared_access.path.iter().any(|f| f.is_empty()) {
837 errors.push(CheckError::InvalidBinding {
838 field: "shared.<empty>".to_string(),
839 file: file_path.to_path_buf(),
840 line,
841 col,
842 });
843 }
844 }
845 }
846}
847
848fn validate_attribute_value(
849 value: &dampen_core::ir::AttributeValue,
850 file_path: &Path,
851 line: u32,
852 col: u32,
853 errors: &mut Vec<CheckError>,
854) {
855 match value {
856 dampen_core::ir::AttributeValue::Static(_) => {
857 }
859 dampen_core::ir::AttributeValue::Binding(binding_expr) => {
860 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
863 }
864 dampen_core::ir::AttributeValue::Interpolated(parts) => {
865 for part in parts {
866 match part {
867 dampen_core::ir::InterpolatedPart::Literal(_) => {
868 }
870 dampen_core::ir::InterpolatedPart::Binding(binding_expr) => {
871 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
872 }
873 }
874 }
875 }
876 }
877}
878
879fn validate_binding_expr(
880 expr: &dampen_core::expr::Expr,
881 file_path: &Path,
882 line: u32,
883 col: u32,
884 errors: &mut Vec<CheckError>,
885) {
886 match expr {
887 dampen_core::expr::Expr::FieldAccess(field_access) => {
888 if field_access.path.is_empty() || field_access.path.iter().any(|f| f.is_empty()) {
891 errors.push(CheckError::InvalidBinding {
892 field: "<empty>".to_string(),
893 file: file_path.to_path_buf(),
894 line,
895 col,
896 });
897 }
898 }
899 dampen_core::expr::Expr::MethodCall(_) => {
900 }
903 dampen_core::expr::Expr::BinaryOp(_) => {
904 }
907 dampen_core::expr::Expr::UnaryOp(_) => {
908 }
910 dampen_core::expr::Expr::Conditional(_) => {
911 }
913 dampen_core::expr::Expr::Literal(_) => {
914 }
916 dampen_core::expr::Expr::SharedFieldAccess(_) => {
917 }
920 }
921}
922
923trait WidgetKindExt {
925 fn all_variants() -> Vec<WidgetKind>;
926}
927
928impl WidgetKindExt for WidgetKind {
929 fn all_variants() -> Vec<WidgetKind> {
930 vec![
931 WidgetKind::Column,
932 WidgetKind::Row,
933 WidgetKind::Container,
934 WidgetKind::Scrollable,
935 WidgetKind::Stack,
936 WidgetKind::Text,
937 WidgetKind::Image,
938 WidgetKind::Svg,
939 WidgetKind::Button,
940 WidgetKind::TextInput,
941 WidgetKind::Checkbox,
942 WidgetKind::Slider,
943 WidgetKind::PickList,
944 WidgetKind::Toggler,
945 WidgetKind::Space,
946 WidgetKind::Rule,
947 WidgetKind::Radio,
948 WidgetKind::ComboBox,
949 WidgetKind::ProgressBar,
950 WidgetKind::Tooltip,
951 WidgetKind::Grid,
952 WidgetKind::Canvas,
953 WidgetKind::Float,
954 WidgetKind::For,
955 WidgetKind::If,
956 ]
957 }
958}
959
960fn validate_references(
962 document: &dampen_core::ir::DampenDocument,
963 file_path: &Path,
964 errors: &mut Vec<CheckError>,
965) {
966 if let Some(global_theme) = &document.global_theme
968 && !document.themes.contains_key(global_theme)
969 {
970 errors.push(CheckError::UnknownTheme {
971 theme: global_theme.clone(),
972 file: file_path.to_path_buf(),
973 line: 1,
974 col: 1,
975 });
976 }
977
978 for (name, theme) in &document.themes {
980 if let Err(msg) = theme.validate(false) {
981 if msg.contains("circular") || msg.contains("Circular") {
983 errors.push(CheckError::XmlValidationError {
984 file: file_path.to_path_buf(),
985 line: 1,
986 col: 1,
987 message: format!("Theme '{}' validation error: {}", name, msg),
988 });
989 } else {
990 errors.push(CheckError::InvalidStyleValue {
991 attr: format!("theme '{}'", name),
992 file: file_path.to_path_buf(),
993 line: 1,
994 col: 1,
995 message: msg,
996 });
997 }
998 }
999 }
1000
1001 for (name, class) in &document.style_classes {
1003 if let Err(msg) = class.validate(&document.style_classes) {
1004 if msg.contains("circular") || msg.contains("Circular") {
1006 errors.push(CheckError::XmlValidationError {
1007 file: file_path.to_path_buf(),
1008 line: 1,
1009 col: 1,
1010 message: format!("Style class '{}' has circular dependency: {}", name, msg),
1011 });
1012 } else {
1013 errors.push(CheckError::InvalidStyleValue {
1014 attr: format!("class '{}'", name),
1015 file: file_path.to_path_buf(),
1016 line: 1,
1017 col: 1,
1018 message: msg,
1019 });
1020 }
1021 }
1022 }
1023}
1024
1025fn validate_widget_with_styles(
1027 node: &dampen_core::ir::WidgetNode,
1028 file_path: &Path,
1029 document: &dampen_core::ir::DampenDocument,
1030 errors: &mut Vec<CheckError>,
1031) {
1032 if let Some(style) = &node.style
1034 && let Err(msg) = style.validate()
1035 {
1036 errors.push(CheckError::InvalidStyleValue {
1037 attr: "structured style".to_string(),
1038 file: file_path.to_path_buf(),
1039 line: node.span.line,
1040 col: node.span.column,
1041 message: msg,
1042 });
1043 }
1044
1045 if let Some(layout) = &node.layout
1047 && let Err(msg) = layout.validate()
1048 {
1049 errors.push(CheckError::InvalidLayoutConstraint {
1050 file: file_path.to_path_buf(),
1051 line: node.span.line,
1052 col: node.span.column,
1053 message: msg,
1054 });
1055 }
1056
1057 for class_name in &node.classes {
1059 if !document.style_classes.contains_key(class_name) {
1060 errors.push(CheckError::UnknownStyleClass {
1061 class: class_name.clone(),
1062 file: file_path.to_path_buf(),
1063 line: node.span.line,
1064 col: node.span.column,
1065 });
1066 }
1067 }
1068
1069 if let Some(theme_ref) = &node.theme_ref {
1071 match theme_ref {
1072 AttributeValue::Static(theme_name) => {
1073 if !document.themes.contains_key(theme_name) {
1074 errors.push(CheckError::UnknownTheme {
1075 theme: theme_name.clone(),
1076 file: file_path.to_path_buf(),
1077 line: node.span.line,
1078 col: node.span.column,
1079 });
1080 }
1081 }
1082 AttributeValue::Binding(_) | AttributeValue::Interpolated(_) => {
1083 }
1086 }
1087 }
1088
1089 validate_style_attributes(node, file_path, errors);
1091
1092 validate_layout_attributes(node, file_path, errors);
1094
1095 validate_breakpoint_attributes(node, file_path, errors);
1097
1098 validate_state_attributes(node, file_path, errors);
1100
1101 for child in &node.children {
1103 validate_widget_with_styles(child, file_path, document, errors);
1104 }
1105}
1106
1107fn validate_style_attributes(
1109 node: &dampen_core::ir::WidgetNode,
1110 file_path: &Path,
1111 errors: &mut Vec<CheckError>,
1112) {
1113 for (attr_name, attr_value) in &node.attributes {
1114 match attr_name.as_str() {
1115 "background" => {
1116 if let AttributeValue::Static(value) = attr_value
1117 && let Err(msg) = style_parser::parse_background_attr(value)
1118 {
1119 errors.push(CheckError::InvalidStyleValue {
1120 attr: attr_name.clone(),
1121 file: file_path.to_path_buf(),
1122 line: node.span.line,
1123 col: node.span.column,
1124 message: msg,
1125 });
1126 }
1127 }
1128 "color" | "border_color" => {
1129 if let AttributeValue::Static(value) = attr_value
1130 && let Err(msg) = style_parser::parse_color_attr(value)
1131 {
1132 errors.push(CheckError::InvalidStyleValue {
1133 attr: attr_name.clone(),
1134 file: file_path.to_path_buf(),
1135 line: node.span.line,
1136 col: node.span.column,
1137 message: msg,
1138 });
1139 }
1140 }
1141 "border_width" | "opacity" => {
1142 if let AttributeValue::Static(value) = attr_value
1143 && let Err(msg) = style_parser::parse_float_attr(value, attr_name)
1144 {
1145 errors.push(CheckError::InvalidStyleValue {
1146 attr: attr_name.clone(),
1147 file: file_path.to_path_buf(),
1148 line: node.span.line,
1149 col: node.span.column,
1150 message: msg,
1151 });
1152 }
1153 }
1154 "border_radius" => {
1155 if let AttributeValue::Static(value) = attr_value
1156 && let Err(msg) = style_parser::parse_border_radius(value)
1157 {
1158 errors.push(CheckError::InvalidStyleValue {
1159 attr: attr_name.clone(),
1160 file: file_path.to_path_buf(),
1161 line: node.span.line,
1162 col: node.span.column,
1163 message: msg,
1164 });
1165 }
1166 }
1167 "border_style" => {
1168 if let AttributeValue::Static(value) = attr_value
1169 && let Err(msg) = style_parser::parse_border_style(value)
1170 {
1171 errors.push(CheckError::InvalidStyleValue {
1172 attr: attr_name.clone(),
1173 file: file_path.to_path_buf(),
1174 line: node.span.line,
1175 col: node.span.column,
1176 message: msg,
1177 });
1178 }
1179 }
1180 "shadow" => {
1181 if let AttributeValue::Static(value) = attr_value
1182 && let Err(msg) = style_parser::parse_shadow_attr(value)
1183 {
1184 errors.push(CheckError::InvalidStyleValue {
1185 attr: attr_name.clone(),
1186 file: file_path.to_path_buf(),
1187 line: node.span.line,
1188 col: node.span.column,
1189 message: msg,
1190 });
1191 }
1192 }
1193 "transform" => {
1194 if let AttributeValue::Static(value) = attr_value
1195 && let Err(msg) = style_parser::parse_transform(value)
1196 {
1197 errors.push(CheckError::InvalidStyleValue {
1198 attr: attr_name.clone(),
1199 file: file_path.to_path_buf(),
1200 line: node.span.line,
1201 col: node.span.column,
1202 message: msg,
1203 });
1204 }
1205 }
1206 _ => {} }
1208 }
1209}
1210
1211fn validate_layout_attributes(
1213 node: &dampen_core::ir::WidgetNode,
1214 file_path: &Path,
1215 errors: &mut Vec<CheckError>,
1216) {
1217 for (attr_name, attr_value) in &node.attributes {
1218 match attr_name.as_str() {
1219 "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1220 if let AttributeValue::Static(value) = attr_value
1221 && let Err(msg) = style_parser::parse_length_attr(value)
1222 {
1223 errors.push(CheckError::InvalidStyleValue {
1224 attr: attr_name.clone(),
1225 file: file_path.to_path_buf(),
1226 line: node.span.line,
1227 col: node.span.column,
1228 message: msg,
1229 });
1230 }
1231 }
1232 "padding" => {
1233 if let AttributeValue::Static(value) = attr_value
1234 && let Err(msg) = style_parser::parse_padding_attr(value)
1235 {
1236 errors.push(CheckError::InvalidStyleValue {
1237 attr: attr_name.clone(),
1238 file: file_path.to_path_buf(),
1239 line: node.span.line,
1240 col: node.span.column,
1241 message: msg,
1242 });
1243 }
1244 }
1245 "spacing" => {
1246 if let AttributeValue::Static(value) = attr_value
1247 && let Err(msg) = style_parser::parse_spacing(value)
1248 {
1249 errors.push(CheckError::InvalidStyleValue {
1250 attr: attr_name.clone(),
1251 file: file_path.to_path_buf(),
1252 line: node.span.line,
1253 col: node.span.column,
1254 message: msg,
1255 });
1256 }
1257 }
1258 "align_items" => {
1259 if let AttributeValue::Static(value) = attr_value
1260 && let Err(msg) = style_parser::parse_alignment(value)
1261 {
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 "justify_content" => {
1272 if let AttributeValue::Static(value) = attr_value
1273 && let Err(msg) = style_parser::parse_justification(value)
1274 {
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 "direction" => {
1285 if let AttributeValue::Static(value) = attr_value
1286 && let Err(msg) = Direction::parse(value)
1287 {
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 "position" => {
1298 if !matches!(node.kind, WidgetKind::Tooltip)
1299 && let AttributeValue::Static(value) = attr_value
1300 && let Err(msg) = Position::parse(value)
1301 {
1302 errors.push(CheckError::InvalidStyleValue {
1303 attr: attr_name.clone(),
1304 file: file_path.to_path_buf(),
1305 line: node.span.line,
1306 col: node.span.column,
1307 message: msg,
1308 });
1309 }
1310 }
1311 "top" | "right" | "bottom" | "left" => {
1312 if let AttributeValue::Static(value) = attr_value
1313 && let Err(msg) = style_parser::parse_float_attr(value, attr_name)
1314 {
1315 errors.push(CheckError::InvalidStyleValue {
1316 attr: attr_name.clone(),
1317 file: file_path.to_path_buf(),
1318 line: node.span.line,
1319 col: node.span.column,
1320 message: msg,
1321 });
1322 }
1323 }
1324 "z_index" => {
1325 if let AttributeValue::Static(value) = attr_value
1326 && let Err(msg) = style_parser::parse_int_attr(value, attr_name)
1327 {
1328 errors.push(CheckError::InvalidStyleValue {
1329 attr: attr_name.clone(),
1330 file: file_path.to_path_buf(),
1331 line: node.span.line,
1332 col: node.span.column,
1333 message: msg,
1334 });
1335 }
1336 }
1337 _ => {} }
1339 }
1340}
1341
1342fn validate_breakpoint_attributes(
1344 node: &dampen_core::ir::WidgetNode,
1345 file_path: &Path,
1346 errors: &mut Vec<CheckError>,
1347) {
1348 for (breakpoint, attrs) in &node.breakpoint_attributes {
1349 for (attr_name, attr_value) in attrs {
1350 let base_attr = attr_name.as_str();
1352 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1353
1354 let is_style_attr = matches!(
1356 base_attr,
1357 "background"
1358 | "color"
1359 | "border_width"
1360 | "border_color"
1361 | "border_radius"
1362 | "border_style"
1363 | "shadow"
1364 | "opacity"
1365 | "transform"
1366 );
1367
1368 let is_layout_attr = matches!(
1369 base_attr,
1370 "width"
1371 | "height"
1372 | "min_width"
1373 | "max_width"
1374 | "min_height"
1375 | "max_height"
1376 | "padding"
1377 | "spacing"
1378 | "align_items"
1379 | "justify_content"
1380 | "direction"
1381 | "position"
1382 | "top"
1383 | "right"
1384 | "bottom"
1385 | "left"
1386 | "z_index"
1387 );
1388
1389 if !is_style_attr && !is_layout_attr {
1390 errors.push(CheckError::InvalidBreakpoint {
1391 attr: full_attr,
1392 file: file_path.to_path_buf(),
1393 line: node.span.line,
1394 col: node.span.column,
1395 });
1396 continue;
1397 }
1398
1399 if let AttributeValue::Static(value) = attr_value {
1401 let result: Result<(), String> = match base_attr {
1402 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1403 "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1404 "border_width" | "opacity" => {
1405 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1406 }
1407 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1408 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1409 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1410 "transform" => style_parser::parse_transform(value).map(|_| ()),
1411 "width" | "height" | "min_width" | "max_width" | "min_height"
1412 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1413 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1414 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1415 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1416 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1417 "direction" => Direction::parse(value).map(|_| ()),
1418 "position" => Position::parse(value).map(|_| ()),
1419 "top" | "right" | "bottom" | "left" => {
1420 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1421 }
1422 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1423 _ => Ok(()),
1424 };
1425
1426 if let Err(msg) = result {
1427 errors.push(CheckError::InvalidStyleValue {
1428 attr: full_attr,
1429 file: file_path.to_path_buf(),
1430 line: node.span.line,
1431 col: node.span.column,
1432 message: msg,
1433 });
1434 }
1435 }
1436 }
1437 }
1438}
1439
1440fn validate_state_attributes(
1442 node: &dampen_core::ir::WidgetNode,
1443 file_path: &Path,
1444 errors: &mut Vec<CheckError>,
1445) {
1446 for (attr_name, attr_value) in &node.attributes {
1447 if attr_name.contains(':') {
1448 let parts: Vec<&str> = attr_name.split(':').collect();
1449 if parts.len() >= 2 {
1450 let prefix = parts[0];
1451 let base_attr = parts[1];
1452
1453 if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1455 errors.push(CheckError::InvalidState {
1456 attr: attr_name.clone(),
1457 file: file_path.to_path_buf(),
1458 line: node.span.line,
1459 col: node.span.column,
1460 });
1461 continue;
1462 }
1463
1464 let is_valid_attr = matches!(
1466 base_attr,
1467 "background"
1468 | "color"
1469 | "border_width"
1470 | "border_color"
1471 | "border_radius"
1472 | "border_style"
1473 | "shadow"
1474 | "opacity"
1475 | "transform"
1476 | "width"
1477 | "height"
1478 | "min_width"
1479 | "max_width"
1480 | "min_height"
1481 | "max_height"
1482 | "padding"
1483 | "spacing"
1484 | "align_items"
1485 | "justify_content"
1486 | "direction"
1487 | "position"
1488 | "top"
1489 | "right"
1490 | "bottom"
1491 | "left"
1492 | "z_index"
1493 );
1494
1495 if !is_valid_attr {
1496 errors.push(CheckError::InvalidState {
1497 attr: attr_name.clone(),
1498 file: file_path.to_path_buf(),
1499 line: node.span.line,
1500 col: node.span.column,
1501 });
1502 continue;
1503 }
1504
1505 if let AttributeValue::Static(value) = attr_value {
1507 let result: Result<(), String> = match base_attr {
1508 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1509 "color" | "border_color" => {
1510 style_parser::parse_color_attr(value).map(|_| ())
1511 }
1512 "border_width" | "opacity" => {
1513 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1514 }
1515 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1516 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1517 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1518 "transform" => style_parser::parse_transform(value).map(|_| ()),
1519 "width" | "height" | "min_width" | "max_width" | "min_height"
1520 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1521 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1522 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1523 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1524 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1525 "direction" => Direction::parse(value).map(|_| ()),
1526 "position" => Position::parse(value).map(|_| ()),
1527 "top" | "right" | "bottom" | "left" => {
1528 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1529 }
1530 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1531 _ => Ok(()),
1532 };
1533
1534 if let Err(msg) = result {
1535 errors.push(CheckError::InvalidStyleValue {
1536 attr: attr_name.clone(),
1537 file: file_path.to_path_buf(),
1538 line: node.span.line,
1539 col: node.span.column,
1540 message: msg,
1541 });
1542 }
1543 }
1544 }
1545 }
1546 }
1547}