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                    #[cfg(feature = "c-abi")]
839                    let line: u16 = raw_line.min(u16::MAX as u32) as u16;
840                    #[cfg(not(feature = "c-abi"))]
841                    let line: u32 = raw_line;
842                    el.line = line;
843                    // Keep the uncapped line for files past 65535 lines;
844                    // `xmlGetLineNo` returns it in preference to the
845                    // saturated `line`.
846                    #[cfg(feature = "c-abi")]
847                    {
848                        el.full_line = raw_line;
849                    }
850                }
851                // Entity-stream start tags carry their attrs pre-
852                // parsed (the lazy iterator can't surface bytes that
853                // don't live in `src`).  Take them out first; the
854                // lazy `attrs()` path returns nothing in that case.
855                let entity_attr_pairs: Option<Vec<(Vec<u8>, Vec<u8>)>> =
856                    tag.entity_attrs().map(|v| v.to_vec());
857
858                // Skip the attribute iterator entirely when there's
859                // nothing between the name and the closing `>` — a
860                // single empty-slice check is cheaper than constructing
861                // a Scanner just to have its `next()` immediately return
862                // None.  Trailing whitespace inside the start tag still
863                // falls through (rare in practice; attrs() short-
864                // circuits on the first .next() either way).
865                if !tag.attrs_bytes().is_empty() {
866                    for a in tag.attrs() { attr_buf.push(a?); }
867                }
868
869                // Splice pre-parsed entity-stream attrs into the buffer
870                // so the existing ns-aware / ns-blind loops below see
871                // them uniformly.  Bytes are arena-copied; the
872                // lifetime cast launders the arena's lifetime to the
873                // attr_buf slot, sound because the arena outlives the
874                // BytesAttr.
875                if let Some(pairs) = entity_attr_pairs {
876                    for (n_bytes, v_bytes) in pairs {
877                        let n_arena: &str = b.alloc_str(unsafe {
878                            std::str::from_utf8_unchecked(&n_bytes)
879                        });
880                        let v_arena: &str = b.alloc_str(unsafe {
881                            std::str::from_utf8_unchecked(&v_bytes)
882                        });
883                        let n_slice: &[u8] = n_arena.as_bytes();
884                        let v_slice: &[u8] = v_arena.as_bytes();
885                        let n_ext: &[u8] = unsafe { &*(n_slice as *const [u8]) };
886                        let v_ext: &[u8] = unsafe { &*(v_slice as *const [u8]) };
887                        attr_buf.push(BytesAttr {
888                            name:  n_ext,
889                            value: std::borrow::Cow::Borrowed(v_ext),
890                        });
891                    }
892                }
893
894                if !ns_aware {
895                    // Fastest path — namespace-blind, mirrors legacy parse_bytes.
896                    for a in attr_buf.drain(..) {
897                        // Attr name is always &'src [u8] (no entity decode for
898                        // names per XML 1.0 § 2.3) so we can always borrow it.
899                        let aname  = alloc_name_bytes_as_str(b, a.name);
900                        let raw_avalue = alloc_cow_bytes_as_str(b, a.value);
901                        // XML 1.0 § 3.3.3 non-CDATA normalization — same
902                        // helper the ns-aware branch uses.  No-op when
903                        // no ATTLIST covers this attribute.
904                        let avalue: &str =
905                            dtd_normalize_attr_value(b, reader.dtd(), name, aname, raw_avalue);
906                        let attr   = b.new_attribute(aname, avalue);
907                        b.append_attribute(el, attr);
908                    }
909                } else {
910                    // ── namespace-aware path ──────────────────────────────────
911                    validate_qname(name, "element")?;
912                    ns_frames.push(ns_bindings.len());
913                    let mut new_bindings_this_frame = 0u32;
914                    let mut any_attr_prefixed = false;
915                    // Prefixed attributes paired with their original QName.
916                    // Namespace resolution must wait until every `xmlns`
917                    // declaration on this element has been seen, so collect
918                    // them and resolve in the pass below.  In c-abi the
919                    // stored `name` is already reduced to the local part
920                    // (libxml2 convention); the prefix is recovered from the
921                    // QName recorded here.
922                    let mut prefixed_attrs: Vec<(&Attribute<'_>, &str)> = Vec::new();
923
924                    for a in attr_buf.drain(..) {
925                        let aname  = alloc_name_bytes_as_str(b, a.name);
926                        let raw_avalue = alloc_cow_bytes_as_str(b, a.value);
927
928                        // Namespace declarations (`xmlns` / `xmlns:foo`)
929                        // do NOT belong in the element's attribute
930                        // list under the c-abi layout — libxml2's
931                        // `_xmlNode::properties` holds only real
932                        // attributes; xmlns declarations live on the
933                        // separate `nsDef` chain.  In the lean
934                        // (non-c-abi) build we keep them in both
935                        // places for backwards-compatibility with
936                        // existing API consumers that iterate
937                        // `attributes()` expecting xmlns decls there.
938                        let is_xmlns_decl =
939                            aname == "xmlns" || aname.starts_with("xmlns:");
940
941                        // XML 1.0 § 3.3.3 attribute-value normalization.
942                        // Applied uniformly: if an `<!ATTLIST>` declared
943                        // this attribute as non-CDATA (ID, IDREF,
944                        // IDREFS, ENTITY/ENTITIES, NMTOKEN/NMTOKENS,
945                        // NOTATION, enumeration), strip leading/trailing
946                        // whitespace and collapse internal runs to a
947                        // single space.  Without this, ID equality,
948                        // IDREF resolution, and enumeration matching
949                        // would all silently disagree with the spec on
950                        // values like `id=" abc "`.  Helper no-ops when
951                        // no ATTLIST covers the pair, so non-DTD docs
952                        // pay nothing.
953                        let avalue: &str =
954                            dtd_normalize_attr_value(b, reader.dtd(), name, aname, raw_avalue);
955
956                        #[cfg(not(feature = "c-abi"))]
957                        let always_attach = true;
958                        #[cfg(feature = "c-abi")]
959                        let always_attach = false;
960                        let is_prefixed = !is_xmlns_decl
961                            && memchr::memchr(b':', aname.as_bytes()).is_some();
962                        if always_attach || !is_xmlns_decl {
963                            // c-abi follows libxml2: `name` holds the local
964                            // part only, with the prefix carried on
965                            // `attr->ns` after resolution.  Mirror the
966                            // element-name reduction so a prefixed attribute
967                            // doesn't serialize as `p:p:name` and `keys()`
968                            // reports the bare local name.  The lean build
969                            // keeps the raw QName (its serializer never
970                            // re-prepends a prefix).
971                            #[cfg(feature = "c-abi")]
972                            let stored_name = if ns_aware && is_prefixed {
973                                let i = memchr::memchr(b':', aname.as_bytes()).unwrap();
974                                &aname[i + 1..]
975                            } else {
976                                aname
977                            };
978                            #[cfg(not(feature = "c-abi"))]
979                            let stored_name = aname;
980                            let attr: &Attribute<'_> = b.new_attribute(stored_name, avalue);
981                            b.append_attribute(el, attr);
982                            if ns_aware && is_prefixed {
983                                prefixed_attrs.push((attr, aname));
984                            }
985                        }
986
987                        if aname == "xmlns" {
988                            let ns = b.new_namespace(None, avalue);
989                            ns_bindings.push((None, erase_ns(ns)));
990                            #[cfg(feature = "c-abi")]
991                            { b.append_ns_def(el, ns); }
992                            new_bindings_this_frame += 1;
993                        } else if let Some(local) = aname.strip_prefix("xmlns:") {
994                            validate_xmlns_decl(local, avalue)?;
995                            if local == "xml" { continue; }
996                            let ns = b.new_namespace(Some(local), avalue);
997                            ns_bindings.push((Some(local), erase_ns(ns)));
998                            #[cfg(feature = "c-abi")]
999                            { b.append_ns_def(el, ns); }
1000                            new_bindings_this_frame += 1;
1001                        } else if memchr::memchr(b':', aname.as_bytes()).is_some() {
1002                            any_attr_prefixed = true;
1003                        }
1004                    }
1005                    active_user_bindings += new_bindings_this_frame;
1006
1007                    // ── Element QName resolution ──
1008                    // Fast path: no prefix and no user bindings ever in scope → no namespace.
1009                    let elem_has_colon = memchr::memchr(b':', name.as_bytes()).is_some();
1010                    if elem_has_colon || active_user_bindings > 0 {
1011                        let el_ns = resolve_qname(name, &ns_bindings, /*is_attribute=*/ false)?;
1012                        el.namespace.set(el_ns);
1013                    }
1014
1015                    // ── Attribute QName resolution + dup-by-expanded-name check ──
1016                    // Skip the FxHashSet allocation entirely when no attribute is prefixed
1017                    // (no namespace = no collision possible after expansion).
1018                    if any_attr_prefixed {
1019                        // Resolve each prefixed attribute's namespace now that
1020                        // every xmlns declaration on this element is in scope.
1021                        // `resolve_qname` takes the original prefixed QName
1022                        // recorded at creation; the stored `name` may already
1023                        // be the local part (c-abi).
1024                        for &(attr, qname) in &prefixed_attrs {
1025                            validate_qname(qname, "attribute")?;
1026                            let ns = resolve_qname(qname, &ns_bindings, true)?;
1027                            attr.namespace.set(ns);
1028                        }
1029                        // XML NS § 6.3: no two attributes may share an expanded
1030                        // name (namespace-uri, local-name).  The local part is
1031                        // `name` after the colon — which equals `name` itself
1032                        // once reduced (c-abi).
1033                        let mut seen: FxHashSet<(&str, &str)> = FxHashSet::default();
1034                        let mut attr_cur = el.first_attribute.get();
1035                        while let Some(attr) = attr_cur {
1036                            if attr.name() != "xmlns" && !attr.name().starts_with("xmlns:") {
1037                                let ns_uri = attr.namespace.get().map(|n| n.href()).unwrap_or("");
1038                                let local  = attr.name().rfind(':')
1039                                    .map(|i| &attr.name()[i + 1..])
1040                                    .unwrap_or(attr.name());
1041                                if !seen.insert((ns_uri, local)) {
1042                                    return Err(ns_err(if ns_uri.is_empty() {
1043                                        format!("duplicate attribute '{local}' after namespace expansion")
1044                                    } else {
1045                                        format!("duplicate attribute '{local}' in namespace '{ns_uri}' after namespace expansion")
1046                                    }));
1047                                }
1048                            }
1049                            attr_cur = attr.next.get();
1050                        }
1051                    }
1052                    // Stash the per-frame binding count in ns_frames is not enough — we
1053                    // also need to know how many to subtract from active_user_bindings
1054                    // on EndElement.  Pack the count into the frame marker by using a
1055                    // sentinel: separate Vec for the counts.  (Simpler: piggyback on
1056                    // ns_frames by recording `new_bindings_this_frame` in a parallel Vec.)
1057                    // For now we track via a side Vec.
1058                    ns_frame_count_stack.push(new_bindings_this_frame);
1059                }
1060
1061                // Attach to parent (if any) — first StartElement becomes the root.
1062                if let Some(&parent_ptr) = stack.last() {
1063                    // SAFETY: parent_ptr points into `b`, still alive here.
1064                    let parent: &Node<'_> = unsafe { unerase(parent_ptr) };
1065                    b.append_child(parent, el);
1066                } else {
1067                    root = Some(erase(el));
1068                }
1069                stack.push(erase(el));
1070            }
1071            BytesEvent::EndElement(_) => {
1072                stack.pop().expect("EndElement without StartElement — XmlBytesReader invariant");
1073                if ns_aware {
1074                    if let Some(frame_start) = ns_frames.pop() {
1075                        ns_bindings.truncate(frame_start);
1076                    }
1077                    if let Some(count) = ns_frame_count_stack.pop() {
1078                        active_user_bindings -= count;
1079                    }
1080                }
1081            }
1082            BytesEvent::Text(t)    => attach_leaf(b, &stack, b.new_text   (alloc_cow_bytes_as_str(b, t.into_bytes())), root.is_some()),
1083            BytesEvent::CData(s)   => {
1084                // `cdata_as_text` (libxml2 NOCDATA / lxml strip_cdata)
1085                // delivers CDATA content as a plain text node.
1086                let content = alloc_cow_bytes_as_str(b, s.into_bytes());
1087                let leaf = if opts.cdata_as_text { b.new_text(content) } else { b.new_cdata(content) };
1088                attach_leaf(b, &stack, leaf, root.is_some());
1089            }
1090            BytesEvent::Comment(s) => {
1091                // `remove_comments` (lxml NULLs the SAX comment callback):
1092                // skip building the node entirely.
1093                if !opts.remove_comments {
1094                    attach_leaf(b, &stack, b.new_comment(alloc_cow_bytes_as_str(b, s.into_bytes())), root.is_some());
1095                }
1096            }
1097            BytesEvent::Pi(p)      => {
1098                if !opts.remove_pis {
1099                    let (t, c) = p.into_parts();
1100                    let target  = alloc_cow_bytes_as_str(b, t);
1101                    // A PI with no data section serializes without the
1102                    // trailing space; libxml2 marks that as NULL content.
1103                    let content = if c.is_empty() { None } else { Some(alloc_cow_bytes_as_str(b, c)) };
1104                    attach_leaf(b, &stack, b.new_pi(target, content), root.is_some());
1105                }
1106            }
1107            BytesEvent::EntityRef(e) => {
1108                // `resolve_entities: false` left an `&name;` literal in
1109                // the source; materialize it as a dedicated
1110                // `NodeKind::EntityRef` node so the tree round-trips
1111                // back to source and lxml's `tag == Entity` /
1112                // `text == "&name;"` semantics work.
1113                let name_bytes = e.name();
1114                // SAFETY: Scanner UTF-8 invariant — entity-name bytes
1115                // are valid UTF-8 (NameChar+).
1116                let name: &str = unsafe { std::str::from_utf8_unchecked(name_bytes) };
1117                let name = b.alloc_str(name);
1118                // Literal source form `&name;` for the serializer.
1119                let lit = format!("&{name};");
1120                let content = b.alloc_str(&lit);
1121                attach_leaf(b, &stack, b.new_entity_ref(name, content), root.is_some());
1122            }
1123            BytesEvent::Eof => break,
1124        }
1125    }
1126
1127    let root_ptr = root.ok_or_else(|| XmlError::new(
1128        ErrorDomain::Parser, ErrorLevel::Fatal,
1129        "document has no root element",
1130    ))?;
1131    // SAFETY: root_ptr was erased from a node allocated in `b`; `b` is still
1132    // alive at this line.
1133    let root_ref: &Node<'_> = unsafe { unerase(root_ptr) };
1134    b.set_root(root_ref);
1135
1136    // Plumb XML declaration fields from the reader's prolog state into the
1137    // Document.  When the document had no `<?xml ... ?>` declaration the
1138    // builder's defaults ("1.0" / "UTF-8" / None) are kept.
1139    if let Some(decl) = reader.xml_decl() {
1140        b.set_version(decl.version.clone());
1141        if let Some(enc) = &decl.encoding {
1142            b.set_encoding(enc.clone());
1143        }
1144        b.set_standalone(decl.standalone);
1145    }
1146
1147    Ok(())
1148}
1149
1150// ── namespace helpers ───────────────────────────────────────────────────────
1151
1152#[inline] fn erase_ns(ns: &Namespace<'_>) -> *const () {
1153    ns as *const Namespace<'_> as *const ()
1154}
1155
1156/// # Safety
1157///
1158/// `p` must have been produced by `erase_ns` from a live `&Namespace`,
1159/// and the caller-chosen lifetime `'a` must not outlive that namespace's
1160/// arena.
1161#[inline] unsafe fn unerase_ns<'a>(p: *const ()) -> &'a Namespace<'a> {
1162    unsafe { &*(p as *const Namespace<'a>) }
1163}
1164
1165/// Find the innermost binding for `prefix` (None = default namespace).
1166/// Returns `None` if no binding is in scope.
1167fn lookup_ns<'a>(
1168    prefix:   Option<&str>,
1169    bindings: &[(Option<&'a str>, *const ())],
1170) -> Option<&'a Namespace<'a>> {
1171    for &(p, ns_ptr) in bindings.iter().rev() {
1172        if p == prefix {
1173            // SAFETY: ns_ptr was minted by erase_ns from a Namespace allocated
1174            // in the builder's arena, which is still alive while this function
1175            // runs (called from inside drive() which owns the arena).
1176            return Some(unsafe { unerase_ns(ns_ptr) });
1177        }
1178    }
1179    None
1180}
1181
1182/// Resolve a QName against the namespace scope.  Unprefixed elements use the
1183/// default namespace; unprefixed attributes never do (XML Namespaces § 6.2).
1184fn resolve_qname<'a>(
1185    qname:        &'a str,
1186    bindings:     &[(Option<&'a str>, *const ())],
1187    is_attribute: bool,
1188) -> Result<Option<&'a Namespace<'a>>> {
1189    if let Some(colon) = qname.find(':') {
1190        let prefix = &qname[..colon];
1191        match lookup_ns(Some(prefix), bindings) {
1192            Some(ns) => Ok(Some(ns)),
1193            None     => Err(ns_err(format!("undeclared namespace prefix '{prefix}' in '{qname}'"))),
1194        }
1195    } else if is_attribute {
1196        Ok(None)
1197    } else {
1198        // Unprefixed element: default namespace if declared with non-empty URI.
1199        match lookup_ns(None, bindings) {
1200            Some(ns) if !ns.href().is_empty() => Ok(Some(ns)),
1201            _                                => Ok(None),
1202        }
1203    }
1204}
1205
1206
1207/// Attach a freshly-allocated leaf node to the top-of-stack element.
1208/// When the stack is empty (prolog before the root or epilogue
1209/// after `</root>`) the leaf is recorded as a document-level
1210/// orphan instead of dropped; [`DocumentBuilder::build`] later
1211/// links it as a sibling of the root so consumers see comments /
1212/// PIs that appeared outside the document element.
1213///
1214/// `after_root` tells the builder whether this orphan goes in the
1215/// prolog (false: root not yet seen) or the epilogue (true: root
1216/// element has opened, regardless of whether it has also closed).
1217fn attach_leaf<'a>(
1218    b:           &'a DocumentBuilder,
1219    stack:       &[ErasedNodePtr],
1220    node:        &'a Node<'a>,
1221    after_root:  bool,
1222) {
1223    if let Some(&parent_ptr) = stack.last() {
1224        // SAFETY: stack invariant — pointer points into the live `b.bump`,
1225        // which is the same arena `node` lives in, so unifying lifetimes is sound.
1226        let parent: &'a Node<'a> = unsafe { unerase(parent_ptr) };
1227        b.append_child(parent, node);
1228    } else if after_root {
1229        b.attach_epilogue_orphan(node);
1230    } else {
1231        b.attach_prolog_orphan(node);
1232    }
1233}
1234
1235// Silence the unused-`ErasedAttrPtr` lint until we extend to namespace tables.
1236#[allow(dead_code)] type _AttrAlias = ErasedAttrPtr;
1237
1238// ── tests ───────────────────────────────────────────────────────────────────
1239
1240#[cfg(test)]
1241mod tests {
1242    use super::*;
1243    use sup_xml_tree::dom::NodeKind;
1244
1245    fn parse(xml: &str) -> Document {
1246        // Default `ParseOptions` has `namespace_aware: false` — most tests here
1247        // exercise the structural shape (children, attrs, kinds), so it doesn't
1248        // matter.  Namespace-specific tests use `parse_ns` instead.
1249        parse_str(xml, &ParseOptions::default()).expect("parse")
1250    }
1251
1252    /// Namespace-aware parse helper for the ns-resolution tests.
1253    fn parse_ns(xml: &str) -> Document {
1254        let opts = ParseOptions { namespace_aware: true, ..ParseOptions::default() };
1255        parse_str(xml, &opts).expect("parse")
1256    }
1257
1258    #[test]
1259    fn empty_element() {
1260        let doc = parse("<r/>");
1261        assert_eq!(doc.root().name(), "r");
1262        assert!(doc.root().children().next().is_none());
1263    }
1264
1265    // ── XML 1.0 § 3.3.3 attribute-value normalization for xmlns ──
1266
1267    /// `xmlns:b` declared as NMTOKEN: leading/trailing whitespace
1268    /// in the URI literal MUST be stripped before the URI binds.
1269    /// CDATA attributes get no such treatment.  Without normalization
1270    /// `xmlns:a="urn:x"` and `xmlns:b=" urn:x "` would bind to two
1271    /// distinct URIs even though the spec treats them as one — the
1272    /// downstream "Unique Att Spec" check would then miss real
1273    /// namespace collisions like W3C rmt-ns10-012.
1274    #[test]
1275    fn xmlns_nmtoken_value_is_stripped_before_binding() {
1276        let xml = r#"<?xml version="1.0"?>
1277<!DOCTYPE foo [
1278<!ELEMENT foo ANY>
1279<!ATTLIST foo xmlns:a CDATA   #IMPLIED
1280              xmlns:b NMTOKEN #IMPLIED>
1281]>
1282<foo xmlns:a="urn:x" xmlns:b="  urn:x  "/>"#;
1283        let doc = parse_ns(xml);
1284        let root = doc.root();
1285        // Both prefixes should now bind to the same URI string.
1286        // Walk the namespace defs and check.
1287        let nsdefs: Vec<(Option<&str>, &str)> = {
1288            #[cfg(feature = "c-abi")]
1289            { root.ns_declarations().collect() }
1290            #[cfg(not(feature = "c-abi"))]
1291            {
1292                // Without c-abi we exposed xmlns decls as attributes
1293                // instead of nsDef.  Pull from the attribute list.
1294                let mut out = Vec::new();
1295                let mut a = root.first_attribute.get();
1296                while let Some(attr) = a {
1297                    let name = attr.name();
1298                    if name == "xmlns" {
1299                        out.push((None, attr.value()));
1300                    } else if let Some(p) = name.strip_prefix("xmlns:") {
1301                        out.push((Some(p), attr.value()));
1302                    }
1303                    a = attr.next.get();
1304                }
1305                out
1306            }
1307        };
1308        let a_uri = nsdefs.iter().find(|(p, _)| *p == Some("a")).map(|(_, u)| *u);
1309        let b_uri = nsdefs.iter().find(|(p, _)| *p == Some("b")).map(|(_, u)| *u);
1310        assert_eq!(a_uri, Some("urn:x"));
1311        assert_eq!(b_uri, Some("urn:x"),
1312            "NMTOKEN normalization should strip whitespace; got {b_uri:?}");
1313    }
1314
1315    /// CDATA-typed xmlns (the default when no ATTLIST covers it):
1316    /// whitespace inside the value is preserved.  This is the spec
1317    /// behaviour — without an `<!ATTLIST>` redeclaring the type the
1318    /// raw value goes through.  Guards against over-normalization.
1319    #[test]
1320    fn xmlns_cdata_value_is_not_stripped() {
1321        let xml = r#"<r xmlns:b="  urn:x  "/>"#;
1322        let doc = parse_ns(xml);
1323        let root = doc.root();
1324        let b_uri = {
1325            #[cfg(feature = "c-abi")]
1326            { root.ns_declarations().find(|(p, _)| *p == Some("b")).map(|(_, u)| u) }
1327            #[cfg(not(feature = "c-abi"))]
1328            {
1329                let mut a = root.first_attribute.get();
1330                let mut found = None;
1331                while let Some(attr) = a {
1332                    if attr.name() == "xmlns:b" { found = Some(attr.value()); break; }
1333                    a = attr.next.get();
1334                }
1335                found
1336            }
1337        };
1338        assert_eq!(b_uri, Some("  urn:x  "),
1339            "no ATTLIST → no non-CDATA normalization, URI must stay verbatim");
1340    }
1341
1342    // ── XML Namespaces 1.0 § 6.3 "Unique Att Spec" ──
1343
1344    /// Two prefixes binding to the SAME namespace URI on the same
1345    /// element make `a:attr` and `b:attr` resolve to the same
1346    /// expanded name `{URI}attr` — must be rejected.  Catches the
1347    /// straightforward (same-element) collision.
1348    #[test]
1349    fn duplicate_expanded_attribute_name_rejected() {
1350        let xml = r#"<r xmlns:a="urn:x" xmlns:b="urn:x" a:attr="1" b:attr="2"/>"#;
1351        let err = parse_str(xml, &ParseOptions { namespace_aware: true, ..ParseOptions::default() })
1352            .expect_err("two prefixes binding the same URI with same local must be rejected");
1353        let msg = err.to_string().to_lowercase();
1354        assert!(msg.contains("duplicate") && (msg.contains("namespace") || msg.contains("attribute")),
1355            "expected duplicate-attribute message, got: {err}");
1356    }
1357
1358    /// The same collision but the xmlns declarations live on a
1359    /// parent element — collision still detected because the
1360    /// namespace resolver walks the full in-scope binding stack.
1361    /// Mirrors W3C rmt-ns10-012's structural shape (xmlns on
1362    /// outer, prefixed attrs on inner) without the DTD-driven
1363    /// normalization layer.
1364    #[test]
1365    fn duplicate_expanded_attribute_name_rejected_across_elements() {
1366        let xml = r#"<f xmlns:a="urn:x" xmlns:b="urn:x"><g a:attr="1" b:attr="2"/></f>"#;
1367        let err = parse_str(xml, &ParseOptions { namespace_aware: true, ..ParseOptions::default() })
1368            .expect_err("inner-element collision must be caught through inherited bindings");
1369        let msg = err.to_string().to_lowercase();
1370        assert!(msg.contains("duplicate"),
1371            "expected duplicate-attribute message, got: {err}");
1372    }
1373
1374    /// The combined case — DTD-aware normalization + namespace-
1375    /// aware uniqueness must both fire to catch this.  This is
1376    /// exactly W3C rmt-ns10-012's input shape, distilled to the
1377    /// minimum needed to flip the test verdict.  Drops the W3C
1378    /// suite's external machinery so the failure mode is testable
1379    /// from inside the core crate without the harness.
1380    #[test]
1381    fn nmtoken_normalization_unlocks_ns_uniqueness_collision() {
1382        let xml = r#"<?xml version="1.0"?>
1383<!DOCTYPE foo [
1384<!ELEMENT foo ANY>
1385<!ATTLIST foo xmlns:a CDATA   #IMPLIED
1386              xmlns:b NMTOKEN #IMPLIED>
1387<!ELEMENT bar ANY>
1388<!ATTLIST bar a:attr CDATA #IMPLIED
1389              b:attr CDATA #IMPLIED>
1390]>
1391<foo xmlns:a="urn:x" xmlns:b=" urn:x ">
1392<bar a:attr="1" b:attr="2"/>
1393</foo>"#;
1394        let err = parse_str(xml, &ParseOptions { namespace_aware: true, ..ParseOptions::default() })
1395            .expect_err("rmt-ns10-012 minimum repro must be rejected once NMTOKEN normalization \
1396                         strips the whitespace from xmlns:b's value");
1397        let msg = err.to_string().to_lowercase();
1398        assert!(msg.contains("duplicate"),
1399            "expected duplicate-after-expansion error, got: {err}");
1400    }
1401
1402    /// XML 1.0 § 3.3.3 attribute-value normalization for a regular
1403    /// (non-xmlns) NMTOKEN attribute: leading/trailing whitespace
1404    /// stripped, internal runs collapsed.  Confirms the normalization
1405    /// helper applies to ALL non-CDATA attributes, not just xmlns:*.
1406    #[test]
1407    fn nmtoken_attribute_value_is_normalized() {
1408        let xml = r#"<?xml version="1.0"?>
1409<!DOCTYPE r [
1410<!ELEMENT r EMPTY>
1411<!ATTLIST r kind NMTOKEN #IMPLIED>
1412]>
1413<r kind="  alpha  "/>"#;
1414        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1415        let kind = doc.root().attributes()
1416            .find(|a| a.name() == "kind")
1417            .map(|a| a.value());
1418        assert_eq!(kind, Some("alpha"),
1419            "NMTOKEN value should be stripped; got {kind:?}");
1420    }
1421
1422    /// Same rule for ID-typed attributes — internal whitespace
1423    /// collapses too.  Without normalization, two `id` values that
1424    /// differ only by whitespace would compare unequal under
1425    /// `getElementById` etc.
1426    #[test]
1427    fn id_attribute_value_is_normalized_collapsed() {
1428        let xml = r#"<?xml version="1.0"?>
1429<!DOCTYPE r [
1430<!ELEMENT r EMPTY>
1431<!ATTLIST r tag ID #IMPLIED>
1432]>
1433<r tag="  a   b   c  "/>"#;
1434        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1435        let tag = doc.root().attributes()
1436            .find(|a| a.name() == "tag")
1437            .map(|a| a.value());
1438        assert_eq!(tag, Some("a b c"),
1439            "ID value should strip + collapse; got {tag:?}");
1440    }
1441
1442    /// CDATA-typed attribute (the default when no ATTLIST covers it)
1443    /// preserves whitespace verbatim — guards against
1444    /// over-normalization of CDATA-shaped values.
1445    #[test]
1446    fn cdata_attribute_value_is_not_stripped() {
1447        // No ATTLIST → defaults to CDATA → no non-CDATA stripping.
1448        let xml = r#"<r tag="  a   b  "/>"#;
1449        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1450        let tag = doc.root().attributes()
1451            .find(|a| a.name() == "tag")
1452            .map(|a| a.value());
1453        assert_eq!(tag, Some("  a   b  "),
1454            "CDATA-defaulted value must stay verbatim; got {tag:?}");
1455    }
1456
1457    /// Enumeration-typed attribute: stripped before matching the
1458    /// enum.  Without this, `<r kind=" yes ">` would silently fail
1459    /// even though the declared enum is `(yes|no)` — and we'd quietly
1460    /// accept the un-matched value instead of validating against it.
1461    #[test]
1462    fn enumeration_attribute_value_is_normalized() {
1463        let xml = r#"<?xml version="1.0"?>
1464<!DOCTYPE r [
1465<!ELEMENT r EMPTY>
1466<!ATTLIST r kind (yes|no) #IMPLIED>
1467]>
1468<r kind="  yes  "/>"#;
1469        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1470        let kind = doc.root().attributes()
1471            .find(|a| a.name() == "kind")
1472            .map(|a| a.value());
1473        assert_eq!(kind, Some("yes"));
1474    }
1475
1476    // ── resolve_entities = false (EntityRef node kind) ──
1477
1478    /// Default: entity references expand inline into Text.  Confirms
1479    /// the baseline behaviour the new flag opts out of.
1480    #[test]
1481    fn resolve_entities_default_expands_inline() {
1482        let xml = r#"<?xml version="1.0"?>
1483<!DOCTYPE r [<!ENTITY hi "hello">]>
1484<r>&hi;</r>"#;
1485        let doc = parse_str(xml, &ParseOptions::default()).expect("parse");
1486        let root = doc.root();
1487        // Should have a single Text child "hello", no EntityRef.
1488        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1489        assert_eq!(kinds, vec![NodeKind::Text]);
1490        let text = root.children().next().unwrap().content();
1491        assert_eq!(text, "hello");
1492    }
1493
1494    /// `resolve_entities = false` preserves user-defined references
1495    /// as `NodeKind::EntityRef` nodes whose `name` is the entity
1496    /// name and whose `content` round-trips to `&name;` source.
1497    #[test]
1498    fn resolve_entities_false_emits_entity_ref_node() {
1499        let xml = r#"<?xml version="1.0"?>
1500<!DOCTYPE r [<!ENTITY hi "hello">]>
1501<r>before &hi; after</r>"#;
1502        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1503        let doc = parse_str(xml, &opts).expect("parse");
1504        let root = doc.root();
1505        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1506        assert_eq!(
1507            kinds,
1508            vec![NodeKind::Text, NodeKind::EntityRef, NodeKind::Text],
1509            "expected Text-EntityRef-Text triple, got {kinds:?}"
1510        );
1511        // The middle child carries the entity name + literal form.
1512        let ref_node = root.children().nth(1).unwrap();
1513        assert_eq!(ref_node.name(),    "hi");
1514        assert_eq!(ref_node.content(), "&hi;");
1515    }
1516
1517    /// Predefined entities always expand regardless of the flag —
1518    /// they're part of the character data production, not the
1519    /// entity-reference machinery.
1520    #[test]
1521    fn resolve_entities_false_still_expands_predefined() {
1522        let xml = r#"<r>a &amp; b &lt; c</r>"#;
1523        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1524        let doc = parse_str(xml, &opts).expect("parse");
1525        let root = doc.root();
1526        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1527        assert_eq!(kinds, vec![NodeKind::Text],
1528            "predefined entities must expand inline, got {kinds:?}");
1529        assert_eq!(root.children().next().unwrap().content(), "a & b < c");
1530    }
1531
1532    /// Numeric character references always expand inline too.
1533    #[test]
1534    fn resolve_entities_false_still_expands_numeric() {
1535        let xml = r#"<r>before &#65; after</r>"#;
1536        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1537        let doc = parse_str(xml, &opts).expect("parse");
1538        let root = doc.root();
1539        let kinds: Vec<NodeKind> = root.children().map(|c| c.kind).collect();
1540        assert_eq!(kinds, vec![NodeKind::Text]);
1541        assert_eq!(root.children().next().unwrap().content(), "before A after");
1542    }
1543
1544    /// Round-trip: serialize a tree with EntityRef nodes back to
1545    /// source.  The literal `&name;` form is preserved.
1546    #[test]
1547    fn resolve_entities_false_round_trips_through_serializer() {
1548        let xml = r#"<r>x &hi; y</r>"#;
1549        let opts = ParseOptions { resolve_entities: false, ..ParseOptions::default() };
1550        let doc = parse_str(xml, &opts).expect("parse");
1551        let out = crate::serialize_to_string(&doc);
1552        assert!(out.contains("&hi;"),
1553            "EntityRef should serialize as `&hi;`, got: {out}");
1554    }
1555
1556    /// Edition guard: without `namespace_aware`, lexical names rule.
1557    /// `a:attr` and `b:attr` are different names → accepted.  Same
1558    /// input as the previous tests, but the namespace pass doesn't
1559    /// run.  Confirms the new check is properly gated.
1560    #[test]
1561    fn duplicate_expanded_name_accepted_when_namespace_blind() {
1562        let xml = r#"<r xmlns:a="urn:x" xmlns:b="urn:x" a:attr="1" b:attr="2"/>"#;
1563        // namespace_aware = false (the default).
1564        parse_str(xml, &ParseOptions::default())
1565            .expect("namespace-blind parse must accept lexically-distinct attribute names");
1566    }
1567
1568    #[test]
1569    fn line_numbers_basic() {
1570        // line 1: <r>
1571        // line 2:   <a/>
1572        // line 3:   <b/>
1573        // line 4: </r>
1574        let doc = parse("<r>\n  <a/>\n  <b/>\n</r>");
1575        let root = doc.root();
1576        let a = root.children().find(|n| n.is_element()).unwrap();
1577        let b = a.next_sibling.get().and_then(|n|
1578            if n.is_element() { Some(n) } else { n.next_sibling.get() }
1579        ).unwrap();
1580
1581        assert_eq!(root.line, 1, "root <r> on line 1");
1582        assert_eq!(a.line,    2, "<a/> on line 2");
1583        assert_eq!(b.line,    3, "<b/> on line 3");
1584    }
1585
1586    /// Regression guard: a previous implementation called
1587    /// `scanner::compute_line_col` once per StartElement, which rescans
1588    /// `src[0..name_offset]` from byte 0 each call — O(N × file_size).
1589    /// On docs with many elements this slowed the parser by 10×–100×.
1590    /// The current implementation maintains an incremental cursor in
1591    /// `drive()`; this test exercises it across many lines to make sure
1592    /// the cursor stays in sync.
1593    #[test]
1594    fn line_numbers_many_elements() {
1595        // 300 elements, one per line.  Each <e i="N"/> on line N+1
1596        // (the <root> opener is line 1).
1597        let mut src = String::from("<root>\n");
1598        let n = 300u32;
1599        for i in 0..n {
1600            src.push_str(&format!("  <e i=\"{i}\"/>\n"));
1601        }
1602        src.push_str("</root>\n");
1603
1604        let doc = parse(&src);
1605        assert_eq!(doc.root().line, 1);
1606
1607        let mut expected: u32 = 2;
1608        let mut walked = 0u32;
1609        for child in doc.root().children().filter(|n| n.is_element()) {
1610            // line is u16 in c-abi mode, u32 in lean — both fit < 65535 here.
1611            assert_eq!(child.line as u32, expected,
1612                "child #{walked}: expected line {expected}, got {}", child.line);
1613            walked += 1;
1614            expected += 1;
1615        }
1616        assert_eq!(walked, n);
1617    }
1618
1619    #[test]
1620    fn nested_elements() {
1621        let doc = parse("<a><b><c/></b></a>");
1622        let a = doc.root();
1623        assert_eq!(a.name(), "a");
1624        let b = a.children().next().unwrap();
1625        assert_eq!(b.name(), "b");
1626        let c = b.children().next().unwrap();
1627        assert_eq!(c.name(), "c");
1628        // parent pointers
1629        assert!(std::ptr::eq(c.parent.get().unwrap(), b));
1630        assert!(std::ptr::eq(b.parent.get().unwrap(), a));
1631    }
1632
1633    #[test]
1634    fn attributes_in_order() {
1635        let doc = parse(r#"<el id="1" class="x" data-y="42"/>"#);
1636        let pairs: Vec<(&str, &str)> = doc.root().attributes()
1637            .map(|a| (a.name(), a.value()))
1638            .collect();
1639        assert_eq!(pairs, vec![("id", "1"), ("class", "x"), ("data-y", "42")]);
1640    }
1641
1642    #[test]
1643    fn mixed_content() {
1644        let doc = parse("<r>before<!-- c --><b>x</b><![CDATA[<raw>]]>after</r>");
1645        let kinds: Vec<NodeKind> = doc.root().children().map(|c| c.kind).collect();
1646        assert_eq!(kinds, vec![
1647            NodeKind::Text, NodeKind::Comment, NodeKind::Element,
1648            NodeKind::CData, NodeKind::Text,
1649        ]);
1650    }
1651
1652    #[test]
1653    fn pi_inside_element() {
1654        let doc = parse(r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
1655        let pi = doc.root().children().next().unwrap();
1656        assert_eq!(pi.kind, NodeKind::Pi);
1657        assert_eq!(pi.name(), "xml-stylesheet");
1658        assert_eq!(pi.content(), r#"href="s.xsl""#);
1659    }
1660
1661    #[test]
1662    fn entity_in_text_is_expanded() {
1663        let doc = parse("<r>a&amp;b&lt;c</r>");
1664        let t = doc.root().children().next().unwrap();
1665        assert_eq!(t.kind, NodeKind::Text);
1666        assert_eq!(t.content(), "a&b<c");
1667    }
1668
1669    #[test]
1670    fn entity_in_attr_is_expanded() {
1671        let doc = parse(r#"<r v="a&amp;b"/>"#);
1672        let v = doc.root().attributes().next().unwrap();
1673        assert_eq!(v.value(), "a&b");
1674    }
1675
1676    #[test]
1677    fn deeply_nested_doc() {
1678        let mut xml = String::new();
1679        for _ in 0..50 { xml.push_str("<n>"); }
1680        xml.push_str("hello");
1681        for _ in 0..50 { xml.push_str("</n>"); }
1682        let doc = parse(&xml);
1683        let mut cur = doc.root();
1684        let mut depth = 1;
1685        while let Some(c) = cur.children().next() {
1686            if c.kind == NodeKind::Element { cur = c; depth += 1; } else { break; }
1687        }
1688        assert_eq!(depth, 50);
1689        assert_eq!(cur.children().next().unwrap().content(), "hello");
1690    }
1691
1692    #[test]
1693    fn root_lifetime_keeps_tree_alive() {
1694        let doc = parse("<r><a><b>x</b></a></r>");
1695        let a = doc.root().children().next().unwrap();
1696        let b = a.children().next().unwrap();
1697        assert_eq!(b.text_content(), Some("x"));
1698    }
1699
1700    #[test]
1701    fn errors_propagate_from_reader() {
1702        let err = parse_str("<r><a></b></r>", &ParseOptions::default());
1703        assert!(err.is_err());
1704    }
1705
1706    #[test]
1707    fn missing_root_returns_error() {
1708        // An empty document fails earlier (XML decl alone) — use whitespace-only.
1709        let r = parse_str("   ", &ParseOptions::default());
1710        assert!(r.is_err());
1711    }
1712
1713    // ── namespace tests ────────────────────────────────────────────────
1714
1715    #[test]
1716    fn no_namespaces_unchanged() {
1717        let doc = parse_ns("<root><child/></root>");
1718        assert!(doc.root().namespace.get().is_none());
1719    }
1720
1721    #[test]
1722    fn default_namespace_applied_to_element() {
1723        let doc = parse_ns(r#"<root xmlns="http://example.com/"/>"#);
1724        let ns = doc.root().namespace.get().unwrap();
1725        assert!(ns.prefix.is_none());
1726        assert_eq!(ns.href(), "http://example.com/");
1727    }
1728
1729    #[test]
1730    fn default_namespace_not_applied_to_attr() {
1731        let doc = parse_ns(r#"<root xmlns="http://example.com/" id="1"/>"#);
1732        let id_attr = doc.root().attributes().find(|a| a.name() == "id").unwrap();
1733        assert!(id_attr.namespace.get().is_none(), "default ns must not apply to unprefixed attrs");
1734    }
1735
1736    #[test]
1737    fn prefixed_element_resolved() {
1738        let doc = parse_ns(r#"<dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">X</dc:title>"#);
1739        let ns = doc.root().namespace.get().unwrap();
1740        assert_eq!(ns.prefix(), Some("dc"));
1741        assert_eq!(ns.href(),   "http://purl.org/dc/elements/1.1/");
1742    }
1743
1744    #[test]
1745    fn prefixed_attribute_resolved() {
1746        let doc = parse_ns(
1747            r#"<root xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:nil="true"/>"#
1748        );
1749        // Match by (local-name, prefix): `name()` is the full QName on
1750        // the lean build but the local part under c-abi.
1751        let nil = doc.root().attributes()
1752            .find(|a| a.local_name() == "nil"
1753                   && a.namespace.get().and_then(|n| n.prefix()) == Some("xsi"))
1754            .unwrap();
1755        assert_eq!(nil.namespace.get().unwrap().href(),
1756                   "http://www.w3.org/2001/XMLSchema-instance");
1757    }
1758
1759    #[test]
1760    fn xml_prefix_builtin() {
1761        let doc = parse_ns(r#"<root xml:lang="en"/>"#);
1762        let lang = doc.root().attributes()
1763            .find(|a| a.local_name() == "lang"
1764                   && a.namespace.get().and_then(|n| n.prefix()) == Some("xml"))
1765            .unwrap();
1766        assert_eq!(lang.namespace.get().unwrap().href(),
1767                   "http://www.w3.org/XML/1998/namespace");
1768    }
1769
1770    #[test]
1771    fn nested_prefix_scope_inherits_outer() {
1772        let doc = parse_ns(r#"
1773            <root xmlns:a="http://a.com/">
1774                <a:child xmlns:b="http://b.com/">
1775                    <b:leaf/>
1776                </a:child>
1777            </root>
1778        "#);
1779        let root = doc.root();
1780        assert!(root.namespace.get().is_none());
1781        // c-abi mode stores `name` as the local part only (libxml2
1782        // convention); the lean build keeps the full QName.
1783        #[cfg(feature = "c-abi")]
1784        let child_local = "child";
1785        #[cfg(not(feature = "c-abi"))]
1786        let child_local = "a:child";
1787        let child = root.children().find(|c| c.is_element() && c.name() == child_local).unwrap();
1788        assert_eq!(child.namespace.get().unwrap().href(), "http://a.com/");
1789        let leaf = child.children().find(|c| c.is_element()).unwrap();
1790        assert_eq!(leaf.namespace.get().unwrap().href(), "http://b.com/");
1791    }
1792
1793    #[test]
1794    fn undeclared_prefix_is_error() {
1795        let r = parse_str("<dc:title>X</dc:title>", &ParseOptions { namespace_aware: true, ..ParseOptions::default() });
1796        let msg = r.unwrap_err().to_string();
1797        assert!(msg.contains("undeclared") || msg.contains("prefix"), "{msg}");
1798    }
1799
1800    #[test]
1801    fn default_namespace_override_in_child() {
1802        let doc = parse_ns(r#"
1803            <root xmlns="http://outer.com/">
1804                <inner xmlns="http://inner.com/"/>
1805            </root>
1806        "#);
1807        assert_eq!(doc.root().namespace.get().unwrap().href(), "http://outer.com/");
1808        let inner = doc.root().children().find(|c| c.is_element()).unwrap();
1809        assert_eq!(inner.namespace.get().unwrap().href(), "http://inner.com/");
1810    }
1811
1812    #[test]
1813    fn undeclare_default_namespace() {
1814        let doc = parse_ns(r#"
1815            <root xmlns="http://example.com/">
1816                <child xmlns=""/>
1817            </root>
1818        "#);
1819        assert!(doc.root().namespace.get().is_some());
1820        let child = doc.root().children().find(|c| c.is_element()).unwrap();
1821        assert!(child.namespace.get().is_none(), "xmlns='' should clear the default namespace");
1822    }
1823
1824    #[test]
1825    fn xmlns_prefix_cannot_be_declared() {
1826        let r = parse_str(
1827            r#"<r xmlns:xmlns="http://x.com/"/>"#,
1828            &ParseOptions { namespace_aware: true, ..ParseOptions::default() },
1829        );
1830        assert!(r.is_err());
1831    }
1832
1833    #[test]
1834    fn duplicate_attribute_after_ns_expansion() {
1835        // Two prefixed attrs that expand to the same (ns, local) pair.
1836        let r = parse_str(
1837            r#"<r xmlns:a="http://x.com/" xmlns:b="http://x.com/" a:id="1" b:id="2"/>"#,
1838            &ParseOptions { namespace_aware: true, ..ParseOptions::default() },
1839        );
1840        assert!(r.is_err(), "duplicate expanded attribute name must be rejected");
1841    }
1842
1843    #[test]
1844    fn deep_nesting_pops_scope_on_close() {
1845        // After nesting popped back to root level, an inner prefix becomes undeclared.
1846        let r = parse_str(r#"
1847            <root>
1848                <a xmlns:p="http://x.com/"><p:leaf/></a>
1849                <p:bad/>
1850            </root>
1851        "#, &ParseOptions { namespace_aware: true, ..ParseOptions::default() });
1852        assert!(r.is_err());
1853    }
1854
1855    #[test]
1856    fn xml_decl_fields_are_captured() {
1857        let doc = parse(r#"<?xml version="1.1" encoding="ISO-8859-1" standalone="yes"?><r/>"#);
1858        assert_eq!(doc.version,    "1.1");
1859        assert_eq!(doc.encoding,   "ISO-8859-1");
1860        assert_eq!(doc.standalone, Some(true));
1861    }
1862
1863    #[test]
1864    fn xml_decl_defaults_when_absent() {
1865        // No `<?xml … ?>` declaration → encoding stays empty
1866        // (matches libxml2's NULL doc->encoding) so serializers
1867        // omit the encoding attribute on output.
1868        let doc = parse("<r/>");
1869        assert_eq!(doc.version,    "1.0");
1870        assert_eq!(doc.encoding,   "");
1871        assert_eq!(doc.standalone, None);
1872    }
1873
1874    #[test]
1875    fn xml_decl_partial_keeps_encoding_default() {
1876        // Only version present — encoding stays empty (no
1877        // declaration to copy), standalone absent.
1878        let doc = parse(r#"<?xml version="1.0"?><r/>"#);
1879        assert_eq!(doc.version,    "1.0");
1880        assert_eq!(doc.encoding,   "");
1881        assert_eq!(doc.standalone, None);
1882    }
1883
1884    #[test]
1885    fn xml_decl_standalone_no_is_captured() {
1886        // Note: standalone without encoding is rejected by both legacy
1887        // and arena parsers (pre-existing behaviour) — include encoding.
1888        let doc = parse(r#"<?xml version="1.0" encoding="UTF-8" standalone="no"?><r/>"#);
1889        assert_eq!(doc.standalone, Some(false));
1890    }
1891
1892    #[test]
1893    fn many_siblings_preserve_order() {
1894        let mut xml = String::from("<r>");
1895        for i in 0..100 {
1896            xml.push_str(&format!("<i>{i}</i>"));
1897        }
1898        xml.push_str("</r>");
1899        let doc = parse(&xml);
1900        let texts: Vec<&str> = doc.root().children()
1901            .filter(|c| c.kind == NodeKind::Element)
1902            .map(|c| c.text_content().unwrap_or(""))
1903            .collect();
1904        assert_eq!(texts.len(), 100);
1905        assert_eq!(texts[0],  "0");
1906        assert_eq!(texts[99], "99");
1907    }
1908
1909    // ── parse_bytes_in_place — entry point + call-time gates ──────
1910
1911    #[test]
1912    fn in_place_parses_basic_document() {
1913        let buf = b"<root><child id=\"1\">hello</child></root>".to_vec();
1914        let doc = parse_bytes_in_place(buf, &ParseOptions::default())
1915            .expect("in-place parse should succeed on well-formed input");
1916        assert_eq!(doc.root().name(), "root");
1917        let child = doc.root().children().next().expect("child element");
1918        assert_eq!(child.name(), "child");
1919        assert_eq!(child.attributes().next().unwrap().value(), "1");
1920    }
1921
1922    #[test]
1923    fn in_place_rejects_recovery_mode_at_call_time() {
1924        let buf = b"<r/>".to_vec();
1925        let opts = ParseOptions { recovery_mode: true, ..ParseOptions::default() };
1926        let err = parse_bytes_in_place(buf, &opts)
1927            .expect_err("recovery_mode=true must be rejected up front");
1928        assert!(
1929            err.message.contains("recovery_mode"),
1930            "error should mention recovery_mode, got: {}", err.message,
1931        );
1932    }
1933
1934    #[test]
1935    fn in_place_transcodes_non_utf8_when_auto_transcode_on() {
1936        // ISO-8859-1 with `<?xml encoding="ISO-8859-1"?>` and one non-ASCII
1937        // byte (0xE9 = 'é').  Transcoded to UTF-8 upfront, then parsed.
1938        let buf: Vec<u8> =
1939            b"<?xml version=\"1.0\" encoding=\"ISO-8859-1\"?><r>caf\xe9</r>".to_vec();
1940        let opts = ParseOptions { auto_transcode: true, ..ParseOptions::default() };
1941        let doc = parse_bytes_in_place(buf, &opts).expect("transcoded parse should succeed");
1942        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
1943        assert_eq!(text, "café");
1944    }
1945
1946    #[test]
1947    fn in_place_rejects_invalid_utf8_when_auto_transcode_off() {
1948        let buf: Vec<u8> = b"<r>\xff</r>".to_vec();
1949        let opts = ParseOptions { auto_transcode: false, ..ParseOptions::default() };
1950        let err = parse_bytes_in_place(buf, &opts).expect_err("invalid UTF-8 must be rejected");
1951        assert_eq!(err.domain, crate::error::ErrorDomain::Encoding);
1952    }
1953
1954    // ── simdutf8 ⇄ std::str::from_utf8 equivalence ───────────────
1955    //
1956    // The input-validation gates (`parse_bytes_in_place`,
1957    // `transcode_and_validate`, `XmlBytesReader::from_bytes`) use
1958    // `simdutf8::compat::from_utf8` as a drop-in for `std::str::from_utf8`.
1959    // The whole correctness claim is that it is *behaviorally identical*:
1960    // same accept/reject verdict, and on rejection the same `valid_up_to()`
1961    // and `error_len()` — the parser pins error line/col to `valid_up_to()`,
1962    // so any divergence would silently shift reported error positions.
1963    // These tests pin that equivalence against `std` as the oracle.
1964
1965    /// Reduce a validation outcome to a comparable shape: `Ok(())` when
1966    /// valid, or the error's `(valid_up_to, error_len)` when not.
1967    fn std_verdict(b: &[u8]) -> std::result::Result<(), (usize, Option<usize>)> {
1968        std::str::from_utf8(b)
1969            .map(|_| ())
1970            .map_err(|e| (e.valid_up_to(), e.error_len()))
1971    }
1972
1973    fn simd_verdict(b: &[u8]) -> std::result::Result<(), (usize, Option<usize>)> {
1974        simdutf8::compat::from_utf8(b)
1975            .map(|_| ())
1976            .map_err(|e| (e.valid_up_to(), e.error_len()))
1977    }
1978
1979    #[test]
1980    fn simdutf8_matches_std_at_chunk_boundaries() {
1981        // SIMD validators process vector-width chunks (16/32/64 bytes) then a
1982        // scalar tail; bugs hide where a malformed byte straddles that seam.
1983        // Sweep a lone 0xFF (never valid UTF-8) across every offset of buffers
1984        // sized around the common vector widths, plus the clean buffer.
1985        for len in [0usize, 1, 7, 8, 9, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 129] {
1986            let base = vec![b'a'; len];
1987            assert_eq!(std_verdict(&base), simd_verdict(&base), "clean buffer len {len}");
1988            for pos in 0..len {
1989                let mut bad = base.clone();
1990                bad[pos] = 0xFF;
1991                assert_eq!(
1992                    std_verdict(&bad), simd_verdict(&bad),
1993                    "diverged: lone 0xFF at offset {pos} in len {len}",
1994                );
1995            }
1996        }
1997    }
1998
1999    #[test]
2000    fn simdutf8_matches_std_on_adversarial_utf8() {
2001        // The classic failure modes for hand-rolled UTF-8 validators: truncated
2002        // multibyte sequences, overlong encodings, surrogate code points, lone
2003        // continuation bytes, and valid→invalid transitions mid-stream.
2004        let cases: &[&[u8]] = &[
2005            b"",
2006            b"hello",
2007            &[0xC3, 0xA9],                          // é — valid 2-byte
2008            &[0xC3],                                // truncated 2-byte lead
2009            &[0xE2, 0x82, 0xAC],                    // € — valid 3-byte
2010            &[0xE2, 0x82],                          // truncated 3-byte
2011            &[0xF0, 0x9F, 0x98, 0x80],              // 😀 — valid 4-byte
2012            &[0xF0, 0x9F, 0x98],                    // truncated 4-byte
2013            &[0x80],                                // lone continuation
2014            &[0xBF],                                // lone continuation
2015            &[0xC0, 0x80],                          // overlong NUL
2016            &[0xE0, 0x80, 0x80],                    // overlong 3-byte
2017            &[0xF0, 0x80, 0x80, 0x80],              // overlong 4-byte
2018            &[0xED, 0xA0, 0x80],                    // lone high surrogate U+D800
2019            &[0xED, 0xBF, 0xBF],                    // lone low surrogate U+DFFF
2020            &[0xF4, 0x90, 0x80, 0x80],              // above U+10FFFF
2021            &[0xFF],
2022            &[0xFE],
2023            b"caf\xe9",                             // Latin-1 é — invalid as UTF-8
2024            &[b'o', b'k', 0xC3, 0xA9, 0xFF, b'x'],  // valid run then invalid byte
2025        ];
2026        for c in cases {
2027            assert_eq!(std_verdict(c), simd_verdict(c), "diverged on {c:02x?}");
2028        }
2029    }
2030
2031    #[test]
2032    fn simdutf8_matches_std_on_random_bytes() {
2033        // Deterministic xorshift corpus — no rng/clock dependency, so failures
2034        // reproduce exactly.  Bias one byte in four into the 0x80..=0xFF range
2035        // so lead/continuation logic gets exercised, not just ASCII runs.
2036        let mut state = 0x9E3779B97F4A7C15u64;
2037        let mut next = move || {
2038            state ^= state << 13;
2039            state ^= state >> 7;
2040            state ^= state << 17;
2041            state
2042        };
2043        for _ in 0..20_000 {
2044            let len = (next() % 70) as usize;
2045            let mut buf = Vec::with_capacity(len);
2046            for _ in 0..len {
2047                let r = next();
2048                let byte = if r & 3 == 0 {
2049                    (r >> 8) as u8 | 0x80
2050                } else {
2051                    (r >> 8) as u8 & 0x7F
2052                };
2053                buf.push(byte);
2054            }
2055            assert_eq!(std_verdict(&buf), simd_verdict(&buf), "diverged on {buf:02x?}");
2056        }
2057    }
2058
2059    #[test]
2060    fn in_place_decodes_builtin_entities() {
2061        // Slow-path exercise: text content with `&amp;` triggers entity
2062        // expansion in the reader.  In-place mode mutates the source
2063        // buffer (overwriting `&amp;` with `&`) and emits the text as
2064        // Cow::Borrowed pointing into the now-mutated buffer.  The arena
2065        // parser then takes the alloc_str_borrow zero-copy path.
2066        let buf = b"<r>tom &amp; jerry</r>".to_vec();
2067        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2068        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2069        assert_eq!(text, "tom & jerry");
2070    }
2071
2072    #[test]
2073    fn in_place_decodes_multiple_builtin_entities() {
2074        let buf = b"<r>&lt;hello&gt; &amp; &quot;hi&quot;</r>".to_vec();
2075        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2076        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2077        assert_eq!(text, r#"<hello> & "hi""#);
2078    }
2079
2080    #[test]
2081    fn in_place_text_without_entities_works() {
2082        // Fast path (Cow::Borrowed straight from reader, no slow path).
2083        let buf = b"<r>plain text with no entities</r>".to_vec();
2084        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2085        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2086        assert_eq!(text, "plain text with no entities");
2087    }
2088
2089    #[test]
2090    fn in_place_accepts_user_entity_that_shrinks() {
2091        // `&x;` (4 source bytes) expands to "AB" (2 bytes) — fits.
2092        let buf = br#"<!DOCTYPE r [<!ENTITY x "AB">]><r>&x;</r>"#.to_vec();
2093        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2094        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2095        assert_eq!(text, "AB");
2096    }
2097
2098    #[test]
2099    fn in_place_rejects_user_entity_that_grows() {
2100        // `&hi;` (5 source bytes) tries to expand to "Hello, World!" (13 bytes).
2101        // Doesn't fit → error at use site with a clear message.
2102        let buf = br#"<!DOCTYPE r [<!ENTITY hi "Hello, World!">]><r>&hi;</r>"#.to_vec();
2103        let err = parse_bytes_in_place(buf, &ParseOptions::default()).expect_err(
2104            "expansion bigger than reference must be rejected in in-place mode",
2105        );
2106        assert!(
2107            err.message.contains("exceeds source span")
2108                || err.message.contains("expansion"),
2109            "error should explain the expansion mismatch, got: {}", err.message,
2110        );
2111    }
2112
2113    #[test]
2114    fn in_place_numeric_char_refs_work() {
2115        // `&#xC9;` (6 source bytes) → `É` (2 UTF-8 bytes) — fits.
2116        // `&#x1D400;` (9 source bytes) → `𝐀` (4 UTF-8 bytes) — fits.
2117        let buf = "<r>caf&#xC9; and &#x1D400;</r>".as_bytes().to_vec();
2118        let doc = parse_bytes_in_place(buf, &ParseOptions::default()).expect("parse");
2119        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2120        assert_eq!(text, "cafÉ and 𝐀");
2121    }
2122
2123    #[test]
2124    fn in_place_rejects_recursive_entity() {
2125        // Direct recursion — same well-formedness error as parse_bytes
2126        // (XML 1.0 § 4.1).  The expansion stack catches it before any
2127        // in-place mutation happens.
2128        let buf = br#"<!DOCTYPE r [<!ENTITY a "&a;">]><r>&a;</r>"#.to_vec();
2129        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2130            .expect_err("recursive entity must be rejected");
2131        let msg = err.message.to_lowercase();
2132        assert!(
2133            msg.contains("recurs") || msg.contains("cycle"),
2134            "error should mention recursion/cycle, got: {}", err.message,
2135        );
2136    }
2137
2138    // ── parse_bytes_in_place — flag honoring ────────────────────────────────
2139    //
2140    // These tests pin down a behavioral contract: `parse_bytes_in_place`
2141    // honors every flag on the caller's `ParseOptions` as-is.  It does
2142    // NOT silently flip the `skip_*` validation flags on (that override
2143    // used to exist; was removed deliberately so callers control the
2144    // validation / speed tradeoff).  Each test pair below verifies one
2145    // flag:
2146    //   - default `ParseOptions` (flag is `false`) → input rejected
2147    //   - the same input with the flag set `true`  → input accepted
2148    // If either half of a pair flips, something has reintroduced the
2149    // override or weakened the validator.
2150
2151    /// XML 1.0 § 2.3 [4]: names start with a letter, `_`, or `:` (or
2152    /// non-ASCII NameStartChar) — never a digit.  `<1foo>` is invalid.
2153    #[test]
2154    fn in_place_default_opts_reject_bad_name_start() {
2155        let buf = b"<1foo/>".to_vec();
2156        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2157            .expect_err("default opts must reject name starting with digit");
2158        assert!(
2159            err.message.contains("name-start") || err.message.contains("name start"),
2160            "error should mention name-start, got: {}", err.message,
2161        );
2162        // libxml2-compatible code: XML_ERR_NAME_REQUIRED = 68.
2163        // Validates the Slice 5a contract that consumer-checked codes
2164        // round-trip via `err.code as i32`.
2165        assert_eq!(err.code, crate::error::ErrorCode::NameRequired);
2166        assert_eq!(err.code as i32, 68);
2167    }
2168
2169    #[test]
2170    fn in_place_skip_name_validation_accepts_bad_name_start() {
2171        // Same malformed name as above; with skip_name_validation the
2172        // name is accepted as-is and parsing reaches Eof cleanly.
2173        let buf = b"<1foo/>".to_vec();
2174        let opts = ParseOptions { skip_name_validation: true, ..ParseOptions::default() };
2175        let doc = parse_bytes_in_place(buf, &opts)
2176            .expect("skip_name_validation=true must accept malformed name");
2177        assert_eq!(doc.root().name(), "1foo");
2178    }
2179
2180    /// XML 1.0 § 3.1 [STag] WFC "Unique Att Spec": no element may have
2181    /// two attributes with the same name.  `<r a="1" a="2"/>` is
2182    /// rejected when `skip_attr_validation` is false (the default).
2183    #[test]
2184    fn in_place_default_opts_reject_duplicate_attribute() {
2185        let buf = br#"<r a="1" a="2"/>"#.to_vec();
2186        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2187            .expect_err("default opts must reject duplicate attribute");
2188        assert!(
2189            err.message.to_lowercase().contains("duplicate") || err.message.contains("Unique Att Spec"),
2190            "error should mention duplicate-attribute, got: {}", err.message,
2191        );
2192    }
2193
2194    #[test]
2195    fn in_place_skip_attr_validation_accepts_duplicate_attribute() {
2196        let buf = br#"<r a="1" a="2"/>"#.to_vec();
2197        let opts = ParseOptions { skip_attr_validation: true, ..ParseOptions::default() };
2198        let doc = parse_bytes_in_place(buf, &opts)
2199            .expect("skip_attr_validation=true must accept duplicate attr");
2200        // Both attributes end up on the element when validation is off —
2201        // we don't dedup at the structural layer.
2202        let n_attrs = doc.root().attributes().count();
2203        assert_eq!(n_attrs, 2, "both duplicate attrs should be present");
2204    }
2205
2206    /// XML 1.0 § 3.1 [STag/ETag]: end tag name must match the start
2207    /// tag name.  `<a></b>` is a well-formedness error.
2208    #[test]
2209    fn in_place_default_opts_reject_end_tag_mismatch() {
2210        let buf = b"<a></b>".to_vec();
2211        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2212            .expect_err("default opts must reject mismatched end tag");
2213        let msg = err.message.to_lowercase();
2214        assert!(
2215            msg.contains("mismatched") || msg.contains("expected"),
2216            "error should mention mismatched end tag, got: {}", err.message,
2217        );
2218    }
2219
2220    #[test]
2221    fn in_place_skip_end_tag_check_accepts_end_tag_mismatch() {
2222        let buf = b"<a></b>".to_vec();
2223        let opts = ParseOptions { skip_end_tag_check: true, ..ParseOptions::default() };
2224        let doc = parse_bytes_in_place(buf, &opts)
2225            .expect("skip_end_tag_check=true must accept mismatched end tag");
2226        assert_eq!(doc.root().name(), "a");
2227    }
2228
2229    /// XML 1.0 § 2.2 [2]: only specific Unicode code points are valid
2230    /// XML characters.  0x01 (control character, not in the allowed
2231    /// set) is rejected when `skip_xml_char_validation` is false.
2232    #[test]
2233    fn in_place_default_opts_reject_invalid_xml_char() {
2234        // Text content containing 0x01 — valid UTF-8 (single ASCII byte)
2235        // but invalid as an XML 1.0 character.
2236        let buf = b"<r>hello\x01world</r>".to_vec();
2237        let err = parse_bytes_in_place(buf, &ParseOptions::default())
2238            .expect_err("default opts must reject invalid XML char");
2239        // Validator's actual wording — check for either the section
2240        // citation or any mention of "character" / "valid".
2241        let msg = err.message.to_lowercase();
2242        assert!(
2243            msg.contains("xml 1.0") || msg.contains("char") || msg.contains("invalid"),
2244            "error should mention invalid XML char, got: {}", err.message,
2245        );
2246    }
2247
2248    #[test]
2249    fn in_place_skip_xml_char_validation_accepts_invalid_xml_char() {
2250        let buf = b"<r>hello\x01world</r>".to_vec();
2251        let opts = ParseOptions { skip_xml_char_validation: true, ..ParseOptions::default() };
2252        let doc = parse_bytes_in_place(buf, &opts)
2253            .expect("skip_xml_char_validation=true must accept 0x01 in text");
2254        let text = doc.root().children().find_map(|n| n.text_content()).unwrap_or("");
2255        assert_eq!(text.as_bytes(), b"hello\x01world");
2256    }
2257
2258    /// Combined "fast path" — all four skips enabled.  Exercises the
2259    /// path callers actually reach for when they want maximum speed.
2260    /// Verifies it works on a well-formed doc (the more interesting
2261    /// cases are covered individually above; this is a smoke test).
2262    #[test]
2263    fn in_place_with_all_skips_parses_well_formed_doc() {
2264        let buf = b"<root><a id=\"1\">hello</a></root>".to_vec();
2265        let opts = ParseOptions {
2266            skip_xml_char_validation: true,
2267            skip_name_validation:     true,
2268            skip_attr_validation:     true,
2269            skip_end_tag_check:       true,
2270            ..ParseOptions::default()
2271        };
2272        let doc = parse_bytes_in_place(buf, &opts).expect("all-skips parse");
2273        assert_eq!(doc.root().name(), "root");
2274        let a = doc.root().children().next().expect("element child");
2275        assert_eq!(a.name(), "a");
2276        assert_eq!(a.attributes().next().unwrap().value(), "1");
2277    }
2278}