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        "template" => WidgetKind::Custom("template".to_string()),
739        "for" => WidgetKind::For,
740        "if" => WidgetKind::If,
741        unknown => {
742            return Err(ParseError {
743                kind: ParseErrorKind::UnknownWidget,
744                message: format!("Unknown widget: {}", unknown),
745                span: get_span(node, source),
746                suggestion: Some(format!(
747                    "Valid widgets are: {}",
748                    WidgetKind::all_standard().join(", ")
749                )),
750            });
751        }
752    };
753
754    // Parse attributes - separate breakpoint-prefixed and state-prefixed from regular
755    let mut attributes = std::collections::HashMap::new();
756    let mut breakpoint_attributes: HashMap<Breakpoint, HashMap<String, AttributeValue>> =
757        HashMap::new();
758    let mut inline_state_variants: HashMap<WidgetState, HashMap<String, AttributeValue>> =
759        HashMap::new();
760    let mut events = Vec::new();
761    let mut id = None;
762
763    // Pre-scan attributes for validation
764    for attr in node.attributes() {
765        if kind == WidgetKind::ColorPicker && attr.name() == "value" {
766            color_validator::validate_color_format(attr.value(), get_span(node, source))?;
767        }
768    }
769
770    for attr in node.attributes() {
771        // Get full attribute name (including namespace prefix if present)
772        let name = if let Some(ns) = attr.namespace() {
773            // If attribute has a Dampen state namespace, find the prefix
774            if ns.starts_with("urn:dampen:state") {
775                // Find the namespace prefix by iterating through namespace declarations
776                let prefix = node
777                    .namespaces()
778                    .find(|n| n.uri() == ns)
779                    .and_then(|n| n.name())
780                    .unwrap_or("");
781                format!("{}:{}", prefix, attr.name())
782            } else {
783                attr.name().to_string()
784            }
785        } else {
786            attr.name().to_string()
787        };
788        let value = attr.value();
789
790        // Check for id attribute
791        if name == "id" {
792            id = Some(value.to_string());
793            continue;
794        }
795
796        // Check for event attributes (on_click, on_change, etc.)
797        if name.starts_with("on_") {
798            let event_kind = match name.as_str() {
799                "on_click" => Some(EventKind::CanvasClick), // Prefer specific Canvas variants if they exist
800                "on_press" => Some(EventKind::Press),
801                "on_release" => Some(EventKind::CanvasRelease),
802                "on_drag" => Some(EventKind::CanvasDrag),
803                "on_move" => Some(EventKind::CanvasMove),
804                "on_change" => Some(EventKind::Change),
805                "on_input" => Some(EventKind::Input),
806                "on_submit" => Some(EventKind::Submit),
807                "on_select" => Some(EventKind::Select),
808                "on_toggle" => Some(EventKind::Toggle),
809                "on_scroll" => Some(EventKind::Scroll),
810                "on_cancel" => Some(EventKind::Cancel),
811                "on_open" => Some(EventKind::Open),
812                "on_close" => Some(EventKind::Close),
813                "on_row_click" => Some(EventKind::RowClick),
814                _ => None,
815            };
816
817            // Fallback for non-canvas widgets (or where CanvasClick isn't desired)
818            let event_kind = if kind != WidgetKind::Canvas {
819                match name.as_str() {
820                    "on_click" => Some(EventKind::Click),
821                    "on_release" => Some(EventKind::Release),
822                    _ => event_kind,
823                }
824            } else {
825                event_kind
826            };
827
828            if let Some(event) = event_kind {
829                // Parse handler name and optional parameter
830                // Syntax: "handler_name", "handler_name:{expression}", or "handler_name:'value'"
831                let (handler_name, param) = if let Some(colon_pos) = value.find(':') {
832                    let handler = value[..colon_pos].to_string();
833                    let param_str = &value[colon_pos + 1..];
834
835                    // Check for single-quoted string: 'value'
836                    if param_str.starts_with('\'')
837                        && param_str.ends_with('\'')
838                        && param_str.len() >= 2
839                    {
840                        let quoted_value = &param_str[1..param_str.len() - 1];
841                        // Create a static string binding expression
842                        let expr = BindingExpr {
843                            expr: Expr::Literal(LiteralExpr::String(quoted_value.to_string())),
844                            span: Span::new(
845                                colon_pos + 1,
846                                colon_pos + 1 + param_str.len(),
847                                1,
848                                colon_pos as u32 + 1,
849                            ),
850                        };
851                        (handler, Some(expr))
852                    } else {
853                        // Remove surrounding braces if present: {item.id} -> item.id
854                        let param_clean = param_str.trim_matches('{').trim_matches('}');
855
856                        // Parse parameter as binding expression
857                        match crate::expr::tokenize_binding_expr(param_clean, 0, 1, 1) {
858                            Ok(expr) => (handler, Some(expr)),
859                            Err(_) => {
860                                // If parsing fails, treat the whole string as handler name
861                                (value.to_string(), None)
862                            }
863                        }
864                    }
865                } else {
866                    (value.to_string(), None)
867                };
868
869                events.push(EventBinding {
870                    event,
871                    handler: handler_name,
872                    param,
873                    span: get_span(node, source),
874                });
875                continue;
876            }
877        }
878
879        // Check for breakpoint-prefixed attributes (e.g., "mobile-spacing", "tablet-width")
880        // Note: We use hyphen instead of colon to avoid XML namespace issues
881        if let Some((prefix, attr_name)) = name.split_once('-')
882            && let Ok(breakpoint) = crate::ir::layout::Breakpoint::parse(prefix)
883        {
884            let attr_value = parse_attribute_value(value, get_span(node, source))?;
885            breakpoint_attributes
886                .entry(breakpoint)
887                .or_default()
888                .insert(attr_name.to_string(), attr_value);
889            continue;
890        }
891
892        if let Some((state_prefix, attr_name)) = name.split_once(':')
893            && let Some(state) = WidgetState::from_prefix(state_prefix)
894        {
895            let attr_value = parse_attribute_value(value, get_span(node, source))?;
896            inline_state_variants
897                .entry(state)
898                .or_default()
899                .insert(attr_name.to_string(), attr_value);
900            continue;
901        }
902
903        // Handle preprocessed state attributes (e.g. "hover_state_background")
904        if let Some((state_prefix, attr_name)) = name.split_once("_state_")
905            && let Some(state) = WidgetState::from_prefix(state_prefix)
906        {
907            let attr_value = parse_attribute_value(value, get_span(node, source))?;
908            inline_state_variants
909                .entry(state)
910                .or_default()
911                .insert(attr_name.to_string(), attr_value);
912            continue;
913        }
914
915        // If state prefix is invalid, log warning and treat as regular attribute
916        // TODO: Add proper logging when verbose mode is implemented
917
918        // Parse attribute value (check for bindings)
919        let attr_value = parse_attribute_value(value, get_span(node, source))?;
920        attributes.insert(name.to_string(), attr_value);
921    }
922
923    // Extract class attribute into classes field
924    let classes = if let Some(AttributeValue::Static(class_attr)) = attributes.get("class") {
925        class_attr
926            .split_whitespace()
927            .map(|s| s.to_string())
928            .collect()
929    } else {
930        Vec::new()
931    };
932
933    // Extract theme attribute into theme_ref field (supports both static and binding)
934    let theme_ref = attributes.get("theme").cloned();
935
936    // Parse children
937    let mut children = Vec::new();
938    for child in node.children() {
939        if child.node_type() == NodeType::Element {
940            children.push(parse_node(child, source)?);
941        }
942    }
943
944    // Validate Tooltip has exactly one child
945    if kind == WidgetKind::Tooltip {
946        validate_tooltip_children(&children, get_span(node, source))?;
947    }
948
949    // Validate Canvas has no children (leaf widget)
950    if kind == WidgetKind::Canvas {
951        validate_canvas_children(&attributes, &children, get_span(node, source))?;
952    }
953
954    // Validate DatePicker/TimePicker has exactly one child
955    if matches!(kind, WidgetKind::DatePicker | WidgetKind::TimePicker) {
956        validate_datetime_picker_children(&kind, &children, get_span(node, source))?;
957    }
958
959    if kind == WidgetKind::ContextMenu {
960        validate_context_menu_children(&children, get_span(node, source))?;
961    }
962
963    // Parse layout and style attributes into structured fields
964    let layout = parse_layout_attributes(&kind, &attributes).map_err(|e| ParseError {
965        kind: ParseErrorKind::InvalidValue,
966        message: e,
967        span: get_span(node, source),
968        suggestion: None,
969    })?;
970    let style = parse_style_attributes(&attributes).map_err(|e| ParseError {
971        kind: ParseErrorKind::InvalidValue,
972        message: e,
973        span: get_span(node, source),
974        suggestion: None,
975    })?;
976
977    // Normalize deprecated attributes to standard names (with warnings)
978    let _attr_warnings = attribute_standard::normalize_attributes(&kind, &mut attributes);
979    // TODO: Log warnings in verbose mode
980
981    // Validate widget-specific required attributes
982    validate_widget_attributes(&kind, &attributes, get_span(node, source))?;
983
984    // Convert inline_state_variants from HashMap<WidgetState, HashMap<String, AttributeValue>>
985    // to HashMap<WidgetState, StyleProperties>
986    let mut final_state_variants: HashMap<WidgetState, StyleProperties> = HashMap::new();
987    for (state, state_attrs) in inline_state_variants {
988        if let Some(state_style) = parse_style_attributes(&state_attrs).map_err(|e| ParseError {
989            kind: ParseErrorKind::InvalidValue,
990            message: format!("Invalid style in {:?} state: {}", state, e),
991            span: get_span(node, source),
992            suggestion: None,
993        })? {
994            final_state_variants.insert(state, state_style);
995        }
996    }
997
998    Ok(WidgetNode {
999        kind,
1000        id,
1001        attributes,
1002        events,
1003        children,
1004        span: get_span(node, source),
1005        style,
1006        layout,
1007        theme_ref,
1008        classes,
1009        breakpoint_attributes,
1010        inline_state_variants: final_state_variants,
1011    })
1012}
1013
1014/// Parse a `<dampen>` document with themes and widgets
1015fn parse_dampen_document(root: Node, source: &str) -> Result<DampenDocument, ParseError> {
1016    let mut themes = HashMap::new();
1017    let mut style_classes = HashMap::new();
1018    let mut root_widget = None;
1019    let mut global_theme = None;
1020    let mut follow_system = true;
1021
1022    // Parse version attribute from <dampen> root element
1023    let span = get_span(root, source);
1024    let version = if let Some(version_attr) = root.attribute("version") {
1025        let parsed = parse_version_string(version_attr, span)?;
1026        validate_version_supported(&parsed, span)?;
1027        parsed
1028    } else {
1029        // Default to version 1.0 for backward compatibility
1030        SchemaVersion::default()
1031    };
1032
1033    // Iterate through children of <dampen>
1034    for child in root.children() {
1035        if child.node_type() != NodeType::Element {
1036            continue;
1037        }
1038
1039        let tag_name = child.tag_name().name();
1040
1041        match tag_name {
1042            "themes" => {
1043                // Parse themes section
1044                for theme_node in child.children() {
1045                    if theme_node.node_type() == NodeType::Element
1046                        && theme_node.tag_name().name() == "theme"
1047                    {
1048                        let theme =
1049                            crate::parser::theme_parser::parse_theme_from_node(theme_node, source)?;
1050                        let name = theme_node
1051                            .attribute("name")
1052                            .map(|s| s.to_string())
1053                            .unwrap_or_else(|| "default".to_string());
1054                        themes.insert(name, theme);
1055                    }
1056                }
1057            }
1058            "style_classes" | "classes" | "styles" => {
1059                // Parse style classes
1060                for class_node in child.children() {
1061                    if class_node.node_type() == NodeType::Element {
1062                        let tag = class_node.tag_name().name();
1063                        if tag == "class" || tag == "style" {
1064                            let class = crate::parser::theme_parser::parse_style_class_from_node(
1065                                class_node, source,
1066                            )?;
1067                            style_classes.insert(class.name.clone(), class);
1068                        }
1069                    }
1070                }
1071            }
1072            "global_theme" | "default_theme" => {
1073                // Set global theme reference
1074                if let Some(theme_name) = child.attribute("name") {
1075                    global_theme = Some(theme_name.to_string());
1076                }
1077            }
1078            "follow_system" => {
1079                if let Some(enabled) = child.attribute("enabled") {
1080                    follow_system = enabled.parse::<bool>().unwrap_or(true);
1081                }
1082            }
1083            _ => {
1084                // This should be a widget - parse as root
1085                if root_widget.is_some() {
1086                    return Err(ParseError {
1087                        kind: ParseErrorKind::XmlSyntax,
1088                        message: "Multiple root widgets found in <dampen>".to_string(),
1089                        span: get_span(child, source),
1090                        suggestion: Some("Only one root widget is allowed".to_string()),
1091                    });
1092                }
1093                root_widget = Some(parse_node(child, source)?);
1094            }
1095        }
1096    }
1097
1098    // Ensure we have a root widget, or provide a default if themes are present
1099    let root_widget = if let Some(w) = root_widget {
1100        w
1101    } else if !themes.is_empty() || !style_classes.is_empty() {
1102        // Create an empty default container if this is a theme/style-only file
1103        WidgetNode::default()
1104    } else {
1105        return Err(ParseError {
1106            kind: ParseErrorKind::XmlSyntax,
1107            message: "No root widget found in <dampen>".to_string(),
1108            span: get_span(root, source),
1109            suggestion: Some("Add a widget like <column> or <row> inside <dampen>".to_string()),
1110        });
1111    };
1112
1113    // T098: Enforce strict version validation
1114    // Widgets requiring a newer schema version than declared must be rejected
1115    validate_widget_versions_strict(&root_widget, &version)?;
1116
1117    // Enforce nesting constraints (e.g. DataColumn must be inside DataTable)
1118    validate_nesting_constraints(&root_widget, None)?;
1119
1120    Ok(DampenDocument {
1121        version,
1122        root: root_widget,
1123        themes,
1124        style_classes,
1125        global_theme,
1126        follow_system,
1127    })
1128}
1129
1130/// Recursively validate widget nesting constraints
1131fn validate_nesting_constraints(
1132    node: &WidgetNode,
1133    parent_kind: Option<&WidgetKind>,
1134) -> Result<(), ParseError> {
1135    // Rule: DataColumn must be inside DataTable
1136    if node.kind == WidgetKind::DataColumn && parent_kind != Some(&WidgetKind::DataTable) {
1137        return Err(ParseError {
1138            kind: ParseErrorKind::InvalidChild,
1139            message: "DataColumn must be a direct child of DataTable".to_string(),
1140            span: node.span,
1141            suggestion: Some("Wrap this column in a <data_table>".to_string()),
1142        });
1143    }
1144
1145    // Recurse
1146    for child in &node.children {
1147        validate_nesting_constraints(child, Some(&node.kind))?;
1148    }
1149
1150    Ok(())
1151}
1152
1153/// Recursively validate widget versions and return an error on mismatch.
1154fn validate_widget_versions_strict(
1155    node: &WidgetNode,
1156    doc_version: &SchemaVersion,
1157) -> Result<(), ParseError> {
1158    let min_version = node.kind.minimum_version();
1159
1160    if (min_version.major, min_version.minor) > (doc_version.major, doc_version.minor) {
1161        return Err(ParseError {
1162            kind: ParseErrorKind::UnsupportedVersion,
1163            message: format!(
1164                "Widget '{}' requires schema v{}.{} but document declares v{}.{}",
1165                node.kind,
1166                min_version.major,
1167                min_version.minor,
1168                doc_version.major,
1169                doc_version.minor
1170            ),
1171            span: node.span,
1172            suggestion: Some(format!(
1173                "Update to <dampen version=\"{}.{}\"> or remove this widget",
1174                min_version.major, min_version.minor
1175            )),
1176        });
1177    }
1178
1179    for child in &node.children {
1180        validate_widget_versions_strict(child, doc_version)?;
1181    }
1182
1183    Ok(())
1184}
1185
1186/// Parse comma-separated list into `Vec<String>`
1187pub fn parse_comma_separated(value: &str) -> Vec<String> {
1188    value
1189        .split(',')
1190        .map(|s| s.trim().to_string())
1191        .filter(|s| !s.is_empty())
1192        .collect()
1193}
1194
1195/// Parse a simple enum value (case-insensitive) and return the matched variant
1196pub fn parse_enum_value<T>(value: &str, valid_variants: &[&str]) -> Result<T, String>
1197where
1198    T: std::str::FromStr + std::fmt::Display,
1199{
1200    let normalized = value.trim().to_lowercase();
1201    for variant in valid_variants.iter() {
1202        if variant.to_lowercase() == normalized {
1203            return T::from_str(variant).map_err(|_| {
1204                format!(
1205                    "Failed to parse '{}' as {}",
1206                    variant,
1207                    std::any::type_name::<T>()
1208                )
1209            });
1210        }
1211    }
1212    Err(format!(
1213        "Invalid value '{}'. Valid options: {}",
1214        value,
1215        valid_variants.join(", ")
1216    ))
1217}
1218
1219/// Parse attribute value, detecting binding expressions
1220fn parse_attribute_value(value: &str, span: Span) -> Result<AttributeValue, ParseError> {
1221    // Check if value contains binding syntax {expr}
1222    if value.contains('{') && value.contains('}') {
1223        // Parse interpolated parts
1224        let mut parts = Vec::new();
1225        let mut remaining = value;
1226
1227        while let Some(start_pos) = remaining.find('{') {
1228            // Add literal before {
1229            if start_pos > 0 {
1230                parts.push(InterpolatedPart::Literal(
1231                    remaining[..start_pos].to_string(),
1232                ));
1233            }
1234
1235            // Find closing }
1236            if let Some(end_pos) = remaining[start_pos..].find('}') {
1237                let expr_start = start_pos + 1;
1238                let expr_end = start_pos + end_pos;
1239                let expr_str = &remaining[expr_start..expr_end];
1240
1241                // Parse the expression
1242                let binding_expr = tokenize_binding_expr(
1243                    expr_str,
1244                    span.start + expr_start,
1245                    span.line,
1246                    span.column + expr_start as u32,
1247                )
1248                .map_err(|e| ParseError {
1249                    kind: ParseErrorKind::InvalidExpression,
1250                    message: format!("Invalid expression: {}", e),
1251                    span: Span::new(
1252                        span.start + expr_start,
1253                        span.start + expr_end,
1254                        span.line,
1255                        span.column + expr_start as u32,
1256                    ),
1257                    suggestion: None,
1258                })?;
1259
1260                parts.push(InterpolatedPart::Binding(binding_expr));
1261
1262                // Move past the }
1263                remaining = &remaining[expr_end + 1..];
1264            } else {
1265                // No closing }, treat rest as literal
1266                parts.push(InterpolatedPart::Literal(remaining.to_string()));
1267                break;
1268            }
1269        }
1270
1271        // Add remaining literal
1272        if !remaining.is_empty() {
1273            parts.push(InterpolatedPart::Literal(remaining.to_string()));
1274        }
1275
1276        // If only one binding with no literals, return Binding
1277        // If multiple parts, return Interpolated
1278        if parts.len() == 1 {
1279            match &parts[0] {
1280                InterpolatedPart::Binding(expr) => {
1281                    return Ok(AttributeValue::Binding(expr.clone()));
1282                }
1283                InterpolatedPart::Literal(lit) => {
1284                    return Ok(AttributeValue::Static(lit.clone()));
1285                }
1286            }
1287        } else {
1288            return Ok(AttributeValue::Interpolated(parts));
1289        }
1290    }
1291
1292    // Static value
1293    Ok(AttributeValue::Static(value.to_string()))
1294}
1295
1296/// Extract span information from roxmltree node
1297fn get_span(node: Node, source: &str) -> Span {
1298    let range = node.range();
1299
1300    // Calculate line and column from byte offset
1301    let (line, col) = calculate_line_col(source, range.start);
1302
1303    Span {
1304        start: range.start,
1305        end: range.end,
1306        line,
1307        column: col,
1308    }
1309}
1310
1311/// Calculate line and column from byte offset
1312///
1313/// Optimized to stop early once the target offset is reached.
1314fn calculate_line_col(source: &str, offset: usize) -> (u32, u32) {
1315    if offset == 0 {
1316        return (1, 1);
1317    }
1318
1319    let mut line = 1;
1320    let mut col = 1;
1321
1322    for (i, c) in source.char_indices().take(offset.saturating_add(1)) {
1323        if i >= offset {
1324            break;
1325        }
1326        if c == '\n' {
1327            line += 1;
1328            col = 1;
1329        } else {
1330            col += 1;
1331        }
1332    }
1333
1334    (line, col)
1335}
1336
1337/// Parse layout-related attributes from the attributes map
1338fn parse_layout_attributes(
1339    kind: &WidgetKind,
1340    attributes: &HashMap<String, AttributeValue>,
1341) -> Result<Option<crate::ir::layout::LayoutConstraints>, String> {
1342    use crate::ir::layout::LayoutConstraints;
1343    use crate::parser::style_parser::{
1344        parse_alignment, parse_constraint, parse_float_attr, parse_int_attr, parse_justification,
1345        parse_length_attr, parse_padding_attr, parse_spacing,
1346    };
1347
1348    let mut layout = LayoutConstraints::default();
1349    let mut has_any = false;
1350
1351    // Parse width
1352    if let Some(AttributeValue::Static(value)) = attributes.get("width") {
1353        layout.width = Some(parse_length_attr(value)?);
1354        has_any = true;
1355    }
1356
1357    // Parse height
1358    if let Some(AttributeValue::Static(value)) = attributes.get("height") {
1359        layout.height = Some(parse_length_attr(value)?);
1360        has_any = true;
1361    }
1362
1363    // Parse min/max constraints
1364    if let Some(AttributeValue::Static(value)) = attributes.get("min_width") {
1365        layout.min_width = Some(parse_constraint(value)?);
1366        has_any = true;
1367    }
1368
1369    if let Some(AttributeValue::Static(value)) = attributes.get("max_width") {
1370        layout.max_width = Some(parse_constraint(value)?);
1371        has_any = true;
1372    }
1373
1374    if let Some(AttributeValue::Static(value)) = attributes.get("min_height") {
1375        layout.min_height = Some(parse_constraint(value)?);
1376        has_any = true;
1377    }
1378
1379    if let Some(AttributeValue::Static(value)) = attributes.get("max_height") {
1380        layout.max_height = Some(parse_constraint(value)?);
1381        has_any = true;
1382    }
1383
1384    // Parse padding
1385    if let Some(AttributeValue::Static(value)) = attributes.get("padding") {
1386        layout.padding = Some(parse_padding_attr(value)?);
1387        has_any = true;
1388    }
1389
1390    // Parse spacing
1391    if let Some(AttributeValue::Static(value)) = attributes.get("spacing") {
1392        layout.spacing = Some(parse_spacing(value)?);
1393        has_any = true;
1394    }
1395
1396    // Parse alignment
1397    if let Some(AttributeValue::Static(value)) = attributes.get("align_items") {
1398        layout.align_items = Some(parse_alignment(value)?);
1399        has_any = true;
1400    }
1401
1402    if let Some(AttributeValue::Static(value)) = attributes.get("justify_content") {
1403        layout.justify_content = Some(parse_justification(value)?);
1404        has_any = true;
1405    }
1406
1407    if let Some(AttributeValue::Static(value)) = attributes.get("align_self") {
1408        layout.align_self = Some(parse_alignment(value)?);
1409        has_any = true;
1410    }
1411
1412    // Parse direct alignment (align_x, align_y)
1413    if let Some(AttributeValue::Static(value)) = attributes.get("align_x") {
1414        layout.align_x = Some(parse_alignment(value)?);
1415        has_any = true;
1416    }
1417
1418    if let Some(AttributeValue::Static(value)) = attributes.get("align_y") {
1419        layout.align_y = Some(parse_alignment(value)?);
1420        has_any = true;
1421    }
1422
1423    // Parse align shorthand (sets both align_items and justify_content)
1424    if let Some(AttributeValue::Static(value)) = attributes.get("align") {
1425        let alignment = parse_alignment(value)?;
1426        layout.align_items = Some(alignment);
1427        layout.justify_content = Some(match alignment {
1428            crate::ir::layout::Alignment::Start => crate::ir::layout::Justification::Start,
1429            crate::ir::layout::Alignment::Center => crate::ir::layout::Justification::Center,
1430            crate::ir::layout::Alignment::End => crate::ir::layout::Justification::End,
1431            crate::ir::layout::Alignment::Stretch => crate::ir::layout::Justification::Center,
1432        });
1433        has_any = true;
1434    }
1435
1436    // Parse direction
1437    if let Some(AttributeValue::Static(value)) = attributes.get("direction") {
1438        layout.direction = Some(crate::ir::layout::Direction::parse(value)?);
1439        has_any = true;
1440    }
1441
1442    // Parse position (skip for Tooltip - it has its own position attribute)
1443    if !matches!(kind, WidgetKind::Tooltip)
1444        && let Some(AttributeValue::Static(value)) = attributes.get("position")
1445    {
1446        layout.position = Some(crate::ir::layout::Position::parse(value)?);
1447        has_any = true;
1448    }
1449
1450    // Parse position offsets
1451    if let Some(AttributeValue::Static(value)) = attributes.get("top") {
1452        layout.top = Some(parse_float_attr(value, "top")?);
1453        has_any = true;
1454    }
1455
1456    if let Some(AttributeValue::Static(value)) = attributes.get("right") {
1457        layout.right = Some(parse_float_attr(value, "right")?);
1458        has_any = true;
1459    }
1460
1461    if let Some(AttributeValue::Static(value)) = attributes.get("bottom") {
1462        layout.bottom = Some(parse_float_attr(value, "bottom")?);
1463        has_any = true;
1464    }
1465
1466    if let Some(AttributeValue::Static(value)) = attributes.get("left") {
1467        layout.left = Some(parse_float_attr(value, "left")?);
1468        has_any = true;
1469    }
1470
1471    // Parse z-index
1472    if let Some(AttributeValue::Static(value)) = attributes.get("z_index") {
1473        layout.z_index = Some(parse_int_attr(value, "z_index")?);
1474        has_any = true;
1475    }
1476
1477    // Validate the layout
1478    if has_any {
1479        layout
1480            .validate()
1481            .map_err(|e| format!("Layout validation failed: {}", e))?;
1482        Ok(Some(layout))
1483    } else {
1484        Ok(None)
1485    }
1486}
1487
1488/// Parse style-related attributes from the attributes map
1489fn parse_style_attributes(
1490    attributes: &HashMap<String, AttributeValue>,
1491) -> Result<Option<crate::ir::style::StyleProperties>, String> {
1492    use crate::parser::style_parser::{
1493        build_border, build_style_properties, parse_background_attr, parse_border_color,
1494        parse_border_radius, parse_border_style, parse_border_width, parse_color_attr,
1495        parse_opacity, parse_shadow_attr, parse_transform,
1496    };
1497
1498    let mut background = None;
1499    let mut color = None;
1500    let mut border_width = None;
1501    let mut border_color = None;
1502    let mut border_radius = None;
1503    let mut border_style = None;
1504    let mut shadow = None;
1505    let mut opacity = None;
1506    let mut transform = None;
1507    let mut has_any = false;
1508
1509    // Parse background
1510    if let Some(AttributeValue::Static(value)) = attributes.get("background") {
1511        background = Some(parse_background_attr(value)?);
1512        has_any = true;
1513    }
1514
1515    // Parse color
1516    if let Some(AttributeValue::Static(value)) = attributes.get("color") {
1517        color = Some(parse_color_attr(value)?);
1518        has_any = true;
1519    }
1520
1521    // Parse border attributes
1522    if let Some(AttributeValue::Static(value)) = attributes.get("border_width") {
1523        border_width = Some(parse_border_width(value)?);
1524        has_any = true;
1525    }
1526
1527    if let Some(AttributeValue::Static(value)) = attributes.get("border_color") {
1528        border_color = Some(parse_border_color(value)?);
1529        has_any = true;
1530    }
1531
1532    if let Some(AttributeValue::Static(value)) = attributes.get("border_radius") {
1533        border_radius = Some(parse_border_radius(value)?);
1534        has_any = true;
1535    }
1536
1537    if let Some(AttributeValue::Static(value)) = attributes.get("border_style") {
1538        border_style = Some(parse_border_style(value)?);
1539        has_any = true;
1540    }
1541
1542    // Parse shadow
1543    if let Some(AttributeValue::Static(value)) = attributes.get("shadow") {
1544        shadow = Some(parse_shadow_attr(value)?);
1545        has_any = true;
1546    }
1547
1548    // Parse opacity
1549    if let Some(AttributeValue::Static(value)) = attributes.get("opacity") {
1550        opacity = Some(parse_opacity(value)?);
1551        has_any = true;
1552    }
1553
1554    // Parse transform
1555    if let Some(AttributeValue::Static(value)) = attributes.get("transform") {
1556        transform = Some(parse_transform(value)?);
1557        has_any = true;
1558    }
1559
1560    if has_any {
1561        let border = build_border(border_width, border_color, border_radius, border_style)?;
1562        let style = build_style_properties(background, color, border, shadow, opacity, transform)?;
1563        Ok(Some(style))
1564    } else {
1565        Ok(None)
1566    }
1567}
1568
1569/// Validates that there are no circular dependencies in UI file includes.
1570///
1571/// **Feature T125 - Not Yet Implemented**
1572///
1573/// Currently, Dampen does not support file includes/imports in XML.
1574/// This function is a placeholder that will be implemented when UI file
1575/// composition is added.
1576///
1577/// # Returns
1578///
1579/// Currently returns `Ok(())` since includes are not supported.
1580/// Once implemented, returns `Err(ParseError)` for circular dependencies.
1581pub fn validate_no_circular_dependencies(
1582    _file_path: &std::path::Path,
1583    _visited: &mut std::collections::HashSet<std::path::PathBuf>,
1584) -> Result<(), ParseError> {
1585    Ok(())
1586}
1587
1588#[cfg(test)]
1589mod circular_dependency_tests {
1590    use super::*;
1591    use std::collections::HashSet;
1592    use std::path::PathBuf;
1593
1594    #[test]
1595    fn test_no_circular_dependencies_without_includes() {
1596        // T125: Validate that single files have no circular dependencies
1597        let file_path = PathBuf::from("test.dampen");
1598        let mut visited = HashSet::new();
1599
1600        let result = validate_no_circular_dependencies(&file_path, &mut visited);
1601        assert!(
1602            result.is_ok(),
1603            "Single file should have no circular dependencies"
1604        );
1605    }
1606
1607    // Future tests when includes are supported:
1608    // - test_detect_simple_circular_dependency: A -> B -> A
1609    // - test_detect_complex_circular_dependency: A -> B -> C -> D -> B
1610    // - test_allow_diamond_dependencies: A->B, A->C, B->D, C->D (this is OK, not circular)
1611    // - test_self_include_rejected: A -> A
1612}
1613
1614#[cfg(test)]
1615mod inline_state_styles_tests {
1616    use super::*;
1617    use crate::ir::theme::WidgetState;
1618
1619    #[test]
1620    fn test_parse_single_state_attribute() {
1621        // T011: Parse button with single hover:background state attribute
1622        // Note: XML requires namespace declaration for colons in attribute names
1623        let xml = r##"
1624            <dampen version="1.0" xmlns:hover="urn:dampen:state:hover">
1625                <button label="Click" hover:background="#ff0000" />
1626            </dampen>
1627        "##;
1628
1629        let result = parse(xml);
1630        assert!(result.is_ok(), "Should parse valid XML with hover state");
1631
1632        let doc = result.unwrap();
1633        let button = &doc.root;
1634
1635        // Verify inline_state_variants contains hover state
1636        assert!(
1637            button
1638                .inline_state_variants
1639                .contains_key(&WidgetState::Hover),
1640            "Should have hover state variant"
1641        );
1642
1643        let hover_style = button
1644            .inline_state_variants
1645            .get(&WidgetState::Hover)
1646            .unwrap();
1647
1648        // Verify hover background color is red
1649        assert!(
1650            hover_style.background.is_some(),
1651            "Hover state should have background"
1652        );
1653    }
1654
1655    #[test]
1656    fn test_parse_multiple_state_attributes() {
1657        // T012: Parse button with multiple state attributes
1658        // Note: Each state needs its own unique namespace URI to avoid attribute conflicts
1659        let xml = r##"
1660            <dampen version="1.0"
1661                xmlns:hover="urn:dampen:state:hover"
1662                xmlns:active="urn:dampen:state:active"
1663                xmlns:disabled="urn:dampen:state:disabled">
1664                <button
1665                    label="Click"
1666                    hover:background="#ff0000"
1667                    active:background="#00ff00"
1668                    disabled:opacity="0.5"
1669                />
1670            </dampen>
1671        "##;
1672
1673        let result = parse(xml);
1674        assert!(
1675            result.is_ok(),
1676            "Should parse valid XML with multiple states"
1677        );
1678
1679        let doc = result.unwrap();
1680        let button = &doc.root;
1681
1682        // Verify all three state variants exist
1683        assert!(
1684            button
1685                .inline_state_variants
1686                .contains_key(&WidgetState::Hover),
1687            "Should have hover state"
1688        );
1689        assert!(
1690            button
1691                .inline_state_variants
1692                .contains_key(&WidgetState::Active),
1693            "Should have active state"
1694        );
1695        assert!(
1696            button
1697                .inline_state_variants
1698                .contains_key(&WidgetState::Disabled),
1699            "Should have disabled state"
1700        );
1701
1702        // Verify hover has background
1703        let hover_style = button
1704            .inline_state_variants
1705            .get(&WidgetState::Hover)
1706            .unwrap();
1707        assert!(
1708            hover_style.background.is_some(),
1709            "Hover state should have background"
1710        );
1711
1712        // Verify active has background
1713        let active_style = button
1714            .inline_state_variants
1715            .get(&WidgetState::Active)
1716            .unwrap();
1717        assert!(
1718            active_style.background.is_some(),
1719            "Active state should have background"
1720        );
1721
1722        // Verify disabled has opacity
1723        let disabled_style = button
1724            .inline_state_variants
1725            .get(&WidgetState::Disabled)
1726            .unwrap();
1727        assert!(
1728            disabled_style.opacity.is_some(),
1729            "Disabled state should have opacity"
1730        );
1731    }
1732
1733    #[test]
1734    fn test_parse_invalid_state_prefix() {
1735        // T013: Parse button with invalid state prefix should treat as regular attribute
1736        let xml = r##"
1737            <dampen version="1.0" xmlns:unknown="urn:dampen:state:unknown">
1738                <button label="Click" unknown:background="#ff0000" />
1739            </dampen>
1740        "##;
1741
1742        let result = parse(xml);
1743        assert!(
1744            result.is_ok(),
1745            "Should parse with warning for invalid state"
1746        );
1747
1748        let doc = result.unwrap();
1749        let button = &doc.root;
1750
1751        // Verify inline_state_variants is empty (invalid prefix ignored)
1752        assert!(
1753            button.inline_state_variants.is_empty(),
1754            "Should have no state variants for invalid prefix"
1755        );
1756
1757        // Verify unknown:background is treated as regular attribute
1758        assert!(
1759            button.attributes.contains_key("unknown:background"),
1760            "Invalid state prefix should be treated as regular attribute"
1761        );
1762    }
1763}