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(false) {
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 match theme_ref {
1056 AttributeValue::Static(theme_name) => {
1057 if !document.themes.contains_key(theme_name) {
1058 errors.push(CheckError::UnknownTheme {
1059 theme: theme_name.clone(),
1060 file: file_path.to_path_buf(),
1061 line: node.span.line,
1062 col: node.span.column,
1063 });
1064 }
1065 }
1066 AttributeValue::Binding(_) | AttributeValue::Interpolated(_) => {
1067 }
1070 }
1071 }
1072
1073 validate_style_attributes(node, file_path, errors);
1075
1076 validate_layout_attributes(node, file_path, errors);
1078
1079 validate_breakpoint_attributes(node, file_path, errors);
1081
1082 validate_state_attributes(node, file_path, errors);
1084
1085 for child in &node.children {
1087 validate_widget_with_styles(child, file_path, document, errors);
1088 }
1089}
1090
1091fn validate_style_attributes(
1093 node: &dampen_core::ir::WidgetNode,
1094 file_path: &Path,
1095 errors: &mut Vec<CheckError>,
1096) {
1097 for (attr_name, attr_value) in &node.attributes {
1098 match attr_name.as_str() {
1099 "background" => {
1100 if let AttributeValue::Static(value) = attr_value {
1101 if let Err(msg) = style_parser::parse_background_attr(value) {
1102 errors.push(CheckError::InvalidStyleValue {
1103 attr: attr_name.clone(),
1104 file: file_path.to_path_buf(),
1105 line: node.span.line,
1106 col: node.span.column,
1107 message: msg,
1108 });
1109 }
1110 }
1111 }
1112 "color" | "border_color" => {
1113 if let AttributeValue::Static(value) = attr_value {
1114 if let Err(msg) = style_parser::parse_color_attr(value) {
1115 errors.push(CheckError::InvalidStyleValue {
1116 attr: attr_name.clone(),
1117 file: file_path.to_path_buf(),
1118 line: node.span.line,
1119 col: node.span.column,
1120 message: msg,
1121 });
1122 }
1123 }
1124 }
1125 "border_width" | "opacity" => {
1126 if let AttributeValue::Static(value) = attr_value {
1127 if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1128 errors.push(CheckError::InvalidStyleValue {
1129 attr: attr_name.clone(),
1130 file: file_path.to_path_buf(),
1131 line: node.span.line,
1132 col: node.span.column,
1133 message: msg,
1134 });
1135 }
1136 }
1137 }
1138 "border_radius" => {
1139 if let AttributeValue::Static(value) = attr_value {
1140 if let Err(msg) = style_parser::parse_border_radius(value) {
1141 errors.push(CheckError::InvalidStyleValue {
1142 attr: attr_name.clone(),
1143 file: file_path.to_path_buf(),
1144 line: node.span.line,
1145 col: node.span.column,
1146 message: msg,
1147 });
1148 }
1149 }
1150 }
1151 "border_style" => {
1152 if let AttributeValue::Static(value) = attr_value {
1153 if let Err(msg) = style_parser::parse_border_style(value) {
1154 errors.push(CheckError::InvalidStyleValue {
1155 attr: attr_name.clone(),
1156 file: file_path.to_path_buf(),
1157 line: node.span.line,
1158 col: node.span.column,
1159 message: msg,
1160 });
1161 }
1162 }
1163 }
1164 "shadow" => {
1165 if let AttributeValue::Static(value) = attr_value {
1166 if let Err(msg) = style_parser::parse_shadow_attr(value) {
1167 errors.push(CheckError::InvalidStyleValue {
1168 attr: attr_name.clone(),
1169 file: file_path.to_path_buf(),
1170 line: node.span.line,
1171 col: node.span.column,
1172 message: msg,
1173 });
1174 }
1175 }
1176 }
1177 "transform" => {
1178 if let AttributeValue::Static(value) = attr_value {
1179 if let Err(msg) = style_parser::parse_transform(value) {
1180 errors.push(CheckError::InvalidStyleValue {
1181 attr: attr_name.clone(),
1182 file: file_path.to_path_buf(),
1183 line: node.span.line,
1184 col: node.span.column,
1185 message: msg,
1186 });
1187 }
1188 }
1189 }
1190 _ => {} }
1192 }
1193}
1194
1195fn validate_layout_attributes(
1197 node: &dampen_core::ir::WidgetNode,
1198 file_path: &Path,
1199 errors: &mut Vec<CheckError>,
1200) {
1201 for (attr_name, attr_value) in &node.attributes {
1202 match attr_name.as_str() {
1203 "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1204 if let AttributeValue::Static(value) = attr_value {
1205 if let Err(msg) = style_parser::parse_length_attr(value) {
1206 errors.push(CheckError::InvalidStyleValue {
1207 attr: attr_name.clone(),
1208 file: file_path.to_path_buf(),
1209 line: node.span.line,
1210 col: node.span.column,
1211 message: msg,
1212 });
1213 }
1214 }
1215 }
1216 "padding" => {
1217 if let AttributeValue::Static(value) = attr_value {
1218 if let Err(msg) = style_parser::parse_padding_attr(value) {
1219 errors.push(CheckError::InvalidStyleValue {
1220 attr: attr_name.clone(),
1221 file: file_path.to_path_buf(),
1222 line: node.span.line,
1223 col: node.span.column,
1224 message: msg,
1225 });
1226 }
1227 }
1228 }
1229 "spacing" => {
1230 if let AttributeValue::Static(value) = attr_value {
1231 if let Err(msg) = style_parser::parse_spacing(value) {
1232 errors.push(CheckError::InvalidStyleValue {
1233 attr: attr_name.clone(),
1234 file: file_path.to_path_buf(),
1235 line: node.span.line,
1236 col: node.span.column,
1237 message: msg,
1238 });
1239 }
1240 }
1241 }
1242 "align_items" => {
1243 if let AttributeValue::Static(value) = attr_value {
1244 if let Err(msg) = style_parser::parse_alignment(value) {
1245 errors.push(CheckError::InvalidStyleValue {
1246 attr: attr_name.clone(),
1247 file: file_path.to_path_buf(),
1248 line: node.span.line,
1249 col: node.span.column,
1250 message: msg,
1251 });
1252 }
1253 }
1254 }
1255 "justify_content" => {
1256 if let AttributeValue::Static(value) = attr_value {
1257 if let Err(msg) = style_parser::parse_justification(value) {
1258 errors.push(CheckError::InvalidStyleValue {
1259 attr: attr_name.clone(),
1260 file: file_path.to_path_buf(),
1261 line: node.span.line,
1262 col: node.span.column,
1263 message: msg,
1264 });
1265 }
1266 }
1267 }
1268 "direction" => {
1269 if let AttributeValue::Static(value) = attr_value {
1270 if let Err(msg) = Direction::parse(value) {
1271 errors.push(CheckError::InvalidStyleValue {
1272 attr: attr_name.clone(),
1273 file: file_path.to_path_buf(),
1274 line: node.span.line,
1275 col: node.span.column,
1276 message: msg,
1277 });
1278 }
1279 }
1280 }
1281 "position" => {
1282 if matches!(node.kind, WidgetKind::Tooltip) {
1283 } else if let AttributeValue::Static(value) = attr_value {
1284 if let Err(msg) = Position::parse(value) {
1285 errors.push(CheckError::InvalidStyleValue {
1286 attr: attr_name.clone(),
1287 file: file_path.to_path_buf(),
1288 line: node.span.line,
1289 col: node.span.column,
1290 message: msg,
1291 });
1292 }
1293 }
1294 }
1295 "top" | "right" | "bottom" | "left" => {
1296 if let AttributeValue::Static(value) = attr_value {
1297 if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1298 errors.push(CheckError::InvalidStyleValue {
1299 attr: attr_name.clone(),
1300 file: file_path.to_path_buf(),
1301 line: node.span.line,
1302 col: node.span.column,
1303 message: msg,
1304 });
1305 }
1306 }
1307 }
1308 "z_index" => {
1309 if let AttributeValue::Static(value) = attr_value {
1310 if let Err(msg) = style_parser::parse_int_attr(value, attr_name) {
1311 errors.push(CheckError::InvalidStyleValue {
1312 attr: attr_name.clone(),
1313 file: file_path.to_path_buf(),
1314 line: node.span.line,
1315 col: node.span.column,
1316 message: msg,
1317 });
1318 }
1319 }
1320 }
1321 _ => {} }
1323 }
1324}
1325
1326fn validate_breakpoint_attributes(
1328 node: &dampen_core::ir::WidgetNode,
1329 file_path: &Path,
1330 errors: &mut Vec<CheckError>,
1331) {
1332 for (breakpoint, attrs) in &node.breakpoint_attributes {
1333 for (attr_name, attr_value) in attrs {
1334 let base_attr = attr_name.as_str();
1336 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1337
1338 let is_style_attr = matches!(
1340 base_attr,
1341 "background"
1342 | "color"
1343 | "border_width"
1344 | "border_color"
1345 | "border_radius"
1346 | "border_style"
1347 | "shadow"
1348 | "opacity"
1349 | "transform"
1350 );
1351
1352 let is_layout_attr = matches!(
1353 base_attr,
1354 "width"
1355 | "height"
1356 | "min_width"
1357 | "max_width"
1358 | "min_height"
1359 | "max_height"
1360 | "padding"
1361 | "spacing"
1362 | "align_items"
1363 | "justify_content"
1364 | "direction"
1365 | "position"
1366 | "top"
1367 | "right"
1368 | "bottom"
1369 | "left"
1370 | "z_index"
1371 );
1372
1373 if !is_style_attr && !is_layout_attr {
1374 errors.push(CheckError::InvalidBreakpoint {
1375 attr: full_attr,
1376 file: file_path.to_path_buf(),
1377 line: node.span.line,
1378 col: node.span.column,
1379 });
1380 continue;
1381 }
1382
1383 if let AttributeValue::Static(value) = attr_value {
1385 let result: Result<(), String> = match base_attr {
1386 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1387 "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1388 "border_width" | "opacity" => {
1389 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1390 }
1391 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1392 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1393 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1394 "transform" => style_parser::parse_transform(value).map(|_| ()),
1395 "width" | "height" | "min_width" | "max_width" | "min_height"
1396 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1397 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1398 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1399 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1400 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1401 "direction" => Direction::parse(value).map(|_| ()),
1402 "position" => Position::parse(value).map(|_| ()),
1403 "top" | "right" | "bottom" | "left" => {
1404 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1405 }
1406 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1407 _ => Ok(()),
1408 };
1409
1410 if let Err(msg) = result {
1411 errors.push(CheckError::InvalidStyleValue {
1412 attr: full_attr,
1413 file: file_path.to_path_buf(),
1414 line: node.span.line,
1415 col: node.span.column,
1416 message: msg,
1417 });
1418 }
1419 }
1420 }
1421 }
1422}
1423
1424fn validate_state_attributes(
1426 node: &dampen_core::ir::WidgetNode,
1427 file_path: &Path,
1428 errors: &mut Vec<CheckError>,
1429) {
1430 for (attr_name, attr_value) in &node.attributes {
1431 if attr_name.contains(':') {
1432 let parts: Vec<&str> = attr_name.split(':').collect();
1433 if parts.len() >= 2 {
1434 let prefix = parts[0];
1435 let base_attr = parts[1];
1436
1437 if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1439 errors.push(CheckError::InvalidState {
1440 attr: attr_name.clone(),
1441 file: file_path.to_path_buf(),
1442 line: node.span.line,
1443 col: node.span.column,
1444 });
1445 continue;
1446 }
1447
1448 let is_valid_attr = matches!(
1450 base_attr,
1451 "background"
1452 | "color"
1453 | "border_width"
1454 | "border_color"
1455 | "border_radius"
1456 | "border_style"
1457 | "shadow"
1458 | "opacity"
1459 | "transform"
1460 | "width"
1461 | "height"
1462 | "min_width"
1463 | "max_width"
1464 | "min_height"
1465 | "max_height"
1466 | "padding"
1467 | "spacing"
1468 | "align_items"
1469 | "justify_content"
1470 | "direction"
1471 | "position"
1472 | "top"
1473 | "right"
1474 | "bottom"
1475 | "left"
1476 | "z_index"
1477 );
1478
1479 if !is_valid_attr {
1480 errors.push(CheckError::InvalidState {
1481 attr: attr_name.clone(),
1482 file: file_path.to_path_buf(),
1483 line: node.span.line,
1484 col: node.span.column,
1485 });
1486 continue;
1487 }
1488
1489 if let AttributeValue::Static(value) = attr_value {
1491 let result: Result<(), String> = match base_attr {
1492 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1493 "color" | "border_color" => {
1494 style_parser::parse_color_attr(value).map(|_| ())
1495 }
1496 "border_width" | "opacity" => {
1497 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1498 }
1499 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1500 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1501 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1502 "transform" => style_parser::parse_transform(value).map(|_| ()),
1503 "width" | "height" | "min_width" | "max_width" | "min_height"
1504 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1505 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1506 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1507 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1508 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1509 "direction" => Direction::parse(value).map(|_| ()),
1510 "position" => Position::parse(value).map(|_| ()),
1511 "top" | "right" | "bottom" | "left" => {
1512 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1513 }
1514 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1515 _ => Ok(()),
1516 };
1517
1518 if let Err(msg) = result {
1519 errors.push(CheckError::InvalidStyleValue {
1520 attr: attr_name.clone(),
1521 file: file_path.to_path_buf(),
1522 line: node.span.line,
1523 col: node.span.column,
1524 message: msg,
1525 });
1526 }
1527 }
1528 }
1529 }
1530 }
1531}