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