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