1pub mod attribute_standard;
2pub mod canvas;
3pub mod color_validator;
4pub mod error;
5pub mod gradient;
6pub mod lexer;
7pub mod style_parser;
8pub mod theme_parser;
9
10use crate::expr::tokenize_binding_expr;
11use crate::expr::{BindingExpr, Expr, LiteralExpr};
12use crate::ir::style::StyleProperties;
13use crate::ir::theme::WidgetState;
14use crate::ir::{
15 AttributeValue, Breakpoint, DampenDocument, EventBinding, EventKind, InterpolatedPart,
16 SchemaVersion, Span, WidgetKind, WidgetNode,
17};
18use crate::parser::error::{ParseError, ParseErrorKind};
19use chrono::{NaiveDate, NaiveTime};
20use roxmltree::{Document, Node, NodeType};
21use std::collections::HashMap;
22
23pub const MAX_SUPPORTED_VERSION: SchemaVersion = SchemaVersion { major: 1, minor: 1 };
28
29pub fn parse_version_string(version_str: &str, span: Span) -> Result<SchemaVersion, ParseError> {
48 let trimmed = version_str.trim();
49
50 if trimmed.is_empty() {
52 return Err(ParseError {
53 kind: ParseErrorKind::InvalidValue,
54 message: "Version attribute cannot be empty".to_string(),
55 span,
56 suggestion: Some("Use format: version=\"1.0\"".to_string()),
57 });
58 }
59
60 let parts: Vec<&str> = trimmed.split('.').collect();
62 if parts.len() != 2 {
63 return Err(ParseError {
64 kind: ParseErrorKind::InvalidValue,
65 message: format!(
66 "Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
67 trimmed
68 ),
69 span,
70 suggestion: Some("Use format: version=\"1.0\"".to_string()),
71 });
72 }
73
74 let major = parts[0].parse::<u16>().map_err(|_| ParseError {
76 kind: ParseErrorKind::InvalidValue,
77 message: format!(
78 "Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
79 trimmed
80 ),
81 span,
82 suggestion: Some("Use format: version=\"1.0\"".to_string()),
83 })?;
84
85 let minor = parts[1].parse::<u16>().map_err(|_| ParseError {
87 kind: ParseErrorKind::InvalidValue,
88 message: format!(
89 "Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
90 trimmed
91 ),
92 span,
93 suggestion: Some("Use format: version=\"1.0\"".to_string()),
94 })?;
95
96 Ok(SchemaVersion { major, minor })
97}
98
99pub fn validate_version_supported(version: &SchemaVersion, span: Span) -> Result<(), ParseError> {
111 if (version.major, version.minor) > (MAX_SUPPORTED_VERSION.major, MAX_SUPPORTED_VERSION.minor) {
112 return Err(ParseError {
113 kind: ParseErrorKind::UnsupportedVersion,
114 message: format!(
115 "Schema version {}.{} is not supported. Maximum supported version: {}.{}",
116 version.major,
117 version.minor,
118 MAX_SUPPORTED_VERSION.major,
119 MAX_SUPPORTED_VERSION.minor
120 ),
121 span,
122 suggestion: Some(format!(
123 "Upgrade dampen-core to support v{}.{}, or use version=\"{}.{}\"",
124 version.major,
125 version.minor,
126 MAX_SUPPORTED_VERSION.major,
127 MAX_SUPPORTED_VERSION.minor
128 )),
129 });
130 }
131 Ok(())
132}
133
134#[derive(Debug, Clone, PartialEq)]
138pub struct ValidationWarning {
139 pub widget_kind: WidgetKind,
141 pub declared_version: SchemaVersion,
143 pub required_version: SchemaVersion,
145 pub span: Span,
147}
148
149impl ValidationWarning {
150 pub fn format_message(&self) -> String {
152 format!(
153 "Widget '{}' requires schema v{}.{} but document declares v{}.{}",
154 self.widget_kind,
155 self.required_version.major,
156 self.required_version.minor,
157 self.declared_version.major,
158 self.declared_version.minor
159 )
160 }
161
162 pub fn suggestion(&self) -> String {
164 format!(
165 "Update to <dampen version=\"{}.{}\"> or remove this widget",
166 self.required_version.major, self.required_version.minor
167 )
168 }
169}
170
171pub fn validate_widget_versions(document: &DampenDocument) -> Vec<ValidationWarning> {
197 let mut warnings = Vec::new();
198 validate_widget_tree(&document.root, &document.version, &mut warnings);
199 warnings
200}
201
202fn validate_widget_tree(
204 node: &WidgetNode,
205 doc_version: &SchemaVersion,
206 warnings: &mut Vec<ValidationWarning>,
207) {
208 let min_version = node.kind.minimum_version();
209
210 if (min_version.major, min_version.minor) > (doc_version.major, doc_version.minor) {
212 warnings.push(ValidationWarning {
213 widget_kind: node.kind.clone(),
214 declared_version: *doc_version,
215 required_version: min_version,
216 span: node.span,
217 });
218 }
219
220 for child in &node.children {
222 validate_widget_tree(child, doc_version, warnings);
223 }
224}
225
226fn preprocess_xml(xml: &str) -> String {
231 let mut result = xml.to_string();
232 let states = ["hover", "active", "focus", "disabled"];
233 let prefixes = [' ', '\n', '\t', '\r'];
234
235 for state in states {
236 for prefix in prefixes {
237 let target = format!("{}{}:", prefix, state);
240 let sub = format!("{}{}_state_", prefix, state);
241 result = result.replace(&target, &sub);
242 }
243 }
244 result
245}
246
247pub fn parse(xml: &str) -> Result<DampenDocument, ParseError> {
279 let processed_xml = preprocess_xml(xml);
281
282 let doc = Document::parse(&processed_xml).map_err(|e| ParseError {
284 kind: ParseErrorKind::XmlSyntax,
285 message: e.to_string(),
286 span: Span::new(0, 0, 1, 1),
287 suggestion: None,
288 })?;
289
290 let root = doc.root().first_child().ok_or_else(|| ParseError {
292 kind: ParseErrorKind::XmlSyntax,
293 message: "No root element found".to_string(),
294 span: Span::new(0, 0, 1, 1),
295 suggestion: None,
296 })?;
297
298 let root_tag = root.tag_name().name();
300
301 if root_tag == "dampen" {
302 parse_dampen_document(root, xml)
304 } else {
305 let root_widget = parse_node(root, xml)?;
308
309 validate_nesting_constraints(&root_widget, None)?;
311
312 Ok(DampenDocument {
313 version: SchemaVersion::default(),
314 root: root_widget,
315 themes: HashMap::new(),
316 style_classes: HashMap::new(),
317 global_theme: None,
318 follow_system: true,
319 })
320 }
321}
322
323fn validate_widget_attributes(
325 kind: &WidgetKind,
326 attributes: &std::collections::HashMap<String, AttributeValue>,
327 span: Span,
328) -> Result<(), ParseError> {
329 match kind {
330 WidgetKind::ComboBox | WidgetKind::PickList => {
331 require_non_empty_attribute(
332 kind,
333 "options",
334 attributes,
335 span,
336 "Add a comma-separated list: options=\"Option1,Option2\"",
337 )?;
338 }
339 WidgetKind::DatePicker => {
340 validate_date_format(kind, attributes, span)?;
341 validate_date_range(kind, attributes, span)?;
342 }
343 WidgetKind::TimePicker => {
344 validate_time_format(kind, attributes, span)?;
345 }
346 WidgetKind::Canvas => {
347 validate_numeric_range(kind, "width", attributes, span, 50..=4000)?;
350 validate_numeric_range(kind, "height", attributes, span, 50..=4000)?;
351
352 if attributes.contains_key("program") {
354 }
360 }
361 WidgetKind::Grid => {
362 require_attribute(
363 kind,
364 "columns",
365 attributes,
366 span,
367 "Add columns attribute: columns=\"5\"",
368 )?;
369 validate_numeric_range(kind, "columns", attributes, span, 1..=20)?;
370 }
371 WidgetKind::Tooltip => {
372 require_attribute(
373 kind,
374 "message",
375 attributes,
376 span,
377 "Add message attribute: message=\"Help text\"",
378 )?;
379 }
380 WidgetKind::For => {
381 require_attribute(
382 kind,
383 "each",
384 attributes,
385 span,
386 "Add each attribute: each=\"item\"",
387 )?;
388 require_attribute(
389 kind,
390 "in",
391 attributes,
392 span,
393 "Add in attribute: in=\"{items}\"",
394 )?;
395 }
396 WidgetKind::CanvasRect
397 | WidgetKind::CanvasCircle
398 | WidgetKind::CanvasLine
399 | WidgetKind::CanvasText
400 | WidgetKind::CanvasGroup => {
401 canvas::validate_shape_attributes(kind, attributes, span)?;
402 }
403 _ => {}
404 }
405 Ok(())
406}
407
408fn validate_date_format(
410 kind: &WidgetKind,
411 attributes: &HashMap<String, AttributeValue>,
412 span: Span,
413) -> Result<(), ParseError> {
414 if let Some(AttributeValue::Static(value)) = attributes.get("value") {
415 let format = if let Some(AttributeValue::Static(f)) = attributes.get("format") {
416 f.as_str()
417 } else {
418 "%Y-%m-%d"
419 };
420
421 if NaiveDate::parse_from_str(value, format).is_err() {
422 return Err(ParseError {
423 kind: ParseErrorKind::InvalidDateFormat,
424 message: format!(
425 "Invalid date format for {:?}: '{}' does not match format '{}'",
426 kind, value, format
427 ),
428 span,
429 suggestion: Some(
430 "Use ISO 8601 format (YYYY-MM-DD) or specify correct format attribute (e.g., format=\"%d/%m/%Y\")".to_string()
431 ),
432 });
433 }
434 }
435 Ok(())
436}
437
438fn validate_time_format(
440 kind: &WidgetKind,
441 attributes: &HashMap<String, AttributeValue>,
442 span: Span,
443) -> Result<(), ParseError> {
444 if let Some(AttributeValue::Static(value)) = attributes.get("value") {
445 let format = if let Some(AttributeValue::Static(f)) = attributes.get("format") {
446 f.as_str()
447 } else {
448 "%H:%M:%S"
449 };
450
451 if NaiveTime::parse_from_str(value, format).is_err() {
452 return Err(ParseError {
453 kind: ParseErrorKind::InvalidTimeFormat,
454 message: format!(
455 "Invalid time format for {:?}: '{}' does not match format '{}'",
456 kind, value, format
457 ),
458 span,
459 suggestion: Some(
460 "Use 24-hour format (HH:MM:SS) or specify correct format attribute (e.g., format=\"%I:%M %p\")".to_string()
461 ),
462 });
463 }
464 }
465 Ok(())
466}
467
468fn validate_date_range(
470 kind: &WidgetKind,
471 attributes: &HashMap<String, AttributeValue>,
472 span: Span,
473) -> Result<(), ParseError> {
474 let min_date = if let Some(AttributeValue::Static(m)) = attributes.get("min_date") {
475 NaiveDate::parse_from_str(m, "%Y-%m-%d").ok()
476 } else {
477 None
478 };
479
480 let max_date = if let Some(AttributeValue::Static(m)) = attributes.get("max_date") {
481 NaiveDate::parse_from_str(m, "%Y-%m-%d").ok()
482 } else {
483 None
484 };
485
486 if let (Some(min), Some(max)) = (min_date, max_date)
487 && min > max
488 {
489 return Err(ParseError {
490 kind: ParseErrorKind::InvalidDateRange,
491 message: format!(
492 "Invalid date range for {:?}: min_date ({}) is after max_date ({})",
493 kind, min, max
494 ),
495 span,
496 suggestion: Some("Ensure min_date is before or equal to max_date".to_string()),
497 });
498 }
499 Ok(())
500}
501
502fn require_attribute(
504 kind: &WidgetKind,
505 attr_name: &str,
506 attributes: &HashMap<String, AttributeValue>,
507 span: Span,
508 suggestion: &str,
509) -> Result<(), ParseError> {
510 if !attributes.contains_key(attr_name) {
511 return Err(ParseError {
512 kind: ParseErrorKind::MissingAttribute,
513 message: format!("{:?} widget requires '{}' attribute", kind, attr_name),
514 span,
515 suggestion: Some(suggestion.to_string()),
516 });
517 }
518 Ok(())
519}
520
521fn require_non_empty_attribute(
523 kind: &WidgetKind,
524 attr_name: &str,
525 attributes: &HashMap<String, AttributeValue>,
526 span: Span,
527 suggestion: &str,
528) -> Result<(), ParseError> {
529 match attributes.get(attr_name) {
530 Some(AttributeValue::Static(value)) if !value.trim().is_empty() => Ok(()),
531 _ => Err(ParseError {
532 kind: ParseErrorKind::MissingAttribute,
533 message: format!(
534 "{:?} widget requires '{}' attribute to be non-empty",
535 kind, attr_name
536 ),
537 span,
538 suggestion: Some(suggestion.to_string()),
539 }),
540 }
541}
542
543fn validate_numeric_range<T: PartialOrd + std::fmt::Display + std::str::FromStr>(
545 kind: &WidgetKind,
546 attr_name: &str,
547 attributes: &HashMap<String, AttributeValue>,
548 span: Span,
549 range: std::ops::RangeInclusive<T>,
550) -> Result<(), ParseError> {
551 if let Some(AttributeValue::Static(value_str)) = attributes.get(attr_name)
552 && let Ok(value) = value_str.parse::<T>()
553 && !range.contains(&value)
554 {
555 return Err(ParseError {
556 kind: ParseErrorKind::InvalidValue,
557 message: format!(
558 "{} for {:?} {} must be between {} and {}, found {}",
559 attr_name,
560 kind,
561 attr_name,
562 range.start(),
563 range.end(),
564 value
565 ),
566 span,
567 suggestion: Some(format!(
568 "Use {} value between {} and {}",
569 attr_name,
570 range.start(),
571 range.end()
572 )),
573 });
574 }
575 Ok(())
576}
577
578fn validate_tooltip_children(children: &[WidgetNode], span: Span) -> Result<(), ParseError> {
580 if children.is_empty() {
581 return Err(ParseError {
582 kind: ParseErrorKind::InvalidValue,
583 message: "Tooltip widget must have exactly one child widget".to_string(),
584 span,
585 suggestion: Some("Wrap a single widget in <tooltip></tooltip>".to_string()),
586 });
587 }
588 if children.len() > 1 {
589 return Err(ParseError {
590 kind: ParseErrorKind::InvalidValue,
591 message: format!(
592 "Tooltip widget must have exactly one child, found {}",
593 children.len()
594 ),
595 span,
596 suggestion: Some("Wrap only one widget in <tooltip></tooltip>".to_string()),
597 });
598 }
599 Ok(())
600}
601
602fn validate_canvas_children(
604 attributes: &HashMap<String, AttributeValue>,
605 children: &[WidgetNode],
606 span: Span,
607) -> Result<(), ParseError> {
608 if attributes.contains_key("program") && !children.is_empty() {
610 return Err(ParseError {
616 kind: ParseErrorKind::InvalidValue,
617 message: "Canvas cannot have both a 'program' attribute and child shapes".to_string(),
618 span,
619 suggestion: Some("Remove the 'program' attribute to use declarative shapes, or remove children to use a custom program".to_string()),
620 });
621 }
622
623 canvas::validate_canvas_children(children, span)
624}
625
626fn validate_datetime_picker_children(
628 kind: &WidgetKind,
629 children: &[WidgetNode],
630 span: Span,
631) -> Result<(), ParseError> {
632 if children.is_empty() {
633 return Err(ParseError {
634 kind: ParseErrorKind::InvalidValue,
635 message: format!(
636 "{:?} widget must have exactly one child widget (the underlay)",
637 kind
638 ),
639 span,
640 suggestion: Some(format!(
641 "Wrap a single widget (e.g., <button>) in <{}>",
642 kind
643 )),
644 });
645 }
646 if children.len() > 1 {
647 return Err(ParseError {
648 kind: ParseErrorKind::InvalidValue,
649 message: format!(
650 "{:?} widget must have exactly one child, found {}",
651 kind,
652 children.len()
653 ),
654 span,
655 suggestion: Some(format!("Wrap only one widget in <{}>", kind)),
656 });
657 }
658 Ok(())
659}
660
661fn validate_context_menu_children(children: &[WidgetNode], span: Span) -> Result<(), ParseError> {
663 if children.len() != 2 {
664 return Err(ParseError {
665 kind: ParseErrorKind::InvalidValue,
666 message: "ContextMenu requires exactly 2 children: underlay and menu".to_string(),
667 span,
668 suggestion: Some(
669 "Add an underlay widget (1st child) and a <menu> element (2nd child)".to_string(),
670 ),
671 });
672 }
673 if children[1].kind != WidgetKind::Menu {
674 return Err(ParseError {
675 kind: ParseErrorKind::InvalidValue,
676 message: "Second child of ContextMenu must be <menu>".to_string(),
677 span: children[1].span,
678 suggestion: None,
679 });
680 }
681 Ok(())
682}
683
684fn parse_node(node: Node, source: &str) -> Result<WidgetNode, ParseError> {
686 if node.node_type() != NodeType::Element {
688 return Err(ParseError {
689 kind: ParseErrorKind::XmlSyntax,
690 message: "Expected element node".to_string(),
691 span: Span::new(0, 0, 1, 1),
692 suggestion: None,
693 });
694 }
695
696 let tag_name = node.tag_name().name();
698 let kind = match tag_name {
699 "column" => WidgetKind::Column,
700 "row" => WidgetKind::Row,
701 "container" => WidgetKind::Container,
702 "scrollable" => WidgetKind::Scrollable,
703 "stack" => WidgetKind::Stack,
704 "text" => WidgetKind::Text,
705 "image" => WidgetKind::Image,
706 "svg" => WidgetKind::Svg,
707 "button" => WidgetKind::Button,
708 "text_input" => WidgetKind::TextInput,
709 "checkbox" => WidgetKind::Checkbox,
710 "slider" => WidgetKind::Slider,
711 "pick_list" => WidgetKind::PickList,
712 "toggler" => WidgetKind::Toggler,
713 "space" => WidgetKind::Space,
714 "rule" => WidgetKind::Rule,
715 "radio" => WidgetKind::Radio,
716 "combobox" => WidgetKind::ComboBox,
717 "progress_bar" => WidgetKind::ProgressBar,
718 "tooltip" => WidgetKind::Tooltip,
719 "grid" => WidgetKind::Grid,
720 "canvas" => WidgetKind::Canvas,
721 "rect" => WidgetKind::CanvasRect,
722 "circle" => WidgetKind::CanvasCircle,
723 "line" => WidgetKind::CanvasLine,
724 "canvas_text" => WidgetKind::CanvasText,
725 "group" => WidgetKind::CanvasGroup,
726 "date_picker" => WidgetKind::DatePicker,
727 "time_picker" => WidgetKind::TimePicker,
728 "color_picker" => WidgetKind::ColorPicker,
729 "menu" => WidgetKind::Menu,
730 "menu_item" => WidgetKind::MenuItem,
731 "menu_separator" => WidgetKind::MenuSeparator,
732 "context_menu" => WidgetKind::ContextMenu,
733 "float" => WidgetKind::Float,
734 "data_table" => WidgetKind::DataTable,
735 "data_column" => WidgetKind::DataColumn,
736 "tree_view" => WidgetKind::TreeView,
737 "tree_node" => WidgetKind::TreeNode,
738 "tab_bar" => WidgetKind::TabBar,
739 "tab" => WidgetKind::Tab,
740 "template" => WidgetKind::Custom("template".to_string()),
741 "for" => WidgetKind::For,
742 "if" => WidgetKind::If,
743 unknown => {
744 return Err(ParseError {
745 kind: ParseErrorKind::UnknownWidget,
746 message: format!("Unknown widget: {}", unknown),
747 span: get_span(node, source),
748 suggestion: Some(format!(
749 "Valid widgets are: {}",
750 WidgetKind::all_standard().join(", ")
751 )),
752 });
753 }
754 };
755
756 let mut attributes = std::collections::HashMap::new();
758 let mut breakpoint_attributes: HashMap<Breakpoint, HashMap<String, AttributeValue>> =
759 HashMap::new();
760 let mut inline_state_variants: HashMap<WidgetState, HashMap<String, AttributeValue>> =
761 HashMap::new();
762 let mut events = Vec::new();
763 let mut id = None;
764
765 for attr in node.attributes() {
767 if kind == WidgetKind::ColorPicker && attr.name() == "value" {
768 color_validator::validate_color_format(attr.value(), get_span(node, source))?;
769 }
770 }
771
772 for attr in node.attributes() {
773 let name = if let Some(ns) = attr.namespace() {
775 if ns.starts_with("urn:dampen:state") {
777 let prefix = node
779 .namespaces()
780 .find(|n| n.uri() == ns)
781 .and_then(|n| n.name())
782 .unwrap_or("");
783 format!("{}:{}", prefix, attr.name())
784 } else {
785 attr.name().to_string()
786 }
787 } else {
788 attr.name().to_string()
789 };
790 let value = attr.value();
791
792 if name == "id" {
794 id = Some(value.to_string());
795 continue;
796 }
797
798 if name.starts_with("on_") {
800 let event_kind = match name.as_str() {
801 "on_click" => Some(EventKind::CanvasClick), "on_press" => Some(EventKind::Press),
803 "on_release" => Some(EventKind::CanvasRelease),
804 "on_drag" => Some(EventKind::CanvasDrag),
805 "on_move" => Some(EventKind::CanvasMove),
806 "on_change" => Some(EventKind::Change),
807 "on_input" => Some(EventKind::Input),
808 "on_submit" => Some(EventKind::Submit),
809 "on_select" => Some(EventKind::Select),
810 "on_toggle" => Some(EventKind::Toggle),
811 "on_scroll" => Some(EventKind::Scroll),
812 "on_cancel" => Some(EventKind::Cancel),
813 "on_open" => Some(EventKind::Open),
814 "on_close" => Some(EventKind::Close),
815 "on_row_click" => Some(EventKind::RowClick),
816 _ => None,
817 };
818
819 let event_kind = if kind != WidgetKind::Canvas {
821 match name.as_str() {
822 "on_click" => Some(EventKind::Click),
823 "on_release" => Some(EventKind::Release),
824 _ => event_kind,
825 }
826 } else {
827 event_kind
828 };
829
830 if let Some(event) = event_kind {
831 let (handler_name, param) = if let Some(colon_pos) = value.find(':') {
834 let handler = value[..colon_pos].to_string();
835 let param_str = &value[colon_pos + 1..];
836
837 if param_str.starts_with('\'')
839 && param_str.ends_with('\'')
840 && param_str.len() >= 2
841 {
842 let quoted_value = ¶m_str[1..param_str.len() - 1];
843 let expr = BindingExpr {
845 expr: Expr::Literal(LiteralExpr::String(quoted_value.to_string())),
846 span: Span::new(
847 colon_pos + 1,
848 colon_pos + 1 + param_str.len(),
849 1,
850 colon_pos as u32 + 1,
851 ),
852 };
853 (handler, Some(expr))
854 } else {
855 let param_clean = param_str.trim_matches('{').trim_matches('}');
857
858 match crate::expr::tokenize_binding_expr(param_clean, 0, 1, 1) {
860 Ok(expr) => (handler, Some(expr)),
861 Err(_) => {
862 (value.to_string(), None)
864 }
865 }
866 }
867 } else {
868 (value.to_string(), None)
869 };
870
871 events.push(EventBinding {
872 event,
873 handler: handler_name,
874 param,
875 span: get_span(node, source),
876 });
877 continue;
878 }
879 }
880
881 if let Some((prefix, attr_name)) = name.split_once('-')
884 && let Ok(breakpoint) = crate::ir::layout::Breakpoint::parse(prefix)
885 {
886 let attr_value = parse_attribute_value(value, get_span(node, source))?;
887 breakpoint_attributes
888 .entry(breakpoint)
889 .or_default()
890 .insert(attr_name.to_string(), attr_value);
891 continue;
892 }
893
894 if let Some((state_prefix, attr_name)) = name.split_once(':')
895 && let Some(state) = WidgetState::from_prefix(state_prefix)
896 {
897 let attr_value = parse_attribute_value(value, get_span(node, source))?;
898 inline_state_variants
899 .entry(state)
900 .or_default()
901 .insert(attr_name.to_string(), attr_value);
902 continue;
903 }
904
905 if let Some((state_prefix, attr_name)) = name.split_once("_state_")
907 && let Some(state) = WidgetState::from_prefix(state_prefix)
908 {
909 let attr_value = parse_attribute_value(value, get_span(node, source))?;
910 inline_state_variants
911 .entry(state)
912 .or_default()
913 .insert(attr_name.to_string(), attr_value);
914 continue;
915 }
916
917 let attr_value = parse_attribute_value(value, get_span(node, source))?;
922 attributes.insert(name.to_string(), attr_value);
923 }
924
925 let classes = if let Some(AttributeValue::Static(class_attr)) = attributes.get("class") {
927 class_attr
928 .split_whitespace()
929 .map(|s| s.to_string())
930 .collect()
931 } else {
932 Vec::new()
933 };
934
935 let theme_ref = attributes.get("theme").cloned();
937
938 let mut children = Vec::new();
940 for child in node.children() {
941 if child.node_type() == NodeType::Element {
942 children.push(parse_node(child, source)?);
943 }
944 }
945
946 if kind == WidgetKind::Tooltip {
948 validate_tooltip_children(&children, get_span(node, source))?;
949 }
950
951 if kind == WidgetKind::Canvas {
953 validate_canvas_children(&attributes, &children, get_span(node, source))?;
954 }
955
956 if matches!(kind, WidgetKind::DatePicker | WidgetKind::TimePicker) {
958 validate_datetime_picker_children(&kind, &children, get_span(node, source))?;
959 }
960
961 if kind == WidgetKind::ContextMenu {
962 validate_context_menu_children(&children, get_span(node, source))?;
963 }
964
965 let layout = parse_layout_attributes(&kind, &attributes).map_err(|e| ParseError {
967 kind: ParseErrorKind::InvalidValue,
968 message: e,
969 span: get_span(node, source),
970 suggestion: None,
971 })?;
972 let style = parse_style_attributes(&attributes).map_err(|e| ParseError {
973 kind: ParseErrorKind::InvalidValue,
974 message: e,
975 span: get_span(node, source),
976 suggestion: None,
977 })?;
978
979 let _attr_warnings = attribute_standard::normalize_attributes(&kind, &mut attributes);
981 validate_widget_attributes(&kind, &attributes, get_span(node, source))?;
985
986 let mut final_state_variants: HashMap<WidgetState, StyleProperties> = HashMap::new();
989 for (state, state_attrs) in inline_state_variants {
990 if let Some(state_style) = parse_style_attributes(&state_attrs).map_err(|e| ParseError {
991 kind: ParseErrorKind::InvalidValue,
992 message: format!("Invalid style in {:?} state: {}", state, e),
993 span: get_span(node, source),
994 suggestion: None,
995 })? {
996 final_state_variants.insert(state, state_style);
997 }
998 }
999
1000 Ok(WidgetNode {
1001 kind,
1002 id,
1003 attributes,
1004 events,
1005 children,
1006 span: get_span(node, source),
1007 style,
1008 layout,
1009 theme_ref,
1010 classes,
1011 breakpoint_attributes,
1012 inline_state_variants: final_state_variants,
1013 })
1014}
1015
1016fn parse_dampen_document(root: Node, source: &str) -> Result<DampenDocument, ParseError> {
1018 let mut themes = HashMap::new();
1019 let mut style_classes = HashMap::new();
1020 let mut root_widget = None;
1021 let mut global_theme = None;
1022 let mut follow_system = true;
1023
1024 let span = get_span(root, source);
1026 let version = if let Some(version_attr) = root.attribute("version") {
1027 let parsed = parse_version_string(version_attr, span)?;
1028 validate_version_supported(&parsed, span)?;
1029 parsed
1030 } else {
1031 SchemaVersion::default()
1033 };
1034
1035 for child in root.children() {
1037 if child.node_type() != NodeType::Element {
1038 continue;
1039 }
1040
1041 let tag_name = child.tag_name().name();
1042
1043 match tag_name {
1044 "themes" => {
1045 for theme_node in child.children() {
1047 if theme_node.node_type() == NodeType::Element
1048 && theme_node.tag_name().name() == "theme"
1049 {
1050 let theme =
1051 crate::parser::theme_parser::parse_theme_from_node(theme_node, source)?;
1052 let name = theme_node
1053 .attribute("name")
1054 .map(|s| s.to_string())
1055 .unwrap_or_else(|| "default".to_string());
1056 themes.insert(name, theme);
1057 }
1058 }
1059 }
1060 "style_classes" | "classes" | "styles" => {
1061 for class_node in child.children() {
1063 if class_node.node_type() == NodeType::Element {
1064 let tag = class_node.tag_name().name();
1065 if tag == "class" || tag == "style" {
1066 let class = crate::parser::theme_parser::parse_style_class_from_node(
1067 class_node, source,
1068 )?;
1069 style_classes.insert(class.name.clone(), class);
1070 }
1071 }
1072 }
1073 }
1074 "global_theme" | "default_theme" => {
1075 if let Some(theme_name) = child.attribute("name") {
1077 global_theme = Some(theme_name.to_string());
1078 }
1079 }
1080 "follow_system" => {
1081 if let Some(enabled) = child.attribute("enabled") {
1082 follow_system = enabled.parse::<bool>().unwrap_or(true);
1083 }
1084 }
1085 _ => {
1086 if root_widget.is_some() {
1088 return Err(ParseError {
1089 kind: ParseErrorKind::XmlSyntax,
1090 message: "Multiple root widgets found in <dampen>".to_string(),
1091 span: get_span(child, source),
1092 suggestion: Some("Only one root widget is allowed".to_string()),
1093 });
1094 }
1095 root_widget = Some(parse_node(child, source)?);
1096 }
1097 }
1098 }
1099
1100 let root_widget = if let Some(w) = root_widget {
1102 w
1103 } else if !themes.is_empty() || !style_classes.is_empty() {
1104 WidgetNode::default()
1106 } else {
1107 return Err(ParseError {
1108 kind: ParseErrorKind::XmlSyntax,
1109 message: "No root widget found in <dampen>".to_string(),
1110 span: get_span(root, source),
1111 suggestion: Some("Add a widget like <column> or <row> inside <dampen>".to_string()),
1112 });
1113 };
1114
1115 validate_widget_versions_strict(&root_widget, &version)?;
1118
1119 validate_nesting_constraints(&root_widget, None)?;
1121
1122 Ok(DampenDocument {
1123 version,
1124 root: root_widget,
1125 themes,
1126 style_classes,
1127 global_theme,
1128 follow_system,
1129 })
1130}
1131
1132fn validate_nesting_constraints(
1134 node: &WidgetNode,
1135 parent_kind: Option<&WidgetKind>,
1136) -> Result<(), ParseError> {
1137 if node.kind == WidgetKind::DataColumn && parent_kind != Some(&WidgetKind::DataTable) {
1139 return Err(ParseError {
1140 kind: ParseErrorKind::InvalidChild,
1141 message: "DataColumn must be a direct child of DataTable".to_string(),
1142 span: node.span,
1143 suggestion: Some("Wrap this column in a <data_table>".to_string()),
1144 });
1145 }
1146
1147 if node.kind == WidgetKind::Tab && parent_kind != Some(&WidgetKind::TabBar) {
1149 return Err(ParseError {
1150 kind: ParseErrorKind::InvalidChild,
1151 message: "Tab must be inside TabBar".to_string(),
1152 span: node.span,
1153 suggestion: Some("Wrap this tab in a <tab_bar>".to_string()),
1154 });
1155 }
1156
1157 if node.kind == WidgetKind::TabBar {
1159 for child in &node.children {
1160 if child.kind != WidgetKind::Tab {
1161 return Err(ParseError {
1162 kind: ParseErrorKind::InvalidChild,
1163 message: "TabBar can only contain Tab widgets".to_string(),
1164 span: child.span,
1165 suggestion: Some("Use <tab> elements inside <tab_bar>".to_string()),
1166 });
1167 }
1168 }
1169 }
1170
1171 for child in &node.children {
1173 validate_nesting_constraints(child, Some(&node.kind))?;
1174 }
1175
1176 Ok(())
1177}
1178
1179fn validate_widget_versions_strict(
1181 node: &WidgetNode,
1182 doc_version: &SchemaVersion,
1183) -> Result<(), ParseError> {
1184 let min_version = node.kind.minimum_version();
1185
1186 if (min_version.major, min_version.minor) > (doc_version.major, doc_version.minor) {
1187 return Err(ParseError {
1188 kind: ParseErrorKind::UnsupportedVersion,
1189 message: format!(
1190 "Widget '{}' requires schema v{}.{} but document declares v{}.{}",
1191 node.kind,
1192 min_version.major,
1193 min_version.minor,
1194 doc_version.major,
1195 doc_version.minor
1196 ),
1197 span: node.span,
1198 suggestion: Some(format!(
1199 "Update to <dampen version=\"{}.{}\"> or remove this widget",
1200 min_version.major, min_version.minor
1201 )),
1202 });
1203 }
1204
1205 for child in &node.children {
1206 validate_widget_versions_strict(child, doc_version)?;
1207 }
1208
1209 Ok(())
1210}
1211
1212pub fn parse_comma_separated(value: &str) -> Vec<String> {
1214 value
1215 .split(',')
1216 .map(|s| s.trim().to_string())
1217 .filter(|s| !s.is_empty())
1218 .collect()
1219}
1220
1221pub fn parse_enum_value<T>(value: &str, valid_variants: &[&str]) -> Result<T, String>
1223where
1224 T: std::str::FromStr + std::fmt::Display,
1225{
1226 let normalized = value.trim().to_lowercase();
1227 for variant in valid_variants.iter() {
1228 if variant.to_lowercase() == normalized {
1229 return T::from_str(variant).map_err(|_| {
1230 format!(
1231 "Failed to parse '{}' as {}",
1232 variant,
1233 std::any::type_name::<T>()
1234 )
1235 });
1236 }
1237 }
1238 Err(format!(
1239 "Invalid value '{}'. Valid options: {}",
1240 value,
1241 valid_variants.join(", ")
1242 ))
1243}
1244
1245fn parse_attribute_value(value: &str, span: Span) -> Result<AttributeValue, ParseError> {
1247 if value.contains('{') && value.contains('}') {
1249 let mut parts = Vec::new();
1251 let mut remaining = value;
1252
1253 while let Some(start_pos) = remaining.find('{') {
1254 if start_pos > 0 {
1256 parts.push(InterpolatedPart::Literal(
1257 remaining[..start_pos].to_string(),
1258 ));
1259 }
1260
1261 if let Some(end_pos) = remaining[start_pos..].find('}') {
1263 let expr_start = start_pos + 1;
1264 let expr_end = start_pos + end_pos;
1265 let expr_str = &remaining[expr_start..expr_end];
1266
1267 let binding_expr = tokenize_binding_expr(
1269 expr_str,
1270 span.start + expr_start,
1271 span.line,
1272 span.column + expr_start as u32,
1273 )
1274 .map_err(|e| ParseError {
1275 kind: ParseErrorKind::InvalidExpression,
1276 message: format!("Invalid expression: {}", e),
1277 span: Span::new(
1278 span.start + expr_start,
1279 span.start + expr_end,
1280 span.line,
1281 span.column + expr_start as u32,
1282 ),
1283 suggestion: None,
1284 })?;
1285
1286 parts.push(InterpolatedPart::Binding(binding_expr));
1287
1288 remaining = &remaining[expr_end + 1..];
1290 } else {
1291 parts.push(InterpolatedPart::Literal(remaining.to_string()));
1293 break;
1294 }
1295 }
1296
1297 if !remaining.is_empty() {
1299 parts.push(InterpolatedPart::Literal(remaining.to_string()));
1300 }
1301
1302 if parts.len() == 1 {
1305 match &parts[0] {
1306 InterpolatedPart::Binding(expr) => {
1307 return Ok(AttributeValue::Binding(expr.clone()));
1308 }
1309 InterpolatedPart::Literal(lit) => {
1310 return Ok(AttributeValue::Static(lit.clone()));
1311 }
1312 }
1313 } else {
1314 return Ok(AttributeValue::Interpolated(parts));
1315 }
1316 }
1317
1318 Ok(AttributeValue::Static(value.to_string()))
1320}
1321
1322fn get_span(node: Node, source: &str) -> Span {
1324 let range = node.range();
1325
1326 let (line, col) = calculate_line_col(source, range.start);
1328
1329 Span {
1330 start: range.start,
1331 end: range.end,
1332 line,
1333 column: col,
1334 }
1335}
1336
1337fn calculate_line_col(source: &str, offset: usize) -> (u32, u32) {
1341 if offset == 0 {
1342 return (1, 1);
1343 }
1344
1345 let mut line = 1;
1346 let mut col = 1;
1347
1348 for (i, c) in source.char_indices().take(offset.saturating_add(1)) {
1349 if i >= offset {
1350 break;
1351 }
1352 if c == '\n' {
1353 line += 1;
1354 col = 1;
1355 } else {
1356 col += 1;
1357 }
1358 }
1359
1360 (line, col)
1361}
1362
1363fn parse_layout_attributes(
1365 kind: &WidgetKind,
1366 attributes: &HashMap<String, AttributeValue>,
1367) -> Result<Option<crate::ir::layout::LayoutConstraints>, String> {
1368 use crate::ir::layout::LayoutConstraints;
1369 use crate::parser::style_parser::{
1370 parse_alignment, parse_constraint, parse_float_attr, parse_int_attr, parse_justification,
1371 parse_length_attr, parse_padding_attr, parse_spacing,
1372 };
1373
1374 let mut layout = LayoutConstraints::default();
1375 let mut has_any = false;
1376
1377 if let Some(AttributeValue::Static(value)) = attributes.get("width") {
1379 layout.width = Some(parse_length_attr(value)?);
1380 has_any = true;
1381 }
1382
1383 if let Some(AttributeValue::Static(value)) = attributes.get("height") {
1385 layout.height = Some(parse_length_attr(value)?);
1386 has_any = true;
1387 }
1388
1389 if let Some(AttributeValue::Static(value)) = attributes.get("min_width") {
1391 layout.min_width = Some(parse_constraint(value)?);
1392 has_any = true;
1393 }
1394
1395 if let Some(AttributeValue::Static(value)) = attributes.get("max_width") {
1396 layout.max_width = Some(parse_constraint(value)?);
1397 has_any = true;
1398 }
1399
1400 if let Some(AttributeValue::Static(value)) = attributes.get("min_height") {
1401 layout.min_height = Some(parse_constraint(value)?);
1402 has_any = true;
1403 }
1404
1405 if let Some(AttributeValue::Static(value)) = attributes.get("max_height") {
1406 layout.max_height = Some(parse_constraint(value)?);
1407 has_any = true;
1408 }
1409
1410 if let Some(AttributeValue::Static(value)) = attributes.get("padding") {
1412 layout.padding = Some(parse_padding_attr(value)?);
1413 has_any = true;
1414 }
1415
1416 if let Some(AttributeValue::Static(value)) = attributes.get("spacing") {
1418 layout.spacing = Some(parse_spacing(value)?);
1419 has_any = true;
1420 }
1421
1422 if let Some(AttributeValue::Static(value)) = attributes.get("align_items") {
1424 layout.align_items = Some(parse_alignment(value)?);
1425 has_any = true;
1426 }
1427
1428 if let Some(AttributeValue::Static(value)) = attributes.get("justify_content") {
1429 layout.justify_content = Some(parse_justification(value)?);
1430 has_any = true;
1431 }
1432
1433 if let Some(AttributeValue::Static(value)) = attributes.get("align_self") {
1434 layout.align_self = Some(parse_alignment(value)?);
1435 has_any = true;
1436 }
1437
1438 if let Some(AttributeValue::Static(value)) = attributes.get("align_x") {
1440 layout.align_x = Some(parse_alignment(value)?);
1441 has_any = true;
1442 }
1443
1444 if let Some(AttributeValue::Static(value)) = attributes.get("align_y") {
1445 layout.align_y = Some(parse_alignment(value)?);
1446 has_any = true;
1447 }
1448
1449 if let Some(AttributeValue::Static(value)) = attributes.get("align") {
1451 let alignment = parse_alignment(value)?;
1452 layout.align_items = Some(alignment);
1453 layout.justify_content = Some(match alignment {
1454 crate::ir::layout::Alignment::Start => crate::ir::layout::Justification::Start,
1455 crate::ir::layout::Alignment::Center => crate::ir::layout::Justification::Center,
1456 crate::ir::layout::Alignment::End => crate::ir::layout::Justification::End,
1457 crate::ir::layout::Alignment::Stretch => crate::ir::layout::Justification::Center,
1458 });
1459 has_any = true;
1460 }
1461
1462 if let Some(AttributeValue::Static(value)) = attributes.get("direction") {
1464 layout.direction = Some(crate::ir::layout::Direction::parse(value)?);
1465 has_any = true;
1466 }
1467
1468 if !matches!(kind, WidgetKind::Tooltip)
1470 && let Some(AttributeValue::Static(value)) = attributes.get("position")
1471 {
1472 layout.position = Some(crate::ir::layout::Position::parse(value)?);
1473 has_any = true;
1474 }
1475
1476 if let Some(AttributeValue::Static(value)) = attributes.get("top") {
1478 layout.top = Some(parse_float_attr(value, "top")?);
1479 has_any = true;
1480 }
1481
1482 if let Some(AttributeValue::Static(value)) = attributes.get("right") {
1483 layout.right = Some(parse_float_attr(value, "right")?);
1484 has_any = true;
1485 }
1486
1487 if let Some(AttributeValue::Static(value)) = attributes.get("bottom") {
1488 layout.bottom = Some(parse_float_attr(value, "bottom")?);
1489 has_any = true;
1490 }
1491
1492 if let Some(AttributeValue::Static(value)) = attributes.get("left") {
1493 layout.left = Some(parse_float_attr(value, "left")?);
1494 has_any = true;
1495 }
1496
1497 if let Some(AttributeValue::Static(value)) = attributes.get("z_index") {
1499 layout.z_index = Some(parse_int_attr(value, "z_index")?);
1500 has_any = true;
1501 }
1502
1503 if has_any {
1505 layout
1506 .validate()
1507 .map_err(|e| format!("Layout validation failed: {}", e))?;
1508 Ok(Some(layout))
1509 } else {
1510 Ok(None)
1511 }
1512}
1513
1514fn parse_style_attributes(
1516 attributes: &HashMap<String, AttributeValue>,
1517) -> Result<Option<crate::ir::style::StyleProperties>, String> {
1518 use crate::parser::style_parser::{
1519 build_border, build_style_properties, parse_background_attr, parse_border_color,
1520 parse_border_radius, parse_border_style, parse_border_width, parse_color_attr,
1521 parse_opacity, parse_shadow_attr, parse_transform,
1522 };
1523
1524 let mut background = None;
1525 let mut color = None;
1526 let mut border_width = None;
1527 let mut border_color = None;
1528 let mut border_radius = None;
1529 let mut border_style = None;
1530 let mut shadow = None;
1531 let mut opacity = None;
1532 let mut transform = None;
1533 let mut has_any = false;
1534
1535 if let Some(AttributeValue::Static(value)) = attributes.get("background") {
1537 background = Some(parse_background_attr(value)?);
1538 has_any = true;
1539 }
1540
1541 if let Some(AttributeValue::Static(value)) = attributes.get("color") {
1543 color = Some(parse_color_attr(value)?);
1544 has_any = true;
1545 }
1546
1547 if let Some(AttributeValue::Static(value)) = attributes.get("border_width") {
1549 border_width = Some(parse_border_width(value)?);
1550 has_any = true;
1551 }
1552
1553 if let Some(AttributeValue::Static(value)) = attributes.get("border_color") {
1554 border_color = Some(parse_border_color(value)?);
1555 has_any = true;
1556 }
1557
1558 if let Some(AttributeValue::Static(value)) = attributes.get("border_radius") {
1559 border_radius = Some(parse_border_radius(value)?);
1560 has_any = true;
1561 }
1562
1563 if let Some(AttributeValue::Static(value)) = attributes.get("border_style") {
1564 border_style = Some(parse_border_style(value)?);
1565 has_any = true;
1566 }
1567
1568 if let Some(AttributeValue::Static(value)) = attributes.get("shadow") {
1570 shadow = Some(parse_shadow_attr(value)?);
1571 has_any = true;
1572 }
1573
1574 if let Some(AttributeValue::Static(value)) = attributes.get("opacity") {
1576 opacity = Some(parse_opacity(value)?);
1577 has_any = true;
1578 }
1579
1580 if let Some(AttributeValue::Static(value)) = attributes.get("transform") {
1582 transform = Some(parse_transform(value)?);
1583 has_any = true;
1584 }
1585
1586 if has_any {
1587 let border = build_border(border_width, border_color, border_radius, border_style)?;
1588 let style = build_style_properties(background, color, border, shadow, opacity, transform)?;
1589 Ok(Some(style))
1590 } else {
1591 Ok(None)
1592 }
1593}
1594
1595pub fn validate_no_circular_dependencies(
1608 _file_path: &std::path::Path,
1609 _visited: &mut std::collections::HashSet<std::path::PathBuf>,
1610) -> Result<(), ParseError> {
1611 Ok(())
1612}
1613
1614#[cfg(test)]
1615mod circular_dependency_tests {
1616 use super::*;
1617 use std::collections::HashSet;
1618 use std::path::PathBuf;
1619
1620 #[test]
1621 fn test_no_circular_dependencies_without_includes() {
1622 let file_path = PathBuf::from("test.dampen");
1624 let mut visited = HashSet::new();
1625
1626 let result = validate_no_circular_dependencies(&file_path, &mut visited);
1627 assert!(
1628 result.is_ok(),
1629 "Single file should have no circular dependencies"
1630 );
1631 }
1632
1633 }
1639
1640#[cfg(test)]
1641mod inline_state_styles_tests {
1642 use super::*;
1643 use crate::ir::theme::WidgetState;
1644
1645 #[test]
1646 fn test_parse_single_state_attribute() {
1647 let xml = r##"
1650 <dampen version="1.0" xmlns:hover="urn:dampen:state:hover">
1651 <button label="Click" hover:background="#ff0000" />
1652 </dampen>
1653 "##;
1654
1655 let result = parse(xml);
1656 assert!(result.is_ok(), "Should parse valid XML with hover state");
1657
1658 let doc = result.unwrap();
1659 let button = &doc.root;
1660
1661 assert!(
1663 button
1664 .inline_state_variants
1665 .contains_key(&WidgetState::Hover),
1666 "Should have hover state variant"
1667 );
1668
1669 let hover_style = button
1670 .inline_state_variants
1671 .get(&WidgetState::Hover)
1672 .unwrap();
1673
1674 assert!(
1676 hover_style.background.is_some(),
1677 "Hover state should have background"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_parse_multiple_state_attributes() {
1683 let xml = r##"
1686 <dampen version="1.0"
1687 xmlns:hover="urn:dampen:state:hover"
1688 xmlns:active="urn:dampen:state:active"
1689 xmlns:disabled="urn:dampen:state:disabled">
1690 <button
1691 label="Click"
1692 hover:background="#ff0000"
1693 active:background="#00ff00"
1694 disabled:opacity="0.5"
1695 />
1696 </dampen>
1697 "##;
1698
1699 let result = parse(xml);
1700 assert!(
1701 result.is_ok(),
1702 "Should parse valid XML with multiple states"
1703 );
1704
1705 let doc = result.unwrap();
1706 let button = &doc.root;
1707
1708 assert!(
1710 button
1711 .inline_state_variants
1712 .contains_key(&WidgetState::Hover),
1713 "Should have hover state"
1714 );
1715 assert!(
1716 button
1717 .inline_state_variants
1718 .contains_key(&WidgetState::Active),
1719 "Should have active state"
1720 );
1721 assert!(
1722 button
1723 .inline_state_variants
1724 .contains_key(&WidgetState::Disabled),
1725 "Should have disabled state"
1726 );
1727
1728 let hover_style = button
1730 .inline_state_variants
1731 .get(&WidgetState::Hover)
1732 .unwrap();
1733 assert!(
1734 hover_style.background.is_some(),
1735 "Hover state should have background"
1736 );
1737
1738 let active_style = button
1740 .inline_state_variants
1741 .get(&WidgetState::Active)
1742 .unwrap();
1743 assert!(
1744 active_style.background.is_some(),
1745 "Active state should have background"
1746 );
1747
1748 let disabled_style = button
1750 .inline_state_variants
1751 .get(&WidgetState::Disabled)
1752 .unwrap();
1753 assert!(
1754 disabled_style.opacity.is_some(),
1755 "Disabled state should have opacity"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_parse_invalid_state_prefix() {
1761 let xml = r##"
1763 <dampen version="1.0" xmlns:unknown="urn:dampen:state:unknown">
1764 <button label="Click" unknown:background="#ff0000" />
1765 </dampen>
1766 "##;
1767
1768 let result = parse(xml);
1769 assert!(
1770 result.is_ok(),
1771 "Should parse with warning for invalid state"
1772 );
1773
1774 let doc = result.unwrap();
1775 let button = &doc.root;
1776
1777 assert!(
1779 button.inline_state_variants.is_empty(),
1780 "Should have no state variants for invalid prefix"
1781 );
1782
1783 assert!(
1785 button.attributes.contains_key("unknown:background"),
1786 "Invalid state prefix should be treated as regular attribute"
1787 );
1788 }
1789}