Skip to main content

azul_core/
xml.rs

1//! XML and XHTML parsing for declarative UI definitions.
2//!
3//! This module provides comprehensive XML parsing and manipulation for Azul's XML-based
4//! UI format (`.azul` files). It supports:
5//!
6//! - **XHTML parsing**: Parse HTML-like syntax into DOM structures
7//! - **CSS extraction**: Extract `<style>` blocks and inline styles
8//! - **Component system**: Define reusable UI components with arguments
9//! - **Hot reload**: Track file changes and rebuild UI incrementally
10//! - **Error reporting**: Detailed syntax error messages with line/column info
11//!
12//! # Examples
13//!
14//! ```rust,no_run,ignore
15//! use azul_core::xml::{XmlNode, XmlParseOptions};
16//!
17//! let xml = "<div>Hello</div>";
18//! // let node = XmlNode::parse(xml)?;
19//! ```
20
21use alloc::{
22    boxed::Box,
23    collections::BTreeMap,
24    string::{String, ToString},
25    vec::Vec,
26};
27use core::{fmt, fmt::Write, hash::Hash};
28
29use azul_css::{
30    css::{
31        Css, CssDeclaration, CssPath, CssPathPseudoSelector, CssPathSelector, CssRuleBlock,
32        NodeTypeTag,
33    },
34    codegen::format::VecContents,
35    parser2::{CssParseErrorOwned, ErrorLocation},
36    props::{
37        basic::{ColorU, StyleFontFamilyVec},
38        property::CssProperty,
39        style::{
40            NormalizedLinearColorStopVec, NormalizedRadialColorStopVec, StyleBackgroundContentVec,
41            StyleBackgroundPositionVec, StyleBackgroundRepeatVec, StyleBackgroundSizeVec,
42            StyleTransformVec,
43        },
44    },
45    AzString, OptionString, StringVec, U8Vec,
46};
47
48use crate::{
49    dom::{Dom, NodeType, OptionNodeType},
50    styled_dom::StyledDom,
51    window::{AzStringPair, StringPairVec},
52};
53
54/// Error that can occur during XML parsing or hot-reload.
55///
56/// Stringified for error reporting; not part of the public API.
57pub type SyntaxError = String;
58
59/// Tag of an XML node, such as the "button" in `<button>Hello</button>`.
60#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
61#[repr(C)]
62pub struct XmlTagName {
63    pub inner: AzString,
64}
65
66impl From<AzString> for XmlTagName {
67    fn from(s: AzString) -> Self {
68        Self { inner: s }
69    }
70}
71
72impl From<String> for XmlTagName {
73    fn from(s: String) -> Self {
74        Self { inner: s.into() }
75    }
76}
77
78impl From<&str> for XmlTagName {
79    fn from(s: &str) -> Self {
80        Self { inner: s.into() }
81    }
82}
83
84impl core::ops::Deref for XmlTagName {
85    type Target = AzString;
86    fn deref(&self) -> &Self::Target {
87        &self.inner
88    }
89}
90
91/// (Unparsed) text content of an XML node, such as the "Hello" in `<button>Hello</button>`.
92pub type XmlTextContent = OptionString;
93
94/// Attributes of an XML node, such as `["color" => "blue"]` in `<button color="blue" />`.
95#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
96#[repr(C)]
97pub struct XmlAttributeMap {
98    pub inner: StringPairVec,
99}
100
101impl From<StringPairVec> for XmlAttributeMap {
102    fn from(v: StringPairVec) -> Self {
103        Self { inner: v }
104    }
105}
106
107impl core::ops::Deref for XmlAttributeMap {
108    type Target = StringPairVec;
109    fn deref(&self) -> &Self::Target {
110        &self.inner
111    }
112}
113
114impl core::ops::DerefMut for XmlAttributeMap {
115    fn deref_mut(&mut self) -> &mut Self::Target {
116        &mut self.inner
117    }
118}
119
120/// Name of a component argument (e.g. `"text"`, `"href"`).
121type ComponentArgumentName = String;
122/// Type of a component argument as a string (e.g. `"String"`, `"bool"`).
123type ComponentArgumentType = String;
124/// Zero-based position of an argument in the component's argument list.
125type ComponentArgumentOrder = usize;
126
127/// FFI-safe replacement for `(ComponentArgumentName, ComponentArgumentType)` tuple.
128#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
129#[repr(C)]
130pub struct ComponentArgument {
131    pub name: AzString,
132    pub arg_type: AzString,
133}
134
135impl_vec!(
136    ComponentArgument,
137    ComponentArgumentVec,
138    ComponentArgumentVecDestructor,
139    ComponentArgumentVecDestructorType,
140    ComponentArgumentVecSlice,
141    OptionComponentArgument
142);
143impl_option!(
144    ComponentArgument,
145    OptionComponentArgument,
146    copy = false,
147    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
148);
149impl_vec_debug!(ComponentArgument, ComponentArgumentVec);
150impl_vec_partialeq!(ComponentArgument, ComponentArgumentVec);
151impl_vec_eq!(ComponentArgument, ComponentArgumentVec);
152impl_vec_partialord!(ComponentArgument, ComponentArgumentVec);
153impl_vec_ord!(ComponentArgument, ComponentArgumentVec);
154impl_vec_hash!(ComponentArgument, ComponentArgumentVec);
155impl_vec_clone!(
156    ComponentArgument,
157    ComponentArgumentVec,
158    ComponentArgumentVecDestructor
159);
160impl_vec_mut!(ComponentArgument, ComponentArgumentVec);
161
162/// Holds the list of arguments and whether the component accepts text content.
163/// Used by the compile pipeline to generate Rust function signatures.
164#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
165pub struct ComponentArguments {
166    pub args: ComponentArgumentVec,
167    pub accepts_text: bool,
168}
169
170/// Name of an XML/HTML component (e.g. `"button"`, `"my-widget"`).
171type ComponentName = String;
172/// Compiled source code string for a component.
173type CompiledComponent = String;
174
175/// Universal HTML attribute names that are handled by the framework
176/// and should not be passed through to component-specific argument lists.
177const DEFAULT_ARGS: [&str; 8] = [
178    "id",
179    "class",
180    "tabindex",
181    "focusable",
182    "accepts_text",
183    "name",
184    "style",
185    "args",
186];
187
188/// Opaque void type for FFI pointers. Uses a custom definition instead of
189/// `core::ffi::c_void` for `#[repr(C)]` compatibility in the generated API.
190#[allow(non_camel_case_types)]
191#[derive(Debug, Copy, Clone)]
192pub enum c_void {}
193
194/// Type of an XML node in the parsed tree.
195#[repr(C)]
196#[derive(Debug, Copy, Clone)]
197pub enum XmlNodeType {
198    Root,
199    Element,
200    PI,
201    Comment,
202    Text,
203}
204
205/// A namespace-qualified XML name (e.g. `svg:rect` has namespace `"svg"` and local name `"rect"`).
206#[repr(C)]
207#[derive(Debug)]
208pub struct XmlQualifiedName {
209    pub local_name: AzString,
210    pub namespace: OptionString,
211}
212
213/// Classification of an external resource referenced in HTML/XML
214#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
215#[repr(C)]
216pub enum ExternalResourceKind {
217    /// Image resource (img src, background-image, etc.)
218    Image,
219    /// Font resource (@font-face src, link rel="preload" as="font")
220    Font,
221    /// Stylesheet (link rel="stylesheet", @import)
222    Stylesheet,
223    /// Script (script src)
224    Script,
225    /// Favicon or icon
226    Icon,
227    /// Video source
228    Video,
229    /// Audio source
230    Audio,
231    /// Generic link or unknown resource type
232    Unknown,
233}
234
235/// MIME type hint for an external resource
236#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
237#[repr(C)]
238pub struct MimeTypeHint {
239    pub inner: AzString,
240}
241
242impl MimeTypeHint {
243    #[must_use] pub fn new(s: &str) -> Self {
244        Self {
245            inner: AzString::from(s),
246        }
247    }
248
249    #[must_use] pub fn from_extension(ext: &str) -> Self {
250        let mime = match ext.to_lowercase().as_str() {
251            // Images
252            "png" => "image/png",
253            "jpg" | "jpeg" => "image/jpeg",
254            "gif" => "image/gif",
255            "webp" => "image/webp",
256            "svg" => "image/svg+xml",
257            "ico" => "image/x-icon",
258            "bmp" => "image/bmp",
259            "avif" => "image/avif",
260            // Fonts
261            "ttf" => "font/ttf",
262            "otf" => "font/otf",
263            "woff" => "font/woff",
264            "woff2" => "font/woff2",
265            "eot" => "application/vnd.ms-fontobject",
266            // Stylesheets
267            "css" => "text/css",
268            // Scripts
269            "js" | "mjs" => "application/javascript",
270            // Video
271            "mp4" => "video/mp4",
272            "webm" => "video/webm",
273            "ogg" => "video/ogg",
274            // Audio
275            "mp3" => "audio/mpeg",
276            "wav" => "audio/wav",
277            "flac" => "audio/flac",
278            // Default
279            _ => "application/octet-stream",
280        };
281        Self {
282            inner: AzString::from(mime),
283        }
284    }
285}
286
287impl_option!(
288    MimeTypeHint,
289    OptionMimeTypeHint,
290    copy = false,
291    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
292);
293
294/// An external resource URL found in an XML/HTML document
295#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
296#[repr(C)]
297pub struct ExternalResource {
298    /// The URL as found in the document (may be relative or absolute)
299    pub url: AzString,
300    /// Classification of the resource type
301    pub kind: ExternalResourceKind,
302    /// MIME type hint (from type attribute, file extension, or heuristics)
303    pub mime_type: OptionMimeTypeHint,
304    /// The HTML element that referenced this resource (e.g., "img", "link", "script")
305    pub source_element: AzString,
306    /// The attribute that contained the URL (e.g., "src", "href")
307    pub source_attribute: AzString,
308}
309
310impl_option!(
311    ExternalResource,
312    OptionExternalResource,
313    copy = false,
314    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
315);
316
317impl_vec!(
318    ExternalResource,
319    ExternalResourceVec,
320    ExternalResourceVecDestructor,
321    ExternalResourceVecDestructorType,
322    ExternalResourceVecSlice,
323    OptionExternalResource
324);
325impl_vec_mut!(ExternalResource, ExternalResourceVec);
326impl_vec_debug!(ExternalResource, ExternalResourceVec);
327impl_vec_partialeq!(ExternalResource, ExternalResourceVec);
328impl_vec_eq!(ExternalResource, ExternalResourceVec);
329impl_vec_partialord!(ExternalResource, ExternalResourceVec);
330impl_vec_ord!(ExternalResource, ExternalResourceVec);
331impl_vec_hash!(ExternalResource, ExternalResourceVec);
332impl_vec_clone!(
333    ExternalResource,
334    ExternalResourceVec,
335    ExternalResourceVecDestructor
336);
337
338/// AUDIT 2026-07-08: maximum XML/HTML nesting depth handled by the recursive
339/// DOM-build (`xml_node_to_dom_fast`, `xml_node_to_fast_dom`), resource-scan
340/// (iterative worklist in `scan_external_resources`) and `<body>`-lookup
341/// (`find_body_recursive`) passes. These bound descent per nesting level, so a pathologically deep
342/// document (e.g. tens of thousands of nested `<div>`s) would overflow the native
343/// stack. Beyond this depth, deeper children are ignored rather than crashing.
344/// 512 is far past any realistic hand-authored markup while staying comfortably
345/// inside the default thread stack.
346const MAX_XML_NESTING_DEPTH: usize = 512;
347
348/// AUDIT 2026-07-08: maximum recursion depth for [`ComponentFieldType::parse`],
349/// which recurses through `Option<..>` / `Vec<..>` wrappers. Caps attacker
350/// strings such as `"Option<".repeat(100_000)` that would otherwise overflow the
351/// stack. 64 nested type wrappers is far beyond any real field type.
352const MAX_TYPE_PARSE_DEPTH: usize = 64;
353
354#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
355#[repr(C)]
356pub struct Xml {
357    pub root: XmlNodeChildVec,
358}
359
360impl Xml {
361    /// Scan the XML/HTML document for external resource URLs.
362    ///
363    /// This function traverses the entire document tree and extracts URLs from:
364    /// - `<img src="...">` - Images
365    /// - `<link href="...">` - Stylesheets, icons, fonts
366    /// - `<script src="...">` - Scripts
367    /// - `<video src="...">`, `<source src="...">` - Video
368    /// - `<audio src="...">` - Audio
369    /// - `<a href="...">` - Links (classified as Unknown)
370    /// - CSS `url()` in style attributes
371    /// - `<style>` blocks with @import or `url()`
372    #[must_use] pub fn scan_external_resources(&self) -> ExternalResourceVec {
373        let mut resources = Vec::new();
374
375        // AUDIT 2026-07-08: iterative DFS with an explicit worklist. The old
376        // per-node recursion overflowed the stack on pathologically deep markup
377        // (a single-purpose scan frame is large: string lowercasing + closure +
378        // wide match). An explicit stack keeps memory on the heap; `depth` still
379        // bounds how deep we descend so unbounded input can't grow the worklist
380        // without limit.
381        let mut stack: Vec<(&XmlNodeChild, usize)> = Vec::new();
382        for child in self.root.as_ref() {
383            stack.push((child, 0));
384        }
385        while let Some((child, depth)) = stack.pop() {
386            match child {
387                XmlNodeChild::Text(text) => {
388                    // CSS @import / url() in text content (inside <style> tags).
389                    Self::extract_css_urls(text.as_str(), &mut resources);
390                }
391                XmlNodeChild::Element(node) => {
392                    if depth > MAX_XML_NESTING_DEPTH {
393                        // Deeper subtrees are simply not scanned.
394                        continue;
395                    }
396                    Self::scan_node(node, &mut resources);
397                    for c in node.children.as_ref() {
398                        stack.push((c, depth + 1));
399                    }
400                }
401            }
402        }
403
404        resources.into()
405    }
406
407    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
408    fn scan_node(node: &XmlNode, resources: &mut Vec<ExternalResource>) {
409        let tag_name = node.node_type.inner.as_str().to_lowercase();
410
411        // Get attribute lookup helper
412        let get_attr = |name: &str| -> Option<String> {
413            node.attributes
414                .inner
415                .as_ref()
416                .iter()
417                .find(|pair| pair.key.as_str().eq_ignore_ascii_case(name))
418                .map(|pair| pair.value.as_str().to_string())
419        };
420
421        match tag_name.as_str() {
422            "img" => {
423                if let Some(src) = get_attr("src") {
424                    let mime = Self::guess_mime_from_url(&src, "image");
425                    resources.push(ExternalResource {
426                        url: AzString::from(src),
427                        kind: ExternalResourceKind::Image,
428                        mime_type: mime.into(),
429                        source_element: AzString::from("img"),
430                        source_attribute: AzString::from("src"),
431                    });
432                }
433                // Also check srcset
434                if let Some(srcset) = get_attr("srcset") {
435                    for src in Self::parse_srcset(&srcset) {
436                        let mime = Self::guess_mime_from_url(&src, "image");
437                        resources.push(ExternalResource {
438                            url: AzString::from(src),
439                            kind: ExternalResourceKind::Image,
440                            mime_type: mime.into(),
441                            source_element: AzString::from("img"),
442                            source_attribute: AzString::from("srcset"),
443                        });
444                    }
445                }
446            }
447            "link" => {
448                if let Some(href) = get_attr("href") {
449                    let rel = get_attr("rel").unwrap_or_default().to_lowercase();
450                    let type_attr = get_attr("type");
451                    let as_attr = get_attr("as").unwrap_or_default().to_lowercase();
452
453                    let (kind, category) = if rel.contains("stylesheet") {
454                        (ExternalResourceKind::Stylesheet, "stylesheet")
455                    } else if rel.contains("icon") || rel.contains("apple-touch-icon") {
456                        (ExternalResourceKind::Icon, "image")
457                    } else if as_attr == "font" {
458                        (ExternalResourceKind::Font, "font")
459                    } else if as_attr == "script" {
460                        (ExternalResourceKind::Script, "script")
461                    } else if as_attr == "image" {
462                        (ExternalResourceKind::Image, "image")
463                    } else {
464                        (ExternalResourceKind::Unknown, "")
465                    };
466
467                    let mime = type_attr
468                        .map(|t| MimeTypeHint::new(&t))
469                        .or_else(|| Self::guess_mime_from_url(&href, category));
470
471                    resources.push(ExternalResource {
472                        url: AzString::from(href),
473                        kind,
474                        mime_type: mime.into(),
475                        source_element: AzString::from("link"),
476                        source_attribute: AzString::from("href"),
477                    });
478                }
479            }
480            "script" => {
481                if let Some(src) = get_attr("src") {
482                    let type_attr = get_attr("type");
483                    let mime = type_attr
484                        .map(|t| MimeTypeHint::new(&t))
485                        .or_else(|| Some(MimeTypeHint::new("application/javascript")));
486
487                    resources.push(ExternalResource {
488                        url: AzString::from(src),
489                        kind: ExternalResourceKind::Script,
490                        mime_type: mime.into(),
491                        source_element: AzString::from("script"),
492                        source_attribute: AzString::from("src"),
493                    });
494                }
495            }
496            "video" => {
497                if let Some(src) = get_attr("src") {
498                    let mime = Self::guess_mime_from_url(&src, "video");
499                    resources.push(ExternalResource {
500                        url: AzString::from(src),
501                        kind: ExternalResourceKind::Video,
502                        mime_type: mime.into(),
503                        source_element: AzString::from("video"),
504                        source_attribute: AzString::from("src"),
505                    });
506                }
507                if let Some(poster) = get_attr("poster") {
508                    let mime = Self::guess_mime_from_url(&poster, "image");
509                    resources.push(ExternalResource {
510                        url: AzString::from(poster),
511                        kind: ExternalResourceKind::Image,
512                        mime_type: mime.into(),
513                        source_element: AzString::from("video"),
514                        source_attribute: AzString::from("poster"),
515                    });
516                }
517            }
518            "audio" => {
519                if let Some(src) = get_attr("src") {
520                    let mime = Self::guess_mime_from_url(&src, "audio");
521                    resources.push(ExternalResource {
522                        url: AzString::from(src),
523                        kind: ExternalResourceKind::Audio,
524                        mime_type: mime.into(),
525                        source_element: AzString::from("audio"),
526                        source_attribute: AzString::from("src"),
527                    });
528                }
529            }
530            "source" => {
531                if let Some(src) = get_attr("src") {
532                    let type_attr = get_attr("type");
533                    // Determine kind based on type or parent (heuristic: assume video)
534                    let kind = if type_attr
535                        .as_ref()
536                        .is_some_and(|t| t.starts_with("audio"))
537                    {
538                        ExternalResourceKind::Audio
539                    } else {
540                        ExternalResourceKind::Video
541                    };
542                    let mime = type_attr.map(|t| MimeTypeHint::new(&t)).or_else(|| {
543                        Self::guess_mime_from_url(
544                            &src,
545                            if kind == ExternalResourceKind::Audio {
546                                "audio"
547                            } else {
548                                "video"
549                            },
550                        )
551                    });
552
553                    resources.push(ExternalResource {
554                        url: AzString::from(src),
555                        kind,
556                        mime_type: mime.into(),
557                        source_element: AzString::from("source"),
558                        source_attribute: AzString::from("src"),
559                    });
560                }
561                // Also handle srcset for picture elements
562                if let Some(srcset) = get_attr("srcset") {
563                    for src in Self::parse_srcset(&srcset) {
564                        let mime = Self::guess_mime_from_url(&src, "image");
565                        resources.push(ExternalResource {
566                            url: AzString::from(src),
567                            kind: ExternalResourceKind::Image,
568                            mime_type: mime.into(),
569                            source_element: AzString::from("source"),
570                            source_attribute: AzString::from("srcset"),
571                        });
572                    }
573                }
574            }
575            "a" => {
576                if let Some(href) = get_attr("href") {
577                    // Only include if it looks like a resource, not a page link
578                    if Self::looks_like_resource(&href) {
579                        let mime = Self::guess_mime_from_url(&href, "");
580                        resources.push(ExternalResource {
581                            url: AzString::from(href),
582                            kind: ExternalResourceKind::Unknown,
583                            mime_type: mime.into(),
584                            source_element: AzString::from("a"),
585                            source_attribute: AzString::from("href"),
586                        });
587                    }
588                }
589            }
590            "virtualized-view" | "embed" | "object" => {
591                let src_attr = if tag_name == "object" { "data" } else { "src" };
592                if let Some(src) = get_attr(src_attr) {
593                    resources.push(ExternalResource {
594                        url: AzString::from(src),
595                        kind: ExternalResourceKind::Unknown,
596                        mime_type: OptionMimeTypeHint::None,
597                        source_element: AzString::from(tag_name.clone()),
598                        source_attribute: AzString::from(src_attr),
599                    });
600                }
601            }
602            "style" => {
603                // Scan text content for CSS URLs
604                for child in node.children.as_ref() {
605                    if let XmlNodeChild::Text(text) = child {
606                        Self::extract_css_urls(text.as_str(), resources);
607                    }
608                }
609            }
610            _ => {}
611        }
612
613        // Check inline style attribute for url()
614        if let Some(style) = get_attr("style") {
615            Self::extract_css_urls(&style, resources);
616        }
617
618        // Check for background attribute (deprecated but still used)
619        if let Some(bg) = get_attr("background") {
620            let mime = Self::guess_mime_from_url(&bg, "image");
621            resources.push(ExternalResource {
622                url: AzString::from(bg),
623                kind: ExternalResourceKind::Image,
624                mime_type: mime.into(),
625                source_element: AzString::from(tag_name),
626                source_attribute: AzString::from("background"),
627            });
628        }
629
630        // Children are walked by the iterative driver in `scan_external_resources`.
631    }
632
633    /// Extract URLs from CSS content (handles `url()` and @import)
634    fn extract_css_urls(css: &str, resources: &mut Vec<ExternalResource>) {
635        // AUDIT 2026-07-08: fold to lowercase ONCE using ASCII-only folding.
636        // `to_ascii_lowercase` never changes a string's byte length (only A-Z are
637        // touched, multi-byte code points are left verbatim), so every byte offset
638        // into `lower` maps 1:1 onto `css`. The old code called `to_lowercase()`
639        // every iteration (O(n^2)) and then sliced the ORIGINAL `css` with an
640        // offset computed in the lowercased temporary -- for characters whose
641        // lowercase changes byte length (e.g. 'İ' U+0130, 2 bytes -> 3 bytes) that
642        // offset landed off a char boundary and panicked. Searching in `lower` and
643        // slicing `css` at the same offset also makes the `url(` / `@import` scans
644        // case-insensitive for free.
645        let lower = css.to_ascii_lowercase();
646
647        // url(...) scan (case-insensitive)
648        let mut search_from = 0;
649        while let Some(rel) = lower[search_from..].find("url(") {
650            let url_start = search_from + rel;
651            let after = url_start + 4;
652            // Skip a `url(` that is the argument of an `@import` — the @import scan
653            // below emits it, correctly tagged as a Stylesheet. Without this guard the
654            // same URL is pushed twice (once here, mistagged "url()").
655            if lower[..url_start].trim_end().ends_with("@import") {
656                search_from = after;
657                continue;
658            }
659            let after_url = &css[after..];
660            if let Some(url) = Self::extract_url_value(after_url) {
661                let mime = Self::guess_mime_from_url(&url, "");
662                let kind = Self::guess_kind_from_url(&url);
663                resources.push(ExternalResource {
664                    url: AzString::from(url),
665                    kind,
666                    mime_type: mime.into(),
667                    source_element: AzString::from("style"),
668                    source_attribute: AzString::from("url()"),
669                });
670            }
671            search_from = after;
672        }
673
674        // Handle @import "url" or @import url(...) (case-insensitive)
675        let mut search_from = 0;
676        while let Some(rel) = lower[search_from..].find("@import") {
677            let after = search_from + rel + 7;
678            let after_import = &css[after..];
679            let trimmed = after_import.trim_start();
680
681            // Match `url(` case-insensitively without allocating. `get(..4)`
682            // returns `None` if byte 4 is not a char boundary, so the slice below
683            // can never panic on multi-byte input.
684            let import_url = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("url(")) {
685                Self::extract_url_value(&trimmed[4..])
686            } else {
687                Self::extract_quoted_string(trimmed)
688            };
689
690            if let Some(url) = import_url {
691                resources.push(ExternalResource {
692                    url: AzString::from(url),
693                    kind: ExternalResourceKind::Stylesheet,
694                    mime_type: Some(MimeTypeHint::new("text/css")).into(),
695                    source_element: AzString::from("style"),
696                    source_attribute: AzString::from("@import"),
697                });
698            }
699
700            search_from = after;
701        }
702    }
703
704    /// Extract value from url(...) - handles quoted and unquoted URLs
705    fn extract_url_value(s: &str) -> Option<String> {
706        let trimmed = s.trim_start();
707        if trimmed.starts_with('"') {
708            Self::extract_quoted_string(trimmed)
709        } else if let Some(rest) = trimmed.strip_prefix('\'') {
710            let end = rest.find('\'')?;
711            Some(rest[..end].to_string())
712        } else {
713            let end = trimmed.find(')')?;
714            Some(trimmed[..end].trim().to_string())
715        }
716    }
717
718    /// Extract a quoted string value
719    fn extract_quoted_string(s: &str) -> Option<String> {
720        if let Some(rest) = s.strip_prefix('"') {
721            let end = rest.find('"')?;
722            Some(rest[..end].to_string())
723        } else if let Some(rest) = s.strip_prefix('\'') {
724            let end = rest.find('\'')?;
725            Some(rest[..end].to_string())
726        } else {
727            None
728        }
729    }
730
731    /// Parse srcset attribute into individual URLs
732    fn parse_srcset(srcset: &str) -> Vec<String> {
733        srcset
734            .split(',')
735            .filter_map(|entry| {
736                let trimmed = entry.trim();
737                // srcset format: "url 1x" or "url 100w"
738                trimmed.split_whitespace().next().map(alloc::string::ToString::to_string)
739            })
740            .filter(|url| !url.is_empty())
741            .collect()
742    }
743
744    /// Check if a URL looks like a downloadable resource (not a page)
745    fn looks_like_resource(url: &str) -> bool {
746        let lower = url.to_lowercase();
747        // Check for common resource extensions
748        let resource_exts = [
749            ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico", ".bmp", ".ttf", ".otf",
750            ".woff", ".woff2", ".eot", ".css", ".js", ".mp4", ".webm", ".ogg", ".mp3", ".wav",
751            ".pdf", ".zip", ".tar", ".gz",
752        ];
753        resource_exts.iter().any(|ext| lower.ends_with(ext))
754    }
755
756    /// Guess the resource kind from URL based on file extension.
757    // `url` is lowercased into `path` below, so these literal `.ext` checks are
758    // already case-insensitive — the lint can't see the runtime lowercasing.
759    #[allow(clippy::case_sensitive_file_extension_comparisons)]
760    fn guess_kind_from_url(url: &str) -> ExternalResourceKind {
761        let lower = url.to_lowercase();
762        // Strip query string before checking extension
763        let path = lower.split('?').next().unwrap_or(&lower);
764        if path.ends_with(".png")
765            || path.ends_with(".jpg")
766            || path.ends_with(".jpeg")
767            || path.ends_with(".gif")
768            || path.ends_with(".webp")
769            || path.ends_with(".svg")
770            || path.ends_with(".bmp")
771            || path.ends_with(".avif")
772        {
773            ExternalResourceKind::Image
774        } else if path.ends_with(".ttf")
775            || path.ends_with(".otf")
776            || path.ends_with(".woff")
777            || path.ends_with(".woff2")
778            || path.ends_with(".eot")
779        {
780            ExternalResourceKind::Font
781        } else if path.ends_with(".css") {
782            ExternalResourceKind::Stylesheet
783        } else if path.ends_with(".js") || path.ends_with(".mjs") {
784            ExternalResourceKind::Script
785        } else if path.ends_with(".mp4") || path.ends_with(".webm") || path.ends_with(".ogg") {
786            ExternalResourceKind::Video
787        } else if path.ends_with(".mp3") || path.ends_with(".wav") || path.ends_with(".flac") {
788            ExternalResourceKind::Audio
789        } else if path.ends_with(".ico") {
790            ExternalResourceKind::Icon
791        } else {
792            ExternalResourceKind::Unknown
793        }
794    }
795
796    /// Guess MIME type from URL based on extension
797    fn guess_mime_from_url(url: &str, category: &str) -> Option<MimeTypeHint> {
798        let lower = url.to_lowercase();
799        // Find extension
800        let ext = lower.rsplit('.').next()?;
801        // Remove query string if present
802        let ext = ext.split('?').next()?;
803
804        // Check if it's a valid extension
805        let valid_exts = [
806            "png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif", "ttf", "otf", "woff",
807            "woff2", "eot", "css", "js", "mjs", "mp4", "webm", "ogg", "mp3", "wav", "flac",
808        ];
809
810        if valid_exts.contains(&ext) {
811            Some(MimeTypeHint::from_extension(ext))
812        } else if !category.is_empty() {
813            // Use category hint for default
814            match category {
815                "image" => Some(MimeTypeHint::new("image/*")),
816                "font" => Some(MimeTypeHint::new("font/*")),
817                "stylesheet" => Some(MimeTypeHint::new("text/css")),
818                "script" => Some(MimeTypeHint::new("application/javascript")),
819                "video" => Some(MimeTypeHint::new("video/*")),
820                "audio" => Some(MimeTypeHint::new("audio/*")),
821                _ => None,
822            }
823        } else {
824            None
825        }
826    }
827}
828
829#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
830#[repr(C)]
831pub struct NonXmlCharError {
832    pub ch: u32, /* u32 = char, but ABI stable */
833    pub pos: XmlTextPos,
834}
835
836#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
837#[repr(C)]
838pub struct InvalidCharError {
839    pub expected: u8,
840    pub got: u8,
841    pub pos: XmlTextPos,
842}
843
844#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
845#[repr(C)]
846pub struct InvalidCharMultipleError {
847    pub expected: u8,
848    pub got: U8Vec,
849    pub pos: XmlTextPos,
850}
851
852#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
853#[repr(C)]
854pub struct InvalidQuoteError {
855    pub got: u8,
856    pub pos: XmlTextPos,
857}
858
859#[derive(Debug, PartialEq, Eq, PartialOrd, Clone, Copy)]
860#[repr(C)]
861pub struct InvalidSpaceError {
862    pub got: u8,
863    pub pos: XmlTextPos,
864}
865
866#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
867#[repr(C)]
868pub struct InvalidStringError {
869    pub got: AzString,
870    pub pos: XmlTextPos,
871}
872
873#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
874#[repr(C, u8)]
875pub enum XmlStreamError {
876    UnexpectedEndOfStream,
877    InvalidName,
878    NonXmlChar(NonXmlCharError),
879    InvalidChar(InvalidCharError),
880    InvalidCharMultiple(InvalidCharMultipleError),
881    InvalidQuote(InvalidQuoteError),
882    InvalidSpace(InvalidSpaceError),
883    InvalidString(InvalidStringError),
884    InvalidReference,
885    InvalidExternalID,
886    InvalidCommentData,
887    InvalidCommentEnd,
888    InvalidCharacterData,
889}
890
891impl fmt::Display for XmlStreamError {
892    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
893        use self::XmlStreamError::{UnexpectedEndOfStream, InvalidName, NonXmlChar, InvalidChar, InvalidCharMultiple, InvalidQuote, InvalidSpace, InvalidString, InvalidReference, InvalidExternalID, InvalidCommentData, InvalidCommentEnd, InvalidCharacterData};
894        match self {
895            UnexpectedEndOfStream => write!(f, "Unexpected end of stream"),
896            InvalidName => write!(f, "Invalid name"),
897            NonXmlChar(nx) => write!(
898                f,
899                "Non-XML character: {:?} at {}",
900                core::char::from_u32(nx.ch),
901                nx.pos
902            ),
903            InvalidChar(ic) => write!(
904                f,
905                "Invalid character: expected: {}, got: {} at {}",
906                ic.expected as char, ic.got as char, ic.pos
907            ),
908            InvalidCharMultiple(imc) => write!(
909                f,
910                "Multiple invalid characters: expected: {}, got: {:?} at {}",
911                imc.expected,
912                imc.got.as_ref(),
913                imc.pos
914            ),
915            InvalidQuote(iq) => write!(f, "Invalid quote: got {} at {}", iq.got as char, iq.pos),
916            InvalidSpace(is) => write!(f, "Invalid space: got {} at {}", is.got as char, is.pos),
917            InvalidString(ise) => write!(
918                f,
919                "Invalid string: got \"{}\" at {}",
920                ise.got.as_str(),
921                ise.pos
922            ),
923            InvalidReference => write!(f, "Invalid reference"),
924            InvalidExternalID => write!(f, "Invalid external ID"),
925            InvalidCommentData => write!(f, "Invalid comment data"),
926            InvalidCommentEnd => write!(f, "Invalid comment end"),
927            InvalidCharacterData => write!(f, "Invalid character data"),
928        }
929    }
930}
931
932#[derive(Debug, PartialEq, PartialOrd, Clone, Copy, Ord, Hash, Eq)]
933#[repr(C)]
934pub struct XmlTextPos {
935    pub row: u32,
936    pub col: u32,
937}
938
939impl fmt::Display for XmlTextPos {
940    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
941        write!(f, "line {}:{}", self.row, self.col)
942    }
943}
944
945#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
946#[repr(C)]
947pub struct XmlTextError {
948    pub stream_error: XmlStreamError,
949    pub pos: XmlTextPos,
950}
951
952#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
953#[repr(C, u8)]
954pub enum XmlParseError {
955    InvalidDeclaration(XmlTextError),
956    InvalidComment(XmlTextError),
957    InvalidPI(XmlTextError),
958    InvalidDoctype(XmlTextError),
959    InvalidEntity(XmlTextError),
960    InvalidElement(XmlTextError),
961    InvalidAttribute(XmlTextError),
962    InvalidCdata(XmlTextError),
963    InvalidCharData(XmlTextError),
964    UnknownToken(XmlTextPos),
965}
966
967impl fmt::Display for XmlParseError {
968    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
969        use self::XmlParseError::{InvalidDeclaration, InvalidComment, InvalidPI, InvalidDoctype, InvalidEntity, InvalidElement, InvalidAttribute, InvalidCdata, InvalidCharData, UnknownToken};
970        match self {
971            InvalidDeclaration(e) => {
972                write!(f, "Invalid declaration: {} at {}", e.stream_error, e.pos)
973            }
974            InvalidComment(e) => write!(f, "Invalid comment: {} at {}", e.stream_error, e.pos),
975            InvalidPI(e) => write!(
976                f,
977                "Invalid processing instruction: {} at {}",
978                e.stream_error, e.pos
979            ),
980            InvalidDoctype(e) => write!(f, "Invalid doctype: {} at {}", e.stream_error, e.pos),
981            InvalidEntity(e) => write!(f, "Invalid entity: {} at {}", e.stream_error, e.pos),
982            InvalidElement(e) => write!(f, "Invalid element: {} at {}", e.stream_error, e.pos),
983            InvalidAttribute(e) => write!(f, "Invalid attribute: {} at {}", e.stream_error, e.pos),
984            InvalidCdata(e) => write!(f, "Invalid CDATA: {} at {}", e.stream_error, e.pos),
985            InvalidCharData(e) => write!(f, "Invalid char data: {} at {}", e.stream_error, e.pos),
986            UnknownToken(e) => write!(f, "Unknown token at {e}"),
987        }
988    }
989}
990
991impl_result!(
992    Xml,
993    XmlError,
994    ResultXmlXmlError,
995    copy = false,
996    [Debug, PartialEq, Eq, PartialOrd, Clone]
997);
998
999#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1000#[repr(C)]
1001pub struct DuplicatedNamespaceError {
1002    pub ns: AzString,
1003    pub pos: XmlTextPos,
1004}
1005
1006#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1007#[repr(C)]
1008pub struct UnknownNamespaceError {
1009    pub ns: AzString,
1010    pub pos: XmlTextPos,
1011}
1012
1013#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1014#[repr(C)]
1015pub struct UnexpectedCloseTagError {
1016    pub expected: AzString,
1017    pub actual: AzString,
1018    pub pos: XmlTextPos,
1019}
1020
1021#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1022#[repr(C)]
1023pub struct UnknownEntityReferenceError {
1024    pub entity: AzString,
1025    pub pos: XmlTextPos,
1026}
1027
1028#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1029#[repr(C)]
1030pub struct DuplicatedAttributeError {
1031    pub attribute: AzString,
1032    pub pos: XmlTextPos,
1033}
1034
1035/// Error for mismatched open/close tags in XML hierarchy
1036#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1037#[repr(C)]
1038pub struct MalformedHierarchyError {
1039    /// The tag that was expected (from the opening tag)
1040    pub expected: AzString,
1041    /// The tag that was actually found (the closing tag)
1042    pub got: AzString,
1043}
1044
1045#[derive(Debug, PartialEq, Eq, PartialOrd, Clone)]
1046#[repr(C, u8)]
1047pub enum XmlError {
1048    NoParserAvailable,
1049    InvalidXmlPrefixUri(XmlTextPos),
1050    UnexpectedXmlUri(XmlTextPos),
1051    UnexpectedXmlnsUri(XmlTextPos),
1052    InvalidElementNamePrefix(XmlTextPos),
1053    DuplicatedNamespace(DuplicatedNamespaceError),
1054    UnknownNamespace(UnknownNamespaceError),
1055    UnexpectedCloseTag(UnexpectedCloseTagError),
1056    UnexpectedEntityCloseTag(XmlTextPos),
1057    UnknownEntityReference(UnknownEntityReferenceError),
1058    MalformedEntityReference(XmlTextPos),
1059    EntityReferenceLoop(XmlTextPos),
1060    InvalidAttributeValue(XmlTextPos),
1061    DuplicatedAttribute(DuplicatedAttributeError),
1062    NoRootNode,
1063    SizeLimit,
1064    DtdDetected,
1065    /// Invalid hierarchy close tags, i.e `<app></p></app>`
1066    MalformedHierarchy(MalformedHierarchyError),
1067    ParserError(XmlParseError),
1068    UnclosedRootNode,
1069    UnexpectedDeclaration(XmlTextPos),
1070    NodesLimitReached,
1071    AttributesLimitReached,
1072    NamespacesLimitReached,
1073    InvalidName(XmlTextPos),
1074    NonXmlChar(XmlTextPos),
1075    InvalidChar(XmlTextPos),
1076    InvalidChar2(XmlTextPos),
1077    InvalidString(XmlTextPos),
1078    InvalidExternalID(XmlTextPos),
1079    InvalidComment(XmlTextPos),
1080    InvalidCharacterData(XmlTextPos),
1081    UnknownToken(XmlTextPos),
1082    UnexpectedEndOfStream,
1083}
1084
1085impl fmt::Display for XmlError {
1086    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1087        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};
1088        match self {
1089            NoParserAvailable => write!(
1090                f,
1091                "Library was compiled without XML parser (XML parser not available)"
1092            ),
1093            InvalidXmlPrefixUri(pos) => {
1094                write!(f, "Invalid XML Prefix URI at line {}:{}", pos.row, pos.col)
1095            }
1096            UnexpectedXmlUri(pos) => {
1097                write!(f, "Unexpected XML URI at line {}:{}", pos.row, pos.col)
1098            }
1099            UnexpectedXmlnsUri(pos) => write!(
1100                f,
1101                "Unexpected XML namespace URI at line {}:{}",
1102                pos.row, pos.col
1103            ),
1104            InvalidElementNamePrefix(pos) => write!(
1105                f,
1106                "Invalid element name prefix at line {}:{}",
1107                pos.row, pos.col
1108            ),
1109            DuplicatedNamespace(ns) => write!(
1110                f,
1111                "Duplicated namespace: \"{}\" at {}",
1112                ns.ns.as_str(),
1113                ns.pos
1114            ),
1115            UnknownNamespace(uns) => write!(
1116                f,
1117                "Unknown namespace: \"{}\" at {}",
1118                uns.ns.as_str(),
1119                uns.pos
1120            ),
1121            UnexpectedCloseTag(ct) => write!(
1122                f,
1123                "Unexpected close tag: expected \"{}\", got \"{}\" at {}",
1124                ct.expected.as_str(),
1125                ct.actual.as_str(),
1126                ct.pos
1127            ),
1128            UnexpectedEntityCloseTag(pos) => write!(
1129                f,
1130                "Unexpected entity close tag at line {}:{}",
1131                pos.row, pos.col
1132            ),
1133            UnknownEntityReference(uer) => write!(
1134                f,
1135                "Unexpected entity reference: \"{}\" at {}",
1136                uer.entity, uer.pos
1137            ),
1138            MalformedEntityReference(pos) => write!(
1139                f,
1140                "Malformed entity reference at line {}:{}",
1141                pos.row, pos.col
1142            ),
1143            EntityReferenceLoop(pos) => write!(
1144                f,
1145                "Entity reference loop (recursive entity reference) at line {}:{}",
1146                pos.row, pos.col
1147            ),
1148            InvalidAttributeValue(pos) => {
1149                write!(f, "Invalid attribute value at line {}:{}", pos.row, pos.col)
1150            }
1151            DuplicatedAttribute(ae) => write!(
1152                f,
1153                "Duplicated attribute \"{}\" at line {}:{}",
1154                ae.attribute.as_str(),
1155                ae.pos.row,
1156                ae.pos.col
1157            ),
1158            NoRootNode => write!(f, "No root node found"),
1159            SizeLimit => write!(f, "XML file too large (size limit reached)"),
1160            DtdDetected => write!(f, "Document type descriptor detected"),
1161            MalformedHierarchy(e) => write!(
1162                f,
1163                "Malformed hierarchy: expected <{}/> closing tag, got <{}/>",
1164                e.expected.as_str(),
1165                e.got.as_str()
1166            ),
1167            ParserError(p) => write!(f, "{p}"),
1168            UnclosedRootNode => write!(f, "unclosed root node"),
1169            UnexpectedDeclaration(tp) => write!(f, "unexpected declaration at {tp}"),
1170            NodesLimitReached => write!(f, "nodes limit reached"),
1171            AttributesLimitReached => write!(f, "attributes limit reached"),
1172            NamespacesLimitReached => write!(f, "namespaces limit reached"),
1173            InvalidName(tp) => write!(f, "invalid name at {tp}"),
1174            NonXmlChar(tp) => write!(f, "non xml char at {tp}"),
1175            InvalidChar(tp) => write!(f, "invalid char at {tp}"),
1176            InvalidChar2(tp) => write!(f, "invalid char2 at {tp}"),
1177            InvalidString(tp) => write!(f, "invalid string at {tp}"),
1178            InvalidExternalID(tp) => write!(f, "invalid externalid at {tp}"),
1179            InvalidComment(tp) => write!(f, "invalid comment at {tp}"),
1180            InvalidCharacterData(tp) => write!(f, "invalid character data at {tp}"),
1181            UnknownToken(tp) => write!(f, "unknown token at {tp}"),
1182            UnexpectedEndOfStream => write!(f, "unexpected end of stream"),
1183        }
1184    }
1185}
1186
1187// ============================================================================
1188// New repr(C) component system
1189// ============================================================================
1190
1191/// Identifies a component within a library collection.
1192/// e.g. collection="builtin", name="div" for the `<div>` element,
1193/// or collection="shadcn", name="avatar" for a custom component.
1194#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1195#[repr(C)]
1196pub struct ComponentId {
1197    /// Library / collection name: "builtin", "shadcn", "myproject"
1198    pub collection: AzString,
1199    /// Component name within the collection: "div", "avatar", "card"
1200    pub name: AzString,
1201}
1202
1203impl ComponentId {
1204    #[must_use] pub fn builtin(name: &str) -> Self {
1205        Self {
1206            collection: AzString::from_const_str("builtin"),
1207            name: AzString::from(name),
1208        }
1209    }
1210
1211    #[must_use] pub fn new(collection: &str, name: &str) -> Self {
1212        Self {
1213            collection: AzString::from(collection),
1214            name: AzString::from(name),
1215        }
1216    }
1217
1218    /// Returns "collection:name" format string
1219    #[must_use] pub fn qualified_name(&self) -> String {
1220        format!("{}:{}", self.collection.as_str(), self.name.as_str())
1221    }
1222}
1223
1224// ============================================================================
1225// Component type system — rich type descriptors for component fields
1226// ============================================================================
1227
1228/// A single argument in a callback signature.
1229#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1230#[repr(C)]
1231pub struct ComponentCallbackArg {
1232    /// Argument name, e.g. "`button_id`"
1233    pub name: AzString,
1234    /// Argument type
1235    pub arg_type: ComponentFieldType,
1236}
1237
1238impl_vec!(
1239    ComponentCallbackArg,
1240    ComponentCallbackArgVec,
1241    ComponentCallbackArgVecDestructor,
1242    ComponentCallbackArgVecDestructorType,
1243    ComponentCallbackArgVecSlice,
1244    OptionComponentCallbackArg
1245);
1246impl_option!(
1247    ComponentCallbackArg,
1248    OptionComponentCallbackArg,
1249    copy = false,
1250    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
1251);
1252impl_vec_debug!(ComponentCallbackArg, ComponentCallbackArgVec);
1253impl_vec_partialeq!(ComponentCallbackArg, ComponentCallbackArgVec);
1254impl_vec_eq!(ComponentCallbackArg, ComponentCallbackArgVec);
1255impl_vec_partialord!(ComponentCallbackArg, ComponentCallbackArgVec);
1256impl_vec_ord!(ComponentCallbackArg, ComponentCallbackArgVec);
1257impl_vec_hash!(ComponentCallbackArg, ComponentCallbackArgVec);
1258impl_vec_clone!(
1259    ComponentCallbackArg,
1260    ComponentCallbackArgVec,
1261    ComponentCallbackArgVecDestructor
1262);
1263
1264/// Callback signature: return type + argument list.
1265#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1266#[repr(C)]
1267pub struct ComponentCallbackSignature {
1268    /// Return type name, e.g. "Update"
1269    pub return_type: AzString,
1270    /// Callback arguments (excluding the implicit `&mut RefAny` and `&mut CallbackInfo`)
1271    pub args: ComponentCallbackArgVec,
1272}
1273
1274/// Heap-allocated box for recursive `ComponentFieldType` (e.g. `Option<String>`).
1275/// Uses raw pointer indirection to break the infinite size.
1276#[repr(C)]
1277pub struct ComponentFieldTypeBox {
1278    pub ptr: *mut ComponentFieldType,
1279}
1280
1281impl ComponentFieldTypeBox {
1282    #[must_use] pub fn new(t: ComponentFieldType) -> Self {
1283        Self {
1284            ptr: Box::into_raw(Box::new(t)),
1285        }
1286    }
1287
1288    #[must_use] pub fn as_ref(&self) -> &ComponentFieldType {
1289        unsafe { &*self.ptr }
1290    }
1291}
1292
1293impl Clone for ComponentFieldTypeBox {
1294    fn clone(&self) -> Self {
1295        Self::new(unsafe { (*self.ptr).clone() })
1296    }
1297}
1298
1299impl Drop for ComponentFieldTypeBox {
1300    fn drop(&mut self) {
1301        // Null the pointer as we free it, so a *second* drop is a no-op instead
1302        // of a double free. This type is a by-value payload of the
1303        // `ComponentFieldType` enum, whose codegen FFI mirror gets
1304        // `impl Drop { _delete }` (= drop_in_place of the real type) AND Rust
1305        // field drop-glue — dropping each by-value field twice. Without this
1306        // take-and-null the second drop would `Box::from_raw` a dangling pointer.
1307        let ptr = core::mem::replace(&mut self.ptr, core::ptr::null_mut());
1308        if !ptr.is_null() {
1309            unsafe {
1310                drop(Box::from_raw(ptr));
1311            }
1312        }
1313    }
1314}
1315
1316impl fmt::Debug for ComponentFieldTypeBox {
1317    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1318        if self.ptr.is_null() {
1319            write!(f, "ComponentFieldTypeBox(null)")
1320        } else {
1321            write!(f, "ComponentFieldTypeBox({:?})", unsafe { &*self.ptr })
1322        }
1323    }
1324}
1325
1326impl PartialEq for ComponentFieldTypeBox {
1327    fn eq(&self, other: &Self) -> bool {
1328        if self.ptr.is_null() && other.ptr.is_null() {
1329            return true;
1330        }
1331        if self.ptr.is_null() || other.ptr.is_null() {
1332            return false;
1333        }
1334        unsafe { *self.ptr == *other.ptr }
1335    }
1336}
1337
1338impl Eq for ComponentFieldTypeBox {}
1339
1340impl PartialOrd for ComponentFieldTypeBox {
1341    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
1342        Some(self.cmp(other))
1343    }
1344}
1345
1346impl Ord for ComponentFieldTypeBox {
1347    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
1348        match (self.ptr.is_null(), other.ptr.is_null()) {
1349            (true, true) => core::cmp::Ordering::Equal,
1350            (true, false) => core::cmp::Ordering::Less,
1351            (false, true) => core::cmp::Ordering::Greater,
1352            (false, false) => unsafe { (*self.ptr).cmp(&*other.ptr) },
1353        }
1354    }
1355}
1356
1357impl Hash for ComponentFieldTypeBox {
1358    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
1359        if !self.ptr.is_null() {
1360            unsafe {
1361                (*self.ptr).hash(state);
1362            }
1363        }
1364    }
1365}
1366
1367/// Heap-allocated box for recursive `ComponentFieldValue` (e.g. `Some(value)`).
1368/// Uses raw pointer indirection to break the infinite size.
1369#[repr(C)]
1370pub struct ComponentFieldValueBox {
1371    pub ptr: *mut ComponentFieldValue,
1372}
1373
1374impl ComponentFieldValueBox {
1375    #[must_use] pub fn new(v: ComponentFieldValue) -> Self {
1376        Self {
1377            ptr: Box::into_raw(Box::new(v)),
1378        }
1379    }
1380
1381    #[must_use] pub fn as_ref(&self) -> &ComponentFieldValue {
1382        unsafe { &*self.ptr }
1383    }
1384}
1385
1386impl Clone for ComponentFieldValueBox {
1387    fn clone(&self) -> Self {
1388        Self::new(unsafe { (*self.ptr).clone() })
1389    }
1390}
1391
1392impl Drop for ComponentFieldValueBox {
1393    fn drop(&mut self) {
1394        // Take-and-null so a second drop (codegen FFI double-drop of a by-value
1395        // field, see `ComponentFieldTypeBox`) is a no-op, not a double free.
1396        let ptr = core::mem::replace(&mut self.ptr, core::ptr::null_mut());
1397        if !ptr.is_null() {
1398            unsafe {
1399                drop(Box::from_raw(ptr));
1400            }
1401        }
1402    }
1403}
1404
1405impl fmt::Debug for ComponentFieldValueBox {
1406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1407        if self.ptr.is_null() {
1408            write!(f, "ComponentFieldValueBox(null)")
1409        } else {
1410            write!(f, "ComponentFieldValueBox({:?})", unsafe { &*self.ptr })
1411        }
1412    }
1413}
1414
1415impl PartialEq for ComponentFieldValueBox {
1416    fn eq(&self, other: &Self) -> bool {
1417        if self.ptr.is_null() && other.ptr.is_null() {
1418            return true;
1419        }
1420        if self.ptr.is_null() || other.ptr.is_null() {
1421            return false;
1422        }
1423        unsafe { *self.ptr == *other.ptr }
1424    }
1425}
1426
1427/// Rich type descriptor for a component field.
1428/// Replaces the old `AzString` type names ("String", "bool", etc.) with
1429/// a structured enum that the debugger can use for type-aware editing.
1430#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1431#[repr(C, u8)]
1432pub enum ComponentFieldType {
1433    String,
1434    Bool,
1435    I32,
1436    I64,
1437    U32,
1438    U64,
1439    Usize,
1440    F32,
1441    F64,
1442    ColorU,
1443    CssProperty,
1444    ImageRef,
1445    FontRef,
1446    /// `StyledDom` slot — field name = slot name
1447    StyledDom,
1448    /// Callback with typed signature
1449    Callback(ComponentCallbackSignature),
1450    /// `RefAny` data binding with type hint
1451    RefAny(AzString),
1452    /// Optional value (recursive via Box)
1453    OptionType(ComponentFieldTypeBox),
1454    /// Vec of values (recursive via Box)
1455    VecType(ComponentFieldTypeBox),
1456    /// Reference to a struct defined in the same library
1457    StructRef(AzString),
1458    /// Reference to an enum defined in the same library
1459    EnumRef(AzString),
1460}
1461
1462impl ComponentFieldType {
1463    /// Parse a field type string like "String", "Option<Bool>", "Vec<I32>",
1464    /// "Callback(fn(LayoutCallbackInfo) -> Dom)", "StructRef(MyStruct)" etc.
1465    /// Returns `None` if the string cannot be parsed.
1466    #[must_use] pub fn parse(s: &str) -> Option<Self> {
1467        Self::parse_depth(s, 0)
1468    }
1469
1470    /// Depth-bounded implementation of [`parse`](Self::parse).
1471    ///
1472    /// AUDIT 2026-07-08: `Option<..>` / `Vec<..>` wrappers recurse once per level,
1473    /// so an attacker string like `"Option<".repeat(100_000)` (with matching `>`)
1474    /// overflowed the stack. Recursion is capped at [`MAX_TYPE_PARSE_DEPTH`];
1475    /// beyond it, parsing fails (`None`) instead of crashing.
1476    fn parse_depth(s: &str, depth: usize) -> Option<Self> {
1477        if depth > MAX_TYPE_PARSE_DEPTH {
1478            return None;
1479        }
1480        let s = s.trim();
1481        match s {
1482            "String" | "string" => return Some(Self::String),
1483            "Bool" | "bool" => return Some(Self::Bool),
1484            "I32" | "i32" => return Some(Self::I32),
1485            "I64" | "i64" => return Some(Self::I64),
1486            "U32" | "u32" => return Some(Self::U32),
1487            "U64" | "u64" => return Some(Self::U64),
1488            "Usize" | "usize" => return Some(Self::Usize),
1489            "F32" | "f32" => return Some(Self::F32),
1490            "F64" | "f64" => return Some(Self::F64),
1491            "ColorU" => return Some(Self::ColorU),
1492            "CssProperty" => return Some(Self::CssProperty),
1493            "ImageRef" => return Some(Self::ImageRef),
1494            "FontRef" => return Some(Self::FontRef),
1495            "StyledDom" => return Some(Self::StyledDom),
1496            "RefAny" => return Some(Self::RefAny(AzString::from(""))),
1497            _ => {}
1498        }
1499
1500        // Option<T>
1501        if let Some(inner) = s.strip_prefix("Option<").and_then(|r| r.strip_suffix('>')) {
1502            let inner_type = Self::parse_depth(inner, depth + 1)?;
1503            return Some(Self::OptionType(ComponentFieldTypeBox::new(
1504                inner_type,
1505            )));
1506        }
1507
1508        // Vec<T>
1509        if let Some(inner) = s.strip_prefix("Vec<").and_then(|r| r.strip_suffix('>')) {
1510            let inner_type = Self::parse_depth(inner, depth + 1)?;
1511            return Some(Self::VecType(ComponentFieldTypeBox::new(
1512                inner_type,
1513            )));
1514        }
1515
1516        // Callback(signature)
1517        if let Some(sig) = s
1518            .strip_prefix("Callback(")
1519            .and_then(|r| r.strip_suffix(')'))
1520        {
1521            return Some(Self::Callback(ComponentCallbackSignature {
1522                return_type: AzString::from(sig),
1523                args: Vec::new().into(),
1524            }));
1525        }
1526
1527        // RefAny(TypeHint)
1528        if let Some(hint) = s.strip_prefix("RefAny(").and_then(|r| r.strip_suffix(')')) {
1529            return Some(Self::RefAny(AzString::from(hint)));
1530        }
1531
1532        // EnumRef(Name) — explicit
1533        if let Some(name) = s.strip_prefix("EnumRef(").and_then(|r| r.strip_suffix(')')) {
1534            return Some(Self::EnumRef(AzString::from(name)));
1535        }
1536
1537        // StructRef(Name) — explicit
1538        if let Some(name) = s
1539            .strip_prefix("StructRef(")
1540            .and_then(|r| r.strip_suffix(')'))
1541        {
1542            return Some(Self::StructRef(AzString::from(name)));
1543        }
1544
1545        // If starts with uppercase, treat as StructRef
1546        if s.chars().next().is_some_and(char::is_uppercase) {
1547            return Some(Self::StructRef(AzString::from(s)));
1548        }
1549
1550        None
1551    }
1552
1553    /// Format this field type to its canonical string representation.
1554    /// This is the inverse of `parse`.
1555    #[must_use] pub fn format(&self) -> String {
1556        match self {
1557            Self::String => "String".to_string(),
1558            Self::Bool => "Bool".to_string(),
1559            Self::I32 => "I32".to_string(),
1560            Self::I64 => "I64".to_string(),
1561            Self::U32 => "U32".to_string(),
1562            Self::U64 => "U64".to_string(),
1563            Self::Usize => "Usize".to_string(),
1564            Self::F32 => "F32".to_string(),
1565            Self::F64 => "F64".to_string(),
1566            Self::ColorU => "ColorU".to_string(),
1567            Self::CssProperty => "CssProperty".to_string(),
1568            Self::ImageRef => "ImageRef".to_string(),
1569            Self::FontRef => "FontRef".to_string(),
1570            Self::StyledDom => "StyledDom".to_string(),
1571            Self::Callback(sig) => format!("Callback({})", sig.return_type.as_str()),
1572            Self::RefAny(hint) => {
1573                if hint.as_str().is_empty() {
1574                    "RefAny".to_string()
1575                } else {
1576                    format!("RefAny({})", hint.as_str())
1577                }
1578            }
1579            Self::OptionType(inner) => format!("Option<{}>", inner.as_ref().format()),
1580            Self::VecType(inner) => format!("Vec<{}>", inner.as_ref().format()),
1581            Self::StructRef(name) | Self::EnumRef(name) => name.as_str().to_string(),
1582        }
1583    }
1584}
1585
1586impl fmt::Display for ComponentFieldType {
1587    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1588        f.write_str(&self.format())
1589    }
1590}
1591
1592/// A single variant in a component enum model.
1593#[derive(Debug, Clone, PartialEq)]
1594#[repr(C)]
1595pub struct ComponentEnumVariant {
1596    /// Variant name, e.g. "Admin", "Editor", "Viewer"
1597    pub name: AzString,
1598    /// Human-readable description for this variant
1599    pub description: AzString,
1600    /// Optional associated fields for this variant
1601    pub fields: ComponentDataFieldVec,
1602}
1603
1604impl_vec!(
1605    ComponentEnumVariant,
1606    ComponentEnumVariantVec,
1607    ComponentEnumVariantVecDestructor,
1608    ComponentEnumVariantVecDestructorType,
1609    ComponentEnumVariantVecSlice,
1610    OptionComponentEnumVariant
1611);
1612impl_option!(
1613    ComponentEnumVariant,
1614    OptionComponentEnumVariant,
1615    copy = false,
1616    [Debug, Clone, PartialEq]
1617);
1618impl_vec_debug!(ComponentEnumVariant, ComponentEnumVariantVec);
1619impl_vec_partialeq!(ComponentEnumVariant, ComponentEnumVariantVec);
1620impl_vec_clone!(
1621    ComponentEnumVariant,
1622    ComponentEnumVariantVec,
1623    ComponentEnumVariantVecDestructor
1624);
1625
1626/// A named enum model for code generation.
1627/// Stored in `ComponentLibrary::enum_models`.
1628#[derive(Debug, Clone, PartialEq)]
1629#[repr(C)]
1630pub struct ComponentEnumModel {
1631    /// Enum name, e.g. "`UserRole`"
1632    pub name: AzString,
1633    /// Human-readable description
1634    pub description: AzString,
1635    /// Variants
1636    pub variants: ComponentEnumVariantVec,
1637}
1638
1639impl_vec!(
1640    ComponentEnumModel,
1641    ComponentEnumModelVec,
1642    ComponentEnumModelVecDestructor,
1643    ComponentEnumModelVecDestructorType,
1644    ComponentEnumModelVecSlice,
1645    OptionComponentEnumModel
1646);
1647impl_option!(
1648    ComponentEnumModel,
1649    OptionComponentEnumModel,
1650    copy = false,
1651    [Debug, Clone, PartialEq]
1652);
1653impl_vec_debug!(ComponentEnumModel, ComponentEnumModelVec);
1654impl_vec_partialeq!(ComponentEnumModel, ComponentEnumModelVec);
1655impl_vec_clone!(
1656    ComponentEnumModel,
1657    ComponentEnumModelVec,
1658    ComponentEnumModelVecDestructor
1659);
1660
1661/// Default value for a component field.
1662#[derive(Debug, Clone, PartialEq)]
1663#[repr(C, u8)]
1664pub enum ComponentDefaultValue {
1665    /// No default value (field is required)
1666    None,
1667    /// String literal default
1668    String(AzString),
1669    /// Boolean default
1670    Bool(bool),
1671    /// i32 default
1672    I32(i32),
1673    /// i64 default
1674    I64(i64),
1675    /// u32 default
1676    U32(u32),
1677    /// u64 default
1678    U64(u64),
1679    /// usize default
1680    Usize(usize),
1681    /// f32 default
1682    F32(f32),
1683    /// f64 default
1684    F64(f64),
1685    /// `ColorU` default
1686    ColorU(ColorU),
1687    /// Default is an instance of another component
1688    ComponentInstance(ComponentInstanceDefault),
1689    /// Default callback function pointer name
1690    CallbackFnPointer(AzString),
1691    /// JSON string representing a complex default value
1692    Json(AzString),
1693}
1694
1695impl_option!(
1696    ComponentDefaultValue,
1697    OptionComponentDefaultValue,
1698    copy = false,
1699    [Debug, Clone, PartialEq]
1700);
1701
1702/// Default component instance for a `StyledDom` slot.
1703#[derive(Debug, Clone, PartialEq)]
1704#[repr(C)]
1705pub struct ComponentInstanceDefault {
1706    /// Library name, e.g. "builtin"
1707    pub library: AzString,
1708    /// Component tag, e.g. "a"
1709    pub component: AzString,
1710    /// Field overrides for this instance
1711    pub field_overrides: ComponentFieldOverrideVec,
1712}
1713
1714/// An override for a single field in a component instance.
1715#[derive(Debug, Clone, PartialEq, Eq)]
1716#[repr(C)]
1717pub struct ComponentFieldOverride {
1718    /// Field name to override
1719    pub field_name: AzString,
1720    /// Value source for this override
1721    pub source: ComponentFieldValueSource,
1722}
1723
1724impl_vec!(
1725    ComponentFieldOverride,
1726    ComponentFieldOverrideVec,
1727    ComponentFieldOverrideVecDestructor,
1728    ComponentFieldOverrideVecDestructorType,
1729    ComponentFieldOverrideVecSlice,
1730    OptionComponentFieldOverride
1731);
1732impl_option!(
1733    ComponentFieldOverride,
1734    OptionComponentFieldOverride,
1735    copy = false,
1736    [Debug, Clone, PartialEq, Eq]
1737);
1738impl_vec_debug!(ComponentFieldOverride, ComponentFieldOverrideVec);
1739impl_vec_partialeq!(ComponentFieldOverride, ComponentFieldOverrideVec);
1740impl_vec_clone!(
1741    ComponentFieldOverride,
1742    ComponentFieldOverrideVec,
1743    ComponentFieldOverrideVecDestructor
1744);
1745
1746/// How a field value is sourced at the instance level.
1747#[derive(Debug, Clone, PartialEq, Eq)]
1748#[repr(C, u8)]
1749pub enum ComponentFieldValueSource {
1750    /// Use the component's default value
1751    Default,
1752    /// Hardcoded literal value (as string, parsed at runtime)
1753    Literal(AzString),
1754    /// Bound to an app state path (e.g. "`app_state.user.name`")
1755    Binding(AzString),
1756}
1757#[allow(variant_size_differences)] // repr(C,u8) FFI enum: boxing the large variant would change the C ABI (api.json bindings); size disparity accepted
1758/// Runtime value for a component field — the "instance" counterpart
1759/// to `ComponentFieldType` (which is the "class" / type descriptor).
1760#[derive(Debug, Clone, PartialEq)]
1761#[repr(C, u8)]
1762#[allow(clippy::large_enum_variant)] // #[repr(C,u8)] FFI enum: boxing a variant changes the C ABI/api.json
1763pub enum ComponentFieldValue {
1764    String(AzString),
1765    Bool(bool),
1766    I32(i32),
1767    I64(i64),
1768    U32(u32),
1769    U64(u64),
1770    Usize(usize),
1771    F32(f32),
1772    F64(f64),
1773    ColorU(ColorU),
1774    /// Option<T> with no value
1775    None,
1776    /// Option<T> with a value
1777    Some(ComponentFieldValueBox),
1778    /// Vec of values
1779    Vec(ComponentFieldValueVec),
1780    /// `StyledDom` slot content
1781    StyledDom(StyledDom),
1782    /// Struct fields, in order
1783    Struct(ComponentFieldNamedValueVec),
1784    /// Enum variant
1785    Enum {
1786        variant: AzString,
1787        fields: ComponentFieldNamedValueVec,
1788    },
1789    /// Callback function reference (function name as string)
1790    Callback(AzString),
1791    /// Opaque reference-counted data
1792    RefAny(crate::refany::RefAny),
1793}
1794
1795/// Named field value: (`field_name`, value) pair.
1796#[derive(Debug, Clone, PartialEq)]
1797#[repr(C)]
1798pub struct ComponentFieldNamedValue {
1799    pub name: AzString,
1800    pub value: ComponentFieldValue,
1801}
1802
1803impl_vec!(
1804    ComponentFieldNamedValue,
1805    ComponentFieldNamedValueVec,
1806    ComponentFieldNamedValueVecDestructor,
1807    ComponentFieldNamedValueVecDestructorType,
1808    ComponentFieldNamedValueVecSlice,
1809    OptionComponentFieldNamedValue
1810);
1811impl_option!(
1812    ComponentFieldNamedValue,
1813    OptionComponentFieldNamedValue,
1814    copy = false,
1815    [Debug, Clone, PartialEq]
1816);
1817impl_vec_debug!(ComponentFieldNamedValue, ComponentFieldNamedValueVec);
1818impl_vec_partialeq!(ComponentFieldNamedValue, ComponentFieldNamedValueVec);
1819impl_vec_clone!(
1820    ComponentFieldNamedValue,
1821    ComponentFieldNamedValueVec,
1822    ComponentFieldNamedValueVecDestructor
1823);
1824
1825impl ComponentFieldNamedValueVec {
1826    /// Look up a field by name, return a reference to its value.
1827    #[must_use] pub fn get_field(&self, name: &str) -> Option<&ComponentFieldValue> {
1828        self.as_ref().iter().find_map(|v| {
1829            if v.name.as_str() == name {
1830                Some(&v.value)
1831            } else {
1832                None
1833            }
1834        })
1835    }
1836
1837    /// Convenience: get a field as `&str` if it is `ComponentFieldValue::String`.
1838    #[must_use] pub fn get_string(&self, name: &str) -> Option<&AzString> {
1839        match self.get_field(name) {
1840            Some(ComponentFieldValue::String(s)) => Some(s),
1841            _ => None,
1842        }
1843    }
1844}
1845
1846impl_vec!(
1847    ComponentFieldValue,
1848    ComponentFieldValueVec,
1849    ComponentFieldValueVecDestructor,
1850    ComponentFieldValueVecDestructorType,
1851    ComponentFieldValueVecSlice,
1852    OptionComponentFieldValue
1853);
1854impl_option!(
1855    ComponentFieldValue,
1856    OptionComponentFieldValue,
1857    copy = false,
1858    [Debug, Clone, PartialEq]
1859);
1860impl_vec_debug!(ComponentFieldValue, ComponentFieldValueVec);
1861impl_vec_partialeq!(ComponentFieldValue, ComponentFieldValueVec);
1862impl_vec_clone!(
1863    ComponentFieldValue,
1864    ComponentFieldValueVec,
1865    ComponentFieldValueVecDestructor
1866);
1867
1868/// A field in the component's internal data model.
1869#[derive(Debug, Clone, PartialEq)]
1870#[repr(C)]
1871pub struct ComponentDataField {
1872    /// Field name, e.g. "counter", "text", "number"
1873    pub name: AzString,
1874    /// Rich type descriptor for this field
1875    pub field_type: ComponentFieldType,
1876    /// Typed default value, or None if the field is required
1877    pub default_value: OptionComponentDefaultValue,
1878    /// Whether this field is required (must be provided by the parent)
1879    pub required: bool,
1880    /// Human-readable description
1881    pub description: AzString,
1882}
1883
1884impl_vec!(
1885    ComponentDataField,
1886    ComponentDataFieldVec,
1887    ComponentDataFieldVecDestructor,
1888    ComponentDataFieldVecDestructorType,
1889    ComponentDataFieldVecSlice,
1890    OptionComponentDataField
1891);
1892impl_option!(
1893    ComponentDataField,
1894    OptionComponentDataField,
1895    copy = false,
1896    [Debug, Clone, PartialEq]
1897);
1898impl_vec_debug!(ComponentDataField, ComponentDataFieldVec);
1899impl_vec_partialeq!(ComponentDataField, ComponentDataFieldVec);
1900impl_vec_clone!(
1901    ComponentDataField,
1902    ComponentDataFieldVec,
1903    ComponentDataFieldVecDestructor
1904);
1905
1906/// A named data model (struct definition) for code generation.
1907///
1908/// Stored in `ComponentLibrary::data_models`. Components reference these
1909/// by name in `ComponentDataField::field_type`, enabling nested/structured
1910/// data models. For example, a `UserCard` component might have a field
1911/// `user: UserProfile` where `UserProfile` is a `ComponentDataModel`.
1912#[derive(Debug, Clone)]
1913#[repr(C)]
1914pub struct ComponentDataModel {
1915    /// Type name, e.g. "`UserProfile`", "`TodoItem`"
1916    pub name: AzString,
1917    /// Human-readable description
1918    pub description: AzString,
1919    /// Fields in this struct
1920    pub fields: ComponentDataFieldVec,
1921}
1922
1923impl ComponentDataModel {
1924    /// Look up a field by name.
1925    #[must_use] pub fn get_field(&self, name: &str) -> Option<&ComponentDataField> {
1926        self.fields
1927            .as_ref()
1928            .iter()
1929            .find(|f| f.name.as_str() == name)
1930    }
1931
1932    /// Look up a field's default value as a string, if it exists and is a String variant.
1933    #[must_use] pub fn get_default_string(&self, name: &str) -> Option<&AzString> {
1934        self.get_field(name).and_then(|f| match &f.default_value {
1935            OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => Some(s),
1936            _ => None,
1937        })
1938    }
1939
1940    /// Clone this data model, overriding the default value for a field by name.
1941    /// If the field is not found, the data model is returned unchanged.
1942    #[must_use] pub fn with_default(mut self, name: &str, value: ComponentDefaultValue) -> Self {
1943        let mut fields_vec = core::mem::replace(
1944            &mut self.fields,
1945            ComponentDataFieldVec::from_const_slice(&[]),
1946        )
1947        .into_library_owned_vec();
1948        for f in &mut fields_vec {
1949            if f.name.as_str() == name {
1950                f.default_value = OptionComponentDefaultValue::Some(value);
1951                break;
1952            }
1953        }
1954        self.fields = ComponentDataFieldVec::from_vec(fields_vec);
1955        self
1956    }
1957}
1958
1959impl_vec!(
1960    ComponentDataModel,
1961    ComponentDataModelVec,
1962    ComponentDataModelVecDestructor,
1963    ComponentDataModelVecDestructorType,
1964    ComponentDataModelVecSlice,
1965    OptionComponentDataModel
1966);
1967impl_option!(
1968    ComponentDataModel,
1969    OptionComponentDataModel,
1970    copy = false,
1971    [Debug, Clone]
1972);
1973impl_vec_debug!(ComponentDataModel, ComponentDataModelVec);
1974impl_vec_clone!(
1975    ComponentDataModel,
1976    ComponentDataModelVec,
1977    ComponentDataModelVecDestructor
1978);
1979impl_vec_mut!(ComponentDataModel, ComponentDataModelVec);
1980
1981// ============================================================================
1982// Serde support for ComponentDataModel (feature-gated)
1983// ============================================================================
1984
1985#[cfg(feature = "serde-json")]
1986mod serde_impl {
1987    use super::*;
1988    use serde::ser::SerializeStruct;
1989    use serde::{Deserialize, Deserializer, Serialize, Serializer};
1990
1991    // --- AzString helpers ---
1992
1993    fn ser_azstring<S: Serializer>(s: &AzString, serializer: S) -> Result<S::Ok, S::Error> {
1994        serializer.serialize_str(s.as_str())
1995    }
1996
1997    fn de_azstring<'de, D: Deserializer<'de>>(deserializer: D) -> Result<AzString, D::Error> {
1998        let s = alloc::string::String::deserialize(deserializer)?;
1999        Ok(AzString::from(s.as_str()))
2000    }
2001
2002    // --- ComponentFieldType ---
2003
2004    impl Serialize for ComponentFieldType {
2005        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2006            serializer.serialize_str(&field_type_to_string(self))
2007        }
2008    }
2009
2010    impl<'de> Deserialize<'de> for ComponentFieldType {
2011        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2012            let s = alloc::string::String::deserialize(deserializer)?;
2013            Ok(string_to_field_type(&s))
2014        }
2015    }
2016
2017    fn field_type_to_string(ft: &ComponentFieldType) -> alloc::string::String {
2018        match ft {
2019            ComponentFieldType::String => "String".into(),
2020            ComponentFieldType::Bool => "bool".into(),
2021            ComponentFieldType::I32 => "i32".into(),
2022            ComponentFieldType::I64 => "i64".into(),
2023            ComponentFieldType::U32 => "u32".into(),
2024            ComponentFieldType::U64 => "u64".into(),
2025            ComponentFieldType::Usize => "usize".into(),
2026            ComponentFieldType::F32 => "f32".into(),
2027            ComponentFieldType::F64 => "f64".into(),
2028            ComponentFieldType::ColorU => "ColorU".into(),
2029            ComponentFieldType::CssProperty => "CssProperty".into(),
2030            ComponentFieldType::ImageRef => "ImageRef".into(),
2031            ComponentFieldType::FontRef => "FontRef".into(),
2032            ComponentFieldType::StyledDom => "Dom".into(),
2033            ComponentFieldType::Callback(sig) => {
2034                alloc::format!("Callback({})", sig.return_type.as_str())
2035            }
2036            ComponentFieldType::RefAny(hint) => alloc::format!("RefAny({})", hint.as_str()),
2037            ComponentFieldType::OptionType(inner) => {
2038                alloc::format!("Option<{}>", field_type_to_string(inner.as_ref()))
2039            }
2040            ComponentFieldType::VecType(inner) => {
2041                alloc::format!("Vec<{}>", field_type_to_string(inner.as_ref()))
2042            }
2043            ComponentFieldType::StructRef(name) => alloc::format!("struct:{}", name.as_str()),
2044            ComponentFieldType::EnumRef(name) => alloc::format!("enum:{}", name.as_str()),
2045        }
2046    }
2047
2048    fn string_to_field_type(s: &str) -> ComponentFieldType {
2049        match s {
2050            "String" | "string" => ComponentFieldType::String,
2051            "bool" | "Bool" => ComponentFieldType::Bool,
2052            "i32" | "I32" => ComponentFieldType::I32,
2053            "i64" | "I64" => ComponentFieldType::I64,
2054            "u32" | "U32" => ComponentFieldType::U32,
2055            "u64" | "U64" => ComponentFieldType::U64,
2056            "usize" | "Usize" => ComponentFieldType::Usize,
2057            "f32" | "F32" => ComponentFieldType::F32,
2058            "f64" | "F64" => ComponentFieldType::F64,
2059            "ColorU" | "Color" | "color" => ComponentFieldType::ColorU,
2060            "CssProperty" => ComponentFieldType::CssProperty,
2061            "ImageRef" | "Image" => ComponentFieldType::ImageRef,
2062            "FontRef" | "Font" => ComponentFieldType::FontRef,
2063            "Dom" | "StyledDom" | "Children" => ComponentFieldType::StyledDom,
2064            other => {
2065                if let Some(inner) = other
2066                    .strip_prefix("Option<")
2067                    .and_then(|s| s.strip_suffix('>'))
2068                {
2069                    ComponentFieldType::OptionType(ComponentFieldTypeBox::new(
2070                        string_to_field_type(inner),
2071                    ))
2072                } else if let Some(inner) =
2073                    other.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>'))
2074                {
2075                    ComponentFieldType::VecType(ComponentFieldTypeBox::new(string_to_field_type(
2076                        inner,
2077                    )))
2078                } else if let Some(name) = other.strip_prefix("struct:") {
2079                    ComponentFieldType::StructRef(AzString::from(name))
2080                } else if let Some(name) = other.strip_prefix("enum:") {
2081                    ComponentFieldType::EnumRef(AzString::from(name))
2082                } else if other.starts_with("Callback") {
2083                    let ret = other
2084                        .strip_prefix("Callback(")
2085                        .and_then(|s| s.strip_suffix(')'))
2086                        .unwrap_or("()");
2087                    ComponentFieldType::Callback(ComponentCallbackSignature {
2088                        return_type: AzString::from(ret),
2089                        args: ComponentCallbackArgVec::from_const_slice(&[]),
2090                    })
2091                } else if other.starts_with("RefAny") {
2092                    let hint = other
2093                        .strip_prefix("RefAny(")
2094                        .and_then(|s| s.strip_suffix(')'))
2095                        .unwrap_or("");
2096                    ComponentFieldType::RefAny(AzString::from(hint))
2097                } else {
2098                    ComponentFieldType::String // fallback
2099                }
2100            }
2101        }
2102    }
2103
2104    // --- ComponentDefaultValue ---
2105
2106    impl Serialize for ComponentDefaultValue {
2107        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2108            use serde::ser::SerializeMap;
2109            match self {
2110                ComponentDefaultValue::None => serializer.serialize_none(),
2111                ComponentDefaultValue::String(s) => serializer.serialize_str(s.as_str()),
2112                ComponentDefaultValue::Bool(b) => serializer.serialize_bool(*b),
2113                ComponentDefaultValue::I32(v) => serializer.serialize_i32(*v),
2114                ComponentDefaultValue::I64(v) => serializer.serialize_i64(*v),
2115                ComponentDefaultValue::U32(v) => serializer.serialize_u32(*v),
2116                ComponentDefaultValue::U64(v) => serializer.serialize_u64(*v),
2117                ComponentDefaultValue::Usize(v) => serializer.serialize_u64(*v as u64),
2118                ComponentDefaultValue::F32(v) => serializer.serialize_f32(*v),
2119                ComponentDefaultValue::F64(v) => serializer.serialize_f64(*v),
2120                ComponentDefaultValue::ColorU(c) => serializer.serialize_str(&alloc::format!(
2121                    "#{:02x}{:02x}{:02x}{:02x}",
2122                    c.r,
2123                    c.g,
2124                    c.b,
2125                    c.a
2126                )),
2127                ComponentDefaultValue::ComponentInstance(ci) => {
2128                    let mut map = serializer.serialize_map(Some(2))?;
2129                    map.serialize_entry("library", ci.library.as_str())?;
2130                    map.serialize_entry("component", ci.component.as_str())?;
2131                    map.end()
2132                }
2133                ComponentDefaultValue::CallbackFnPointer(name) => {
2134                    serializer.serialize_str(name.as_str())
2135                }
2136                ComponentDefaultValue::Json(json_str) => {
2137                    // Serialize raw JSON string as-is by parsing and re-emitting
2138                    match serde_json::from_str::<serde_json::Value>(json_str.as_str()) {
2139                        Ok(v) => v.serialize(serializer),
2140                        Err(_) => serializer.serialize_str(json_str.as_str()),
2141                    }
2142                }
2143            }
2144        }
2145    }
2146
2147    impl<'de> Deserialize<'de> for ComponentDefaultValue {
2148        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2149            let val = serde_json::Value::deserialize(deserializer)?;
2150            Ok(match val {
2151                serde_json::Value::Null => ComponentDefaultValue::None,
2152                serde_json::Value::Bool(b) => ComponentDefaultValue::Bool(b),
2153                serde_json::Value::Number(n) => {
2154                    if let Some(i) = n.as_i64() {
2155                        if let Ok(v) = i32::try_from(i) {
2156                            ComponentDefaultValue::I32(v)
2157                        } else {
2158                            ComponentDefaultValue::I64(i)
2159                        }
2160                    } else if let Some(f) = n.as_f64() {
2161                        ComponentDefaultValue::F64(f)
2162                    } else {
2163                        ComponentDefaultValue::None
2164                    }
2165                }
2166                serde_json::Value::String(s) => {
2167                    ComponentDefaultValue::String(AzString::from(s.as_str()))
2168                }
2169                _ => ComponentDefaultValue::None,
2170            })
2171        }
2172    }
2173
2174    // --- OptionComponentDefaultValue ---
2175
2176    impl Serialize for OptionComponentDefaultValue {
2177        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2178            match self {
2179                OptionComponentDefaultValue::Some(v) => v.serialize(serializer),
2180                OptionComponentDefaultValue::None => serializer.serialize_none(),
2181            }
2182        }
2183    }
2184
2185    impl<'de> Deserialize<'de> for OptionComponentDefaultValue {
2186        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2187            let val = Option::<ComponentDefaultValue>::deserialize(deserializer)?;
2188            Ok(match val {
2189                Some(v) => OptionComponentDefaultValue::Some(v),
2190                None => OptionComponentDefaultValue::None,
2191            })
2192        }
2193    }
2194
2195    // --- ComponentDataField ---
2196
2197    impl Serialize for ComponentDataField {
2198        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2199            let mut s = serializer.serialize_struct("ComponentDataField", 5)?;
2200            s.serialize_field("name", self.name.as_str())?;
2201            s.serialize_field("type", &self.field_type)?;
2202            s.serialize_field("default", &self.default_value)?;
2203            s.serialize_field("required", &self.required)?;
2204            s.serialize_field("description", self.description.as_str())?;
2205            s.end()
2206        }
2207    }
2208
2209    impl<'de> Deserialize<'de> for ComponentDataField {
2210        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2211            #[derive(Deserialize)]
2212            struct Helper {
2213                name: alloc::string::String,
2214                #[serde(rename = "type", default = "default_type")]
2215                field_type: ComponentFieldType,
2216                #[serde(default)]
2217                default: OptionComponentDefaultValue,
2218                #[serde(default)]
2219                required: bool,
2220                #[serde(default)]
2221                description: alloc::string::String,
2222            }
2223            fn default_type() -> ComponentFieldType {
2224                ComponentFieldType::String
2225            }
2226
2227            let h = Helper::deserialize(deserializer)?;
2228            Ok(ComponentDataField {
2229                name: AzString::from(h.name.as_str()),
2230                field_type: h.field_type,
2231                default_value: h.default,
2232                required: h.required,
2233                description: AzString::from(h.description.as_str()),
2234            })
2235        }
2236    }
2237
2238    // --- ComponentDataModel ---
2239
2240    impl Serialize for ComponentDataModel {
2241        fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
2242            let mut s = serializer.serialize_struct("ComponentDataModel", 3)?;
2243            s.serialize_field("name", self.name.as_str())?;
2244            s.serialize_field("description", self.description.as_str())?;
2245            let fields: alloc::vec::Vec<&ComponentDataField> =
2246                self.fields.as_ref().iter().collect();
2247            s.serialize_field("fields", &fields)?;
2248            s.end()
2249        }
2250    }
2251
2252    impl<'de> Deserialize<'de> for ComponentDataModel {
2253        fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
2254            #[derive(Deserialize)]
2255            struct Helper {
2256                #[serde(default)]
2257                name: alloc::string::String,
2258                #[serde(default)]
2259                description: alloc::string::String,
2260                #[serde(default)]
2261                fields: alloc::vec::Vec<ComponentDataField>,
2262            }
2263
2264            let h = Helper::deserialize(deserializer)?;
2265            Ok(ComponentDataModel {
2266                name: AzString::from(h.name.as_str()),
2267                description: AzString::from(h.description.as_str()),
2268                fields: ComponentDataFieldVec::from_vec(h.fields),
2269            })
2270        }
2271    }
2272}
2273
2274// Re-export serde impls so they're visible when the feature is enabled
2275#[cfg(feature = "serde-json")]
2276pub use serde_impl::*;
2277
2278#[cfg(feature = "serde-json")]
2279impl ComponentDataModel {
2280    /// Serialize this data model to a JSON string.
2281    pub fn to_json(&self) -> Result<alloc::string::String, alloc::string::String> {
2282        serde_json::to_string_pretty(self).map_err(|e| alloc::format!("{}", e))
2283    }
2284
2285    /// Deserialize a data model from a JSON string.
2286    pub fn from_json(json: &str) -> Result<Self, alloc::string::String> {
2287        serde_json::from_str(json).map_err(|e| alloc::format!("{}", e))
2288    }
2289}
2290
2291/// Source of a component definition — determines whether it can be exported
2292#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2293#[repr(C)]
2294#[derive(Default)]
2295pub enum ComponentSource {
2296    /// Built into the DLL (HTML elements). Never exported.
2297    Builtin,
2298    /// Compiled Rust widget (Button, `TextInput`, etc.). Never exported.
2299    Compiled,
2300    /// Defined via JSON/XML at runtime. Can be exported.
2301    #[default]
2302    UserDefined,
2303}
2304
2305
2306impl ComponentSource {
2307    #[must_use] pub fn create() -> Self {
2308        Self::default()
2309    }
2310}
2311
2312/// The target language for code compilation
2313// Threaded by reference through the codegen call graph; kept non-Copy so
2314// deriving Copy doesn't force trivially_copy_pass_by_ref churn across the many
2315// &CompileTarget codegen callers for a perf-neutral change.
2316#[allow(missing_copy_implementations)]
2317#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2318#[repr(C)]
2319pub enum CompileTarget {
2320    Rust,
2321    C,
2322    Cpp,
2323    Python,
2324}
2325
2326impl_result!(
2327    StyledDom,
2328    RenderDomError,
2329    ResultStyledDomRenderDomError,
2330    copy = false,
2331    [Debug, Clone, PartialEq]
2332);
2333
2334impl_result!(
2335    AzString,
2336    CompileError,
2337    ResultStringCompileError,
2338    copy = false,
2339    [Debug, Clone, PartialEq]
2340);
2341
2342/// Render function type: takes component definition + data model (with current values
2343/// in `default_value` fields) + component map for recursive sub-component instantiation,
2344/// returns `StyledDom`.
2345///
2346/// The `data` parameter is typically `def.data_model` cloned and with caller-provided
2347/// values substituted into the `default_value` fields.
2348pub type ComponentRenderFn =
2349    fn(&ComponentDef, &ComponentDataModel, &ComponentMap) -> ResultStyledDomRenderDomError;
2350
2351/// Compile function type: takes component definition + target language + data model, returns source code.
2352pub type ComponentCompileFn = fn(
2353    &ComponentDef,
2354    &CompileTarget,
2355    &ComponentDataModel,
2356    indent: usize,
2357) -> ResultStringCompileError;
2358
2359/// Raw function pointer type that returns a single `ComponentDef` when called.
2360/// Used as the `cb` field in `RegisterComponentFn`.
2361pub type RegisterComponentFnType = extern "C" fn() -> ComponentDef;
2362
2363/// Callback struct for registering individual components at startup.
2364///
2365/// In C: pass a bare `extern "C" fn() -> ComponentDef` function pointer —
2366/// it converts automatically via `From<RegisterComponentFnType>`.
2367///
2368/// In Python: construct this struct with `cb` set to a trampoline and
2369/// `ctx` set to `Some(RefAny(...))` wrapping the Python callable.
2370#[repr(C)]
2371pub struct RegisterComponentFn {
2372    pub cb: RegisterComponentFnType,
2373    /// For FFI: stores the foreign callable (e.g., `PyFunction`).
2374    /// Native Rust/C code sets this to None.
2375    pub ctx: crate::refany::OptionRefAny,
2376}
2377
2378impl_callback!(RegisterComponentFn, RegisterComponentFnType);
2379
2380/// Raw function pointer type that returns a complete `ComponentLibrary` when called.
2381/// Used as the `cb` field in `RegisterComponentLibraryFn`.
2382pub type RegisterComponentLibraryFnType = extern "C" fn() -> ComponentLibrary;
2383
2384/// Callback struct for registering entire component libraries at startup.
2385///
2386/// In C: pass a bare `extern "C" fn() -> ComponentLibrary` function pointer —
2387/// it converts automatically via `From<RegisterComponentLibraryFnType>`.
2388///
2389/// In Python: construct this struct with `cb` set to a trampoline and
2390/// `ctx` set to `Some(RefAny(...))` wrapping the Python callable.
2391#[repr(C)]
2392pub struct RegisterComponentLibraryFn {
2393    pub cb: RegisterComponentLibraryFnType,
2394    /// For FFI: stores the foreign callable (e.g., `PyFunction`).
2395    /// Native Rust/C code sets this to None.
2396    pub ctx: crate::refany::OptionRefAny,
2397}
2398
2399impl_callback!(RegisterComponentLibraryFn, RegisterComponentLibraryFnType);
2400
2401/// A component definition — the "class" / "template" of a component.
2402/// Can come from Rust builtins, compiled widgets, JSON, or user creation in debugger.
2403///
2404#[derive(Clone)]
2405#[repr(C)]
2406pub struct ComponentDef {
2407    /// Collection + name, e.g. builtin:div, shadcn:avatar
2408    pub id: ComponentId,
2409    /// Human-readable display name, e.g. "Link" for builtin:a, "Avatar" for shadcn:avatar
2410    pub display_name: AzString,
2411    /// Markdown documentation for the component
2412    pub description: AzString,
2413    /// The component's CSS
2414    pub css: AzString,
2415    /// Where this component was defined (determines exportability)
2416    pub source: ComponentSource,
2417    /// Unified data model: all value fields, callback slots, and child slots
2418    /// in a single named struct. Code gen uses `data_model.name` as the
2419    /// input struct type name (e.g. "`ButtonData`").
2420    /// The `default_value` on each field doubles as the "current value" for
2421    /// preview rendering — callers override defaults before calling `render_fn`.
2422    pub data_model: ComponentDataModel,
2423    /// Render to live DOM
2424    pub render_fn: ComponentRenderFn,
2425    /// Compile to source code in target language
2426    pub compile_fn: ComponentCompileFn,
2427    /// Source code for `render_fn` (user-defined components only)
2428    pub render_fn_source: OptionString,
2429    /// Source code for `compile_fn` (user-defined components only)
2430    pub compile_fn_source: OptionString,
2431}
2432
2433impl fmt::Debug for ComponentDef {
2434    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2435        f.debug_struct("ComponentDef")
2436            .field("id", &self.id)
2437            .field("display_name", &self.display_name)
2438            .field("source", &self.source)
2439            .field("data_model", &self.data_model.name)
2440            .finish_non_exhaustive()
2441    }
2442}
2443
2444impl_vec!(
2445    ComponentDef,
2446    ComponentDefVec,
2447    ComponentDefVecDestructor,
2448    ComponentDefVecDestructorType,
2449    ComponentDefVecSlice,
2450    OptionComponentDef
2451);
2452impl_option!(ComponentDef, OptionComponentDef, copy = false, [Clone]);
2453impl_vec_debug!(ComponentDef, ComponentDefVec);
2454impl_vec_clone!(ComponentDef, ComponentDefVec, ComponentDefVecDestructor);
2455impl_vec_mut!(ComponentDef, ComponentDefVec);
2456
2457/// A named collection of component definitions
2458#[derive(Debug, Clone)]
2459#[repr(C)]
2460pub struct ComponentLibrary {
2461    /// Library identifier, e.g. "builtin", "shadcn", "myproject"
2462    pub name: AzString,
2463    /// Version string
2464    pub version: AzString,
2465    /// Human-readable description
2466    pub description: AzString,
2467    /// The components in this library
2468    pub components: ComponentDefVec,
2469    /// Whether this library can be exported (false for builtin/compiled)
2470    pub exportable: bool,
2471    /// Whether this library can be modified by the user (add/remove/edit components).
2472    /// False for builtin and compiled libraries. True for user-created libraries.
2473    pub modifiable: bool,
2474    /// Named data model types defined by this library.
2475    /// Components reference these by name in their `field_type`.
2476    pub data_models: ComponentDataModelVec,
2477    /// Named enum types defined by this library.
2478    /// Components reference these via `ComponentFieldType::EnumRef(name)`.
2479    pub enum_models: ComponentEnumModelVec,
2480}
2481
2482impl_vec!(
2483    ComponentLibrary,
2484    ComponentLibraryVec,
2485    ComponentLibraryVecDestructor,
2486    ComponentLibraryVecDestructorType,
2487    ComponentLibraryVecSlice,
2488    OptionComponentLibrary
2489);
2490impl_option!(
2491    ComponentLibrary,
2492    OptionComponentLibrary,
2493    copy = false,
2494    [Debug, Clone]
2495);
2496impl_vec_debug!(ComponentLibrary, ComponentLibraryVec);
2497impl_vec_clone!(
2498    ComponentLibrary,
2499    ComponentLibraryVec,
2500    ComponentLibraryVecDestructor
2501);
2502impl_vec_mut!(ComponentLibrary, ComponentLibraryVec);
2503
2504/// The component map — holds libraries with namespaced components.
2505#[derive(Debug, Clone)]
2506#[repr(C)]
2507pub struct ComponentMap {
2508    /// Libraries indexed by name. "builtin" is always present.
2509    pub libraries: ComponentLibraryVec,
2510}
2511
2512impl ComponentMap {
2513    /// Qualified lookup: "shadcn:avatar" -> finds library "shadcn", component "avatar"
2514    #[must_use] pub fn get(&self, collection: &str, name: &str) -> Option<&ComponentDef> {
2515        self.libraries
2516            .iter()
2517            .find(|lib| lib.name.as_str() == collection)
2518            .and_then(|lib| lib.components.iter().find(|c| c.id.name.as_str() == name))
2519    }
2520
2521    /// Unqualified lookup: "div" -> searches ONLY the "builtin" library.
2522    #[must_use] pub fn get_unqualified(&self, name: &str) -> Option<&ComponentDef> {
2523        self.get("builtin", name)
2524    }
2525
2526    /// Parse a "collection:name" string into a lookup
2527    #[must_use] pub fn get_by_qualified_name(&self, qualified: &str) -> Option<&ComponentDef> {
2528        if let Some((collection, name)) = qualified.split_once(':') {
2529            self.get(collection, name)
2530        } else {
2531            self.get_unqualified(qualified)
2532        }
2533    }
2534
2535    /// Get all libraries that can be exported (user-defined only)
2536    #[must_use] pub fn get_exportable_libraries(&self) -> Vec<&ComponentLibrary> {
2537        self.libraries.iter().filter(|lib| lib.exportable).collect()
2538    }
2539
2540    /// Get all component definitions across all libraries
2541    #[must_use] pub fn all_components(&self) -> Vec<&ComponentDef> {
2542        self.libraries
2543            .iter()
2544            .flat_map(|lib| lib.components.iter())
2545            .collect()
2546    }
2547}
2548
2549// ============================================================================
2550// Builtin component bridge — wraps existing render/compile into ComponentDef
2551// ============================================================================
2552
2553/// Single source of truth mapping HTML/SVG tag names to node variants.
2554///
2555/// Each `"tag" => Variant` entry expands to **both** a `NodeType::Variant` arm in
2556/// [`tag_to_node_type`] and a `NodeTypeTag::Variant` arm in [`tag_to_node_type_tag`],
2557/// so the two lookups can never drift apart. Tags whose two enums diverge —
2558/// `img`, `image`, `icon` — are handled as explicit special cases inside each
2559/// generated function and are intentionally absent from this table.
2560macro_rules! html_tag_node_types {
2561    ($($tag:literal => $variant:ident),* $(,)?) => {
2562        /// Map a builtin tag name to its corresponding `NodeType`.
2563        /// Falls back to `NodeType::Div` for unknown tags.
2564        #[must_use] pub fn tag_to_node_type(tag: &str) -> NodeType {
2565            match tag {
2566                // `<img>` becomes a replaced `NodeType::Image`. The `src` attribute is not
2567                // available here, so a placeholder `NullImage` (0x0, empty tag) is created;
2568                // `xml_node_to_dom_fast` overrides it with a `NullImage` whose `tag` carries
2569                // the `src` bytes so a renderer (e.g. printpdf) can resolve the actual image.
2570                "img" => NodeType::Image(azul_css::css::BoxOrStatic::heap(
2571                    crate::resources::ImageRef::null_image(
2572                        0,
2573                        0,
2574                        crate::resources::RawImageFormat::RGBA8,
2575                        alloc::vec::Vec::new(),
2576                    ),
2577                )),
2578                $($tag => NodeType::$variant,)*
2579                _ => NodeType::Div,
2580            }
2581        }
2582
2583        /// Map a tag name to its CSS `NodeTypeTag` for CSS matching in the compile pipeline.
2584        /// Falls back to `NodeTypeTag::Div` for unknown tags.
2585        fn tag_to_node_type_tag(tag: &str) -> NodeTypeTag {
2586            match tag {
2587                // `img`/`image`/`icon` have no 1:1 `NodeType` equivalent (see
2588                // `tag_to_node_type`), so they map to dedicated `NodeTypeTag` variants.
2589                "img" | "image" => NodeTypeTag::Img,
2590                "icon" => NodeTypeTag::Icon,
2591                $($tag => NodeTypeTag::$variant,)*
2592                _ => NodeTypeTag::Div,
2593            }
2594        }
2595    };
2596}
2597
2598html_tag_node_types! {
2599    // Document structure
2600    "html" => Html,
2601    "head" => Head,
2602    "title" => Title,
2603    "body" => Body,
2604    // Block-level
2605    "div" => Div,
2606    "header" => Header,
2607    "footer" => Footer,
2608    "section" => Section,
2609    "article" => Article,
2610    "aside" => Aside,
2611    "nav" => Nav,
2612    "main" => Main,
2613    "figure" => Figure,
2614    "figcaption" => FigCaption,
2615    "address" => Address,
2616    "details" => Details,
2617    "summary" => Summary,
2618    "dialog" => Dialog,
2619    // Headings
2620    "h1" => H1,
2621    "h2" => H2,
2622    "h3" => H3,
2623    "h4" => H4,
2624    "h5" => H5,
2625    "h6" => H6,
2626    // Text content
2627    "p" => P,
2628    "span" => Span,
2629    "pre" => Pre,
2630    "code" => Code,
2631    "blockquote" => BlockQuote,
2632    "br" => Br,
2633    "hr" => Hr,
2634    // Lists
2635    "ul" => Ul,
2636    "ol" => Ol,
2637    "li" => Li,
2638    "dl" => Dl,
2639    "dt" => Dt,
2640    "dd" => Dd,
2641    "menu" => Menu,
2642    "menuitem" => MenuItem,
2643    "dir" => Dir,
2644    // Tables
2645    "table" => Table,
2646    "caption" => Caption,
2647    "thead" => THead,
2648    "tbody" => TBody,
2649    "tfoot" => TFoot,
2650    "tr" => Tr,
2651    "th" => Th,
2652    "td" => Td,
2653    "colgroup" => ColGroup,
2654    "col" => Col,
2655    // Forms
2656    "form" => Form,
2657    "fieldset" => FieldSet,
2658    "legend" => Legend,
2659    "label" => Label,
2660    "input" => Input,
2661    "button" => Button,
2662    "select" => Select,
2663    "optgroup" => OptGroup,
2664    "option" => SelectOption,
2665    "textarea" => TextArea,
2666    "output" => Output,
2667    "progress" => Progress,
2668    "meter" => Meter,
2669    "datalist" => DataList,
2670    // Inline
2671    "a" => A,
2672    "strong" => Strong,
2673    "em" => Em,
2674    "b" => B,
2675    "i" => I,
2676    "u" => U,
2677    "s" => S,
2678    "small" => Small,
2679    "mark" => Mark,
2680    "del" => Del,
2681    "ins" => Ins,
2682    "samp" => Samp,
2683    "kbd" => Kbd,
2684    "var" => Var,
2685    "cite" => Cite,
2686    "dfn" => Dfn,
2687    "abbr" => Abbr,
2688    "acronym" => Acronym,
2689    "q" => Q,
2690    "time" => Time,
2691    "sub" => Sub,
2692    "sup" => Sup,
2693    "big" => Big,
2694    "bdo" => Bdo,
2695    "bdi" => Bdi,
2696    "wbr" => Wbr,
2697    "ruby" => Ruby,
2698    "rt" => Rt,
2699    "rtc" => Rtc,
2700    "rp" => Rp,
2701    "data" => Data,
2702    // Embedded content (`img` is a special case in the generated fns)
2703    "canvas" => Canvas,
2704    "object" => Object,
2705    "param" => Param,
2706    "embed" => Embed,
2707    "audio" => Audio,
2708    "video" => Video,
2709    "source" => Source,
2710    "track" => Track,
2711    "map" => Map,
2712    "area" => Area,
2713    // SVG elements
2714    "svg" => Svg,
2715    "g" => SvgG,
2716    "defs" => SvgDefs,
2717    "symbol" => SvgSymbol,
2718    "use" => SvgUse,
2719    "switch" => SvgSwitch,
2720    "path" => SvgPath,
2721    "circle" => SvgCircle,
2722    "rect" => SvgRect,
2723    "ellipse" => SvgEllipse,
2724    "line" => SvgLine,
2725    "polygon" => SvgPolygon,
2726    "polyline" => SvgPolyline,
2727    "tspan" => SvgTspan,
2728    "textpath" => SvgTextPath,
2729    "lineargradient" => SvgLinearGradient,
2730    "radialgradient" => SvgRadialGradient,
2731    "stop" => SvgStop,
2732    "pattern" => SvgPattern,
2733    "clippath" => SvgClipPathElement,
2734    "mask" => SvgMask,
2735    "filter" => SvgFilter,
2736    "feblend" => SvgFeBlend,
2737    "fecolormatrix" => SvgFeColorMatrix,
2738    "fecomponenttransfer" => SvgFeComponentTransfer,
2739    "fecomposite" => SvgFeComposite,
2740    "feconvolvematrix" => SvgFeConvolveMatrix,
2741    "fediffuselighting" => SvgFeDiffuseLighting,
2742    "fedisplacementmap" => SvgFeDisplacementMap,
2743    "fedistantlight" => SvgFeDistantLight,
2744    "fedropshadow" => SvgFeDropShadow,
2745    "feflood" => SvgFeFlood,
2746    "fefuncr" => SvgFeFuncR,
2747    "fefuncg" => SvgFeFuncG,
2748    "fefuncb" => SvgFeFuncB,
2749    "fefunca" => SvgFeFuncA,
2750    "fegaussianblur" => SvgFeGaussianBlur,
2751    "feimage" => SvgFeImage,
2752    "femerge" => SvgFeMerge,
2753    "femergenode" => SvgFeMergeNode,
2754    "femorphology" => SvgFeMorphology,
2755    "feoffset" => SvgFeOffset,
2756    "fepointlight" => SvgFePointLight,
2757    "fespecularlighting" => SvgFeSpecularLighting,
2758    "fespotlight" => SvgFeSpotLight,
2759    "fetile" => SvgFeTile,
2760    "feturbulence" => SvgFeTurbulence,
2761    "foreignobject" => SvgForeignObject,
2762    "desc" => SvgDesc,
2763    "view" => SvgView,
2764    "animate" => SvgAnimate,
2765    "animatemotion" => SvgAnimateMotion,
2766    "animatetransform" => SvgAnimateTransform,
2767    "set" => SvgSet,
2768    "mpath" => SvgMpath,
2769    // Metadata
2770    "meta" => Meta,
2771    "link" => Link,
2772    "script" => Script,
2773    "style" => Style,
2774    "base" => Base,
2775}
2776
2777/// Default render function for builtin HTML elements.
2778/// Delegates to creating a DOM node of the appropriate `NodeType`.
2779fn builtin_render_fn(
2780    def: &ComponentDef,
2781    data: &ComponentDataModel,
2782    _component_map: &ComponentMap,
2783) -> ResultStyledDomRenderDomError {
2784    let node_type = tag_to_node_type(def.id.name.as_str());
2785    let mut dom = Dom::create_node(node_type);
2786    if let Some(text_str) = data.get_default_string("text") {
2787        let prepared = prepare_string(text_str);
2788        if !prepared.is_empty() {
2789            dom = dom.with_children(alloc::vec![Dom::create_text(prepared)].into());
2790        }
2791    }
2792    let r: Result<StyledDom, RenderDomError> = Ok(StyledDom::create(&mut dom, Css::empty()));
2793    r.into()
2794}
2795
2796/// Default compile function for builtin HTML elements.
2797/// Generates `Dom::create_node(NodeType::Div)` style code for the target language.
2798fn builtin_compile_fn(
2799    def: &ComponentDef,
2800    target: &CompileTarget,
2801    data: &ComponentDataModel,
2802    indent: usize,
2803) -> ResultStringCompileError {
2804    let node_type = tag_to_node_type(def.id.name.as_str());
2805    let type_name = format!("{node_type:?}"); // "Div", "Body", "P", etc.
2806    let text = data.get_default_string("text");
2807
2808    let r: Result<AzString, CompileError> = match target {
2809        CompileTarget::Rust => {
2810            text.map_or_else(|| Ok(format!("Dom::create_node(NodeType::{type_name})").into()), |text_str| Ok(format!(
2811                    "Dom::create_node(NodeType::{}).with_children(vec![Dom::create_text(\"{}\")])",
2812                    type_name,
2813                    text_str.as_str().replace('\\', "\\\\").replace('"', "\\\"")
2814                ).into()))
2815        }
2816        CompileTarget::C => {
2817            text.map_or_else(|| Ok(format!("AzDom_create{type_name}()").into()), |text_str| Ok(format!(
2818                    "AzDom_createText(AZ_STR(\"{}\"))",
2819                    text_str
2820                        .as_str()
2821                        .replace('\\', "\\\\")
2822                        .replace('"', "\\\"")
2823                )
2824                .into()))
2825        }
2826        CompileTarget::Cpp => Ok(format!("Dom::create_{}()", type_name.to_lowercase()).into()),
2827        CompileTarget::Python => Ok(format!("Dom.create_{}()", type_name.to_lowercase()).into()),
2828    };
2829    r.into()
2830}
2831
2832/// Pushes a `<div>` containing `"field_name: value"` text into the children list.
2833fn push_scalar_field(children: &mut Vec<Dom>, field_name: &str, value: &dyn fmt::Display) {
2834    use crate::dom::{Dom, NodeType};
2835    let text = alloc::format!("{field_name}: {value}");
2836    children.push(
2837        Dom::create_node(NodeType::Div).with_children(alloc::vec![Dom::create_text(text)].into()),
2838    );
2839}
2840
2841/// Default render function for user-defined (JSON-imported) components.
2842///
2843/// Interprets the `ComponentDef` structure generically:
2844/// 1. Creates a wrapper `<div>` with the component's CSS class
2845/// 2. For each data field, renders content based on type:
2846///    - String fields → text node with current value
2847///    - Bool fields → conditional display
2848///    - `StyledDom` fields → embeds the child DOM subtree
2849///    - StructRef/EnumRef → recursively renders sub-components if found in `ComponentMap`
2850///    - Other scalar fields → text display of the value
2851/// 3. Applies the component's scoped CSS
2852#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
2853#[must_use] pub fn user_defined_render_fn(
2854    def: &ComponentDef,
2855    data: &ComponentDataModel,
2856    component_map: &ComponentMap,
2857) -> ResultStyledDomRenderDomError {
2858    use crate::dom::{Dom, NodeType};
2859    use azul_css::css::Css;
2860
2861    let mut children: Vec<Dom> = Vec::new();
2862
2863    for field in data.fields.as_ref() {
2864        let field_name = field.name.as_str();
2865
2866        // Get the current value from default_value
2867        match &field.default_value {
2868            OptionComponentDefaultValue::None => {
2869                // Required field with no value — skip in preview
2870            }
2871            OptionComponentDefaultValue::Some(default_val) => {
2872                match default_val {
2873                    ComponentDefaultValue::String(s) => {
2874                        let text = s.as_str().trim();
2875                        if !text.is_empty() {
2876                            let label_dom = Dom::create_node(NodeType::Div).with_children(
2877                                alloc::vec![Dom::create_text(text.to_string())].into(),
2878                            );
2879                            children.push(label_dom);
2880                        }
2881                    }
2882                    ComponentDefaultValue::Bool(v) => {
2883                        push_scalar_field(&mut children, field_name, v);
2884                    }
2885                    ComponentDefaultValue::I32(v) => {
2886                        push_scalar_field(&mut children, field_name, v);
2887                    }
2888                    ComponentDefaultValue::I64(v) => {
2889                        push_scalar_field(&mut children, field_name, v);
2890                    }
2891                    ComponentDefaultValue::U32(v) => {
2892                        push_scalar_field(&mut children, field_name, v);
2893                    }
2894                    ComponentDefaultValue::U64(v) => {
2895                        push_scalar_field(&mut children, field_name, v);
2896                    }
2897                    ComponentDefaultValue::Usize(v) => {
2898                        push_scalar_field(&mut children, field_name, v);
2899                    }
2900                    ComponentDefaultValue::F32(v) => {
2901                        push_scalar_field(&mut children, field_name, v);
2902                    }
2903                    ComponentDefaultValue::F64(v) => {
2904                        push_scalar_field(&mut children, field_name, v);
2905                    }
2906                    ComponentDefaultValue::ColorU(c) => {
2907                        let text = alloc::format!(
2908                            "{}: #{:02x}{:02x}{:02x}{:02x}",
2909                            field_name,
2910                            c.r,
2911                            c.g,
2912                            c.b,
2913                            c.a
2914                        );
2915                        children.push(
2916                            Dom::create_node(NodeType::Div)
2917                                .with_children(alloc::vec![Dom::create_text(text)].into()),
2918                        );
2919                    }
2920                    ComponentDefaultValue::ComponentInstance(ci) => {
2921                        // Recursively instantiate sub-component from ComponentMap
2922                        if let Some(sub_comp) =
2923                            component_map.get(ci.library.as_str(), ci.component.as_str())
2924                        {
2925                            let sub_data = sub_comp.data_model.clone();
2926                            match (sub_comp.render_fn)(sub_comp, &sub_data, component_map) {
2927                                ResultStyledDomRenderDomError::Ok(_styled_dom) => {
2928                                    // Sub-component rendered successfully — add a placeholder
2929                                    // (StyledDom cannot be directly converted back to Dom)
2930                                    let text = alloc::format!(
2931                                        "[{}:{}]",
2932                                        ci.library.as_str(),
2933                                        ci.component.as_str()
2934                                    );
2935                                    children.push(
2936                                        Dom::create_node(NodeType::Div).with_children(
2937                                            alloc::vec![Dom::create_text(text)].into(),
2938                                        ),
2939                                    );
2940                                }
2941                                ResultStyledDomRenderDomError::Err(_) => {
2942                                    // On error, show a placeholder
2943                                    let text = alloc::format!(
2944                                        "[Error rendering {}:{}]",
2945                                        ci.library.as_str(),
2946                                        ci.component.as_str()
2947                                    );
2948                                    children.push(
2949                                        Dom::create_node(NodeType::Div).with_children(
2950                                            alloc::vec![Dom::create_text(text)].into(),
2951                                        ),
2952                                    );
2953                                }
2954                            }
2955                        } else {
2956                            let text = alloc::format!(
2957                                "[Unknown component {}:{}]",
2958                                ci.library.as_str(),
2959                                ci.component.as_str()
2960                            );
2961                            children.push(
2962                                Dom::create_node(NodeType::Div)
2963                                    .with_children(alloc::vec![Dom::create_text(text)].into()),
2964                            );
2965                        }
2966                    }
2967                    ComponentDefaultValue::CallbackFnPointer(name) => {
2968                        // Callbacks are not rendered, just acknowledged
2969                        let text = alloc::format!("{}: fn({})", field_name, name.as_str());
2970                        children.push(
2971                            Dom::create_node(NodeType::Div)
2972                                .with_children(alloc::vec![Dom::create_text(text)].into()),
2973                        );
2974                    }
2975                    ComponentDefaultValue::Json(json_str) => {
2976                        let text = alloc::format!("{}: {}", field_name, json_str.as_str());
2977                        children.push(
2978                            Dom::create_node(NodeType::Div)
2979                                .with_children(alloc::vec![Dom::create_text(text)].into()),
2980                        );
2981                    }
2982                    ComponentDefaultValue::None => {
2983                        // No default, skip
2984                    }
2985                }
2986            }
2987        }
2988    }
2989
2990    let mut wrapper = Dom::create_node(NodeType::Div);
2991    if !children.is_empty() {
2992        wrapper = wrapper.with_children(children.into());
2993    }
2994
2995    // Apply component CSS
2996    let css = if def.css.as_str().is_empty() {
2997        Css::empty()
2998    } else {
2999        Css::from_string(def.css.clone())
3000    };
3001
3002    let r: Result<StyledDom, RenderDomError> = Ok(StyledDom::create(&mut wrapper, css));
3003    r.into()
3004}
3005
3006/// Default compile function for user-defined (JSON-imported) components.
3007///
3008/// Generates source code that creates the component's DOM structure for the
3009/// target language. For each data field, emits the appropriate code:
3010/// - String fields → text node creation
3011/// - Scalar fields → formatted display
3012/// - `ComponentInstance` → function call to sub-component's render function
3013/// - `StyledDom` slots → child parameter pass-through
3014#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
3015#[must_use] pub fn user_defined_compile_fn(
3016    def: &ComponentDef,
3017    target: &CompileTarget,
3018    data: &ComponentDataModel,
3019    indent: usize,
3020) -> ResultStringCompileError {
3021    let tag = def.id.name.as_str();
3022    let indent_str = " ".repeat(indent * 4);
3023    let inner_indent = " ".repeat((indent + 1) * 4);
3024
3025    let r: Result<AzString, CompileError> = match target {
3026        CompileTarget::Rust => {
3027            let mut lines = Vec::new();
3028            lines.push(alloc::format!("{indent_str}// Component: {tag}"));
3029            lines.push(alloc::format!(
3030                "{indent_str}let mut children: Vec<Dom> = Vec::new();"
3031            ));
3032
3033            for field in data.fields.as_ref() {
3034                let fname = field.name.as_str();
3035                match &field.default_value {
3036                    OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
3037                        let escaped = s.as_str().replace('\\', "\\\\").replace('"', "\\\"");
3038                        lines.push(alloc::format!(
3039                            "{inner_indent}children.push(Dom::create_text(\"{escaped}\"));"
3040                        ));
3041                    }
3042                    OptionComponentDefaultValue::Some(ComponentDefaultValue::Bool(b)) => {
3043                        lines.push(alloc::format!(
3044                            "{inner_indent}children.push(Dom::create_text(format!(\"{{}}: {{}}\", \"{fname}\", {b}).as_str()));"
3045                        ));
3046                    }
3047                    OptionComponentDefaultValue::Some(
3048                        ComponentDefaultValue::ComponentInstance(ci),
3049                    ) => {
3050                        let fn_name =
3051                            alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
3052                        lines.push(alloc::format!(
3053                            "{}children.push({}()); // sub-component {}:{}",
3054                            inner_indent,
3055                            fn_name,
3056                            ci.library.as_str(),
3057                            ci.component.as_str()
3058                        ));
3059                    }
3060                    _ => {
3061                        // For other types, generate a placeholder comment
3062                        lines.push(alloc::format!(
3063                            "{}// field '{}': {:?}",
3064                            inner_indent,
3065                            fname,
3066                            field.field_type
3067                        ));
3068                    }
3069                }
3070            }
3071
3072            lines.push(alloc::format!(
3073                "{indent_str}Dom::create_node(NodeType::Div).with_children(children.into())"
3074            ));
3075            Ok(lines.join("\n").into())
3076        }
3077        CompileTarget::C => {
3078            let mut lines = Vec::new();
3079            lines.push(alloc::format!("{indent_str}/* Component: {tag} */"));
3080            lines.push(alloc::format!(
3081                "{indent_str}AzDom root = AzDom_createDiv();"
3082            ));
3083
3084            for field in data.fields.as_ref() {
3085                let fname = field.name.as_str();
3086                match &field.default_value {
3087                    OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
3088                        let escaped = s.as_str().replace('\\', "\\\\").replace('"', "\\\"");
3089                        lines.push(alloc::format!(
3090                            "{inner_indent}AzDom_addChild(&root, AzDom_createText(AZ_STR(\"{escaped}\")));"
3091                        ));
3092                    }
3093                    OptionComponentDefaultValue::Some(
3094                        ComponentDefaultValue::ComponentInstance(ci),
3095                    ) => {
3096                        let fn_name =
3097                            alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
3098                        lines.push(alloc::format!(
3099                            "{inner_indent}AzDom_addChild(&root, {fn_name}());"
3100                        ));
3101                    }
3102                    _ => {
3103                        lines.push(alloc::format!("{inner_indent}/* field '{fname}' */"));
3104                    }
3105                }
3106            }
3107
3108            lines.push(alloc::format!("{indent_str}return root;"));
3109            Ok(lines.join("\n").into())
3110        }
3111        CompileTarget::Cpp => {
3112            let mut lines = Vec::new();
3113            lines.push(alloc::format!("{indent_str}// Component: {tag}"));
3114            lines.push(alloc::format!(
3115                "{indent_str}auto root = Dom::create_div();"
3116            ));
3117
3118            for field in data.fields.as_ref() {
3119                let fname = field.name.as_str();
3120                match &field.default_value {
3121                    OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
3122                        let escaped = s.as_str().replace('\\', "\\\\").replace('"', "\\\"");
3123                        lines.push(alloc::format!(
3124                            "{inner_indent}root.add_child(Dom::create_text(String(\"{escaped}\")));"
3125                        ));
3126                    }
3127                    OptionComponentDefaultValue::Some(
3128                        ComponentDefaultValue::ComponentInstance(ci),
3129                    ) => {
3130                        let fn_name =
3131                            alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
3132                        lines.push(alloc::format!(
3133                            "{inner_indent}root.add_child({fn_name}());"
3134                        ));
3135                    }
3136                    _ => {
3137                        lines.push(alloc::format!("{inner_indent}// field '{fname}'"));
3138                    }
3139                }
3140            }
3141
3142            lines.push(alloc::format!("{indent_str}return root;"));
3143            Ok(lines.join("\n").into())
3144        }
3145        CompileTarget::Python => {
3146            let mut lines = Vec::new();
3147            lines.push(alloc::format!("{indent_str}# Component: {tag}"));
3148            lines.push(alloc::format!("{indent_str}root = Dom.create_div()"));
3149
3150            for field in data.fields.as_ref() {
3151                let fname = field.name.as_str();
3152                match &field.default_value {
3153                    OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
3154                        let escaped = s
3155                            .as_str()
3156                            .replace('\\', "\\\\")
3157                            .replace('"', "\\\"")
3158                            .replace('\'', "\\'");
3159                        lines.push(alloc::format!(
3160                            "{inner_indent}root = root.with_child(Dom.create_text(\"{escaped}\"))"
3161                        ));
3162                    }
3163                    OptionComponentDefaultValue::Some(
3164                        ComponentDefaultValue::ComponentInstance(ci),
3165                    ) => {
3166                        let fn_name =
3167                            alloc::format!("render_{}", ci.component.as_str().replace('-', "_"));
3168                        lines.push(alloc::format!(
3169                            "{inner_indent}root = root.with_child({fn_name}())"
3170                        ));
3171                    }
3172                    _ => {
3173                        lines.push(alloc::format!("{inner_indent}# field '{fname}'"));
3174                    }
3175                }
3176            }
3177
3178            lines.push(alloc::format!("{indent_str}return root"));
3179            Ok(lines.join("\n").into())
3180        }
3181    };
3182    r.into()
3183}
3184
3185/// Create a `ComponentDef` for a builtin HTML element.
3186///
3187/// # Arguments
3188/// * `tag` - HTML tag name (e.g. "button", "div")
3189/// * `display_name` - Human-readable name (e.g. "Button", "Div")
3190/// * `default_text` - Default text content for the preview, or `None` if the element has no text.
3191///   Pass `Some("Button text")` for `<button>`, `Some("")` for text elements like `<span>` that
3192///   accept text but have no meaningful default.
3193/// * `css` - Component-level CSS string. For most builtin elements this is `""` because
3194///   styling comes from `ua_css.rs` and the `SystemStyle`. Components that need extra
3195///   styling (e.g. a future high-level button widget) can pass CSS here.
3196fn builtin_component_def(
3197    tag: &str,
3198    display_name: &str,
3199    default_text: Option<&str>,
3200    css: &str,
3201) -> ComponentDef {
3202    let mut fields = builtin_data_model(tag);
3203    // If a default_text is provided, this element accepts text content
3204    if let Some(text) = default_text {
3205        fields.push(data_field(
3206            "text",
3207            ComponentFieldType::String,
3208            Some(ComponentDefaultValue::String(AzString::from(text))),
3209            "Text content of the element",
3210        ));
3211    }
3212    let model_name = format!("{display_name}Data");
3213    ComponentDef {
3214        id: ComponentId::builtin(tag),
3215        display_name: AzString::from(display_name),
3216        description: AzString::from(format!("HTML <{tag}> element").as_str()),
3217        css: AzString::from(css),
3218        source: ComponentSource::Builtin,
3219        data_model: ComponentDataModel {
3220            name: AzString::from(model_name.as_str()),
3221            description: AzString::from(format!("Data model for <{tag}>").as_str()),
3222            fields: fields.into(),
3223        },
3224        render_fn: builtin_render_fn,
3225        compile_fn: builtin_compile_fn,
3226        render_fn_source: None.into(),
3227        compile_fn_source: None.into(),
3228    }
3229}
3230
3231/// Helper to create a `ComponentDataField` with a rich type
3232fn data_field(
3233    name: &str,
3234    ft: ComponentFieldType,
3235    default: Option<ComponentDefaultValue>,
3236    description: &str,
3237) -> ComponentDataField {
3238    let required = default.is_none();
3239    ComponentDataField {
3240        name: AzString::from(name),
3241        field_type: ft,
3242        default_value: default.map_or_else(|| OptionComponentDefaultValue::None, OptionComponentDefaultValue::Some),
3243        required,
3244        description: AzString::from(description),
3245    }
3246}
3247
3248/// Returns the tag-specific data model fields for builtin HTML elements.
3249/// These are the component's "main data model" — the attributes that define
3250/// what the component needs as configuration (e.g., `href` for `<a>`,
3251/// `src` for `<img>`). Universal HTML attributes (id, class, style, etc.)
3252/// are NOT included here — they are added separately by the debug server.
3253#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
3254fn builtin_data_model(tag: &str) -> Vec<ComponentDataField> {
3255    use ComponentDefaultValue as D;
3256    use ComponentFieldType::{String, Bool, I32};
3257    match tag {
3258        "a" => alloc::vec![
3259            data_field(
3260                "href",
3261                String,
3262                Some(D::String(AzString::from_const_str(""))),
3263                "URL the link points to"
3264            ),
3265            data_field(
3266                "target",
3267                String,
3268                Some(D::String(AzString::from_const_str(""))),
3269                "Where to open the linked document (_blank, _self, _parent, _top)"
3270            ),
3271            data_field(
3272                "rel",
3273                String,
3274                Some(D::String(AzString::from_const_str(""))),
3275                "Relationship between current and linked document"
3276            ),
3277        ],
3278        "img" | "image" => alloc::vec![
3279            data_field("src", String, None, "URL of the image"),
3280            data_field(
3281                "alt",
3282                String,
3283                Some(D::String(AzString::from_const_str(""))),
3284                "Alternative text for the image"
3285            ),
3286            data_field(
3287                "width",
3288                String,
3289                Some(D::String(AzString::from_const_str(""))),
3290                "Width of the image"
3291            ),
3292            data_field(
3293                "height",
3294                String,
3295                Some(D::String(AzString::from_const_str(""))),
3296                "Height of the image"
3297            ),
3298        ],
3299        "form" => alloc::vec![
3300            data_field(
3301                "action",
3302                String,
3303                Some(D::String(AzString::from_const_str(""))),
3304                "URL where form data is submitted"
3305            ),
3306            data_field(
3307                "method",
3308                String,
3309                Some(D::String(AzString::from_const_str("GET"))),
3310                "HTTP method for form submission (GET or POST)"
3311            ),
3312        ],
3313        "label" => alloc::vec![data_field(
3314            "for",
3315            String,
3316            Some(D::String(AzString::from_const_str(""))),
3317            "ID of the form element this label is for"
3318        ),],
3319        "button" => alloc::vec![
3320            data_field(
3321                "type",
3322                String,
3323                Some(D::String(AzString::from_const_str("button"))),
3324                "Button type (button, submit, reset)"
3325            ),
3326            data_field(
3327                "disabled",
3328                Bool,
3329                Some(D::Bool(false)),
3330                "Whether the button is disabled"
3331            ),
3332        ],
3333        "td" | "th" => alloc::vec![
3334            data_field(
3335                "colspan",
3336                I32,
3337                Some(D::I32(1)),
3338                "Number of columns the cell spans"
3339            ),
3340            data_field(
3341                "rowspan",
3342                I32,
3343                Some(D::I32(1)),
3344                "Number of rows the cell spans"
3345            ),
3346        ],
3347        "icon" => alloc::vec![data_field(
3348            "name",
3349            String,
3350            Some(D::String(AzString::from_const_str(""))),
3351            "Icon name"
3352        ),],
3353        "ol" => alloc::vec![
3354            data_field(
3355                "start",
3356                I32,
3357                Some(D::I32(1)),
3358                "Start value for the ordered list"
3359            ),
3360            data_field(
3361                "type",
3362                String,
3363                Some(D::String(AzString::from_const_str("1"))),
3364                "Numbering type (1, A, a, I, i)"
3365            ),
3366        ],
3367        // Form controls
3368        "input" => alloc::vec![
3369            data_field(
3370                "type",
3371                String,
3372                Some(D::String(AzString::from_const_str("text"))),
3373                "Input type (text, password, email, number, checkbox, radio, etc.)"
3374            ),
3375            data_field(
3376                "name",
3377                String,
3378                Some(D::String(AzString::from_const_str(""))),
3379                "Name of the input for form submission"
3380            ),
3381            data_field(
3382                "value",
3383                String,
3384                Some(D::String(AzString::from_const_str(""))),
3385                "Current value of the input"
3386            ),
3387            data_field(
3388                "placeholder",
3389                String,
3390                Some(D::String(AzString::from_const_str(""))),
3391                "Placeholder text"
3392            ),
3393            data_field(
3394                "disabled",
3395                Bool,
3396                Some(D::Bool(false)),
3397                "Whether the input is disabled"
3398            ),
3399            data_field(
3400                "required",
3401                Bool,
3402                Some(D::Bool(false)),
3403                "Whether the input is required"
3404            ),
3405            data_field(
3406                "readonly",
3407                Bool,
3408                Some(D::Bool(false)),
3409                "Whether the input is read-only"
3410            ),
3411            data_field(
3412                "checked",
3413                Bool,
3414                Some(D::Bool(false)),
3415                "Whether the checkbox/radio is checked"
3416            ),
3417            data_field(
3418                "min",
3419                String,
3420                Some(D::String(AzString::from_const_str(""))),
3421                "Minimum value (for number, range, date)"
3422            ),
3423            data_field(
3424                "max",
3425                String,
3426                Some(D::String(AzString::from_const_str(""))),
3427                "Maximum value (for number, range, date)"
3428            ),
3429            data_field(
3430                "step",
3431                String,
3432                Some(D::String(AzString::from_const_str(""))),
3433                "Step increment (for number, range)"
3434            ),
3435            data_field(
3436                "pattern",
3437                String,
3438                Some(D::String(AzString::from_const_str(""))),
3439                "Regex pattern for validation"
3440            ),
3441            data_field(
3442                "maxlength",
3443                String,
3444                Some(D::String(AzString::from_const_str(""))),
3445                "Maximum number of characters"
3446            ),
3447        ],
3448        "select" => alloc::vec![
3449            data_field(
3450                "name",
3451                String,
3452                Some(D::String(AzString::from_const_str(""))),
3453                "Name for form submission"
3454            ),
3455            data_field(
3456                "multiple",
3457                Bool,
3458                Some(D::Bool(false)),
3459                "Whether multiple options can be selected"
3460            ),
3461            data_field(
3462                "disabled",
3463                Bool,
3464                Some(D::Bool(false)),
3465                "Whether the select is disabled"
3466            ),
3467            data_field(
3468                "required",
3469                Bool,
3470                Some(D::Bool(false)),
3471                "Whether selection is required"
3472            ),
3473            data_field(
3474                "size",
3475                String,
3476                Some(D::String(AzString::from_const_str(""))),
3477                "Number of visible options"
3478            ),
3479        ],
3480        "option" => alloc::vec![
3481            data_field(
3482                "value",
3483                String,
3484                Some(D::String(AzString::from_const_str(""))),
3485                "Value submitted with the form"
3486            ),
3487            data_field(
3488                "selected",
3489                Bool,
3490                Some(D::Bool(false)),
3491                "Whether this option is selected"
3492            ),
3493            data_field(
3494                "disabled",
3495                Bool,
3496                Some(D::Bool(false)),
3497                "Whether this option is disabled"
3498            ),
3499        ],
3500        "optgroup" => alloc::vec![
3501            data_field(
3502                "label",
3503                String,
3504                Some(D::String(AzString::from_const_str(""))),
3505                "Label for the option group"
3506            ),
3507            data_field(
3508                "disabled",
3509                Bool,
3510                Some(D::Bool(false)),
3511                "Whether the group is disabled"
3512            ),
3513        ],
3514        "textarea" => alloc::vec![
3515            data_field(
3516                "name",
3517                String,
3518                Some(D::String(AzString::from_const_str(""))),
3519                "Name for form submission"
3520            ),
3521            data_field(
3522                "placeholder",
3523                String,
3524                Some(D::String(AzString::from_const_str(""))),
3525                "Placeholder text"
3526            ),
3527            data_field("rows", I32, Some(D::I32(2)), "Number of visible text lines"),
3528            data_field(
3529                "cols",
3530                I32,
3531                Some(D::I32(20)),
3532                "Visible width in average character widths"
3533            ),
3534            data_field(
3535                "disabled",
3536                Bool,
3537                Some(D::Bool(false)),
3538                "Whether the textarea is disabled"
3539            ),
3540            data_field(
3541                "required",
3542                Bool,
3543                Some(D::Bool(false)),
3544                "Whether content is required"
3545            ),
3546            data_field(
3547                "readonly",
3548                Bool,
3549                Some(D::Bool(false)),
3550                "Whether the textarea is read-only"
3551            ),
3552            data_field(
3553                "maxlength",
3554                String,
3555                Some(D::String(AzString::from_const_str(""))),
3556                "Maximum number of characters"
3557            ),
3558        ],
3559        "fieldset" => alloc::vec![data_field(
3560            "disabled",
3561            Bool,
3562            Some(D::Bool(false)),
3563            "Whether all controls in the fieldset are disabled"
3564        ),],
3565        "output" => alloc::vec![
3566            data_field(
3567                "for",
3568                String,
3569                Some(D::String(AzString::from_const_str(""))),
3570                "IDs of elements that contributed to the output"
3571            ),
3572            data_field(
3573                "name",
3574                String,
3575                Some(D::String(AzString::from_const_str(""))),
3576                "Name for form submission"
3577            ),
3578        ],
3579        "progress" => alloc::vec![
3580            data_field(
3581                "value",
3582                String,
3583                Some(D::String(AzString::from_const_str(""))),
3584                "Current progress value"
3585            ),
3586            data_field(
3587                "max",
3588                String,
3589                Some(D::String(AzString::from_const_str("1"))),
3590                "Maximum value"
3591            ),
3592        ],
3593        "meter" => alloc::vec![
3594            data_field(
3595                "value",
3596                String,
3597                Some(D::String(AzString::from_const_str(""))),
3598                "Current value"
3599            ),
3600            data_field(
3601                "min",
3602                String,
3603                Some(D::String(AzString::from_const_str("0"))),
3604                "Minimum value"
3605            ),
3606            data_field(
3607                "max",
3608                String,
3609                Some(D::String(AzString::from_const_str("1"))),
3610                "Maximum value"
3611            ),
3612            data_field(
3613                "low",
3614                String,
3615                Some(D::String(AzString::from_const_str(""))),
3616                "Low threshold"
3617            ),
3618            data_field(
3619                "high",
3620                String,
3621                Some(D::String(AzString::from_const_str(""))),
3622                "High threshold"
3623            ),
3624            data_field(
3625                "optimum",
3626                String,
3627                Some(D::String(AzString::from_const_str(""))),
3628                "Optimum value"
3629            ),
3630        ],
3631        // Interactive
3632        "details" => alloc::vec![data_field(
3633            "open",
3634            Bool,
3635            Some(D::Bool(false)),
3636            "Whether the details are visible"
3637        ),],
3638        "dialog" => alloc::vec![data_field(
3639            "open",
3640            Bool,
3641            Some(D::Bool(false)),
3642            "Whether the dialog is active and can be interacted with"
3643        ),],
3644        // Embedded content
3645        "audio" | "video" => alloc::vec![
3646            data_field(
3647                "src",
3648                String,
3649                Some(D::String(AzString::from_const_str(""))),
3650                "URL of the media resource"
3651            ),
3652            data_field(
3653                "controls",
3654                Bool,
3655                Some(D::Bool(false)),
3656                "Whether to show playback controls"
3657            ),
3658            data_field(
3659                "autoplay",
3660                Bool,
3661                Some(D::Bool(false)),
3662                "Whether to start playing automatically"
3663            ),
3664            data_field(
3665                "loop",
3666                Bool,
3667                Some(D::Bool(false)),
3668                "Whether to loop playback"
3669            ),
3670            data_field(
3671                "muted",
3672                Bool,
3673                Some(D::Bool(false)),
3674                "Whether audio is muted"
3675            ),
3676            data_field(
3677                "preload",
3678                String,
3679                Some(D::String(AzString::from_const_str("auto"))),
3680                "Preload hint (none, metadata, auto)"
3681            ),
3682        ],
3683        "source" => alloc::vec![
3684            data_field("src", String, None, "URL of the media resource"),
3685            data_field(
3686                "type",
3687                String,
3688                Some(D::String(AzString::from_const_str(""))),
3689                "MIME type of the resource"
3690            ),
3691        ],
3692        "track" => alloc::vec![
3693            data_field("src", String, None, "URL of the track file"),
3694            data_field(
3695                "kind",
3696                String,
3697                Some(D::String(AzString::from_const_str("subtitles"))),
3698                "Kind of text track (subtitles, captions, descriptions, chapters, metadata)"
3699            ),
3700            data_field(
3701                "srclang",
3702                String,
3703                Some(D::String(AzString::from_const_str(""))),
3704                "Language of the track text"
3705            ),
3706            data_field(
3707                "label",
3708                String,
3709                Some(D::String(AzString::from_const_str(""))),
3710                "User-readable title for the track"
3711            ),
3712            data_field(
3713                "default",
3714                Bool,
3715                Some(D::Bool(false)),
3716                "Whether this is the default track"
3717            ),
3718        ],
3719        "canvas" => alloc::vec![
3720            data_field(
3721                "width",
3722                String,
3723                Some(D::String(AzString::from_const_str("300"))),
3724                "Width of the canvas in pixels"
3725            ),
3726            data_field(
3727                "height",
3728                String,
3729                Some(D::String(AzString::from_const_str("150"))),
3730                "Height of the canvas in pixels"
3731            ),
3732        ],
3733        "embed" => alloc::vec![
3734            data_field("src", String, None, "URL of the resource to embed"),
3735            data_field(
3736                "type",
3737                String,
3738                Some(D::String(AzString::from_const_str(""))),
3739                "MIME type of the embedded content"
3740            ),
3741            data_field(
3742                "width",
3743                String,
3744                Some(D::String(AzString::from_const_str(""))),
3745                "Width"
3746            ),
3747            data_field(
3748                "height",
3749                String,
3750                Some(D::String(AzString::from_const_str(""))),
3751                "Height"
3752            ),
3753        ],
3754        "object" => alloc::vec![
3755            data_field(
3756                "data",
3757                String,
3758                Some(D::String(AzString::from_const_str(""))),
3759                "URL of the resource"
3760            ),
3761            data_field(
3762                "type",
3763                String,
3764                Some(D::String(AzString::from_const_str(""))),
3765                "MIME type of the resource"
3766            ),
3767            data_field(
3768                "width",
3769                String,
3770                Some(D::String(AzString::from_const_str(""))),
3771                "Width"
3772            ),
3773            data_field(
3774                "height",
3775                String,
3776                Some(D::String(AzString::from_const_str(""))),
3777                "Height"
3778            ),
3779        ],
3780        "param" => alloc::vec![
3781            data_field("name", String, None, "Name of the parameter"),
3782            data_field(
3783                "value",
3784                String,
3785                Some(D::String(AzString::from_const_str(""))),
3786                "Value of the parameter"
3787            ),
3788        ],
3789        "area" => alloc::vec![
3790            data_field(
3791                "shape",
3792                String,
3793                Some(D::String(AzString::from_const_str("default"))),
3794                "Shape of the area (default, rect, circle, poly)"
3795            ),
3796            data_field(
3797                "coords",
3798                String,
3799                Some(D::String(AzString::from_const_str(""))),
3800                "Coordinates of the area"
3801            ),
3802            data_field(
3803                "href",
3804                String,
3805                Some(D::String(AzString::from_const_str(""))),
3806                "URL for the area link"
3807            ),
3808            data_field(
3809                "alt",
3810                String,
3811                Some(D::String(AzString::from_const_str(""))),
3812                "Alternative text"
3813            ),
3814            data_field(
3815                "target",
3816                String,
3817                Some(D::String(AzString::from_const_str(""))),
3818                "Where to open the linked document"
3819            ),
3820        ],
3821        "map" => alloc::vec![data_field(
3822            "name",
3823            String,
3824            None,
3825            "Name of the image map (referenced by usemap)"
3826        ),],
3827        // Inline semantics with special attributes
3828        "time" => alloc::vec![data_field(
3829            "datetime",
3830            String,
3831            Some(D::String(AzString::from_const_str(""))),
3832            "Machine-readable date/time value"
3833        ),],
3834        "data" => alloc::vec![data_field(
3835            "value",
3836            String,
3837            Some(D::String(AzString::from_const_str(""))),
3838            "Machine-readable value"
3839        ),],
3840        "abbr" | "acronym" | "dfn" => alloc::vec![data_field(
3841            "title",
3842            String,
3843            Some(D::String(AzString::from_const_str(""))),
3844            "Full expansion or definition"
3845        ),],
3846        "q" | "blockquote" => alloc::vec![data_field(
3847            "cite",
3848            String,
3849            Some(D::String(AzString::from_const_str(""))),
3850            "URL of the source of the quotation"
3851        ),],
3852        "del" | "ins" => alloc::vec![
3853            data_field(
3854                "cite",
3855                String,
3856                Some(D::String(AzString::from_const_str(""))),
3857                "URL explaining the change"
3858            ),
3859            data_field(
3860                "datetime",
3861                String,
3862                Some(D::String(AzString::from_const_str(""))),
3863                "Date/time of the change"
3864            ),
3865        ],
3866        "bdo" => alloc::vec![data_field(
3867            "dir",
3868            String,
3869            Some(D::String(AzString::from_const_str("ltr"))),
3870            "Text direction (ltr, rtl)"
3871        ),],
3872        "col" | "colgroup" => alloc::vec![data_field(
3873            "span",
3874            I32,
3875            Some(D::I32(1)),
3876            "Number of columns the element spans"
3877        ),],
3878        // Metadata
3879        "meta" => alloc::vec![
3880            data_field(
3881                "name",
3882                String,
3883                Some(D::String(AzString::from_const_str(""))),
3884                "Metadata name"
3885            ),
3886            data_field(
3887                "content",
3888                String,
3889                Some(D::String(AzString::from_const_str(""))),
3890                "Metadata value"
3891            ),
3892            data_field(
3893                "charset",
3894                String,
3895                Some(D::String(AzString::from_const_str(""))),
3896                "Character encoding"
3897            ),
3898            data_field(
3899                "http-equiv",
3900                String,
3901                Some(D::String(AzString::from_const_str(""))),
3902                "HTTP header equivalent"
3903            ),
3904        ],
3905        "link" => alloc::vec![
3906            data_field("rel", String, None, "Relationship type"),
3907            data_field(
3908                "href",
3909                String,
3910                Some(D::String(AzString::from_const_str(""))),
3911                "URL of the linked resource"
3912            ),
3913            data_field(
3914                "type",
3915                String,
3916                Some(D::String(AzString::from_const_str(""))),
3917                "MIME type of the linked resource"
3918            ),
3919        ],
3920        "script" => alloc::vec![
3921            data_field(
3922                "src",
3923                String,
3924                Some(D::String(AzString::from_const_str(""))),
3925                "URL of external script"
3926            ),
3927            data_field(
3928                "type",
3929                String,
3930                Some(D::String(AzString::from_const_str(""))),
3931                "MIME type or module"
3932            ),
3933            data_field(
3934                "async",
3935                Bool,
3936                Some(D::Bool(false)),
3937                "Execute asynchronously"
3938            ),
3939            data_field(
3940                "defer",
3941                Bool,
3942                Some(D::Bool(false)),
3943                "Defer execution until page load"
3944            ),
3945        ],
3946        "style" => alloc::vec![data_field(
3947            "type",
3948            String,
3949            Some(D::String(AzString::from_const_str("text/css"))),
3950            "MIME type of the style sheet"
3951        ),],
3952        "base" => alloc::vec![
3953            data_field(
3954                "href",
3955                String,
3956                Some(D::String(AzString::from_const_str(""))),
3957                "Base URL for relative URLs"
3958            ),
3959            data_field(
3960                "target",
3961                String,
3962                Some(D::String(AzString::from_const_str(""))),
3963                "Default target for hyperlinks"
3964            ),
3965        ],
3966        _ => alloc::vec![],
3967    }
3968}
3969
3970impl Default for ComponentMap {
3971    /// Returns an empty `ComponentMap` with no libraries.
3972    ///
3973    /// Use `AppConfig::create()` (which registers the 52 builtins via
3974    /// `register_builtin_components`) followed by `ComponentMap::from_libraries()`
3975    /// to get a fully-populated map.
3976    fn default() -> Self {
3977        Self {
3978            libraries: ComponentLibraryVec::from_const_slice(&[]),
3979        }
3980    }
3981}
3982
3983impl ComponentMap {
3984    #[must_use] pub fn create() -> Self {
3985        Self::default()
3986    }
3987
3988    /// Create a `ComponentMap` with the 52 built-in HTML element components pre-registered.
3989    #[must_use] pub fn with_builtin() -> Self {
3990        Self {
3991            libraries: alloc::vec![register_builtin_components()].into(),
3992        }
3993    }
3994
3995    /// Build a `ComponentMap` from the libraries stored in an `AppConfig`.
3996    ///
3997    /// The `component_libraries` field already contains builtins (registered in
3998    /// `AppConfig::create()`) plus any user-added libraries.  No merging needed —
3999    /// `add_component_library` / `add_component` handle insertion at registration time.
4000    #[must_use] pub fn from_libraries(libs: &ComponentLibraryVec) -> Self {
4001        Self {
4002            libraries: libs.clone(),
4003        }
4004    }
4005}
4006
4007/// Convert XML attributes to a `ComponentDataModel` by cloning the component's
4008/// base data model and overriding field defaults with values from the XML attributes.
4009///
4010/// This is the bridge between the XML parsing layer (key-value string pairs)
4011/// and the typed component data model. For each field in the base model,
4012/// if a matching XML attribute exists, its string value is set as the new default.
4013///
4014/// # Arguments
4015/// * `base_model` - The component's data model template (from `ComponentDef::data_model`)
4016/// * `xml_attributes` - The XML node's attribute map
4017/// * `text_content` - Optional text content from child text nodes
4018///
4019/// # Returns
4020/// A cloned `ComponentDataModel` with overridden defaults
4021fn xml_attrs_to_data_model(
4022    base_model: &ComponentDataModel,
4023    xml_attributes: &XmlAttributeMap,
4024    text_content: Option<&str>,
4025) -> ComponentDataModel {
4026    let mut model = base_model.clone();
4027
4028    // Override defaults from XML attributes
4029    let mut fields_vec = core::mem::replace(
4030        &mut model.fields,
4031        ComponentDataFieldVec::from_const_slice(&[]),
4032    )
4033    .into_library_owned_vec();
4034
4035    for field in &mut fields_vec {
4036        if let Some(attr_value) = xml_attributes.get_key(field.name.as_str()) {
4037            // Override the default_value with the XML attribute's string value
4038            field.default_value = OptionComponentDefaultValue::Some(ComponentDefaultValue::String(
4039                attr_value.clone(),
4040            ));
4041        }
4042    }
4043
4044    model.fields = ComponentDataFieldVec::from_vec(fields_vec);
4045
4046    // Handle text content — set the "text" field if present
4047    if let Some(text) = text_content {
4048        let prepared = prepare_string(text);
4049        if !prepared.is_empty() {
4050            model = model.with_default(
4051                "text",
4052                ComponentDefaultValue::String(AzString::from(prepared.as_str())),
4053            );
4054        }
4055    }
4056
4057    model
4058}
4059
4060// ============================================================================
4061// Structural builtin components: if, for, map
4062// ============================================================================
4063
4064/// `builtin:if` — conditional rendering.
4065/// Takes `condition: Bool`, `then: StyledDom`, and optionally `else: StyledDom`.
4066fn builtin_if_component() -> ComponentDef {
4067    ComponentDef {
4068        id: ComponentId::builtin("if"),
4069        display_name: AzString::from_const_str("If"),
4070        description: AzString::from_const_str("Conditional rendering: shows 'then' if condition is true, else shows 'else' (if provided)."),
4071        css: AzString::from_const_str(""),
4072        source: ComponentSource::Builtin,
4073        data_model: ComponentDataModel {
4074            name: AzString::from_const_str("IfData"),
4075            description: AzString::from_const_str("Data for conditional rendering"),
4076            fields: alloc::vec![
4077                data_field("condition", ComponentFieldType::Bool, Some(ComponentDefaultValue::Bool(false)), "The boolean condition to evaluate"),
4078            ].into(),
4079        },
4080        render_fn: builtin_if_render_fn,
4081        compile_fn: builtin_if_compile_fn,
4082        render_fn_source: None.into(),
4083        compile_fn_source: None.into(),
4084    }
4085}
4086
4087fn builtin_if_render_fn(
4088    _comp: &ComponentDef,
4089    data_model: &ComponentDataModel,
4090    _component_map: &ComponentMap,
4091) -> ResultStyledDomRenderDomError {
4092    // Evaluate the condition field
4093    let condition = data_model
4094        .fields
4095        .iter()
4096        .find(|f| f.name.as_str() == "condition")
4097        .and_then(|f| match &f.default_value {
4098            OptionComponentDefaultValue::Some(ComponentDefaultValue::Bool(b)) => Some(*b),
4099            _ => None,
4100        })
4101        .unwrap_or(false);
4102
4103    let label = if condition {
4104        "if: true (then branch)"
4105    } else {
4106        "if: false (else branch)"
4107    };
4108    let mut dom =
4109        Dom::create_node(NodeType::Div).with_children(alloc::vec![Dom::create_text(label)].into());
4110    let css = Css::empty();
4111    ResultStyledDomRenderDomError::Ok(StyledDom::create(&mut dom, css))
4112}
4113
4114fn builtin_if_compile_fn(
4115    _comp: &ComponentDef,
4116    target: &CompileTarget,
4117    _data: &ComponentDataModel,
4118    _indent: usize,
4119) -> ResultStringCompileError {
4120    match target {
4121        CompileTarget::Rust => ResultStringCompileError::Ok(AzString::from(
4122            "if data.condition {\n    // then branch\n    Dom::create_div()\n} else {\n    // else branch\n    Dom::create_div()\n}"
4123        )),
4124        CompileTarget::C => ResultStringCompileError::Ok(AzString::from(
4125            "if (data.condition) {\n    // then branch\n    AzDom_createDiv();\n} else {\n    // else branch\n    AzDom_createDiv();\n}"
4126        )),
4127        CompileTarget::Cpp => ResultStringCompileError::Ok(AzString::from(
4128            "if (data.condition) {\n    // then branch\n    Dom::create_div();\n} else {\n    // else branch\n    Dom::create_div();\n}"
4129        )),
4130        CompileTarget::Python => ResultStringCompileError::Ok(AzString::from(
4131            "if data.condition:\n    # then branch\n    Dom.create_div()\nelse:\n    # else branch\n    Dom.create_div()"
4132        )),
4133    }
4134}
4135
4136/// `builtin:for` — iterative rendering.
4137/// Takes `count: U32` (number of iterations), renders children N times.
4138fn builtin_for_component() -> ComponentDef {
4139    ComponentDef {
4140        id: ComponentId::builtin("for"),
4141        display_name: AzString::from_const_str("For Loop"),
4142        description: AzString::from_const_str(
4143            "Iterative rendering: repeats children 'count' times.",
4144        ),
4145        css: AzString::from_const_str(""),
4146        source: ComponentSource::Builtin,
4147        data_model: ComponentDataModel {
4148            name: AzString::from_const_str("ForData"),
4149            description: AzString::from_const_str("Data for iterative rendering"),
4150            fields: alloc::vec![data_field(
4151                "count",
4152                ComponentFieldType::U32,
4153                Some(ComponentDefaultValue::U32(3)),
4154                "Number of iterations"
4155            ),]
4156            .into(),
4157        },
4158        render_fn: builtin_for_render_fn,
4159        compile_fn: builtin_for_compile_fn,
4160        render_fn_source: None.into(),
4161        compile_fn_source: None.into(),
4162    }
4163}
4164
4165fn builtin_for_render_fn(
4166    _comp: &ComponentDef,
4167    data_model: &ComponentDataModel,
4168    _component_map: &ComponentMap,
4169) -> ResultStyledDomRenderDomError {
4170    let count = data_model
4171        .fields
4172        .iter()
4173        .find(|f| f.name.as_str() == "count")
4174        .and_then(|f| match &f.default_value {
4175            OptionComponentDefaultValue::Some(ComponentDefaultValue::U32(n)) => Some(*n),
4176            _ => None,
4177        })
4178        .unwrap_or(3);
4179
4180    let mut items: Vec<Dom> = Vec::new();
4181    for i in 0..count {
4182        items.push(
4183            Dom::create_node(NodeType::Div)
4184                .with_children(alloc::vec![Dom::create_text(alloc::format!("Item {i}"))].into()),
4185        );
4186    }
4187    let mut dom = Dom::create_node(NodeType::Div).with_children(items.into());
4188    let css = Css::empty();
4189    ResultStyledDomRenderDomError::Ok(StyledDom::create(&mut dom, css))
4190}
4191
4192fn builtin_for_compile_fn(
4193    _comp: &ComponentDef,
4194    target: &CompileTarget,
4195    _data: &ComponentDataModel,
4196    _indent: usize,
4197) -> ResultStringCompileError {
4198    match target {
4199        CompileTarget::Rust => ResultStringCompileError::Ok(AzString::from(
4200            "let mut children = Vec::new();\nfor i in 0..data.count {\n    children.push(Dom::create_div());\n}\nDom::create_div().with_children(children)"
4201        )),
4202        CompileTarget::C => ResultStringCompileError::Ok(AzString::from(
4203            "AzDom container = AzDom_createDiv();\nfor (uint32_t i = 0; i < data.count; i++) {\n    AzDom_addChild(&container, AzDom_createDiv());\n}"
4204        )),
4205        CompileTarget::Cpp => ResultStringCompileError::Ok(AzString::from(
4206            "auto container = Dom::create_div();\nfor (uint32_t i = 0; i < data.count; i++) {\n    container.add_child(Dom::create_div());\n}"
4207        )),
4208        CompileTarget::Python => ResultStringCompileError::Ok(AzString::from(
4209            "container = Dom.create_div()\nfor i in range(data.count):\n    container = container.with_child(Dom.create_div())"
4210        )),
4211    }
4212}
4213
4214/// `builtin:map` — map data to DOM.
4215/// Takes `data_json: String` (JSON array) + maps each element.
4216fn builtin_map_component() -> ComponentDef {
4217    ComponentDef {
4218        id: ComponentId::builtin("map"),
4219        display_name: AzString::from_const_str("Map"),
4220        description: AzString::from_const_str(
4221            "Map data to DOM: applies a template to each item in a collection.",
4222        ),
4223        css: AzString::from_const_str(""),
4224        source: ComponentSource::Builtin,
4225        data_model: ComponentDataModel {
4226            name: AzString::from_const_str("MapData"),
4227            description: AzString::from_const_str("Data for map rendering"),
4228            fields: alloc::vec![data_field(
4229                "data_json",
4230                ComponentFieldType::String,
4231                Some(ComponentDefaultValue::String(AzString::from_const_str(
4232                    "[]"
4233                ))),
4234                "JSON array of items to map over"
4235            ),]
4236            .into(),
4237        },
4238        render_fn: builtin_map_render_fn,
4239        compile_fn: builtin_map_compile_fn,
4240        render_fn_source: None.into(),
4241        compile_fn_source: None.into(),
4242    }
4243}
4244
4245fn builtin_map_render_fn(
4246    _comp: &ComponentDef,
4247    data_model: &ComponentDataModel,
4248    _component_map: &ComponentMap,
4249) -> ResultStyledDomRenderDomError {
4250    // For now, render a placeholder — actual mapping requires callback support
4251    let data_str = data_model
4252        .fields
4253        .iter()
4254        .find(|f| f.name.as_str() == "data_json")
4255        .and_then(|f| match &f.default_value {
4256            OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => {
4257                Some(s.as_str().to_string())
4258            }
4259            _ => None,
4260        })
4261        .unwrap_or_else(|| "[]".to_string());
4262
4263    let label = alloc::format!("map: data_json={data_str}");
4264    let mut dom =
4265        Dom::create_node(NodeType::Div).with_children(alloc::vec![Dom::create_text(label)].into());
4266    let css = Css::empty();
4267    ResultStyledDomRenderDomError::Ok(StyledDom::create(&mut dom, css))
4268}
4269
4270fn builtin_map_compile_fn(
4271    _comp: &ComponentDef,
4272    target: &CompileTarget,
4273    _data: &ComponentDataModel,
4274    _indent: usize,
4275) -> ResultStringCompileError {
4276    match target {
4277        CompileTarget::Rust => ResultStringCompileError::Ok(AzString::from(
4278            "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)"
4279        )),
4280        CompileTarget::C => ResultStringCompileError::Ok(AzString::from(
4281            "// Parse data.data_json and map each item\nAzDom container = AzDom_createDiv();\n// TODO: iterate parsed JSON array"
4282        )),
4283        CompileTarget::Cpp => ResultStringCompileError::Ok(AzString::from(
4284            "// Parse data.data_json and map each item\nauto container = Dom::create_div();\n// TODO: iterate parsed JSON array"
4285        )),
4286        CompileTarget::Python => ResultStringCompileError::Ok(AzString::from(
4287            "import json\nitems = json.loads(data.data_json)\ncontainer = Dom.create_div()\nfor item in items:\n    container = container.with_child(Dom.create_div())"
4288        )),
4289    }
4290}
4291
4292/// Register the 52 built-in HTML element components.
4293///
4294/// This is an `extern "C"` function pointer compatible with
4295/// `RegisterComponentLibraryFnType`, so it can be passed directly to
4296/// `AppConfig::add_component_library()`.
4297///
4298/// Called once during `AppConfig::create()` — the framework dogfoods
4299/// its own component registration system for builtins.
4300#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
4301#[must_use] pub extern "C" fn register_builtin_components() -> ComponentLibrary {
4302    ComponentLibrary {
4303        name: AzString::from_const_str("builtin"),
4304        version: AzString::from_const_str("1.0.0"),
4305        description: AzString::from_const_str("Built-in HTML elements"),
4306        exportable: false,
4307        modifiable: false,
4308        data_models: Vec::new().into(),
4309        enum_models: Vec::new().into(),
4310        components: alloc::vec![
4311            // Structural
4312            builtin_component_def("html", "HTML", None, ""),
4313            builtin_component_def("head", "Head", None, ""),
4314            builtin_component_def("title", "Title", Some(""), ""),
4315            builtin_component_def("body", "Body", None, ""),
4316            // Block-level
4317            builtin_component_def("div", "Div", None, ""),
4318            builtin_component_def("header", "Header", None, ""),
4319            builtin_component_def("footer", "Footer", None, ""),
4320            builtin_component_def("section", "Section", None, ""),
4321            builtin_component_def("article", "Article", None, ""),
4322            builtin_component_def("aside", "Aside", None, ""),
4323            builtin_component_def("nav", "Nav", None, ""),
4324            builtin_component_def("main", "Main", None, ""),
4325            builtin_component_def("figure", "Figure", None, ""),
4326            builtin_component_def("figcaption", "Figure Caption", Some(""), ""),
4327            builtin_component_def("address", "Address", Some(""), ""),
4328            builtin_component_def("details", "Details", None, ""),
4329            builtin_component_def("summary", "Summary", Some("Details"), ""),
4330            builtin_component_def("dialog", "Dialog", None, ""),
4331            // Headings — default text is the heading level name so preview is visible
4332            builtin_component_def("h1", "Heading 1", Some("Heading 1"), ""),
4333            builtin_component_def("h2", "Heading 2", Some("Heading 2"), ""),
4334            builtin_component_def("h3", "Heading 3", Some("Heading 3"), ""),
4335            builtin_component_def("h4", "Heading 4", Some("Heading 4"), ""),
4336            builtin_component_def("h5", "Heading 5", Some("Heading 5"), ""),
4337            builtin_component_def("h6", "Heading 6", Some("Heading 6"), ""),
4338            // Text content
4339            builtin_component_def("p", "Paragraph", Some("Paragraph text"), ""),
4340            builtin_component_def("span", "Span", Some(""), ""),
4341            builtin_component_def("pre", "Preformatted", Some(""), ""),
4342            builtin_component_def("code", "Code", Some(""), ""),
4343            builtin_component_def("blockquote", "Blockquote", Some(""), ""),
4344            builtin_component_def("br", "Line Break", None, ""),
4345            builtin_component_def("hr", "Horizontal Rule", None, ""),
4346            builtin_component_def("icon", "Icon", Some(""), ""),
4347            // Lists
4348            builtin_component_def("ul", "Unordered List", None, ""),
4349            builtin_component_def("ol", "Ordered List", None, ""),
4350            builtin_component_def("li", "List Item", Some("List item"), ""),
4351            builtin_component_def("dl", "Description List", None, ""),
4352            builtin_component_def("dt", "Description Term", Some(""), ""),
4353            builtin_component_def("dd", "Description Details", Some(""), ""),
4354            builtin_component_def("menu", "Menu", None, ""),
4355            builtin_component_def("menuitem", "Menu Item", Some(""), ""),
4356            builtin_component_def("dir", "Directory List", None, ""),
4357            // Tables
4358            builtin_component_def("table", "Table", None, ""),
4359            builtin_component_def("caption", "Table Caption", Some(""), ""),
4360            builtin_component_def("thead", "Table Head", None, ""),
4361            builtin_component_def("tbody", "Table Body", None, ""),
4362            builtin_component_def("tfoot", "Table Foot", None, ""),
4363            builtin_component_def("tr", "Table Row", None, ""),
4364            builtin_component_def("th", "Table Header Cell", Some("Header"), ""),
4365            builtin_component_def("td", "Table Data Cell", Some(""), ""),
4366            builtin_component_def("colgroup", "Column Group", None, ""),
4367            builtin_component_def("col", "Column", None, ""),
4368            // Inline
4369            builtin_component_def("a", "Link", Some("Link text"), ""),
4370            builtin_component_def("strong", "Strong", Some(""), ""),
4371            builtin_component_def("em", "Emphasis", Some(""), ""),
4372            builtin_component_def("b", "Bold", Some(""), ""),
4373            builtin_component_def("i", "Italic", Some(""), ""),
4374            builtin_component_def("u", "Underline", Some(""), ""),
4375            builtin_component_def("s", "Strikethrough", Some(""), ""),
4376            builtin_component_def("small", "Small", Some(""), ""),
4377            builtin_component_def("mark", "Mark", Some(""), ""),
4378            builtin_component_def("del", "Deleted Text", Some(""), ""),
4379            builtin_component_def("ins", "Inserted Text", Some(""), ""),
4380            builtin_component_def("sub", "Subscript", Some(""), ""),
4381            builtin_component_def("sup", "Superscript", Some(""), ""),
4382            builtin_component_def("samp", "Sample Output", Some(""), ""),
4383            builtin_component_def("kbd", "Keyboard Input", Some(""), ""),
4384            builtin_component_def("var", "Variable", Some(""), ""),
4385            builtin_component_def("cite", "Citation", Some(""), ""),
4386            builtin_component_def("dfn", "Definition", Some(""), ""),
4387            builtin_component_def("abbr", "Abbreviation", Some(""), ""),
4388            builtin_component_def("acronym", "Acronym", Some(""), ""),
4389            builtin_component_def("q", "Inline Quote", Some(""), ""),
4390            builtin_component_def("time", "Time", Some(""), ""),
4391            builtin_component_def("big", "Big", Some(""), ""),
4392            builtin_component_def("bdo", "BiDi Override", Some(""), ""),
4393            builtin_component_def("bdi", "BiDi Isolate", Some(""), ""),
4394            builtin_component_def("wbr", "Word Break Opportunity", None, ""),
4395            builtin_component_def("ruby", "Ruby Annotation", None, ""),
4396            builtin_component_def("rt", "Ruby Text", Some(""), ""),
4397            builtin_component_def("rtc", "Ruby Text Container", None, ""),
4398            builtin_component_def("rp", "Ruby Parenthesis", Some(""), ""),
4399            builtin_component_def("data", "Data", Some(""), ""),
4400            // Forms
4401            builtin_component_def("form", "Form", None, ""),
4402            builtin_component_def("fieldset", "Field Set", None, ""),
4403            builtin_component_def("legend", "Legend", Some("Legend"), ""),
4404            builtin_component_def("label", "Label", Some("Label"), ""),
4405            builtin_component_def("input", "Input", None, ""),
4406            builtin_component_def("button", "Button", Some("Button text"), ""),
4407            builtin_component_def("select", "Select", None, ""),
4408            builtin_component_def("optgroup", "Option Group", None, ""),
4409            builtin_component_def("option", "Option", Some(""), ""),
4410            builtin_component_def("textarea", "Text Area", Some(""), ""),
4411            builtin_component_def("output", "Output", Some(""), ""),
4412            builtin_component_def("progress", "Progress", None, ""),
4413            builtin_component_def("meter", "Meter", None, ""),
4414            builtin_component_def("datalist", "Data List", None, ""),
4415            // Embedded content
4416            builtin_component_def("canvas", "Canvas", None, ""),
4417            builtin_component_def("object", "Object", None, ""),
4418            builtin_component_def("param", "Parameter", None, ""),
4419            builtin_component_def("embed", "Embed", None, ""),
4420            builtin_component_def("audio", "Audio", None, ""),
4421            builtin_component_def("video", "Video", None, ""),
4422            builtin_component_def("source", "Source", None, ""),
4423            builtin_component_def("track", "Track", None, ""),
4424            builtin_component_def("map", "Image Map", None, ""),
4425            builtin_component_def("area", "Map Area", None, ""),
4426            builtin_component_def("svg", "SVG", None, ""),
4427            // Metadata
4428            builtin_component_def("meta", "Meta", None, ""),
4429            builtin_component_def("link", "Link (Resource)", None, ""),
4430            builtin_component_def("script", "Script", Some(""), ""),
4431            builtin_component_def("style", "Style", Some(""), ""),
4432            builtin_component_def("base", "Base URL", None, ""),
4433            // Structural control-flow builtins (F1-F3)
4434            builtin_if_component(),
4435            builtin_for_component(),
4436            builtin_map_component(),
4437        ]
4438        .into(),
4439    }
4440}
4441
4442// ============================================================================
4443// End new component system types
4444// ============================================================================
4445
4446/// Wrapper for the XML parser - necessary to easily create a Dom from
4447/// XML without putting an XML solver into `azul-core`.
4448#[derive(Debug, Default)]
4449pub struct DomXml {
4450    pub parsed_dom: StyledDom,
4451}
4452
4453impl DomXml {
4454    /// Convenience function, only available in tests, useful for quickly writing UI tests.
4455    /// Wraps the XML string in the required `<app></app>` braces, panics if the XML couldn't be
4456    /// parsed.
4457    ///
4458    /// ## Example
4459    ///
4460    /// ```rust,ignore
4461    /// # use azul::dom::Dom;
4462    /// # use azul::xml::DomXml;
4463    /// let dom = DomXml::mock("<div id='test' />");
4464    /// dom.assert_eq(Dom::create_div().with_id("test"));
4465    /// ```
4466    ///
4467    /// # Panics
4468    ///
4469    /// Panics if the rendered DOM does not equal `other` (this is a test-only
4470    /// assertion helper).
4471    #[cfg(test)]
4472    pub fn assert_eq(self, other: StyledDom) {
4473        let mut body = Dom::create_body();
4474        let mut fixed = StyledDom::create(&mut body, Css::empty());
4475        fixed.append_child(other);
4476        assert!(!(self.parsed_dom != fixed), 
4477                "\r\nExpected DOM did not match:\r\n\r\nexpected: ----------\r\n{}\r\ngot: \
4478                 ----------\r\n{}\r\n",
4479                self.parsed_dom.get_html_string("", "", true),
4480                fixed.get_html_string("", "", true)
4481            );
4482    }
4483
4484    #[must_use] pub fn into_styled_dom(self) -> StyledDom {
4485        self.into()
4486    }
4487}
4488
4489impl From<DomXml> for StyledDom {
4490    fn from(val: DomXml) -> Self {
4491        val.parsed_dom
4492    }
4493}
4494
4495/// Represents a child of an XML node - either an element or text
4496#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4497#[repr(C, u8)]
4498pub enum XmlNodeChild {
4499    /// A text node
4500    Text(AzString),
4501    /// An element node
4502    Element(XmlNode),
4503}
4504
4505impl_option!(
4506    XmlNodeChild,
4507    OptionXmlNodeChild,
4508    copy = false,
4509    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
4510);
4511
4512impl XmlNodeChild {
4513    /// Get the text content if this is a text node
4514    #[must_use] pub fn as_text(&self) -> Option<&str> {
4515        match self {
4516            Self::Text(s) => Some(s.as_str()),
4517            Self::Element(_) => None,
4518        }
4519    }
4520
4521    /// Get the element if this is an element node
4522    #[must_use] pub const fn as_element(&self) -> Option<&XmlNode> {
4523        match self {
4524            Self::Text(_) => None,
4525            Self::Element(node) => Some(node),
4526        }
4527    }
4528
4529    /// Get the element mutably if this is an element node
4530    pub const fn as_element_mut(&mut self) -> Option<&mut XmlNode> {
4531        match self {
4532            Self::Text(_) => None,
4533            Self::Element(node) => Some(node),
4534        }
4535    }
4536}
4537
4538impl_vec!(
4539    XmlNodeChild,
4540    XmlNodeChildVec,
4541    XmlNodeChildVecDestructor,
4542    XmlNodeChildVecDestructorType,
4543    XmlNodeChildVecSlice,
4544    OptionXmlNodeChild
4545);
4546impl_vec_mut!(XmlNodeChild, XmlNodeChildVec);
4547impl_vec_debug!(XmlNodeChild, XmlNodeChildVec);
4548impl_vec_partialeq!(XmlNodeChild, XmlNodeChildVec);
4549impl_vec_eq!(XmlNodeChild, XmlNodeChildVec);
4550impl_vec_partialord!(XmlNodeChild, XmlNodeChildVec);
4551impl_vec_ord!(XmlNodeChild, XmlNodeChildVec);
4552impl_vec_hash!(XmlNodeChild, XmlNodeChildVec);
4553impl_vec_clone!(XmlNodeChild, XmlNodeChildVec, XmlNodeChildVecDestructor);
4554
4555/// Represents one XML node tag
4556#[derive(Default, Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4557#[repr(C)]
4558pub struct XmlNode {
4559    /// Type of the node
4560    pub node_type: XmlTagName,
4561    /// Attributes of an XML node (note: not yet filtered and / or broken into function arguments!)
4562    pub attributes: XmlAttributeMap,
4563    /// Direct children of this node (can be text or element nodes)
4564    pub children: XmlNodeChildVec,
4565}
4566
4567impl_option!(
4568    XmlNode,
4569    OptionXmlNode,
4570    copy = false,
4571    [Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash]
4572);
4573
4574impl XmlNode {
4575    pub fn create<I: Into<XmlTagName>>(node_type: I) -> Self {
4576        Self {
4577            node_type: node_type.into(),
4578            ..Default::default()
4579        }
4580    }
4581    #[must_use] pub fn with_children(mut self, v: Vec<XmlNodeChild>) -> Self {
4582        Self {
4583            children: v.into(),
4584            ..self
4585        }
4586    }
4587
4588    /// Get all text content concatenated from direct children
4589    #[must_use] pub fn get_text_content(&self) -> String {
4590        self.children
4591            .as_ref()
4592            .iter()
4593            .filter_map(|child| child.as_text())
4594            .collect::<Vec<_>>()
4595            .join("")
4596    }
4597
4598    /// Check if this node has only text children (no element children)
4599    #[must_use] pub fn has_only_text_children(&self) -> bool {
4600        self.children
4601            .as_ref()
4602            .iter()
4603            .all(|child| matches!(child, XmlNodeChild::Text(_)))
4604    }
4605}
4606
4607impl_vec!(
4608    XmlNode,
4609    XmlNodeVec,
4610    XmlNodeVecDestructor,
4611    XmlNodeVecDestructorType,
4612    XmlNodeVecSlice,
4613    OptionXmlNode
4614);
4615impl_vec_mut!(XmlNode, XmlNodeVec);
4616impl_vec_debug!(XmlNode, XmlNodeVec);
4617impl_vec_partialeq!(XmlNode, XmlNodeVec);
4618impl_vec_eq!(XmlNode, XmlNodeVec);
4619impl_vec_partialord!(XmlNode, XmlNodeVec);
4620impl_vec_ord!(XmlNode, XmlNodeVec);
4621impl_vec_hash!(XmlNode, XmlNodeVec);
4622impl_vec_clone!(XmlNode, XmlNodeVec, XmlNodeVecDestructor);
4623
4624#[derive(Debug, Clone, PartialEq)]
4625#[repr(C, u8)]
4626pub enum DomXmlParseError {
4627    /// No `<html></html>` node component present
4628    NoHtmlNode,
4629    /// Multiple `<html>` nodes
4630    MultipleHtmlRootNodes,
4631    /// No ´<body></body>´ node in the root HTML
4632    NoBodyInHtml,
4633    /// The DOM can only have one <body> node, not multiple.
4634    MultipleBodyNodes,
4635    /// Note: Sadly, the error type can only be a string because xmlparser
4636    /// returns all errors as strings. There is an open PR to fix
4637    /// this deficiency, but since the XML parsing is only needed for
4638    /// hot-reloading and compiling, it doesn't matter that much.
4639    Xml(XmlError),
4640    /// Invalid hierarchy close tags, i.e `<app></p></app>`
4641    MalformedHierarchy(MalformedHierarchyError),
4642    /// A component raised an error while rendering the DOM - holds the component name + error
4643    /// string
4644    RenderDom(RenderDomError),
4645    /// Something went wrong while parsing an XML component
4646    Component(ComponentParseError),
4647    /// Error parsing global CSS in head node
4648    Css(CssParseErrorOwned),
4649}
4650
4651impl From<XmlError> for DomXmlParseError {
4652    fn from(e: XmlError) -> Self {
4653        Self::Xml(e)
4654    }
4655}
4656
4657impl From<ComponentParseError> for DomXmlParseError {
4658    fn from(e: ComponentParseError) -> Self {
4659        Self::Component(e)
4660    }
4661}
4662
4663impl From<RenderDomError> for DomXmlParseError {
4664    fn from(e: RenderDomError) -> Self {
4665        Self::RenderDom(e)
4666    }
4667}
4668
4669impl From<CssParseErrorOwned> for DomXmlParseError {
4670    fn from(e: CssParseErrorOwned) -> Self {
4671        Self::Css(e)
4672    }
4673}
4674
4675/// Error that can happen from the translation from XML code to Rust code -
4676/// stringified, since it is only used for printing and is not exposed in the public API
4677#[derive(Debug, Clone, PartialEq)]
4678#[repr(C, u8)]
4679pub enum CompileError {
4680    Dom(RenderDomError),
4681    Xml(DomXmlParseError),
4682    Css(CssParseErrorOwned),
4683}
4684
4685impl From<ComponentError> for CompileError {
4686    fn from(e: ComponentError) -> Self {
4687        Self::Dom(RenderDomError::Component(e))
4688    }
4689}
4690
4691impl From<CssParseErrorOwned> for CompileError {
4692    fn from(e: CssParseErrorOwned) -> Self {
4693        Self::Css(e)
4694    }
4695}
4696
4697impl fmt::Display for CompileError {
4698    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4699        use self::CompileError::{Dom, Xml, Css};
4700        match self {
4701            Dom(d) => write!(f, "{d}"),
4702            Xml(s) => write!(f, "{s}"),
4703            Css(s) => write!(f, "{}", s.to_shared()),
4704        }
4705    }
4706}
4707
4708impl From<RenderDomError> for CompileError {
4709    fn from(e: RenderDomError) -> Self {
4710        Self::Dom(e)
4711    }
4712}
4713
4714impl From<DomXmlParseError> for CompileError {
4715    fn from(e: DomXmlParseError) -> Self {
4716        Self::Xml(e)
4717    }
4718}
4719
4720/// Wrapper for `UselessFunctionArgument` error data.
4721#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4722#[repr(C)]
4723pub struct UselessFunctionArgumentError {
4724    pub component_name: AzString,
4725    pub argument_name: AzString,
4726    pub valid_args: StringVec,
4727}
4728
4729#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
4730#[repr(C, u8)]
4731pub enum ComponentError {
4732    /// While instantiating a component, a function argument
4733    /// was encountered that the component won't use or react to.
4734    UselessFunctionArgument(UselessFunctionArgumentError),
4735    /// A certain node type can't be rendered, because the
4736    /// renderer for this node is not available isn't available
4737    ///
4738    /// `UnknownComponent(component_name)`
4739    UnknownComponent(AzString),
4740}
4741
4742#[derive(Debug, Clone, PartialEq)]
4743#[repr(C, u8)]
4744pub enum RenderDomError {
4745    Component(ComponentError),
4746    /// Error parsing the CSS on the component style
4747    CssError(CssParseErrorOwned),
4748}
4749
4750impl From<ComponentError> for RenderDomError {
4751    fn from(e: ComponentError) -> Self {
4752        Self::Component(e)
4753    }
4754}
4755
4756impl From<CssParseErrorOwned> for RenderDomError {
4757    fn from(e: CssParseErrorOwned) -> Self {
4758        Self::CssError(e)
4759    }
4760}
4761
4762/// Wrapper for `MissingType` error data.
4763#[derive(Debug, Clone, PartialEq, Eq)]
4764#[repr(C)]
4765pub struct MissingTypeError {
4766    pub arg_pos: usize,
4767    pub arg_name: AzString,
4768}
4769
4770/// Wrapper for `WhiteSpaceInComponentName` error data.
4771#[derive(Debug, Clone, PartialEq, Eq)]
4772#[repr(C)]
4773pub struct WhiteSpaceInComponentNameError {
4774    pub arg_pos: usize,
4775    pub arg_name: AzString,
4776}
4777
4778/// Wrapper for `WhiteSpaceInComponentType` error data.
4779#[derive(Debug, Clone, PartialEq, Eq)]
4780#[repr(C)]
4781pub struct WhiteSpaceInComponentTypeError {
4782    pub arg_pos: usize,
4783    pub arg_name: AzString,
4784    pub arg_type: AzString,
4785}
4786
4787#[derive(Debug, Clone, PartialEq)]
4788#[repr(C, u8)]
4789pub enum ComponentParseError {
4790    /// Given `XmlNode` is not a `<component />` node.
4791    NotAComponent,
4792    /// A `<component>` node does not have a `name` attribute.
4793    UnnamedComponent,
4794    /// Argument at position `usize` is either empty or has no name
4795    MissingName(usize),
4796    /// Argument at position `usize` with the name
4797    /// `String` doesn't have a `: type`
4798    MissingType(MissingTypeError),
4799    /// Component name may not contain a whitespace
4800    /// (probably missing a `:` between the name and the type)
4801    WhiteSpaceInComponentName(WhiteSpaceInComponentNameError),
4802    /// Component type may not contain a whitespace
4803    /// (probably missing a `,` between the type and the next name)
4804    WhiteSpaceInComponentType(WhiteSpaceInComponentTypeError),
4805    /// Error parsing the <style> tag / CSS
4806    CssError(CssParseErrorOwned),
4807}
4808
4809impl fmt::Display for DomXmlParseError {
4810    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4811        use self::DomXmlParseError::{NoHtmlNode, MultipleHtmlRootNodes, NoBodyInHtml, MultipleBodyNodes, Xml, MalformedHierarchy, RenderDom, Component, Css};
4812        match self {
4813            NoHtmlNode => write!(
4814                f,
4815                "No <html> node found as the root of the file - empty file?"
4816            ),
4817            MultipleHtmlRootNodes => write!(
4818                f,
4819                "Multiple <html> nodes found as the root of the file - only one root node allowed"
4820            ),
4821            NoBodyInHtml => write!(
4822                f,
4823                "No <body> node found as a direct child of an <html> node - malformed DOM \
4824                 hierarchy?"
4825            ),
4826            MultipleBodyNodes => write!(
4827                f,
4828                "Multiple <body> nodes present, only one <body> node is allowed"
4829            ),
4830            Xml(e) => write!(f, "Error parsing XML: {e}"),
4831            MalformedHierarchy(e) => write!(
4832                f,
4833                "Invalid </{}> tag: expected </{}>",
4834                e.got.as_str(),
4835                e.expected.as_str()
4836            ),
4837            RenderDom(e) => write!(f, "Error rendering DOM: {e}"),
4838            Component(c) => write!(f, "Error parsing component in <head> node:\r\n{c}"),
4839            Css(c) => write!(f, "Error parsing CSS in <head> node:\r\n{}", c.to_shared()),
4840        }
4841    }
4842}
4843
4844impl fmt::Display for ComponentParseError {
4845    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4846        use self::ComponentParseError::{NotAComponent, UnnamedComponent, MissingName, MissingType, WhiteSpaceInComponentName, WhiteSpaceInComponentType, CssError};
4847        match self {
4848            NotAComponent => write!(f, "Expected <component/> node, found no such node"),
4849            UnnamedComponent => write!(
4850                f,
4851                "Found <component/> tag with out a \"name\" attribute, component must have a name"
4852            ),
4853            MissingName(arg_pos) => write!(
4854                f,
4855                "Argument at position {arg_pos} is either empty or has no name"
4856            ),
4857            MissingType(e) => write!(
4858                f,
4859                "Argument \"{}\" at position {} doesn't have a `: type`",
4860                e.arg_name, e.arg_pos
4861            ),
4862            WhiteSpaceInComponentName(e) => {
4863                write!(
4864                    f,
4865                    "Missing `:` between the name and the type in argument {} (around \"{}\")",
4866                    e.arg_pos, e.arg_name
4867                )
4868            }
4869            WhiteSpaceInComponentType(e) => {
4870                write!(
4871                    f,
4872                    "Missing `,` between two arguments (in argument {}, position {}, around \
4873                     \"{}\")",
4874                    e.arg_name, e.arg_pos, e.arg_type
4875                )
4876            }
4877            CssError(lsf) => write!(f, "Error parsing <style> tag: {}", lsf.to_shared()),
4878        }
4879    }
4880}
4881
4882impl fmt::Display for ComponentError {
4883    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4884        use self::ComponentError::{UselessFunctionArgument, UnknownComponent};
4885        match self {
4886            UselessFunctionArgument(e) => {
4887                write!(
4888                    f,
4889                    "Useless component argument \"{}\": \"{}\" - available args are: {:#?}",
4890                    e.component_name, e.argument_name, e.valid_args
4891                )
4892            }
4893            UnknownComponent(name) => write!(f, "Unknown component: \"{name}\""),
4894        }
4895    }
4896}
4897
4898impl fmt::Display for RenderDomError {
4899    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4900        use self::RenderDomError::{Component, CssError};
4901        match self {
4902            Component(c) => write!(f, "{c}"),
4903            CssError(e) => write!(f, "Error parsing CSS in component: {}", e.to_shared()),
4904        }
4905    }
4906}
4907
4908/// Find the one and only `<body>` node, return error if
4909/// there is no app node or there are multiple app nodes
4910#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
4911/// # Errors
4912///
4913/// Returns an error if the document has no `<html>` root node.
4914pub fn get_html_node(root_nodes: &[XmlNodeChild]) -> Result<&XmlNode, DomXmlParseError> {
4915    let mut html_node_iterator = root_nodes.iter().filter_map(|child| {
4916        if let XmlNodeChild::Element(node) = child {
4917            // HTML element names are case-insensitive (ASCII). NOT normalize_casing:
4918            // that inserts '_' before each uppercase letter for component-name
4919            // canonicalisation, so "HTML" would become "h_t_m_l" and never match.
4920            if node.node_type.as_str().eq_ignore_ascii_case("html") {
4921                Some(node)
4922            } else {
4923                None
4924            }
4925        } else {
4926            None
4927        }
4928    });
4929
4930    let html_node = html_node_iterator
4931        .next()
4932        .ok_or(DomXmlParseError::NoHtmlNode)?;
4933    if html_node_iterator.next().is_some() {
4934        Err(DomXmlParseError::MultipleHtmlRootNodes)
4935    } else {
4936        Ok(html_node)
4937    }
4938}
4939
4940/// Find the one and only `<body>` node, return error if
4941/// there is no app node or there are multiple app nodes
4942#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
4943/// # Errors
4944///
4945/// Returns an error if the document has no `<body>` node.
4946pub fn get_body_node(root_nodes: &[XmlNodeChild]) -> Result<&XmlNode, DomXmlParseError> {
4947    fn find_body_recursive(nodes: &[XmlNodeChild], depth: usize) -> Option<&XmlNode> {
4948        // AUDIT 2026-07-08: bound recursion depth to avoid a stack overflow on
4949        // pathologically deep markup while hunting for the <body> element.
4950        if depth > MAX_XML_NESTING_DEPTH {
4951            return None;
4952        }
4953        for child in nodes {
4954            if let XmlNodeChild::Element(node) = child {
4955                // case-insensitive ASCII tag match; see get_html_node.
4956                if node.node_type.as_str().eq_ignore_ascii_case("body") {
4957                    return Some(node);
4958                }
4959                // Recurse into children
4960                if let Some(found) = find_body_recursive(node.children.as_ref(), depth + 1) {
4961                    return Some(found);
4962                }
4963            }
4964        }
4965        None
4966    }
4967
4968    // First try to find body as a direct child (proper HTML structure)
4969    let direct_body = root_nodes.iter().find_map(|child| {
4970        if let XmlNodeChild::Element(node) = child {
4971            // case-insensitive ASCII tag match; see get_html_node.
4972            if node.node_type.as_str().eq_ignore_ascii_case("body") {
4973                Some(node)
4974            } else {
4975                None
4976            }
4977        } else {
4978            None
4979        }
4980    });
4981
4982    if let Some(body) = direct_body {
4983        return Ok(body);
4984    }
4985
4986    // If not found as direct child, search recursively (for malformed HTML like example.com)
4987    // where <body> might be nested inside <head> due to missing </head> tag
4988    find_body_recursive(root_nodes, 0).ok_or(DomXmlParseError::NoBodyInHtml)
4989}
4990
4991/// Searches in the the `root_nodes` for a `node_type`, convenience function in order to
4992/// for example find the first <blah /> node in all these nodes.
4993/// This function searches recursively through the entire tree.
4994fn find_node_by_type<'a>(root_nodes: &'a [XmlNodeChild], node_type: &str) -> Option<&'a XmlNode> {
4995    // First check direct children
4996    for child in root_nodes {
4997        if let XmlNodeChild::Element(node) = child {
4998            // case-insensitive ASCII tag match; see get_html_node.
4999            if node.node_type.as_str().eq_ignore_ascii_case(node_type) {
5000                return Some(node);
5001            }
5002        }
5003    }
5004
5005    // If not found, search recursively (for malformed HTML)
5006    for child in root_nodes {
5007        if let XmlNodeChild::Element(node) = child {
5008            if let Some(found) = find_node_by_type(node.children.as_ref(), node_type) {
5009                return Some(found);
5010            }
5011        }
5012    }
5013
5014    None
5015}
5016
5017#[must_use] pub fn find_attribute<'a>(node: &'a XmlNode, attribute: &str) -> Option<&'a AzString> {
5018    node.attributes
5019        .iter()
5020        .find(|n| normalize_casing(n.key.as_str()).as_str() == attribute)
5021        .map(|s| &s.value)
5022}
5023
5024/// Normalizes input such as `abcDef`, `AbcDef`, `abc-def` to the normalized form of `abc_def`
5025#[must_use] pub fn normalize_casing(input: &str) -> String {
5026    let mut words: Vec<String> = Vec::new();
5027    let mut cur_str = Vec::new();
5028
5029    for ch in input.chars() {
5030        if ch.is_uppercase() || ch == '_' || ch == '-' {
5031            if !cur_str.is_empty() {
5032                words.push(cur_str.iter().collect());
5033                cur_str.clear();
5034            }
5035            if ch.is_uppercase() {
5036                cur_str.extend(ch.to_lowercase());
5037            }
5038        } else {
5039            cur_str.extend(ch.to_lowercase());
5040        }
5041    }
5042
5043    if !cur_str.is_empty() {
5044        words.push(cur_str.iter().collect());
5045        cur_str.clear();
5046    }
5047
5048    words.join("_")
5049}
5050
5051/// Given a root node, traverses along the hierarchy, and returns a
5052/// mutable reference to the last child node of the root node
5053#[allow(trivial_casts)]
5054pub fn get_item<'a>(hierarchy: &[usize], root_node: &'a mut XmlNode) -> Option<&'a mut XmlNode> {
5055    let mut hierarchy = hierarchy.to_vec();
5056    hierarchy.reverse();
5057    let Some(item) = hierarchy.pop() else {
5058        return Some(root_node);
5059    };
5060    let child = root_node.children.as_mut().get_mut(item)?;
5061    match child {
5062        XmlNodeChild::Element(node) => get_item_internal(&mut hierarchy, node),
5063        XmlNodeChild::Text(_) => None, // Can't traverse into text nodes
5064    }
5065}
5066
5067fn get_item_internal<'a>(
5068    hierarchy: &mut Vec<usize>,
5069    root_node: &'a mut XmlNode,
5070) -> Option<&'a mut XmlNode> {
5071    if hierarchy.is_empty() {
5072        return Some(root_node);
5073    }
5074    let Some(cur_item) = hierarchy.pop() else {
5075        return Some(root_node);
5076    };
5077    let child = root_node.children.as_mut().get_mut(cur_item)?;
5078    match child {
5079        XmlNodeChild::Element(node) => get_item_internal(hierarchy, node),
5080        XmlNodeChild::Text(_) => None, // Can't traverse into text nodes
5081    }
5082}
5083
5084/// Parses an XML string and returns a `StyledDom` with the components instantiated in the
5085/// `<app></app>`
5086#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5087/// # Errors
5088///
5089/// Returns an error if the XML cannot be parsed into a DOM (malformed markup or an unknown component).
5090pub fn str_to_dom<'a>(
5091    root_nodes: &'a [XmlNodeChild],
5092    component_map: &'a ComponentMap,
5093    max_width: Option<f32>,
5094) -> Result<StyledDom, DomXmlParseError> {
5095    // Delegate to the fast path (Dom::Fast / CompactDom arena).
5096    str_to_dom_fast(root_nodes, component_map, max_width)
5097}
5098
5099/// Parse XML to `StyledDom` via arena-based `FastDom` (no tree intermediary).
5100///
5101/// **Note**: `str_to_dom()` now delegates to this function, so you can use
5102/// either one. This function is kept for backward compatibility.
5103#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5104fn str_to_dom_fast<'a>(
5105    root_nodes: &'a [XmlNodeChild],
5106    component_map: &'a ComponentMap,
5107    max_width: Option<f32>,
5108) -> Result<StyledDom, DomXmlParseError> {
5109    let html_node = get_html_node(root_nodes)?;
5110    let body_node = get_body_node(html_node.children.as_ref())?;
5111
5112    let mut global_style = None;
5113
5114    if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
5115        if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
5116            let text = style_node.get_text_content();
5117            if !text.is_empty() {
5118                let parsed_css = Css::from_string(text.into());
5119                global_style = Some(parsed_css);
5120            }
5121        }
5122    }
5123
5124    render_dom_from_body_node_fast(body_node, global_style, component_map, max_width)
5125        .map_err(Into::into)
5126}
5127
5128/// Parses XML nodes and returns a `Dom` with CSS stylesheets attached (but not applied).
5129///
5130/// Unlike `str_to_dom` which returns a fully styled `StyledDom`, this function
5131/// returns an unstyled `Dom` whose `css` field carries the parsed `<style>` rules.
5132/// The layout framework will apply the CSS during the cascade pass.
5133///
5134/// This is the correct function for building a `Dom` from XML in layout callbacks
5135/// (which must return `Dom`, not `StyledDom`).
5136#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5137/// # Errors
5138///
5139/// Returns an error if the XML cannot be parsed into a DOM (malformed markup or an unknown component).
5140pub fn str_to_dom_unstyled<'a>(
5141    root_nodes: &'a [XmlNodeChild],
5142    component_map: &'a ComponentMap,
5143) -> Result<Dom, DomXmlParseError> {
5144    let html_node = get_html_node(root_nodes)?;
5145    let body_node = get_body_node(html_node.children.as_ref())?;
5146
5147    let mut global_style = None;
5148
5149    if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
5150        if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
5151            let text = style_node.get_text_content();
5152            if !text.is_empty() {
5153                let parsed_css = Css::from_string(text.into());
5154                global_style = Some(parsed_css);
5155            }
5156        }
5157    }
5158
5159    // Build the DOM tree from the body node
5160    let body_dom = xml_node_to_dom_fast(body_node, component_map, false, 0)
5161        .map_err(DomXmlParseError::from)?;
5162
5163    // Wrap in proper HTML structure (NodeType is imported at module top)
5164    let root_node_type = body_dom.root.node_type.clone();
5165
5166    let mut full_dom = match root_node_type {
5167        NodeType::Html => body_dom,
5168        NodeType::Body => Dom::create_html().with_child(body_dom),
5169        _ => {
5170            let body_wrapper = Dom::create_body().with_child(body_dom);
5171            Dom::create_html().with_child(body_wrapper)
5172        }
5173    };
5174
5175    // Attach CSS to the Dom's css field instead of applying it immediately
5176    if let Some(css) = global_style {
5177        full_dom.css = alloc::vec![css].into();
5178    }
5179
5180    Ok(full_dom)
5181}
5182
5183/// Parses an XML string and returns a `String`, which contains the Rust source code
5184/// (i.e. it compiles the XML to valid Rust)
5185#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5186/// # Errors
5187///
5188/// Returns an error if the XML cannot be parsed or compiled to Rust code.
5189pub fn str_to_rust_code<'a>(
5190    root_nodes: &'a [XmlNodeChild],
5191    imports: &str,
5192    component_map: &'a ComponentMap,
5193) -> Result<String, CompileError> {
5194    let html_node = get_html_node(root_nodes)?;
5195    let body_node = get_body_node(html_node.children.as_ref())?;
5196    let mut global_style = Css::empty();
5197
5198    if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
5199        if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
5200            let text = style_node.get_text_content();
5201            if !text.is_empty() {
5202                let parsed_css = azul_css::parser2::new_from_str(&text).0;
5203                global_style = parsed_css;
5204            }
5205        }
5206    }
5207
5208    global_style.sort_by_specificity();
5209
5210    let mut css_blocks = BTreeMap::new();
5211    let mut extra_blocks = VecContents::default();
5212    let app_source = compile_body_node_to_rust_code(
5213        body_node,
5214        component_map,
5215        &mut extra_blocks,
5216        &mut css_blocks,
5217        &global_style,
5218        CssMatcher {
5219            path: Vec::new(),
5220            indices_in_parent: vec![0],
5221            children_length: vec![body_node.children.as_ref().len()],
5222        },
5223    )?;
5224
5225    let app_source = app_source
5226        .lines()
5227        .map(|l| format!("        {l}"))
5228        .collect::<Vec<String>>()
5229        .join("\r\n");
5230
5231    // NOTE: `css_blocks` / `extra_blocks` are no longer emitted — per-node styles
5232    // are now inlined as `.with_css("..")` strings (public API) rather than as
5233    // `const CSS_MATCH_*: NodeDataInlineCssPropertyVec` blocks (that API was
5234    // removed in 32d44ed8a). The maps stay in the signatures for compatibility.
5235    let _ = (&css_blocks, &extra_blocks);
5236
5237    let main_func = "
5238
5239use azul::{
5240    app::{App, AppConfig},
5241    dom::Dom,
5242    callbacks::{RefAny, LayoutCallbackInfo},
5243    window::WindowCreateOptions,
5244};
5245
5246struct Data { }
5247
5248extern \"C\" fn render(_: RefAny, _: LayoutCallbackInfo) -> Dom {
5249    crate::ui::render()
5250}
5251
5252fn main() {
5253    let config = AppConfig::create();
5254    let app = App::create(RefAny::new(Data { }), config);
5255    let window = WindowCreateOptions::create(render);
5256    app.run(window);
5257}";
5258
5259    let ui_module = format!(
5260        "#[allow(unused_imports)]\r\npub mod ui {{
5261
5262    use azul::prelude::*;
5263    use azul::dom::{{NodeType, TabIndex, SmallAriaInfo}};
5264    use azul::str::String as AzString;
5265
5266    pub fn render() -> Dom {{\r\n{app_source}\r\n    }}\r\n}}"
5267    );
5268    let source_code = format!(
5269        "#![windows_subsystem = \"windows\"]\r\n//! Auto-generated UI source \
5270         code\r\n{}\r\n{}\r\n\r\n{}{}",
5271        imports,
5272        compile_components(Vec::new()), // no user-defined components to compile
5273        ui_module,
5274        main_func,
5275    );
5276
5277    Ok(source_code)
5278}
5279
5280// Compile all components to source code
5281#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
5282fn compile_components(
5283    components: Vec<(
5284        ComponentName,
5285        CompiledComponent,
5286        ComponentArguments,
5287        BTreeMap<String, String>,
5288    )>,
5289) -> String {
5290    let cs = components
5291        .iter()
5292        .map(|(name, function_body, function_args, css_blocks)| {
5293            let name = &normalize_casing(name);
5294            let f = compile_component(name, function_args, function_body)
5295                .lines()
5296                .map(|l| format!("    {l}"))
5297                .collect::<Vec<String>>()
5298                .join("\r\n");
5299
5300            // let css_blocks = ...
5301
5302            format!(
5303                "#[allow(unused_imports)]\r\npub mod {name} {{\r\n    use azul::dom::Dom;\r\n    use \
5304                 azul::str::String as AzString;\r\n{f}\r\n}}"
5305            )
5306        })
5307        .collect::<Vec<String>>()
5308        .join("\r\n\r\n");
5309
5310    let cs = cs
5311        .lines()
5312        .map(|l| format!("    {l}"))
5313        .collect::<Vec<String>>()
5314        .join("\r\n");
5315
5316    if cs.is_empty() {
5317        cs
5318    } else {
5319        format!("pub mod components {{\r\n{cs}\r\n}}")
5320    }
5321}
5322
5323fn format_component_args(component_args: &ComponentArgumentVec) -> String {
5324    let mut args = component_args
5325        .iter()
5326        .map(|a| format!("{}: {}", a.name, a.arg_type))
5327        .collect::<Vec<String>>();
5328
5329    args.sort_by(|a, b| b.cmp(a));
5330
5331    args.join(", ")
5332}
5333
5334#[must_use] pub fn compile_component(
5335    component_name: &str,
5336    component_args: &ComponentArguments,
5337    component_function_body: &str,
5338) -> String {
5339    let component_name = &normalize_casing(component_name);
5340    let function_args = format_component_args(&component_args.args);
5341    let component_function_body = component_function_body
5342        .lines()
5343        .map(|l| format!("    {l}"))
5344        .collect::<Vec<String>>()
5345        .join("\r\n");
5346    let should_inline = component_function_body.lines().count() == 1;
5347    format!(
5348        "{}pub fn render({}{}{}) -> Dom {{\r\n{}\r\n}}",
5349        if should_inline { "#[inline]\r\n" } else { "" },
5350        // pass the text content as the first
5351        if component_args.accepts_text {
5352            "text: AzString"
5353        } else {
5354            ""
5355        },
5356        if function_args.is_empty() || !component_args.accepts_text {
5357            ""
5358        } else {
5359            ", "
5360        },
5361        function_args,
5362        component_function_body,
5363    )
5364}
5365
5366/// Parse an SVG numeric attribute value to f32.
5367fn parse_svg_float(attr: Option<&AzString>) -> Option<f32> {
5368    attr?.as_str().trim().parse::<f32>().ok()
5369}
5370
5371/// Parse an SVG `points` attribute (used by `<polygon>` and `<polyline>`).
5372fn parse_svg_points(pts: &str, close: bool) -> Option<crate::svg::SvgMultiPolygon> {
5373    let nums: Vec<f32> = pts
5374        .split(|c: char| c == ',' || c.is_ascii_whitespace())
5375        .filter(|s| !s.is_empty())
5376        .filter_map(|s| s.parse::<f32>().ok())
5377        .collect();
5378    if nums.len() < 4 || !nums.len().is_multiple_of(2) {
5379        return None;
5380    }
5381    let mut elements = Vec::new();
5382    let points: Vec<azul_css::props::basic::SvgPoint> = nums
5383        .chunks_exact(2)
5384        .map(|c| azul_css::props::basic::SvgPoint { x: c[0], y: c[1] })
5385        .collect();
5386    for w in points.windows(2) {
5387        elements.push(crate::svg::SvgPathElement::Line(crate::svg::SvgLine::new(
5388            w[0], w[1],
5389        )));
5390    }
5391    if close && points.len() >= 2 {
5392        let first = points[0];
5393        let last = *points.last().unwrap();
5394        if (first.x - last.x).abs() > 0.001 || (first.y - last.y).abs() > 0.001 {
5395            elements.push(crate::svg::SvgPathElement::Line(crate::svg::SvgLine::new(
5396                last, first,
5397            )));
5398        }
5399    }
5400    Some(crate::svg::SvgMultiPolygon {
5401        rings: crate::svg::SvgPathVec::from_vec(vec![crate::svg::SvgPath {
5402            items: crate::svg::SvgPathElementVec::from_vec(elements),
5403        }]),
5404    })
5405}
5406
5407/// Fast XML to Dom conversion that builds Dom tree directly without intermediate `StyledDom`
5408/// This is O(n) instead of O(n²) for large documents
5409/// Apply the shared set of XML attributes onto a single [`NodeData`] node.
5410///
5411/// Handles `<img src>` rebuild, `id`/`class`, `focusable`, `tabindex`, inline
5412/// `style`, and SVG-shape geometry — the block that was previously duplicated
5413/// verbatim between [`xml_node_to_dom_fast`] (operating on `dom.root`) and
5414/// [`xml_node_to_fast_dom`] (operating on the arena `NodeData`). `component_name`
5415/// must already be normalized (lowercased); the caller computes `child_inside_svg`.
5416#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
5417fn apply_xml_node_attributes(
5418    node: &mut crate::dom::NodeData,
5419    xml_node: &XmlNode,
5420    component_name: &str,
5421    inside_svg: bool,
5422) {
5423    use crate::dom::{IdOrClass, NodeType, TabIndex};
5424
5425    // `<img src="...">`: rebuild the placeholder Image node so its `NullImage`
5426    // carries the `src` string (as UTF-8 bytes in `tag`). The bytes are NOT
5427    // resolved here — a downstream renderer (printpdf, the compositor, ...) uses
5428    // the tag to look up and embed the actual image. Optional `width`/`height`
5429    // attributes set the intrinsic size used for layout (CSS still overrides).
5430    if component_name == "img" {
5431        if let Some(src) = xml_node.attributes.get_key("src") {
5432            let width = xml_node
5433                .attributes
5434                .get_key("width")
5435                .and_then(|w| {
5436                    w.as_str()
5437                        .trim()
5438                        .trim_end_matches("px")
5439                        .trim()
5440                        .parse::<usize>()
5441                        .ok()
5442                })
5443                .unwrap_or(0);
5444            let height = xml_node
5445                .attributes
5446                .get_key("height")
5447                .and_then(|h| {
5448                    h.as_str()
5449                        .trim()
5450                        .trim_end_matches("px")
5451                        .trim()
5452                        .parse::<usize>()
5453                        .ok()
5454                })
5455                .unwrap_or(0);
5456            let image_ref = crate::resources::ImageRef::null_image(
5457                width,
5458                height,
5459                crate::resources::RawImageFormat::RGBA8,
5460                src.as_str().as_bytes().to_vec(),
5461            );
5462            node
5463                .set_node_type(NodeType::Image(azul_css::css::BoxOrStatic::heap(image_ref)));
5464        }
5465    }
5466
5467    // Set id and class attributes
5468    let mut ids_and_classes = Vec::new();
5469    if let Some(id_str) = xml_node.attributes.get_key("id") {
5470        for id in id_str.split_whitespace() {
5471            ids_and_classes.push(IdOrClass::Id(id.into()));
5472        }
5473    }
5474    if let Some(class_str) = xml_node.attributes.get_key("class") {
5475        for class in class_str.split_whitespace() {
5476            ids_and_classes.push(IdOrClass::Class(class.into()));
5477        }
5478    }
5479    if !ids_and_classes.is_empty() {
5480        node.set_ids_and_classes(ids_and_classes.into());
5481    }
5482
5483    // Handle focusable attribute
5484    if let Some(focusable) = xml_node
5485        .attributes
5486        .get_key("focusable")
5487        .and_then(|f| parse_bool(f.as_str()))
5488    {
5489        if focusable { node.set_tab_index(TabIndex::Auto) } else { node.set_tab_index(TabIndex::NoKeyboardFocus) }
5490    }
5491
5492    // Handle tabindex attribute
5493    if let Some(tab_index) = xml_node
5494        .attributes
5495        .get_key("tabindex")
5496        .and_then(|val| val.parse::<isize>().ok())
5497    {
5498        match tab_index {
5499            0 => node.set_tab_index(TabIndex::Auto),
5500            i if i > 0 => node.set_tab_index(TabIndex::OverrideInParent(u32::try_from(i).unwrap_or(u32::MAX))),
5501            _ => node.set_tab_index(TabIndex::NoKeyboardFocus),
5502        }
5503    }
5504
5505    // Table cell span attributes (`colspan` / `rowspan`).
5506    apply_cell_span_attributes(node, xml_node);
5507
5508    // HTML `dir` attribute → the `direction` CSS property (dir="rtl"/"ltr"). Without
5509    // this, dir="rtl" (the common way to set RTL in HTML) had no effect. Appended
5510    // BEFORE the inline `style` below so author style still wins on equal specificity.
5511    let dir_prop = xml_node.attributes.get_key("dir").and_then(|d| {
5512        let v = d.as_str().trim();
5513        if v.eq_ignore_ascii_case("rtl") {
5514            Some(azul_css::props::style::StyleDirection::Rtl)
5515        } else if v.eq_ignore_ascii_case("ltr") {
5516            Some(azul_css::props::style::StyleDirection::Ltr)
5517        } else {
5518            None
5519        }
5520    });
5521
5522    // Handle inline style attribute (and the mapped `dir` attribute above)
5523    let style_attr = xml_node.attributes.get_key("style");
5524    if style_attr.is_some() || dir_prop.is_some() {
5525        use azul_css::dynamic_selector::CssPropertyWithConditions;
5526        let css_key_map = azul_css::props::property::get_css_key_map();
5527        let mut props: Vec<CssPropertyWithConditions> = Vec::new();
5528        if let Some(dir) = dir_prop {
5529            props.push(CssPropertyWithConditions::simple(
5530                azul_css::props::property::CssProperty::Direction(
5531                    azul_css::css::CssPropertyValue::Exact(dir),
5532                ),
5533            ));
5534        }
5535        if let Some(style) = style_attr {
5536            let mut attributes = Vec::new();
5537            for s in style.as_str().split(';') {
5538                let mut s = s.split(':');
5539                let Some(key) = s.next() else {
5540                    continue;
5541                };
5542                let Some(value) = s.next() else {
5543                    continue;
5544                };
5545                // Called for its side effect (writes parsed props into `attributes`);
5546                // the returned value is intentionally discarded.
5547                drop(azul_css::parser2::parse_css_declaration(
5548                    key.trim(),
5549                    value.trim(),
5550                    azul_css::parser2::ErrorLocationRange::default(),
5551                    &css_key_map,
5552                    &mut Vec::new(),
5553                    &mut attributes,
5554                ));
5555            }
5556            props.extend(attributes.into_iter().filter_map(|s| match s {
5557                CssDeclaration::Static(s) => Some(CssPropertyWithConditions::simple(s)),
5558                CssDeclaration::Dynamic(_) => None,
5559            }));
5560        }
5561        if !props.is_empty() {
5562            node.set_css_props(props.into());
5563        }
5564    }
5565
5566    // Handle SVG shape elements when inside an <svg> context
5567    let tag = component_name;
5568    let is_svg_shape = inside_svg
5569        && matches!(
5570            tag,
5571            "path" | "circle" | "rect" | "ellipse" | "line" | "polygon" | "polyline"
5572        );
5573
5574    if is_svg_shape {
5575        let clip = match tag {
5576            "path" => xml_node
5577                .attributes
5578                .get_key("d")
5579                .and_then(|d| crate::path_parser::parse_svg_path_d(d.as_str()).ok()),
5580            "circle" => {
5581                let cx = parse_svg_float(xml_node.attributes.get_key("cx")).unwrap_or(0.0);
5582                let cy = parse_svg_float(xml_node.attributes.get_key("cy")).unwrap_or(0.0);
5583                let r = parse_svg_float(xml_node.attributes.get_key("r")).unwrap_or(0.0);
5584                if r > 0.0 {
5585                    Some(crate::svg::SvgMultiPolygon {
5586                        rings: crate::svg::SvgPathVec::from_vec(vec![
5587                            crate::path_parser::svg_circle_to_paths(cx, cy, r),
5588                        ]),
5589                    })
5590                } else {
5591                    None
5592                }
5593            }
5594            "rect" => {
5595                let x = parse_svg_float(xml_node.attributes.get_key("x")).unwrap_or(0.0);
5596                let y = parse_svg_float(xml_node.attributes.get_key("y")).unwrap_or(0.0);
5597                let w = parse_svg_float(xml_node.attributes.get_key("width")).unwrap_or(0.0);
5598                let h = parse_svg_float(xml_node.attributes.get_key("height")).unwrap_or(0.0);
5599                let rx = parse_svg_float(xml_node.attributes.get_key("rx")).unwrap_or(0.0);
5600                let ry = parse_svg_float(xml_node.attributes.get_key("ry")).unwrap_or(rx);
5601                if w > 0.0 && h > 0.0 {
5602                    Some(crate::svg::SvgMultiPolygon {
5603                        rings: crate::svg::SvgPathVec::from_vec(vec![
5604                            crate::path_parser::svg_rect_to_path(x, y, w, h, rx, ry),
5605                        ]),
5606                    })
5607                } else {
5608                    None
5609                }
5610            }
5611            "ellipse" => {
5612                let cx = parse_svg_float(xml_node.attributes.get_key("cx")).unwrap_or(0.0);
5613                let cy = parse_svg_float(xml_node.attributes.get_key("cy")).unwrap_or(0.0);
5614                let rx = parse_svg_float(xml_node.attributes.get_key("rx")).unwrap_or(0.0);
5615                let ry = parse_svg_float(xml_node.attributes.get_key("ry")).unwrap_or(0.0);
5616                if rx > 0.0 && ry > 0.0 {
5617                    // Approximate ellipse with 4 cubic beziers (using rx for x-kappa, ry for y-kappa)
5618                    use azul_css::props::basic::{SvgCubicCurve, SvgPoint};
5619                    const KAPPA: f32 = 0.552_284_8;
5620                    let kx = rx * KAPPA;
5621                    let ky = ry * KAPPA;
5622                    let elements = vec![
5623                        crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
5624                            start: SvgPoint { x: cx, y: cy - ry },
5625                            ctrl_1: SvgPoint {
5626                                x: cx + kx,
5627                                y: cy - ry,
5628                            },
5629                            ctrl_2: SvgPoint {
5630                                x: cx + rx,
5631                                y: cy - ky,
5632                            },
5633                            end: SvgPoint { x: cx + rx, y: cy },
5634                        }),
5635                        crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
5636                            start: SvgPoint { x: cx + rx, y: cy },
5637                            ctrl_1: SvgPoint {
5638                                x: cx + rx,
5639                                y: cy + ky,
5640                            },
5641                            ctrl_2: SvgPoint {
5642                                x: cx + kx,
5643                                y: cy + ry,
5644                            },
5645                            end: SvgPoint { x: cx, y: cy + ry },
5646                        }),
5647                        crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
5648                            start: SvgPoint { x: cx, y: cy + ry },
5649                            ctrl_1: SvgPoint {
5650                                x: cx - kx,
5651                                y: cy + ry,
5652                            },
5653                            ctrl_2: SvgPoint {
5654                                x: cx - rx,
5655                                y: cy + ky,
5656                            },
5657                            end: SvgPoint { x: cx - rx, y: cy },
5658                        }),
5659                        crate::svg::SvgPathElement::CubicCurve(SvgCubicCurve {
5660                            start: SvgPoint { x: cx - rx, y: cy },
5661                            ctrl_1: SvgPoint {
5662                                x: cx - rx,
5663                                y: cy - ky,
5664                            },
5665                            ctrl_2: SvgPoint {
5666                                x: cx - kx,
5667                                y: cy - ry,
5668                            },
5669                            end: SvgPoint { x: cx, y: cy - ry },
5670                        }),
5671                    ];
5672                    Some(crate::svg::SvgMultiPolygon {
5673                        rings: crate::svg::SvgPathVec::from_vec(vec![crate::svg::SvgPath {
5674                            items: crate::svg::SvgPathElementVec::from_vec(elements),
5675                        }]),
5676                    })
5677                } else {
5678                    None
5679                }
5680            }
5681            "line" => {
5682                let x1 = parse_svg_float(xml_node.attributes.get_key("x1")).unwrap_or(0.0);
5683                let y1 = parse_svg_float(xml_node.attributes.get_key("y1")).unwrap_or(0.0);
5684                let x2 = parse_svg_float(xml_node.attributes.get_key("x2")).unwrap_or(0.0);
5685                let y2 = parse_svg_float(xml_node.attributes.get_key("y2")).unwrap_or(0.0);
5686                Some(crate::svg::SvgMultiPolygon {
5687                    rings: crate::svg::SvgPathVec::from_vec(vec![crate::svg::SvgPath {
5688                        items: crate::svg::SvgPathElementVec::from_vec(vec![
5689                            crate::svg::SvgPathElement::Line(crate::svg::SvgLine::new(
5690                                azul_css::props::basic::SvgPoint { x: x1, y: y1 },
5691                                azul_css::props::basic::SvgPoint { x: x2, y: y2 },
5692                            )),
5693                        ]),
5694                    }]),
5695                })
5696            }
5697            "polygon" | "polyline" => xml_node
5698                .attributes
5699                .get_key("points")
5700                .and_then(|pts| parse_svg_points(pts.as_str(), tag == "polygon")),
5701            _ => None,
5702        };
5703
5704        if let Some(mp) = clip {
5705            node.set_svg_data(crate::dom::SvgNodeData::Path(mp));
5706        }
5707    }
5708}
5709
5710/// Parse the HTML `colspan` / `rowspan` presentational attributes into
5711/// `AttributeType`s on the node. The table layout reads them back via
5712/// `get_cell_spans`. Without this the XML→DOM conversion dropped them and every
5713/// cell defaulted to span 1, so `<th colspan="2">` only covered one column.
5714/// Parsed unconditionally — non-cell elements simply don't carry these attributes.
5715fn apply_cell_span_attributes(node: &mut crate::dom::NodeData, xml_node: &XmlNode) {
5716    let mut spans = Vec::new();
5717    if let Some(n) = xml_node
5718        .attributes
5719        .get_key("colspan")
5720        .and_then(|v| v.as_str().trim().parse::<i32>().ok())
5721    {
5722        spans.push(crate::dom::AttributeType::ColSpan(n));
5723    }
5724    if let Some(n) = xml_node
5725        .attributes
5726        .get_key("rowspan")
5727        .and_then(|v| v.as_str().trim().parse::<i32>().ok())
5728    {
5729        spans.push(crate::dom::AttributeType::RowSpan(n));
5730    }
5731    if !spans.is_empty() {
5732        let mut v = node.attributes().clone().into_library_owned_vec();
5733        v.extend(spans);
5734        node.set_attributes(v.into());
5735    }
5736}
5737
5738#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5739// component_map is threaded through the whole fast-DOM pipeline for parity with the
5740// component-expanding interpreter path (see ~xml.rs:2845); this fast path never expands
5741// components, so it only forwards the map into recursive calls. Removing it here would
5742// cascade unused-param removals up the entire pipeline.
5743#[allow(clippy::only_used_in_recursion)]
5744fn xml_node_to_dom_fast<'a>(
5745    xml_node: &'a XmlNode,
5746    component_map: &'a ComponentMap,
5747    inside_svg: bool,
5748    depth: usize,
5749) -> Result<Dom, RenderDomError> {
5750    use crate::dom::Dom;
5751
5752    let component_name = normalize_casing(&xml_node.node_type);
5753
5754    // Look up the component definition
5755    let node_type = tag_to_node_type(&component_name);
5756    let mut dom = Dom::create_node(node_type);
5757
5758    apply_xml_node_attributes(&mut dom.root, xml_node, &component_name, inside_svg);
5759
5760    let child_inside_svg = inside_svg || component_name == "svg";
5761
5762    // AUDIT 2026-07-08: bound recursion depth to avoid a native stack overflow on
5763    // pathologically deep markup. At the cap, this node is emitted without its
5764    // children (truncation) rather than crashing the process.
5765    // AUDIT-TODO: a worklist-based iterative builder would preserve deep subtrees.
5766    if depth >= MAX_XML_NESTING_DEPTH {
5767        return Ok(dom);
5768    }
5769
5770    // Recursively convert children
5771    let mut children = Vec::new();
5772    for child in xml_node.children.as_ref() {
5773        match child {
5774            XmlNodeChild::Element(child_node) => {
5775                let child_dom =
5776                    xml_node_to_dom_fast(child_node, component_map, child_inside_svg, depth + 1)?;
5777                children.push(child_dom);
5778            }
5779            XmlNodeChild::Text(text) => {
5780                let text_dom = Dom::create_text(AzString::from(text.as_str()));
5781                children.push(text_dom);
5782            }
5783        }
5784    }
5785
5786    if !children.is_empty() {
5787        dom = dom.with_children(children.into());
5788    }
5789
5790    Ok(dom)
5791}
5792
5793/// Builder for arena-based DOM construction (`FastDom`).
5794/// Builds two parallel Vecs (hierarchy + `node_data`) in a single DFS pass.
5795#[derive(Debug)]
5796pub struct CompactDomBuilder {
5797    hierarchy: Vec<crate::styled_dom::NodeHierarchyItem>,
5798    node_data: Vec<crate::dom::NodeData>,
5799    css: Vec<crate::dom::CssWithNodeId>,
5800    /// Stack of (`node_index`, `previous_child_index`) for open elements
5801    stack: Vec<(usize, Option<usize>)>,
5802}
5803
5804impl Default for CompactDomBuilder {
5805    fn default() -> Self {
5806        Self::new()
5807    }
5808}
5809
5810impl CompactDomBuilder {
5811    #[must_use] pub const fn new() -> Self {
5812        Self {
5813            hierarchy: Vec::new(),
5814            node_data: Vec::new(),
5815            css: Vec::new(),
5816            stack: Vec::new(),
5817        }
5818    }
5819
5820    #[must_use] pub fn with_capacity(cap: usize) -> Self {
5821        Self {
5822            hierarchy: Vec::with_capacity(cap),
5823            node_data: Vec::with_capacity(cap),
5824            css: Vec::new(),
5825            stack: Vec::new(),
5826        }
5827    }
5828
5829    /// Open a new element node. Must be paired with `close_node()`.
5830    pub fn open_node(&mut self, node_data: crate::dom::NodeData) {
5831        use crate::id::NodeId;
5832        use crate::styled_dom::NodeHierarchyItem;
5833
5834        let idx = self.hierarchy.len();
5835
5836        // Determine parent from stack
5837        let parent_raw = if let Some(&(parent_idx, _)) = self.stack.last() {
5838            NodeId::into_raw(&Some(NodeId::new(parent_idx)))
5839        } else {
5840            0 // No parent (root)
5841        };
5842
5843        // Determine previous sibling from parent's last child tracking
5844        let prev_sibling_raw = if let Some(&(_, prev_child)) = self.stack.last() {
5845            prev_child
5846                .map_or(0, |pi| NodeId::into_raw(&Some(NodeId::new(pi))))
5847        } else {
5848            0
5849        };
5850
5851        // If there's a previous sibling, set its next_sibling to us
5852        if let Some(&(_, Some(prev_idx))) = self.stack.last() {
5853            self.hierarchy[prev_idx].next_sibling = NodeId::into_raw(&Some(NodeId::new(idx)));
5854        }
5855
5856        // Update parent's "last seen child" to us
5857        if let Some(parent) = self.stack.last_mut() {
5858            parent.1 = Some(idx);
5859        }
5860
5861        // Push the hierarchy item (last_child will be set in close_node)
5862        self.hierarchy.push(NodeHierarchyItem {
5863            parent: parent_raw,
5864            previous_sibling: prev_sibling_raw,
5865            next_sibling: 0, // Will be set by next sibling's open_node
5866            last_child: 0,   // Will be set in close_node
5867        });
5868        self.node_data.push(node_data);
5869
5870        // Push onto stack: this node is now the "open" element, no children yet
5871        self.stack.push((idx, None));
5872    }
5873
5874    /// Close the current element. Sets the `last_child` pointer.
5875    pub fn close_node(&mut self) {
5876        use crate::id::NodeId;
5877
5878        if let Some((idx, last_child_idx)) = self.stack.pop() {
5879            // Set last_child on this node's hierarchy item
5880            self.hierarchy[idx].last_child = last_child_idx
5881                .map_or(0, |lc| NodeId::into_raw(&Some(NodeId::new(lc))));
5882        }
5883    }
5884
5885    /// Add a leaf node (text, br, hr, etc.) that has no children.
5886    pub fn add_leaf(&mut self, node_data: crate::dom::NodeData) {
5887        self.open_node(node_data);
5888        self.close_node();
5889    }
5890
5891    /// Add a CSS stylesheet scoped to a node ID.
5892    pub fn add_css(&mut self, node_id: usize, css: Css) {
5893        self.css.push(crate::dom::CssWithNodeId { node_id, css });
5894    }
5895
5896    /// Finish building and produce a `FastDom`.
5897    #[must_use] pub fn finish(self) -> crate::dom::FastDom {
5898        crate::dom::FastDom {
5899            node_hierarchy: self.hierarchy.into(),
5900            node_data: self.node_data.into(),
5901            css: self.css.into(),
5902        }
5903    }
5904}
5905
5906/// Convert an XML node tree into a `FastDom` (arena-based) in a single DFS pass.
5907/// This is the fast path equivalent of `xml_node_to_dom_fast`.
5908#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5909// See xml_node_to_dom_fast: component_map is forwarded for pipeline parity, not read here.
5910#[allow(clippy::only_used_in_recursion)]
5911fn xml_node_to_fast_dom<'a>(
5912    xml_node: &'a XmlNode,
5913    component_map: &'a ComponentMap,
5914    inside_svg: bool,
5915    builder: &mut CompactDomBuilder,
5916    depth: usize,
5917) -> Result<(), RenderDomError> {
5918    use crate::dom::NodeData;
5919
5920    let component_name = normalize_casing(&xml_node.node_type);
5921    let node_type = tag_to_node_type(&component_name);
5922    let mut node_data = NodeData::create_node(node_type);
5923
5924    apply_xml_node_attributes(&mut node_data, xml_node, &component_name, inside_svg);
5925
5926    let child_inside_svg = inside_svg || component_name == "svg";
5927
5928    // Open this node in the builder
5929    builder.open_node(node_data);
5930
5931    // AUDIT 2026-07-08: bound recursion depth to avoid a native stack overflow on
5932    // pathologically deep markup. At the cap, children are dropped (the node is
5933    // still opened+closed) rather than crashing the process.
5934    // AUDIT-TODO: a worklist-based iterative builder would preserve deep subtrees.
5935    if depth < MAX_XML_NESTING_DEPTH {
5936        // Recursively convert children
5937        for child in xml_node.children.as_ref() {
5938            match child {
5939                XmlNodeChild::Element(child_node) => {
5940                    xml_node_to_fast_dom(
5941                        child_node,
5942                        component_map,
5943                        child_inside_svg,
5944                        builder,
5945                        depth + 1,
5946                    )?;
5947                }
5948                XmlNodeChild::Text(text) => {
5949                    builder.add_leaf(NodeData::create_text(AzString::from(text.as_str())));
5950                }
5951            }
5952        }
5953    }
5954
5955    // Close this node
5956    builder.close_node();
5957
5958    Ok(())
5959}
5960
5961/// Render a DOM from an XML body node using the fast arena-based path.
5962/// Builds a `FastDom` directly (no tree intermediary), then creates `StyledDom`.
5963#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
5964fn render_dom_from_body_node_fast<'a>(
5965    body_node: &'a XmlNode,
5966    mut global_css: Option<Css>,
5967    component_map: &'a ComponentMap,
5968    max_width: Option<f32>,
5969) -> Result<StyledDom, RenderDomError> {
5970    use crate::dom::{NodeData, NodeType};
5971
5972    let mut builder = CompactDomBuilder::new();
5973
5974    // Build the HTML > Body wrapper + body content in one pass
5975    // Open <html>
5976    builder.open_node(NodeData::create_node(NodeType::Html));
5977    // Open <body> (the body_node content goes inside)
5978    xml_node_to_fast_dom(body_node, component_map, false, &mut builder, 0)?;
5979    // Close <html>
5980    builder.close_node();
5981
5982    // Collect CSS rules from each source.
5983    let mut combined_rules: Vec<CssRuleBlock> = Vec::new();
5984    if let Some(max_width) = max_width {
5985        let max_width_css =
5986            Css::from_string(format!("html {{ max-width: {max_width}px; }}").into());
5987        combined_rules.extend(max_width_css.rules.into_library_owned_vec());
5988    }
5989    if let Some(css) = global_css.take() {
5990        combined_rules.extend(css.rules.into_library_owned_vec());
5991    }
5992    let combined_css = Css::new(combined_rules);
5993
5994    // Add CSS to the FastDom
5995    let mut fast_dom = builder.finish();
5996    fast_dom.css = vec![crate::dom::CssWithNodeId {
5997        node_id: 0, // Global scope (root)
5998        css: combined_css,
5999    }]
6000    .into();
6001
6002    // Create StyledDom via the fast path (no tree→arena conversion)
6003    let styled = StyledDom::create_from_fast_dom(fast_dom);
6004    Ok(styled)
6005}
6006
6007// render_dom_from_body_node() removed — use render_dom_from_body_node_fast() or str_to_dom()
6008
6009fn set_stringified_attributes(
6010    dom_string: &mut String,
6011    xml_attributes: &XmlAttributeMap,
6012    filtered_xml_attributes: &ComponentArgumentVec,
6013    tabs: usize,
6014) {
6015    let t0 = String::from("    ").repeat(tabs);
6016    let t = String::from("    ").repeat(tabs + 1);
6017
6018    // push ids and classes as chained `.with_id("..")` / `.with_class("..")`
6019    // calls (public builder API; both take `Into<AzString>`, so bare &str works).
6020    let _ = &t;
6021    for id in xml_attributes
6022        .get_key("id")
6023        .map(|s| s.split_whitespace().collect::<Vec<_>>())
6024        .unwrap_or_default()
6025    {
6026        let _ = write!(
6027            dom_string,
6028            "\r\n{}.with_id(\"{}\")",
6029            t0,
6030            format_args_dynamic(id, filtered_xml_attributes)
6031        );
6032    }
6033
6034    for class in xml_attributes
6035        .get_key("class")
6036        .map(|s| s.split_whitespace().collect::<Vec<_>>())
6037        .unwrap_or_default()
6038    {
6039        let _ = write!(
6040            dom_string,
6041            "\r\n{}.with_class(\"{}\")",
6042            t0,
6043            format_args_dynamic(class, filtered_xml_attributes)
6044        );
6045    }
6046
6047    if let Some(focusable) = xml_attributes
6048        .get_key("focusable")
6049        .map(|f| format_args_dynamic(f, filtered_xml_attributes))
6050        .and_then(|f| parse_bool(&f))
6051    {
6052        if focusable { let _ = write!(dom_string, "\r\n{t}.with_tab_index(TabIndex::Auto)"); } else { let _ = write!(dom_string,
6053            "\r\n{t}.with_tab_index(TabIndex::NoKeyboardFocus)"
6054        ); }
6055    }
6056
6057    if let Some(tab_index) = xml_attributes
6058        .get_key("tabindex")
6059        .map(|val| format_args_dynamic(val, filtered_xml_attributes))
6060        .and_then(|val| val.parse::<isize>().ok())
6061    {
6062        match tab_index {
6063            0 => { let _ = write!(dom_string, "\r\n{t}.with_tab_index(TabIndex::Auto)"); },
6064            i if i > 0 => { let _ = write!(dom_string,
6065                "\r\n{}.with_tab_index(TabIndex::OverrideInParent({}))",
6066                t, usize::try_from(i).unwrap_or(0)
6067            ); },
6068            _ => { let _ = write!(dom_string,
6069                "\r\n{t}.with_tab_index(TabIndex::NoKeyboardFocus)"
6070            ); },
6071        }
6072    }
6073}
6074
6075/// Item of a split string - either a variable name (with optional format spec) or a string
6076#[derive(Debug, Clone, PartialEq, PartialOrd, Eq, Ord, Hash)]
6077pub enum DynamicItem {
6078    /// A variable reference, e.g. {counter} or {counter:?} or {price:.2}
6079    Var {
6080        name: String,
6081        /// Optional format specifier after the colon: "?" for debug, ".2" for precision, etc.
6082        format_spec: Option<String>,
6083    },
6084    Str(String),
6085}
6086
6087/// Splits a string into formatting arguments, supporting format specifiers like `{var:?}`
6088/// ```rust
6089/// # use azul_core::xml::DynamicItem::*;
6090/// # use azul_core::xml::split_dynamic_string;
6091/// let s = "hello {a}, {b}{{ {c} }}";
6092/// let split = split_dynamic_string(s);
6093/// let output = vec![
6094///     Str("hello ".to_string()),
6095///     Var { name: "a".to_string(), format_spec: None },
6096///     Str(", ".to_string()),
6097///     Var { name: "b".to_string(), format_spec: None },
6098///     Str("{ ".to_string()),
6099///     Var { name: "c".to_string(), format_spec: None },
6100///     Str(" }".to_string()),
6101/// ];
6102/// assert_eq!(output, split);
6103/// ```
6104#[must_use] pub fn split_dynamic_string(input: &str) -> Vec<DynamicItem> {
6105    use self::DynamicItem::{Str, Var};
6106
6107    let input: Vec<char> = input.chars().collect();
6108    let input_chars_len = input.len();
6109
6110    let mut items = Vec::new();
6111    let mut current_idx = 0;
6112    let mut last_idx = 0;
6113
6114    while current_idx < input_chars_len {
6115        let c = input[current_idx];
6116        match c {
6117            '{' if input.get(current_idx + 1).copied() != Some('{') => {
6118                // variable start, search until next closing brace or whitespace or end of string
6119                let mut start_offset = 1;
6120                let mut has_found_variable = false;
6121                while let Some(c) = input.get(current_idx + start_offset) {
6122                    if c.is_whitespace() {
6123                        break;
6124                    }
6125                    if *c == '}' && input.get(current_idx + start_offset + 1).copied() != Some('}')
6126                    {
6127                        start_offset += 1;
6128                        has_found_variable = true;
6129                        break;
6130                    }
6131                    start_offset += 1;
6132                }
6133
6134                // advance current_idx accordingly
6135                // on fail, set cursor to end
6136                // set last_idx accordingly
6137                if has_found_variable {
6138                    if last_idx != current_idx {
6139                        items.push(Str(input[last_idx..current_idx].iter().collect()));
6140                    }
6141
6142                    // subtract 1 from start for opening brace, one from end for closing brace
6143                    let var_content: String = input
6144                        [(current_idx + 1)..(current_idx + start_offset - 1)]
6145                        .iter()
6146                        .collect();
6147                    // Split on first ':' to separate variable name from format specifier
6148                    let (var_name, format_spec) = if let Some(colon_pos) = var_content.find(':') {
6149                        let name = var_content[..colon_pos].to_string();
6150                        let spec = var_content[(colon_pos + 1)..].to_string();
6151                        (name, Some(spec))
6152                    } else {
6153                        (var_content, None)
6154                    };
6155                    items.push(Var {
6156                        name: var_name,
6157                        format_spec,
6158                    });
6159                    current_idx += start_offset;
6160                    last_idx = current_idx;
6161                } else {
6162                    current_idx += start_offset;
6163                }
6164            }
6165            _ => {
6166                current_idx += 1;
6167            }
6168        }
6169    }
6170
6171    if current_idx != last_idx {
6172        items.push(Str(input[last_idx..].iter().collect()));
6173    }
6174
6175    for item in &mut items {
6176        // replace {{ with { in strings
6177        if let Str(s) = item {
6178            *s = s.replace("{{", "{").replace("}}", "}");
6179        }
6180    }
6181
6182    items
6183}
6184
6185/// Combines the split string back into its original form while replacing the variables with their
6186/// values
6187///
6188/// let variables = btreemap!{ "a" => "value1", "b" => "value2" };
6189/// [Str("hello "), Var("a"), Str(", "), Var("b"), Str("{ "), Var("c"), Str(" }}")]
6190/// => "hello value1, valuec{ {c} }"
6191fn combine_and_replace_dynamic_items(
6192    input: &[DynamicItem],
6193    variables: &ComponentArgumentVec,
6194) -> String {
6195    let mut s = String::new();
6196
6197    for item in input {
6198        match item {
6199            DynamicItem::Var { name, format_spec } => {
6200                let variable_name = normalize_casing(name.trim());
6201                if let Some(resolved_var) = variables
6202                    .iter()
6203                    .find(|s| s.name.as_str() == variable_name)
6204                    .map(|q| &q.arg_type) {
6205                    // Format specifiers are applied at compile time, not at runtime replacement
6206                    s.push_str(resolved_var);
6207                } else {
6208                    s.push('{');
6209                    s.push_str(name);
6210                    if let Some(spec) = format_spec {
6211                        s.push(':');
6212                        s.push_str(spec);
6213                    }
6214                    s.push('}');
6215                }
6216            }
6217            DynamicItem::Str(dynamic_str) => {
6218                s.push_str(dynamic_str);
6219            }
6220        }
6221    }
6222
6223    s
6224}
6225
6226/// Given a string and a key => value mapping, replaces parts of the string with the value, i.e.:
6227///
6228/// ```rust
6229/// # use azul_core::xml::{format_args_dynamic, ComponentArgument, ComponentArgumentVec};
6230/// # use azul_css::AzString;
6231/// let variables: ComponentArgumentVec = vec![
6232///     ComponentArgument { name: AzString::from("a"), arg_type: AzString::from("value1") },
6233///     ComponentArgument { name: AzString::from("b"), arg_type: AzString::from("value2") },
6234/// ].into();
6235///
6236/// let initial = "hello {a}, {b}{{ {c} }}";
6237/// let expected = "hello value1, value2{ {c} }".to_string();
6238/// assert_eq!(format_args_dynamic(initial, &variables), expected);
6239/// ```
6240///
6241/// Note: the number (0, 1, etc.) is the order of the argument, it is irrelevant for
6242/// runtime formatting, only important for keeping the component / function arguments
6243/// in order when compiling the arguments to Rust code
6244#[must_use] pub fn format_args_dynamic(input: &str, variables: &ComponentArgumentVec) -> String {
6245    let dynamic_str_items = split_dynamic_string(input);
6246    combine_and_replace_dynamic_items(&dynamic_str_items, variables)
6247}
6248
6249/// Decode a numeric character reference body (the part between `&` and `;`),
6250/// e.g. `"#65"` -> `'A'`, `"#x41"` -> `'A'`. Returns `None` if it is not a valid
6251/// numeric reference.
6252fn decode_numeric_entity(entity: &str) -> Option<char> {
6253    let num = entity.strip_prefix('#')?;
6254    let code = if let Some(hex) = num.strip_prefix(['x', 'X']) {
6255        u32::from_str_radix(hex, 16).ok()?
6256    } else {
6257        num.parse::<u32>().ok()?
6258    };
6259    char::from_u32(code)
6260}
6261
6262/// Decode the common HTML/XML entities in a single left-to-right pass.
6263///
6264/// Handles `&lt;` `&gt;` `&amp;` `&quot;` `&apos;` and numeric references
6265/// (`&#NN;` / `&#xHH;`). `&nbsp;` and any unrecognized `&...;` sequence are left
6266/// verbatim. The single pass guarantees `&amp;` never double-decodes a following
6267/// entity. See [`prepare_string`] for why `&nbsp;` is deliberately preserved.
6268fn decode_entities(input: &str) -> String {
6269    // Longest handled entity body is a hex numeric ref like `#x10FFFF` (8 bytes);
6270    // cap the `;` search window so a stray `&` far from a `;` stays cheap.
6271    const MAX_ENTITY_BODY: usize = 12;
6272
6273    let mut out = String::with_capacity(input.len());
6274    let bytes = input.as_bytes();
6275    let mut i = 0;
6276    while i < input.len() {
6277        if bytes[i] == b'&' {
6278            if let Some(semi_rel) = input[i + 1..].find(';') {
6279                if semi_rel <= MAX_ENTITY_BODY {
6280                    let body = &input[i + 1..i + 1 + semi_rel];
6281                    let end = i + 1 + semi_rel; // index of ';'
6282                    // Leave &nbsp; for the per-line pass in prepare_string.
6283                    if body.eq_ignore_ascii_case("nbsp") {
6284                        out.push_str(&input[i..=end]);
6285                        i = end + 1;
6286                        continue;
6287                    }
6288                    let decoded = match body {
6289                        "lt" => Some('<'),
6290                        "gt" => Some('>'),
6291                        "amp" => Some('&'),
6292                        "quot" => Some('"'),
6293                        "apos" => Some('\''),
6294                        _ => decode_numeric_entity(body),
6295                    };
6296                    if let Some(c) = decoded {
6297                        out.push(c);
6298                        i = end + 1;
6299                        continue;
6300                    }
6301                }
6302            }
6303            // Not a recognized entity: emit the '&' literally.
6304            out.push('&');
6305            i += 1;
6306        } else {
6307            // Copy one whole UTF-8 char (i is always on a char boundary here).
6308            let ch = input[i..].chars().next().unwrap_or('\u{FFFD}');
6309            out.push(ch);
6310            i += ch.len_utf8();
6311        }
6312    }
6313    out
6314}
6315
6316// NOTE: Two sequential returns count as a single return, while single returns get ignored.
6317#[must_use] pub fn prepare_string(input: &str) -> String {
6318    const SPACE: &str = " ";
6319    const RETURN: &str = "\n";
6320
6321    let input = input.trim();
6322
6323    if input.is_empty() {
6324        return String::new();
6325    }
6326
6327    // AUDIT 2026-07-08: previously only `&lt;`/`&gt;` were decoded. Decode the full
6328    // common named-entity set (`&lt;` `&gt;` `&amp;` `&quot;` `&apos;`) plus numeric
6329    // references (`&#NN;` decimal and `&#xHH;` hex) in a single left-to-right pass.
6330    // A single pass is used deliberately so `&amp;` cannot double-decode a following
6331    // entity (e.g. "&amp;lt;" -> literal "&lt;", not "<"). `&nbsp;` is intentionally
6332    // left untouched here so the per-line pass below (which runs AFTER trimming) can
6333    // still turn it into a space that survives leading/trailing trim.
6334    let input = decode_entities(input);
6335
6336    let input_len = input.len();
6337    let mut final_lines: Vec<String> = Vec::new();
6338    let mut last_line_was_empty = false;
6339
6340    for line in input.lines() {
6341        let line = line.trim();
6342        let line = line.replace("&nbsp;", " ");
6343        let current_line_is_empty = line.is_empty();
6344
6345        if !current_line_is_empty {
6346            if last_line_was_empty {
6347                final_lines.push(format!("{RETURN}{line}"));
6348            } else {
6349                final_lines.push(line.to_string());
6350            }
6351        }
6352
6353        last_line_was_empty = current_line_is_empty;
6354    }
6355
6356    let mut target = String::with_capacity(input_len);
6357    for (line_idx, line) in final_lines.iter().enumerate() {
6358        // A joining space goes before every line EXCEPT the first (idx 0) and a
6359        // paragraph break (RETURN-prefixed). The old code also skipped the LAST line,
6360        // which dropped the word boundary for a soft-wrapped final line
6361        // ("Hello\nworld" -> "Helloworld").
6362        if !(line.starts_with(RETURN) || line_idx == 0) {
6363            target.push_str(SPACE);
6364        }
6365        target.push_str(line);
6366    }
6367    target
6368}
6369
6370/// Parses a string ("true" or "false")
6371#[must_use] pub fn parse_bool(input: &str) -> Option<bool> {
6372    match input {
6373        "true" => Some(true),
6374        "false" => Some(false),
6375        _ => None,
6376    }
6377}
6378
6379#[derive(Debug, Clone)]
6380pub struct CssMatcher {
6381    path: Vec<CssPathSelector>,
6382    indices_in_parent: Vec<usize>,
6383    children_length: Vec<usize>,
6384}
6385
6386impl CssMatcher {
6387    fn get_hash(&self) -> u64 {
6388        use core::hash::Hash;
6389
6390        use core::hash::Hasher;
6391
6392        let mut hasher = crate::hash::DefaultHasher::new();
6393        for p in &self.path {
6394            p.hash(&mut hasher);
6395        }
6396        hasher.finish()
6397    }
6398}
6399
6400impl CssMatcher {
6401    fn matches(&self, path: &CssPath) -> bool {
6402        use azul_css::css::CssPathSelector::*;
6403
6404        use crate::style::{CssGroupIterator, CssGroupSplitReason};
6405
6406        if self.path.is_empty() {
6407            return false;
6408        }
6409        if path.selectors.as_ref().is_empty() {
6410            return false;
6411        }
6412
6413        // self_matcher is only ever going to contain "Children" selectors, never "DirectChildren"
6414        let mut path_groups = CssGroupIterator::new(path.selectors.as_ref()).collect::<Vec<_>>();
6415        path_groups.reverse();
6416
6417        if path_groups.is_empty() {
6418            return false;
6419        }
6420        let mut self_groups = CssGroupIterator::new(self.path.as_ref()).collect::<Vec<_>>();
6421        self_groups.reverse();
6422        if self_groups.is_empty() {
6423            return false;
6424        }
6425
6426        if self.indices_in_parent.len() != self_groups.len() {
6427            return false;
6428        }
6429        if self.children_length.len() != self_groups.len() {
6430            return false;
6431        }
6432
6433        // self_groups = [ // HTML
6434        //     "body",
6435        //     "div.__azul_native-ribbon-container"
6436        //     "div.__azul_native-ribbon-tabs"
6437        //     "p.home"
6438        // ]
6439        //
6440        // path_groups = [ // CSS
6441        //     ".__azul_native-ribbon-tabs"
6442        //     "div.after-tabs"
6443        // ]
6444
6445        // get the first path group and see if it matches anywhere in the self group
6446        let mut cur_selfgroup_scan = 0;
6447        let mut cur_pathgroup_scan = 0;
6448        let mut valid = false;
6449        let mut path_group = path_groups[cur_pathgroup_scan].clone();
6450
6451        while cur_selfgroup_scan < self_groups.len() {
6452            let mut advance = None;
6453
6454            // scan all remaining path groups
6455            for (id, cg) in self_groups[cur_selfgroup_scan..].iter().enumerate() {
6456                let gm = group_matches(
6457                    &path_group.0,
6458                    &self_groups[cur_selfgroup_scan + id].0,
6459                    self.indices_in_parent[cur_selfgroup_scan + id],
6460                    self.children_length[cur_selfgroup_scan + id],
6461                );
6462
6463                if gm {
6464                    // ok: ".__azul_native-ribbon-tabs" was found within self_groups
6465                    // advance the self_groups by n
6466                    advance = Some(id);
6467                    break;
6468                }
6469            }
6470
6471            match advance {
6472                Some(n) => {
6473                    // group was found in remaining items
6474                    // advance cur_pathgroup_scan by 1 and cur_selfgroup_scan by n
6475                    if cur_pathgroup_scan == path_groups.len() - 1 {
6476                        // last path group
6477                        return cur_selfgroup_scan + n == self_groups.len() - 1;
6478                    }
6479                    cur_pathgroup_scan += 1;
6480                    cur_selfgroup_scan += n;
6481                    path_group = path_groups[cur_pathgroup_scan].clone();
6482                }
6483                None => return false, // group was not found in remaining items
6484            }
6485        }
6486
6487        // only return true if all path_groups matched
6488        cur_pathgroup_scan == path_groups.len() - 1
6489    }
6490}
6491
6492// does p.home match div.after-tabs?
6493// a: div.after-tabs
6494fn group_matches(
6495    a: &[&CssPathSelector],
6496    b: &[&CssPathSelector],
6497    idx_in_parent: usize,
6498    parent_children: usize,
6499) -> bool {
6500    use azul_css::css::{CssNthChildSelector, CssPathPseudoSelector, CssPathSelector::{Global, PseudoSelector, Type, Class, Id}};
6501
6502    for selector in a {
6503        match selector {
6504            // always matches
6505            Global |
6506PseudoSelector(CssPathPseudoSelector::Hover | CssPathPseudoSelector::Active |
6507CssPathPseudoSelector::Focus) => {}
6508
6509            Type(tag) => {
6510                if !b.iter().any(|t| **t == Type(*tag)) {
6511                    return false;
6512                }
6513            }
6514            Class(class) => {
6515                if !b.iter().any(|t| **t == Class(class.clone())) {
6516                    return false;
6517                }
6518            }
6519            Id(id) => {
6520                if !b.iter().any(|t| **t == Id(id.clone())) {
6521                    return false;
6522                }
6523            }
6524            PseudoSelector(CssPathPseudoSelector::First) => {
6525                if idx_in_parent != 0 {
6526                    return false;
6527                }
6528            }
6529            PseudoSelector(CssPathPseudoSelector::Last) => {
6530                if idx_in_parent != parent_children.saturating_sub(1) {
6531                    return false;
6532                }
6533            }
6534            PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Number(i))) => {
6535                if idx_in_parent != *i as usize {
6536                    return false;
6537                }
6538            }
6539            PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Even)) => {
6540                if !idx_in_parent.is_multiple_of(2) {
6541                    return false;
6542                }
6543            }
6544            PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Odd)) => {
6545                if idx_in_parent.is_multiple_of(2) {
6546                    return false;
6547                }
6548            }
6549            PseudoSelector(CssPathPseudoSelector::NthChild(CssNthChildSelector::Pattern(p))) => {
6550                if !idx_in_parent.saturating_sub(p.offset as usize).is_multiple_of(p.pattern_repeat as usize)
6551                {
6552                    return false;
6553                }
6554            }
6555
6556            _ => return false, // can't happen
6557        }
6558    }
6559
6560    true
6561}
6562
6563struct CssBlock {
6564    ending: Option<CssPathPseudoSelector>,
6565    block: CssRuleBlock,
6566}
6567
6568#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
6569/// # Errors
6570///
6571/// Returns an error if the body node cannot be compiled to Rust code.
6572pub fn compile_body_node_to_rust_code<'a>(
6573    body_node: &'a XmlNode,
6574    component_map: &'a ComponentMap,
6575    extra_blocks: &mut VecContents,
6576    css_blocks: &mut BTreeMap<String, String>,
6577    css: &Css,
6578    mut matcher: CssMatcher,
6579) -> Result<String, CompileError> {
6580    use azul_css::css::CssDeclaration;
6581
6582    let t = "";
6583    let t2 = "    ";
6584    let mut dom_string = String::from("Dom::create_body()");
6585    let node_type = CssPathSelector::Type(NodeTypeTag::Body);
6586    matcher.path.push(node_type);
6587
6588    let ids = body_node
6589        .attributes
6590        .get_key("id")
6591        .map(|s| s.split_whitespace().collect::<Vec<_>>())
6592        .unwrap_or_default();
6593    matcher.path.extend(
6594        ids.into_iter()
6595            .map(|id| CssPathSelector::Id(id.to_string().into())),
6596    );
6597    let classes = body_node
6598        .attributes
6599        .get_key("class")
6600        .map(|s| s.split_whitespace().collect::<Vec<_>>())
6601        .unwrap_or_default();
6602    matcher.path.extend(
6603        classes
6604            .into_iter()
6605            .map(|class| CssPathSelector::Class(class.to_string().into())),
6606    );
6607
6608    let matcher_hash = matcher.get_hash();
6609    let css_blocks_for_this_node = get_css_blocks(css, &matcher);
6610    if !css_blocks_for_this_node.is_empty() {
6611        // Track property types for the helper-const machinery, then emit the
6612        // matched declarations as an inline CSS string. (The old path emitted a
6613        // `const CSS_MATCH_*: NodeDataInlineCssPropertyVec` + `.with_inline_css_props`,
6614        // but that API was removed in 32d44ed8a; `.with_css(<str>)` is the
6615        // current equivalent and parses pseudo blocks too.)
6616        for css_block in &css_blocks_for_this_node {
6617            for declaration in css_block.block.declarations.as_ref() {
6618                let prop = match declaration {
6619                    CssDeclaration::Static(s) => s,
6620                    CssDeclaration::Dynamic(d) => &d.default_value,
6621                };
6622                extra_blocks.insert_from_css_property(prop);
6623            }
6624        }
6625
6626        let inline_css = css_blocks_to_inline_string(&css_blocks_for_this_node);
6627        if !inline_css.is_empty() {
6628            let escaped = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
6629            let _ = write!(dom_string, "\r\n{t2}.with_css(\"{escaped}\")");
6630        }
6631        let _ = (&mut *css_blocks, matcher_hash); // retained for signature compat
6632    }
6633
6634    if !body_node.children.as_ref().is_empty() {
6635        use azul_css::codegen::format::GetHash;
6636        let children_hash = body_node.children.as_ref().get_hash();
6637        dom_string.push_str("\r\n.with_children(vec![\r\n");
6638
6639        for (child_idx, child) in body_node.children.as_ref().iter().enumerate() {
6640            match child {
6641                XmlNodeChild::Element(child_node) => {
6642                    let mut matcher = matcher.clone();
6643                    matcher.path.push(CssPathSelector::Children);
6644                    matcher.indices_in_parent.push(child_idx);
6645                    matcher.children_length.push(body_node.children.len());
6646
6647                    let _ = write!(dom_string,
6648                        "{}{},\r\n",
6649                        t,
6650                        compile_node_to_rust_code_inner(
6651                            child_node,
6652                            component_map,
6653                            1,
6654                            extra_blocks,
6655                            css_blocks,
6656                            css,
6657                            matcher,
6658                        )?
6659                    );
6660                }
6661                XmlNodeChild::Text(text) => {
6662                    let text = text.trim();
6663                    if !text.is_empty() {
6664                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
6665                        let _ = write!(dom_string,
6666                            "{t}Dom::create_text(\"{escaped}\"),\r\n"
6667                        );
6668                    }
6669                }
6670            }
6671        }
6672        let _ = write!(dom_string, "\r\n{t}])");
6673    }
6674
6675    let dom_string = dom_string.trim();
6676    Ok(dom_string.to_string())
6677}
6678
6679/// Serialize the CSS blocks matched for a node into one inline CSS string for
6680/// `Dom::with_css(...)`. `with_css` parses via `Css::parse_inline`, which runs
6681/// the full selector+nesting machinery, so `:hover`/`:active`/`:focus` are
6682/// emitted as nested pseudo blocks and round-trip faithfully; plain rules are
6683/// emitted flat as `key: value;` (via `CssProperty::key()` / `value()`).
6684fn css_blocks_to_inline_string(blocks: &[CssBlock]) -> String {
6685    fn decls_of(block: &CssBlock) -> Vec<String> {
6686        block
6687            .block
6688            .declarations
6689            .as_ref()
6690            .iter()
6691            .map(|d| {
6692                let prop = match d {
6693                    CssDeclaration::Static(s) => s,
6694                    CssDeclaration::Dynamic(dy) => &dy.default_value,
6695                };
6696                format!("{}: {};", prop.key(), prop.value())
6697            })
6698            .collect()
6699    }
6700
6701    let mut normal: Vec<String> = Vec::new();
6702    let mut pseudo: Vec<String> = Vec::new();
6703    for block in blocks {
6704        let pseudo_sel = match block.ending {
6705            Some(CssPathPseudoSelector::Hover) => Some(":hover"),
6706            Some(CssPathPseudoSelector::Active) => Some(":active"),
6707            Some(CssPathPseudoSelector::Focus) => Some(":focus"),
6708            _ => None,
6709        };
6710        match pseudo_sel {
6711            None => normal.extend(decls_of(block)),
6712            Some(sel) => pseudo.push(format!("{} {{ {} }}", sel, decls_of(block).join(" "))),
6713        }
6714    }
6715
6716    let mut parts = normal;
6717    parts.extend(pseudo);
6718    parts.join(" ")
6719}
6720
6721fn get_css_blocks(css: &Css, matcher: &CssMatcher) -> Vec<CssBlock> {
6722    let mut blocks = Vec::new();
6723
6724    for css_block in css.rules.as_ref() {
6725        if matcher.matches(&css_block.path) {
6726            let mut ending = None;
6727
6728            if let Some(CssPathSelector::PseudoSelector(p)) =
6729                css_block.path.selectors.as_ref().last()
6730            {
6731                ending = Some(p.clone());
6732            }
6733
6734            blocks.push(CssBlock {
6735                ending,
6736                block: css_block.clone(),
6737            });
6738        }
6739    }
6740
6741    blocks
6742}
6743
6744fn compile_and_format_dynamic_items(input: &[DynamicItem]) -> String {
6745    use self::DynamicItem::{Var, Str};
6746    if input.is_empty() {
6747        String::from("AzString::from_const_str(\"\")")
6748    } else if input.len() == 1 {
6749        // common: there is only one "dynamic item" - skip the "format!()" macro
6750        match &input[0] {
6751            Var { name, format_spec } => {
6752                let var_name = normalize_casing(name.trim());
6753                if let Some(spec) = format_spec {
6754                    format!("format!(\"{{:{spec}}}\", {var_name}).into()")
6755                } else {
6756                    var_name
6757                }
6758            }
6759            Str(s) => format!("AzString::from_const_str(\"{s}\")"),
6760        }
6761    } else {
6762        // build a "format!("{var}, blah", var)" string
6763        let mut formatted_str = String::from("format!(\"");
6764        let mut variables = Vec::new();
6765        for item in input {
6766            match item {
6767                Var { name, format_spec } => {
6768                    let variable_name = normalize_casing(name.trim());
6769                    if let Some(spec) = format_spec {
6770                        let _ = write!(formatted_str, "{{{variable_name}:{spec}}}");
6771                    } else {
6772                        let _ = write!(formatted_str, "{{{variable_name}}}");
6773                    }
6774                    variables.push(variable_name.clone());
6775                }
6776                Str(s) => {
6777                    let s = s.replace('"', "\\\"");
6778                    formatted_str.push_str(&s);
6779                }
6780            }
6781        }
6782
6783        formatted_str.push('\"');
6784        if !variables.is_empty() {
6785            formatted_str.push_str(", ");
6786        }
6787
6788        formatted_str.push_str(&variables.join(", "));
6789        formatted_str.push_str(").into()");
6790        formatted_str
6791    }
6792}
6793
6794fn format_args_for_rust_code(input: &str) -> String {
6795    let dynamic_str_items = split_dynamic_string(input);
6796    compile_and_format_dynamic_items(&dynamic_str_items)
6797}
6798
6799#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
6800// component_map is forwarded through the codegen recursion for parity with the
6801// component-expanding path; this Rust-codegen path only threads it into recursive calls.
6802#[allow(clippy::only_used_in_recursion)]
6803#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
6804fn compile_node_to_rust_code_inner(
6805    node: &XmlNode,
6806    component_map: &ComponentMap,
6807    tabs: usize,
6808    extra_blocks: &mut VecContents,
6809    css_blocks: &mut BTreeMap<String, String>,
6810    css: &Css,
6811    mut matcher: CssMatcher,
6812) -> Result<String, CompileError> {
6813    use azul_css::css::CssDeclaration;
6814
6815    let t = String::from("    ").repeat(tabs - 1);
6816    let t2 = String::from("    ").repeat(tabs);
6817
6818    let component_name = normalize_casing(&node.node_type);
6819
6820    // Look up the CSS NodeTypeTag
6821    let node_type_tag = tag_to_node_type_tag(&component_name);
6822    let node_type = CssPathSelector::Type(node_type_tag);
6823
6824    // Emit a plain `create_node(<Tag>)` for the base node. Do NOT route through
6825    // the component `compile_fn`: its Rust arm bakes inline text into a
6826    // `.with_children(..)`, which the child-walk below would then OVERWRITE with
6827    // a second `.with_children(..)` — silently dropping the text on any node
6828    // that has BOTH text and element children. The child-walk handles ALL
6829    // children (text + elements) in order, so the base node must stay childless.
6830    // Interactive/data tags (Button/Input/…) whose NodeType carries data fall
6831    // back to `div`, matching the C/C++/Python walkers (`safe_container_tag`).
6832    let ctor = analyze_node_ctor(&component_name, node);
6833    let mut dom_string = ctor.render_rust().map_or_else(|| {
6834        let tag = safe_container_tag(&format!("{:?}", tag_to_node_type(&component_name)));
6835        format!("{t2}Dom::create_node(NodeType::{tag})")
6836    }, |expr| format!("{t2}{expr}"));
6837
6838    matcher.path.push(node_type);
6839    let ids = node
6840        .attributes
6841        .get_key("id")
6842        .map(|s| s.split_whitespace().collect::<Vec<_>>())
6843        .unwrap_or_default();
6844
6845    matcher.path.extend(
6846        ids.into_iter()
6847            .map(|id| CssPathSelector::Id(id.to_string().into())),
6848    );
6849
6850    let classes = node
6851        .attributes
6852        .get_key("class")
6853        .map(|s| s.split_whitespace().collect::<Vec<_>>())
6854        .unwrap_or_default();
6855
6856    matcher.path.extend(
6857        classes
6858            .into_iter()
6859            .map(|class| CssPathSelector::Class(class.to_string().into())),
6860    );
6861
6862    let matcher_hash = matcher.get_hash();
6863    let css_blocks_for_this_node = get_css_blocks(css, &matcher);
6864    if !css_blocks_for_this_node.is_empty() {
6865        // Track property types for the helper-const machinery, then emit the
6866        // matched declarations as an inline CSS string. (The old path emitted a
6867        // `const CSS_MATCH_*: NodeDataInlineCssPropertyVec` + `.with_inline_css_props`,
6868        // but that API was removed in 32d44ed8a; `.with_css(<str>)` is the
6869        // current equivalent and parses pseudo blocks too.)
6870        for css_block in &css_blocks_for_this_node {
6871            for declaration in css_block.block.declarations.as_ref() {
6872                let prop = match declaration {
6873                    CssDeclaration::Static(s) => s,
6874                    CssDeclaration::Dynamic(d) => &d.default_value,
6875                };
6876                extra_blocks.insert_from_css_property(prop);
6877            }
6878        }
6879
6880        let inline_css = css_blocks_to_inline_string(&css_blocks_for_this_node);
6881        if !inline_css.is_empty() {
6882            let escaped = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
6883            let _ = write!(dom_string, "\r\n{t2}.with_css(\"{escaped}\")");
6884        }
6885        let _ = (&mut *css_blocks, matcher_hash); // retained for signature compat
6886    }
6887
6888    set_stringified_attributes(
6889        &mut dom_string,
6890        &node.attributes,
6891        &ComponentArgumentVec::new(),
6892        tabs,
6893    );
6894
6895    // Text folded into the ctor (Tier A/C) is skipped, as is a `<caption>`
6896    // already injected by `create_table`.
6897    let mut caption_skipped = false;
6898    let mut children_string = node
6899        .children
6900        .as_ref()
6901        .iter()
6902        .enumerate()
6903        .filter_map(|(child_idx, c)| match c {
6904            XmlNodeChild::Element(child_node) => {
6905                if ctor.skip_caption()
6906                    && !caption_skipped
6907                    && child_node.node_type.as_str().eq_ignore_ascii_case("caption")
6908                {
6909                    caption_skipped = true;
6910                    return None;
6911                }
6912                let mut matcher = matcher.clone();
6913                matcher.path.push(CssPathSelector::Children);
6914                matcher.indices_in_parent.push(child_idx);
6915                matcher.children_length.push(node.children.len());
6916
6917                Some(compile_node_to_rust_code_inner(
6918                    child_node,
6919                    component_map,
6920                    tabs + 1,
6921                    extra_blocks,
6922                    css_blocks,
6923                    css,
6924                    matcher,
6925                ))
6926            }
6927            XmlNodeChild::Text(text) => {
6928                if ctor.consumes_text() {
6929                    return None;
6930                }
6931                let text = text.trim();
6932                if text.is_empty() {
6933                    None
6934                } else {
6935                    let t2 = String::from("    ").repeat(tabs);
6936                    let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
6937                    Some(Ok(format!(
6938                        "{t2}Dom::create_text(\"{escaped}\")"
6939                    )))
6940                }
6941            }
6942        })
6943        .collect::<Result<Vec<_>, _>>()?
6944        .join(",\r\n");
6945
6946    if !children_string.is_empty() {
6947        let _ = write!(dom_string,
6948            "\r\n{t2}.with_children(vec![\r\n{children_string}\r\n{t2}])"
6949        );
6950    }
6951
6952    Ok(dom_string)
6953}
6954
6955// ───────────────────────────────────────────────────────────────────────────
6956// Generic FLUENT DOM-builder emitter (C++ / Python).
6957//
6958// Rust has its own dedicated walker above (`compile_*_to_rust_code`). C++ and
6959// Python share this generic walker because their builder APIs are also fluent
6960// (`Dom::create_*().with_css(..).with_child(..)`); only the surface tokens
6961// differ, captured in `FluentSyntax`. Plain C is imperative and has its own
6962// walker (`compile_*_to_c_code`).
6963// ───────────────────────────────────────────────────────────────────────────
6964
6965/// Tags with a zero-arg per-tag creator (`create_<tag>()` / `AzDom_create<Tag>()`
6966/// / `create_node(NodeType::<Tag>)`). Interactive / data elements (Button, Input,
6967/// Img, Select, Textarea, Label, A, Table, …) take constructor arguments, so an
6968/// exported page maps them to a plain `div` container (structure preserved; the
6969/// user re-wires behavior). Keep these CamelCase to match `NodeTypeTag` debug names.
6970const SAFE_CONTAINER_TAGS: &[&str] = &[
6971    // These must match the real `NodeType` Debug names exactly (the lookup below is a
6972    // string compare against `{:?}`). Six used to be mis-cased — "Blockquote",
6973    // "Colgroup", "Figcaption", "Tbody", "Tfoot", "Thead" — so those tags silently
6974    // degraded to "Div".
6975    "Abbr", "Acronym", "Address", "Article", "Aside", "B", "Bdi", "Bdo", "Big",
6976    "BlockQuote", "Body", "Br", "Caption", "Cite", "Code", "ColGroup", "Dd",
6977    "Del", "Dfn", "Dir", "Div", "Dl", "Dt", "Em", "Embed", "FigCaption",
6978    "Figure", "Footer", "H1", "H2", "H3", "H4", "H5", "H6", "Head", "Header",
6979    "Hr", "Html", "I", "Ins", "Kbd", "Li", "Link", "Main", "Map", "Mark",
6980    "Meta", "Nav", "Object", "Ol", "P", "Pre", "Q", "Rp", "Rt", "Rtc", "Ruby",
6981    "S", "Samp", "Script", "Section", "Small", "Span", "Strong", "Style", "Sub",
6982    "Sup", "Svg", "TBody", "Td", "TFoot", "Th", "THead", "Title", "Tr", "U",
6983    "Ul", "Var", "Wbr",
6984];
6985
6986/// The CamelCase tag to actually emit a creator for: the tag itself if it has a
6987/// zero-arg creator, else `"Div"`.
6988fn safe_container_tag(tag_dbg: &str) -> &'static str {
6989    SAFE_CONTAINER_TAGS.iter().copied().find(|t| *t == tag_dbg).unwrap_or("Div")
6990}
6991
6992// ───────────────────────────────────────────────────────────────────────────
6993// Semantic / accessibility-aware constructor selection.
6994//
6995// Instead of mapping every element to a plain `div`, an exported live page
6996// picks the *most specific* Azul constructor so the generated app keeps the
6997// page's semantics + accessibility tree:
6998//
6999//   • Tier A  `create_<tag>_with_text(text)` — a tag with a single text child
7000//             and no element children (P, Span, H1-H6, Li, Td, Code, …).
7001//   • Tier B  aria-only / void widgets (Details, Summary, Form, Canvas, Area,
7002//             …) — `create_<tag>(SmallAriaInfo::label(..))` when `aria-label`
7003//             is present, else `create_<tag>_no_a11y()`.
7004//   • Tier C  multi-arg widgets (Button, A, Label, Input, Select, Option,
7005//             Optgroup, Textarea, Table) — args pulled from HTML attributes.
7006//   • Tier D  scalar-driven widgets (Progress, Meter, Dialog) — the `*_no_a11y`
7007//             form with extracted numeric args (the full aria structs are
7008//             complex; the NoA11y form is simplest + correct).
7009//
7010// Every symbol emitted here is verified to exist in `target/codegen/azul.h` (C)
7011// and `azul20.hpp` (C++); anything else falls back to `safe_container_tag`
7012// (`div`). The four walkers share `analyze_node_ctor` and each renders the
7013// result with its own surface tokens.
7014// ───────────────────────────────────────────────────────────────────────────
7015
7016/// A single positional argument of a semantic constructor. String payloads are
7017/// RAW — escaping happens at render time (matching the walkers).
7018#[derive(Debug, Clone)]
7019enum CtorArg {
7020    /// Plain string literal (`AzString` / `String` / `"…"`).
7021    Str(String),
7022    /// `SmallAriaInfo` built from an accessible label.
7023    Aria(String),
7024    /// `f32` numeric literal.
7025    Float(f32),
7026    /// `OptionString::Some(text)`.
7027    OptSome(String),
7028    /// `OptionString::None`.
7029    OptNone,
7030}
7031
7032/// The constructor chosen for an element node.
7033enum NodeCtor {
7034    /// Plain container — keep each walker's existing `create_<tag>()` path.
7035    Plain,
7036    /// A specific semantic constructor.
7037    Semantic {
7038        /// Canonical CamelCase suffix after `create` / `AzDom_create`
7039        /// (e.g. `Button`, `ButtonNoA11y`, `PWithText`, `A`, `ANoA11y`).
7040        suffix: String,
7041        args: Vec<CtorArg>,
7042        /// The node's direct text is folded into the ctor — skip text children
7043        /// in the walk so it isn't emitted twice.
7044        consumes_text: bool,
7045        /// The table aria form injects its own `<caption>` child — drop the
7046        /// first literal `<caption>` element so it isn't duplicated.
7047        skip_caption: bool,
7048    },
7049}
7050
7051/// Uppercase the first character (`button` → `Button`, `h1` → `H1`). HTML tags
7052/// are single lowercase tokens, so this yields the exact `AzDom_create<Suffix>`
7053/// spelling.
7054fn cap_first(tag: &str) -> String {
7055    let mut c = tag.chars();
7056    c.next().map_or_else(String::new, |f| f.to_uppercase().collect::<String>() + c.as_str())
7057}
7058
7059/// CamelCase → `snake_case` for the C++/Python/Rust method names
7060/// (`ButtonNoA11y` → `button_no_a11y`, `PWithText` → `p_with_text`,
7061/// `ANoA11y` → `a_no_a11y`, `H1WithText` → `h1_with_text`).
7062fn camel_to_snake(s: &str) -> String {
7063    let chars: Vec<char> = s.chars().collect();
7064    let mut out = String::new();
7065    for (i, &ch) in chars.iter().enumerate() {
7066        if ch.is_ascii_uppercase() && i > 0 {
7067            let prev = chars[i - 1];
7068            let next_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
7069            if prev.is_ascii_lowercase()
7070                || prev.is_ascii_digit()
7071                || (prev.is_ascii_uppercase() && next_lower)
7072            {
7073                out.push('_');
7074            }
7075        }
7076        out.extend(ch.to_lowercase());
7077    }
7078    out
7079}
7080
7081/// Escape `\` and `"` for a double-quoted string literal.
7082fn esc_lit(s: &str) -> String {
7083    s.replace('\\', "\\\\").replace('"', "\\\"")
7084}
7085
7086/// Format an `f32` as a valid float literal with a decimal point (`1` → `1.0`).
7087fn fmt_f32_lit(f: f32) -> String {
7088    let s = format!("{f}");
7089    if s.contains('.') || s.contains('e') || s.contains("inf") || s.contains("NaN") {
7090        s
7091    } else {
7092        format!("{s}.0")
7093    }
7094}
7095
7096/// Joined, trimmed text of a node's *direct* text children (`"  Go  "` → `"Go"`).
7097fn node_direct_text(node: &XmlNode) -> String {
7098    node.children
7099        .as_ref()
7100        .iter()
7101        .filter_map(|c| match c {
7102            XmlNodeChild::Text(t) => {
7103                let t = t.trim();
7104                if t.is_empty() { None } else { Some(t.to_string()) }
7105            }
7106            XmlNodeChild::Element(_) => None,
7107        })
7108        .collect::<Vec<_>>()
7109        .join(" ")
7110}
7111
7112/// Non-empty `aria-label` attribute value, if present.
7113fn node_aria_label(node: &XmlNode) -> Option<String> {
7114    node.attributes.get_key("aria-label").and_then(|v| {
7115        let v = v.as_str().trim();
7116        if v.is_empty() { None } else { Some(v.to_string()) }
7117    })
7118}
7119
7120/// Attribute value, or `default` when absent.
7121fn node_attr_or(node: &XmlNode, key: &str, default: &str) -> String {
7122    node.attributes
7123        .get_key(key).map_or_else(|| default.to_string(), |v| v.as_str().to_string())
7124}
7125
7126/// Attribute parsed as `f32`, or `default` when absent / unparsable.
7127fn node_attr_f32(node: &XmlNode, key: &str, default: f32) -> f32 {
7128    node.attributes
7129        .get_key(key)
7130        .and_then(|v| v.as_str().trim().parse::<f32>().ok())
7131        .unwrap_or(default)
7132}
7133
7134/// Text of the node's first `<caption>` element child, if any (non-empty).
7135fn first_caption_text(node: &XmlNode) -> Option<String> {
7136    node.children.as_ref().iter().find_map(|c| match c {
7137        XmlNodeChild::Element(e) if e.node_type.as_str().eq_ignore_ascii_case("caption") => {
7138            let t = e.get_text_content();
7139            let t = t.trim();
7140            if t.is_empty() { None } else { Some(t.to_string()) }
7141        }
7142        _ => None,
7143    })
7144}
7145
7146/// Tags with a single-arg `create_<tag>_with_text(text)` constructor (Tier A).
7147const WITH_TEXT_TAGS: &[&str] = &[
7148    "acronym", "b", "bdi", "bdo", "big", "blockquote", "cite", "code", "del",
7149    "dfn", "em", "h1", "h2", "h3", "h4", "h5", "h6", "i", "ins", "kbd", "li",
7150    "mark", "p", "pre", "rp", "rt", "s", "samp", "small", "span", "strong",
7151    "style", "sub", "sup", "td", "th", "title", "u", "var",
7152];
7153
7154/// Pick the semantic constructor for `tag` (lowercase HTML tag) + `node`.
7155#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose parser/builder/dispatch (one branch per input variant)
7156fn analyze_node_ctor(tag: &str, node: &XmlNode) -> NodeCtor {
7157    // Helper for the common "no caption skip" case.
7158    fn sem(suffix: impl Into<String>, args: Vec<CtorArg>, consumes_text: bool) -> NodeCtor {
7159        NodeCtor::Semantic {
7160            suffix: suffix.into(),
7161            args,
7162            consumes_text,
7163            skip_caption: false,
7164        }
7165    }
7166
7167    let aria = node_aria_label(node);
7168    let has_aria = aria.is_some();
7169    let label = aria.unwrap_or_default();
7170    // `has_only_text_children()` is also true for childless nodes; pair it with
7171    // `has_text` so empty elements stay plain containers.
7172    let pure_text = node.has_only_text_children();
7173    let text = node_direct_text(node);
7174    let has_text = !text.is_empty();
7175    let cap = cap_first(tag);
7176
7177    // Tier A — *_with_text (single text child, no element children).
7178    if WITH_TEXT_TAGS.contains(&tag) {
7179        if pure_text && has_text {
7180            return sem(format!("{cap}WithText"), vec![CtorArg::Str(text)], true);
7181        }
7182        return NodeCtor::Plain;
7183    }
7184
7185    match tag {
7186        // Tier B — aria-only / void widgets.
7187        "details" | "form" | "fieldset" | "legend" | "menu" | "output"
7188        | "datalist" | "canvas" | "audio" | "video" | "area" => {
7189            if has_aria {
7190                sem(cap, vec![CtorArg::Aria(label)], false)
7191            } else {
7192                sem(format!("{cap}NoA11y"), vec![], false)
7193            }
7194        }
7195        // Summary is Tier B but also has a WithText form for a single text child.
7196        "summary" => {
7197            if pure_text && has_text {
7198                if has_aria {
7199                    sem("SummaryWithText", vec![CtorArg::Str(text), CtorArg::Aria(label)], true)
7200                } else {
7201                    sem("SummaryWithTextNoA11y", vec![CtorArg::Str(text)], true)
7202                }
7203            } else if has_aria {
7204                sem("Summary", vec![CtorArg::Aria(label)], false)
7205            } else {
7206                sem("SummaryNoA11y", vec![], false)
7207            }
7208        }
7209
7210        // Tier C — multi-arg widgets (args from HTML attributes).
7211        "button" => {
7212            if has_aria {
7213                sem("Button", vec![CtorArg::Str(text), CtorArg::Aria(label)], true)
7214            } else {
7215                sem("ButtonNoA11y", vec![CtorArg::Str(text)], true)
7216            }
7217        }
7218        "a" => {
7219            let href = node_attr_or(node, "href", "");
7220            if has_aria {
7221                sem("A", vec![CtorArg::Str(href), CtorArg::Str(text), CtorArg::Aria(label)], true)
7222            } else {
7223                let lbl = if has_text { CtorArg::OptSome(text) } else { CtorArg::OptNone };
7224                sem("ANoA11y", vec![CtorArg::Str(href), lbl], true)
7225            }
7226        }
7227        "label" => {
7228            let for_id = node_attr_or(node, "for", "");
7229            if has_aria {
7230                sem("Label", vec![CtorArg::Str(for_id), CtorArg::Str(text), CtorArg::Aria(label)], true)
7231            } else {
7232                sem("LabelNoA11y", vec![CtorArg::Str(for_id), CtorArg::Str(text)], true)
7233            }
7234        }
7235        "input" => {
7236            let ty = node_attr_or(node, "type", "text");
7237            let name = node_attr_or(node, "name", "");
7238            if has_aria {
7239                sem("Input", vec![CtorArg::Str(ty), CtorArg::Str(name), CtorArg::Str(label.clone()), CtorArg::Aria(label)], false)
7240            } else {
7241                sem("InputNoA11y", vec![CtorArg::Str(ty), CtorArg::Str(name), CtorArg::Str(label)], false)
7242            }
7243        }
7244        "textarea" => {
7245            let name = node_attr_or(node, "name", "");
7246            if has_aria {
7247                sem("Textarea", vec![CtorArg::Str(name), CtorArg::Str(label.clone()), CtorArg::Aria(label)], false)
7248            } else {
7249                sem("TextareaNoA11y", vec![CtorArg::Str(name), CtorArg::Str(label)], false)
7250            }
7251        }
7252        "select" => {
7253            let name = node_attr_or(node, "name", "");
7254            if has_aria {
7255                sem("Select", vec![CtorArg::Str(name), CtorArg::Str(label.clone()), CtorArg::Aria(label)], false)
7256            } else {
7257                sem("SelectNoA11y", vec![CtorArg::Str(name), CtorArg::Str(label)], false)
7258            }
7259        }
7260        "option" => {
7261            let value = node_attr_or(node, "value", "");
7262            if has_aria {
7263                sem("Option", vec![CtorArg::Str(value), CtorArg::Str(text), CtorArg::Aria(label)], true)
7264            } else {
7265                sem("OptionNoA11y", vec![CtorArg::Str(value), CtorArg::Str(text)], true)
7266            }
7267        }
7268        "optgroup" => {
7269            let lbl = node_attr_or(node, "label", "");
7270            if has_aria {
7271                sem("Optgroup", vec![CtorArg::Str(lbl), CtorArg::Aria(label)], false)
7272            } else {
7273                sem("OptgroupNoA11y", vec![CtorArg::Str(lbl)], false)
7274            }
7275        }
7276        "table" => {
7277            if has_aria {
7278                // The aria form injects a caption child, so take the caption from
7279                // the literal <caption> (or the aria label) and drop the literal.
7280                let caption = first_caption_text(node).unwrap_or_else(|| label.clone());
7281                NodeCtor::Semantic {
7282                    suffix: "Table".to_string(),
7283                    args: vec![CtorArg::Str(caption), CtorArg::Aria(label)],
7284                    consumes_text: false,
7285                    skip_caption: true,
7286                }
7287            } else {
7288                sem("TableNoA11y", vec![], false)
7289            }
7290        }
7291
7292        // Tier D — scalar-driven widgets (NoA11y form with extracted numbers).
7293        "progress" => sem(
7294            "ProgressNoA11y",
7295            vec![
7296                CtorArg::Float(node_attr_f32(node, "value", 0.0)),
7297                CtorArg::Float(node_attr_f32(node, "max", 1.0)),
7298            ],
7299            false,
7300        ),
7301        "meter" => sem(
7302            "MeterNoA11y",
7303            vec![
7304                CtorArg::Float(node_attr_f32(node, "value", 0.0)),
7305                CtorArg::Float(node_attr_f32(node, "min", 0.0)),
7306                CtorArg::Float(node_attr_f32(node, "max", 1.0)),
7307            ],
7308            false,
7309        ),
7310        "dialog" => sem("DialogNoA11y", vec![], false),
7311
7312        _ => NodeCtor::Plain,
7313    }
7314}
7315
7316impl CtorArg {
7317    /// Rust expression for this argument (`AzString::from(..)` works for both the
7318    /// `Into<AzString>` and the concrete `AzString` parameter forms).
7319    fn render_rust(&self) -> String {
7320        match self {
7321            Self::Str(s) => format!("AzString::from(\"{}\")", esc_lit(s)),
7322            Self::Aria(s) => format!("SmallAriaInfo::label(AzString::from(\"{}\"))", esc_lit(s)),
7323            Self::Float(f) => fmt_f32_lit(*f),
7324            Self::OptSome(s) => format!("OptionString::Some(AzString::from(\"{}\"))", esc_lit(s)),
7325            Self::OptNone => "OptionString::None".to_string(),
7326        }
7327    }
7328    fn render_c(&self) -> String {
7329        match self {
7330            Self::Str(s) => format!("AZ_STR(\"{}\")", esc_lit(s)),
7331            Self::Aria(s) => format!("AzSmallAriaInfo_label(AZ_STR(\"{}\"))", esc_lit(s)),
7332            Self::Float(f) => format!("{}f", fmt_f32_lit(*f)),
7333            Self::OptSome(s) => format!("AzOptionString_some(AZ_STR(\"{}\"))", esc_lit(s)),
7334            Self::OptNone => "AzOptionString_none()".to_string(),
7335        }
7336    }
7337    fn render_cpp(&self) -> String {
7338        match self {
7339            Self::Str(s) => format!("String(\"{}\")", esc_lit(s)),
7340            Self::Aria(s) => format!("SmallAriaInfo::label(String(\"{}\"))", esc_lit(s)),
7341            Self::Float(f) => format!("{}f", fmt_f32_lit(*f)),
7342            Self::OptSome(s) => format!("OptionString::some(String(\"{}\"))", esc_lit(s)),
7343            Self::OptNone => "OptionString::none()".to_string(),
7344        }
7345    }
7346    fn render_python(&self) -> String {
7347        match self {
7348            Self::Str(s) => format!("\"{}\"", esc_lit(s)),
7349            Self::Aria(s) => format!("azul.SmallAriaInfo.label(\"{}\")", esc_lit(s)),
7350            Self::Float(f) => fmt_f32_lit(*f),
7351            Self::OptSome(s) => format!("azul.OptionString.some(\"{}\")", esc_lit(s)),
7352            Self::OptNone => "azul.OptionString.none()".to_string(),
7353        }
7354    }
7355}
7356
7357impl NodeCtor {
7358    const fn consumes_text(&self) -> bool {
7359        matches!(self, Self::Semantic { consumes_text: true, .. })
7360    }
7361    const fn skip_caption(&self) -> bool {
7362        matches!(self, Self::Semantic { skip_caption: true, .. })
7363    }
7364    /// `Dom::create_…(args)` for Rust, or `None` for a plain container.
7365    fn render_rust(&self) -> Option<String> {
7366        match self {
7367            Self::Plain => None,
7368            Self::Semantic { suffix, args, .. } => Some(format!(
7369                "Dom::create_{}({})",
7370                camel_to_snake(suffix),
7371                args.iter().map(CtorArg::render_rust).collect::<Vec<_>>().join(", ")
7372            )),
7373        }
7374    }
7375    /// `AzDom_create…(args)` for C, or `None` for a plain container.
7376    fn render_c(&self) -> Option<String> {
7377        match self {
7378            Self::Plain => None,
7379            Self::Semantic { suffix, args, .. } => Some(format!(
7380                "AzDom_create{}({})",
7381                suffix,
7382                args.iter().map(CtorArg::render_c).collect::<Vec<_>>().join(", ")
7383            )),
7384        }
7385    }
7386    /// Fluent `Dom::create_…` (C++) / `azul.Dom.create_…` (Python), or `None`.
7387    fn render_fluent(&self, target: &CompileTarget) -> Option<String> {
7388        match self {
7389            Self::Plain => None,
7390            Self::Semantic { suffix, args, .. } => {
7391                let snake = camel_to_snake(suffix);
7392                let (prefix, rendered) = match target {
7393                    CompileTarget::Cpp => (
7394                        format!("Dom::create_{snake}"),
7395                        args.iter().map(CtorArg::render_cpp).collect::<Vec<_>>(),
7396                    ),
7397                    CompileTarget::Python => (
7398                        format!("azul.Dom.create_{snake}"),
7399                        args.iter().map(CtorArg::render_python).collect::<Vec<_>>(),
7400                    ),
7401                    _ => return None,
7402                };
7403                Some(format!("{}({})", prefix, rendered.join(", ")))
7404            }
7405        }
7406    }
7407}
7408
7409/// Per-language token hooks for the fluent walker. The `&str` args are already
7410/// escaped for a double-quoted string literal.
7411struct FluentSyntax {
7412    target: CompileTarget,
7413    /// tag debug-name (e.g. "Div") -> full create expression
7414    create_node: fn(&str) -> String,
7415    /// escaped text -> create-text expression
7416    create_text: fn(&str) -> String,
7417    /// escaped css -> `.with_css(..)` call
7418    with_css: fn(&str) -> String,
7419    /// escaped class -> `.with_class(..)` call
7420    with_class: fn(&str) -> String,
7421    /// escaped id -> `.with_id(..)` call
7422    with_id: fn(&str) -> String,
7423    /// escaped child expression -> `.with_child(..)` call (children are chained)
7424    with_child: fn(&str) -> String,
7425}
7426
7427const CPP_SYNTAX: FluentSyntax = FluentSyntax {
7428    target: CompileTarget::Cpp,
7429    // Use per-tag creators (Dom::create_div(), create_p(), create_body(), …)
7430    // — `NodeType` is a tagged union, so `create_node` would need union
7431    // construction; the per-tag creators exist for every common HTML element.
7432    create_node: |tag| alloc::format!("Dom::create_{}()", tag.to_lowercase()),
7433    create_text: |s| alloc::format!("Dom::create_text(String(\"{s}\"))"),
7434    with_css: |s| alloc::format!(".with_css(String(\"{s}\"))"),
7435    with_class: |s| alloc::format!(".with_class(String(\"{s}\"))"),
7436    with_id: |s| alloc::format!(".with_id(String(\"{s}\"))"),
7437    with_child: |c| alloc::format!(".with_child({c})"),
7438};
7439
7440const PYTHON_SYNTAX: FluentSyntax = FluentSyntax {
7441    target: CompileTarget::Python,
7442    // Per-tag creators (azul.Dom.create_div(), …) — see CPP_SYNTAX note.
7443    create_node: |tag| alloc::format!("azul.Dom.create_{}()", tag.to_lowercase()),
7444    create_text: |s| alloc::format!("azul.Dom.create_text(\"{s}\")"),
7445    with_css: |s| alloc::format!(".with_css(\"{s}\")"),
7446    with_class: |s| alloc::format!(".with_class(\"{s}\")"),
7447    with_id: |s| alloc::format!(".with_id(\"{s}\")"),
7448    with_child: |c| alloc::format!(".with_child({c})"),
7449};
7450
7451/// Walk one element node, emitting a fluent create-expression for `syntax`'s
7452/// language. Mirrors `compile_node_to_rust_code_inner` but token-parameterized.
7453#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7454// See compile_node_to_rust_code_inner: component_map is forwarded for codegen-path parity.
7455#[allow(clippy::only_used_in_recursion)]
7456fn compile_node_fluent(
7457    node: &XmlNode,
7458    syntax: &FluentSyntax,
7459    component_map: &ComponentMap,
7460    css: &Css,
7461    mut matcher: CssMatcher,
7462) -> Result<String, CompileError> {
7463    use azul_css::css::CssDeclaration;
7464
7465    let component_name = normalize_casing(&node.node_type);
7466    let node_type_tag = tag_to_node_type_tag(&component_name);
7467    let tag_dbg = alloc::format!("{:?}", tag_to_node_type(&component_name));
7468
7469    // Base create-expression. For an exported live page every node is a plain
7470    // HTML element, so emit a per-tag creator directly via the language hooks
7471    // (universal + verified) rather than the per-component `compile_fn`, whose
7472    // C++/Python arms emit stale placeholder syntax (`Dom.div()` etc.).
7473    // Interactive/data tags (whose creators need args) fall back to `div`. Any
7474    // element text shows up as a Text child below and is handled there.
7475    let ctor = analyze_node_ctor(&component_name, node);
7476    let mut s = ctor.render_fluent(&syntax.target).map_or_else(|| (syntax.create_node)(safe_container_tag(&tag_dbg)), |expr| expr);
7477
7478    matcher.path.push(CssPathSelector::Type(node_type_tag));
7479    let ids: Vec<String> = node.attributes.get_key("id")
7480        .map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
7481        .unwrap_or_default();
7482    matcher.path.extend(ids.iter().map(|id| CssPathSelector::Id(id.clone().into())));
7483    let classes: Vec<String> = node.attributes.get_key("class")
7484        .map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
7485        .unwrap_or_default();
7486    matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
7487
7488    // Inline CSS (matched rules -> `.with_css("..")`, pseudo blocks included).
7489    let blocks = get_css_blocks(css, &matcher);
7490    if !blocks.is_empty() {
7491        let inline_css = css_blocks_to_inline_string(&blocks);
7492        if !inline_css.is_empty() {
7493            let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
7494            s.push_str(&(syntax.with_css)(&esc));
7495        }
7496    }
7497    for id in &ids {
7498        s.push_str(&(syntax.with_id)(&id.replace('\\', "\\\\").replace('"', "\\\"")));
7499    }
7500    for class in &classes {
7501        s.push_str(&(syntax.with_class)(&class.replace('\\', "\\\\").replace('"', "\\\"")));
7502    }
7503
7504    // Children (chained `.with_child(..)`). Text folded into the ctor (Tier A/C)
7505    // is skipped here, as is a `<caption>` already injected by `create_table`.
7506    let mut caption_skipped = false;
7507    for (child_idx, child) in node.children.as_ref().iter().enumerate() {
7508        match child {
7509            XmlNodeChild::Element(child_node) => {
7510                if ctor.skip_caption()
7511                    && !caption_skipped
7512                    && child_node.node_type.as_str().eq_ignore_ascii_case("caption")
7513                {
7514                    caption_skipped = true;
7515                    continue;
7516                }
7517                let mut m = matcher.clone();
7518                m.path.push(CssPathSelector::Children);
7519                m.indices_in_parent.push(child_idx);
7520                m.children_length.push(node.children.len());
7521                let child_src = compile_node_fluent(child_node, syntax, component_map, css, m)?;
7522                s.push_str(&(syntax.with_child)(&child_src));
7523            }
7524            XmlNodeChild::Text(text) => {
7525                if ctor.consumes_text() {
7526                    continue;
7527                }
7528                let text = text.trim();
7529                if !text.is_empty() {
7530                    let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
7531                    s.push_str(&(syntax.with_child)(&(syntax.create_text)(&esc)));
7532                }
7533            }
7534        }
7535    }
7536
7537    Ok(s)
7538}
7539
7540/// Build the `<body>` render-expression for `syntax`'s language.
7541#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7542fn compile_body_fluent<'a>(
7543    body_node: &'a XmlNode,
7544    syntax: &FluentSyntax,
7545    component_map: &'a ComponentMap,
7546    css: &Css,
7547    mut matcher: CssMatcher,
7548) -> Result<String, CompileError> {
7549    let mut s = (syntax.create_node)("Body");
7550    matcher.path.push(CssPathSelector::Type(NodeTypeTag::Body));
7551    let classes: Vec<String> = body_node.attributes.get_key("class")
7552        .map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
7553        .unwrap_or_default();
7554    matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
7555
7556    let blocks = get_css_blocks(css, &matcher);
7557    if !blocks.is_empty() {
7558        let inline_css = css_blocks_to_inline_string(&blocks);
7559        if !inline_css.is_empty() {
7560            let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
7561            s.push_str(&(syntax.with_css)(&esc));
7562        }
7563    }
7564    for class in &classes {
7565        s.push_str(&(syntax.with_class)(&class.replace('\\', "\\\\").replace('"', "\\\"")));
7566    }
7567
7568    for (child_idx, child) in body_node.children.as_ref().iter().enumerate() {
7569        match child {
7570            XmlNodeChild::Element(child_node) => {
7571                let mut m = matcher.clone();
7572                m.path.push(CssPathSelector::Children);
7573                m.indices_in_parent.push(child_idx);
7574                m.children_length.push(body_node.children.len());
7575                let child_src = compile_node_fluent(child_node, syntax, component_map, css, m)?;
7576                s.push_str(&(syntax.with_child)(&child_src));
7577            }
7578            XmlNodeChild::Text(text) => {
7579                let text = text.trim();
7580                if !text.is_empty() {
7581                    let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
7582                    s.push_str(&(syntax.with_child)(&(syntax.create_text)(&esc)));
7583                }
7584            }
7585        }
7586    }
7587    Ok(s)
7588}
7589
7590/// Parse the page's `<style>` and seed a matcher rooted at `<body>`. Shared by
7591/// the C++/Python/C entry points (mirrors the head of `str_to_rust_code`).
7592#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7593fn parse_page_style_and_body(
7594    root_nodes: &[XmlNodeChild],
7595) -> Result<(Css, &XmlNode), CompileError> {
7596    let html_node = get_html_node(root_nodes)?;
7597    let body_node = get_body_node(html_node.children.as_ref())?;
7598    let mut global_style = Css::empty();
7599    if let Some(head_node) = find_node_by_type(html_node.children.as_ref(), "head") {
7600        if let Some(style_node) = find_node_by_type(head_node.children.as_ref(), "style") {
7601            let text = style_node.get_text_content();
7602            if !text.is_empty() {
7603                global_style = azul_css::parser2::new_from_str(&text).0;
7604            }
7605        }
7606    }
7607    global_style.sort_by_specificity();
7608    Ok((global_style, body_node))
7609}
7610
7611fn body_matcher(body_node: &XmlNode) -> CssMatcher {
7612    CssMatcher {
7613        path: Vec::new(),
7614        indices_in_parent: vec![0],
7615        children_length: vec![body_node.children.as_ref().len()],
7616    }
7617}
7618
7619/// Compile a full HTML page to a compilable **C++** Azul app.
7620#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7621/// # Errors
7622///
7623/// Returns an error if the XML cannot be parsed or compiled to C++ code.
7624pub fn str_to_cpp_code<'a>(
7625    root_nodes: &'a [XmlNodeChild],
7626    component_map: &'a ComponentMap,
7627) -> Result<String, CompileError> {
7628    let (global_style, body_node) = parse_page_style_and_body(root_nodes)?;
7629    let render = compile_body_fluent(body_node, &CPP_SYNTAX, component_map, &global_style, body_matcher(body_node))?;
7630    Ok(alloc::format!(
7631        "// Auto-generated UI source code (C++). Build:\n\
7632         //   clang++ -std=c++20 -I <azul>/target/codegen main.cpp -lazul\n\
7633         #include \"azul20.hpp\"\n\
7634         using namespace azul;\n\n\
7635         struct Data {{}};\n\n\
7636         AzDom render(AzRefAny data, AzLayoutCallbackInfo info) {{\n    \
7637         return {render};\n}}\n\n\
7638         int main() {{\n    \
7639         RefAny data = RefAny::create(Data{{}});\n    \
7640         WindowCreateOptions window = WindowCreateOptions::create(render);\n    \
7641         App app = App::create(std::move(data), AppConfig::default_());\n    \
7642         app.run(std::move(window));\n    \
7643         return 0;\n}}\n"
7644    ))
7645}
7646
7647/// Compile a full HTML page to a compilable **Python** Azul app.
7648#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7649/// # Errors
7650///
7651/// Returns an error if the XML cannot be parsed or compiled to Python code.
7652pub fn str_to_python_code<'a>(
7653    root_nodes: &'a [XmlNodeChild],
7654    component_map: &'a ComponentMap,
7655) -> Result<String, CompileError> {
7656    let (global_style, body_node) = parse_page_style_and_body(root_nodes)?;
7657    let render = compile_body_fluent(body_node, &PYTHON_SYNTAX, component_map, &global_style, body_matcher(body_node))?;
7658    Ok(alloc::format!(
7659        "# Auto-generated UI source code (Python). Run: python3 main.py\n\
7660         import azul\n\n\
7661         class Data:\n    pass\n\n\
7662         def render(data, info):\n    return (\n        {}\n    )\n\n\
7663         def main():\n    \
7664         app = azul.App.create(Data(), azul.AppConfig.create())\n    \
7665         window = azul.WindowCreateOptions.create(render)\n    \
7666         app.run(window)\n\n\
7667         if __name__ == \"__main__\":\n    main()\n",
7668        render.replace("\r\n", "\n        ")
7669    ))
7670}
7671
7672// ───────────────────────────────────────────────────────────────────────────
7673// Imperative C emitter. C has no fluent builder: each node is a statement that
7674// creates an `AzDom` local, applies css/class (by-value, returns), and pushes
7675// children via `AzDom_addChild(&parent, child)`. A recursive walk emits the
7676// statements bottom-up and returns the variable name holding each node.
7677// ───────────────────────────────────────────────────────────────────────────
7678
7679/// C per-tag creator suffix: `NodeTypeTag` debug name with first char kept and
7680/// the rest lowercased (`Div`->`Div`, `BlockQuote`->`Blockquote`, `H1`->`H1`),
7681/// matching `AzDom_create<Suffix>` in azul.h.
7682fn c_creator_suffix(tag_dbg: &str) -> String {
7683    let mut chars = tag_dbg.chars();
7684    chars.next().map_or_else(|| "Div".to_string(), |first| {
7685            let rest: String = chars.as_str().to_lowercase();
7686            alloc::format!("{first}{rest}")
7687        })
7688}
7689
7690#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7691fn compile_node_c(
7692    node: &XmlNode,
7693    component_map: &ComponentMap,
7694    css: &Css,
7695    mut matcher: CssMatcher,
7696    counter: &mut usize,
7697    out: &mut String,
7698) -> Result<String, CompileError> {
7699    let _ = component_map;
7700    let component_name = normalize_casing(&node.node_type);
7701    let node_type_tag = tag_to_node_type_tag(&component_name);
7702    let tag_dbg = alloc::format!("{:?}", tag_to_node_type(&component_name));
7703
7704    let var = alloc::format!("n{}", *counter);
7705    *counter += 1;
7706    let ctor = analyze_node_ctor(&component_name, node);
7707    match ctor.render_c() {
7708        Some(expr) => { let _ = writeln!(out, "    AzDom {var} = {expr};"); },
7709        None => { let _ = writeln!(out,
7710            "    AzDom {} = AzDom_create{}();",
7711            var,
7712            c_creator_suffix(safe_container_tag(&tag_dbg))
7713        ); },
7714    }
7715
7716    matcher.path.push(CssPathSelector::Type(node_type_tag));
7717    let ids: Vec<String> = node.attributes.get_key("id")
7718        .map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
7719        .unwrap_or_default();
7720    matcher.path.extend(ids.iter().map(|id| CssPathSelector::Id(id.clone().into())));
7721    let classes: Vec<String> = node.attributes.get_key("class")
7722        .map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
7723        .unwrap_or_default();
7724    matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
7725
7726    let blocks = get_css_blocks(css, &matcher);
7727    if !blocks.is_empty() {
7728        let inline_css = css_blocks_to_inline_string(&blocks);
7729        if !inline_css.is_empty() {
7730            let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
7731            let _ = writeln!(out, "    {var} = AzDom_withCss({var}, AZ_STR(\"{esc}\"));");
7732        }
7733    }
7734    for id in &ids {
7735        let esc = id.replace('\\', "\\\\").replace('"', "\\\"");
7736        let _ = writeln!(out, "    {var} = AzDom_withId({var}, AZ_STR(\"{esc}\"));");
7737    }
7738    for class in &classes {
7739        let esc = class.replace('\\', "\\\\").replace('"', "\\\"");
7740        let _ = writeln!(out, "    {var} = AzDom_withClass({var}, AZ_STR(\"{esc}\"));");
7741    }
7742
7743    let mut caption_skipped = false;
7744    for (child_idx, child) in node.children.as_ref().iter().enumerate() {
7745        match child {
7746            XmlNodeChild::Element(child_node) => {
7747                if ctor.skip_caption()
7748                    && !caption_skipped
7749                    && child_node.node_type.as_str().eq_ignore_ascii_case("caption")
7750                {
7751                    caption_skipped = true;
7752                    continue;
7753                }
7754                let mut m = matcher.clone();
7755                m.path.push(CssPathSelector::Children);
7756                m.indices_in_parent.push(child_idx);
7757                m.children_length.push(node.children.len());
7758                let child_var = compile_node_c(child_node, component_map, css, m, counter, out)?;
7759                let _ = writeln!(out, "    AzDom_addChild(&{var}, {child_var});");
7760            }
7761            XmlNodeChild::Text(text) => {
7762                if ctor.consumes_text() {
7763                    continue;
7764                }
7765                let text = text.trim();
7766                if !text.is_empty() {
7767                    let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
7768                    let _ = writeln!(out,
7769                        "    AzDom_addChild(&{var}, AzDom_createText(AZ_STR(\"{esc}\")));"
7770                    );
7771                }
7772            }
7773        }
7774    }
7775    Ok(var)
7776}
7777
7778/// Compile a full HTML page to a compilable **C** Azul app.
7779#[allow(clippy::result_large_err)] // returns a #[repr(C,u8)] FFI error enum; boxing a variant would break the C ABI/api.json
7780/// # Errors
7781///
7782/// Returns an error if the XML cannot be parsed or compiled to C code.
7783pub fn str_to_c_code<'a>(
7784    root_nodes: &'a [XmlNodeChild],
7785    component_map: &'a ComponentMap,
7786) -> Result<String, CompileError> {
7787    let (global_style, body_node) = parse_page_style_and_body(root_nodes)?;
7788    let mut body = String::new();
7789    let mut counter = 0usize;
7790
7791    // Emit the body as the root node, then its children.
7792    let root = alloc::format!("n{counter}");
7793    counter += 1;
7794    let _ = writeln!(body, "    AzDom {root} = AzDom_createBody();");
7795
7796    let mut matcher = body_matcher(body_node);
7797    matcher.path.push(CssPathSelector::Type(NodeTypeTag::Body));
7798    let classes: Vec<String> = body_node.attributes.get_key("class")
7799        .map(|v| v.split_whitespace().map(alloc::string::ToString::to_string).collect())
7800        .unwrap_or_default();
7801    matcher.path.extend(classes.iter().map(|c| CssPathSelector::Class(c.clone().into())));
7802    let blocks = get_css_blocks(&global_style, &matcher);
7803    if !blocks.is_empty() {
7804        let inline_css = css_blocks_to_inline_string(&blocks);
7805        if !inline_css.is_empty() {
7806            let esc = inline_css.replace('\\', "\\\\").replace('"', "\\\"");
7807            let _ = writeln!(body, "    {root} = AzDom_withCss({root}, AZ_STR(\"{esc}\"));");
7808        }
7809    }
7810    for (child_idx, child) in body_node.children.as_ref().iter().enumerate() {
7811        match child {
7812            XmlNodeChild::Element(child_node) => {
7813                let mut m = matcher.clone();
7814                m.path.push(CssPathSelector::Children);
7815                m.indices_in_parent.push(child_idx);
7816                m.children_length.push(body_node.children.len());
7817                let child_var = compile_node_c(child_node, component_map, &global_style, m, &mut counter, &mut body)?;
7818                let _ = writeln!(body, "    AzDom_addChild(&{root}, {child_var});");
7819            }
7820            XmlNodeChild::Text(text) => {
7821                let text = text.trim();
7822                if !text.is_empty() {
7823                    let esc = text.replace('\\', "\\\\").replace('"', "\\\"");
7824                    let _ = writeln!(body,
7825                        "    AzDom_addChild(&{root}, AzDom_createText(AZ_STR(\"{esc}\")));"
7826                    );
7827                }
7828            }
7829        }
7830    }
7831
7832    Ok(alloc::format!(
7833        "/* Auto-generated UI source code (C). Build:\n\
7834         *   clang -I <azul>/target/codegen main.c -lazul\n */\n\
7835         #include \"azul.h\"\n\
7836         #include <string.h>\n\
7837         #define AZ_STR(s) AzString_copyFromBytes((const uint8_t*)(s), 0, strlen(s))\n\n\
7838         AzDom render(AzRefAny data, AzLayoutCallbackInfo info) {{\n\
7839         {body}    return {root};\n}}\n\n\
7840         int main(void) {{\n    \
7841         AzString data_type = AZ_STR(\"Data\");\n    \
7842         AzRefAny data = AzRefAny_newC((AzGlVoidPtrConst){{ .ptr = NULL }}, 0, 1, 0, data_type, NULL, 0, 0);\n    \
7843         AzApp app = AzApp_create(data, AzAppConfig_create());\n    \
7844         AzWindowCreateOptions window = AzWindowCreateOptions_create(render);\n    \
7845         AzApp_run(&app, window);\n    \
7846         AzApp_delete(&app);\n    \
7847         return 0;\n}}\n"
7848    ))
7849}
7850
7851#[cfg(test)]
7852mod tests {
7853    use super::*;
7854    use crate::dom::{Dom, NodeType};
7855
7856    #[test]
7857    fn test_inline_span_parsing() {
7858        // This test verifies that HTML with inline spans is parsed correctly
7859        // The DOM structure should preserve text nodes before, inside, and after the span
7860
7861        let html = r#"<p>Text before <span class="highlight">inline text</span> text after.</p>"#;
7862
7863        // Expected DOM structure:
7864        // <p>
7865        //   ├─ TextNode: "Text before "
7866        //   ├─ <span class="highlight">
7867        //   │   └─ TextNode: "inline text"
7868        //   └─ TextNode: " text after."
7869
7870        // For this test, we'll create the DOM structure manually
7871        // since we're testing the parsing logic
7872        let expected_dom = Dom::create_p().with_children(
7873            vec![
7874                Dom::create_text("Text before "),
7875                Dom::create_node(NodeType::Span)
7876                    .with_children(vec![Dom::create_text("inline text")].into()),
7877                Dom::create_text(" text after."),
7878            ]
7879            .into(),
7880        );
7881
7882        // Verify the structure has 3 children at the top level
7883        assert_eq!(expected_dom.children.as_ref().len(), 3);
7884
7885        // Verify the middle child is a span
7886        match &expected_dom.children.as_ref()[1].root.node_type {
7887            NodeType::Span => {}
7888            other => panic!("Expected Span, got {:?}", other),
7889        }
7890
7891        // Verify the span has 1 child (the text node)
7892        assert_eq!(expected_dom.children.as_ref()[1].children.as_ref().len(), 1);
7893
7894        println!("Test passed: Inline span parsing structure is correct");
7895    }
7896
7897    #[test]
7898    fn test_xml_node_structure() {
7899        // Test the basic XmlNode structure to ensure text content is preserved
7900        // Updated to use XmlNodeChild enum (Text/Element)
7901
7902        let node = XmlNode {
7903            node_type: "p".into(),
7904            attributes: XmlAttributeMap {
7905                inner: StringPairVec::from_const_slice(&[]),
7906            },
7907            children: vec![
7908                XmlNodeChild::Text("Before ".into()),
7909                XmlNodeChild::Element(XmlNode {
7910                    node_type: "span".into(),
7911                    children: vec![XmlNodeChild::Text("inline".into())].into(),
7912                    ..Default::default()
7913                }),
7914                XmlNodeChild::Text(" after".into()),
7915            ]
7916            .into(),
7917        };
7918
7919        // Verify structure
7920        assert_eq!(node.children.as_ref().len(), 3);
7921        assert_eq!(node.children.as_ref()[0].as_text(), Some("Before "));
7922        assert_eq!(
7923            node.children.as_ref()[1]
7924                .as_element()
7925                .unwrap()
7926                .node_type
7927                .as_str(),
7928            "span"
7929        );
7930        assert_eq!(node.children.as_ref()[2].as_text(), Some(" after"));
7931
7932        // Verify span's child
7933        let span = node.children.as_ref()[1].as_element().unwrap();
7934        assert_eq!(span.children.as_ref().len(), 1);
7935        assert_eq!(span.children.as_ref()[0].as_text(), Some("inline"));
7936
7937        println!("Test passed: XmlNode structure preserves text nodes correctly");
7938    }
7939
7940    #[test]
7941    fn test_img_tag_becomes_image_node_with_src_tag() {
7942        // `<img src="cat.jpg" width="300" height="169">` must become a
7943        // `NodeType::Image` whose `NullImage` carries the `src` string as its
7944        // `tag` (so a renderer can resolve the bytes later), plus the declared
7945        // intrinsic size.
7946        use crate::resources::DecodedImage;
7947        use crate::window::{AzStringPair, StringPairVec};
7948
7949        let img_node = XmlNode {
7950            node_type: "img".into(),
7951            attributes: XmlAttributeMap::from(StringPairVec::from_vec(alloc::vec![
7952                AzStringPair {
7953                    key: "src".into(),
7954                    value: "cat.jpg".into()
7955                },
7956                AzStringPair {
7957                    key: "width".into(),
7958                    value: "300".into()
7959                },
7960                AzStringPair {
7961                    key: "height".into(),
7962                    value: "169".into()
7963                },
7964            ])),
7965            children: Vec::new().into(),
7966        };
7967
7968        let component_map = ComponentMap::default();
7969        let dom = xml_node_to_dom_fast(&img_node, &component_map, false, 0)
7970            .expect("xml_node_to_dom_fast for <img> should succeed");
7971
7972        match dom.root.get_node_type() {
7973            NodeType::Image(image_ref) => match image_ref.as_ref().get_data() {
7974                DecodedImage::NullImage {
7975                    tag, width, height, ..
7976                } => {
7977                    assert_eq!(
7978                        core::str::from_utf8(tag).unwrap(),
7979                        "cat.jpg",
7980                        "image tag must carry the src string"
7981                    );
7982                    assert_eq!(*width, 300, "width attribute should set intrinsic width");
7983                    assert_eq!(*height, 169, "height attribute should set intrinsic height");
7984                }
7985                other => panic!("expected NullImage carrying the src tag, got {:?}", other),
7986            },
7987            other => panic!("expected NodeType::Image for <img>, got {:?}", other),
7988        }
7989
7990        println!("Test passed: <img src=\"cat.jpg\"> -> NodeType::Image tagged \"cat.jpg\"");
7991    }
7992
7993    #[test]
7994    fn test_tag_to_node_type_img_is_image() {
7995        // The bare tag mapping should also yield an Image (placeholder, empty tag).
7996        match tag_to_node_type("img") {
7997            NodeType::Image(_) => {}
7998            other => panic!("tag_to_node_type(\"img\") should be Image, got {:?}", other),
7999        }
8000    }
8001
8002    /// Build a `<div>` nested `depth` levels deep, innermost first.
8003    fn nested_divs(depth: usize) -> XmlNode {
8004        let mut node = XmlNode {
8005            node_type: "div".into(),
8006            ..Default::default()
8007        };
8008        for _ in 0..depth {
8009            node = XmlNode {
8010                node_type: "div".into(),
8011                children: vec![XmlNodeChild::Element(node)].into(),
8012                ..Default::default()
8013            };
8014        }
8015        node
8016    }
8017
8018    /// AUDIT 2026-07-08: `extract_css_urls` used to slice the original string with
8019    /// a byte offset computed in a `to_lowercase()` temporary. On `'İ'` (whose
8020    /// lowercase is longer in bytes) that offset was misaligned. This must no
8021    /// longer panic and must still find the `@import` target.
8022    #[test]
8023    fn extract_css_urls_unicode_import_no_panic() {
8024        let mut res = Vec::new();
8025        Xml::extract_css_urls("İ@import 'x'", &mut res);
8026        assert_eq!(res.len(), 1, "should find the one @import target");
8027        assert_eq!(res[0].url.as_str(), "x");
8028    }
8029
8030    /// The `url(` and `@import` scans are case-insensitive after the audit fix.
8031    #[test]
8032    fn extract_css_urls_is_case_insensitive() {
8033        let mut res = Vec::new();
8034        Xml::extract_css_urls("body { background: URL(http://e.com/a.png); }", &mut res);
8035        assert_eq!(res.len(), 1);
8036        assert_eq!(res[0].url.as_str(), "http://e.com/a.png");
8037
8038        let mut res2 = Vec::new();
8039        Xml::extract_css_urls("@IMPORT \"theme.css\";", &mut res2);
8040        assert_eq!(res2.len(), 1);
8041        assert_eq!(res2[0].url.as_str(), "theme.css");
8042    }
8043
8044    /// AUDIT 2026-07-08: the resource scan recurses per nesting level; deep markup
8045    /// must not overflow the stack (deeper-than-cap subtrees are just not scanned).
8046    #[test]
8047    fn scan_external_resources_deep_nesting_ok() {
8048        let xml = Xml {
8049            root: vec![XmlNodeChild::Element(nested_divs(2000))].into(),
8050        };
8051        // Must simply return (no stack overflow); no resources in a plain tree.
8052        drop(xml.scan_external_resources());
8053    }
8054
8055    /// AUDIT 2026-07-08: the fast + tree DOM builders recurse per nesting level;
8056    /// deep markup must not overflow the stack (children beyond the cap are
8057    /// dropped, but the call returns `Ok`).
8058    #[test]
8059    fn xml_node_to_dom_fast_deep_nesting_ok() {
8060        let deep = nested_divs(2000);
8061        let component_map = ComponentMap::default();
8062        let dom = xml_node_to_dom_fast(&deep, &component_map, false, 0);
8063        assert!(dom.is_ok(), "deep DOM build must not overflow the stack");
8064
8065        let mut builder = CompactDomBuilder::new();
8066        let fast = xml_node_to_fast_dom(&deep, &component_map, false, &mut builder, 0);
8067        assert!(fast.is_ok(), "deep FastDom build must not overflow the stack");
8068    }
8069
8070    /// AUDIT 2026-07-08: `ComponentFieldType::parse` recurses through `Option<..>`
8071    /// / `Vec<..>` wrappers; an over-deep type string is rejected rather than
8072    /// overflowing the stack, while ordinary nesting still parses.
8073    #[test]
8074    fn component_field_type_parse_depth_capped() {
8075        let deep = format!("{}Bool{}", "Option<".repeat(4000), ">".repeat(4000));
8076        assert!(
8077            ComponentFieldType::parse(&deep).is_none(),
8078            "over-deep type string must be rejected, not overflow"
8079        );
8080
8081        let shallow = format!("{}Bool{}", "Option<".repeat(8), ">".repeat(8));
8082        assert!(
8083            ComponentFieldType::parse(&shallow).is_some(),
8084            "ordinary nesting must still parse"
8085        );
8086    }
8087
8088    /// AUDIT 2026-07-08: `prepare_string` now decodes the full common entity set
8089    /// plus numeric references, in a single pass so `&amp;` cannot double-decode.
8090    #[test]
8091    fn prepare_string_entity_decoding() {
8092        assert_eq!(prepare_string("a &amp; b"), "a & b");
8093        // `&amp;lt;` must yield the literal text "&lt;", not "<".
8094        assert_eq!(prepare_string("&amp;lt;"), "&lt;");
8095        assert_eq!(prepare_string("&quot;hi&quot;"), "\"hi\"");
8096        assert_eq!(prepare_string("&#65;&#66;"), "AB");
8097        assert_eq!(prepare_string("&#x41;"), "A");
8098        // Existing behavior preserved.
8099        assert_eq!(prepare_string("&lt;tag&gt;"), "<tag>");
8100    }
8101}
8102
8103#[cfg(test)]
8104#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
8105mod autotest_generated {
8106    use super::*;
8107    use crate::dom::{NodeData, NodeType};
8108    use azul_css::css::{CssNthChildPattern, CssNthChildSelector};
8109
8110    // ----------------------------------------------------------------- helpers
8111
8112    fn attrs(kv: &[(&str, &str)]) -> XmlAttributeMap {
8113        XmlAttributeMap::from(StringPairVec::from_vec(
8114            kv.iter()
8115                .map(|(k, v)| AzStringPair {
8116                    key: AzString::from(*k),
8117                    value: AzString::from(*v),
8118                })
8119                .collect::<Vec<_>>(),
8120        ))
8121    }
8122
8123    fn node(tag: &str, kv: &[(&str, &str)], children: Vec<XmlNodeChild>) -> XmlNode {
8124        XmlNode {
8125            node_type: tag.into(),
8126            attributes: attrs(kv),
8127            children: children.into(),
8128        }
8129    }
8130
8131    fn txt(s: &str) -> XmlNodeChild {
8132        XmlNodeChild::Text(AzString::from(s))
8133    }
8134
8135    fn elem(n: XmlNode) -> XmlNodeChild {
8136        XmlNodeChild::Element(n)
8137    }
8138
8139    /// `<html><head><style>{css}</style></head><body>{children}</body></html>`
8140    fn doc(css: &str, body_children: Vec<XmlNodeChild>) -> Vec<XmlNodeChild> {
8141        let style = node("style", &[], vec![txt(css)]);
8142        let head = node("head", &[], vec![elem(style)]);
8143        let body = node("body", &[], body_children);
8144        vec![elem(node("html", &[], vec![elem(head), elem(body)]))]
8145    }
8146
8147    fn no_args() -> ComponentArgumentVec {
8148        ComponentArgumentVec::from_const_slice(&[])
8149    }
8150
8151    fn dm(name: &str, fields: Vec<ComponentDataField>) -> ComponentDataModel {
8152        ComponentDataModel {
8153            name: AzString::from(name),
8154            description: AzString::from_const_str(""),
8155            fields: fields.into(),
8156        }
8157    }
8158
8159    fn user_def(css: &str, fields: Vec<ComponentDataField>) -> ComponentDef {
8160        ComponentDef {
8161            id: ComponentId::new("mylib", "widget"),
8162            display_name: AzString::from_const_str("Widget"),
8163            description: AzString::from_const_str(""),
8164            css: AzString::from(css),
8165            source: ComponentSource::UserDefined,
8166            data_model: dm("WidgetData", fields),
8167            render_fn: user_defined_render_fn,
8168            compile_fn: user_defined_compile_fn,
8169            render_fn_source: None.into(),
8170            compile_fn_source: None.into(),
8171        }
8172    }
8173
8174    /// A string that is long enough to smoke out O(n^2) / allocation blowups but
8175    /// still finishes fast in a debug-profile test run.
8176    const LONG: usize = 200_000;
8177
8178    // ================================================================
8179    // Xml::extract_url_value  (parser)
8180    // ================================================================
8181
8182    #[test]
8183    fn extract_url_value_empty_and_whitespace() {
8184        assert_eq!(Xml::extract_url_value(""), None);
8185        assert_eq!(Xml::extract_url_value("   "), None);
8186        assert_eq!(Xml::extract_url_value("\t\n\r "), None);
8187    }
8188
8189    #[test]
8190    fn extract_url_value_valid_minimal() {
8191        assert_eq!(
8192            Xml::extract_url_value("a.png)"),
8193            Some("a.png".to_string()),
8194            "unquoted url terminated by ')'"
8195        );
8196        assert_eq!(
8197            Xml::extract_url_value("\"a.png\")"),
8198            Some("a.png".to_string()),
8199            "double-quoted url"
8200        );
8201        assert_eq!(
8202            Xml::extract_url_value("'a.png')"),
8203            Some("a.png".to_string()),
8204            "single-quoted url"
8205        );
8206    }
8207
8208    #[test]
8209    fn extract_url_value_leading_trailing_junk_is_trimmed() {
8210        // Leading whitespace is trimmed by `trim_start`, inner padding by `trim`.
8211        assert_eq!(
8212            Xml::extract_url_value("   a.png   )tail"),
8213            Some("a.png".to_string())
8214        );
8215    }
8216
8217    #[test]
8218    fn extract_url_value_garbage_returns_none() {
8219        // Unterminated quote / no closing paren => None, never a panic.
8220        assert_eq!(Xml::extract_url_value("\"unterminated"), None);
8221        assert_eq!(Xml::extract_url_value("'unterminated"), None);
8222        assert_eq!(Xml::extract_url_value("no-closing-paren"), None);
8223        assert_eq!(Xml::extract_url_value("\u{0}\u{1}\u{7f}"), None);
8224    }
8225
8226    #[test]
8227    fn extract_url_value_boundary_numbers() {
8228        for s in [
8229            "0)",
8230            "-0)",
8231            "9223372036854775807)",
8232            "-9223372036854775808)",
8233            "NaN)",
8234            "inf)",
8235            "1e400)",
8236        ] {
8237            let got = Xml::extract_url_value(s);
8238            assert!(got.is_some(), "numeric-looking url {s:?} is still a url");
8239        }
8240        assert_eq!(Xml::extract_url_value("0)"), Some("0".to_string()));
8241    }
8242
8243    #[test]
8244    fn extract_url_value_unicode_no_panic() {
8245        // The ')' scan must land on a char boundary of the ORIGINAL string.
8246        assert_eq!(
8247            Xml::extract_url_value("\u{1F600}\u{0301})"),
8248            Some("\u{1F600}\u{0301}".to_string())
8249        );
8250        assert_eq!(Xml::extract_url_value("\"\u{130}\")"), Some("\u{130}".to_string()));
8251        assert_eq!(Xml::extract_url_value("\u{1F600}"), None);
8252    }
8253
8254    #[test]
8255    fn extract_url_value_extremely_long_terminates() {
8256        let s = "a".repeat(LONG);
8257        assert_eq!(Xml::extract_url_value(&s), None, "no ')' anywhere => None");
8258        let s2 = format!("{})", "b".repeat(LONG));
8259        assert_eq!(Xml::extract_url_value(&s2).map(|v| v.len()), Some(LONG));
8260    }
8261
8262    #[test]
8263    fn extract_url_value_nested_brackets_no_stack_overflow() {
8264        // Not recursive, but confirm deeply "nested" input is handled iteratively.
8265        let s = "(".repeat(10_000);
8266        assert_eq!(Xml::extract_url_value(&s), None);
8267        let s2 = format!("{}{}", "(".repeat(10_000), ")");
8268        assert_eq!(Xml::extract_url_value(&s2), Some("(".repeat(10_000)));
8269    }
8270
8271    // ================================================================
8272    // Xml::extract_quoted_string  (parser)
8273    // ================================================================
8274
8275    #[test]
8276    fn extract_quoted_string_empty_whitespace_garbage() {
8277        assert_eq!(Xml::extract_quoted_string(""), None);
8278        assert_eq!(Xml::extract_quoted_string("   "), None);
8279        assert_eq!(Xml::extract_quoted_string("\t\n"), None);
8280        assert_eq!(Xml::extract_quoted_string("bare"), None);
8281        // Leading whitespace is NOT trimmed here (unlike extract_url_value).
8282        assert_eq!(Xml::extract_quoted_string("  \"x\""), None);
8283    }
8284
8285    #[test]
8286    fn extract_quoted_string_valid_minimal_and_empty_quotes() {
8287        assert_eq!(Xml::extract_quoted_string("\"x\""), Some("x".to_string()));
8288        assert_eq!(Xml::extract_quoted_string("'x'"), Some("x".to_string()));
8289        // An empty quoted string is Some(""), not None.
8290        assert_eq!(Xml::extract_quoted_string("\"\""), Some(String::new()));
8291        assert_eq!(Xml::extract_quoted_string("''"), Some(String::new()));
8292    }
8293
8294    #[test]
8295    fn extract_quoted_string_unterminated_is_none() {
8296        assert_eq!(Xml::extract_quoted_string("\"abc"), None);
8297        assert_eq!(Xml::extract_quoted_string("'abc"), None);
8298        // Mismatched quotes do not pair up.
8299        assert_eq!(Xml::extract_quoted_string("\"abc'"), None);
8300    }
8301
8302    #[test]
8303    fn extract_quoted_string_boundary_numbers_and_unicode() {
8304        assert_eq!(Xml::extract_quoted_string("\"0\""), Some("0".to_string()));
8305        assert_eq!(Xml::extract_quoted_string("\"-0\""), Some("-0".to_string()));
8306        assert_eq!(Xml::extract_quoted_string("\"NaN\""), Some("NaN".to_string()));
8307        assert_eq!(
8308            Xml::extract_quoted_string("\"\u{1F600}\u{0301}\""),
8309            Some("\u{1F600}\u{0301}".to_string())
8310        );
8311    }
8312
8313    #[test]
8314    fn extract_quoted_string_extremely_long_terminates() {
8315        let unterminated = format!("\"{}", "x".repeat(LONG));
8316        assert_eq!(Xml::extract_quoted_string(&unterminated), None);
8317        let terminated = format!("\"{}\"", "x".repeat(LONG));
8318        assert_eq!(
8319            Xml::extract_quoted_string(&terminated).map(|s| s.len()),
8320            Some(LONG)
8321        );
8322    }
8323
8324    // ================================================================
8325    // Xml::parse_srcset  (parser)
8326    // ================================================================
8327
8328    #[test]
8329    fn parse_srcset_empty_and_whitespace_yield_no_urls() {
8330        assert!(Xml::parse_srcset("").is_empty());
8331        assert!(Xml::parse_srcset("   ").is_empty());
8332        assert!(Xml::parse_srcset("\t\n").is_empty());
8333        assert!(Xml::parse_srcset(",,,").is_empty(), "all-empty entries dropped");
8334    }
8335
8336    #[test]
8337    fn parse_srcset_valid_minimal() {
8338        assert_eq!(
8339            Xml::parse_srcset("a.png 1x, b.png 2x"),
8340            vec!["a.png".to_string(), "b.png".to_string()]
8341        );
8342        // No descriptor at all is still a valid single entry.
8343        assert_eq!(Xml::parse_srcset("a.png"), vec!["a.png".to_string()]);
8344    }
8345
8346    #[test]
8347    fn parse_srcset_garbage_and_boundary_numbers() {
8348        assert_eq!(Xml::parse_srcset("0, -0, NaN"), vec!["0", "-0", "NaN"]);
8349        // Garbage bytes still round out to "first whitespace-delimited token".
8350        assert_eq!(Xml::parse_srcset("\u{0}\u{7f} 1x"), vec!["\u{0}\u{7f}".to_string()]);
8351    }
8352
8353    #[test]
8354    fn parse_srcset_unicode_no_panic() {
8355        assert_eq!(
8356            Xml::parse_srcset("\u{1F600}.png 1x, \u{130}.png 2x"),
8357            vec!["\u{1F600}.png".to_string(), "\u{130}.png".to_string()]
8358        );
8359    }
8360
8361    #[test]
8362    fn parse_srcset_extremely_long_terminates() {
8363        let s = "a.png 1x,".repeat(20_000);
8364        assert_eq!(Xml::parse_srcset(&s).len(), 20_000);
8365        let one_huge = "a".repeat(LONG);
8366        assert_eq!(Xml::parse_srcset(&one_huge).len(), 1);
8367    }
8368
8369    // ================================================================
8370    // Xml::looks_like_resource / guess_kind_from_url / guess_mime_from_url
8371    // ================================================================
8372
8373    #[test]
8374    fn looks_like_resource_edges() {
8375        assert!(!Xml::looks_like_resource(""));
8376        assert!(!Xml::looks_like_resource("   "));
8377        assert!(!Xml::looks_like_resource("/about"));
8378        assert!(Xml::looks_like_resource("/a.PNG"), "case-insensitive");
8379        assert!(Xml::looks_like_resource("x.pdf"));
8380        // A query string defeats the extension check (documented consequence of
8381        // matching on `ends_with`).
8382        assert!(!Xml::looks_like_resource("x.png?v=1"));
8383        assert!(!Xml::looks_like_resource(&"a".repeat(LONG)));
8384    }
8385
8386    #[test]
8387    fn guess_kind_from_url_covers_every_bucket() {
8388        use ExternalResourceKind::*;
8389        assert_eq!(Xml::guess_kind_from_url(""), Unknown);
8390        assert_eq!(Xml::guess_kind_from_url("a.PNG"), Image);
8391        assert_eq!(Xml::guess_kind_from_url("a.woff2"), Font);
8392        assert_eq!(Xml::guess_kind_from_url("a.css"), Stylesheet);
8393        assert_eq!(Xml::guess_kind_from_url("a.mjs"), Script);
8394        assert_eq!(Xml::guess_kind_from_url("a.webm"), Video);
8395        assert_eq!(Xml::guess_kind_from_url("a.flac"), Audio);
8396        assert_eq!(Xml::guess_kind_from_url("a.ico"), Icon);
8397        // Query strings ARE stripped here (unlike looks_like_resource).
8398        assert_eq!(Xml::guess_kind_from_url("a.png?v=1"), Image);
8399        assert_eq!(Xml::guess_kind_from_url("\u{1F600}"), Unknown);
8400    }
8401
8402    #[test]
8403    fn guess_mime_from_url_empty_and_garbage() {
8404        assert_eq!(Xml::guess_mime_from_url("", ""), None);
8405        assert_eq!(Xml::guess_mime_from_url("   ", ""), None);
8406        assert_eq!(Xml::guess_mime_from_url("\u{0}\u{7f}", ""), None);
8407        assert_eq!(Xml::guess_mime_from_url("\u{1F600}", ""), None);
8408    }
8409
8410    #[test]
8411    fn guess_mime_from_url_valid_minimal_and_category_fallback() {
8412        let m = Xml::guess_mime_from_url("a.PNG", "").expect("png is a known extension");
8413        assert_eq!(m.inner.as_str(), "image/png");
8414        let m = Xml::guess_mime_from_url("a.png?v=1", "").expect("query string stripped");
8415        assert_eq!(m.inner.as_str(), "image/png");
8416        // Unknown extension + a category hint => the category wildcard.
8417        let m = Xml::guess_mime_from_url("/no-ext", "image").expect("category fallback");
8418        assert_eq!(m.inner.as_str(), "image/*");
8419        // Unknown category => None.
8420        assert_eq!(Xml::guess_mime_from_url("/no-ext", "bogus"), None);
8421    }
8422
8423    #[test]
8424    fn guess_mime_from_url_boundary_numbers_and_long() {
8425        assert_eq!(Xml::guess_mime_from_url("0", ""), None);
8426        assert_eq!(Xml::guess_mime_from_url("-0", ""), None);
8427        assert_eq!(Xml::guess_mime_from_url("NaN", ""), None);
8428        assert_eq!(Xml::guess_mime_from_url("inf", ""), None);
8429        let long = format!("{}.png", "a".repeat(LONG));
8430        assert_eq!(
8431            Xml::guess_mime_from_url(&long, "").map(|m| m.inner.as_str().to_string()),
8432            Some("image/png".to_string())
8433        );
8434    }
8435
8436    // ================================================================
8437    // Xml::extract_css_urls / scan_node / scan_external_resources
8438    // ================================================================
8439
8440    #[test]
8441    fn extract_css_urls_empty_and_garbage_no_panic() {
8442        let mut v = Vec::new();
8443        Xml::extract_css_urls("", &mut v);
8444        Xml::extract_css_urls("   ", &mut v);
8445        Xml::extract_css_urls("\u{0}\u{7f}\u{1F600}", &mut v);
8446        Xml::extract_css_urls("url(", &mut v);
8447        Xml::extract_css_urls("@import", &mut v);
8448        Xml::extract_css_urls("@import url(", &mut v);
8449        assert!(v.is_empty(), "no well-formed url in any of those inputs");
8450    }
8451
8452    #[test]
8453    fn extract_css_urls_valid_minimal() {
8454        let mut v = Vec::new();
8455        Xml::extract_css_urls("a { background: url('x.png'); }", &mut v);
8456        assert_eq!(v.len(), 1);
8457        assert_eq!(v[0].url.as_str(), "x.png");
8458        assert_eq!(v[0].kind, ExternalResourceKind::Image);
8459        assert_eq!(v[0].source_attribute.as_str(), "url()");
8460    }
8461
8462    #[test]
8463    fn extract_css_urls_import_is_tagged_as_stylesheet() {
8464        let mut v = Vec::new();
8465        Xml::extract_css_urls("@import url(theme.css);", &mut v);
8466        assert_eq!(v.len(), 1);
8467        assert_eq!(v[0].url.as_str(), "theme.css");
8468        assert_eq!(v[0].kind, ExternalResourceKind::Stylesheet);
8469        assert_eq!(v[0].source_attribute.as_str(), "@import");
8470    }
8471
8472    #[test]
8473    fn extract_css_urls_multibyte_before_url_no_panic() {
8474        // ASCII-only lowercasing keeps byte offsets 1:1 with the original.
8475        let mut v = Vec::new();
8476        Xml::extract_css_urls("\u{130}\u{1F600} URL(\"a.css\") \u{0301}", &mut v);
8477        assert_eq!(v.len(), 1);
8478        assert_eq!(v[0].url.as_str(), "a.css");
8479    }
8480
8481    #[test]
8482    fn extract_css_urls_extremely_long_terminates() {
8483        // Each iteration advances search_from past the "url(" it just matched, so
8484        // this must terminate (and not spin).
8485        let mut v = Vec::new();
8486        Xml::extract_css_urls(&"url(".repeat(2_000), &mut v);
8487        // No ')' anywhere => nothing extractable, but the scan still terminates.
8488        assert!(v.is_empty());
8489
8490        let mut v2 = Vec::new();
8491        Xml::extract_css_urls(&"url(a.png)".repeat(2_000), &mut v2);
8492        assert_eq!(v2.len(), 2_000);
8493    }
8494
8495    #[test]
8496    fn scan_node_on_empty_and_extreme_nodes_no_panic() {
8497        let mut v = Vec::new();
8498        Xml::scan_node(&XmlNode::default(), &mut v);
8499        Xml::scan_node(&node("", &[], vec![]), &mut v);
8500        Xml::scan_node(&node(&"a".repeat(10_000), &[("style", "url(x.png)")], vec![]), &mut v);
8501        assert_eq!(v.len(), 1, "only the inline style url()");
8502        assert_eq!(v[0].url.as_str(), "x.png");
8503    }
8504
8505    #[test]
8506    fn scan_node_img_srcset_and_background() {
8507        let mut v = Vec::new();
8508        Xml::scan_node(
8509            &node(
8510                "IMG",
8511                &[("src", "a.png"), ("srcset", "b.png 1x, c.png 2x"), ("background", "d.gif")],
8512                vec![],
8513            ),
8514            &mut v,
8515        );
8516        let urls: Vec<&str> = v.iter().map(|r| r.url.as_str()).collect();
8517        assert_eq!(urls, vec!["a.png", "b.png", "c.png", "d.gif"]);
8518        assert!(v.iter().all(|r| r.kind == ExternalResourceKind::Image));
8519    }
8520
8521    #[test]
8522    fn scan_external_resources_on_empty_document() {
8523        let xml = Xml {
8524            root: Vec::new().into(),
8525        };
8526        assert_eq!(xml.scan_external_resources().as_ref().len(), 0);
8527    }
8528
8529    #[test]
8530    fn scan_external_resources_finds_every_element_kind() {
8531        let xml = Xml {
8532            root: vec![
8533                elem(node("img", &[("src", "i.png")], vec![])),
8534                elem(node("link", &[("href", "s.css"), ("rel", "stylesheet")], vec![])),
8535                elem(node("script", &[("src", "s.js")], vec![])),
8536                elem(node("video", &[("src", "v.mp4"), ("poster", "p.jpg")], vec![])),
8537                elem(node("audio", &[("src", "a.mp3")], vec![])),
8538                elem(node("a", &[("href", "f.pdf")], vec![])),
8539                elem(node("a", &[("href", "/page")], vec![])),
8540            ]
8541            .into(),
8542        };
8543        let res = xml.scan_external_resources();
8544        let mut urls: Vec<&str> = res.as_ref().iter().map(|r| r.url.as_str()).collect();
8545        urls.sort_unstable();
8546        assert_eq!(
8547            urls,
8548            vec!["a.mp3", "f.pdf", "i.png", "p.jpg", "s.css", "s.js", "v.mp4"],
8549            "`/page` is not a resource and must be skipped"
8550        );
8551    }
8552
8553    // ================================================================
8554    // MimeTypeHint  (constructor)
8555    // ================================================================
8556
8557    #[test]
8558    fn mime_type_hint_new_no_panic_and_fields_match_args() {
8559        for s in ["", "   ", "text/css", "\u{1F600}", "\u{0}"] {
8560            assert_eq!(MimeTypeHint::new(s).inner.as_str(), s, "new() stores verbatim");
8561        }
8562        let long = "x".repeat(LONG);
8563        assert_eq!(MimeTypeHint::new(&long).inner.as_str().len(), LONG);
8564    }
8565
8566    #[test]
8567    fn mime_type_hint_from_extension_edges() {
8568        assert_eq!(
8569            MimeTypeHint::from_extension("").inner.as_str(),
8570            "application/octet-stream"
8571        );
8572        assert_eq!(
8573            MimeTypeHint::from_extension("PnG").inner.as_str(),
8574            "image/png",
8575            "extension match is case-insensitive"
8576        );
8577        assert_eq!(MimeTypeHint::from_extension("jpeg").inner.as_str(), "image/jpeg");
8578        assert_eq!(MimeTypeHint::from_extension("woff2").inner.as_str(), "font/woff2");
8579        assert_eq!(
8580            MimeTypeHint::from_extension("\u{1F600}").inner.as_str(),
8581            "application/octet-stream"
8582        );
8583        assert_eq!(
8584            MimeTypeHint::from_extension(&"z".repeat(LONG)).inner.as_str(),
8585            "application/octet-stream"
8586        );
8587    }
8588
8589    // ================================================================
8590    // ComponentId  (constructor / getter)
8591    // ================================================================
8592
8593    #[test]
8594    fn component_id_builtin_and_new_invariants() {
8595        let b = ComponentId::builtin("div");
8596        assert_eq!(b.collection.as_str(), "builtin");
8597        assert_eq!(b.name.as_str(), "div");
8598
8599        let c = ComponentId::new("", "");
8600        assert_eq!(c.collection.as_str(), "");
8601        assert_eq!(c.name.as_str(), "");
8602
8603        let u = ComponentId::new("\u{1F600}", "\u{130}");
8604        assert_eq!(u.collection.as_str(), "\u{1F600}");
8605        assert_eq!(u.name.as_str(), "\u{130}");
8606    }
8607
8608    #[test]
8609    fn component_id_qualified_name_roundtrips_through_the_map_lookup() {
8610        assert_eq!(ComponentId::builtin("div").qualified_name(), "builtin:div");
8611        assert_eq!(ComponentId::new("", "").qualified_name(), ":");
8612        // A name that itself contains ':' makes the qualified name ambiguous —
8613        // pin the (lossy) behavior so a change is noticed.
8614        assert_eq!(ComponentId::new("a", "b:c").qualified_name(), "a:b:c");
8615    }
8616
8617    // ================================================================
8618    // ComponentFieldTypeBox / ComponentFieldValueBox  (constructor / getter)
8619    // ================================================================
8620
8621    #[test]
8622    fn component_field_type_box_new_as_ref_and_clone() {
8623        let b = ComponentFieldTypeBox::new(ComponentFieldType::Bool);
8624        assert!(!b.ptr.is_null());
8625        assert_eq!(*b.as_ref(), ComponentFieldType::Bool);
8626
8627        let c = b.clone();
8628        assert_eq!(*c.as_ref(), ComponentFieldType::Bool);
8629        assert_ne!(b.ptr, c.ptr, "clone must deep-copy, not alias");
8630        assert_eq!(b, c, "PartialEq compares pointees");
8631        drop(c);
8632        assert_eq!(*b.as_ref(), ComponentFieldType::Bool, "original survives");
8633    }
8634
8635    #[test]
8636    fn component_field_type_box_nested_deeply_drops_cleanly() {
8637        let mut t = ComponentFieldType::Bool;
8638        for _ in 0..64 {
8639            t = ComponentFieldType::OptionType(ComponentFieldTypeBox::new(t));
8640        }
8641        assert_eq!(t.format(), format!("{}Bool{}", "Option<".repeat(64), ">".repeat(64)));
8642        drop(t);
8643    }
8644
8645    #[test]
8646    fn component_field_value_box_new_as_ref_and_clone() {
8647        let v = ComponentFieldValueBox::new(ComponentFieldValue::I32(i32::MIN));
8648        assert!(!v.ptr.is_null());
8649        assert_eq!(*v.as_ref(), ComponentFieldValue::I32(i32::MIN));
8650        let c = v.clone();
8651        assert_ne!(v.ptr, c.ptr);
8652        assert_eq!(v, c);
8653    }
8654
8655    // ================================================================
8656    // ComponentFieldType::parse / parse_depth / format  (round-trip)
8657    // ================================================================
8658
8659    #[test]
8660    fn component_field_type_parse_empty_whitespace_garbage() {
8661        assert_eq!(ComponentFieldType::parse(""), None);
8662        assert_eq!(ComponentFieldType::parse("   "), None);
8663        assert_eq!(ComponentFieldType::parse("\t\n"), None);
8664        assert_eq!(ComponentFieldType::parse("lowercase"), None);
8665        assert_eq!(ComponentFieldType::parse("\u{0}\u{7f}"), None);
8666        assert_eq!(ComponentFieldType::parse("Option<>"), None, "empty inner rejected");
8667        assert_eq!(ComponentFieldType::parse("Vec<>"), None);
8668        assert_eq!(ComponentFieldType::parse("Option<lowercase>"), None);
8669    }
8670
8671    #[test]
8672    fn component_field_type_parse_valid_minimal_and_trimming() {
8673        assert_eq!(ComponentFieldType::parse("String"), Some(ComponentFieldType::String));
8674        assert_eq!(
8675            ComponentFieldType::parse("  String  "),
8676            Some(ComponentFieldType::String),
8677            "leading/trailing whitespace is trimmed"
8678        );
8679        assert_eq!(ComponentFieldType::parse("bool"), Some(ComponentFieldType::Bool));
8680        assert_eq!(ComponentFieldType::parse("usize"), Some(ComponentFieldType::Usize));
8681        assert_eq!(
8682            ComponentFieldType::parse("StructRef(Foo)"),
8683            Some(ComponentFieldType::StructRef(AzString::from("Foo")))
8684        );
8685        assert_eq!(
8686            ComponentFieldType::parse("EnumRef(Foo)"),
8687            Some(ComponentFieldType::EnumRef(AzString::from("Foo")))
8688        );
8689        assert_eq!(
8690            ComponentFieldType::parse("RefAny"),
8691            Some(ComponentFieldType::RefAny(AzString::from("")))
8692        );
8693    }
8694
8695    #[test]
8696    fn component_field_type_parse_leading_trailing_junk_is_rejected_or_absorbed() {
8697        // Trailing junk after a known keyword falls through to the
8698        // "starts uppercase => StructRef" catch-all rather than being rejected.
8699        assert_eq!(
8700            ComponentFieldType::parse("String;garbage"),
8701            Some(ComponentFieldType::StructRef(AzString::from("String;garbage")))
8702        );
8703        // Lowercase junk has no uppercase first char => rejected.
8704        assert_eq!(ComponentFieldType::parse("string;garbage"), None);
8705    }
8706
8707    #[test]
8708    fn component_field_type_parse_boundary_numbers() {
8709        for s in ["0", "-0", "9223372036854775807", "-9223372036854775808", "1e400", "inf"] {
8710            assert_eq!(
8711                ComponentFieldType::parse(s),
8712                None,
8713                "numeric literal {s:?} is not a type name"
8714            );
8715        }
8716        // ...but anything starting with an uppercase letter hits the StructRef
8717        // catch-all, so "NaN" parses as a struct reference rather than failing.
8718        assert_eq!(
8719            ComponentFieldType::parse("NaN"),
8720            Some(ComponentFieldType::StructRef(AzString::from("NaN")))
8721        );
8722    }
8723
8724    #[test]
8725    fn component_field_type_parse_unicode_no_panic() {
8726        assert_eq!(ComponentFieldType::parse("\u{1F600}"), None, "emoji is not uppercase");
8727        // A real uppercase non-ASCII letter hits the StructRef catch-all.
8728        assert_eq!(
8729            ComponentFieldType::parse("\u{0391}bc"),
8730            Some(ComponentFieldType::StructRef(AzString::from("\u{0391}bc")))
8731        );
8732    }
8733
8734    #[test]
8735    fn component_field_type_parse_depth_boundary_is_exact() {
8736        // MAX_TYPE_PARSE_DEPTH wrappers parse; one more is rejected.
8737        let ok = format!(
8738            "{}Bool{}",
8739            "Option<".repeat(MAX_TYPE_PARSE_DEPTH),
8740            ">".repeat(MAX_TYPE_PARSE_DEPTH)
8741        );
8742        assert!(
8743            ComponentFieldType::parse(&ok).is_some(),
8744            "exactly MAX_TYPE_PARSE_DEPTH wrappers must still parse"
8745        );
8746
8747        let too_deep = format!(
8748            "{}Bool{}",
8749            "Option<".repeat(MAX_TYPE_PARSE_DEPTH + 1),
8750            ">".repeat(MAX_TYPE_PARSE_DEPTH + 1)
8751        );
8752        assert_eq!(
8753            ComponentFieldType::parse(&too_deep),
8754            None,
8755            "one wrapper past the cap must be rejected, not overflow"
8756        );
8757    }
8758
8759    #[test]
8760    fn component_field_type_parse_depth_direct_call_honors_start_depth() {
8761        assert_eq!(
8762            ComponentFieldType::parse_depth("Bool", MAX_TYPE_PARSE_DEPTH),
8763            Some(ComponentFieldType::Bool),
8764            "depth == cap is still allowed"
8765        );
8766        assert_eq!(
8767            ComponentFieldType::parse_depth("Bool", MAX_TYPE_PARSE_DEPTH + 1),
8768            None
8769        );
8770        assert_eq!(
8771            ComponentFieldType::parse_depth("Bool", usize::MAX),
8772            None,
8773            "usize::MAX start depth must not overflow, just refuse"
8774        );
8775    }
8776
8777    #[test]
8778    fn component_field_type_parse_nested_recursion_does_not_stack_overflow() {
8779        let bomb = format!("{}Bool{}", "Vec<".repeat(50_000), ">".repeat(50_000));
8780        assert_eq!(ComponentFieldType::parse(&bomb), None);
8781    }
8782
8783    #[test]
8784    fn component_field_type_parse_extremely_long_terminates() {
8785        // A single 200k-char uppercase token becomes a StructRef of that name.
8786        let long = format!("A{}", "b".repeat(LONG));
8787        assert_eq!(
8788            ComponentFieldType::parse(&long),
8789            Some(ComponentFieldType::StructRef(AzString::from(long.as_str())))
8790        );
8791    }
8792
8793    #[test]
8794    fn component_field_type_round_trip_representative() {
8795        let representative = vec![
8796            ComponentFieldType::String,
8797            ComponentFieldType::Bool,
8798            ComponentFieldType::I32,
8799            ComponentFieldType::I64,
8800            ComponentFieldType::U32,
8801            ComponentFieldType::U64,
8802            ComponentFieldType::Usize,
8803            ComponentFieldType::F32,
8804            ComponentFieldType::F64,
8805            ComponentFieldType::ColorU,
8806            ComponentFieldType::CssProperty,
8807            ComponentFieldType::ImageRef,
8808            ComponentFieldType::FontRef,
8809            ComponentFieldType::StyledDom,
8810            ComponentFieldType::StructRef(AzString::from("Foo")),
8811            ComponentFieldType::OptionType(ComponentFieldTypeBox::new(ComponentFieldType::Bool)),
8812            ComponentFieldType::VecType(ComponentFieldTypeBox::new(ComponentFieldType::I32)),
8813            ComponentFieldType::RefAny(AzString::from("")),
8814            ComponentFieldType::RefAny(AzString::from("Hint")),
8815            ComponentFieldType::Callback(ComponentCallbackSignature {
8816                return_type: AzString::from("Update"),
8817                args: Vec::new().into(),
8818            }),
8819        ];
8820        for x in representative {
8821            let s = x.format();
8822            assert_eq!(
8823                ComponentFieldType::parse(&s),
8824                Some(x.clone()),
8825                "parse(format({x:?})) must round-trip"
8826            );
8827        }
8828    }
8829
8830    #[test]
8831    fn component_field_type_round_trip_edge_values() {
8832        // Empty / unicode-bearing payloads.
8833        for x in [
8834            ComponentFieldType::StructRef(AzString::from("\u{0391}\u{1F600}")),
8835            ComponentFieldType::OptionType(ComponentFieldTypeBox::new(
8836                ComponentFieldType::VecType(ComponentFieldTypeBox::new(
8837                    ComponentFieldType::StructRef(AzString::from("Foo")),
8838                )),
8839            )),
8840        ] {
8841            assert_eq!(ComponentFieldType::parse(&x.format()), Some(x.clone()));
8842        }
8843    }
8844
8845    #[test]
8846    fn component_field_type_format_is_an_idempotent_normalization() {
8847        // `EnumRef` and `StructRef` share the same canonical spelling, so parse()
8848        // collapses EnumRef -> StructRef. The normalization is still STABLE:
8849        // format(parse(format(x))) == format(x).
8850        let e = ComponentFieldType::EnumRef(AzString::from("Role"));
8851        let once = e.format();
8852        assert_eq!(once, "Role");
8853        let reparsed = ComponentFieldType::parse(&once).expect("parses");
8854        assert_eq!(
8855            reparsed,
8856            ComponentFieldType::StructRef(AzString::from("Role")),
8857            "EnumRef is lossy through format() — it comes back as StructRef"
8858        );
8859        assert_eq!(reparsed.format(), once, "but the normalization is idempotent");
8860    }
8861
8862    #[test]
8863    fn component_field_type_display_matches_format() {
8864        let t = ComponentFieldType::OptionType(ComponentFieldTypeBox::new(ComponentFieldType::F64));
8865        assert_eq!(format!("{t}"), t.format());
8866        assert_eq!(format!("{t}"), "Option<F64>");
8867    }
8868
8869    #[test]
8870    fn component_field_type_format_no_panic_on_empty_payloads() {
8871        let t = ComponentFieldType::Callback(ComponentCallbackSignature {
8872            return_type: AzString::from(""),
8873            args: Vec::new().into(),
8874        });
8875        assert_eq!(t.format(), "Callback()");
8876        // Callback() with an empty signature round-trips.
8877        assert_eq!(ComponentFieldType::parse("Callback()"), Some(t));
8878    }
8879
8880    // ================================================================
8881    // ComponentFieldNamedValueVec::get_field / get_string  (parser-ish lookup)
8882    // ================================================================
8883
8884    fn named(name: &str, v: ComponentFieldValue) -> ComponentFieldNamedValue {
8885        ComponentFieldNamedValue {
8886            name: AzString::from(name),
8887            value: v,
8888        }
8889    }
8890
8891    fn named_vec() -> ComponentFieldNamedValueVec {
8892        vec![
8893            named("a", ComponentFieldValue::String(AzString::from("x"))),
8894            named("b", ComponentFieldValue::Bool(true)),
8895            named("", ComponentFieldValue::U64(u64::MAX)),
8896            named("\u{1F600}", ComponentFieldValue::String(AzString::from("emoji"))),
8897        ]
8898        .into()
8899    }
8900
8901    #[test]
8902    fn named_value_vec_get_field_valid_minimal() {
8903        let v = named_vec();
8904        assert_eq!(
8905            v.get_field("a"),
8906            Some(&ComponentFieldValue::String(AzString::from("x")))
8907        );
8908        assert_eq!(v.get_field("b"), Some(&ComponentFieldValue::Bool(true)));
8909    }
8910
8911    #[test]
8912    fn named_value_vec_get_field_empty_whitespace_garbage_unicode() {
8913        let v = named_vec();
8914        // An empty NAME is a legal key here — it matches the field literally named "".
8915        assert_eq!(v.get_field(""), Some(&ComponentFieldValue::U64(u64::MAX)));
8916        assert_eq!(v.get_field("   "), None);
8917        assert_eq!(v.get_field("\t\n"), None);
8918        assert_eq!(v.get_field("\u{0}\u{7f}"), None);
8919        assert!(v.get_field("\u{1F600}").is_some());
8920        assert_eq!(v.get_field(" a "), None, "no trimming: lookup is exact");
8921        assert_eq!(v.get_field("a;garbage"), None);
8922    }
8923
8924    #[test]
8925    fn named_value_vec_get_field_on_empty_vec_and_long_key() {
8926        let empty = ComponentFieldNamedValueVec::from_const_slice(&[]);
8927        assert_eq!(empty.get_field("a"), None);
8928        assert_eq!(empty.get_string("a"), None);
8929        assert_eq!(named_vec().get_field(&"z".repeat(LONG)), None);
8930    }
8931
8932    #[test]
8933    fn named_value_vec_get_string_only_matches_string_variant() {
8934        let v = named_vec();
8935        assert_eq!(v.get_string("a").map(AzString::as_str), Some("x"));
8936        assert_eq!(v.get_string("b"), None, "Bool is not a String");
8937        assert_eq!(v.get_string(""), None, "U64 is not a String");
8938        assert_eq!(v.get_string("missing"), None);
8939    }
8940
8941    #[test]
8942    fn named_value_vec_boundary_numeric_keys() {
8943        let v: ComponentFieldNamedValueVec = vec![
8944            named("0", ComponentFieldValue::I32(0)),
8945            named("-0", ComponentFieldValue::I32(i32::MIN)),
8946            named("9223372036854775807", ComponentFieldValue::I64(i64::MAX)),
8947            named("NaN", ComponentFieldValue::F32(f32::NAN)),
8948        ]
8949        .into();
8950        assert_eq!(v.get_field("0"), Some(&ComponentFieldValue::I32(0)));
8951        assert_eq!(v.get_field("-0"), Some(&ComponentFieldValue::I32(i32::MIN)));
8952        assert_eq!(
8953            v.get_field("9223372036854775807"),
8954            Some(&ComponentFieldValue::I64(i64::MAX))
8955        );
8956        // NaN != NaN, so only check the variant, not equality.
8957        assert!(matches!(v.get_field("NaN"), Some(ComponentFieldValue::F32(f)) if f.is_nan()));
8958    }
8959
8960    // ================================================================
8961    // ComponentDataModel::get_field / get_default_string / with_default
8962    // ================================================================
8963
8964    fn model_with_text() -> ComponentDataModel {
8965        dm(
8966            "M",
8967            vec![
8968                data_field(
8969                    "text",
8970                    ComponentFieldType::String,
8971                    Some(ComponentDefaultValue::String(AzString::from("hi"))),
8972                    "",
8973                ),
8974                data_field("count", ComponentFieldType::U32, Some(ComponentDefaultValue::U32(3)), ""),
8975                data_field("required_one", ComponentFieldType::String, None, ""),
8976            ],
8977        )
8978    }
8979
8980    #[test]
8981    fn data_model_get_field_valid_minimal_and_missing() {
8982        let m = model_with_text();
8983        assert!(m.get_field("text").is_some());
8984        assert!(m.get_field("count").is_some());
8985        assert!(m.get_field("missing").is_none());
8986        assert!(m.get_field("").is_none());
8987        assert!(m.get_field("   ").is_none());
8988        assert!(m.get_field(" text ").is_none(), "exact match, no trimming");
8989        assert!(m.get_field("\u{1F600}").is_none());
8990        assert!(m.get_field(&"z".repeat(LONG)).is_none());
8991    }
8992
8993    #[test]
8994    fn data_model_get_field_on_empty_model() {
8995        let m = dm("Empty", Vec::new());
8996        assert!(m.get_field("anything").is_none());
8997        assert!(m.get_default_string("anything").is_none());
8998    }
8999
9000    #[test]
9001    fn data_model_get_default_string_only_for_string_defaults() {
9002        let m = model_with_text();
9003        assert_eq!(m.get_default_string("text").map(AzString::as_str), Some("hi"));
9004        assert_eq!(m.get_default_string("count"), None, "U32 default is not a String");
9005        assert_eq!(m.get_default_string("required_one"), None, "no default at all");
9006        assert_eq!(m.get_default_string("missing"), None);
9007    }
9008
9009    #[test]
9010    fn data_model_required_flag_follows_default_presence() {
9011        let m = model_with_text();
9012        assert!(!m.get_field("text").unwrap().required);
9013        assert!(
9014            m.get_field("required_one").unwrap().required,
9015            "a field with no default must be marked required"
9016        );
9017    }
9018
9019    #[test]
9020    fn data_model_with_default_overrides_and_preserves_len() {
9021        let m = model_with_text();
9022        let before = m.fields.as_ref().len();
9023        let m = m.with_default("text", ComponentDefaultValue::String(AzString::from("bye")));
9024        assert_eq!(m.fields.as_ref().len(), before, "len is preserved");
9025        assert_eq!(m.get_default_string("text").map(AzString::as_str), Some("bye"));
9026    }
9027
9028    #[test]
9029    fn data_model_with_default_on_missing_field_is_a_no_op() {
9030        let m = model_with_text();
9031        let m = m.with_default("nope", ComponentDefaultValue::Bool(true));
9032        assert_eq!(m.fields.as_ref().len(), 3);
9033        assert_eq!(m.get_default_string("text").map(AzString::as_str), Some("hi"));
9034        assert!(m.get_field("nope").is_none(), "no field is inserted");
9035    }
9036
9037    #[test]
9038    fn data_model_with_default_extreme_names_and_values_no_panic() {
9039        let m = model_with_text()
9040            .with_default("", ComponentDefaultValue::None)
9041            .with_default(&"z".repeat(10_000), ComponentDefaultValue::F64(f64::NAN))
9042            .with_default("count", ComponentDefaultValue::Usize(usize::MAX))
9043            .with_default("text", ComponentDefaultValue::I64(i64::MIN));
9044        assert_eq!(m.fields.as_ref().len(), 3);
9045        assert!(matches!(
9046            m.get_field("count").unwrap().default_value,
9047            OptionComponentDefaultValue::Some(ComponentDefaultValue::Usize(usize::MAX))
9048        ));
9049        assert_eq!(
9050            m.get_default_string("text"),
9051            None,
9052            "text is now an I64 default, no longer a String"
9053        );
9054    }
9055
9056    #[test]
9057    fn data_model_with_default_fills_only_the_first_match() {
9058        // Duplicate field names: `with_default` breaks after the first hit.
9059        let m = dm(
9060            "Dup",
9061            vec![
9062                data_field("x", ComponentFieldType::String, Some(ComponentDefaultValue::String(AzString::from("1"))), ""),
9063                data_field("x", ComponentFieldType::String, Some(ComponentDefaultValue::String(AzString::from("2"))), ""),
9064            ],
9065        )
9066        .with_default("x", ComponentDefaultValue::String(AzString::from("3")));
9067        let vals: Vec<&str> = m
9068            .fields
9069            .as_ref()
9070            .iter()
9071            .filter_map(|f| match &f.default_value {
9072                OptionComponentDefaultValue::Some(ComponentDefaultValue::String(s)) => Some(s.as_str()),
9073                _ => None,
9074            })
9075            .collect();
9076        assert_eq!(vals, vec!["3", "2"], "only the first duplicate is overridden");
9077    }
9078
9079    // ================================================================
9080    // ComponentSource / ComponentMap  (constructor / getter / lookup)
9081    // ================================================================
9082
9083    #[test]
9084    fn component_source_create_is_user_defined() {
9085        assert_eq!(ComponentSource::create(), ComponentSource::UserDefined);
9086        assert_eq!(ComponentSource::default(), ComponentSource::UserDefined);
9087    }
9088
9089    #[test]
9090    fn component_map_create_is_empty_and_all_lookups_return_none() {
9091        let m = ComponentMap::create();
9092        assert_eq!(m.libraries.as_ref().len(), 0);
9093        assert!(m.get("builtin", "div").is_none());
9094        assert!(m.get_unqualified("div").is_none());
9095        assert!(m.get_by_qualified_name("builtin:div").is_none());
9096        assert!(m.all_components().is_empty());
9097        assert!(m.get_exportable_libraries().is_empty());
9098    }
9099
9100    #[test]
9101    fn component_map_with_builtin_invariants() {
9102        let m = ComponentMap::with_builtin();
9103        assert_eq!(m.libraries.as_ref().len(), 1);
9104        let lib = &m.libraries.as_ref()[0];
9105        assert_eq!(lib.name.as_str(), "builtin");
9106        assert!(!lib.exportable, "builtins must never be exportable");
9107        assert!(!lib.modifiable, "builtins must never be modifiable");
9108        assert_eq!(
9109            m.all_components().len(),
9110            lib.components.as_ref().len(),
9111            "all_components must see every registered component"
9112        );
9113        assert!(
9114            m.get_exportable_libraries().is_empty(),
9115            "the builtin library is not exportable"
9116        );
9117    }
9118
9119    #[test]
9120    fn component_map_get_valid_minimal() {
9121        let m = ComponentMap::with_builtin();
9122        assert!(m.get("builtin", "div").is_some());
9123        assert!(m.get("builtin", "if").is_some());
9124        assert!(m.get("builtin", "for").is_some());
9125        assert!(m.get("builtin", "map").is_some());
9126        assert_eq!(
9127            m.get("builtin", "div").unwrap().id.qualified_name(),
9128            "builtin:div"
9129        );
9130    }
9131
9132    #[test]
9133    fn component_map_get_empty_whitespace_garbage_unicode() {
9134        let m = ComponentMap::with_builtin();
9135        assert!(m.get("", "").is_none());
9136        assert!(m.get("builtin", "").is_none());
9137        assert!(m.get("", "div").is_none());
9138        assert!(m.get("builtin", "   ").is_none());
9139        assert!(m.get("builtin", " div ").is_none(), "no trimming");
9140        assert!(m.get("builtin", "DIV").is_none(), "lookup is case-sensitive");
9141        assert!(m.get("builtin", "\u{1F600}").is_none());
9142        assert!(m.get("builtin", "\u{0}\u{7f}").is_none());
9143        assert!(m.get("builtin", "div;garbage").is_none());
9144    }
9145
9146    #[test]
9147    fn component_map_get_extremely_long_name_terminates() {
9148        let m = ComponentMap::with_builtin();
9149        assert!(m.get(&"a".repeat(LONG), &"b".repeat(LONG)).is_none());
9150        assert!(m.get_unqualified(&"b".repeat(LONG)).is_none());
9151        assert!(m.get_by_qualified_name(&"c".repeat(LONG)).is_none());
9152    }
9153
9154    #[test]
9155    fn component_map_get_unqualified_only_searches_builtin() {
9156        let lib = ComponentLibrary {
9157            name: AzString::from("mylib"),
9158            version: AzString::from("1.0.0"),
9159            description: AzString::from(""),
9160            components: vec![user_def("", Vec::new())].into(),
9161            exportable: true,
9162            modifiable: true,
9163            data_models: Vec::new().into(),
9164            enum_models: Vec::new().into(),
9165        };
9166        let libs: ComponentLibraryVec = vec![lib].into();
9167        let m = ComponentMap::from_libraries(&libs);
9168
9169        assert!(m.get("mylib", "widget").is_some());
9170        assert!(
9171            m.get_unqualified("widget").is_none(),
9172            "unqualified lookup must NOT reach non-builtin libraries"
9173        );
9174        assert!(m.get_by_qualified_name("mylib:widget").is_some());
9175        assert_eq!(m.get_exportable_libraries().len(), 1);
9176        assert_eq!(m.all_components().len(), 1);
9177    }
9178
9179    #[test]
9180    fn component_map_get_by_qualified_name_boundary_forms() {
9181        let m = ComponentMap::with_builtin();
9182        // No colon => falls back to the builtin library.
9183        assert!(m.get_by_qualified_name("div").is_some());
9184        // Exactly one colon.
9185        assert!(m.get_by_qualified_name("builtin:div").is_some());
9186        // Splits on the FIRST colon, so the remainder (incl. colons) is the name.
9187        assert!(m.get_by_qualified_name("builtin:div:extra").is_none());
9188        assert!(m.get_by_qualified_name(":").is_none());
9189        assert!(m.get_by_qualified_name(":div").is_none());
9190        assert!(m.get_by_qualified_name("builtin:").is_none());
9191        assert!(m.get_by_qualified_name("").is_none());
9192        assert!(m.get_by_qualified_name("   ").is_none());
9193    }
9194
9195    #[test]
9196    fn component_map_from_libraries_clones_without_losing_entries() {
9197        let src = ComponentMap::with_builtin();
9198        let copy = ComponentMap::from_libraries(&src.libraries);
9199        assert_eq!(copy.all_components().len(), src.all_components().len());
9200        assert!(copy.get_unqualified("div").is_some());
9201    }
9202
9203    #[test]
9204    fn register_builtin_components_is_stable_across_calls() {
9205        let a = register_builtin_components();
9206        let b = register_builtin_components();
9207        assert_eq!(a.components.as_ref().len(), b.components.as_ref().len());
9208        assert!(a.components.as_ref().len() > 50);
9209        assert_eq!(a.name.as_str(), "builtin");
9210        assert_eq!(a.version.as_str(), "1.0.0");
9211        // Every component must be namespaced into "builtin".
9212        assert!(a
9213            .components
9214            .as_ref()
9215            .iter()
9216            .all(|c| c.id.collection.as_str() == "builtin"));
9217        assert!(a
9218            .components
9219            .as_ref()
9220            .iter()
9221            .all(|c| c.source == ComponentSource::Builtin));
9222    }
9223
9224    // ================================================================
9225    // XmlNodeChild / XmlNode  (getter / predicate / constructor)
9226    // ================================================================
9227
9228    #[test]
9229    fn xml_node_child_as_text_and_as_element_are_mutually_exclusive() {
9230        let t = txt("hello");
9231        assert_eq!(t.as_text(), Some("hello"));
9232        assert!(t.as_element().is_none());
9233
9234        let e = elem(node("div", &[], vec![]));
9235        assert!(e.as_text().is_none());
9236        assert_eq!(e.as_element().map(|n| n.node_type.as_str()), Some("div"));
9237    }
9238
9239    #[test]
9240    fn xml_node_child_as_text_edge_values() {
9241        assert_eq!(txt("").as_text(), Some(""), "an empty text node is still text");
9242        assert_eq!(txt("   ").as_text(), Some("   "), "no trimming in the getter");
9243        assert_eq!(txt("\u{1F600}\u{0301}").as_text(), Some("\u{1F600}\u{0301}"));
9244        assert_eq!(txt("\u{0}").as_text(), Some("\u{0}"));
9245    }
9246
9247    #[test]
9248    fn xml_node_child_as_element_mut_allows_mutation_and_rejects_text() {
9249        let mut e = elem(node("div", &[], vec![]));
9250        e.as_element_mut().expect("is an element").node_type = "span".into();
9251        assert_eq!(e.as_element().map(|n| n.node_type.as_str()), Some("span"));
9252
9253        let mut t = txt("x");
9254        assert!(t.as_element_mut().is_none(), "text nodes have no element");
9255    }
9256
9257    #[test]
9258    fn xml_node_create_and_with_children_invariants() {
9259        let n = XmlNode::create("div");
9260        assert_eq!(n.node_type.as_str(), "div");
9261        assert_eq!(n.children.as_ref().len(), 0);
9262        assert_eq!(n.attributes.as_ref().len(), 0);
9263
9264        let n = n.with_children(vec![txt("a"), elem(XmlNode::create("b"))]);
9265        assert_eq!(n.children.as_ref().len(), 2);
9266        assert_eq!(n.node_type.as_str(), "div", "tag survives with_children");
9267
9268        // with_children REPLACES, it does not append.
9269        let n = n.with_children(Vec::new());
9270        assert_eq!(n.children.as_ref().len(), 0);
9271    }
9272
9273    #[test]
9274    fn xml_node_create_extreme_tag_names_no_panic() {
9275        assert_eq!(XmlNode::create("").node_type.as_str(), "");
9276        assert_eq!(XmlNode::create("\u{1F600}").node_type.as_str(), "\u{1F600}");
9277        assert_eq!(
9278            XmlNode::create(&*"a".repeat(10_000)).node_type.as_str().len(),
9279            10_000
9280        );
9281    }
9282
9283    #[test]
9284    fn xml_node_get_text_content_concatenates_only_direct_text() {
9285        let n = node(
9286            "p",
9287            &[],
9288            vec![
9289                txt("a "),
9290                elem(node("span", &[], vec![txt("IGNORED")])),
9291                txt("b"),
9292            ],
9293        );
9294        assert_eq!(
9295            n.get_text_content(),
9296            "a b",
9297            "only DIRECT text children, nested element text is not included"
9298        );
9299        assert_eq!(XmlNode::default().get_text_content(), "");
9300        assert_eq!(node("p", &[], vec![txt(""), txt("")]).get_text_content(), "");
9301    }
9302
9303    #[test]
9304    fn xml_node_has_only_text_children_true_false_and_empty() {
9305        assert!(
9306            XmlNode::default().has_only_text_children(),
9307            "vacuously true for a childless node — callers must pair this with a text check"
9308        );
9309        assert!(node("p", &[], vec![txt("a"), txt("b")]).has_only_text_children());
9310        assert!(!node("p", &[], vec![txt("a"), elem(XmlNode::create("b"))]).has_only_text_children());
9311        assert!(!node("p", &[], vec![elem(XmlNode::create("b"))]).has_only_text_children());
9312    }
9313
9314    // ================================================================
9315    // get_html_node / get_body_node / find_node_by_type / find_attribute
9316    // ================================================================
9317
9318    #[test]
9319    fn get_html_node_empty_input_is_no_html_node() {
9320        assert_eq!(get_html_node(&[]), Err(DomXmlParseError::NoHtmlNode));
9321        assert_eq!(get_html_node(&[txt("just text")]), Err(DomXmlParseError::NoHtmlNode));
9322        assert_eq!(
9323            get_html_node(&[elem(XmlNode::create("div"))]),
9324            Err(DomXmlParseError::NoHtmlNode)
9325        );
9326    }
9327
9328    #[test]
9329    fn get_html_node_valid_minimal_and_case_insensitive() {
9330        let roots = vec![elem(XmlNode::create("HTML"))];
9331        assert!(get_html_node(&roots).is_ok(), "tag casing is normalized");
9332    }
9333
9334    #[test]
9335    fn get_html_node_rejects_multiple_roots() {
9336        let roots = vec![elem(XmlNode::create("html")), elem(XmlNode::create("html"))];
9337        assert_eq!(
9338            get_html_node(&roots),
9339            Err(DomXmlParseError::MultipleHtmlRootNodes)
9340        );
9341    }
9342
9343    #[test]
9344    fn get_body_node_empty_and_missing() {
9345        assert_eq!(get_body_node(&[]), Err(DomXmlParseError::NoBodyInHtml));
9346        assert_eq!(
9347            get_body_node(&[elem(XmlNode::create("head"))]),
9348            Err(DomXmlParseError::NoBodyInHtml)
9349        );
9350    }
9351
9352    #[test]
9353    fn get_body_node_direct_and_nested() {
9354        let direct = vec![elem(XmlNode::create("body"))];
9355        assert!(get_body_node(&direct).is_ok());
9356
9357        // Malformed markup: <body> buried inside <head>.
9358        let nested = vec![elem(node(
9359            "head",
9360            &[],
9361            vec![elem(node("div", &[], vec![elem(XmlNode::create("BODY"))]))],
9362        ))];
9363        assert!(
9364            get_body_node(&nested).is_ok(),
9365            "the recursive fallback finds a nested body"
9366        );
9367    }
9368
9369    /// Build a `<div>` chain `depth` levels deep with `inner` at the bottom.
9370    fn wrap_divs(depth: usize, inner: XmlNode) -> XmlNode {
9371        let mut n = inner;
9372        for _ in 0..depth {
9373            n = node("div", &[], vec![elem(n)]);
9374        }
9375        n
9376    }
9377
9378    #[test]
9379    fn get_body_node_deep_nesting_is_depth_capped_not_stack_overflowing() {
9380        // Body sits just inside the cap => found.
9381        let ok = vec![elem(wrap_divs(
9382            MAX_XML_NESTING_DEPTH - 2,
9383            XmlNode::create("body"),
9384        ))];
9385        assert!(get_body_node(&ok).is_ok(), "body within the depth cap is found");
9386
9387        // Body far below the cap => reported missing, but MUST NOT overflow.
9388        let too_deep = vec![elem(wrap_divs(2_000, XmlNode::create("body")))];
9389        assert_eq!(
9390            get_body_node(&too_deep),
9391            Err(DomXmlParseError::NoBodyInHtml),
9392            "past MAX_XML_NESTING_DEPTH the search gives up instead of crashing"
9393        );
9394    }
9395
9396    #[test]
9397    fn find_node_by_type_empty_garbage_unicode() {
9398        assert!(find_node_by_type(&[], "div").is_none());
9399        assert!(find_node_by_type(&[txt("x")], "div").is_none());
9400
9401        let roots = vec![elem(XmlNode::create("div"))];
9402        assert!(find_node_by_type(&roots, "").is_none());
9403        assert!(find_node_by_type(&roots, "   ").is_none());
9404        assert!(find_node_by_type(&roots, "\u{1F600}").is_none());
9405        assert!(find_node_by_type(&roots, &"z".repeat(LONG)).is_none());
9406        // Tag matching is ASCII case-insensitive (HTML tags are), so "DIV" matches a
9407        // <div> node. (It used to compare against the normalize_casing'd tag, which was
9408        // case-sensitive on the needle AND mangled uppercase tags to "d_i_v".)
9409        assert!(find_node_by_type(&roots, "DIV").is_some());
9410    }
9411
9412    #[test]
9413    fn find_node_by_type_valid_minimal_and_recursive() {
9414        let roots = vec![elem(node(
9415            "html",
9416            &[],
9417            vec![elem(node("head", &[], vec![elem(XmlNode::create("STYLE"))]))],
9418        ))];
9419        assert!(find_node_by_type(&roots, "html").is_some());
9420        assert!(
9421            find_node_by_type(&roots, "style").is_some(),
9422            "search recurses into the whole tree"
9423        );
9424        assert!(find_node_by_type(&roots, "body").is_none());
9425    }
9426
9427    #[test]
9428    fn find_node_by_type_prefers_the_shallowest_match() {
9429        let roots = vec![
9430            elem(node("div", &[], vec![elem(XmlNode::create("span"))])),
9431            elem(node("span", &[("id", "shallow")], vec![])),
9432        ];
9433        let found = find_node_by_type(&roots, "span").expect("found");
9434        assert_eq!(
9435            found.attributes.get_key("id").map(AzString::as_str),
9436            Some("shallow"),
9437            "direct children are scanned before recursing"
9438        );
9439    }
9440
9441    #[test]
9442    fn find_attribute_valid_minimal_and_missing() {
9443        let n = node("a", &[("href", "x"), ("id", "y")], vec![]);
9444        assert_eq!(find_attribute(&n, "href").map(AzString::as_str), Some("x"));
9445        assert_eq!(find_attribute(&n, "id").map(AzString::as_str), Some("y"));
9446        assert!(find_attribute(&n, "missing").is_none());
9447        assert!(find_attribute(&n, "").is_none());
9448        assert!(find_attribute(&n, "   ").is_none());
9449        assert!(find_attribute(&XmlNode::default(), "href").is_none());
9450        assert!(find_attribute(&n, &"z".repeat(LONG)).is_none());
9451    }
9452
9453    #[test]
9454    fn find_attribute_compares_against_the_normalized_key() {
9455        // `normalize_casing` turns `aria-label` into `aria_label`, so callers must
9456        // pass the NORMALIZED spelling — the raw HTML spelling does not match.
9457        let n = node("button", &[("aria-label", "Save")], vec![]);
9458        assert_eq!(
9459            find_attribute(&n, "aria_label").map(AzString::as_str),
9460            Some("Save")
9461        );
9462        assert!(
9463            find_attribute(&n, "aria-label").is_none(),
9464            "the hyphenated spelling never matches (keys are normalized first)"
9465        );
9466    }
9467
9468    #[test]
9469    fn find_attribute_unicode_keys_no_panic() {
9470        let n = node("div", &[("\u{130}", "v"), ("\u{1F600}", "w")], vec![]);
9471        assert!(find_attribute(&n, "\u{1F600}").is_some(), "emoji keys pass through");
9472        // 'İ' lowercases to 2 chars, so the normalized key is not 'İ'.
9473        assert!(find_attribute(&n, "\u{130}").is_none());
9474    }
9475
9476    // ================================================================
9477    // normalize_casing
9478    // ================================================================
9479
9480    #[test]
9481    fn normalize_casing_documented_forms() {
9482        assert_eq!(normalize_casing("abcDef"), "abc_def");
9483        assert_eq!(normalize_casing("AbcDef"), "abc_def");
9484        assert_eq!(normalize_casing("abc-def"), "abc_def");
9485        assert_eq!(normalize_casing("abc_def"), "abc_def");
9486    }
9487
9488    #[test]
9489    fn normalize_casing_edge_inputs() {
9490        assert_eq!(normalize_casing(""), "");
9491        assert_eq!(normalize_casing("---"), "", "separators alone produce no words");
9492        assert_eq!(normalize_casing("___"), "");
9493        assert_eq!(normalize_casing("A"), "a");
9494        assert_eq!(
9495            normalize_casing("ABC"),
9496            "a_b_c",
9497            "every uppercase char starts a new word"
9498        );
9499        assert_eq!(normalize_casing("h1"), "h1");
9500        assert_eq!(normalize_casing("   "), "   ", "whitespace is not a separator");
9501    }
9502
9503    #[test]
9504    fn normalize_casing_unicode_and_long_no_panic() {
9505        // 'İ' (U+0130) lowercases to TWO chars — the fn must not slice bytes.
9506        assert_eq!(normalize_casing("\u{130}"), "i\u{307}");
9507        assert_eq!(normalize_casing("\u{1F600}"), "\u{1F600}");
9508        assert_eq!(normalize_casing(&"a".repeat(50_000)).len(), 50_000);
9509        // 50k uppercase chars => 50k single-char words joined by '_'.
9510        assert_eq!(normalize_casing(&"A".repeat(50_000)).len(), 50_000 * 2 - 1);
9511    }
9512
9513    // ================================================================
9514    // get_item / get_item_internal
9515    // ================================================================
9516
9517    #[test]
9518    fn get_item_empty_hierarchy_returns_the_root() {
9519        let mut root = node("div", &[("id", "root")], vec![]);
9520        let got = get_item(&[], &mut root).expect("empty hierarchy => root");
9521        assert_eq!(got.attributes.get_key("id").map(AzString::as_str), Some("root"));
9522    }
9523
9524    #[test]
9525    fn get_item_walks_nested_elements() {
9526        let mut root = node(
9527            "a",
9528            &[],
9529            vec![elem(node("b", &[], vec![elem(node("c", &[("id", "deep")], vec![]))]))],
9530        );
9531        let got = get_item(&[0, 0], &mut root).expect("a > b > c");
9532        assert_eq!(got.node_type.as_str(), "c");
9533        assert_eq!(got.attributes.get_key("id").map(AzString::as_str), Some("deep"));
9534    }
9535
9536    #[test]
9537    fn get_item_out_of_bounds_and_text_nodes_return_none() {
9538        let mut root = node("a", &[], vec![txt("hello"), elem(XmlNode::create("b"))]);
9539        assert!(get_item(&[5], &mut root).is_none(), "out of bounds => None");
9540        assert!(
9541            get_item(&[usize::MAX], &mut root).is_none(),
9542            "usize::MAX index must not panic"
9543        );
9544        assert!(
9545            get_item(&[0], &mut root).is_none(),
9546            "index 0 is a TEXT node — not traversable"
9547        );
9548        assert!(get_item(&[1], &mut root).is_some());
9549        assert!(
9550            get_item(&[1, 0], &mut root).is_none(),
9551            "descending past a leaf => None"
9552        );
9553    }
9554
9555    #[test]
9556    fn get_item_deep_hierarchy_terminates() {
9557        // 400 levels: deep, but bounded by the (short) hierarchy vec, not by markup.
9558        let mut root = wrap_divs(400, node("div", &[("id", "bottom")], vec![]));
9559        let path = vec![0usize; 400];
9560        let got = get_item(&path, &mut root).expect("reaches the bottom");
9561        assert_eq!(got.attributes.get_key("id").map(AzString::as_str), Some("bottom"));
9562    }
9563
9564    // ================================================================
9565    // decode_numeric_entity  (parser)
9566    // ================================================================
9567
9568    #[test]
9569    fn decode_numeric_entity_valid_minimal() {
9570        assert_eq!(decode_numeric_entity("#65"), Some('A'));
9571        assert_eq!(decode_numeric_entity("#x41"), Some('A'));
9572        assert_eq!(decode_numeric_entity("#X41"), Some('A'), "uppercase X accepted");
9573        assert_eq!(decode_numeric_entity("#x1F600"), Some('\u{1F600}'));
9574    }
9575
9576    #[test]
9577    fn decode_numeric_entity_empty_whitespace_garbage() {
9578        assert_eq!(decode_numeric_entity(""), None);
9579        assert_eq!(decode_numeric_entity("   "), None);
9580        assert_eq!(decode_numeric_entity("\t\n"), None);
9581        assert_eq!(decode_numeric_entity("amp"), None, "named entity is not numeric");
9582        assert_eq!(decode_numeric_entity("#"), None);
9583        assert_eq!(decode_numeric_entity("#x"), None);
9584        assert_eq!(decode_numeric_entity("#zz"), None);
9585        assert_eq!(decode_numeric_entity("# 65"), None);
9586        assert_eq!(decode_numeric_entity("#65junk"), None);
9587        assert_eq!(decode_numeric_entity("\u{1F600}"), None);
9588    }
9589
9590    #[test]
9591    fn decode_numeric_entity_boundary_code_points() {
9592        assert_eq!(decode_numeric_entity("#0"), Some('\u{0}'), "NUL is a valid char");
9593        assert_eq!(decode_numeric_entity("#x10FFFF"), Some('\u{10FFFF}'), "max scalar");
9594        assert_eq!(
9595            decode_numeric_entity("#x110000"),
9596            None,
9597            "one past the max scalar value"
9598        );
9599        assert_eq!(
9600            decode_numeric_entity("#xD800"),
9601            None,
9602            "a lone surrogate is not a char"
9603        );
9604        assert_eq!(
9605            decode_numeric_entity("#4294967295"),
9606            None,
9607            "u32::MAX is not a scalar value"
9608        );
9609        assert_eq!(
9610            decode_numeric_entity("#4294967296"),
9611            None,
9612            "one past u32::MAX must not wrap — it must fail to parse"
9613        );
9614        assert_eq!(decode_numeric_entity("#-1"), None, "negative is rejected by u32");
9615        assert_eq!(
9616            decode_numeric_entity("#xFFFFFFFFFFFF"),
9617            None,
9618            "hex overflow is rejected, not truncated"
9619        );
9620    }
9621
9622    #[test]
9623    fn decode_numeric_entity_extremely_long_terminates() {
9624        let s = format!("#{}", "9".repeat(LONG));
9625        assert_eq!(s.len(), LONG + 1);
9626        assert_eq!(decode_numeric_entity(&s), None, "overflows u32 => None");
9627    }
9628
9629    // ================================================================
9630    // decode_entities / prepare_string
9631    // ================================================================
9632
9633    #[test]
9634    fn decode_entities_leaves_unrecognized_sequences_verbatim() {
9635        assert_eq!(decode_entities(""), "");
9636        assert_eq!(decode_entities("&"), "&", "a bare '&' at EOF must not panic");
9637        assert_eq!(decode_entities("&;"), "&;", "empty entity body is not decoded");
9638        assert_eq!(decode_entities("&bogus;"), "&bogus;");
9639        assert_eq!(decode_entities("&nbsp;"), "&nbsp;", "&nbsp; is deliberately kept");
9640        // A ';' further away than MAX_ENTITY_BODY is not treated as an entity end.
9641        assert_eq!(
9642            decode_entities("&averyveryverylongbody;"),
9643            "&averyveryverylongbody;"
9644        );
9645    }
9646
9647    #[test]
9648    fn decode_entities_single_pass_prevents_double_decoding() {
9649        assert_eq!(decode_entities("&amp;lt;"), "&lt;", "&amp; must not re-open an entity");
9650        assert_eq!(decode_entities("&lt;&gt;&amp;&quot;&apos;"), "<>&\"'");
9651    }
9652
9653    #[test]
9654    fn decode_entities_unicode_and_long_input_terminate() {
9655        assert_eq!(decode_entities("\u{1F600}\u{0301}\u{130}"), "\u{1F600}\u{0301}\u{130}");
9656        // NOTE: each '&' triggers a `find(';')` over the whole remaining suffix, so
9657        // this is quadratic in the number of '&'. Keep the input modest.
9658        let amps = "&".repeat(20_000);
9659        assert_eq!(decode_entities(&amps).len(), 20_000);
9660    }
9661
9662    #[test]
9663    fn prepare_string_empty_and_whitespace() {
9664        assert_eq!(prepare_string(""), "");
9665        assert_eq!(prepare_string("   "), "");
9666        assert_eq!(prepare_string("\t\n\r  \n"), "");
9667    }
9668
9669    #[test]
9670    fn prepare_string_nbsp_becomes_a_space_that_survives_trim() {
9671        assert_eq!(prepare_string("&nbsp;"), " ");
9672        assert_eq!(prepare_string("a&nbsp;b"), "a b");
9673    }
9674
9675    #[test]
9676    fn prepare_string_collapses_a_blank_line_into_a_single_return() {
9677        assert_eq!(prepare_string("a\n\nb"), "a\nb");
9678        assert_eq!(prepare_string("a\n\n\n\nb"), "a\nb", "runs of blanks collapse");
9679    }
9680
9681    #[test]
9682    fn prepare_string_unicode_and_long_input_no_panic() {
9683        assert_eq!(prepare_string("\u{1F600}"), "\u{1F600}");
9684        assert_eq!(prepare_string("  \u{130}\u{0301}  "), "\u{130}\u{0301}");
9685        assert_eq!(prepare_string(&"x".repeat(LONG)).len(), LONG);
9686    }
9687
9688    /// BUG (reported): a soft-wrapped multi-line text node loses the word break
9689    /// on the FINAL line, because `prepare_string` skips the joining space when
9690    /// `line_idx == line_len - 1`. HTML must collapse the newline into a space.
9691    #[test]
9692    fn prepare_string_joins_wrapped_lines_with_a_space() {
9693        assert_eq!(
9694            prepare_string("Hello\nworld"),
9695            "Hello world",
9696            "a single newline between words must collapse to a space, not vanish"
9697        );
9698        assert_eq!(prepare_string("a\nb\nc"), "a b c");
9699    }
9700
9701    // ================================================================
9702    // parse_bool  (parser)
9703    // ================================================================
9704
9705    #[test]
9706    fn parse_bool_valid_minimal_and_everything_else_is_none() {
9707        assert_eq!(parse_bool("true"), Some(true));
9708        assert_eq!(parse_bool("false"), Some(false));
9709
9710        for s in [
9711            "", "   ", "\t\n", " true", "true ", "TRUE", "True", "FALSE", "1", "0", "-0", "yes",
9712            "no", "NaN", "inf", "9223372036854775807", "\u{1F600}", "true;garbage",
9713        ] {
9714            assert_eq!(parse_bool(s), None, "{s:?} must not parse as a bool");
9715        }
9716        assert_eq!(parse_bool(&"a".repeat(LONG)), None);
9717    }
9718
9719    // ================================================================
9720    // split_dynamic_string / format_args_dynamic
9721    // ================================================================
9722
9723    fn args(kv: &[(&str, &str)]) -> ComponentArgumentVec {
9724        kv.iter()
9725            .map(|(n, t)| ComponentArgument {
9726                name: AzString::from(*n),
9727                arg_type: AzString::from(*t),
9728            })
9729            .collect::<Vec<_>>()
9730            .into()
9731    }
9732
9733    #[test]
9734    fn split_dynamic_string_empty_and_plain() {
9735        assert!(split_dynamic_string("").is_empty());
9736        assert_eq!(
9737            split_dynamic_string("abc"),
9738            vec![DynamicItem::Str("abc".to_string())]
9739        );
9740    }
9741
9742    #[test]
9743    fn split_dynamic_string_format_spec_is_split_on_the_first_colon() {
9744        assert_eq!(
9745            split_dynamic_string("{a:?}"),
9746            vec![DynamicItem::Var {
9747                name: "a".to_string(),
9748                format_spec: Some("?".to_string()),
9749            }]
9750        );
9751        assert_eq!(
9752            split_dynamic_string("{a:x:y}"),
9753            vec![DynamicItem::Var {
9754                name: "a".to_string(),
9755                format_spec: Some("x:y".to_string()),
9756            }],
9757            "only the FIRST colon separates name from spec"
9758        );
9759    }
9760
9761    #[test]
9762    fn split_dynamic_string_unterminated_var_stays_literal() {
9763        // No closing brace => the scan runs to EOF and the text stays a literal.
9764        assert_eq!(
9765            split_dynamic_string("{unterminated"),
9766            vec![DynamicItem::Str("{unterminated".to_string())]
9767        );
9768        // A whitespace inside the braces aborts the variable scan.
9769        assert_eq!(
9770            split_dynamic_string("{a b}"),
9771            vec![DynamicItem::Str("{a b}".to_string())]
9772        );
9773    }
9774
9775    #[test]
9776    fn split_dynamic_string_unicode_and_long_input_terminate() {
9777        assert_eq!(
9778            split_dynamic_string("\u{1F600}{v}\u{130}"),
9779            vec![
9780                DynamicItem::Str("\u{1F600}".to_string()),
9781                DynamicItem::Var {
9782                    name: "v".to_string(),
9783                    format_spec: None
9784                },
9785                DynamicItem::Str("\u{130}".to_string()),
9786            ]
9787        );
9788        // The failed-variable scan advances the cursor by the amount it scanned,
9789        // so this stays linear rather than quadratic.
9790        let bomb = "{a".repeat(50_000);
9791        let out = split_dynamic_string(&bomb);
9792        assert!(out.len() <= 2, "a never-closed variable collapses, got {}", out.len());
9793
9794        let braces = "{".repeat(100_000);
9795        assert!(split_dynamic_string(&braces).len() <= 1);
9796    }
9797
9798    #[test]
9799    fn format_args_dynamic_documented_example() {
9800        let vars = args(&[("a", "value1"), ("b", "value2")]);
9801        assert_eq!(
9802            format_args_dynamic("hello {a}, {b}{{ {c} }}", &vars),
9803            "hello value1, value2{ {c} }"
9804        );
9805    }
9806
9807    #[test]
9808    fn format_args_dynamic_unknown_var_is_preserved_verbatim() {
9809        let empty = no_args();
9810        assert_eq!(format_args_dynamic("{c}", &empty), "{c}");
9811        assert_eq!(
9812            format_args_dynamic("{c:?}", &empty),
9813            "{c:?}",
9814            "the format spec is re-attached when the var is unresolved"
9815        );
9816        // Escaped braces round-trip to themselves.
9817        assert_eq!(format_args_dynamic("{{}}", &empty), "{{}}");
9818    }
9819
9820    #[test]
9821    fn format_args_dynamic_variable_names_are_normalized() {
9822        let vars = args(&[("my_var", "V")]);
9823        assert_eq!(format_args_dynamic("{myVar}", &vars), "V", "camelCase is normalized");
9824        assert_eq!(format_args_dynamic("{ my-var }", &vars), "{ my-var }",
9825            "whitespace inside the braces aborts the variable scan entirely");
9826        assert_eq!(format_args_dynamic("{my-var}", &vars), "V");
9827    }
9828
9829    #[test]
9830    fn format_args_dynamic_edge_values_no_panic() {
9831        let empty = no_args();
9832        assert_eq!(format_args_dynamic("", &empty), "");
9833        assert_eq!(format_args_dynamic("   ", &empty), "   ");
9834        assert_eq!(format_args_dynamic("\u{1F600}", &empty), "\u{1F600}");
9835        assert_eq!(format_args_dynamic(&"x".repeat(50_000), &empty).len(), 50_000);
9836    }
9837
9838    #[test]
9839    fn combine_and_replace_dynamic_items_on_empty_input() {
9840        assert_eq!(combine_and_replace_dynamic_items(&[], &no_args()), "");
9841    }
9842
9843    // ================================================================
9844    // compile_and_format_dynamic_items / format_args_for_rust_code
9845    // ================================================================
9846
9847    #[test]
9848    fn format_args_for_rust_code_empty_and_single_items() {
9849        assert_eq!(format_args_for_rust_code(""), "AzString::from_const_str(\"\")");
9850        assert_eq!(format_args_for_rust_code("hi"), "AzString::from_const_str(\"hi\")");
9851        assert_eq!(format_args_for_rust_code("{a}"), "a", "a lone var becomes a bare ident");
9852        assert_eq!(
9853            format_args_for_rust_code("{a:?}"),
9854            "format!(\"{:?}\", a).into()"
9855        );
9856    }
9857
9858    #[test]
9859    fn format_args_for_rust_code_multi_item_builds_a_format_call() {
9860        assert_eq!(
9861            format_args_for_rust_code("x={a} y={b}"),
9862            "format!(\"x={a} y={b}\", a, b).into()"
9863        );
9864    }
9865
9866    #[test]
9867    fn format_args_for_rust_code_escapes_quotes_in_literals() {
9868        let out = format_args_for_rust_code("say \"hi\" {a}");
9869        assert!(
9870            out.contains("say \\\"hi\\\""),
9871            "double quotes must be escaped for the emitted literal, got {out}"
9872        );
9873    }
9874
9875    #[test]
9876    fn compile_and_format_dynamic_items_edge_values() {
9877        assert_eq!(
9878            compile_and_format_dynamic_items(&[]),
9879            "AzString::from_const_str(\"\")"
9880        );
9881        assert_eq!(
9882            compile_and_format_dynamic_items(&[DynamicItem::Str(String::new())]),
9883            "AzString::from_const_str(\"\")"
9884        );
9885        assert_eq!(
9886            compile_and_format_dynamic_items(&[DynamicItem::Var {
9887                name: "  spaced  ".to_string(),
9888                format_spec: None,
9889            }]),
9890            "spaced",
9891            "the var name is trimmed + normalized"
9892        );
9893    }
9894
9895    // ================================================================
9896    // cap_first / camel_to_snake / esc_lit / c_creator_suffix
9897    // ================================================================
9898
9899    #[test]
9900    fn cap_first_edge_inputs() {
9901        assert_eq!(cap_first(""), "", "empty input must not panic");
9902        assert_eq!(cap_first("h1"), "H1");
9903        assert_eq!(cap_first("button"), "Button");
9904        assert_eq!(cap_first("A"), "A", "already-uppercase is idempotent");
9905        assert_eq!(cap_first("\u{1F600}x"), "\u{1F600}x", "emoji has no uppercase form");
9906        // 'ß' uppercases to TWO chars — the fn must not assume 1:1.
9907        assert_eq!(cap_first("\u{df}x"), "SSx");
9908        assert_eq!(cap_first(&"a".repeat(10_000)).len(), 10_000);
9909    }
9910
9911    #[test]
9912    fn camel_to_snake_documented_forms() {
9913        assert_eq!(camel_to_snake("ButtonNoA11y"), "button_no_a11y");
9914        assert_eq!(camel_to_snake("PWithText"), "p_with_text");
9915        assert_eq!(camel_to_snake("ANoA11y"), "a_no_a11y");
9916        assert_eq!(camel_to_snake("H1WithText"), "h1_with_text");
9917        assert_eq!(camel_to_snake("Div"), "div");
9918    }
9919
9920    #[test]
9921    fn camel_to_snake_edge_inputs() {
9922        assert_eq!(camel_to_snake(""), "");
9923        assert_eq!(camel_to_snake("A"), "a");
9924        assert_eq!(camel_to_snake("AB"), "ab", "an all-caps run is not split");
9925        assert_eq!(camel_to_snake("ABc"), "a_bc", "a caps run splits before the last cap");
9926        assert_eq!(camel_to_snake("\u{1F600}"), "\u{1F600}");
9927        assert_eq!(camel_to_snake(&"a".repeat(10_000)).len(), 10_000);
9928    }
9929
9930    #[test]
9931    fn esc_lit_escapes_backslash_before_quote() {
9932        assert_eq!(esc_lit(""), "");
9933        assert_eq!(esc_lit("plain"), "plain");
9934        assert_eq!(esc_lit("a\"b"), "a\\\"b");
9935        assert_eq!(esc_lit("a\\b"), "a\\\\b");
9936        // The backslash pass must run FIRST so an escaped quote is not double-escaped.
9937        assert_eq!(esc_lit("\\\""), "\\\\\\\"");
9938        assert_eq!(esc_lit("\u{1F600}"), "\u{1F600}");
9939    }
9940
9941    #[test]
9942    fn c_creator_suffix_edge_inputs() {
9943        assert_eq!(c_creator_suffix(""), "Div", "empty debug name falls back to Div");
9944        assert_eq!(c_creator_suffix("Div"), "Div");
9945        assert_eq!(c_creator_suffix("H1"), "H1");
9946        assert_eq!(c_creator_suffix("BlockQuote"), "Blockquote");
9947        assert_eq!(c_creator_suffix("FigCaption"), "Figcaption");
9948        assert_eq!(c_creator_suffix("\u{1F600}"), "\u{1F600}");
9949    }
9950
9951    // ================================================================
9952    // safe_container_tag
9953    // ================================================================
9954
9955    #[test]
9956    fn safe_container_tag_falls_back_to_div_for_arg_taking_widgets() {
9957        assert_eq!(safe_container_tag(""), "Div");
9958        assert_eq!(safe_container_tag("Div"), "Div");
9959        assert_eq!(safe_container_tag("Span"), "Span");
9960        assert_eq!(safe_container_tag("H1"), "H1");
9961        // Interactive / arg-taking elements deliberately degrade to a container.
9962        assert_eq!(safe_container_tag("Button"), "Div");
9963        assert_eq!(safe_container_tag("Input"), "Div");
9964        assert_eq!(safe_container_tag("A"), "Div");
9965        assert_eq!(safe_container_tag("\u{1F600}"), "Div");
9966        assert_eq!(safe_container_tag(&"z".repeat(10_000)), "Div");
9967    }
9968
9969    /// BUG (reported): `SAFE_CONTAINER_TAGS` is documented as holding the
9970    /// `NodeType`/`NodeTypeTag` **debug names**, and `safe_container_tag` compares
9971    /// against `format!("{:?}", tag_to_node_type(tag))`. But six entries are spelled
9972    /// with a different inner capitalization than the actual variant, so the
9973    /// comparison never matches and `<blockquote>`/`<figcaption>`/`<thead>`/`<tbody>`
9974    /// /`<tfoot>`/`<colgroup>` silently compile down to a plain `div`.
9975    #[test]
9976    fn safe_container_tag_matches_the_real_nodetype_debug_names() {
9977        for tag in [
9978            "blockquote",
9979            "figcaption",
9980            "thead",
9981            "tbody",
9982            "tfoot",
9983            "colgroup",
9984        ] {
9985            let dbg = format!("{:?}", tag_to_node_type(tag));
9986            assert_ne!(
9987                safe_container_tag(&dbg),
9988                "Div",
9989                "<{tag}> (NodeType debug name {dbg:?}) is a pure container and must keep \
9990                 its own creator instead of degrading to a div"
9991            );
9992        }
9993    }
9994
9995    // ================================================================
9996    // fmt_f32_lit  (numeric)
9997    // ================================================================
9998
9999    #[test]
10000    fn fmt_f32_lit_zero_and_negative() {
10001        assert_eq!(fmt_f32_lit(0.0), "0.0", "an integral value gains a decimal point");
10002        assert_eq!(fmt_f32_lit(-0.0), "-0.0");
10003        assert_eq!(fmt_f32_lit(-1.0), "-1.0");
10004        assert_eq!(fmt_f32_lit(1.5), "1.5");
10005        assert_eq!(fmt_f32_lit(-1.5), "-1.5");
10006    }
10007
10008    #[test]
10009    fn fmt_f32_lit_min_max_stay_parseable_float_literals() {
10010        for f in [f32::MAX, f32::MIN, f32::MIN_POSITIVE, f32::EPSILON] {
10011            let s = fmt_f32_lit(f);
10012            assert_eq!(
10013                s.parse::<f32>(),
10014                Ok(f),
10015                "{f:e} must round-trip through its emitted literal ({s})"
10016            );
10017        }
10018    }
10019
10020    #[test]
10021    fn fmt_f32_lit_nan_inf_produce_a_defined_result_and_do_not_panic() {
10022        // NOTE: these are NOT valid Rust/C float literals — a page with
10023        // `<progress value="NaN">` emits `create_progress_no_a11y(NaN, 1.0)`.
10024        // Pinned so a fix (e.g. clamping to 0.0) is a visible change.
10025        assert_eq!(fmt_f32_lit(f32::NAN), "NaN");
10026        assert_eq!(fmt_f32_lit(f32::INFINITY), "inf");
10027        assert_eq!(fmt_f32_lit(f32::NEG_INFINITY), "-inf");
10028    }
10029
10030    // ================================================================
10031    // node_direct_text / node_aria_label / node_attr_or / node_attr_f32
10032    // first_caption_text
10033    // ================================================================
10034
10035    #[test]
10036    fn node_direct_text_trims_and_skips_elements() {
10037        assert_eq!(node_direct_text(&XmlNode::default()), "");
10038        assert_eq!(node_direct_text(&node("p", &[], vec![txt("  Go  ")])), "Go");
10039        assert_eq!(
10040            node_direct_text(&node(
10041                "p",
10042                &[],
10043                vec![txt("a"), elem(node("b", &[], vec![txt("IGNORED")])), txt("b")]
10044            )),
10045            "a b",
10046            "direct text children are joined with a single space"
10047        );
10048        assert_eq!(
10049            node_direct_text(&node("p", &[], vec![txt("   "), txt("\t\n")])),
10050            "",
10051            "whitespace-only children are dropped"
10052        );
10053    }
10054
10055    #[test]
10056    fn node_aria_label_ignores_empty_and_whitespace() {
10057        assert_eq!(node_aria_label(&XmlNode::default()), None);
10058        assert_eq!(node_aria_label(&node("b", &[("aria-label", "")], vec![])), None);
10059        assert_eq!(node_aria_label(&node("b", &[("aria-label", "   ")], vec![])), None);
10060        assert_eq!(
10061            node_aria_label(&node("b", &[("aria-label", "  Save  ")], vec![])),
10062            Some("Save".to_string())
10063        );
10064    }
10065
10066    #[test]
10067    fn node_attr_or_returns_the_default_when_absent() {
10068        let n = node("a", &[("href", "/x"), ("empty", "")], vec![]);
10069        assert_eq!(node_attr_or(&n, "href", "FALLBACK"), "/x");
10070        assert_eq!(node_attr_or(&n, "missing", "FALLBACK"), "FALLBACK");
10071        assert_eq!(
10072            node_attr_or(&n, "empty", "FALLBACK"),
10073            "",
10074            "a present-but-empty attribute wins over the default"
10075        );
10076        assert_eq!(node_attr_or(&XmlNode::default(), "x", ""), "");
10077    }
10078
10079    #[test]
10080    fn node_attr_f32_zero_negative_and_defaults() {
10081        let n = node(
10082            "meter",
10083            &[("zero", "0"), ("negzero", "-0"), ("neg", "-2.5"), ("pad", "  1.5  ")],
10084            vec![],
10085        );
10086        assert_eq!(node_attr_f32(&n, "zero", 9.0), 0.0);
10087        assert!(node_attr_f32(&n, "negzero", 9.0).is_sign_negative());
10088        assert_eq!(node_attr_f32(&n, "neg", 9.0), -2.5);
10089        assert_eq!(node_attr_f32(&n, "pad", 9.0), 1.5, "the value is trimmed first");
10090        assert_eq!(node_attr_f32(&n, "missing", 9.0), 9.0);
10091    }
10092
10093    #[test]
10094    fn node_attr_f32_unparsable_falls_back_and_min_max_saturate() {
10095        let n = node(
10096            "meter",
10097            &[
10098                ("junk", "abc"),
10099                ("empty", ""),
10100                ("huge", "1e400"),
10101                ("tiny", "-1e400"),
10102                ("big", "340282350000000000000000000000000000000"),
10103            ],
10104            vec![],
10105        );
10106        assert_eq!(node_attr_f32(&n, "junk", 7.0), 7.0);
10107        assert_eq!(node_attr_f32(&n, "empty", 7.0), 7.0);
10108        assert!(
10109            node_attr_f32(&n, "huge", 7.0).is_infinite(),
10110            "an out-of-range literal saturates to inf, it does not panic"
10111        );
10112        assert_eq!(node_attr_f32(&n, "tiny", 7.0), f32::NEG_INFINITY);
10113        assert_eq!(node_attr_f32(&n, "big", 7.0), f32::MAX);
10114    }
10115
10116    #[test]
10117    fn node_attr_f32_accepts_nan_and_inf_spellings() {
10118        // Rust's f32 FromStr accepts "NaN"/"inf", so hostile markup can inject a
10119        // non-finite value straight into codegen (see fmt_f32_lit above).
10120        let n = node("progress", &[("value", "NaN"), ("max", "inf")], vec![]);
10121        assert!(node_attr_f32(&n, "value", 0.0).is_nan());
10122        assert_eq!(node_attr_f32(&n, "max", 1.0), f32::INFINITY);
10123    }
10124
10125    #[test]
10126    fn node_attr_f32_nan_default_is_returned_verbatim() {
10127        assert!(node_attr_f32(&XmlNode::default(), "x", f32::NAN).is_nan());
10128        assert_eq!(node_attr_f32(&XmlNode::default(), "x", f32::INFINITY), f32::INFINITY);
10129    }
10130
10131    #[test]
10132    fn first_caption_text_edges() {
10133        assert_eq!(first_caption_text(&XmlNode::default()), None);
10134        assert_eq!(
10135            first_caption_text(&node("table", &[], vec![elem(node("caption", &[], vec![]))])),
10136            None,
10137            "an empty caption yields None"
10138        );
10139        assert_eq!(
10140            first_caption_text(&node(
10141                "table",
10142                &[],
10143                vec![elem(node("CAPTION", &[], vec![txt("  Hi  ")]))]
10144            )),
10145            Some("Hi".to_string()),
10146            "the tag match is ASCII-case-insensitive and the text is trimmed"
10147        );
10148    }
10149
10150    // ================================================================
10151    // analyze_node_ctor / CtorArg / NodeCtor
10152    // ================================================================
10153
10154    #[test]
10155    fn analyze_node_ctor_plain_for_unknown_and_empty_tags() {
10156        assert!(matches!(analyze_node_ctor("div", &XmlNode::default()), NodeCtor::Plain));
10157        assert!(matches!(analyze_node_ctor("", &XmlNode::default()), NodeCtor::Plain));
10158        assert!(matches!(
10159            analyze_node_ctor("\u{1F600}", &XmlNode::default()),
10160            NodeCtor::Plain
10161        ));
10162        let plain = analyze_node_ctor("div", &XmlNode::default());
10163        assert_eq!(plain.render_rust(), None);
10164        assert_eq!(plain.render_c(), None);
10165        assert_eq!(plain.render_fluent(&CompileTarget::Cpp), None);
10166        assert!(!plain.consumes_text());
10167        assert!(!plain.skip_caption());
10168    }
10169
10170    #[test]
10171    fn analyze_node_ctor_with_text_tier_requires_actual_text() {
10172        // Empty <p> stays a plain container (has_only_text_children() is vacuously
10173        // true for a childless node, so `has_text` is the real gate).
10174        assert!(matches!(analyze_node_ctor("p", &XmlNode::default()), NodeCtor::Plain));
10175        // <p> with an element child is not "pure text" either.
10176        let mixed = node("p", &[], vec![txt("a"), elem(XmlNode::create("span"))]);
10177        assert!(matches!(analyze_node_ctor("p", &mixed), NodeCtor::Plain));
10178
10179        let pure = node("p", &[], vec![txt("  Hello  ")]);
10180        let ctor = analyze_node_ctor("p", &pure);
10181        assert!(ctor.consumes_text(), "the text is folded into the constructor");
10182        assert_eq!(
10183            ctor.render_rust().as_deref(),
10184            Some("Dom::create_p_with_text(AzString::from(\"Hello\"))")
10185        );
10186        assert_eq!(
10187            ctor.render_c().as_deref(),
10188            Some("AzDom_createPWithText(AZ_STR(\"Hello\"))")
10189        );
10190        assert_eq!(
10191            ctor.render_fluent(&CompileTarget::Python).as_deref(),
10192            Some("azul.Dom.create_p_with_text(\"Hello\")")
10193        );
10194    }
10195
10196    #[test]
10197    fn analyze_node_ctor_button_with_and_without_aria() {
10198        let plain_btn = node("button", &[], vec![txt("Go")]);
10199        assert_eq!(
10200            analyze_node_ctor("button", &plain_btn).render_rust().as_deref(),
10201            Some("Dom::create_button_no_a11y(AzString::from(\"Go\"))")
10202        );
10203
10204        let aria_btn = node("button", &[("aria-label", "Save")], vec![txt("Go")]);
10205        assert_eq!(
10206            analyze_node_ctor("button", &aria_btn).render_rust().as_deref(),
10207            Some(
10208                "Dom::create_button(AzString::from(\"Go\"), \
10209                 SmallAriaInfo::label(AzString::from(\"Save\")))"
10210            )
10211        );
10212    }
10213
10214    #[test]
10215    fn analyze_node_ctor_escapes_quotes_and_backslashes_in_text() {
10216        let btn = node("button", &[], vec![txt("say \"hi\"\\now")]);
10217        let rust = analyze_node_ctor("button", &btn).render_rust().expect("semantic");
10218        assert!(
10219            rust.contains("say \\\"hi\\\"\\\\now"),
10220            "quotes and backslashes must be escaped for the literal, got {rust}"
10221        );
10222    }
10223
10224    #[test]
10225    fn analyze_node_ctor_anchor_uses_option_string_when_it_has_no_text() {
10226        let bare = node("a", &[], vec![]);
10227        assert_eq!(
10228            analyze_node_ctor("a", &bare).render_rust().as_deref(),
10229            Some("Dom::create_a_no_a11y(AzString::from(\"\"), OptionString::None)"),
10230            "a missing href defaults to an empty string, missing text to OptionString::None"
10231        );
10232
10233        let full = node("a", &[("href", "/x")], vec![txt("Home")]);
10234        assert_eq!(
10235            analyze_node_ctor("a", &full).render_rust().as_deref(),
10236            Some(
10237                "Dom::create_a_no_a11y(AzString::from(\"/x\"), \
10238                 OptionString::Some(AzString::from(\"Home\")))"
10239            )
10240        );
10241        assert_eq!(
10242            analyze_node_ctor("a", &full).render_c().as_deref(),
10243            Some("AzDom_createANoA11y(AZ_STR(\"/x\"), AzOptionString_some(AZ_STR(\"Home\")))")
10244        );
10245    }
10246
10247    #[test]
10248    fn analyze_node_ctor_table_aria_form_skips_the_literal_caption() {
10249        let t = node(
10250            "table",
10251            &[("aria-label", "Prices")],
10252            vec![elem(node("caption", &[], vec![txt("Q1")]))],
10253        );
10254        let ctor = analyze_node_ctor("table", &t);
10255        assert!(ctor.skip_caption(), "the aria form injects its own caption child");
10256        assert_eq!(
10257            ctor.render_rust().as_deref(),
10258            Some(
10259                "Dom::create_table(AzString::from(\"Q1\"), \
10260                 SmallAriaInfo::label(AzString::from(\"Prices\")))"
10261            )
10262        );
10263
10264        let plain = analyze_node_ctor("table", &node("table", &[], vec![]));
10265        assert!(!plain.skip_caption());
10266        assert_eq!(plain.render_rust().as_deref(), Some("Dom::create_table_no_a11y()"));
10267    }
10268
10269    #[test]
10270    fn analyze_node_ctor_scalar_widgets_use_defaults_and_emit_float_literals() {
10271        let p = node("progress", &[], vec![]);
10272        assert_eq!(
10273            analyze_node_ctor("progress", &p).render_rust().as_deref(),
10274            Some("Dom::create_progress_no_a11y(0.0, 1.0)"),
10275            "missing value/max fall back to 0.0 / 1.0 as float literals"
10276        );
10277
10278        let m = node("meter", &[("value", "5"), ("min", "-1"), ("max", "10")], vec![]);
10279        assert_eq!(
10280            analyze_node_ctor("meter", &m).render_c().as_deref(),
10281            Some("AzDom_createMeterNoA11y(5.0f, -1.0f, 10.0f)")
10282        );
10283    }
10284
10285    /// Non-finite attribute values flow straight into the emitted literal. Pinned
10286    /// so that a fix (clamping / rejecting them) shows up as a change.
10287    #[test]
10288    fn analyze_node_ctor_non_finite_attributes_emit_non_finite_literals() {
10289        let p = node("progress", &[("value", "NaN"), ("max", "inf")], vec![]);
10290        let rust = analyze_node_ctor("progress", &p).render_rust().expect("semantic");
10291        assert_eq!(rust, "Dom::create_progress_no_a11y(NaN, inf)");
10292    }
10293
10294    #[test]
10295    fn ctor_arg_render_targets_are_distinct() {
10296        let s = CtorArg::Str("a\"b".to_string());
10297        assert_eq!(s.render_rust(), "AzString::from(\"a\\\"b\")");
10298        assert_eq!(s.render_c(), "AZ_STR(\"a\\\"b\")");
10299        assert_eq!(s.render_cpp(), "String(\"a\\\"b\")");
10300        assert_eq!(s.render_python(), "\"a\\\"b\"");
10301
10302        assert_eq!(CtorArg::OptNone.render_rust(), "OptionString::None");
10303        assert_eq!(CtorArg::OptNone.render_c(), "AzOptionString_none()");
10304        assert_eq!(CtorArg::OptNone.render_cpp(), "OptionString::none()");
10305        assert_eq!(CtorArg::OptNone.render_python(), "azul.OptionString.none()");
10306
10307        assert_eq!(CtorArg::Float(0.0).render_rust(), "0.0");
10308        assert_eq!(CtorArg::Float(0.0).render_c(), "0.0f");
10309        assert_eq!(CtorArg::Float(f32::NAN).render_c(), "NaNf");
10310    }
10311
10312    #[test]
10313    fn node_ctor_render_fluent_returns_none_for_non_fluent_targets() {
10314        let ctor = analyze_node_ctor("p", &node("p", &[], vec![txt("x")]));
10315        assert!(ctor.render_fluent(&CompileTarget::Rust).is_none());
10316        assert!(ctor.render_fluent(&CompileTarget::C).is_none());
10317        assert!(ctor.render_fluent(&CompileTarget::Cpp).is_some());
10318        assert!(ctor.render_fluent(&CompileTarget::Python).is_some());
10319    }
10320
10321    // ================================================================
10322    // format_component_args / compile_component / compile_components
10323    // ================================================================
10324
10325    #[test]
10326    fn format_component_args_empty_and_ordering() {
10327        assert_eq!(format_component_args(&no_args()), "");
10328        // Args are sorted DESCENDING by their rendered "name: type" string.
10329        assert_eq!(
10330            format_component_args(&args(&[("a", "u32"), ("b", "String")])),
10331            "b: String, a: u32"
10332        );
10333    }
10334
10335    #[test]
10336    fn format_component_args_edge_values_no_panic() {
10337        let a = args(&[("", ""), ("\u{1F600}", "\u{130}")]);
10338        let out = format_component_args(&a);
10339        assert!(out.contains(": "), "still emits `name: type` pairs, got {out:?}");
10340    }
10341
10342    #[test]
10343    fn compile_component_emits_a_render_fn() {
10344        let ca = ComponentArguments {
10345            args: args(&[("count", "u32")]),
10346            accepts_text: false,
10347        };
10348        let out = compile_component("MyWidget", &ca, "Dom::create_div()");
10349        assert!(out.contains("pub fn render(count: u32) -> Dom {"), "got:\n{out}");
10350        assert!(out.contains("#[inline]"), "a one-line body is inlined");
10351    }
10352
10353    #[test]
10354    fn compile_component_accepts_text_prepends_the_text_param() {
10355        let ca = ComponentArguments {
10356            args: args(&[("count", "u32")]),
10357            accepts_text: true,
10358        };
10359        let out = compile_component("my-widget", &ca, "Dom::create_div()");
10360        assert!(
10361            out.contains("pub fn render(text: AzString, count: u32) -> Dom {"),
10362            "got:\n{out}"
10363        );
10364
10365        let ca_no_args = ComponentArguments {
10366            args: no_args(),
10367            accepts_text: true,
10368        };
10369        let out = compile_component("w", &ca_no_args, "Dom::create_div()");
10370        assert!(
10371            out.contains("pub fn render(text: AzString) -> Dom {"),
10372            "no trailing comma when there are no extra args, got:\n{out}"
10373        );
10374    }
10375
10376    #[test]
10377    fn compile_component_empty_name_and_body_no_panic() {
10378        let ca = ComponentArguments::default();
10379        let out = compile_component("", &ca, "");
10380        assert!(out.contains("pub fn render() -> Dom {"), "got:\n{out}");
10381    }
10382
10383    #[test]
10384    fn compile_components_of_an_empty_list_is_empty() {
10385        assert_eq!(compile_components(Vec::new()), "");
10386    }
10387
10388    // ================================================================
10389    // parse_svg_float / parse_svg_points  (parser / numeric)
10390    // ================================================================
10391
10392    #[test]
10393    fn parse_svg_float_none_empty_whitespace_garbage() {
10394        assert_eq!(parse_svg_float(None), None);
10395        let empty = AzString::from("");
10396        assert_eq!(parse_svg_float(Some(&empty)), None);
10397        let ws = AzString::from("   \t\n");
10398        assert_eq!(parse_svg_float(Some(&ws)), None);
10399        let junk = AzString::from("10px");
10400        assert_eq!(parse_svg_float(Some(&junk)), None, "units are not stripped");
10401        let uni = AzString::from("\u{1F600}");
10402        assert_eq!(parse_svg_float(Some(&uni)), None);
10403    }
10404
10405    #[test]
10406    fn parse_svg_float_valid_and_boundary_numbers() {
10407        let padded = AzString::from("  1.5  ");
10408        assert_eq!(parse_svg_float(Some(&padded)), Some(1.5), "value is trimmed");
10409        let zero = AzString::from("0");
10410        assert_eq!(parse_svg_float(Some(&zero)), Some(0.0));
10411        let negzero = AzString::from("-0");
10412        assert!(parse_svg_float(Some(&negzero)).unwrap().is_sign_negative());
10413        let huge = AzString::from("1e400");
10414        assert!(
10415            parse_svg_float(Some(&huge)).unwrap().is_infinite(),
10416            "overflow saturates to inf rather than erroring"
10417        );
10418        let nan = AzString::from("NaN");
10419        assert!(parse_svg_float(Some(&nan)).unwrap().is_nan());
10420        let inf = AzString::from("-inf");
10421        assert_eq!(parse_svg_float(Some(&inf)), Some(f32::NEG_INFINITY));
10422    }
10423
10424    #[test]
10425    fn parse_svg_points_rejects_degenerate_input() {
10426        assert!(parse_svg_points("", false).is_none());
10427        assert!(parse_svg_points("   ", false).is_none());
10428        assert!(parse_svg_points("garbage", false).is_none());
10429        assert!(parse_svg_points("1 2", false).is_none(), "a single point is not a line");
10430        assert!(
10431            parse_svg_points("1 2 3", false).is_none(),
10432            "an odd coordinate count is rejected"
10433        );
10434        assert!(parse_svg_points("\u{1F600}", false).is_none());
10435    }
10436
10437    #[test]
10438    fn parse_svg_points_valid_minimal_and_close() {
10439        let open = parse_svg_points("0,0 10,0", false).expect("two points => one line");
10440        assert_eq!(open.rings.as_ref().len(), 1);
10441        assert_eq!(open.rings.as_ref()[0].items.as_ref().len(), 1);
10442
10443        // `close` adds a segment back to the first point when it differs.
10444        let closed = parse_svg_points("0,0 10,0 10,10", true).expect("triangle");
10445        assert_eq!(
10446            closed.rings.as_ref()[0].items.as_ref().len(),
10447            3,
10448            "2 segments + 1 closing segment"
10449        );
10450
10451        // Already-closed rings do not get a duplicate closing segment.
10452        let already = parse_svg_points("0,0 10,0 0,0", true).expect("closed ring");
10453        assert_eq!(already.rings.as_ref()[0].items.as_ref().len(), 2);
10454    }
10455
10456    #[test]
10457    fn parse_svg_points_skips_unparsable_tokens_and_handles_boundaries() {
10458        // Unparsable coordinates are silently dropped, which can shift the pairing.
10459        let p = parse_svg_points("0,0 junk 10,0", false).expect("junk token dropped");
10460        assert_eq!(p.rings.as_ref()[0].items.as_ref().len(), 1);
10461
10462        let nan = parse_svg_points("NaN,0 1,1", false).expect("NaN is a parseable f32");
10463        assert_eq!(nan.rings.as_ref()[0].items.as_ref().len(), 1);
10464    }
10465
10466    #[test]
10467    fn parse_svg_points_extremely_long_terminates() {
10468        let pts = "1,2 ".repeat(20_000);
10469        let p = parse_svg_points(&pts, false).expect("20k points");
10470        assert_eq!(p.rings.as_ref()[0].items.as_ref().len(), 19_999);
10471    }
10472
10473    // ================================================================
10474    // CompactDomBuilder  (constructor / numeric)
10475    // ================================================================
10476
10477    #[test]
10478    fn compact_dom_builder_new_and_with_capacity_start_empty() {
10479        for b in [
10480            CompactDomBuilder::new(),
10481            CompactDomBuilder::with_capacity(0),
10482            CompactDomBuilder::with_capacity(1),
10483            CompactDomBuilder::with_capacity(4096),
10484        ] {
10485            let fd = b.finish();
10486            assert_eq!(fd.node_hierarchy.as_ref().len(), 0);
10487            assert_eq!(fd.node_data.as_ref().len(), 0);
10488            assert_eq!(fd.css.as_ref().len(), 0);
10489        }
10490        assert_eq!(
10491            CompactDomBuilder::default().finish().node_data.as_ref().len(),
10492            0
10493        );
10494    }
10495
10496    #[test]
10497    fn compact_dom_builder_close_node_on_an_empty_stack_is_a_no_op() {
10498        let mut b = CompactDomBuilder::new();
10499        b.close_node();
10500        b.close_node();
10501        assert_eq!(b.finish().node_hierarchy.as_ref().len(), 0, "no panic, no nodes");
10502    }
10503
10504    #[test]
10505    fn compact_dom_builder_keeps_hierarchy_and_data_parallel() {
10506        let mut b = CompactDomBuilder::new();
10507        b.open_node(NodeData::create_node(NodeType::Html));
10508        b.add_leaf(NodeData::create_text("a"));
10509        b.add_leaf(NodeData::create_text("b"));
10510        b.close_node();
10511        let fd = b.finish();
10512        assert_eq!(fd.node_data.as_ref().len(), 3);
10513        assert_eq!(
10514            fd.node_hierarchy.as_ref().len(),
10515            fd.node_data.as_ref().len(),
10516            "the two arenas must stay the same length"
10517        );
10518    }
10519
10520    #[test]
10521    fn compact_dom_builder_unclosed_node_still_finishes() {
10522        let mut b = CompactDomBuilder::new();
10523        b.open_node(NodeData::create_node(NodeType::Div));
10524        // Deliberately NOT closed.
10525        let fd = b.finish();
10526        assert_eq!(fd.node_hierarchy.as_ref().len(), 1);
10527        assert_eq!(
10528            fd.node_hierarchy.as_ref()[0].last_child, 0,
10529            "last_child stays unset when close_node() is never called"
10530        );
10531    }
10532
10533    #[test]
10534    fn compact_dom_builder_add_css_accepts_zero_and_usize_max_node_ids() {
10535        let mut b = CompactDomBuilder::new();
10536        b.add_css(0, Css::empty());
10537        b.add_css(usize::MAX, Css::empty());
10538        let fd = b.finish();
10539        assert_eq!(fd.css.as_ref().len(), 2);
10540        assert_eq!(fd.css.as_ref()[0].node_id, 0);
10541        assert_eq!(
10542            fd.css.as_ref()[1].node_id,
10543            usize::MAX,
10544            "an out-of-range node id is stored verbatim (no bounds check, no panic)"
10545        );
10546    }
10547
10548    // ================================================================
10549    // xml_node_to_dom_fast / xml_node_to_fast_dom  (numeric: depth)
10550    // ================================================================
10551
10552    #[test]
10553    fn xml_node_to_dom_fast_depth_zero_builds_children() {
10554        let map = ComponentMap::default();
10555        let n = node("div", &[], vec![txt("hi"), elem(XmlNode::create("span"))]);
10556        let dom = xml_node_to_dom_fast(&n, &map, false, 0).expect("ok");
10557        assert_eq!(dom.children.as_ref().len(), 2);
10558    }
10559
10560    #[test]
10561    fn xml_node_to_dom_fast_at_and_past_the_depth_cap_truncates_instead_of_panicking() {
10562        let map = ComponentMap::default();
10563        let n = node("div", &[], vec![txt("hi")]);
10564
10565        let at_cap = xml_node_to_dom_fast(&n, &map, false, MAX_XML_NESTING_DEPTH).expect("ok");
10566        assert!(
10567            at_cap.children.as_ref().is_empty(),
10568            "at the cap the node is emitted without children"
10569        );
10570
10571        let saturated = xml_node_to_dom_fast(&n, &map, false, usize::MAX)
10572            .expect("usize::MAX depth must not overflow when computing depth + 1");
10573        assert!(saturated.children.as_ref().is_empty());
10574
10575        let below = xml_node_to_dom_fast(&n, &map, false, MAX_XML_NESTING_DEPTH - 1).expect("ok");
10576        assert_eq!(below.children.as_ref().len(), 1, "one below the cap still recurses");
10577    }
10578
10579    #[test]
10580    fn xml_node_to_fast_dom_at_the_depth_cap_still_emits_the_node() {
10581        let map = ComponentMap::default();
10582        let n = node("div", &[], vec![txt("hi")]);
10583
10584        let mut b = CompactDomBuilder::new();
10585        xml_node_to_fast_dom(&n, &map, false, &mut b, usize::MAX).expect("no overflow");
10586        let fd = b.finish();
10587        assert_eq!(
10588            fd.node_data.as_ref().len(),
10589            1,
10590            "the node itself is still opened+closed, only its children are dropped"
10591        );
10592
10593        let mut b2 = CompactDomBuilder::new();
10594        xml_node_to_fast_dom(&n, &map, false, &mut b2, 0).expect("ok");
10595        assert_eq!(b2.finish().node_data.as_ref().len(), 2, "node + text child");
10596    }
10597
10598    #[test]
10599    fn apply_xml_node_attributes_extreme_tabindex_does_not_panic() {
10600        let map = ComponentMap::default();
10601        for v in [
10602            "0",
10603            "-1",
10604            "2147483647",
10605            "9223372036854775807",
10606            "-9223372036854775808",
10607            "99999999999999999999999999999999",
10608            "abc",
10609            "",
10610            "\u{1F600}",
10611        ] {
10612            let n = node("div", &[("tabindex", v), ("focusable", "true")], vec![]);
10613            assert!(
10614                xml_node_to_dom_fast(&n, &map, false, 0).is_ok(),
10615                "tabindex={v:?} must not panic"
10616            );
10617        }
10618    }
10619
10620    #[test]
10621    fn apply_xml_node_attributes_img_width_height_garbage_falls_back_to_zero() {
10622        let map = ComponentMap::default();
10623        let n = node(
10624            "img",
10625            &[("src", "a.png"), ("width", "-5"), ("height", "not-a-number")],
10626            vec![],
10627        );
10628        let dom = xml_node_to_dom_fast(&n, &map, false, 0).expect("ok");
10629        match dom.root.get_node_type() {
10630            NodeType::Image(_) => {}
10631            other => panic!("expected an Image node, got {other:?}"),
10632        }
10633    }
10634
10635    // ================================================================
10636    // set_stringified_attributes  (numeric: tabs / tabindex)
10637    // ================================================================
10638
10639    #[test]
10640    fn set_stringified_attributes_zero_tabs_and_empty_attrs() {
10641        let mut s = String::new();
10642        set_stringified_attributes(&mut s, &attrs(&[]), &no_args(), 0);
10643        assert_eq!(s, "", "nothing to emit for an attribute-less node");
10644    }
10645
10646    #[test]
10647    fn set_stringified_attributes_splits_ids_and_classes_on_whitespace() {
10648        let mut s = String::new();
10649        set_stringified_attributes(
10650            &mut s,
10651            &attrs(&[("id", "a  b"), ("class", "c\td")]),
10652            &no_args(),
10653            0,
10654        );
10655        assert!(s.contains(".with_id(\"a\")"), "got {s:?}");
10656        assert!(s.contains(".with_id(\"b\")"));
10657        assert!(s.contains(".with_class(\"c\")"));
10658        assert!(s.contains(".with_class(\"d\")"));
10659    }
10660
10661    #[test]
10662    fn set_stringified_attributes_tabindex_boundaries() {
10663        let cases: &[(&str, &str)] = &[
10664            ("0", "TabIndex::Auto"),
10665            ("5", "TabIndex::OverrideInParent(5)"),
10666            ("-1", "TabIndex::NoKeyboardFocus"),
10667        ];
10668        for (val, expected) in cases {
10669            let mut s = String::new();
10670            set_stringified_attributes(&mut s, &attrs(&[("tabindex", val)]), &no_args(), 0);
10671            assert!(s.contains(expected), "tabindex={val:?} => {s:?}");
10672        }
10673
10674        // Unparsable / overflowing values emit nothing rather than panicking.
10675        for val in ["abc", "", "99999999999999999999999999999999", "1.5"] {
10676            let mut s = String::new();
10677            set_stringified_attributes(&mut s, &attrs(&[("tabindex", val)]), &no_args(), 0);
10678            assert!(
10679                !s.contains("TabIndex"),
10680                "tabindex={val:?} must be ignored, got {s:?}"
10681            );
10682        }
10683    }
10684
10685    #[test]
10686    fn set_stringified_attributes_focusable_only_accepts_exact_true_false() {
10687        let mut s = String::new();
10688        set_stringified_attributes(&mut s, &attrs(&[("focusable", "true")]), &no_args(), 0);
10689        assert!(s.contains("TabIndex::Auto"));
10690
10691        let mut s = String::new();
10692        set_stringified_attributes(&mut s, &attrs(&[("focusable", "false")]), &no_args(), 0);
10693        assert!(s.contains("TabIndex::NoKeyboardFocus"));
10694
10695        let mut s = String::new();
10696        set_stringified_attributes(&mut s, &attrs(&[("focusable", "TRUE")]), &no_args(), 0);
10697        assert!(s.is_empty(), "casing other than `true`/`false` is ignored, got {s:?}");
10698    }
10699
10700    #[test]
10701    fn set_stringified_attributes_large_tab_depth_does_not_overflow() {
10702        // `tabs` becomes `"    ".repeat(tabs)`; a large-but-sane nesting depth must
10703        // stay linear and allocate without panicking.
10704        let mut s = String::new();
10705        set_stringified_attributes(&mut s, &attrs(&[("id", "x")]), &no_args(), 1_000);
10706        assert!(s.contains(".with_id(\"x\")"));
10707        assert!(s.len() > 4_000, "the 1000-level indent is actually emitted");
10708    }
10709
10710    // ================================================================
10711    // group_matches / CssMatcher  (numeric: indices)
10712    // ================================================================
10713
10714    fn refs(v: &[CssPathSelector]) -> Vec<&CssPathSelector> {
10715        v.iter().collect()
10716    }
10717
10718    #[test]
10719    fn group_matches_global_matches_at_any_index() {
10720        let a = vec![CssPathSelector::Global];
10721        assert!(group_matches(&refs(&a), &[], 0, 0));
10722        assert!(
10723            group_matches(&refs(&a), &[], usize::MAX, usize::MAX),
10724            "usize::MAX indices must not overflow"
10725        );
10726    }
10727
10728    #[test]
10729    fn group_matches_type_class_id() {
10730        let div = vec![CssPathSelector::Type(NodeTypeTag::Div)];
10731        let p = vec![CssPathSelector::Type(NodeTypeTag::P)];
10732        assert!(group_matches(&refs(&div), &refs(&div), 0, 1));
10733        assert!(!group_matches(&refs(&div), &refs(&p), 0, 1));
10734        assert!(!group_matches(&refs(&div), &[], 0, 1), "an empty haystack never matches");
10735
10736        let cls = vec![CssPathSelector::Class(AzString::from("x"))];
10737        assert!(group_matches(&refs(&cls), &refs(&cls), 0, 1));
10738        let id = vec![CssPathSelector::Id(AzString::from("x"))];
10739        assert!(!group_matches(&refs(&id), &refs(&cls), 0, 1), "an id is not a class");
10740    }
10741
10742    #[test]
10743    fn group_matches_first_and_last_pseudo_at_boundaries() {
10744        let first = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::First)];
10745        assert!(group_matches(&refs(&first), &[], 0, 10));
10746        assert!(!group_matches(&refs(&first), &[], 1, 10));
10747
10748        let last = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::Last)];
10749        assert!(group_matches(&refs(&last), &[], 9, 10));
10750        assert!(!group_matches(&refs(&last), &[], 8, 10));
10751        assert!(
10752            group_matches(&refs(&last), &[], 0, 0),
10753            "parent_children == 0 saturates to 0, so index 0 counts as last"
10754        );
10755    }
10756
10757    #[test]
10758    fn group_matches_nth_child_even_odd_and_number() {
10759        let even = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
10760            CssNthChildSelector::Even,
10761        ))];
10762        assert!(group_matches(&refs(&even), &[], 0, 0));
10763        assert!(!group_matches(&refs(&even), &[], 1, 0));
10764        assert!(
10765            !group_matches(&refs(&even), &[], usize::MAX, 0),
10766            "usize::MAX is odd"
10767        );
10768
10769        let odd = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
10770            CssNthChildSelector::Odd,
10771        ))];
10772        assert!(group_matches(&refs(&odd), &[], 1, 0));
10773        assert!(!group_matches(&refs(&odd), &[], 2, 0));
10774
10775        let n = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
10776            CssNthChildSelector::Number(u32::MAX),
10777        ))];
10778        assert!(group_matches(&refs(&n), &[], u32::MAX as usize, 0));
10779        assert!(!group_matches(&refs(&n), &[], 0, 0));
10780    }
10781
10782    #[test]
10783    fn group_matches_nth_child_pattern_zero_repeat_does_not_divide_by_zero() {
10784        let zero = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
10785            CssNthChildSelector::Pattern(CssNthChildPattern {
10786                pattern_repeat: 0,
10787                offset: 0,
10788            }),
10789        ))];
10790        // `is_multiple_of(0)` is `self == 0` — no division by zero.
10791        assert!(group_matches(&refs(&zero), &[], 0, 0));
10792        assert!(!group_matches(&refs(&zero), &[], 5, 0));
10793
10794        let offset_past = vec![CssPathSelector::PseudoSelector(CssPathPseudoSelector::NthChild(
10795            CssNthChildSelector::Pattern(CssNthChildPattern {
10796                pattern_repeat: 2,
10797                offset: u32::MAX,
10798            }),
10799        ))];
10800        assert!(
10801            group_matches(&refs(&offset_past), &[], 0, 0),
10802            "index - offset saturates to 0 rather than underflowing"
10803        );
10804    }
10805
10806    #[test]
10807    fn group_matches_structural_combinators_never_match() {
10808        for sel in [
10809            CssPathSelector::Children,
10810            CssPathSelector::DirectChildren,
10811            CssPathSelector::AdjacentSibling,
10812            CssPathSelector::GeneralSibling,
10813        ] {
10814            let a = vec![sel.clone()];
10815            assert!(
10816                !group_matches(&refs(&a), &refs(&a), 0, 1),
10817                "{sel:?} is a combinator, not a matchable group member"
10818            );
10819        }
10820    }
10821
10822    #[test]
10823    fn css_matcher_empty_path_never_matches() {
10824        let m = CssMatcher {
10825            path: Vec::new(),
10826            indices_in_parent: vec![0],
10827            children_length: vec![0],
10828        };
10829        let path = CssPath {
10830            selectors: vec![CssPathSelector::Type(NodeTypeTag::Body)].into(),
10831        };
10832        assert!(!m.matches(&path), "an empty matcher path can never match");
10833
10834        let m2 = CssMatcher {
10835            path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
10836            indices_in_parent: vec![0],
10837            children_length: vec![0],
10838        };
10839        let empty_path = CssPath {
10840            selectors: Vec::new().into(),
10841        };
10842        assert!(!m2.matches(&empty_path), "an empty CSS path can never match");
10843    }
10844
10845    #[test]
10846    fn css_matcher_get_hash_is_deterministic_and_path_sensitive() {
10847        let a = CssMatcher {
10848            path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
10849            indices_in_parent: vec![0],
10850            children_length: vec![0],
10851        };
10852        let b = CssMatcher {
10853            path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
10854            indices_in_parent: vec![9],
10855            children_length: vec![9],
10856        };
10857        let c = CssMatcher {
10858            path: vec![CssPathSelector::Type(NodeTypeTag::Div)],
10859            indices_in_parent: vec![0],
10860            children_length: vec![0],
10861        };
10862        assert_eq!(a.get_hash(), a.get_hash(), "stable across calls");
10863        assert_eq!(
10864            a.get_hash(),
10865            b.get_hash(),
10866            "the hash covers only `path`, not the sibling indices"
10867        );
10868        assert_ne!(a.get_hash(), c.get_hash());
10869
10870        let empty = CssMatcher {
10871            path: Vec::new(),
10872            indices_in_parent: Vec::new(),
10873            children_length: Vec::new(),
10874        };
10875        let _ = empty.get_hash(); // must not panic
10876    }
10877
10878    #[test]
10879    fn css_matcher_mismatched_bookkeeping_vec_lengths_bail_out() {
10880        // `indices_in_parent` / `children_length` must be as long as the group list.
10881        let m = CssMatcher {
10882            path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
10883            indices_in_parent: Vec::new(),
10884            children_length: Vec::new(),
10885        };
10886        let path = CssPath {
10887            selectors: vec![CssPathSelector::Type(NodeTypeTag::Body)].into(),
10888        };
10889        assert!(
10890            !m.matches(&path),
10891            "a desynced matcher must return false, not index out of bounds"
10892        );
10893    }
10894
10895    #[test]
10896    fn get_css_blocks_and_inline_string_on_empty_css() {
10897        let m = CssMatcher {
10898            path: vec![CssPathSelector::Type(NodeTypeTag::Body)],
10899            indices_in_parent: vec![0],
10900            children_length: vec![0],
10901        };
10902        assert!(get_css_blocks(&Css::empty(), &m).is_empty());
10903        assert_eq!(css_blocks_to_inline_string(&[]), "");
10904    }
10905
10906    // ================================================================
10907    // str_to_dom / str_to_dom_unstyled / parse_page_style_and_body / body_matcher
10908    // ================================================================
10909
10910    #[test]
10911    fn str_to_dom_rejects_documents_without_html_or_body() {
10912        let map = ComponentMap::with_builtin();
10913        assert_eq!(
10914            str_to_dom(&[], &map, None).unwrap_err(),
10915            DomXmlParseError::NoHtmlNode
10916        );
10917        let html_only = vec![elem(XmlNode::create("html"))];
10918        assert_eq!(
10919            str_to_dom(&html_only, &map, None).unwrap_err(),
10920            DomXmlParseError::NoBodyInHtml
10921        );
10922        assert!(str_to_dom_unstyled(&[], &map).is_err());
10923    }
10924
10925    #[test]
10926    fn str_to_dom_valid_minimal() {
10927        let map = ComponentMap::with_builtin();
10928        let d = doc("body { color: red; }", vec![elem(node("div", &[("id", "x")], vec![]))]);
10929        assert!(str_to_dom(&d, &map, None).is_ok());
10930        assert!(str_to_dom_unstyled(&d, &map).is_ok());
10931    }
10932
10933    #[test]
10934    fn str_to_dom_max_width_edge_values_do_not_panic() {
10935        let map = ComponentMap::with_builtin();
10936        let d = doc("", vec![elem(XmlNode::create("div"))]);
10937        for w in [
10938            Some(0.0f32),
10939            Some(-0.0),
10940            Some(-1.0),
10941            Some(f32::MAX),
10942            Some(f32::MIN),
10943            Some(f32::NAN),
10944            Some(f32::INFINITY),
10945            Some(f32::NEG_INFINITY),
10946            None,
10947        ] {
10948            assert!(
10949                str_to_dom(&d, &map, w).is_ok(),
10950                "max_width={w:?} is formatted straight into a CSS string and must not panic"
10951            );
10952        }
10953    }
10954
10955    #[test]
10956    fn str_to_dom_deeply_nested_body_is_depth_capped_not_stack_overflowing() {
10957        let map = ComponentMap::with_builtin();
10958        let deep = wrap_divs(2_000, node("div", &[("id", "bottom")], vec![]));
10959        let d = doc("", vec![elem(deep)]);
10960        assert!(
10961            str_to_dom(&d, &map, None).is_ok(),
10962            "children past MAX_XML_NESTING_DEPTH are dropped, not crashed on"
10963        );
10964    }
10965
10966    #[test]
10967    fn parse_page_style_and_body_and_body_matcher() {
10968        let d = doc("body { color: red; }", vec![elem(XmlNode::create("div"))]);
10969        let (css, body) = parse_page_style_and_body(&d).expect("well-formed page");
10970        assert_eq!(body.node_type.as_str(), "body");
10971        assert!(!css.rules.as_ref().is_empty(), "the <style> block is parsed");
10972
10973        let m = body_matcher(body);
10974        assert!(m.path.is_empty(), "the matcher starts with an empty path");
10975        assert_eq!(m.indices_in_parent, vec![0]);
10976        assert_eq!(m.children_length, vec![body.children.as_ref().len()]);
10977    }
10978
10979    #[test]
10980    fn parse_page_style_and_body_with_no_style_block() {
10981        let head = node("head", &[], vec![]);
10982        let body = node("body", &[], vec![]);
10983        let d = vec![elem(node("html", &[], vec![elem(head), elem(body)]))];
10984        let (css, body) = parse_page_style_and_body(&d).expect("ok");
10985        assert!(css.rules.as_ref().is_empty());
10986        assert_eq!(body.children.as_ref().len(), 0);
10987    }
10988
10989    // ================================================================
10990    // str_to_rust_code / str_to_c_code / str_to_cpp_code / str_to_python_code
10991    // ================================================================
10992
10993    #[test]
10994    fn str_to_rust_code_empty_input_is_an_error_not_a_panic() {
10995        let map = ComponentMap::with_builtin();
10996        assert!(matches!(
10997            str_to_rust_code(&[], "", &map),
10998            Err(CompileError::Xml(DomXmlParseError::NoHtmlNode))
10999        ));
11000        assert!(str_to_c_code(&[], &map).is_err());
11001        assert!(str_to_cpp_code(&[], &map).is_err());
11002        assert!(str_to_python_code(&[], &map).is_err());
11003    }
11004
11005    #[test]
11006    fn str_to_rust_code_whitespace_and_text_only_roots_are_errors() {
11007        let map = ComponentMap::with_builtin();
11008        for roots in [vec![txt("   ")], vec![txt("\t\n")], vec![txt("garbage")]] {
11009            assert!(
11010                str_to_rust_code(&roots, "", &map).is_err(),
11011                "a document with no <html> element must be rejected"
11012            );
11013        }
11014    }
11015
11016    #[test]
11017    fn str_to_rust_code_valid_minimal() {
11018        let map = ComponentMap::with_builtin();
11019        let d = doc("", vec![elem(node("p", &[], vec![txt("Hi")]))]);
11020        let src = str_to_rust_code(&d, "// imports", &map).expect("compiles");
11021        assert!(src.contains("Dom::create_body()"), "got:\n{src}");
11022        assert!(src.contains("Dom::create_p_with_text(AzString::from(\"Hi\"))"));
11023        assert!(src.contains("// imports"), "the imports blob is spliced in");
11024        assert!(src.contains("fn main()"));
11025    }
11026
11027    #[test]
11028    fn str_to_c_cpp_python_code_valid_minimal() {
11029        let map = ComponentMap::with_builtin();
11030        let d = doc("", vec![elem(node("p", &[], vec![txt("Hi")]))]);
11031
11032        let c = str_to_c_code(&d, &map).expect("compiles");
11033        assert!(c.contains("#include \"azul.h\""), "got:\n{c}");
11034        assert!(c.contains("AzDom n0 = AzDom_createBody();"));
11035        assert!(c.contains("AzDom_createPWithText(AZ_STR(\"Hi\"))"));
11036
11037        let cpp = str_to_cpp_code(&d, &map).expect("compiles");
11038        assert!(cpp.contains("#include \"azul20.hpp\""), "got:\n{cpp}");
11039        assert!(cpp.contains("Dom::create_p_with_text(String(\"Hi\"))"));
11040
11041        let py = str_to_python_code(&d, &map).expect("compiles");
11042        assert!(py.contains("import azul"), "got:\n{py}");
11043        assert!(py.contains("azul.Dom.create_p_with_text(\"Hi\")"));
11044    }
11045
11046    #[test]
11047    fn compile_targets_escape_quotes_in_text_content() {
11048        let map = ComponentMap::with_builtin();
11049        let d = doc("", vec![elem(node("div", &[], vec![txt("say \"hi\"")]))]);
11050
11051        let rust = str_to_rust_code(&d, "", &map).expect("compiles");
11052        assert!(rust.contains("say \\\"hi\\\""), "got:\n{rust}");
11053        let c = str_to_c_code(&d, &map).expect("compiles");
11054        assert!(c.contains("say \\\"hi\\\""), "got:\n{c}");
11055    }
11056
11057    #[test]
11058    fn compile_body_node_to_rust_code_on_an_empty_body() {
11059        let map = ComponentMap::with_builtin();
11060        let body = node("body", &[], vec![]);
11061        let mut extra = VecContents::default();
11062        let mut blocks = BTreeMap::new();
11063        let out = compile_body_node_to_rust_code(
11064            &body,
11065            &map,
11066            &mut extra,
11067            &mut blocks,
11068            &Css::empty(),
11069            body_matcher(&body),
11070        )
11071        .expect("ok");
11072        assert_eq!(out, "Dom::create_body()", "no children => no .with_children()");
11073    }
11074
11075    #[test]
11076    fn compile_body_node_to_rust_code_skips_whitespace_only_text_children() {
11077        let map = ComponentMap::with_builtin();
11078        let body = node("body", &[], vec![txt("   \n\t ")]);
11079        let mut extra = VecContents::default();
11080        let mut blocks = BTreeMap::new();
11081        let out = compile_body_node_to_rust_code(
11082            &body,
11083            &map,
11084            &mut extra,
11085            &mut blocks,
11086            &Css::empty(),
11087            body_matcher(&body),
11088        )
11089        .expect("ok");
11090        assert!(
11091            !out.contains("create_text"),
11092            "a whitespace-only text child emits nothing, got:\n{out}"
11093        );
11094    }
11095
11096    // ================================================================
11097    // builtin_render_fn / builtin_compile_fn  (numeric: indent)
11098    // ================================================================
11099
11100    #[test]
11101    fn builtin_render_fn_for_a_text_and_a_textless_element() {
11102        let map = ComponentMap::with_builtin();
11103        let div = map.get_unqualified("div").expect("builtin div");
11104        assert!(matches!(
11105            builtin_render_fn(div, &div.data_model, &map),
11106            ResultStyledDomRenderDomError::Ok(_)
11107        ));
11108
11109        let p = map.get_unqualified("p").expect("builtin p");
11110        assert!(matches!(
11111            builtin_render_fn(p, &p.data_model, &map),
11112            ResultStyledDomRenderDomError::Ok(_)
11113        ));
11114    }
11115
11116    #[test]
11117    fn builtin_compile_fn_ignores_indent_so_usize_max_is_safe() {
11118        let map = ComponentMap::with_builtin();
11119        let div = map.get_unqualified("div").expect("builtin div");
11120        for indent in [0usize, 1, 1024, usize::MAX] {
11121            match builtin_compile_fn(div, &CompileTarget::Rust, &div.data_model, indent) {
11122                ResultStringCompileError::Ok(s) => assert_eq!(
11123                    s.as_str(),
11124                    "Dom::create_node(NodeType::Div)",
11125                    "indent is unused by builtin_compile_fn (indent={indent})"
11126                ),
11127                ResultStringCompileError::Err(e) => panic!("unexpected error: {e:?}"),
11128            }
11129        }
11130    }
11131
11132    #[test]
11133    fn builtin_compile_fn_emits_text_and_escapes_it() {
11134        let map = ComponentMap::with_builtin();
11135        let p = map.get_unqualified("p").expect("builtin p");
11136        let data = p
11137            .data_model
11138            .clone()
11139            .with_default("text", ComponentDefaultValue::String(AzString::from("a\"b\\c")));
11140
11141        match builtin_compile_fn(p, &CompileTarget::Rust, &data, 0) {
11142            ResultStringCompileError::Ok(s) => {
11143                assert!(s.as_str().contains("a\\\"b\\\\c"), "got {}", s.as_str());
11144            }
11145            ResultStringCompileError::Err(e) => panic!("unexpected error: {e:?}"),
11146        }
11147    }
11148
11149    #[test]
11150    fn builtin_compile_fn_covers_every_target() {
11151        let map = ComponentMap::with_builtin();
11152        let div = map.get_unqualified("div").expect("builtin div");
11153        for target in [
11154            CompileTarget::Rust,
11155            CompileTarget::C,
11156            CompileTarget::Cpp,
11157            CompileTarget::Python,
11158        ] {
11159            match builtin_compile_fn(div, &target, &div.data_model, 0) {
11160                ResultStringCompileError::Ok(s) => {
11161                    assert!(!s.as_str().is_empty(), "{target:?} emitted nothing");
11162                }
11163                ResultStringCompileError::Err(e) => panic!("{target:?}: {e:?}"),
11164            }
11165        }
11166    }
11167
11168    // ================================================================
11169    // user_defined_render_fn / user_defined_compile_fn
11170    // ================================================================
11171
11172    fn every_default_kind() -> Vec<ComponentDataField> {
11173        use ComponentDefaultValue as D;
11174        vec![
11175            data_field("s", ComponentFieldType::String, Some(D::String(AzString::from("txt"))), ""),
11176            data_field("b", ComponentFieldType::Bool, Some(D::Bool(true)), ""),
11177            data_field("i32", ComponentFieldType::I32, Some(D::I32(i32::MIN)), ""),
11178            data_field("i64", ComponentFieldType::I64, Some(D::I64(i64::MIN)), ""),
11179            data_field("u32", ComponentFieldType::U32, Some(D::U32(u32::MAX)), ""),
11180            data_field("u64", ComponentFieldType::U64, Some(D::U64(u64::MAX)), ""),
11181            data_field("us", ComponentFieldType::Usize, Some(D::Usize(usize::MAX)), ""),
11182            data_field("f32", ComponentFieldType::F32, Some(D::F32(f32::NAN)), ""),
11183            data_field("f64", ComponentFieldType::F64, Some(D::F64(f64::INFINITY)), ""),
11184            data_field(
11185                "c",
11186                ComponentFieldType::ColorU,
11187                Some(D::ColorU(ColorU { r: 0, g: 0, b: 0, a: 0 })),
11188                "",
11189            ),
11190            data_field("cb", ComponentFieldType::StyledDom, Some(D::CallbackFnPointer(AzString::from("on_click"))), ""),
11191            data_field("j", ComponentFieldType::String, Some(D::Json(AzString::from("{}"))), ""),
11192            data_field("none", ComponentFieldType::String, Some(D::None), ""),
11193            data_field("missing", ComponentFieldType::String, None, ""),
11194        ]
11195    }
11196
11197    #[test]
11198    fn user_defined_render_fn_handles_every_default_value_kind() {
11199        let map = ComponentMap::with_builtin();
11200        let def = user_def("", every_default_kind());
11201        assert!(matches!(
11202            user_defined_render_fn(&def, &def.data_model, &map),
11203            ResultStyledDomRenderDomError::Ok(_)
11204        ));
11205    }
11206
11207    #[test]
11208    fn user_defined_render_fn_on_an_empty_model_and_with_css() {
11209        let map = ComponentMap::with_builtin();
11210        let empty = user_def("", Vec::new());
11211        assert!(matches!(
11212            user_defined_render_fn(&empty, &empty.data_model, &map),
11213            ResultStyledDomRenderDomError::Ok(_)
11214        ));
11215
11216        let styled = user_def(".widget { color: red; }", Vec::new());
11217        assert!(matches!(
11218            user_defined_render_fn(&styled, &styled.data_model, &map),
11219            ResultStyledDomRenderDomError::Ok(_)
11220        ));
11221    }
11222
11223    #[test]
11224    fn user_defined_render_fn_unknown_sub_component_renders_a_placeholder() {
11225        let map = ComponentMap::create(); // empty: no library can resolve the instance
11226        let def = user_def(
11227            "",
11228            vec![data_field(
11229                "child",
11230                ComponentFieldType::StyledDom,
11231                Some(ComponentDefaultValue::ComponentInstance(ComponentInstanceDefault {
11232                    library: AzString::from("nope"),
11233                    component: AzString::from("missing"),
11234                    field_overrides: Vec::new().into(),
11235                })),
11236                "",
11237            )],
11238        );
11239        assert!(
11240            matches!(
11241                user_defined_render_fn(&def, &def.data_model, &map),
11242                ResultStyledDomRenderDomError::Ok(_)
11243            ),
11244            "an unresolvable sub-component must render a placeholder, not error out"
11245        );
11246    }
11247
11248    #[test]
11249    fn user_defined_compile_fn_indent_zero_and_every_target() {
11250        let def = user_def("", every_default_kind());
11251        for target in [
11252            CompileTarget::Rust,
11253            CompileTarget::C,
11254            CompileTarget::Cpp,
11255            CompileTarget::Python,
11256        ] {
11257            match user_defined_compile_fn(&def, &target, &def.data_model, 0) {
11258                ResultStringCompileError::Ok(s) => {
11259                    assert!(!s.as_str().is_empty(), "{target:?} emitted nothing");
11260                }
11261                ResultStringCompileError::Err(e) => panic!("{target:?}: {e:?}"),
11262            }
11263        }
11264    }
11265
11266    #[test]
11267    fn user_defined_compile_fn_indent_scales_the_leading_whitespace() {
11268        // NOTE: `indent` is used as `" ".repeat(indent * 4)`, so it is NOT safe at
11269        // usize::MAX (the multiply overflows). Exercise the realistic range.
11270        let def = user_def("", Vec::new());
11271        let mut prev = 0usize;
11272        for indent in [0usize, 1, 2, 8] {
11273            match user_defined_compile_fn(&def, &CompileTarget::Rust, &def.data_model, indent) {
11274                ResultStringCompileError::Ok(s) => {
11275                    let len = s.as_str().len();
11276                    assert!(len > prev, "indent={indent} must widen the output");
11277                    prev = len;
11278                }
11279                ResultStringCompileError::Err(e) => panic!("indent={indent}: {e:?}"),
11280            }
11281        }
11282    }
11283
11284    #[test]
11285    fn user_defined_compile_fn_escapes_string_defaults() {
11286        let def = user_def(
11287            "",
11288            vec![data_field(
11289                "s",
11290                ComponentFieldType::String,
11291                Some(ComponentDefaultValue::String(AzString::from("a\"b\\c"))),
11292                "",
11293            )],
11294        );
11295        match user_defined_compile_fn(&def, &CompileTarget::Rust, &def.data_model, 0) {
11296            ResultStringCompileError::Ok(s) => {
11297                assert!(s.as_str().contains("a\\\"b\\\\c"), "got:\n{}", s.as_str());
11298            }
11299            ResultStringCompileError::Err(e) => panic!("{e:?}"),
11300        }
11301    }
11302
11303    #[test]
11304    fn push_scalar_field_appends_one_div_per_call() {
11305        let mut children: Vec<Dom> = Vec::new();
11306        push_scalar_field(&mut children, "n", &i64::MIN);
11307        push_scalar_field(&mut children, "", &f32::NAN);
11308        push_scalar_field(&mut children, "\u{1F600}", &usize::MAX);
11309        assert_eq!(children.len(), 3);
11310    }
11311
11312    // ================================================================
11313    // Structural builtins: if / for / map
11314    // ================================================================
11315
11316    #[test]
11317    fn builtin_if_for_map_component_defs_are_well_formed() {
11318        for (def, model, field) in [
11319            (builtin_if_component(), "IfData", "condition"),
11320            (builtin_for_component(), "ForData", "count"),
11321            (builtin_map_component(), "MapData", "data_json"),
11322        ] {
11323            assert_eq!(def.id.collection.as_str(), "builtin");
11324            assert_eq!(def.data_model.name.as_str(), model);
11325            assert!(
11326                def.data_model.get_field(field).is_some(),
11327                "{model} must expose `{field}`"
11328            );
11329        }
11330    }
11331
11332    #[test]
11333    fn builtin_if_render_fn_defaults_to_the_else_branch() {
11334        let map = ComponentMap::create();
11335        let def = builtin_if_component();
11336        // Missing / wrongly-typed condition => false, no panic.
11337        let empty = dm("IfData", Vec::new());
11338        assert!(matches!(
11339            builtin_if_render_fn(&def, &empty, &map),
11340            ResultStyledDomRenderDomError::Ok(_)
11341        ));
11342
11343        let truthy = def
11344            .data_model
11345            .clone()
11346            .with_default("condition", ComponentDefaultValue::Bool(true));
11347        assert!(matches!(
11348            builtin_if_render_fn(&def, &truthy, &map),
11349            ResultStyledDomRenderDomError::Ok(_)
11350        ));
11351    }
11352
11353    #[test]
11354    fn builtin_for_render_fn_handles_zero_and_a_wrongly_typed_count() {
11355        let map = ComponentMap::create();
11356        let def = builtin_for_component();
11357
11358        let zero = def
11359            .data_model
11360            .clone()
11361            .with_default("count", ComponentDefaultValue::U32(0));
11362        assert!(matches!(
11363            builtin_for_render_fn(&def, &zero, &map),
11364            ResultStyledDomRenderDomError::Ok(_)
11365        ));
11366
11367        // A non-U32 default falls back to the documented default of 3.
11368        let wrong_type = def
11369            .data_model
11370            .clone()
11371            .with_default("count", ComponentDefaultValue::String(AzString::from("9")));
11372        assert!(matches!(
11373            builtin_for_render_fn(&def, &wrong_type, &map),
11374            ResultStyledDomRenderDomError::Ok(_)
11375        ));
11376    }
11377
11378    #[test]
11379    fn builtin_map_render_fn_defaults_to_an_empty_json_array() {
11380        let map = ComponentMap::create();
11381        let def = builtin_map_component();
11382        assert!(matches!(
11383            builtin_map_render_fn(&def, &dm("MapData", Vec::new()), &map),
11384            ResultStyledDomRenderDomError::Ok(_)
11385        ));
11386        let garbage = def
11387            .data_model
11388            .clone()
11389            .with_default("data_json", ComponentDefaultValue::String(AzString::from("{{{")));
11390        assert!(
11391            matches!(
11392                builtin_map_render_fn(&def, &garbage, &map),
11393                ResultStyledDomRenderDomError::Ok(_)
11394            ),
11395            "malformed JSON must not panic — it is only echoed into a label"
11396        );
11397    }
11398
11399    #[test]
11400    fn structural_builtin_compile_fns_ignore_indent_entirely() {
11401        let cases: [(ComponentDef, ComponentCompileFn); 3] = [
11402            (builtin_if_component(), builtin_if_compile_fn),
11403            (builtin_for_component(), builtin_for_compile_fn),
11404            (builtin_map_component(), builtin_map_compile_fn),
11405        ];
11406        for (def, f) in cases {
11407            for target in [
11408                CompileTarget::Rust,
11409                CompileTarget::C,
11410                CompileTarget::Cpp,
11411                CompileTarget::Python,
11412            ] {
11413                for indent in [0usize, usize::MAX] {
11414                    match f(&def, &target, &def.data_model, indent) {
11415                        ResultStringCompileError::Ok(s) => {
11416                            assert!(!s.as_str().is_empty(), "{target:?}/{indent} emitted nothing");
11417                        }
11418                        ResultStringCompileError::Err(e) => panic!("{target:?}: {e:?}"),
11419                    }
11420                }
11421            }
11422        }
11423    }
11424
11425    // ================================================================
11426    // data_field / builtin_data_model / builtin_component_def
11427    // ================================================================
11428
11429    #[test]
11430    fn data_field_required_is_the_inverse_of_having_a_default() {
11431        let with = data_field(
11432            "x",
11433            ComponentFieldType::String,
11434            Some(ComponentDefaultValue::String(AzString::from("v"))),
11435            "d",
11436        );
11437        assert!(!with.required);
11438        assert_eq!(with.description.as_str(), "d");
11439
11440        let without = data_field("x", ComponentFieldType::String, None, "");
11441        assert!(without.required);
11442        assert!(matches!(
11443            without.default_value,
11444            OptionComponentDefaultValue::None
11445        ));
11446    }
11447
11448    #[test]
11449    fn builtin_data_model_unknown_tag_is_empty() {
11450        assert!(builtin_data_model("").is_empty());
11451        assert!(builtin_data_model("div").is_empty());
11452        assert!(builtin_data_model("\u{1F600}").is_empty());
11453        assert!(builtin_data_model(&"z".repeat(10_000)).is_empty());
11454    }
11455
11456    #[test]
11457    fn builtin_data_model_known_tags_expose_their_attributes() {
11458        let a = builtin_data_model("a");
11459        assert!(
11460            a.iter().any(|f| f.name.as_str() == "href"),
11461            "<a> must expose href"
11462        );
11463        // `src` on <img> is required (it has no default value).
11464        let img = builtin_data_model("img");
11465        let src = img
11466            .iter()
11467            .find(|f| f.name.as_str() == "src")
11468            .expect("img has src");
11469        assert!(src.required, "<img src> must be a required field");
11470        // `img` and `image` share the same model.
11471        assert_eq!(builtin_data_model("image").len(), img.len());
11472    }
11473
11474    #[test]
11475    fn builtin_component_def_default_text_controls_the_text_field() {
11476        let with_text = builtin_component_def("p", "Paragraph", Some("Hi"), "");
11477        assert_eq!(
11478            with_text.data_model.get_default_string("text").map(AzString::as_str),
11479            Some("Hi")
11480        );
11481        assert_eq!(with_text.data_model.name.as_str(), "ParagraphData");
11482        assert_eq!(with_text.id.qualified_name(), "builtin:p");
11483
11484        let no_text = builtin_component_def("div", "Div", None, "");
11485        assert!(
11486            no_text.data_model.get_field("text").is_none(),
11487            "a `None` default_text means the element has no text field at all"
11488        );
11489
11490        // An empty-string default still creates the field.
11491        let empty_text = builtin_component_def("span", "Span", Some(""), "");
11492        assert!(empty_text.data_model.get_field("text").is_some());
11493        assert_eq!(
11494            empty_text.data_model.get_default_string("text").map(AzString::as_str),
11495            Some("")
11496        );
11497    }
11498
11499    // ================================================================
11500    // xml_attrs_to_data_model
11501    // ================================================================
11502
11503    #[test]
11504    fn xml_attrs_to_data_model_overrides_defaults_from_attributes() {
11505        let base = builtin_component_def("a", "Link", Some("Link text"), "").data_model;
11506        let model = xml_attrs_to_data_model(&base, &attrs(&[("href", "/x")]), None);
11507        assert_eq!(
11508            model.get_default_string("href").map(AzString::as_str),
11509            Some("/x")
11510        );
11511        assert_eq!(
11512            model.get_default_string("text").map(AzString::as_str),
11513            Some("Link text"),
11514            "un-supplied fields keep their defaults"
11515        );
11516        assert_eq!(
11517            model.fields.as_ref().len(),
11518            base.fields.as_ref().len(),
11519            "no field is added or dropped"
11520        );
11521    }
11522
11523    #[test]
11524    fn xml_attrs_to_data_model_text_content_is_prepared_and_empty_text_is_ignored() {
11525        let base = builtin_component_def("a", "Link", Some("Link text"), "").data_model;
11526
11527        let with_text = xml_attrs_to_data_model(&base, &attrs(&[]), Some("  Hello &amp; bye  "));
11528        assert_eq!(
11529            with_text.get_default_string("text").map(AzString::as_str),
11530            Some("Hello & bye"),
11531            "text content is trimmed and entity-decoded"
11532        );
11533
11534        let blank = xml_attrs_to_data_model(&base, &attrs(&[]), Some("   \n\t "));
11535        assert_eq!(
11536            blank.get_default_string("text").map(AzString::as_str),
11537            Some("Link text"),
11538            "whitespace-only text content leaves the default intact"
11539        );
11540    }
11541
11542    #[test]
11543    fn xml_attrs_to_data_model_ignores_unknown_attributes() {
11544        let base = builtin_component_def("a", "Link", Some(""), "").data_model;
11545        let before = base.fields.as_ref().len();
11546        let model = xml_attrs_to_data_model(
11547            &base,
11548            &attrs(&[("data-nonsense", "1"), ("", ""), ("\u{1F600}", "x")]),
11549            None,
11550        );
11551        assert_eq!(
11552            model.fields.as_ref().len(),
11553            before,
11554            "unknown attributes must not create fields"
11555        );
11556    }
11557
11558    // ================================================================
11559    // DomXml
11560    // ================================================================
11561
11562    #[test]
11563    fn dom_xml_into_styled_dom_matches_the_from_impl() {
11564        let via_method: StyledDom = DomXml::default().into_styled_dom();
11565        let via_from: StyledDom = DomXml::default().into();
11566        assert_eq!(
11567            via_method, via_from,
11568            "into_styled_dom() must be exactly the From<DomXml> impl"
11569        );
11570    }
11571
11572    // ================================================================
11573    // Display impls  (serializer)
11574    // ================================================================
11575
11576    fn pos() -> XmlTextPos {
11577        XmlTextPos {
11578            row: u32::MAX,
11579            col: 0,
11580        }
11581    }
11582
11583    #[test]
11584    fn xml_text_pos_display_is_non_empty_for_edge_values() {
11585        assert_eq!(
11586            format!("{}", XmlTextPos { row: 0, col: 0 }),
11587            "line 0:0",
11588            "a zero position is still rendered"
11589        );
11590        assert_eq!(
11591            format!(
11592                "{}",
11593                XmlTextPos {
11594                    row: u32::MAX,
11595                    col: u32::MAX
11596                }
11597            ),
11598            "line 4294967295:4294967295"
11599        );
11600    }
11601
11602    #[test]
11603    fn xml_stream_error_display_covers_every_variant() {
11604        let variants = vec![
11605            XmlStreamError::UnexpectedEndOfStream,
11606            XmlStreamError::InvalidName,
11607            XmlStreamError::NonXmlChar(NonXmlCharError {
11608                ch: u32::MAX,
11609                pos: pos(),
11610            }),
11611            XmlStreamError::InvalidChar(InvalidCharError {
11612                expected: u8::MAX,
11613                got: 0,
11614                pos: pos(),
11615            }),
11616            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
11617                expected: 0,
11618                got: Vec::<u8>::new().into(),
11619                pos: pos(),
11620            }),
11621            XmlStreamError::InvalidQuote(InvalidQuoteError { got: 0, pos: pos() }),
11622            XmlStreamError::InvalidSpace(InvalidSpaceError { got: 0, pos: pos() }),
11623            XmlStreamError::InvalidString(InvalidStringError {
11624                got: AzString::from(""),
11625                pos: pos(),
11626            }),
11627            XmlStreamError::InvalidReference,
11628            XmlStreamError::InvalidExternalID,
11629            XmlStreamError::InvalidCommentData,
11630            XmlStreamError::InvalidCommentEnd,
11631            XmlStreamError::InvalidCharacterData,
11632        ];
11633        for v in &variants {
11634            let s = format!("{v}");
11635            assert!(!s.is_empty(), "{v:?} must render a non-empty message");
11636        }
11637        // `char::from_u32(u32::MAX)` is None — the formatter must not unwrap it.
11638        assert!(format!("{}", variants[2]).contains("None"));
11639    }
11640
11641    #[test]
11642    fn xml_parse_error_display_covers_every_variant() {
11643        let te = XmlTextError {
11644            stream_error: XmlStreamError::InvalidName,
11645            pos: pos(),
11646        };
11647        let variants = vec![
11648            XmlParseError::InvalidDeclaration(te.clone()),
11649            XmlParseError::InvalidComment(te.clone()),
11650            XmlParseError::InvalidPI(te.clone()),
11651            XmlParseError::InvalidDoctype(te.clone()),
11652            XmlParseError::InvalidEntity(te.clone()),
11653            XmlParseError::InvalidElement(te.clone()),
11654            XmlParseError::InvalidAttribute(te.clone()),
11655            XmlParseError::InvalidCdata(te.clone()),
11656            XmlParseError::InvalidCharData(te),
11657            XmlParseError::UnknownToken(pos()),
11658        ];
11659        for v in &variants {
11660            assert!(!format!("{v}").is_empty(), "{v:?} must render");
11661        }
11662    }
11663
11664    #[test]
11665    fn xml_error_display_covers_the_non_css_variants() {
11666        let variants = vec![
11667            XmlError::NoParserAvailable,
11668            XmlError::InvalidXmlPrefixUri(pos()),
11669            XmlError::UnexpectedXmlUri(pos()),
11670            XmlError::UnexpectedXmlnsUri(pos()),
11671            XmlError::InvalidElementNamePrefix(pos()),
11672            XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
11673                ns: AzString::from(""),
11674                pos: pos(),
11675            }),
11676            XmlError::UnknownNamespace(UnknownNamespaceError {
11677                ns: AzString::from("\u{1F600}"),
11678                pos: pos(),
11679            }),
11680            XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
11681                expected: AzString::from("a"),
11682                actual: AzString::from("b"),
11683                pos: pos(),
11684            }),
11685            XmlError::UnexpectedEntityCloseTag(pos()),
11686            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
11687                entity: AzString::from("x"),
11688                pos: pos(),
11689            }),
11690            XmlError::MalformedEntityReference(pos()),
11691            XmlError::EntityReferenceLoop(pos()),
11692            XmlError::InvalidAttributeValue(pos()),
11693            XmlError::DuplicatedAttribute(DuplicatedAttributeError {
11694                attribute: AzString::from("id"),
11695                pos: pos(),
11696            }),
11697            XmlError::NoRootNode,
11698            XmlError::SizeLimit,
11699            XmlError::DtdDetected,
11700            XmlError::MalformedHierarchy(MalformedHierarchyError {
11701                expected: AzString::from("app"),
11702                got: AzString::from("p"),
11703            }),
11704            XmlError::ParserError(XmlParseError::UnknownToken(pos())),
11705            XmlError::UnclosedRootNode,
11706            XmlError::UnexpectedDeclaration(pos()),
11707            XmlError::NodesLimitReached,
11708            XmlError::AttributesLimitReached,
11709            XmlError::NamespacesLimitReached,
11710            XmlError::InvalidName(pos()),
11711            XmlError::NonXmlChar(pos()),
11712            XmlError::InvalidChar(pos()),
11713            XmlError::InvalidChar2(pos()),
11714            XmlError::InvalidString(pos()),
11715            XmlError::InvalidExternalID(pos()),
11716            XmlError::InvalidComment(pos()),
11717            XmlError::InvalidCharacterData(pos()),
11718            XmlError::UnknownToken(pos()),
11719            XmlError::UnexpectedEndOfStream,
11720        ];
11721        for v in &variants {
11722            assert!(!format!("{v}").is_empty(), "{v:?} must render");
11723        }
11724    }
11725
11726    #[test]
11727    fn component_and_render_and_compile_error_display() {
11728        let unknown = ComponentError::UnknownComponent(AzString::from("\u{1F600}"));
11729        assert!(format!("{unknown}").contains("Unknown component"));
11730
11731        let useless = ComponentError::UselessFunctionArgument(UselessFunctionArgumentError {
11732            component_name: AzString::from("c"),
11733            argument_name: AzString::from("a"),
11734            valid_args: Vec::<AzString>::new().into(),
11735        });
11736        assert!(!format!("{useless}").is_empty());
11737
11738        let render: RenderDomError = unknown.clone().into();
11739        assert!(!format!("{render}").is_empty());
11740
11741        let compile: CompileError = render.clone().into();
11742        assert!(!format!("{compile}").is_empty());
11743
11744        let dom_xml: DomXmlParseError = render.into();
11745        assert!(!format!("{dom_xml}").is_empty());
11746        let compile2: CompileError = dom_xml.into();
11747        assert!(!format!("{compile2}").is_empty());
11748    }
11749
11750    #[test]
11751    fn dom_xml_parse_error_display_covers_the_non_css_variants() {
11752        let variants = vec![
11753            DomXmlParseError::NoHtmlNode,
11754            DomXmlParseError::MultipleHtmlRootNodes,
11755            DomXmlParseError::NoBodyInHtml,
11756            DomXmlParseError::MultipleBodyNodes,
11757            DomXmlParseError::Xml(XmlError::NoRootNode),
11758            DomXmlParseError::MalformedHierarchy(MalformedHierarchyError {
11759                expected: AzString::from("app"),
11760                got: AzString::from("p"),
11761            }),
11762            DomXmlParseError::RenderDom(RenderDomError::Component(
11763                ComponentError::UnknownComponent(AzString::from("x")),
11764            )),
11765            DomXmlParseError::Component(ComponentParseError::NotAComponent),
11766        ];
11767        for v in &variants {
11768            assert!(!format!("{v}").is_empty(), "{v:?} must render");
11769        }
11770    }
11771
11772    #[test]
11773    fn component_parse_error_display_covers_the_non_css_variants() {
11774        let variants = vec![
11775            ComponentParseError::NotAComponent,
11776            ComponentParseError::UnnamedComponent,
11777            ComponentParseError::MissingName(usize::MAX),
11778            ComponentParseError::MissingType(MissingTypeError {
11779                arg_pos: 0,
11780                arg_name: AzString::from(""),
11781            }),
11782            ComponentParseError::WhiteSpaceInComponentName(WhiteSpaceInComponentNameError {
11783                arg_pos: usize::MAX,
11784                arg_name: AzString::from("a b"),
11785            }),
11786            ComponentParseError::WhiteSpaceInComponentType(WhiteSpaceInComponentTypeError {
11787                arg_pos: 0,
11788                arg_name: AzString::from("a"),
11789                arg_type: AzString::from("b c"),
11790            }),
11791        ];
11792        for v in &variants {
11793            assert!(!format!("{v}").is_empty(), "{v:?} must render");
11794        }
11795    }
11796
11797    // ================================================================
11798    // serde-json gated: ComponentDataModel::to_json / from_json
11799    // ================================================================
11800
11801    #[cfg(feature = "serde-json")]
11802    #[test]
11803    fn data_model_to_json_round_trips() {
11804        let m = model_with_text();
11805        let json = m.to_json().expect("serializes");
11806        let back = ComponentDataModel::from_json(&json).expect("deserializes");
11807        assert_eq!(back.name.as_str(), m.name.as_str());
11808        assert_eq!(back.fields.as_ref().len(), m.fields.as_ref().len());
11809        assert_eq!(back.get_default_string("text").map(AzString::as_str), Some("hi"));
11810    }
11811
11812    #[cfg(feature = "serde-json")]
11813    #[test]
11814    fn data_model_from_json_rejects_garbage_without_panicking() {
11815        for s in [
11816            "",
11817            "   ",
11818            "\t\n",
11819            "not json",
11820            "{",
11821            "[]",
11822            "null",
11823            "0",
11824            "-0",
11825            "9223372036854775807",
11826            "NaN",
11827            "\u{1F600}",
11828        ] {
11829            assert!(
11830                ComponentDataModel::from_json(s).is_err(),
11831                "{s:?} is not a data model"
11832            );
11833        }
11834    }
11835
11836    #[cfg(feature = "serde-json")]
11837    #[test]
11838    fn data_model_from_json_deeply_nested_input_does_not_stack_overflow() {
11839        let bomb = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
11840        assert!(
11841            ComponentDataModel::from_json(&bomb).is_err(),
11842            "serde_json must reject the nesting bomb, not crash"
11843        );
11844    }
11845
11846    #[cfg(feature = "serde-json")]
11847    #[test]
11848    fn data_model_to_json_on_an_empty_model() {
11849        let m = dm("Empty", Vec::new());
11850        let json = m.to_json().expect("serializes");
11851        assert!(json.contains("\"fields\""), "got {json}");
11852        assert!(ComponentDataModel::from_json(&json).is_ok());
11853    }
11854}