//! XML and XHTML parsing for declarative UI definitions.
//!
//! This module provides comprehensive XML parsing and manipulation for Azul's XML-based
//! UI format (`.azul` files). It supports:
//!
//! - **XHTML parsing**: Parse HTML-like syntax into DOM structures
//! - **CSS extraction**: Extract `<style>` blocks and inline styles
//! - **Component system**: Define reusable UI components with arguments
//! - **Hot reload**: Track file changes and rebuild UI incrementally
//! - **Error reporting**: Detailed syntax error messages with line/column info
//!
//! # Examples
//!
//! ```rust,no_run,ignore
//! use azul_core::xml::{XmlNode, XmlParseOptions};
//!
//! let xml = "<div>Hello</div>";
//! // let node = XmlNode::parse(xml)?;
//! ```
use alloc::{
boxed::Box,
collections::BTreeMap,
string::{String, ToString},
vec::Vec,
};
use core::{fmt, fmt::Write, hash::Hash};
use azul_css::{
css::{
Css, CssDeclaration, CssPath, CssPathPseudoSelector, CssPathSelector, CssRuleBlock,
NodeTypeTag,
},
codegen::format::VecContents,
parser2::{CssParseErrorOwned, ErrorLocation},
props::{
basic::{ColorU, StyleFontFamilyVec},
property::CssProperty,
style::{
NormalizedLinearColorStopVec, NormalizedRadialColorStopVec, StyleBackgroundContentVec,
StyleBackgroundPositionVec, StyleBackgroundRepeatVec, StyleBackgroundSizeVec,
StyleTransformVec,
},
},
AzString, OptionString, StringVec, U8Vec,
};
use crate::{
dom::{Dom, NodeType, OptionNodeType},
styled_dom::StyledDom,
window::{AzStringPair, StringPairVec},
};
/// Error that can occur during XML parsing or hot-reload.
///
/// Stringified for error reporting; not part of the public API.
pub type SyntaxError = String;
/// Tag of an XML node, such as the "button" in `<button>Hello</button>`.
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct XmlTagName {
pub inner: AzString,
}
impl From<AzString> for XmlTagName {
fn from(s: AzString) -> Self {
Self { inner: s }
}
}
impl From<String> for XmlTagName {
fn from(s: String) -> Self {
Self { inner: s.into() }
}
}
impl From<&str> for XmlTagName {
fn from(s: &str) -> Self {
Self { inner: s.into() }
}
}
impl core::ops::Deref for XmlTagName {
type Target = AzString;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
/// (Unparsed) text content of an XML node, such as the "Hello" in `<button>Hello</button>`.
pub type XmlTextContent = OptionString;
/// Attributes of an XML node, such as `["color" => "blue"]` in `<button color="blue" />`.
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct XmlAttributeMap {
pub inner: StringPairVec,
}
impl From<StringPairVec> for XmlAttributeMap {
fn from(v: StringPairVec) -> Self {
Self { inner: v }
}
}
impl core::ops::Deref for XmlAttributeMap {
type Target = StringPairVec;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl core::ops::DerefMut for XmlAttributeMap {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}
/// Name of a component argument (e.g. `"text"`, `"href"`).
type ComponentArgumentName = String;
/// Type of a component argument as a string (e.g. `"String"`, `"bool"`).
type ComponentArgumentType = String;
/// Zero-based position of an argument in the component's argument list.
type ComponentArgumentOrder = usize;
/// FFI-safe replacement for `(ComponentArgumentName, ComponentArgumentType)` tuple.
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct ComponentArgument {
pub name: AzString,
pub arg_type: AzString,
}
impl_vec!(
ComponentArgument,
ComponentArgumentVec,
ComponentArgumentVecDestructor,
ComponentArgumentVecDestructorType,
ComponentArgumentVecSlice,
OptionComponentArgument
);
impl_option!(
ComponentArgument,
OptionComponentArgument,
copy = false,
[Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
);
impl_vec_debug!(ComponentArgument, ComponentArgumentVec);
impl_vec_partialeq!(ComponentArgument, ComponentArgumentVec);
impl_vec_eq!(ComponentArgument, ComponentArgumentVec);
impl_vec_partialord!(ComponentArgument, ComponentArgumentVec);
impl_vec_ord!(ComponentArgument, ComponentArgumentVec);
impl_vec_hash!(ComponentArgument, ComponentArgumentVec);
impl_vec_clone!(
ComponentArgument,
ComponentArgumentVec,
ComponentArgumentVecDestructor
);
impl_vec_mut!(ComponentArgument, ComponentArgumentVec);
/// Holds the list of arguments and whether the component accepts text content.
/// Used by the compile pipeline to generate Rust function signatures.
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ComponentArguments {
pub args: ComponentArgumentVec,
pub accepts_text: bool,
}
/// Name of an XML/HTML component (e.g. `"button"`, `"my-widget"`).
type ComponentName = String;
/// Compiled source code string for a component.
type CompiledComponent = String;
/// Universal HTML attribute names that are handled by the framework
/// and should not be passed through to component-specific argument lists.
const DEFAULT_ARGS: [&str; 8] = [
"id",
"class",
"tabindex",
"focusable",
"accepts_text",
"name",
"style",
"args",
];
/// Opaque void type for FFI pointers. Uses a custom definition instead of
/// `core::ffi::c_void` for `#[repr(C)]` compatibility in the generated API.
#[allow(non_camel_case_types)]
#[derive(Debug, Copy, Clone)]
pub enum c_void {}
/// Type of an XML node in the parsed tree.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub enum XmlNodeType {
Root,
Element,
PI,
Comment,
Text,
}
/// A namespace-qualified XML name (e.g. `svg:rect` has namespace `"svg"` and local name `"rect"`).
#[repr(C)]
#[derive(Debug)]
pub struct XmlQualifiedName {
pub local_name: AzString,
pub namespace: OptionString,
}
/// Classification of an external resource referenced in HTML/XML
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub enum ExternalResourceKind {
/// Image resource (img src, background-image, etc.)
Image,
/// Font resource (@font-face src, link rel="preload" as="font")
Font,
/// Stylesheet (link rel="stylesheet", @import)
Stylesheet,
/// Script (script src)
Script,
/// Favicon or icon
Icon,
/// Video source
Video,
/// Audio source
Audio,
/// Generic link or unknown resource type
Unknown,
}
/// MIME type hint for an external resource
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct MimeTypeHint {
pub inner: AzString,
}
impl MimeTypeHint {
#[must_use] pub fn new(s: &str) -> Self {
Self {
inner: AzString::from(s),
}
}
#[must_use] pub fn from_extension(ext: &str) -> Self {
let mime = match ext.to_lowercase().as_str() {
// Images
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"bmp" => "image/bmp",
"avif" => "image/avif",
// Fonts
"ttf" => "font/ttf",
"otf" => "font/otf",
"woff" => "font/woff",
"woff2" => "font/woff2",
"eot" => "application/vnd.ms-fontobject",
// Stylesheets
"css" => "text/css",
// Scripts
"js" | "mjs" => "application/javascript",
// Video
"mp4" => "video/mp4",
"webm" => "video/webm",
"ogg" => "video/ogg",
// Audio
"mp3" => "audio/mpeg",
"wav" => "audio/wav",
"flac" => "audio/flac",
// Default
_ => "application/octet-stream",
};
Self {
inner: AzString::from(mime),
}
}
}
impl_option!(
MimeTypeHint,
OptionMimeTypeHint,
copy = false,
[Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
);
/// An external resource URL found in an XML/HTML document
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct ExternalResource {
/// The URL as found in the document (may be relative or absolute)
pub url: AzString,
/// Classification of the resource type
pub kind: ExternalResourceKind,
/// MIME type hint (from type attribute, file extension, or heuristics)
pub mime_type: OptionMimeTypeHint,
/// The HTML element that referenced this resource (e.g., "img", "link", "script")
pub source_element: AzString,
/// The attribute that contained the URL (e.g., "src", "href")
pub source_attribute: AzString,
}
impl_option!(
ExternalResource,
OptionExternalResource,
copy = false,
[Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
);
impl_vec!(
ExternalResource,
ExternalResourceVec,
ExternalResourceVecDestructor,
ExternalResourceVecDestructorType,
ExternalResourceVecSlice,
OptionExternalResource
);
impl_vec_mut!(ExternalResource, ExternalResourceVec);
impl_vec_debug!(ExternalResource, ExternalResourceVec);
impl_vec_partialeq!(ExternalResource, ExternalResourceVec);
impl_vec_eq!(ExternalResource, ExternalResourceVec);
impl_vec_partialord!(ExternalResource, ExternalResourceVec);
impl_vec_ord!(ExternalResource, ExternalResourceVec);
impl_vec_hash!(ExternalResource, ExternalResourceVec);
impl_vec_clone!(
ExternalResource,
ExternalResourceVec,
ExternalResourceVecDestructor
);
/// AUDIT 2026-07-08: maximum XML/HTML nesting depth handled by the recursive
/// DOM-build (`xml_node_to_dom_fast`, `xml_node_to_fast_dom`), resource-scan
/// (iterative worklist in `scan_external_resources`) and `<body>`-lookup
/// (`find_body_recursive`) passes. These bound descent per nesting level, so a pathologically deep
/// document (e.g. tens of thousands of nested `<div>`s) would overflow the native
/// stack. Beyond this depth, deeper children are ignored rather than crashing.
/// 512 is far past any realistic hand-authored markup while staying comfortably
/// inside the default thread stack.
const MAX_XML_NESTING_DEPTH: usize = 512;
/// AUDIT 2026-07-08: maximum recursion depth for [`ComponentFieldType::parse`],
/// which recurses through `Option<..>` / `Vec<..>` wrappers. Caps attacker
/// strings such as `"Option<".repeat(100_000)` that would otherwise overflow the
/// stack. 64 nested type wrappers is far beyond any real field type.
const MAX_TYPE_PARSE_DEPTH: usize = 64;
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct Xml {
pub root: XmlNodeChildVec,
}
impl Xml {
/// Scan the XML/HTML document for external resource URLs.
///
/// This function traverses the entire document tree and extracts URLs from:
/// - `<img src="...">` - Images
/// - `<link href="...">` - Stylesheets, icons, fonts
/// - `<script src="...">` - Scripts
/// - `<video src="...">`, `<source src="...">` - Video
/// - `<audio src="...">` - Audio
/// - `<a href="...">` - Links (classified as Unknown)
/// - CSS `url()` in style attributes
/// - `<style>` blocks with @import or `url()`
#[must_use] pub fn scan_external_resources(&self) -> ExternalResourceVec {
let mut resources = Vec::new();
// AUDIT 2026-07-08: iterative DFS with an explicit worklist. The old
// per-node recursion overflowed the stack on pathologically deep markup
// (a single-purpose scan frame is large: string lowercasing + closure +
// wide match). An explicit stack keeps memory on the heap; `depth` still
// bounds how deep we descend so unbounded input can't grow the worklist
// without limit.
let mut stack: Vec<(&XmlNodeChild, usize)> = Vec::new();
for child in self.root.as_ref() {
stack.push((child, 0));
}
while let Some((child, depth)) = stack.pop() {
match child {
XmlNodeChild::Text(text) => {
// CSS @import / url() in text content (inside <style> tags).
Self::extract_css_urls(text.as_str(), &mut resources);
}
XmlNodeChild::Element(node) => {
if depth > MAX_XML_NESTING_DEPTH {
// Deeper subtrees are simply not scanned.
continue;
}
Self::scan_node(node, &mut resources);
for c in node.children.as_ref() {
stack.push((c, depth + 1));
}
}
}
}
resources.into()
}
#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
fn scan_node(node: &XmlNode, resources: &mut Vec<ExternalResource>) {
let tag_name = node.node_type.inner.as_str().to_lowercase();
// Get attribute lookup helper
let get_attr = |name: &str| -> Option<String> {
node.attributes
.inner
.as_ref()
.iter()
.find(|pair| pair.key.as_str().eq_ignore_ascii_case(name))
.map(|pair| pair.value.as_str().to_string())
};
match tag_name.as_str() {
"img" => {
if let Some(src) = get_attr("src") {
let mime = Self::guess_mime_from_url(&src, "image");
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Image,
mime_type: mime.into(),
source_element: AzString::from("img"),
source_attribute: AzString::from("src"),
});
}
// Also check srcset
if let Some(srcset) = get_attr("srcset") {
for src in Self::parse_srcset(&srcset) {
let mime = Self::guess_mime_from_url(&src, "image");
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Image,
mime_type: mime.into(),
source_element: AzString::from("img"),
source_attribute: AzString::from("srcset"),
});
}
}
}
"link" => {
if let Some(href) = get_attr("href") {
let rel = get_attr("rel").unwrap_or_default().to_lowercase();
let type_attr = get_attr("type");
let as_attr = get_attr("as").unwrap_or_default().to_lowercase();
let (kind, category) = if rel.contains("stylesheet") {
(ExternalResourceKind::Stylesheet, "stylesheet")
} else if rel.contains("icon") || rel.contains("apple-touch-icon") {
(ExternalResourceKind::Icon, "image")
} else if as_attr == "font" {
(ExternalResourceKind::Font, "font")
} else if as_attr == "script" {
(ExternalResourceKind::Script, "script")
} else if as_attr == "image" {
(ExternalResourceKind::Image, "image")
} else {
(ExternalResourceKind::Unknown, "")
};
let mime = type_attr
.map(|t| MimeTypeHint::new(&t))
.or_else(|| Self::guess_mime_from_url(&href, category));
resources.push(ExternalResource {
url: AzString::from(href),
kind,
mime_type: mime.into(),
source_element: AzString::from("link"),
source_attribute: AzString::from("href"),
});
}
}
"script" => {
if let Some(src) = get_attr("src") {
let type_attr = get_attr("type");
let mime = type_attr
.map(|t| MimeTypeHint::new(&t))
.or_else(|| Some(MimeTypeHint::new("application/javascript")));
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Script,
mime_type: mime.into(),
source_element: AzString::from("script"),
source_attribute: AzString::from("src"),
});
}
}
"video" => {
if let Some(src) = get_attr("src") {
let mime = Self::guess_mime_from_url(&src, "video");
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Video,
mime_type: mime.into(),
source_element: AzString::from("video"),
source_attribute: AzString::from("src"),
});
}
if let Some(poster) = get_attr("poster") {
let mime = Self::guess_mime_from_url(&poster, "image");
resources.push(ExternalResource {
url: AzString::from(poster),
kind: ExternalResourceKind::Image,
mime_type: mime.into(),
source_element: AzString::from("video"),
source_attribute: AzString::from("poster"),
});
}
}
"audio" => {
if let Some(src) = get_attr("src") {
let mime = Self::guess_mime_from_url(&src, "audio");
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Audio,
mime_type: mime.into(),
source_element: AzString::from("audio"),
source_attribute: AzString::from("src"),
});
}
}
"source" => {
if let Some(src) = get_attr("src") {
let type_attr = get_attr("type");
// Determine kind based on type or parent (heuristic: assume video)
let kind = if type_attr
.as_ref()
.is_some_and(|t| t.starts_with("audio"))
{
ExternalResourceKind::Audio
} else {
ExternalResourceKind::Video
};
let mime = type_attr.map(|t| MimeTypeHint::new(&t)).or_else(|| {
Self::guess_mime_from_url(
&src,
if kind == ExternalResourceKind::Audio {
"audio"
} else {
"video"
},
)
});
resources.push(ExternalResource {
url: AzString::from(src),
kind,
mime_type: mime.into(),
source_element: AzString::from("source"),
source_attribute: AzString::from("src"),
});
}
// Also handle srcset for picture elements
if let Some(srcset) = get_attr("srcset") {
for src in Self::parse_srcset(&srcset) {
let mime = Self::guess_mime_from_url(&src, "image");
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Image,
mime_type: mime.into(),
source_element: AzString::from("source"),
source_attribute: AzString::from("srcset"),
});
}
}
}
"a" => {
if let Some(href) = get_attr("href") {
// Only include if it looks like a resource, not a page link
if Self::looks_like_resource(&href) {
let mime = Self::guess_mime_from_url(&href, "");
resources.push(ExternalResource {
url: AzString::from(href),
kind: ExternalResourceKind::Unknown,
mime_type: mime.into(),
source_element: AzString::from("a"),
source_attribute: AzString::from("href"),
});
}
}
}
"virtualized-view" | "embed" | "object" => {
let src_attr = if tag_name == "object" { "data" } else { "src" };
if let Some(src) = get_attr(src_attr) {
resources.push(ExternalResource {
url: AzString::from(src),
kind: ExternalResourceKind::Unknown,
mime_type: OptionMimeTypeHint::None,
source_element: AzString::from(tag_name.clone()),
source_attribute: AzString::from(src_attr),
});
}
}
"style" => {
// Scan text content for CSS URLs
for child in node.children.as_ref() {
if let XmlNodeChild::Text(text) = child {
Self::extract_css_urls(text.as_str(), resources);
}
}
}
_ => {}
}
// Check inline style attribute for url()
if let Some(style) = get_attr("style") {
Self::extract_css_urls(&style, resources);
}
// Check for background attribute (deprecated but still used)
if let Some(bg) = get_attr("background") {
let mime = Self::guess_mime_from_url(&bg, "image");
resources.push(ExternalResource {
url: AzString::from(bg),
kind: ExternalResourceKind::Image,
mime_type: mime.into(),
source_element: AzString::from(tag_name),
source_attribute: AzString::from("background"),
});
}
// Children are walked by the iterative driver in `scan_external_resources`.
}
/// Extract URLs from CSS content (handles `url()` and @import)
fn extract_css_urls(css: &str, resources: &mut Vec<ExternalResource>) {
// AUDIT 2026-07-08: fold to lowercase ONCE using ASCII-only folding.
// `to_ascii_lowercase` never changes a string's byte length (only A-Z are
// touched, multi-byte code points are left verbatim), so every byte offset
// into `lower` maps 1:1 onto `css`. The old code called `to_lowercase()`
// every iteration (O(n^2)) and then sliced the ORIGINAL `css` with an
// offset computed in the lowercased temporary -- for characters whose
// lowercase changes byte length (e.g. 'İ' U+0130, 2 bytes -> 3 bytes) that
// offset landed off a char boundary and panicked. Searching in `lower` and
// slicing `css` at the same offset also makes the `url(` / `@import` scans
// case-insensitive for free.
let lower = css.to_ascii_lowercase();
// url(...) scan (case-insensitive)
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("url(") {
let after = search_from + rel + 4;
let after_url = &css[after..];
if let Some(url) = Self::extract_url_value(after_url) {
let mime = Self::guess_mime_from_url(&url, "");
let kind = Self::guess_kind_from_url(&url);
resources.push(ExternalResource {
url: AzString::from(url),
kind,
mime_type: mime.into(),
source_element: AzString::from("style"),
source_attribute: AzString::from("url()"),
});
}
search_from = after;
}
// Handle @import "url" or @import url(...) (case-insensitive)
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("@import") {
let after = search_from + rel + 7;
let after_import = &css[after..];
let trimmed = after_import.trim_start();
// Match `url(` case-insensitively without allocating. `get(..4)`
// returns `None` if byte 4 is not a char boundary, so the slice below
// can never panic on multi-byte input.
let import_url = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("url(")) {
Self::extract_url_value(&trimmed[4..])
} else {
Self::extract_quoted_string(trimmed)
};
if let Some(url) = import_url {
resources.push(ExternalResource {
url: AzString::from(url),
kind: ExternalResourceKind::Stylesheet,
mime_type: Some(MimeTypeHint::new("text/css")).into(),
source_element: AzString::from("style"),
source_attribute: AzString::from("@import"),
});
}
search_from = after;
}
}
/// Extract value from url(...) - handles quoted and unquoted URLs
fn extract_url_value(s: &str) -> Option<String> {
let trimmed = s.trim_start();
if trimmed.starts_with('"') {
Self::extract_quoted_string(trimmed)
} else if let Some(rest) = trimmed.strip_prefix('\'') {
let end = rest.find('\'')?;
Some(rest[..end].to_string())
} else {
let end = trimmed.find(')')?;
Some(trimmed[..end].trim().to_string())
}
}
/// Extract a quoted string value
fn extract_quoted_string(s: &str) -> Option<String> {
if let Some(rest) = s.strip_prefix('"') {
let end = rest.find('"')?;
Some(rest[..end].to_string())
} else if let Some(rest) = s.strip_prefix('\'') {
let end = rest.find('\'')?;
Some(rest[..end].to_string())
} else {
None
}
}
/// Parse srcset attribute into individual URLs
fn parse_srcset(srcset: &str) -> Vec<String> {
srcset
.split(',')
.filter_map(|entry| {
let trimmed = entry.trim();
// srcset format: "url 1x" or "url 100w"
trimmed.split_whitespace().next().map(alloc::string::ToString::to_string)
})
.filter(|url| !url.is_empty())
.collect()
}
/// Check if a URL looks like a downloadable resource (not a page)
fn looks_like_resource(url: &str) -> bool {
let lower = url.to_lowercase();
// Check for common resource extensions
let resource_exts = [
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp", ".ttf", ".otf",
".woff", ".woff2", ".eot", ".css", ".js", ".mp4", ".webm", ".ogg", ".mp3", ".wav",
".pdf", ".zip", ".tar", ".gz",
];
resource_exts.iter().any(|ext| lower.ends_with(ext))
}
/// Guess the resource kind from URL based on file extension.
// `url` is lowercased into `path` below, so these literal `.ext` checks are
// already case-insensitive — the lint can't see the runtime lowercasing.
#[allow(clippy::case_sensitive_file_extension_comparisons)]
fn guess_kind_from_url(url: &str) -> ExternalResourceKind {
let lower = url.to_lowercase();
// Strip query string before checking extension
let path = lower.split('?').next().unwrap_or(&lower);
if path.ends_with(".png")
|| path.ends_with(".jpg")
|| path.ends_with(".jpeg")
|| path.ends_with(".gif")
|| path.ends_with(".webp")
|| path.ends_with(".svg")
|| path.ends_with(".bmp")
|| path.ends_with(".avif")
{
ExternalResourceKind::Image
} else if path.ends_with(".ttf")
|| path.ends_with(".otf")
|| path.ends_with(".woff")
|| path.ends_with(".woff2")
|| path.ends_with(".eot")
{
ExternalResourceKind::Font
} else if path.ends_with(".css") {
ExternalResourceKind::Stylesheet
} else if path.ends_with(".js") || path.ends_with(".mjs") {
ExternalResourceKind::Script
} else if path.ends_with(".mp4") || path.ends_with(".webm") || path.ends_with(".ogg") {
ExternalResourceKind::Video
} else if path.ends_with(".mp3") || path.ends_with(".wav") || path.ends_with(".flac") {
ExternalResourceKind::Audio
} else if path.ends_with(".ico") {
ExternalResourceKind::Icon
} else {
ExternalResourceKind::Unknown
}
}
/// Guess MIME type from URL based on extension
fn guess_mime_from_url(url: &str, category: &str) -> Option<MimeTypeHint> {
let lower = url.to_lowercase();
// Find extension
let ext = lower.rsplit('.').next()?;
// Remove query string if present
let ext = ext.split('?').next()?;
// Check if it's a valid extension
let valid_exts = [
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif", "ttf", "otf", "woff",
"woff2", "eot", "css", "js", "mjs", "mp4", "webm", "ogg", "mp3", "wav", "flac",
];
if valid_exts.contains(&ext) {
Some(MimeTypeHint::from_extension(ext))
} else if !category.is_empty() {
// Use category hint for default
match category {
"image" => Some(MimeTypeHint::new("image/*")),
"font" => Some(MimeTypeHint::new("font/*")),
"stylesheet" => Some(MimeTypeHint::new("text/css")),
"script" => Some(MimeTypeHint::new("application/javascript")),
"video" => Some(MimeTypeHint::new("video/*")),
"audio" => Some(MimeTypeHint::new("audio/*")),
_ => None,
}
} else {
None
}
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
#[repr(C)]
pub struct NonXmlCharError {
pub ch: u32, /* u32 = char, but ABI stable */
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
#[repr(C)]
pub struct InvalidCharError {
pub expected: u8,
pub got: u8,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct InvalidCharMultipleError {
pub expected: u8,
pub got: U8Vec,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
#[repr(C)]
pub struct InvalidQuoteError {
pub got: u8,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
#[repr(C)]
pub struct InvalidSpaceError {
pub got: u8,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct InvalidStringError {
pub got: AzString,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C, u8)]
pub enum XmlStreamError {
UnexpectedEndOfStream,
InvalidName,
NonXmlChar(NonXmlCharError),
InvalidChar(InvalidCharError),
InvalidCharMultiple(InvalidCharMultipleError),
InvalidQuote(InvalidQuoteError),
InvalidSpace(InvalidSpaceError),
InvalidString(InvalidStringError),
InvalidReference,
InvalidExternalID,
InvalidCommentData,
InvalidCommentEnd,
InvalidCharacterData,
}
impl fmt::Display for XmlStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::XmlStreamError::{UnexpectedEndOfStream, InvalidName, NonXmlChar, InvalidChar, InvalidCharMultiple, InvalidQuote, InvalidSpace, InvalidString, InvalidReference, InvalidExternalID, InvalidCommentData, InvalidCommentEnd, InvalidCharacterData};
match self {
UnexpectedEndOfStream => write!(f, "Unexpected end of stream"),
InvalidName => write!(f, "Invalid name"),
NonXmlChar(nx) => write!(
f,
"Non-XML character: {:?} at {}",
core::char::from_u32(nx.ch),
nx.pos
),
InvalidChar(ic) => write!(
f,
"Invalid character: expected: {}, got: {} at {}",
ic.expected as char, ic.got as char, ic.pos
),
InvalidCharMultiple(imc) => write!(
f,
"Multiple invalid characters: expected: {}, got: {:?} at {}",
imc.expected,
imc.got.as_ref(),
imc.pos
),
InvalidQuote(iq) => write!(f, "Invalid quote: got {} at {}", iq.got as char, iq.pos),
InvalidSpace(is) => write!(f, "Invalid space: got {} at {}", is.got as char, is.pos),
InvalidString(ise) => write!(
f,
"Invalid string: got \"{}\" at {}",
ise.got.as_str(),
ise.pos
),
InvalidReference => write!(f, "Invalid reference"),
InvalidExternalID => write!(f, "Invalid external ID"),
InvalidCommentData => write!(f, "Invalid comment data"),
InvalidCommentEnd => write!(f, "Invalid comment end"),
InvalidCharacterData => write!(f, "Invalid character data"),
}
}
}
#[derive(Debug, PartialEq, PartialOrd, Clone, Copy, Ord, Hash, Eq)]
#[repr(C)]
pub struct XmlTextPos {
pub row: u32,
pub col: u32,
}
impl fmt::Display for XmlTextPos {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "line {}:{}", self.row, self.col)
}
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct XmlTextError {
pub stream_error: XmlStreamError,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C, u8)]
pub enum XmlParseError {
InvalidDeclaration(XmlTextError),
InvalidComment(XmlTextError),
InvalidPI(XmlTextError),
InvalidDoctype(XmlTextError),
InvalidEntity(XmlTextError),
InvalidElement(XmlTextError),
InvalidAttribute(XmlTextError),
InvalidCdata(XmlTextError),
InvalidCharData(XmlTextError),
UnknownToken(XmlTextPos),
}
impl fmt::Display for XmlParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::XmlParseError::{InvalidDeclaration, InvalidComment, InvalidPI, InvalidDoctype, InvalidEntity, InvalidElement, InvalidAttribute, InvalidCdata, InvalidCharData, UnknownToken};
match self {
InvalidDeclaration(e) => {
write!(f, "Invalid declaration: {} at {}", e.stream_error, e.pos)
}
InvalidComment(e) => write!(f, "Invalid comment: {} at {}", e.stream_error, e.pos),
InvalidPI(e) => write!(
f,
"Invalid processing instruction: {} at {}",
e.stream_error, e.pos
),
InvalidDoctype(e) => write!(f, "Invalid doctype: {} at {}", e.stream_error, e.pos),
InvalidEntity(e) => write!(f, "Invalid entity: {} at {}", e.stream_error, e.pos),
InvalidElement(e) => write!(f, "Invalid element: {} at {}", e.stream_error, e.pos),
InvalidAttribute(e) => write!(f, "Invalid attribute: {} at {}", e.stream_error, e.pos),
InvalidCdata(e) => write!(f, "Invalid CDATA: {} at {}", e.stream_error, e.pos),
InvalidCharData(e) => write!(f, "Invalid char data: {} at {}", e.stream_error, e.pos),
UnknownToken(e) => write!(f, "Unknown token at {e}"),
}
}
}
impl_result!(
Xml,
XmlError,
ResultXmlXmlError,
copy = false,
[Debug, PartialEq, Eq, PartialOrd, Clone]
);
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct DuplicatedNamespaceError {
pub ns: AzString,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct UnknownNamespaceError {
pub ns: AzString,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct UnexpectedCloseTagError {
pub expected: AzString,
pub actual: AzString,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct UnknownEntityReferenceError {
pub entity: AzString,
pub pos: XmlTextPos,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct DuplicatedAttributeError {
pub attribute: AzString,
pub pos: XmlTextPos,
}
/// Error for mismatched open/close tags in XML hierarchy
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C)]
pub struct MalformedHierarchyError {
/// The tag that was expected (from the opening tag)
pub expected: AzString,
/// The tag that was actually found (the closing tag)
pub got: AzString,
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
#[repr(C, u8)]
pub enum XmlError {
NoParserAvailable,
InvalidXmlPrefixUri(XmlTextPos),
UnexpectedXmlUri(XmlTextPos),
UnexpectedXmlnsUri(XmlTextPos),
InvalidElementNamePrefix(XmlTextPos),
DuplicatedNamespace(DuplicatedNamespaceError),
UnknownNamespace(UnknownNamespaceError),
UnexpectedCloseTag(UnexpectedCloseTagError),
UnexpectedEntityCloseTag(XmlTextPos),
UnknownEntityReference(UnknownEntityReferenceError),
MalformedEntityReference(XmlTextPos),
EntityReferenceLoop(XmlTextPos),
InvalidAttributeValue(XmlTextPos),
DuplicatedAttribute(DuplicatedAttributeError),
NoRootNode,
SizeLimit,
DtdDetected,
/// Invalid hierarchy close tags, i.e `<app></p></app>`
MalformedHierarchy(MalformedHierarchyError),
ParserError(XmlParseError),
UnclosedRootNode,
UnexpectedDeclaration(XmlTextPos),
NodesLimitReached,
AttributesLimitReached,
NamespacesLimitReached,
InvalidName(XmlTextPos),
NonXmlChar(XmlTextPos),
InvalidChar(XmlTextPos),
InvalidChar2(XmlTextPos),
InvalidString(XmlTextPos),
InvalidExternalID(XmlTextPos),
InvalidComment(XmlTextPos),
InvalidCharacterData(XmlTextPos),
UnknownToken(XmlTextPos),
UnexpectedEndOfStream,
}
impl fmt::Display for XmlError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::XmlError::{NoParserAvailable, InvalidXmlPrefixUri, UnexpectedXmlUri, UnexpectedXmlnsUri, InvalidElementNamePrefix, DuplicatedNamespace, UnknownNamespace, UnexpectedCloseTag, UnexpectedEntityCloseTag, UnknownEntityReference, MalformedEntityReference, EntityReferenceLoop, InvalidAttributeValue, DuplicatedAttribute, NoRootNode, SizeLimit, DtdDetected, MalformedHierarchy, ParserError, UnclosedRootNode, UnexpectedDeclaration, NodesLimitReached, AttributesLimitReached, NamespacesLimitReached, InvalidName, NonXmlChar, InvalidChar, InvalidChar2, InvalidString, InvalidExternalID, InvalidComment, InvalidCharacterData, UnknownToken, UnexpectedEndOfStream};
match self {
NoParserAvailable => write!(
f,
"Library was compiled without XML parser (XML parser not available)"
),
InvalidXmlPrefixUri(pos) => {
write!(f, "Invalid XML Prefix URI at line {}:{}", pos.row, pos.col)
}
UnexpectedXmlUri(pos) => {
write!(f, "Unexpected XML URI at line {}:{}", pos.row, pos.col)
}
UnexpectedXmlnsUri(pos) => write!(
f,
"Unexpected XML namespace URI at line {}:{}",
pos.row, pos.col
),
InvalidElementNamePrefix(pos) => write!(
f,
"Invalid element name prefix at line {}:{}",
pos.row, pos.col
),
DuplicatedNamespace(ns) => write!(
f,
"Duplicated namespace: \"{}\" at {}",
ns.ns.as_str(),
ns.pos
),
UnknownNamespace(uns) => write!(
f,
"Unknown namespace: \"{}\" at {}",
uns.ns.as_str(),
uns.pos
),
UnexpectedCloseTag(ct) => write!(
f,
"Unexpected close tag: expected \"{}\", got \"{}\" at {}",
ct.expected.as_str(),
ct.actual.as_str(),
ct.pos
),
UnexpectedEntityCloseTag(pos) => write!(
f,
"Unexpected entity close tag at line {}:{}",
pos.row, pos.col
),
UnknownEntityReference(uer) => write!(
f,
"Unexpected entity reference: \"{}\" at {}",
uer.entity, uer.pos
),
MalformedEntityReference(pos) => write!(
f,
"Malformed entity reference at line {}:{}",
pos.row, pos.col
),
EntityReferenceLoop(pos) => write!(
f,
"Entity reference loop (recursive entity reference) at line {}:{}",
pos.row, pos.col
),
InvalidAttributeValue(pos) => {
write!(f, "Invalid attribute value at line {}:{}", pos.row, pos.col)
}
DuplicatedAttribute(ae) => write!(
f,
"Duplicated attribute \"{}\" at line {}:{}",
ae.attribute.as_str(),
ae.pos.row,
ae.pos.col
),
NoRootNode => write!(f, "No root node found"),
SizeLimit => write!(f, "XML file too large (size limit reached)"),
DtdDetected => write!(f, "Document type descriptor detected"),
MalformedHierarchy(e) => write!(
f,
"Malformed hierarchy: expected <{}/> closing tag, got <{}/>",
e.expected.as_str(),
e.got.as_str()
),
ParserError(p) => write!(f, "{p}"),
UnclosedRootNode => write!(f, "unclosed root node"),
UnexpectedDeclaration(tp) => write!(f, "unexpected declaration at {tp}"),
NodesLimitReached => write!(f, "nodes limit reached"),
AttributesLimitReached => write!(f, "attributes limit reached"),
NamespacesLimitReached => write!(f, "namespaces limit reached"),
InvalidName(tp) => write!(f, "invalid name at {tp}"),
NonXmlChar(tp) => write!(f, "non xml char at {tp}"),
InvalidChar(tp) => write!(f, "invalid char at {tp}"),
InvalidChar2(tp) => write!(f, "invalid char2 at {tp}"),
InvalidString(tp) => write!(f, "invalid string at {tp}"),
InvalidExternalID(tp) => write!(f, "invalid externalid at {tp}"),
InvalidComment(tp) => write!(f, "invalid comment at {tp}"),
InvalidCharacterData(tp) => write!(f, "invalid character data at {tp}"),
UnknownToken(tp) => write!(f, "unknown token at {tp}"),
UnexpectedEndOfStream => write!(f, "unexpected end of stream"),
}
}
}
// ============================================================================
// New repr(C) component system
// ============================================================================
/// Identifies a component within a library collection.
/// e.g. collection="builtin", name="div" for the `<div>` element,
/// or collection="shadcn", name="avatar" for a custom component.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct ComponentId {
/// Library / collection name: "builtin", "shadcn", "myproject"
pub collection: AzString,
/// Component name within the collection: "div", "avatar", "card"
pub name: AzString,
}
impl ComponentId {
#[must_use] pub fn builtin(name: &str) -> Self {
Self {
collection: AzString::from_const_str("builtin"),
name: AzString::from(name),
}
}
#[must_use] pub fn new(collection: &str, name: &str) -> Self {
Self {
collection: AzString::from(collection),
name: AzString::from(name),
}
}
/// Returns "collection:name" format string
#[must_use] pub fn qualified_name(&self) -> String {
format!("{}:{}", self.collection.as_str(), self.name.as_str())
}
}
// ============================================================================
// Component type system — rich type descriptors for component fields
// ============================================================================
/// A single argument in a callback signature.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct ComponentCallbackArg {
/// Argument name, e.g. "`button_id`"
pub name: AzString,
/// Argument type
pub arg_type: ComponentFieldType,
}
impl_vec!(
ComponentCallbackArg,
ComponentCallbackArgVec,
ComponentCallbackArgVecDestructor,
ComponentCallbackArgVecDestructorType,
ComponentCallbackArgVecSlice,
OptionComponentCallbackArg
);
impl_option!(
ComponentCallbackArg,
OptionComponentCallbackArg,
copy = false,
[Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
);
impl_vec_debug!(ComponentCallbackArg, ComponentCallbackArgVec);
impl_vec_partialeq!(ComponentCallbackArg, ComponentCallbackArgVec);
impl_vec_eq!(ComponentCallbackArg, ComponentCallbackArgVec);
impl_vec_partialord!(ComponentCallbackArg, ComponentCallbackArgVec);
impl_vec_ord!(ComponentCallbackArg, ComponentCallbackArgVec);
impl_vec_hash!(ComponentCallbackArg, ComponentCallbackArgVec);
impl_vec_clone!(
ComponentCallbackArg,
ComponentCallbackArgVec,
ComponentCallbackArgVecDestructor
);
/// Callback signature: return type + argument list.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct ComponentCallbackSignature {
/// Return type name, e.g. "Update"
pub return_type: AzString,
/// Callback arguments (excluding the implicit `&mut RefAny` and `&mut CallbackInfo`)
pub args: ComponentCallbackArgVec,
}
/// Heap-allocated box for recursive `ComponentFieldType` (e.g. `Option<String>`).
/// Uses raw pointer indirection to break the infinite size.
#[repr(C)]
pub struct ComponentFieldTypeBox {
pub ptr: *mut ComponentFieldType,
}
impl ComponentFieldTypeBox {
#[must_use] pub fn new(t: ComponentFieldType) -> Self {
Self {
ptr: Box::into_raw(Box::new(t)),
}
}
#[must_use] pub fn as_ref(&self) -> &ComponentFieldType {
unsafe { &*self.ptr }
}
}
impl Clone for ComponentFieldTypeBox {
fn clone(&self) -> Self {
Self::new(unsafe { (*self.ptr).clone() })
}
}
impl Drop for ComponentFieldTypeBox {
fn drop(&mut self) {
// Null the pointer as we free it, so a *second* drop is a no-op instead
// of a double free. This type is a by-value payload of the
// `ComponentFieldType` enum, whose codegen FFI mirror gets
// `impl Drop { _delete }` (= drop_in_place of the real type) AND Rust
// field drop-glue — dropping each by-value field twice. Without this
// take-and-null the second drop would `Box::from_raw` a dangling pointer.
let ptr = core::mem::replace(&mut self.ptr, core::ptr::null_mut());
if !ptr.is_null() {
unsafe {
drop(Box::from_raw(ptr));
}
}
}
}
impl fmt::Debug for ComponentFieldTypeBox {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.ptr.is_null() {
write!(f, "ComponentFieldTypeBox(null)")
} else {
write!(f, "ComponentFieldTypeBox({:?})", unsafe { &*self.ptr })
}
}
}
impl PartialEq for ComponentFieldTypeBox {
fn eq(&self, other: &Self) -> bool {
if self.ptr.is_null() && other.ptr.is_null() {
return true;
}
if self.ptr.is_null() || other.ptr.is_null() {
return false;
}
unsafe { *self.ptr == *other.ptr }
}
}
impl Eq for ComponentFieldTypeBox {}
impl PartialOrd for ComponentFieldTypeBox {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ComponentFieldTypeBox {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
match (self.ptr.is_null(), other.ptr.is_null()) {
(true, true) => core::cmp::Ordering::Equal,
(true, false) => core::cmp::Ordering::Less,
(false, true) => core::cmp::Ordering::Greater,
(false, false) => unsafe { (*self.ptr).cmp(&*other.ptr) },
}
}
}
impl Hash for ComponentFieldTypeBox {
fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
if !self.ptr.is_null() {
unsafe {
(*self.ptr).hash(state);
}
}
}
}
/// Heap-allocated box for recursive `ComponentFieldValue` (e.g. `Some(value)`).
/// Uses raw pointer indirection to break the infinite size.
#[repr(C)]
pub struct ComponentFieldValueBox {
pub ptr: *mut ComponentFieldValue,
}
impl ComponentFieldValueBox {
#[must_use] pub fn new(v: ComponentFieldValue) -> Self {
Self {
ptr: Box::into_raw(Box::new(v)),
}
}
#[must_use] pub fn as_ref(&self) -> &ComponentFieldValue {
unsafe { &*self.ptr }
}
}
impl Clone for ComponentFieldValueBox {
fn clone(&self) -> Self {
Self::new(unsafe { (*self.ptr).clone() })
}
}
impl Drop for ComponentFieldValueBox {
fn drop(&mut self) {
// Take-and-null so a second drop (codegen FFI double-drop of a by-value
// field, see `ComponentFieldTypeBox`) is a no-op, not a double free.
let ptr = core::mem::replace(&mut self.ptr, core::ptr::null_mut());
if !ptr.is_null() {
unsafe {
drop(Box::from_raw(ptr));
}
}
}
}
impl fmt::Debug for ComponentFieldValueBox {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.ptr.is_null() {
write!(f, "ComponentFieldValueBox(null)")
} else {
write!(f, "ComponentFieldValueBox({:?})", unsafe { &*self.ptr })
}
}
}
impl PartialEq for ComponentFieldValueBox {
fn eq(&self, other: &Self) -> bool {
if self.ptr.is_null() && other.ptr.is_null() {
return true;
}
if self.ptr.is_null() || other.ptr.is_null() {
return false;
}
unsafe { *self.ptr == *other.ptr }
}
}
/// Rich type descriptor for a component field.
/// Replaces the old `AzString` type names ("String", "bool", etc.) with
/// a structured enum that the debugger can use for type-aware editing.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C, u8)]
pub enum ComponentFieldType {
String,
Bool,
I32,
I64,
U32,
U64,
Usize,
F32,
F64,
ColorU,
CssProperty,
ImageRef,
FontRef,
/// `StyledDom` slot — field name = slot name
StyledDom,
/// Callback with typed signature
Callback(ComponentCallbackSignature),
/// `RefAny` data binding with type hint
RefAny(AzString),
/// Optional value (recursive via Box)
OptionType(ComponentFieldTypeBox),
/// Vec of values (recursive via Box)
VecType(ComponentFieldTypeBox),
/// Reference to a struct defined in the same library
StructRef(AzString),
/// Reference to an enum defined in the same library
EnumRef(AzString),
}
impl ComponentFieldType {
/// Parse a field type string like "String", "Option<Bool>", "Vec<I32>",
/// "Callback(fn(LayoutCallbackInfo) -> Dom)", "StructRef(MyStruct)" etc.
/// Returns `None` if the string cannot be parsed.
#[must_use] pub fn parse(s: &str) -> Option<Self> {
Self::parse_depth(s, 0)
}
/// Depth-bounded implementation of [`parse`](Self::parse).
///
/// AUDIT 2026-07-08: `Option<..>` / `Vec<..>` wrappers recurse once per level,
/// so an attacker string like `"Option<".repeat(100_000)` (with matching `>`)
/// overflowed the stack. Recursion is capped at [`MAX_TYPE_PARSE_DEPTH`];
/// beyond it, parsing fails (`None`) instead of crashing.
fn parse_depth(s: &str, depth: usize) -> Option<Self> {
if depth > MAX_TYPE_PARSE_DEPTH {
return None;
}
let s = s.trim();
match s {
"String" | "string" => return Some(Self::String),
"Bool" | "bool" => return Some(Self::Bool),
"I32" | "i32" => return Some(Self::I32),
"I64" | "i64" => return Some(Self::I64),
"U32" | "u32" => return Some(Self::U32),
"U64" | "u64" => return Some(Self::U64),
"Usize" | "usize" => return Some(Self::Usize),
"F32" | "f32" => return Some(Self::F32),
"F64" | "f64" => return Some(Self::F64),
"ColorU" => return Some(Self::ColorU),
"CssProperty" => return Some(Self::CssProperty),
"ImageRef" => return Some(Self::ImageRef),
"FontRef" => return Some(Self::FontRef),
"StyledDom" => return Some(Self::StyledDom),
"RefAny" => return Some(Self::RefAny(AzString::from(""))),
_ => {}
}
// Option<T>
if let Some(inner) = s.strip_prefix("Option<").and_then(|r| r.strip_suffix('>')) {
let inner_type = Self::parse_depth(inner, depth + 1)?;
return Some(Self::OptionType(ComponentFieldTypeBox::new(
inner_type,
)));
}
// Vec<T>
if let Some(inner) = s.strip_prefix("Vec<").and_then(|r| r.strip_suffix('>')) {
let inner_type = Self::parse_depth(inner, depth + 1)?;
return Some(Self::VecType(ComponentFieldTypeBox::new(
inner_type,
)));
}
// Callback(signature)
if let Some(sig) = s
.strip_prefix("Callback(")
.and_then(|r| r.strip_suffix(')'))
{
return Some(Self::Callback(ComponentCallbackSignature {
return_type: AzString::from(sig),
args: Vec::new().into(),
}));
}
// RefAny(TypeHint)
if let Some(hint) = s.strip_prefix("RefAny(").and_then(|r| r.strip_suffix(')')) {
return Some(Self::RefAny(AzString::from(hint)));
}
// EnumRef(Name) — explicit
if let Some(name) = s.strip_prefix("EnumRef(").and_then(|r| r.strip_suffix(')')) {
return Some(Self::EnumRef(AzString::from(name)));
}
// StructRef(Name) — explicit
if let Some(name) = s
.strip_prefix("StructRef(")
.and_then(|r| r.strip_suffix(')'))
{
return Some(Self::StructRef(AzString::from(name)));
}
// If starts with uppercase, treat as StructRef
if s.chars().next().is_some_and(char::is_uppercase) {
return Some(Self::StructRef(AzString::from(s)));
}
None
}
/// Format this field type to its canonical string representation.
/// This is the inverse of `parse`.
#[must_use] pub fn format(&self) -> String {
match self {
Self::String => "String".to_string(),
Self::Bool => "Bool".to_string(),
Self::I32 => "I32".to_string(),
Self::I64 => "I64".to_string(),
Self::U32 => "U32".to_string(),
Self::U64 => "U64".to_string(),
Self::Usize => "Usize".to_string(),
Self::F32 => "F32".to_string(),
Self::F64 => "F64".to_string(),
Self::ColorU => "ColorU".to_string(),
Self::CssProperty => "CssProperty".to_string(),
Self::ImageRef => "ImageRef".to_string(),
Self::FontRef => "FontRef".to_string(),
Self::StyledDom => "StyledDom".to_string(),
Self::Callback(sig) => format!("Callback({})", sig.return_type.as_str()),
Self::RefAny(hint) => {
if hint.as_str().is_empty() {
"RefAny".to_string()
} else {
format!("RefAny({})", hint.as_str())
}
}
Self::OptionType(inner) => format!("Option<{}>", inner.as_ref().format()),
Self::VecType(inner) => format!("Vec<{}>", inner.as_ref().format()),
Self::StructRef(name) | Self::EnumRef(name) => name.as_str().to_string(),
}
}
}
impl fmt::Display for ComponentFieldType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.format())
}
}
/// A single variant in a component enum model.
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct ComponentEnumVariant {
/// Variant name, e.g. "Admin", "Editor", "Viewer"
pub name: AzString,
/// Human-readable description for this variant
pub description: AzString,
/// Optional associated fields for this variant
pub fields: ComponentDataFieldVec,
}
impl_vec!(
ComponentEnumVariant,
ComponentEnumVariantVec,
ComponentEnumVariantVecDestructor,
ComponentEnumVariantVecDestructorType,
ComponentEnumVariantVecSlice,
OptionComponentEnumVariant
);
impl_option!(
ComponentEnumVariant,
OptionComponentEnumVariant,
copy = false,
[Debug, Clone, PartialEq]
);
impl_vec_debug!(ComponentEnumVariant, ComponentEnumVariantVec);
impl_vec_partialeq!(ComponentEnumVariant, ComponentEnumVariantVec);
impl_vec_clone!(
ComponentEnumVariant,
ComponentEnumVariantVec,
ComponentEnumVariantVecDestructor
);
/// A named enum model for code generation.
/// Stored in `ComponentLibrary::enum_models`.
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct ComponentEnumModel {
/// Enum name, e.g. "`UserRole`"
pub name: AzString,
/// Human-readable description
pub description: AzString,
/// Variants
pub variants: ComponentEnumVariantVec,
}
impl_vec!(
ComponentEnumModel,
ComponentEnumModelVec,
ComponentEnumModelVecDestructor,
ComponentEnumModelVecDestructorType,
ComponentEnumModelVecSlice,
OptionComponentEnumModel
);
impl_option!(
ComponentEnumModel,
OptionComponentEnumModel,
copy = false,
[Debug, Clone, PartialEq]
);
impl_vec_debug!(ComponentEnumModel, ComponentEnumModelVec);
impl_vec_partialeq!(ComponentEnumModel, ComponentEnumModelVec);
impl_vec_clone!(
ComponentEnumModel,
ComponentEnumModelVec,
ComponentEnumModelVecDestructor
);
/// Default value for a component field.
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum ComponentDefaultValue {
/// No default value (field is required)
None,
/// String literal default
String(AzString),
/// Boolean default
Bool(bool),
/// i32 default
I32(i32),
/// i64 default
I64(i64),
/// u32 default
U32(u32),
/// u64 default
U64(u64),
/// usize default
Usize(usize),
/// f32 default
F32(f32),
/// f64 default
F64(f64),
/// `ColorU` default
ColorU(ColorU),
/// Default is an instance of another component
ComponentInstance(ComponentInstanceDefault),
/// Default callback function pointer name
CallbackFnPointer(AzString),
/// JSON string representing a complex default value
Json(AzString),
}
impl_option!(
ComponentDefaultValue,
OptionComponentDefaultValue,
copy = false,
[Debug, Clone, PartialEq]
);
/// Default component instance for a `StyledDom` slot.
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct ComponentInstanceDefault {
/// Library name, e.g. "builtin"
pub library: AzString,
/// Component tag, e.g. "a"
pub component: AzString,
/// Field overrides for this instance
pub field_overrides: ComponentFieldOverrideVec,
}
/// An override for a single field in a component instance.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct ComponentFieldOverride {
/// Field name to override
pub field_name: AzString,
/// Value source for this override
pub source: ComponentFieldValueSource,
}
impl_vec!(
ComponentFieldOverride,
ComponentFieldOverrideVec,
ComponentFieldOverrideVecDestructor,
ComponentFieldOverrideVecDestructorType,
ComponentFieldOverrideVecSlice,
OptionComponentFieldOverride
);
impl_option!(
ComponentFieldOverride,
OptionComponentFieldOverride,
copy = false,
[Debug, Clone, PartialEq, Eq]
);
impl_vec_debug!(ComponentFieldOverride, ComponentFieldOverrideVec);
impl_vec_partialeq!(ComponentFieldOverride, ComponentFieldOverrideVec);
impl_vec_clone!(
ComponentFieldOverride,
ComponentFieldOverrideVec,
ComponentFieldOverrideVecDestructor
);
/// How a field value is sourced at the instance level.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C, u8)]
pub enum ComponentFieldValueSource {
/// Use the component's default value
Default,
/// Hardcoded literal value (as string, parsed at runtime)
Literal(AzString),
/// Bound to an app state path (e.g. "`app_state.user.name`")
Binding(AzString),
}
#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
/// Runtime value for a component field — the "instance" counterpart
/// to `ComponentFieldType` (which is the "class" / type descriptor).
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
#[allow(clippy::large_enum_variant)] // #[repr(C,u8)] FFI enum: boxing a variant changes the C ABI/api.json
pub enum ComponentFieldValue {
String(AzString),
Bool(bool),
I32(i32),
I64(i64),
U32(u32),
U64(u64),
Usize(usize),
F32(f32),
F64(f64),
ColorU(ColorU),
/// Option<T> with no value
None,
/// Option<T> with a value
Some(ComponentFieldValueBox),
/// Vec of values
Vec(ComponentFieldValueVec),
/// `StyledDom` slot content
StyledDom(StyledDom),
/// Struct fields, in order
Struct(ComponentFieldNamedValueVec),
/// Enum variant
Enum {
variant: AzString,
fields: ComponentFieldNamedValueVec,
},
/// Callback function reference (function name as string)
Callback(AzString),
/// Opaque reference-counted data
RefAny(crate::refany::RefAny),
}
/// Named field value: (`field_name`, value) pair.
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct ComponentFieldNamedValue {
pub name: AzString,
pub value: ComponentFieldValue,
}
impl_vec!(
ComponentFieldNamedValue,
ComponentFieldNamedValueVec,
ComponentFieldNamedValueVecDestructor,
ComponentFieldNamedValueVecDestructorType,
ComponentFieldNamedValueVecSlice,
OptionComponentFieldNamedValue
);
impl_option!(
ComponentFieldNamedValue,
OptionComponentFieldNamedValue,
copy = false,
[Debug, Clone, PartialEq]
);
impl_vec_debug!(ComponentFieldNamedValue, ComponentFieldNamedValueVec);
impl_vec_partialeq!(ComponentFieldNamedValue, ComponentFieldNamedValueVec);
impl_vec_clone!(
ComponentFieldNamedValue,
ComponentFieldNamedValueVec,
ComponentFieldNamedValueVecDestructor
);
impl ComponentFieldNamedValueVec {
/// Look up a field by name, return a reference to its value.
#[must_use] pub fn get_field(&self, name: &str) -> Option<&ComponentFieldValue> {
self.as_ref().iter().find_map(|v| {
if v.name.as_str() == name {
Some(&v.value)
} else {
None
}
})
}
/// Convenience: get a field as `&str` if it is `ComponentFieldValue::String`.
#[must_use] pub fn get_string(&self, name: &str) -> Option<&AzString> {
match self.get_field(name) {
Some(ComponentFieldValue::String(s)) => Some(s),
_ => None,
}
}
}
impl_vec!(
ComponentFieldValue,
ComponentFieldValueVec,
ComponentFieldValueVecDestructor,
ComponentFieldValueVecDestructorType,
ComponentFieldValueVecSlice,
OptionComponentFieldValue
);
impl_option!(
ComponentFieldValue,
OptionComponentFieldValue,
copy = false,
[Debug, Clone, PartialEq]
);
impl_vec_debug!(ComponentFieldValue, ComponentFieldValueVec);
impl_vec_partialeq!(ComponentFieldValue, ComponentFieldValueVec);
impl_vec_clone!(
ComponentFieldValue,
ComponentFieldValueVec,
ComponentFieldValueVecDestructor
);
/// A field in the component's internal data model.
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct ComponentDataField {
/// Field name, e.g. "counter", "text", "number"
pub name: AzString,
/// Rich type descriptor for this field
pub field_type: ComponentFieldType,
/// Typed default value, or None if the field is required
pub default_value: OptionComponentDefaultValue,
/// Whether this field is required (must be provided by the parent)
pub required: bool,
/// Human-readable description
pub description: AzString,
}
impl_vec!(
ComponentDataField,
ComponentDataFieldVec,
ComponentDataFieldVecDestructor,
ComponentDataFieldVecDestructorType,
ComponentDataFieldVecSlice,
OptionComponentDataField
);
impl_option!(
ComponentDataField,
OptionComponentDataField,
copy = false,
[Debug, Clone, PartialEq]
);
impl_vec_debug!(ComponentDataField, ComponentDataFieldVec);
impl_vec_partialeq!(ComponentDataField, ComponentDataFieldVec);
impl_vec_clone!(
ComponentDataField,
ComponentDataFieldVec,
ComponentDataFieldVecDestructor
);
/// A named data model (struct definition) for code generation.
///
/// Stored in `ComponentLibrary::data_models`. Components reference these
/// by name in `ComponentDataField::field_type`, enabling nested/structured
/// data models. For example, a `UserCard` component might have a field
/// `user: UserProfile` where `UserProfile` is a `ComponentDataModel`.
#[derive(Debug, Clone)]
#[repr(C)]
pub struct ComponentDataModel {
/// Type name, e.g. "`UserProfile`", "`TodoItem`"
pub name: AzString,
/// Human-readable description
pub description: AzString,
/// Fields in this struct
pub fields: ComponentDataFieldVec,
}
impl ComponentDataModel {
/// Look up a field by name.
#[must_use] pub fn get_field(&self, name: &str) -> Option<&ComponentDataField> {
self.fields
.as_ref()
.iter()
.find(|f| f.name.as_str() == name)
}
/// Look up a field's default value as a string, if it exists and is a String variant.
#[must_use] pub fn get_default_string(&self, name: &str) -> Option<&AzString> {
self.get_field(name).and_then(|f| match &f.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => Some(s),
_ => None,
})
}
/// Clone this data model, overriding the default value for a field by name.
/// If the field is not found, the data model is returned unchanged.
#[must_use] pub fn with_default(mut self, name: &str, value: ComponentDefaultValue) -> Self {
let mut fields_vec = core::mem::replace(
&mut self.fields,
ComponentDataFieldVec::from_const_slice(&[]),
)
.into_library_owned_vec();
for f in &mut fields_vec {
if f.name.as_str() == name {
f.default_value = OptionComponentDefaultValue::Some(value);
break;
}
}
self.fields = ComponentDataFieldVec::from_vec(fields_vec);
self
}
}
impl_vec!(
ComponentDataModel,
ComponentDataModelVec,
ComponentDataModelVecDestructor,
ComponentDataModelVecDestructorType,
ComponentDataModelVecSlice,
OptionComponentDataModel
);
impl_option!(
ComponentDataModel,
OptionComponentDataModel,
copy = false,
[Debug, Clone]
);
impl_vec_debug!(ComponentDataModel, ComponentDataModelVec);
impl_vec_clone!(
ComponentDataModel,
ComponentDataModelVec,
ComponentDataModelVecDestructor
);
impl_vec_mut!(ComponentDataModel, ComponentDataModelVec);
// ============================================================================
// Serde support for ComponentDataModel (feature-gated)
// ============================================================================
#[cfg(feature = "serde-json")]
mod serde_impl {
use super::*;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// --- AzString helpers ---
fn ser_azstring<S: Serializer>(s: &AzString, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(s.as_str())
}
fn de_azstring<'de, D: Deserializer<'de>>(deserializer: D) -> Result<AzString, D::Error> {
let s = alloc::string::String::deserialize(deserializer)?;
Ok(AzString::from(s.as_str()))
}
// --- ComponentFieldType ---
impl Serialize for ComponentFieldType {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&field_type_to_string(self))
}
}
impl<'de> Deserialize<'de> for ComponentFieldType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = alloc::string::String::deserialize(deserializer)?;
Ok(string_to_field_type(&s))
}
}
fn field_type_to_string(ft: &ComponentFieldType) -> alloc::string::String {
match ft {
ComponentFieldType::String => "String".into(),
ComponentFieldType::Bool => "bool".into(),
ComponentFieldType::I32 => "i32".into(),
ComponentFieldType::I64 => "i64".into(),
ComponentFieldType::U32 => "u32".into(),
ComponentFieldType::U64 => "u64".into(),
ComponentFieldType::Usize => "usize".into(),
ComponentFieldType::F32 => "f32".into(),
ComponentFieldType::F64 => "f64".into(),
ComponentFieldType::ColorU => "ColorU".into(),
ComponentFieldType::CssProperty => "CssProperty".into(),
ComponentFieldType::ImageRef => "ImageRef".into(),
ComponentFieldType::FontRef => "FontRef".into(),
ComponentFieldType::StyledDom => "Dom".into(),
ComponentFieldType::Callback(sig) => {
alloc::format!("Callback({})", sig.return_type.as_str())
}
ComponentFieldType::RefAny(hint) => alloc::format!("RefAny({})", hint.as_str()),
ComponentFieldType::OptionType(inner) => {
alloc::format!("Option<{}>", field_type_to_string(inner.as_ref()))
}
ComponentFieldType::VecType(inner) => {
alloc::format!("Vec<{}>", field_type_to_string(inner.as_ref()))
}
ComponentFieldType::StructRef(name) => alloc::format!("struct:{}", name.as_str()),
ComponentFieldType::EnumRef(name) => alloc::format!("enum:{}", name.as_str()),
}
}
fn string_to_field_type(s: &str) -> ComponentFieldType {
match s {
"String" | "string" => ComponentFieldType::String,
"bool" | "Bool" => ComponentFieldType::Bool,
"i32" | "I32" => ComponentFieldType::I32,
"i64" | "I64" => ComponentFieldType::I64,
"u32" | "U32" => ComponentFieldType::U32,
"u64" | "U64" => ComponentFieldType::U64,
"usize" | "Usize" => ComponentFieldType::Usize,
"f32" | "F32" => ComponentFieldType::F32,
"f64" | "F64" => ComponentFieldType::F64,
"ColorU" | "Color" | "color" => ComponentFieldType::ColorU,
"CssProperty" => ComponentFieldType::CssProperty,
"ImageRef" | "Image" => ComponentFieldType::ImageRef,
"FontRef" | "Font" => ComponentFieldType::FontRef,
"Dom" | "StyledDom" | "Children" => ComponentFieldType::StyledDom,
other => {
if let Some(inner) = other
.strip_prefix("Option<")
.and_then(|s| s.strip_suffix('>'))
{
ComponentFieldType::OptionType(ComponentFieldTypeBox::new(
string_to_field_type(inner),
))
} else if let Some(inner) =
other.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>'))
{
ComponentFieldType::VecType(ComponentFieldTypeBox::new(string_to_field_type(
inner,
)))
} else if let Some(name) = other.strip_prefix("struct:") {
ComponentFieldType::StructRef(AzString::from(name))
} else if let Some(name) = other.strip_prefix("enum:") {
ComponentFieldType::EnumRef(AzString::from(name))
} else if other.starts_with("Callback") {
let ret = other
.strip_prefix("Callback(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or("()");
ComponentFieldType::Callback(ComponentCallbackSignature {
return_type: AzString::from(ret),
args: ComponentCallbackArgVec::from_const_slice(&[]),
})
} else if other.starts_with("RefAny") {
let hint = other
.strip_prefix("RefAny(")
.and_then(|s| s.strip_suffix(')'))
.unwrap_or("");
ComponentFieldType::RefAny(AzString::from(hint))
} else {
ComponentFieldType::String // fallback
}
}
}
}
// --- ComponentDefaultValue ---
impl Serialize for ComponentDefaultValue {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
ComponentDefaultValue::None => serializer.serialize_none(),
ComponentDefaultValue::String(s) => serializer.serialize_str(s.as_str()),
ComponentDefaultValue::Bool(b) => serializer.serialize_bool(*b),
ComponentDefaultValue::I32(v) => serializer.serialize_i32(*v),
ComponentDefaultValue::I64(v) => serializer.serialize_i64(*v),
ComponentDefaultValue::U32(v) => serializer.serialize_u32(*v),
ComponentDefaultValue::U64(v) => serializer.serialize_u64(*v),
ComponentDefaultValue::Usize(v) => serializer.serialize_u64(*v as u64),
ComponentDefaultValue::F32(v) => serializer.serialize_f32(*v),
ComponentDefaultValue::F64(v) => serializer.serialize_f64(*v),
ComponentDefaultValue::ColorU(c) => serializer.serialize_str(&alloc::format!(
"#{:02x}{:02x}{:02x}{:02x}",
c.r,
c.g,
c.b,
c.a
)),
ComponentDefaultValue::ComponentInstance(ci) => {
let mut map = serializer.serialize_map(Some(2))?;
map.serialize_entry("library", ci.library.as_str())?;
map.serialize_entry("component", ci.component.as_str())?;
map.end()
}
ComponentDefaultValue::CallbackFnPointer(name) => {
serializer.serialize_str(name.as_str())
}
ComponentDefaultValue::Json(json_str) => {
// Serialize raw JSON string as-is by parsing and re-emitting
match serde_json::from_str::<serde_json::Value>(json_str.as_str()) {
Ok(v) => v.serialize(serializer),
Err(_) => serializer.serialize_str(json_str.as_str()),
}
}
}
}
}
impl<'de> Deserialize<'de> for ComponentDefaultValue {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let val = serde_json::Value::deserialize(deserializer)?;
Ok(match val {
serde_json::Value::Null => ComponentDefaultValue::None,
serde_json::Value::Bool(b) => ComponentDefaultValue::Bool(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if let Ok(v) = i32::try_from(i) {
ComponentDefaultValue::I32(v)
} else {
ComponentDefaultValue::I64(i)
}
} else if let Some(f) = n.as_f64() {
ComponentDefaultValue::F64(f)
} else {
ComponentDefaultValue::None
}
}
serde_json::Value::String(s) => {
ComponentDefaultValue::String(AzString::from(s.as_str()))
}
_ => ComponentDefaultValue::None,
})
}
}
// --- OptionComponentDefaultValue ---
impl Serialize for OptionComponentDefaultValue {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
OptionComponentDefaultValue::Some(v) => v.serialize(serializer),
OptionComponentDefaultValue::None => serializer.serialize_none(),
}
}
}
impl<'de> Deserialize<'de> for OptionComponentDefaultValue {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let val = Option::<ComponentDefaultValue>::deserialize(deserializer)?;
Ok(match val {
Some(v) => OptionComponentDefaultValue::Some(v),
None => OptionComponentDefaultValue::None,
})
}
}
// --- ComponentDataField ---
impl Serialize for ComponentDataField {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut s = serializer.serialize_struct("ComponentDataField", 5)?;
s.serialize_field("name", self.name.as_str())?;
s.serialize_field("type", &self.field_type)?;
s.serialize_field("default", &self.default_value)?;
s.serialize_field("required", &self.required)?;
s.serialize_field("description", self.description.as_str())?;
s.end()
}
}
impl<'de> Deserialize<'de> for ComponentDataField {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Helper {
name: alloc::string::String,
#[serde(rename = "type", default = "default_type")]
field_type: ComponentFieldType,
#[serde(default)]
default: OptionComponentDefaultValue,
#[serde(default)]
required: bool,
#[serde(default)]
description: alloc::string::String,
}
fn default_type() -> ComponentFieldType {
ComponentFieldType::String
}
let h = Helper::deserialize(deserializer)?;
Ok(ComponentDataField {
name: AzString::from(h.name.as_str()),
field_type: h.field_type,
default_value: h.default,
required: h.required,
description: AzString::from(h.description.as_str()),
})
}
}
// --- ComponentDataModel ---
impl Serialize for ComponentDataModel {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut s = serializer.serialize_struct("ComponentDataModel", 3)?;
s.serialize_field("name", self.name.as_str())?;
s.serialize_field("description", self.description.as_str())?;
let fields: alloc::vec::Vec<&ComponentDataField> =
self.fields.as_ref().iter().collect();
s.serialize_field("fields", &fields)?;
s.end()
}
}
impl<'de> Deserialize<'de> for ComponentDataModel {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
struct Helper {
#[serde(default)]
name: alloc::string::String,
#[serde(default)]
description: alloc::string::String,
#[serde(default)]
fields: alloc::vec::Vec<ComponentDataField>,
}
let h = Helper::deserialize(deserializer)?;
Ok(ComponentDataModel {
name: AzString::from(h.name.as_str()),
description: AzString::from(h.description.as_str()),
fields: ComponentDataFieldVec::from_vec(h.fields),
})
}
}
}
// Re-export serde impls so they're visible when the feature is enabled
#[cfg(feature = "serde-json")]
pub use serde_impl::*;
#[cfg(feature = "serde-json")]
impl ComponentDataModel {
/// Serialize this data model to a JSON string.
pub fn to_json(&self) -> Result<alloc::string::String, alloc::string::String> {
serde_json::to_string_pretty(self).map_err(|e| alloc::format!("{}", e))
}
/// Deserialize a data model from a JSON string.
pub fn from_json(json: &str) -> Result<Self, alloc::string::String> {
serde_json::from_str(json).map_err(|e| alloc::format!("{}", e))
}
}
/// Source of a component definition — determines whether it can be exported
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
#[derive(Default)]
pub enum ComponentSource {
/// Built into the DLL (HTML elements). Never exported.
Builtin,
/// Compiled Rust widget (Button, `TextInput`, etc.). Never exported.
Compiled,
/// Defined via JSON/XML at runtime. Can be exported.
#[default]
UserDefined,
}
impl ComponentSource {
#[must_use] pub fn create() -> Self {
Self::default()
}
}
/// The target language for code compilation
// Threaded by reference through the codegen call graph; kept non-Copy so
// deriving Copy doesn't force trivially_copy_pass_by_ref churn across the many
// &CompileTarget codegen callers for a perf-neutral change.
#[allow(missing_copy_implementations)]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub enum CompileTarget {
Rust,
C,
Cpp,
Python,
}
impl_result!(
StyledDom,
RenderDomError,
ResultStyledDomRenderDomError,
copy = false,
[Debug, Clone, PartialEq]
);
impl_result!(
AzString,
CompileError,
ResultStringCompileError,
copy = false,
[Debug, Clone, PartialEq]
);
/// Render function type: takes component definition + data model (with current values
/// in `default_value` fields) + component map for recursive sub-component instantiation,
/// returns `StyledDom`.
///
/// The `data` parameter is typically `def.data_model` cloned and with caller-provided
/// values substituted into the `default_value` fields.
pub type ComponentRenderFn =
fn(&ComponentDef, &ComponentDataModel, &ComponentMap) -> ResultStyledDomRenderDomError;
/// Compile function type: takes component definition + target language + data model, returns source code.
pub type ComponentCompileFn = fn(
&ComponentDef,
&CompileTarget,
&ComponentDataModel,
indent: usize,
) -> ResultStringCompileError;
/// Raw function pointer type that returns a single `ComponentDef` when called.
/// Used as the `cb` field in `RegisterComponentFn`.
pub type RegisterComponentFnType = extern "C" fn() -> ComponentDef;
/// Callback struct for registering individual components at startup.
///
/// In C: pass a bare `extern "C" fn() -> ComponentDef` function pointer —
/// it converts automatically via `From<RegisterComponentFnType>`.
///
/// In Python: construct this struct with `cb` set to a trampoline and
/// `ctx` set to `Some(RefAny(...))` wrapping the Python callable.
#[repr(C)]
pub struct RegisterComponentFn {
pub cb: RegisterComponentFnType,
/// For FFI: stores the foreign callable (e.g., `PyFunction`).
/// Native Rust/C code sets this to None.
pub ctx: crate::refany::OptionRefAny,
}
impl_callback!(RegisterComponentFn, RegisterComponentFnType);
/// Raw function pointer type that returns a complete `ComponentLibrary` when called.
/// Used as the `cb` field in `RegisterComponentLibraryFn`.
pub type RegisterComponentLibraryFnType = extern "C" fn() -> ComponentLibrary;
/// Callback struct for registering entire component libraries at startup.
///
/// In C: pass a bare `extern "C" fn() -> ComponentLibrary` function pointer —
/// it converts automatically via `From<RegisterComponentLibraryFnType>`.
///
/// In Python: construct this struct with `cb` set to a trampoline and
/// `ctx` set to `Some(RefAny(...))` wrapping the Python callable.
#[repr(C)]
pub struct RegisterComponentLibraryFn {
pub cb: RegisterComponentLibraryFnType,
/// For FFI: stores the foreign callable (e.g., `PyFunction`).
/// Native Rust/C code sets this to None.
pub ctx: crate::refany::OptionRefAny,
}
impl_callback!(RegisterComponentLibraryFn, RegisterComponentLibraryFnType);
/// A component definition — the "class" / "template" of a component.
/// Can come from Rust builtins, compiled widgets, JSON, or user creation in debugger.
///
#[derive(Clone)]
#[repr(C)]
pub struct ComponentDef {
/// Collection + name, e.g. builtin:div, shadcn:avatar
pub id: ComponentId,
/// Human-readable display name, e.g. "Link" for builtin:a, "Avatar" for shadcn:avatar
pub display_name: AzString,
/// Markdown documentation for the component
pub description: AzString,
/// The component's CSS
pub css: AzString,
/// Where this component was defined (determines exportability)
pub source: ComponentSource,
/// Unified data model: all value fields, callback slots, and child slots
/// in a single named struct. Code gen uses `data_model.name` as the
/// input struct type name (e.g. "`ButtonData`").
/// The `default_value` on each field doubles as the "current value" for
/// preview rendering — callers override defaults before calling `render_fn`.
pub data_model: ComponentDataModel,
/// Render to live DOM
pub render_fn: ComponentRenderFn,
/// Compile to source code in target language
pub compile_fn: ComponentCompileFn,
/// Source code for `render_fn` (user-defined components only)
pub render_fn_source: OptionString,
/// Source code for `compile_fn` (user-defined components only)
pub compile_fn_source: OptionString,
}
impl fmt::Debug for ComponentDef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ComponentDef")
.field("id", &self.id)
.field("display_name", &self.display_name)
.field("source", &self.source)
.field("data_model", &self.data_model.name)
.finish_non_exhaustive()
}
}
impl_vec!(
ComponentDef,
ComponentDefVec,
ComponentDefVecDestructor,
ComponentDefVecDestructorType,
ComponentDefVecSlice,
OptionComponentDef
);
impl_option!(ComponentDef, OptionComponentDef, copy = false, [Clone]);
impl_vec_debug!(ComponentDef, ComponentDefVec);
impl_vec_clone!(ComponentDef, ComponentDefVec, ComponentDefVecDestructor);
impl_vec_mut!(ComponentDef, ComponentDefVec);
/// A named collection of component definitions
#[derive(Debug, Clone)]
#[repr(C)]
pub struct ComponentLibrary {
/// Library identifier, e.g. "builtin", "shadcn", "myproject"
pub name: AzString,
/// Version string
pub version: AzString,
/// Human-readable description
pub description: AzString,
/// The components in this library
pub components: ComponentDefVec,
/// Whether this library can be exported (false for builtin/compiled)
pub exportable: bool,
/// Whether this library can be modified by the user (add/remove/edit components).
/// False for builtin and compiled libraries. True for user-created libraries.
pub modifiable: bool,
/// Named data model types defined by this library.
/// Components reference these by name in their `field_type`.
pub data_models: ComponentDataModelVec,
/// Named enum types defined by this library.
/// Components reference these via `ComponentFieldType::EnumRef(name)`.
pub enum_models: ComponentEnumModelVec,
}
impl_vec!(
ComponentLibrary,
ComponentLibraryVec,
ComponentLibraryVecDestructor,
ComponentLibraryVecDestructorType,
ComponentLibraryVecSlice,
OptionComponentLibrary
);
impl_option!(
ComponentLibrary,
OptionComponentLibrary,
copy = false,
[Debug, Clone]
);
impl_vec_debug!(ComponentLibrary, ComponentLibraryVec);
impl_vec_clone!(
ComponentLibrary,
ComponentLibraryVec,
ComponentLibraryVecDestructor
);
impl_vec_mut!(ComponentLibrary, ComponentLibraryVec);
/// The component map — holds libraries with namespaced components.
#[derive(Debug, Clone)]
#[repr(C)]
pub struct ComponentMap {
/// Libraries indexed by name. "builtin" is always present.
pub libraries: ComponentLibraryVec,
}
impl ComponentMap {
/// Qualified lookup: "shadcn:avatar" -> finds library "shadcn", component "avatar"
#[must_use] pub fn get(&self, collection: &str, name: &str) -> Option<&ComponentDef> {
self.libraries
.iter()
.find(|lib| lib.name.as_str() == collection)
.and_then(|lib| lib.components.iter().find(|c| c.id.name.as_str() == name))
}
/// Unqualified lookup: "div" -> searches ONLY the "builtin" library.
#[must_use] pub fn get_unqualified(&self, name: &str) -> Option<&ComponentDef> {
self.get("builtin", name)
}
/// Parse a "collection:name" string into a lookup
#[must_use] pub fn get_by_qualified_name(&self, qualified: &str) -> Option<&ComponentDef> {
if let Some((collection, name)) = qualified.split_once(':') {
self.get(collection, name)
} else {
self.get_unqualified(qualified)
}
}
/// Get all libraries that can be exported (user-defined only)
#[must_use] pub fn get_exportable_libraries(&self) -> Vec<&ComponentLibrary> {
self.libraries.iter().filter(|lib| lib.exportable).collect()
}
/// Get all component definitions across all libraries
#[must_use] pub fn all_components(&self) -> Vec<&ComponentDef> {
self.libraries
.iter()
.flat_map(|lib| lib.components.iter())
.collect()
}
}
// ============================================================================
// Builtin component bridge — wraps existing render/compile into ComponentDef
// ============================================================================
/// Single source of truth mapping HTML/SVG tag names to node variants.
///
/// Each `"tag" => Variant` entry expands to **both** a `NodeType::Variant` arm in
/// [`tag_to_node_type`] and a `NodeTypeTag::Variant` arm in [`tag_to_node_type_tag`],
/// so the two lookups can never drift apart. Tags whose two enums diverge —
/// `img`, `image`, `icon` — are handled as explicit special cases inside each
/// generated function and are intentionally absent from this table.
macro_rules! html_tag_node_types {
($($tag:literal => $variant:ident),* $(,)?) => {
/// Map a builtin tag name to its corresponding `NodeType`.
/// Falls back to `NodeType::Div` for unknown tags.
#[must_use] pub fn tag_to_node_type(tag: &str) -> NodeType {
match tag {
// `<img>` becomes a replaced `NodeType::Image`. The `src` attribute is not
// available here, so a placeholder `NullImage` (0x0, empty tag) is created;
// `xml_node_to_dom_fast` overrides it with a `NullImage` whose `tag` carries
// the `src` bytes so a renderer (e.g. printpdf) can resolve the actual image.
"img" => NodeType::Image(azul_css::css::BoxOrStatic::heap(
crate::resources::ImageRef::null_image(
0,
0,
crate::resources::RawImageFormat::RGBA8,
alloc::vec::Vec::new(),
),
)),
$($tag => NodeType::$variant,)*
_ => NodeType::Div,
}
}
/// Map a tag name to its CSS `NodeTypeTag` for CSS matching in the compile pipeline.
/// Falls back to `NodeTypeTag::Div` for unknown tags.
fn tag_to_node_type_tag(tag: &str) -> NodeTypeTag {
match tag {
// `img`/`image`/`icon` have no 1:1 `NodeType` equivalent (see
// `tag_to_node_type`), so they map to dedicated `NodeTypeTag` variants.
"img" | "image" => NodeTypeTag::Img,
"icon" => NodeTypeTag::Icon,
$($tag => NodeTypeTag::$variant,)*
_ => NodeTypeTag::Div,
}
}
};
}
html_tag_node_types! {
// Document structure
"html" => Html,
"head" => Head,
"title" => Title,
"body" => Body,
// Block-level
"div" => Div,
"header" => Header,
"footer" => Footer,
"section" => Section,
"article" => Article,
"aside" => Aside,
"nav" => Nav,
"main" => Main,
"figure" => Figure,
"figcaption" => FigCaption,
"address" => Address,
"details" => Details,
"summary" => Summary,
"dialog" => Dialog,
// Headings
"h1" => H1,
"h2" => H2,
"h3" => H3,
"h4" => H4,
"h5" => H5,
"h6" => H6,
// Text content
"p" => P,
"span" => Span,
"pre" => Pre,
"code" => Code,
"blockquote" => BlockQuote,
"br" => Br,
"hr" => Hr,
// Lists
"ul" => Ul,
"ol" => Ol,
"li" => Li,
"dl" => Dl,
"dt" => Dt,
"dd" => Dd,
"menu" => Menu,
"menuitem" => MenuItem,
"dir" => Dir,
// Tables
"table" => Table,
"caption" => Caption,
"thead" => THead,
"tbody" => TBody,
"tfoot" => TFoot,
"tr" => Tr,
"th" => Th,
"td" => Td,
"colgroup" => ColGroup,
"col" => Col,
// Forms
"form" => Form,
"fieldset" => FieldSet,
"legend" => Legend,
"label" => Label,
"input" => Input,
"button" => Button,
"select" => Select,
"optgroup" => OptGroup,
"option" => SelectOption,
"textarea" => TextArea,
"output" => Output,
"progress" => Progress,
"meter" => Meter,
"datalist" => DataList,
// Inline
"a" => A,
"strong" => Strong,
"em" => Em,
"b" => B,
"i" => I,
"u" => U,
"s" => S,
"small" => Small,
"mark" => Mark,
"del" => Del,
"ins" => Ins,
"samp" => Samp,
"kbd" => Kbd,
"var" => Var,
"cite" => Cite,
"dfn" => Dfn,
"abbr" => Abbr,
"acronym" => Acronym,
"q" => Q,
"time" => Time,
"sub" => Sub,
"sup" => Sup,
"big" => Big,
"bdo" => Bdo,
"bdi" => Bdi,
"wbr" => Wbr,
"ruby" => Ruby,
"rt" => Rt,
"rtc" => Rtc,
"rp" => Rp,
"data" => Data,
// Embedded content (`img` is a special case in the generated fns)
"canvas" => Canvas,
"object" => Object,
"param" => Param,
"embed" => Embed,
"audio" => Audio,
"video" => Video,
"source" => Source,
"track" => Track,
"map" => Map,
"area" => Area,
// SVG elements
"svg" => Svg,
"g" => SvgG,
"defs" => SvgDefs,
"symbol" => SvgSymbol,
"use" => SvgUse,
"switch" => SvgSwitch,
"path" => SvgPath,
"circle" => SvgCircle,
"rect" => SvgRect,
"ellipse" => SvgEllipse,
"line" => SvgLine,
"polygon" => SvgPolygon,
"polyline" => SvgPolyline,
"tspan" => SvgTspan,
"textpath" => SvgTextPath,
"lineargradient" => SvgLinearGradient,
"radialgradient" => SvgRadialGradient,
"stop" => SvgStop,
"pattern" => SvgPattern,
"clippath" => SvgClipPathElement,
"mask" => SvgMask,
"filter" => SvgFilter,
"feblend" => SvgFeBlend,
"fecolormatrix" => SvgFeColorMatrix,
"fecomponenttransfer" => SvgFeComponentTransfer,
"fecomposite" => SvgFeComposite,
"feconvolvematrix" => SvgFeConvolveMatrix,
"fediffuselighting" => SvgFeDiffuseLighting,
"fedisplacementmap" => SvgFeDisplacementMap,
"fedistantlight" => SvgFeDistantLight,
"fedropshadow" => SvgFeDropShadow,
"feflood" => SvgFeFlood,
"fefuncr" => SvgFeFuncR,
"fefuncg" => SvgFeFuncG,
"fefuncb" => SvgFeFuncB,
"fefunca" => SvgFeFuncA,
"fegaussianblur" => SvgFeGaussianBlur,
"feimage" => SvgFeImage,
"femerge" => SvgFeMerge,
"femergenode" => SvgFeMergeNode,
"femorphology" => SvgFeMorphology,
"feoffset" => SvgFeOffset,
"fepointlight" => SvgFePointLight,
"fespecularlighting" => SvgFeSpecularLighting,
"fespotlight" => SvgFeSpotLight,
"fetile" => SvgFeTile,
"feturbulence" => SvgFeTurbulence,
"foreignobject" => SvgForeignObject,
"desc" => SvgDesc,
"view" => SvgView,
"animate" => SvgAnimate,
"animatemotion" => SvgAnimateMotion,
"animatetransform" => SvgAnimateTransform,
"set" => SvgSet,
"mpath" => SvgMpath,
// Metadata
"meta" => Meta,
"link" => Link,
"script" => Script,
"style" => Style,
"base" => Base,
}
/// Default render function for builtin HTML elements.
/// Delegates to creating a DOM node of the appropriate `NodeType`.
fn builtin_render_fn(
def: &ComponentDef,
data: &ComponentDataModel,
_component_map: &ComponentMap,
) -> ResultStyledDomRenderDomError {
let node_type = tag_to_node_type(def.id.name.as_str());
let mut dom = Dom::create_node(node_type);
if let Some(text_str) = data.get_default_string("text") {
let prepared = prepare_string(text_str);
if !prepared.is_empty() {
dom = dom.with_children(alloc::vec![Dom::create_text(prepared)].into());
}
}
let r: Result<StyledDom, RenderDomError> = Ok(StyledDom::create(&mut dom, Css::empty()));
r.into()
}
/// Default compile function for builtin HTML elements.
/// Generates `Dom::create_node(NodeType::Div)` style code for the target language.
fn builtin_compile_fn(
def: &ComponentDef,
target: &CompileTarget,
data: &ComponentDataModel,
indent: usize,
) -> ResultStringCompileError {
let node_type = tag_to_node_type(def.id.name.as_str());
let type_name = format!("{node_type:?}"); // "Div", "Body", "P", etc.
let text = data.get_default_string("text");
let r: Result<AzString, CompileError> = match target {
CompileTarget::Rust => {
text.map_or_else(|| Ok(format!("Dom::create_node(NodeType::{type_name})").into()), |text_str| Ok(format!(
"Dom::create_node(NodeType::{}).with_children(vec![Dom::create_text(\"{}\")])",
type_name,
text_str.as_str().replace('\\', "\\\\").replace('"', "\\\"")
).into()))
}
CompileTarget::C => {
text.map_or_else(|| Ok(format!("AzDom_create{type_name}()").into()), |text_str| Ok(format!(
"AzDom_createText(AZ_STR(\"{}\"))",
text_str
.as_str()
.replace('\\', "\\\\")
.replace('"', "\\\"")
)
.into()))
}
CompileTarget::Cpp => Ok(format!("Dom::create_{}()", type_name.to_lowercase()).into()),
CompileTarget::Python => Ok(format!("Dom.create_{}()", type_name.to_lowercase()).into()),
};
r.into()
}
/// Pushes a `<div>` containing `"field_name: value"` text into the children list.
fn push_scalar_field(children: &mut Vec<Dom>, field_name: &str, value: &dyn fmt::Display) {
use crate::dom::{Dom, NodeType};
let text = alloc::format!("{field_name}: {value}");
children.push(
Dom::create_node(NodeType::Div).with_children(alloc::vec![Dom::create_text(text)].into()),
);
}
/// Default render function for user-defined (JSON-imported) components.
///
/// Interprets the `ComponentDef` structure generically:
/// 1. Creates a wrapper `<div>` with the component's CSS class
/// 2. For each data field, renders content based on type:
/// - String fields → text node with current value
/// - Bool fields → conditional display
/// - `StyledDom` fields → embeds the child DOM subtree
/// - StructRef/EnumRef → recursively renders sub-components if found in `ComponentMap`
/// - Other scalar fields → text display of the value
/// 3. Applies the component's scoped CSS
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
#[must_use] pub fn user_defined_render_fn(
def: &ComponentDef,
data: &ComponentDataModel,
component_map: &ComponentMap,
) -> ResultStyledDomRenderDomError {
use crate::dom::{Dom, NodeType};
use azul_css::css::Css;
let mut children: Vec<Dom> = Vec::new();
for field in data.fields.as_ref() {
let field_name = field.name.as_str();
// Get the current value from default_value
match &field.default_value {
OptionComponentDefaultValue::None => {
// Required field with no value — skip in preview
}
OptionComponentDefaultValue::Some(default_val) => {
match default_val {
ComponentDefaultValue::String(s) => {
let text = s.as_str().trim();
if !text.is_empty() {
let label_dom = Dom::create_node(NodeType::Div).with_children(
alloc::vec![Dom::create_text(text.to_string())].into(),
);
children.push(label_dom);
}
}
ComponentDefaultValue::Bool(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::I32(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::I64(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::U32(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::U64(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::Usize(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::F32(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::F64(v) => {
push_scalar_field(&mut children, field_name, v);
}
ComponentDefaultValue::ColorU(c) => {
let text = alloc::format!(
"{}: #{:02x}{:02x}{:02x}{:02x}",
field_name,
c.r,
c.g,
c.b,
c.a
);
children.push(
Dom::create_node(NodeType::Div)
.with_children(alloc::vec![Dom::create_text(text)].into()),
);
}
ComponentDefaultValue::ComponentInstance(ci) => {
// Recursively instantiate sub-component from ComponentMap
if let Some(sub_comp) =
component_map.get(ci.library.as_str(), ci.component.as_str())
{
let sub_data = sub_comp.data_model.clone();
match (sub_comp.render_fn)(sub_comp, &sub_data, component_map) {
ResultStyledDomRenderDomError::Ok(_styled_dom) => {
// Sub-component rendered successfully — add a placeholder
// (StyledDom cannot be directly converted back to Dom)
let text = alloc::format!(
"[{}:{}]",
ci.library.as_str(),
ci.component.as_str()
);
children.push(
Dom::create_node(NodeType::Div).with_children(
alloc::vec![Dom::create_text(text)].into(),
),
);
}
ResultStyledDomRenderDomError::Err(_) => {
// On error, show a placeholder
let text = alloc::format!(
"[Error rendering {}:{}]",
ci.library.as_str(),
ci.component.as_str()
);
children.push(
Dom::create_node(NodeType::Div).with_children(
alloc::vec![Dom::create_text(text)].into(),
),
);
}
}
} else {
let text = alloc::format!(
"[Unknown component {}:{}]",
ci.library.as_str(),
ci.component.as_str()
);
children.push(
Dom::create_node(NodeType::Div)
.with_children(alloc::vec![Dom::create_text(text)].into()),
);
}
}
ComponentDefaultValue::CallbackFnPointer(name) => {
// Callbacks are not rendered, just acknowledged
let text = alloc::format!("{}: fn({})", field_name, name.as_str());
children.push(
Dom::create_node(NodeType::Div)
.with_children(alloc::vec![Dom::create_text(text)].into()),
);
}
ComponentDefaultValue::Json(json_str) => {
let text = alloc::format!("{}: {}", field_name, json_str.as_str());
children.push(
Dom::create_node(NodeType::Div)
.with_children(alloc::vec![Dom::create_text(text)].into()),
);
}
ComponentDefaultValue::None => {
// No default, skip
}
}
}
}
}
let mut wrapper = Dom::create_node(NodeType::Div);
if !children.is_empty() {
wrapper = wrapper.with_children(children.into());
}
// Apply component CSS
let css = if def.css.as_str().is_empty() {
Css::empty()
} else {
Css::from_string(def.css.clone())
};
let r: Result<StyledDom, RenderDomError> = Ok(StyledDom::create(&mut wrapper, css));
r.into()
}
/// Default compile function for user-defined (JSON-imported) components.
///
/// Generates source code that creates the component's DOM structure for the
/// target language. For each data field, emits the appropriate code:
/// - String fields → text node creation
/// - Scalar fields → formatted display
/// - `ComponentInstance` → function call to sub-component's render function
/// - `StyledDom` slots → child parameter pass-through
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
#[must_use] pub fn user_defined_compile_fn(
def: &ComponentDef,
target: &CompileTarget,
data: &ComponentDataModel,
indent: usize,
) -> ResultStringCompileError {
let tag = def.id.name.as_str();
let indent_str = " ".repeat(indent * 4);
let inner_indent = " ".repeat((indent + 1) * 4);
let r: Result<AzString, CompileError> = match target {
CompileTarget::Rust => {
let mut lines = Vec::new();
lines.push(alloc::format!("{indent_str}// Component: {tag}"));
lines.push(alloc::format!(
"{indent_str}let mut children: Vec<Dom> = Vec::new();"
));
for field in data.fields.as_ref() {
let fname = field.name.as_str();
match &field.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
let escaped = s.as_str().replace('\\', "\\\\").replace('"', "\\\"");
lines.push(alloc::format!(
"{inner_indent}children.push(Dom::create_text(\"{escaped}\"));"
));
}
OptionComponentDefaultValue::Some(ComponentDefaultValue::Bool(b)) => {
lines.push(alloc::format!(
"{inner_indent}children.push(Dom::create_text(format!(\"{{}}: {{}}\", \"{fname}\", {b}).as_str()));"
));
}
OptionComponentDefaultValue::Some(
ComponentDefaultValue::ComponentInstance(ci),
) => {
let fn_name =
alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
lines.push(alloc::format!(
"{}children.push({}()); // sub-component {}:{}",
inner_indent,
fn_name,
ci.library.as_str(),
ci.component.as_str()
));
}
_ => {
// For other types, generate a placeholder comment
lines.push(alloc::format!(
"{}// field '{}': {:?}",
inner_indent,
fname,
field.field_type
));
}
}
}
lines.push(alloc::format!(
"{indent_str}Dom::create_node(NodeType::Div).with_children(children.into())"
));
Ok(lines.join("\n").into())
}
CompileTarget::C => {
let mut lines = Vec::new();
lines.push(alloc::format!("{indent_str}/* Component: {tag} */"));
lines.push(alloc::format!(
"{indent_str}AzDom root = AzDom_createDiv();"
));
for field in data.fields.as_ref() {
let fname = field.name.as_str();
match &field.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
let escaped = s.as_str().replace('\\', "\\\\").replace('"', "\\\"");
lines.push(alloc::format!(
"{inner_indent}AzDom_addChild(&root, AzDom_createText(AZ_STR(\"{escaped}\")));"
));
}
OptionComponentDefaultValue::Some(
ComponentDefaultValue::ComponentInstance(ci),
) => {
let fn_name =
alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
lines.push(alloc::format!(
"{inner_indent}AzDom_addChild(&root, {fn_name}());"
));
}
_ => {
lines.push(alloc::format!("{inner_indent}/* field '{fname}' */"));
}
}
}
lines.push(alloc::format!("{indent_str}return root;"));
Ok(lines.join("\n").into())
}
CompileTarget::Cpp => {
let mut lines = Vec::new();
lines.push(alloc::format!("{indent_str}// Component: {tag}"));
lines.push(alloc::format!(
"{indent_str}auto root = Dom::create_div();"
));
for field in data.fields.as_ref() {
let fname = field.name.as_str();
match &field.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
let escaped = s.as_str().replace('\\', "\\\\").replace('"', "\\\"");
lines.push(alloc::format!(
"{inner_indent}root.add_child(Dom::create_text(String(\"{escaped}\")));"
));
}
OptionComponentDefaultValue::Some(
ComponentDefaultValue::ComponentInstance(ci),
) => {
let fn_name =
alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
lines.push(alloc::format!(
"{inner_indent}root.add_child({fn_name}());"
));
}
_ => {
lines.push(alloc::format!("{inner_indent}// field '{fname}'"));
}
}
}
lines.push(alloc::format!("{indent_str}return root;"));
Ok(lines.join("\n").into())
}
CompileTarget::Python => {
let mut lines = Vec::new();
lines.push(alloc::format!("{indent_str}# Component: {tag}"));
lines.push(alloc::format!("{indent_str}root = Dom.create_div()"));
for field in data.fields.as_ref() {
let fname = field.name.as_str();
match &field.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
let escaped = s
.as_str()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\'', "\\'");
lines.push(alloc::format!(
"{inner_indent}root = root.with_child(Dom.create_text(\"{escaped}\"))"
));
}
OptionComponentDefaultValue::Some(
ComponentDefaultValue::ComponentInstance(ci),
) => {
let fn_name =
alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
lines.push(alloc::format!(
"{inner_indent}root = root.with_child({fn_name}())"
));
}
_ => {
lines.push(alloc::format!("{inner_indent}# field '{fname}'"));
}
}
}
lines.push(alloc::format!("{indent_str}return root"));
Ok(lines.join("\n").into())
}
};
r.into()
}
/// Create a `ComponentDef` for a builtin HTML element.
///
/// # Arguments
/// * `tag` - HTML tag name (e.g. "button", "div")
/// * `display_name` - Human-readable name (e.g. "Button", "Div")
/// * `default_text` - Default text content for the preview, or `None` if the element has no text.
/// Pass `Some("Button text")` for `<button>`, `Some("")` for text elements like `<span>` that
/// accept text but have no meaningful default.
/// * `css` - Component-level CSS string. For most builtin elements this is `""` because
/// styling comes from `ua_css.rs` and the `SystemStyle`. Components that need extra
/// styling (e.g. a future high-level button widget) can pass CSS here.
fn builtin_component_def(
tag: &str,
display_name: &str,
default_text: Option<&str>,
css: &str,
) -> ComponentDef {
let mut fields = builtin_data_model(tag);
// If a default_text is provided, this element accepts text content
if let Some(text) = default_text {
fields.push(data_field(
"text",
ComponentFieldType::String,
Some(ComponentDefaultValue::String(AzString::from(text))),
"Text content of the element",
));
}
let model_name = format!("{display_name}Data");
ComponentDef {
id: ComponentId::builtin(tag),
display_name: AzString::from(display_name),
description: AzString::from(format!("HTML <{tag}> element").as_str()),
css: AzString::from(css),
source: ComponentSource::Builtin,
data_model: ComponentDataModel {
name: AzString::from(model_name.as_str()),
description: AzString::from(format!("Data model for <{tag}>").as_str()),
fields: fields.into(),
},
render_fn: builtin_render_fn,
compile_fn: builtin_compile_fn,
render_fn_source: None.into(),
compile_fn_source: None.into(),
}
}
/// Helper to create a `ComponentDataField` with a rich type
fn data_field(
name: &str,
ft: ComponentFieldType,
default: Option<ComponentDefaultValue>,
description: &str,
) -> ComponentDataField {
let required = default.is_none();
ComponentDataField {
name: AzString::from(name),
field_type: ft,
default_value: default.map_or_else(|| OptionComponentDefaultValue::None, OptionComponentDefaultValue::Some),
required,
description: AzString::from(description),
}
}
/// Returns the tag-specific data model fields for builtin HTML elements.
/// These are the component's "main data model" — the attributes that define
/// what the component needs as configuration (e.g., `href` for `<a>`,
/// `src` for `<img>`). Universal HTML attributes (id, class, style, etc.)
/// are NOT included here — they are added separately by the debug server.
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
fn builtin_data_model(tag: &str) -> Vec<ComponentDataField> {
use ComponentDefaultValue as D;
use ComponentFieldType::{String, Bool, I32};
match tag {
"a" => alloc::vec![
data_field(
"href",
String,
Some(D::String(AzString::from_const_str(""))),
"URL the link points to"
),
data_field(
"target",
String,
Some(D::String(AzString::from_const_str(""))),
"Where to open the linked document (_blank, _self, _parent, _top)"
),
data_field(
"rel",
String,
Some(D::String(AzString::from_const_str(""))),
"Relationship between current and linked document"
),
],
"img" | "image" => alloc::vec![
data_field("src", String, None, "URL of the image"),
data_field(
"alt",
String,
Some(D::String(AzString::from_const_str(""))),
"Alternative text for the image"
),
data_field(
"width",
String,
Some(D::String(AzString::from_const_str(""))),
"Width of the image"
),
data_field(
"height",
String,
Some(D::String(AzString::from_const_str(""))),
"Height of the image"
),
],
"form" => alloc::vec![
data_field(
"action",
String,
Some(D::String(AzString::from_const_str(""))),
"URL where form data is submitted"
),
data_field(
"method",
String,
Some(D::String(AzString::from_const_str("GET"))),
"HTTP method for form submission (GET or POST)"
),
],
"label" => alloc::vec![data_field(
"for",
String,
Some(D::String(AzString::from_const_str(""))),
"ID of the form element this label is for"
),],
"button" => alloc::vec![
data_field(
"type",
String,
Some(D::String(AzString::from_const_str("button"))),
"Button type (button, submit, reset)"
),
data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether the button is disabled"
),
],
"td" | "th" => alloc::vec![
data_field(
"colspan",
I32,
Some(D::I32(1)),
"Number of columns the cell spans"
),
data_field(
"rowspan",
I32,
Some(D::I32(1)),
"Number of rows the cell spans"
),
],
"icon" => alloc::vec![data_field(
"name",
String,
Some(D::String(AzString::from_const_str(""))),
"Icon name"
),],
"ol" => alloc::vec![
data_field(
"start",
I32,
Some(D::I32(1)),
"Start value for the ordered list"
),
data_field(
"type",
String,
Some(D::String(AzString::from_const_str("1"))),
"Numbering type (1, A, a, I, i)"
),
],
// Form controls
"input" => alloc::vec![
data_field(
"type",
String,
Some(D::String(AzString::from_const_str("text"))),
"Input type (text, password, email, number, checkbox, radio, etc.)"
),
data_field(
"name",
String,
Some(D::String(AzString::from_const_str(""))),
"Name of the input for form submission"
),
data_field(
"value",
String,
Some(D::String(AzString::from_const_str(""))),
"Current value of the input"
),
data_field(
"placeholder",
String,
Some(D::String(AzString::from_const_str(""))),
"Placeholder text"
),
data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether the input is disabled"
),
data_field(
"required",
Bool,
Some(D::Bool(false)),
"Whether the input is required"
),
data_field(
"readonly",
Bool,
Some(D::Bool(false)),
"Whether the input is read-only"
),
data_field(
"checked",
Bool,
Some(D::Bool(false)),
"Whether the checkbox/radio is checked"
),
data_field(
"min",
String,
Some(D::String(AzString::from_const_str(""))),
"Minimum value (for number, range, date)"
),
data_field(
"max",
String,
Some(D::String(AzString::from_const_str(""))),
"Maximum value (for number, range, date)"
),
data_field(
"step",
String,
Some(D::String(AzString::from_const_str(""))),
"Step increment (for number, range)"
),
data_field(
"pattern",
String,
Some(D::String(AzString::from_const_str(""))),
"Regex pattern for validation"
),
data_field(
"maxlength",
String,
Some(D::String(AzString::from_const_str(""))),
"Maximum number of characters"
),
],
"select" => alloc::vec![
data_field(
"name",
String,
Some(D::String(AzString::from_const_str(""))),
"Name for form submission"
),
data_field(
"multiple",
Bool,
Some(D::Bool(false)),
"Whether multiple options can be selected"
),
data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether the select is disabled"
),
data_field(
"required",
Bool,
Some(D::Bool(false)),
"Whether selection is required"
),
data_field(
"size",
String,
Some(D::String(AzString::from_const_str(""))),
"Number of visible options"
),
],
"option" => alloc::vec![
data_field(
"value",
String,
Some(D::String(AzString::from_const_str(""))),
"Value submitted with the form"
),
data_field(
"selected",
Bool,
Some(D::Bool(false)),
"Whether this option is selected"
),
data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether this option is disabled"
),
],
"optgroup" => alloc::vec![
data_field(
"label",
String,
Some(D::String(AzString::from_const_str(""))),
"Label for the option group"
),
data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether the group is disabled"
),
],
"textarea" => alloc::vec![
data_field(
"name",
String,
Some(D::String(AzString::from_const_str(""))),
"Name for form submission"
),
data_field(
"placeholder",
String,
Some(D::String(AzString::from_const_str(""))),
"Placeholder text"
),
data_field("rows", I32, Some(D::I32(2)), "Number of visible text lines"),
data_field(
"cols",
I32,
Some(D::I32(20)),
"Visible width in average character widths"
),
data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether the textarea is disabled"
),
data_field(
"required",
Bool,
Some(D::Bool(false)),
"Whether content is required"
),
data_field(
"readonly",
Bool,
Some(D::Bool(false)),
"Whether the textarea is read-only"
),
data_field(
"maxlength",
String,
Some(D::String(AzString::from_const_str(""))),
"Maximum number of characters"
),
],
"fieldset" => alloc::vec![data_field(
"disabled",
Bool,
Some(D::Bool(false)),
"Whether all controls in the fieldset are disabled"
),],
"output" => alloc::vec![
data_field(
"for",
String,
Some(D::String(AzString::from_const_str(""))),
"IDs of elements that contributed to the output"
),
data_field(
"name",
String,
Some(D::String(AzString::from_const_str(""))),
"Name for form submission"
),
],
"progress" => alloc::vec![
data_field(
"value",
String,
Some(D::String(AzString::from_const_str(""))),
"Current progress value"
),
data_field(
"max",
String,
Some(D::String(AzString::from_const_str("1"))),
"Maximum value"
),
],
"meter" => alloc::vec![
data_field(
"value",
String,
Some(D::String(AzString::from_const_str(""))),
"Current value"
),
data_field(
"min",
String,
Some(D::String(AzString::from_const_str("0"))),
"Minimum value"
),
data_field(
"max",
String,
Some(D::String(AzString::from_const_str("1"))),
"Maximum value"
),
data_field(
"low",
String,
Some(D::String(AzString::from_const_str(""))),
"Low threshold"
),
data_field(
"high",
String,
Some(D::String(AzString::from_const_str(""))),
"High threshold"
),
data_field(
"optimum",
String,
Some(D::String(AzString::from_const_str(""))),
"Optimum value"
),
],
// Interactive
"details" => alloc::vec![data_field(
"open",
Bool,
Some(D::Bool(false)),
"Whether the details are visible"
),],
"dialog" => alloc::vec![data_field(
"open",
Bool,
Some(D::Bool(false)),
"Whether the dialog is active and can be interacted with"
),],
// Embedded content
"audio" | "video" => alloc::vec![
data_field(
"src",
String,
Some(D::String(AzString::from_const_str(""))),
"URL of the media resource"
),
data_field(
"controls",
Bool,
Some(D::Bool(false)),
"Whether to show playback controls"
),
data_field(
"autoplay",
Bool,
Some(D::Bool(false)),
"Whether to start playing automatically"
),
data_field(
"loop",
Bool,
Some(D::Bool(false)),
"Whether to loop playback"
),
data_field(
"muted",
Bool,
Some(D::Bool(false)),
"Whether audio is muted"
),
data_field(
"preload",
String,
Some(D::String(AzString::from_const_str("auto"))),
"Preload hint (none, metadata, auto)"
),
],
"source" => alloc::vec![
data_field("src", String, None, "URL of the media resource"),
data_field(
"type",
String,
Some(D::String(AzString::from_const_str(""))),
"MIME type of the resource"
),
],
"track" => alloc::vec![
data_field("src", String, None, "URL of the track file"),
data_field(
"kind",
String,
Some(D::String(AzString::from_const_str("subtitles"))),
"Kind of text track (subtitles, captions, descriptions, chapters, metadata)"
),
data_field(
"srclang",
String,
Some(D::String(AzString::from_const_str(""))),
"Language of the track text"
),
data_field(
"label",
String,
Some(D::String(AzString::from_const_str(""))),
"User-readable title for the track"
),
data_field(
"default",
Bool,
Some(D::Bool(false)),
"Whether this is the default track"
),
],
"canvas" => alloc::vec![
data_field(
"width",
String,
Some(D::String(AzString::from_const_str("300"))),
"Width of the canvas in pixels"
),
data_field(
"height",
String,
Some(D::String(AzString::from_const_str("150"))),
"Height of the canvas in pixels"
),
],
"embed" => alloc::vec![
data_field("src", String, None, "URL of the resource to embed"),
data_field(
"type",
String,
Some(D::String(AzString::from_const_str(""))),
"MIME type of the embedded content"
),
data_field(
"width",
String,
Some(D::String(AzString::from_const_str(""))),
"Width"
),
data_field(
"height",
String,
Some(D::String(AzString::from_const_str(""))),
"Height"
),
],
"object" => alloc::vec![
data_field(
"data",
String,
Some(D::String(AzString::from_const_str(""))),
"URL of the resource"
),
data_field(
"type",
String,
Some(D::String(AzString::from_const_str(""))),
"MIME type of the resource"
),
data_field(
"width",
String,
Some(D::String(AzString::from_const_str(""))),
"Width"
),
data_field(
"height",
String,
Some(D::String(AzString::from_const_str(""))),
"Height"
),
],
"param" => alloc::vec![
data_field("name", String, None, "Name of the parameter"),
data_field(
"value",
String,
Some(D::String(AzString::from_const_str(""))),
"Value of the parameter"
),
],
"area" => alloc::vec![
data_field(
"shape",
String,
Some(D::String(AzString::from_const_str("default"))),
"Shape of the area (default, rect, circle, poly)"
),
data_field(
"coords",
String,
Some(D::String(AzString::from_const_str(""))),
"Coordinates of the area"
),
data_field(
"href",
String,
Some(D::String(AzString::from_const_str(""))),
"URL for the area link"
),
data_field(
"alt",
String,
Some(D::String(AzString::from_const_str(""))),
"Alternative text"
),
data_field(
"target",
String,
Some(D::String(AzString::from_const_str(""))),
"Where to open the linked document"
),
],
"map" => alloc::vec![data_field(
"name",
String,
None,
"Name of the image map (referenced by usemap)"
),],
// Inline semantics with special attributes
"time" => alloc::vec![data_field(
"datetime",
String,
Some(D::String(AzString::from_const_str(""))),
"Machine-readable date/time value"
),],
"data" => alloc::vec![data_field(
"value",
String,
Some(D::String(AzString::from_const_str(""))),
"Machine-readable value"
),],
"abbr" | "acronym" | "dfn" => alloc::vec![data_field(
"title",
String,
Some(D::String(AzString::from_const_str(""))),
"Full expansion or definition"
),],
"q" | "blockquote" => alloc::vec![data_field(
"cite",
String,
Some(D::String(AzString::from_const_str(""))),
"URL of the source of the quotation"
),],
"del" | "ins" => alloc::vec![
data_field(
"cite",
String,
Some(D::String(AzString::from_const_str(""))),
"URL explaining the change"
),
data_field(
"datetime",
String,
Some(D::String(AzString::from_const_str(""))),
"Date/time of the change"
),
],
"bdo" => alloc::vec![data_field(
"dir",
String,
Some(D::String(AzString::from_const_str("ltr"))),
"Text direction (ltr, rtl)"
),],
"col" | "colgroup" => alloc::vec![data_field(
"span",
I32,
Some(D::I32(1)),
"Number of columns the element spans"
),],
// Metadata
"meta" => alloc::vec![
data_field(
"name",
String,
Some(D::String(AzString::from_const_str(""))),
"Metadata name"
),
data_field(
"content",
String,
Some(D::String(AzString::from_const_str(""))),
"Metadata value"
),
data_field(
"charset",
String,
Some(D::String(AzString::from_const_str(""))),
"Character encoding"
),
data_field(
"http-equiv",
String,
Some(D::String(AzString::from_const_str(""))),
"HTTP header equivalent"
),
],
"link" => alloc::vec![
data_field("rel", String, None, "Relationship type"),
data_field(
"href",
String,
Some(D::String(AzString::from_const_str(""))),
"URL of the linked resource"
),
data_field(
"type",
String,
Some(D::String(AzString::from_const_str(""))),
"MIME type of the linked resource"
),
],
"script" => alloc::vec![
data_field(
"src",
String,
Some(D::String(AzString::from_const_str(""))),
"URL of external script"
),
data_field(
"type",
String,
Some(D::String(AzString::from_const_str(""))),
"MIME type or module"
),
data_field(
"async",
Bool,
Some(D::Bool(false)),
"Execute asynchronously"
),
data_field(
"defer",
Bool,
Some(D::Bool(false)),
"Defer execution until page load"
),
],
"style" => alloc::vec![data_field(
"type",
String,
Some(D::String(AzString::from_const_str("text/css"))),
"MIME type of the style sheet"
),],
"base" => alloc::vec![
data_field(
"href",
String,
Some(D::String(AzString::from_const_str(""))),
"Base URL for relative URLs"
),
data_field(
"target",
String,
Some(D::String(AzString::from_const_str(""))),
"Default target for hyperlinks"
),
],
_ => alloc::vec![],
}
}
impl Default for ComponentMap {
/// Returns an empty `ComponentMap` with no libraries.
///
/// Use `AppConfig::create()` (which registers the 52 builtins via
/// `register_builtin_components`) followed by `ComponentMap::from_libraries()`
/// to get a fully-populated map.
fn default() -> Self {
Self {
libraries: ComponentLibraryVec::from_const_slice(&[]),
}
}
}
impl ComponentMap {
#[must_use] pub fn create() -> Self {
Self::default()
}
/// Create a `ComponentMap` with the 52 built-in HTML element components pre-registered.
#[must_use] pub fn with_builtin() -> Self {
Self {
libraries: alloc::vec![register_builtin_components()].into(),
}
}
/// Build a `ComponentMap` from the libraries stored in an `AppConfig`.
///
/// The `component_libraries` field already contains builtins (registered in
/// `AppConfig::create()`) plus any user-added libraries. No merging needed —
/// `add_component_library` / `add_component` handle insertion at registration time.
#[must_use] pub fn from_libraries(libs: &ComponentLibraryVec) -> Self {
Self {
libraries: libs.clone(),
}
}
}
/// Convert XML attributes to a `ComponentDataModel` by cloning the component's
/// base data model and overriding field defaults with values from the XML attributes.
///
/// This is the bridge between the XML parsing layer (key-value string pairs)
/// and the typed component data model. For each field in the base model,
/// if a matching XML attribute exists, its string value is set as the new default.
///
/// # Arguments
/// * `base_model` - The component's data model template (from `ComponentDef::data_model`)
/// * `xml_attributes` - The XML node's attribute map
/// * `text_content` - Optional text content from child text nodes
///
/// # Returns
/// A cloned `ComponentDataModel` with overridden defaults
fn xml_attrs_to_data_model(
base_model: &ComponentDataModel,
xml_attributes: &XmlAttributeMap,
text_content: Option<&str>,
) -> ComponentDataModel {
let mut model = base_model.clone();
// Override defaults from XML attributes
let mut fields_vec = core::mem::replace(
&mut model.fields,
ComponentDataFieldVec::from_const_slice(&[]),
)
.into_library_owned_vec();
for field in &mut fields_vec {
if let Some(attr_value) = xml_attributes.get_key(field.name.as_str()) {
// Override the default_value with the XML attribute's string value
field.default_value = OptionComponentDefaultValue::Some(ComponentDefaultValue::String(
attr_value.clone(),
));
}
}
model.fields = ComponentDataFieldVec::from_vec(fields_vec);
// Handle text content — set the "text" field if present
if let Some(text) = text_content {
let prepared = prepare_string(text);
if !prepared.is_empty() {
model = model.with_default(
"text",
ComponentDefaultValue::String(AzString::from(prepared.as_str())),
);
}
}
model
}
// ============================================================================
// Structural builtin components: if, for, map
// ============================================================================
/// `builtin:if` — conditional rendering.
/// Takes `condition: Bool`, `then: StyledDom`, and optionally `else: StyledDom`.
fn builtin_if_component() -> ComponentDef {
ComponentDef {
id: ComponentId::builtin("if"),
display_name: AzString::from_const_str("If"),
description: AzString::from_const_str("Conditional rendering: shows 'then' if condition is true, else shows 'else' (if provided)."),
css: AzString::from_const_str(""),
source: ComponentSource::Builtin,
data_model: ComponentDataModel {
name: AzString::from_const_str("IfData"),
description: AzString::from_const_str("Data for conditional rendering"),
fields: alloc::vec![
data_field("condition", ComponentFieldType::Bool, Some(ComponentDefaultValue::Bool(false)), "The boolean condition to evaluate"),
].into(),
},
render_fn: builtin_if_render_fn,
compile_fn: builtin_if_compile_fn,
render_fn_source: None.into(),
compile_fn_source: None.into(),
}
}
fn builtin_if_render_fn(
_comp: &ComponentDef,
data_model: &ComponentDataModel,
_component_map: &ComponentMap,
) -> ResultStyledDomRenderDomError {
// Evaluate the condition field
let condition = data_model
.fields
.iter()
.find(|f| f.name.as_str() == "condition")
.and_then(|f| match &f.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::Bool(b)) => Some(*b),
_ => None,
})
.unwrap_or(false);
let label = if condition {
"if: true (then branch)"
} else {
"if: false (else branch)"
};
let mut dom =
Dom::create_node(NodeType::Div).with_children(alloc::vec![Dom::create_text(label)].into());
let css = Css::empty();
ResultStyledDomRenderDomError::Ok(StyledDom::create(&mut dom, css))
}
fn builtin_if_compile_fn(
_comp: &ComponentDef,
target: &CompileTarget,
_data: &ComponentDataModel,
_indent: usize,
) -> ResultStringCompileError {
match target {
CompileTarget::Rust => ResultStringCompileError::Ok(AzString::from(
"if data.condition {\n // then branch\n Dom::create_div()\n} else {\n // else branch\n Dom::create_div()\n}"
)),
CompileTarget::C => ResultStringCompileError::Ok(AzString::from(
"if (data.condition) {\n // then branch\n AzDom_createDiv();\n} else {\n // else branch\n AzDom_createDiv();\n}"
)),
CompileTarget::Cpp => ResultStringCompileError::Ok(AzString::from(
"if (data.condition) {\n // then branch\n Dom::create_div();\n} else {\n // else branch\n Dom::create_div();\n}"
)),
CompileTarget::Python => ResultStringCompileError::Ok(AzString::from(
"if data.condition:\n # then branch\n Dom.create_div()\nelse:\n # else branch\n Dom.create_div()"
)),
}
}
/// `builtin:for` — iterative rendering.
/// Takes `count: U32` (number of iterations), renders children N times.
fn builtin_for_component() -> ComponentDef {
ComponentDef {
id: ComponentId::builtin("for"),
display_name: AzString::from_const_str("For Loop"),
description: AzString::from_const_str(
"Iterative rendering: repeats children 'count' times.",
),
css: AzString::from_const_str(""),
source: ComponentSource::Builtin,
data_model: ComponentDataModel {
name: AzString::from_const_str("ForData"),
description: AzString::from_const_str("Data for iterative rendering"),
fields: alloc::vec![data_field(
"count",
ComponentFieldType::U32,
Some(ComponentDefaultValue::U32(3)),
"Number of iterations"
),]
.into(),
},
render_fn: builtin_for_render_fn,
compile_fn: builtin_for_compile_fn,
render_fn_source: None.into(),
compile_fn_source: None.into(),
}
}
fn builtin_for_render_fn(
_comp: &ComponentDef,
data_model: &ComponentDataModel,
_component_map: &ComponentMap,
) -> ResultStyledDomRenderDomError {
let count = data_model
.fields
.iter()
.find(|f| f.name.as_str() == "count")
.and_then(|f| match &f.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::U32(n)) => Some(*n),
_ => None,
})
.unwrap_or(3);
let mut items: Vec<Dom> = Vec::new();
for i in 0..count {
items.push(
Dom::create_node(NodeType::Div)
.with_children(alloc::vec![Dom::create_text(alloc::format!("Item {i}"))].into()),
);
}
let mut dom = Dom::create_node(NodeType::Div).with_children(items.into());
let css = Css::empty();
ResultStyledDomRenderDomError::Ok(StyledDom::create(&mut dom, css))
}
fn builtin_for_compile_fn(
_comp: &ComponentDef,
target: &CompileTarget,
_data: &ComponentDataModel,
_indent: usize,
) -> ResultStringCompileError {
match target {
CompileTarget::Rust => ResultStringCompileError::Ok(AzString::from(
"let mut children = Vec::new();\nfor i in 0..data.count {\n children.push(Dom::create_div());\n}\nDom::create_div().with_children(children)"
)),
CompileTarget::C => ResultStringCompileError::Ok(AzString::from(
"AzDom container = AzDom_createDiv();\nfor (uint32_t i = 0; i < data.count; i++) {\n AzDom_addChild(&container, AzDom_createDiv());\n}"
)),
CompileTarget::Cpp => ResultStringCompileError::Ok(AzString::from(
"auto container = Dom::create_div();\nfor (uint32_t i = 0; i < data.count; i++) {\n container.add_child(Dom::create_div());\n}"
)),
CompileTarget::Python => ResultStringCompileError::Ok(AzString::from(
"container = Dom.create_div()\nfor i in range(data.count):\n container = container.with_child(Dom.create_div())"
)),
}
}
/// `builtin:map` — map data to DOM.
/// Takes `data_json: String` (JSON array) + maps each element.
fn builtin_map_component() -> ComponentDef {
ComponentDef {
id: ComponentId::builtin("map"),
display_name: AzString::from_const_str("Map"),
description: AzString::from_const_str(
"Map data to DOM: applies a template to each item in a collection.",
),
css: AzString::from_const_str(""),
source: ComponentSource::Builtin,
data_model: ComponentDataModel {
name: AzString::from_const_str("MapData"),
description: AzString::from_const_str("Data for map rendering"),
fields: alloc::vec![data_field(
"data_json",
ComponentFieldType::String,
Some(ComponentDefaultValue::String(AzString::from_const_str(
"[]"
))),
"JSON array of items to map over"
),]
.into(),
},
render_fn: builtin_map_render_fn,
compile_fn: builtin_map_compile_fn,
render_fn_source: None.into(),
compile_fn_source: None.into(),
}
}
fn builtin_map_render_fn(
_comp: &ComponentDef,
data_model: &ComponentDataModel,
_component_map: &ComponentMap,
) -> ResultStyledDomRenderDomError {
// For now, render a placeholder — actual mapping requires callback support
let data_str = data_model
.fields
.iter()
.find(|f| f.name.as_str() == "data_json")
.and_then(|f| match &f.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
Some(s.as_str().to_string())
}
_ => None,
})
.unwrap_or_else(|| "[]".to_string());
let label = alloc::format!("map: data_json={data_str}");
let mut dom =
Dom::create_node(NodeType::Div).with_children(alloc::vec![Dom::create_text(label)].into());
let css = Css::empty();
ResultStyledDomRenderDomError::Ok(StyledDom::create(&mut dom, css))
}
fn builtin_map_compile_fn(
_comp: &ComponentDef,
target: &CompileTarget,
_data: &ComponentDataModel,
_indent: usize,
) -> ResultStringCompileError {
match target {
CompileTarget::Rust => ResultStringCompileError::Ok(AzString::from(
"let items: Vec<serde_json::Value> = serde_json::from_str(&data.data_json).unwrap_or_default();\nlet children: Vec<Dom> = items.iter().map(|item| {\n Dom::create_div() // map template\n}).collect();\nDom::create_div().with_children(children)"
)),
CompileTarget::C => ResultStringCompileError::Ok(AzString::from(
"// Parse data.data_json and map each item\nAzDom container = AzDom_createDiv();\n// TODO: iterate parsed JSON array"
)),
CompileTarget::Cpp => ResultStringCompileError::Ok(AzString::from(
"// Parse data.data_json and map each item\nauto container = Dom::create_div();\n// TODO: iterate parsed JSON array"
)),
CompileTarget::Python => ResultStringCompileError::Ok(AzString::from(
"import json\nitems = json.loads(data.data_json)\ncontainer = Dom.create_div()\nfor item in items:\n container = container.with_child(Dom.create_div())"
)),
}
}
/// Register the 52 built-in HTML element components.
///
/// This is an `extern "C"` function pointer compatible with
/// `RegisterComponentLibraryFnType`, so it can be passed directly to
/// `AppConfig::add_component_library()`.
///
/// Called once during `AppConfig::create()` — the framework dogfoods
/// its own component registration system for builtins.
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
#[must_use] pub extern "C" fn register_builtin_components() -> ComponentLibrary {
ComponentLibrary {
name: AzString::from_const_str("builtin"),
version: AzString::from_const_str("1.0.0"),
description: AzString::from_const_str("Built-in HTML elements"),
exportable: false,
modifiable: false,
data_models: Vec::new().into(),
enum_models: Vec::new().into(),
components: alloc::vec![
// Structural
builtin_component_def("html", "HTML", None, ""),
builtin_component_def("head", "Head", None, ""),
builtin_component_def("title", "Title", Some(""), ""),
builtin_component_def("body", "Body", None, ""),
// Block-level
builtin_component_def("div", "Div", None, ""),
builtin_component_def("header", "Header", None, ""),
builtin_component_def("footer", "Footer", None, ""),
builtin_component_def("section", "Section", None, ""),
builtin_component_def("article", "Article", None, ""),
builtin_component_def("aside", "Aside", None, ""),
builtin_component_def("nav", "Nav", None, ""),
builtin_component_def("main", "Main", None, ""),
builtin_component_def("figure", "Figure", None, ""),
builtin_component_def("figcaption", "Figure Caption", Some(""), ""),
builtin_component_def("address", "Address", Some(""), ""),
builtin_component_def("details", "Details", None, ""),
builtin_component_def("summary", "Summary", Some("Details"), ""),
builtin_component_def("dialog", "Dialog", None, ""),
// Headings — default text is the heading level name so preview is visible
builtin_component_def("h1", "Heading 1", Some("Heading 1"), ""),
builtin_component_def("h2", "Heading 2", Some("Heading 2"), ""),
builtin_component_def("h3", "Heading 3", Some("Heading 3"), ""),
builtin_component_def("h4", "Heading 4", Some("Heading 4"), ""),
builtin_component_def("h5", "Heading 5", Some("Heading 5"), ""),
builtin_component_def("h6", "Heading 6", Some("Heading 6"), ""),
// Text content
builtin_component_def("p", "Paragraph", Some("Paragraph text"), ""),
builtin_component_def("span", "Span", Some(""), ""),
builtin_component_def("pre", "Preformatted", Some(""), ""),
builtin_component_def("code", "Code", Some(""), ""),
builtin_component_def("blockquote", "Blockquote", Some(""), ""),
builtin_component_def("br", "Line Break", None, ""),
builtin_component_def("hr", "Horizontal Rule", None, ""),
builtin_component_def("icon", "Icon", Some(""), ""),
// Lists
builtin_component_def("ul", "Unordered List", None, ""),
builtin_component_def("ol", "Ordered List", None, ""),
builtin_component_def("li", "List Item", Some("List item"), ""),
builtin_component_def("dl", "Description List", None, ""),
builtin_component_def("dt", "Description Term", Some(""), ""),
builtin_component_def("dd", "Description Details", Some(""), ""),
builtin_component_def("menu", "Menu", None, ""),
builtin_component_def("menuitem", "Menu Item", Some(""), ""),
builtin_component_def("dir", "Directory List", None, ""),
// Tables
builtin_component_def("table", "Table", None, ""),
builtin_component_def("caption", "Table Caption", Some(""), ""),
builtin_component_def("thead", "Table Head", None, ""),
builtin_component_def("tbody", "Table Body", None, ""),
builtin_component_def("tfoot", "Table Foot", None, ""),
builtin_component_def("tr", "Table Row", None, ""),
builtin_component_def("th", "Table Header Cell", Some("Header"), ""),
builtin_component_def("td", "Table Data Cell", Some(""), ""),
builtin_component_def("colgroup", "Column Group", None, ""),
builtin_component_def("col", "Column", None, ""),
// Inline
builtin_component_def("a", "Link", Some("Link text"), ""),
builtin_component_def("strong", "Strong", Some(""), ""),
builtin_component_def("em", "Emphasis", Some(""), ""),
builtin_component_def("b", "Bold", Some(""), ""),
builtin_component_def("i", "Italic", Some(""), ""),
builtin_component_def("u", "Underline", Some(""), ""),
builtin_component_def("s", "Strikethrough", Some(""), ""),
builtin_component_def("small", "Small", Some(""), ""),
builtin_component_def("mark", "Mark", Some(""), ""),
builtin_component_def("del", "Deleted Text", Some(""), ""),
builtin_component_def("ins", "Inserted Text", Some(""), ""),
builtin_component_def("sub", "Subscript", Some(""), ""),
builtin_component_def("sup", "Superscript", Some(""), ""),
builtin_component_def("samp", "Sample Output", Some(""), ""),
builtin_component_def("kbd", "Keyboard Input", Some(""), ""),
builtin_component_def("var", "Variable", Some(""), ""),
builtin_component_def("cite", "Citation", Some(""), ""),
builtin_component_def("dfn", "Definition", Some(""), ""),
builtin_component_def("abbr", "Abbreviation", Some(""), ""),
builtin_component_def("acronym", "Acronym", Some(""), ""),
builtin_component_def("q", "Inline Quote", Some(""), ""),
builtin_component_def("time", "Time", Some(""), ""),
builtin_component_def("big", "Big", Some(""), ""),
builtin_component_def("bdo", "BiDi Override", Some(""), ""),
builtin_component_def("bdi", "BiDi Isolate", Some(""), ""),
builtin_component_def("wbr", "Word Break Opportunity", None, ""),
builtin_component_def("ruby", "Ruby Annotation", None, ""),
builtin_component_def("rt", "Ruby Text", Some(""), ""),
builtin_component_def("rtc", "Ruby Text Container", None, ""),
builtin_component_def("rp", "Ruby Parenthesis", Some(""), ""),
builtin_component_def("data", "Data", Some(""), ""),
// Forms
builtin_component_def("form", "Form", None, ""),
builtin_component_def("fieldset", "Field Set", None, ""),
builtin_component_def("legend", "Legend", Some("Legend"), ""),
builtin_component_def("label", "Label", Some("Label"), ""),
builtin_component_def("input", "Input", None, ""),
builtin_component_def("button", "Button", Some("Button text"), ""),
builtin_component_def("select", "Select", None, ""),
builtin_component_def("optgroup", "Option Group", None, ""),
builtin_component_def("option", "Option", Some(""), ""),
builtin_component_def("textarea", "Text Area", Some(""), ""),
builtin_component_def("output", "Output", Some(""), ""),
builtin_component_def("progress", "Progress", None, ""),
builtin_component_def("meter", "Meter", None, ""),
builtin_component_def("datalist", "Data List", None, ""),
// Embedded content
builtin_component_def("canvas", "Canvas", None, ""),
builtin_component_def("object", "Object", None, ""),
builtin_component_def("param", "Parameter", None, ""),
builtin_component_def("embed", "Embed", None, ""),
builtin_component_def("audio", "Audio", None, ""),
builtin_component_def("video", "Video", None, ""),
builtin_component_def("source", "Source", None, ""),
builtin_component_def("track", "Track", None, ""),
builtin_component_def("map", "Image Map", None, ""),
builtin_component_def("area", "Map Area", None, ""),
builtin_component_def("svg", "SVG", None, ""),
// Metadata
builtin_component_def("meta", "Meta", None, ""),
builtin_component_def("link", "Link (Resource)", None, ""),
builtin_component_def("script", "Script", Some(""), ""),
builtin_component_def("style", "Style", Some(""), ""),
builtin_component_def("base", "Base URL", None, ""),
// Structural control-flow builtins (F1-F3)
builtin_if_component(),
builtin_for_component(),
builtin_map_component(),
]
.into(),
}
}
// ============================================================================
// End new component system types
// ============================================================================
/// Wrapper for the XML parser - necessary to easily create a Dom from
/// XML without putting an XML solver into `azul-core`.
#[derive(Debug, Default)]
pub struct DomXml {
pub parsed_dom: StyledDom,
}
impl DomXml {
/// Convenience function, only available in tests, useful for quickly writing UI tests.
/// Wraps the XML string in the required `<app></app>` braces, panics if the XML couldn't be
/// parsed.
///
/// ## Example
///
/// ```rust,ignore
/// # use azul::dom::Dom;
/// # use azul::xml::DomXml;
/// let dom = DomXml::mock("<div id='test' />");
/// dom.assert_eq(Dom::create_div().with_id("test"));
/// ```
///
/// # Panics
///
/// Panics if the rendered DOM does not equal `other` (this is a test-only
/// assertion helper).
#[cfg(test)]
pub fn assert_eq(self, other: StyledDom) {
let mut body = Dom::create_body();
let mut fixed = StyledDom::create(&mut body, Css::empty());
fixed.append_child(other);
assert!(!(self.parsed_dom != fixed),
"\r\nExpected DOM did not match:\r\n\r\nexpected: ----------\r\n{}\r\ngot: \
----------\r\n{}\r\n",
self.parsed_dom.get_html_string("", "", true),
fixed.get_html_string("", "", true)
);
}
#[must_use] pub fn into_styled_dom(self) -> StyledDom {
self.into()
}
}
impl From<DomXml> for StyledDom {
fn from(val: DomXml) -> Self {
val.parsed_dom
}
}
/// Represents a child of an XML node - either an element or text
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C, u8)]
pub enum XmlNodeChild {
/// A text node
Text(AzString),
/// An element node
Element(XmlNode),
}
impl_option!(
XmlNodeChild,
OptionXmlNodeChild,
copy = false,
[Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
);
impl XmlNodeChild {
/// Get the text content if this is a text node
#[must_use] pub fn as_text(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s.as_str()),
Self::Element(_) => None,
}
}
/// Get the element if this is an element node
#[must_use] pub const fn as_element(&self) -> Option<&XmlNode> {
match self {
Self::Text(_) => None,
Self::Element(node) => Some(node),
}
}
/// Get the element mutably if this is an element node
pub const fn as_element_mut(&mut self) -> Option<&mut XmlNode> {
match self {
Self::Text(_) => None,
Self::Element(node) => Some(node),
}
}
}
impl_vec!(
XmlNodeChild,
XmlNodeChildVec,
XmlNodeChildVecDestructor,
XmlNodeChildVecDestructorType,
XmlNodeChildVecSlice,
OptionXmlNodeChild
);
impl_vec_mut!(XmlNodeChild, XmlNodeChildVec);
impl_vec_debug!(XmlNodeChild, XmlNodeChildVec);
impl_vec_partialeq!(XmlNodeChild, XmlNodeChildVec);
impl_vec_eq!(XmlNodeChild, XmlNodeChildVec);
impl_vec_partialord!(XmlNodeChild, XmlNodeChildVec);
impl_vec_ord!(XmlNodeChild, XmlNodeChildVec);
impl_vec_hash!(XmlNodeChild, XmlNodeChildVec);
impl_vec_clone!(XmlNodeChild, XmlNodeChildVec, XmlNodeChildVecDestructor);
/// Represents one XML node tag
#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct XmlNode {
/// Type of the node
pub node_type: XmlTagName,
/// Attributes of an XML node (note: not yet filtered and / or broken into function arguments!)
pub attributes: XmlAttributeMap,
/// Direct children of this node (can be text or element nodes)
pub children: XmlNodeChildVec,
}
impl_option!(
XmlNode,
OptionXmlNode,
copy = false,
[Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
);
impl XmlNode {
pub fn create<I: Into<XmlTagName>>(node_type: I) -> Self {
Self {
node_type: node_type.into(),
..Default::default()
}
}
#[must_use] pub fn with_children(mut self, v: Vec<XmlNodeChild>) -> Self {
Self {
children: v.into(),
..self
}
}
/// Get all text content concatenated from direct children
#[must_use] pub fn get_text_content(&self) -> String {
self.children
.as_ref()
.iter()
.filter_map(|child| child.as_text())
.collect::<Vec<_>>()
.join("")
}
/// Check if this node has only text children (no element children)
#[must_use] pub fn has_only_text_children(&self) -> bool {
self.children
.as_ref()
.iter()
.all(|child| matches!(child, XmlNodeChild::Text(_)))
}
}
impl_vec!(
XmlNode,
XmlNodeVec,
XmlNodeVecDestructor,
XmlNodeVecDestructorType,
XmlNodeVecSlice,
OptionXmlNode
);
impl_vec_mut!(XmlNode, XmlNodeVec);
impl_vec_debug!(XmlNode, XmlNodeVec);
impl_vec_partialeq!(XmlNode, XmlNodeVec);
impl_vec_eq!(XmlNode, XmlNodeVec);
impl_vec_partialord!(XmlNode, XmlNodeVec);
impl_vec_ord!(XmlNode, XmlNodeVec);
impl_vec_hash!(XmlNode, XmlNodeVec);
impl_vec_clone!(XmlNode, XmlNodeVec, XmlNodeVecDestructor);
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum DomXmlParseError {
/// No `<html></html>` node component present
NoHtmlNode,
/// Multiple `<html>` nodes
MultipleHtmlRootNodes,
/// No ´<body></body>´ node in the root HTML
NoBodyInHtml,
/// The DOM can only have one <body> node, not multiple.
MultipleBodyNodes,
/// Note: Sadly, the error type can only be a string because xmlparser
/// returns all errors as strings. There is an open PR to fix
/// this deficiency, but since the XML parsing is only needed for
/// hot-reloading and compiling, it doesn't matter that much.
Xml(XmlError),
/// Invalid hierarchy close tags, i.e `<app></p></app>`
MalformedHierarchy(MalformedHierarchyError),
/// A component raised an error while rendering the DOM - holds the component name + error
/// string
RenderDom(RenderDomError),
/// Something went wrong while parsing an XML component
Component(ComponentParseError),
/// Error parsing global CSS in head node
Css(CssParseErrorOwned),
}
impl From<XmlError> for DomXmlParseError {
fn from(e: XmlError) -> Self {
Self::Xml(e)
}
}
impl From<ComponentParseError> for DomXmlParseError {
fn from(e: ComponentParseError) -> Self {
Self::Component(e)
}
}
impl From<RenderDomError> for DomXmlParseError {
fn from(e: RenderDomError) -> Self {
Self::RenderDom(e)
}
}
impl From<CssParseErrorOwned> for DomXmlParseError {
fn from(e: CssParseErrorOwned) -> Self {
Self::Css(e)
}
}
/// Error that can happen from the translation from XML code to Rust code -
/// stringified, since it is only used for printing and is not exposed in the public API
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum CompileError {
Dom(RenderDomError),
Xml(DomXmlParseError),
Css(CssParseErrorOwned),
}
impl From<ComponentError> for CompileError {
fn from(e: ComponentError) -> Self {
Self::Dom(RenderDomError::Component(e))
}
}
impl From<CssParseErrorOwned> for CompileError {
fn from(e: CssParseErrorOwned) -> Self {
Self::Css(e)
}
}
impl fmt::Display for CompileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::CompileError::{Dom, Xml, Css};
match self {
Dom(d) => write!(f, "{d}"),
Xml(s) => write!(f, "{s}"),
Css(s) => write!(f, "{}", s.to_shared()),
}
}
}
impl From<RenderDomError> for CompileError {
fn from(e: RenderDomError) -> Self {
Self::Dom(e)
}
}
impl From<DomXmlParseError> for CompileError {
fn from(e: DomXmlParseError) -> Self {
Self::Xml(e)
}
}
/// Wrapper for `UselessFunctionArgument` error data.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct UselessFunctionArgumentError {
pub component_name: AzString,
pub argument_name: AzString,
pub valid_args: StringVec,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C, u8)]
pub enum ComponentError {
/// While instantiating a component, a function argument
/// was encountered that the component won't use or react to.
UselessFunctionArgument(UselessFunctionArgumentError),
/// A certain node type can't be rendered, because the
/// renderer for this node is not available isn't available
///
/// `UnknownComponent(component_name)`
UnknownComponent(AzString),
}
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum RenderDomError {
Component(ComponentError),
/// Error parsing the CSS on the component style
CssError(CssParseErrorOwned),
}
impl From<ComponentError> for RenderDomError {
fn from(e: ComponentError) -> Self {
Self::Component(e)
}
}
impl From<CssParseErrorOwned> for RenderDomError {
fn from(e: CssParseErrorOwned) -> Self {
Self::CssError(e)
}
}
/// Wrapper for `MissingType` error data.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct MissingTypeError {
pub arg_pos: usize,
pub arg_name: AzString,
}
/// Wrapper for `WhiteSpaceInComponentName` error data.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct WhiteSpaceInComponentNameError {
pub arg_pos: usize,
pub arg_name: AzString,
}
/// Wrapper for `WhiteSpaceInComponentType` error data.
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct WhiteSpaceInComponentTypeError {
pub arg_pos: usize,
pub arg_name: AzString,
pub arg_type: AzString,
}
#[derive(Debug, Clone, PartialEq)]
#[repr(C, u8)]
pub enum ComponentParseError {
/// Given `XmlNode` is not a `<component />` node.
NotAComponent,
/// A `<component>` node does not have a `name` attribute.
UnnamedComponent,
/// Argument at position `usize` is either empty or has no name
MissingName(usize),
/// Argument at position `usize` with the name
/// `String` doesn't have a `: type`
MissingType(MissingTypeError),
/// Component name may not contain a whitespace
/// (probably missing a `:` between the name and the type)
WhiteSpaceInComponentName(WhiteSpaceInComponentNameError),
/// Component type may not contain a whitespace
/// (probably missing a `,` between the type and the next name)
WhiteSpaceInComponentType(WhiteSpaceInComponentTypeError),
/// Error parsing the <style> tag / CSS
CssError(CssParseErrorOwned),
}
impl fmt::Display for DomXmlParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::DomXmlParseError::{NoHtmlNode, MultipleHtmlRootNodes, NoBodyInHtml, MultipleBodyNodes, Xml, MalformedHierarchy, RenderDom, Component, Css};
match self {
NoHtmlNode => write!(
f,
"No <html> node found as the root of the file - empty file?"
),
MultipleHtmlRootNodes => write!(
f,
"Multiple <html> nodes found as the root of the file - only one root node allowed"
),
NoBodyInHtml => write!(
f,
"No <body> node found as a direct child of an <html> node - malformed DOM \
hierarchy?"
),
MultipleBodyNodes => write!(
f,
"Multiple <body> nodes present, only one <body> node is allowed"
),
Xml(e) => write!(f, "Error parsing XML: {e}"),
MalformedHierarchy(e) => write!(
f,
"Invalid </{}> tag: expected </{}>",
e.got.as_str(),
e.expected.as_str()
),
RenderDom(e) => write!(f, "Error rendering DOM: {e}"),
Component(c) => write!(f, "Error parsing component in <head> node:\r\n{c}"),
Css(c) => write!(f, "Error parsing CSS in <head> node:\r\n{}", c.to_shared()),
}
}
}
impl fmt::Display for ComponentParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::ComponentParseError::{NotAComponent, UnnamedComponent, MissingName, MissingType, WhiteSpaceInComponentName, WhiteSpaceInComponentType, CssError};
match self {
NotAComponent => write!(f, "Expected <component/> node, found no such node"),
UnnamedComponent => write!(
f,
"Found <component/> tag with out a \"name\" attribute, component must have a name"
),
MissingName(arg_pos) => write!(
f,
"Argument at position {arg_pos} is either empty or has no name"
),
MissingType(e) => write!(
f,
"Argument \"{}\" at position {} doesn't have a `: type`",
e.arg_name, e.arg_pos
),
WhiteSpaceInComponentName(e) => {
write!(
f,
"Missing `:` between the name and the type in argument {} (around \"{}\")",
e.arg_pos, e.arg_name
)
}
WhiteSpaceInComponentType(e) => {
write!(
f,
"Missing `,` between two arguments (in argument {}, position {}, around \
\"{}\")",
e.arg_name, e.arg_pos, e.arg_type
)
}
CssError(lsf) => write!(f, "Error parsing <style> tag: {}", lsf.to_shared()),
}
}
}
impl fmt::Display for ComponentError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::ComponentError::{UselessFunctionArgument, UnknownComponent};
match self {
UselessFunctionArgument(e) => {
write!(
f,
"Useless component argument \"{}\": \"{}\" - available args are: {:#?}",
e.component_name, e.argument_name, e.valid_args
)
}
UnknownComponent(name) => write!(f, "Unknown component: \"{name}\""),
}
}
}
impl fmt::Display for RenderDomError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use self::RenderDomError::{Component, CssError};
match self {
Component(c) => write!(f, "{c}"),
CssError(e) => write!(f, "Error parsing CSS in component: {}", e.to_shared()),
}
}
}
/// Find the one and only `<body>` node, return error if
/// there is no app node or there are multiple app nodes
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the document has no `<html>` root node.
pub fn get_html_node(root_nodes: &[XmlNodeChild]) -> Result<&XmlNode, DomXmlParseError> {
let mut html_node_iterator = root_nodes.iter().filter_map(|child| {
if let XmlNodeChild::Element(node) = child {
let node_type_normalized = normalize_casing(&node.node_type);
if &node_type_normalized == "html" {
Some(node)
} else {
None
}
} else {
None
}
});
let html_node = html_node_iterator
.next()
.ok_or(DomXmlParseError::NoHtmlNode)?;
if html_node_iterator.next().is_some() {
Err(DomXmlParseError::MultipleHtmlRootNodes)
} else {
Ok(html_node)
}
}
/// Find the one and only `<body>` node, return error if
/// there is no app node or there are multiple app nodes
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the document has no `<body>` node.
pub fn get_body_node(root_nodes: &[XmlNodeChild]) -> Result<&XmlNode, DomXmlParseError> {
fn find_body_recursive(nodes: &[XmlNodeChild], depth: usize) -> Option<&XmlNode> {
// AUDIT 2026-07-08: bound recursion depth to avoid a stack overflow on
// pathologically deep markup while hunting for the <body> element.
if depth > MAX_XML_NESTING_DEPTH {
return None;
}
for child in nodes {
if let XmlNodeChild::Element(node) = child {
let node_type_normalized = normalize_casing(&node.node_type);
if &node_type_normalized == "body" {
return Some(node);
}
// Recurse into children
if let Some(found) = find_body_recursive(node.children.as_ref(), depth + 1) {
return Some(found);
}
}
}
None
}
// First try to find body as a direct child (proper HTML structure)
let direct_body = root_nodes.iter().find_map(|child| {
if let XmlNodeChild::Element(node) = child {
let node_type_normalized = normalize_casing(&node.node_type);
if &node_type_normalized == "body" {
Some(node)
} else {
None
}
} else {
None
}
});
if let Some(body) = direct_body {
return Ok(body);
}
// If not found as direct child, search recursively (for malformed HTML like example.com)
// where <body> might be nested inside <head> due to missing </head> tag
find_body_recursive(root_nodes, 0).ok_or(DomXmlParseError::NoBodyInHtml)
}
/// Searches in the the `root_nodes` for a `node_type`, convenience function in order to
/// for example find the first <blah /> node in all these nodes.
/// This function searches recursively through the entire tree.
fn find_node_by_type<'a>(root_nodes: &'a [XmlNodeChild], node_type: &str) -> Option<&'a XmlNode> {
// First check direct children
for child in root_nodes {
if let XmlNodeChild::Element(node) = child {
if normalize_casing(&node.node_type).as_str() == node_type {
return Some(node);
}
}
}
// If not found, search recursively (for malformed HTML)
for child in root_nodes {
if let XmlNodeChild::Element(node) = child {
if let Some(found) = find_node_by_type(node.children.as_ref(), node_type) {
return Some(found);
}
}
}
None
}
#[must_use] pub fn find_attribute<'a>(node: &'a XmlNode, attribute: &str) -> Option<&'a AzString> {
node.attributes
.iter()
.find(|n| normalize_casing(n.key.as_str()).as_str() == attribute)
.map(|s| &s.value)
}
/// Normalizes input such as `abcDef`, `AbcDef`, `abc-def` to the normalized form of `abc_def`
#[must_use] pub fn normalize_casing(input: &str) -> String {
let mut words: Vec<String> = Vec::new();
let mut cur_str = Vec::new();
for ch in input.chars() {
if ch.is_uppercase() || ch == '_' || ch == '-' {
if !cur_str.is_empty() {
words.push(cur_str.iter().collect());
cur_str.clear();
}
if ch.is_uppercase() {
cur_str.extend(ch.to_lowercase());
}
} else {
cur_str.extend(ch.to_lowercase());
}
}
if !cur_str.is_empty() {
words.push(cur_str.iter().collect());
cur_str.clear();
}
words.join("_")
}
/// Given a root node, traverses along the hierarchy, and returns a
/// mutable reference to the last child node of the root node
#[allow(trivial_casts)]
pub fn get_item<'a>(hierarchy: &[usize], root_node: &'a mut XmlNode) -> Option<&'a mut XmlNode> {
let mut hierarchy = hierarchy.to_vec();
hierarchy.reverse();
let Some(item) = hierarchy.pop() else {
return Some(root_node);
};
let child = root_node.children.as_mut().get_mut(item)?;
match child {
XmlNodeChild::Element(node) => get_item_internal(&mut hierarchy, node),
XmlNodeChild::Text(_) => None, // Can't traverse into text nodes
}
}
fn get_item_internal<'a>(
hierarchy: &mut Vec<usize>,
root_node: &'a mut XmlNode,
) -> Option<&'a mut XmlNode> {
if hierarchy.is_empty() {
return Some(root_node);
}
let Some(cur_item) = hierarchy.pop() else {
return Some(root_node);
};
let child = root_node.children.as_mut().get_mut(cur_item)?;
match child {
XmlNodeChild::Element(node) => get_item_internal(hierarchy, node),
XmlNodeChild::Text(_) => None, // Can't traverse into text nodes
}
}
/// Parses an XML string and returns a `StyledDom` with the components instantiated in the
/// `<app></app>`
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the XML cannot be parsed into a DOM (malformed markup or an unknown component).
pub fn str_to_dom<'a>(
root_nodes: &'a [XmlNodeChild],
component_map: &'a ComponentMap,
max_width: Option<f32>,
) -> Result<StyledDom, DomXmlParseError> {
// Delegate to the fast path (Dom::Fast / CompactDom arena).
str_to_dom_fast(root_nodes, component_map, max_width)
}
/// Parse XML to `StyledDom` via arena-based `FastDom` (no tree intermediary).
///
/// **Note**: `str_to_dom()` now delegates to this function, so you can use
/// either one. This function is kept for backward compatibility.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
fn str_to_dom_fast<'a>(
root_nodes: &'a [XmlNodeChild],
component_map: &'a ComponentMap,
max_width: Option<f32>,
) -> Result<StyledDom, DomXmlParseError> {
let html_node = get_html_node(root_nodes)?;
let body_node = get_body_node(html_node.children.as_ref())?;
let mut global_style = None;
if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
let text = style_node.get_text_content();
if !text.is_empty() {
let parsed_css = Css::from_string(text.into());
global_style = Some(parsed_css);
}
}
}
render_dom_from_body_node_fast(body_node, global_style, component_map, max_width)
.map_err(Into::into)
}
/// Parses XML nodes and returns a `Dom` with CSS stylesheets attached (but not applied).
///
/// Unlike `str_to_dom` which returns a fully styled `StyledDom`, this function
/// returns an unstyled `Dom` whose `css` field carries the parsed `<style>` rules.
/// The layout framework will apply the CSS during the cascade pass.
///
/// This is the correct function for building a `Dom` from XML in layout callbacks
/// (which must return `Dom`, not `StyledDom`).
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the XML cannot be parsed into a DOM (malformed markup or an unknown component).
pub fn str_to_dom_unstyled<'a>(
root_nodes: &'a [XmlNodeChild],
component_map: &'a ComponentMap,
) -> Result<Dom, DomXmlParseError> {
let html_node = get_html_node(root_nodes)?;
let body_node = get_body_node(html_node.children.as_ref())?;
let mut global_style = None;
if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
let text = style_node.get_text_content();
if !text.is_empty() {
let parsed_css = Css::from_string(text.into());
global_style = Some(parsed_css);
}
}
}
// Build the DOM tree from the body node
let body_dom = xml_node_to_dom_fast(body_node, component_map, false, 0)
.map_err(DomXmlParseError::from)?;
// Wrap in proper HTML structure (NodeType is imported at module top)
let root_node_type = body_dom.root.node_type.clone();
let mut full_dom = match root_node_type {
NodeType::Html => body_dom,
NodeType::Body => Dom::create_html().with_child(body_dom),
_ => {
let body_wrapper = Dom::create_body().with_child(body_dom);
Dom::create_html().with_child(body_wrapper)
}
};
// Attach CSS to the Dom's css field instead of applying it immediately
if let Some(css) = global_style {
full_dom.css = alloc::vec![css].into();
}
Ok(full_dom)
}
/// Parses an XML string and returns a `String`, which contains the Rust source code
/// (i.e. it compiles the XML to valid Rust)
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the XML cannot be parsed or compiled to Rust code.
pub fn str_to_rust_code<'a>(
root_nodes: &'a [XmlNodeChild],
imports: &str,
component_map: &'a ComponentMap,
) -> Result<String, CompileError> {
let html_node = get_html_node(root_nodes)?;
let body_node = get_body_node(html_node.children.as_ref())?;
let mut global_style = Css::empty();
if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
let text = style_node.get_text_content();
if !text.is_empty() {
let parsed_css = azul_css::parser2::new_from_str(&text).0;
global_style = parsed_css;
}
}
}
global_style.sort_by_specificity();
let mut css_blocks = BTreeMap::new();
let mut extra_blocks = VecContents::default();
let app_source = compile_body_node_to_rust_code(
body_node,
component_map,
&mut extra_blocks,
&mut css_blocks,
&global_style,
CssMatcher {
path: Vec::new(),
indices_in_parent: vec![0],
children_length: vec![body_node.children.as_ref().len()],
},
)?;
let app_source = app_source
.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<String>>()
.join("\r\n");
// NOTE: `css_blocks` / `extra_blocks` are no longer emitted — per-node styles
// are now inlined as `.with_css("..")` strings (public API) rather than as
// `const CSS_MATCH_*: NodeDataInlineCssPropertyVec` blocks (that API was
// removed in 32d44ed8a). The maps stay in the signatures for compatibility.
let _ = (&css_blocks, &extra_blocks);
let main_func = "
use azul::{
app::{App, AppConfig},
dom::Dom,
callbacks::{RefAny, LayoutCallbackInfo},
window::WindowCreateOptions,
};
struct Data { }
extern \"C\" fn render(_: RefAny, _: LayoutCallbackInfo) -> Dom {
crate::ui::render()
}
fn main() {
let config = AppConfig::create();
let app = App::create(RefAny::new(Data { }), config);
let window = WindowCreateOptions::create(render);
app.run(window);
}";
let ui_module = format!(
"#[allow(unused_imports)]\r\npub mod ui {{
use azul::prelude::*;
use azul::dom::{{NodeType, TabIndex, SmallAriaInfo}};
use azul::str::String as AzString;
pub fn render() -> Dom {{\r\n{app_source}\r\n }}\r\n}}"
);
let source_code = format!(
"#![windows_subsystem = \"windows\"]\r\n//! Auto-generated UI source \
code\r\n{}\r\n{}\r\n\r\n{}{}",
imports,
compile_components(Vec::new()), // no user-defined components to compile
ui_module,
main_func,
);
Ok(source_code)
}
// Compile all components to source code
#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
fn compile_components(
components: Vec<(
ComponentName,
CompiledComponent,
ComponentArguments,
BTreeMap<String, String>,
)>,
) -> String {
let cs = components
.iter()
.map(|(name, function_body, function_args, css_blocks)| {
let name = &normalize_casing(name);
let f = compile_component(name, function_args, function_body)
.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<String>>()
.join("\r\n");
// let css_blocks = ...
format!(
"#[allow(unused_imports)]\r\npub mod {name} {{\r\n use azul::dom::Dom;\r\n use \
azul::str::String as AzString;\r\n{f}\r\n}}"
)
})
.collect::<Vec<String>>()
.join("\r\n\r\n");
let cs = cs
.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<String>>()
.join("\r\n");
if cs.is_empty() {
cs
} else {
format!("pub mod components {{\r\n{cs}\r\n}}")
}
}
fn format_component_args(component_args: &ComponentArgumentVec) -> String {
let mut args = component_args
.iter()
.map(|a| format!("{}: {}", a.name, a.arg_type))
.collect::<Vec<String>>();
args.sort_by(|a, b| b.cmp(a));
args.join(", ")
}
#[must_use] pub fn compile_component(
component_name: &str,
component_args: &ComponentArguments,
component_function_body: &str,
) -> String {
let component_name = &normalize_casing(component_name);
let function_args = format_component_args(&component_args.args);
let component_function_body = component_function_body
.lines()
.map(|l| format!(" {l}"))
.collect::<Vec<String>>()
.join("\r\n");
let should_inline = component_function_body.lines().count() == 1;
format!(
"{}pub fn render({}{}{}) -> Dom {{\r\n{}\r\n}}",
if should_inline { "#[inline]\r\n" } else { "" },
// pass the text content as the first
if component_args.accepts_text {
"text: AzString"
} else {
""
},
if function_args.is_empty() || !component_args.accepts_text {
""
} else {
", "
},
function_args,
component_function_body,
)
}
/// Parse an SVG numeric attribute value to f32.
fn parse_svg_float(attr: Option<&AzString>) -> Option<f32> {
attr?.as_str().trim().parse::<f32>().ok()
}
/// Parse an SVG `points` attribute (used by `<polygon>` and `<polyline>`).
fn parse_svg_points(pts: &str, close: bool) -> Option<crate::svg::SvgMultiPolygon> {
let nums: Vec<f32> = pts
.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f32>().ok())
.collect();
if nums.len() < 4 || !nums.len().is_multiple_of(2) {
return None;
}
let mut elements = Vec::new();
let points: Vec<azul_css::props::basic::SvgPoint> = nums
.chunks_exact(2)
.map(|c| azul_css::props::basic::SvgPoint { x: c[0], y: c[1] })
.collect();
for w in points.windows(2) {
elements.push(crate::svg::SvgPathElement::Line(crate::svg::SvgLine::new(
w[0], w[1],
)));
}
if close && points.len() >= 2 {
let first = points[0];
let last = *points.last().unwrap();
if (first.x - last.x).abs() > 0.001 || (first.y - last.y).abs() > 0.001 {
elements.push(crate::svg::SvgPathElement::Line(crate::svg::SvgLine::new(
last, first,
)));
}
}
Some(crate::svg::SvgMultiPolygon {
rings: crate::svg::SvgPathVec::from_vec(vec![crate::svg::SvgPath {
items: crate::svg::SvgPathElementVec::from_vec(elements),
}]),
})
}
/// Fast XML to Dom conversion that builds Dom tree directly without intermediate `StyledDom`
/// This is O(n) instead of O(n²) for large documents
/// Apply the shared set of XML attributes onto a single [`NodeData`] node.
///
/// Handles `<img src>` rebuild, `id`/`class`, `focusable`, `tabindex`, inline
/// `style`, and SVG-shape geometry — the block that was previously duplicated
/// verbatim between [`xml_node_to_dom_fast`] (operating on `dom.root`) and
/// [`xml_node_to_fast_dom`] (operating on the arena `NodeData`). `component_name`
/// must already be normalized (lowercased); the caller computes `child_inside_svg`.
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
fn apply_xml_node_attributes(
node: &mut crate::dom::NodeData,
xml_node: &XmlNode,
component_name: &str,
inside_svg: bool,
) {
use crate::dom::{IdOrClass, NodeType, TabIndex};
// `<img src="...">`: rebuild the placeholder Image node so its `NullImage`
// carries the `src` string (as UTF-8 bytes in `tag`). The bytes are NOT
// resolved here — a downstream renderer (printpdf, the compositor, ...) uses
// the tag to look up and embed the actual image. Optional `width`/`height`
// attributes set the intrinsic size used for layout (CSS still overrides).
if component_name == "img" {
if let Some(src) = xml_node.attributes.get_key("src") {
let width = xml_node
.attributes
.get_key("width")
.and_then(|w| {
w.as_str()
.trim()
.trim_end_matches("px")
.trim()
.parse::<usize>()
.ok()
})
.unwrap_or(0);
let height = xml_node
.attributes
.get_key("height")
.and_then(|h| {
h.as_str()
.trim()
.trim_end_matches("px")
.trim()
.parse::<usize>()
.ok()
})
.unwrap_or(0);
let image_ref = crate::resources::ImageRef::null_image(
width,
height,
crate::resources::RawImageFormat::RGBA8,
src.as_str().as_bytes().to_vec(),
);
node
.set_node_type(NodeType::Image(azul_css::css::BoxOrStatic::heap(image_ref)));
}
}
// Set id and class attributes
let mut ids_and_classes = Vec::new();
if let Some(id_str) = xml_node.attributes.get_key("id") {
for id in id_str.split_whitespace() {
ids_and_classes.push(IdOrClass::Id(id.into()));
}
}
if let Some(class_str) = xml_node.attributes.get_key("class") {
for class in class_str.split_whitespace() {
ids_and_classes.push(IdOrClass::Class(class.into()));
}
}
if !ids_and_classes.is_empty() {
node.set_ids_and_classes(ids_and_classes.into());
}
// Handle focusable attribute
if let Some(focusable) = xml_node
.attributes
.get_key("focusable")
.and_then(|f| parse_bool(f.as_str()))
{
if focusable { node.set_tab_index(TabIndex::Auto) } else { node.set_tab_index(TabIndex::NoKeyboardFocus) }
}
// Handle tabindex attribute
if let Some(tab_index) = xml_node
.attributes
.get_key("tabindex")
.and_then(|val| val.parse::<isize>().ok())
{
match tab_index {
0 => node.set_tab_index(TabIndex::Auto),
i if i > 0 => node.set_tab_index(TabIndex::OverrideInParent(u32::try_from(i).unwrap_or(u32::MAX))),
_ => node.set_tab_index(TabIndex::NoKeyboardFocus),
}
}
// Handle inline style attribute
if let Some(style) = xml_node.attributes.get_key("style") {
let css_key_map = azul_css::props::property::get_css_key_map();
let mut attributes = Vec::new();
for s in style.as_str().split(';') {
let mut s = s.split(':');
let Some(key) = s.next() else {
continue;
};
let Some(value) = s.next() else {
continue;
};
// Called for its side effect (writes parsed props into `attributes`);
// the returned value is intentionally discarded.
drop(azul_css::parser2::parse_css_declaration(
key.trim(),
value.trim(),
azul_css::parser2::ErrorLocationRange::default(),
&css_key_map,
&mut Vec::new(),
&mut attributes,
));
}
let props = attributes
.into_iter()
.filter_map(|s| {
use azul_css::dynamic_selector::CssPropertyWithConditions;
match s {
CssDeclaration::Static(s) => Some(CssPropertyWithConditions::simple(s)),
CssDeclaration::Dynamic(_) => None,
}
})
.collect::<Vec<_>>();
if !props.is_empty() {
node.set_css_props(props.into());
}
}
// Handle SVG shape elements when inside an <svg> context
let tag = component_name;
let is_svg_shape = inside_svg
&& matches!(
tag,
"path" | "circle" | "rect" | "ellipse" | "line" | "polygon" | "polyline"
);
if is_svg_shape {
let clip = match tag {
"path" => xml_node
.attributes
.get_key("d")
.and_then(|d| crate::path_parser::parse_svg_path_d(d.as_str()).ok()),
"circle" => {
let cx = parse_svg_float(xml_node.attributes.get_key("cx")).unwrap_or(0.0);
let cy = parse_svg_float(xml_node.attributes.get_key("cy")).unwrap_or(0.0);
let r = parse_svg_float(xml_node.attributes.get_key("r")).unwrap_or(0.0);
if r > 0.0 {
Some(crate::svg::SvgMultiPolygon {
rings: crate::svg::SvgPathVec::from_vec(vec![
crate::path_parser::svg_circle_to_paths(cx, cy, r),
]),
})
} else {
None
}
}
"rect" => {
let x = parse_svg_float(xml_node.attributes.get_key("x")).unwrap_or(0.0);
let y = parse_svg_float(xml_node.attributes.get_key("y")).unwrap_or(0.0);
let w = parse_svg_float(xml_node.attributes.get_key("width")).unwrap_or(0.0);
let h = parse_svg_float(xml_node.attributes.get_key("height")).unwrap_or(0.0);
let rx = parse_svg_float(xml_node.attributes.get_key("rx")).unwrap_or(0.0);
let ry = parse_svg_float(xml_node.attributes.get_key("ry")).unwrap_or(rx);
if w > 0.0 && h > 0.0 {
Some(crate::svg::SvgMultiPolygon {
rings: crate::svg::SvgPathVec::from_vec(vec![
crate::path_parser::svg_rect_to_path(x, y, w, h, rx, ry),
]),
})
} else {
None
}
}
"ellipse" => {
let cx = parse_svg_float(xml_node.attributes.get_key("cx")).unwrap_or(0.0);
let cy = parse_svg_float(xml_node.attributes.get_key("cy")).unwrap_or(0.0);
let rx = parse_svg_float(xml_node.attributes.get_key("rx")).unwrap_or(0.0);
let ry = parse_svg_float(xml_node.attributes.get_key("ry")).unwrap_or(0.0);
if rx > 0.0 && ry > 0.0 {
// Approximate ellipse with 4 cubic beziers (using rx for x-kappa, ry for y-kappa)
use azul_css::props::basic::{SvgCubicCurve, SvgPoint};
const KAPPA: f32 = 0.552_284_8;
let kx = rx * KAPPA;
let ky = ry * KAPPA;
let elements = vec![
crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx, y: cy - ry },
ctrl_1: SvgPoint {
x: cx + kx,
y: cy - ry,
},
ctrl_2: SvgPoint {
x: cx + rx,
y: cy - ky,
},
end: SvgPoint { x: cx + rx, y: cy },
}),
crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx + rx, y: cy },
ctrl_1: SvgPoint {
x: cx + rx,
y: cy + ky,
},
ctrl_2: SvgPoint {
x: cx + kx,
y: cy + ry,
},
end: SvgPoint { x: cx, y: cy + ry },
}),
crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx, y: cy + ry },
ctrl_1: SvgPoint {
x: cx - kx,
y: cy + ry,
},
ctrl_2: SvgPoint {
x: cx - rx,
y: cy + ky,
},
end: SvgPoint { x: cx - rx, y: cy },
}),
crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
start: SvgPoint { x: cx - rx, y: cy },
ctrl_1: SvgPoint {
x: cx - rx,
y: cy - ky,
},
ctrl_2: SvgPoint {
x: cx - kx,
y: cy - ry,
},
end: SvgPoint { x: cx, y: cy - ry },
}),
];
Some(crate::svg::SvgMultiPolygon {
rings: crate::svg::SvgPathVec::from_vec(vec![crate::svg::SvgPath {
items: crate::svg::SvgPathElementVec::from_vec(elements),
}]),
})
} else {
None
}
}
"line" => {
let x1 = parse_svg_float(xml_node.attributes.get_key("x1")).unwrap_or(0.0);
let y1 = parse_svg_float(xml_node.attributes.get_key("y1")).unwrap_or(0.0);
let x2 = parse_svg_float(xml_node.attributes.get_key("x2")).unwrap_or(0.0);
let y2 = parse_svg_float(xml_node.attributes.get_key("y2")).unwrap_or(0.0);
Some(crate::svg::SvgMultiPolygon {
rings: crate::svg::SvgPathVec::from_vec(vec![crate::svg::SvgPath {
items: crate::svg::SvgPathElementVec::from_vec(vec![
crate::svg::SvgPathElement::Line(crate::svg::SvgLine::new(
azul_css::props::basic::SvgPoint { x: x1, y: y1 },
azul_css::props::basic::SvgPoint { x: x2, y: y2 },
)),
]),
}]),
})
}
"polygon" | "polyline" => xml_node
.attributes
.get_key("points")
.and_then(|pts| parse_svg_points(pts.as_str(), tag == "polygon")),
_ => None,
};
if let Some(mp) = clip {
node.set_svg_data(crate::dom::SvgNodeData::Path(mp));
}
}
}
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
// component_map is threaded through the whole fast-DOM pipeline for parity with the
// component-expanding interpreter path (see ~xml.rs:2845); this fast path never expands
// components, so it only forwards the map into recursive calls. Removing it here would
// cascade unused-param removals up the entire pipeline.
#[allow(clippy::only_used_in_recursion)]
fn xml_node_to_dom_fast<'a>(
xml_node: &'a XmlNode,
component_map: &'a ComponentMap,
inside_svg: bool,
depth: usize,
) -> Result<Dom, RenderDomError> {
use crate::dom::Dom;
let component_name = normalize_casing(&xml_node.node_type);
// Look up the component definition
let node_type = tag_to_node_type(&component_name);
let mut dom = Dom::create_node(node_type);
apply_xml_node_attributes(&mut dom.root, xml_node, &component_name, inside_svg);
let child_inside_svg = inside_svg || component_name == "svg";
// AUDIT 2026-07-08: bound recursion depth to avoid a native stack overflow on
// pathologically deep markup. At the cap, this node is emitted without its
// children (truncation) rather than crashing the process.
// AUDIT-TODO: a worklist-based iterative builder would preserve deep subtrees.
if depth >= MAX_XML_NESTING_DEPTH {
return Ok(dom);
}
// Recursively convert children
let mut children = Vec::new();
for child in xml_node.children.as_ref() {
match child {
XmlNodeChild::Element(child_node) => {
let child_dom =
xml_node_to_dom_fast(child_node, component_map, child_inside_svg, depth + 1)?;
children.push(child_dom);
}
XmlNodeChild::Text(text) => {
let text_dom = Dom::create_text(AzString::from(text.as_str()));
children.push(text_dom);
}
}
}
if !children.is_empty() {
dom = dom.with_children(children.into());
}
Ok(dom)
}
/// Builder for arena-based DOM construction (`FastDom`).
/// Builds two parallel Vecs (hierarchy + `node_data`) in a single DFS pass.
#[derive(Debug)]
pub struct CompactDomBuilder {
hierarchy: Vec<crate::styled_dom::NodeHierarchyItem>,
node_data: Vec<crate::dom::NodeData>,
css: Vec<crate::dom::CssWithNodeId>,
/// Stack of (`node_index`, `previous_child_index`) for open elements
stack: Vec<(usize, Option<usize>)>,
}
impl Default for CompactDomBuilder {
fn default() -> Self {
Self::new()
}
}
impl CompactDomBuilder {
#[must_use] pub const fn new() -> Self {
Self {
hierarchy: Vec::new(),
node_data: Vec::new(),
css: Vec::new(),
stack: Vec::new(),
}
}
#[must_use] pub fn with_capacity(cap: usize) -> Self {
Self {
hierarchy: Vec::with_capacity(cap),
node_data: Vec::with_capacity(cap),
css: Vec::new(),
stack: Vec::new(),
}
}
/// Open a new element node. Must be paired with `close_node()`.
pub fn open_node(&mut self, node_data: crate::dom::NodeData) {
use crate::id::NodeId;
use crate::styled_dom::NodeHierarchyItem;
let idx = self.hierarchy.len();
// Determine parent from stack
let parent_raw = if let Some(&(parent_idx, _)) = self.stack.last() {
NodeId::into_raw(&Some(NodeId::new(parent_idx)))
} else {
0 // No parent (root)
};
// Determine previous sibling from parent's last child tracking
let prev_sibling_raw = if let Some(&(_, prev_child)) = self.stack.last() {
prev_child
.map_or(0, |pi| NodeId::into_raw(&Some(NodeId::new(pi))))
} else {
0
};
// If there's a previous sibling, set its next_sibling to us
if let Some(&(_, Some(prev_idx))) = self.stack.last() {
self.hierarchy[prev_idx].next_sibling = NodeId::into_raw(&Some(NodeId::new(idx)));
}
// Update parent's "last seen child" to us
if let Some(parent) = self.stack.last_mut() {
parent.1 = Some(idx);
}
// Push the hierarchy item (last_child will be set in close_node)
self.hierarchy.push(NodeHierarchyItem {
parent: parent_raw,
previous_sibling: prev_sibling_raw,
next_sibling: 0, // Will be set by next sibling's open_node
last_child: 0, // Will be set in close_node
});
self.node_data.push(node_data);
// Push onto stack: this node is now the "open" element, no children yet
self.stack.push((idx, None));
}
/// Close the current element. Sets the `last_child` pointer.
pub fn close_node(&mut self) {
use crate::id::NodeId;
if let Some((idx, last_child_idx)) = self.stack.pop() {
// Set last_child on this node's hierarchy item
self.hierarchy[idx].last_child = last_child_idx
.map_or(0, |lc| NodeId::into_raw(&Some(NodeId::new(lc))));
}
}
/// Add a leaf node (text, br, hr, etc.) that has no children.
pub fn add_leaf(&mut self, node_data: crate::dom::NodeData) {
self.open_node(node_data);
self.close_node();
}
/// Add a CSS stylesheet scoped to a node ID.
pub fn add_css(&mut self, node_id: usize, css: Css) {
self.css.push(crate::dom::CssWithNodeId { node_id, css });
}
/// Finish building and produce a `FastDom`.
#[must_use] pub fn finish(self) -> crate::dom::FastDom {
crate::dom::FastDom {
node_hierarchy: self.hierarchy.into(),
node_data: self.node_data.into(),
css: self.css.into(),
}
}
}
/// Convert an XML node tree into a `FastDom` (arena-based) in a single DFS pass.
/// This is the fast path equivalent of `xml_node_to_dom_fast`.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
// See xml_node_to_dom_fast: component_map is forwarded for pipeline parity, not read here.
#[allow(clippy::only_used_in_recursion)]
fn xml_node_to_fast_dom<'a>(
xml_node: &'a XmlNode,
component_map: &'a ComponentMap,
inside_svg: bool,
builder: &mut CompactDomBuilder,
depth: usize,
) -> Result<(), RenderDomError> {
use crate::dom::NodeData;
let component_name = normalize_casing(&xml_node.node_type);
let node_type = tag_to_node_type(&component_name);
let mut node_data = NodeData::create_node(node_type);
apply_xml_node_attributes(&mut node_data, xml_node, &component_name, inside_svg);
let child_inside_svg = inside_svg || component_name == "svg";
// Open this node in the builder
builder.open_node(node_data);
// AUDIT 2026-07-08: bound recursion depth to avoid a native stack overflow on
// pathologically deep markup. At the cap, children are dropped (the node is
// still opened+closed) rather than crashing the process.
// AUDIT-TODO: a worklist-based iterative builder would preserve deep subtrees.
if depth < MAX_XML_NESTING_DEPTH {
// Recursively convert children
for child in xml_node.children.as_ref() {
match child {
XmlNodeChild::Element(child_node) => {
xml_node_to_fast_dom(
child_node,
component_map,
child_inside_svg,
builder,
depth + 1,
)?;
}
XmlNodeChild::Text(text) => {
builder.add_leaf(NodeData::create_text(AzString::from(text.as_str())));
}
}
}
}
// Close this node
builder.close_node();
Ok(())
}
/// Render a DOM from an XML body node using the fast arena-based path.
/// Builds a `FastDom` directly (no tree intermediary), then creates `StyledDom`.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
fn render_dom_from_body_node_fast<'a>(
body_node: &'a XmlNode,
mut global_css: Option<Css>,
component_map: &'a ComponentMap,
max_width: Option<f32>,
) -> Result<StyledDom, RenderDomError> {
use crate::dom::{NodeData, NodeType};
let mut builder = CompactDomBuilder::new();
// Build the HTML > Body wrapper + body content in one pass
// Open <html>
builder.open_node(NodeData::create_node(NodeType::Html));
// Open <body> (the body_node content goes inside)
xml_node_to_fast_dom(body_node, component_map, false, &mut builder, 0)?;
// Close <html>
builder.close_node();
// Collect CSS rules from each source.
let mut combined_rules: Vec<CssRuleBlock> = Vec::new();
if let Some(max_width) = max_width {
let max_width_css =
Css::from_string(format!("html {{ max-width: {max_width}px; }}").into());
combined_rules.extend(max_width_css.rules.into_library_owned_vec());
}
if let Some(css) = global_css.take() {
combined_rules.extend(css.rules.into_library_owned_vec());
}
let combined_css = Css::new(combined_rules);
// Add CSS to the FastDom
let mut fast_dom = builder.finish();
fast_dom.css = vec![crate::dom::CssWithNodeId {
node_id: 0, // Global scope (root)
css: combined_css,
}]
.into();
// Create StyledDom via the fast path (no tree→arena conversion)
let styled = StyledDom::create_from_fast_dom(fast_dom);
Ok(styled)
}
// render_dom_from_body_node() removed — use render_dom_from_body_node_fast() or str_to_dom()
fn set_stringified_attributes(
dom_string: &mut String,
xml_attributes: &XmlAttributeMap,
filtered_xml_attributes: &ComponentArgumentVec,
tabs: usize,
) {
let t0 = String::from(" ").repeat(tabs);
let t = String::from(" ").repeat(tabs + 1);
// push ids and classes as chained `.with_id("..")` / `.with_class("..")`
// calls (public builder API; both take `Into<AzString>`, so bare &str works).
let _ = &t;
for id in xml_attributes
.get_key("id")
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.unwrap_or_default()
{
let _ = write!(
dom_string,
"\r\n{}.with_id(\"{}\")",
t0,
format_args_dynamic(id, filtered_xml_attributes)
);
}
for class in xml_attributes
.get_key("class")
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.unwrap_or_default()
{
let _ = write!(
dom_string,
"\r\n{}.with_class(\"{}\")",
t0,
format_args_dynamic(class, filtered_xml_attributes)
);
}
if let Some(focusable) = xml_attributes
.get_key("focusable")
.map(|f| format_args_dynamic(f, filtered_xml_attributes))
.and_then(|f| parse_bool(&f))
{
if focusable { let _ = write!(dom_string, "\r\n{t}.with_tab_index(TabIndex::Auto)"); } else { let _ = write!(dom_string,
"\r\n{t}.with_tab_index(TabIndex::NoKeyboardFocus)"
); }
}
if let Some(tab_index) = xml_attributes
.get_key("tabindex")
.map(|val| format_args_dynamic(val, filtered_xml_attributes))
.and_then(|val| val.parse::<isize>().ok())
{
match tab_index {
0 => { let _ = write!(dom_string, "\r\n{t}.with_tab_index(TabIndex::Auto)"); },
i if i > 0 => { let _ = write!(dom_string,
"\r\n{}.with_tab_index(TabIndex::OverrideInParent({}))",
t, usize::try_from(i).unwrap_or(0)
); },
_ => { let _ = write!(dom_string,
"\r\n{t}.with_tab_index(TabIndex::NoKeyboardFocus)"
); },
}
}
}
/// Item of a split string - either a variable name (with optional format spec) or a string
#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
pub enum DynamicItem {
/// A variable reference, e.g. {counter} or {counter:?} or {price:.2}
Var {
name: String,
/// Optional format specifier after the colon: "?" for debug, ".2" for precision, etc.
format_spec: Option<String>,
},
Str(String),
}
/// Splits a string into formatting arguments, supporting format specifiers like `{var:?}`
/// ```rust
/// # use azul_core::xml::DynamicItem::*;
/// # use azul_core::xml::split_dynamic_string;
/// let s = "hello {a}, {b}{{ {c} }}";
/// let split = split_dynamic_string(s);
/// let output = vec![
/// Str("hello ".to_string()),
/// Var { name: "a".to_string(), format_spec: None },
/// Str(", ".to_string()),
/// Var { name: "b".to_string(), format_spec: None },
/// Str("{ ".to_string()),
/// Var { name: "c".to_string(), format_spec: None },
/// Str(" }".to_string()),
/// ];
/// assert_eq!(output, split);
/// ```
#[must_use] pub fn split_dynamic_string(input: &str) -> Vec<DynamicItem> {
use self::DynamicItem::{Str, Var};
let input: Vec<char> = input.chars().collect();
let input_chars_len = input.len();
let mut items = Vec::new();
let mut current_idx = 0;
let mut last_idx = 0;
while current_idx < input_chars_len {
let c = input[current_idx];
match c {
'{' if input.get(current_idx + 1).copied() != Some('{') => {
// variable start, search until next closing brace or whitespace or end of string
let mut start_offset = 1;
let mut has_found_variable = false;
while let Some(c) = input.get(current_idx + start_offset) {
if c.is_whitespace() {
break;
}
if *c == '}' && input.get(current_idx + start_offset + 1).copied() != Some('}')
{
start_offset += 1;
has_found_variable = true;
break;
}
start_offset += 1;
}
// advance current_idx accordingly
// on fail, set cursor to end
// set last_idx accordingly
if has_found_variable {
if last_idx != current_idx {
items.push(Str(input[last_idx..current_idx].iter().collect()));
}
// subtract 1 from start for opening brace, one from end for closing brace
let var_content: String = input
[(current_idx + 1)..(current_idx + start_offset - 1)]
.iter()
.collect();
// Split on first ':' to separate variable name from format specifier
let (var_name, format_spec) = if let Some(colon_pos) = var_content.find(':') {
let name = var_content[..colon_pos].to_string();
let spec = var_content[(colon_pos + 1)..].to_string();
(name, Some(spec))
} else {
(var_content, None)
};
items.push(Var {
name: var_name,
format_spec,
});
current_idx += start_offset;
last_idx = current_idx;
} else {
current_idx += start_offset;
}
}
_ => {
current_idx += 1;
}
}
}
if current_idx != last_idx {
items.push(Str(input[last_idx..].iter().collect()));
}
for item in &mut items {
// replace {{ with { in strings
if let Str(s) = item {
*s = s.replace("{{", "{").replace("}}", "}");
}
}
items
}
/// Combines the split string back into its original form while replacing the variables with their
/// values
///
/// let variables = btreemap!{ "a" => "value1", "b" => "value2" };
/// [Str("hello "), Var("a"), Str(", "), Var("b"), Str("{ "), Var("c"), Str(" }}")]
/// => "hello value1, valuec{ {c} }"
fn combine_and_replace_dynamic_items(
input: &[DynamicItem],
variables: &ComponentArgumentVec,
) -> String {
let mut s = String::new();
for item in input {
match item {
DynamicItem::Var { name, format_spec } => {
let variable_name = normalize_casing(name.trim());
if let Some(resolved_var) = variables
.iter()
.find(|s| s.name.as_str() == variable_name)
.map(|q| &q.arg_type) {
// Format specifiers are applied at compile time, not at runtime replacement
s.push_str(resolved_var);
} else {
s.push('{');
s.push_str(name);
if let Some(spec) = format_spec {
s.push(':');
s.push_str(spec);
}
s.push('}');
}
}
DynamicItem::Str(dynamic_str) => {
s.push_str(dynamic_str);
}
}
}
s
}
/// Given a string and a key => value mapping, replaces parts of the string with the value, i.e.:
///
/// ```rust
/// # use azul_core::xml::{format_args_dynamic, ComponentArgument, ComponentArgumentVec};
/// # use azul_css::AzString;
/// let variables: ComponentArgumentVec = vec![
/// ComponentArgument { name: AzString::from("a"), arg_type: AzString::from("value1") },
/// ComponentArgument { name: AzString::from("b"), arg_type: AzString::from("value2") },
/// ].into();
///
/// let initial = "hello {a}, {b}{{ {c} }}";
/// let expected = "hello value1, value2{ {c} }".to_string();
/// assert_eq!(format_args_dynamic(initial, &variables), expected);
/// ```
///
/// Note: the number (0, 1, etc.) is the order of the argument, it is irrelevant for
/// runtime formatting, only important for keeping the component / function arguments
/// in order when compiling the arguments to Rust code
#[must_use] pub fn format_args_dynamic(input: &str, variables: &ComponentArgumentVec) -> String {
let dynamic_str_items = split_dynamic_string(input);
combine_and_replace_dynamic_items(&dynamic_str_items, variables)
}
/// Decode a numeric character reference body (the part between `&` and `;`),
/// e.g. `"#65"` -> `'A'`, `"#x41"` -> `'A'`. Returns `None` if it is not a valid
/// numeric reference.
fn decode_numeric_entity(entity: &str) -> Option<char> {
let num = entity.strip_prefix('#')?;
let code = if let Some(hex) = num.strip_prefix(['x', 'X']) {
u32::from_str_radix(hex, 16).ok()?
} else {
num.parse::<u32>().ok()?
};
char::from_u32(code)
}
/// Decode the common HTML/XML entities in a single left-to-right pass.
///
/// Handles `<` `>` `&` `"` `'` and numeric references
/// (`&#NN;` / `&#xHH;`). ` ` and any unrecognized `&...;` sequence are left
/// verbatim. The single pass guarantees `&` never double-decodes a following
/// entity. See [`prepare_string`] for why ` ` is deliberately preserved.
fn decode_entities(input: &str) -> String {
// Longest handled entity body is a hex numeric ref like `#x10FFFF` (8 bytes);
// cap the `;` search window so a stray `&` far from a `;` stays cheap.
const MAX_ENTITY_BODY: usize = 12;
let mut out = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < input.len() {
if bytes[i] == b'&' {
if let Some(semi_rel) = input[i + 1..].find(';') {
if semi_rel <= MAX_ENTITY_BODY {
let body = &input[i + 1..i + 1 + semi_rel];
let end = i + 1 + semi_rel; // index of ';'
// Leave for the per-line pass in prepare_string.
if body.eq_ignore_ascii_case("nbsp") {
out.push_str(&input[i..=end]);
i = end + 1;
continue;
}
let decoded = match body {
"lt" => Some('<'),
"gt" => Some('>'),
"amp" => Some('&'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => decode_numeric_entity(body),
};
if let Some(c) = decoded {
out.push(c);
i = end + 1;
continue;
}
}
}
// Not a recognized entity: emit the '&' literally.
out.push('&');
i += 1;
} else {
// Copy one whole UTF-8 char (i is always on a char boundary here).
let ch = input[i..].chars().next().unwrap_or('\u{FFFD}');
out.push(ch);
i += ch.len_utf8();
}
}
out
}
// NOTE: Two sequential returns count as a single return, while single returns get ignored.
#[must_use] pub fn prepare_string(input: &str) -> String {
const SPACE: &str = " ";
const RETURN: &str = "\n";
let input = input.trim();
if input.is_empty() {
return String::new();
}
// AUDIT 2026-07-08: previously only `<`/`>` were decoded. Decode the full
// common named-entity set (`<` `>` `&` `"` `'`) plus numeric
// references (`&#NN;` decimal and `&#xHH;` hex) in a single left-to-right pass.
// A single pass is used deliberately so `&` cannot double-decode a following
// entity (e.g. "&lt;" -> literal "<", not "<"). ` ` is intentionally
// left untouched here so the per-line pass below (which runs AFTER trimming) can
// still turn it into a space that survives leading/trailing trim.
let input = decode_entities(input);
let input_len = input.len();
let mut final_lines: Vec<String> = Vec::new();
let mut last_line_was_empty = false;
for line in input.lines() {
let line = line.trim();
let line = line.replace(" ", " ");
let current_line_is_empty = line.is_empty();
if !current_line_is_empty {
if last_line_was_empty {
final_lines.push(format!("{RETURN}{line}"));
} else {
final_lines.push(line.to_string());
}
}
last_line_was_empty = current_line_is_empty;
}
let line_len = final_lines.len();
let mut target = String::with_capacity(input_len);
for (line_idx, line) in final_lines.iter().enumerate() {
if !(line.starts_with(RETURN) || line_idx == 0 || line_idx == line_len.saturating_sub(1)) {
target.push_str(SPACE);
}
target.push_str(line);
}
target
}
/// Parses a string ("true" or "false")
#[must_use] pub fn parse_bool(input: &str) -> Option<bool> {
match input {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct CssMatcher {
path: Vec<CssPathSelector>,
indices_in_parent: Vec<usize>,
children_length: Vec<usize>,
}
impl CssMatcher {
fn get_hash(&self) -> u64 {
use core::hash::Hash;
use core::hash::Hasher;
let mut hasher = crate::hash::DefaultHasher::new();
for p in &self.path {
p.hash(&mut hasher);
}
hasher.finish()
}
}
impl CssMatcher {
fn matches(&self, path: &CssPath) -> bool {
use azul_css::css::CssPathSelector::*;
use crate::style::{CssGroupIterator, CssGroupSplitReason};
if self.path.is_empty() {
return false;
}
if path.selectors.as_ref().is_empty() {
return false;
}
// self_matcher is only ever going to contain "Children" selectors, never "DirectChildren"
let mut path_groups = CssGroupIterator::new(path.selectors.as_ref()).collect::<Vec<_>>();
path_groups.reverse();
if path_groups.is_empty() {
return false;
}
let mut self_groups = CssGroupIterator::new(self.path.as_ref()).collect::<Vec<_>>();
self_groups.reverse();
if self_groups.is_empty() {
return false;
}
if self.indices_in_parent.len() != self_groups.len() {
return false;
}
if self.children_length.len() != self_groups.len() {
return false;
}
// self_groups = [ // HTML
// "body",
// "div.__azul_native-ribbon-container"
// "div.__azul_native-ribbon-tabs"
// "p.home"
// ]
//
// path_groups = [ // CSS
// ".__azul_native-ribbon-tabs"
// "div.after-tabs"
// ]
// get the first path group and see if it matches anywhere in the self group
let mut cur_selfgroup_scan = 0;
let mut cur_pathgroup_scan = 0;
let mut valid = false;
let mut path_group = path_groups[cur_pathgroup_scan].clone();
while cur_selfgroup_scan < self_groups.len() {
let mut advance = None;
// scan all remaining path groups
for (id, cg) in self_groups[cur_selfgroup_scan..].iter().enumerate() {
let gm = group_matches(
&path_group.0,
&self_groups[cur_selfgroup_scan + id].0,
self.indices_in_parent[cur_selfgroup_scan + id],
self.children_length[cur_selfgroup_scan + id],
);
if gm {
// ok: ".__azul_native-ribbon-tabs" was found within self_groups
// advance the self_groups by n
advance = Some(id);
break;
}
}
match advance {
Some(n) => {
// group was found in remaining items
// advance cur_pathgroup_scan by 1 and cur_selfgroup_scan by n
if cur_pathgroup_scan == path_groups.len() - 1 {
// last path group
return cur_selfgroup_scan + n == self_groups.len() - 1;
}
cur_pathgroup_scan += 1;
cur_selfgroup_scan += n;
path_group = path_groups[cur_pathgroup_scan].clone();
}
None => return false, // group was not found in remaining items
}
}
// only return true if all path_groups matched
cur_pathgroup_scan == path_groups.len() - 1
}
}
// does p.home match div.after-tabs?
// a: div.after-tabs
fn group_matches(
a: &[&CssPathSelector],
b: &[&CssPathSelector],
idx_in_parent: usize,
parent_children: usize,
) -> bool {
use azul_css::css::{CssNthChildSelector, CssPathPseudoSelector, CssPathSelector::{Global, PseudoSelector, Type, Class, Id}};
for selector in a {
match selector {
// always matches
Global |
PseudoSelector(CssPathPseudoSelector::Hover | CssPathPseudoSelector::Active |
CssPathPseudoSelector::Focus) => {}
Type(tag) => {
if !b.iter().any(|t| **t == Type(*tag)) {
return false;
}
}
Class(class) => {
if !b.iter().any(|t| **t == Class(class.clone())) {
return false;
}
}
Id(id) => {
if !b.iter().any(|t| **t == Id(id.clone())) {
return false;
}
}
PseudoSelector(CssPathPseudoSelector::First) => {
if idx_in_parent != 0 {
return false;
}
}
PseudoSelector(CssPathPseudoSelector::Last) => {
if idx_in_parent != parent_children.saturating_sub(1) {
return false;
}
}
PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(i))) => {
if idx_in_parent != *i as usize {
return false;
}
}
PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Even)) => {
if !idx_in_parent.is_multiple_of(2) {
return false;
}
}
PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Odd)) => {
if idx_in_parent.is_multiple_of(2) {
return false;
}
}
PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Pattern(p))) => {
if !idx_in_parent.saturating_sub(p.offset as usize).is_multiple_of(p.pattern_repeat as usize)
{
return false;
}
}
_ => return false, // can't happen
}
}
true
}
struct CssBlock {
ending: Option<CssPathPseudoSelector>,
block: CssRuleBlock,
}
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the body node cannot be compiled to Rust code.
pub fn compile_body_node_to_rust_code<'a>(
body_node: &'a XmlNode,
component_map: &'a ComponentMap,
extra_blocks: &mut VecContents,
css_blocks: &mut BTreeMap<String, String>,
css: &Css,
mut matcher: CssMatcher,
) -> Result<String, CompileError> {
use azul_css::css::CssDeclaration;
let t = "";
let t2 = " ";
let mut dom_string = String::from("Dom::create_body()");
let node_type = CssPathSelector::Type(NodeTypeTag::Body);
matcher.path.push(node_type);
let ids = body_node
.attributes
.get_key("id")
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.unwrap_or_default();
matcher.path.extend(
ids.into_iter()
.map(|id| CssPathSelector::Id(id.to_string().into())),
);
let classes = body_node
.attributes
.get_key("class")
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.unwrap_or_default();
matcher.path.extend(
classes
.into_iter()
.map(|class| CssPathSelector::Class(class.to_string().into())),
);
let matcher_hash = matcher.get_hash();
let css_blocks_for_this_node = get_css_blocks(css, &matcher);
if !css_blocks_for_this_node.is_empty() {
// Track property types for the helper-const machinery, then emit the
// matched declarations as an inline CSS string. (The old path emitted a
// `const CSS_MATCH_*: NodeDataInlineCssPropertyVec` + `.with_inline_css_props`,
// but that API was removed in 32d44ed8a; `.with_css(<str>)` is the
// current equivalent and parses pseudo blocks too.)
for css_block in &css_blocks_for_this_node {
for declaration in css_block.block.declarations.as_ref() {
let prop = match declaration {
CssDeclaration::Static(s) => s,
CssDeclaration::Dynamic(d) => &d.default_value,
};
extra_blocks.insert_from_css_property(prop);
}
}
let inline_css = css_blocks_to_inline_string(&css_blocks_for_this_node);
if !inline_css.is_empty() {
let escaped = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
let _ = write!(dom_string, "\r\n{t2}.with_css(\"{escaped}\")");
}
let _ = (&mut *css_blocks, matcher_hash); // retained for signature compat
}
if !body_node.children.as_ref().is_empty() {
use azul_css::codegen::format::GetHash;
let children_hash = body_node.children.as_ref().get_hash();
dom_string.push_str("\r\n.with_children(vec![\r\n");
for (child_idx, child) in body_node.children.as_ref().iter().enumerate() {
match child {
XmlNodeChild::Element(child_node) => {
let mut matcher = matcher.clone();
matcher.path.push(CssPathSelector::Children);
matcher.indices_in_parent.push(child_idx);
matcher.children_length.push(body_node.children.len());
let _ = write!(dom_string,
"{}{},\r\n",
t,
compile_node_to_rust_code_inner(
child_node,
component_map,
1,
extra_blocks,
css_blocks,
css,
matcher,
)?
);
}
XmlNodeChild::Text(text) => {
let text = text.trim();
if !text.is_empty() {
let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
let _ = write!(dom_string,
"{t}Dom::create_text(\"{escaped}\"),\r\n"
);
}
}
}
}
let _ = write!(dom_string, "\r\n{t}])");
}
let dom_string = dom_string.trim();
Ok(dom_string.to_string())
}
/// Serialize the CSS blocks matched for a node into one inline CSS string for
/// `Dom::with_css(...)`. `with_css` parses via `Css::parse_inline`, which runs
/// the full selector+nesting machinery, so `:hover`/`:active`/`:focus` are
/// emitted as nested pseudo blocks and round-trip faithfully; plain rules are
/// emitted flat as `key: value;` (via `CssProperty::key()` / `value()`).
fn css_blocks_to_inline_string(blocks: &[CssBlock]) -> String {
fn decls_of(block: &CssBlock) -> Vec<String> {
block
.block
.declarations
.as_ref()
.iter()
.map(|d| {
let prop = match d {
CssDeclaration::Static(s) => s,
CssDeclaration::Dynamic(dy) => &dy.default_value,
};
format!("{}: {};", prop.key(), prop.value())
})
.collect()
}
let mut normal: Vec<String> = Vec::new();
let mut pseudo: Vec<String> = Vec::new();
for block in blocks {
let pseudo_sel = match block.ending {
Some(CssPathPseudoSelector::Hover) => Some(":hover"),
Some(CssPathPseudoSelector::Active) => Some(":active"),
Some(CssPathPseudoSelector::Focus) => Some(":focus"),
_ => None,
};
match pseudo_sel {
None => normal.extend(decls_of(block)),
Some(sel) => pseudo.push(format!("{} {{ {} }}", sel, decls_of(block).join(" "))),
}
}
let mut parts = normal;
parts.extend(pseudo);
parts.join(" ")
}
fn get_css_blocks(css: &Css, matcher: &CssMatcher) -> Vec<CssBlock> {
let mut blocks = Vec::new();
for css_block in css.rules.as_ref() {
if matcher.matches(&css_block.path) {
let mut ending = None;
if let Some(CssPathSelector::PseudoSelector(p)) =
css_block.path.selectors.as_ref().last()
{
ending = Some(p.clone());
}
blocks.push(CssBlock {
ending,
block: css_block.clone(),
});
}
}
blocks
}
fn compile_and_format_dynamic_items(input: &[DynamicItem]) -> String {
use self::DynamicItem::{Var, Str};
if input.is_empty() {
String::from("AzString::from_const_str(\"\")")
} else if input.len() == 1 {
// common: there is only one "dynamic item" - skip the "format!()" macro
match &input[0] {
Var { name, format_spec } => {
let var_name = normalize_casing(name.trim());
if let Some(spec) = format_spec {
format!("format!(\"{{:{spec}}}\", {var_name}).into()")
} else {
var_name
}
}
Str(s) => format!("AzString::from_const_str(\"{s}\")"),
}
} else {
// build a "format!("{var}, blah", var)" string
let mut formatted_str = String::from("format!(\"");
let mut variables = Vec::new();
for item in input {
match item {
Var { name, format_spec } => {
let variable_name = normalize_casing(name.trim());
if let Some(spec) = format_spec {
let _ = write!(formatted_str, "{{{variable_name}:{spec}}}");
} else {
let _ = write!(formatted_str, "{{{variable_name}}}");
}
variables.push(variable_name.clone());
}
Str(s) => {
let s = s.replace('"', "\\\"");
formatted_str.push_str(&s);
}
}
}
formatted_str.push('\"');
if !variables.is_empty() {
formatted_str.push_str(", ");
}
formatted_str.push_str(&variables.join(", "));
formatted_str.push_str(").into()");
formatted_str
}
}
fn format_args_for_rust_code(input: &str) -> String {
let dynamic_str_items = split_dynamic_string(input);
compile_and_format_dynamic_items(&dynamic_str_items)
}
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
// component_map is forwarded through the codegen recursion for parity with the
// component-expanding path; this Rust-codegen path only threads it into recursive calls.
#[allow(clippy::only_used_in_recursion)]
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
fn compile_node_to_rust_code_inner(
node: &XmlNode,
component_map: &ComponentMap,
tabs: usize,
extra_blocks: &mut VecContents,
css_blocks: &mut BTreeMap<String, String>,
css: &Css,
mut matcher: CssMatcher,
) -> Result<String, CompileError> {
use azul_css::css::CssDeclaration;
let t = String::from(" ").repeat(tabs - 1);
let t2 = String::from(" ").repeat(tabs);
let component_name = normalize_casing(&node.node_type);
// Look up the CSS NodeTypeTag
let node_type_tag = tag_to_node_type_tag(&component_name);
let node_type = CssPathSelector::Type(node_type_tag);
// Emit a plain `create_node(<Tag>)` for the base node. Do NOT route through
// the component `compile_fn`: its Rust arm bakes inline text into a
// `.with_children(..)`, which the child-walk below would then OVERWRITE with
// a second `.with_children(..)` — silently dropping the text on any node
// that has BOTH text and element children. The child-walk handles ALL
// children (text + elements) in order, so the base node must stay childless.
// Interactive/data tags (Button/Input/…) whose NodeType carries data fall
// back to `div`, matching the C/C++/Python walkers (`safe_container_tag`).
let ctor = analyze_node_ctor(&component_name, node);
let mut dom_string = ctor.render_rust().map_or_else(|| {
let tag = safe_container_tag(&format!("{:?}", tag_to_node_type(&component_name)));
format!("{t2}Dom::create_node(NodeType::{tag})")
}, |expr| format!("{t2}{expr}"));
matcher.path.push(node_type);
let ids = node
.attributes
.get_key("id")
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.unwrap_or_default();
matcher.path.extend(
ids.into_iter()
.map(|id| CssPathSelector::Id(id.to_string().into())),
);
let classes = node
.attributes
.get_key("class")
.map(|s| s.split_whitespace().collect::<Vec<_>>())
.unwrap_or_default();
matcher.path.extend(
classes
.into_iter()
.map(|class| CssPathSelector::Class(class.to_string().into())),
);
let matcher_hash = matcher.get_hash();
let css_blocks_for_this_node = get_css_blocks(css, &matcher);
if !css_blocks_for_this_node.is_empty() {
// Track property types for the helper-const machinery, then emit the
// matched declarations as an inline CSS string. (The old path emitted a
// `const CSS_MATCH_*: NodeDataInlineCssPropertyVec` + `.with_inline_css_props`,
// but that API was removed in 32d44ed8a; `.with_css(<str>)` is the
// current equivalent and parses pseudo blocks too.)
for css_block in &css_blocks_for_this_node {
for declaration in css_block.block.declarations.as_ref() {
let prop = match declaration {
CssDeclaration::Static(s) => s,
CssDeclaration::Dynamic(d) => &d.default_value,
};
extra_blocks.insert_from_css_property(prop);
}
}
let inline_css = css_blocks_to_inline_string(&css_blocks_for_this_node);
if !inline_css.is_empty() {
let escaped = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
let _ = write!(dom_string, "\r\n{t2}.with_css(\"{escaped}\")");
}
let _ = (&mut *css_blocks, matcher_hash); // retained for signature compat
}
set_stringified_attributes(
&mut dom_string,
&node.attributes,
&ComponentArgumentVec::new(),
tabs,
);
// Text folded into the ctor (Tier A/C) is skipped, as is a `<caption>`
// already injected by `create_table`.
let mut caption_skipped = false;
let mut children_string = node
.children
.as_ref()
.iter()
.enumerate()
.filter_map(|(child_idx, c)| match c {
XmlNodeChild::Element(child_node) => {
if ctor.skip_caption()
&& !caption_skipped
&& child_node.node_type.as_str().eq_ignore_ascii_case("caption")
{
caption_skipped = true;
return None;
}
let mut matcher = matcher.clone();
matcher.path.push(CssPathSelector::Children);
matcher.indices_in_parent.push(child_idx);
matcher.children_length.push(node.children.len());
Some(compile_node_to_rust_code_inner(
child_node,
component_map,
tabs + 1,
extra_blocks,
css_blocks,
css,
matcher,
))
}
XmlNodeChild::Text(text) => {
if ctor.consumes_text() {
return None;
}
let text = text.trim();
if text.is_empty() {
None
} else {
let t2 = String::from(" ").repeat(tabs);
let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
Some(Ok(format!(
"{t2}Dom::create_text(\"{escaped}\")"
)))
}
}
})
.collect::<Result<Vec<_>, _>>()?
.join(",\r\n");
if !children_string.is_empty() {
let _ = write!(dom_string,
"\r\n{t2}.with_children(vec![\r\n{children_string}\r\n{t2}])"
);
}
Ok(dom_string)
}
// ───────────────────────────────────────────────────────────────────────────
// Generic FLUENT DOM-builder emitter (C++ / Python).
//
// Rust has its own dedicated walker above (`compile_*_to_rust_code`). C++ and
// Python share this generic walker because their builder APIs are also fluent
// (`Dom::create_*().with_css(..).with_child(..)`); only the surface tokens
// differ, captured in `FluentSyntax`. Plain C is imperative and has its own
// walker (`compile_*_to_c_code`).
// ───────────────────────────────────────────────────────────────────────────
/// Tags with a zero-arg per-tag creator (`create_<tag>()` / `AzDom_create<Tag>()`
/// / `create_node(NodeType::<Tag>)`). Interactive / data elements (Button, Input,
/// Img, Select, Textarea, Label, A, Table, …) take constructor arguments, so an
/// exported page maps them to a plain `div` container (structure preserved; the
/// user re-wires behavior). Keep these CamelCase to match `NodeTypeTag` debug names.
const SAFE_CONTAINER_TAGS: &[&str] = &[
"Abbr", "Acronym", "Address", "Article", "Aside", "B", "Bdi", "Bdo", "Big",
"Blockquote", "Body", "Br", "Caption", "Cite", "Code", "Colgroup", "Dd",
"Del", "Dfn", "Dir", "Div", "Dl", "Dt", "Em", "Embed", "Figcaption",
"Figure", "Footer", "H1", "H2", "H3", "H4", "H5", "H6", "Head", "Header",
"Hr", "Html", "I", "Ins", "Kbd", "Li", "Link", "Main", "Map", "Mark",
"Meta", "Nav", "Object", "Ol", "P", "Pre", "Q", "Rp", "Rt", "Rtc", "Ruby",
"S", "Samp", "Script", "Section", "Small", "Span", "Strong", "Style", "Sub",
"Sup", "Svg", "Tbody", "Td", "Tfoot", "Th", "Thead", "Title", "Tr", "U",
"Ul", "Var", "Wbr",
];
/// The CamelCase tag to actually emit a creator for: the tag itself if it has a
/// zero-arg creator, else `"Div"`.
fn safe_container_tag(tag_dbg: &str) -> &'static str {
SAFE_CONTAINER_TAGS.iter().copied().find(|t| *t == tag_dbg).unwrap_or("Div")
}
// ───────────────────────────────────────────────────────────────────────────
// Semantic / accessibility-aware constructor selection.
//
// Instead of mapping every element to a plain `div`, an exported live page
// picks the *most specific* Azul constructor so the generated app keeps the
// page's semantics + accessibility tree:
//
// • Tier A `create_<tag>_with_text(text)` — a tag with a single text child
// and no element children (P, Span, H1-H6, Li, Td, Code, …).
// • Tier B aria-only / void widgets (Details, Summary, Form, Canvas, Area,
// …) — `create_<tag>(SmallAriaInfo::label(..))` when `aria-label`
// is present, else `create_<tag>_no_a11y()`.
// • Tier C multi-arg widgets (Button, A, Label, Input, Select, Option,
// Optgroup, Textarea, Table) — args pulled from HTML attributes.
// • Tier D scalar-driven widgets (Progress, Meter, Dialog) — the `*_no_a11y`
// form with extracted numeric args (the full aria structs are
// complex; the NoA11y form is simplest + correct).
//
// Every symbol emitted here is verified to exist in `target/codegen/azul.h` (C)
// and `azul20.hpp` (C++); anything else falls back to `safe_container_tag`
// (`div`). The four walkers share `analyze_node_ctor` and each renders the
// result with its own surface tokens.
// ───────────────────────────────────────────────────────────────────────────
/// A single positional argument of a semantic constructor. String payloads are
/// RAW — escaping happens at render time (matching the walkers).
#[derive(Debug, Clone)]
enum CtorArg {
/// Plain string literal (`AzString` / `String` / `"…"`).
Str(String),
/// `SmallAriaInfo` built from an accessible label.
Aria(String),
/// `f32` numeric literal.
Float(f32),
/// `OptionString::Some(text)`.
OptSome(String),
/// `OptionString::None`.
OptNone,
}
/// The constructor chosen for an element node.
enum NodeCtor {
/// Plain container — keep each walker's existing `create_<tag>()` path.
Plain,
/// A specific semantic constructor.
Semantic {
/// Canonical CamelCase suffix after `create` / `AzDom_create`
/// (e.g. `Button`, `ButtonNoA11y`, `PWithText`, `A`, `ANoA11y`).
suffix: String,
args: Vec<CtorArg>,
/// The node's direct text is folded into the ctor — skip text children
/// in the walk so it isn't emitted twice.
consumes_text: bool,
/// The table aria form injects its own `<caption>` child — drop the
/// first literal `<caption>` element so it isn't duplicated.
skip_caption: bool,
},
}
/// Uppercase the first character (`button` → `Button`, `h1` → `H1`). HTML tags
/// are single lowercase tokens, so this yields the exact `AzDom_create<Suffix>`
/// spelling.
fn cap_first(tag: &str) -> String {
let mut c = tag.chars();
c.next().map_or_else(String::new, |f| f.to_uppercase().collect::<String>() + c.as_str())
}
/// CamelCase → `snake_case` for the C++/Python/Rust method names
/// (`ButtonNoA11y` → `button_no_a11y`, `PWithText` → `p_with_text`,
/// `ANoA11y` → `a_no_a11y`, `H1WithText` → `h1_with_text`).
fn camel_to_snake(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::new();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_ascii_uppercase() && i > 0 {
let prev = chars[i - 1];
let next_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
if prev.is_ascii_lowercase()
|| prev.is_ascii_digit()
|| (prev.is_ascii_uppercase() && next_lower)
{
out.push('_');
}
}
out.extend(ch.to_lowercase());
}
out
}
/// Escape `\` and `"` for a double-quoted string literal.
fn esc_lit(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Format an `f32` as a valid float literal with a decimal point (`1` → `1.0`).
fn fmt_f32_lit(f: f32) -> String {
let s = format!("{f}");
if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
s
} else {
format!("{s}.0")
}
}
/// Joined, trimmed text of a node's *direct* text children (`" Go "` → `"Go"`).
fn node_direct_text(node: &XmlNode) -> String {
node.children
.as_ref()
.iter()
.filter_map(|c| match c {
XmlNodeChild::Text(t) => {
let t = t.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
}
XmlNodeChild::Element(_) => None,
})
.collect::<Vec<_>>()
.join(" ")
}
/// Non-empty `aria-label` attribute value, if present.
fn node_aria_label(node: &XmlNode) -> Option<String> {
node.attributes.get_key("aria-label").and_then(|v| {
let v = v.as_str().trim();
if v.is_empty() { None } else { Some(v.to_string()) }
})
}
/// Attribute value, or `default` when absent.
fn node_attr_or(node: &XmlNode, key: &str, default: &str) -> String {
node.attributes
.get_key(key).map_or_else(|| default.to_string(), |v| v.as_str().to_string())
}
/// Attribute parsed as `f32`, or `default` when absent / unparsable.
fn node_attr_f32(node: &XmlNode, key: &str, default: f32) -> f32 {
node.attributes
.get_key(key)
.and_then(|v| v.as_str().trim().parse::<f32>().ok())
.unwrap_or(default)
}
/// Text of the node's first `<caption>` element child, if any (non-empty).
fn first_caption_text(node: &XmlNode) -> Option<String> {
node.children.as_ref().iter().find_map(|c| match c {
XmlNodeChild::Element(e) if e.node_type.as_str().eq_ignore_ascii_case("caption") => {
let t = e.get_text_content();
let t = t.trim();
if t.is_empty() { None } else { Some(t.to_string()) }
}
_ => None,
})
}
/// Tags with a single-arg `create_<tag>_with_text(text)` constructor (Tier A).
const WITH_TEXT_TAGS: &[&str] = &[
"acronym", "b", "bdi", "bdo", "big", "blockquote", "cite", "code", "del",
"dfn", "em", "h1", "h2", "h3", "h4", "h5", "h6", "i", "ins", "kbd", "li",
"mark", "p", "pre", "rp", "rt", "s", "samp", "small", "span", "strong",
"style", "sub", "sup", "td", "th", "title", "u", "var",
];
/// Pick the semantic constructor for `tag` (lowercase HTML tag) + `node`.
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
fn analyze_node_ctor(tag: &str, node: &XmlNode) -> NodeCtor {
// Helper for the common "no caption skip" case.
fn sem(suffix: impl Into<String>, args: Vec<CtorArg>, consumes_text: bool) -> NodeCtor {
NodeCtor::Semantic {
suffix: suffix.into(),
args,
consumes_text,
skip_caption: false,
}
}
let aria = node_aria_label(node);
let has_aria = aria.is_some();
let label = aria.unwrap_or_default();
// `has_only_text_children()` is also true for childless nodes; pair it with
// `has_text` so empty elements stay plain containers.
let pure_text = node.has_only_text_children();
let text = node_direct_text(node);
let has_text = !text.is_empty();
let cap = cap_first(tag);
// Tier A — *_with_text (single text child, no element children).
if WITH_TEXT_TAGS.contains(&tag) {
if pure_text && has_text {
return sem(format!("{cap}WithText"), vec![CtorArg::Str(text)], true);
}
return NodeCtor::Plain;
}
match tag {
// Tier B — aria-only / void widgets.
"details" | "form" | "fieldset" | "legend" | "menu" | "output"
| "datalist" | "canvas" | "audio" | "video" | "area" => {
if has_aria {
sem(cap, vec![CtorArg::Aria(label)], false)
} else {
sem(format!("{cap}NoA11y"), vec![], false)
}
}
// Summary is Tier B but also has a WithText form for a single text child.
"summary" => {
if pure_text && has_text {
if has_aria {
sem("SummaryWithText", vec![CtorArg::Str(text), CtorArg::Aria(label)], true)
} else {
sem("SummaryWithTextNoA11y", vec![CtorArg::Str(text)], true)
}
} else if has_aria {
sem("Summary", vec![CtorArg::Aria(label)], false)
} else {
sem("SummaryNoA11y", vec![], false)
}
}
// Tier C — multi-arg widgets (args from HTML attributes).
"button" => {
if has_aria {
sem("Button", vec![CtorArg::Str(text), CtorArg::Aria(label)], true)
} else {
sem("ButtonNoA11y", vec![CtorArg::Str(text)], true)
}
}
"a" => {
let href = node_attr_or(node, "href", "");
if has_aria {
sem("A", vec![CtorArg::Str(href), CtorArg::Str(text), CtorArg::Aria(label)], true)
} else {
let lbl = if has_text { CtorArg::OptSome(text) } else { CtorArg::OptNone };
sem("ANoA11y", vec![CtorArg::Str(href), lbl], true)
}
}
"label" => {
let for_id = node_attr_or(node, "for", "");
if has_aria {
sem("Label", vec![CtorArg::Str(for_id), CtorArg::Str(text), CtorArg::Aria(label)], true)
} else {
sem("LabelNoA11y", vec![CtorArg::Str(for_id), CtorArg::Str(text)], true)
}
}
"input" => {
let ty = node_attr_or(node, "type", "text");
let name = node_attr_or(node, "name", "");
if has_aria {
sem("Input", vec![CtorArg::Str(ty), CtorArg::Str(name), CtorArg::Str(label.clone()), CtorArg::Aria(label)], false)
} else {
sem("InputNoA11y", vec![CtorArg::Str(ty), CtorArg::Str(name), CtorArg::Str(label)], false)
}
}
"textarea" => {
let name = node_attr_or(node, "name", "");
if has_aria {
sem("Textarea", vec![CtorArg::Str(name), CtorArg::Str(label.clone()), CtorArg::Aria(label)], false)
} else {
sem("TextareaNoA11y", vec![CtorArg::Str(name), CtorArg::Str(label)], false)
}
}
"select" => {
let name = node_attr_or(node, "name", "");
if has_aria {
sem("Select", vec![CtorArg::Str(name), CtorArg::Str(label.clone()), CtorArg::Aria(label)], false)
} else {
sem("SelectNoA11y", vec![CtorArg::Str(name), CtorArg::Str(label)], false)
}
}
"option" => {
let value = node_attr_or(node, "value", "");
if has_aria {
sem("Option", vec![CtorArg::Str(value), CtorArg::Str(text), CtorArg::Aria(label)], true)
} else {
sem("OptionNoA11y", vec![CtorArg::Str(value), CtorArg::Str(text)], true)
}
}
"optgroup" => {
let lbl = node_attr_or(node, "label", "");
if has_aria {
sem("Optgroup", vec![CtorArg::Str(lbl), CtorArg::Aria(label)], false)
} else {
sem("OptgroupNoA11y", vec![CtorArg::Str(lbl)], false)
}
}
"table" => {
if has_aria {
// The aria form injects a caption child, so take the caption from
// the literal <caption> (or the aria label) and drop the literal.
let caption = first_caption_text(node).unwrap_or_else(|| label.clone());
NodeCtor::Semantic {
suffix: "Table".to_string(),
args: vec![CtorArg::Str(caption), CtorArg::Aria(label)],
consumes_text: false,
skip_caption: true,
}
} else {
sem("TableNoA11y", vec![], false)
}
}
// Tier D — scalar-driven widgets (NoA11y form with extracted numbers).
"progress" => sem(
"ProgressNoA11y",
vec![
CtorArg::Float(node_attr_f32(node, "value", 0.0)),
CtorArg::Float(node_attr_f32(node, "max", 1.0)),
],
false,
),
"meter" => sem(
"MeterNoA11y",
vec![
CtorArg::Float(node_attr_f32(node, "value", 0.0)),
CtorArg::Float(node_attr_f32(node, "min", 0.0)),
CtorArg::Float(node_attr_f32(node, "max", 1.0)),
],
false,
),
"dialog" => sem("DialogNoA11y", vec![], false),
_ => NodeCtor::Plain,
}
}
impl CtorArg {
/// Rust expression for this argument (`AzString::from(..)` works for both the
/// `Into<AzString>` and the concrete `AzString` parameter forms).
fn render_rust(&self) -> String {
match self {
Self::Str(s) => format!("AzString::from(\"{}\")", esc_lit(s)),
Self::Aria(s) => format!("SmallAriaInfo::label(AzString::from(\"{}\"))", esc_lit(s)),
Self::Float(f) => fmt_f32_lit(*f),
Self::OptSome(s) => format!("OptionString::Some(AzString::from(\"{}\"))", esc_lit(s)),
Self::OptNone => "OptionString::None".to_string(),
}
}
fn render_c(&self) -> String {
match self {
Self::Str(s) => format!("AZ_STR(\"{}\")", esc_lit(s)),
Self::Aria(s) => format!("AzSmallAriaInfo_label(AZ_STR(\"{}\"))", esc_lit(s)),
Self::Float(f) => format!("{}f", fmt_f32_lit(*f)),
Self::OptSome(s) => format!("AzOptionString_some(AZ_STR(\"{}\"))", esc_lit(s)),
Self::OptNone => "AzOptionString_none()".to_string(),
}
}
fn render_cpp(&self) -> String {
match self {
Self::Str(s) => format!("String(\"{}\")", esc_lit(s)),
Self::Aria(s) => format!("SmallAriaInfo::label(String(\"{}\"))", esc_lit(s)),
Self::Float(f) => format!("{}f", fmt_f32_lit(*f)),
Self::OptSome(s) => format!("OptionString::some(String(\"{}\"))", esc_lit(s)),
Self::OptNone => "OptionString::none()".to_string(),
}
}
fn render_python(&self) -> String {
match self {
Self::Str(s) => format!("\"{}\"", esc_lit(s)),
Self::Aria(s) => format!("azul.SmallAriaInfo.label(\"{}\")", esc_lit(s)),
Self::Float(f) => fmt_f32_lit(*f),
Self::OptSome(s) => format!("azul.OptionString.some(\"{}\")", esc_lit(s)),
Self::OptNone => "azul.OptionString.none()".to_string(),
}
}
}
impl NodeCtor {
const fn consumes_text(&self) -> bool {
matches!(self, Self::Semantic { consumes_text: true, .. })
}
const fn skip_caption(&self) -> bool {
matches!(self, Self::Semantic { skip_caption: true, .. })
}
/// `Dom::create_…(args)` for Rust, or `None` for a plain container.
fn render_rust(&self) -> Option<String> {
match self {
Self::Plain => None,
Self::Semantic { suffix, args, .. } => Some(format!(
"Dom::create_{}({})",
camel_to_snake(suffix),
args.iter().map(CtorArg::render_rust).collect::<Vec<_>>().join(", ")
)),
}
}
/// `AzDom_create…(args)` for C, or `None` for a plain container.
fn render_c(&self) -> Option<String> {
match self {
Self::Plain => None,
Self::Semantic { suffix, args, .. } => Some(format!(
"AzDom_create{}({})",
suffix,
args.iter().map(CtorArg::render_c).collect::<Vec<_>>().join(", ")
)),
}
}
/// Fluent `Dom::create_…` (C++) / `azul.Dom.create_…` (Python), or `None`.
fn render_fluent(&self, target: &CompileTarget) -> Option<String> {
match self {
Self::Plain => None,
Self::Semantic { suffix, args, .. } => {
let snake = camel_to_snake(suffix);
let (prefix, rendered) = match target {
CompileTarget::Cpp => (
format!("Dom::create_{snake}"),
args.iter().map(CtorArg::render_cpp).collect::<Vec<_>>(),
),
CompileTarget::Python => (
format!("azul.Dom.create_{snake}"),
args.iter().map(CtorArg::render_python).collect::<Vec<_>>(),
),
_ => return None,
};
Some(format!("{}({})", prefix, rendered.join(", ")))
}
}
}
}
/// Per-language token hooks for the fluent walker. The `&str` args are already
/// escaped for a double-quoted string literal.
struct FluentSyntax {
target: CompileTarget,
/// tag debug-name (e.g. "Div") -> full create expression
create_node: fn(&str) -> String,
/// escaped text -> create-text expression
create_text: fn(&str) -> String,
/// escaped css -> `.with_css(..)` call
with_css: fn(&str) -> String,
/// escaped class -> `.with_class(..)` call
with_class: fn(&str) -> String,
/// escaped id -> `.with_id(..)` call
with_id: fn(&str) -> String,
/// escaped child expression -> `.with_child(..)` call (children are chained)
with_child: fn(&str) -> String,
}
const CPP_SYNTAX: FluentSyntax = FluentSyntax {
target: CompileTarget::Cpp,
// Use per-tag creators (Dom::create_div(), create_p(), create_body(), …)
// — `NodeType` is a tagged union, so `create_node` would need union
// construction; the per-tag creators exist for every common HTML element.
create_node: |tag| alloc::format!("Dom::create_{}()", tag.to_lowercase()),
create_text: |s| alloc::format!("Dom::create_text(String(\"{s}\"))"),
with_css: |s| alloc::format!(".with_css(String(\"{s}\"))"),
with_class: |s| alloc::format!(".with_class(String(\"{s}\"))"),
with_id: |s| alloc::format!(".with_id(String(\"{s}\"))"),
with_child: |c| alloc::format!(".with_child({c})"),
};
const PYTHON_SYNTAX: FluentSyntax = FluentSyntax {
target: CompileTarget::Python,
// Per-tag creators (azul.Dom.create_div(), …) — see CPP_SYNTAX note.
create_node: |tag| alloc::format!("azul.Dom.create_{}()", tag.to_lowercase()),
create_text: |s| alloc::format!("azul.Dom.create_text(\"{s}\")"),
with_css: |s| alloc::format!(".with_css(\"{s}\")"),
with_class: |s| alloc::format!(".with_class(\"{s}\")"),
with_id: |s| alloc::format!(".with_id(\"{s}\")"),
with_child: |c| alloc::format!(".with_child({c})"),
};
/// Walk one element node, emitting a fluent create-expression for `syntax`'s
/// language. Mirrors `compile_node_to_rust_code_inner` but token-parameterized.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
// See compile_node_to_rust_code_inner: component_map is forwarded for codegen-path parity.
#[allow(clippy::only_used_in_recursion)]
fn compile_node_fluent(
node: &XmlNode,
syntax: &FluentSyntax,
component_map: &ComponentMap,
css: &Css,
mut matcher: CssMatcher,
) -> Result<String, CompileError> {
use azul_css::css::CssDeclaration;
let component_name = normalize_casing(&node.node_type);
let node_type_tag = tag_to_node_type_tag(&component_name);
let tag_dbg = alloc::format!("{:?}", tag_to_node_type(&component_name));
// Base create-expression. For an exported live page every node is a plain
// HTML element, so emit a per-tag creator directly via the language hooks
// (universal + verified) rather than the per-component `compile_fn`, whose
// C++/Python arms emit stale placeholder syntax (`Dom.div()` etc.).
// Interactive/data tags (whose creators need args) fall back to `div`. Any
// element text shows up as a Text child below and is handled there.
let ctor = analyze_node_ctor(&component_name, node);
let mut s = ctor.render_fluent(&syntax.target).map_or_else(|| (syntax.create_node)(safe_container_tag(&tag_dbg)), |expr| expr);
matcher.path.push(CssPathSelector::Type(node_type_tag));
let ids: Vec<String> = node.attributes.get_key("id")
.map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
.unwrap_or_default();
matcher.path.extend(ids.iter().map(|id| CssPathSelector::Id(id.clone().into())));
let classes: Vec<String> = node.attributes.get_key("class")
.map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
.unwrap_or_default();
matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
// Inline CSS (matched rules -> `.with_css("..")`, pseudo blocks included).
let blocks = get_css_blocks(css, &matcher);
if !blocks.is_empty() {
let inline_css = css_blocks_to_inline_string(&blocks);
if !inline_css.is_empty() {
let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
s.push_str(&(syntax.with_css)(&esc));
}
}
for id in &ids {
s.push_str(&(syntax.with_id)(&id.replace('\\', "\\\\").replace('"', "\\\"")));
}
for class in &classes {
s.push_str(&(syntax.with_class)(&class.replace('\\', "\\\\").replace('"', "\\\"")));
}
// Children (chained `.with_child(..)`). Text folded into the ctor (Tier A/C)
// is skipped here, as is a `<caption>` already injected by `create_table`.
let mut caption_skipped = false;
for (child_idx, child) in node.children.as_ref().iter().enumerate() {
match child {
XmlNodeChild::Element(child_node) => {
if ctor.skip_caption()
&& !caption_skipped
&& child_node.node_type.as_str().eq_ignore_ascii_case("caption")
{
caption_skipped = true;
continue;
}
let mut m = matcher.clone();
m.path.push(CssPathSelector::Children);
m.indices_in_parent.push(child_idx);
m.children_length.push(node.children.len());
let child_src = compile_node_fluent(child_node, syntax, component_map, css, m)?;
s.push_str(&(syntax.with_child)(&child_src));
}
XmlNodeChild::Text(text) => {
if ctor.consumes_text() {
continue;
}
let text = text.trim();
if !text.is_empty() {
let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
s.push_str(&(syntax.with_child)(&(syntax.create_text)(&esc)));
}
}
}
}
Ok(s)
}
/// Build the `<body>` render-expression for `syntax`'s language.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
fn compile_body_fluent<'a>(
body_node: &'a XmlNode,
syntax: &FluentSyntax,
component_map: &'a ComponentMap,
css: &Css,
mut matcher: CssMatcher,
) -> Result<String, CompileError> {
let mut s = (syntax.create_node)("Body");
matcher.path.push(CssPathSelector::Type(NodeTypeTag::Body));
let classes: Vec<String> = body_node.attributes.get_key("class")
.map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
.unwrap_or_default();
matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
let blocks = get_css_blocks(css, &matcher);
if !blocks.is_empty() {
let inline_css = css_blocks_to_inline_string(&blocks);
if !inline_css.is_empty() {
let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
s.push_str(&(syntax.with_css)(&esc));
}
}
for class in &classes {
s.push_str(&(syntax.with_class)(&class.replace('\\', "\\\\").replace('"', "\\\"")));
}
for (child_idx, child) in body_node.children.as_ref().iter().enumerate() {
match child {
XmlNodeChild::Element(child_node) => {
let mut m = matcher.clone();
m.path.push(CssPathSelector::Children);
m.indices_in_parent.push(child_idx);
m.children_length.push(body_node.children.len());
let child_src = compile_node_fluent(child_node, syntax, component_map, css, m)?;
s.push_str(&(syntax.with_child)(&child_src));
}
XmlNodeChild::Text(text) => {
let text = text.trim();
if !text.is_empty() {
let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
s.push_str(&(syntax.with_child)(&(syntax.create_text)(&esc)));
}
}
}
}
Ok(s)
}
/// Parse the page's `<style>` and seed a matcher rooted at `<body>`. Shared by
/// the C++/Python/C entry points (mirrors the head of `str_to_rust_code`).
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
fn parse_page_style_and_body(
root_nodes: &[XmlNodeChild],
) -> Result<(Css, &XmlNode), CompileError> {
let html_node = get_html_node(root_nodes)?;
let body_node = get_body_node(html_node.children.as_ref())?;
let mut global_style = Css::empty();
if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
let text = style_node.get_text_content();
if !text.is_empty() {
global_style = azul_css::parser2::new_from_str(&text).0;
}
}
}
global_style.sort_by_specificity();
Ok((global_style, body_node))
}
fn body_matcher(body_node: &XmlNode) -> CssMatcher {
CssMatcher {
path: Vec::new(),
indices_in_parent: vec![0],
children_length: vec![body_node.children.as_ref().len()],
}
}
/// Compile a full HTML page to a compilable **C++** Azul app.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the XML cannot be parsed or compiled to C++ code.
pub fn str_to_cpp_code<'a>(
root_nodes: &'a [XmlNodeChild],
component_map: &'a ComponentMap,
) -> Result<String, CompileError> {
let (global_style, body_node) = parse_page_style_and_body(root_nodes)?;
let render = compile_body_fluent(body_node, &CPP_SYNTAX, component_map, &global_style, body_matcher(body_node))?;
Ok(alloc::format!(
"// Auto-generated UI source code (C++). Build:\n\
// clang++ -std=c++20 -I <azul>/target/codegen main.cpp -lazul\n\
#include \"azul20.hpp\"\n\
using namespace azul;\n\n\
struct Data {{}};\n\n\
AzDom render(AzRefAny data, AzLayoutCallbackInfo info) {{\n \
return {render};\n}}\n\n\
int main() {{\n \
RefAny data = RefAny::create(Data{{}});\n \
WindowCreateOptions window = WindowCreateOptions::create(render);\n \
App app = App::create(std::move(data), AppConfig::default_());\n \
app.run(std::move(window));\n \
return 0;\n}}\n"
))
}
/// Compile a full HTML page to a compilable **Python** Azul app.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the XML cannot be parsed or compiled to Python code.
pub fn str_to_python_code<'a>(
root_nodes: &'a [XmlNodeChild],
component_map: &'a ComponentMap,
) -> Result<String, CompileError> {
let (global_style, body_node) = parse_page_style_and_body(root_nodes)?;
let render = compile_body_fluent(body_node, &PYTHON_SYNTAX, component_map, &global_style, body_matcher(body_node))?;
Ok(alloc::format!(
"# Auto-generated UI source code (Python). Run: python3 main.py\n\
import azul\n\n\
class Data:\n pass\n\n\
def render(data, info):\n return (\n {}\n )\n\n\
def main():\n \
app = azul.App.create(Data(), azul.AppConfig.create())\n \
window = azul.WindowCreateOptions.create(render)\n \
app.run(window)\n\n\
if __name__ == \"__main__\":\n main()\n",
render.replace("\r\n", "\n ")
))
}
// ───────────────────────────────────────────────────────────────────────────
// Imperative C emitter. C has no fluent builder: each node is a statement that
// creates an `AzDom` local, applies css/class (by-value, returns), and pushes
// children via `AzDom_addChild(&parent, child)`. A recursive walk emits the
// statements bottom-up and returns the variable name holding each node.
// ───────────────────────────────────────────────────────────────────────────
/// C per-tag creator suffix: `NodeTypeTag` debug name with first char kept and
/// the rest lowercased (`Div`->`Div`, `BlockQuote`->`Blockquote`, `H1`->`H1`),
/// matching `AzDom_create<Suffix>` in azul.h.
fn c_creator_suffix(tag_dbg: &str) -> String {
let mut chars = tag_dbg.chars();
chars.next().map_or_else(|| "Div".to_string(), |first| {
let rest: String = chars.as_str().to_lowercase();
alloc::format!("{first}{rest}")
})
}
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
fn compile_node_c(
node: &XmlNode,
component_map: &ComponentMap,
css: &Css,
mut matcher: CssMatcher,
counter: &mut usize,
out: &mut String,
) -> Result<String, CompileError> {
let _ = component_map;
let component_name = normalize_casing(&node.node_type);
let node_type_tag = tag_to_node_type_tag(&component_name);
let tag_dbg = alloc::format!("{:?}", tag_to_node_type(&component_name));
let var = alloc::format!("n{}", *counter);
*counter += 1;
let ctor = analyze_node_ctor(&component_name, node);
match ctor.render_c() {
Some(expr) => { let _ = writeln!(out, " AzDom {var} = {expr};"); },
None => { let _ = writeln!(out,
" AzDom {} = AzDom_create{}();",
var,
c_creator_suffix(safe_container_tag(&tag_dbg))
); },
}
matcher.path.push(CssPathSelector::Type(node_type_tag));
let ids: Vec<String> = node.attributes.get_key("id")
.map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
.unwrap_or_default();
matcher.path.extend(ids.iter().map(|id| CssPathSelector::Id(id.clone().into())));
let classes: Vec<String> = node.attributes.get_key("class")
.map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
.unwrap_or_default();
matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
let blocks = get_css_blocks(css, &matcher);
if !blocks.is_empty() {
let inline_css = css_blocks_to_inline_string(&blocks);
if !inline_css.is_empty() {
let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = AzDom_withCss({var}, AZ_STR(\"{esc}\"));");
}
}
for id in &ids {
let esc = id.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = AzDom_withId({var}, AZ_STR(\"{esc}\"));");
}
for class in &classes {
let esc = class.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out, " {var} = AzDom_withClass({var}, AZ_STR(\"{esc}\"));");
}
let mut caption_skipped = false;
for (child_idx, child) in node.children.as_ref().iter().enumerate() {
match child {
XmlNodeChild::Element(child_node) => {
if ctor.skip_caption()
&& !caption_skipped
&& child_node.node_type.as_str().eq_ignore_ascii_case("caption")
{
caption_skipped = true;
continue;
}
let mut m = matcher.clone();
m.path.push(CssPathSelector::Children);
m.indices_in_parent.push(child_idx);
m.children_length.push(node.children.len());
let child_var = compile_node_c(child_node, component_map, css, m, counter, out)?;
let _ = writeln!(out, " AzDom_addChild(&{var}, {child_var});");
}
XmlNodeChild::Text(text) => {
if ctor.consumes_text() {
continue;
}
let text = text.trim();
if !text.is_empty() {
let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(out,
" AzDom_addChild(&{var}, AzDom_createText(AZ_STR(\"{esc}\")));"
);
}
}
}
}
Ok(var)
}
/// Compile a full HTML page to a compilable **C** Azul app.
#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
/// # Errors
///
/// Returns an error if the XML cannot be parsed or compiled to C code.
pub fn str_to_c_code<'a>(
root_nodes: &'a [XmlNodeChild],
component_map: &'a ComponentMap,
) -> Result<String, CompileError> {
let (global_style, body_node) = parse_page_style_and_body(root_nodes)?;
let mut body = String::new();
let mut counter = 0usize;
// Emit the body as the root node, then its children.
let root = alloc::format!("n{counter}");
counter += 1;
let _ = writeln!(body, " AzDom {root} = AzDom_createBody();");
let mut matcher = body_matcher(body_node);
matcher.path.push(CssPathSelector::Type(NodeTypeTag::Body));
let classes: Vec<String> = body_node.attributes.get_key("class")
.map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
.unwrap_or_default();
matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
let blocks = get_css_blocks(&global_style, &matcher);
if !blocks.is_empty() {
let inline_css = css_blocks_to_inline_string(&blocks);
if !inline_css.is_empty() {
let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(body, " {root} = AzDom_withCss({root}, AZ_STR(\"{esc}\"));");
}
}
for (child_idx, child) in body_node.children.as_ref().iter().enumerate() {
match child {
XmlNodeChild::Element(child_node) => {
let mut m = matcher.clone();
m.path.push(CssPathSelector::Children);
m.indices_in_parent.push(child_idx);
m.children_length.push(body_node.children.len());
let child_var = compile_node_c(child_node, component_map, &global_style, m, &mut counter, &mut body)?;
let _ = writeln!(body, " AzDom_addChild(&{root}, {child_var});");
}
XmlNodeChild::Text(text) => {
let text = text.trim();
if !text.is_empty() {
let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
let _ = writeln!(body,
" AzDom_addChild(&{root}, AzDom_createText(AZ_STR(\"{esc}\")));"
);
}
}
}
}
Ok(alloc::format!(
"/* Auto-generated UI source code (C). Build:\n\
* clang -I <azul>/target/codegen main.c -lazul\n */\n\
#include \"azul.h\"\n\
#include <string.h>\n\
#define AZ_STR(s) AzString_copyFromBytes((const uint8_t*)(s), 0, strlen(s))\n\n\
AzDom render(AzRefAny data, AzLayoutCallbackInfo info) {{\n\
{body} return {root};\n}}\n\n\
int main(void) {{\n \
AzString data_type = AZ_STR(\"Data\");\n \
AzRefAny data = AzRefAny_newC((AzGlVoidPtrConst){{ .ptr = NULL }}, 0, 1, 0, data_type, NULL, 0, 0);\n \
AzApp app = AzApp_create(data, AzAppConfig_create());\n \
AzWindowCreateOptions window = AzWindowCreateOptions_create(render);\n \
AzApp_run(&app, window);\n \
AzApp_delete(&app);\n \
return 0;\n}}\n"
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dom::{Dom, NodeType};
#[test]
fn test_inline_span_parsing() {
// This test verifies that HTML with inline spans is parsed correctly
// The DOM structure should preserve text nodes before, inside, and after the span
let html = r#"<p>Text before <span class="highlight">inline text</span> text after.</p>"#;
// Expected DOM structure:
// <p>
// ├─ TextNode: "Text before "
// ├─ <span class="highlight">
// │ └─ TextNode: "inline text"
// └─ TextNode: " text after."
// For this test, we'll create the DOM structure manually
// since we're testing the parsing logic
let expected_dom = Dom::create_p().with_children(
vec![
Dom::create_text("Text before "),
Dom::create_node(NodeType::Span)
.with_children(vec![Dom::create_text("inline text")].into()),
Dom::create_text(" text after."),
]
.into(),
);
// Verify the structure has 3 children at the top level
assert_eq!(expected_dom.children.as_ref().len(), 3);
// Verify the middle child is a span
match &expected_dom.children.as_ref()[1].root.node_type {
NodeType::Span => {}
other => panic!("Expected Span, got {:?}", other),
}
// Verify the span has 1 child (the text node)
assert_eq!(expected_dom.children.as_ref()[1].children.as_ref().len(), 1);
println!("Test passed: Inline span parsing structure is correct");
}
#[test]
fn test_xml_node_structure() {
// Test the basic XmlNode structure to ensure text content is preserved
// Updated to use XmlNodeChild enum (Text/Element)
let node = XmlNode {
node_type: "p".into(),
attributes: XmlAttributeMap {
inner: StringPairVec::from_const_slice(&[]),
},
children: vec![
XmlNodeChild::Text("Before ".into()),
XmlNodeChild::Element(XmlNode {
node_type: "span".into(),
children: vec![XmlNodeChild::Text("inline".into())].into(),
..Default::default()
}),
XmlNodeChild::Text(" after".into()),
]
.into(),
};
// Verify structure
assert_eq!(node.children.as_ref().len(), 3);
assert_eq!(node.children.as_ref()[0].as_text(), Some("Before "));
assert_eq!(
node.children.as_ref()[1]
.as_element()
.unwrap()
.node_type
.as_str(),
"span"
);
assert_eq!(node.children.as_ref()[2].as_text(), Some(" after"));
// Verify span's child
let span = node.children.as_ref()[1].as_element().unwrap();
assert_eq!(span.children.as_ref().len(), 1);
assert_eq!(span.children.as_ref()[0].as_text(), Some("inline"));
println!("Test passed: XmlNode structure preserves text nodes correctly");
}
#[test]
fn test_img_tag_becomes_image_node_with_src_tag() {
// `<img src="cat.jpg" width="300" height="169">` must become a
// `NodeType::Image` whose `NullImage` carries the `src` string as its
// `tag` (so a renderer can resolve the bytes later), plus the declared
// intrinsic size.
use crate::resources::DecodedImage;
use crate::window::{AzStringPair, StringPairVec};
let img_node = XmlNode {
node_type: "img".into(),
attributes: XmlAttributeMap::from(StringPairVec::from_vec(alloc::vec![
AzStringPair {
key: "src".into(),
value: "cat.jpg".into()
},
AzStringPair {
key: "width".into(),
value: "300".into()
},
AzStringPair {
key: "height".into(),
value: "169".into()
},
])),
children: Vec::new().into(),
};
let component_map = ComponentMap::default();
let dom = xml_node_to_dom_fast(&img_node, &component_map, false, 0)
.expect("xml_node_to_dom_fast for <img> should succeed");
match dom.root.get_node_type() {
NodeType::Image(image_ref) => match image_ref.as_ref().get_data() {
DecodedImage::NullImage {
tag, width, height, ..
} => {
assert_eq!(
core::str::from_utf8(tag).unwrap(),
"cat.jpg",
"image tag must carry the src string"
);
assert_eq!(*width, 300, "width attribute should set intrinsic width");
assert_eq!(*height, 169, "height attribute should set intrinsic height");
}
other => panic!("expected NullImage carrying the src tag, got {:?}", other),
},
other => panic!("expected NodeType::Image for <img>, got {:?}", other),
}
println!("Test passed: <img src=\"cat.jpg\"> -> NodeType::Image tagged \"cat.jpg\"");
}
#[test]
fn test_tag_to_node_type_img_is_image() {
// The bare tag mapping should also yield an Image (placeholder, empty tag).
match tag_to_node_type("img") {
NodeType::Image(_) => {}
other => panic!("tag_to_node_type(\"img\") should be Image, got {:?}", other),
}
}
/// Build a `<div>` nested `depth` levels deep, innermost first.
fn nested_divs(depth: usize) -> XmlNode {
let mut node = XmlNode {
node_type: "div".into(),
..Default::default()
};
for _ in 0..depth {
node = XmlNode {
node_type: "div".into(),
children: vec![XmlNodeChild::Element(node)].into(),
..Default::default()
};
}
node
}
/// AUDIT 2026-07-08: `extract_css_urls` used to slice the original string with
/// a byte offset computed in a `to_lowercase()` temporary. On `'İ'` (whose
/// lowercase is longer in bytes) that offset was misaligned. This must no
/// longer panic and must still find the `@import` target.
#[test]
fn extract_css_urls_unicode_import_no_panic() {
let mut res = Vec::new();
Xml::extract_css_urls("İ@import 'x'", &mut res);
assert_eq!(res.len(), 1, "should find the one @import target");
assert_eq!(res[0].url.as_str(), "x");
}
/// The `url(` and `@import` scans are case-insensitive after the audit fix.
#[test]
fn extract_css_urls_is_case_insensitive() {
let mut res = Vec::new();
Xml::extract_css_urls("body { background: URL(http://e.com/a.png); }", &mut res);
assert_eq!(res.len(), 1);
assert_eq!(res[0].url.as_str(), "http://e.com/a.png");
let mut res2 = Vec::new();
Xml::extract_css_urls("@IMPORT \"theme.css\";", &mut res2);
assert_eq!(res2.len(), 1);
assert_eq!(res2[0].url.as_str(), "theme.css");
}
/// AUDIT 2026-07-08: the resource scan recurses per nesting level; deep markup
/// must not overflow the stack (deeper-than-cap subtrees are just not scanned).
#[test]
fn scan_external_resources_deep_nesting_ok() {
let xml = Xml {
root: vec![XmlNodeChild::Element(nested_divs(2000))].into(),
};
// Must simply return (no stack overflow); no resources in a plain tree.
drop(xml.scan_external_resources());
}
/// AUDIT 2026-07-08: the fast + tree DOM builders recurse per nesting level;
/// deep markup must not overflow the stack (children beyond the cap are
/// dropped, but the call returns `Ok`).
#[test]
fn xml_node_to_dom_fast_deep_nesting_ok() {
let deep = nested_divs(2000);
let component_map = ComponentMap::default();
let dom = xml_node_to_dom_fast(&deep, &component_map, false, 0);
assert!(dom.is_ok(), "deep DOM build must not overflow the stack");
let mut builder = CompactDomBuilder::new();
let fast = xml_node_to_fast_dom(&deep, &component_map, false, &mut builder, 0);
assert!(fast.is_ok(), "deep FastDom build must not overflow the stack");
}
/// AUDIT 2026-07-08: `ComponentFieldType::parse` recurses through `Option<..>`
/// / `Vec<..>` wrappers; an over-deep type string is rejected rather than
/// overflowing the stack, while ordinary nesting still parses.
#[test]
fn component_field_type_parse_depth_capped() {
let deep = format!("{}Bool{}", "Option<".repeat(4000), ">".repeat(4000));
assert!(
ComponentFieldType::parse(&deep).is_none(),
"over-deep type string must be rejected, not overflow"
);
let shallow = format!("{}Bool{}", "Option<".repeat(8), ">".repeat(8));
assert!(
ComponentFieldType::parse(&shallow).is_some(),
"ordinary nesting must still parse"
);
}
/// AUDIT 2026-07-08: `prepare_string` now decodes the full common entity set
/// plus numeric references, in a single pass so `&` cannot double-decode.
#[test]
fn prepare_string_entity_decoding() {
assert_eq!(prepare_string("a & b"), "a & b");
// `&lt;` must yield the literal text "<", not "<".
assert_eq!(prepare_string("&lt;"), "<");
assert_eq!(prepare_string(""hi""), "\"hi\"");
assert_eq!(prepare_string("AB"), "AB");
assert_eq!(prepare_string("A"), "A");
// Existing behavior preserved.
assert_eq!(prepare_string("<tag>"), "<tag>");
}
}
#[cfg(test)]
#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
mod autotest_generated {
use super::*;
use crate::dom::{NodeData, NodeType};
use azul_css::css::{CssNthChildPattern, CssNthChildSelector};
// ----------------------------------------------------------------- helpers
fn attrs(kv: &[(&str, &str)]) -> XmlAttributeMap {
XmlAttributeMap::from(StringPairVec::from_vec(
kv.iter()
.map(|(k, v)| AzStringPair {
key: AzString::from(*k),
value: AzString::from(*v),
})
.collect::<Vec<_>>(),
))
}
fn node(tag: &str, kv: &[(&str, &str)], children: Vec<XmlNodeChild>) -> XmlNode {
XmlNode {
node_type: tag.into(),
attributes: attrs(kv),
children: children.into(),
}
}
fn txt(s: &str) -> XmlNodeChild {
XmlNodeChild::Text(AzString::from(s))
}
fn elem(n: XmlNode) -> XmlNodeChild {
XmlNodeChild::Element(n)
}
/// `<html><head><style>{css}</style></head><body>{children}</body></html>`
fn doc(css: &str, body_children: Vec<XmlNodeChild>) -> Vec<XmlNodeChild> {
let style = node("style", &[], vec![txt(css)]);
let head = node("head", &[], vec![elem(style)]);
let body = node("body", &[], body_children);
vec![elem(node("html", &[], vec![elem(head), elem(body)]))]
}
fn no_args() -> ComponentArgumentVec {
ComponentArgumentVec::from_const_slice(&[])
}
fn dm(name: &str, fields: Vec<ComponentDataField>) -> ComponentDataModel {
ComponentDataModel {
name: AzString::from(name),
description: AzString::from_const_str(""),
fields: fields.into(),
}
}
fn user_def(css: &str, fields: Vec<ComponentDataField>) -> ComponentDef {
ComponentDef {
id: ComponentId::new("mylib", "widget"),
display_name: AzString::from_const_str("Widget"),
description: AzString::from_const_str(""),
css: AzString::from(css),
source: ComponentSource::UserDefined,
data_model: dm("WidgetData", fields),
render_fn: user_defined_render_fn,
compile_fn: user_defined_compile_fn,
render_fn_source: None.into(),
compile_fn_source: None.into(),
}
}
/// A string that is long enough to smoke out O(n^2) / allocation blowups but
/// still finishes fast in a debug-profile test run.
const LONG: usize = 200_000;
// ================================================================
// Xml::extract_url_value (parser)
// ================================================================
#[test]
fn extract_url_value_empty_and_whitespace() {
assert_eq!(Xml::extract_url_value(""), None);
assert_eq!(Xml::extract_url_value(" "), None);
assert_eq!(Xml::extract_url_value("\t\n\r "), None);
}
#[test]
fn extract_url_value_valid_minimal() {
assert_eq!(
Xml::extract_url_value("a.png)"),
Some("a.png".to_string()),
"unquoted url terminated by ')'"
);
assert_eq!(
Xml::extract_url_value("\"a.png\")"),
Some("a.png".to_string()),
"double-quoted url"
);
assert_eq!(
Xml::extract_url_value("'a.png')"),
Some("a.png".to_string()),
"single-quoted url"
);
}
#[test]
fn extract_url_value_leading_trailing_junk_is_trimmed() {
// Leading whitespace is trimmed by `trim_start`, inner padding by `trim`.
assert_eq!(
Xml::extract_url_value(" a.png )tail"),
Some("a.png".to_string())
);
}
#[test]
fn extract_url_value_garbage_returns_none() {
// Unterminated quote / no closing paren => None, never a panic.
assert_eq!(Xml::extract_url_value("\"unterminated"), None);
assert_eq!(Xml::extract_url_value("'unterminated"), None);
assert_eq!(Xml::extract_url_value("no-closing-paren"), None);
assert_eq!(Xml::extract_url_value("\u{0}\u{1}\u{7f}"), None);
}
#[test]
fn extract_url_value_boundary_numbers() {
for s in [
"0)",
"-0)",
"9223372036854775807)",
"-9223372036854775808)",
"NaN)",
"inf)",
"1e400)",
] {
let got = Xml::extract_url_value(s);
assert!(got.is_some(), "numeric-looking url {s:?} is still a url");
}
assert_eq!(Xml::extract_url_value("0)"), Some("0".to_string()));
}
#[test]
fn extract_url_value_unicode_no_panic() {
// The ')' scan must land on a char boundary of the ORIGINAL string.
assert_eq!(
Xml::extract_url_value("\u{1F600}\u{0301})"),
Some("\u{1F600}\u{0301}".to_string())
);
assert_eq!(Xml::extract_url_value("\"\u{130}\")"), Some("\u{130}".to_string()));
assert_eq!(Xml::extract_url_value("\u{1F600}"), None);
}
#[test]
fn extract_url_value_extremely_long_terminates() {
let s = "a".repeat(LONG);
assert_eq!(Xml::extract_url_value(&s), None, "no ')' anywhere => None");
let s2 = format!("{})", "b".repeat(LONG));
assert_eq!(Xml::extract_url_value(&s2).map(|v| v.len()), Some(LONG));
}
#[test]
fn extract_url_value_nested_brackets_no_stack_overflow() {
// Not recursive, but confirm deeply "nested" input is handled iteratively.
let s = "(".repeat(10_000);
assert_eq!(Xml::extract_url_value(&s), None);
let s2 = format!("{}{}", "(".repeat(10_000), ")");
assert_eq!(Xml::extract_url_value(&s2), Some("(".repeat(10_000)));
}
// ================================================================
// Xml::extract_quoted_string (parser)
// ================================================================
#[test]
fn extract_quoted_string_empty_whitespace_garbage() {
assert_eq!(Xml::extract_quoted_string(""), None);
assert_eq!(Xml::extract_quoted_string(" "), None);
assert_eq!(Xml::extract_quoted_string("\t\n"), None);
assert_eq!(Xml::extract_quoted_string("bare"), None);
// Leading whitespace is NOT trimmed here (unlike extract_url_value).
assert_eq!(Xml::extract_quoted_string(" \"x\""), None);
}
#[test]
fn extract_quoted_string_valid_minimal_and_empty_quotes() {
assert_eq!(Xml::extract_quoted_string("\"x\""), Some("x".to_string()));
assert_eq!(Xml::extract_quoted_string("'x'"), Some("x".to_string()));
// An empty quoted string is Some(""), not None.
assert_eq!(Xml::extract_quoted_string("\"\""), Some(String::new()));
assert_eq!(Xml::extract_quoted_string("''"), Some(String::new()));
}
#[test]
fn extract_quoted_string_unterminated_is_none() {
assert_eq!(Xml::extract_quoted_string("\"abc"), None);
assert_eq!(Xml::extract_quoted_string("'abc"), None);
// Mismatched quotes do not pair up.
assert_eq!(Xml::extract_quoted_string("\"abc'"), None);
}
#[test]
fn extract_quoted_string_boundary_numbers_and_unicode() {
assert_eq!(Xml::extract_quoted_string("\"0\""), Some("0".to_string()));
assert_eq!(Xml::extract_quoted_string("\"-0\""), Some("-0".to_string()));
assert_eq!(Xml::extract_quoted_string("\"NaN\""), Some("NaN".to_string()));
assert_eq!(
Xml::extract_quoted_string("\"\u{1F600}\u{0301}\""),
Some("\u{1F600}\u{0301}".to_string())
);
}
#[test]
fn extract_quoted_string_extremely_long_terminates() {
let unterminated = format!("\"{}", "x".repeat(LONG));
assert_eq!(Xml::extract_quoted_string(&unterminated), None);
let terminated = format!("\"{}\"", "x".repeat(LONG));
assert_eq!(
Xml::extract_quoted_string(&terminated).map(|s| s.len()),
Some(LONG)
);
}
// ================================================================
// Xml::parse_srcset (parser)
// ================================================================
#[test]
fn parse_srcset_empty_and_whitespace_yield_no_urls() {
assert!(Xml::parse_srcset("").is_empty());
assert!(Xml::parse_srcset(" ").is_empty());
assert!(Xml::parse_srcset("\t\n").is_empty());
assert!(Xml::parse_srcset(",,,").is_empty(), "all-empty entries dropped");
}
#[test]
fn parse_srcset_valid_minimal() {
assert_eq!(
Xml::parse_srcset("a.png 1x, b.png 2x"),
vec!["a.png".to_string(), "b.png".to_string()]
);
// No descriptor at all is still a valid single entry.
assert_eq!(Xml::parse_srcset("a.png"), vec!["a.png".to_string()]);
}
#[test]
fn parse_srcset_garbage_and_boundary_numbers() {
assert_eq!(Xml::parse_srcset("0, -0, NaN"), vec!["0", "-0", "NaN"]);
// Garbage bytes still round out to "first whitespace-delimited token".
assert_eq!(Xml::parse_srcset("\u{0}\u{7f} 1x"), vec!["\u{0}\u{7f}".to_string()]);
}
#[test]
fn parse_srcset_unicode_no_panic() {
assert_eq!(
Xml::parse_srcset("\u{1F600}.png 1x, \u{130}.png 2x"),
vec!["\u{1F600}.png".to_string(), "\u{130}.png".to_string()]
);
}
#[test]
fn parse_srcset_extremely_long_terminates() {
let s = "a.png 1x,".repeat(20_000);
assert_eq!(Xml::parse_srcset(&s).len(), 20_000);
let one_huge = "a".repeat(LONG);
assert_eq!(Xml::parse_srcset(&one_huge).len(), 1);
}
// ================================================================
// Xml::looks_like_resource / guess_kind_from_url / guess_mime_from_url
// ================================================================
#[test]
fn looks_like_resource_edges() {
assert!(!Xml::looks_like_resource(""));
assert!(!Xml::looks_like_resource(" "));
assert!(!Xml::looks_like_resource("/about"));
assert!(Xml::looks_like_resource("/a.PNG"), "case-insensitive");
assert!(Xml::looks_like_resource("x.pdf"));
// A query string defeats the extension check (documented consequence of
// matching on `ends_with`).
assert!(!Xml::looks_like_resource("x.png?v=1"));
assert!(!Xml::looks_like_resource(&"a".repeat(LONG)));
}
#[test]
fn guess_kind_from_url_covers_every_bucket() {
use ExternalResourceKind::*;
assert_eq!(Xml::guess_kind_from_url(""), Unknown);
assert_eq!(Xml::guess_kind_from_url("a.PNG"), Image);
assert_eq!(Xml::guess_kind_from_url("a.woff2"), Font);
assert_eq!(Xml::guess_kind_from_url("a.css"), Stylesheet);
assert_eq!(Xml::guess_kind_from_url("a.mjs"), Script);
assert_eq!(Xml::guess_kind_from_url("a.webm"), Video);
assert_eq!(Xml::guess_kind_from_url("a.flac"), Audio);
assert_eq!(Xml::guess_kind_from_url("a.ico"), Icon);
// Query strings ARE stripped here (unlike looks_like_resource).
assert_eq!(Xml::guess_kind_from_url("a.png?v=1"), Image);
assert_eq!(Xml::guess_kind_from_url("\u{1F600}"), Unknown);
}
#[test]
fn guess_mime_from_url_empty_and_garbage() {
assert_eq!(Xml::guess_mime_from_url("", ""), None);
assert_eq!(Xml::guess_mime_from_url(" ", ""), None);
assert_eq!(Xml::guess_mime_from_url("\u{0}\u{7f}", ""), None);
assert_eq!(Xml::guess_mime_from_url("\u{1F600}", ""), None);
}
#[test]
fn guess_mime_from_url_valid_minimal_and_category_fallback() {
let m = Xml::guess_mime_from_url("a.PNG", "").expect("png is a known extension");
assert_eq!(m.inner.as_str(), "image/png");
let m = Xml::guess_mime_from_url("a.png?v=1", "").expect("query string stripped");
assert_eq!(m.inner.as_str(), "image/png");
// Unknown extension + a category hint => the category wildcard.
let m = Xml::guess_mime_from_url("/no-ext", "image").expect("category fallback");
assert_eq!(m.inner.as_str(), "image/*");
// Unknown category => None.
assert_eq!(Xml::guess_mime_from_url("/no-ext", "bogus"), None);
}
#[test]
fn guess_mime_from_url_boundary_numbers_and_long() {
assert_eq!(Xml::guess_mime_from_url("0", ""), None);
assert_eq!(Xml::guess_mime_from_url("-0", ""), None);
assert_eq!(Xml::guess_mime_from_url("NaN", ""), None);
assert_eq!(Xml::guess_mime_from_url("inf", ""), None);
let long = format!("{}.png", "a".repeat(LONG));
assert_eq!(
Xml::guess_mime_from_url(&long, "").map(|m| m.inner.as_str().to_string()),
Some("image/png".to_string())
);
}
// ================================================================
// Xml::extract_css_urls / scan_node / scan_external_resources
// ================================================================
#[test]
fn extract_css_urls_empty_and_garbage_no_panic() {
let mut v = Vec::new();
Xml::extract_css_urls("", &mut v);
Xml::extract_css_urls(" ", &mut v);
Xml::extract_css_urls("\u{0}\u{7f}\u{1F600}", &mut v);
Xml::extract_css_urls("url(", &mut v);
Xml::extract_css_urls("@import", &mut v);
Xml::extract_css_urls("@import url(", &mut v);
assert!(v.is_empty(), "no well-formed url in any of those inputs");
}
#[test]
fn extract_css_urls_valid_minimal() {
let mut v = Vec::new();
Xml::extract_css_urls("a { background: url('x.png'); }", &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].url.as_str(), "x.png");
assert_eq!(v[0].kind, ExternalResourceKind::Image);
assert_eq!(v[0].source_attribute.as_str(), "url()");
}
#[test]
fn extract_css_urls_import_is_tagged_as_stylesheet() {
let mut v = Vec::new();
Xml::extract_css_urls("@import url(theme.css);", &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].url.as_str(), "theme.css");
assert_eq!(v[0].kind, ExternalResourceKind::Stylesheet);
assert_eq!(v[0].source_attribute.as_str(), "@import");
}
#[test]
fn extract_css_urls_multibyte_before_url_no_panic() {
// ASCII-only lowercasing keeps byte offsets 1:1 with the original.
let mut v = Vec::new();
Xml::extract_css_urls("\u{130}\u{1F600} URL(\"a.css\") \u{0301}", &mut v);
assert_eq!(v.len(), 1);
assert_eq!(v[0].url.as_str(), "a.css");
}
#[test]
fn extract_css_urls_extremely_long_terminates() {
// Each iteration advances search_from past the "url(" it just matched, so
// this must terminate (and not spin).
let mut v = Vec::new();
Xml::extract_css_urls(&"url(".repeat(2_000), &mut v);
// No ')' anywhere => nothing extractable, but the scan still terminates.
assert!(v.is_empty());
let mut v2 = Vec::new();
Xml::extract_css_urls(&"url(a.png)".repeat(2_000), &mut v2);
assert_eq!(v2.len(), 2_000);
}
#[test]
fn scan_node_on_empty_and_extreme_nodes_no_panic() {
let mut v = Vec::new();
Xml::scan_node(&XmlNode::default(), &mut v);
Xml::scan_node(&node("", &[], vec![]), &mut v);
Xml::scan_node(&node(&"a".repeat(10_000), &[("style", "url(x.png)")], vec![]), &mut v);
assert_eq!(v.len(), 1, "only the inline style url()");
assert_eq!(v[0].url.as_str(), "x.png");
}
#[test]
fn scan_node_img_srcset_and_background() {
let mut v = Vec::new();
Xml::scan_node(
&node(
"IMG",
&[("src", "a.png"), ("srcset", "b.png 1x, c.png 2x"), ("background", "d.gif")],
vec![],
),
&mut v,
);
let urls: Vec<&str> = v.iter().map(|r| r.url.as_str()).collect();
assert_eq!(urls, vec!["a.png", "b.png", "c.png", "d.gif"]);
assert!(v.iter().all(|r| r.kind == ExternalResourceKind::Image));
}
#[test]
fn scan_external_resources_on_empty_document() {
let xml = Xml {
root: Vec::new().into(),
};
assert_eq!(xml.scan_external_resources().as_ref().len(), 0);
}
#[test]
fn scan_external_resources_finds_every_element_kind() {
let xml = Xml {
root: vec![
elem(node("img", &[("src", "i.png")], vec![])),
elem(node("link", &[("href", "s.css"), ("rel", "stylesheet")], vec![])),
elem(node("script", &[("src", "s.js")], vec![])),
elem(node("video", &[("src", "v.mp4"), ("poster", "p.jpg")], vec![])),
elem(node("audio", &[("src", "a.mp3")], vec![])),
elem(node("a", &[("href", "f.pdf")], vec![])),
elem(node("a", &[("href", "/page")], vec![])),
]
.into(),
};
let res = xml.scan_external_resources();
let mut urls: Vec<&str> = res.as_ref().iter().map(|r| r.url.as_str()).collect();
urls.sort_unstable();
assert_eq!(
urls,
vec!["a.mp3", "f.pdf", "i.png", "p.jpg", "s.css", "s.js", "v.mp4"],
"`/page` is not a resource and must be skipped"
);
}
// ================================================================
// MimeTypeHint (constructor)
// ================================================================
#[test]
fn mime_type_hint_new_no_panic_and_fields_match_args() {
for s in ["", " ", "text/css", "\u{1F600}", "\u{0}"] {
assert_eq!(MimeTypeHint::new(s).inner.as_str(), s, "new() stores verbatim");
}
let long = "x".repeat(LONG);
assert_eq!(MimeTypeHint::new(&long).inner.as_str().len(), LONG);
}
#[test]
fn mime_type_hint_from_extension_edges() {
assert_eq!(
MimeTypeHint::from_extension("").inner.as_str(),
"application/octet-stream"
);
assert_eq!(
MimeTypeHint::from_extension("PnG").inner.as_str(),
"image/png",
"extension match is case-insensitive"
);
assert_eq!(MimeTypeHint::from_extension("jpeg").inner.as_str(), "image/jpeg");
assert_eq!(MimeTypeHint::from_extension("woff2").inner.as_str(), "font/woff2");
assert_eq!(
MimeTypeHint::from_extension("\u{1F600}").inner.as_str(),
"application/octet-stream"
);
assert_eq!(
MimeTypeHint::from_extension(&"z".repeat(LONG)).inner.as_str(),
"application/octet-stream"
);
}
// ================================================================
// ComponentId (constructor / getter)
// ================================================================
#[test]
fn component_id_builtin_and_new_invariants() {
let b = ComponentId::builtin("div");
assert_eq!(b.collection.as_str(), "builtin");
assert_eq!(b.name.as_str(), "div");
let c = ComponentId::new("", "");
assert_eq!(c.collection.as_str(), "");
assert_eq!(c.name.as_str(), "");
let u = ComponentId::new("\u{1F600}", "\u{130}");
assert_eq!(u.collection.as_str(), "\u{1F600}");
assert_eq!(u.name.as_str(), "\u{130}");
}
#[test]
fn component_id_qualified_name_roundtrips_through_the_map_lookup() {
assert_eq!(ComponentId::builtin("div").qualified_name(), "builtin:div");
assert_eq!(ComponentId::new("", "").qualified_name(), ":");
// A name that itself contains ':' makes the qualified name ambiguous —
// pin the (lossy) behavior so a change is noticed.
assert_eq!(ComponentId::new("a", "b:c").qualified_name(), "a:b:c");
}
// ================================================================
// ComponentFieldTypeBox / ComponentFieldValueBox (constructor / getter)
// ================================================================
#[test]
fn component_field_type_box_new_as_ref_and_clone() {
let b = ComponentFieldTypeBox::new(ComponentFieldType::Bool);
assert!(!b.ptr.is_null());
assert_eq!(*b.as_ref(), ComponentFieldType::Bool);
let c = b.clone();
assert_eq!(*c.as_ref(), ComponentFieldType::Bool);
assert_ne!(b.ptr, c.ptr, "clone must deep-copy, not alias");
assert_eq!(b, c, "PartialEq compares pointees");
drop(c);
assert_eq!(*b.as_ref(), ComponentFieldType::Bool, "original survives");
}
#[test]
fn component_field_type_box_nested_deeply_drops_cleanly() {
let mut t = ComponentFieldType::Bool;
for _ in 0..64 {
t = ComponentFieldType::OptionType(ComponentFieldTypeBox::new(t));
}
assert_eq!(t.format(), format!("{}Bool{}", "Option<".repeat(64), ">".repeat(64)));
drop(t);
}
#[test]
fn component_field_value_box_new_as_ref_and_clone() {
let v = ComponentFieldValueBox::new(ComponentFieldValue::I32(i32::MIN));
assert!(!v.ptr.is_null());
assert_eq!(*v.as_ref(), ComponentFieldValue::I32(i32::MIN));
let c = v.clone();
assert_ne!(v.ptr, c.ptr);
assert_eq!(v, c);
}
// ================================================================
// ComponentFieldType::parse / parse_depth / format (round-trip)
// ================================================================
#[test]
fn component_field_type_parse_empty_whitespace_garbage() {
assert_eq!(ComponentFieldType::parse(""), None);
assert_eq!(ComponentFieldType::parse(" "), None);
assert_eq!(ComponentFieldType::parse("\t\n"), None);
assert_eq!(ComponentFieldType::parse("lowercase"), None);
assert_eq!(ComponentFieldType::parse("\u{0}\u{7f}"), None);
assert_eq!(ComponentFieldType::parse("Option<>"), None, "empty inner rejected");
assert_eq!(ComponentFieldType::parse("Vec<>"), None);
assert_eq!(ComponentFieldType::parse("Option<lowercase>"), None);
}
#[test]
fn component_field_type_parse_valid_minimal_and_trimming() {
assert_eq!(ComponentFieldType::parse("String"), Some(ComponentFieldType::String));
assert_eq!(
ComponentFieldType::parse(" String "),
Some(ComponentFieldType::String),
"leading/trailing whitespace is trimmed"
);
assert_eq!(ComponentFieldType::parse("bool"), Some(ComponentFieldType::Bool));
assert_eq!(ComponentFieldType::parse("usize"), Some(ComponentFieldType::Usize));
assert_eq!(
ComponentFieldType::parse("StructRef(Foo)"),
Some(ComponentFieldType::StructRef(AzString::from("Foo")))
);
assert_eq!(
ComponentFieldType::parse("EnumRef(Foo)"),
Some(ComponentFieldType::EnumRef(AzString::from("Foo")))
);
assert_eq!(
ComponentFieldType::parse("RefAny"),
Some(ComponentFieldType::RefAny(AzString::from("")))
);
}
#[test]
fn component_field_type_parse_leading_trailing_junk_is_rejected_or_absorbed() {
// Trailing junk after a known keyword falls through to the
// "starts uppercase => StructRef" catch-all rather than being rejected.
assert_eq!(
ComponentFieldType::parse("String;garbage"),
Some(ComponentFieldType::StructRef(AzString::from("String;garbage")))
);
// Lowercase junk has no uppercase first char => rejected.
assert_eq!(ComponentFieldType::parse("string;garbage"), None);
}
#[test]
fn component_field_type_parse_boundary_numbers() {
for s in ["0", "-0", "9223372036854775807", "-9223372036854775808", "1e400", "inf"] {
assert_eq!(
ComponentFieldType::parse(s),
None,
"numeric literal {s:?} is not a type name"
);
}
// ...but anything starting with an uppercase letter hits the StructRef
// catch-all, so "NaN" parses as a struct reference rather than failing.
assert_eq!(
ComponentFieldType::parse("NaN"),
Some(ComponentFieldType::StructRef(AzString::from("NaN")))
);
}
#[test]
fn component_field_type_parse_unicode_no_panic() {
assert_eq!(ComponentFieldType::parse("\u{1F600}"), None, "emoji is not uppercase");
// A real uppercase non-ASCII letter hits the StructRef catch-all.
assert_eq!(
ComponentFieldType::parse("\u{0391}bc"),
Some(ComponentFieldType::StructRef(AzString::from("\u{0391}bc")))
);
}
#[test]
fn component_field_type_parse_depth_boundary_is_exact() {
// MAX_TYPE_PARSE_DEPTH wrappers parse; one more is rejected.
let ok = format!(
"{}Bool{}",
"Option<".repeat(MAX_TYPE_PARSE_DEPTH),
">".repeat(MAX_TYPE_PARSE_DEPTH)
);
assert!(
ComponentFieldType::parse(&ok).is_some(),
"exactly MAX_TYPE_PARSE_DEPTH wrappers must still parse"
);
let too_deep = format!(
"{}Bool{}",
"Option<".repeat(MAX_TYPE_PARSE_DEPTH + 1),
">".repeat(MAX_TYPE_PARSE_DEPTH + 1)
);
assert_eq!(
ComponentFieldType::parse(&too_deep),
None,
"one wrapper past the cap must be rejected, not overflow"
);
}
#[test]
fn component_field_type_parse_depth_direct_call_honors_start_depth() {
assert_eq!(
ComponentFieldType::parse_depth("Bool", MAX_TYPE_PARSE_DEPTH),
Some(ComponentFieldType::Bool),
"depth == cap is still allowed"
);
assert_eq!(
ComponentFieldType::parse_depth("Bool", MAX_TYPE_PARSE_DEPTH + 1),
None
);
assert_eq!(
ComponentFieldType::parse_depth("Bool", usize::MAX),
None,
"usize::MAX start depth must not overflow, just refuse"
);
}
#[test]
fn component_field_type_parse_nested_recursion_does_not_stack_overflow() {
let bomb = format!("{}Bool{}", "Vec<".repeat(50_000), ">".repeat(50_000));
assert_eq!(ComponentFieldType::parse(&bomb), None);
}
#[test]
fn component_field_type_parse_extremely_long_terminates() {
// A single 200k-char uppercase token becomes a StructRef of that name.
let long = format!("A{}", "b".repeat(LONG));
assert_eq!(
ComponentFieldType::parse(&long),
Some(ComponentFieldType::StructRef(AzString::from(long.as_str())))
);
}
#[test]
fn component_field_type_round_trip_representative() {
let representative = vec![
ComponentFieldType::String,
ComponentFieldType::Bool,
ComponentFieldType::I32,
ComponentFieldType::I64,
ComponentFieldType::U32,
ComponentFieldType::U64,
ComponentFieldType::Usize,
ComponentFieldType::F32,
ComponentFieldType::F64,
ComponentFieldType::ColorU,
ComponentFieldType::CssProperty,
ComponentFieldType::ImageRef,
ComponentFieldType::FontRef,
ComponentFieldType::StyledDom,
ComponentFieldType::StructRef(AzString::from("Foo")),
ComponentFieldType::OptionType(ComponentFieldTypeBox::new(ComponentFieldType::Bool)),
ComponentFieldType::VecType(ComponentFieldTypeBox::new(ComponentFieldType::I32)),
ComponentFieldType::RefAny(AzString::from("")),
ComponentFieldType::RefAny(AzString::from("Hint")),
ComponentFieldType::Callback(ComponentCallbackSignature {
return_type: AzString::from("Update"),
args: Vec::new().into(),
}),
];
for x in representative {
let s = x.format();
assert_eq!(
ComponentFieldType::parse(&s),
Some(x.clone()),
"parse(format({x:?})) must round-trip"
);
}
}
#[test]
fn component_field_type_round_trip_edge_values() {
// Empty / unicode-bearing payloads.
for x in [
ComponentFieldType::StructRef(AzString::from("\u{0391}\u{1F600}")),
ComponentFieldType::OptionType(ComponentFieldTypeBox::new(
ComponentFieldType::VecType(ComponentFieldTypeBox::new(
ComponentFieldType::StructRef(AzString::from("Foo")),
)),
)),
] {
assert_eq!(ComponentFieldType::parse(&x.format()), Some(x.clone()));
}
}
#[test]
fn component_field_type_format_is_an_idempotent_normalization() {
// `EnumRef` and `StructRef` share the same canonical spelling, so parse()
// collapses EnumRef -> StructRef. The normalization is still STABLE:
// format(parse(format(x))) == format(x).
let e = ComponentFieldType::EnumRef(AzString::from("Role"));
let once = e.format();
assert_eq!(once, "Role");
let reparsed = ComponentFieldType::parse(&once).expect("parses");
assert_eq!(
reparsed,
ComponentFieldType::StructRef(AzString::from("Role")),
"EnumRef is lossy through format() — it comes back as StructRef"
);
assert_eq!(reparsed.format(), once, "but the normalization is idempotent");
}
#[test]
fn component_field_type_display_matches_format() {
let t = ComponentFieldType::OptionType(ComponentFieldTypeBox::new(ComponentFieldType::F64));
assert_eq!(format!("{t}"), t.format());
assert_eq!(format!("{t}"), "Option<F64>");
}
#[test]
fn component_field_type_format_no_panic_on_empty_payloads() {
let t = ComponentFieldType::Callback(ComponentCallbackSignature {
return_type: AzString::from(""),
args: Vec::new().into(),
});
assert_eq!(t.format(), "Callback()");
// Callback() with an empty signature round-trips.
assert_eq!(ComponentFieldType::parse("Callback()"), Some(t));
}
// ================================================================
// ComponentFieldNamedValueVec::get_field / get_string (parser-ish lookup)
// ================================================================
fn named(name: &str, v: ComponentFieldValue) -> ComponentFieldNamedValue {
ComponentFieldNamedValue {
name: AzString::from(name),
value: v,
}
}
fn named_vec() -> ComponentFieldNamedValueVec {
vec![
named("a", ComponentFieldValue::String(AzString::from("x"))),
named("b", ComponentFieldValue::Bool(true)),
named("", ComponentFieldValue::U64(u64::MAX)),
named("\u{1F600}", ComponentFieldValue::String(AzString::from("emoji"))),
]
.into()
}
#[test]
fn named_value_vec_get_field_valid_minimal() {
let v = named_vec();
assert_eq!(
v.get_field("a"),
Some(&ComponentFieldValue::String(AzString::from("x")))
);
assert_eq!(v.get_field("b"), Some(&ComponentFieldValue::Bool(true)));
}
#[test]
fn named_value_vec_get_field_empty_whitespace_garbage_unicode() {
let v = named_vec();
// An empty NAME is a legal key here — it matches the field literally named "".
assert_eq!(v.get_field(""), Some(&ComponentFieldValue::U64(u64::MAX)));
assert_eq!(v.get_field(" "), None);
assert_eq!(v.get_field("\t\n"), None);
assert_eq!(v.get_field("\u{0}\u{7f}"), None);
assert!(v.get_field("\u{1F600}").is_some());
assert_eq!(v.get_field(" a "), None, "no trimming: lookup is exact");
assert_eq!(v.get_field("a;garbage"), None);
}
#[test]
fn named_value_vec_get_field_on_empty_vec_and_long_key() {
let empty = ComponentFieldNamedValueVec::from_const_slice(&[]);
assert_eq!(empty.get_field("a"), None);
assert_eq!(empty.get_string("a"), None);
assert_eq!(named_vec().get_field(&"z".repeat(LONG)), None);
}
#[test]
fn named_value_vec_get_string_only_matches_string_variant() {
let v = named_vec();
assert_eq!(v.get_string("a").map(AzString::as_str), Some("x"));
assert_eq!(v.get_string("b"), None, "Bool is not a String");
assert_eq!(v.get_string(""), None, "U64 is not a String");
assert_eq!(v.get_string("missing"), None);
}
#[test]
fn named_value_vec_boundary_numeric_keys() {
let v: ComponentFieldNamedValueVec = vec![
named("0", ComponentFieldValue::I32(0)),
named("-0", ComponentFieldValue::I32(i32::MIN)),
named("9223372036854775807", ComponentFieldValue::I64(i64::MAX)),
named("NaN", ComponentFieldValue::F32(f32::NAN)),
]
.into();
assert_eq!(v.get_field("0"), Some(&ComponentFieldValue::I32(0)));
assert_eq!(v.get_field("-0"), Some(&ComponentFieldValue::I32(i32::MIN)));
assert_eq!(
v.get_field("9223372036854775807"),
Some(&ComponentFieldValue::I64(i64::MAX))
);
// NaN != NaN, so only check the variant, not equality.
assert!(matches!(v.get_field("NaN"), Some(ComponentFieldValue::F32(f)) if f.is_nan()));
}
// ================================================================
// ComponentDataModel::get_field / get_default_string / with_default
// ================================================================
fn model_with_text() -> ComponentDataModel {
dm(
"M",
vec![
data_field(
"text",
ComponentFieldType::String,
Some(ComponentDefaultValue::String(AzString::from("hi"))),
"",
),
data_field("count", ComponentFieldType::U32, Some(ComponentDefaultValue::U32(3)), ""),
data_field("required_one", ComponentFieldType::String, None, ""),
],
)
}
#[test]
fn data_model_get_field_valid_minimal_and_missing() {
let m = model_with_text();
assert!(m.get_field("text").is_some());
assert!(m.get_field("count").is_some());
assert!(m.get_field("missing").is_none());
assert!(m.get_field("").is_none());
assert!(m.get_field(" ").is_none());
assert!(m.get_field(" text ").is_none(), "exact match, no trimming");
assert!(m.get_field("\u{1F600}").is_none());
assert!(m.get_field(&"z".repeat(LONG)).is_none());
}
#[test]
fn data_model_get_field_on_empty_model() {
let m = dm("Empty", Vec::new());
assert!(m.get_field("anything").is_none());
assert!(m.get_default_string("anything").is_none());
}
#[test]
fn data_model_get_default_string_only_for_string_defaults() {
let m = model_with_text();
assert_eq!(m.get_default_string("text").map(AzString::as_str), Some("hi"));
assert_eq!(m.get_default_string("count"), None, "U32 default is not a String");
assert_eq!(m.get_default_string("required_one"), None, "no default at all");
assert_eq!(m.get_default_string("missing"), None);
}
#[test]
fn data_model_required_flag_follows_default_presence() {
let m = model_with_text();
assert!(!m.get_field("text").unwrap().required);
assert!(
m.get_field("required_one").unwrap().required,
"a field with no default must be marked required"
);
}
#[test]
fn data_model_with_default_overrides_and_preserves_len() {
let m = model_with_text();
let before = m.fields.as_ref().len();
let m = m.with_default("text", ComponentDefaultValue::String(AzString::from("bye")));
assert_eq!(m.fields.as_ref().len(), before, "len is preserved");
assert_eq!(m.get_default_string("text").map(AzString::as_str), Some("bye"));
}
#[test]
fn data_model_with_default_on_missing_field_is_a_no_op() {
let m = model_with_text();
let m = m.with_default("nope", ComponentDefaultValue::Bool(true));
assert_eq!(m.fields.as_ref().len(), 3);
assert_eq!(m.get_default_string("text").map(AzString::as_str), Some("hi"));
assert!(m.get_field("nope").is_none(), "no field is inserted");
}
#[test]
fn data_model_with_default_extreme_names_and_values_no_panic() {
let m = model_with_text()
.with_default("", ComponentDefaultValue::None)
.with_default(&"z".repeat(10_000), ComponentDefaultValue::F64(f64::NAN))
.with_default("count", ComponentDefaultValue::Usize(usize::MAX))
.with_default("text", ComponentDefaultValue::I64(i64::MIN));
assert_eq!(m.fields.as_ref().len(), 3);
assert!(matches!(
m.get_field("count").unwrap().default_value,
OptionComponentDefaultValue::Some(ComponentDefaultValue::Usize(usize::MAX))
));
assert_eq!(
m.get_default_string("text"),
None,
"text is now an I64 default, no longer a String"
);
}
#[test]
fn data_model_with_default_fills_only_the_first_match() {
// Duplicate field names: `with_default` breaks after the first hit.
let m = dm(
"Dup",
vec![
data_field("x", ComponentFieldType::String, Some(ComponentDefaultValue::String(AzString::from("1"))), ""),
data_field("x", ComponentFieldType::String, Some(ComponentDefaultValue::String(AzString::from("2"))), ""),
],
)
.with_default("x", ComponentDefaultValue::String(AzString::from("3")));
let vals: Vec<&str> = m
.fields
.as_ref()
.iter()
.filter_map(|f| match &f.default_value {
OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => Some(s.as_str()),
_ => None,
})
.collect();
assert_eq!(vals, vec!["3", "2"], "only the first duplicate is overridden");
}
// ================================================================
// ComponentSource / ComponentMap (constructor / getter / lookup)
// ================================================================
#[test]
fn component_source_create_is_user_defined() {
assert_eq!(ComponentSource::create(), ComponentSource::UserDefined);
assert_eq!(ComponentSource::default(), ComponentSource::UserDefined);
}
#[test]
fn component_map_create_is_empty_and_all_lookups_return_none() {
let m = ComponentMap::create();
assert_eq!(m.libraries.as_ref().len(), 0);
assert!(m.get("builtin", "div").is_none());
assert!(m.get_unqualified("div").is_none());
assert!(m.get_by_qualified_name("builtin:div").is_none());
assert!(m.all_components().is_empty());
assert!(m.get_exportable_libraries().is_empty());
}
#[test]
fn component_map_with_builtin_invariants() {
let m = ComponentMap::with_builtin();
assert_eq!(m.libraries.as_ref().len(), 1);
let lib = &m.libraries.as_ref()[0];
assert_eq!(lib.name.as_str(), "builtin");
assert!(!lib.exportable, "builtins must never be exportable");
assert!(!lib.modifiable, "builtins must never be modifiable");
assert_eq!(
m.all_components().len(),
lib.components.as_ref().len(),
"all_components must see every registered component"
);
assert!(
m.get_exportable_libraries().is_empty(),
"the builtin library is not exportable"
);
}
#[test]
fn component_map_get_valid_minimal() {
let m = ComponentMap::with_builtin();
assert!(m.get("builtin", "div").is_some());
assert!(m.get("builtin", "if").is_some());
assert!(m.get("builtin", "for").is_some());
assert!(m.get("builtin", "map").is_some());
assert_eq!(
m.get("builtin", "div").unwrap().id.qualified_name(),
"builtin:div"
);
}
#[test]
fn component_map_get_empty_whitespace_garbage_unicode() {
let m = ComponentMap::with_builtin();
assert!(m.get("", "").is_none());
assert!(m.get("builtin", "").is_none());
assert!(m.get("", "div").is_none());
assert!(m.get("builtin", " ").is_none());
assert!(m.get("builtin", " div ").is_none(), "no trimming");
assert!(m.get("builtin", "DIV").is_none(), "lookup is case-sensitive");
assert!(m.get("builtin", "\u{1F600}").is_none());
assert!(m.get("builtin", "\u{0}\u{7f}").is_none());
assert!(m.get("builtin", "div;garbage").is_none());
}
#[test]
fn component_map_get_extremely_long_name_terminates() {
let m = ComponentMap::with_builtin();
assert!(m.get(&"a".repeat(LONG), &"b".repeat(LONG)).is_none());
assert!(m.get_unqualified(&"b".repeat(LONG)).is_none());
assert!(m.get_by_qualified_name(&"c".repeat(LONG)).is_none());
}
#[test]
fn component_map_get_unqualified_only_searches_builtin() {
let lib = ComponentLibrary {
name: AzString::from("mylib"),
version: AzString::from("1.0.0"),
description: AzString::from(""),
components: vec![user_def("", Vec::new())].into(),
exportable: true,
modifiable: true,
data_models: Vec::new().into(),
enum_models: Vec::new().into(),
};
let libs: ComponentLibraryVec = vec![lib].into();
let m = ComponentMap::from_libraries(&libs);
assert!(m.get("mylib", "widget").is_some());
assert!(
m.get_unqualified("widget").is_none(),
"unqualified lookup must NOT reach non-builtin libraries"
);
assert!(m.get_by_qualified_name("mylib:widget").is_some());
assert_eq!(m.get_exportable_libraries().len(), 1);
assert_eq!(m.all_components().len(), 1);
}
#[test]
fn component_map_get_by_qualified_name_boundary_forms() {
let m = ComponentMap::with_builtin();
// No colon => falls back to the builtin library.
assert!(m.get_by_qualified_name("div").is_some());
// Exactly one colon.
assert!(m.get_by_qualified_name("builtin:div").is_some());
// Splits on the FIRST colon, so the remainder (incl. colons) is the name.
assert!(m.get_by_qualified_name("builtin:div:extra").is_none());
assert!(m.get_by_qualified_name(":").is_none());
assert!(m.get_by_qualified_name(":div").is_none());
assert!(m.get_by_qualified_name("builtin:").is_none());
assert!(m.get_by_qualified_name("").is_none());
assert!(m.get_by_qualified_name(" ").is_none());
}
#[test]
fn component_map_from_libraries_clones_without_losing_entries() {
let src = ComponentMap::with_builtin();
let copy = ComponentMap::from_libraries(&src.libraries);
assert_eq!(copy.all_components().len(), src.all_components().len());
assert!(copy.get_unqualified("div").is_some());
}
#[test]
fn register_builtin_components_is_stable_across_calls() {
let a = register_builtin_components();
let b = register_builtin_components();
assert_eq!(a.components.as_ref().len(), b.components.as_ref().len());
assert!(a.components.as_ref().len() > 50);
assert_eq!(a.name.as_str(), "builtin");
assert_eq!(a.version.as_str(), "1.0.0");
// Every component must be namespaced into "builtin".
assert!(a
.components
.as_ref()
.iter()
.all(|c| c.id.collection.as_str() == "builtin"));
assert!(a
.components
.as_ref()
.iter()
.all(|c| c.source == ComponentSource::Builtin));
}
// ================================================================
// XmlNodeChild / XmlNode (getter / predicate / constructor)
// ================================================================
#[test]
fn xml_node_child_as_text_and_as_element_are_mutually_exclusive() {
let t = txt("hello");
assert_eq!(t.as_text(), Some("hello"));
assert!(t.as_element().is_none());
let e = elem(node("div", &[], vec![]));
assert!(e.as_text().is_none());
assert_eq!(e.as_element().map(|n| n.node_type.as_str()), Some("div"));
}
#[test]
fn xml_node_child_as_text_edge_values() {
assert_eq!(txt("").as_text(), Some(""), "an empty text node is still text");
assert_eq!(txt(" ").as_text(), Some(" "), "no trimming in the getter");
assert_eq!(txt("\u{1F600}\u{0301}").as_text(), Some("\u{1F600}\u{0301}"));
assert_eq!(txt("\u{0}").as_text(), Some("\u{0}"));
}
#[test]
fn xml_node_child_as_element_mut_allows_mutation_and_rejects_text() {
let mut e = elem(node("div", &[], vec![]));
e.as_element_mut().expect("is an element").node_type = "span".into();
assert_eq!(e.as_element().map(|n| n.node_type.as_str()), Some("span"));
let mut t = txt("x");
assert!(t.as_element_mut().is_none(), "text nodes have no element");
}
#[test]
fn xml_node_create_and_with_children_invariants() {
let n = XmlNode::create("div");
assert_eq!(n.node_type.as_str(), "div");
assert_eq!(n.children.as_ref().len(), 0);
assert_eq!(n.attributes.as_ref().len(), 0);
let n = n.with_children(vec![txt("a"), elem(XmlNode::create("b"))]);
assert_eq!(n.children.as_ref().len(), 2);
assert_eq!(n.node_type.as_str(), "div", "tag survives with_children");
// with_children REPLACES, it does not append.
let n = n.with_children(Vec::new());
assert_eq!(n.children.as_ref().len(), 0);
}
#[test]
fn xml_node_create_extreme_tag_names_no_panic() {
assert_eq!(XmlNode::create("").node_type.as_str(), "");
assert_eq!(XmlNode::create("\u{1F600}").node_type.as_str(), "\u{1F600}");
assert_eq!(
XmlNode::create(&*"a".repeat(10_000)).node_type.as_str().len(),
10_000
);
}
#[test]
fn xml_node_get_text_content_concatenates_only_direct_text() {
let n = node(
"p",
&[],
vec![
txt("a "),
elem(node("span", &[], vec![txt("IGNORED")])),
txt("b"),
],
);
assert_eq!(
n.get_text_content(),
"a b",
"only DIRECT text children, nested element text is not included"
);
assert_eq!(XmlNode::default().get_text_content(), "");
assert_eq!(node("p", &[], vec![txt(""), txt("")]).get_text_content(), "");
}
#[test]
fn xml_node_has_only_text_children_true_false_and_empty() {
assert!(
XmlNode::default().has_only_text_children(),
"vacuously true for a childless node — callers must pair this with a text check"
);
assert!(node("p", &[], vec![txt("a"), txt("b")]).has_only_text_children());
assert!(!node("p", &[], vec![txt("a"), elem(XmlNode::create("b"))]).has_only_text_children());
assert!(!node("p", &[], vec![elem(XmlNode::create("b"))]).has_only_text_children());
}
// ================================================================
// get_html_node / get_body_node / find_node_by_type / find_attribute
// ================================================================
#[test]
fn get_html_node_empty_input_is_no_html_node() {
assert_eq!(get_html_node(&[]), Err(DomXmlParseError::NoHtmlNode));
assert_eq!(get_html_node(&[txt("just text")]), Err(DomXmlParseError::NoHtmlNode));
assert_eq!(
get_html_node(&[elem(XmlNode::create("div"))]),
Err(DomXmlParseError::NoHtmlNode)
);
}
#[test]
fn get_html_node_valid_minimal_and_case_insensitive() {
let roots = vec![elem(XmlNode::create("HTML"))];
assert!(get_html_node(&roots).is_ok(), "tag casing is normalized");
}
#[test]
fn get_html_node_rejects_multiple_roots() {
let roots = vec![elem(XmlNode::create("html")), elem(XmlNode::create("html"))];
assert_eq!(
get_html_node(&roots),
Err(DomXmlParseError::MultipleHtmlRootNodes)
);
}
#[test]
fn get_body_node_empty_and_missing() {
assert_eq!(get_body_node(&[]), Err(DomXmlParseError::NoBodyInHtml));
assert_eq!(
get_body_node(&[elem(XmlNode::create("head"))]),
Err(DomXmlParseError::NoBodyInHtml)
);
}
#[test]
fn get_body_node_direct_and_nested() {
let direct = vec![elem(XmlNode::create("body"))];
assert!(get_body_node(&direct).is_ok());
// Malformed markup: <body> buried inside <head>.
let nested = vec![elem(node(
"head",
&[],
vec![elem(node("div", &[], vec![elem(XmlNode::create("BODY"))]))],
))];
assert!(
get_body_node(&nested).is_ok(),
"the recursive fallback finds a nested body"
);
}
/// Build a `<div>` chain `depth` levels deep with `inner` at the bottom.
fn wrap_divs(depth: usize, inner: XmlNode) -> XmlNode {
let mut n = inner;
for _ in 0..depth {
n = node("div", &[], vec![elem(n)]);
}
n
}
#[test]
fn get_body_node_deep_nesting_is_depth_capped_not_stack_overflowing() {
// Body sits just inside the cap => found.
let ok = vec![elem(wrap_divs(
MAX_XML_NESTING_DEPTH - 2,
XmlNode::create("body"),
))];
assert!(get_body_node(&ok).is_ok(), "body within the depth cap is found");
// Body far below the cap => reported missing, but MUST NOT overflow.
let too_deep = vec![elem(wrap_divs(2_000, XmlNode::create("body")))];
assert_eq!(
get_body_node(&too_deep),
Err(DomXmlParseError::NoBodyInHtml),
"past MAX_XML_NESTING_DEPTH the search gives up instead of crashing"
);
}
#[test]
fn find_node_by_type_empty_garbage_unicode() {
assert!(find_node_by_type(&[], "div").is_none());
assert!(find_node_by_type(&[txt("x")], "div").is_none());
let roots = vec![elem(XmlNode::create("div"))];
assert!(find_node_by_type(&roots, "").is_none());
assert!(find_node_by_type(&roots, " ").is_none());
assert!(find_node_by_type(&roots, "\u{1F600}").is_none());
assert!(find_node_by_type(&roots, &"z".repeat(LONG)).is_none());
// The needle is compared against the NORMALIZED tag, so it must already
// be in normalized (lowercase / snake) form.
assert!(find_node_by_type(&roots, "DIV").is_none());
}
#[test]
fn find_node_by_type_valid_minimal_and_recursive() {
let roots = vec![elem(node(
"html",
&[],
vec![elem(node("head", &[], vec![elem(XmlNode::create("STYLE"))]))],
))];
assert!(find_node_by_type(&roots, "html").is_some());
assert!(
find_node_by_type(&roots, "style").is_some(),
"search recurses into the whole tree"
);
assert!(find_node_by_type(&roots, "body").is_none());
}
#[test]
fn find_node_by_type_prefers_the_shallowest_match() {
let roots = vec![
elem(node("div", &[], vec![elem(XmlNode::create("span"))])),
elem(node("span", &[("id", "shallow")], vec![])),
];
let found = find_node_by_type(&roots, "span").expect("found");
assert_eq!(
found.attributes.get_key("id").map(AzString::as_str),
Some("shallow"),
"direct children are scanned before recursing"
);
}
#[test]
fn find_attribute_valid_minimal_and_missing() {
let n = node("a", &[("href", "x"), ("id", "y")], vec![]);
assert_eq!(find_attribute(&n, "href").map(AzString::as_str), Some("x"));
assert_eq!(find_attribute(&n, "id").map(AzString::as_str), Some("y"));
assert!(find_attribute(&n, "missing").is_none());
assert!(find_attribute(&n, "").is_none());
assert!(find_attribute(&n, " ").is_none());
assert!(find_attribute(&XmlNode::default(), "href").is_none());
assert!(find_attribute(&n, &"z".repeat(LONG)).is_none());
}
#[test]
fn find_attribute_compares_against_the_normalized_key() {
// `normalize_casing` turns `aria-label` into `aria_label`, so callers must
// pass the NORMALIZED spelling — the raw HTML spelling does not match.
let n = node("button", &[("aria-label", "Save")], vec![]);
assert_eq!(
find_attribute(&n, "aria_label").map(AzString::as_str),
Some("Save")
);
assert!(
find_attribute(&n, "aria-label").is_none(),
"the hyphenated spelling never matches (keys are normalized first)"
);
}
#[test]
fn find_attribute_unicode_keys_no_panic() {
let n = node("div", &[("\u{130}", "v"), ("\u{1F600}", "w")], vec![]);
assert!(find_attribute(&n, "\u{1F600}").is_some(), "emoji keys pass through");
// 'İ' lowercases to 2 chars, so the normalized key is not 'İ'.
assert!(find_attribute(&n, "\u{130}").is_none());
}
// ================================================================
// normalize_casing
// ================================================================
#[test]
fn normalize_casing_documented_forms() {
assert_eq!(normalize_casing("abcDef"), "abc_def");
assert_eq!(normalize_casing("AbcDef"), "abc_def");
assert_eq!(normalize_casing("abc-def"), "abc_def");
assert_eq!(normalize_casing("abc_def"), "abc_def");
}
#[test]
fn normalize_casing_edge_inputs() {
assert_eq!(normalize_casing(""), "");
assert_eq!(normalize_casing("---"), "", "separators alone produce no words");
assert_eq!(normalize_casing("___"), "");
assert_eq!(normalize_casing("A"), "a");
assert_eq!(
normalize_casing("ABC"),
"a_b_c",
"every uppercase char starts a new word"
);
assert_eq!(normalize_casing("h1"), "h1");
assert_eq!(normalize_casing(" "), " ", "whitespace is not a separator");
}
#[test]
fn normalize_casing_unicode_and_long_no_panic() {
// 'İ' (U+0130) lowercases to TWO chars — the fn must not slice bytes.
assert_eq!(normalize_casing("\u{130}"), "i\u{307}");
assert_eq!(normalize_casing("\u{1F600}"), "\u{1F600}");
assert_eq!(normalize_casing(&"a".repeat(50_000)).len(), 50_000);
// 50k uppercase chars => 50k single-char words joined by '_'.
assert_eq!(normalize_casing(&"A".repeat(50_000)).len(), 50_000 * 2 - 1);
}
// ================================================================
// get_item / get_item_internal
// ================================================================
#[test]
fn get_item_empty_hierarchy_returns_the_root() {
let mut root = node("div", &[("id", "root")], vec![]);
let got = get_item(&[], &mut root).expect("empty hierarchy => root");
assert_eq!(got.attributes.get_key("id").map(AzString::as_str), Some("root"));
}
#[test]
fn get_item_walks_nested_elements() {
let mut root = node(
"a",
&[],
vec![elem(node("b", &[], vec![elem(node("c", &[("id", "deep")], vec![]))]))],
);
let got = get_item(&[0, 0], &mut root).expect("a > b > c");
assert_eq!(got.node_type.as_str(), "c");
assert_eq!(got.attributes.get_key("id").map(AzString::as_str), Some("deep"));
}
#[test]
fn get_item_out_of_bounds_and_text_nodes_return_none() {
let mut root = node("a", &[], vec![txt("hello"), elem(XmlNode::create("b"))]);
assert!(get_item(&[5], &mut root).is_none(), "out of bounds => None");
assert!(
get_item(&[usize::MAX], &mut root).is_none(),
"usize::MAX index must not panic"
);
assert!(
get_item(&[0], &mut root).is_none(),
"index 0 is a TEXT node — not traversable"
);
assert!(get_item(&[1], &mut root).is_some());
assert!(
get_item(&[1, 0], &mut root).is_none(),
"descending past a leaf => None"
);
}
#[test]
fn get_item_deep_hierarchy_terminates() {
// 400 levels: deep, but bounded by the (short) hierarchy vec, not by markup.
let mut root = wrap_divs(400, node("div", &[("id", "bottom")], vec![]));
let path = vec![0usize; 400];
let got = get_item(&path, &mut root).expect("reaches the bottom");
assert_eq!(got.attributes.get_key("id").map(AzString::as_str), Some("bottom"));
}
// ================================================================
// decode_numeric_entity (parser)
// ================================================================
#[test]
fn decode_numeric_entity_valid_minimal() {
assert_eq!(decode_numeric_entity("#65"), Some('A'));
assert_eq!(decode_numeric_entity("#x41"), Some('A'));
assert_eq!(decode_numeric_entity("#X41"), Some('A'), "uppercase X accepted");
assert_eq!(decode_numeric_entity("#x1F600"), Some('\u{1F600}'));
}
#[test]
fn decode_numeric_entity_empty_whitespace_garbage() {
assert_eq!(decode_numeric_entity(""), None);
assert_eq!(decode_numeric_entity(" "), None);
assert_eq!(decode_numeric_entity("\t\n"), None);
assert_eq!(decode_numeric_entity("amp"), None, "named entity is not numeric");
assert_eq!(decode_numeric_entity("#"), None);
assert_eq!(decode_numeric_entity("#x"), None);
assert_eq!(decode_numeric_entity("#zz"), None);
assert_eq!(decode_numeric_entity("# 65"), None);
assert_eq!(decode_numeric_entity("#65junk"), None);
assert_eq!(decode_numeric_entity("\u{1F600}"), None);
}
#[test]
fn decode_numeric_entity_boundary_code_points() {
assert_eq!(decode_numeric_entity("#0"), Some('\u{0}'), "NUL is a valid char");
assert_eq!(decode_numeric_entity("#x10FFFF"), Some('\u{10FFFF}'), "max scalar");
assert_eq!(
decode_numeric_entity("#x110000"),
None,
"one past the max scalar value"
);
assert_eq!(
decode_numeric_entity("#xD800"),
None,
"a lone surrogate is not a char"
);
assert_eq!(
decode_numeric_entity("#4294967295"),
None,
"u32::MAX is not a scalar value"
);
assert_eq!(
decode_numeric_entity("#4294967296"),
None,
"one past u32::MAX must not wrap — it must fail to parse"
);
assert_eq!(decode_numeric_entity("#-1"), None, "negative is rejected by u32");
assert_eq!(
decode_numeric_entity("#xFFFFFFFFFFFF"),
None,
"hex overflow is rejected, not truncated"
);
}
#[test]
fn decode_numeric_entity_extremely_long_terminates() {
let s = format!("#{}", "9".repeat(LONG));
assert_eq!(s.len(), LONG + 1);
assert_eq!(decode_numeric_entity(&s), None, "overflows u32 => None");
}
// ================================================================
// decode_entities / prepare_string
// ================================================================
#[test]
fn decode_entities_leaves_unrecognized_sequences_verbatim() {
assert_eq!(decode_entities(""), "");
assert_eq!(decode_entities("&"), "&", "a bare '&' at EOF must not panic");
assert_eq!(decode_entities("&;"), "&;", "empty entity body is not decoded");
assert_eq!(decode_entities("&bogus;"), "&bogus;");
assert_eq!(decode_entities(" "), " ", " is deliberately kept");
// A ';' further away than MAX_ENTITY_BODY is not treated as an entity end.
assert_eq!(
decode_entities("&averyveryverylongbody;"),
"&averyveryverylongbody;"
);
}
#[test]
fn decode_entities_single_pass_prevents_double_decoding() {
assert_eq!(decode_entities("&lt;"), "<", "& must not re-open an entity");
assert_eq!(decode_entities("<>&"'"), "<>&\"'");
}
#[test]
fn decode_entities_unicode_and_long_input_terminate() {
assert_eq!(decode_entities("\u{1F600}\u{0301}\u{130}"), "\u{1F600}\u{0301}\u{130}");
// NOTE: each '&' triggers a `find(';')` over the whole remaining suffix, so
// this is quadratic in the number of '&'. Keep the input modest.
let amps = "&".repeat(20_000);
assert_eq!(decode_entities(&s).len(), 20_000);
}
#[test]
fn prepare_string_empty_and_whitespace() {
assert_eq!(prepare_string(""), "");
assert_eq!(prepare_string(" "), "");
assert_eq!(prepare_string("\t\n\r \n"), "");
}
#[test]
fn prepare_string_nbsp_becomes_a_space_that_survives_trim() {
assert_eq!(prepare_string(" "), " ");
assert_eq!(prepare_string("a b"), "a b");
}
#[test]
fn prepare_string_collapses_a_blank_line_into_a_single_return() {
assert_eq!(prepare_string("a\n\nb"), "a\nb");
assert_eq!(prepare_string("a\n\n\n\nb"), "a\nb", "runs of blanks collapse");
}
#[test]
fn prepare_string_unicode_and_long_input_no_panic() {
assert_eq!(prepare_string("\u{1F600}"), "\u{1F600}");
assert_eq!(prepare_string(" \u{130}\u{0301} "), "\u{130}\u{0301}");
assert_eq!(prepare_string(&"x".repeat(LONG)).len(), LONG);
}
/// BUG (reported): a soft-wrapped multi-line text node loses the word break
/// on the FINAL line, because `prepare_string` skips the joining space when
/// `line_idx == line_len - 1`. HTML must collapse the newline into a space.
#[test]
fn prepare_string_joins_wrapped_lines_with_a_space() {
assert_eq!(
prepare_string("Hello\nworld"),
"Hello world",
"a single newline between words must collapse to a space, not vanish"
);
assert_eq!(prepare_string("a\nb\nc"), "a b c");
}
// ================================================================
// parse_bool (parser)
// ================================================================
#[test]
fn parse_bool_valid_minimal_and_everything_else_is_none() {
assert_eq!(parse_bool("true"), Some(true));
assert_eq!(parse_bool("false"), Some(false));
for s in [
"", " ", "\t\n", " true", "true ", "TRUE", "True", "FALSE", "1", "0", "-0", "yes",
"no", "NaN", "inf", "9223372036854775807", "\u{1F600}", "true;garbage",
] {
assert_eq!(parse_bool(s), None, "{s:?} must not parse as a bool");
}
assert_eq!(parse_bool(&"a".repeat(LONG)), None);
}
// ================================================================
// split_dynamic_string / format_args_dynamic
// ================================================================
fn args(kv: &[(&str, &str)]) -> ComponentArgumentVec {
kv.iter()
.map(|(n, t)| ComponentArgument {
name: AzString::from(*n),
arg_type: AzString::from(*t),
})
.collect::<Vec<_>>()
.into()
}
#[test]
fn split_dynamic_string_empty_and_plain() {
assert!(split_dynamic_string("").is_empty());
assert_eq!(
split_dynamic_string("abc"),
vec![DynamicItem::Str("abc".to_string())]
);
}
#[test]
fn split_dynamic_string_format_spec_is_split_on_the_first_colon() {
assert_eq!(
split_dynamic_string("{a:?}"),
vec![DynamicItem::Var {
name: "a".to_string(),
format_spec: Some("?".to_string()),
}]
);
assert_eq!(
split_dynamic_string("{a:x:y}"),
vec![DynamicItem::Var {
name: "a".to_string(),
format_spec: Some("x:y".to_string()),
}],
"only the FIRST colon separates name from spec"
);
}
#[test]
fn split_dynamic_string_unterminated_var_stays_literal() {
// No closing brace => the scan runs to EOF and the text stays a literal.
assert_eq!(
split_dynamic_string("{unterminated"),
vec![DynamicItem::Str("{unterminated".to_string())]
);
// A whitespace inside the braces aborts the variable scan.
assert_eq!(
split_dynamic_string("{a b}"),
vec![DynamicItem::Str("{a b}".to_string())]
);
}
#[test]
fn split_dynamic_string_unicode_and_long_input_terminate() {
assert_eq!(
split_dynamic_string("\u{1F600}{v}\u{130}"),
vec![
DynamicItem::Str("\u{1F600}".to_string()),
DynamicItem::Var {
name: "v".to_string(),
format_spec: None
},
DynamicItem::Str("\u{130}".to_string()),
]
);
// The failed-variable scan advances the cursor by the amount it scanned,
// so this stays linear rather than quadratic.
let bomb = "{a".repeat(50_000);
let out = split_dynamic_string(&bomb);
assert!(out.len() <= 2, "a never-closed variable collapses, got {}", out.len());
let braces = "{".repeat(100_000);
assert!(split_dynamic_string(&braces).len() <= 1);
}
#[test]
fn format_args_dynamic_documented_example() {
let vars = args(&[("a", "value1"), ("b", "value2")]);
assert_eq!(
format_args_dynamic("hello {a}, {b}{{ {c} }}", &vars),
"hello value1, value2{ {c} }"
);
}
#[test]
fn format_args_dynamic_unknown_var_is_preserved_verbatim() {
let empty = no_args();
assert_eq!(format_args_dynamic("{c}", &empty), "{c}");
assert_eq!(
format_args_dynamic("{c:?}", &empty),
"{c:?}",
"the format spec is re-attached when the var is unresolved"
);
// Escaped braces round-trip to themselves.
assert_eq!(format_args_dynamic("{{}}", &empty), "{{}}");
}
#[test]
fn format_args_dynamic_variable_names_are_normalized() {
let vars = args(&[("my_var", "V")]);
assert_eq!(format_args_dynamic("{myVar}", &vars), "V", "camelCase is normalized");
assert_eq!(format_args_dynamic("{ my-var }", &vars), "{ my-var }",
"whitespace inside the braces aborts the variable scan entirely");
assert_eq!(format_args_dynamic("{my-var}", &vars), "V");
}
#[test]
fn format_args_dynamic_edge_values_no_panic() {
let empty = no_args();
assert_eq!(format_args_dynamic("", &empty), "");
assert_eq!(format_args_dynamic(" ", &empty), " ");
assert_eq!(format_args_dynamic("\u{1F600}", &empty), "\u{1F600}");
assert_eq!(format_args_dynamic(&"x".repeat(50_000), &empty).len(), 50_000);
}
#[test]
fn combine_and_replace_dynamic_items_on_empty_input() {
assert_eq!(combine_and_replace_dynamic_items(&[], &no_args()), "");
}
// ================================================================
// compile_and_format_dynamic_items / format_args_for_rust_code
// ================================================================
#[test]
fn format_args_for_rust_code_empty_and_single_items() {
assert_eq!(format_args_for_rust_code(""), "AzString::from_const_str(\"\")");
assert_eq!(format_args_for_rust_code("hi"), "AzString::from_const_str(\"hi\")");
assert_eq!(format_args_for_rust_code("{a}"), "a", "a lone var becomes a bare ident");
assert_eq!(
format_args_for_rust_code("{a:?}"),
"format!(\"{:?}\", a).into()"
);
}
#[test]
fn format_args_for_rust_code_multi_item_builds_a_format_call() {
assert_eq!(
format_args_for_rust_code("x={a} y={b}"),
"format!(\"x={a} y={b}\", a, b).into()"
);
}
#[test]
fn format_args_for_rust_code_escapes_quotes_in_literals() {
let out = format_args_for_rust_code("say \"hi\" {a}");
assert!(
out.contains("say \\\"hi\\\""),
"double quotes must be escaped for the emitted literal, got {out}"
);
}
#[test]
fn compile_and_format_dynamic_items_edge_values() {
assert_eq!(
compile_and_format_dynamic_items(&[]),
"AzString::from_const_str(\"\")"
);
assert_eq!(
compile_and_format_dynamic_items(&[DynamicItem::Str(String::new())]),
"AzString::from_const_str(\"\")"
);
assert_eq!(
compile_and_format_dynamic_items(&[DynamicItem::Var {
name: " spaced ".to_string(),
format_spec: None,
}]),
"spaced",
"the var name is trimmed + normalized"
);
}
// ================================================================
// cap_first / camel_to_snake / esc_lit / c_creator_suffix
// ================================================================
#[test]
fn cap_first_edge_inputs() {
assert_eq!(cap_first(""), "", "empty input must not panic");
assert_eq!(cap_first("h1"), "H1");
assert_eq!(cap_first("button"), "Button");
assert_eq!(cap_first("A"), "A", "already-uppercase is idempotent");
assert_eq!(cap_first("\u{1F600}x"), "\u{1F600}x", "emoji has no uppercase form");
// 'ß' uppercases to TWO chars — the fn must not assume 1:1.
assert_eq!(cap_first("\u{df}x"), "SSx");
assert_eq!(cap_first(&"a".repeat(10_000)).len(), 10_000);
}
#[test]
fn camel_to_snake_documented_forms() {
assert_eq!(camel_to_snake("ButtonNoA11y"), "button_no_a11y");
assert_eq!(camel_to_snake("PWithText"), "p_with_text");
assert_eq!(camel_to_snake("ANoA11y"), "a_no_a11y");
assert_eq!(camel_to_snake("H1WithText"), "h1_with_text");
assert_eq!(camel_to_snake("Div"), "div");
}
#[test]
fn camel_to_snake_edge_inputs() {
assert_eq!(camel_to_snake(""), "");
assert_eq!(camel_to_snake("A"), "a");
assert_eq!(camel_to_snake("AB"), "ab", "an all-caps run is not split");
assert_eq!(camel_to_snake("ABc"), "a_bc", "a caps run splits before the last cap");
assert_eq!(camel_to_snake("\u{1F600}"), "\u{1F600}");
assert_eq!(camel_to_snake(&"a".repeat(10_000)).len(), 10_000);
}
#[test]
fn esc_lit_escapes_backslash_before_quote() {
assert_eq!(esc_lit(""), "");
assert_eq!(esc_lit("plain"), "plain");
assert_eq!(esc_lit("a\"b"), "a\\\"b");
assert_eq!(esc_lit("a\\b"), "a\\\\b");
// The backslash pass must run FIRST so an escaped quote is not double-escaped.
assert_eq!(esc_lit("\\\""), "\\\\\\\"");
assert_eq!(esc_lit("\u{1F600}"), "\u{1F600}");
}
#[test]
fn c_creator_suffix_edge_inputs() {
assert_eq!(c_creator_suffix(""), "Div", "empty debug name falls back to Div");
assert_eq!(c_creator_suffix("Div"), "Div");
assert_eq!(c_creator_suffix("H1"), "H1");
assert_eq!(c_creator_suffix("BlockQuote"), "Blockquote");
assert_eq!(c_creator_suffix("FigCaption"), "Figcaption");
assert_eq!(c_creator_suffix("\u{1F600}"), "\u{1F600}");
}
// ================================================================
// safe_container_tag
// ================================================================
#[test]
fn safe_container_tag_falls_back_to_div_for_arg_taking_widgets() {
assert_eq!(safe_container_tag(""), "Div");
assert_eq!(safe_container_tag("Div"), "Div");
assert_eq!(safe_container_tag("Span"), "Span");
assert_eq!(safe_container_tag("H1"), "H1");
// Interactive / arg-taking elements deliberately degrade to a container.
assert_eq!(safe_container_tag("Button"), "Div");
assert_eq!(safe_container_tag("Input"), "Div");
assert_eq!(safe_container_tag("A"), "Div");
assert_eq!(safe_container_tag("\u{1F600}"), "Div");
assert_eq!(safe_container_tag(&"z".repeat(10_000)), "Div");
}
/// BUG (reported): `SAFE_CONTAINER_TAGS` is documented as holding the
/// `NodeType`/`NodeTypeTag` **debug names**, and `safe_container_tag` compares
/// against `format!("{:?}", tag_to_node_type(tag))`. But six entries are spelled
/// with a different inner capitalization than the actual variant, so the
/// comparison never matches and `<blockquote>`/`<figcaption>`/`<thead>`/`<tbody>`
/// /`<tfoot>`/`<colgroup>` silently compile down to a plain `div`.
#[test]
fn safe_container_tag_matches_the_real_nodetype_debug_names() {
for tag in [
"blockquote",
"figcaption",
"thead",
"tbody",
"tfoot",
"colgroup",
] {
let dbg = format!("{:?}", tag_to_node_type(tag));
assert_ne!(
safe_container_tag(&dbg),
"Div",
"<{tag}> (NodeType debug name {dbg:?}) is a pure container and must keep \
its own creator instead of degrading to a div"
);
}
}
// ================================================================
// fmt_f32_lit (numeric)
// ================================================================
#[test]
fn fmt_f32_lit_zero_and_negative() {
assert_eq!(fmt_f32_lit(0.0), "0.0", "an integral value gains a decimal point");
assert_eq!(fmt_f32_lit(-0.0), "-0.0");
assert_eq!(fmt_f32_lit(-1.0), "-1.0");
assert_eq!(fmt_f32_lit(1.5), "1.5");
assert_eq!(fmt_f32_lit(-1.5), "-1.5");
}
#[test]
fn fmt_f32_lit_min_max_stay_parseable_float_literals() {
for f in [f32::MAX, f32::MIN, f32::MIN_POSITIVE, f32::EPSILON] {
let s = fmt_f32_lit(f);
assert_eq!(
s.parse::<f32>(),
Ok(f),
"{f:e} must round-trip through its emitted literal ({s})"
);
}
}
#[test]
fn fmt_f32_lit_nan_inf_produce_a_defined_result_and_do_not_panic() {
// NOTE: these are NOT valid Rust/C float literals — a page with
// `<progress value="NaN">` emits `create_progress_no_a11y(NaN, 1.0)`.
// Pinned so a fix (e.g. clamping to 0.0) is a visible change.
assert_eq!(fmt_f32_lit(f32::NAN), "NaN");
assert_eq!(fmt_f32_lit(f32::INFINITY), "inf");
assert_eq!(fmt_f32_lit(f32::NEG_INFINITY), "-inf");
}
// ================================================================
// node_direct_text / node_aria_label / node_attr_or / node_attr_f32
// first_caption_text
// ================================================================
#[test]
fn node_direct_text_trims_and_skips_elements() {
assert_eq!(node_direct_text(&XmlNode::default()), "");
assert_eq!(node_direct_text(&node("p", &[], vec![txt(" Go ")])), "Go");
assert_eq!(
node_direct_text(&node(
"p",
&[],
vec![txt("a"), elem(node("b", &[], vec![txt("IGNORED")])), txt("b")]
)),
"a b",
"direct text children are joined with a single space"
);
assert_eq!(
node_direct_text(&node("p", &[], vec![txt(" "), txt("\t\n")])),
"",
"whitespace-only children are dropped"
);
}
#[test]
fn node_aria_label_ignores_empty_and_whitespace() {
assert_eq!(node_aria_label(&XmlNode::default()), None);
assert_eq!(node_aria_label(&node("b", &[("aria-label", "")], vec![])), None);
assert_eq!(node_aria_label(&node("b", &[("aria-label", " ")], vec![])), None);
assert_eq!(
node_aria_label(&node("b", &[("aria-label", " Save ")], vec![])),
Some("Save".to_string())
);
}
#[test]
fn node_attr_or_returns_the_default_when_absent() {
let n = node("a", &[("href", "/x"), ("empty", "")], vec![]);
assert_eq!(node_attr_or(&n, "href", "FALLBACK"), "/x");
assert_eq!(node_attr_or(&n, "missing", "FALLBACK"), "FALLBACK");
assert_eq!(
node_attr_or(&n, "empty", "FALLBACK"),
"",
"a present-but-empty attribute wins over the default"
);
assert_eq!(node_attr_or(&XmlNode::default(), "x", ""), "");
}
#[test]
fn node_attr_f32_zero_negative_and_defaults() {
let n = node(
"meter",
&[("zero", "0"), ("negzero", "-0"), ("neg", "-2.5"), ("pad", " 1.5 ")],
vec![],
);
assert_eq!(node_attr_f32(&n, "zero", 9.0), 0.0);
assert!(node_attr_f32(&n, "negzero", 9.0).is_sign_negative());
assert_eq!(node_attr_f32(&n, "neg", 9.0), -2.5);
assert_eq!(node_attr_f32(&n, "pad", 9.0), 1.5, "the value is trimmed first");
assert_eq!(node_attr_f32(&n, "missing", 9.0), 9.0);
}
#[test]
fn node_attr_f32_unparsable_falls_back_and_min_max_saturate() {
let n = node(
"meter",
&[
("junk", "abc"),
("empty", ""),
("huge", "1e400"),
("tiny", "-1e400"),
("big", "340282350000000000000000000000000000000"),
],
vec![],
);
assert_eq!(node_attr_f32(&n, "junk", 7.0), 7.0);
assert_eq!(node_attr_f32(&n, "empty", 7.0), 7.0);
assert!(
node_attr_f32(&n, "huge", 7.0).is_infinite(),
"an out-of-range literal saturates to inf, it does not panic"
);
assert_eq!(node_attr_f32(&n, "tiny", 7.0), f32::NEG_INFINITY);
assert_eq!(node_attr_f32(&n, "big", 7.0), f32::MAX);
}
#[test]
fn node_attr_f32_accepts_nan_and_inf_spellings() {
// Rust's f32 FromStr accepts "NaN"/"inf", so hostile markup can inject a
// non-finite value straight into codegen (see fmt_f32_lit above).
let n = node("progress", &[("value", "NaN"), ("max", "inf")], vec![]);
assert!(node_attr_f32(&n, "value", 0.0).is_nan());
assert_eq!(node_attr_f32(&n, "max", 1.0), f32::INFINITY);
}
#[test]
fn node_attr_f32_nan_default_is_returned_verbatim() {
assert!(node_attr_f32(&XmlNode::default(), "x", f32::NAN).is_nan());
assert_eq!(node_attr_f32(&XmlNode::default(), "x", f32::INFINITY), f32::INFINITY);
}
#[test]
fn first_caption_text_edges() {
assert_eq!(first_caption_text(&XmlNode::default()), None);
assert_eq!(
first_caption_text(&node("table", &[], vec![elem(node("caption", &[], vec![]))])),
None,
"an empty caption yields None"
);
assert_eq!(
first_caption_text(&node(
"table",
&[],
vec![elem(node("CAPTION", &[], vec![txt(" Hi ")]))]
)),
Some("Hi".to_string()),
"the tag match is ASCII-case-insensitive and the text is trimmed"
);
}
// ================================================================
// analyze_node_ctor / CtorArg / NodeCtor
// ================================================================
#[test]
fn analyze_node_ctor_plain_for_unknown_and_empty_tags() {
assert!(matches!(analyze_node_ctor("div", &XmlNode::default()), NodeCtor::Plain));
assert!(matches!(analyze_node_ctor("", &XmlNode::default()), NodeCtor::Plain));
assert!(matches!(
analyze_node_ctor("\u{1F600}", &XmlNode::default()),
NodeCtor::Plain
));
let plain = analyze_node_ctor("div", &XmlNode::default());
assert_eq!(plain.render_rust(), None);
assert_eq!(plain.render_c(), None);
assert_eq!(plain.render_fluent(&CompileTarget::Cpp), None);
assert!(!plain.consumes_text());
assert!(!plain.skip_caption());
}
#[test]
fn analyze_node_ctor_with_text_tier_requires_actual_text() {
// Empty <p> stays a plain container (has_only_text_children() is vacuously
// true for a childless node, so `has_text` is the real gate).
assert!(matches!(analyze_node_ctor("p", &XmlNode::default()), NodeCtor::Plain));
// <p> with an element child is not "pure text" either.
let mixed = node("p", &[], vec![txt("a"), elem(XmlNode::create("span"))]);
assert!(matches!(analyze_node_ctor("p", &mixed), NodeCtor::Plain));
let pure = node("p", &[], vec![txt(" Hello ")]);
let ctor = analyze_node_ctor("p", &pure);
assert!(ctor.consumes_text(), "the text is folded into the constructor");
assert_eq!(
ctor.render_rust().as_deref(),
Some("Dom::create_p_with_text(AzString::from(\"Hello\"))")
);
assert_eq!(
ctor.render_c().as_deref(),
Some("AzDom_createPWithText(AZ_STR(\"Hello\"))")
);
assert_eq!(
ctor.render_fluent(&CompileTarget::Python).as_deref(),
Some("azul.Dom.create_p_with_text(\"Hello\")")
);
}
#[test]
fn analyze_node_ctor_button_with_and_without_aria() {
let plain_btn = node("button", &[], vec![txt("Go")]);
assert_eq!(
analyze_node_ctor("button", &plain_btn).render_rust().as_deref(),
Some("Dom::create_button_no_a11y(AzString::from(\"Go\"))")
);
let aria_btn = node("button", &[("aria-label", "Save")], vec![txt("Go")]);
assert_eq!(
analyze_node_ctor("button", &aria_btn).render_rust().as_deref(),
Some(
"Dom::create_button(AzString::from(\"Go\"), \
SmallAriaInfo::label(AzString::from(\"Save\")))"
)
);
}
#[test]
fn analyze_node_ctor_escapes_quotes_and_backslashes_in_text() {
let btn = node("button", &[], vec![txt("say \"hi\"\\now")]);
let rust = analyze_node_ctor("button", &btn).render_rust().expect("semantic");
assert!(
rust.contains("say \\\"hi\\\"\\\\now"),
"quotes and backslashes must be escaped for the literal, got {rust}"
);
}
#[test]
fn analyze_node_ctor_anchor_uses_option_string_when_it_has_no_text() {
let bare = node("a", &[], vec![]);
assert_eq!(
analyze_node_ctor("a", &bare).render_rust().as_deref(),
Some("Dom::create_a_no_a11y(AzString::from(\"\"), OptionString::None)"),
"a missing href defaults to an empty string, missing text to OptionString::None"
);
let full = node("a", &[("href", "/x")], vec![txt("Home")]);
assert_eq!(
analyze_node_ctor("a", &full).render_rust().as_deref(),
Some(
"Dom::create_a_no_a11y(AzString::from(\"/x\"), \
OptionString::Some(AzString::from(\"Home\")))"
)
);
assert_eq!(
analyze_node_ctor("a", &full).render_c().as_deref(),
Some("AzDom_createANoA11y(AZ_STR(\"/x\"), AzOptionString_some(AZ_STR(\"Home\")))")
);
}
#[test]
fn analyze_node_ctor_table_aria_form_skips_the_literal_caption() {
let t = node(
"table",
&[("aria-label", "Prices")],
vec![elem(node("caption", &[], vec![txt("Q1")]))],
);
let ctor = analyze_node_ctor("table", &t);
assert!(ctor.skip_caption(), "the aria form injects its own caption child");
assert_eq!(
ctor.render_rust().as_deref(),
Some(
"Dom::create_table(AzString::from(\"Q1\"), \
SmallAriaInfo::label(AzString::from(\"Prices\")))"
)
);
let plain = analyze_node_ctor("table", &node("table", &[], vec![]));
assert!(!plain.skip_caption());
assert_eq!(plain.render_rust().as_deref(), Some("Dom::create_table_no_a11y()"));
}
#[test]
fn analyze_node_ctor_scalar_widgets_use_defaults_and_emit_float_literals() {
let p = node("progress", &[], vec![]);
assert_eq!(
analyze_node_ctor("progress", &p).render_rust().as_deref(),
Some("Dom::create_progress_no_a11y(0.0, 1.0)"),
"missing value/max fall back to 0.0 / 1.0 as float literals"
);
let m = node("meter", &[("value", "5"), ("min", "-1"), ("max", "10")], vec![]);
assert_eq!(
analyze_node_ctor("meter", &m).render_c().as_deref(),
Some("AzDom_createMeterNoA11y(5.0f, -1.0f, 10.0f)")
);
}
/// Non-finite attribute values flow straight into the emitted literal. Pinned
/// so that a fix (clamping / rejecting them) shows up as a change.
#[test]
fn analyze_node_ctor_non_finite_attributes_emit_non_finite_literals() {
let p = node("progress", &[("value", "NaN"), ("max", "inf")], vec![]);
let rust = analyze_node_ctor("progress", &p).render_rust().expect("semantic");
assert_eq!(rust, "Dom::create_progress_no_a11y(NaN, inf)");
}
#[test]
fn ctor_arg_render_targets_are_distinct() {
let s = CtorArg::Str("a\"b".to_string());
assert_eq!(s.render_rust(), "AzString::from(\"a\\\"b\")");
assert_eq!(s.render_c(), "AZ_STR(\"a\\\"b\")");
assert_eq!(s.render_cpp(), "String(\"a\\\"b\")");
assert_eq!(s.render_python(), "\"a\\\"b\"");
assert_eq!(CtorArg::OptNone.render_rust(), "OptionString::None");
assert_eq!(CtorArg::OptNone.render_c(), "AzOptionString_none()");
assert_eq!(CtorArg::OptNone.render_cpp(), "OptionString::none()");
assert_eq!(CtorArg::OptNone.render_python(), "azul.OptionString.none()");
assert_eq!(CtorArg::Float(0.0).render_rust(), "0.0");
assert_eq!(CtorArg::Float(0.0).render_c(), "0.0f");
assert_eq!(CtorArg::Float(f32::NAN).render_c(), "NaNf");
}
#[test]
fn node_ctor_render_fluent_returns_none_for_non_fluent_targets() {
let ctor = analyze_node_ctor("p", &node("p", &[], vec![txt("x")]));
assert!(ctor.render_fluent(&CompileTarget::Rust).is_none());
assert!(ctor.render_fluent(&CompileTarget::C).is_none());
assert!(ctor.render_fluent(&CompileTarget::Cpp).is_some());
assert!(ctor.render_fluent(&CompileTarget::Python).is_some());
}
// ================================================================
// format_component_args / compile_component / compile_components
// ================================================================
#[test]
fn format_component_args_empty_and_ordering() {
assert_eq!(format_component_args(&no_args()), "");
// Args are sorted DESCENDING by their rendered "name: type" string.
assert_eq!(
format_component_args(&args(&[("a", "u32"), ("b", "String")])),
"b: String, a: u32"
);
}
#[test]
fn format_component_args_edge_values_no_panic() {
let a = args(&[("", ""), ("\u{1F600}", "\u{130}")]);
let out = format_component_args(&a);
assert!(out.contains(": "), "still emits `name: type` pairs, got {out:?}");
}
#[test]
fn compile_component_emits_a_render_fn() {
let ca = ComponentArguments {
args: args(&[("count", "u32")]),
accepts_text: false,
};
let out = compile_component("MyWidget", &ca, "Dom::create_div()");
assert!(out.contains("pub fn render(count: u32) -> Dom {"), "got:\n{out}");
assert!(out.contains("#[inline]"), "a one-line body is inlined");
}
#[test]
fn compile_component_accepts_text_prepends_the_text_param() {
let ca = ComponentArguments {
args: args(&[("count", "u32")]),
accepts_text: true,
};
let out = compile_component("my-widget", &ca, "Dom::create_div()");
assert!(
out.contains("pub fn render(text: AzString, count: u32) -> Dom {"),
"got:\n{out}"
);
let ca_no_args = ComponentArguments {
args: no_args(),
accepts_text: true,
};
let out = compile_component("w", &ca_no_args, "Dom::create_div()");
assert!(
out.contains("pub fn render(text: AzString) -> Dom {"),
"no trailing comma when there are no extra args, got:\n{out}"
);
}
#[test]
fn compile_component_empty_name_and_body_no_panic() {
let ca = ComponentArguments::default();
let out = compile_component("", &ca, "");
assert!(out.contains("pub fn render() -> Dom {"), "got:\n{out}");
}
#[test]
fn compile_components_of_an_empty_list_is_empty() {
assert_eq!(compile_components(Vec::new()), "");
}
// ================================================================
// parse_svg_float / parse_svg_points (parser / numeric)
// ================================================================
#[test]
fn parse_svg_float_none_empty_whitespace_garbage() {
assert_eq!(parse_svg_float(None), None);
let empty = AzString::from("");
assert_eq!(parse_svg_float(Some(&empty)), None);
let ws = AzString::from(" \t\n");
assert_eq!(parse_svg_float(Some(&ws)), None);
let junk = AzString::from("10px");
assert_eq!(parse_svg_float(Some(&junk)), None, "units are not stripped");
let uni = AzString::from("\u{1F600}");
assert_eq!(parse_svg_float(Some(&uni)), None);
}
#[test]
fn parse_svg_float_valid_and_boundary_numbers() {
let padded = AzString::from(" 1.5 ");
assert_eq!(parse_svg_float(Some(&padded)), Some(1.5), "value is trimmed");
let zero = AzString::from("0");
assert_eq!(parse_svg_float(Some(&zero)), Some(0.0));
let negzero = AzString::from("-0");
assert!(parse_svg_float(Some(&negzero)).unwrap().is_sign_negative());
let huge = AzString::from("1e400");
assert!(
parse_svg_float(Some(&huge)).unwrap().is_infinite(),
"overflow saturates to inf rather than erroring"
);
let nan = AzString::from("NaN");
assert!(parse_svg_float(Some(&nan)).unwrap().is_nan());
let inf = AzString::from("-inf");
assert_eq!(parse_svg_float(Some(&inf)), Some(f32::NEG_INFINITY));
}
#[test]
fn parse_svg_points_rejects_degenerate_input() {
assert!(parse_svg_points("", false).is_none());
assert!(parse_svg_points(" ", false).is_none());
assert!(parse_svg_points("garbage", false).is_none());
assert!(parse_svg_points("1 2", false).is_none(), "a single point is not a line");
assert!(
parse_svg_points("1 2 3", false).is_none(),
"an odd coordinate count is rejected"
);
assert!(parse_svg_points("\u{1F600}", false).is_none());
}
#[test]
fn parse_svg_points_valid_minimal_and_close() {
let open = parse_svg_points("0,0 10,0", false).expect("two points => one line");
assert_eq!(open.rings.as_ref().len(), 1);
assert_eq!(open.rings.as_ref()[0].items.as_ref().len(), 1);
// `close` adds a segment back to the first point when it differs.
let closed = parse_svg_points("0,0 10,0 10,10", true).expect("triangle");
assert_eq!(
closed.rings.as_ref()[0].items.as_ref().len(),
3,
"2 segments + 1 closing segment"
);
// Already-closed rings do not get a duplicate closing segment.
let already = parse_svg_points("0,0 10,0 0,0", true).expect("closed ring");
assert_eq!(already.rings.as_ref()[0].items.as_ref().len(), 2);
}
#[test]
fn parse_svg_points_skips_unparsable_tokens_and_handles_boundaries() {
// Unparsable coordinates are silently dropped, which can shift the pairing.
let p = parse_svg_points("0,0 junk 10,0", false).expect("junk token dropped");
assert_eq!(p.rings.as_ref()[0].items.as_ref().len(), 1);
let nan = parse_svg_points("NaN,0 1,1", false).expect("NaN is a parseable f32");
assert_eq!(nan.rings.as_ref()[0].items.as_ref().len(), 1);
}
#[test]
fn parse_svg_points_extremely_long_terminates() {
let pts = "1,2 ".repeat(20_000);
let p = parse_svg_points(&pts, false).expect("20k points");
assert_eq!(p.rings.as_ref()[0].items.as_ref().len(), 19_999);
}
// ================================================================
// CompactDomBuilder (constructor / numeric)
// ================================================================
#[test]
fn compact_dom_builder_new_and_with_capacity_start_empty() {
for b in [
CompactDomBuilder::new(),
CompactDomBuilder::with_capacity(0),
CompactDomBuilder::with_capacity(1),
CompactDomBuilder::with_capacity(4096),
] {
let fd = b.finish();
assert_eq!(fd.node_hierarchy.as_ref().len(), 0);
assert_eq!(fd.node_data.as_ref().len(), 0);
assert_eq!(fd.css.as_ref().len(), 0);
}
assert_eq!(
CompactDomBuilder::default().finish().node_data.as_ref().len(),
0
);
}
#[test]
fn compact_dom_builder_close_node_on_an_empty_stack_is_a_no_op() {
let mut b = CompactDomBuilder::new();
b.close_node();
b.close_node();
assert_eq!(b.finish().node_hierarchy.as_ref().len(), 0, "no panic, no nodes");
}
#[test]
fn compact_dom_builder_keeps_hierarchy_and_data_parallel() {
let mut b = CompactDomBuilder::new();
b.open_node(NodeData::create_node(NodeType::Html));
b.add_leaf(NodeData::create_text("a"));
b.add_leaf(NodeData::create_text("b"));
b.close_node();
let fd = b.finish();
assert_eq!(fd.node_data.as_ref().len(), 3);
assert_eq!(
fd.node_hierarchy.as_ref().len(),
fd.node_data.as_ref().len(),
"the two arenas must stay the same length"
);
}
#[test]
fn compact_dom_builder_unclosed_node_still_finishes() {
let mut b = CompactDomBuilder::new();
b.open_node(NodeData::create_node(NodeType::Div));
// Deliberately NOT closed.
let fd = b.finish();
assert_eq!(fd.node_hierarchy.as_ref().len(), 1);
assert_eq!(
fd.node_hierarchy.as_ref()[0].last_child, 0,
"last_child stays unset when close_node() is never called"
);
}
#[test]
fn compact_dom_builder_add_css_accepts_zero_and_usize_max_node_ids() {
let mut b = CompactDomBuilder::new();
b.add_css(0, Css::empty());
b.add_css(usize::MAX, Css::empty());
let fd = b.finish();
assert_eq!(fd.css.as_ref().len(), 2);
assert_eq!(fd.css.as_ref()[0].node_id, 0);
assert_eq!(
fd.css.as_ref()[1].node_id,
usize::MAX,
"an out-of-range node id is stored verbatim (no bounds check, no panic)"
);
}
// ================================================================
// xml_node_to_dom_fast / xml_node_to_fast_dom (numeric: depth)
// ================================================================
#[test]
fn xml_node_to_dom_fast_depth_zero_builds_children() {
let map = ComponentMap::default();
let n = node("div", &[], vec![txt("hi"), elem(XmlNode::create("span"))]);
let dom = xml_node_to_dom_fast(&n, &map, false, 0).expect("ok");
assert_eq!(dom.children.as_ref().len(), 2);
}
#[test]
fn xml_node_to_dom_fast_at_and_past_the_depth_cap_truncates_instead_of_panicking() {
let map = ComponentMap::default();
let n = node("div", &[], vec![txt("hi")]);
let at_cap = xml_node_to_dom_fast(&n, &map, false, MAX_XML_NESTING_DEPTH).expect("ok");
assert!(
at_cap.children.as_ref().is_empty(),
"at the cap the node is emitted without children"
);
let saturated = xml_node_to_dom_fast(&n, &map, false, usize::MAX)
.expect("usize::MAX depth must not overflow when computing depth + 1");
assert!(saturated.children.as_ref().is_empty());
let below = xml_node_to_dom_fast(&n, &map, false, MAX_XML_NESTING_DEPTH - 1).expect("ok");
assert_eq!(below.children.as_ref().len(), 1, "one below the cap still recurses");
}
#[test]
fn xml_node_to_fast_dom_at_the_depth_cap_still_emits_the_node() {
let map = ComponentMap::default();
let n = node("div", &[], vec![txt("hi")]);
let mut b = CompactDomBuilder::new();
xml_node_to_fast_dom(&n, &map, false, &mut b, usize::MAX).expect("no overflow");
let fd = b.finish();
assert_eq!(
fd.node_data.as_ref().len(),
1,
"the node itself is still opened+closed, only its children are dropped"
);
let mut b2 = CompactDomBuilder::new();
xml_node_to_fast_dom(&n, &map, false, &mut b2, 0).expect("ok");
assert_eq!(b2.finish().node_data.as_ref().len(), 2, "node + text child");
}
#[test]
fn apply_xml_node_attributes_extreme_tabindex_does_not_panic() {
let map = ComponentMap::default();
for v in [
"0",
"-1",
"2147483647",
"9223372036854775807",
"-9223372036854775808",
"99999999999999999999999999999999",
"abc",
"",
"\u{1F600}",
] {
let n = node("div", &[("tabindex", v), ("focusable", "true")], vec![]);
assert!(
xml_node_to_dom_fast(&n, &map, false, 0).is_ok(),
"tabindex={v:?} must not panic"
);
}
}
#[test]
fn apply_xml_node_attributes_img_width_height_garbage_falls_back_to_zero() {
let map = ComponentMap::default();
let n = node(
"img",
&[("src", "a.png"), ("width", "-5"), ("height", "not-a-number")],
vec![],
);
let dom = xml_node_to_dom_fast(&n, &map, false, 0).expect("ok");
match dom.root.get_node_type() {
NodeType::Image(_) => {}
other => panic!("expected an Image node, got {other:?}"),
}
}
// ================================================================
// set_stringified_attributes (numeric: tabs / tabindex)
// ================================================================
#[test]
fn set_stringified_attributes_zero_tabs_and_empty_attrs() {
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[]), &no_args(), 0);
assert_eq!(s, "", "nothing to emit for an attribute-less node");
}
#[test]
fn set_stringified_attributes_splits_ids_and_classes_on_whitespace() {
let mut s = String::new();
set_stringified_attributes(
&mut s,
&attrs(&[("id", "a b"), ("class", "c\td")]),
&no_args(),
0,
);
assert!(s.contains(".with_id(\"a\")"), "got {s:?}");
assert!(s.contains(".with_id(\"b\")"));
assert!(s.contains(".with_class(\"c\")"));
assert!(s.contains(".with_class(\"d\")"));
}
#[test]
fn set_stringified_attributes_tabindex_boundaries() {
let cases: &[(&str, &str)] = &[
("0", "TabIndex::Auto"),
("5", "TabIndex::OverrideInParent(5)"),
("-1", "TabIndex::NoKeyboardFocus"),
];
for (val, expected) in cases {
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[("tabindex", val)]), &no_args(), 0);
assert!(s.contains(expected), "tabindex={val:?} => {s:?}");
}
// Unparsable / overflowing values emit nothing rather than panicking.
for val in ["abc", "", "99999999999999999999999999999999", "1.5"] {
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[("tabindex", val)]), &no_args(), 0);
assert!(
!s.contains("TabIndex"),
"tabindex={val:?} must be ignored, got {s:?}"
);
}
}
#[test]
fn set_stringified_attributes_focusable_only_accepts_exact_true_false() {
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[("focusable", "true")]), &no_args(), 0);
assert!(s.contains("TabIndex::Auto"));
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[("focusable", "false")]), &no_args(), 0);
assert!(s.contains("TabIndex::NoKeyboardFocus"));
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[("focusable", "TRUE")]), &no_args(), 0);
assert!(s.is_empty(), "casing other than `true`/`false` is ignored, got {s:?}");
}
#[test]
fn set_stringified_attributes_large_tab_depth_does_not_overflow() {
// `tabs` becomes `" ".repeat(tabs)`; a large-but-sane nesting depth must
// stay linear and allocate without panicking.
let mut s = String::new();
set_stringified_attributes(&mut s, &attrs(&[("id", "x")]), &no_args(), 1_000);
assert!(s.contains(".with_id(\"x\")"));
assert!(s.len() > 4_000, "the 1000-level indent is actually emitted");
}
// ================================================================
// group_matches / CssMatcher (numeric: indices)
// ================================================================
fn refs(v: &[CssPathSelector]) -> Vec<&CssPathSelector> {
v.iter().collect()
}
#[test]
fn group_matches_global_matches_at_any_index() {
let a = vec![CssPathSelector::Global];
assert!(group_matches(&refs(&a), &[], 0, 0));
assert!(
group_matches(&refs(&a), &[], usize::MAX, usize::MAX),
"usize::MAX indices must not overflow"
);
}
#[test]
fn group_matches_type_class_id() {
let div = vec![CssPathSelector::Type(NodeTypeTag::Div)];
let p = vec![CssPathSelector::Type(NodeTypeTag::P)];
assert!(group_matches(&refs(&div), &refs(&div), 0, 1));
assert!(!group_matches(&refs(&div), &refs(&p), 0, 1));
assert!(!group_matches(&refs(&div), &[], 0, 1), "an empty haystack never matches");
let cls = vec![CssPathSelector::Class(AzString::from("x"))];
assert!(group_matches(&refs(&cls), &refs(&cls), 0, 1));
let id = vec![CssPathSelector::Id(AzString::from("x"))];
assert!(!group_matches(&refs(&id), &refs(&cls), 0, 1), "an id is not a class");
}
#[test]
fn group_matches_first_and_last_pseudo_at_boundaries() {
let first = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::First)];
assert!(group_matches(&refs(&first), &[], 0, 10));
assert!(!group_matches(&refs(&first), &[], 1, 10));
let last = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::Last)];
assert!(group_matches(&refs(&last), &[], 9, 10));
assert!(!group_matches(&refs(&last), &[], 8, 10));
assert!(
group_matches(&refs(&last), &[], 0, 0),
"parent_children == 0 saturates to 0, so index 0 counts as last"
);
}
#[test]
fn group_matches_nth_child_even_odd_and_number() {
let even = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
CssNthChildSelector::Even,
))];
assert!(group_matches(&refs(&even), &[], 0, 0));
assert!(!group_matches(&refs(&even), &[], 1, 0));
assert!(
!group_matches(&refs(&even), &[], usize::MAX, 0),
"usize::MAX is odd"
);
let odd = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
CssNthChildSelector::Odd,
))];
assert!(group_matches(&refs(&odd), &[], 1, 0));
assert!(!group_matches(&refs(&odd), &[], 2, 0));
let n = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
CssNthChildSelector::Number(u32::MAX),
))];
assert!(group_matches(&refs(&n), &[], u32::MAX as usize, 0));
assert!(!group_matches(&refs(&n), &[], 0, 0));
}
#[test]
fn group_matches_nth_child_pattern_zero_repeat_does_not_divide_by_zero() {
let zero = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
CssNthChildSelector::Pattern(CssNthChildPattern {
pattern_repeat: 0,
offset: 0,
}),
))];
// `is_multiple_of(0)` is `self == 0` — no division by zero.
assert!(group_matches(&refs(&zero), &[], 0, 0));
assert!(!group_matches(&refs(&zero), &[], 5, 0));
let offset_past = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
CssNthChildSelector::Pattern(CssNthChildPattern {
pattern_repeat: 2,
offset: u32::MAX,
}),
))];
assert!(
group_matches(&refs(&offset_past), &[], 0, 0),
"index - offset saturates to 0 rather than underflowing"
);
}
#[test]
fn group_matches_structural_combinators_never_match() {
for sel in [
CssPathSelector::Children,
CssPathSelector::DirectChildren,
CssPathSelector::AdjacentSibling,
CssPathSelector::GeneralSibling,
] {
let a = vec![sel.clone()];
assert!(
!group_matches(&refs(&a), &refs(&a), 0, 1),
"{sel:?} is a combinator, not a matchable group member"
);
}
}
#[test]
fn css_matcher_empty_path_never_matches() {
let m = CssMatcher {
path: Vec::new(),
indices_in_parent: vec![0],
children_length: vec![0],
};
let path = CssPath {
selectors: vec![CssPathSelector::Type(NodeTypeTag::Body)].into(),
};
assert!(!m.matches(&path), "an empty matcher path can never match");
let m2 = CssMatcher {
path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
indices_in_parent: vec![0],
children_length: vec![0],
};
let empty_path = CssPath {
selectors: Vec::new().into(),
};
assert!(!m2.matches(&empty_path), "an empty CSS path can never match");
}
#[test]
fn css_matcher_get_hash_is_deterministic_and_path_sensitive() {
let a = CssMatcher {
path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
indices_in_parent: vec![0],
children_length: vec![0],
};
let b = CssMatcher {
path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
indices_in_parent: vec![9],
children_length: vec![9],
};
let c = CssMatcher {
path: vec![CssPathSelector::Type(NodeTypeTag::Div)],
indices_in_parent: vec![0],
children_length: vec![0],
};
assert_eq!(a.get_hash(), a.get_hash(), "stable across calls");
assert_eq!(
a.get_hash(),
b.get_hash(),
"the hash covers only `path`, not the sibling indices"
);
assert_ne!(a.get_hash(), c.get_hash());
let empty = CssMatcher {
path: Vec::new(),
indices_in_parent: Vec::new(),
children_length: Vec::new(),
};
let _ = empty.get_hash(); // must not panic
}
#[test]
fn css_matcher_mismatched_bookkeeping_vec_lengths_bail_out() {
// `indices_in_parent` / `children_length` must be as long as the group list.
let m = CssMatcher {
path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
indices_in_parent: Vec::new(),
children_length: Vec::new(),
};
let path = CssPath {
selectors: vec![CssPathSelector::Type(NodeTypeTag::Body)].into(),
};
assert!(
!m.matches(&path),
"a desynced matcher must return false, not index out of bounds"
);
}
#[test]
fn get_css_blocks_and_inline_string_on_empty_css() {
let m = CssMatcher {
path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
indices_in_parent: vec![0],
children_length: vec![0],
};
assert!(get_css_blocks(&Css::empty(), &m).is_empty());
assert_eq!(css_blocks_to_inline_string(&[]), "");
}
// ================================================================
// str_to_dom / str_to_dom_unstyled / parse_page_style_and_body / body_matcher
// ================================================================
#[test]
fn str_to_dom_rejects_documents_without_html_or_body() {
let map = ComponentMap::with_builtin();
assert_eq!(
str_to_dom(&[], &map, None).unwrap_err(),
DomXmlParseError::NoHtmlNode
);
let html_only = vec![elem(XmlNode::create("html"))];
assert_eq!(
str_to_dom(&html_only, &map, None).unwrap_err(),
DomXmlParseError::NoBodyInHtml
);
assert!(str_to_dom_unstyled(&[], &map).is_err());
}
#[test]
fn str_to_dom_valid_minimal() {
let map = ComponentMap::with_builtin();
let d = doc("body { color: red; }", vec![elem(node("div", &[("id", "x")], vec![]))]);
assert!(str_to_dom(&d, &map, None).is_ok());
assert!(str_to_dom_unstyled(&d, &map).is_ok());
}
#[test]
fn str_to_dom_max_width_edge_values_do_not_panic() {
let map = ComponentMap::with_builtin();
let d = doc("", vec![elem(XmlNode::create("div"))]);
for w in [
Some(0.0f32),
Some(-0.0),
Some(-1.0),
Some(f32::MAX),
Some(f32::MIN),
Some(f32::NAN),
Some(f32::INFINITY),
Some(f32::NEG_INFINITY),
None,
] {
assert!(
str_to_dom(&d, &map, w).is_ok(),
"max_width={w:?} is formatted straight into a CSS string and must not panic"
);
}
}
#[test]
fn str_to_dom_deeply_nested_body_is_depth_capped_not_stack_overflowing() {
let map = ComponentMap::with_builtin();
let deep = wrap_divs(2_000, node("div", &[("id", "bottom")], vec![]));
let d = doc("", vec![elem(deep)]);
assert!(
str_to_dom(&d, &map, None).is_ok(),
"children past MAX_XML_NESTING_DEPTH are dropped, not crashed on"
);
}
#[test]
fn parse_page_style_and_body_and_body_matcher() {
let d = doc("body { color: red; }", vec![elem(XmlNode::create("div"))]);
let (css, body) = parse_page_style_and_body(&d).expect("well-formed page");
assert_eq!(body.node_type.as_str(), "body");
assert!(!css.rules.as_ref().is_empty(), "the <style> block is parsed");
let m = body_matcher(body);
assert!(m.path.is_empty(), "the matcher starts with an empty path");
assert_eq!(m.indices_in_parent, vec![0]);
assert_eq!(m.children_length, vec![body.children.as_ref().len()]);
}
#[test]
fn parse_page_style_and_body_with_no_style_block() {
let head = node("head", &[], vec![]);
let body = node("body", &[], vec![]);
let d = vec![elem(node("html", &[], vec![elem(head), elem(body)]))];
let (css, body) = parse_page_style_and_body(&d).expect("ok");
assert!(css.rules.as_ref().is_empty());
assert_eq!(body.children.as_ref().len(), 0);
}
// ================================================================
// str_to_rust_code / str_to_c_code / str_to_cpp_code / str_to_python_code
// ================================================================
#[test]
fn str_to_rust_code_empty_input_is_an_error_not_a_panic() {
let map = ComponentMap::with_builtin();
assert!(matches!(
str_to_rust_code(&[], "", &map),
Err(CompileError::Xml(DomXmlParseError::NoHtmlNode))
));
assert!(str_to_c_code(&[], &map).is_err());
assert!(str_to_cpp_code(&[], &map).is_err());
assert!(str_to_python_code(&[], &map).is_err());
}
#[test]
fn str_to_rust_code_whitespace_and_text_only_roots_are_errors() {
let map = ComponentMap::with_builtin();
for roots in [vec![txt(" ")], vec![txt("\t\n")], vec![txt("garbage")]] {
assert!(
str_to_rust_code(&roots, "", &map).is_err(),
"a document with no <html> element must be rejected"
);
}
}
#[test]
fn str_to_rust_code_valid_minimal() {
let map = ComponentMap::with_builtin();
let d = doc("", vec![elem(node("p", &[], vec![txt("Hi")]))]);
let src = str_to_rust_code(&d, "// imports", &map).expect("compiles");
assert!(src.contains("Dom::create_body()"), "got:\n{src}");
assert!(src.contains("Dom::create_p_with_text(AzString::from(\"Hi\"))"));
assert!(src.contains("// imports"), "the imports blob is spliced in");
assert!(src.contains("fn main()"));
}
#[test]
fn str_to_c_cpp_python_code_valid_minimal() {
let map = ComponentMap::with_builtin();
let d = doc("", vec![elem(node("p", &[], vec![txt("Hi")]))]);
let c = str_to_c_code(&d, &map).expect("compiles");
assert!(c.contains("#include \"azul.h\""), "got:\n{c}");
assert!(c.contains("AzDom n0 = AzDom_createBody();"));
assert!(c.contains("AzDom_createPWithText(AZ_STR(\"Hi\"))"));
let cpp = str_to_cpp_code(&d, &map).expect("compiles");
assert!(cpp.contains("#include \"azul20.hpp\""), "got:\n{cpp}");
assert!(cpp.contains("Dom::create_p_with_text(String(\"Hi\"))"));
let py = str_to_python_code(&d, &map).expect("compiles");
assert!(py.contains("import azul"), "got:\n{py}");
assert!(py.contains("azul.Dom.create_p_with_text(\"Hi\")"));
}
#[test]
fn compile_targets_escape_quotes_in_text_content() {
let map = ComponentMap::with_builtin();
let d = doc("", vec![elem(node("div", &[], vec![txt("say \"hi\"")]))]);
let rust = str_to_rust_code(&d, "", &map).expect("compiles");
assert!(rust.contains("say \\\"hi\\\""), "got:\n{rust}");
let c = str_to_c_code(&d, &map).expect("compiles");
assert!(c.contains("say \\\"hi\\\""), "got:\n{c}");
}
#[test]
fn compile_body_node_to_rust_code_on_an_empty_body() {
let map = ComponentMap::with_builtin();
let body = node("body", &[], vec![]);
let mut extra = VecContents::default();
let mut blocks = BTreeMap::new();
let out = compile_body_node_to_rust_code(
&body,
&map,
&mut extra,
&mut blocks,
&Css::empty(),
body_matcher(&body),
)
.expect("ok");
assert_eq!(out, "Dom::create_body()", "no children => no .with_children()");
}
#[test]
fn compile_body_node_to_rust_code_skips_whitespace_only_text_children() {
let map = ComponentMap::with_builtin();
let body = node("body", &[], vec![txt(" \n\t ")]);
let mut extra = VecContents::default();
let mut blocks = BTreeMap::new();
let out = compile_body_node_to_rust_code(
&body,
&map,
&mut extra,
&mut blocks,
&Css::empty(),
body_matcher(&body),
)
.expect("ok");
assert!(
!out.contains("create_text"),
"a whitespace-only text child emits nothing, got:\n{out}"
);
}
// ================================================================
// builtin_render_fn / builtin_compile_fn (numeric: indent)
// ================================================================
#[test]
fn builtin_render_fn_for_a_text_and_a_textless_element() {
let map = ComponentMap::with_builtin();
let div = map.get_unqualified("div").expect("builtin div");
assert!(matches!(
builtin_render_fn(div, &div.data_model, &map),
ResultStyledDomRenderDomError::Ok(_)
));
let p = map.get_unqualified("p").expect("builtin p");
assert!(matches!(
builtin_render_fn(p, &p.data_model, &map),
ResultStyledDomRenderDomError::Ok(_)
));
}
#[test]
fn builtin_compile_fn_ignores_indent_so_usize_max_is_safe() {
let map = ComponentMap::with_builtin();
let div = map.get_unqualified("div").expect("builtin div");
for indent in [0usize, 1, 1024, usize::MAX] {
match builtin_compile_fn(div, &CompileTarget::Rust, &div.data_model, indent) {
ResultStringCompileError::Ok(s) => assert_eq!(
s.as_str(),
"Dom::create_node(NodeType::Div)",
"indent is unused by builtin_compile_fn (indent={indent})"
),
ResultStringCompileError::Err(e) => panic!("unexpected error: {e:?}"),
}
}
}
#[test]
fn builtin_compile_fn_emits_text_and_escapes_it() {
let map = ComponentMap::with_builtin();
let p = map.get_unqualified("p").expect("builtin p");
let data = p
.data_model
.clone()
.with_default("text", ComponentDefaultValue::String(AzString::from("a\"b\\c")));
match builtin_compile_fn(p, &CompileTarget::Rust, &data, 0) {
ResultStringCompileError::Ok(s) => {
assert!(s.as_str().contains("a\\\"b\\\\c"), "got {}", s.as_str());
}
ResultStringCompileError::Err(e) => panic!("unexpected error: {e:?}"),
}
}
#[test]
fn builtin_compile_fn_covers_every_target() {
let map = ComponentMap::with_builtin();
let div = map.get_unqualified("div").expect("builtin div");
for target in [
CompileTarget::Rust,
CompileTarget::C,
CompileTarget::Cpp,
CompileTarget::Python,
] {
match builtin_compile_fn(div, &target, &div.data_model, 0) {
ResultStringCompileError::Ok(s) => {
assert!(!s.as_str().is_empty(), "{target:?} emitted nothing");
}
ResultStringCompileError::Err(e) => panic!("{target:?}: {e:?}"),
}
}
}
// ================================================================
// user_defined_render_fn / user_defined_compile_fn
// ================================================================
fn every_default_kind() -> Vec<ComponentDataField> {
use ComponentDefaultValue as D;
vec![
data_field("s", ComponentFieldType::String, Some(D::String(AzString::from("txt"))), ""),
data_field("b", ComponentFieldType::Bool, Some(D::Bool(true)), ""),
data_field("i32", ComponentFieldType::I32, Some(D::I32(i32::MIN)), ""),
data_field("i64", ComponentFieldType::I64, Some(D::I64(i64::MIN)), ""),
data_field("u32", ComponentFieldType::U32, Some(D::U32(u32::MAX)), ""),
data_field("u64", ComponentFieldType::U64, Some(D::U64(u64::MAX)), ""),
data_field("us", ComponentFieldType::Usize, Some(D::Usize(usize::MAX)), ""),
data_field("f32", ComponentFieldType::F32, Some(D::F32(f32::NAN)), ""),
data_field("f64", ComponentFieldType::F64, Some(D::F64(f64::INFINITY)), ""),
data_field(
"c",
ComponentFieldType::ColorU,
Some(D::ColorU(ColorU { r: 0, g: 0, b: 0, a: 0 })),
"",
),
data_field("cb", ComponentFieldType::StyledDom, Some(D::CallbackFnPointer(AzString::from("on_click"))), ""),
data_field("j", ComponentFieldType::String, Some(D::Json(AzString::from("{}"))), ""),
data_field("none", ComponentFieldType::String, Some(D::None), ""),
data_field("missing", ComponentFieldType::String, None, ""),
]
}
#[test]
fn user_defined_render_fn_handles_every_default_value_kind() {
let map = ComponentMap::with_builtin();
let def = user_def("", every_default_kind());
assert!(matches!(
user_defined_render_fn(&def, &def.data_model, &map),
ResultStyledDomRenderDomError::Ok(_)
));
}
#[test]
fn user_defined_render_fn_on_an_empty_model_and_with_css() {
let map = ComponentMap::with_builtin();
let empty = user_def("", Vec::new());
assert!(matches!(
user_defined_render_fn(&empty, &empty.data_model, &map),
ResultStyledDomRenderDomError::Ok(_)
));
let styled = user_def(".widget { color: red; }", Vec::new());
assert!(matches!(
user_defined_render_fn(&styled, &styled.data_model, &map),
ResultStyledDomRenderDomError::Ok(_)
));
}
#[test]
fn user_defined_render_fn_unknown_sub_component_renders_a_placeholder() {
let map = ComponentMap::create(); // empty: no library can resolve the instance
let def = user_def(
"",
vec![data_field(
"child",
ComponentFieldType::StyledDom,
Some(ComponentDefaultValue::ComponentInstance(ComponentInstanceDefault {
library: AzString::from("nope"),
component: AzString::from("missing"),
field_overrides: Vec::new().into(),
})),
"",
)],
);
assert!(
matches!(
user_defined_render_fn(&def, &def.data_model, &map),
ResultStyledDomRenderDomError::Ok(_)
),
"an unresolvable sub-component must render a placeholder, not error out"
);
}
#[test]
fn user_defined_compile_fn_indent_zero_and_every_target() {
let def = user_def("", every_default_kind());
for target in [
CompileTarget::Rust,
CompileTarget::C,
CompileTarget::Cpp,
CompileTarget::Python,
] {
match user_defined_compile_fn(&def, &target, &def.data_model, 0) {
ResultStringCompileError::Ok(s) => {
assert!(!s.as_str().is_empty(), "{target:?} emitted nothing");
}
ResultStringCompileError::Err(e) => panic!("{target:?}: {e:?}"),
}
}
}
#[test]
fn user_defined_compile_fn_indent_scales_the_leading_whitespace() {
// NOTE: `indent` is used as `" ".repeat(indent * 4)`, so it is NOT safe at
// usize::MAX (the multiply overflows). Exercise the realistic range.
let def = user_def("", Vec::new());
let mut prev = 0usize;
for indent in [0usize, 1, 2, 8] {
match user_defined_compile_fn(&def, &CompileTarget::Rust, &def.data_model, indent) {
ResultStringCompileError::Ok(s) => {
let len = s.as_str().len();
assert!(len > prev, "indent={indent} must widen the output");
prev = len;
}
ResultStringCompileError::Err(e) => panic!("indent={indent}: {e:?}"),
}
}
}
#[test]
fn user_defined_compile_fn_escapes_string_defaults() {
let def = user_def(
"",
vec![data_field(
"s",
ComponentFieldType::String,
Some(ComponentDefaultValue::String(AzString::from("a\"b\\c"))),
"",
)],
);
match user_defined_compile_fn(&def, &CompileTarget::Rust, &def.data_model, 0) {
ResultStringCompileError::Ok(s) => {
assert!(s.as_str().contains("a\\\"b\\\\c"), "got:\n{}", s.as_str());
}
ResultStringCompileError::Err(e) => panic!("{e:?}"),
}
}
#[test]
fn push_scalar_field_appends_one_div_per_call() {
let mut children: Vec<Dom> = Vec::new();
push_scalar_field(&mut children, "n", &i64::MIN);
push_scalar_field(&mut children, "", &f32::NAN);
push_scalar_field(&mut children, "\u{1F600}", &usize::MAX);
assert_eq!(children.len(), 3);
}
// ================================================================
// Structural builtins: if / for / map
// ================================================================
#[test]
fn builtin_if_for_map_component_defs_are_well_formed() {
for (def, model, field) in [
(builtin_if_component(), "IfData", "condition"),
(builtin_for_component(), "ForData", "count"),
(builtin_map_component(), "MapData", "data_json"),
] {
assert_eq!(def.id.collection.as_str(), "builtin");
assert_eq!(def.data_model.name.as_str(), model);
assert!(
def.data_model.get_field(field).is_some(),
"{model} must expose `{field}`"
);
}
}
#[test]
fn builtin_if_render_fn_defaults_to_the_else_branch() {
let map = ComponentMap::create();
let def = builtin_if_component();
// Missing / wrongly-typed condition => false, no panic.
let empty = dm("IfData", Vec::new());
assert!(matches!(
builtin_if_render_fn(&def, &empty, &map),
ResultStyledDomRenderDomError::Ok(_)
));
let truthy = def
.data_model
.clone()
.with_default("condition", ComponentDefaultValue::Bool(true));
assert!(matches!(
builtin_if_render_fn(&def, &truthy, &map),
ResultStyledDomRenderDomError::Ok(_)
));
}
#[test]
fn builtin_for_render_fn_handles_zero_and_a_wrongly_typed_count() {
let map = ComponentMap::create();
let def = builtin_for_component();
let zero = def
.data_model
.clone()
.with_default("count", ComponentDefaultValue::U32(0));
assert!(matches!(
builtin_for_render_fn(&def, &zero, &map),
ResultStyledDomRenderDomError::Ok(_)
));
// A non-U32 default falls back to the documented default of 3.
let wrong_type = def
.data_model
.clone()
.with_default("count", ComponentDefaultValue::String(AzString::from("9")));
assert!(matches!(
builtin_for_render_fn(&def, &wrong_type, &map),
ResultStyledDomRenderDomError::Ok(_)
));
}
#[test]
fn builtin_map_render_fn_defaults_to_an_empty_json_array() {
let map = ComponentMap::create();
let def = builtin_map_component();
assert!(matches!(
builtin_map_render_fn(&def, &dm("MapData", Vec::new()), &map),
ResultStyledDomRenderDomError::Ok(_)
));
let garbage = def
.data_model
.clone()
.with_default("data_json", ComponentDefaultValue::String(AzString::from("{{{")));
assert!(
matches!(
builtin_map_render_fn(&def, &garbage, &map),
ResultStyledDomRenderDomError::Ok(_)
),
"malformed JSON must not panic — it is only echoed into a label"
);
}
#[test]
fn structural_builtin_compile_fns_ignore_indent_entirely() {
let cases: [(ComponentDef, ComponentCompileFn); 3] = [
(builtin_if_component(), builtin_if_compile_fn),
(builtin_for_component(), builtin_for_compile_fn),
(builtin_map_component(), builtin_map_compile_fn),
];
for (def, f) in cases {
for target in [
CompileTarget::Rust,
CompileTarget::C,
CompileTarget::Cpp,
CompileTarget::Python,
] {
for indent in [0usize, usize::MAX] {
match f(&def, &target, &def.data_model, indent) {
ResultStringCompileError::Ok(s) => {
assert!(!s.as_str().is_empty(), "{target:?}/{indent} emitted nothing");
}
ResultStringCompileError::Err(e) => panic!("{target:?}: {e:?}"),
}
}
}
}
}
// ================================================================
// data_field / builtin_data_model / builtin_component_def
// ================================================================
#[test]
fn data_field_required_is_the_inverse_of_having_a_default() {
let with = data_field(
"x",
ComponentFieldType::String,
Some(ComponentDefaultValue::String(AzString::from("v"))),
"d",
);
assert!(!with.required);
assert_eq!(with.description.as_str(), "d");
let without = data_field("x", ComponentFieldType::String, None, "");
assert!(without.required);
assert!(matches!(
without.default_value,
OptionComponentDefaultValue::None
));
}
#[test]
fn builtin_data_model_unknown_tag_is_empty() {
assert!(builtin_data_model("").is_empty());
assert!(builtin_data_model("div").is_empty());
assert!(builtin_data_model("\u{1F600}").is_empty());
assert!(builtin_data_model(&"z".repeat(10_000)).is_empty());
}
#[test]
fn builtin_data_model_known_tags_expose_their_attributes() {
let a = builtin_data_model("a");
assert!(
a.iter().any(|f| f.name.as_str() == "href"),
"<a> must expose href"
);
// `src` on <img> is required (it has no default value).
let img = builtin_data_model("img");
let src = img
.iter()
.find(|f| f.name.as_str() == "src")
.expect("img has src");
assert!(src.required, "<img src> must be a required field");
// `img` and `image` share the same model.
assert_eq!(builtin_data_model("image").len(), img.len());
}
#[test]
fn builtin_component_def_default_text_controls_the_text_field() {
let with_text = builtin_component_def("p", "Paragraph", Some("Hi"), "");
assert_eq!(
with_text.data_model.get_default_string("text").map(AzString::as_str),
Some("Hi")
);
assert_eq!(with_text.data_model.name.as_str(), "ParagraphData");
assert_eq!(with_text.id.qualified_name(), "builtin:p");
let no_text = builtin_component_def("div", "Div", None, "");
assert!(
no_text.data_model.get_field("text").is_none(),
"a `None` default_text means the element has no text field at all"
);
// An empty-string default still creates the field.
let empty_text = builtin_component_def("span", "Span", Some(""), "");
assert!(empty_text.data_model.get_field("text").is_some());
assert_eq!(
empty_text.data_model.get_default_string("text").map(AzString::as_str),
Some("")
);
}
// ================================================================
// xml_attrs_to_data_model
// ================================================================
#[test]
fn xml_attrs_to_data_model_overrides_defaults_from_attributes() {
let base = builtin_component_def("a", "Link", Some("Link text"), "").data_model;
let model = xml_attrs_to_data_model(&base, &attrs(&[("href", "/x")]), None);
assert_eq!(
model.get_default_string("href").map(AzString::as_str),
Some("/x")
);
assert_eq!(
model.get_default_string("text").map(AzString::as_str),
Some("Link text"),
"un-supplied fields keep their defaults"
);
assert_eq!(
model.fields.as_ref().len(),
base.fields.as_ref().len(),
"no field is added or dropped"
);
}
#[test]
fn xml_attrs_to_data_model_text_content_is_prepared_and_empty_text_is_ignored() {
let base = builtin_component_def("a", "Link", Some("Link text"), "").data_model;
let with_text = xml_attrs_to_data_model(&base, &attrs(&[]), Some(" Hello & bye "));
assert_eq!(
with_text.get_default_string("text").map(AzString::as_str),
Some("Hello & bye"),
"text content is trimmed and entity-decoded"
);
let blank = xml_attrs_to_data_model(&base, &attrs(&[]), Some(" \n\t "));
assert_eq!(
blank.get_default_string("text").map(AzString::as_str),
Some("Link text"),
"whitespace-only text content leaves the default intact"
);
}
#[test]
fn xml_attrs_to_data_model_ignores_unknown_attributes() {
let base = builtin_component_def("a", "Link", Some(""), "").data_model;
let before = base.fields.as_ref().len();
let model = xml_attrs_to_data_model(
&base,
&attrs(&[("data-nonsense", "1"), ("", ""), ("\u{1F600}", "x")]),
None,
);
assert_eq!(
model.fields.as_ref().len(),
before,
"unknown attributes must not create fields"
);
}
// ================================================================
// DomXml
// ================================================================
#[test]
fn dom_xml_into_styled_dom_matches_the_from_impl() {
let via_method: StyledDom = DomXml::default().into_styled_dom();
let via_from: StyledDom = DomXml::default().into();
assert_eq!(
via_method, via_from,
"into_styled_dom() must be exactly the From<DomXml> impl"
);
}
// ================================================================
// Display impls (serializer)
// ================================================================
fn pos() -> XmlTextPos {
XmlTextPos {
row: u32::MAX,
col: 0,
}
}
#[test]
fn xml_text_pos_display_is_non_empty_for_edge_values() {
assert_eq!(
format!("{}", XmlTextPos { row: 0, col: 0 }),
"line 0:0",
"a zero position is still rendered"
);
assert_eq!(
format!(
"{}",
XmlTextPos {
row: u32::MAX,
col: u32::MAX
}
),
"line 4294967295:4294967295"
);
}
#[test]
fn xml_stream_error_display_covers_every_variant() {
let variants = vec![
XmlStreamError::UnexpectedEndOfStream,
XmlStreamError::InvalidName,
XmlStreamError::NonXmlChar(NonXmlCharError {
ch: u32::MAX,
pos: pos(),
}),
XmlStreamError::InvalidChar(InvalidCharError {
expected: u8::MAX,
got: 0,
pos: pos(),
}),
XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
expected: 0,
got: Vec::<u8>::new().into(),
pos: pos(),
}),
XmlStreamError::InvalidQuote(InvalidQuoteError { got: 0, pos: pos() }),
XmlStreamError::InvalidSpace(InvalidSpaceError { got: 0, pos: pos() }),
XmlStreamError::InvalidString(InvalidStringError {
got: AzString::from(""),
pos: pos(),
}),
XmlStreamError::InvalidReference,
XmlStreamError::InvalidExternalID,
XmlStreamError::InvalidCommentData,
XmlStreamError::InvalidCommentEnd,
XmlStreamError::InvalidCharacterData,
];
for v in &variants {
let s = format!("{v}");
assert!(!s.is_empty(), "{v:?} must render a non-empty message");
}
// `char::from_u32(u32::MAX)` is None — the formatter must not unwrap it.
assert!(format!("{}", variants[2]).contains("None"));
}
#[test]
fn xml_parse_error_display_covers_every_variant() {
let te = XmlTextError {
stream_error: XmlStreamError::InvalidName,
pos: pos(),
};
let variants = vec![
XmlParseError::InvalidDeclaration(te.clone()),
XmlParseError::InvalidComment(te.clone()),
XmlParseError::InvalidPI(te.clone()),
XmlParseError::InvalidDoctype(te.clone()),
XmlParseError::InvalidEntity(te.clone()),
XmlParseError::InvalidElement(te.clone()),
XmlParseError::InvalidAttribute(te.clone()),
XmlParseError::InvalidCdata(te.clone()),
XmlParseError::InvalidCharData(te),
XmlParseError::UnknownToken(pos()),
];
for v in &variants {
assert!(!format!("{v}").is_empty(), "{v:?} must render");
}
}
#[test]
fn xml_error_display_covers_the_non_css_variants() {
let variants = vec![
XmlError::NoParserAvailable,
XmlError::InvalidXmlPrefixUri(pos()),
XmlError::UnexpectedXmlUri(pos()),
XmlError::UnexpectedXmlnsUri(pos()),
XmlError::InvalidElementNamePrefix(pos()),
XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
ns: AzString::from(""),
pos: pos(),
}),
XmlError::UnknownNamespace(UnknownNamespaceError {
ns: AzString::from("\u{1F600}"),
pos: pos(),
}),
XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
expected: AzString::from("a"),
actual: AzString::from("b"),
pos: pos(),
}),
XmlError::UnexpectedEntityCloseTag(pos()),
XmlError::UnknownEntityReference(UnknownEntityReferenceError {
entity: AzString::from("x"),
pos: pos(),
}),
XmlError::MalformedEntityReference(pos()),
XmlError::EntityReferenceLoop(pos()),
XmlError::InvalidAttributeValue(pos()),
XmlError::DuplicatedAttribute(DuplicatedAttributeError {
attribute: AzString::from("id"),
pos: pos(),
}),
XmlError::NoRootNode,
XmlError::SizeLimit,
XmlError::DtdDetected,
XmlError::MalformedHierarchy(MalformedHierarchyError {
expected: AzString::from("app"),
got: AzString::from("p"),
}),
XmlError::ParserError(XmlParseError::UnknownToken(pos())),
XmlError::UnclosedRootNode,
XmlError::UnexpectedDeclaration(pos()),
XmlError::NodesLimitReached,
XmlError::AttributesLimitReached,
XmlError::NamespacesLimitReached,
XmlError::InvalidName(pos()),
XmlError::NonXmlChar(pos()),
XmlError::InvalidChar(pos()),
XmlError::InvalidChar2(pos()),
XmlError::InvalidString(pos()),
XmlError::InvalidExternalID(pos()),
XmlError::InvalidComment(pos()),
XmlError::InvalidCharacterData(pos()),
XmlError::UnknownToken(pos()),
XmlError::UnexpectedEndOfStream,
];
for v in &variants {
assert!(!format!("{v}").is_empty(), "{v:?} must render");
}
}
#[test]
fn component_and_render_and_compile_error_display() {
let unknown = ComponentError::UnknownComponent(AzString::from("\u{1F600}"));
assert!(format!("{unknown}").contains("Unknown component"));
let useless = ComponentError::UselessFunctionArgument(UselessFunctionArgumentError {
component_name: AzString::from("c"),
argument_name: AzString::from("a"),
valid_args: Vec::new().into(),
});
assert!(!format!("{useless}").is_empty());
let render: RenderDomError = unknown.clone().into();
assert!(!format!("{render}").is_empty());
let compile: CompileError = render.clone().into();
assert!(!format!("{compile}").is_empty());
let dom_xml: DomXmlParseError = render.into();
assert!(!format!("{dom_xml}").is_empty());
let compile2: CompileError = dom_xml.into();
assert!(!format!("{compile2}").is_empty());
}
#[test]
fn dom_xml_parse_error_display_covers_the_non_css_variants() {
let variants = vec![
DomXmlParseError::NoHtmlNode,
DomXmlParseError::MultipleHtmlRootNodes,
DomXmlParseError::NoBodyInHtml,
DomXmlParseError::MultipleBodyNodes,
DomXmlParseError::Xml(XmlError::NoRootNode),
DomXmlParseError::MalformedHierarchy(MalformedHierarchyError {
expected: AzString::from("app"),
got: AzString::from("p"),
}),
DomXmlParseError::RenderDom(RenderDomError::Component(
ComponentError::UnknownComponent(AzString::from("x")),
)),
DomXmlParseError::Component(ComponentParseError::NotAComponent),
];
for v in &variants {
assert!(!format!("{v}").is_empty(), "{v:?} must render");
}
}
#[test]
fn component_parse_error_display_covers_the_non_css_variants() {
let variants = vec![
ComponentParseError::NotAComponent,
ComponentParseError::UnnamedComponent,
ComponentParseError::MissingName(usize::MAX),
ComponentParseError::MissingType(MissingTypeError {
arg_pos: 0,
arg_name: AzString::from(""),
}),
ComponentParseError::WhiteSpaceInComponentName(WhiteSpaceInComponentNameError {
arg_pos: usize::MAX,
arg_name: AzString::from("a b"),
}),
ComponentParseError::WhiteSpaceInComponentType(WhiteSpaceInComponentTypeError {
arg_pos: 0,
arg_name: AzString::from("a"),
arg_type: AzString::from("b c"),
}),
];
for v in &variants {
assert!(!format!("{v}").is_empty(), "{v:?} must render");
}
}
// ================================================================
// serde-json gated: ComponentDataModel::to_json / from_json
// ================================================================
#[cfg(feature = "serde-json")]
#[test]
fn data_model_to_json_round_trips() {
let m = model_with_text();
let json = m.to_json().expect("serializes");
let back = ComponentDataModel::from_json(&json).expect("deserializes");
assert_eq!(back.name.as_str(), m.name.as_str());
assert_eq!(back.fields.as_ref().len(), m.fields.as_ref().len());
assert_eq!(back.get_default_string("text").map(AzString::as_str), Some("hi"));
}
#[cfg(feature = "serde-json")]
#[test]
fn data_model_from_json_rejects_garbage_without_panicking() {
for s in [
"",
" ",
"\t\n",
"not json",
"{",
"[]",
"null",
"0",
"-0",
"9223372036854775807",
"NaN",
"\u{1F600}",
] {
assert!(
ComponentDataModel::from_json(s).is_err(),
"{s:?} is not a data model"
);
}
}
#[cfg(feature = "serde-json")]
#[test]
fn data_model_from_json_deeply_nested_input_does_not_stack_overflow() {
let bomb = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
assert!(
ComponentDataModel::from_json(&bomb).is_err(),
"serde_json must reject the nesting bomb, not crash"
);
}
#[cfg(feature = "serde-json")]
#[test]
fn data_model_to_json_on_an_empty_model() {
let m = dm("Empty", Vec::new());
let json = m.to_json().expect("serializes");
assert!(json.contains("\"fields\""), "got {json}");
assert!(ComponentDataModel::from_json(&json).is_ok());
}
}