Skip to main content

dampen_core/parser/
mod.rs

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
23/// Maximum schema version supported by this framework release.
24///
25/// Files declaring a version higher than this will be rejected with an error.
26/// Update this constant when the framework adds support for new schema versions.
27pub const MAX_SUPPORTED_VERSION: SchemaVersion = SchemaVersion { major: 1, minor: 1 };
28
29/// Parse a version string in "major.minor" format into a SchemaVersion.
30///
31/// # Arguments
32///
33/// * `version_str` - Raw version string from XML attribute (e.g., "1.0")
34/// * `span` - Source location for error reporting
35///
36/// # Returns
37///
38/// `Ok(SchemaVersion)` on success, `Err(ParseError)` for invalid formats.
39///
40/// # Examples
41///
42/// ```ignore
43/// let v = parse_version_string("1.0", span)?;
44/// assert_eq!(v.major, 1);
45/// assert_eq!(v.minor, 0);
46/// ```
47pub fn parse_version_string(version_str: &str, span: Span) -> Result<SchemaVersion, ParseError> {
48    let trimmed = version_str.trim();
49
50    // Reject empty strings
51    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    // Split on "." and validate exactly 2 parts
61    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    // Parse major version
75    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    // Parse minor version
86    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
99/// Validate that a parsed version is supported by this framework.
100///
101/// # Arguments
102///
103/// * `version` - Parsed version to validate
104/// * `span` - Source location for error reporting
105///
106/// # Returns
107///
108/// `Ok(())` if the version is supported, `Err(ParseError)` if the version
109/// is newer than `MAX_SUPPORTED_VERSION`.
110pub 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/// Warning about a widget requiring a higher schema version than declared.
135///
136/// This is non-blocking validation - widgets may work but compatibility is not guaranteed.
137#[derive(Debug, Clone, PartialEq)]
138pub struct ValidationWarning {
139    /// The widget that requires a higher version
140    pub widget_kind: WidgetKind,
141    /// The schema version declared in the document
142    pub declared_version: SchemaVersion,
143    /// The minimum version required by the widget
144    pub required_version: SchemaVersion,
145    /// Source location of the widget
146    pub span: Span,
147}
148
149impl ValidationWarning {
150    /// Format the warning as a human-readable message
151    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    /// Get a suggestion for resolving the warning
163    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
171/// Validate that all widgets in the document are compatible with the declared schema version.
172///
173/// Returns warnings (not errors) for widgets that require a higher version than declared.
174/// This is non-blocking validation to help developers identify potential compatibility issues.
175///
176/// # Arguments
177///
178/// * `document` - The parsed document to validate
179///
180/// # Returns
181///
182/// A vector of `ValidationWarning` for widgets requiring higher versions.
183/// Empty vector means all widgets are compatible with the declared version.
184///
185/// # Examples
186///
187/// ```rust
188/// use dampen_core::{parse, validate_widget_versions};
189///
190/// // Implicit document defaults to v1.0, but Canvas requires v1.1
191/// let xml = r#"<canvas width="400" height="200" program="{chart}" />"#;
192/// let doc = parse(xml).unwrap();
193/// let warnings = validate_widget_versions(&doc);
194/// assert_eq!(warnings.len(), 1); // Canvas requires v1.1
195/// ```
196pub 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
202/// Recursively validate widget tree for version compatibility
203fn 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    // Check if widget requires a higher version than declared
211    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    // Recursively check children
221    for child in &node.children {
222        validate_widget_tree(child, doc_version, warnings);
223    }
224}
225
226/// Preprocess XML to handle state attributes without XML namespaces.
227///
228/// Converts attributes like `hover:background` to `hover__state__background`
229/// to avoid "unknown namespace prefix" errors from roxmltree.
230fn 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            // We want to replace " hover:" with " hover__state__"
238            // Note: We deliberately remove the colon to make it a valid non-namespaced attribute
239            let target = format!("{}{}:", prefix, state);
240            let sub = format!("{}{}_state_", prefix, state);
241            result = result.replace(&target, &sub);
242        }
243    }
244    result
245}
246
247/// Parse XML markup into a DampenDocument.
248///
249/// This is the main entry point for the parser. It takes XML markup and
250/// converts it into the Intermediate Representation (IR) suitable for
251/// rendering or code generation.
252///
253/// # Arguments
254///
255/// * `xml` - XML markup string
256///
257/// # Returns
258///
259/// `Ok(DampenDocument)` on success, `Err(ParseError)` on failure
260///
261/// # Examples
262///
263/// ```rust
264/// use dampen_core::parse;
265///
266/// let xml = r#"<dampen><column><text value="Hello" /></column></dampen>"#;
267/// let doc = parse(xml).unwrap();
268/// assert_eq!(doc.root.children.len(), 1);
269/// ```
270///
271/// # Errors
272///
273/// Returns `ParseError` for:
274/// - Invalid XML syntax
275/// - Unknown widget elements
276/// - Invalid attribute values
277/// - Malformed binding expressions
278pub fn parse(xml: &str) -> Result<DampenDocument, ParseError> {
279    // Preprocess XML to handle state attributes
280    let processed_xml = preprocess_xml(xml);
281
282    // Parse XML using roxmltree
283    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    // Find root element (skip XML declaration)
291    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    // Check if root is <dampen> wrapper
299    let root_tag = root.tag_name().name();
300
301    if root_tag == "dampen" {
302        // Parse <dampen> document with themes and widgets
303        parse_dampen_document(root, xml)
304    } else {
305        // Parse direct widget (backward compatibility)
306        // Default to version 1.0 for backward compatibility
307        let root_widget = parse_node(root, xml)?;
308
309        // Validate nesting constraints
310        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
323/// Validate widget-specific required attributes
324fn 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            // Width and height are optional (defaulted in builder if missing)
348            // But validation ensures they are numbers if present
349            validate_numeric_range(kind, "width", attributes, span, 50..=4000)?;
350            validate_numeric_range(kind, "height", attributes, span, 50..=4000)?;
351
352            // T069: Warn if both 'program' attribute and children shapes are present
353            if attributes.contains_key("program") {
354                // We need access to children to check this.
355                // But validate_widget_attributes doesn't have children.
356                // I should probably move this check to where children are available,
357                // or change the signature.
358                // Actually, I can check this in parse_node or validate_canvas_children.
359            }
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
408/// Helper to validate date format for static value
409fn 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
438/// Helper to validate time format for static value
439fn 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
468/// Helper to validate date range (min <= max)
469fn 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
502/// Helper to require an attribute exists
503fn 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
521/// Helper to require a non-empty attribute
522fn 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
543/// Helper to validate numeric range
544fn 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
578/// Validate Tooltip widget has exactly one child
579fn 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
602/// Validate Canvas widget children (must be shapes)
603fn validate_canvas_children(
604    attributes: &HashMap<String, AttributeValue>,
605    children: &[WidgetNode],
606    span: Span,
607) -> Result<(), ParseError> {
608    // T069: Warn if both 'program' attribute and children shapes are present
609    if attributes.contains_key("program") && !children.is_empty() {
610        // This is a warning-level check, but Dampen parser usually returns Result.
611        // For now, let's treat it as a validation error or just a log?
612        // Constitution says "Prefer helpful errors with suggestions".
613        // If both are present, 'program' wins in the builder.
614        // So we should probably error to avoid confusion.
615        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
626/// Validate DatePicker/TimePicker has exactly one child
627fn 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
661/// Validate ContextMenu has exactly 2 children: underlay + menu
662fn 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
684/// Parse a single XML node into a WidgetNode
685fn parse_node(node: Node, source: &str) -> Result<WidgetNode, ParseError> {
686    // Only process element nodes
687    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    // Get element name and map to WidgetKind
697    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    // Parse attributes - separate breakpoint-prefixed and state-prefixed from regular
757    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    // Pre-scan attributes for validation
766    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        // Get full attribute name (including namespace prefix if present)
774        let name = if let Some(ns) = attr.namespace() {
775            // If attribute has a Dampen state namespace, find the prefix
776            if ns.starts_with("urn:dampen:state") {
777                // Find the namespace prefix by iterating through namespace declarations
778                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        // Check for id attribute
793        if name == "id" {
794            id = Some(value.to_string());
795            continue;
796        }
797
798        // Check for event attributes (on_click, on_change, etc.)
799        if name.starts_with("on_") {
800            let event_kind = match name.as_str() {
801                "on_click" => Some(EventKind::CanvasClick), // Prefer specific Canvas variants if they exist
802                "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            // Fallback for non-canvas widgets (or where CanvasClick isn't desired)
820            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                // Parse handler name and optional parameter
832                // Syntax: "handler_name", "handler_name:{expression}", or "handler_name:'value'"
833                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                    // Check for single-quoted string: 'value'
838                    if param_str.starts_with('\'')
839                        && param_str.ends_with('\'')
840                        && param_str.len() >= 2
841                    {
842                        let quoted_value = &param_str[1..param_str.len() - 1];
843                        // Create a static string binding expression
844                        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                        // Remove surrounding braces if present: {item.id} -> item.id
856                        let param_clean = param_str.trim_matches('{').trim_matches('}');
857
858                        // Parse parameter as binding expression
859                        match crate::expr::tokenize_binding_expr(param_clean, 0, 1, 1) {
860                            Ok(expr) => (handler, Some(expr)),
861                            Err(_) => {
862                                // If parsing fails, treat the whole string as handler name
863                                (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        // Check for breakpoint-prefixed attributes (e.g., "mobile-spacing", "tablet-width")
882        // Note: We use hyphen instead of colon to avoid XML namespace issues
883        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        // Handle preprocessed state attributes (e.g. "hover_state_background")
906        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        // If state prefix is invalid, log warning and treat as regular attribute
918        // TODO: Add proper logging when verbose mode is implemented
919
920        // Parse attribute value (check for bindings)
921        let attr_value = parse_attribute_value(value, get_span(node, source))?;
922        attributes.insert(name.to_string(), attr_value);
923    }
924
925    // Extract class attribute into classes field
926    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    // Extract theme attribute into theme_ref field (supports both static and binding)
936    let theme_ref = attributes.get("theme").cloned();
937
938    // Parse children
939    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    // Validate Tooltip has exactly one child
947    if kind == WidgetKind::Tooltip {
948        validate_tooltip_children(&children, get_span(node, source))?;
949    }
950
951    // Validate Canvas has no children (leaf widget)
952    if kind == WidgetKind::Canvas {
953        validate_canvas_children(&attributes, &children, get_span(node, source))?;
954    }
955
956    // Validate DatePicker/TimePicker has exactly one child
957    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    // Parse layout and style attributes into structured fields
966    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    // Normalize deprecated attributes to standard names (with warnings)
980    let _attr_warnings = attribute_standard::normalize_attributes(&kind, &mut attributes);
981    // TODO: Log warnings in verbose mode
982
983    // Validate widget-specific required attributes
984    validate_widget_attributes(&kind, &attributes, get_span(node, source))?;
985
986    // Convert inline_state_variants from HashMap<WidgetState, HashMap<String, AttributeValue>>
987    // to HashMap<WidgetState, StyleProperties>
988    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
1016/// Parse a `<dampen>` document with themes and widgets
1017fn 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    // Parse version attribute from <dampen> root element
1025    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        // Default to version 1.0 for backward compatibility
1032        SchemaVersion::default()
1033    };
1034
1035    // Iterate through children of <dampen>
1036    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                // Parse themes section
1046                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                // Parse style classes
1062                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                // Set global theme reference
1076                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                // This should be a widget - parse as root
1087                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    // Ensure we have a root widget, or provide a default if themes are present
1101    let root_widget = if let Some(w) = root_widget {
1102        w
1103    } else if !themes.is_empty() || !style_classes.is_empty() {
1104        // Create an empty default container if this is a theme/style-only file
1105        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    // T098: Enforce strict version validation
1116    // Widgets requiring a newer schema version than declared must be rejected
1117    validate_widget_versions_strict(&root_widget, &version)?;
1118
1119    // Enforce nesting constraints (e.g. DataColumn must be inside DataTable)
1120    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
1132/// Recursively validate widget nesting constraints
1133fn validate_nesting_constraints(
1134    node: &WidgetNode,
1135    parent_kind: Option<&WidgetKind>,
1136) -> Result<(), ParseError> {
1137    // Rule: DataColumn must be inside DataTable
1138    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    // Rule: Tab must be inside TabBar
1148    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    // Rule: TabBar must contain only Tab children
1158    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    // Recurse
1172    for child in &node.children {
1173        validate_nesting_constraints(child, Some(&node.kind))?;
1174    }
1175
1176    Ok(())
1177}
1178
1179/// Recursively validate widget versions and return an error on mismatch.
1180fn 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
1212/// Parse comma-separated list into `Vec<String>`
1213pub 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
1221/// Parse a simple enum value (case-insensitive) and return the matched variant
1222pub 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
1245/// Parse attribute value, detecting binding expressions
1246fn parse_attribute_value(value: &str, span: Span) -> Result<AttributeValue, ParseError> {
1247    // Check if value contains binding syntax {expr}
1248    if value.contains('{') && value.contains('}') {
1249        // Parse interpolated parts
1250        let mut parts = Vec::new();
1251        let mut remaining = value;
1252
1253        while let Some(start_pos) = remaining.find('{') {
1254            // Add literal before {
1255            if start_pos > 0 {
1256                parts.push(InterpolatedPart::Literal(
1257                    remaining[..start_pos].to_string(),
1258                ));
1259            }
1260
1261            // Find closing }
1262            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                // Parse the expression
1268                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                // Move past the }
1289                remaining = &remaining[expr_end + 1..];
1290            } else {
1291                // No closing }, treat rest as literal
1292                parts.push(InterpolatedPart::Literal(remaining.to_string()));
1293                break;
1294            }
1295        }
1296
1297        // Add remaining literal
1298        if !remaining.is_empty() {
1299            parts.push(InterpolatedPart::Literal(remaining.to_string()));
1300        }
1301
1302        // If only one binding with no literals, return Binding
1303        // If multiple parts, return Interpolated
1304        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    // Static value
1319    Ok(AttributeValue::Static(value.to_string()))
1320}
1321
1322/// Extract span information from roxmltree node
1323fn get_span(node: Node, source: &str) -> Span {
1324    let range = node.range();
1325
1326    // Calculate line and column from byte offset
1327    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
1337/// Calculate line and column from byte offset
1338///
1339/// Optimized to stop early once the target offset is reached.
1340fn 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
1363/// Parse layout-related attributes from the attributes map
1364fn 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    // Parse width
1378    if let Some(AttributeValue::Static(value)) = attributes.get("width") {
1379        layout.width = Some(parse_length_attr(value)?);
1380        has_any = true;
1381    }
1382
1383    // Parse height
1384    if let Some(AttributeValue::Static(value)) = attributes.get("height") {
1385        layout.height = Some(parse_length_attr(value)?);
1386        has_any = true;
1387    }
1388
1389    // Parse min/max constraints
1390    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    // Parse padding
1411    if let Some(AttributeValue::Static(value)) = attributes.get("padding") {
1412        layout.padding = Some(parse_padding_attr(value)?);
1413        has_any = true;
1414    }
1415
1416    // Parse spacing
1417    if let Some(AttributeValue::Static(value)) = attributes.get("spacing") {
1418        layout.spacing = Some(parse_spacing(value)?);
1419        has_any = true;
1420    }
1421
1422    // Parse alignment
1423    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    // Parse direct alignment (align_x, align_y)
1439    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    // Parse align shorthand (sets both align_items and justify_content)
1450    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    // Parse direction
1463    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    // Parse position (skip for Tooltip - it has its own position attribute)
1469    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    // Parse position offsets
1477    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    // Parse z-index
1498    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    // Validate the layout
1504    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
1514/// Parse style-related attributes from the attributes map
1515fn 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    // Parse background
1536    if let Some(AttributeValue::Static(value)) = attributes.get("background") {
1537        background = Some(parse_background_attr(value)?);
1538        has_any = true;
1539    }
1540
1541    // Parse color
1542    if let Some(AttributeValue::Static(value)) = attributes.get("color") {
1543        color = Some(parse_color_attr(value)?);
1544        has_any = true;
1545    }
1546
1547    // Parse border attributes
1548    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    // Parse shadow
1569    if let Some(AttributeValue::Static(value)) = attributes.get("shadow") {
1570        shadow = Some(parse_shadow_attr(value)?);
1571        has_any = true;
1572    }
1573
1574    // Parse opacity
1575    if let Some(AttributeValue::Static(value)) = attributes.get("opacity") {
1576        opacity = Some(parse_opacity(value)?);
1577        has_any = true;
1578    }
1579
1580    // Parse transform
1581    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
1595/// Validates that there are no circular dependencies in UI file includes.
1596///
1597/// **Feature T125 - Not Yet Implemented**
1598///
1599/// Currently, Dampen does not support file includes/imports in XML.
1600/// This function is a placeholder that will be implemented when UI file
1601/// composition is added.
1602///
1603/// # Returns
1604///
1605/// Currently returns `Ok(())` since includes are not supported.
1606/// Once implemented, returns `Err(ParseError)` for circular dependencies.
1607pub 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        // T125: Validate that single files have no circular dependencies
1623        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    // Future tests when includes are supported:
1634    // - test_detect_simple_circular_dependency: A -> B -> A
1635    // - test_detect_complex_circular_dependency: A -> B -> C -> D -> B
1636    // - test_allow_diamond_dependencies: A->B, A->C, B->D, C->D (this is OK, not circular)
1637    // - test_self_include_rejected: A -> A
1638}
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        // T011: Parse button with single hover:background state attribute
1648        // Note: XML requires namespace declaration for colons in attribute names
1649        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        // Verify inline_state_variants contains hover state
1662        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        // Verify hover background color is red
1675        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        // T012: Parse button with multiple state attributes
1684        // Note: Each state needs its own unique namespace URI to avoid attribute conflicts
1685        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        // Verify all three state variants exist
1709        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        // Verify hover has background
1729        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        // Verify active has background
1739        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        // Verify disabled has opacity
1749        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        // T013: Parse button with invalid state prefix should treat as regular attribute
1762        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        // Verify inline_state_variants is empty (invalid prefix ignored)
1778        assert!(
1779            button.inline_state_variants.is_empty(),
1780            "Should have no state variants for invalid prefix"
1781        );
1782
1783        // Verify unknown:background is treated as regular attribute
1784        assert!(
1785            button.attributes.contains_key("unknown:background"),
1786            "Invalid state prefix should be treated as regular attribute"
1787        );
1788    }
1789}