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 dampen_core::expr::Expr::SharedFieldAccess(shared_access) => {
820 if shared_access.path.is_empty() || shared_access.path.iter().any(|f| f.is_empty()) {
822 errors.push(CheckError::InvalidBinding {
823 field: "shared.<empty>".to_string(),
824 file: file_path.to_path_buf(),
825 line,
826 col,
827 });
828 }
829 }
830 }
831}
832
833fn validate_attribute_value(
834 value: &dampen_core::ir::AttributeValue,
835 file_path: &Path,
836 line: u32,
837 col: u32,
838 errors: &mut Vec<CheckError>,
839) {
840 match value {
841 dampen_core::ir::AttributeValue::Static(_) => {
842 }
844 dampen_core::ir::AttributeValue::Binding(binding_expr) => {
845 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
848 }
849 dampen_core::ir::AttributeValue::Interpolated(parts) => {
850 for part in parts {
851 match part {
852 dampen_core::ir::InterpolatedPart::Literal(_) => {
853 }
855 dampen_core::ir::InterpolatedPart::Binding(binding_expr) => {
856 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
857 }
858 }
859 }
860 }
861 }
862}
863
864fn validate_binding_expr(
865 expr: &dampen_core::expr::Expr,
866 file_path: &Path,
867 line: u32,
868 col: u32,
869 errors: &mut Vec<CheckError>,
870) {
871 match expr {
872 dampen_core::expr::Expr::FieldAccess(field_access) => {
873 if field_access.path.is_empty() || field_access.path.iter().any(|f| f.is_empty()) {
876 errors.push(CheckError::InvalidBinding {
877 field: "<empty>".to_string(),
878 file: file_path.to_path_buf(),
879 line,
880 col,
881 });
882 }
883 }
884 dampen_core::expr::Expr::MethodCall(_) => {
885 }
888 dampen_core::expr::Expr::BinaryOp(_) => {
889 }
892 dampen_core::expr::Expr::UnaryOp(_) => {
893 }
895 dampen_core::expr::Expr::Conditional(_) => {
896 }
898 dampen_core::expr::Expr::Literal(_) => {
899 }
901 dampen_core::expr::Expr::SharedFieldAccess(_) => {
902 }
905 }
906}
907
908trait WidgetKindExt {
910 fn all_variants() -> Vec<WidgetKind>;
911}
912
913impl WidgetKindExt for WidgetKind {
914 fn all_variants() -> Vec<WidgetKind> {
915 vec![
916 WidgetKind::Column,
917 WidgetKind::Row,
918 WidgetKind::Container,
919 WidgetKind::Scrollable,
920 WidgetKind::Stack,
921 WidgetKind::Text,
922 WidgetKind::Image,
923 WidgetKind::Svg,
924 WidgetKind::Button,
925 WidgetKind::TextInput,
926 WidgetKind::Checkbox,
927 WidgetKind::Slider,
928 WidgetKind::PickList,
929 WidgetKind::Toggler,
930 WidgetKind::Space,
931 WidgetKind::Rule,
932 WidgetKind::Radio,
933 WidgetKind::ComboBox,
934 WidgetKind::ProgressBar,
935 WidgetKind::Tooltip,
936 WidgetKind::Grid,
937 WidgetKind::Canvas,
938 WidgetKind::Float,
939 WidgetKind::For,
940 ]
941 }
942}
943
944fn validate_references(
946 document: &dampen_core::ir::DampenDocument,
947 file_path: &Path,
948 errors: &mut Vec<CheckError>,
949) {
950 if let Some(global_theme) = &document.global_theme {
952 if !document.themes.contains_key(global_theme) {
953 errors.push(CheckError::UnknownTheme {
954 theme: global_theme.clone(),
955 file: file_path.to_path_buf(),
956 line: 1,
957 col: 1,
958 });
959 }
960 }
961
962 for (name, theme) in &document.themes {
964 if let Err(msg) = theme.validate() {
965 if msg.contains("circular") || msg.contains("Circular") {
967 errors.push(CheckError::XmlValidationError {
968 file: file_path.to_path_buf(),
969 line: 1,
970 col: 1,
971 message: format!("Theme '{}' validation error: {}", name, msg),
972 });
973 } else {
974 errors.push(CheckError::InvalidStyleValue {
975 attr: format!("theme '{}'", name),
976 file: file_path.to_path_buf(),
977 line: 1,
978 col: 1,
979 message: msg,
980 });
981 }
982 }
983 }
984
985 for (name, class) in &document.style_classes {
987 if let Err(msg) = class.validate(&document.style_classes) {
988 if msg.contains("circular") || msg.contains("Circular") {
990 errors.push(CheckError::XmlValidationError {
991 file: file_path.to_path_buf(),
992 line: 1,
993 col: 1,
994 message: format!("Style class '{}' has circular dependency: {}", name, msg),
995 });
996 } else {
997 errors.push(CheckError::InvalidStyleValue {
998 attr: format!("class '{}'", name),
999 file: file_path.to_path_buf(),
1000 line: 1,
1001 col: 1,
1002 message: msg,
1003 });
1004 }
1005 }
1006 }
1007}
1008
1009fn validate_widget_with_styles(
1011 node: &dampen_core::ir::WidgetNode,
1012 file_path: &Path,
1013 document: &dampen_core::ir::DampenDocument,
1014 errors: &mut Vec<CheckError>,
1015) {
1016 if let Some(style) = &node.style {
1018 if let Err(msg) = style.validate() {
1019 errors.push(CheckError::InvalidStyleValue {
1020 attr: "structured style".to_string(),
1021 file: file_path.to_path_buf(),
1022 line: node.span.line,
1023 col: node.span.column,
1024 message: msg,
1025 });
1026 }
1027 }
1028
1029 if let Some(layout) = &node.layout {
1031 if let Err(msg) = layout.validate() {
1032 errors.push(CheckError::InvalidLayoutConstraint {
1033 file: file_path.to_path_buf(),
1034 line: node.span.line,
1035 col: node.span.column,
1036 message: msg,
1037 });
1038 }
1039 }
1040
1041 for class_name in &node.classes {
1043 if !document.style_classes.contains_key(class_name) {
1044 errors.push(CheckError::UnknownStyleClass {
1045 class: class_name.clone(),
1046 file: file_path.to_path_buf(),
1047 line: node.span.line,
1048 col: node.span.column,
1049 });
1050 }
1051 }
1052
1053 if let Some(theme_ref) = &node.theme_ref {
1055 if !document.themes.contains_key(theme_ref) {
1056 errors.push(CheckError::UnknownTheme {
1057 theme: theme_ref.clone(),
1058 file: file_path.to_path_buf(),
1059 line: node.span.line,
1060 col: node.span.column,
1061 });
1062 }
1063 }
1064
1065 validate_style_attributes(node, file_path, errors);
1067
1068 validate_layout_attributes(node, file_path, errors);
1070
1071 validate_breakpoint_attributes(node, file_path, errors);
1073
1074 validate_state_attributes(node, file_path, errors);
1076
1077 for child in &node.children {
1079 validate_widget_with_styles(child, file_path, document, errors);
1080 }
1081}
1082
1083fn validate_style_attributes(
1085 node: &dampen_core::ir::WidgetNode,
1086 file_path: &Path,
1087 errors: &mut Vec<CheckError>,
1088) {
1089 for (attr_name, attr_value) in &node.attributes {
1090 match attr_name.as_str() {
1091 "background" => {
1092 if let AttributeValue::Static(value) = attr_value {
1093 if let Err(msg) = style_parser::parse_background_attr(value) {
1094 errors.push(CheckError::InvalidStyleValue {
1095 attr: attr_name.clone(),
1096 file: file_path.to_path_buf(),
1097 line: node.span.line,
1098 col: node.span.column,
1099 message: msg,
1100 });
1101 }
1102 }
1103 }
1104 "color" | "border_color" => {
1105 if let AttributeValue::Static(value) = attr_value {
1106 if let Err(msg) = style_parser::parse_color_attr(value) {
1107 errors.push(CheckError::InvalidStyleValue {
1108 attr: attr_name.clone(),
1109 file: file_path.to_path_buf(),
1110 line: node.span.line,
1111 col: node.span.column,
1112 message: msg,
1113 });
1114 }
1115 }
1116 }
1117 "border_width" | "opacity" => {
1118 if let AttributeValue::Static(value) = attr_value {
1119 if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1120 errors.push(CheckError::InvalidStyleValue {
1121 attr: attr_name.clone(),
1122 file: file_path.to_path_buf(),
1123 line: node.span.line,
1124 col: node.span.column,
1125 message: msg,
1126 });
1127 }
1128 }
1129 }
1130 "border_radius" => {
1131 if let AttributeValue::Static(value) = attr_value {
1132 if let Err(msg) = style_parser::parse_border_radius(value) {
1133 errors.push(CheckError::InvalidStyleValue {
1134 attr: attr_name.clone(),
1135 file: file_path.to_path_buf(),
1136 line: node.span.line,
1137 col: node.span.column,
1138 message: msg,
1139 });
1140 }
1141 }
1142 }
1143 "border_style" => {
1144 if let AttributeValue::Static(value) = attr_value {
1145 if let Err(msg) = style_parser::parse_border_style(value) {
1146 errors.push(CheckError::InvalidStyleValue {
1147 attr: attr_name.clone(),
1148 file: file_path.to_path_buf(),
1149 line: node.span.line,
1150 col: node.span.column,
1151 message: msg,
1152 });
1153 }
1154 }
1155 }
1156 "shadow" => {
1157 if let AttributeValue::Static(value) = attr_value {
1158 if let Err(msg) = style_parser::parse_shadow_attr(value) {
1159 errors.push(CheckError::InvalidStyleValue {
1160 attr: attr_name.clone(),
1161 file: file_path.to_path_buf(),
1162 line: node.span.line,
1163 col: node.span.column,
1164 message: msg,
1165 });
1166 }
1167 }
1168 }
1169 "transform" => {
1170 if let AttributeValue::Static(value) = attr_value {
1171 if let Err(msg) = style_parser::parse_transform(value) {
1172 errors.push(CheckError::InvalidStyleValue {
1173 attr: attr_name.clone(),
1174 file: file_path.to_path_buf(),
1175 line: node.span.line,
1176 col: node.span.column,
1177 message: msg,
1178 });
1179 }
1180 }
1181 }
1182 _ => {} }
1184 }
1185}
1186
1187fn validate_layout_attributes(
1189 node: &dampen_core::ir::WidgetNode,
1190 file_path: &Path,
1191 errors: &mut Vec<CheckError>,
1192) {
1193 for (attr_name, attr_value) in &node.attributes {
1194 match attr_name.as_str() {
1195 "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1196 if let AttributeValue::Static(value) = attr_value {
1197 if let Err(msg) = style_parser::parse_length_attr(value) {
1198 errors.push(CheckError::InvalidStyleValue {
1199 attr: attr_name.clone(),
1200 file: file_path.to_path_buf(),
1201 line: node.span.line,
1202 col: node.span.column,
1203 message: msg,
1204 });
1205 }
1206 }
1207 }
1208 "padding" => {
1209 if let AttributeValue::Static(value) = attr_value {
1210 if let Err(msg) = style_parser::parse_padding_attr(value) {
1211 errors.push(CheckError::InvalidStyleValue {
1212 attr: attr_name.clone(),
1213 file: file_path.to_path_buf(),
1214 line: node.span.line,
1215 col: node.span.column,
1216 message: msg,
1217 });
1218 }
1219 }
1220 }
1221 "spacing" => {
1222 if let AttributeValue::Static(value) = attr_value {
1223 if let Err(msg) = style_parser::parse_spacing(value) {
1224 errors.push(CheckError::InvalidStyleValue {
1225 attr: attr_name.clone(),
1226 file: file_path.to_path_buf(),
1227 line: node.span.line,
1228 col: node.span.column,
1229 message: msg,
1230 });
1231 }
1232 }
1233 }
1234 "align_items" => {
1235 if let AttributeValue::Static(value) = attr_value {
1236 if let Err(msg) = style_parser::parse_alignment(value) {
1237 errors.push(CheckError::InvalidStyleValue {
1238 attr: attr_name.clone(),
1239 file: file_path.to_path_buf(),
1240 line: node.span.line,
1241 col: node.span.column,
1242 message: msg,
1243 });
1244 }
1245 }
1246 }
1247 "justify_content" => {
1248 if let AttributeValue::Static(value) = attr_value {
1249 if let Err(msg) = style_parser::parse_justification(value) {
1250 errors.push(CheckError::InvalidStyleValue {
1251 attr: attr_name.clone(),
1252 file: file_path.to_path_buf(),
1253 line: node.span.line,
1254 col: node.span.column,
1255 message: msg,
1256 });
1257 }
1258 }
1259 }
1260 "direction" => {
1261 if let AttributeValue::Static(value) = attr_value {
1262 if let Err(msg) = Direction::parse(value) {
1263 errors.push(CheckError::InvalidStyleValue {
1264 attr: attr_name.clone(),
1265 file: file_path.to_path_buf(),
1266 line: node.span.line,
1267 col: node.span.column,
1268 message: msg,
1269 });
1270 }
1271 }
1272 }
1273 "position" => {
1274 if matches!(node.kind, WidgetKind::Tooltip) {
1275 } else if let AttributeValue::Static(value) = attr_value {
1276 if let Err(msg) = Position::parse(value) {
1277 errors.push(CheckError::InvalidStyleValue {
1278 attr: attr_name.clone(),
1279 file: file_path.to_path_buf(),
1280 line: node.span.line,
1281 col: node.span.column,
1282 message: msg,
1283 });
1284 }
1285 }
1286 }
1287 "top" | "right" | "bottom" | "left" => {
1288 if let AttributeValue::Static(value) = attr_value {
1289 if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1290 errors.push(CheckError::InvalidStyleValue {
1291 attr: attr_name.clone(),
1292 file: file_path.to_path_buf(),
1293 line: node.span.line,
1294 col: node.span.column,
1295 message: msg,
1296 });
1297 }
1298 }
1299 }
1300 "z_index" => {
1301 if let AttributeValue::Static(value) = attr_value {
1302 if let Err(msg) = style_parser::parse_int_attr(value, attr_name) {
1303 errors.push(CheckError::InvalidStyleValue {
1304 attr: attr_name.clone(),
1305 file: file_path.to_path_buf(),
1306 line: node.span.line,
1307 col: node.span.column,
1308 message: msg,
1309 });
1310 }
1311 }
1312 }
1313 _ => {} }
1315 }
1316}
1317
1318fn validate_breakpoint_attributes(
1320 node: &dampen_core::ir::WidgetNode,
1321 file_path: &Path,
1322 errors: &mut Vec<CheckError>,
1323) {
1324 for (breakpoint, attrs) in &node.breakpoint_attributes {
1325 for (attr_name, attr_value) in attrs {
1326 let base_attr = attr_name.as_str();
1328 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1329
1330 let is_style_attr = matches!(
1332 base_attr,
1333 "background"
1334 | "color"
1335 | "border_width"
1336 | "border_color"
1337 | "border_radius"
1338 | "border_style"
1339 | "shadow"
1340 | "opacity"
1341 | "transform"
1342 );
1343
1344 let is_layout_attr = matches!(
1345 base_attr,
1346 "width"
1347 | "height"
1348 | "min_width"
1349 | "max_width"
1350 | "min_height"
1351 | "max_height"
1352 | "padding"
1353 | "spacing"
1354 | "align_items"
1355 | "justify_content"
1356 | "direction"
1357 | "position"
1358 | "top"
1359 | "right"
1360 | "bottom"
1361 | "left"
1362 | "z_index"
1363 );
1364
1365 if !is_style_attr && !is_layout_attr {
1366 errors.push(CheckError::InvalidBreakpoint {
1367 attr: full_attr,
1368 file: file_path.to_path_buf(),
1369 line: node.span.line,
1370 col: node.span.column,
1371 });
1372 continue;
1373 }
1374
1375 if let AttributeValue::Static(value) = attr_value {
1377 let result: Result<(), String> = match base_attr {
1378 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1379 "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1380 "border_width" | "opacity" => {
1381 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1382 }
1383 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1384 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1385 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1386 "transform" => style_parser::parse_transform(value).map(|_| ()),
1387 "width" | "height" | "min_width" | "max_width" | "min_height"
1388 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1389 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1390 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1391 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1392 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1393 "direction" => Direction::parse(value).map(|_| ()),
1394 "position" => Position::parse(value).map(|_| ()),
1395 "top" | "right" | "bottom" | "left" => {
1396 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1397 }
1398 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1399 _ => Ok(()),
1400 };
1401
1402 if let Err(msg) = result {
1403 errors.push(CheckError::InvalidStyleValue {
1404 attr: full_attr,
1405 file: file_path.to_path_buf(),
1406 line: node.span.line,
1407 col: node.span.column,
1408 message: msg,
1409 });
1410 }
1411 }
1412 }
1413 }
1414}
1415
1416fn validate_state_attributes(
1418 node: &dampen_core::ir::WidgetNode,
1419 file_path: &Path,
1420 errors: &mut Vec<CheckError>,
1421) {
1422 for (attr_name, attr_value) in &node.attributes {
1423 if attr_name.contains(':') {
1424 let parts: Vec<&str> = attr_name.split(':').collect();
1425 if parts.len() >= 2 {
1426 let prefix = parts[0];
1427 let base_attr = parts[1];
1428
1429 if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1431 errors.push(CheckError::InvalidState {
1432 attr: attr_name.clone(),
1433 file: file_path.to_path_buf(),
1434 line: node.span.line,
1435 col: node.span.column,
1436 });
1437 continue;
1438 }
1439
1440 let is_valid_attr = matches!(
1442 base_attr,
1443 "background"
1444 | "color"
1445 | "border_width"
1446 | "border_color"
1447 | "border_radius"
1448 | "border_style"
1449 | "shadow"
1450 | "opacity"
1451 | "transform"
1452 | "width"
1453 | "height"
1454 | "min_width"
1455 | "max_width"
1456 | "min_height"
1457 | "max_height"
1458 | "padding"
1459 | "spacing"
1460 | "align_items"
1461 | "justify_content"
1462 | "direction"
1463 | "position"
1464 | "top"
1465 | "right"
1466 | "bottom"
1467 | "left"
1468 | "z_index"
1469 );
1470
1471 if !is_valid_attr {
1472 errors.push(CheckError::InvalidState {
1473 attr: attr_name.clone(),
1474 file: file_path.to_path_buf(),
1475 line: node.span.line,
1476 col: node.span.column,
1477 });
1478 continue;
1479 }
1480
1481 if let AttributeValue::Static(value) = attr_value {
1483 let result: Result<(), String> = match base_attr {
1484 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1485 "color" | "border_color" => {
1486 style_parser::parse_color_attr(value).map(|_| ())
1487 }
1488 "border_width" | "opacity" => {
1489 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1490 }
1491 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1492 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1493 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1494 "transform" => style_parser::parse_transform(value).map(|_| ()),
1495 "width" | "height" | "min_width" | "max_width" | "min_height"
1496 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1497 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1498 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1499 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1500 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1501 "direction" => Direction::parse(value).map(|_| ()),
1502 "position" => Position::parse(value).map(|_| ()),
1503 "top" | "right" | "bottom" | "left" => {
1504 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1505 }
1506 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1507 _ => Ok(()),
1508 };
1509
1510 if let Err(msg) = result {
1511 errors.push(CheckError::InvalidStyleValue {
1512 attr: attr_name.clone(),
1513 file: file_path.to_path_buf(),
1514 line: node.span.line,
1515 col: node.span.column,
1516 message: msg,
1517 });
1518 }
1519 }
1520 }
1521 }
1522 }
1523}