Skip to main content

sup_xml_core/
parser.rs

1//! SAX-driven arena DOM parser.
2//!
3//! Produces a [`sup_xml_tree::dom::Document`] by feeding [`XmlReader`]
4//! events into a [`DocumentBuilder`].  Compared to the existing
5//! [`parse_str`](crate::parse_str) / [`parse_bytes`](crate::parse_bytes) DOM
6//! parsers — which build a per-node-heap-allocated tree — this version
7//! allocates the entire tree in a single [`bumpalo::Bump`].  Per-node alloc
8//! cost drops to a pointer bump; drop is free per node.
9//!
10//! # Why a separate entry point
11//!
12//! The arena DOM has a different type ([`arena::Document`]) than the legacy
13//! tree.  We expose this as a parallel API so consumers can migrate one at a
14//! time.  Once everything's ported, the legacy entry points can become thin
15//! wrappers (or get deleted).
16//!
17//! # Implementation
18//!
19//! Wraps [`XmlReader`] — the SAX layer.  All XML correctness (well-formedness
20//! checks, entity expansion, encoding handling, recovery mode) comes from the
21//! reader.  This module is pure tree assembly: pop/push elements on a stack,
22//! attach leaf nodes to the current top, copy strings into the arena.
23//!
24//! # Example
25//!
26//! ```
27//! use sup_xml_core::{parse_str, ParseOptions};
28//! let doc = parse_str("<r><a id='1'/></r>", &ParseOptions::default()).unwrap();
29//! let root = doc.root();
30//! assert_eq!(root.name(), "r");
31//! let a = root.children().next().unwrap();
32//! assert_eq!(a.name(), "a");
33//! assert_eq!(a.attributes().next().unwrap().value(), "1");
34//! ```
35
36use crate::encoding;
37use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
38use crate::ns_helpers::{
39    ns_err, validate_qname, validate_xmlns_decl, XML_NS_URI, XMLNS_NS_URI,
40};
41use crate::options::ParseOptions;
42use crate::xml_bytes_reader::{BytesAttr, BytesEvent, XmlBytesReader};
43use rustc_hash::FxHashSet;
44use sup_xml_tree::dom::{Attribute, Document, DocumentBuilder, Namespace, Node};
45
46/// Parse `input` into an arena-allocated [`Document`].  Uses default
47/// [`ParseOptions`].
48pub fn parse_str(input: &str, opts: &ParseOptions) -> Result<Document> {
49    let source: Box<[u8]> = input.as_bytes().to_vec().into_boxed_slice();
50    parse_owned_bytes(source, opts)
51}
52
53/// Recovery-mode sibling of [`parse_str`].  Returns the (best-effort)
54/// parsed [`Document`] along with the list of non-fatal errors that
55/// recovery mode forgave.  When [`ParseOptions::recovery_mode`] is `false`
56/// the second element is always empty and the first is the same `Result`
57/// as [`parse_str`] would have produced.
58pub fn parse_str_with_recovered(
59    input: &str,
60    opts: &ParseOptions,
61) -> (Result<Document>, Vec<XmlError>) {
62    let source: Box<[u8]> = input.as_bytes().to_vec().into_boxed_slice();
63    parse_owned_bytes_with_recovered(source, opts)
64}
65
66/// Byte-slice sibling of [`parse_str`].
67///
68/// With [`ParseOptions::auto_transcode`] true (the default), non-UTF-8
69/// input is detected and converted to UTF-8 before parsing — see that
70/// field's docs for the supported set.  Set `auto_transcode = false` to
71/// require UTF-8 input and reject anything else with
72/// [`ErrorDomain::Encoding`].
73pub fn parse_bytes(input: &[u8], opts: &ParseOptions) -> Result<Document> {
74    let source = transcode_and_validate(input, opts)?;
75    parse_owned_bytes(source, opts)
76}
77
78/// Variant of [`parse_bytes`] that also returns the DTD captured from
79/// the internal subset.  Returns an empty [`Dtd`](crate::dtd::Dtd)
80/// when the document had no `<!DOCTYPE … [ … ]>` or no
81/// `<!ELEMENT>` / `<!ATTLIST>` declarations.
82pub fn parse_bytes_with_dtd(
83    input: &[u8],
84    opts:  &ParseOptions,
85) -> Result<(Document, crate::dtd::Dtd)> {
86    let source = transcode_and_validate(input, opts)?;
87    let b = DocumentBuilder::new();
88    b.set_source(source);
89    let src_bytes: &[u8] = b.source().expect("source just set");
90    // SAFETY: transcode_and_validate guarantees UTF-8.
91    let mut reader = unsafe { XmlBytesReader::from_bytes_unchecked(src_bytes) }
92        .with_options(opts.clone());
93    drive(&b, &mut reader, opts)?;
94    let dtd = reader.take_dtd();
95    Ok((b.build(), dtd))
96}
97
98/// Parse a standalone external DTD subset into a [`Dtd`](crate::dtd::Dtd).
99///
100/// `input` is the raw bytes of a DTD — the markup declarations a `.dtd`
101/// file (or an `etree.DTD(...)` source) contains, with no surrounding
102/// document or `<!DOCTYPE>` wrapper.  Conditional sections and
103/// top-level parameter-entity references are permitted, per the XML 1.0
104/// § 2.8 external-subset grammar (the internal subset forbids both).
105///
106/// Returns the captured declarations.  A fatal malformation returns
107/// `Err`; recoverable issues are tolerated (a non-validating DTD parse
108/// is best-effort, matching libxml2's `xmlParseDTD`).
109pub fn parse_external_subset(
110    input: &[u8],
111    opts:  &ParseOptions,
112) -> Result<crate::dtd::Dtd> {
113    let bytes = transcode_and_validate(input, opts)?;
114    // SAFETY: transcode_and_validate guarantees the buffer is UTF-8.
115    let text = unsafe { String::from_utf8_unchecked(bytes.into_vec()) };
116    // The subset is read from a pushed entity-stream frame; the reader's
117    // own source is the empty (static) slice, which is a valid origin.
118    let mut reader = unsafe { XmlBytesReader::from_bytes_unchecked(&[]) }
119        .with_options(opts.clone());
120    reader.parse_standalone_external_subset(text)?;
121    Ok(reader.take_dtd())
122}
123
124/// Variant of [`parse_bytes_with_dtd`] that interns names through
125/// a caller-supplied refcounted dict instead of an internal one.
126/// Use when the resulting document needs to share name canonicals
127/// with another consumer that owns the same dict (e.g. a C-ABI
128/// parser context whose `ctxt->dict` already points to a thread-
129/// shared interner).
130///
131/// The dict's refcount is bumped for the new document's reference;
132/// the caller's own reference remains independent.
133///
134/// # Safety
135///
136/// `dict` must be a valid pointer returned by
137/// [`crate::dict::Dict::new_refcounted`] (or otherwise refcount-
138/// managed), with at least one outstanding reference.
139#[cfg(feature = "c-abi")]
140pub unsafe fn parse_bytes_with_dtd_and_dict(
141    input: &[u8],
142    opts:  &ParseOptions,
143    dict:  *mut sup_xml_tree::dict::Dict,
144) -> Result<(Document, crate::dtd::Dtd)> {
145    let source = transcode_and_validate(input, opts)?;
146    // SAFETY: caller asserts `dict` is live with positive refcount.
147    let b = unsafe { DocumentBuilder::new_with_dict(dict) };
148    b.set_source(source);
149    let src_bytes: &[u8] = b.source().expect("source just set");
150    let mut reader = unsafe { XmlBytesReader::from_bytes_unchecked(src_bytes) }
151        .with_options(opts.clone());
152    drive(&b, &mut reader, opts)?;
153    let dtd = reader.take_dtd();
154    Ok((b.build(), dtd))
155}
156
157/// Variant of [`parse_bytes_with_dtd_and_dict`] that also adopts an
158/// externally-supplied [`Bump`] arena (shared via `Arc`).  Used by
159/// C-ABI consumers that route every per-thread parse through a
160/// single shared arena — node memory then outlives any individual
161/// document and cross-doc graft operations are safe by construction.
162///
163/// # Safety
164///
165/// `dict` must be a valid refcount-managed [`crate::dict::Dict`].
166/// `arena` may be cloned from any source; it becomes one of the
167/// document's references.
168#[cfg(feature = "c-abi")]
169pub unsafe fn parse_bytes_with_dtd_dict_arena(
170    input: &[u8],
171    opts:  &ParseOptions,
172    dict:  *mut sup_xml_tree::dict::Dict,
173    arena: std::sync::Arc<bumpalo::Bump>,
174) -> Result<(Document, crate::dtd::Dtd)> {
175    let source = transcode_and_validate(input, opts)?;
176    // SAFETY: caller asserts `dict` is live with positive refcount.
177    let b = unsafe { DocumentBuilder::new_with_dict_and_arena(dict, arena) };
178    b.set_source(source);
179    let src_bytes: &[u8] = b.source().expect("source just set");
180    let mut reader = unsafe { XmlBytesReader::from_bytes_unchecked(src_bytes) }
181        .with_options(opts.clone());
182    drive(&b, &mut reader, opts)?;
183    let dtd = reader.take_dtd();
184    Ok((b.build(), dtd))
185}
186
187/// Recovery-mode sibling of [`parse_bytes`].  See
188/// [`parse_str_with_recovered`] for semantics.
189pub fn parse_bytes_with_recovered(
190    input: &[u8],
191    opts: &ParseOptions,
192) -> (Result<Document>, Vec<XmlError>) {
193    let source = match transcode_and_validate(input, opts) {
194        Ok(s) => s,
195        Err(e) => return (Err(e), Vec::new()),
196    };
197    parse_owned_bytes_with_recovered(source, opts)
198}
199
200/// Byte-slice version that skips the upfront UTF-8 validation.  Mirrors
201/// [`parse_bytes_unchecked`](crate::parse_bytes_unchecked).
202///
203/// # Safety
204///
205/// `input` must be valid UTF-8.
206pub unsafe fn parse_bytes_unchecked(input: &[u8], opts: &ParseOptions) -> Result<Document> {
207    // SAFETY: caller asserts UTF-8 (per the function contract).
208    let source: Box<[u8]> = input.to_vec().into_boxed_slice();
209    parse_owned_bytes(source, opts)
210}
211
212/// Destructive-parse fast path.  Takes ownership of `buf`, mutates it
213/// in place during parsing, and returns a [`Document`] whose strings
214/// point directly into the (now-mutated) buffer.  The Document keeps
215/// the buffer alive for its lifetime.
216///
217/// **The speedup vs [`parse_bytes`] is workload-dependent and depends
218/// on what flags you pass in `opts`.**  On entity-heavy documents the
219/// structural in-place mechanism (in-place entity decode, zero string
220/// copy) wins ~20-30% even with full XML 1.0 validation enabled.  On
221/// documents that contain few or no entities (most "data" XML —
222/// swiss_prot, OSM, sitemaps, RSS) the structural win is small —
223/// often within run-to-run noise — because the validation cost
224/// dominates and both paths pay it equally.  In that regime, the
225/// bigger lever is the four `skip_*` validation flags: passing all
226/// four `true` reaches roughly half of pugixml's throughput.  See
227/// "When to use this" below for which combination matches your needs.
228///
229/// # Why it's faster
230///
231/// Two structural advantages over [`parse_bytes`], independent of any
232/// validation flags:
233///
234/// 1. **No string copy into the arena.**  Element names, attribute
235///    values, and text content slices point directly at bytes inside
236///    `buf`.  [`parse_bytes`] copies them into a fresh arena (or, when
237///    borrowing succeeds, holds the source separately).
238/// 2. **In-place entity decode.**  Builtin entities (`&amp;`, `&lt;`,
239///    `&gt;`, `&apos;`, `&quot;`), numeric character references, and
240///    newline normalization are decoded by mutating the source bytes
241///    in place — no scratch buffer per text chunk.  User-defined
242///    entities with replacement text smaller than the `&name;`
243///    reference also fit in place; larger ones are rejected (see
244///    Errors below).
245///
246/// **The skip-all validation flags are NOT applied automatically.**
247/// If you want the maximum speed shown in the benchmarks (about ~30%
248/// on top of the structural wins above), build `ParseOptions` with
249/// `skip_xml_char_validation`, `skip_name_validation`,
250/// `skip_attr_validation`, and `skip_end_tag_check` set to `true`.
251/// Otherwise the parser still performs full XML 1.0 validation while
252/// it parses destructively.
253///
254/// ```
255/// use sup_xml_core::{parse_bytes_in_place, ParseOptions};
256///
257/// let buf: Vec<u8> = b"<root><child id=\"1\"/></root>".to_vec();
258///
259/// // Full XML 1.0 validation + destructive parse:
260/// let _doc = parse_bytes_in_place(buf.clone(), &ParseOptions::default())?;
261///
262/// // Trust-the-input maximum-speed:
263/// let fast_opts = ParseOptions {
264///     skip_xml_char_validation: true,
265///     skip_name_validation:     true,
266///     skip_attr_validation:     true,
267///     skip_end_tag_check:       true,
268///     ..ParseOptions::default()
269/// };
270/// let _doc = parse_bytes_in_place(buf, &fast_opts)?;
271/// # Ok::<(), sup_xml_core::XmlError>(())
272/// ```
273///
274/// # When to use this vs [`parse_bytes`]
275///
276/// Pick **`parse_bytes_in_place`** when:
277/// - You own the input buffer and don't need to preserve its original
278///   bytes (round-trip-byte-identical serialization isn't a goal).
279/// - Your inputs use only the 5 XML 1.0 builtin entities, OR any
280///   user-defined `<!ENTITY>` declarations have replacement text whose
281///   byte length is ≤ the corresponding `&name;` reference.
282/// - You do NOT need [`ParseOptions::recovery_mode`].
283///
284/// Pick **[`parse_bytes`]** when:
285/// - You need lossless round-trip (preserve the input bytes verbatim).
286/// - You need [`ParseOptions::recovery_mode`].
287/// - You don't own the buffer or can't have it consumed.
288///
289/// # Errors
290///
291/// Returns `Err` immediately (before any mutation) for:
292/// - `opts.recovery_mode == true` — recovery is incompatible with
293///   destructive parsing (we can't unmutate after the fact).
294///
295/// Returns `Err` during parsing for:
296/// - User `<!ENTITY>` whose expansion exceeds its reference length —
297///   the bytes don't fit in place.
298/// - Cyclic entity references — well-formedness error, same as
299///   [`parse_bytes`].
300/// - Any other XML 1.0 well-formedness violation.
301///
302/// On any error, `buf` is consumed and dropped (it has been partially
303/// mutated by the time most errors fire; handing it back would be
304/// misleading).
305///
306/// # Buffer ownership
307///
308/// `buf` is consumed unconditionally — successful parse returns a
309/// [`Document`] that owns the buffer; failed parse drops it.  If you
310/// might need to fall back to [`parse_bytes`], use that entry point
311/// from the start; speculative pre-cloning defeats the performance
312/// benefit this function exists for.
313pub fn parse_bytes_in_place(buf: Vec<u8>, opts: &ParseOptions) -> Result<Document> {
314    // `parse_bytes_in_place` honors every flag on `opts` as-is.  In
315    // particular it does NOT silently flip the four `skip_*` validation
316    // flags on — callers who want the fastest possible path build their
317    // own `ParseOptions` with `skip_xml_char_validation`,
318    // `skip_name_validation`, `skip_attr_validation`, and
319    // `skip_end_tag_check` all set to `true`.  Callers who want
320    // destructive parsing PLUS full validation (e.g. content from a
321    // semi-trusted source, but they own the buffer and want the
322    // in-place perf win on entity decode + zero string copy) pass
323    // `ParseOptions::default()` and get that.
324
325    // Up-front: recovery + in-place is fundamentally incompatible.
326    if opts.recovery_mode {
327        return Err(XmlError::new(
328            ErrorDomain::Parser,
329            ErrorLevel::Fatal,
330            "parse_bytes_in_place does not support ParseOptions::recovery_mode \
331             — destructive parsing can't unwind after a partial mutation. \
332             Use parse_bytes if you need recovery."
333                .to_string(),
334        ));
335    }
336
337    // Encoding: if auto-transcode is on and the input isn't UTF-8, we
338    // transcode upfront into a fresh Vec<u8>.  That's one copy; from
339    // there, all subsequent string handling is in-place against the
340    // transcoded buffer.
341    let source: Box<[u8]> = if let Some(enc) = opts.forced_encoding.clone() {
342        // Explicit encoding overrides auto-detection (BOM / declaration).
343        encoding::transcode_to_utf8_as(&buf, enc)?
344            .into_owned()
345            .into_boxed_slice()
346    } else if opts.auto_transcode {
347        encoding::transcode_to_utf8(&buf)?
348            .into_owned()
349            .into_boxed_slice()
350    } else {
351        // Without auto_transcode the caller asserts the input is already
352        // UTF-8 (or any byte sequence we should just try to parse).
353        // We still validate before parsing to keep the unsafe boundary
354        // tight; the arena's name/text fields are `&str`, so we must
355        // know the bytes are valid UTF-8 before we point at them.
356        simdutf8::compat::from_utf8(&buf).map_err(|e| {
357            XmlError::new(
358                ErrorDomain::Encoding,
359                ErrorLevel::Fatal,
360                format!("invalid UTF-8: {e}"),
361            )
362        })?;
363        buf.into_boxed_slice()
364    };
365
366    parse_owned_bytes_inplace(source, opts)
367}
368
369/// In-place variant of `parse_owned_bytes`.  Mirrors that function but
370/// constructs the reader via `XmlReader::from_bytes_in_place_unchecked`
371/// so the SAX layer can mutate the source buffer during entity decoding,
372/// newline normalization, etc.  See `crate::scanner::Scanner::compact_at`.
373fn parse_owned_bytes_inplace(source: Box<[u8]>, opts: &ParseOptions) -> Result<Document> {
374    let b = DocumentBuilder::new();
375    b.set_source(source);
376    // SAFETY: the builder owns the source buffer (we just gave it via
377    // `set_source`); we hand mutable access to the reader for the
378    // duration of parsing.  The reader is dropped before `b.build()` is
379    // called, so no outstanding `&mut` exists when ownership of the
380    // bytes transfers to the resulting `Document`.
381    let src_bytes: &mut [u8] = {
382        // Reconstruct a `&mut [u8]` from the builder's leaked pointer.
383        // The builder's `Drop` (or `build()`'s ownership transfer) is
384        // the sole other potential consumer; neither runs concurrently
385        // with the reader.
386        let (ptr, len) = (b.source_ptr_for_inplace(), b.source_len_for_inplace());
387        unsafe { std::slice::from_raw_parts_mut(ptr, len) }
388    };
389    let mut reader = unsafe { XmlBytesReader::from_bytes_in_place_unchecked(src_bytes) }
390        .with_options(opts.clone());
391    drive(&b, &mut reader, opts)?;
392    Ok(b.build())
393}
394
395/// Transcode if needed and validate UTF-8; return an owned byte buffer
396/// suitable for stashing on the builder.
397fn transcode_and_validate(input: &[u8], opts: &ParseOptions) -> Result<Box<[u8]>> {
398    let owned: Vec<u8> = if let Some(enc) = opts.forced_encoding.clone() {
399        // Explicit encoding overrides auto-detection (BOM / declaration).
400        encoding::transcode_to_utf8_as(input, enc)?.into_owned()
401    } else if opts.auto_transcode {
402        // transcode_to_utf8 returns Cow; only re-owns if it transcoded.
403        // Either way we end up with an owned Vec we can box for stashing.
404        encoding::transcode_to_utf8(input)?.into_owned()
405    } else {
406        input.to_vec()
407    };
408    simdutf8::compat::from_utf8(&owned).map_err(|e| {
409        // `valid_up_to` is the byte index of the first ill-formed
410        // sequence in the post-transcode buffer — identical to the
411        // caller's input byte index when input was already UTF-8.
412        // Compute line/col against the prefix that *is* valid UTF-8
413        // (compute_line_col only inspects newline bytes, so a partial
414        // UTF-8 buffer is safe to feed).
415        let off = e.valid_up_to();
416        let (line, col) = crate::scanner::compute_line_col(&owned, off);
417        XmlError::new(ErrorDomain::Encoding, ErrorLevel::Fatal, format!("invalid UTF-8: {e}"))
418            .at("<input>", line, col, off as u64)
419    })?;
420    Ok(owned.into_boxed_slice())
421}
422
423/// Drive the arena parser over an already-owned source buffer.  Used
424/// by both `parse_str` and `parse_bytes` after they've
425/// produced (and possibly transcoded) a UTF-8 byte buffer.  The source
426/// is stashed on the builder so the resulting [`Document`] keeps the
427/// bytes alive — letting arena strings borrow into them.
428fn parse_owned_bytes(source: Box<[u8]>, opts: &ParseOptions) -> Result<Document> {
429    let b = DocumentBuilder::new();
430    b.set_source(source);
431    // `b.source()` returns a `&[u8]` pointing at the leaked-Box bytes.  The
432    // bytes live at a stable heap address until the builder (or, after
433    // `build`, the resulting Document) drops them — so the slice is valid
434    // for the entire parse.
435    let src_bytes: &[u8] = b.source().expect("source just set");
436    // SAFETY: `transcode_and_validate` (and the str → bytes conversion in
437    // `parse_str`) guarantees UTF-8.
438    let mut reader = unsafe { XmlBytesReader::from_bytes_unchecked(src_bytes) }
439        .with_options(opts.clone());
440    drive(&b, &mut reader, opts)?;
441    let dtd = reader.take_dtd();
442    let mut doc = b.build();
443    if !dtd.unparsed_entities.is_empty() {
444        doc.set_unparsed_entities(dtd.unparsed_entities.clone());
445    }
446    // XML 1.0 §3.3.2 — apply ATTLIST-declared default / #FIXED
447    // attribute values to elements that didn't supply them
448    // explicitly.  This was previously gated on `validating: true`,
449    // but XSLT 1.0 §3.4 expects the source tree to carry defaults
450    // (id() / xsl:copy / etc. rely on them) regardless of whether
451    // the caller asked for validation.
452    if !dtd.is_empty() {
453        let _ = crate::dtd::inject::inject_defaults(&doc, &dtd);
454        // Snapshot DTD-declared ID attributes (`<!ATTLIST e a ID>`)
455        // onto the document so XPath's `id()` can find them.  Stored
456        // as element local-name → set of attribute local-names; the
457        // DTD model is element-name + attr-name keyed so we keep the
458        // same shape.
459        let id_map = crate::dtd::collect_id_attrs(&dtd);
460        if !id_map.is_empty() {
461            doc.set_id_attributes(id_map);
462        }
463        let idref_map = crate::dtd::collect_idref_attrs(&dtd);
464        if !idref_map.is_empty() {
465            doc.set_idref_attributes(idref_map);
466        }
467    }
468    Ok(doc)
469}
470
471fn parse_owned_bytes_with_recovered(
472    source: Box<[u8]>,
473    opts: &ParseOptions,
474) -> (Result<Document>, Vec<XmlError>) {
475    let b = DocumentBuilder::new();
476    b.set_source(source);
477    let src_bytes: &[u8] = b.source().expect("source just set");
478    let mut reader = unsafe { XmlBytesReader::from_bytes_unchecked(src_bytes) }
479        .with_options(opts.clone());
480    let drive_result = drive(&b, &mut reader, opts);
481    let recovered = reader.recovered_errors().to_vec();
482    let unparsed = reader.take_dtd().unparsed_entities;
483    let result = drive_result.map(|()| {
484        let mut d = b.build();
485        if !unparsed.is_empty() { d.set_unparsed_entities(unparsed); }
486        d
487    });
488    (result, recovered)
489}
490
491/// Parse `input` with XML Namespaces 1.0 processing enabled — resolves
492/// `xmlns` declarations, fills the `namespace` field on every element and
493/// prefixed attribute, validates QName syntax, and rejects undeclared
494/// prefixes.  Convenience wrapper over [`parse_str`] with
495/// `namespace_aware: true`.
496///
497/// Arena-DOM equivalent of [`parse_ns_str`](crate::parse_ns_str).
498pub fn parse_ns_str(input: &str) -> Result<Document> {
499    let opts = ParseOptions { namespace_aware: true, ..ParseOptions::default() };
500    parse_str(input, &opts)
501}
502
503/// Byte-slice sibling of [`parse_ns_str`].  Validates UTF-8 (or
504/// auto-transcodes if `auto_transcode` is on) before parsing.
505pub fn parse_ns_bytes(input: &[u8]) -> Result<Document> {
506    let opts = ParseOptions { namespace_aware: true, ..ParseOptions::default() };
507    parse_bytes(input, &opts)
508}
509
510// ── driver ──────────────────────────────────────────────────────────────────
511
512/// Type-erased pointer to a `Node` living in the builder's arena.  Stored on
513/// the construction stack; re-bound to `&'a Node<'a>` only at deref time, in
514/// short scopes where the builder borrow is also active.
515///
516/// We don't use `*const Node<'static>` directly: `Node<'doc>` is invariant
517/// over `'doc`, so casts between `*const Node<'a>` and `*const Node<'static>`
518/// are not free at the type-checker level.  An untyped pointer sidesteps the
519/// invariance dance entirely.
520type ErasedNodePtr = *const ();
521type ErasedAttrPtr = *const ();
522
523/// Convert a typed node reference into a type-erased pointer.
524#[inline] fn erase(node: &Node<'_>) -> ErasedNodePtr {
525    node as *const Node<'_> as *const ()
526}
527
528/// Re-type an erased pointer back to a node reference with lifetime `'a`.
529///
530/// # Safety
531///
532/// The pointer must have been obtained from [`erase`] on a node allocated
533/// in an arena that is still alive at the call site (with lifetime ≥ `'a`).
534#[inline] unsafe fn unerase<'a>(p: ErasedNodePtr) -> &'a Node<'a> {
535    unsafe { &*(p as *const Node<'a>) }
536}
537
538/// Consume events from `reader` and build the arena Document.
539///
540/// Namespace handling is gated on [`ParseOptions::namespace_aware`].  When
541/// false (the default for `ParseOptions::default()`), this matches the legacy
542/// `parse_bytes` path — no QName validation, no xmlns scanning, no per-element
543/// namespace assignment.  When true, full XML Namespaces 1.0 resolution runs
544/// inline with a per-element fast-path that skips the work for elements +
545/// attributes that have no `:` in their names *and* are not inside a default-
546/// namespace scope.
547fn drive(
548    b: &DocumentBuilder,
549    reader: &mut XmlBytesReader<'_>,
550    opts: &ParseOptions,
551) -> Result<()> {
552    // Every XML document parse funnels through here; the license gate
553    // verifies once per process (cached) and is a no-op thereafter.
554    crate::license_gate::ensure_licensed()?;
555
556    // Pre-reserve typical capacities so the first few pushes don't trigger
557    // grow-doublings on the hot path.  XML element nesting rarely exceeds
558    // ~16 (deeper than that is unusual); per-element attrs in
559    // attribute-heavy formats (OSM, SVG) regularly hit 8-16.
560    let mut stack:    Vec<ErasedNodePtr> = Vec::with_capacity(16);
561    let mut attr_buf: Vec<BytesAttr<'_>> = Vec::with_capacity(16);
562    let mut root:     Option<ErasedNodePtr> = None;
563
564    // Incremental line-number cursor for `node.line` / `Element.sourceline`.
565    // The naive approach — call `compute_line_col` per StartElement — rescans
566    // `src[0..name_offset]` each time, which is O(N × file_size) ≈ O(N²)
567    // across the whole parse and dominates throughput on docs with many
568    // elements (10× — 100× slowdown observed).  Instead we keep a `(offset,
569    // line)` cursor that only ever moves forward: each StartElement scans
570    // newlines in `src[cursor.0 .. tag.name_offset]`, then bumps the cursor.
571    // Total scanning work over the parse is O(file_size).
572    let mut line_cursor: (usize, u32) = (0, 1);
573
574    // The source URI (when the caller supplied one) becomes the
575    // document node's base URI — `fn:base-uri()`/`fn:document-uri()`
576    // resolve against it (XPath 2.0 §2.5).
577    if opts.base_url.is_some() {
578        b.set_base_url(opts.base_url.clone());
579    }
580
581    let ns_aware = opts.namespace_aware;
582
583    // Namespace scope state — only populated when `ns_aware` is true.  Kept
584    // as a flat Vec of (prefix, &Namespace) bindings with per-element frame
585    // markers; type-erased pointers sidestep `Namespace<'_>` invariance.
586    let mut ns_bindings: Vec<(Option<&str>, *const ())> = Vec::new();
587    let mut ns_frames:   Vec<usize>                     = Vec::new();
588    // Per-frame count of `xmlns`/`xmlns:foo` bindings added by that element,
589    // so EndElement can decrement `active_user_bindings` correctly.
590    let mut ns_frame_count_stack: Vec<u32>              = Vec::new();
591    // Cached "is there a non-None binding in scope right now?" — set when
592    // any `xmlns` or `xmlns:foo` is declared; cleared as frames pop.  This
593    // is the fast-path gate: when false, no prefix can resolve (built-in
594    // xml/xmlns aside) and no default namespace applies, so elements and
595    // attributes without `:` need no resolution work at all.
596    let mut active_user_bindings: u32 = 0;
597
598    if ns_aware {
599        let xml_ns   = b.new_namespace(Some("xml"),   XML_NS_URI);
600        let xmlns_ns = b.new_namespace(Some("xmlns"), XMLNS_NS_URI);
601        ns_bindings.push((Some("xml"),   erase_ns(xml_ns)));
602        ns_bindings.push((Some("xmlns"), erase_ns(xmlns_ns)));
603    }
604
605    // Borrow-from-source: when a `BytesEvent` payload arrives as
606    // `Cow::Borrowed`, the bytes live in the input buffer (now owned by the
607    // builder via `set_source`).  Stash a `&str` slice directly via
608    // `alloc_str_borrow` — zero copy.  When the payload is `Cow::Owned`, the
609    // reader had to materialize it (entity decode, char ref, encoding
610    // conversion, etc.) — we copy into the bump.
611    //
612    // SAFETY: the input lifetime — which the borrowed Cow slices come from —
613    // is the lifetime of the builder's pinned `source` buffer.  That buffer
614    // moves into the `Document` on `build()` and stays alive for the
615    // document's lifetime, so the `&'doc str`s produced here outlive their
616    // referents only if the builder retains the source.  All public entry
617    // points (`parse_str`, `parse_bytes*`) install the source
618    // before calling `drive`.
619    //
620    // We accept `Cow<'_, [u8]>` (not `Cow<'_, str>`) so we can take payloads
621    // straight from `XmlBytesReader` without bouncing through `XmlReader`'s
622    // `&str`-typed wrappers (Lever 5 — see commit history).  The Scanner's
623    // UTF-8 invariant means `from_utf8_unchecked` is sound at every borrow.
624    #[inline]
625    fn alloc_cow_bytes_as_str<'b>(
626        b: &'b DocumentBuilder,
627        c: std::borrow::Cow<'_, [u8]>,
628    ) -> &'b str {
629        match c {
630            std::borrow::Cow::Borrowed(bytes) => {
631                // SAFETY: Scanner UTF-8 invariant — every `Cow::Borrowed`
632                // payload is a slice of the original source buffer, which
633                // was validated as UTF-8 by the entry point.
634                let s: &str = unsafe { std::str::from_utf8_unchecked(bytes) };
635                // SAFETY: extend `'src` (the reader's input lifetime, which
636                // is really `'b` — the builder's `source` buffer) to `'b`.
637                // Sound because: caller installed the same buffer on the
638                // builder via `set_source` before constructing the reader.
639                let extended: &'b str = unsafe { &*(s as *const str) };
640                unsafe { b.alloc_str_borrow(extended) }
641            }
642            std::borrow::Cow::Owned(v) => {
643                // SAFETY: Owned payloads come from entity expansion / char
644                // refs / encoding transcode — all of which write only
645                // complete UTF-8 sequences into the temp buffer.
646                let s: &str = unsafe { std::str::from_utf8_unchecked(&v) };
647                b.alloc_str(s)
648            }
649        }
650    }
651
652    // Borrow an element-name byte slice as a `&'src str` in the arena.
653    // Names never contain entity refs (XML 1.0 § 2.3), so they're always a
654    // direct source slice — no `Cow::Owned` arm needed.
655    #[inline]
656    fn alloc_name_bytes_as_str<'b>(b: &'b DocumentBuilder, bytes: &[u8]) -> &'b str {
657        // SAFETY: Scanner UTF-8 invariant + lifetime extension as in
658        // `alloc_cow_bytes_as_str`'s Borrowed arm.
659        let s: &str = unsafe { std::str::from_utf8_unchecked(bytes) };
660        let extended: &'b str = unsafe { &*(s as *const str) };
661        unsafe { b.alloc_str_borrow(extended) }
662    }
663
664    // Look up the AttType the DTD's `<!ATTLIST>` declared for the
665    // given (element, attribute).  Returns `None` if no decl
666    // covers it — falls back to CDATA semantics (no
667    // normalization).
668    fn dtd_att_type<'d>(
669        dtd:        &'d crate::dtd::Dtd,
670        elem_name:  &str,
671        attr_name:  &str,
672    ) -> Option<&'d crate::dtd::AttType> {
673        let attlist = dtd.attlists.get(elem_name)?;
674        attlist.iter().find(|d| d.name == attr_name).map(|d| &d.att_type)
675    }
676
677    // Normalize an attribute value according to the DTD-declared
678    // type when present.  Returns a `&'b str` interned into the
679    // builder's bump so the lifetime matches the rest of the tree.
680    //
681    // No-op when no `<!ATTLIST>` covers the (element, attr) pair —
682    // attributes default to CDATA semantics, which need no
683    // additional normalization at this layer (the byte reader has
684    // already done its part).  Generic across xmlns and regular
685    // attributes; the spec rules don't distinguish.
686    fn dtd_normalize_attr_value<'b>(
687        b:           &'b DocumentBuilder,
688        dtd:         &crate::dtd::Dtd,
689        elem_name:   &str,
690        attr_name:   &str,
691        raw_value:   &'b str,
692    ) -> &'b str {
693        use crate::dtd::AttType;
694        // W3C `xml:id` Recommendation §4 — the attribute's value is
695        // assigned ID type regardless of DTD typing, with the same
696        // non-CDATA normalization (strip + collapse whitespace).
697        // Fast-path the literal-name check before consulting the
698        // DTD: every element pays one byte comparison, no DTD lookup
699        // needed for the common case of "no xml:id, no DTD typing."
700        let is_xml_id = attr_name == "xml:id";
701        let needs_non_cdata = is_xml_id || {
702            let att_type = dtd_att_type(dtd, elem_name, attr_name);
703            matches!(
704                att_type,
705                Some(AttType::Id | AttType::IdRef | AttType::IdRefs
706                    | AttType::Entity | AttType::Entities
707                    | AttType::Nmtoken | AttType::Nmtokens
708                    | AttType::Notation(_) | AttType::Enumeration(_))
709            )
710        };
711        if !needs_non_cdata { return raw_value; }
712        let normalized = normalize_non_cdata(raw_value);
713        b.alloc_str(&normalized)
714    }
715
716    // Capture the source bytes for line-number translation at
717    // StartElement time.  The returned slice has the input's
718    // lifetime (`'src` on the reader), so it's safe to use across
719    // subsequent mutable borrows of the reader.
720    let src_bytes: &[u8] = reader.src_bytes();
721
722    // XML 1.0 § 3.3.3 attribute-value normalization.
723    //
724    // The reader returns attribute values with no normalization
725    // applied (raw bytes between the quotes).  When an ATTLIST
726    // declares the attribute as non-CDATA (NMTOKEN, ID, IDREF, …),
727    // the spec requires line-break and whitespace handling beyond
728    // the CDATA default: strip leading/trailing spaces and collapse
729    // internal runs to a single space.  Applied at the xmlns-
730    // binding site so namespace URIs feed the "Unique Att Spec"
731    // check after normalization — without it,
732    // `xmlns:b=" urn:x "` would bind to a URI that doesn't compare
733    // equal to another `xmlns:a="urn:x"`, missing a real collision.
734    fn normalize_non_cdata(value: &str) -> String {
735        // `value` has already been through §3.3.3 CDATA-default
736        // normalization, which folds every *literal* whitespace byte
737        // (space, tab, CR, LF) to a single `#x20`.  Any `#x9` / `#xA`
738        // / `#xD` still present therefore arrived via a character
739        // reference (`&#9;` / `&#xA;` / `&#xD;`), and §3.3.3 forbids
740        // rewriting those.  The tokenized-type step layered on top
741        // accordingly collapses runs of — and trims leading/trailing —
742        // `#x20` ONLY, leaving character-reference whitespace intact.
743        let bytes = value.as_bytes();
744        let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
745        let mut in_run = true; // leading-trim by treating start as in-run
746        for &b in bytes {
747            if b == b' ' {
748                if !in_run {
749                    out.push(b' ');
750                    in_run = true;
751                }
752            } else {
753                // Every non-space byte (ASCII or a UTF-8 lead /
754                // continuation byte) is copied verbatim — `#x20` never
755                // occurs inside a multi-byte sequence, so this can't
756                // split one.
757                out.push(b);
758                in_run = false;
759            }
760        }
761        if out.last() == Some(&b' ') { out.pop(); }
762        // SAFETY: `value` was valid UTF-8 and we only dropped or
763        // relocated standalone `#x20` bytes, never touching the bytes
764        // of a multi-byte sequence.
765        unsafe { String::from_utf8_unchecked(out) }
766    }
767    loop {
768        // attr_buf is drained inside the StartElement arm below, so it
769        // re-enters the loop empty.  No `clear()` is needed at the top.
770        debug_assert!(attr_buf.is_empty());
771        match reader.next()? {
772            BytesEvent::StartElement(tag) => {
773                // Element name: borrow from source on the common
774                // path; copy into the arena when the tag came from
775                // an entity-replacement stream (those bytes are
776                // owned by the tag and die at end-of-match-arm, so
777                // a borrow would dangle).
778                let name: &str = match tag.name_cow() {
779                    std::borrow::Cow::Borrowed(bytes) => {
780                        alloc_name_bytes_as_str(b, bytes)
781                    }
782                    std::borrow::Cow::Owned(bytes) => {
783                        b.alloc_str(unsafe { std::str::from_utf8_unchecked(&bytes) })
784                    }
785                };
786                // In c-abi mode + namespace-aware parsing, `Node::name`
787                // follows libxml2's convention: the local part only.
788                // The prefix lives on `node.ns->prefix` after namespace
789                // resolution.  Consumers (lxml, libxslt) read names as
790                // local strings and combine with `ns->href` to form
791                // the expanded tag; keeping the prefix here breaks tag
792                // equality, attribute lookup, and namespacedNameFromNsName.
793                //
794                // Without namespace awareness, names stay raw (with any
795                // prefix) — internal regression tests and tools that
796                // process documents pre-namespace-resolution rely on
797                // that.
798                #[cfg(feature = "c-abi")]
799                let elem_name = if ns_aware {
800                    match memchr::memchr(b':', name.as_bytes()) {
801                        Some(idx) => &name[idx + 1..],
802                        None      => name,
803                    }
804                } else {
805                    name
806                };
807                #[cfg(not(feature = "c-abi"))]
808                let elem_name = name;
809                let el   = b.new_element(elem_name);
810                // Record the source line number so consumers can
811                // ask for `node.line` / lxml's `Element.sourceline`.
812                // libxml2 caps the field at u16 (65535) — values
813                // beyond that get the special "encoded line" trick
814                // (`(extra >> 16)`); we keep things simple and
815                // saturate.
816                {
817                    let name_offset = (tag.name_offset() as usize).min(src_bytes.len());
818                    // Advance the line cursor through any new bytes since
819                    // the last StartElement.  Element name offsets are
820                    // monotonically non-decreasing through the parse, so the
821                    // cursor only ever moves forward; on the rare chance it
822                    // would move backward (shouldn't happen in practice) we
823                    // leave the cursor in place and the recorded line is the
824                    // last seen value — never worse than the old quadratic
825                    // path that would just produce the same number with
826                    // more work.
827                    if name_offset > line_cursor.0 {
828                        let slice = &src_bytes[line_cursor.0..name_offset];
829                        line_cursor.1 += memchr::memchr_iter(b'\n', slice).count() as u32;
830                        line_cursor.0 = name_offset;
831                    }
832                    let raw_line = line_cursor.1;
833                    // The `line` field is `u16` in c-abi mode
834                    // (matching libxml2's 16-bit slot) and `u32`
835                    // in the lean build (no ABI constraint, so we can
836                    // handle large files better).
837                    // Saturate when narrowing to u16.
838                    // c-abi stores the line in a saturating `Cell<u16>`;
839                    // the lean build keeps a plain `u32`.  `el` is the
840                    // freshly-built `&mut Node`, so either write is sound.
841                    #[cfg(feature = "c-abi")]
842                    el.line.set(raw_line.min(u16::MAX as u32) as u16);
843                    #[cfg(not(feature = "c-abi"))]
844                    {
845                        el.line = raw_line;
846                    }
847                    // Keep the uncapped line for files past 65535 lines;
848                    // `xmlGetLineNo` returns it in preference to the
849                    // saturated `line`.
850                    #[cfg(feature = "c-abi")]
851                    {
852                        el.full_line.set(raw_line);
853                    }
854                    // Byte offset of the element name — the ground truth
855                    // from which line/column are derived on demand
856                    // (`scanner::compute_line_col`).  Saturates at u32.
857                    el.source_offset = name_offset.min(u32::MAX as usize) as u32;
858                }
859                // Entity-stream start tags carry their attrs pre-
860                // parsed (the lazy iterator can't surface bytes that
861                // don't live in `src`).  Take them out first; the
862                // lazy `attrs()` path returns nothing in that case.
863                let entity_attr_pairs: Option<Vec<(Vec<u8>, Vec<u8>)>> =
864                    tag.entity_attrs().map(|v| v.to_vec());
865
866                // Skip the attribute iterator entirely when there's
867                // nothing between the name and the closing `>` — a
868                // single empty-slice check is cheaper than constructing
869                // a Scanner just to have its `next()` immediately return
870                // None.  Trailing whitespace inside the start tag still
871                // falls through (rare in practice; attrs() short-
872                // circuits on the first .next() either way).
873                if !tag.attrs_bytes().is_empty() {
874                    for a in tag.attrs() { attr_buf.push(a?); }
875                }
876
877                // Splice pre-parsed entity-stream attrs into the buffer
878                // so the existing ns-aware / ns-blind loops below see
879                // them uniformly.  Bytes are arena-copied; the
880                // lifetime cast launders the arena's lifetime to the
881                // attr_buf slot, sound because the arena outlives the
882                // BytesAttr.
883                if let Some(pairs) = entity_attr_pairs {
884                    for (n_bytes, v_bytes) in pairs {
885                        let n_arena: &str = b.alloc_str(unsafe {
886                            std::str::from_utf8_unchecked(&n_bytes)
887                        });
888                        let v_arena: &str = b.alloc_str(unsafe {
889                            std::str::from_utf8_unchecked(&v_bytes)
890                        });
891                        let n_slice: &[u8] = n_arena.as_bytes();
892                        let v_slice: &[u8] = v_arena.as_bytes();
893                        let n_ext: &[u8] = unsafe { &*(n_slice as *const [u8]) };
894                        let v_ext: &[u8] = unsafe { &*(v_slice as *const [u8]) };
895                        attr_buf.push(BytesAttr {
896                            name:  n_ext,
897                            value: std::borrow::Cow::Borrowed(v_ext),
898                        });
899                    }
900                }
901
902                if !ns_aware {
903                    // Fastest path — namespace-blind, mirrors legacy parse_bytes.
904                    for a in attr_buf.drain(..) {
905                        // Attr name is always &'src [u8] (no entity decode for
906                        // names per XML 1.0 § 2.3) so we can always borrow it.
907                        let aname  = alloc_name_bytes_as_str(b, a.name);
908                        let raw_avalue = alloc_cow_bytes_as_str(b, a.value);
909                        // XML 1.0 § 3.3.3 non-CDATA normalization — same
910                        // helper the ns-aware branch uses.  No-op when
911                        // no ATTLIST covers this attribute.
912                        let avalue: &str =
913                            dtd_normalize_attr_value(b, reader.dtd(), name, aname, raw_avalue);
914                        let attr   = b.new_attribute(aname, avalue);
915                        b.append_attribute(el, attr);
916                    }
917                } else {
918                    // ── namespace-aware path ──────────────────────────────────
919                    validate_qname(name, "element")?;
920                    ns_frames.push(ns_bindings.len());
921                    let mut new_bindings_this_frame = 0u32;
922                    let mut any_attr_prefixed = false;
923                    // Prefixed attributes paired with their original QName.
924                    // Namespace resolution must wait until every `xmlns`
925                    // declaration on this element has been seen, so collect
926                    // them and resolve in the pass below.  In c-abi the
927                    // stored `name` is already reduced to the local part
928                    // (libxml2 convention); the prefix is recovered from the
929                    // QName recorded here.
930                    let mut prefixed_attrs: Vec<(&Attribute<'_>, &str)> = Vec::new();
931
932                    for a in attr_buf.drain(..) {
933                        let aname  = alloc_name_bytes_as_str(b, a.name);
934                        let raw_avalue = alloc_cow_bytes_as_str(b, a.value);
935
936                        // Namespace declarations (`xmlns` / `xmlns:foo`)
937                        // do NOT belong in the element's attribute
938                        // list under the c-abi layout — libxml2's
939                        // `_xmlNode::properties` holds only real
940                        // attributes; xmlns declarations live on the
941                        // separate `nsDef` chain.  In the lean
942                        // (non-c-abi) build we keep them in both
943                        // places for backwards-compatibility with
944                        // existing API consumers that iterate
945                        // `attributes()` expecting xmlns decls there.
946                        let is_xmlns_decl =
947                            aname == "xmlns" || aname.starts_with("xmlns:");
948
949                        // XML 1.0 § 3.3.3 attribute-value normalization.
950                        // Applied uniformly: if an `<!ATTLIST>` declared
951                        // this attribute as non-CDATA (ID, IDREF,
952                        // IDREFS, ENTITY/ENTITIES, NMTOKEN/NMTOKENS,
953                        // NOTATION, enumeration), strip leading/trailing
954                        // whitespace and collapse internal runs to a
955                        // single space.  Without this, ID equality,
956                        // IDREF resolution, and enumeration matching
957                        // would all silently disagree with the spec on
958                        // values like `id=" abc "`.  Helper no-ops when
959                        // no ATTLIST covers the pair, so non-DTD docs
960                        // pay nothing.
961                        let avalue: &str =
962                            dtd_normalize_attr_value(b, reader.dtd(), name, aname, raw_avalue);
963
964                        #[cfg(not(feature = "c-abi"))]
965                        let always_attach = true;
966                        #[cfg(feature = "c-abi")]
967                        let always_attach = false;
968                        let is_prefixed = !is_xmlns_decl
969                            && memchr::memchr(b':', aname.as_bytes()).is_some();
970                        if always_attach || !is_xmlns_decl {
971                            // c-abi follows libxml2: `name` holds the local
972                            // part only, with the prefix carried on
973                            // `attr->ns` after resolution.  Mirror the
974                            // element-name reduction so a prefixed attribute
975                            // doesn't serialize as `p:p:name` and `keys()`
976                            // reports the bare local name.  The lean build
977                            // keeps the raw QName (its serializer never
978                            // re-prepends a prefix).
979                            #[cfg(feature = "c-abi")]
980                            let stored_name = if ns_aware && is_prefixed {
981                                let i = memchr::memchr(b':', aname.as_bytes()).unwrap();
982                                &aname[i + 1..]
983                            } else {
984                                aname
985                            };
986                            #[cfg(not(feature = "c-abi"))]
987                            let stored_name = aname;
988                            let attr: &Attribute<'_> = b.new_attribute(stored_name, avalue);
989                            b.append_attribute(el, attr);
990                            if ns_aware && is_prefixed {
991                                prefixed_attrs.push((attr, aname));
992                            }
993                        }
994
995                        if aname == "xmlns" {
996                            let ns = b.new_namespace(None, avalue);
997                            ns_bindings.push((None, erase_ns(ns)));
998                            #[cfg(feature = "c-abi")]
999                            { b.append_ns_def(el, ns); }
1000                            new_bindings_this_frame += 1;
1001                        } else if let Some(local) = aname.strip_prefix("xmlns:") {
1002                            validate_xmlns_decl(local, avalue)?;
1003                            if local == "xml" { continue; }
1004                            let ns = b.new_namespace(Some(local), avalue);
1005                            ns_bindings.push((Some(local), erase_ns(ns)));
1006                            #[cfg(feature = "c-abi")]
1007                            { b.append_ns_def(el, ns); }
1008                            new_bindings_this_frame += 1;
1009                        } else if memchr::memchr(b':', aname.as_bytes()).is_some() {
1010                            any_attr_prefixed = true;
1011                        }
1012                    }
1013                    active_user_bindings += new_bindings_this_frame;
1014
1015                    // ── Element QName resolution ──
1016                    // Fast path: no prefix and no user bindings ever in scope → no namespace.
1017                    let elem_has_colon = memchr::memchr(b':', name.as_bytes()).is_some();
1018                    if elem_has_colon || active_user_bindings > 0 {
1019                        let el_ns = resolve_qname(name, &ns_bindings, /*is_attribute=*/ false)?;
1020                        el.namespace.set(el_ns);
1021                    }
1022
1023                    // ── Attribute QName resolution + dup-by-expanded-name check ──
1024                    // Skip the FxHashSet allocation entirely when no attribute is prefixed
1025                    // (no namespace = no collision possible after expansion).
1026                    if any_attr_prefixed {
1027                        // Resolve each prefixed attribute's namespace now that
1028                        // every xmlns declaration on this element is in scope.
1029                        // `resolve_qname` takes the original prefixed QName
1030                        // recorded at creation; the stored `name` may already
1031                        // be the local part (c-abi).
1032                        for &(attr, qname) in &prefixed_attrs {
1033                            validate_qname(qname, "attribute")?;
1034                            let ns = resolve_qname(qname, &ns_bindings, true)?;
1035                            attr.namespace.set(ns);
1036                        }
1037                        // XML NS § 6.3: no two attributes may share an expanded
1038                        // name (namespace-uri, local-name).  The local part is
1039                        // `name` after the colon — which equals `name` itself
1040                        // once reduced (c-abi).
1041                        let mut seen: FxHashSet<(&str, &str)> = FxHashSet::default();
1042                        let mut attr_cur = el.first_attribute.get();
1043                        while let Some(attr) = attr_cur {
1044                            if attr.name() != "xmlns" && !attr.name().starts_with("xmlns:") {
1045                                let ns_uri = attr.namespace.get().map(|n| n.href()).unwrap_or("");
1046                                let local  = attr.name().rfind(':')
1047                                    .map(|i| &attr.name()[i + 1..])
1048                                    .unwrap_or(attr.name());
1049                                if !seen.insert((ns_uri, local)) {
1050                                    return Err(ns_err(if ns_uri.is_empty() {
1051                                        format!("duplicate attribute '{local}' after namespace expansion")
1052                                    } else {
1053                                        format!("duplicate attribute '{local}' in namespace '{ns_uri}' after namespace expansion")
1054                                    }));
1055                                }
1056                            }
1057                            attr_cur = attr.next.get();
1058                        }
1059                    }
1060                    // Stash the per-frame binding count in ns_frames is not enough — we
1061                    // also need to know how many to subtract from active_user_bindings
1062                    // on EndElement.  Pack the count into the frame marker by using a
1063                    // sentinel: separate Vec for the counts.  (Simpler: piggyback on
1064                    // ns_frames by recording `new_bindings_this_frame` in a parallel Vec.)
1065                    // For now we track via a side Vec.
1066                    ns_frame_count_stack.push(new_bindings_this_frame);
1067                }
1068
1069                // Attach to parent (if any) — first StartElement becomes the root.
1070                if let Some(&parent_ptr) = stack.last() {
1071                    // SAFETY: parent_ptr points into `b`, still alive here.
1072                    let parent: &Node<'_> = unsafe { unerase(parent_ptr) };
1073                    b.append_child(parent, el);
1074                } else {
1075                    root = Some(erase(el));
1076                }
1077                stack.push(erase(el));
1078            }
1079            BytesEvent::EndElement(_) => {
1080                stack.pop().expect("EndElement without StartElement — XmlBytesReader invariant");
1081                if ns_aware {
1082                    if let Some(frame_start) = ns_frames.pop() {
1083                        ns_bindings.truncate(frame_start);
1084                    }
1085                    if let Some(count) = ns_frame_count_stack.pop() {
1086                        active_user_bindings -= count;
1087                    }
1088                }
1089            }
1090            BytesEvent::Text(t)    => attach_leaf(b, &stack, b.new_text   (alloc_cow_bytes_as_str(b, t.into_bytes())), root.is_some()),
1091            BytesEvent::CData(s)   => {
1092                // `cdata_as_text` (libxml2 NOCDATA / lxml strip_cdata)
1093                // delivers CDATA content as a plain text node.
1094                let content = alloc_cow_bytes_as_str(b, s.into_bytes());
1095                let leaf = if opts.cdata_as_text { b.new_text(content) } else { b.new_cdata(content) };
1096                attach_leaf(b, &stack, leaf, root.is_some());
1097            }
1098            BytesEvent::Comment(s) => {
1099                // `remove_comments` (lxml NULLs the SAX comment callback):
1100                // skip building the node entirely.
1101                if !opts.remove_comments {
1102                    attach_leaf(b, &stack, b.new_comment(alloc_cow_bytes_as_str(b, s.into_bytes())), root.is_some());
1103                }
1104            }
1105            BytesEvent::Pi(p)      => {
1106                if !opts.remove_pis {
1107                    let (t, c) = p.into_parts();
1108                    let target  = alloc_cow_bytes_as_str(b, t);
1109                    // A PI with no data section serializes without the
1110                    // trailing space; libxml2 marks that as NULL content.
1111                    let content = if c.is_empty() { None } else { Some(alloc_cow_bytes_as_str(b, c)) };
1112                    attach_leaf(b, &stack, b.new_pi(target, content), root.is_some());
1113                }
1114            }
1115            BytesEvent::EntityRef(e) => {
1116                // `resolve_entities: false` left an `&name;` literal in
1117                // the source; materialize it as a dedicated
1118                // `NodeKind::EntityRef` node so the tree round-trips
1119                // back to source and lxml's `tag == Entity` /
1120                // `text == "&name;"` semantics work.
1121                let name_bytes = e.name();
1122                // SAFETY: Scanner UTF-8 invariant — entity-name bytes
1123                // are valid UTF-8 (NameChar+).
1124                let name: &str = unsafe { std::str::from_utf8_unchecked(name_bytes) };
1125                let name = b.alloc_str(name);
1126                // Literal source form `&name;` for the serializer.
1127                let lit = format!("&{name};");
1128                let content = b.alloc_str(&lit);
1129                attach_leaf(b, &stack, b.new_entity_ref(name, content), root.is_some());
1130            }
1131            BytesEvent::Eof => break,
1132        }
1133    }
1134
1135    let root_ptr = root.ok_or_else(|| XmlError::new(
1136        ErrorDomain::Parser, ErrorLevel::Fatal,
1137        "document has no root element",
1138    ))?;
1139    // SAFETY: root_ptr was erased from a node allocated in `b`; `b` is still
1140    // alive at this line.
1141    let root_ref: &Node<'_> = unsafe { unerase(root_ptr) };
1142    b.set_root(root_ref);
1143
1144    // Plumb XML declaration fields from the reader's prolog state into the
1145    // Document.  When the document had no `<?xml ... ?>` declaration the
1146    // builder's defaults ("1.0" / "UTF-8" / None) are kept.
1147    if let Some(decl) = reader.xml_decl() {
1148        b.set_version(decl.version.clone());
1149        if let Some(enc) = &decl.encoding {
1150            b.set_encoding(enc.clone());
1151        }
1152        b.set_standalone(decl.standalone);
1153    }
1154
1155    Ok(())
1156}
1157
1158// ── namespace helpers ───────────────────────────────────────────────────────
1159
1160#[inline] fn erase_ns(ns: &Namespace<'_>) -> *const () {
1161    ns as *const Namespace<'_> as *const ()
1162}
1163
1164/// # Safety
1165///
1166/// `p` must have been produced by `erase_ns` from a live `&Namespace`,
1167/// and the caller-chosen lifetime `'a` must not outlive that namespace's
1168/// arena.
1169#[inline] unsafe fn unerase_ns<'a>(p: *const ()) -> &'a Namespace<'a> {
1170    unsafe { &*(p as *const Namespace<'a>) }
1171}
1172
1173/// Find the innermost binding for `prefix` (None = default namespace).
1174/// Returns `None` if no binding is in scope.
1175fn lookup_ns<'a>(
1176    prefix:   Option<&str>,
1177    bindings: &[(Option<&'a str>, *const ())],
1178) -> Option<&'a Namespace<'a>> {
1179    for &(p, ns_ptr) in bindings.iter().rev() {
1180        if p == prefix {
1181            // SAFETY: ns_ptr was minted by erase_ns from a Namespace allocated
1182            // in the builder's arena, which is still alive while this function
1183            // runs (called from inside drive() which owns the arena).
1184            return Some(unsafe { unerase_ns(ns_ptr) });
1185        }
1186    }
1187    None
1188}
1189
1190/// Resolve a QName against the namespace scope.  Unprefixed elements use the
1191/// default namespace; unprefixed attributes never do (XML Namespaces § 6.2).
1192fn resolve_qname<'a>(
1193    qname:        &'a str,
1194    bindings:     &[(Option<&'a str>, *const ())],
1195    is_attribute: bool,
1196) -> Result<Option<&'a Namespace<'a>>> {
1197    if let Some(colon) = qname.find(':') {
1198        let prefix = &qname[..colon];
1199        match lookup_ns(Some(prefix), bindings) {
1200            Some(ns) => Ok(Some(ns)),
1201            None     => Err(ns_err(format!("undeclared namespace prefix '{prefix}' in '{qname}'"))),
1202        }
1203    } else if is_attribute {
1204        Ok(None)
1205    } else {
1206        // Unprefixed element: default namespace if declared with non-empty URI.
1207        match lookup_ns(None, bindings) {
1208            Some(ns) if !ns.href().is_empty() => Ok(Some(ns)),
1209            _                                => Ok(None),
1210        }
1211    }
1212}
1213
1214
1215/// Attach a freshly-allocated leaf node to the top-of-stack element.
1216/// When the stack is empty (prolog before the root or epilogue
1217/// after `</root>`) the leaf is recorded as a document-level
1218/// orphan instead of dropped; [`DocumentBuilder::build`] later
1219/// links it as a sibling of the root so consumers see comments /
1220/// PIs that appeared outside the document element.
1221///
1222/// `after_root` tells the builder whether this orphan goes in the
1223/// prolog (false: root not yet seen) or the epilogue (true: root
1224/// element has opened, regardless of whether it has also closed).
1225fn attach_leaf<'a>(
1226    b:           &'a DocumentBuilder,
1227    stack:       &[ErasedNodePtr],
1228    node:        &'a Node<'a>,
1229    after_root:  bool,
1230) {
1231    if let Some(&parent_ptr) = stack.last() {
1232        // SAFETY: stack invariant — pointer points into the live `b.bump`,
1233        // which is the same arena `node` lives in, so unifying lifetimes is sound.
1234        let parent: &'a Node<'a> = unsafe { unerase(parent_ptr) };
1235        b.append_child(parent, node);
1236    } else if after_root {
1237        b.attach_epilogue_orphan(node);
1238    } else {
1239        b.attach_prolog_orphan(node);
1240    }
1241}
1242
1243// Silence the unused-`ErasedAttrPtr` lint until we extend to namespace tables.
1244#[allow(dead_code)] type _AttrAlias = ErasedAttrPtr;
1245
1246// ── tests ───────────────────────────────────────────────────────────────────
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use sup_xml_tree::dom::NodeKind;
1252
1253    fn parse(xml: &str) -> Document {
1254        // Default `ParseOptions` has `namespace_aware: false` — most tests here
1255        // exercise the structural shape (children, attrs, kinds), so it doesn't
1256        // matter.  Namespace-specific tests use `parse_ns` instead.
1257        parse_str(xml, &ParseOptions::default()).expect("parse")
1258    }
1259
1260    /// Namespace-aware parse helper for the ns-resolution tests.
1261    fn parse_ns(xml: &str) -> Document {
1262        let opts = ParseOptions { namespace_aware: true, ..ParseOptions::default() };
1263        parse_str(xml, &opts).expect("parse")
1264    }
1265
1266    #[test]
1267    fn empty_element() {
1268        let doc = parse("<r/>");
1269        assert_eq!(doc.root().name(), "r");
1270        assert!(doc.root().children().next().is_none());
1271    }
1272
1273    #[test]
1274    fn element_records_source_offset_for_line_col_derivation() {
1275        // `<b>` opens at byte 9 (after "<a>\n  <b>" — the name `b` is
1276        // at offset 9).  Line/column derive from that offset via
1277        // `compute_line_col`, the single source of truth.
1278        let xml = "<a>\n  <b/></a>";
1279        let doc = parse(xml);
1280        let b = doc.root().children()
1281            .find(|n| n.is_element() && n.name() == "b")
1282            .expect("found <b>");
1283        assert_eq!(b.source_offset, 7, "byte offset of `b` name");
1284        let (line, col) = crate::compute_line_col(xml.as_bytes(), b.source_offset as usize);
1285        assert_eq!((line, col), (2, 4), "derived line/col");
1286    }
1287
1288    // ── XML 1.0 § 3.3.3 attribute-value normalization for xmlns ──
1289
1290    /// `xmlns:b` declared as NMTOKEN: leading/trailing whitespace
1291    /// in the URI literal MUST be stripped before the URI binds.
1292    /// CDATA attributes get no such treatment.  Without normalization
1293    /// `xmlns:a="urn:x"` and `xmlns:b=" urn:x "` would bind to two
1294    /// distinct URIs even though the spec treats them as one — the
1295    /// downstream "Unique Att Spec" check would then miss real
1296    /// namespace collisions like W3C rmt-ns10-012.
1297    #[test]
1298    fn xmlns_nmtoken_value_is_stripped_before_binding() {
1299        let xml = r#"<?xml version="1.0"?>
1300<!DOCTYPE foo [
1301<!ELEMENT foo ANY>
1302<!ATTLIST foo xmlns:a CDATA   #IMPLIED
1303              xmlns:b NMTOKEN #IMPLIED>
1304]>
1305<foo xmlns:a="urn:x" xmlns:b="  urn:x  "/>"#;
1306        let doc = parse_ns(xml);
1307        let root = doc.root();
1308        // Both prefixes should now bind to the same URI string.
1309        // Walk the namespace defs and check.
1310        let nsdefs: Vec<(Option<&str>, &str)> = {
1311            #[cfg(feature = "c-abi")]
1312            { root.ns_declarations().collect() }
1313            #[cfg(not(feature = "c-abi"))]
1314            {
1315                // Without c-abi we exposed xmlns decls as attributes
1316                // instead of nsDef.  Pull from the attribute list.
1317                let mut out = Vec::new();
1318                let mut a = root.first_attribute.get();
1319                while let Some(attr) = a {
1320                    let name = attr.name();
1321                    if name == "xmlns" {
1322                        out.push((None, attr.value()));
1323                    } else if let Some(p) = name.strip_prefix("xmlns:") {
1324                        out.push((Some(p), attr.value()));
1325                    }
1326                    a = attr.next.get();
1327                }
1328                out
1329            }
1330        };
1331        let a_uri = nsdefs.iter().find(|(p, _)| *p == Some("a")).map(|(_, u)| *u);
1332        let b_uri = nsdefs.iter().find(|(p, _)| *p == Some("b")).map(|(_, u)| *u);
1333        assert_eq!(a_uri, Some("urn:x"));
1334        assert_eq!(b_uri, Some("urn:x"),
1335            "NMTOKEN normalization should strip whitespace; got {b_uri:?}");
1336    }
1337
1338    /// CDATA-typed xmlns (the default when no ATTLIST covers it):
1339    /// whitespace inside the value is preserved.  This is the spec
1340    /// behaviour — without an `<!ATTLIST>` redeclaring the type the
1341    /// raw value goes through.  Guards against over-normalization.
1342    #[test]
1343    fn xmlns_cdata_value_is_not_stripped() {
1344        let xml = r#"<r xmlns:b="  urn:x  "/>"#;
1345        let doc = parse_ns(xml);
1346        let root = doc.root();
1347        let b_uri = {
1348            #[cfg(feature = "c-abi")]
1349            { root.ns_declarations().find(|(p, _)| *p == Some("b")).map(|(_, u)| u) }
1350            #[cfg(not(feature = "c-abi"))]
1351            {
1352                let mut a = root.first_attribute.get();
1353                let mut found = None;
1354                while let Some(attr) = a {
1355                    if attr.name() == "xmlns:b" { found = Some(attr.value()); break; }
1356                    a = attr.next.get();
1357                }
1358                found
1359            }
1360        };
1361        assert_eq!(b_uri, Some("  urn:x  "),
1362            "no ATTLIST → no non-CDATA normalization, URI must stay verbatim");
1363    }
1364
1365    // ── XML Namespaces 1.0 § 6.3 "Unique Att Spec" ──
1366
1367    /// Two prefixes binding to the SAME namespace URI on the same
1368    /// element make `a:attr` and `b:attr` resolve to the same
1369    /// expanded name `{URI}attr` — must be rejected.  Catches the
1370    /// straightforward (same-element) collision.
1371    #[test]
1372    fn duplicate_expanded_attribute_name_rejected() {
1373        let xml = r#"<r xmlns:a="urn:x" xmlns:b="urn:x" a:attr="1" b:attr="2"/>"#;
1374        let err = parse_str(xml, &ParseOptions { namespace_aware: true, ..ParseOptions::default() })
1375            .expect_err("two prefixes binding the same URI with same local must be rejected");
1376        let msg = err.to_string().to_lowercase();
1377        assert!(msg.contains("duplicate") && (msg.contains("namespace") || msg.contains("attribute")),
1378            "expected duplicate-attribute message, got: {err}");
1379    }
1380
1381    /// The same collision but the xmlns declarations live on a
1382    /// parent element — collision still detected because the
1383    /// namespace resolver walks the full in-scope binding stack.
1384    /// Mirrors W3C rmt-ns10-012's structural shape (xmlns on
1385    /// outer, prefixed attrs on inner) without the DTD-driven
1386    /// normalization layer.
1387    #[test]
1388    fn duplicate_expanded_attribute_name_rejected_across_elements() {
1389        let xml = r#"<f xmlns:a="urn:x" xmlns:b="urn:x"><g a:attr="1" b:attr="2"/></f>"#;
1390        let err = parse_str(xml, &ParseOptions { namespace_aware: true, ..ParseOptions::default() })
1391            .expect_err("inner-element collision must be caught through inherited bindings");
1392        let msg = err.to_string().to_lowercase();
1393        assert!(msg.contains("duplicate"),
1394            "expected duplicate-attribute message, got: {err}");
1395    }
1396
1397    /// The combined case — DTD-aware normalization + namespace-
1398    /// aware uniqueness must both fire to catch this.  This is
1399    /// exactly W3C rmt-ns10-012's input shape, distilled to the
1400    /// minimum needed to flip the test verdict.  Drops the W3C
1401    /// suite's external machinery so the failure mode is testable
1402    /// from inside the core crate without the harness.
1403    #[test]
1404    fn nmtoken_normalization_unlocks_ns_uniqueness_collision() {
1405        let xml = r#"<?xml version="1.0"?>
1406<!DOCTYPE foo [
1407<!ELEMENT foo ANY>
1408<!ATTLIST foo xmlns:a CDATA   #IMPLIED
1409              xmlns:b NMTOKEN #IMPLIED>
1410<!ELEMENT bar ANY>
1411<!ATTLIST bar a:attr CDATA #IMPLIED
1412              b:attr CDATA #IMPLIED>
1413]>
1414<foo xmlns:a="urn:x" xmlns:b=" urn:x ">
1415<bar a:attr="1" b:attr="2"/>
1416</foo>"#;
1417        let err = parse_str(xml, &ParseOptions { namespace_aware: true, ..ParseOptions::default() })
1418            .expect_err("rmt-ns10-012 minimum repro must be rejected once NMTOKEN normalization \
1419                         strips the whitespace from xmlns:b's value");
1420        let msg = err.to_string().to_lowercase();
1421        assert!(msg.contains("duplicate"),
1422            "expected duplicate-after-expansion error, got: {err}");
1423    }
1424
1425    /// XML 1.0 § 3.3.3 attribute-value normalization for a regular
1426    /// (non-xmlns) NMTOKEN attribute: leading/trailing whitespace
1427    /// stripped, internal runs collapsed.  Confirms the normalization
1428    /// helper applies to ALL non-CDATA attributes, not just xmlns:*.
1429    #[test]
1430    fn nmtoken_attribute_value_is_normalized() {
1431        let xml = r#"<?xml version="1.0"?>
1432<!DOCTYPE r [
1433<!ELEMENT r EMPTY>
1434<!ATTLIST r kind NMTOKEN #IMPLIED>
1435]>
1436<r kind="  alpha  "/>"#;
1437        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1438        let kind = doc.root().attributes()
1439            .find(|a| a.name() == "kind")
1440            .map(|a| a.value());
1441        assert_eq!(kind, Some("alpha"),
1442            "NMTOKEN value should be stripped; got {kind:?}");
1443    }
1444
1445    /// Same rule for ID-typed attributes — internal whitespace
1446    /// collapses too.  Without normalization, two `id` values that
1447    /// differ only by whitespace would compare unequal under
1448    /// `getElementById` etc.
1449    #[test]
1450    fn id_attribute_value_is_normalized_collapsed() {
1451        let xml = r#"<?xml version="1.0"?>
1452<!DOCTYPE r [
1453<!ELEMENT r EMPTY>
1454<!ATTLIST r tag ID #IMPLIED>
1455]>
1456<r tag="  a   b   c  "/>"#;
1457        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1458        let tag = doc.root().attributes()
1459            .find(|a| a.name() == "tag")
1460            .map(|a| a.value());
1461        assert_eq!(tag, Some("a b c"),
1462            "ID value should strip + collapse; got {tag:?}");
1463    }
1464
1465    /// CDATA-typed attribute (the default when no ATTLIST covers it)
1466    /// preserves whitespace verbatim — guards against
1467    /// over-normalization of CDATA-shaped values.
1468    #[test]
1469    fn cdata_attribute_value_is_not_stripped() {
1470        // No ATTLIST → defaults to CDATA → no non-CDATA stripping.
1471        let xml = r#"<r tag="  a   b  "/>"#;
1472        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1473        let tag = doc.root().attributes()
1474            .find(|a| a.name() == "tag")
1475            .map(|a| a.value());
1476        assert_eq!(tag, Some("  a   b  "),
1477            "CDATA-defaulted value must stay verbatim; got {tag:?}");
1478    }
1479
1480    /// Enumeration-typed attribute: stripped before matching the
1481    /// enum.  Without this, `<r kind=" yes ">` would silently fail
1482    /// even though the declared enum is `(yes|no)` — and we'd quietly
1483    /// accept the un-matched value instead of validating against it.
1484    #[test]
1485    fn enumeration_attribute_value_is_normalized() {
1486        let xml = r#"<?xml version="1.0"?>
1487<!DOCTYPE r [
1488<!ELEMENT r EMPTY>
1489<!ATTLIST r kind (yes|no) #IMPLIED>
1490]>
1491<r kind="  yes  "/>"#;
1492        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1493        let kind = doc.root().attributes()
1494            .find(|a| a.name() == "kind")
1495            .map(|a| a.value());
1496        assert_eq!(kind, Some("yes"));
1497    }
1498
1499    // ── resolve_entities = false (EntityRef node kind) ──
1500
1501    /// Default: entity references expand inline into Text.  Confirms
1502    /// the baseline behaviour the new flag opts out of.
1503    #[test]
1504    fn resolve_entities_default_expands_inline() {
1505        let xml = r#"<?xml version="1.0"?>
1506<!DOCTYPE r [<!ENTITY hi "hello">]>
1507<r>&hi;</r>"#;
1508        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1509        let root = doc.root();
1510        // Should have a single Text child "hello", no EntityRef.
1511        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1512        assert_eq!(kinds, vec![NodeKind::Text]);
1513        let text = root.children().next().unwrap().content();
1514        assert_eq!(text, "hello");
1515    }
1516
1517    /// `resolve_entities = false` preserves user-defined references
1518    /// as `NodeKind::EntityRef` nodes whose `name` is the entity
1519    /// name and whose `content` round-trips to `&name;` source.
1520    #[test]
1521    fn resolve_entities_false_emits_entity_ref_node() {
1522        let xml = r#"<?xml version="1.0"?>
1523<!DOCTYPE r [<!ENTITY hi "hello">]>
1524<r>before &hi; after</r>"#;
1525        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1526        let doc = parse_str(xml, &opts).expect("parse");
1527        let root = doc.root();
1528        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1529        assert_eq!(
1530            kinds,
1531            vec![NodeKind::Text, NodeKind::EntityRef, NodeKind::Text],
1532            "expected Text-EntityRef-Text triple, got {kinds:?}"
1533        );
1534        // The middle child carries the entity name + literal form.
1535        let ref_node = root.children().nth(1).unwrap();
1536        assert_eq!(ref_node.name(),    "hi");
1537        assert_eq!(ref_node.content(), "&hi;");
1538    }
1539
1540    /// Predefined entities always expand regardless of the flag —
1541    /// they're part of the character data production, not the
1542    /// entity-reference machinery.
1543    #[test]
1544    fn resolve_entities_false_still_expands_predefined() {
1545        let xml = r#"<r>a &amp; b &lt; c</r>"#;
1546        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1547        let doc = parse_str(xml, &opts).expect("parse");
1548        let root = doc.root();
1549        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1550        assert_eq!(kinds, vec![NodeKind::Text],
1551            "predefined entities must expand inline, got {kinds:?}");
1552        assert_eq!(root.children().next().unwrap().content(), "a & b < c");
1553    }
1554
1555    /// Numeric character references always expand inline too.
1556    #[test]
1557    fn resolve_entities_false_still_expands_numeric() {
1558        let xml = r#"<r>before &#65; after</r>"#;
1559        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1560        let doc = parse_str(xml, &opts).expect("parse");
1561        let root = doc.root();
1562        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1563        assert_eq!(kinds, vec![NodeKind::Text]);
1564        assert_eq!(root.children().next().unwrap().content(), "before A after");
1565    }
1566
1567    /// Round-trip: serialize a tree with EntityRef nodes back to
1568    /// source.  The literal `&name;` form is preserved.
1569    #[test]
1570    fn resolve_entities_false_round_trips_through_serializer() {
1571        let xml = r#"<r>x &hi; y</r>"#;
1572        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1573        let doc = parse_str(xml, &opts).expect("parse");
1574        let out = crate::serialize_to_string(&doc);
1575        assert!(out.contains("&hi;"),
1576            "EntityRef should serialize as `&hi;`, got: {out}");
1577    }
1578
1579    /// Edition guard: without `namespace_aware`, lexical names rule.
1580    /// `a:attr` and `b:attr` are different names → accepted.  Same
1581    /// input as the previous tests, but the namespace pass doesn't
1582    /// run.  Confirms the new check is properly gated.
1583    #[test]
1584    fn duplicate_expanded_name_accepted_when_namespace_blind() {
1585        let xml = r#"<r xmlns:a="urn:x" xmlns:b="urn:x" a:attr="1" b:attr="2"/>"#;
1586        // namespace_aware = false (the default).
1587        parse_str(xml, &ParseOptions::default())
1588            .expect("namespace-blind parse must accept lexically-distinct attribute names");
1589    }
1590
1591    #[test]
1592    fn line_numbers_basic() {
1593        // line 1: <r>
1594        // line 2:   <a/>
1595        // line 3:   <b/>
1596        // line 4: </r>
1597        let doc = parse("<r>\n  <a/>\n  <b/>\n</r>");
1598        let root = doc.root();
1599        let a = root.children().find(|n| n.is_element()).unwrap();
1600        let b = a.next_sibling.get().and_then(|n|
1601            if n.is_element() { Some(n) } else { n.next_sibling.get() }
1602        ).unwrap();
1603
1604        assert_eq!(root.line_no(), 1, "root <r> on line 1");
1605        assert_eq!(a.line_no(),    2, "<a/> on line 2");
1606        assert_eq!(b.line_no(),    3, "<b/> on line 3");
1607    }
1608
1609    /// Regression guard: a previous implementation called
1610    /// `scanner::compute_line_col` once per StartElement, which rescans
1611    /// `src[0..name_offset]` from byte 0 each call — O(N × file_size).
1612    /// On docs with many elements this slowed the parser by 10×–100×.
1613    /// The current implementation maintains an incremental cursor in
1614    /// `drive()`; this test exercises it across many lines to make sure
1615    /// the cursor stays in sync.
1616    #[test]
1617    fn line_numbers_many_elements() {
1618        // 300 elements, one per line.  Each <e i="N"/> on line N+1
1619        // (the <root> opener is line 1).
1620        let mut src = String::from("<root>\n");
1621        let n = 300u32;
1622        for i in 0..n {
1623            src.push_str(&format!("  <e i=\"{i}\"/>\n"));
1624        }
1625        src.push_str("</root>\n");
1626
1627        let doc = parse(&src);
1628        assert_eq!(doc.root().line_no(), 1);
1629
1630        let mut expected: u32 = 2;
1631        let mut walked = 0u32;
1632        for child in doc.root().children().filter(|n| n.is_element()) {
1633            // line is u16 in c-abi mode, u32 in lean — both fit < 65535 here.
1634            assert_eq!(child.line_no(), expected,
1635                "child #{walked}: expected line {expected}, got {}", child.line_no());
1636            walked += 1;
1637            expected += 1;
1638        }
1639        assert_eq!(walked, n);
1640    }
1641
1642    #[test]
1643    fn nested_elements() {
1644        let doc = parse("<a><b><c/></b></a>");
1645        let a = doc.root();
1646        assert_eq!(a.name(), "a");
1647        let b = a.children().next().unwrap();
1648        assert_eq!(b.name(), "b");
1649        let c = b.children().next().unwrap();
1650        assert_eq!(c.name(), "c");
1651        // parent pointers
1652        assert!(std::ptr::eq(c.parent.get().unwrap(), b));
1653        assert!(std::ptr::eq(b.parent.get().unwrap(), a));
1654    }
1655
1656    #[test]
1657    fn attributes_in_order() {
1658        let doc = parse(r#"<el id="1" class="x" data-y="42"/>"#);
1659        let pairs: Vec<(&str, &str)> = doc.root().attributes()
1660            .map(|a| (a.name(), a.value()))
1661            .collect();
1662        assert_eq!(pairs, vec![("id", "1"), ("class", "x"), ("data-y", "42")]);
1663    }
1664
1665    #[test]
1666    fn mixed_content() {
1667        let doc = parse("<r>before<!-- c --><b>x</b><![CDATA[<raw>]]>after</r>");
1668        let kinds: Vec<NodeKind> = doc.root().children().map(|c| c.kind).collect();
1669        assert_eq!(kinds, vec![
1670            NodeKind::Text, NodeKind::Comment, NodeKind::Element,
1671            NodeKind::CData, NodeKind::Text,
1672        ]);
1673    }
1674
1675    #[test]
1676    fn pi_inside_element() {
1677        let doc = parse(r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
1678        let pi = doc.root().children().next().unwrap();
1679        assert_eq!(pi.kind, NodeKind::Pi);
1680        assert_eq!(pi.name(), "xml-stylesheet");
1681        assert_eq!(pi.content(), r#"href="s.xsl""#);
1682    }
1683
1684    #[test]
1685    fn entity_in_text_is_expanded() {
1686        let doc = parse("<r>a&amp;b&lt;c</r>");
1687        let t = doc.root().children().next().unwrap();
1688        assert_eq!(t.kind, NodeKind::Text);
1689        assert_eq!(t.content(), "a&b<c");
1690    }
1691
1692    #[test]
1693    fn entity_in_attr_is_expanded() {
1694        let doc = parse(r#"<r v="a&amp;b"/>"#);
1695        let v = doc.root().attributes().next().unwrap();
1696        assert_eq!(v.value(), "a&b");
1697    }
1698
1699    #[test]
1700    fn deeply_nested_doc() {
1701        let mut xml = String::new();
1702        for _ in 0..50 { xml.push_str("<n>"); }
1703        xml.push_str("hello");
1704        for _ in 0..50 { xml.push_str("</n>"); }
1705        let doc = parse(&xml);
1706        let mut cur = doc.root();
1707        let mut depth = 1;
1708        while let Some(c) = cur.children().next() {
1709            if c.kind == NodeKind::Element { cur = c; depth += 1; } else { break; }
1710        }
1711        assert_eq!(depth, 50);
1712        assert_eq!(cur.children().next().unwrap().content(), "hello");
1713    }
1714
1715    #[test]
1716    fn root_lifetime_keeps_tree_alive() {
1717        let doc = parse("<r><a><b>x</b></a></r>");
1718        let a = doc.root().children().next().unwrap();
1719        let b = a.children().next().unwrap();
1720        assert_eq!(b.text_content(), Some("x"));
1721    }
1722
1723    #[test]
1724    fn errors_propagate_from_reader() {
1725        let err = parse_str("<r><a></b></r>", &ParseOptions::default());
1726        assert!(err.is_err());
1727    }
1728
1729    #[test]
1730    fn missing_root_returns_error() {
1731        // An empty document fails earlier (XML decl alone) — use whitespace-only.
1732        let r = parse_str("   ", &ParseOptions::default());
1733        assert!(r.is_err());
1734    }
1735
1736    // ── namespace tests ────────────────────────────────────────────────
1737
1738    #[test]
1739    fn no_namespaces_unchanged() {
1740        let doc = parse_ns("<root><child/></root>");
1741        assert!(doc.root().namespace.get().is_none());
1742    }
1743
1744    #[test]
1745    fn default_namespace_applied_to_element() {
1746        let doc = parse_ns(r#"<root xmlns="http://example.com/"/>"#);
1747        let ns = doc.root().namespace.get().unwrap();
1748        assert!(ns.prefix.is_none());
1749        assert_eq!(ns.href(), "http://example.com/");
1750    }
1751
1752    #[test]
1753    fn default_namespace_not_applied_to_attr() {
1754        let doc = parse_ns(r#"<root xmlns="http://example.com/" id="1"/>"#);
1755        let id_attr = doc.root().attributes().find(|a| a.name() == "id").unwrap();
1756        assert!(id_attr.namespace.get().is_none(), "default ns must not apply to unprefixed attrs");
1757    }
1758
1759    #[test]
1760    fn prefixed_element_resolved() {
1761        let doc = parse_ns(r#"<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">X</dc:title>"#);
1762        let ns = doc.root().namespace.get().unwrap();
1763        assert_eq!(ns.prefix(), Some("dc"));
1764        assert_eq!(ns.href(),   "http://purl.org/dc/elements/1.1/");
1765    }
1766
1767    #[test]
1768    fn prefixed_attribute_resolved() {
1769        let doc = parse_ns(
1770            r#"<root xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true"/>"#
1771        );
1772        // Match by (local-name, prefix): `name()` is the full QName on
1773        // the lean build but the local part under c-abi.
1774        let nil = doc.root().attributes()
1775            .find(|a| a.local_name() == "nil"
1776                   && a.namespace.get().and_then(|n| n.prefix()) == Some("xsi"))
1777            .unwrap();
1778        assert_eq!(nil.namespace.get().unwrap().href(),
1779                   "http://www.w3.org/2001/XMLSchema-instance");
1780    }
1781
1782    #[test]
1783    fn xml_prefix_builtin() {
1784        let doc = parse_ns(r#"<root xml:lang="en"/>"#);
1785        let lang = doc.root().attributes()
1786            .find(|a| a.local_name() == "lang"
1787                   && a.namespace.get().and_then(|n| n.prefix()) == Some("xml"))
1788            .unwrap();
1789        assert_eq!(lang.namespace.get().unwrap().href(),
1790                   "http://www.w3.org/XML/1998/namespace");
1791    }
1792
1793    #[test]
1794    fn nested_prefix_scope_inherits_outer() {
1795        let doc = parse_ns(r#"
1796            <root xmlns:a="http://a.com/">
1797                <a:child xmlns:b="http://b.com/">
1798                    <b:leaf/>
1799                </a:child>
1800            </root>
1801        "#);
1802        let root = doc.root();
1803        assert!(root.namespace.get().is_none());
1804        // c-abi mode stores `name` as the local part only (libxml2
1805        // convention); the lean build keeps the full QName.
1806        #[cfg(feature = "c-abi")]
1807        let child_local = "child";
1808        #[cfg(not(feature = "c-abi"))]
1809        let child_local = "a:child";
1810        let child = root.children().find(|c| c.is_element() && c.name() == child_local).unwrap();
1811        assert_eq!(child.namespace.get().unwrap().href(), "http://a.com/");
1812        let leaf = child.children().find(|c| c.is_element()).unwrap();
1813        assert_eq!(leaf.namespace.get().unwrap().href(), "http://b.com/");
1814    }
1815
1816    #[test]
1817    fn undeclared_prefix_is_error() {
1818        let r = parse_str("<dc:title>X</dc:title>", &ParseOptions { namespace_aware: true, ..ParseOptions::default() });
1819        let msg = r.unwrap_err().to_string();
1820        assert!(msg.contains("undeclared") || msg.contains("prefix"), "{msg}");
1821    }
1822
1823    #[test]
1824    fn default_namespace_override_in_child() {
1825        let doc = parse_ns(r#"
1826            <root xmlns="http://outer.com/">
1827                <inner xmlns="http://inner.com/"/>
1828            </root>
1829        "#);
1830        assert_eq!(doc.root().namespace.get().unwrap().href(), "http://outer.com/");
1831        let inner = doc.root().children().find(|c| c.is_element()).unwrap();
1832        assert_eq!(inner.namespace.get().unwrap().href(), "http://inner.com/");
1833    }
1834
1835    #[test]
1836    fn undeclare_default_namespace() {
1837        let doc = parse_ns(r#"
1838            <root xmlns="http://example.com/">
1839                <child xmlns=""/>
1840            </root>
1841        "#);
1842        assert!(doc.root().namespace.get().is_some());
1843        let child = doc.root().children().find(|c| c.is_element()).unwrap();
1844        assert!(child.namespace.get().is_none(), "xmlns='' should clear the default namespace");
1845    }
1846
1847    #[test]
1848    fn xmlns_prefix_cannot_be_declared() {
1849        let r = parse_str(
1850            r#"<r xmlns:xmlns="http://x.com/"/>"#,
1851            &ParseOptions { namespace_aware: true, ..ParseOptions::default() },
1852        );
1853        assert!(r.is_err());
1854    }
1855
1856    #[test]
1857    fn duplicate_attribute_after_ns_expansion() {
1858        // Two prefixed attrs that expand to the same (ns, local) pair.
1859        let r = parse_str(
1860            r#"<r xmlns:a="http://x.com/" xmlns:b="http://x.com/" a:id="1" b:id="2"/>"#,
1861            &ParseOptions { namespace_aware: true, ..ParseOptions::default() },
1862        );
1863        assert!(r.is_err(), "duplicate expanded attribute name must be rejected");
1864    }
1865
1866    #[test]
1867    fn deep_nesting_pops_scope_on_close() {
1868        // After nesting popped back to root level, an inner prefix becomes undeclared.
1869        let r = parse_str(r#"
1870            <root>
1871                <a xmlns:p="http://x.com/"><p:leaf/></a>
1872                <p:bad/>
1873            </root>
1874        "#, &ParseOptions { namespace_aware: true, ..ParseOptions::default() });
1875        assert!(r.is_err());
1876    }
1877
1878    #[test]
1879    fn xml_decl_fields_are_captured() {
1880        let doc = parse(r#"<?xml version="1.1" encoding="ISO-8859-1" standalone="yes"?><r/>"#);
1881        assert_eq!(doc.version,    "1.1");
1882        assert_eq!(doc.encoding,   "ISO-8859-1");
1883        assert_eq!(doc.standalone, Some(true));
1884    }
1885
1886    #[test]
1887    fn xml_decl_defaults_when_absent() {
1888        // No `<?xml … ?>` declaration → encoding stays empty
1889        // (matches libxml2's NULL doc->encoding) so serializers
1890        // omit the encoding attribute on output.
1891        let doc = parse("<r/>");
1892        assert_eq!(doc.version,    "1.0");
1893        assert_eq!(doc.encoding,   "");
1894        assert_eq!(doc.standalone, None);
1895    }
1896
1897    #[test]
1898    fn xml_decl_partial_keeps_encoding_default() {
1899        // Only version present — encoding stays empty (no
1900        // declaration to copy), standalone absent.
1901        let doc = parse(r#"<?xml version="1.0"?><r/>"#);
1902        assert_eq!(doc.version,    "1.0");
1903        assert_eq!(doc.encoding,   "");
1904        assert_eq!(doc.standalone, None);
1905    }
1906
1907    #[test]
1908    fn xml_decl_standalone_no_is_captured() {
1909        // Note: standalone without encoding is rejected by both legacy
1910        // and arena parsers (pre-existing behaviour) — include encoding.
1911        let doc = parse(r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?><r/>"#);
1912        assert_eq!(doc.standalone, Some(false));
1913    }
1914
1915    #[test]
1916    fn many_siblings_preserve_order() {
1917        let mut xml = String::from("<r>");
1918        for i in 0..100 {
1919            xml.push_str(&format!("<i>{i}</i>"));
1920        }
1921        xml.push_str("</r>");
1922        let doc = parse(&xml);
1923        let texts: Vec<&str> = doc.root().children()
1924            .filter(|c| c.kind == NodeKind::Element)
1925            .map(|c| c.text_content().unwrap_or(""))
1926            .collect();
1927        assert_eq!(texts.len(), 100);
1928        assert_eq!(texts[0],  "0");
1929        assert_eq!(texts[99], "99");
1930    }
1931
1932    // ── parse_bytes_in_place — entry point + call-time gates ──────
1933
1934    #[test]
1935    fn in_place_parses_basic_document() {
1936        let buf = b"<root><child id=\"1\">hello</child></root>".to_vec();
1937        let doc = parse_bytes_in_place(buf, &ParseOptions::default())
1938            .expect("in-place parse should succeed on well-formed input");
1939        assert_eq!(doc.root().name(), "root");
1940        let child = doc.root().children().next().expect("child element");
1941        assert_eq!(child.name(), "child");
1942        assert_eq!(child.attributes().next().unwrap().value(), "1");
1943    }
1944
1945    #[test]
1946    fn in_place_rejects_recovery_mode_at_call_time() {
1947        let buf = b"<r/>".to_vec();
1948        let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
1949        let err = parse_bytes_in_place(buf, &opts)
1950            .expect_err("recovery_mode=true must be rejected up front");
1951        assert!(
1952            err.message.contains("recovery_mode"),
1953            "error should mention recovery_mode, got: {}", err.message,
1954        );
1955    }
1956
1957    #[test]
1958    fn in_place_transcodes_non_utf8_when_auto_transcode_on() {
1959        // ISO-8859-1 with `<?xml encoding="ISO-8859-1"?>` and one non-ASCII
1960        // byte (0xE9 = 'é').  Transcoded to UTF-8 upfront, then parsed.
1961        let buf: Vec<u8> =
1962            b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><r>caf\xe9</r>".to_vec();
1963        let opts = ParseOptions { auto_transcode: true, ..ParseOptions::default() };
1964        let doc = parse_bytes_in_place(buf, &opts).expect("transcoded parse should succeed");
1965        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
1966        assert_eq!(text, "café");
1967    }
1968
1969    #[test]
1970    fn in_place_rejects_invalid_utf8_when_auto_transcode_off() {
1971        let buf: Vec<u8> = b"<r>\xff</r>".to_vec();
1972        let opts = ParseOptions { auto_transcode: false, ..ParseOptions::default() };
1973        let err = parse_bytes_in_place(buf, &opts).expect_err("invalid UTF-8 must be rejected");
1974        assert_eq!(err.domain, crate::error::ErrorDomain::Encoding);
1975    }
1976
1977    // ── simdutf8 ⇄ std::str::from_utf8 equivalence ───────────────
1978    //
1979    // The input-validation gates (`parse_bytes_in_place`,
1980    // `transcode_and_validate`, `XmlBytesReader::from_bytes`) use
1981    // `simdutf8::compat::from_utf8` as a drop-in for `std::str::from_utf8`.
1982    // The whole correctness claim is that it is *behaviorally identical*:
1983    // same accept/reject verdict, and on rejection the same `valid_up_to()`
1984    // and `error_len()` — the parser pins error line/col to `valid_up_to()`,
1985    // so any divergence would silently shift reported error positions.
1986    // These tests pin that equivalence against `std` as the oracle.
1987
1988    /// Reduce a validation outcome to a comparable shape: `Ok(())` when
1989    /// valid, or the error's `(valid_up_to, error_len)` when not.
1990    fn std_verdict(b: &[u8]) -> std::result::Result<(), (usize, Option<usize>)> {
1991        std::str::from_utf8(b)
1992            .map(|_| ())
1993            .map_err(|e| (e.valid_up_to(), e.error_len()))
1994    }
1995
1996    fn simd_verdict(b: &[u8]) -> std::result::Result<(), (usize, Option<usize>)> {
1997        simdutf8::compat::from_utf8(b)
1998            .map(|_| ())
1999            .map_err(|e| (e.valid_up_to(), e.error_len()))
2000    }
2001
2002    #[test]
2003    fn simdutf8_matches_std_at_chunk_boundaries() {
2004        // SIMD validators process vector-width chunks (16/32/64 bytes) then a
2005        // scalar tail; bugs hide where a malformed byte straddles that seam.
2006        // Sweep a lone 0xFF (never valid UTF-8) across every offset of buffers
2007        // sized around the common vector widths, plus the clean buffer.
2008        for len in [0usize, 1, 7, 8, 9, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129] {
2009            let base = vec![b'a'; len];
2010            assert_eq!(std_verdict(&base), simd_verdict(&base), "clean buffer len {len}");
2011            for pos in 0..len {
2012                let mut bad = base.clone();
2013                bad[pos] = 0xFF;
2014                assert_eq!(
2015                    std_verdict(&bad), simd_verdict(&bad),
2016                    "diverged: lone 0xFF at offset {pos} in len {len}",
2017                );
2018            }
2019        }
2020    }
2021
2022    #[test]
2023    fn simdutf8_matches_std_on_adversarial_utf8() {
2024        // The classic failure modes for hand-rolled UTF-8 validators: truncated
2025        // multibyte sequences, overlong encodings, surrogate code points, lone
2026        // continuation bytes, and valid→invalid transitions mid-stream.
2027        let cases: &[&[u8]] = &[
2028            b"",
2029            b"hello",
2030            &[0xC3, 0xA9],                          // é — valid 2-byte
2031            &[0xC3],                                // truncated 2-byte lead
2032            &[0xE2, 0x82, 0xAC],                    // € — valid 3-byte
2033            &[0xE2, 0x82],                          // truncated 3-byte
2034            &[0xF0, 0x9F, 0x98, 0x80],              // 😀 — valid 4-byte
2035            &[0xF0, 0x9F, 0x98],                    // truncated 4-byte
2036            &[0x80],                                // lone continuation
2037            &[0xBF],                                // lone continuation
2038            &[0xC0, 0x80],                          // overlong NUL
2039            &[0xE0, 0x80, 0x80],                    // overlong 3-byte
2040            &[0xF0, 0x80, 0x80, 0x80],              // overlong 4-byte
2041            &[0xED, 0xA0, 0x80],                    // lone high surrogate U+D800
2042            &[0xED, 0xBF, 0xBF],                    // lone low surrogate U+DFFF
2043            &[0xF4, 0x90, 0x80, 0x80],              // above U+10FFFF
2044            &[0xFF],
2045            &[0xFE],
2046            b"caf\xe9",                             // Latin-1 é — invalid as UTF-8
2047            &[b'o', b'k', 0xC3, 0xA9, 0xFF, b'x'],  // valid run then invalid byte
2048        ];
2049        for c in cases {
2050            assert_eq!(std_verdict(c), simd_verdict(c), "diverged on {c:02x?}");
2051        }
2052    }
2053
2054    #[test]
2055    fn simdutf8_matches_std_on_random_bytes() {
2056        // Deterministic xorshift corpus — no rng/clock dependency, so failures
2057        // reproduce exactly.  Bias one byte in four into the 0x80..=0xFF range
2058        // so lead/continuation logic gets exercised, not just ASCII runs.
2059        let mut state = 0x9E3779B97F4A7C15u64;
2060        let mut next = move || {
2061            state ^= state << 13;
2062            state ^= state >> 7;
2063            state ^= state << 17;
2064            state
2065        };
2066        for _ in 0..20_000 {
2067            let len = (next() % 70) as usize;
2068            let mut buf = Vec::with_capacity(len);
2069            for _ in 0..len {
2070                let r = next();
2071                let byte = if r & 3 == 0 {
2072                    (r >> 8) as u8 | 0x80
2073                } else {
2074                    (r >> 8) as u8 & 0x7F
2075                };
2076                buf.push(byte);
2077            }
2078            assert_eq!(std_verdict(&buf), simd_verdict(&buf), "diverged on {buf:02x?}");
2079        }
2080    }
2081
2082    #[test]
2083    fn in_place_decodes_builtin_entities() {
2084        // Slow-path exercise: text content with `&amp;` triggers entity
2085        // expansion in the reader.  In-place mode mutates the source
2086        // buffer (overwriting `&amp;` with `&`) and emits the text as
2087        // Cow::Borrowed pointing into the now-mutated buffer.  The arena
2088        // parser then takes the alloc_str_borrow zero-copy path.
2089        let buf = b"<r>tom &amp; jerry</r>".to_vec();
2090        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2091        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2092        assert_eq!(text, "tom & jerry");
2093    }
2094
2095    #[test]
2096    fn in_place_decodes_multiple_builtin_entities() {
2097        let buf = b"<r>&lt;hello&gt; &amp; &quot;hi&quot;</r>".to_vec();
2098        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2099        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2100        assert_eq!(text, r#"<hello> & "hi""#);
2101    }
2102
2103    #[test]
2104    fn in_place_text_without_entities_works() {
2105        // Fast path (Cow::Borrowed straight from reader, no slow path).
2106        let buf = b"<r>plain text with no entities</r>".to_vec();
2107        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2108        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2109        assert_eq!(text, "plain text with no entities");
2110    }
2111
2112    #[test]
2113    fn in_place_accepts_user_entity_that_shrinks() {
2114        // `&x;` (4 source bytes) expands to "AB" (2 bytes) — fits.
2115        let buf = br#"<!DOCTYPE r [<!ENTITY x "AB">]><r>&x;</r>"#.to_vec();
2116        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2117        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2118        assert_eq!(text, "AB");
2119    }
2120
2121    #[test]
2122    fn in_place_rejects_user_entity_that_grows() {
2123        // `&hi;` (5 source bytes) tries to expand to "Hello, World!" (13 bytes).
2124        // Doesn't fit → error at use site with a clear message.
2125        let buf = br#"<!DOCTYPE r [<!ENTITY hi "Hello, World!">]><r>&hi;</r>"#.to_vec();
2126        let err = parse_bytes_in_place(buf, &ParseOptions::default()).expect_err(
2127            "expansion bigger than reference must be rejected in in-place mode",
2128        );
2129        assert!(
2130            err.message.contains("exceeds source span")
2131                || err.message.contains("expansion"),
2132            "error should explain the expansion mismatch, got: {}", err.message,
2133        );
2134    }
2135
2136    #[test]
2137    fn in_place_numeric_char_refs_work() {
2138        // `&#xC9;` (6 source bytes) → `É` (2 UTF-8 bytes) — fits.
2139        // `&#x1D400;` (9 source bytes) → `𝐀` (4 UTF-8 bytes) — fits.
2140        let buf = "<r>caf&#xC9; and &#x1D400;</r>".as_bytes().to_vec();
2141        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2142        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2143        assert_eq!(text, "cafÉ and 𝐀");
2144    }
2145
2146    #[test]
2147    fn in_place_rejects_recursive_entity() {
2148        // Direct recursion — same well-formedness error as parse_bytes
2149        // (XML 1.0 § 4.1).  The expansion stack catches it before any
2150        // in-place mutation happens.
2151        let buf = br#"<!DOCTYPE r [<!ENTITY a "&a;">]><r>&a;</r>"#.to_vec();
2152        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2153            .expect_err("recursive entity must be rejected");
2154        let msg = err.message.to_lowercase();
2155        assert!(
2156            msg.contains("recurs") || msg.contains("cycle"),
2157            "error should mention recursion/cycle, got: {}", err.message,
2158        );
2159    }
2160
2161    // ── parse_bytes_in_place — flag honoring ────────────────────────────────
2162    //
2163    // These tests pin down a behavioral contract: `parse_bytes_in_place`
2164    // honors every flag on the caller's `ParseOptions` as-is.  It does
2165    // NOT silently flip the `skip_*` validation flags on (that override
2166    // used to exist; was removed deliberately so callers control the
2167    // validation / speed tradeoff).  Each test pair below verifies one
2168    // flag:
2169    //   - default `ParseOptions` (flag is `false`) → input rejected
2170    //   - the same input with the flag set `true`  → input accepted
2171    // If either half of a pair flips, something has reintroduced the
2172    // override or weakened the validator.
2173
2174    /// XML 1.0 § 2.3 [4]: names start with a letter, `_`, or `:` (or
2175    /// non-ASCII NameStartChar) — never a digit.  `<1foo>` is invalid.
2176    #[test]
2177    fn in_place_default_opts_reject_bad_name_start() {
2178        let buf = b"<1foo/>".to_vec();
2179        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2180            .expect_err("default opts must reject name starting with digit");
2181        assert!(
2182            err.message.contains("name-start") || err.message.contains("name start"),
2183            "error should mention name-start, got: {}", err.message,
2184        );
2185        // libxml2-compatible code: XML_ERR_NAME_REQUIRED = 68.
2186        // Validates the Slice 5a contract that consumer-checked codes
2187        // round-trip via `err.code as i32`.
2188        assert_eq!(err.code, crate::error::ErrorCode::NameRequired);
2189        assert_eq!(err.code as i32, 68);
2190    }
2191
2192    #[test]
2193    fn in_place_skip_name_validation_accepts_bad_name_start() {
2194        // Same malformed name as above; with skip_name_validation the
2195        // name is accepted as-is and parsing reaches Eof cleanly.
2196        let buf = b"<1foo/>".to_vec();
2197        let opts = ParseOptions { skip_name_validation: true, ..ParseOptions::default() };
2198        let doc = parse_bytes_in_place(buf, &opts)
2199            .expect("skip_name_validation=true must accept malformed name");
2200        assert_eq!(doc.root().name(), "1foo");
2201    }
2202
2203    /// XML 1.0 § 3.1 [STag] WFC "Unique Att Spec": no element may have
2204    /// two attributes with the same name.  `<r a="1" a="2"/>` is
2205    /// rejected when `skip_attr_validation` is false (the default).
2206    #[test]
2207    fn in_place_default_opts_reject_duplicate_attribute() {
2208        let buf = br#"<r a="1" a="2"/>"#.to_vec();
2209        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2210            .expect_err("default opts must reject duplicate attribute");
2211        assert!(
2212            err.message.to_lowercase().contains("duplicate") || err.message.contains("Unique Att Spec"),
2213            "error should mention duplicate-attribute, got: {}", err.message,
2214        );
2215    }
2216
2217    #[test]
2218    fn in_place_skip_attr_validation_accepts_duplicate_attribute() {
2219        let buf = br#"<r a="1" a="2"/>"#.to_vec();
2220        let opts = ParseOptions { skip_attr_validation: true, ..ParseOptions::default() };
2221        let doc = parse_bytes_in_place(buf, &opts)
2222            .expect("skip_attr_validation=true must accept duplicate attr");
2223        // Both attributes end up on the element when validation is off —
2224        // we don't dedup at the structural layer.
2225        let n_attrs = doc.root().attributes().count();
2226        assert_eq!(n_attrs, 2, "both duplicate attrs should be present");
2227    }
2228
2229    /// XML 1.0 § 3.1 [STag/ETag]: end tag name must match the start
2230    /// tag name.  `<a></b>` is a well-formedness error.
2231    #[test]
2232    fn in_place_default_opts_reject_end_tag_mismatch() {
2233        let buf = b"<a></b>".to_vec();
2234        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2235            .expect_err("default opts must reject mismatched end tag");
2236        let msg = err.message.to_lowercase();
2237        assert!(
2238            msg.contains("mismatched") || msg.contains("expected"),
2239            "error should mention mismatched end tag, got: {}", err.message,
2240        );
2241    }
2242
2243    #[test]
2244    fn in_place_skip_end_tag_check_accepts_end_tag_mismatch() {
2245        let buf = b"<a></b>".to_vec();
2246        let opts = ParseOptions { skip_end_tag_check: true, ..ParseOptions::default() };
2247        let doc = parse_bytes_in_place(buf, &opts)
2248            .expect("skip_end_tag_check=true must accept mismatched end tag");
2249        assert_eq!(doc.root().name(), "a");
2250    }
2251
2252    /// XML 1.0 § 2.2 [2]: only specific Unicode code points are valid
2253    /// XML characters.  0x01 (control character, not in the allowed
2254    /// set) is rejected when `skip_xml_char_validation` is false.
2255    #[test]
2256    fn in_place_default_opts_reject_invalid_xml_char() {
2257        // Text content containing 0x01 — valid UTF-8 (single ASCII byte)
2258        // but invalid as an XML 1.0 character.
2259        let buf = b"<r>hello\x01world</r>".to_vec();
2260        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2261            .expect_err("default opts must reject invalid XML char");
2262        // Validator's actual wording — check for either the section
2263        // citation or any mention of "character" / "valid".
2264        let msg = err.message.to_lowercase();
2265        assert!(
2266            msg.contains("xml 1.0") || msg.contains("char") || msg.contains("invalid"),
2267            "error should mention invalid XML char, got: {}", err.message,
2268        );
2269    }
2270
2271    #[test]
2272    fn in_place_skip_xml_char_validation_accepts_invalid_xml_char() {
2273        let buf = b"<r>hello\x01world</r>".to_vec();
2274        let opts = ParseOptions { skip_xml_char_validation: true, ..ParseOptions::default() };
2275        let doc = parse_bytes_in_place(buf, &opts)
2276            .expect("skip_xml_char_validation=true must accept 0x01 in text");
2277        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2278        assert_eq!(text.as_bytes(), b"hello\x01world");
2279    }
2280
2281    /// Combined "fast path" — all four skips enabled.  Exercises the
2282    /// path callers actually reach for when they want maximum speed.
2283    /// Verifies it works on a well-formed doc (the more interesting
2284    /// cases are covered individually above; this is a smoke test).
2285    #[test]
2286    fn in_place_with_all_skips_parses_well_formed_doc() {
2287        let buf = b"<root><a id=\"1\">hello</a></root>".to_vec();
2288        let opts = ParseOptions {
2289            skip_xml_char_validation: true,
2290            skip_name_validation:     true,
2291            skip_attr_validation:     true,
2292            skip_end_tag_check:       true,
2293            ..ParseOptions::default()
2294        };
2295        let doc = parse_bytes_in_place(buf, &opts).expect("all-skips parse");
2296        assert_eq!(doc.root().name(), "root");
2297        let a = doc.root().children().next().expect("element child");
2298        assert_eq!(a.name(), "a");
2299        assert_eq!(a.attributes().next().unwrap().value(), "1");
2300    }
2301}