Skip to main content

azul_layout/xml/
mod.rs

1//! XML/HTML parsing module for the Azul toolkit.
2//!
3//! Provides two parsing paths:
4//! - `parse_xml_string`: builds an `XmlNode` tree (used by `domxml_from_str`)
5//! - `parse_xml_to_fast_dom_with_css`: builds an arena-based `FastDom` directly
6//!   from XML tokens (used by `parse_xml_to_styled_dom`)
7//!
8//! Both paths handle HTML5-lite features: void elements, auto-closing tags,
9//! XML entity decoding, `<style>` CSS extraction, and BOM/DOCTYPE stripping.
10//!
11//! Data types (`XmlNode`, `XmlError`, etc.) live in `azul_core::xml`; this
12//! module provides the parsing implementations.
13
14#![allow(unused_variables)]
15
16use alloc::{boxed::Box, collections::BTreeMap, string::String, vec::Vec};
17use core::fmt;
18#[cfg(feature = "std")]
19use std::path::Path;
20
21#[cfg(feature = "svg")]
22pub mod svg;
23
24/// Decodes XML/HTML entities in a string.
25/// Handles standard XML entities: &lt; &gt; &amp; &apos; &quot;
26/// and numeric character references: &#60; &#x3C;
27/// Returns `Cow::Borrowed` when no entities are found (zero-alloc fast path).
28fn decode_xml_entities(s: &str) -> std::borrow::Cow<'_, str> {
29    // Fast path: if no ampersand, no entities to decode
30    if !s.contains('&') {
31        return std::borrow::Cow::Borrowed(s);
32    }
33    decode_xml_entities_slow(s)
34}
35
36fn decode_xml_entities_slow(s: &str) -> std::borrow::Cow<'_, str> {
37    let mut result = String::with_capacity(s.len());
38    let mut chars = s.chars().peekable();
39    
40    while let Some(c) = chars.next() {
41        if c == '&' {
42            // Collect the entity reference
43            let mut entity = String::new();
44            let mut found_semicolon = false;
45            
46            while let Some(&next) = chars.peek() {
47                if next == ';' {
48                    chars.next();
49                    found_semicolon = true;
50                    break;
51                }
52                if !next.is_alphanumeric() && next != '#' {
53                    break;
54                }
55                entity.push(chars.next().unwrap());
56                if entity.len() > 10 {
57                    // Entity too long, not a valid entity
58                    break;
59                }
60            }
61            
62            if found_semicolon {
63                // Try to decode the entity
64                match entity.as_str() {
65                    "lt" => result.push('<'),
66                    "gt" => result.push('>'),
67                    "amp" => result.push('&'),
68                    "apos" => result.push('\''),
69                    "quot" => result.push('"'),
70                    "nbsp" => result.push('\u{00A0}'),
71                    s if s.starts_with('#') => {
72                        // Numeric character reference
73                        let num_str = &s[1..];
74                        let code_point = if num_str.starts_with('x') || num_str.starts_with('X') {
75                            // Hexadecimal
76                            u32::from_str_radix(&num_str[1..], 16).ok()
77                        } else {
78                            // Decimal
79                            num_str.parse::<u32>().ok()
80                        };
81                        if let Some(cp) = code_point {
82                            if let Some(ch) = char::from_u32(cp) {
83                                result.push(ch);
84                            } else {
85                                // Invalid code point, keep original
86                                result.push('&');
87                                result.push_str(&entity);
88                                result.push(';');
89                            }
90                        } else {
91                            // Parse failed, keep original
92                            result.push('&');
93                            result.push_str(&entity);
94                            result.push(';');
95                        }
96                    }
97                    _ => {
98                        // Unknown entity, keep original
99                        result.push('&');
100                        result.push_str(&entity);
101                        result.push(';');
102                    }
103                }
104            } else {
105                // No semicolon found, not a valid entity reference
106                result.push('&');
107                result.push_str(&entity);
108            }
109        } else {
110            result.push(c);
111        }
112    }
113    
114    std::borrow::Cow::Owned(result)
115}
116
117pub use azul_core::xml::*;
118use azul_core::{dom::Dom, impl_from, styled_dom::StyledDom, window::StringPairVec};
119#[cfg(feature = "parser")]
120use azul_css::parser2::CssParseError;
121use azul_css::{css::Css, AzString, OptionString, U8Vec};
122use xmlparser::Tokenizer;
123
124#[cfg(feature = "xml")]
125#[must_use] pub fn domxml_from_str(xml: &str, component_map: &ComponentMap) -> DomXml {
126    let error_css = Css::empty();
127
128    let parsed = match parse_xml_string(xml) {
129        Ok(parsed) => parsed,
130        Err(e) => {
131            return DomXml {
132                parsed_dom: {
133                    let mut dom = Dom::create_body()
134                        .with_children(vec![Dom::create_text(format!("{e}"))].into());
135                    StyledDom::create(&mut dom, error_css)
136                },
137            };
138        }
139    };
140
141    let parsed_dom = match str_to_dom(parsed.as_ref(), component_map, None) {
142        Ok(o) => o,
143        Err(e) => {
144            return DomXml {
145                parsed_dom: {
146                    let mut dom = Dom::create_body()
147                        .with_children(vec![Dom::create_text(format!("{e}"))].into());
148                    StyledDom::create(&mut dom, error_css)
149                },
150            };
151        }
152    };
153
154    DomXml { parsed_dom }
155}
156
157/// Create a Dom (with CSS attached but not applied) from an already-parsed Xml structure.
158///
159/// Returns an unstyled `Dom` suitable for use in layout callbacks (which return `Dom`,
160/// not `StyledDom`). The CSS from `<style>` tags is attached to the `Dom.css` field
161/// and will be applied during the cascade pass.
162// FFI-exported (api.json fn_body azul_layout::xml::dom_from_parsed_xml(xml)): owned Xml by value.
163#[allow(clippy::needless_pass_by_value)]
164#[must_use] pub fn dom_from_parsed_xml(xml: Xml) -> Dom {
165    let component_map = ComponentMap::with_builtin();
166    match str_to_dom_unstyled(xml.root.as_ref(), &component_map) {
167        Ok(dom) => dom,
168        Err(e) => Dom::create_body().with_children(vec![Dom::create_text(format!("{e}"))].into()),
169    }
170}
171
172/// Fastest path: parse XML string directly into `FastDom` without intermediate `XmlNode` tree.
173///
174/// Feeds XML tokenizer events directly into `CompactDomBuilder`, skipping both the
175/// `XmlNode` tree construction AND the Dom tree construction.
176/// Parse XML string directly into a `FastDom` (arena-based DOM) in a single pass.
177///
178/// Also extracts `<style>` tag content as CSS. Returns both the `FastDom` and
179/// collected CSS stylesheets. No intermediate `XmlNode` tree is built.
180///
181/// This is the fastest XML→DOM path: XML tokens feed directly into
182/// `CompactDomBuilder`, and `<style>` text is collected inline.
183/// # Errors
184///
185/// Returns an `XmlError` if the XML cannot be parsed.
186pub fn parse_xml_to_fast_dom(xml: &str) -> Result<azul_core::dom::FastDom, XmlError> {
187    let (fast_dom, _css) = parse_xml_to_fast_dom_with_css(xml)?;
188    Ok(fast_dom)
189}
190
191/// Parse XML directly into `FastDom` + extracted CSS, ready for `StyledDom`.
192#[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
193/// # Errors
194///
195/// Returns an `XmlError` if the XML cannot be parsed.
196pub fn parse_xml_to_styled_dom(xml: &str) -> Result<StyledDom, XmlError> {
197    // Optional per-phase RSS/timing breakdown.
198    // Gated on AZ_MEM_BREAKDOWN=1 — prints
199    //   [XML] tokenize+fast_dom       : +XX MiB in YY ms
200    //   [XML] css attach              : +XX MiB in YY ms
201    //   [XML] create_from_fast_dom    : +XX MiB in YY ms
202    // to locate which sub-phase of the parse-cascade dominates the
203    // RSS jump seen between `page start` and `xml parsed`.
204    static MEM_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
205    let mem_on = *MEM_ENABLED.get_or_init(azul_core::profile::memory_enabled);
206
207    let rss0 = if mem_on { peak_rss_bytes() } else { 0 };
208    let (mut fast_dom, css) = parse_xml_to_fast_dom_with_css(xml)?;
209    if mem_on {
210        let rss1 = peak_rss_bytes();
211        eprintln!(
212            "[XML] tokenize+fast_dom       : +{:.2} MiB",
213            (rss1.saturating_sub(rss0)) as f64 / 1024.0 / 1024.0,
214        );
215    }
216
217    let rss1 = if mem_on { peak_rss_bytes() } else { 0 };
218    // Attach CSS to the FastDom
219    if !css.is_empty() {
220        let combined_css = Css::new(css.into_iter()
221            .flat_map(|c| c.rules.into_library_owned_vec())
222            .collect());
223        fast_dom.css = vec![azul_core::dom::CssWithNodeId {
224            node_id: 0, // global scope
225            css: combined_css,
226        }].into();
227    }
228    if mem_on {
229        let rss2 = peak_rss_bytes();
230        eprintln!(
231            "[XML] css attach              : +{:.2} MiB",
232            (rss2.saturating_sub(rss1)) as f64 / 1024.0 / 1024.0,
233        );
234    }
235
236    // Hint the allocator to return pages freed by the CSS parser.
237    // The tokenizer+parser created many small allocations (selectors,
238    // declarations, strings) that are now packed into FastDom. Purging
239    // here returns those pages before the cascade allocates more.
240    crate::probe::hint_purge_allocator();
241
242    let rss2 = if mem_on { peak_rss_bytes() } else { 0 };
243    let styled = StyledDom::create_from_fast_dom(fast_dom);
244
245    // Major purge point: the cascade just freed ~3 MiB of intermediate
246    // allocations (build-phase Vecs, CSS selector matching state, pruned
247    // properties). Tell the allocator to return those pages NOW before
248    // the layout pass allocates more on top of them.
249    crate::probe::hint_purge_allocator();
250
251    if mem_on {
252        let rss3 = peak_rss_bytes();
253        eprintln!(
254            "[XML] create_from_fast_dom    : +{:.2} MiB",
255            (rss3.saturating_sub(rss2)) as f64 / 1024.0 / 1024.0,
256        );
257    }
258
259    Ok(styled)
260}
261
262/// Resident-set bytes for RSS checkpoints — mirrors servo-shot's
263/// `peak_rss_bytes()`. Uses `getrusage(RUSAGE_SELF)` via the
264/// `probe` feature's `libc` dep; returns 0 without it so the
265/// caller just doesn't emit meaningful deltas.
266#[cfg(all(unix, feature = "probe"))]
267fn peak_rss_bytes() -> u64 {
268    let mut usage: libc::rusage = unsafe { std::mem::zeroed() };
269    if unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut usage) } != 0 {
270        return 0;
271    }
272    let ru = usage.ru_maxrss as u64;
273    // macOS reports bytes, Linux reports KiB.
274    #[cfg(target_os = "macos")]
275    { ru }
276    #[cfg(not(target_os = "macos"))]
277    { ru.saturating_mul(1024) }
278}
279
280#[cfg(not(all(unix, feature = "probe")))]
281const fn peak_rss_bytes() -> u64 {
282    0
283}
284
285/// Internal: parse XML into `FastDom` + collected CSS stylesheets.
286#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
287#[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
288fn parse_xml_to_fast_dom_with_css(xml: &str) -> Result<(azul_core::dom::FastDom, Vec<Css>), XmlError> {
289    use xmlparser::{ElementEnd::{Open, Empty, Close}, Token::{ElementStart, Attribute, ElementEnd, Text}, Tokenizer};
290    use azul_core::dom::{NodeData, NodeType, IdOrClass, TabIndex};
291    use azul_core::xml::CompactDomBuilder;
292
293    const ESTIMATED_BYTES_PER_NODE: usize = 20;
294
295    const VOID_ELEMENTS: &[&str] = &[
296        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta",
297        "param", "source", "track", "wbr",
298    ];
299
300    // Lowercase `src` into `dst`, reusing `dst`'s existing capacity.
301    // Zero-alloc when dst's capacity is already ≥ src.len() AND no uppercase
302    // conversion is needed (the happy path for HTML5 where tags are lowercase).
303    fn lowercase_into(dst: &mut String, src: &str) {
304        dst.clear();
305        if src.bytes().all(|b| !b.is_ascii_uppercase()) {
306            dst.push_str(src);
307        } else {
308            dst.reserve(src.len());
309            for b in src.bytes() {
310                dst.push(b.to_ascii_lowercase() as char);
311            }
312        }
313    }
314
315    // Strip BOM
316    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
317    let mut xml = xml.trim();
318
319    // Skip <?xml ... ?>
320    if xml.starts_with("<?") {
321        if let Some(pos) = xml.find("?>") {
322            xml = &xml[(pos + 2)..];
323        }
324    }
325
326    // Skip <!DOCTYPE ...>
327    let mut xml = xml.trim();
328    if xml.len() > 9 && xml.is_char_boundary(9) && xml[..9].to_ascii_lowercase().starts_with("<!doctype") {
329        if let Some(pos) = xml.find('>') {
330            xml = &xml[(pos + 1)..];
331        }
332    } else if xml.starts_with("<!--") {
333        if let Some(end) = xml.find("-->") {
334            xml = &xml[(end + 3)..];
335            xml = xml.trim();
336        }
337    }
338
339    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
340
341    let estimated_nodes = xml.len() / ESTIMATED_BYTES_PER_NODE;
342    let mut builder = CompactDomBuilder::with_capacity(estimated_nodes);
343    let mut collected_css: Vec<Css> = Vec::new();
344    let mut inside_style_tag = false;
345    let mut style_text = String::new();
346    // Track <head> depth: skip DOM nodes inside <head> (still collect <style> CSS).
347    // This ensures the FastDom contains only <html><body>... as the layout engine expects.
348    let mut head_depth: usize = 0;
349
350    // Temporary storage for current element's attributes
351    let mut current_tag: String = String::new();
352    let mut current_attrs: Vec<(String, String)> = Vec::new();
353    let mut pending_open = false;
354
355    // Pre-compute the CSS key map once (used for style= attribute parsing)
356    let css_key_map = azul_css::props::property::get_css_key_map();
357
358    // One bump arena for every AzString produced during this parse —
359    // id/class tokens, text nodes, etc. Replaces ~1k small heap allocs
360    // with a handful of 64 KiB chunks. Each AzString carries its own
361    // Arc reference to the arena, so the arena survives until the last
362    // string is dropped (typically when the StyledDom is dropped).
363    let mut str_arena = azul_css::corety::StringArena::new();
364
365    // Finalize the pending open element: create NodeData from tag + attrs, push to builder
366    // tag is already lowercase
367    let finalize_open = |
368        builder: &mut CompactDomBuilder,
369        str_arena: &mut azul_css::corety::StringArena,
370        tag: &str,
371        attrs: &[(String, String)],
372        css_key_map: &azul_css::props::property::CssKeyMap,
373    | {
374        let node_type = tag_to_node_type(tag);
375        let mut nd = NodeData::create_node(node_type);
376
377        // Apply attributes — build AttributeTypeVec directly (avoids the
378        // clone + retain dance in set_ids_and_classes for fresh NodeData).
379        let mut attr_vec: Vec<azul_core::dom::AttributeType> = Vec::new();
380        for (key, value) in attrs {
381            match key.as_str() {
382                "id" => {
383                    for id in value.split_whitespace() {
384                        attr_vec.push(azul_core::dom::AttributeType::Id(str_arena.intern(id)));
385                    }
386                }
387                "class" => {
388                    for class in value.split_whitespace() {
389                        attr_vec.push(azul_core::dom::AttributeType::Class(str_arena.intern(class)));
390                    }
391                }
392                "focusable" => {
393                    if let Some(f) = parse_bool(value.as_str()) {
394                        nd.set_tab_index(if f { TabIndex::Auto } else { TabIndex::NoKeyboardFocus });
395                    }
396                }
397                "tabindex" => {
398                    if let Ok(ti) = value.parse::<isize>() {
399                        match ti {
400                            0 => nd.set_tab_index(TabIndex::Auto),
401                            i if i > 0 => nd.set_tab_index(TabIndex::OverrideInParent(i as u32)),
402                            _ => nd.set_tab_index(TabIndex::NoKeyboardFocus),
403                        }
404                    }
405                }
406                "style" => {
407                    let mut css_attrs = Vec::new();
408                    for s in value.split(';') {
409                        let mut s = s.split(':');
410                        let Some(key) = s.next() else { continue };
411                        let Some(val) = s.next() else { continue };
412                        // Called for its side effect (writes parsed props into
413                        // `css_attrs`); the returned value is intentionally discarded.
414                        drop(azul_css::parser2::parse_css_declaration(
415                            key.trim(), val.trim(),
416                            azul_css::parser2::ErrorLocationRange::default(),
417                            css_key_map, &mut Vec::new(), &mut css_attrs,
418                        ));
419                    }
420                    let props = css_attrs.into_iter().filter_map(|s| {
421                        use azul_css::css::CssDeclaration;
422                        use azul_css::dynamic_selector::CssPropertyWithConditions;
423                        match s {
424                            CssDeclaration::Static(s) => Some(CssPropertyWithConditions::simple(s)),
425                            CssDeclaration::Dynamic(_) => None,
426                        }
427                    }).collect::<Vec<_>>();
428                    if !props.is_empty() {
429                        nd.set_css_props(props.into());
430                    }
431                }
432                "contenteditable" => {
433                    if parse_bool(value.as_str()).unwrap_or(false) {
434                        nd.set_contenteditable(true);
435                    }
436                }
437                _ => {}
438            }
439        }
440        if !attr_vec.is_empty() {
441            nd.set_attributes(attr_vec.into());
442        }
443
444        builder.open_node(nd);
445    };
446
447    let mut last_was_void = false;
448    let mut tag_stack: Vec<String> = Vec::new(); // for matching close tags
449
450    for token in tokenizer {
451        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
452        match token {
453            ElementStart { local, .. } => {
454                // Flush any pending open element
455                if pending_open {
456                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
457                    if current_tag == "head" { head_depth += 1; }
458                    if head_depth == 0 {
459                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
460                        if is_void { builder.close_node(); }
461                    }
462                    if !is_void {
463                        tag_stack.push(core::mem::take(&mut current_tag));
464                    }
465                }
466
467                // Reuse the current_tag buffer — avoids ~1023 fresh String
468                // allocations per parse (one per ElementStart).
469                lowercase_into(&mut current_tag, local.as_str());
470                current_attrs.clear();
471                pending_open = true;
472                last_was_void = VOID_ELEMENTS.contains(&current_tag.as_str());
473            }
474            Attribute { local, value, .. } => {
475                // decode_xml_entities returns Cow::Borrowed when no entities
476                // are present (the common case), so `.into_owned()` is the
477                // only fresh allocation here. The key is copied via
478                // `to_string()` because we can't hold a borrow across token
479                // iterations. TODO: when we switch current_attrs to
480                // Vec<(&str, Cow<str>)> this becomes zero-alloc for the key.
481                current_attrs.push((local.to_string(), decode_xml_entities(value.as_str()).into_owned()));
482            }
483            ElementEnd { end: Open, .. } => {
484                if pending_open {
485                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
486                    if current_tag == "style" {
487                        inside_style_tag = true;
488                        style_text.clear();
489                    }
490                    if current_tag == "head" { head_depth += 1; }
491                    if head_depth == 0 {
492                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
493                        if is_void { builder.close_node(); }
494                    }
495                    if !is_void {
496                        // Use take() instead of clone() — after pending_open=false,
497                        // current_tag is not read again until the next ElementStart
498                        // reassigns it via lowercase_into.
499                        tag_stack.push(core::mem::take(&mut current_tag));
500                    }
501                    pending_open = false;
502                }
503            }
504            ElementEnd { end: Empty, .. } => {
505                // Self-closing element: open + immediately close
506                if pending_open {
507                    if current_tag == "head" { head_depth += 1; }
508                    if head_depth == 0 {
509                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
510                        builder.close_node();
511                    }
512                    if current_tag == "head" && head_depth > 0 { head_depth -= 1; }
513                    pending_open = false;
514                }
515            }
516            ElementEnd { end: Close(_, close_value), .. } => {
517                if pending_open {
518                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
519                    if current_tag == "head" { head_depth += 1; }
520                    if head_depth == 0 {
521                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
522                        if is_void { builder.close_node(); }
523                    }
524                    if !is_void {
525                        tag_stack.push(core::mem::take(&mut current_tag));
526                    }
527                    pending_open = false;
528                }
529
530                let close_lower = close_value.as_str().to_ascii_lowercase();
531                let close_str = close_lower.as_str();
532                if VOID_ELEMENTS.contains(&close_str) {
533                    continue;
534                }
535
536                // If closing a <style> tag, parse collected CSS
537                if close_str == "style" && inside_style_tag {
538                    if !style_text.is_empty() {
539                        let parsed_css = Css::from_string(core::mem::take(&mut style_text).into());
540                        collected_css.push(parsed_css);
541                    }
542                    inside_style_tag = false;
543                }
544
545                // Pop until we find matching tag
546                while let Some(top) = tag_stack.last() {
547                    let is_match = top == close_str;
548                    let was_head = top == "head";
549                    // Pop this tag (unconditionally auto-close mismatched tags)
550                    let popped = tag_stack.pop().unwrap();
551                    if popped == "head" && head_depth > 0 { head_depth -= 1; }
552                    if head_depth == 0 && !was_head {
553                        builder.close_node();
554                    }
555                    if is_match { break; }
556                }
557            }
558            Text { text } => {
559                if pending_open {
560                    let is_void = VOID_ELEMENTS.contains(&current_tag.as_str());
561                    if current_tag == "style" {
562                        inside_style_tag = true;
563                        style_text.clear();
564                    }
565                    if current_tag == "head" { head_depth += 1; }
566                    if head_depth == 0 {
567                        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
568                        if is_void { builder.close_node(); }
569                    }
570                    if !is_void {
571                        tag_stack.push(current_tag.clone());
572                    }
573                    pending_open = false;
574                }
575
576                let text_str = text.as_str();
577                if !text_str.is_empty() {
578                    if inside_style_tag {
579                        style_text.push_str(text_str);
580                    } else if head_depth == 0 {
581                        // Skip whitespace-only text at <html> level (between </head> and <body>)
582                        // but keep whitespace inside <body> (it's significant for inline layout)
583                        let inside_body = tag_stack.iter().any(|t| t == "body");
584                        if inside_body || !text_str.trim().is_empty() {
585                            let decoded = decode_xml_entities(text_str);
586                            builder.add_leaf(NodeData::create_text(str_arena.intern(&decoded)));
587                        }
588                    }
589                }
590            }
591            _ => {}
592        }
593    }
594
595    // Close any remaining open elements
596    if pending_open {
597        finalize_open(&mut builder, &mut str_arena, &current_tag, &current_attrs, &css_key_map);
598    }
599    while tag_stack.pop().is_some() {
600        builder.close_node();
601    }
602
603    // Drop the arena handle explicitly. AzStrings already embedded in
604    // the FastDom keep the backing bytes alive via their cloned Arc refs.
605    drop(str_arena);
606
607    Ok((builder.finish(), collected_css))
608}
609
610/// Loads, parses and builds a DOM from an XML file
611///
612/// **Warning**: The file is reloaded from disk on every function call - do not
613/// use this in release builds! This function deliberately never fails: In an error case,
614/// the error gets rendered as a `NodeType::Label`.
615#[cfg(all(feature = "std", feature = "xml"))]
616pub fn domxml_from_file<I: AsRef<Path>>(
617    file_path: I,
618    component_map: &ComponentMap,
619) -> DomXml {
620    use std::fs;
621
622    let error_css = Css::empty();
623
624    let xml = match fs::read_to_string(file_path.as_ref()) {
625        Ok(xml) => xml,
626        Err(e) => {
627            return DomXml {
628                parsed_dom: {
629                    let mut dom = Dom::create_body()
630                        .with_children(
631                            vec![Dom::create_text(format!(
632                                "Error reading: \"{}\": {}",
633                                file_path.as_ref().to_string_lossy(),
634                                e
635                            ))]
636                            .into(),
637                        );
638                    StyledDom::create(&mut dom, error_css)
639                },
640            };
641        }
642    };
643
644    domxml_from_str(&xml, component_map)
645}
646
647/// Parses the XML string into an XML tree, returns
648/// the root `<app></app>` node, with the children attached to it.
649///
650/// Since the XML allows multiple root nodes, this function returns
651/// a `Vec<XmlNode>` - which are the "root" nodes, containing all their
652/// children recursively.
653#[cfg(feature = "xml")]
654#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
655/// # Errors
656///
657/// Returns an `XmlError` if the XML cannot be parsed.
658pub fn parse_xml_string(xml: &str) -> Result<Vec<XmlNodeChild>, XmlError> {
659    use xmlparser::{ElementEnd::{Empty, Close}, Token::{ElementStart, ElementEnd, Attribute, Text}, Tokenizer};
660
661    use self::XmlParseError::*;
662
663    // HTML5-lite parser: List of void elements that should auto-close
664    // See: https://developer.mozilla.org/en-US/docs/Glossary/Void_element
665    const VOID_ELEMENTS: &[&str] = &[
666        "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
667        "source", "track", "wbr",
668    ];
669
670    // HTML5-lite parser: Elements that auto-close when certain other elements are encountered
671    // Format: (element_name, closes_when_encountering)
672    const AUTO_CLOSE_RULES: &[(&str, &[&str])] = &[
673        // List items close when encountering another list item or when parent closes
674        ("li", &["li"]),
675        // Table cells/rows have complex closing rules
676        ("td", &["td", "th", "tr"]),
677        ("th", &["td", "th", "tr"]),
678        ("tr", &["tr"]),
679        // Paragraphs close on block-level elements
680        (
681            "p",
682            &[
683                "address",
684                "article",
685                "aside",
686                "blockquote",
687                "div",
688                "dl",
689                "fieldset",
690                "footer",
691                "form",
692                "h1",
693                "h2",
694                "h3",
695                "h4",
696                "h5",
697                "h6",
698                "header",
699                "hr",
700                "main",
701                "nav",
702                "ol",
703                "p",
704                "pre",
705                "section",
706                "table",
707                "ul",
708            ],
709        ),
710        // Option closes on another option or optgroup
711        ("option", &["option", "optgroup"]),
712        ("optgroup", &["optgroup"]),
713        // DD/DT close on each other
714        ("dd", &["dd", "dt"]),
715        ("dt", &["dd", "dt"]),
716    ];
717
718    let mut root_node = XmlNode::default();
719
720    // Strip UTF-8 BOM if present (some W3C test files have it)
721    let xml = xml.strip_prefix('\u{FEFF}').unwrap_or(xml);
722
723    // Search for "<?xml" and "?>" tags and delete them from the XML
724    let mut xml = xml.trim();
725    if xml.starts_with("<?") {
726        let pos = xml.find("?>").ok_or(XmlError::MalformedHierarchy(
727            MalformedHierarchyError {
728                expected: "<?xml".into(),
729                got: "?>".into(),
730            },
731        ))?;
732        xml = &xml[(pos + 2)..];
733    }
734
735    // Delete <!DOCTYPE ...> if necessary (case-insensitive)
736    let mut xml = xml.trim();
737    if xml.len() > 9 && xml.is_char_boundary(9) && xml[..9].to_ascii_lowercase().starts_with("<!doctype") {
738        let pos = xml.find('>').ok_or(XmlError::MalformedHierarchy(
739            MalformedHierarchyError {
740                expected: "<!DOCTYPE".into(),
741                got: ">".into(),
742            },
743        ))?;
744        xml = &xml[(pos + 1)..];
745    } else if xml.starts_with("<!--") {
746        // Skip HTML comments at the start
747        if let Some(end) = xml.find("-->") {
748            xml = &xml[(end + 3)..];
749            xml = xml.trim();
750        }
751    }
752
753    let tokenizer = Tokenizer::from_fragment(xml, 0..xml.len());
754
755    // OPTIMIZED: Use a stack of raw pointers to avoid O(n*d) traversal on every token.
756    // This is safe because:
757    // 1. All pointers point into `root_node` which is owned and not moved
758    // 2. We never hold multiple mutable references simultaneously
759    // 3. The stack is only used within this function
760    let mut node_stack: Vec<*mut XmlNode> = vec![&raw mut root_node];
761
762    // Track which hierarchy level is a void element (shouldn't be pushed to hierarchy)
763    let mut last_was_void = false;
764
765    for token in tokenizer {
766        let token = token.map_err(|e| XmlError::ParserError(translate_xmlparser_error(e)))?;
767        match token {
768            ElementStart { local, .. } => {
769                let tag_name = local.to_string();
770                let is_void_element = VOID_ELEMENTS.contains(&tag_name.as_str());
771
772                // HTML5-lite: If last element was a void element (like <img src="...">),
773                // pop it from hierarchy before processing the new element
774                if last_was_void {
775                    node_stack.pop();
776                    last_was_void = false;
777                }
778
779                // HTML5-lite: Check if we need to auto-close the current element
780                if node_stack.len() > 1 {
781                    // SAFETY: We only access the last element, which is valid
782                    let current_element = unsafe { &*node_stack[node_stack.len() - 1] };
783                    let current_tag = current_element.node_type.as_str();
784
785                    // Check if current element should auto-close when encountering this new tag
786                    for (element, closes_on) in AUTO_CLOSE_RULES {
787                        if current_tag == *element && closes_on.contains(&tag_name.as_str()) {
788                            // Auto-close the current element
789                            node_stack.pop();
790                            break;
791                        }
792                    }
793                }
794
795                // SAFETY: We access the last element which is valid
796                if let Some(&current_parent_ptr) = node_stack.last() {
797                    let current_parent = unsafe { &mut *current_parent_ptr };
798                    
799                    current_parent.children.push(XmlNodeChild::Element(XmlNode {
800                        node_type: tag_name.into(),
801                        attributes: StringPairVec::new().into(),
802                        children: Vec::new().into(),
803                    }));
804
805                    // Get pointer to the newly added child
806                    let children_len = current_parent.children.len();
807                    if let Some(XmlNodeChild::Element(ref mut new_child)) = current_parent.children.as_mut().get_mut(children_len - 1) {
808                        node_stack.push(std::ptr::from_mut::<XmlNode>(new_child));
809                    }
810                    
811                    last_was_void = is_void_element;
812                }
813            }
814            ElementEnd { end: Empty, .. } => {
815                // Pop hierarchy for all elements (including void elements after their attributes)
816                if node_stack.len() > 1 {
817                    node_stack.pop();
818                }
819                last_was_void = false;
820            }
821            ElementEnd {
822                end: Close(_, close_value),
823                ..
824            } => {
825                // HTML5-lite: If last element was a void element, pop it first
826                if last_was_void {
827                    node_stack.pop();
828                    last_was_void = false;
829                }
830
831                // HTML5-lite: Check if this is a void element - if so, ignore the closing tag
832                let is_void_element = VOID_ELEMENTS.contains(&close_value.as_str());
833                if is_void_element {
834                    // Void elements shouldn't have closing tags, but tolerate them
835                    continue;
836                }
837
838                // HTML5-lite: Auto-close any elements that should be closed
839                // Walk up the hierarchy and auto-close elements until we find a match
840                let close_value_str = close_value.as_str();
841
842                // Find matching element in stack (skip root at index 0)
843                let mut found_idx = None;
844                for i in (1..node_stack.len()).rev() {
845                    // SAFETY: All pointers in stack are valid
846                    let node = unsafe { &*node_stack[i] };
847                    if node.node_type.as_str() == close_value_str {
848                        found_idx = Some(i);
849                        break;
850                    }
851                }
852
853                if let Some(idx) = found_idx {
854                    // Pop all elements from current position to the matching element (inclusive)
855                    node_stack.truncate(idx);
856                }
857                // If no match found, just ignore (lenient HTML parsing)
858
859                last_was_void = false;
860            }
861            Attribute { local, value, .. } => {
862                // SAFETY: Last element in stack is valid
863                if let Some(&last_ptr) = node_stack.last() {
864                    let last = unsafe { &mut *last_ptr };
865                    // NOTE: Only lowercase the key ("local"), not the value!
866                    // Decode XML entities in attribute values as well
867                    last.attributes.push(azul_core::window::AzStringPair {
868                        key: local.to_string().into(),
869                        value: AzString::from(&*decode_xml_entities(value.as_str())),
870                    });
871                }
872            }
873            Text { text } => {
874                // HTML5-lite: If last element was a void element, pop it before adding text
875                if last_was_void {
876                    node_stack.pop();
877                    last_was_void = false;
878                }
879
880                // IMPORTANT: Preserve ALL text nodes including whitespace-only nodes.
881                // Whether whitespace is significant depends on the CSS `white-space` property,
882                // which is determined during layout, not during parsing.
883                // 
884                // For example: <pre><span>    </span></pre> must preserve the 4 spaces.
885                // 
886                // We only skip completely EMPTY text nodes (zero-length strings).
887                let text_str = text.as_str();
888
889                if !text_str.is_empty() {
890                    // SAFETY: Last element in stack is valid
891                    if let Some(&current_parent_ptr) = node_stack.last() {
892                        let current_parent = unsafe { &mut *current_parent_ptr };
893                        // Decode XML entities (e.g., &lt; -> <, &gt; -> >, etc.)
894                        let decoded_text = decode_xml_entities(text_str);
895                        // Add text as a child node
896                        current_parent
897                            .children
898                            .push(XmlNodeChild::Text(AzString::from(&*decoded_text)));
899                    }
900                }
901            }
902            _ => {}
903        }
904    }
905
906    // Clean up: if we ended with a void element, pop it
907    if last_was_void {
908        node_stack.pop();
909    }
910
911    // A well-formed document unwinds back to just the root sentinel. If an element was
912    // left open (e.g. a bare "<svg" with no closing bracket, which the fragment tokenizer
913    // yields as one ElementStart then cleanly ends), node_stack still holds it — reject
914    // it instead of returning a "valid" partial tree.
915    if node_stack.len() != 1 {
916        return Err(XmlError::UnclosedRootNode);
917    }
918
919    Ok(root_node.children.into())
920}
921
922#[cfg(feature = "xml")]
923/// # Errors
924///
925/// Returns an `XmlError` if the XML cannot be parsed.
926pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
927    Ok(Xml {
928        root: parse_xml_string(s)?.into(),
929    })
930}
931
932#[cfg(not(feature = "xml"))]
933pub fn parse_xml(s: &str) -> Result<Xml, XmlError> {
934    Err(XmlError::NoParserAvailable)
935}
936
937// to_string(&self) -> String
938
939#[cfg(feature = "xml")]
940#[must_use] pub fn translate_roxmltree_expandedname(
941    e: roxmltree::ExpandedName<'_, '_>,
942) -> XmlQualifiedName {
943    let ns: Option<AzString> = e.namespace().map(|e| e.to_string().into());
944    XmlQualifiedName {
945        local_name: e.name().to_string().into(),
946        namespace: ns.into(),
947    }
948}
949
950#[cfg(feature = "xml")]
951fn translate_roxmltree_attribute(e: roxmltree::Attribute<'_, '_>) -> XmlQualifiedName {
952    XmlQualifiedName {
953        local_name: e.name().to_string().into(),
954        namespace: e.namespace().map(|e| e.to_string().into()).into(),
955    }
956}
957
958#[cfg(feature = "xml")]
959fn translate_xmlparser_streamerror(e: xmlparser::StreamError) -> XmlStreamError {
960    match e {
961        xmlparser::StreamError::UnexpectedEndOfStream => XmlStreamError::UnexpectedEndOfStream,
962        xmlparser::StreamError::InvalidName => XmlStreamError::InvalidName,
963        xmlparser::StreamError::InvalidReference => XmlStreamError::InvalidReference,
964        xmlparser::StreamError::InvalidExternalID => XmlStreamError::InvalidExternalID,
965        xmlparser::StreamError::InvalidCommentData => XmlStreamError::InvalidCommentData,
966        xmlparser::StreamError::InvalidCommentEnd => XmlStreamError::InvalidCommentEnd,
967        xmlparser::StreamError::InvalidCharacterData => XmlStreamError::InvalidCharacterData,
968        xmlparser::StreamError::NonXmlChar(c, tp) => XmlStreamError::NonXmlChar(NonXmlCharError {
969            ch: c.into(),
970            pos: translate_xmlparser_textpos(tp),
971        }),
972        xmlparser::StreamError::InvalidChar(a, b, tp) => {
973            XmlStreamError::InvalidChar(InvalidCharError {
974                expected: a,
975                got: b,
976                pos: translate_xmlparser_textpos(tp),
977            })
978        }
979        xmlparser::StreamError::InvalidCharMultiple(a, b, tp) => {
980            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
981                expected: a,
982                got: b.to_vec().into(),
983                pos: translate_xmlparser_textpos(tp),
984            })
985        }
986        xmlparser::StreamError::InvalidQuote(a, tp) => {
987            XmlStreamError::InvalidQuote(InvalidQuoteError {
988                got: a,
989                pos: translate_xmlparser_textpos(tp),
990            })
991        }
992        xmlparser::StreamError::InvalidSpace(a, tp) => {
993            XmlStreamError::InvalidSpace(InvalidSpaceError {
994                got: a,
995                pos: translate_xmlparser_textpos(tp),
996            })
997        }
998        xmlparser::StreamError::InvalidString(a, tp) => {
999            XmlStreamError::InvalidString(InvalidStringError {
1000                got: a.to_string().into(),
1001                pos: translate_xmlparser_textpos(tp),
1002            })
1003        }
1004    }
1005}
1006
1007#[cfg(feature = "xml")]
1008fn translate_xmlparser_error(e: xmlparser::Error) -> XmlParseError {
1009    match e {
1010        xmlparser::Error::InvalidDeclaration(se, tp) => {
1011            XmlParseError::InvalidDeclaration(XmlTextError {
1012                stream_error: translate_xmlparser_streamerror(se),
1013                pos: translate_xmlparser_textpos(tp),
1014            })
1015        }
1016        xmlparser::Error::InvalidComment(se, tp) => XmlParseError::InvalidComment(XmlTextError {
1017            stream_error: translate_xmlparser_streamerror(se),
1018            pos: translate_xmlparser_textpos(tp),
1019        }),
1020        xmlparser::Error::InvalidPI(se, tp) => XmlParseError::InvalidPI(XmlTextError {
1021            stream_error: translate_xmlparser_streamerror(se),
1022            pos: translate_xmlparser_textpos(tp),
1023        }),
1024        xmlparser::Error::InvalidDoctype(se, tp) => XmlParseError::InvalidDoctype(XmlTextError {
1025            stream_error: translate_xmlparser_streamerror(se),
1026            pos: translate_xmlparser_textpos(tp),
1027        }),
1028        xmlparser::Error::InvalidEntity(se, tp) => XmlParseError::InvalidEntity(XmlTextError {
1029            stream_error: translate_xmlparser_streamerror(se),
1030            pos: translate_xmlparser_textpos(tp),
1031        }),
1032        xmlparser::Error::InvalidElement(se, tp) => XmlParseError::InvalidElement(XmlTextError {
1033            stream_error: translate_xmlparser_streamerror(se),
1034            pos: translate_xmlparser_textpos(tp),
1035        }),
1036        xmlparser::Error::InvalidAttribute(se, tp) => {
1037            XmlParseError::InvalidAttribute(XmlTextError {
1038                stream_error: translate_xmlparser_streamerror(se),
1039                pos: translate_xmlparser_textpos(tp),
1040            })
1041        }
1042        xmlparser::Error::InvalidCdata(se, tp) => XmlParseError::InvalidCdata(XmlTextError {
1043            stream_error: translate_xmlparser_streamerror(se),
1044            pos: translate_xmlparser_textpos(tp),
1045        }),
1046        xmlparser::Error::InvalidCharData(se, tp) => XmlParseError::InvalidCharData(XmlTextError {
1047            stream_error: translate_xmlparser_streamerror(se),
1048            pos: translate_xmlparser_textpos(tp),
1049        }),
1050        xmlparser::Error::UnknownToken(tp) => {
1051            XmlParseError::UnknownToken(translate_xmlparser_textpos(tp))
1052        }
1053    }
1054}
1055
1056#[cfg(feature = "xml")]
1057#[must_use] pub fn translate_roxmltree_error(e: roxmltree::Error) -> XmlError {
1058    match e {
1059        roxmltree::Error::InvalidXmlPrefixUri(s) => {
1060            XmlError::InvalidXmlPrefixUri(translate_roxml_textpos(s))
1061        }
1062        roxmltree::Error::UnexpectedXmlUri(s) => {
1063            XmlError::UnexpectedXmlUri(translate_roxml_textpos(s))
1064        }
1065        roxmltree::Error::UnexpectedXmlnsUri(s) => {
1066            XmlError::UnexpectedXmlnsUri(translate_roxml_textpos(s))
1067        }
1068        roxmltree::Error::InvalidElementNamePrefix(s) => {
1069            XmlError::InvalidElementNamePrefix(translate_roxml_textpos(s))
1070        }
1071        roxmltree::Error::DuplicatedNamespace(s, tp) => {
1072            XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
1073                ns: s.into(),
1074                pos: translate_roxml_textpos(tp),
1075            })
1076        }
1077        roxmltree::Error::UnknownNamespace(s, tp) => {
1078            XmlError::UnknownNamespace(UnknownNamespaceError {
1079                ns: s.into(),
1080                pos: translate_roxml_textpos(tp),
1081            })
1082        }
1083        roxmltree::Error::UnexpectedCloseTag(expected, actual, pos) => {
1084            XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
1085                expected: expected.into(),
1086                actual: actual.into(),
1087                pos: translate_roxml_textpos(pos),
1088            })
1089        }
1090        roxmltree::Error::UnexpectedEntityCloseTag(s) => {
1091            XmlError::UnexpectedEntityCloseTag(translate_roxml_textpos(s))
1092        }
1093        roxmltree::Error::UnknownEntityReference(s, tp) => {
1094            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
1095                entity: s.into(),
1096                pos: translate_roxml_textpos(tp),
1097            })
1098        }
1099        roxmltree::Error::MalformedEntityReference(s) => {
1100            XmlError::MalformedEntityReference(translate_roxml_textpos(s))
1101        }
1102        roxmltree::Error::EntityReferenceLoop(s) => {
1103            XmlError::EntityReferenceLoop(translate_roxml_textpos(s))
1104        }
1105        roxmltree::Error::InvalidAttributeValue(s) => {
1106            XmlError::InvalidAttributeValue(translate_roxml_textpos(s))
1107        }
1108        roxmltree::Error::DuplicatedAttribute(s, tp) => {
1109            XmlError::DuplicatedAttribute(DuplicatedAttributeError {
1110                attribute: s.into(),
1111                pos: translate_roxml_textpos(tp),
1112            })
1113        }
1114        roxmltree::Error::NoRootNode => XmlError::NoRootNode,
1115        roxmltree::Error::DtdDetected => XmlError::DtdDetected,
1116        roxmltree::Error::UnclosedRootNode => XmlError::UnclosedRootNode,
1117        roxmltree::Error::UnexpectedDeclaration(tp) => {
1118            XmlError::UnexpectedDeclaration(translate_roxml_textpos(tp))
1119        }
1120        roxmltree::Error::NodesLimitReached => XmlError::NodesLimitReached,
1121        roxmltree::Error::AttributesLimitReached => XmlError::AttributesLimitReached,
1122        roxmltree::Error::NamespacesLimitReached => XmlError::NamespacesLimitReached,
1123        roxmltree::Error::InvalidName(tp) => XmlError::InvalidName(translate_roxml_textpos(tp)),
1124        roxmltree::Error::NonXmlChar(_, tp) => XmlError::NonXmlChar(translate_roxml_textpos(tp)),
1125        roxmltree::Error::InvalidChar(_, _, tp) => {
1126            XmlError::InvalidChar(translate_roxml_textpos(tp))
1127        }
1128        roxmltree::Error::InvalidChar2(_, _, tp) => {
1129            XmlError::InvalidChar2(translate_roxml_textpos(tp))
1130        }
1131        roxmltree::Error::InvalidString(_, tp) => {
1132            XmlError::InvalidString(translate_roxml_textpos(tp))
1133        }
1134        roxmltree::Error::InvalidExternalID(tp) => {
1135            XmlError::InvalidExternalID(translate_roxml_textpos(tp))
1136        }
1137        roxmltree::Error::InvalidComment(tp) => {
1138            XmlError::InvalidComment(translate_roxml_textpos(tp))
1139        }
1140        roxmltree::Error::InvalidCharacterData(tp) => {
1141            XmlError::InvalidCharacterData(translate_roxml_textpos(tp))
1142        }
1143        roxmltree::Error::UnknownToken(tp) => XmlError::UnknownToken(translate_roxml_textpos(tp)),
1144        roxmltree::Error::UnexpectedEndOfStream => XmlError::UnexpectedEndOfStream,
1145        roxmltree::Error::EntityResolver(tp, s) => {
1146            // New in roxmltree 0.21: EntityResolver error variant
1147            // For now, treat as a generic entity reference error
1148            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
1149                entity: s.into(),
1150                pos: translate_roxml_textpos(tp),
1151            })
1152        }
1153    }
1154}
1155
1156#[cfg(feature = "xml")]
1157#[inline]
1158const fn translate_xmlparser_textpos(o: xmlparser::TextPos) -> XmlTextPos {
1159    XmlTextPos {
1160        row: o.row,
1161        col: o.col,
1162    }
1163}
1164
1165#[cfg(feature = "xml")]
1166#[inline]
1167const fn translate_roxml_textpos(o: roxmltree::TextPos) -> XmlTextPos {
1168    XmlTextPos {
1169        row: o.row,
1170        col: o.col,
1171    }
1172}
1173
1174/// Extension trait to add XML parsing capabilities to Dom
1175///
1176/// This trait provides methods to parse XML/XHTML strings and convert them
1177/// into Azul DOM trees. It's implemented as a trait to avoid circular dependencies
1178/// between azul-core and azul-layout.
1179#[cfg(feature = "xml")]
1180pub trait DomXmlExt {
1181    /// Parse XML/XHTML string into a DOM tree
1182    ///
1183    /// This method parses the XML string and converts it to an Azul `StyledDom`.
1184    /// On error, it returns a `StyledDom` displaying the error message.
1185    ///
1186    /// # Arguments
1187    /// * `xml` - The XML/XHTML string to parse
1188    ///
1189    /// # Returns
1190    /// A `StyledDom` tree representing the parsed XML, or an error DOM on parse failure
1191    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom;
1192}
1193
1194#[cfg(feature = "xml")]
1195impl DomXmlExt for Dom {
1196    fn from_xml_string<S: AsRef<str>>(xml: S) -> StyledDom {
1197        let component_map = ComponentMap::with_builtin();
1198        let dom_xml = domxml_from_str(xml.as_ref(), &component_map);
1199        dom_xml.parsed_dom
1200    }
1201}
1202
1203// ============================================================================
1204// Adversarial unit tests (autotest). Inline so the private helpers
1205// (`decode_xml_entities*`, `parse_xml_to_fast_dom_with_css`, `peak_rss_bytes`,
1206// `translate_*`) are reachable.
1207// ============================================================================
1208
1209#[cfg(test)]
1210mod autotest_generated {
1211    use azul_core::dom::{FastDom, NodeData, NodeType, TabIndex};
1212
1213    use super::*;
1214
1215    // ------------------------------------------------------------------
1216    // helpers
1217    // ------------------------------------------------------------------
1218
1219    /// Element children of an `XmlNodeChild` slice (skips text nodes).
1220    #[cfg(feature = "xml")]
1221    fn elements(children: &[XmlNodeChild]) -> Vec<&XmlNode> {
1222        children
1223            .iter()
1224            .filter_map(XmlNodeChild::as_element)
1225            .collect()
1226    }
1227
1228    /// Text children of an `XmlNodeChild` slice (skips element nodes).
1229    #[cfg(feature = "xml")]
1230    fn texts(children: &[XmlNodeChild]) -> Vec<&str> {
1231        children.iter().filter_map(XmlNodeChild::as_text).collect()
1232    }
1233
1234    /// `<html><body>…</body></html>` around `body`.
1235    ///
1236    /// Every fixture goes through this so the document's first 9 bytes are
1237    /// ASCII: `parse_xml*` slices `xml[..9]` for the DOCTYPE sniff without a
1238    /// char-boundary check (see
1239    /// `parse_entrypoints_do_not_panic_on_short_multibyte_input`).
1240    fn doc(body: &str) -> String {
1241        format!("<html><body>{body}</body></html>")
1242    }
1243
1244    /// Flat node arena of a `FastDom`.
1245    fn nodes(dom: &FastDom) -> &[NodeData] {
1246        dom.node_data.as_ref()
1247    }
1248
1249    /// Text content of a `NodeType::Text` node (`None` for every other kind).
1250    fn text_of(nd: &NodeData) -> Option<String> {
1251        match nd.get_node_type() {
1252            NodeType::Text(_) => nd.get_node_type().format(),
1253            _ => None,
1254        }
1255    }
1256
1257    /// Minimal XML escaper — the inverse of `decode_xml_entities`.
1258    #[cfg(feature = "xml")]
1259    fn escape(s: &str) -> String {
1260        let mut out = String::with_capacity(s.len());
1261        for c in s.chars() {
1262            match c {
1263                '&' => out.push_str("&amp;"),
1264                '<' => out.push_str("&lt;"),
1265                '>' => out.push_str("&gt;"),
1266                '"' => out.push_str("&quot;"),
1267                '\'' => out.push_str("&apos;"),
1268                _ => out.push(c),
1269            }
1270        }
1271        out
1272    }
1273
1274    /// Non-grammar / hostile fragments. All ASCII on purpose so they exercise
1275    /// the tokenizer rather than the `xml[..9]` slice.
1276    const GARBAGE: &[&str] = &[
1277        "<<<<>>>>",
1278        "!!!not xml at all!!!",
1279        "<a b=c>",
1280        "</>",
1281        "</div>",
1282        "<a></a",
1283        "&&&&&&&&&&&&",
1284        "]]>",
1285        "<!--",
1286        "<![CDATA[",
1287        "<?",
1288        "<!DOCTYPE",
1289        "\u{0}\u{1}\u{2}",
1290        "<a><<a><<<a>",
1291        "= = = = = = = = = =",
1292    ];
1293
1294    // ------------------------------------------------------------------
1295    // decode_xml_entities / decode_xml_entities_slow
1296    // ------------------------------------------------------------------
1297
1298    #[test]
1299    fn decode_xml_entities_borrows_when_there_is_no_ampersand() {
1300        for s in ["", "hello", "  ", "日本語 🙂", "<tag/>", "a;b;c;"] {
1301            assert!(
1302                matches!(decode_xml_entities(s), std::borrow::Cow::Borrowed(_)),
1303                "{s:?} has no '&' and must take the zero-alloc path"
1304            );
1305            assert_eq!(&*decode_xml_entities(s), s);
1306        }
1307    }
1308
1309    #[test]
1310    fn decode_xml_entities_decodes_the_five_named_entities_and_nbsp() {
1311        assert_eq!(&*decode_xml_entities("&lt;"), "<");
1312        assert_eq!(&*decode_xml_entities("&gt;"), ">");
1313        assert_eq!(&*decode_xml_entities("&amp;"), "&");
1314        assert_eq!(&*decode_xml_entities("&apos;"), "'");
1315        assert_eq!(&*decode_xml_entities("&quot;"), "\"");
1316        assert_eq!(&*decode_xml_entities("&nbsp;"), "\u{00A0}");
1317        assert_eq!(
1318            &*decode_xml_entities("a&lt;b&gt;c&amp;d&quot;e&apos;f"),
1319            "a<b>c&d\"e'f"
1320        );
1321    }
1322
1323    #[test]
1324    fn decode_xml_entities_decodes_numeric_references() {
1325        // decimal, lowercase hex, uppercase hex marker
1326        assert_eq!(&*decode_xml_entities("&#60;"), "<");
1327        assert_eq!(&*decode_xml_entities("&#x3C;"), "<");
1328        assert_eq!(&*decode_xml_entities("&#X3c;"), "<");
1329        assert_eq!(&*decode_xml_entities("&#65;"), "A");
1330        // boundary code points: NUL, BMP max, astral, and the last legal scalar
1331        assert_eq!(&*decode_xml_entities("&#0;"), "\u{0}");
1332        assert_eq!(&*decode_xml_entities("&#xFFFF;"), "\u{FFFF}");
1333        assert_eq!(&*decode_xml_entities("&#65536;"), "\u{10000}");
1334        assert_eq!(&*decode_xml_entities("&#1114111;"), "\u{10FFFF}");
1335        assert_eq!(&*decode_xml_entities("&#x10FFFF;"), "\u{10FFFF}");
1336        // combining marks survive
1337        assert_eq!(&*decode_xml_entities("e&#x301;"), "e\u{301}");
1338    }
1339
1340    #[test]
1341    fn decode_xml_entities_keeps_out_of_range_and_surrogate_code_points_verbatim() {
1342        // Every one of these must round-trip to itself: no panic, no
1343        // replacement char, no silent truncation to a wrong scalar.
1344        for s in [
1345            "&#xD800;",      // lone high surrogate
1346            "&#xDFFF;",      // lone low surrogate
1347            "&#55296;",      // decimal surrogate
1348            "&#x110000;",    // one past the last scalar
1349            "&#1114112;",    // decimal, one past the last scalar
1350            "&#123456789;",  // entity name is exactly 10 bytes (the length cap)
1351            "&#4294967296;", // u32::MAX + 1
1352            "&#99999999999999;",
1353            "&#x;",
1354            "&#;",
1355            "&#xZZ;",
1356            "&#-1;",
1357        ] {
1358            assert_eq!(
1359                &*decode_xml_entities(s),
1360                s,
1361                "{s:?} is not a decodable reference and must be preserved byte-for-byte"
1362            );
1363        }
1364    }
1365
1366    #[test]
1367    fn decode_xml_entities_keeps_unterminated_and_unknown_entities_verbatim() {
1368        for s in [
1369            "&",
1370            "&&",
1371            "&lt",
1372            "&#",
1373            "&#x",
1374            "&foo;",
1375            "&LT;", // entity table is case-sensitive
1376            "&Amp;",
1377            "& lt;",
1378            "a & b",
1379            "&;",
1380        ] {
1381            assert_eq!(&*decode_xml_entities(s), s, "{s:?} must be preserved");
1382        }
1383        // Trailing garbage after a valid entity is still emitted.
1384        assert_eq!(&*decode_xml_entities("&lt;&"), "<&");
1385    }
1386
1387    #[test]
1388    fn decode_xml_entities_does_not_double_decode() {
1389        // A single pass only. `&amp;lt;` is the escaped form of the literal
1390        // text `&lt;` and must NOT collapse to `<` — that would be an
1391        // injection vector for anything that escapes user text once.
1392        assert_eq!(&*decode_xml_entities("&amp;lt;"), "&lt;");
1393        assert_eq!(&*decode_xml_entities("&amp;amp;"), "&amp;");
1394        assert_eq!(&*decode_xml_entities("&amp;#60;"), "&#60;");
1395    }
1396
1397    #[test]
1398    fn decode_xml_entities_handles_pathological_input_without_panicking() {
1399        // Entity name far past the 10-byte cap: bails out and preserves input.
1400        let long_name = format!("&{};", "a".repeat(10_000));
1401        assert_eq!(&*decode_xml_entities(&long_name), long_name);
1402
1403        // Unterminated '&' followed by a megabyte of text.
1404        let long_tail = format!("&{}", "x".repeat(1_000_000));
1405        assert_eq!(decode_xml_entities(&long_tail).len(), long_tail.len());
1406
1407        // Multibyte / astral / combining input mixed with entities. The entity
1408        // scanner uses `char::is_alphanumeric`, so multibyte chars can land in
1409        // the accumulator — slicing must stay on char boundaries.
1410        for s in [
1411            "&\u{1F600}\u{1F600};",
1412            "&½;",
1413            "&日本;",
1414            "&#\u{1F600};",
1415            "&e\u{301};",
1416            "🙂&amp;🙂",
1417        ] {
1418            let out = decode_xml_entities(s);
1419            assert!(
1420                !out.is_empty(),
1421                "{s:?} decoded to nothing (input was non-empty)"
1422            );
1423        }
1424
1425        // Alternating entities at scale must not go quadratic-and-panic.
1426        let many = "&lt;".repeat(50_000);
1427        assert_eq!(decode_xml_entities(&many).chars().count(), 50_000);
1428    }
1429
1430    #[test]
1431    fn decode_xml_entities_slow_matches_the_fast_path() {
1432        // The fast path is only a `contains('&')` short-circuit: for '&'-free
1433        // input the slow path must be the identity, and for everything else
1434        // the two must agree exactly.
1435        for s in [
1436            "",
1437            "plain",
1438            "日本語 🙂",
1439            "&lt;",
1440            "&amp;lt;",
1441            "&#x1F600;",
1442            "&unknown;",
1443            "&",
1444        ] {
1445            assert_eq!(
1446                &*decode_xml_entities(s),
1447                &*decode_xml_entities_slow(s),
1448                "fast/slow path disagree on {s:?}"
1449            );
1450        }
1451        for s in ["", "plain", "日本語 🙂", "a;b", "<>"] {
1452            assert_eq!(&*decode_xml_entities_slow(s), s);
1453        }
1454    }
1455
1456    // ------------------------------------------------------------------
1457    // KNOWN BUG: unchecked `xml[..9]` slice in the DOCTYPE sniff
1458    // ------------------------------------------------------------------
1459
1460    /// `parse_xml_string` (line ~737) and `parse_xml_to_fast_dom_with_css`
1461    /// (line ~328) both do
1462    ///
1463    /// ```ignore
1464    /// if xml.len() > 9 && xml[..9].to_ascii_lowercase().starts_with("<!doctype")
1465    /// ```
1466    ///
1467    /// `&str[..9]` panics when byte 9 is not a UTF-8 char boundary, so any
1468    /// input longer than 9 bytes whose third-or-so character is multibyte
1469    /// aborts the parse with `byte index 9 is not a char boundary` instead of
1470    /// returning `Err`. `domxml_from_str`, which documents that it "deliberately
1471    /// never fails", inherits the panic.
1472    ///
1473    /// The fix belongs in the source (`xml.is_char_boundary(9)` guard, or
1474    /// `xml.get(..9)`), so this test asserts the correct invariant and is
1475    /// expected to be RED until that lands.
1476    #[cfg(feature = "xml")]
1477    #[test]
1478    fn parse_entrypoints_do_not_panic_on_short_multibyte_input() {
1479        // 3 x 4-byte emoji = 12 bytes; boundaries are 0/4/8/12, so 9 is inside
1480        // the third character.
1481        const INPUT: &str = "😀😀😀";
1482        assert!(INPUT.len() > 9 && !INPUT.is_char_boundary(9));
1483
1484        let a = std::panic::catch_unwind(|| parse_xml_string(INPUT).is_ok());
1485        let b = std::panic::catch_unwind(|| parse_xml_to_fast_dom(INPUT).is_ok());
1486
1487        assert!(
1488            a.is_ok(),
1489            "parse_xml_string panicked on {INPUT:?}: the DOCTYPE sniff slices \
1490             xml[..9] without an is_char_boundary check"
1491        );
1492        assert!(
1493            b.is_ok(),
1494            "parse_xml_to_fast_dom panicked on {INPUT:?}: same unchecked \
1495             xml[..9] slice"
1496        );
1497    }
1498
1499    // ------------------------------------------------------------------
1500    // parse_xml_string
1501    // ------------------------------------------------------------------
1502
1503    #[cfg(feature = "xml")]
1504    #[test]
1505    fn parse_xml_string_accepts_empty_and_whitespace_only_input() {
1506        for s in ["", " ", "   ", "\t\n", "\r\n\r\n", "\u{FEFF}", "\u{FEFF}   "] {
1507            let parsed = parse_xml_string(s)
1508                .unwrap_or_else(|e| panic!("{s:?} should parse to an empty tree, got {e}"));
1509            assert!(parsed.is_empty(), "{s:?} produced {} roots", parsed.len());
1510        }
1511    }
1512
1513    #[cfg(feature = "xml")]
1514    #[test]
1515    fn parse_xml_string_parses_a_minimal_document() {
1516        let parsed = parse_xml_string(&doc("<div>hi</div>")).expect("valid document");
1517        let roots = elements(&parsed);
1518        assert_eq!(roots.len(), 1);
1519        assert_eq!(roots[0].node_type.as_str(), "html");
1520
1521        let body = elements(roots[0].children.as_ref());
1522        assert_eq!(body.len(), 1);
1523        assert_eq!(body[0].node_type.as_str(), "body");
1524
1525        let div = elements(body[0].children.as_ref());
1526        assert_eq!(div.len(), 1);
1527        assert_eq!(div[0].node_type.as_str(), "div");
1528        assert_eq!(texts(div[0].children.as_ref()), vec!["hi"]);
1529    }
1530
1531    #[cfg(feature = "xml")]
1532    #[test]
1533    fn parse_xml_string_rejects_unclosed_elements() {
1534        // A well-formed document unwinds to the root sentinel; anything left
1535        // open must be an error rather than a silently-truncated tree.
1536        assert!(matches!(
1537            parse_xml_string("<div>"),
1538            Err(XmlError::UnclosedRootNode)
1539        ));
1540        assert!(matches!(
1541            parse_xml_string("<html><body><div>"),
1542            Err(XmlError::UnclosedRootNode)
1543        ));
1544        assert!(
1545            parse_xml_string("<html><body><div>text").is_err(),
1546            "an unclosed element must not yield a partial 'valid' tree"
1547        );
1548    }
1549
1550    #[cfg(feature = "xml")]
1551    #[test]
1552    fn parse_xml_string_rejects_truncated_declaration_and_doctype() {
1553        assert!(matches!(
1554            parse_xml_string("<?xml version=\"1.0\""),
1555            Err(XmlError::MalformedHierarchy(_))
1556        ));
1557        assert!(matches!(
1558            parse_xml_string("<!DOCTYPE html PUBLIC \"x\""),
1559            Err(XmlError::MalformedHierarchy(_))
1560        ));
1561        // ...but the complete forms are stripped and the rest parses.
1562        for prefix in [
1563            "<?xml version=\"1.0\"?>",
1564            "<!DOCTYPE html>",
1565            "<!doctype HTML>",
1566            "<!-- leading comment -->",
1567            "\u{FEFF}",
1568        ] {
1569            let src = format!("{prefix}{}", doc("<div/>"));
1570            let parsed = parse_xml_string(&src)
1571                .unwrap_or_else(|e| panic!("{prefix:?} prefix should be stripped, got {e}"));
1572            let roots = elements(&parsed);
1573            assert_eq!(roots.len(), 1, "{prefix:?} -> {roots:?}");
1574            assert_eq!(roots[0].node_type.as_str(), "html");
1575        }
1576    }
1577
1578    #[cfg(feature = "xml")]
1579    #[test]
1580    fn parse_xml_string_is_deterministic_on_garbage() {
1581        for g in GARBAGE {
1582            // The contract is "Err or a tree", never a panic and never a
1583            // different answer for the same bytes.
1584            let a = parse_xml_string(g);
1585            let b = parse_xml_string(g);
1586            assert_eq!(a.is_ok(), b.is_ok(), "{g:?} parsed non-deterministically");
1587            assert_eq!(a.ok(), b.ok(), "{g:?} produced two different trees");
1588        }
1589    }
1590
1591    #[cfg(feature = "xml")]
1592    #[test]
1593    fn parse_xml_string_trims_leading_and_trailing_whitespace() {
1594        let padded = format!("  \t\n{}\n\t  ", doc("<div/>"));
1595        let a = parse_xml_string(&padded).expect("padded document");
1596        let b = parse_xml_string(&doc("<div/>")).expect("bare document");
1597        assert_eq!(a, b, "surrounding whitespace must not change the tree");
1598    }
1599
1600    #[cfg(feature = "xml")]
1601    #[test]
1602    fn parse_xml_string_keeps_trailing_junk_as_text() {
1603        // Lenient HTML-ish parsing: trailing junk becomes a text node at the
1604        // root rather than an error or a dropped document.
1605        let parsed = parse_xml_string(&format!("{};garbage", doc("<div/>"))).expect("lenient parse");
1606        assert_eq!(elements(&parsed).len(), 1);
1607        assert_eq!(texts(&parsed), vec![";garbage"]);
1608    }
1609
1610    #[cfg(feature = "xml")]
1611    #[test]
1612    fn parse_xml_string_round_trips_escaped_text() {
1613        for raw in [
1614            "a",
1615            "<b>bold</b> & \"quotes\" 'apos'",
1616            "&&&&",
1617            "  spaced  ",
1618            "日本語 🙂 combining e\u{301}",
1619            "1 < 2 > 0 && true",
1620        ] {
1621            let src = doc(&escape(raw));
1622            let parsed = parse_xml_string(&src)
1623                .unwrap_or_else(|e| panic!("{src:?} should parse, got {e}"));
1624            let html = elements(&parsed);
1625            let body = elements(html[0].children.as_ref());
1626            assert_eq!(
1627                texts(body[0].children.as_ref()),
1628                vec![raw],
1629                "escape -> parse must be the identity for {raw:?}"
1630            );
1631        }
1632    }
1633
1634    #[cfg(feature = "xml")]
1635    #[test]
1636    fn parse_xml_string_decodes_attribute_entities() {
1637        let parsed = parse_xml_string(&doc(
1638            r#"<div t="&amp;&lt;&gt;&quot;&apos;x" u="&nosuch;" v="&#x1F600;"></div>"#,
1639        ))
1640        .expect("valid document");
1641        let html = elements(&parsed);
1642        let body = elements(html[0].children.as_ref());
1643        let div = elements(body[0].children.as_ref());
1644        let attrs = &div[0].attributes;
1645
1646        assert_eq!(attrs.get_key("t").map(AzString::as_str), Some("&<>\"'x"));
1647        assert_eq!(attrs.get_key("u").map(AzString::as_str), Some("&nosuch;"));
1648        assert_eq!(attrs.get_key("v").map(AzString::as_str), Some("😀"));
1649    }
1650
1651    #[cfg(feature = "xml")]
1652    #[test]
1653    fn parse_xml_string_tolerates_extra_and_mismatched_close_tags() {
1654        let nested = doc("<div></span></div>");
1655        let paragraphs = doc("<p>one<p>two");
1656        for src in [
1657            "<a></a></a>",
1658            "<a></a></b>",
1659            nested.as_str(),
1660            paragraphs.as_str(),
1661            "<br></br>",
1662            "<br>",
1663            "<br><br><br>",
1664        ] {
1665            let a = parse_xml_string(src);
1666            let b = parse_xml_string(src);
1667            assert_eq!(a.is_ok(), b.is_ok(), "{src:?} is non-deterministic");
1668            assert_eq!(a.ok(), b.ok(), "{src:?} produced two different trees");
1669        }
1670        // A bare void element is a complete document (auto-closed at EOF).
1671        let parsed = parse_xml_string("<br>").expect("bare void element");
1672        assert_eq!(elements(&parsed).len(), 1);
1673        assert_eq!(elements(&parsed)[0].node_type.as_str(), "br");
1674    }
1675
1676    #[cfg(feature = "xml")]
1677    #[test]
1678    fn parse_xml_string_handles_deep_nesting_without_stack_overflow() {
1679        // Building is iterative, but dropping the resulting `XmlNode` tree is
1680        // recursive, so this pins the depth the *whole* lifecycle survives.
1681        // (The arena path is exercised at 10k in
1682        // `parse_xml_to_fast_dom_handles_ten_thousand_nested_elements`.)
1683        const DEPTH: usize = 1_000;
1684        let mut src = String::with_capacity(DEPTH * 12);
1685        for _ in 0..DEPTH {
1686            src.push_str("<a>");
1687        }
1688        for _ in 0..DEPTH {
1689            src.push_str("</a>");
1690        }
1691
1692        let parsed = parse_xml_string(&src).expect("balanced nesting is valid");
1693        let mut depth = 0_usize;
1694        {
1695            let mut cursor: Vec<&XmlNode> = elements(&parsed);
1696            while !cursor.is_empty() {
1697                depth += 1;
1698                let node: &XmlNode = cursor[0];
1699                cursor = elements(node.children.as_ref());
1700            }
1701        }
1702        assert_eq!(depth, DEPTH, "every nesting level must be preserved");
1703        // Dropping the tree is the recursive half of the lifecycle — a deeper
1704        // tree would blow the stack here, not during the (iterative) parse.
1705        drop(parsed);
1706    }
1707
1708    #[cfg(feature = "xml")]
1709    #[test]
1710    fn parse_xml_string_handles_a_one_million_char_text_node() {
1711        let payload = "x".repeat(1_000_000);
1712        let parsed = parse_xml_string(&doc(&payload)).expect("long text is valid");
1713        let html = elements(&parsed);
1714        let body = elements(html[0].children.as_ref());
1715        let t = texts(body[0].children.as_ref());
1716        assert_eq!(t.len(), 1);
1717        assert_eq!(t[0].len(), 1_000_000);
1718    }
1719
1720    #[cfg(feature = "xml")]
1721    #[test]
1722    fn parse_xml_string_handles_many_sibling_elements() {
1723        const N: usize = 2_000;
1724        let parsed = parse_xml_string(&doc(&"<i>x</i>".repeat(N))).expect("wide tree is valid");
1725        let html = elements(&parsed);
1726        let body = elements(html[0].children.as_ref());
1727        assert_eq!(elements(body[0].children.as_ref()).len(), N);
1728    }
1729
1730    // ------------------------------------------------------------------
1731    // parse_xml
1732    // ------------------------------------------------------------------
1733
1734    #[cfg(feature = "xml")]
1735    #[test]
1736    fn parse_xml_agrees_with_parse_xml_string() {
1737        let one = doc("<div>hi</div>");
1738        let two = doc("<i/><i/>");
1739        for src in ["", "   ", one.as_str(), two.as_str()] {
1740            let via_xml = parse_xml(src).expect("valid");
1741            let via_string = parse_xml_string(src).expect("valid");
1742            assert_eq!(
1743                via_xml.root.as_ref(),
1744                via_string.as_slice(),
1745                "parse_xml must be a thin wrapper over parse_xml_string for {src:?}"
1746            );
1747        }
1748        assert!(parse_xml("<div>").is_err());
1749    }
1750
1751    #[cfg(not(feature = "xml"))]
1752    #[test]
1753    fn parse_xml_without_the_xml_feature_reports_no_parser() {
1754        for s in ["", "   ", "<div/>", "garbage"] {
1755            assert!(matches!(parse_xml(s), Err(XmlError::NoParserAvailable)));
1756        }
1757    }
1758
1759    // ------------------------------------------------------------------
1760    // parse_xml_to_fast_dom / parse_xml_to_fast_dom_with_css
1761    // ------------------------------------------------------------------
1762
1763    #[test]
1764    fn parse_xml_to_fast_dom_accepts_empty_and_whitespace_only_input() {
1765        for s in ["", " ", "   ", "\t\n", "\u{FEFF}", "\u{FEFF}  \n "] {
1766            let dom = parse_xml_to_fast_dom(s)
1767                .unwrap_or_else(|e| panic!("{s:?} should yield an empty arena, got {e}"));
1768            assert!(nodes(&dom).is_empty(), "{s:?} produced {} nodes", nodes(&dom).len());
1769            assert_eq!(dom.node_hierarchy.as_ref().len(), nodes(&dom).len());
1770        }
1771    }
1772
1773    #[test]
1774    fn parse_xml_to_fast_dom_builds_the_expected_arena() {
1775        let dom = parse_xml_to_fast_dom(&doc("<div>hi</div>")).expect("valid document");
1776        let n = nodes(&dom);
1777        assert_eq!(n.len(), 4, "html + body + div + text");
1778        assert_eq!(
1779            dom.node_hierarchy.as_ref().len(),
1780            n.len(),
1781            "hierarchy and node_data arenas must stay parallel"
1782        );
1783        assert!(matches!(n[0].get_node_type(), NodeType::Html));
1784        assert!(matches!(n[1].get_node_type(), NodeType::Body));
1785        assert!(matches!(n[2].get_node_type(), NodeType::Div));
1786        assert_eq!(text_of(&n[3]).as_deref(), Some("hi"));
1787    }
1788
1789    #[test]
1790    fn parse_xml_to_fast_dom_lowercases_tag_names() {
1791        let dom = parse_xml_to_fast_dom("<HTML><BODY><DiV/></BODY></HTML>").expect("valid");
1792        let n = nodes(&dom);
1793        assert_eq!(n.len(), 3);
1794        assert!(matches!(n[0].get_node_type(), NodeType::Html));
1795        assert!(matches!(n[1].get_node_type(), NodeType::Body));
1796        assert!(matches!(n[2].get_node_type(), NodeType::Div));
1797    }
1798
1799    #[test]
1800    fn parse_xml_to_fast_dom_skips_head_but_collects_style_css() {
1801        let src = "<html><head><title>T</title>\
1802                   <style>div { width: 10px; }</style></head>\
1803                   <body>x</body></html>";
1804        let (dom, css) = parse_xml_to_fast_dom_with_css(src).expect("valid document");
1805        let n = nodes(&dom);
1806
1807        assert_eq!(n.len(), 3, "html + body + text; <head> subtree is dropped");
1808        assert!(
1809            !n.iter()
1810                .any(|nd| matches!(nd.get_node_type(), NodeType::Head | NodeType::Title)),
1811            "no <head>/<title> node may reach the arena"
1812        );
1813        assert_eq!(text_of(&n[2]).as_deref(), Some("x"));
1814        assert_eq!(css.len(), 1, "the <style> body must still be collected");
1815        assert!(!css[0].rules.as_ref().is_empty(), "the CSS must have parsed");
1816    }
1817
1818    #[test]
1819    fn parse_xml_to_fast_dom_splits_ids_and_classes_on_whitespace() {
1820        let dom = parse_xml_to_fast_dom(&doc(r#"<div id="a b" class="c  d
1821        e"></div>"#))
1822        .expect("valid document");
1823        let div = &nodes(&dom)[2];
1824
1825        assert!(div.has_id("a") && div.has_id("b"));
1826        assert!(div.has_class("c") && div.has_class("d") && div.has_class("e"));
1827        assert!(!div.has_id("a b"), "the raw joined value must not survive");
1828        assert_eq!(div.get_ids_and_classes().as_ref().len(), 5);
1829    }
1830
1831    /// Reads the tab index of the `<div>` in `doc("<div {attrs}></div>")`.
1832    fn tab_index_with(attrs: &str) -> Option<TabIndex> {
1833        let dom = parse_xml_to_fast_dom(&doc(&format!("<div {attrs}></div>")))
1834            .unwrap_or_else(|e| panic!("{attrs:?} should parse, got {e}"));
1835        nodes(&dom)[2].get_tab_index()
1836    }
1837
1838    #[test]
1839    fn parse_xml_to_fast_dom_maps_tabindex_boundaries() {
1840        assert_eq!(tab_index_with(r#"tabindex="0""#), Some(TabIndex::Auto));
1841        assert_eq!(tab_index_with(r#"tabindex="-0""#), Some(TabIndex::Auto));
1842        assert_eq!(
1843            tab_index_with(r#"tabindex="1""#),
1844            Some(TabIndex::OverrideInParent(1))
1845        );
1846        assert_eq!(
1847            tab_index_with(r#"tabindex="+3""#),
1848            Some(TabIndex::OverrideInParent(3)),
1849            "isize::from_str accepts a leading '+'"
1850        );
1851        assert_eq!(
1852            tab_index_with(r#"tabindex="-1""#),
1853            Some(TabIndex::NoKeyboardFocus)
1854        );
1855        assert_eq!(
1856            tab_index_with(r#"tabindex="-9223372036854775808""#),
1857            Some(TabIndex::NoKeyboardFocus),
1858            "i64::MIN is still just 'negative'"
1859        );
1860
1861        // NodeFlags packs the override value into bits [27:0].
1862        const MAX_EXACT: u32 = (1 << 28) - 1;
1863        assert_eq!(
1864            tab_index_with(&format!(r#"tabindex="{MAX_EXACT}""#)),
1865            Some(TabIndex::OverrideInParent(MAX_EXACT))
1866        );
1867        // Past that it truncates rather than saturating or panicking. Two
1868        // lossy steps stack up here: `isize as u32` in the XML parser, then
1869        // the 28-bit mask in `NodeFlags::set_tab_index`. Pinned as-is because
1870        // the safety property is "bounded and deterministic", not "exact".
1871        assert_eq!(
1872            tab_index_with(r#"tabindex="268435456""#),
1873            Some(TabIndex::OverrideInParent(0)),
1874            "1 << 28 truncates to 0"
1875        );
1876        assert_eq!(
1877            tab_index_with(r#"tabindex="9223372036854775807""#),
1878            Some(TabIndex::OverrideInParent(MAX_EXACT)),
1879            "i64::MAX -> u32::MAX -> 28-bit mask"
1880        );
1881    }
1882
1883    #[test]
1884    fn parse_xml_to_fast_dom_ignores_unparseable_tabindex() {
1885        let baseline = tab_index_with("");
1886        for junk in [
1887            r#"tabindex="""#,
1888            r#"tabindex="NaN""#,
1889            r#"tabindex="inf""#,
1890            r#"tabindex="-inf""#,
1891            r#"tabindex="1.0""#,
1892            r#"tabindex="1e5""#,
1893            r#"tabindex=" 3 ""#,
1894            r#"tabindex="0x10""#,
1895            r#"tabindex="99999999999999999999999999""#,
1896            r#"tabindex="-99999999999999999999999999""#,
1897            r#"tabindex="🙂""#,
1898        ] {
1899            assert_eq!(
1900                tab_index_with(junk),
1901                baseline,
1902                "{junk} must leave the tab index untouched"
1903            );
1904        }
1905    }
1906
1907    #[test]
1908    fn parse_xml_to_fast_dom_parses_bool_attributes_case_sensitively() {
1909        assert_eq!(
1910            tab_index_with(r#"focusable="true""#),
1911            Some(TabIndex::Auto)
1912        );
1913        assert_eq!(
1914            tab_index_with(r#"focusable="false""#),
1915            Some(TabIndex::NoKeyboardFocus)
1916        );
1917        for junk in [r#"focusable="TRUE""#, r#"focusable="1""#, r#"focusable="yes""#] {
1918            assert_eq!(
1919                tab_index_with(junk),
1920                tab_index_with(""),
1921                "{junk} is not a bool literal and must be ignored"
1922            );
1923        }
1924
1925        let editable = |v: &str| {
1926            let dom = parse_xml_to_fast_dom(&doc(&format!(r#"<div contenteditable="{v}"></div>"#)))
1927                .expect("valid");
1928            nodes(&dom)[2].is_contenteditable()
1929        };
1930        assert!(editable("true"));
1931        assert!(!editable("false"));
1932        assert!(!editable("TRUE"));
1933        assert!(!editable(""));
1934        assert!(!editable("1"));
1935    }
1936
1937    #[test]
1938    fn parse_xml_to_fast_dom_survives_malformed_style_attributes() {
1939        let big = "a:b;".repeat(2_000);
1940        for style in [
1941            "",
1942            ";;;;",
1943            "::::",
1944            ":",
1945            "width",
1946            "width:",
1947            ":10px",
1948            "a:b:c",
1949            ";:;:;:",
1950            "width:10px",
1951            "width:10px;;;height:;;",
1952            "width:not-a-length",
1953            "🙂:🙂",
1954            big.as_str(),
1955        ] {
1956            let dom = parse_xml_to_fast_dom(&doc(&format!(r#"<div style="{style}"></div>"#)))
1957                .unwrap_or_else(|e| panic!("style={style:?} should parse, got {e}"));
1958            assert_eq!(
1959                nodes(&dom).len(),
1960                3,
1961                "style={style:?} must not change the node count"
1962            );
1963        }
1964    }
1965
1966    #[test]
1967    fn parse_xml_to_fast_dom_survives_unbalanced_tags() {
1968        // The interesting case: elements opened inside <head> are pushed onto
1969        // the tag stack but never opened in the builder, so the EOF unwind
1970        // calls close_node() more often than open_node() ran. That must be a
1971        // no-op, not an underflow.
1972        let dom = parse_xml_to_fast_dom("<html><head><title>").expect("lenient parse");
1973        assert_eq!(nodes(&dom).len(), 1, "only <html> survives");
1974        assert!(matches!(nodes(&dom)[0].get_node_type(), NodeType::Html));
1975
1976        for src in [
1977            "</div>",
1978            "</div></div></div>",
1979            "<a></a></a>",
1980            "<html><body></body></body></html>",
1981            "<html><head><head><head>",
1982            "<html><body><div></span></div></body></html>",
1983        ] {
1984            let a = parse_xml_to_fast_dom(src);
1985            let b = parse_xml_to_fast_dom(src);
1986            assert_eq!(a.is_ok(), b.is_ok(), "{src:?} is non-deterministic");
1987            if let (Ok(a), Ok(b)) = (&a, &b) {
1988                assert_eq!(nodes(a).len(), nodes(b).len(), "{src:?} node count drifted");
1989            }
1990        }
1991    }
1992
1993    #[test]
1994    fn parse_xml_to_fast_dom_is_deterministic_on_garbage() {
1995        for g in GARBAGE {
1996            let a = parse_xml_to_fast_dom(g);
1997            let b = parse_xml_to_fast_dom(g);
1998            assert_eq!(a.is_ok(), b.is_ok(), "{g:?} parsed non-deterministically");
1999            if let (Ok(a), Ok(b)) = (&a, &b) {
2000                assert_eq!(nodes(a).len(), nodes(b).len(), "{g:?} node count drifted");
2001            }
2002        }
2003    }
2004
2005    #[test]
2006    fn parse_xml_to_fast_dom_handles_ten_thousand_nested_elements() {
2007        // The arena path is iterative on the way in and flat on the way out,
2008        // so it should hold a depth the recursive XmlNode tree cannot.
2009        const DEPTH: usize = 10_000;
2010        let mut src = String::with_capacity(DEPTH * 12 + 32);
2011        src.push_str("<html><body>");
2012        for _ in 0..DEPTH {
2013            src.push_str("<div>");
2014        }
2015        for _ in 0..DEPTH {
2016            src.push_str("</div>");
2017        }
2018        src.push_str("</body></html>");
2019
2020        let dom = parse_xml_to_fast_dom(&src).expect("balanced nesting is valid");
2021        assert_eq!(nodes(&dom).len(), DEPTH + 2);
2022    }
2023
2024    #[test]
2025    fn parse_xml_to_fast_dom_handles_a_one_million_char_document() {
2026        let payload = "x".repeat(1_000_000);
2027        let dom = parse_xml_to_fast_dom(&doc(&payload)).expect("long text is valid");
2028        let n = nodes(&dom);
2029        assert_eq!(n.len(), 3, "html + body + one text node");
2030        assert_eq!(text_of(&n[2]).map(|s| s.len()), Some(1_000_000));
2031    }
2032
2033    #[test]
2034    fn parse_xml_to_fast_dom_strips_bom_declaration_doctype_and_comments() {
2035        let expected = nodes(&parse_xml_to_fast_dom(&doc("<div/>")).expect("baseline")).len();
2036        for prefix in [
2037            "\u{FEFF}",
2038            "<?xml version=\"1.0\" encoding=\"utf-8\"?>",
2039            "<!DOCTYPE html>",
2040            "<!doctype HTML>",
2041            "<!DoCtYpE html SYSTEM \"about:legacy-compat\">",
2042            "<!-- leading comment -->",
2043        ] {
2044            let src = format!("{prefix}{}", doc("<div/>"));
2045            let dom = parse_xml_to_fast_dom(&src)
2046                .unwrap_or_else(|e| panic!("{prefix:?} should be stripped, got {e}"));
2047            assert_eq!(nodes(&dom).len(), expected, "{prefix:?} changed the arena");
2048        }
2049    }
2050
2051    #[test]
2052    fn parse_xml_to_fast_dom_preserves_unicode_text() {
2053        for payload in [
2054            "日本語",
2055            "🙂🙂🙂🙂",
2056            "e\u{301}\u{302}\u{303}",
2057            "\u{200B}\u{FEFF}mid-string BOM",
2058            "ﷺ",
2059        ] {
2060            let dom = parse_xml_to_fast_dom(&doc(payload))
2061                .unwrap_or_else(|e| panic!("{payload:?} should parse, got {e}"));
2062            let n = nodes(&dom);
2063            assert_eq!(n.len(), 3, "{payload:?}");
2064            assert_eq!(text_of(&n[2]).as_deref(), Some(payload));
2065        }
2066    }
2067
2068    #[test]
2069    fn parse_xml_to_fast_dom_treats_numeric_looking_documents_as_text() {
2070        // Boundary numeric strings are markup content here, not numbers: they
2071        // must survive verbatim rather than being coerced or rejected.
2072        for payload in [
2073            "0",
2074            "-0",
2075            "9223372036854775807",
2076            "-9223372036854775808",
2077            "18446744073709551616",
2078            "1e309",
2079            "-1e-309",
2080            "NaN",
2081            "inf",
2082            "-inf",
2083        ] {
2084            let dom = parse_xml_to_fast_dom(&doc(payload))
2085                .unwrap_or_else(|e| panic!("{payload:?} should parse, got {e}"));
2086            assert_eq!(text_of(&nodes(&dom)[2]).as_deref(), Some(payload));
2087        }
2088    }
2089
2090    // ------------------------------------------------------------------
2091    // parse_xml_to_styled_dom
2092    // ------------------------------------------------------------------
2093
2094    #[test]
2095    fn parse_xml_to_styled_dom_accepts_empty_and_whitespace_only_input() {
2096        for s in ["", "   ", "\t\n", "\u{FEFF}"] {
2097            let styled = parse_xml_to_styled_dom(s)
2098                .unwrap_or_else(|e| panic!("{s:?} should cascade cleanly, got {e}"));
2099            assert!(styled.node_data.as_ref().is_empty(), "{s:?}");
2100        }
2101    }
2102
2103    #[test]
2104    fn parse_xml_to_styled_dom_keeps_the_fast_dom_node_count() {
2105        for src in [
2106            doc("<div>hi</div>"),
2107            doc("<div><span>a</span><span>b</span></div>"),
2108            "<html><head><style>div { width: 10px; }</style></head><body><div/></body></html>"
2109                .to_string(),
2110        ] {
2111            let fast = parse_xml_to_fast_dom(&src).expect("fast path");
2112            let styled = parse_xml_to_styled_dom(&src).expect("styled path");
2113            assert_eq!(
2114                styled.node_data.as_ref().len(),
2115                nodes(&fast).len(),
2116                "the cascade must not add or drop nodes for {src:?}"
2117            );
2118            assert_eq!(
2119                styled.node_hierarchy.as_ref().len(),
2120                styled.node_data.as_ref().len()
2121            );
2122        }
2123    }
2124
2125    #[test]
2126    fn parse_xml_to_styled_dom_is_deterministic_on_garbage() {
2127        for g in GARBAGE {
2128            let a = parse_xml_to_styled_dom(g);
2129            let b = parse_xml_to_styled_dom(g);
2130            assert_eq!(a.is_ok(), b.is_ok(), "{g:?} cascaded non-deterministically");
2131        }
2132    }
2133
2134    // ------------------------------------------------------------------
2135    // dom_from_parsed_xml
2136    // ------------------------------------------------------------------
2137
2138    #[test]
2139    fn dom_from_parsed_xml_reports_errors_instead_of_panicking() {
2140        // No <html>/<body>: the documented behaviour is an error Dom, not a
2141        // panic and not an empty tree.
2142        for root in [
2143            Vec::new(),
2144            vec![XmlNodeChild::Text("bare text".into())],
2145            vec![XmlNodeChild::Element(XmlNode::create("div"))],
2146            vec![XmlNodeChild::Element(XmlNode::create("html"))],
2147        ] {
2148            let dom = dom_from_parsed_xml(Xml { root: root.into() });
2149            assert!(
2150                matches!(dom.root.get_node_type(), NodeType::Body),
2151                "the error Dom is rendered as a <body> with a label"
2152            );
2153            assert_eq!(dom.children.as_ref().len(), 1);
2154        }
2155    }
2156
2157    #[test]
2158    fn dom_from_parsed_xml_builds_a_dom_for_a_minimal_document() {
2159        let body = XmlNode::create("body")
2160            .with_children(vec![XmlNodeChild::Element(XmlNode::create("div"))]);
2161        let html = XmlNode::create("html").with_children(vec![XmlNodeChild::Element(body)]);
2162        let dom = dom_from_parsed_xml(Xml {
2163            root: vec![XmlNodeChild::Element(html)].into(),
2164        });
2165
2166        assert!(matches!(dom.root.get_node_type(), NodeType::Html));
2167        assert_eq!(dom.children.as_ref().len(), 1, "the <body> subtree");
2168    }
2169
2170    #[test]
2171    fn dom_from_parsed_xml_caps_recursion_on_deeply_nested_input() {
2172        // MAX_XML_NESTING_DEPTH is 512; past it the builder drops children
2173        // instead of blowing the native stack.
2174        const DEPTH: usize = 550;
2175        let mut node = XmlNode::create("div");
2176        for _ in 0..DEPTH {
2177            node = XmlNode::create("div").with_children(vec![XmlNodeChild::Element(node)]);
2178        }
2179        let body = XmlNode::create("body").with_children(vec![XmlNodeChild::Element(node)]);
2180        let html = XmlNode::create("html").with_children(vec![XmlNodeChild::Element(body)]);
2181
2182        let dom = dom_from_parsed_xml(Xml {
2183            root: vec![XmlNodeChild::Element(html)].into(),
2184        });
2185        assert!(matches!(dom.root.get_node_type(), NodeType::Html));
2186    }
2187
2188    // ------------------------------------------------------------------
2189    // domxml_from_str / domxml_from_file / DomXmlExt
2190    // ------------------------------------------------------------------
2191
2192    #[cfg(feature = "xml")]
2193    #[test]
2194    fn domxml_from_str_never_fails() {
2195        let map = ComponentMap::with_builtin();
2196        let mut cases: Vec<String> = GARBAGE.iter().map(|s| (*s).to_string()).collect();
2197        cases.push(String::new());
2198        cases.push("   ".to_string());
2199        cases.push("<svg".to_string());
2200        cases.push("<?xml".to_string());
2201        cases.push(doc("<div>hi</div>"));
2202
2203        for src in cases {
2204            let dom_xml = domxml_from_str(&src, &map);
2205            assert!(
2206                !dom_xml.parsed_dom.node_data.as_ref().is_empty(),
2207                "{src:?} produced an empty StyledDom; errors must render as a label"
2208            );
2209        }
2210    }
2211
2212    #[cfg(all(feature = "std", feature = "xml"))]
2213    #[test]
2214    fn domxml_from_file_renders_io_errors_as_a_dom() {
2215        let map = ComponentMap::with_builtin();
2216        for path in [
2217            "/nonexistent-azul-autotest-dir/definitely-not-here.xml",
2218            "",
2219            "/",
2220            "/proc/self/nonexistent-🙂",
2221        ] {
2222            let dom_xml = domxml_from_file(path, &map);
2223            assert!(
2224                !dom_xml.parsed_dom.node_data.as_ref().is_empty(),
2225                "{path:?} must render the io::Error as a label, not fail"
2226            );
2227        }
2228    }
2229
2230    #[cfg(feature = "xml")]
2231    #[test]
2232    fn dom_xml_ext_matches_domxml_from_str() {
2233        let map = ComponentMap::with_builtin();
2234        let valid = doc("<div>hi</div>");
2235        for src in ["", "<svg", valid.as_str()] {
2236            let via_ext = <Dom as DomXmlExt>::from_xml_string(src);
2237            let via_fn = domxml_from_str(src, &map).parsed_dom;
2238            assert_eq!(
2239                via_ext.node_data.as_ref().len(),
2240                via_fn.node_data.as_ref().len(),
2241                "the extension trait must be a pure delegation for {src:?}"
2242            );
2243        }
2244    }
2245
2246    // ------------------------------------------------------------------
2247    // peak_rss_bytes
2248    // ------------------------------------------------------------------
2249
2250    #[test]
2251    fn peak_rss_bytes_never_panics_and_never_goes_backwards() {
2252        let a = peak_rss_bytes();
2253        let _ballast = "x".repeat(4 * 1024 * 1024);
2254        let b = peak_rss_bytes();
2255
2256        #[cfg(all(unix, feature = "probe"))]
2257        assert!(
2258            b >= a,
2259            "ru_maxrss is a high-water mark and must never decrease ({a} -> {b})"
2260        );
2261        #[cfg(not(all(unix, feature = "probe")))]
2262        assert_eq!(
2263            (a, b),
2264            (0, 0),
2265            "without the probe feature the stub must be a constant 0"
2266        );
2267    }
2268
2269    // ------------------------------------------------------------------
2270    // translate_* (xmlparser / roxmltree -> FFI-stable azul types)
2271    // ------------------------------------------------------------------
2272
2273    #[cfg(feature = "xml")]
2274    #[test]
2275    fn translate_textpos_round_trips_boundary_values() {
2276        for (row, col) in [
2277            (0, 0),
2278            (1, 1),
2279            (0, u32::MAX),
2280            (u32::MAX, 0),
2281            (u32::MAX, u32::MAX),
2282        ] {
2283            let expected = XmlTextPos { row, col };
2284            assert_eq!(
2285                translate_xmlparser_textpos(xmlparser::TextPos::new(row, col)),
2286                expected
2287            );
2288            assert_eq!(
2289                translate_roxml_textpos(roxmltree::TextPos::new(row, col)),
2290                expected
2291            );
2292        }
2293    }
2294
2295    #[cfg(feature = "xml")]
2296    #[test]
2297    fn translate_roxmltree_expandedname_preserves_name_and_namespace() {
2298        let plain: roxmltree::ExpandedName<'_, '_> = "rect".into();
2299        let out = translate_roxmltree_expandedname(plain);
2300        assert_eq!(out.local_name.as_str(), "rect");
2301        assert!(out.namespace.as_ref().is_none());
2302
2303        let ns: roxmltree::ExpandedName<'_, '_> = ("http://www.w3.org/2000/svg", "rect").into();
2304        let out = translate_roxmltree_expandedname(ns);
2305        assert_eq!(out.local_name.as_str(), "rect");
2306        assert_eq!(
2307            out.namespace.as_ref().map(AzString::as_str),
2308            Some("http://www.w3.org/2000/svg")
2309        );
2310
2311        // Degenerate names must survive untouched, not be normalised away.
2312        for name in ["", " ", "日本語-🙂", "a:b"] {
2313            let e: roxmltree::ExpandedName<'_, '_> = name.into();
2314            assert_eq!(translate_roxmltree_expandedname(e).local_name.as_str(), name);
2315        }
2316        let empty_ns: roxmltree::ExpandedName<'_, '_> = ("", "x").into();
2317        assert_eq!(
2318            translate_roxmltree_expandedname(empty_ns)
2319                .namespace
2320                .as_ref()
2321                .map(AzString::as_str),
2322            Some(""),
2323            "an empty namespace URI is Some(\"\"), not None"
2324        );
2325    }
2326
2327    #[cfg(feature = "xml")]
2328    #[test]
2329    fn translate_roxmltree_attribute_preserves_name_and_namespace() {
2330        let rdoc = roxmltree::Document::parse(r#"<e xmlns:x="urn:x" x:a="1" b="2"/>"#)
2331            .expect("valid XML");
2332        let attrs: Vec<XmlQualifiedName> = rdoc
2333            .root_element()
2334            .attributes()
2335            .map(translate_roxmltree_attribute)
2336            .collect();
2337
2338        assert_eq!(attrs.len(), 2, "xmlns declarations are not attributes");
2339        let a = attrs
2340            .iter()
2341            .find(|q| q.local_name.as_str() == "a")
2342            .expect("x:a");
2343        assert_eq!(a.namespace.as_ref().map(AzString::as_str), Some("urn:x"));
2344        let b = attrs
2345            .iter()
2346            .find(|q| q.local_name.as_str() == "b")
2347            .expect("b");
2348        assert!(
2349            b.namespace.as_ref().is_none(),
2350            "an unprefixed attribute has no namespace"
2351        );
2352    }
2353
2354    #[cfg(feature = "xml")]
2355    #[test]
2356    fn translate_xmlparser_streamerror_maps_every_variant() {
2357        use xmlparser::StreamError as Se;
2358
2359        let p = xmlparser::TextPos::new(3, 7);
2360        let x = XmlTextPos { row: 3, col: 7 };
2361
2362        assert_eq!(
2363            translate_xmlparser_streamerror(Se::UnexpectedEndOfStream),
2364            XmlStreamError::UnexpectedEndOfStream
2365        );
2366        assert_eq!(
2367            translate_xmlparser_streamerror(Se::InvalidName),
2368            XmlStreamError::InvalidName
2369        );
2370        assert_eq!(
2371            translate_xmlparser_streamerror(Se::InvalidReference),
2372            XmlStreamError::InvalidReference
2373        );
2374        assert_eq!(
2375            translate_xmlparser_streamerror(Se::InvalidExternalID),
2376            XmlStreamError::InvalidExternalID
2377        );
2378        assert_eq!(
2379            translate_xmlparser_streamerror(Se::InvalidCommentData),
2380            XmlStreamError::InvalidCommentData
2381        );
2382        assert_eq!(
2383            translate_xmlparser_streamerror(Se::InvalidCommentEnd),
2384            XmlStreamError::InvalidCommentEnd
2385        );
2386        assert_eq!(
2387            translate_xmlparser_streamerror(Se::InvalidCharacterData),
2388            XmlStreamError::InvalidCharacterData
2389        );
2390        // Astral char -> u32 (the FFI-stable representation) without loss.
2391        assert_eq!(
2392            translate_xmlparser_streamerror(Se::NonXmlChar('\u{1F600}', p)),
2393            XmlStreamError::NonXmlChar(NonXmlCharError {
2394                ch: 0x1F600,
2395                pos: x
2396            })
2397        );
2398        assert_eq!(
2399            translate_xmlparser_streamerror(Se::InvalidQuote(b'`', p)),
2400            XmlStreamError::InvalidQuote(InvalidQuoteError { got: b'`', pos: x })
2401        );
2402        assert_eq!(
2403            translate_xmlparser_streamerror(Se::InvalidSpace(b'\t', p)),
2404            XmlStreamError::InvalidSpace(InvalidSpaceError { got: b'\t', pos: x })
2405        );
2406        assert_eq!(
2407            translate_xmlparser_streamerror(Se::InvalidString("?>", p)),
2408            XmlStreamError::InvalidString(InvalidStringError {
2409                got: "?>".into(),
2410                pos: x
2411            })
2412        );
2413        // NOTE: xmlparser documents InvalidChar/InvalidCharMultiple as
2414        // (actual, expected, pos), but the translation stores the first field
2415        // as `expected` and the second as `got` — i.e. the two are swapped.
2416        // Characterised here rather than "fixed" in the test: it only affects
2417        // error-message wording, and pinning it makes the swap visible if the
2418        // mapping is ever corrected.
2419        assert_eq!(
2420            translate_xmlparser_streamerror(Se::InvalidChar(b'a', b'b', p)),
2421            XmlStreamError::InvalidChar(InvalidCharError {
2422                expected: b'a',
2423                got: b'b',
2424                pos: x
2425            })
2426        );
2427        assert_eq!(
2428            translate_xmlparser_streamerror(Se::InvalidCharMultiple(b'a', &b"xy"[..], p)),
2429            XmlStreamError::InvalidCharMultiple(InvalidCharMultipleError {
2430                expected: b'a',
2431                got: vec![b'x', b'y'].into(),
2432                pos: x
2433            })
2434        );
2435    }
2436
2437    #[cfg(feature = "xml")]
2438    #[test]
2439    fn translate_xmlparser_error_maps_every_variant() {
2440        use xmlparser::{Error as Xe, StreamError as Se};
2441
2442        let p = xmlparser::TextPos::new(9, 4);
2443        let x = XmlTextPos { row: 9, col: 4 };
2444        let te = XmlTextError {
2445            stream_error: XmlStreamError::InvalidName,
2446            pos: x,
2447        };
2448
2449        assert_eq!(
2450            translate_xmlparser_error(Xe::InvalidDeclaration(Se::InvalidName, p)),
2451            XmlParseError::InvalidDeclaration(te.clone())
2452        );
2453        assert_eq!(
2454            translate_xmlparser_error(Xe::InvalidComment(Se::InvalidName, p)),
2455            XmlParseError::InvalidComment(te.clone())
2456        );
2457        assert_eq!(
2458            translate_xmlparser_error(Xe::InvalidPI(Se::InvalidName, p)),
2459            XmlParseError::InvalidPI(te.clone())
2460        );
2461        assert_eq!(
2462            translate_xmlparser_error(Xe::InvalidDoctype(Se::InvalidName, p)),
2463            XmlParseError::InvalidDoctype(te.clone())
2464        );
2465        assert_eq!(
2466            translate_xmlparser_error(Xe::InvalidEntity(Se::InvalidName, p)),
2467            XmlParseError::InvalidEntity(te.clone())
2468        );
2469        assert_eq!(
2470            translate_xmlparser_error(Xe::InvalidElement(Se::InvalidName, p)),
2471            XmlParseError::InvalidElement(te.clone())
2472        );
2473        assert_eq!(
2474            translate_xmlparser_error(Xe::InvalidAttribute(Se::InvalidName, p)),
2475            XmlParseError::InvalidAttribute(te.clone())
2476        );
2477        assert_eq!(
2478            translate_xmlparser_error(Xe::InvalidCdata(Se::InvalidName, p)),
2479            XmlParseError::InvalidCdata(te.clone())
2480        );
2481        assert_eq!(
2482            translate_xmlparser_error(Xe::InvalidCharData(Se::InvalidName, p)),
2483            XmlParseError::InvalidCharData(te)
2484        );
2485        assert_eq!(
2486            translate_xmlparser_error(Xe::UnknownToken(p)),
2487            XmlParseError::UnknownToken(x)
2488        );
2489    }
2490
2491    #[cfg(feature = "xml")]
2492    #[test]
2493    fn translate_roxmltree_error_maps_every_variant() {
2494        use roxmltree::Error as Re;
2495
2496        let p = roxmltree::TextPos::new(2, 5);
2497        let x = XmlTextPos { row: 2, col: 5 };
2498
2499        assert_eq!(
2500            translate_roxmltree_error(Re::InvalidXmlPrefixUri(p)),
2501            XmlError::InvalidXmlPrefixUri(x)
2502        );
2503        assert_eq!(
2504            translate_roxmltree_error(Re::UnexpectedXmlUri(p)),
2505            XmlError::UnexpectedXmlUri(x)
2506        );
2507        assert_eq!(
2508            translate_roxmltree_error(Re::UnexpectedXmlnsUri(p)),
2509            XmlError::UnexpectedXmlnsUri(x)
2510        );
2511        assert_eq!(
2512            translate_roxmltree_error(Re::InvalidElementNamePrefix(p)),
2513            XmlError::InvalidElementNamePrefix(x)
2514        );
2515        assert_eq!(
2516            translate_roxmltree_error(Re::DuplicatedNamespace(String::from("ns"), p)),
2517            XmlError::DuplicatedNamespace(DuplicatedNamespaceError {
2518                ns: "ns".into(),
2519                pos: x
2520            })
2521        );
2522        assert_eq!(
2523            translate_roxmltree_error(Re::UnknownNamespace(String::from("ns"), p)),
2524            XmlError::UnknownNamespace(UnknownNamespaceError {
2525                ns: "ns".into(),
2526                pos: x
2527            })
2528        );
2529        assert_eq!(
2530            translate_roxmltree_error(Re::UnexpectedCloseTag(
2531                String::from("a"),
2532                String::from("b"),
2533                p
2534            )),
2535            XmlError::UnexpectedCloseTag(UnexpectedCloseTagError {
2536                expected: "a".into(),
2537                actual: "b".into(),
2538                pos: x
2539            })
2540        );
2541        assert_eq!(
2542            translate_roxmltree_error(Re::UnexpectedEntityCloseTag(p)),
2543            XmlError::UnexpectedEntityCloseTag(x)
2544        );
2545        assert_eq!(
2546            translate_roxmltree_error(Re::UnknownEntityReference(String::from("e"), p)),
2547            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
2548                entity: "e".into(),
2549                pos: x
2550            })
2551        );
2552        assert_eq!(
2553            translate_roxmltree_error(Re::MalformedEntityReference(p)),
2554            XmlError::MalformedEntityReference(x)
2555        );
2556        assert_eq!(
2557            translate_roxmltree_error(Re::EntityReferenceLoop(p)),
2558            XmlError::EntityReferenceLoop(x)
2559        );
2560        assert_eq!(
2561            translate_roxmltree_error(Re::InvalidAttributeValue(p)),
2562            XmlError::InvalidAttributeValue(x)
2563        );
2564        assert_eq!(
2565            translate_roxmltree_error(Re::DuplicatedAttribute(String::from("a"), p)),
2566            XmlError::DuplicatedAttribute(DuplicatedAttributeError {
2567                attribute: "a".into(),
2568                pos: x
2569            })
2570        );
2571        assert_eq!(
2572            translate_roxmltree_error(Re::NoRootNode),
2573            XmlError::NoRootNode
2574        );
2575        assert_eq!(
2576            translate_roxmltree_error(Re::DtdDetected),
2577            XmlError::DtdDetected
2578        );
2579        assert_eq!(
2580            translate_roxmltree_error(Re::UnclosedRootNode),
2581            XmlError::UnclosedRootNode
2582        );
2583        assert_eq!(
2584            translate_roxmltree_error(Re::UnexpectedDeclaration(p)),
2585            XmlError::UnexpectedDeclaration(x)
2586        );
2587        assert_eq!(
2588            translate_roxmltree_error(Re::NodesLimitReached),
2589            XmlError::NodesLimitReached
2590        );
2591        assert_eq!(
2592            translate_roxmltree_error(Re::AttributesLimitReached),
2593            XmlError::AttributesLimitReached
2594        );
2595        assert_eq!(
2596            translate_roxmltree_error(Re::NamespacesLimitReached),
2597            XmlError::NamespacesLimitReached
2598        );
2599        assert_eq!(
2600            translate_roxmltree_error(Re::InvalidName(p)),
2601            XmlError::InvalidName(x)
2602        );
2603        assert_eq!(
2604            translate_roxmltree_error(Re::NonXmlChar('\u{0}', p)),
2605            XmlError::NonXmlChar(x)
2606        );
2607        assert_eq!(
2608            translate_roxmltree_error(Re::InvalidChar(b'a', b'b', p)),
2609            XmlError::InvalidChar(x)
2610        );
2611        assert_eq!(
2612            translate_roxmltree_error(Re::InvalidChar2("ab", b'c', p)),
2613            XmlError::InvalidChar2(x)
2614        );
2615        assert_eq!(
2616            translate_roxmltree_error(Re::InvalidString("s", p)),
2617            XmlError::InvalidString(x)
2618        );
2619        assert_eq!(
2620            translate_roxmltree_error(Re::InvalidExternalID(p)),
2621            XmlError::InvalidExternalID(x)
2622        );
2623        assert_eq!(
2624            translate_roxmltree_error(Re::InvalidComment(p)),
2625            XmlError::InvalidComment(x)
2626        );
2627        assert_eq!(
2628            translate_roxmltree_error(Re::InvalidCharacterData(p)),
2629            XmlError::InvalidCharacterData(x)
2630        );
2631        assert_eq!(
2632            translate_roxmltree_error(Re::UnknownToken(p)),
2633            XmlError::UnknownToken(x)
2634        );
2635        assert_eq!(
2636            translate_roxmltree_error(Re::UnexpectedEndOfStream),
2637            XmlError::UnexpectedEndOfStream
2638        );
2639        // roxmltree 0.21's EntityResolver is folded into UnknownEntityReference.
2640        assert_eq!(
2641            translate_roxmltree_error(Re::EntityResolver(p, String::from("e"))),
2642            XmlError::UnknownEntityReference(UnknownEntityReferenceError {
2643                entity: "e".into(),
2644                pos: x
2645            })
2646        );
2647    }
2648}