dampen_core/parser/
mod.rs

1pub mod error;
2pub mod gradient;
3pub mod lexer;
4pub mod style_parser;
5pub mod theme_parser;
6
7use crate::expr::tokenize_binding_expr;
8use crate::ir::{
9    AttributeValue, DampenDocument, EventBinding, EventKind, InterpolatedPart, SchemaVersion, Span,
10    WidgetKind, WidgetNode,
11};
12use crate::parser::error::{ParseError, ParseErrorKind};
13use roxmltree::{Document, Node, NodeType};
14use std::collections::HashMap;
15
16/// Maximum schema version supported by this framework release.
17///
18/// Files declaring a version higher than this will be rejected with an error.
19/// Update this constant when the framework adds support for new schema versions.
20pub const MAX_SUPPORTED_VERSION: SchemaVersion = SchemaVersion { major: 1, minor: 0 };
21
22/// Parse a version string in "major.minor" format into a SchemaVersion.
23///
24/// # Arguments
25///
26/// * `version_str` - Raw version string from XML attribute (e.g., "1.0")
27/// * `span` - Source location for error reporting
28///
29/// # Returns
30///
31/// `Ok(SchemaVersion)` on success, `Err(ParseError)` for invalid formats.
32///
33/// # Examples
34///
35/// ```ignore
36/// let v = parse_version_string("1.0", span)?;
37/// assert_eq!(v.major, 1);
38/// assert_eq!(v.minor, 0);
39/// ```
40pub fn parse_version_string(version_str: &str, span: Span) -> Result<SchemaVersion, ParseError> {
41    let trimmed = version_str.trim();
42
43    // Reject empty strings
44    if trimmed.is_empty() {
45        return Err(ParseError {
46            kind: ParseErrorKind::InvalidValue,
47            message: "Version attribute cannot be empty".to_string(),
48            span,
49            suggestion: Some("Use format: version=\"1.0\"".to_string()),
50        });
51    }
52
53    // Split on "." and validate exactly 2 parts
54    let parts: Vec<&str> = trimmed.split('.').collect();
55    if parts.len() != 2 {
56        return Err(ParseError {
57            kind: ParseErrorKind::InvalidValue,
58            message: format!(
59                "Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
60                trimmed
61            ),
62            span,
63            suggestion: Some("Use format: version=\"1.0\"".to_string()),
64        });
65    }
66
67    // Parse major version
68    let major = parts[0].parse::<u16>().map_err(|_| ParseError {
69        kind: ParseErrorKind::InvalidValue,
70        message: format!(
71            "Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
72            trimmed
73        ),
74        span,
75        suggestion: Some("Use format: version=\"1.0\"".to_string()),
76    })?;
77
78    // Parse minor version
79    let minor = parts[1].parse::<u16>().map_err(|_| ParseError {
80        kind: ParseErrorKind::InvalidValue,
81        message: format!(
82            "Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
83            trimmed
84        ),
85        span,
86        suggestion: Some("Use format: version=\"1.0\"".to_string()),
87    })?;
88
89    Ok(SchemaVersion { major, minor })
90}
91
92/// Validate that a parsed version is supported by this framework.
93///
94/// # Arguments
95///
96/// * `version` - Parsed version to validate
97/// * `span` - Source location for error reporting
98///
99/// # Returns
100///
101/// `Ok(())` if the version is supported, `Err(ParseError)` if the version
102/// is newer than `MAX_SUPPORTED_VERSION`.
103pub fn validate_version_supported(version: &SchemaVersion, span: Span) -> Result<(), ParseError> {
104    if (version.major, version.minor) > (MAX_SUPPORTED_VERSION.major, MAX_SUPPORTED_VERSION.minor) {
105        return Err(ParseError {
106            kind: ParseErrorKind::UnsupportedVersion,
107            message: format!(
108                "Schema version {}.{} is not supported. Maximum supported version: {}.{}",
109                version.major,
110                version.minor,
111                MAX_SUPPORTED_VERSION.major,
112                MAX_SUPPORTED_VERSION.minor
113            ),
114            span,
115            suggestion: Some(format!(
116                "Upgrade dampen-core to support v{}.{}, or use version=\"{}.{}\"",
117                version.major,
118                version.minor,
119                MAX_SUPPORTED_VERSION.major,
120                MAX_SUPPORTED_VERSION.minor
121            )),
122        });
123    }
124    Ok(())
125}
126
127/// Warning about a widget requiring a higher schema version than declared.
128///
129/// This is non-blocking validation - widgets may work but compatibility is not guaranteed.
130#[derive(Debug, Clone, PartialEq)]
131pub struct ValidationWarning {
132    /// The widget that requires a higher version
133    pub widget_kind: WidgetKind,
134    /// The schema version declared in the document
135    pub declared_version: SchemaVersion,
136    /// The minimum version required by the widget
137    pub required_version: SchemaVersion,
138    /// Source location of the widget
139    pub span: Span,
140}
141
142impl ValidationWarning {
143    /// Format the warning as a human-readable message
144    pub fn format_message(&self) -> String {
145        format!(
146            "Widget '{}' requires schema v{}.{} but document declares v{}.{}",
147            widget_kind_name(&self.widget_kind),
148            self.required_version.major,
149            self.required_version.minor,
150            self.declared_version.major,
151            self.declared_version.minor
152        )
153    }
154
155    /// Get a suggestion for resolving the warning
156    pub fn suggestion(&self) -> String {
157        format!(
158            "Update to <dampen version=\"{}.{}\"> or remove this widget",
159            self.required_version.major, self.required_version.minor
160        )
161    }
162}
163
164/// Helper function to get widget kind name as string
165fn widget_kind_name(kind: &WidgetKind) -> String {
166    match kind {
167        WidgetKind::Column => "column".to_string(),
168        WidgetKind::Row => "row".to_string(),
169        WidgetKind::Container => "container".to_string(),
170        WidgetKind::Scrollable => "scrollable".to_string(),
171        WidgetKind::Stack => "stack".to_string(),
172        WidgetKind::Text => "text".to_string(),
173        WidgetKind::Image => "image".to_string(),
174        WidgetKind::Svg => "svg".to_string(),
175        WidgetKind::Button => "button".to_string(),
176        WidgetKind::TextInput => "text_input".to_string(),
177        WidgetKind::Checkbox => "checkbox".to_string(),
178        WidgetKind::Slider => "slider".to_string(),
179        WidgetKind::PickList => "pick_list".to_string(),
180        WidgetKind::Toggler => "toggler".to_string(),
181        WidgetKind::Space => "space".to_string(),
182        WidgetKind::Rule => "rule".to_string(),
183        WidgetKind::Radio => "radio".to_string(),
184        WidgetKind::ComboBox => "combobox".to_string(),
185        WidgetKind::ProgressBar => "progress_bar".to_string(),
186        WidgetKind::Tooltip => "tooltip".to_string(),
187        WidgetKind::Grid => "grid".to_string(),
188        WidgetKind::Canvas => "canvas".to_string(),
189        WidgetKind::Float => "float".to_string(),
190        WidgetKind::For => "for".to_string(),
191        WidgetKind::Custom(name) => name.clone(),
192    }
193}
194
195/// Validate that all widgets in the document are compatible with the declared schema version.
196///
197/// Returns warnings (not errors) for widgets that require a higher version than declared.
198/// This is non-blocking validation to help developers identify potential compatibility issues.
199///
200/// # Arguments
201///
202/// * `document` - The parsed document to validate
203///
204/// # Returns
205///
206/// A vector of `ValidationWarning` for widgets requiring higher versions.
207/// Empty vector means all widgets are compatible with the declared version.
208///
209/// # Examples
210///
211/// ```rust
212/// use dampen_core::{parse, validate_widget_versions};
213///
214/// let xml = r#"<dampen version="1.0"><canvas width="400" height="200" program="{chart}" /></dampen>"#;
215/// let doc = parse(xml).unwrap();
216/// let warnings = validate_widget_versions(&doc);
217/// assert_eq!(warnings.len(), 1); // Canvas requires v1.1
218/// ```
219pub fn validate_widget_versions(document: &DampenDocument) -> Vec<ValidationWarning> {
220    let mut warnings = Vec::new();
221    validate_widget_tree(&document.root, &document.version, &mut warnings);
222    warnings
223}
224
225/// Recursively validate widget tree for version compatibility
226fn validate_widget_tree(
227    node: &WidgetNode,
228    doc_version: &SchemaVersion,
229    warnings: &mut Vec<ValidationWarning>,
230) {
231    let min_version = node.kind.minimum_version();
232
233    // Check if widget requires a higher version than declared
234    if (min_version.major, min_version.minor) > (doc_version.major, doc_version.minor) {
235        warnings.push(ValidationWarning {
236            widget_kind: node.kind.clone(),
237            declared_version: *doc_version,
238            required_version: min_version,
239            span: node.span,
240        });
241    }
242
243    // Recursively check children
244    for child in &node.children {
245        validate_widget_tree(child, doc_version, warnings);
246    }
247}
248
249/// Parse XML markup into a DampenDocument.
250///
251/// This is the main entry point for the parser. It takes XML markup and
252/// converts it into the Intermediate Representation (IR) suitable for
253/// rendering or code generation.
254///
255/// # Arguments
256///
257/// * `xml` - XML markup string
258///
259/// # Returns
260///
261/// `Ok(DampenDocument)` on success, `Err(ParseError)` on failure
262///
263/// # Examples
264///
265/// ```rust
266/// use dampen_core::parse;
267///
268/// let xml = r#"<dampen><column><text value="Hello" /></column></dampen>"#;
269/// let doc = parse(xml).unwrap();
270/// assert_eq!(doc.root.children.len(), 1);
271/// ```
272///
273/// # Errors
274///
275/// Returns `ParseError` for:
276/// - Invalid XML syntax
277/// - Unknown widget elements
278/// - Invalid attribute values
279/// - Malformed binding expressions
280pub fn parse(xml: &str) -> Result<DampenDocument, ParseError> {
281    // Parse XML using roxmltree
282    let doc = Document::parse(xml).map_err(|e| ParseError {
283        kind: ParseErrorKind::XmlSyntax,
284        message: e.to_string(),
285        span: Span::new(0, 0, 1, 1),
286        suggestion: None,
287    })?;
288
289    // Find root element (skip XML declaration)
290    let root = doc.root().first_child().ok_or_else(|| ParseError {
291        kind: ParseErrorKind::XmlSyntax,
292        message: "No root element found".to_string(),
293        span: Span::new(0, 0, 1, 1),
294        suggestion: None,
295    })?;
296
297    // Check if root is <dampen> wrapper
298    let root_tag = root.tag_name().name();
299
300    if root_tag == "dampen" {
301        // Parse <dampen> document with themes and widgets
302        parse_dampen_document(root, xml)
303    } else {
304        // Parse direct widget (backward compatibility)
305        // Default to version 1.0 for backward compatibility
306        let root_widget = parse_node(root, xml)?;
307
308        Ok(DampenDocument {
309            version: SchemaVersion::default(),
310            root: root_widget,
311            themes: HashMap::new(),
312            style_classes: HashMap::new(),
313            global_theme: None,
314        })
315    }
316}
317
318/// Validate widget-specific required attributes
319fn validate_widget_attributes(
320    kind: &WidgetKind,
321    attributes: &std::collections::HashMap<String, AttributeValue>,
322    span: Span,
323) -> Result<(), ParseError> {
324    match kind {
325        WidgetKind::ComboBox | WidgetKind::PickList => {
326            // Check for required 'options' attribute
327            if let Some(AttributeValue::Static(options_value)) = attributes.get("options") {
328                if options_value.trim().is_empty() {
329                    return Err(ParseError {
330                        kind: ParseErrorKind::MissingAttribute,
331                        message: format!(
332                            "{:?} widget requires 'options' attribute to be non-empty",
333                            kind
334                        ),
335                        span,
336                        suggestion: Some(
337                            "Add a comma-separated list: options=\"Option1,Option2\"".to_string(),
338                        ),
339                    });
340                }
341            } else {
342                return Err(ParseError {
343                    kind: ParseErrorKind::MissingAttribute,
344                    message: format!("{:?} widget requires 'options' attribute", kind),
345                    span,
346                    suggestion: Some(
347                        "Add options attribute: options=\"Option1,Option2\"".to_string(),
348                    ),
349                });
350            }
351        }
352        WidgetKind::Canvas => {
353            // Check for required 'width' attribute
354            if !attributes.contains_key("width") {
355                return Err(ParseError {
356                    kind: ParseErrorKind::MissingAttribute,
357                    message: format!("{:?} widget requires 'width' attribute", kind),
358                    span,
359                    suggestion: Some("Add width attribute: width=\"400\"".to_string()),
360                });
361            }
362            // Check for required 'height' attribute
363            if !attributes.contains_key("height") {
364                return Err(ParseError {
365                    kind: ParseErrorKind::MissingAttribute,
366                    message: format!("{:?} widget requires 'height' attribute", kind),
367                    span,
368                    suggestion: Some("Add height attribute: height=\"200\"".to_string()),
369                });
370            }
371            // Check for required 'program' attribute
372            if !attributes.contains_key("program") {
373                return Err(ParseError {
374                    kind: ParseErrorKind::MissingAttribute,
375                    message: format!("{:?} widget requires 'program' attribute", kind),
376                    span,
377                    suggestion: Some("Add program attribute: program=\"{{chart}}\"".to_string()),
378                });
379            }
380
381            // Validate width size range [50, 4000]
382            if let Some(AttributeValue::Static(width_str)) = attributes.get("width") {
383                if let Ok(width) = width_str.parse::<u32>() {
384                    if !(50..=4000).contains(&width) {
385                        return Err(ParseError {
386                            kind: ParseErrorKind::InvalidValue,
387                            message: format!(
388                                "Canvas width must be between 50 and 4000 pixels, found {}",
389                                width
390                            ),
391                            span,
392                            suggestion: Some("Use width value between 50 and 4000".to_string()),
393                        });
394                    }
395                }
396            }
397
398            // Validate height size range [50, 4000]
399            if let Some(AttributeValue::Static(height_str)) = attributes.get("height") {
400                if let Ok(height) = height_str.parse::<u32>() {
401                    if !(50..=4000).contains(&height) {
402                        return Err(ParseError {
403                            kind: ParseErrorKind::InvalidValue,
404                            message: format!(
405                                "Canvas height must be between 50 and 4000 pixels, found {}",
406                                height
407                            ),
408                            span,
409                            suggestion: Some("Use height value between 50 and 4000".to_string()),
410                        });
411                    }
412                }
413            }
414        }
415        WidgetKind::Grid => {
416            // Check for required 'columns' attribute
417            if !attributes.contains_key("columns") {
418                return Err(ParseError {
419                    kind: ParseErrorKind::MissingAttribute,
420                    message: format!("{:?} widget requires 'columns' attribute", kind),
421                    span,
422                    suggestion: Some("Add columns attribute: columns=\"5\"".to_string()),
423                });
424            }
425            // Validate columns value range [1, 20]
426            if let Some(AttributeValue::Static(cols)) = attributes.get("columns") {
427                if let Ok(cols_num) = cols.parse::<u32>() {
428                    if !(1..=20).contains(&cols_num) {
429                        return Err(ParseError {
430                            kind: ParseErrorKind::InvalidValue,
431                            message: format!(
432                                "Grid columns must be between 1 and 20, found {}",
433                                cols_num
434                            ),
435                            span,
436                            suggestion: Some("Use columns value between 1 and 20".to_string()),
437                        });
438                    }
439                }
440            }
441        }
442        WidgetKind::Tooltip => {
443            // Check for required 'message' attribute
444            if !attributes.contains_key("message") {
445                return Err(ParseError {
446                    kind: ParseErrorKind::MissingAttribute,
447                    message: format!("{:?} widget requires 'message' attribute", kind),
448                    span,
449                    suggestion: Some("Add message attribute: message=\"Help text\"".to_string()),
450                });
451            }
452        }
453        WidgetKind::For => {
454            // Check for required 'each' attribute
455            if !attributes.contains_key("each") {
456                return Err(ParseError {
457                    kind: ParseErrorKind::MissingAttribute,
458                    message: "For loop requires 'each' attribute to name the loop variable"
459                        .to_string(),
460                    span,
461                    suggestion: Some("Add each attribute: each=\"item\"".to_string()),
462                });
463            }
464            // Check for required 'in' attribute
465            if !attributes.contains_key("in") {
466                return Err(ParseError {
467                    kind: ParseErrorKind::MissingAttribute,
468                    message: "For loop requires 'in' attribute with collection binding".to_string(),
469                    span,
470                    suggestion: Some("Add in attribute: in=\"{items}\"".to_string()),
471                });
472            }
473        }
474        _ => {}
475    }
476    Ok(())
477}
478
479/// Validate Tooltip widget has exactly one child
480fn validate_tooltip_children(children: &[WidgetNode], span: Span) -> Result<(), ParseError> {
481    if children.is_empty() {
482        return Err(ParseError {
483            kind: ParseErrorKind::InvalidValue,
484            message: "Tooltip widget must have exactly one child widget".to_string(),
485            span,
486            suggestion: Some("Wrap a single widget in <tooltip></tooltip>".to_string()),
487        });
488    }
489    if children.len() > 1 {
490        return Err(ParseError {
491            kind: ParseErrorKind::InvalidValue,
492            message: format!(
493                "Tooltip widget must have exactly one child, found {}",
494                children.len()
495            ),
496            span,
497            suggestion: Some("Wrap only one widget in <tooltip></tooltip>".to_string()),
498        });
499    }
500    Ok(())
501}
502
503/// Validate Canvas widget has no children (is a leaf widget)
504fn validate_canvas_children(children: &[WidgetNode], span: Span) -> Result<(), ParseError> {
505    if !children.is_empty() {
506        return Err(ParseError {
507            kind: ParseErrorKind::InvalidValue,
508            message: format!(
509                "Canvas widget cannot have children, found {}",
510                children.len()
511            ),
512            span,
513            suggestion: Some("Canvas is a leaf widget - remove child elements".to_string()),
514        });
515    }
516    Ok(())
517}
518
519/// Parse a single XML node into a WidgetNode
520fn parse_node(node: Node, source: &str) -> Result<WidgetNode, ParseError> {
521    // Only process element nodes
522    if node.node_type() != NodeType::Element {
523        return Err(ParseError {
524            kind: ParseErrorKind::XmlSyntax,
525            message: "Expected element node".to_string(),
526            span: Span::new(0, 0, 1, 1),
527            suggestion: None,
528        });
529    }
530
531    // Get element name and map to WidgetKind
532    let tag_name = node.tag_name().name();
533    let kind = match tag_name {
534        "column" => WidgetKind::Column,
535        "row" => WidgetKind::Row,
536        "container" => WidgetKind::Container,
537        "scrollable" => WidgetKind::Scrollable,
538        "stack" => WidgetKind::Stack,
539        "text" => WidgetKind::Text,
540        "image" => WidgetKind::Image,
541        "svg" => WidgetKind::Svg,
542        "button" => WidgetKind::Button,
543        "text_input" => WidgetKind::TextInput,
544        "checkbox" => WidgetKind::Checkbox,
545        "slider" => WidgetKind::Slider,
546        "pick_list" => WidgetKind::PickList,
547        "toggler" => WidgetKind::Toggler,
548        "space" => WidgetKind::Space,
549        "rule" => WidgetKind::Rule,
550        "radio" => WidgetKind::Radio,
551        "combobox" => WidgetKind::ComboBox,
552        "progress_bar" => WidgetKind::ProgressBar,
553        "tooltip" => WidgetKind::Tooltip,
554        "grid" => WidgetKind::Grid,
555        "canvas" => WidgetKind::Canvas,
556        "float" => WidgetKind::Float,
557        "for" => WidgetKind::For,
558        _ => {
559            return Err(ParseError {
560                kind: ParseErrorKind::UnknownWidget,
561                message: format!("Unknown widget: <{}>", tag_name),
562                span: get_span(node, source),
563                suggestion: Some("Did you mean one of the standard widgets?".to_string()),
564            });
565        }
566    };
567
568    // Parse attributes - separate breakpoint-prefixed from regular
569    let mut attributes = std::collections::HashMap::new();
570    let mut breakpoint_attributes = std::collections::HashMap::new();
571    let mut events = Vec::new();
572    let mut id = None;
573
574    for attr in node.attributes() {
575        let name = attr.name();
576        let value = attr.value();
577
578        // Check for id attribute
579        if name == "id" {
580            id = Some(value.to_string());
581            continue;
582        }
583
584        // Check for event attributes (on_click, on_change, etc.)
585        if name.starts_with("on_") {
586            let event_kind = match name {
587                "on_click" => Some(EventKind::Click),
588                "on_press" => Some(EventKind::Press),
589                "on_release" => Some(EventKind::Release),
590                "on_change" => Some(EventKind::Change),
591                "on_input" => Some(EventKind::Input),
592                "on_submit" => Some(EventKind::Submit),
593                "on_select" => Some(EventKind::Select),
594                "on_toggle" => Some(EventKind::Toggle),
595                "on_scroll" => Some(EventKind::Scroll),
596                _ => None,
597            };
598
599            if let Some(event) = event_kind {
600                // Parse handler name and optional parameter
601                // Syntax: "handler_name" or "handler_name:{expression}"
602                let (handler_name, param) = if let Some(colon_pos) = value.find(':') {
603                    let handler = value[..colon_pos].to_string();
604                    let param_str = &value[colon_pos + 1..];
605
606                    // Remove surrounding braces if present: {item.id} -> item.id
607                    let param_clean = param_str.trim_matches('{').trim_matches('}');
608
609                    // Parse parameter as binding expression
610                    match crate::expr::tokenize_binding_expr(param_clean, 0, 1, 1) {
611                        Ok(expr) => (handler, Some(expr)),
612                        Err(_) => {
613                            // If parsing fails, treat the whole string as handler name
614                            (value.to_string(), None)
615                        }
616                    }
617                } else {
618                    (value.to_string(), None)
619                };
620
621                events.push(EventBinding {
622                    event,
623                    handler: handler_name,
624                    param,
625                    span: get_span(node, source),
626                });
627                continue;
628            }
629        }
630
631        // Check for breakpoint-prefixed attributes (e.g., "mobile-spacing", "tablet-width")
632        // Note: We use hyphen instead of colon to avoid XML namespace issues
633        if let Some((prefix, attr_name)) = name.split_once('-') {
634            if let Ok(breakpoint) = crate::ir::layout::Breakpoint::parse(prefix) {
635                // Store in breakpoint_attributes map
636                let attr_value = parse_attribute_value(value, get_span(node, source))?;
637                breakpoint_attributes
638                    .entry(breakpoint)
639                    .or_insert_with(HashMap::new)
640                    .insert(attr_name.to_string(), attr_value);
641                continue;
642            }
643        }
644
645        // Parse attribute value (check for bindings)
646        let attr_value = parse_attribute_value(value, get_span(node, source))?;
647        attributes.insert(name.to_string(), attr_value);
648    }
649
650    // Extract class attribute into classes field
651    let classes = if let Some(AttributeValue::Static(class_attr)) = attributes.get("class") {
652        class_attr
653            .split_whitespace()
654            .map(|s| s.to_string())
655            .collect()
656    } else {
657        Vec::new()
658    };
659
660    // Parse children
661    let mut children = Vec::new();
662    for child in node.children() {
663        if child.node_type() == NodeType::Element {
664            children.push(parse_node(child, source)?);
665        }
666    }
667
668    // Validate Tooltip has exactly one child
669    if kind == WidgetKind::Tooltip {
670        validate_tooltip_children(&children, get_span(node, source))?;
671    }
672
673    // Validate Canvas has no children (leaf widget)
674    if kind == WidgetKind::Canvas {
675        validate_canvas_children(&children, get_span(node, source))?;
676    }
677
678    // Parse layout and style attributes into structured fields
679    let layout = parse_layout_attributes(&kind, &attributes).map_err(|e| ParseError {
680        kind: ParseErrorKind::InvalidValue,
681        message: e,
682        span: get_span(node, source),
683        suggestion: None,
684    })?;
685    let style = parse_style_attributes(&attributes).map_err(|e| ParseError {
686        kind: ParseErrorKind::InvalidValue,
687        message: e,
688        span: get_span(node, source),
689        suggestion: None,
690    })?;
691
692    // Validate widget-specific required attributes
693    validate_widget_attributes(&kind, &attributes, get_span(node, source))?;
694
695    Ok(WidgetNode {
696        kind,
697        id,
698        attributes,
699        events,
700        children,
701        span: get_span(node, source),
702        style,
703        layout,
704        theme_ref: None,
705        classes,
706        breakpoint_attributes,
707    })
708}
709
710/// Parse a <dampen> document with themes and widgets
711fn parse_dampen_document(root: Node, source: &str) -> Result<DampenDocument, ParseError> {
712    let mut themes = HashMap::new();
713    let mut style_classes = HashMap::new();
714    let mut root_widget = None;
715    let mut global_theme = None;
716
717    // Parse version attribute from <dampen> root element
718    let span = get_span(root, source);
719    let version = if let Some(version_attr) = root.attribute("version") {
720        let parsed = parse_version_string(version_attr, span)?;
721        validate_version_supported(&parsed, span)?;
722        parsed
723    } else {
724        // Default to version 1.0 for backward compatibility
725        SchemaVersion::default()
726    };
727
728    // Iterate through children of <dampen>
729    for child in root.children() {
730        if child.node_type() != NodeType::Element {
731            continue;
732        }
733
734        let tag_name = child.tag_name().name();
735
736        match tag_name {
737            "themes" => {
738                // Parse themes section
739                for theme_node in child.children() {
740                    if theme_node.node_type() == NodeType::Element
741                        && theme_node.tag_name().name() == "theme"
742                    {
743                        let theme =
744                            crate::parser::theme_parser::parse_theme_from_node(theme_node, source)?;
745                        let name = theme_node
746                            .attribute("name")
747                            .map(|s| s.to_string())
748                            .unwrap_or_else(|| "default".to_string());
749                        themes.insert(name, theme);
750                    }
751                }
752            }
753            "style_classes" | "classes" | "styles" => {
754                // Parse style classes
755                for class_node in child.children() {
756                    if class_node.node_type() == NodeType::Element {
757                        let tag = class_node.tag_name().name();
758                        if tag == "class" || tag == "style" {
759                            let class = crate::parser::theme_parser::parse_style_class_from_node(
760                                class_node, source,
761                            )?;
762                            style_classes.insert(class.name.clone(), class);
763                        }
764                    }
765                }
766            }
767            "global_theme" => {
768                // Set global theme reference
769                if let Some(theme_name) = child.attribute("name") {
770                    global_theme = Some(theme_name.to_string());
771                }
772            }
773            _ => {
774                // This should be a widget - parse as root
775                if root_widget.is_some() {
776                    return Err(ParseError {
777                        kind: ParseErrorKind::XmlSyntax,
778                        message: "Multiple root widgets found in <dampen>".to_string(),
779                        span: get_span(child, source),
780                        suggestion: Some("Only one root widget is allowed".to_string()),
781                    });
782                }
783                root_widget = Some(parse_node(child, source)?);
784            }
785        }
786    }
787
788    // Ensure we have a root widget
789    let root_widget = root_widget.ok_or_else(|| ParseError {
790        kind: ParseErrorKind::XmlSyntax,
791        message: "No root widget found in <dampen>".to_string(),
792        span: get_span(root, source),
793        suggestion: Some("Add a widget like <column> or <row> inside <dampen>".to_string()),
794    })?;
795
796    Ok(DampenDocument {
797        version,
798        root: root_widget,
799        themes,
800        style_classes,
801        global_theme,
802    })
803}
804
805/// Parse comma-separated list into Vec<String>
806pub fn parse_comma_separated(value: &str) -> Vec<String> {
807    value
808        .split(',')
809        .map(|s| s.trim().to_string())
810        .filter(|s| !s.is_empty())
811        .collect()
812}
813
814/// Parse a simple enum value (case-insensitive) and return the matched variant
815pub fn parse_enum_value<T>(value: &str, valid_variants: &[&str]) -> Result<T, String>
816where
817    T: std::str::FromStr + std::fmt::Display,
818{
819    let normalized = value.trim().to_lowercase();
820    for variant in valid_variants.iter() {
821        if variant.to_lowercase() == normalized {
822            return T::from_str(variant).map_err(|_| {
823                format!(
824                    "Failed to parse '{}' as {}",
825                    variant,
826                    std::any::type_name::<T>()
827                )
828            });
829        }
830    }
831    Err(format!(
832        "Invalid value '{}'. Valid options: {}",
833        value,
834        valid_variants.join(", ")
835    ))
836}
837
838/// Parse attribute value, detecting binding expressions
839fn parse_attribute_value(value: &str, span: Span) -> Result<AttributeValue, ParseError> {
840    // Check if value contains binding syntax {expr}
841    if value.contains('{') && value.contains('}') {
842        // Parse interpolated parts
843        let mut parts = Vec::new();
844        let mut remaining = value;
845
846        while let Some(start_pos) = remaining.find('{') {
847            // Add literal before {
848            if start_pos > 0 {
849                parts.push(InterpolatedPart::Literal(
850                    remaining[..start_pos].to_string(),
851                ));
852            }
853
854            // Find closing }
855            if let Some(end_pos) = remaining[start_pos..].find('}') {
856                let expr_start = start_pos + 1;
857                let expr_end = start_pos + end_pos;
858                let expr_str = &remaining[expr_start..expr_end];
859
860                // Parse the expression
861                let binding_expr = tokenize_binding_expr(
862                    expr_str,
863                    span.start + expr_start,
864                    span.line,
865                    span.column + expr_start as u32,
866                )
867                .map_err(|e| ParseError {
868                    kind: ParseErrorKind::InvalidExpression,
869                    message: format!("Invalid expression: {}", e),
870                    span: Span::new(
871                        span.start + expr_start,
872                        span.start + expr_end,
873                        span.line,
874                        span.column + expr_start as u32,
875                    ),
876                    suggestion: None,
877                })?;
878
879                parts.push(InterpolatedPart::Binding(binding_expr));
880
881                // Move past the }
882                remaining = &remaining[expr_end + 1..];
883            } else {
884                // No closing }, treat rest as literal
885                parts.push(InterpolatedPart::Literal(remaining.to_string()));
886                break;
887            }
888        }
889
890        // Add remaining literal
891        if !remaining.is_empty() {
892            parts.push(InterpolatedPart::Literal(remaining.to_string()));
893        }
894
895        // If only one binding with no literals, return Binding
896        // If multiple parts, return Interpolated
897        if parts.len() == 1 {
898            match &parts[0] {
899                InterpolatedPart::Binding(expr) => {
900                    return Ok(AttributeValue::Binding(expr.clone()));
901                }
902                InterpolatedPart::Literal(lit) => {
903                    return Ok(AttributeValue::Static(lit.clone()));
904                }
905            }
906        } else {
907            return Ok(AttributeValue::Interpolated(parts));
908        }
909    }
910
911    // Static value
912    Ok(AttributeValue::Static(value.to_string()))
913}
914
915/// Extract span information from roxmltree node
916fn get_span(node: Node, source: &str) -> Span {
917    let range = node.range();
918
919    // Calculate line and column from byte offset
920    let (line, col) = calculate_line_col(source, range.start);
921
922    Span {
923        start: range.start,
924        end: range.end,
925        line,
926        column: col,
927    }
928}
929
930/// Calculate line and column from byte offset
931fn calculate_line_col(source: &str, offset: usize) -> (u32, u32) {
932    let mut line = 1;
933    let mut col = 1;
934
935    for (i, c) in source.chars().enumerate() {
936        if i >= offset {
937            break;
938        }
939        if c == '\n' {
940            line += 1;
941            col = 1;
942        } else {
943            col += 1;
944        }
945    }
946
947    (line, col)
948}
949
950/// Parse layout-related attributes from the attributes map
951fn parse_layout_attributes(
952    kind: &WidgetKind,
953    attributes: &HashMap<String, AttributeValue>,
954) -> Result<Option<crate::ir::layout::LayoutConstraints>, String> {
955    use crate::ir::layout::LayoutConstraints;
956    use crate::parser::style_parser::{
957        parse_alignment, parse_constraint, parse_float_attr, parse_int_attr, parse_justification,
958        parse_length_attr, parse_padding_attr, parse_spacing,
959    };
960
961    let mut layout = LayoutConstraints::default();
962    let mut has_any = false;
963
964    // Parse width
965    if let Some(AttributeValue::Static(value)) = attributes.get("width") {
966        layout.width = Some(parse_length_attr(value)?);
967        has_any = true;
968    }
969
970    // Parse height
971    if let Some(AttributeValue::Static(value)) = attributes.get("height") {
972        layout.height = Some(parse_length_attr(value)?);
973        has_any = true;
974    }
975
976    // Parse min/max constraints
977    if let Some(AttributeValue::Static(value)) = attributes.get("min_width") {
978        layout.min_width = Some(parse_constraint(value)?);
979        has_any = true;
980    }
981
982    if let Some(AttributeValue::Static(value)) = attributes.get("max_width") {
983        layout.max_width = Some(parse_constraint(value)?);
984        has_any = true;
985    }
986
987    if let Some(AttributeValue::Static(value)) = attributes.get("min_height") {
988        layout.min_height = Some(parse_constraint(value)?);
989        has_any = true;
990    }
991
992    if let Some(AttributeValue::Static(value)) = attributes.get("max_height") {
993        layout.max_height = Some(parse_constraint(value)?);
994        has_any = true;
995    }
996
997    // Parse padding
998    if let Some(AttributeValue::Static(value)) = attributes.get("padding") {
999        layout.padding = Some(parse_padding_attr(value)?);
1000        has_any = true;
1001    }
1002
1003    // Parse spacing
1004    if let Some(AttributeValue::Static(value)) = attributes.get("spacing") {
1005        layout.spacing = Some(parse_spacing(value)?);
1006        has_any = true;
1007    }
1008
1009    // Parse alignment
1010    if let Some(AttributeValue::Static(value)) = attributes.get("align_items") {
1011        layout.align_items = Some(parse_alignment(value)?);
1012        has_any = true;
1013    }
1014
1015    if let Some(AttributeValue::Static(value)) = attributes.get("justify_content") {
1016        layout.justify_content = Some(parse_justification(value)?);
1017        has_any = true;
1018    }
1019
1020    if let Some(AttributeValue::Static(value)) = attributes.get("align_self") {
1021        layout.align_self = Some(parse_alignment(value)?);
1022        has_any = true;
1023    }
1024
1025    // Parse align shorthand (sets both align_items and justify_content)
1026    if let Some(AttributeValue::Static(value)) = attributes.get("align") {
1027        let alignment = parse_alignment(value)?;
1028        layout.align_items = Some(alignment);
1029        layout.justify_content = Some(match alignment {
1030            crate::ir::layout::Alignment::Start => crate::ir::layout::Justification::Start,
1031            crate::ir::layout::Alignment::Center => crate::ir::layout::Justification::Center,
1032            crate::ir::layout::Alignment::End => crate::ir::layout::Justification::End,
1033            crate::ir::layout::Alignment::Stretch => crate::ir::layout::Justification::Center,
1034        });
1035        has_any = true;
1036    }
1037
1038    // Parse direction
1039    if let Some(AttributeValue::Static(value)) = attributes.get("direction") {
1040        layout.direction = Some(crate::ir::layout::Direction::parse(value)?);
1041        has_any = true;
1042    }
1043
1044    // Parse position (skip for Tooltip - it has its own position attribute)
1045    if !matches!(kind, WidgetKind::Tooltip) {
1046        if let Some(AttributeValue::Static(value)) = attributes.get("position") {
1047            layout.position = Some(crate::ir::layout::Position::parse(value)?);
1048            has_any = true;
1049        }
1050    }
1051
1052    // Parse position offsets
1053    if let Some(AttributeValue::Static(value)) = attributes.get("top") {
1054        layout.top = Some(parse_float_attr(value, "top")?);
1055        has_any = true;
1056    }
1057
1058    if let Some(AttributeValue::Static(value)) = attributes.get("right") {
1059        layout.right = Some(parse_float_attr(value, "right")?);
1060        has_any = true;
1061    }
1062
1063    if let Some(AttributeValue::Static(value)) = attributes.get("bottom") {
1064        layout.bottom = Some(parse_float_attr(value, "bottom")?);
1065        has_any = true;
1066    }
1067
1068    if let Some(AttributeValue::Static(value)) = attributes.get("left") {
1069        layout.left = Some(parse_float_attr(value, "left")?);
1070        has_any = true;
1071    }
1072
1073    // Parse z-index
1074    if let Some(AttributeValue::Static(value)) = attributes.get("z_index") {
1075        layout.z_index = Some(parse_int_attr(value, "z_index")?);
1076        has_any = true;
1077    }
1078
1079    // Validate the layout
1080    if has_any {
1081        layout
1082            .validate()
1083            .map_err(|e| format!("Layout validation failed: {}", e))?;
1084        Ok(Some(layout))
1085    } else {
1086        Ok(None)
1087    }
1088}
1089
1090/// Parse style-related attributes from the attributes map
1091fn parse_style_attributes(
1092    attributes: &HashMap<String, AttributeValue>,
1093) -> Result<Option<crate::ir::style::StyleProperties>, String> {
1094    use crate::parser::style_parser::{
1095        build_border, build_style_properties, parse_background_attr, parse_border_color,
1096        parse_border_radius, parse_border_style, parse_border_width, parse_color_attr,
1097        parse_opacity, parse_shadow_attr, parse_transform,
1098    };
1099
1100    let mut background = None;
1101    let mut color = None;
1102    let mut border_width = None;
1103    let mut border_color = None;
1104    let mut border_radius = None;
1105    let mut border_style = None;
1106    let mut shadow = None;
1107    let mut opacity = None;
1108    let mut transform = None;
1109    let mut has_any = false;
1110
1111    // Parse background
1112    if let Some(AttributeValue::Static(value)) = attributes.get("background") {
1113        background = Some(parse_background_attr(value)?);
1114        has_any = true;
1115    }
1116
1117    // Parse color
1118    if let Some(AttributeValue::Static(value)) = attributes.get("color") {
1119        color = Some(parse_color_attr(value)?);
1120        has_any = true;
1121    }
1122
1123    // Parse border attributes
1124    if let Some(AttributeValue::Static(value)) = attributes.get("border_width") {
1125        border_width = Some(parse_border_width(value)?);
1126        has_any = true;
1127    }
1128
1129    if let Some(AttributeValue::Static(value)) = attributes.get("border_color") {
1130        border_color = Some(parse_border_color(value)?);
1131        has_any = true;
1132    }
1133
1134    if let Some(AttributeValue::Static(value)) = attributes.get("border_radius") {
1135        border_radius = Some(parse_border_radius(value)?);
1136        has_any = true;
1137    }
1138
1139    if let Some(AttributeValue::Static(value)) = attributes.get("border_style") {
1140        border_style = Some(parse_border_style(value)?);
1141        has_any = true;
1142    }
1143
1144    // Parse shadow
1145    if let Some(AttributeValue::Static(value)) = attributes.get("shadow") {
1146        shadow = Some(parse_shadow_attr(value)?);
1147        has_any = true;
1148    }
1149
1150    // Parse opacity
1151    if let Some(AttributeValue::Static(value)) = attributes.get("opacity") {
1152        opacity = Some(parse_opacity(value)?);
1153        has_any = true;
1154    }
1155
1156    // Parse transform
1157    if let Some(AttributeValue::Static(value)) = attributes.get("transform") {
1158        transform = Some(parse_transform(value)?);
1159        has_any = true;
1160    }
1161
1162    if has_any {
1163        let border = build_border(border_width, border_color, border_radius, border_style)?;
1164        let style = build_style_properties(background, color, border, shadow, opacity, transform)?;
1165        Ok(Some(style))
1166    } else {
1167        Ok(None)
1168    }
1169}
1170
1171/// Validates that there are no circular dependencies in UI file includes
1172///
1173/// **T125**: Currently, Dampen does not support file includes/imports in XML,
1174/// so circular dependencies are not possible. This function is a placeholder
1175/// for future validation when UI file composition is added.
1176///
1177/// # Future Implementation
1178///
1179/// When UI file includes are added (e.g., `<include src="header.dampen" />`),
1180/// this function should:
1181/// 1. Build a dependency graph of all included files
1182/// 2. Detect cycles using depth-first search or topological sort
1183/// 3. Return ParseError with the cycle path if detected
1184///
1185/// # Arguments
1186///
1187/// * `file_path` - The root UI file being parsed
1188/// * `visited` - Set of already visited files (for cycle detection)
1189///
1190/// # Returns
1191///
1192/// `Ok(())` if no circular dependencies exist, or `Err(ParseError)` with
1193/// the dependency cycle information.
1194///
1195/// # Example Error Message
1196///
1197/// ```text
1198/// Circular UI file dependency detected:
1199///   app.dampen -> header.dampen -> nav.dampen -> app.dampen
1200/// ```
1201#[allow(dead_code)]
1202pub fn validate_no_circular_dependencies(
1203    _file_path: &std::path::Path,
1204    _visited: &mut std::collections::HashSet<std::path::PathBuf>,
1205) -> Result<(), ParseError> {
1206    // Placeholder: No includes supported yet, so no circular dependencies possible
1207    Ok(())
1208}
1209
1210#[cfg(test)]
1211mod circular_dependency_tests {
1212    use super::*;
1213    use std::collections::HashSet;
1214    use std::path::PathBuf;
1215
1216    #[test]
1217    fn test_no_circular_dependencies_without_includes() {
1218        // T125: Validate that single files have no circular dependencies
1219        let file_path = PathBuf::from("test.dampen");
1220        let mut visited = HashSet::new();
1221
1222        let result = validate_no_circular_dependencies(&file_path, &mut visited);
1223        assert!(
1224            result.is_ok(),
1225            "Single file should have no circular dependencies"
1226        );
1227    }
1228
1229    // Future tests when includes are supported:
1230    // - test_detect_simple_circular_dependency: A -> B -> A
1231    // - test_detect_complex_circular_dependency: A -> B -> C -> D -> B
1232    // - test_allow_diamond_dependencies: A->B, A->C, B->D, C->D (this is OK, not circular)
1233    // - test_self_include_rejected: A -> A
1234}