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
183fn resolve_ui_directory(explicit_input: Option<&str>) -> Result<PathBuf, String> {
185 if let Some(path) = explicit_input {
187 let path_buf = PathBuf::from(path);
188 if path_buf.exists() {
189 return Ok(path_buf);
190 } else {
191 return Err(format!("Specified UI directory does not exist: {}", path));
192 }
193 }
194
195 let src_ui = PathBuf::from("src/ui");
197 if src_ui.exists() && src_ui.is_dir() {
198 return Ok(src_ui);
199 }
200
201 let ui = PathBuf::from("ui");
203 if ui.exists() && ui.is_dir() {
204 return Ok(ui);
205 }
206
207 Err("No UI directory found. Please create one of:\n\
209 - src/ui/ (recommended for Rust projects)\n\
210 - ui/ (general purpose)\n\n\
211 Or specify a custom path with --input:\n\
212 dampen check --input path/to/ui"
213 .to_string())
214}
215
216fn resolve_optional_file(explicit_path: Option<&str>, filename: &str) -> Option<PathBuf> {
218 if let Some(path) = explicit_path {
220 let path_buf = PathBuf::from(path);
221 if path_buf.exists() {
222 return Some(path_buf);
223 }
224 return Some(path_buf);
226 }
227
228 let root_file = PathBuf::from(filename);
230 if root_file.exists() {
231 return Some(root_file);
232 }
233
234 let src_file = PathBuf::from("src").join(filename);
236 if src_file.exists() {
237 return Some(src_file);
238 }
239
240 None
242}
243
244pub fn execute(args: &CheckArgs) -> Result<(), CheckError> {
245 use crate::commands::check::handlers::HandlerRegistry;
246
247 let input_path = resolve_ui_directory(args.input.as_deref())
249 .map_err(|msg| CheckError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, msg)))?;
250
251 if args.verbose {
252 eprintln!("Using UI directory: {}", input_path.display());
253 }
254
255 let handlers_path = resolve_optional_file(args.handlers.as_deref(), "handlers.json");
257 if args.verbose {
258 if let Some(ref path) = handlers_path {
259 eprintln!("Using handler registry: {}", path.display());
260 }
261 }
262
263 let model_path = resolve_optional_file(args.model.as_deref(), "model.json");
264 if args.verbose {
265 if let Some(ref path) = model_path {
266 eprintln!("Using model info: {}", path.display());
267 }
268 }
269
270 let handler_registry = if let Some(path) = handlers_path {
272 let registry = HandlerRegistry::load_from_json(&path).map_err(|e| {
273 CheckError::HandlerRegistryLoadError {
274 path: path.clone(),
275 source: serde_json::Error::io(std::io::Error::new(
276 std::io::ErrorKind::Other,
277 e.to_string(),
278 )),
279 }
280 })?;
281 Some(registry)
282 } else {
283 None
284 };
285
286 let model_info = if let Some(path) = model_path {
288 let model =
289 crate::commands::check::model::ModelInfo::load_from_json(&path).map_err(|e| {
290 CheckError::ModelInfoLoadError {
291 path: path.clone(),
292 source: serde_json::Error::io(std::io::Error::new(
293 std::io::ErrorKind::Other,
294 e.to_string(),
295 )),
296 }
297 })?;
298 Some(model)
299 } else {
300 None
301 };
302
303 let mut errors = Vec::new();
304 let mut files_checked = 0;
305
306 for entry in WalkDir::new(input_path)
308 .follow_links(true)
309 .into_iter()
310 .filter_map(|e| e.ok())
311 .filter(|e| {
312 e.path()
313 .extension()
314 .map(|ext| ext == "dampen")
315 .unwrap_or(false)
316 })
317 {
318 let file_path = entry.path();
319 files_checked += 1;
320
321 if args.verbose {
322 eprintln!("Checking: {}", file_path.display());
323 }
324
325 let content = fs::read_to_string(file_path)?;
327
328 validate_xml_declaration(&content, file_path, &mut errors);
330
331 if !errors.is_empty() {
333 continue;
334 }
335
336 match parser::parse(&content) {
337 Ok(document) => {
338 validate_document(
340 &document,
341 file_path,
342 &handler_registry,
343 &model_info,
344 &mut errors,
345 );
346
347 validate_references(&document, file_path, &mut errors);
349
350 validate_widget_with_styles(&document.root, file_path, &document, &mut errors);
352 }
353 Err(parse_error) => {
354 errors.push(CheckError::ParseError {
355 file: file_path.to_path_buf(),
356 line: parse_error.span.line,
357 col: parse_error.span.column,
358 message: parse_error.to_string(),
359 });
360 }
361 }
362 }
363
364 if args.verbose {
365 eprintln!("Checked {} files", files_checked);
366 }
367
368 if !errors.is_empty() {
370 let error_label = if args.strict { "error(s)" } else { "error(s)" };
374 eprintln!("Found {} {}:", errors.len(), error_label);
375
376 for error in &errors {
377 let prefix = if args.strict { "ERROR" } else { "ERROR" };
379 eprintln!(" [{}] {}", prefix, error);
380 }
381
382 Err(errors.remove(0))
385 } else {
386 if args.verbose {
387 let status = if args.strict {
388 "✓ All files passed validation (strict mode)"
389 } else {
390 "✓ All files passed validation"
391 };
392 eprintln!("{}", status);
393 }
394 Ok(())
395 }
396}
397
398fn validate_xml_declaration(content: &str, file_path: &Path, errors: &mut Vec<CheckError>) {
399 let trimmed = content.trim_start();
401 if !trimmed.starts_with("<?xml version=\"1.0\"") {
402 errors.push(CheckError::XmlValidationError {
403 file: file_path.to_path_buf(),
404 line: 1,
405 col: 1,
406 message: "Missing or invalid XML declaration. Expected: <?xml version=\"1.0\" encoding=\"UTF-8\"?>".to_string(),
407 });
408 }
409}
410
411fn validate_document(
412 document: &dampen_core::ir::DampenDocument,
413 file_path: &Path,
414 handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
415 model_info: &Option<crate::commands::check::model::ModelInfo>,
416 errors: &mut Vec<CheckError>,
417) {
418 use crate::commands::check::cross_widget::RadioGroupValidator;
419
420 let valid_widgets: HashSet<String> = WidgetKind::all_variants()
422 .iter()
423 .map(|w| format!("{:?}", w).to_lowercase())
424 .collect();
425
426 let mut radio_validator = RadioGroupValidator::new();
428
429 validate_widget_node(
431 &document.root,
432 file_path,
433 &valid_widgets,
434 handler_registry,
435 model_info,
436 &mut radio_validator,
437 errors,
438 );
439
440 let radio_errors = radio_validator.validate();
442 for error in radio_errors {
443 match error {
445 crate::commands::check::errors::CheckError::DuplicateRadioValue {
446 value,
447 group,
448 file,
449 line,
450 col,
451 first_file,
452 first_line,
453 first_col,
454 } => {
455 errors.push(CheckError::XmlValidationError {
456 file: file.clone(),
457 line,
458 col,
459 message: format!(
460 "Duplicate radio value '{}' in group '{}'. First occurrence: {}:{}:{}",
461 value,
462 group,
463 first_file.display(),
464 first_line,
465 first_col
466 ),
467 });
468 }
469 crate::commands::check::errors::CheckError::InconsistentRadioHandlers {
470 group,
471 file,
472 line,
473 col,
474 handlers,
475 } => {
476 errors.push(CheckError::XmlValidationError {
477 file: file.clone(),
478 line,
479 col,
480 message: format!(
481 "Radio group '{}' has inconsistent on_select handlers. Found handlers: {}",
482 group, handlers
483 ),
484 });
485 }
486 _ => {}
487 }
488 }
489}
490
491fn validate_widget_node(
492 node: &dampen_core::ir::WidgetNode,
493 file_path: &Path,
494 valid_widgets: &HashSet<String>,
495 handler_registry: &Option<crate::commands::check::handlers::HandlerRegistry>,
496 model_info: &Option<crate::commands::check::model::ModelInfo>,
497 radio_validator: &mut crate::commands::check::cross_widget::RadioGroupValidator,
498 errors: &mut Vec<CheckError>,
499) {
500 use crate::commands::check::attributes;
501 use crate::commands::check::suggestions;
502
503 let widget_name = format!("{:?}", node.kind).to_lowercase();
505 if !valid_widgets.contains(&widget_name) && !matches!(node.kind, WidgetKind::Custom(_)) {
506 errors.push(CheckError::InvalidWidget {
507 widget: widget_name.clone(),
508 file: file_path.to_path_buf(),
509 line: node.span.line,
510 col: node.span.column,
511 });
512 }
513
514 let attr_names: Vec<String> = node.attributes.keys().map(|s| s.to_string()).collect();
516 let unknown_attrs = attributes::validate_widget_attributes(&node.kind, &attr_names);
517
518 for (attr, _suggestion_opt) in unknown_attrs {
519 let schema = attributes::WidgetAttributeSchema::for_widget(&node.kind);
520 let all_valid = schema.all_valid_names();
521 let suggestion = suggestions::suggest(&attr, &all_valid, 3);
522
523 errors.push(CheckError::UnknownAttribute {
524 attr,
525 widget: widget_name.clone(),
526 file: file_path.to_path_buf(),
527 line: node.span.line,
528 col: node.span.column,
529 suggestion,
530 });
531 }
532
533 let missing_required = attributes::validate_required_attributes(&node.kind, &attr_names);
535 for missing_attr in missing_required {
536 errors.push(CheckError::XmlValidationError {
537 file: file_path.to_path_buf(),
538 line: node.span.line,
539 col: node.span.column,
540 message: format!(
541 "Missing required attribute '{}' for widget '{}'",
542 missing_attr, widget_name
543 ),
544 });
545 }
546
547 if let Some(registry) = handler_registry {
549 for event_binding in &node.events {
550 if !registry.contains(&event_binding.handler) {
551 let all_handler_names = registry.all_names();
553 let handler_refs: Vec<&str> =
554 all_handler_names.iter().map(|s| s.as_str()).collect();
555 let suggestion = suggestions::suggest(&event_binding.handler, &handler_refs, 3);
556
557 errors.push(CheckError::UnknownHandler {
558 handler: event_binding.handler.clone(),
559 file: file_path.to_path_buf(),
560 line: event_binding.span.line,
561 col: event_binding.span.column,
562 suggestion,
563 });
564 }
565 }
566 } else {
567 for event_binding in &node.events {
569 if event_binding.handler.is_empty() {
570 errors.push(CheckError::UnknownHandler {
571 handler: "<empty>".to_string(),
572 file: file_path.to_path_buf(),
573 line: event_binding.span.line,
574 col: event_binding.span.column,
575 suggestion: String::new(),
576 });
577 }
578 }
579 }
580
581 if let Some(model) = model_info {
583 for (attr_name, attr_value) in &node.attributes {
584 validate_attribute_bindings(
585 attr_name,
586 attr_value,
587 file_path,
588 node.span.line,
589 node.span.column,
590 model,
591 errors,
592 );
593 }
594 }
595
596 for attr_value in node.attributes.values() {
598 validate_attribute_value(
599 attr_value,
600 file_path,
601 node.span.line,
602 node.span.column,
603 errors,
604 );
605 }
606
607 if matches!(node.kind, WidgetKind::Radio) {
609 let group_id = node
611 .attributes
612 .get("id")
613 .and_then(|v| match v {
614 AttributeValue::Static(s) => Some(s.as_str()),
615 _ => None,
616 })
617 .unwrap_or("default");
618
619 let value = node
620 .attributes
621 .get("value")
622 .and_then(|v| match v {
623 AttributeValue::Static(s) => Some(s.as_str()),
624 _ => None,
625 })
626 .unwrap_or("");
627
628 let handler = node
630 .events
631 .iter()
632 .find(|e| e.event == EventKind::Select)
633 .map(|e| e.handler.clone());
634
635 radio_validator.add_radio(
636 group_id,
637 value,
638 file_path.to_str().unwrap_or("unknown"),
639 node.span.line,
640 node.span.column,
641 handler,
642 );
643 }
644
645 for child in &node.children {
647 validate_widget_node(
648 child,
649 file_path,
650 valid_widgets,
651 handler_registry,
652 model_info,
653 radio_validator,
654 errors,
655 );
656 }
657}
658
659fn validate_attribute_bindings(
660 _attr_name: &str,
661 value: &dampen_core::ir::AttributeValue,
662 file_path: &Path,
663 line: u32,
664 col: u32,
665 model: &crate::commands::check::model::ModelInfo,
666 errors: &mut Vec<CheckError>,
667) {
668 if let dampen_core::ir::AttributeValue::Binding(binding_expr) = value {
670 validate_expr_fields(&binding_expr.expr, file_path, line, col, model, errors);
672 }
673}
674
675fn validate_expr_fields(
676 expr: &dampen_core::expr::Expr,
677 file_path: &Path,
678 line: u32,
679 col: u32,
680 model: &crate::commands::check::model::ModelInfo,
681 errors: &mut Vec<CheckError>,
682) {
683 match expr {
684 dampen_core::expr::Expr::FieldAccess(field_access) => {
685 let field_parts: Vec<&str> = field_access.path.iter().map(|s| s.as_str()).collect();
687
688 if !model.contains_field(&field_parts) {
689 let all_paths = model.all_field_paths();
691 let available = if all_paths.len() > 5 {
692 format!("{} ({} total)", &all_paths[..5].join(", "), all_paths.len())
693 } else {
694 all_paths.join(", ")
695 };
696
697 let field_path = field_access.path.join(".");
698
699 errors.push(CheckError::InvalidBinding {
700 field: field_path,
701 file: file_path.to_path_buf(),
702 line,
703 col,
704 });
705
706 eprintln!(" Available fields: {}", available);
708 }
709 }
710 dampen_core::expr::Expr::MethodCall(method_call) => {
711 validate_expr_fields(&method_call.receiver, file_path, line, col, model, errors);
713 for arg in &method_call.args {
715 validate_expr_fields(arg, file_path, line, col, model, errors);
716 }
717 }
718 dampen_core::expr::Expr::BinaryOp(binary_op) => {
719 validate_expr_fields(&binary_op.left, file_path, line, col, model, errors);
721 validate_expr_fields(&binary_op.right, file_path, line, col, model, errors);
722 }
723 dampen_core::expr::Expr::UnaryOp(unary_op) => {
724 validate_expr_fields(&unary_op.operand, file_path, line, col, model, errors);
726 }
727 dampen_core::expr::Expr::Conditional(conditional) => {
728 validate_expr_fields(&conditional.condition, file_path, line, col, model, errors);
730 validate_expr_fields(
731 &conditional.then_branch,
732 file_path,
733 line,
734 col,
735 model,
736 errors,
737 );
738 validate_expr_fields(
739 &conditional.else_branch,
740 file_path,
741 line,
742 col,
743 model,
744 errors,
745 );
746 }
747 dampen_core::expr::Expr::Literal(_) => {
748 }
750 }
751}
752
753fn validate_attribute_value(
754 value: &dampen_core::ir::AttributeValue,
755 file_path: &Path,
756 line: u32,
757 col: u32,
758 errors: &mut Vec<CheckError>,
759) {
760 match value {
761 dampen_core::ir::AttributeValue::Static(_) => {
762 }
764 dampen_core::ir::AttributeValue::Binding(binding_expr) => {
765 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
768 }
769 dampen_core::ir::AttributeValue::Interpolated(parts) => {
770 for part in parts {
771 match part {
772 dampen_core::ir::InterpolatedPart::Literal(_) => {
773 }
775 dampen_core::ir::InterpolatedPart::Binding(binding_expr) => {
776 validate_binding_expr(&binding_expr.expr, file_path, line, col, errors);
777 }
778 }
779 }
780 }
781 }
782}
783
784fn validate_binding_expr(
785 expr: &dampen_core::expr::Expr,
786 file_path: &Path,
787 line: u32,
788 col: u32,
789 errors: &mut Vec<CheckError>,
790) {
791 match expr {
792 dampen_core::expr::Expr::FieldAccess(field_access) => {
793 if field_access.path.is_empty() || field_access.path.iter().any(|f| f.is_empty()) {
796 errors.push(CheckError::InvalidBinding {
797 field: "<empty>".to_string(),
798 file: file_path.to_path_buf(),
799 line,
800 col,
801 });
802 }
803 }
804 dampen_core::expr::Expr::MethodCall(_) => {
805 }
808 dampen_core::expr::Expr::BinaryOp(_) => {
809 }
812 dampen_core::expr::Expr::UnaryOp(_) => {
813 }
815 dampen_core::expr::Expr::Conditional(_) => {
816 }
818 dampen_core::expr::Expr::Literal(_) => {
819 }
821 }
822}
823
824trait WidgetKindExt {
826 fn all_variants() -> Vec<WidgetKind>;
827}
828
829impl WidgetKindExt for WidgetKind {
830 fn all_variants() -> Vec<WidgetKind> {
831 vec![
832 WidgetKind::Column,
833 WidgetKind::Row,
834 WidgetKind::Container,
835 WidgetKind::Scrollable,
836 WidgetKind::Stack,
837 WidgetKind::Text,
838 WidgetKind::Image,
839 WidgetKind::Svg,
840 WidgetKind::Button,
841 WidgetKind::TextInput,
842 WidgetKind::Checkbox,
843 WidgetKind::Slider,
844 WidgetKind::PickList,
845 WidgetKind::Toggler,
846 WidgetKind::Space,
847 WidgetKind::Rule,
848 WidgetKind::Radio,
849 WidgetKind::ComboBox,
850 WidgetKind::ProgressBar,
851 WidgetKind::Tooltip,
852 WidgetKind::Grid,
853 WidgetKind::Canvas,
854 WidgetKind::Float,
855 WidgetKind::For,
856 ]
857 }
858}
859
860fn validate_references(
862 document: &dampen_core::ir::DampenDocument,
863 file_path: &Path,
864 errors: &mut Vec<CheckError>,
865) {
866 if let Some(global_theme) = &document.global_theme {
868 if !document.themes.contains_key(global_theme) {
869 errors.push(CheckError::UnknownTheme {
870 theme: global_theme.clone(),
871 file: file_path.to_path_buf(),
872 line: 1,
873 col: 1,
874 });
875 }
876 }
877
878 for (name, theme) in &document.themes {
880 if let Err(msg) = theme.validate() {
881 if msg.contains("circular") || msg.contains("Circular") {
883 errors.push(CheckError::XmlValidationError {
884 file: file_path.to_path_buf(),
885 line: 1,
886 col: 1,
887 message: format!("Theme '{}' validation error: {}", name, msg),
888 });
889 } else {
890 errors.push(CheckError::InvalidStyleValue {
891 attr: format!("theme '{}'", name),
892 file: file_path.to_path_buf(),
893 line: 1,
894 col: 1,
895 message: msg,
896 });
897 }
898 }
899 }
900
901 for (name, class) in &document.style_classes {
903 if let Err(msg) = class.validate(&document.style_classes) {
904 if msg.contains("circular") || msg.contains("Circular") {
906 errors.push(CheckError::XmlValidationError {
907 file: file_path.to_path_buf(),
908 line: 1,
909 col: 1,
910 message: format!("Style class '{}' has circular dependency: {}", name, msg),
911 });
912 } else {
913 errors.push(CheckError::InvalidStyleValue {
914 attr: format!("class '{}'", name),
915 file: file_path.to_path_buf(),
916 line: 1,
917 col: 1,
918 message: msg,
919 });
920 }
921 }
922 }
923}
924
925fn validate_widget_with_styles(
927 node: &dampen_core::ir::WidgetNode,
928 file_path: &Path,
929 document: &dampen_core::ir::DampenDocument,
930 errors: &mut Vec<CheckError>,
931) {
932 if let Some(style) = &node.style {
934 if let Err(msg) = style.validate() {
935 errors.push(CheckError::InvalidStyleValue {
936 attr: "structured style".to_string(),
937 file: file_path.to_path_buf(),
938 line: node.span.line,
939 col: node.span.column,
940 message: msg,
941 });
942 }
943 }
944
945 if let Some(layout) = &node.layout {
947 if let Err(msg) = layout.validate() {
948 errors.push(CheckError::InvalidLayoutConstraint {
949 file: file_path.to_path_buf(),
950 line: node.span.line,
951 col: node.span.column,
952 message: msg,
953 });
954 }
955 }
956
957 for class_name in &node.classes {
959 if !document.style_classes.contains_key(class_name) {
960 errors.push(CheckError::UnknownStyleClass {
961 class: class_name.clone(),
962 file: file_path.to_path_buf(),
963 line: node.span.line,
964 col: node.span.column,
965 });
966 }
967 }
968
969 if let Some(theme_ref) = &node.theme_ref {
971 if !document.themes.contains_key(theme_ref) {
972 errors.push(CheckError::UnknownTheme {
973 theme: theme_ref.clone(),
974 file: file_path.to_path_buf(),
975 line: node.span.line,
976 col: node.span.column,
977 });
978 }
979 }
980
981 validate_style_attributes(node, file_path, errors);
983
984 validate_layout_attributes(node, file_path, errors);
986
987 validate_breakpoint_attributes(node, file_path, errors);
989
990 validate_state_attributes(node, file_path, errors);
992
993 for child in &node.children {
995 validate_widget_with_styles(child, file_path, document, errors);
996 }
997}
998
999fn validate_style_attributes(
1001 node: &dampen_core::ir::WidgetNode,
1002 file_path: &Path,
1003 errors: &mut Vec<CheckError>,
1004) {
1005 for (attr_name, attr_value) in &node.attributes {
1006 match attr_name.as_str() {
1007 "background" => {
1008 if let AttributeValue::Static(value) = attr_value {
1009 if let Err(msg) = style_parser::parse_background_attr(value) {
1010 errors.push(CheckError::InvalidStyleValue {
1011 attr: attr_name.clone(),
1012 file: file_path.to_path_buf(),
1013 line: node.span.line,
1014 col: node.span.column,
1015 message: msg,
1016 });
1017 }
1018 }
1019 }
1020 "color" | "border_color" => {
1021 if let AttributeValue::Static(value) = attr_value {
1022 if let Err(msg) = style_parser::parse_color_attr(value) {
1023 errors.push(CheckError::InvalidStyleValue {
1024 attr: attr_name.clone(),
1025 file: file_path.to_path_buf(),
1026 line: node.span.line,
1027 col: node.span.column,
1028 message: msg,
1029 });
1030 }
1031 }
1032 }
1033 "border_width" | "opacity" => {
1034 if let AttributeValue::Static(value) = attr_value {
1035 if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
1036 errors.push(CheckError::InvalidStyleValue {
1037 attr: attr_name.clone(),
1038 file: file_path.to_path_buf(),
1039 line: node.span.line,
1040 col: node.span.column,
1041 message: msg,
1042 });
1043 }
1044 }
1045 }
1046 "border_radius" => {
1047 if let AttributeValue::Static(value) = attr_value {
1048 if let Err(msg) = style_parser::parse_border_radius(value) {
1049 errors.push(CheckError::InvalidStyleValue {
1050 attr: attr_name.clone(),
1051 file: file_path.to_path_buf(),
1052 line: node.span.line,
1053 col: node.span.column,
1054 message: msg,
1055 });
1056 }
1057 }
1058 }
1059 "border_style" => {
1060 if let AttributeValue::Static(value) = attr_value {
1061 if let Err(msg) = style_parser::parse_border_style(value) {
1062 errors.push(CheckError::InvalidStyleValue {
1063 attr: attr_name.clone(),
1064 file: file_path.to_path_buf(),
1065 line: node.span.line,
1066 col: node.span.column,
1067 message: msg,
1068 });
1069 }
1070 }
1071 }
1072 "shadow" => {
1073 if let AttributeValue::Static(value) = attr_value {
1074 if let Err(msg) = style_parser::parse_shadow_attr(value) {
1075 errors.push(CheckError::InvalidStyleValue {
1076 attr: attr_name.clone(),
1077 file: file_path.to_path_buf(),
1078 line: node.span.line,
1079 col: node.span.column,
1080 message: msg,
1081 });
1082 }
1083 }
1084 }
1085 "transform" => {
1086 if let AttributeValue::Static(value) = attr_value {
1087 if let Err(msg) = style_parser::parse_transform(value) {
1088 errors.push(CheckError::InvalidStyleValue {
1089 attr: attr_name.clone(),
1090 file: file_path.to_path_buf(),
1091 line: node.span.line,
1092 col: node.span.column,
1093 message: msg,
1094 });
1095 }
1096 }
1097 }
1098 _ => {} }
1100 }
1101}
1102
1103fn validate_layout_attributes(
1105 node: &dampen_core::ir::WidgetNode,
1106 file_path: &Path,
1107 errors: &mut Vec<CheckError>,
1108) {
1109 for (attr_name, attr_value) in &node.attributes {
1110 match attr_name.as_str() {
1111 "width" | "height" | "min_width" | "max_width" | "min_height" | "max_height" => {
1112 if let AttributeValue::Static(value) = attr_value {
1113 if let Err(msg) = style_parser::parse_length_attr(value) {
1114 errors.push(CheckError::InvalidStyleValue {
1115 attr: attr_name.clone(),
1116 file: file_path.to_path_buf(),
1117 line: node.span.line,
1118 col: node.span.column,
1119 message: msg,
1120 });
1121 }
1122 }
1123 }
1124 "padding" => {
1125 if let AttributeValue::Static(value) = attr_value {
1126 if let Err(msg) = style_parser::parse_padding_attr(value) {
1127 errors.push(CheckError::InvalidStyleValue {
1128 attr: attr_name.clone(),
1129 file: file_path.to_path_buf(),
1130 line: node.span.line,
1131 col: node.span.column,
1132 message: msg,
1133 });
1134 }
1135 }
1136 }
1137 "spacing" => {
1138 if let AttributeValue::Static(value) = attr_value {
1139 if let Err(msg) = style_parser::parse_spacing(value) {
1140 errors.push(CheckError::InvalidStyleValue {
1141 attr: attr_name.clone(),
1142 file: file_path.to_path_buf(),
1143 line: node.span.line,
1144 col: node.span.column,
1145 message: msg,
1146 });
1147 }
1148 }
1149 }
1150 "align_items" => {
1151 if let AttributeValue::Static(value) = attr_value {
1152 if let Err(msg) = style_parser::parse_alignment(value) {
1153 errors.push(CheckError::InvalidStyleValue {
1154 attr: attr_name.clone(),
1155 file: file_path.to_path_buf(),
1156 line: node.span.line,
1157 col: node.span.column,
1158 message: msg,
1159 });
1160 }
1161 }
1162 }
1163 "justify_content" => {
1164 if let AttributeValue::Static(value) = attr_value {
1165 if let Err(msg) = style_parser::parse_justification(value) {
1166 errors.push(CheckError::InvalidStyleValue {
1167 attr: attr_name.clone(),
1168 file: file_path.to_path_buf(),
1169 line: node.span.line,
1170 col: node.span.column,
1171 message: msg,
1172 });
1173 }
1174 }
1175 }
1176 "direction" => {
1177 if let AttributeValue::Static(value) = attr_value {
1178 if let Err(msg) = Direction::parse(value) {
1179 errors.push(CheckError::InvalidStyleValue {
1180 attr: attr_name.clone(),
1181 file: file_path.to_path_buf(),
1182 line: node.span.line,
1183 col: node.span.column,
1184 message: msg,
1185 });
1186 }
1187 }
1188 }
1189 "position" => {
1190 if matches!(node.kind, WidgetKind::Tooltip) {
1191 } else if let AttributeValue::Static(value) = attr_value {
1192 if let Err(msg) = Position::parse(value) {
1193 errors.push(CheckError::InvalidStyleValue {
1194 attr: attr_name.clone(),
1195 file: file_path.to_path_buf(),
1196 line: node.span.line,
1197 col: node.span.column,
1198 message: msg,
1199 });
1200 }
1201 }
1202 }
1203 "top" | "right" | "bottom" | "left" => {
1204 if let AttributeValue::Static(value) = attr_value {
1205 if let Err(msg) = style_parser::parse_float_attr(value, attr_name) {
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 "z_index" => {
1217 if let AttributeValue::Static(value) = attr_value {
1218 if let Err(msg) = style_parser::parse_int_attr(value, attr_name) {
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 _ => {} }
1231 }
1232}
1233
1234fn validate_breakpoint_attributes(
1236 node: &dampen_core::ir::WidgetNode,
1237 file_path: &Path,
1238 errors: &mut Vec<CheckError>,
1239) {
1240 for (breakpoint, attrs) in &node.breakpoint_attributes {
1241 for (attr_name, attr_value) in attrs {
1242 let base_attr = attr_name.as_str();
1244 let full_attr = format!("{:?}:{}", breakpoint, base_attr);
1245
1246 let is_style_attr = matches!(
1248 base_attr,
1249 "background"
1250 | "color"
1251 | "border_width"
1252 | "border_color"
1253 | "border_radius"
1254 | "border_style"
1255 | "shadow"
1256 | "opacity"
1257 | "transform"
1258 );
1259
1260 let is_layout_attr = matches!(
1261 base_attr,
1262 "width"
1263 | "height"
1264 | "min_width"
1265 | "max_width"
1266 | "min_height"
1267 | "max_height"
1268 | "padding"
1269 | "spacing"
1270 | "align_items"
1271 | "justify_content"
1272 | "direction"
1273 | "position"
1274 | "top"
1275 | "right"
1276 | "bottom"
1277 | "left"
1278 | "z_index"
1279 );
1280
1281 if !is_style_attr && !is_layout_attr {
1282 errors.push(CheckError::InvalidBreakpoint {
1283 attr: full_attr,
1284 file: file_path.to_path_buf(),
1285 line: node.span.line,
1286 col: node.span.column,
1287 });
1288 continue;
1289 }
1290
1291 if let AttributeValue::Static(value) = attr_value {
1293 let result: Result<(), String> = match base_attr {
1294 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1295 "color" | "border_color" => style_parser::parse_color_attr(value).map(|_| ()),
1296 "border_width" | "opacity" => {
1297 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1298 }
1299 "border_radius" => style_parser::parse_border_radius(value).map(|_| ()),
1300 "border_style" => style_parser::parse_border_style(value).map(|_| ()),
1301 "shadow" => style_parser::parse_shadow_attr(value).map(|_| ()),
1302 "transform" => style_parser::parse_transform(value).map(|_| ()),
1303 "width" | "height" | "min_width" | "max_width" | "min_height"
1304 | "max_height" => style_parser::parse_length_attr(value).map(|_| ()),
1305 "padding" => style_parser::parse_padding_attr(value).map(|_| ()),
1306 "spacing" => style_parser::parse_spacing(value).map(|_| ()),
1307 "align_items" => style_parser::parse_alignment(value).map(|_| ()),
1308 "justify_content" => style_parser::parse_justification(value).map(|_| ()),
1309 "direction" => Direction::parse(value).map(|_| ()),
1310 "position" => Position::parse(value).map(|_| ()),
1311 "top" | "right" | "bottom" | "left" => {
1312 style_parser::parse_float_attr(value, base_attr).map(|_| ())
1313 }
1314 "z_index" => style_parser::parse_int_attr(value, base_attr).map(|_| ()),
1315 _ => Ok(()),
1316 };
1317
1318 if let Err(msg) = result {
1319 errors.push(CheckError::InvalidStyleValue {
1320 attr: full_attr,
1321 file: file_path.to_path_buf(),
1322 line: node.span.line,
1323 col: node.span.column,
1324 message: msg,
1325 });
1326 }
1327 }
1328 }
1329 }
1330}
1331
1332fn validate_state_attributes(
1334 node: &dampen_core::ir::WidgetNode,
1335 file_path: &Path,
1336 errors: &mut Vec<CheckError>,
1337) {
1338 for (attr_name, attr_value) in &node.attributes {
1339 if attr_name.contains(':') {
1340 let parts: Vec<&str> = attr_name.split(':').collect();
1341 if parts.len() >= 2 {
1342 let prefix = parts[0];
1343 let base_attr = parts[1];
1344
1345 if !["hover", "focus", "active", "disabled"].contains(&prefix) {
1347 errors.push(CheckError::InvalidState {
1348 attr: attr_name.clone(),
1349 file: file_path.to_path_buf(),
1350 line: node.span.line,
1351 col: node.span.column,
1352 });
1353 continue;
1354 }
1355
1356 let is_valid_attr = matches!(
1358 base_attr,
1359 "background"
1360 | "color"
1361 | "border_width"
1362 | "border_color"
1363 | "border_radius"
1364 | "border_style"
1365 | "shadow"
1366 | "opacity"
1367 | "transform"
1368 | "width"
1369 | "height"
1370 | "min_width"
1371 | "max_width"
1372 | "min_height"
1373 | "max_height"
1374 | "padding"
1375 | "spacing"
1376 | "align_items"
1377 | "justify_content"
1378 | "direction"
1379 | "position"
1380 | "top"
1381 | "right"
1382 | "bottom"
1383 | "left"
1384 | "z_index"
1385 );
1386
1387 if !is_valid_attr {
1388 errors.push(CheckError::InvalidState {
1389 attr: attr_name.clone(),
1390 file: file_path.to_path_buf(),
1391 line: node.span.line,
1392 col: node.span.column,
1393 });
1394 continue;
1395 }
1396
1397 if let AttributeValue::Static(value) = attr_value {
1399 let result: Result<(), String> = match base_attr {
1400 "background" => style_parser::parse_background_attr(value).map(|_| ()),
1401 "color" | "border_color" => {
1402 style_parser::parse_color_attr(value).map(|_| ())
1403 }
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: attr_name.clone(),
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}