dampen_core/parser/
mod.rs

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