pub mod attribute_standard;
pub mod canvas;
pub mod color_validator;
pub mod error;
pub mod gradient;
pub mod lexer;
pub mod style_parser;
pub mod theme_parser;
use crate::expr::tokenize_binding_expr;
use crate::expr::{BindingExpr, Expr, LiteralExpr};
use crate::ir::style::StyleProperties;
use crate::ir::theme::WidgetState;
use crate::ir::{
AttributeValue, Breakpoint, DampenDocument, EventBinding, EventKind, InterpolatedPart,
SchemaVersion, Span, WidgetKind, WidgetNode,
};
use crate::parser::error::{ParseError, ParseErrorKind};
use chrono::{NaiveDate, NaiveTime};
use roxmltree::{Document, Node, NodeType};
use std::collections::HashMap;
pub const MAX_SUPPORTED_VERSION: SchemaVersion = SchemaVersion { major: 1, minor: 1 };
pub fn parse_version_string(version_str: &str, span: Span) -> Result<SchemaVersion, ParseError> {
let trimmed = version_str.trim();
if trimmed.is_empty() {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: "Version attribute cannot be empty".to_string(),
span,
suggestion: Some("Use format: version=\"1.0\"".to_string()),
});
}
let parts: Vec<&str> = trimmed.split('.').collect();
if parts.len() != 2 {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
trimmed
),
span,
suggestion: Some("Use format: version=\"1.0\"".to_string()),
});
}
let major = parts[0].parse::<u16>().map_err(|_| ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
trimmed
),
span,
suggestion: Some("Use format: version=\"1.0\"".to_string()),
})?;
let minor = parts[1].parse::<u16>().map_err(|_| ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"Invalid version format '{}'. Expected 'major.minor' (e.g., '1.0')",
trimmed
),
span,
suggestion: Some("Use format: version=\"1.0\"".to_string()),
})?;
Ok(SchemaVersion { major, minor })
}
pub fn validate_version_supported(version: &SchemaVersion, span: Span) -> Result<(), ParseError> {
if (version.major, version.minor) > (MAX_SUPPORTED_VERSION.major, MAX_SUPPORTED_VERSION.minor) {
return Err(ParseError {
kind: ParseErrorKind::UnsupportedVersion,
message: format!(
"Schema version {}.{} is not supported. Maximum supported version: {}.{}",
version.major,
version.minor,
MAX_SUPPORTED_VERSION.major,
MAX_SUPPORTED_VERSION.minor
),
span,
suggestion: Some(format!(
"Upgrade dampen-core to support v{}.{}, or use version=\"{}.{}\"",
version.major,
version.minor,
MAX_SUPPORTED_VERSION.major,
MAX_SUPPORTED_VERSION.minor
)),
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationWarning {
pub widget_kind: WidgetKind,
pub declared_version: SchemaVersion,
pub required_version: SchemaVersion,
pub span: Span,
}
impl ValidationWarning {
pub fn format_message(&self) -> String {
format!(
"Widget '{}' requires schema v{}.{} but document declares v{}.{}",
self.widget_kind,
self.required_version.major,
self.required_version.minor,
self.declared_version.major,
self.declared_version.minor
)
}
pub fn suggestion(&self) -> String {
format!(
"Update to <dampen version=\"{}.{}\"> or remove this widget",
self.required_version.major, self.required_version.minor
)
}
}
pub fn validate_widget_versions(document: &DampenDocument) -> Vec<ValidationWarning> {
let mut warnings = Vec::new();
validate_widget_tree(&document.root, &document.version, &mut warnings);
warnings
}
fn validate_widget_tree(
node: &WidgetNode,
doc_version: &SchemaVersion,
warnings: &mut Vec<ValidationWarning>,
) {
let min_version = node.kind.minimum_version();
if (min_version.major, min_version.minor) > (doc_version.major, doc_version.minor) {
warnings.push(ValidationWarning {
widget_kind: node.kind.clone(),
declared_version: *doc_version,
required_version: min_version,
span: node.span,
});
}
for child in &node.children {
validate_widget_tree(child, doc_version, warnings);
}
}
fn preprocess_xml(xml: &str) -> String {
let mut result = xml.to_string();
let states = ["hover", "active", "focus", "disabled"];
let prefixes = [' ', '\n', '\t', '\r'];
for state in states {
for prefix in prefixes {
let target = format!("{}{}:", prefix, state);
let sub = format!("{}{}_state_", prefix, state);
result = result.replace(&target, &sub);
}
}
result
}
pub fn parse(xml: &str) -> Result<DampenDocument, ParseError> {
let processed_xml = preprocess_xml(xml);
let doc = Document::parse(&processed_xml).map_err(|e| ParseError {
kind: ParseErrorKind::XmlSyntax,
message: e.to_string(),
span: Span::new(0, 0, 1, 1),
suggestion: None,
})?;
let root = doc.root().first_child().ok_or_else(|| ParseError {
kind: ParseErrorKind::XmlSyntax,
message: "No root element found".to_string(),
span: Span::new(0, 0, 1, 1),
suggestion: None,
})?;
let root_tag = root.tag_name().name();
if root_tag == "dampen" {
parse_dampen_document(root, xml)
} else {
let root_widget = parse_node(root, xml)?;
validate_nesting_constraints(&root_widget, None)?;
Ok(DampenDocument {
version: SchemaVersion::default(),
root: root_widget,
themes: HashMap::new(),
style_classes: HashMap::new(),
global_theme: None,
follow_system: true,
})
}
}
fn validate_widget_attributes(
kind: &WidgetKind,
attributes: &std::collections::HashMap<String, AttributeValue>,
span: Span,
) -> Result<(), ParseError> {
match kind {
WidgetKind::ComboBox | WidgetKind::PickList => {
require_non_empty_attribute(
kind,
"options",
attributes,
span,
"Add a comma-separated list: options=\"Option1,Option2\"",
)?;
}
WidgetKind::DatePicker => {
validate_date_format(kind, attributes, span)?;
validate_date_range(kind, attributes, span)?;
}
WidgetKind::TimePicker => {
validate_time_format(kind, attributes, span)?;
}
WidgetKind::Canvas => {
validate_numeric_range(kind, "width", attributes, span, 50..=4000)?;
validate_numeric_range(kind, "height", attributes, span, 50..=4000)?;
if attributes.contains_key("program") {
}
}
WidgetKind::Grid => {
require_attribute(
kind,
"columns",
attributes,
span,
"Add columns attribute: columns=\"5\"",
)?;
validate_numeric_range(kind, "columns", attributes, span, 1..=20)?;
}
WidgetKind::Tooltip => {
require_attribute(
kind,
"message",
attributes,
span,
"Add message attribute: message=\"Help text\"",
)?;
}
WidgetKind::For => {
require_attribute(
kind,
"each",
attributes,
span,
"Add each attribute: each=\"item\"",
)?;
require_attribute(
kind,
"in",
attributes,
span,
"Add in attribute: in=\"{items}\"",
)?;
}
WidgetKind::CanvasRect
| WidgetKind::CanvasCircle
| WidgetKind::CanvasLine
| WidgetKind::CanvasText
| WidgetKind::CanvasGroup => {
canvas::validate_shape_attributes(kind, attributes, span)?;
}
_ => {}
}
Ok(())
}
fn validate_date_format(
kind: &WidgetKind,
attributes: &HashMap<String, AttributeValue>,
span: Span,
) -> Result<(), ParseError> {
if let Some(AttributeValue::Static(value)) = attributes.get("value") {
let format = if let Some(AttributeValue::Static(f)) = attributes.get("format") {
f.as_str()
} else {
"%Y-%m-%d"
};
if NaiveDate::parse_from_str(value, format).is_err() {
return Err(ParseError {
kind: ParseErrorKind::InvalidDateFormat,
message: format!(
"Invalid date format for {:?}: '{}' does not match format '{}'",
kind, value, format
),
span,
suggestion: Some(
"Use ISO 8601 format (YYYY-MM-DD) or specify correct format attribute (e.g., format=\"%d/%m/%Y\")".to_string()
),
});
}
}
Ok(())
}
fn validate_time_format(
kind: &WidgetKind,
attributes: &HashMap<String, AttributeValue>,
span: Span,
) -> Result<(), ParseError> {
if let Some(AttributeValue::Static(value)) = attributes.get("value") {
let format = if let Some(AttributeValue::Static(f)) = attributes.get("format") {
f.as_str()
} else {
"%H:%M:%S"
};
if NaiveTime::parse_from_str(value, format).is_err() {
return Err(ParseError {
kind: ParseErrorKind::InvalidTimeFormat,
message: format!(
"Invalid time format for {:?}: '{}' does not match format '{}'",
kind, value, format
),
span,
suggestion: Some(
"Use 24-hour format (HH:MM:SS) or specify correct format attribute (e.g., format=\"%I:%M %p\")".to_string()
),
});
}
}
Ok(())
}
fn validate_date_range(
kind: &WidgetKind,
attributes: &HashMap<String, AttributeValue>,
span: Span,
) -> Result<(), ParseError> {
let min_date = if let Some(AttributeValue::Static(m)) = attributes.get("min_date") {
NaiveDate::parse_from_str(m, "%Y-%m-%d").ok()
} else {
None
};
let max_date = if let Some(AttributeValue::Static(m)) = attributes.get("max_date") {
NaiveDate::parse_from_str(m, "%Y-%m-%d").ok()
} else {
None
};
if let (Some(min), Some(max)) = (min_date, max_date)
&& min > max
{
return Err(ParseError {
kind: ParseErrorKind::InvalidDateRange,
message: format!(
"Invalid date range for {:?}: min_date ({}) is after max_date ({})",
kind, min, max
),
span,
suggestion: Some("Ensure min_date is before or equal to max_date".to_string()),
});
}
Ok(())
}
fn require_attribute(
kind: &WidgetKind,
attr_name: &str,
attributes: &HashMap<String, AttributeValue>,
span: Span,
suggestion: &str,
) -> Result<(), ParseError> {
if !attributes.contains_key(attr_name) {
return Err(ParseError {
kind: ParseErrorKind::MissingAttribute,
message: format!("{:?} widget requires '{}' attribute", kind, attr_name),
span,
suggestion: Some(suggestion.to_string()),
});
}
Ok(())
}
fn require_non_empty_attribute(
kind: &WidgetKind,
attr_name: &str,
attributes: &HashMap<String, AttributeValue>,
span: Span,
suggestion: &str,
) -> Result<(), ParseError> {
match attributes.get(attr_name) {
Some(AttributeValue::Static(value)) if !value.trim().is_empty() => Ok(()),
_ => Err(ParseError {
kind: ParseErrorKind::MissingAttribute,
message: format!(
"{:?} widget requires '{}' attribute to be non-empty",
kind, attr_name
),
span,
suggestion: Some(suggestion.to_string()),
}),
}
}
fn validate_numeric_range<T: PartialOrd + std::fmt::Display + std::str::FromStr>(
kind: &WidgetKind,
attr_name: &str,
attributes: &HashMap<String, AttributeValue>,
span: Span,
range: std::ops::RangeInclusive<T>,
) -> Result<(), ParseError> {
if let Some(AttributeValue::Static(value_str)) = attributes.get(attr_name)
&& let Ok(value) = value_str.parse::<T>()
&& !range.contains(&value)
{
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"{} for {:?} {} must be between {} and {}, found {}",
attr_name,
kind,
attr_name,
range.start(),
range.end(),
value
),
span,
suggestion: Some(format!(
"Use {} value between {} and {}",
attr_name,
range.start(),
range.end()
)),
});
}
Ok(())
}
fn validate_tooltip_children(children: &[WidgetNode], span: Span) -> Result<(), ParseError> {
if children.is_empty() {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: "Tooltip widget must have exactly one child widget".to_string(),
span,
suggestion: Some("Wrap a single widget in <tooltip></tooltip>".to_string()),
});
}
if children.len() > 1 {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"Tooltip widget must have exactly one child, found {}",
children.len()
),
span,
suggestion: Some("Wrap only one widget in <tooltip></tooltip>".to_string()),
});
}
Ok(())
}
fn validate_canvas_children(
attributes: &HashMap<String, AttributeValue>,
children: &[WidgetNode],
span: Span,
) -> Result<(), ParseError> {
if attributes.contains_key("program") && !children.is_empty() {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: "Canvas cannot have both a 'program' attribute and child shapes".to_string(),
span,
suggestion: Some("Remove the 'program' attribute to use declarative shapes, or remove children to use a custom program".to_string()),
});
}
canvas::validate_canvas_children(children, span)
}
fn validate_datetime_picker_children(
kind: &WidgetKind,
children: &[WidgetNode],
span: Span,
) -> Result<(), ParseError> {
if children.is_empty() {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"{:?} widget must have exactly one child widget (the underlay)",
kind
),
span,
suggestion: Some(format!(
"Wrap a single widget (e.g., <button>) in <{}>",
kind
)),
});
}
if children.len() > 1 {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!(
"{:?} widget must have exactly one child, found {}",
kind,
children.len()
),
span,
suggestion: Some(format!("Wrap only one widget in <{}>", kind)),
});
}
Ok(())
}
fn validate_context_menu_children(children: &[WidgetNode], span: Span) -> Result<(), ParseError> {
if children.len() != 2 {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: "ContextMenu requires exactly 2 children: underlay and menu".to_string(),
span,
suggestion: Some(
"Add an underlay widget (1st child) and a <menu> element (2nd child)".to_string(),
),
});
}
if children[1].kind != WidgetKind::Menu {
return Err(ParseError {
kind: ParseErrorKind::InvalidValue,
message: "Second child of ContextMenu must be <menu>".to_string(),
span: children[1].span,
suggestion: None,
});
}
Ok(())
}
fn parse_node(node: Node, source: &str) -> Result<WidgetNode, ParseError> {
if node.node_type() != NodeType::Element {
return Err(ParseError {
kind: ParseErrorKind::XmlSyntax,
message: "Expected element node".to_string(),
span: Span::new(0, 0, 1, 1),
suggestion: None,
});
}
let tag_name = node.tag_name().name();
let kind = match tag_name {
"column" => WidgetKind::Column,
"row" => WidgetKind::Row,
"container" => WidgetKind::Container,
"scrollable" => WidgetKind::Scrollable,
"stack" => WidgetKind::Stack,
"text" => WidgetKind::Text,
"image" => WidgetKind::Image,
"svg" => WidgetKind::Svg,
"button" => WidgetKind::Button,
"text_input" => WidgetKind::TextInput,
"checkbox" => WidgetKind::Checkbox,
"slider" => WidgetKind::Slider,
"pick_list" => WidgetKind::PickList,
"toggler" => WidgetKind::Toggler,
"space" => WidgetKind::Space,
"rule" => WidgetKind::Rule,
"radio" => WidgetKind::Radio,
"combobox" => WidgetKind::ComboBox,
"progress_bar" => WidgetKind::ProgressBar,
"tooltip" => WidgetKind::Tooltip,
"grid" => WidgetKind::Grid,
"canvas" => WidgetKind::Canvas,
"rect" => WidgetKind::CanvasRect,
"circle" => WidgetKind::CanvasCircle,
"line" => WidgetKind::CanvasLine,
"canvas_text" => WidgetKind::CanvasText,
"group" => WidgetKind::CanvasGroup,
"date_picker" => WidgetKind::DatePicker,
"time_picker" => WidgetKind::TimePicker,
"color_picker" => WidgetKind::ColorPicker,
"menu" => WidgetKind::Menu,
"menu_item" => WidgetKind::MenuItem,
"menu_separator" => WidgetKind::MenuSeparator,
"context_menu" => WidgetKind::ContextMenu,
"float" => WidgetKind::Float,
"data_table" => WidgetKind::DataTable,
"data_column" => WidgetKind::DataColumn,
"tree_view" => WidgetKind::TreeView,
"tree_node" => WidgetKind::TreeNode,
"tab_bar" => WidgetKind::TabBar,
"tab" => WidgetKind::Tab,
"template" => WidgetKind::Custom("template".to_string()),
"for" => WidgetKind::For,
"if" => WidgetKind::If,
unknown => {
return Err(ParseError {
kind: ParseErrorKind::UnknownWidget,
message: format!("Unknown widget: {}", unknown),
span: get_span(node, source),
suggestion: Some(format!(
"Valid widgets are: {}",
WidgetKind::all_standard().join(", ")
)),
});
}
};
let mut attributes = std::collections::HashMap::new();
let mut breakpoint_attributes: HashMap<Breakpoint, HashMap<String, AttributeValue>> =
HashMap::new();
let mut inline_state_variants: HashMap<WidgetState, HashMap<String, AttributeValue>> =
HashMap::new();
let mut events = Vec::new();
let mut id = None;
for attr in node.attributes() {
if kind == WidgetKind::ColorPicker && attr.name() == "value" {
color_validator::validate_color_format(attr.value(), get_span(node, source))?;
}
}
for attr in node.attributes() {
let name = if let Some(ns) = attr.namespace() {
if ns.starts_with("urn:dampen:state") {
let prefix = node
.namespaces()
.find(|n| n.uri() == ns)
.and_then(|n| n.name())
.unwrap_or("");
format!("{}:{}", prefix, attr.name())
} else {
attr.name().to_string()
}
} else {
attr.name().to_string()
};
let value = attr.value();
if name == "id" {
id = Some(value.to_string());
continue;
}
if name.starts_with("on_") {
let event_kind = match name.as_str() {
"on_click" => Some(EventKind::CanvasClick), "on_press" => Some(EventKind::Press),
"on_release" => Some(EventKind::CanvasRelease),
"on_drag" => Some(EventKind::CanvasDrag),
"on_move" => Some(EventKind::CanvasMove),
"on_change" => Some(EventKind::Change),
"on_input" => Some(EventKind::Input),
"on_submit" => Some(EventKind::Submit),
"on_select" => Some(EventKind::Select),
"on_toggle" => Some(EventKind::Toggle),
"on_scroll" => Some(EventKind::Scroll),
"on_cancel" => Some(EventKind::Cancel),
"on_open" => Some(EventKind::Open),
"on_close" => Some(EventKind::Close),
"on_row_click" => Some(EventKind::RowClick),
_ => None,
};
let event_kind = if kind != WidgetKind::Canvas {
match name.as_str() {
"on_click" => Some(EventKind::Click),
"on_release" => Some(EventKind::Release),
_ => event_kind,
}
} else {
event_kind
};
if let Some(event) = event_kind {
let (handler_name, param) = if let Some(colon_pos) = value.find(':') {
let handler = value[..colon_pos].to_string();
let param_str = &value[colon_pos + 1..];
if param_str.starts_with('\'')
&& param_str.ends_with('\'')
&& param_str.len() >= 2
{
let quoted_value = ¶m_str[1..param_str.len() - 1];
let expr = BindingExpr {
expr: Expr::Literal(LiteralExpr::String(quoted_value.to_string())),
span: Span::new(
colon_pos + 1,
colon_pos + 1 + param_str.len(),
1,
colon_pos as u32 + 1,
),
};
(handler, Some(expr))
} else {
let param_clean = param_str.trim_matches('{').trim_matches('}');
match crate::expr::tokenize_binding_expr(param_clean, 0, 1, 1) {
Ok(expr) => (handler, Some(expr)),
Err(_) => {
(value.to_string(), None)
}
}
}
} else {
(value.to_string(), None)
};
events.push(EventBinding {
event,
handler: handler_name,
param,
span: get_span(node, source),
});
continue;
}
}
if let Some((prefix, attr_name)) = name.split_once('-')
&& let Ok(breakpoint) = crate::ir::layout::Breakpoint::parse(prefix)
{
let attr_value = parse_attribute_value(value, get_span(node, source))?;
breakpoint_attributes
.entry(breakpoint)
.or_default()
.insert(attr_name.to_string(), attr_value);
continue;
}
if let Some((state_prefix, attr_name)) = name.split_once(':')
&& let Some(state) = WidgetState::from_prefix(state_prefix)
{
let attr_value = parse_attribute_value(value, get_span(node, source))?;
inline_state_variants
.entry(state)
.or_default()
.insert(attr_name.to_string(), attr_value);
continue;
}
if let Some((state_prefix, attr_name)) = name.split_once("_state_")
&& let Some(state) = WidgetState::from_prefix(state_prefix)
{
let attr_value = parse_attribute_value(value, get_span(node, source))?;
inline_state_variants
.entry(state)
.or_default()
.insert(attr_name.to_string(), attr_value);
continue;
}
let attr_value = parse_attribute_value(value, get_span(node, source))?;
attributes.insert(name.to_string(), attr_value);
}
let classes = if let Some(AttributeValue::Static(class_attr)) = attributes.get("class") {
class_attr
.split_whitespace()
.map(|s| s.to_string())
.collect()
} else {
Vec::new()
};
let theme_ref = attributes.get("theme").cloned();
let mut children = Vec::new();
for child in node.children() {
if child.node_type() == NodeType::Element {
children.push(parse_node(child, source)?);
}
}
if kind == WidgetKind::Tooltip {
validate_tooltip_children(&children, get_span(node, source))?;
}
if kind == WidgetKind::Canvas {
validate_canvas_children(&attributes, &children, get_span(node, source))?;
}
if matches!(kind, WidgetKind::DatePicker | WidgetKind::TimePicker) {
validate_datetime_picker_children(&kind, &children, get_span(node, source))?;
}
if kind == WidgetKind::ContextMenu {
validate_context_menu_children(&children, get_span(node, source))?;
}
let layout = parse_layout_attributes(&kind, &attributes).map_err(|e| ParseError {
kind: ParseErrorKind::InvalidValue,
message: e,
span: get_span(node, source),
suggestion: None,
})?;
let style = parse_style_attributes(&attributes).map_err(|e| ParseError {
kind: ParseErrorKind::InvalidValue,
message: e,
span: get_span(node, source),
suggestion: None,
})?;
let _attr_warnings = attribute_standard::normalize_attributes(&kind, &mut attributes);
validate_widget_attributes(&kind, &attributes, get_span(node, source))?;
let mut final_state_variants: HashMap<WidgetState, StyleProperties> = HashMap::new();
for (state, state_attrs) in inline_state_variants {
if let Some(state_style) = parse_style_attributes(&state_attrs).map_err(|e| ParseError {
kind: ParseErrorKind::InvalidValue,
message: format!("Invalid style in {:?} state: {}", state, e),
span: get_span(node, source),
suggestion: None,
})? {
final_state_variants.insert(state, state_style);
}
}
Ok(WidgetNode {
kind,
id,
attributes,
events,
children,
span: get_span(node, source),
style,
layout,
theme_ref,
classes,
breakpoint_attributes,
inline_state_variants: final_state_variants,
})
}
fn parse_dampen_document(root: Node, source: &str) -> Result<DampenDocument, ParseError> {
let mut themes = HashMap::new();
let mut style_classes = HashMap::new();
let mut root_widget = None;
let mut global_theme = None;
let mut follow_system = true;
let span = get_span(root, source);
let version = if let Some(version_attr) = root.attribute("version") {
let parsed = parse_version_string(version_attr, span)?;
validate_version_supported(&parsed, span)?;
parsed
} else {
SchemaVersion::default()
};
for child in root.children() {
if child.node_type() != NodeType::Element {
continue;
}
let tag_name = child.tag_name().name();
match tag_name {
"themes" => {
for theme_node in child.children() {
if theme_node.node_type() == NodeType::Element
&& theme_node.tag_name().name() == "theme"
{
let theme =
crate::parser::theme_parser::parse_theme_from_node(theme_node, source)?;
let name = theme_node
.attribute("name")
.map(|s| s.to_string())
.unwrap_or_else(|| "default".to_string());
themes.insert(name, theme);
}
}
}
"style_classes" | "classes" | "styles" => {
for class_node in child.children() {
if class_node.node_type() == NodeType::Element {
let tag = class_node.tag_name().name();
if tag == "class" || tag == "style" {
let class = crate::parser::theme_parser::parse_style_class_from_node(
class_node, source,
)?;
style_classes.insert(class.name.clone(), class);
}
}
}
}
"global_theme" | "default_theme" => {
if let Some(theme_name) = child.attribute("name") {
global_theme = Some(theme_name.to_string());
}
}
"follow_system" => {
if let Some(enabled) = child.attribute("enabled") {
follow_system = enabled.parse::<bool>().unwrap_or(true);
}
}
_ => {
if root_widget.is_some() {
return Err(ParseError {
kind: ParseErrorKind::XmlSyntax,
message: "Multiple root widgets found in <dampen>".to_string(),
span: get_span(child, source),
suggestion: Some("Only one root widget is allowed".to_string()),
});
}
root_widget = Some(parse_node(child, source)?);
}
}
}
let root_widget = if let Some(w) = root_widget {
w
} else if !themes.is_empty() || !style_classes.is_empty() {
WidgetNode::default()
} else {
return Err(ParseError {
kind: ParseErrorKind::XmlSyntax,
message: "No root widget found in <dampen>".to_string(),
span: get_span(root, source),
suggestion: Some("Add a widget like <column> or <row> inside <dampen>".to_string()),
});
};
validate_widget_versions_strict(&root_widget, &version)?;
validate_nesting_constraints(&root_widget, None)?;
Ok(DampenDocument {
version,
root: root_widget,
themes,
style_classes,
global_theme,
follow_system,
})
}
fn validate_nesting_constraints(
node: &WidgetNode,
parent_kind: Option<&WidgetKind>,
) -> Result<(), ParseError> {
if node.kind == WidgetKind::DataColumn && parent_kind != Some(&WidgetKind::DataTable) {
return Err(ParseError {
kind: ParseErrorKind::InvalidChild,
message: "DataColumn must be a direct child of DataTable".to_string(),
span: node.span,
suggestion: Some("Wrap this column in a <data_table>".to_string()),
});
}
if node.kind == WidgetKind::Tab && parent_kind != Some(&WidgetKind::TabBar) {
return Err(ParseError {
kind: ParseErrorKind::InvalidChild,
message: "Tab must be inside TabBar".to_string(),
span: node.span,
suggestion: Some("Wrap this tab in a <tab_bar>".to_string()),
});
}
if node.kind == WidgetKind::TabBar {
for child in &node.children {
if child.kind != WidgetKind::Tab {
return Err(ParseError {
kind: ParseErrorKind::InvalidChild,
message: "TabBar can only contain Tab widgets".to_string(),
span: child.span,
suggestion: Some("Use <tab> elements inside <tab_bar>".to_string()),
});
}
}
}
for child in &node.children {
validate_nesting_constraints(child, Some(&node.kind))?;
}
Ok(())
}
fn validate_widget_versions_strict(
node: &WidgetNode,
doc_version: &SchemaVersion,
) -> Result<(), ParseError> {
let min_version = node.kind.minimum_version();
if (min_version.major, min_version.minor) > (doc_version.major, doc_version.minor) {
return Err(ParseError {
kind: ParseErrorKind::UnsupportedVersion,
message: format!(
"Widget '{}' requires schema v{}.{} but document declares v{}.{}",
node.kind,
min_version.major,
min_version.minor,
doc_version.major,
doc_version.minor
),
span: node.span,
suggestion: Some(format!(
"Update to <dampen version=\"{}.{}\"> or remove this widget",
min_version.major, min_version.minor
)),
});
}
for child in &node.children {
validate_widget_versions_strict(child, doc_version)?;
}
Ok(())
}
pub fn parse_comma_separated(value: &str) -> Vec<String> {
value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn parse_enum_value<T>(value: &str, valid_variants: &[&str]) -> Result<T, String>
where
T: std::str::FromStr + std::fmt::Display,
{
let normalized = value.trim().to_lowercase();
for variant in valid_variants.iter() {
if variant.to_lowercase() == normalized {
return T::from_str(variant).map_err(|_| {
format!(
"Failed to parse '{}' as {}",
variant,
std::any::type_name::<T>()
)
});
}
}
Err(format!(
"Invalid value '{}'. Valid options: {}",
value,
valid_variants.join(", ")
))
}
fn parse_attribute_value(value: &str, span: Span) -> Result<AttributeValue, ParseError> {
if value.contains('{') && value.contains('}') {
let mut parts = Vec::new();
let mut remaining = value;
while let Some(start_pos) = remaining.find('{') {
if start_pos > 0 {
parts.push(InterpolatedPart::Literal(
remaining[..start_pos].to_string(),
));
}
if let Some(end_pos) = remaining[start_pos..].find('}') {
let expr_start = start_pos + 1;
let expr_end = start_pos + end_pos;
let expr_str = &remaining[expr_start..expr_end];
let binding_expr = tokenize_binding_expr(
expr_str,
span.start + expr_start,
span.line,
span.column + expr_start as u32,
)
.map_err(|e| ParseError {
kind: ParseErrorKind::InvalidExpression,
message: format!("Invalid expression: {}", e),
span: Span::new(
span.start + expr_start,
span.start + expr_end,
span.line,
span.column + expr_start as u32,
),
suggestion: None,
})?;
parts.push(InterpolatedPart::Binding(binding_expr));
remaining = &remaining[expr_end + 1..];
} else {
parts.push(InterpolatedPart::Literal(remaining.to_string()));
break;
}
}
if !remaining.is_empty() {
parts.push(InterpolatedPart::Literal(remaining.to_string()));
}
if parts.len() == 1 {
match &parts[0] {
InterpolatedPart::Binding(expr) => {
return Ok(AttributeValue::Binding(expr.clone()));
}
InterpolatedPart::Literal(lit) => {
return Ok(AttributeValue::Static(lit.clone()));
}
}
} else {
return Ok(AttributeValue::Interpolated(parts));
}
}
Ok(AttributeValue::Static(value.to_string()))
}
fn get_span(node: Node, source: &str) -> Span {
let range = node.range();
let (line, col) = calculate_line_col(source, range.start);
Span {
start: range.start,
end: range.end,
line,
column: col,
}
}
fn calculate_line_col(source: &str, offset: usize) -> (u32, u32) {
if offset == 0 {
return (1, 1);
}
let mut line = 1;
let mut col = 1;
for (i, c) in source.char_indices().take(offset.saturating_add(1)) {
if i >= offset {
break;
}
if c == '\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn parse_layout_attributes(
kind: &WidgetKind,
attributes: &HashMap<String, AttributeValue>,
) -> Result<Option<crate::ir::layout::LayoutConstraints>, String> {
use crate::ir::layout::LayoutConstraints;
use crate::parser::style_parser::{
parse_alignment, parse_constraint, parse_float_attr, parse_int_attr, parse_justification,
parse_length_attr, parse_padding_attr, parse_spacing,
};
let mut layout = LayoutConstraints::default();
let mut has_any = false;
if let Some(AttributeValue::Static(value)) = attributes.get("width") {
layout.width = Some(parse_length_attr(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("height") {
layout.height = Some(parse_length_attr(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("min_width") {
layout.min_width = Some(parse_constraint(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("max_width") {
layout.max_width = Some(parse_constraint(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("min_height") {
layout.min_height = Some(parse_constraint(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("max_height") {
layout.max_height = Some(parse_constraint(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("padding") {
layout.padding = Some(parse_padding_attr(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("spacing") {
layout.spacing = Some(parse_spacing(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("align_items") {
layout.align_items = Some(parse_alignment(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("justify_content") {
layout.justify_content = Some(parse_justification(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("align_self") {
layout.align_self = Some(parse_alignment(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("align_x") {
layout.align_x = Some(parse_alignment(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("align_y") {
layout.align_y = Some(parse_alignment(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("align") {
let alignment = parse_alignment(value)?;
layout.align_items = Some(alignment);
layout.justify_content = Some(match alignment {
crate::ir::layout::Alignment::Start => crate::ir::layout::Justification::Start,
crate::ir::layout::Alignment::Center => crate::ir::layout::Justification::Center,
crate::ir::layout::Alignment::End => crate::ir::layout::Justification::End,
crate::ir::layout::Alignment::Stretch => crate::ir::layout::Justification::Center,
});
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("direction") {
layout.direction = Some(crate::ir::layout::Direction::parse(value)?);
has_any = true;
}
if !matches!(kind, WidgetKind::Tooltip)
&& let Some(AttributeValue::Static(value)) = attributes.get("position")
{
layout.position = Some(crate::ir::layout::Position::parse(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("top") {
layout.top = Some(parse_float_attr(value, "top")?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("right") {
layout.right = Some(parse_float_attr(value, "right")?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("bottom") {
layout.bottom = Some(parse_float_attr(value, "bottom")?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("left") {
layout.left = Some(parse_float_attr(value, "left")?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("z_index") {
layout.z_index = Some(parse_int_attr(value, "z_index")?);
has_any = true;
}
if has_any {
layout
.validate()
.map_err(|e| format!("Layout validation failed: {}", e))?;
Ok(Some(layout))
} else {
Ok(None)
}
}
fn parse_style_attributes(
attributes: &HashMap<String, AttributeValue>,
) -> Result<Option<crate::ir::style::StyleProperties>, String> {
use crate::parser::style_parser::{
build_border, build_style_properties, parse_background_attr, parse_border_color,
parse_border_radius, parse_border_style, parse_border_width, parse_color_attr,
parse_opacity, parse_shadow_attr, parse_transform,
};
let mut background = None;
let mut color = None;
let mut border_width = None;
let mut border_color = None;
let mut border_radius = None;
let mut border_style = None;
let mut shadow = None;
let mut opacity = None;
let mut transform = None;
let mut has_any = false;
if let Some(AttributeValue::Static(value)) = attributes.get("background") {
background = Some(parse_background_attr(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("color") {
color = Some(parse_color_attr(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("border_width") {
border_width = Some(parse_border_width(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("border_color") {
border_color = Some(parse_border_color(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("border_radius") {
border_radius = Some(parse_border_radius(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("border_style") {
border_style = Some(parse_border_style(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("shadow") {
shadow = Some(parse_shadow_attr(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("opacity") {
opacity = Some(parse_opacity(value)?);
has_any = true;
}
if let Some(AttributeValue::Static(value)) = attributes.get("transform") {
transform = Some(parse_transform(value)?);
has_any = true;
}
if has_any {
let border = build_border(border_width, border_color, border_radius, border_style)?;
let style = build_style_properties(background, color, border, shadow, opacity, transform)?;
Ok(Some(style))
} else {
Ok(None)
}
}
pub fn validate_no_circular_dependencies(
_file_path: &std::path::Path,
_visited: &mut std::collections::HashSet<std::path::PathBuf>,
) -> Result<(), ParseError> {
Ok(())
}
#[cfg(test)]
mod circular_dependency_tests {
use super::*;
use std::collections::HashSet;
use std::path::PathBuf;
#[test]
fn test_no_circular_dependencies_without_includes() {
let file_path = PathBuf::from("test.dampen");
let mut visited = HashSet::new();
let result = validate_no_circular_dependencies(&file_path, &mut visited);
assert!(
result.is_ok(),
"Single file should have no circular dependencies"
);
}
}
#[cfg(test)]
mod inline_state_styles_tests {
use super::*;
use crate::ir::theme::WidgetState;
#[test]
fn test_parse_single_state_attribute() {
let xml = r##"
<dampen version="1.0" xmlns:hover="urn:dampen:state:hover">
<button label="Click" hover:background="#ff0000" />
</dampen>
"##;
let result = parse(xml);
assert!(result.is_ok(), "Should parse valid XML with hover state");
let doc = result.unwrap();
let button = &doc.root;
assert!(
button
.inline_state_variants
.contains_key(&WidgetState::Hover),
"Should have hover state variant"
);
let hover_style = button
.inline_state_variants
.get(&WidgetState::Hover)
.unwrap();
assert!(
hover_style.background.is_some(),
"Hover state should have background"
);
}
#[test]
fn test_parse_multiple_state_attributes() {
let xml = r##"
<dampen version="1.0"
xmlns:hover="urn:dampen:state:hover"
xmlns:active="urn:dampen:state:active"
xmlns:disabled="urn:dampen:state:disabled">
<button
label="Click"
hover:background="#ff0000"
active:background="#00ff00"
disabled:opacity="0.5"
/>
</dampen>
"##;
let result = parse(xml);
assert!(
result.is_ok(),
"Should parse valid XML with multiple states"
);
let doc = result.unwrap();
let button = &doc.root;
assert!(
button
.inline_state_variants
.contains_key(&WidgetState::Hover),
"Should have hover state"
);
assert!(
button
.inline_state_variants
.contains_key(&WidgetState::Active),
"Should have active state"
);
assert!(
button
.inline_state_variants
.contains_key(&WidgetState::Disabled),
"Should have disabled state"
);
let hover_style = button
.inline_state_variants
.get(&WidgetState::Hover)
.unwrap();
assert!(
hover_style.background.is_some(),
"Hover state should have background"
);
let active_style = button
.inline_state_variants
.get(&WidgetState::Active)
.unwrap();
assert!(
active_style.background.is_some(),
"Active state should have background"
);
let disabled_style = button
.inline_state_variants
.get(&WidgetState::Disabled)
.unwrap();
assert!(
disabled_style.opacity.is_some(),
"Disabled state should have opacity"
);
}
#[test]
fn test_parse_invalid_state_prefix() {
let xml = r##"
<dampen version="1.0" xmlns:unknown="urn:dampen:state:unknown">
<button label="Click" unknown:background="#ff0000" />
</dampen>
"##;
let result = parse(xml);
assert!(
result.is_ok(),
"Should parse with warning for invalid state"
);
let doc = result.unwrap();
let button = &doc.root;
assert!(
button.inline_state_variants.is_empty(),
"Should have no state variants for invalid prefix"
);
assert!(
button.attributes.contains_key("unknown:background"),
"Invalid state prefix should be treated as regular attribute"
);
}
}