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