Skip to main content

sup_xml_core/
xinclude.rs

1#![forbid(unsafe_code)]
2
3//! W3C XInclude 1.0 — arena-DOM port of [`crate::xinclude`].
4//!
5//! Mirrors the legacy [`crate::xinclude::process_xincludes`] behaviour, but
6//! operates on the arena DOM ([`sup_xml_tree::dom::Document`]).
7//!
8//! # API shape: returns a *new* [`Document`] (not mutate-in-place)
9//!
10//! The legacy implementation mutates the tree in place: it locates every
11//! `<xi:include>` element inside a parent's `Vec<Node>`, removes it, and
12//! splices in the resolved nodes.  That model maps poorly onto the arena
13//! DOM because:
14//!
15//! 1. Arena nodes live in a [`bumpalo::Bump`] that's owned by the
16//!    [`Document`]; per-node allocations cannot be freed individually.  An
17//!    `<xi:include>` element that gets "replaced" would simply leak its
18//!    bytes (harmless, but wasteful).
19//! 2. Included content is parsed into a separate [`Document`] with its own
20//!    arena.  An arena `Node<'doc>` carries references (`&'doc str`,
21//!    `&'doc Namespace`) tied to that arena and so cannot be moved into
22//!    a different one — it must be deep-copied.
23//! 3. The neat way to deep-copy *across* arenas while at the same time
24//!    expanding nested `xi:include` elements is to do both in a single
25//!    walk: read the source tree, allocate equivalents in a destination
26//!    builder, and substitute include elements as they're encountered.
27//!
28//! So this module's public entry point [`process_xincludes`]
29//! returns a new [`Document`] rather than mutating its argument.  The
30//! original document is left untouched.
31//!
32use std::collections::HashSet;
33use std::sync::Arc;
34
35use sup_xml_tree::dom::{Document, DocumentBuilder, Node, NodeKind};
36
37use crate::entity_resolver::{EntityResolver, ResolveError};
38use crate::error::{ErrorDomain, ErrorLevel, Result, XmlError};
39use crate::options::ParseOptions;
40use crate::parser::parse_str;
41
42/// XInclude namespace URI.  Constant per spec § 4.
43pub const XINCLUDE_NS: &str = "http://www.w3.org/2001/XInclude";
44
45/// Options for [`process_xincludes`].
46#[derive(Clone)]
47pub struct XIncludeOptions {
48    /// Resolver used to fetch referenced resources (XML or text).
49    /// Without one, every `xi:include` with `href` triggers
50    /// `xi:fallback` or errors.
51    pub resolver: Option<Arc<dyn EntityResolver>>,
52    /// Maximum include depth — protects against pathological
53    /// recursion that escapes the cycle-detector (e.g. a → b → a').
54    /// Default: 16.
55    pub max_depth: u32,
56    /// Maximum total bytes across all included resources.  Protects
57    /// against XInclude bombs analogous to billion-laughs.  Default:
58    /// 10 MB.
59    pub max_total_bytes: u64,
60}
61
62impl Default for XIncludeOptions {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl XIncludeOptions {
69    /// Construct with default limits (depth 16, total bytes 10 MB) and no
70    /// resolver — every `xi:include` with `href` will trigger `xi:fallback`
71    /// or error until a resolver is set.
72    pub fn new() -> Self {
73        Self {
74            resolver: None,
75            max_depth: 16,
76            max_total_bytes: 10 * 1024 * 1024,
77        }
78    }
79}
80
81/// Process every `xi:include` element in `doc`, returning a fresh
82/// [`Document`] in which each include has been replaced by the resolved
83/// content.  Operates by deep-copying the original tree into a new arena;
84/// `doc` is left untouched.
85///
86/// Errors:
87/// - `XmlError(domain=Io)` — resolver refused or file unreadable AND no
88///   `<xi:fallback>` was provided
89/// - `XmlError(domain=Parser)` — included XML didn't parse
90/// - `XmlError(domain=Validation)` — cycle detected, depth/byte limit
91///   exceeded, or `xi:include` element used incorrectly (missing href,
92///   bad parse= value, etc.)
93///
94/// # API divergence from legacy
95///
96/// The legacy [`crate::xinclude::process_xincludes`] mutates its argument
97/// in-place.  This arena version returns a new [`Document`] instead — see
98/// the module docs for the reasoning.
99pub fn process_xincludes(
100    doc: &Document,
101    opts: &XIncludeOptions,
102) -> Result<Document> {
103    let b = DocumentBuilder::new();
104    let mut state = State {
105        opts: opts.clone(),
106        hrefs: HashSet::new(),
107        depth: 0,
108        total_bytes: 0,
109    };
110
111    // Copy XML declaration fields verbatim.
112    b.set_version(doc.version.clone());
113    b.set_encoding(doc.encoding.clone());
114    b.set_standalone(doc.standalone);
115
116    let src_root = doc.root();
117    // The root itself can be `<xi:include>` in pathological cases, but
118    // that's not legal XInclude (the included content must replace it
119    // and a document must have exactly one root element).  Reject by
120    // refusing to flatten the root.  Detected indirectly: if the root
121    // is an xi:include, copy_element_into produces zero or more nodes
122    // and the destination ends up rootless.
123    if src_root.kind == NodeKind::Element && is_xinclude_element_named(src_root.name()) {
124        return Err(validation_err(
125            "xi:include is not allowed as the document root element",
126        ));
127    }
128
129    let dst_root = copy_subtree(&b, src_root, &mut state)?;
130    b.set_root(dst_root);
131    Ok(b.build())
132}
133
134// ── state ───────────────────────────────────────────────────────────────────
135
136struct State {
137    opts: XIncludeOptions,
138    /// Stack of resolved hrefs already in the include chain — for cycle
139    /// detection.
140    hrefs: HashSet<String>,
141    depth: u32,
142    total_bytes: u64,
143}
144
145// ── core walker ─────────────────────────────────────────────────────────────
146
147/// Deep-copy `src` (an arbitrary node) into the destination arena owned by
148/// `b`, returning the new node.  Caller is responsible for attaching the
149/// result to a parent (or marking it as the root).
150///
151/// Encountering `<xi:include>` while copying a *child list* (inside
152/// [`copy_children_into`]) triggers expansion.  This function is for nodes
153/// that are NOT themselves include elements — callers must filter or use
154/// [`copy_children_into`] which handles the splice.
155fn copy_subtree<'a>(
156    b: &'a DocumentBuilder,
157    src: &Node<'_>,
158    state: &mut State,
159) -> Result<&'a Node<'a>> {
160    match src.kind {
161        NodeKind::Element => {
162            let name = b.alloc_str(src.name());
163            let el = b.new_element(name);
164            for attr in src.attributes() {
165                let aname = b.alloc_str(attr.name());
166                let aval = b.alloc_str(attr.value());
167                let new_attr = b.new_attribute(aname, aval);
168                b.append_attribute(el, new_attr);
169            }
170            copy_children_into(b, el, src, state)?;
171            Ok(el)
172        }
173        NodeKind::Text => {
174            let content = b.alloc_str(src.content());
175            Ok(b.new_text(content))
176        }
177        NodeKind::CData => {
178            let content = b.alloc_str(src.content());
179            Ok(b.new_cdata(content))
180        }
181        NodeKind::Comment => {
182            let content = b.alloc_str(src.content());
183            Ok(b.new_comment(content))
184        }
185        NodeKind::Pi => {
186            let target = b.alloc_str(src.name());
187            let content = src.content_opt().map(|c| &*b.alloc_str(c));
188            Ok(b.new_pi(target, content))
189        }
190        NodeKind::EntityRef => {
191            // Preserve the unresolved reference across the XInclude
192            // copy.  Source was parsed with `resolve_entities=false`;
193            // the included view should round-trip the same.
194            let name    = b.alloc_str(src.name());
195            let content = b.alloc_str(src.content());
196            Ok(b.new_entity_ref(name, content))
197        }
198        // c-abi-only discriminant; never appears on a real Node.
199        NodeKind::Attribute => unreachable!("Attribute kind never appears on a Node"),
200        NodeKind::Document  => unreachable!("Document kind never appears on a Node"),
201        NodeKind::DocumentFragment => unreachable!(
202            "DocumentFragment is a compat-shim transient; XInclude does not walk into one"
203        ),
204        NodeKind::DtdDecl => unreachable!(
205            "DtdDecl is an internal-subset child; XInclude copies element subtrees only"
206        ),
207        NodeKind::Dtd => unreachable!(
208            "Dtd is a document-level internal-subset node; XInclude copies element subtrees only"
209        ),
210    }
211}
212
213/// Copy `src_parent`'s children into `dst_parent`, expanding any
214/// `<xi:include>` elements encountered along the way.
215fn copy_children_into<'a>(
216    b: &'a DocumentBuilder,
217    dst_parent: &'a Node<'a>,
218    src_parent: &Node<'_>,
219    state: &mut State,
220) -> Result<()> {
221    for child in src_parent.children() {
222        if child.kind == NodeKind::Element && is_xinclude_element(child) {
223            // Resolve and splice in zero or more nodes.
224            let replacements = resolve_include(b, child, state)?;
225            for n in replacements {
226                b.append_child(dst_parent, n);
227            }
228        } else {
229            let new_child = copy_subtree(b, child, state)?;
230            b.append_child(dst_parent, new_child);
231        }
232    }
233    Ok(())
234}
235
236// ── xi:include detection ────────────────────────────────────────────────────
237
238fn is_xinclude_element(elem: &Node<'_>) -> bool {
239    // First check namespace if set — most reliable.
240    if let Some(ns) = elem.namespace.get() {
241        if ns.href() == XINCLUDE_NS {
242            let local = local_name(elem.name());
243            return local == "include";
244        }
245    }
246    // Fall back to name-and-binding heuristic (matches legacy behaviour
247    // for non-namespace-aware parses).
248    is_xinclude_element_named_with_attrs(elem)
249}
250
251/// Same as [`is_xinclude_element`] but works on just the element's name
252/// (used for the root-element check before any element body inspection).
253fn is_xinclude_element_named(name: &str) -> bool {
254    name == "xi:include"
255}
256
257/// Heuristic check used when the arena DOM was parsed without namespace
258/// awareness (so [`Node::namespace`] is unset): inspect the element's
259/// attributes for an `xmlns*` binding to the XInclude namespace.
260fn is_xinclude_element_named_with_attrs(elem: &Node<'_>) -> bool {
261    let name = elem.name();
262    let local = local_name(name);
263    if local != "include" {
264        return false;
265    }
266    // Either the element name has a prefix bound on this element to
267    // the XInclude namespace (xmlns:xi="..." on this element), or the
268    // default namespace is XInclude.  We only check this element —
269    // matches legacy v1 behaviour, which lacked full namespace-scope
270    // tracking.
271    if elem.attributes().any(|a| {
272        (a.name() == "xmlns" || a.name().starts_with("xmlns:"))
273            && a.value() == XINCLUDE_NS
274    }) {
275        return true;
276    }
277    // Last-resort heuristic — the prefix-bare local name "xi:include"
278    // is distinctive enough that we accept it even without finding a
279    // binding.  Trade-off documented in legacy module.
280    name == "xi:include"
281}
282
283fn is_xi_fallback(elem: &Node<'_>) -> bool {
284    let local = local_name(elem.name());
285    local == "fallback"
286}
287
288#[inline]
289fn local_name(qname: &str) -> &str {
290    qname.rsplit_once(':').map(|(_, l)| l).unwrap_or(qname)
291}
292
293// ── attribute parsing ───────────────────────────────────────────────────────
294
295#[derive(Default)]
296struct XiAttrs {
297    href: Option<String>,
298    parse: XiParseMode,
299    /// XPointer fragment selector (XInclude § 4.2).  When `Some`,
300    /// the included document is parsed first, then the xpointer is
301    /// evaluated against it and only the matching subtree is
302    /// spliced in.  `None` (the common case) splices the entire
303    /// included root.
304    xpointer: Option<String>,
305}
306
307#[derive(Default, Clone, Copy, PartialEq, Eq)]
308enum XiParseMode {
309    #[default]
310    Xml,
311    Text,
312}
313
314fn parse_xi_include_attrs(elem: &Node<'_>) -> Result<XiAttrs> {
315    let mut out = XiAttrs::default();
316    for attr in elem.attributes() {
317        let local = local_name(attr.name());
318        match local {
319            "href" => out.href = Some(attr.value().to_string()),
320            "parse" => {
321                out.parse = match attr.value() {
322                    "xml" => XiParseMode::Xml,
323                    "text" => XiParseMode::Text,
324                    other => {
325                        return Err(validation_err(format!(
326                            "xi:include parse={other:?} is not supported \
327                             (only \"xml\" and \"text\")"
328                        )))
329                    }
330                };
331            }
332            "xpointer" => {
333                out.xpointer = Some(attr.value().to_string());
334            }
335            // accept, accept-language, encoding, xml:base — silently
336            // ignored.  See legacy module docs.
337            _ => {}
338        }
339    }
340    Ok(out)
341}
342
343// ── include resolution ──────────────────────────────────────────────────────
344
345/// Resolve a single `xi:include` element, returning the destination-arena
346/// nodes that should replace it in its parent's children list.  Handles
347/// fallback: on primary-resolution failure, falls back to the element's
348/// `<xi:fallback>` child (if any), failing with the primary error
349/// otherwise.
350fn resolve_include<'a>(
351    b: &'a DocumentBuilder,
352    include_elem: &Node<'_>,
353    state: &mut State,
354) -> Result<Vec<&'a Node<'a>>> {
355    state.depth += 1;
356    if state.depth > state.opts.max_depth {
357        state.depth -= 1;
358        return Err(validation_err(format!(
359            "XInclude depth limit ({}) exceeded — possible recursion",
360            state.opts.max_depth
361        )));
362    }
363
364    let primary = resolve_include_inner(b, include_elem, state);
365    state.depth -= 1;
366
367    match primary {
368        Ok(nodes) => Ok(nodes),
369        Err(primary_err) => {
370            // Try fallback: search children for <xi:fallback>.
371            let fallback_elem = include_elem.children().find(|c| {
372                c.kind == NodeKind::Element && is_xi_fallback(c)
373            });
374            match fallback_elem {
375                Some(fb) => {
376                    // Build a transparent container in the destination
377                    // arena: copy the fallback's children into it,
378                    // recursing through the standard walker (which
379                    // handles any further xi:includes inside the
380                    // fallback per XInclude § 4.4).  We don't actually
381                    // attach the container — we collect the children it
382                    // accumulated.
383                    let tmp = b.new_element(b.alloc_str("__xi_fallback_tmp__"));
384                    copy_children_into(b, tmp, fb, state)?;
385                    // Detach children from tmp and collect.  We could
386                    // also just return the children-iterator's nodes
387                    // directly — `tmp` will leak in the arena (a single
388                    // unreferenced placeholder element, negligible).
389                    // Detaching keeps things tidy and ensures the
390                    // caller can re-append them.
391                    let mut out = Vec::new();
392                    let mut cur = tmp.first_child.get();
393                    while let Some(c) = cur {
394                        let next = c.next_sibling.get();
395                        b.detach(c);
396                        out.push(c);
397                        cur = next;
398                    }
399                    Ok(out)
400                }
401                None => Err(primary_err),
402            }
403        }
404    }
405}
406
407fn resolve_include_inner<'a>(
408    b: &'a DocumentBuilder,
409    include_elem: &Node<'_>,
410    state: &mut State,
411) -> Result<Vec<&'a Node<'a>>> {
412    let attrs = parse_xi_include_attrs(include_elem)?;
413
414    let href = attrs.href.ok_or_else(|| {
415        validation_err(
416            "xi:include without href is not supported in v1 (use \
417             href to reference an external resource)",
418        )
419    })?;
420
421    if state.hrefs.contains(&href) {
422        return Err(validation_err(format!(
423            "XInclude cycle detected: {href:?} is already in the \
424             include chain"
425        )));
426    }
427
428    let resolver = state
429        .opts
430        .resolver
431        .as_ref()
432        .cloned()
433        .ok_or_else(|| {
434            io_err(format!(
435                "xi:include {href:?} cannot be resolved — no \
436                 resolver configured (set XIncludeOptions::resolver)"
437            ))
438        })?;
439
440    let bytes = resolver
441        .resolve(None, &href, None)
442        .map_err(|e| match e {
443            ResolveError::Refused(msg) => io_err(format!(
444                "xi:include {href:?} refused by resolver: {msg}"
445            )),
446            ResolveError::Io(io) => io_err(format!(
447                "xi:include {href:?} I/O error: {io}"
448            )),
449            ResolveError::Other(other) => io_err(format!(
450                "xi:include {href:?} resolver error: {other}"
451            )),
452        })?;
453
454    let added = bytes.len() as u64;
455    if state.total_bytes.saturating_add(added) > state.opts.max_total_bytes {
456        return Err(validation_err(format!(
457            "XInclude byte budget ({}) exceeded by {href:?}",
458            state.opts.max_total_bytes
459        )));
460    }
461    state.total_bytes += added;
462
463    match attrs.parse {
464        XiParseMode::Xml => {
465            let text = std::str::from_utf8(&bytes).map_err(|e| {
466                XmlError::new(
467                    ErrorDomain::Encoding,
468                    ErrorLevel::Fatal,
469                    format!("xi:include {href:?} bytes are not UTF-8: {e}"),
470                )
471            })?;
472            // Parse the included document into its own arena.  We then
473            // walk its tree and copy nodes into `b`'s arena, expanding
474            // nested xi:include elements along the way.
475            let sub_doc = parse_str(text, &ParseOptions::default())?;
476
477            state.hrefs.insert(href.clone());
478
479            // XPointer fragment selection (XInclude § 4.2).  When the
480            // xpointer attribute is set, the included document is
481            // first resolved by the xpointer; only the matching
482            // subtree is then spliced in.  When unset, splice the
483            // whole document root — common case.
484            let result: Result<Vec<&Node<'_>>> = (|| {
485                let targets: Vec<&Node<'_>> = match &attrs.xpointer {
486                    Some(xp) => resolve_xpointer(&sub_doc, xp, &href)?,
487                    None     => vec![sub_doc.root()],
488                };
489                let mut out: Vec<&Node<'_>> = Vec::with_capacity(targets.len());
490                for tgt in targets {
491                    // If the matched node is itself an xi:include,
492                    // expand it (mirrors the legacy root-is-xinclude
493                    // handling).  This also covers the case where an
494                    // xpointer selected an xi:include inside the
495                    // included doc.
496                    if is_xinclude_element(tgt) {
497                        out.extend(resolve_include(b, tgt, state)?);
498                    } else {
499                        out.push(copy_subtree(b, tgt, state)?);
500                    }
501                }
502                Ok(out)
503            })();
504
505            state.hrefs.remove(&href);
506            result
507        }
508        XiParseMode::Text => {
509            let text = String::from_utf8_lossy(&bytes).into_owned();
510            let alloc = b.alloc_str(&text);
511            Ok(vec![b.new_text(alloc)])
512        }
513    }
514}
515
516// ── xpointer ────────────────────────────────────────────────────────────────
517
518/// Resolve an XPointer expression against an included document,
519/// returning the element/document nodes selected, in document order.
520///
521/// This is the XInclude § 4.2 fragment-selector subset, shared by the
522/// pure-Rust engine and the libxml2-compat C-ABI XInclude path.
523/// Supports the three forms most commonly seen in practice; anything
524/// else returns a validation error so the caller can fall back to
525/// `xi:fallback` (or fail loudly rather than splice the wrong content):
526///
527/// - **`xpointer(EXPR)`** — `EXPR` is parsed as XPath 1.0 and
528///   evaluated against the included document.  The resulting
529///   nodeset is returned in document order.  Empty nodeset is an
530///   error (no nodes → can't splice anything).
531/// - **`element(/N1/N2/...)`** — the ChildSequence scheme.  Walk
532///   from the document root, picking the Nth element-typed child
533///   at each step (1-based).  XInclude § 4.2 / XPointer
534///   ChildSequence.
535/// - **`element(NAME)`** / **`element(NAME/N1/...)`** — start with
536///   the element whose `id` attribute is `NAME`, then optionally
537///   walk a ChildSequence from there.
538/// - **`NAME`** (bare ID) — shorthand for `element(NAME)`.
539pub fn resolve_xpointer<'a>(
540    sub_doc:  &'a sup_xml_tree::dom::Document,
541    expr:     &str,
542    href:     &str,
543) -> Result<Vec<&'a Node<'a>>> {
544    let bad = |msg: String| validation_err(format!(
545        "xi:include xpointer={expr:?} ({href}): {msg}"
546    ));
547
548    // ── xpointer(EXPR) scheme: full XPath ─────────────────────────────────
549    if let Some(rest) = expr.strip_prefix("xpointer(") {
550        let inner = rest.strip_suffix(')').ok_or_else(|| {
551            bad("missing closing `)` after xpointer scheme".to_string())
552        })?;
553        let result = crate::xpath::xpath_eval(sub_doc, inner)
554            .map_err(|e| bad(format!("XPath evaluation failed: {e}")))?;
555        let ids = match result {
556            crate::xpath::XPathValue::NodeSet(ns) if !ns.is_empty() => ns,
557            crate::xpath::XPathValue::NodeSet(_) => {
558                return Err(bad("XPath returned an empty nodeset".to_string()));
559            }
560            _ => return Err(bad(
561                "XPath must return a nodeset for xi:include splicing".to_string()
562            )),
563        };
564        // Resolve NodeId → &Node via DocIndex.  We can't share the
565        // sub_doc's index across the function boundary easily, so
566        // rebuild it locally — XInclude expansion is the cold path,
567        // not perf-critical.
568        let idx = crate::xpath::context::DocIndex::build(sub_doc);
569        let mut out: Vec<&Node<'_>> = Vec::with_capacity(ids.len());
570        for id in ids {
571            if let Some(n) = node_id_to_arena(&idx, id) {
572                out.push(n);
573            }
574        }
575        if out.is_empty() {
576            return Err(bad(
577                "XPath matched only non-element nodes (text, attributes, \
578                 etc.) — XInclude can only splice element / document \
579                 subtrees".to_string()
580            ));
581        }
582        return Ok(out);
583    }
584
585    // ── element(...) scheme: ChildSequence or ID + ChildSequence ──────────
586    if let Some(rest) = expr.strip_prefix("element(") {
587        let inner = rest.strip_suffix(')').ok_or_else(|| {
588            bad("missing closing `)` after element scheme".to_string())
589        })?;
590        // Split into (optional name) + ChildSequence segments.
591        // Leading `/` means "anchor at root, walk the ChildSequence".
592        // Leading name means "find element by ID, then walk".
593        let trimmed = inner.trim();
594        let (start, seq): (Option<&str>, &str) = if trimmed.starts_with('/') {
595            (None, trimmed)
596        } else {
597            // ID (and optional `/` ChildSequence appended).
598            match trimmed.find('/') {
599                Some(slash) => (Some(&trimmed[..slash]), &trimmed[slash..]),
600                None        => (Some(trimmed), ""),
601            }
602        };
603        // XPointer ChildSequence anchored at `/` starts FROM the
604        // document — its first step (`/N`) selects the Nth top-level
605        // element, of which XML allows exactly one (the root).  So
606        // `/1` is the root, `/1/2` is the root's 2nd element child,
607        // etc.  When the start is an ID, the named element IS the
608        // anchor and walks descend from there directly.
609        let (anchor, seq_after_root): (&Node<'_>, &str) = match start {
610            None => {
611                // Strip leading `/` and the mandatory `1` (or it's
612                // out-of-range).  The remainder walks from the root.
613                let after_slash = seq.trim_start_matches('/');
614                let (first, rest) = match after_slash.find('/') {
615                    Some(i) => (&after_slash[..i], &after_slash[i + 1..]),
616                    None    => (after_slash, ""),
617                };
618                if !first.is_empty() {
619                    let n: usize = first.parse().map_err(|_| {
620                        bad(format!("ChildSequence root step {first:?} is not a positive integer"))
621                    })?;
622                    if n != 1 {
623                        return Err(bad(format!(
624                            "ChildSequence root step must be 1 (only one root \
625                             element exists); got {n}"
626                        )));
627                    }
628                }
629                (sub_doc.root(), rest)
630            }
631            Some(name) => {
632                let n = find_by_id(sub_doc, name).ok_or_else(|| {
633                    bad(format!("no element with id={name:?}"))
634                })?;
635                (n, seq.trim_start_matches('/'))
636            }
637        };
638        let target = walk_child_sequence(anchor, seq_after_root, &bad)?;
639        return Ok(vec![target]);
640    }
641
642    // ── bare ID (no scheme parens) ────────────────────────────────────────
643    if !expr.contains('(') && !expr.contains('/') && !expr.is_empty() {
644        let n = find_by_id(sub_doc, expr).ok_or_else(|| {
645            bad(format!("no element with id={expr:?}"))
646        })?;
647        return Ok(vec![n]);
648    }
649
650    Err(bad(format!(
651        "unrecognized xpointer form — supported: \
652         `xpointer(EXPR)`, `element(/N/...)`, `element(NAME[/N/...])`, \
653         or bare `NAME`"
654    )))
655}
656
657/// Walk a ChildSequence (slash-separated positive integers) from
658/// `anchor`, picking the Nth element-typed child at each step
659/// (1-based).  `seq` may be empty (→ return `anchor`), `/1` (→ first
660/// child), `/1/2/3` (→ third child of the second child of the first
661/// child), etc.  Leading `/` is allowed and skipped.
662fn walk_child_sequence<'a, F>(
663    anchor: &'a Node<'a>,
664    seq:    &str,
665    bad:    &F,
666) -> Result<&'a Node<'a>>
667where
668    F: Fn(String) -> XmlError,
669{
670    let mut current = anchor;
671    let mut remaining = seq.trim_start_matches('/');
672    while !remaining.is_empty() {
673        let (head, tail) = match remaining.find('/') {
674            Some(i) => (&remaining[..i], &remaining[i + 1..]),
675            None    => (remaining, ""),
676        };
677        let n: usize = head.parse().map_err(|_| {
678            bad(format!("ChildSequence step {head:?} is not a positive integer"))
679        })?;
680        if n == 0 {
681            return Err(bad(
682                "ChildSequence steps are 1-based; got 0".to_string(),
683            ));
684        }
685        let mut next_node: Option<&Node<'_>> = None;
686        let mut seen = 0usize;
687        for child in current.children() {
688            if child.is_element() {
689                seen += 1;
690                if seen == n {
691                    next_node = Some(child);
692                    break;
693                }
694            }
695        }
696        current = next_node.ok_or_else(|| {
697            bad(format!(
698                "ChildSequence step {n} out of range — only {seen} element \
699                 child(ren) under <{}>",
700                current.name()
701            ))
702        })?;
703        remaining = tail;
704    }
705    Ok(current)
706}
707
708/// Walk the document looking for an element whose `id`-like
709/// attribute equals `id`.  No DTD-driven ID-attribute resolution
710/// yet — we accept the literal attribute `id` or `xml:id`, which
711/// covers the overwhelming majority of fragment uses.
712fn find_by_id<'a>(
713    doc: &'a sup_xml_tree::dom::Document,
714    id:  &str,
715) -> Option<&'a Node<'a>> {
716    fn walk<'a>(n: &'a Node<'a>, id: &str) -> Option<&'a Node<'a>> {
717        if n.is_element() {
718            for a in n.attributes() {
719                let nm = a.name();
720                if (nm == "id" || nm == "xml:id" || nm.ends_with(":id"))
721                    && a.value() == id
722                {
723                    return Some(n);
724                }
725            }
726        }
727        for c in n.children() {
728            if let Some(found) = walk(c, id) {
729                return Some(found);
730            }
731        }
732        None
733    }
734    walk(doc.root(), id)
735}
736
737/// Look up the arena Node behind an XPath `NodeId`.  Returns `None`
738/// for nodes the engine indexes that don't have a tree backing
739/// (Document virtual root, namespace synthetic nodes).
740fn node_id_to_arena<'doc>(
741    idx: &crate::xpath::context::DocIndex<'doc>,
742    id:  crate::xpath::NodeId,
743) -> Option<&'doc Node<'doc>> {
744    use crate::xpath::context::INodeKind;
745    match &idx.nodes.get(id)?.kind {
746        INodeKind::Element(n) => Some(n),
747        // For non-element matches we don't splice — XInclude needs
748        // element- or document-rooted subtrees.  Caller filters.
749        _ => None,
750    }
751}
752
753// ── error helpers ───────────────────────────────────────────────────────────
754
755fn io_err(msg: String) -> XmlError {
756    XmlError::new(ErrorDomain::Io, ErrorLevel::Fatal, msg)
757}
758
759fn validation_err(msg: impl Into<String>) -> XmlError {
760    XmlError::new(ErrorDomain::Validation, ErrorLevel::Fatal, msg)
761}
762
763// ── tests ───────────────────────────────────────────────────────────────────
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use crate::entity_resolver::InMemoryResolver;
769    use std::collections::HashMap;
770    use std::sync::Arc;
771
772    fn opts_with_resolver(map: HashMap<String, Vec<u8>>) -> XIncludeOptions {
773        let mut r = InMemoryResolver::new();
774        for (sys, bytes) in map {
775            r = r.with_system(&sys, bytes);
776        }
777        XIncludeOptions {
778            resolver: Some(Arc::new(r)),
779            ..XIncludeOptions::new()
780        }
781    }
782
783    fn parse(xml: &str) -> Document {
784        parse_str(xml, &ParseOptions::default()).expect("parse")
785    }
786
787    #[test]
788    fn xinclude_xml_replaces_include_with_referenced_subtree() {
789        let mut docs = HashMap::new();
790        docs.insert("part.xml".to_string(), b"<chunk>hello</chunk>".to_vec());
791        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="part.xml"/></root>"#;
792        let doc = parse(xml);
793        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
794        let root = out.root();
795        assert_eq!(root.name(), "root");
796        // The xi:include is replaced by the <chunk> element.
797        let kids: Vec<_> = root.children().collect();
798        assert_eq!(kids.len(), 1);
799        let chunk = kids[0];
800        assert_eq!(chunk.kind, NodeKind::Element);
801        assert_eq!(chunk.name(), "chunk");
802        assert_eq!(chunk.text_content(), Some("hello"));
803    }
804
805    #[test]
806    fn xinclude_text_includes_raw_bytes_as_text_node() {
807        let mut docs = HashMap::new();
808        docs.insert(
809            "readme.txt".to_string(),
810            b"raw <text> with & specials".to_vec(),
811        );
812        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="readme.txt" parse="text"/></root>"#;
813        let doc = parse(xml);
814        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
815        let root = out.root();
816        let kids: Vec<_> = root.children().collect();
817        assert_eq!(kids.len(), 1);
818        assert_eq!(kids[0].kind, NodeKind::Text);
819        assert_eq!(kids[0].content(), "raw <text> with & specials");
820    }
821
822    #[test]
823    fn xinclude_fallback_used_when_resolve_fails() {
824        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
825            <xi:include href="missing.xml">
826                <xi:fallback><default>fallback content</default></xi:fallback>
827            </xi:include>
828        </root>"#;
829        let doc = parse(xml);
830        // Empty resolver — `missing.xml` won't be found.
831        let out = process_xincludes(&doc, &opts_with_resolver(HashMap::new()))
832            .unwrap();
833        let root = out.root();
834        let has_default = root.children().any(|c| {
835            c.kind == NodeKind::Element && c.name() == "default"
836        });
837        assert!(
838            has_default,
839            "fallback content should replace the failed include"
840        );
841    }
842
843    #[test]
844    fn xinclude_no_resolver_errors_on_include() {
845        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="x.xml"/></root>"#;
846        let doc = parse(xml);
847        let opts = XIncludeOptions::new();
848        let err = process_xincludes(&doc, &opts).expect_err("no resolver");
849        assert!(err.message.contains("no resolver"), "got: {}", err.message);
850    }
851
852    #[test]
853    fn xinclude_recursive_processing() {
854        let mut docs = HashMap::new();
855        docs.insert(
856            "outer.xml".to_string(),
857            br#"<wrap xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="inner.xml"/></wrap>"#.to_vec(),
858        );
859        docs.insert("inner.xml".to_string(), b"<leaf>x</leaf>".to_vec());
860        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="outer.xml"/></root>"#;
861        let doc = parse(xml);
862        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
863        // Walk the tree to confirm the leaf is reachable.
864        let root = out.root();
865        let wrap = root.children().next().unwrap();
866        assert_eq!(wrap.name(), "wrap");
867        let leaf = wrap.children().next().unwrap();
868        assert_eq!(leaf.name(), "leaf");
869        assert_eq!(leaf.text_content(), Some("x"));
870    }
871
872    #[test]
873    fn xinclude_cycle_detected() {
874        let mut docs = HashMap::new();
875        docs.insert(
876            "a.xml".to_string(),
877            br#"<a xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></a>"#.to_vec(),
878        );
879        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="a.xml"/></root>"#;
880        let doc = parse(xml);
881        let err = process_xincludes(&doc, &opts_with_resolver(docs))
882            .expect_err("cycle");
883        assert!(
884            err.message.contains("cycle") || err.message.contains("depth"),
885            "got: {}",
886            err.message
887        );
888    }
889
890    #[test]
891    fn xinclude_max_depth_enforced() {
892        let mut docs = HashMap::new();
893        for (i, next) in [(1, 2), (2, 3), (3, 4), (4, 5), (5, 6)] {
894            docs.insert(
895                format!("d{i}.xml"),
896                format!(
897                    r#"<l xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="d{next}.xml"/></l>"#
898                )
899                .into_bytes(),
900            );
901        }
902        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="d1.xml"/></root>"#;
903        let doc = parse(xml);
904        let opts = XIncludeOptions {
905            max_depth: 2,
906            ..opts_with_resolver(docs)
907        };
908        let err = process_xincludes(&doc, &opts).expect_err("depth limit");
909        assert!(err.message.contains("depth"), "got: {}", err.message);
910    }
911
912    #[test]
913    fn xinclude_no_xi_elements_is_noop() {
914        let xml = "<r><a/><b/></r>";
915        let doc = parse(xml);
916        let out = process_xincludes(&doc, &XIncludeOptions::new()).unwrap();
917        // Tree shape preserved.
918        let root = out.root();
919        assert_eq!(root.name(), "r");
920        let kids: Vec<&str> = root.children().map(|c| c.name()).collect();
921        assert_eq!(kids, vec!["a", "b"]);
922    }
923
924    #[test]
925    fn xinclude_unsupported_parse_mode_errors() {
926        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="x" parse="binary"/></root>"#;
927        let doc = parse(xml);
928        let err = process_xincludes(&doc, &opts_with_resolver(HashMap::new()))
929            .expect_err("bad parse mode");
930        assert!(err.message.contains("parse"), "got: {}", err.message);
931    }
932
933    // ── arena-specific tests ────────────────────────────────────────────
934
935    #[test]
936    fn xinclude_preserves_surrounding_siblings() {
937        // Make sure the splice doesn't disturb other children.
938        let mut docs = HashMap::new();
939        docs.insert("p.xml".to_string(), b"<inc/>".to_vec());
940        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
941            <before/>
942            <xi:include href="p.xml"/>
943            <after/>
944        </root>"#;
945        let doc = parse(xml);
946        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
947        let names: Vec<&str> = out
948            .root()
949            .children()
950            .filter(|c| c.kind == NodeKind::Element)
951            .map(|c| c.name())
952            .collect();
953        assert_eq!(names, vec!["before", "inc", "after"]);
954    }
955
956    #[test]
957    fn xinclude_copies_attributes_on_other_elements() {
958        // Ensure deep-copy preserves attributes on non-include elements.
959        let mut docs = HashMap::new();
960        docs.insert("p.xml".to_string(), b"<i/>".to_vec());
961        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude" id="r" class="c"><xi:include href="p.xml"/></root>"#;
962        let doc = parse(xml);
963        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
964        let root = out.root();
965        let attrs: Vec<(&str, &str)> = root
966            .attributes()
967            .filter(|a| a.name() != "xmlns:xi")
968            .map(|a| (a.name(), a.value()))
969            .collect();
970        // Order preserved; xmlns:xi may or may not appear in the iter
971        // depending on parser path — filter for clarity.
972        assert!(attrs.iter().any(|&(n, v)| n == "id" && v == "r"));
973        assert!(attrs.iter().any(|&(n, v)| n == "class" && v == "c"));
974    }
975
976    #[test]
977    fn xinclude_returns_independent_document() {
978        // Output document must be a separate arena that survives the
979        // input being dropped.
980        let mut docs = HashMap::new();
981        docs.insert("p.xml".to_string(), b"<inc>hi</inc>".to_vec());
982        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="p.xml"/></root>"#;
983        let opts = opts_with_resolver(docs);
984        let out = {
985            let doc = parse(xml);
986            process_xincludes(&doc, &opts).unwrap()
987            // `doc` drops here.
988        };
989        assert_eq!(out.root().name(), "root");
990        let inc = out.root().children().next().unwrap();
991        assert_eq!(inc.name(), "inc");
992        assert_eq!(inc.text_content(), Some("hi"));
993    }
994
995    #[test]
996    fn xinclude_xml_decl_fields_preserved() {
997        let mut docs = HashMap::new();
998        docs.insert("p.xml".to_string(), b"<x/>".to_vec());
999        let xml = r#"<?xml version="1.1" encoding="UTF-8" standalone="yes"?><root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="p.xml"/></root>"#;
1000        let doc = parse(xml);
1001        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1002        assert_eq!(out.version, "1.1");
1003        assert_eq!(out.encoding, "UTF-8");
1004        assert_eq!(out.standalone, Some(true));
1005    }
1006
1007    #[test]
1008    fn xinclude_text_mode_does_not_parse_xml() {
1009        // parse="text" content should be inserted as-is, even if it
1010        // contains XML-like syntax — verifies we route to the text path.
1011        let mut docs = HashMap::new();
1012        docs.insert(
1013            "snippet.txt".to_string(),
1014            b"<not-parsed/>".to_vec(),
1015        );
1016        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude"><xi:include href="snippet.txt" parse="text"/></root>"#;
1017        let doc = parse(xml);
1018        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1019        let kids: Vec<_> = out.root().children().collect();
1020        assert_eq!(kids.len(), 1);
1021        assert_eq!(kids[0].kind, NodeKind::Text);
1022        assert_eq!(kids[0].content(), "<not-parsed/>");
1023    }
1024
1025    #[test]
1026    fn xinclude_fallback_with_nested_include() {
1027        // Per XInclude § 4.4, xi:include inside xi:fallback should also
1028        // get processed.
1029        let mut docs = HashMap::new();
1030        docs.insert("ok.xml".to_string(), b"<from-fallback/>".to_vec());
1031        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
1032            <xi:include href="missing.xml">
1033                <xi:fallback><xi:include href="ok.xml"/></xi:fallback>
1034            </xi:include>
1035        </root>"#;
1036        let doc = parse(xml);
1037        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1038        let has_inner = out
1039            .root()
1040            .children()
1041            .any(|c| c.kind == NodeKind::Element && c.name() == "from-fallback");
1042        assert!(
1043            has_inner,
1044            "xi:include nested in fallback should be expanded"
1045        );
1046    }
1047
1048    // ── XPointer fragment selection ────────────────────────────────
1049
1050    /// `xpointer(EXPR)` — the inner expression is XPath 1.0.
1051    /// Selects a subtree from the included doc rather than the
1052    /// whole root.
1053    #[test]
1054    fn xinclude_xpointer_xpath_selects_subtree() {
1055        let mut docs = HashMap::new();
1056        docs.insert(
1057            "part.xml".to_string(),
1058            b"<a><b><c>match</c></b><b><c>skip</c></b></a>".to_vec(),
1059        );
1060        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
1061            <xi:include href="part.xml" xpointer="xpointer(/a/b[1]/c)"/>
1062        </root>"#;
1063        let doc = parse(xml);
1064        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1065        let included: Vec<_> = out.root().children()
1066            .filter(|c| c.kind == NodeKind::Element)
1067            .collect();
1068        assert_eq!(included.len(), 1);
1069        assert_eq!(included[0].name(), "c");
1070        assert_eq!(included[0].text_content(), Some("match"));
1071    }
1072
1073    /// `element(/1/2/3)` — ChildSequence scheme.  Walks element
1074    /// children by 1-based index.
1075    #[test]
1076    fn xinclude_xpointer_element_child_sequence() {
1077        let mut docs = HashMap::new();
1078        docs.insert(
1079            "part.xml".to_string(),
1080            b"<a><b id='b1'/><b id='b2'><c id='c1'/><c id='c2'/></b></a>".to_vec(),
1081        );
1082        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
1083            <xi:include href="part.xml" xpointer="element(/1/2/2)"/>
1084        </root>"#;
1085        let doc = parse(xml);
1086        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1087        // /1 → <a>; /1/2 → second <b>; /1/2/2 → second <c id="c2">.
1088        let included: Vec<_> = out.root().children()
1089            .filter(|c| c.kind == NodeKind::Element)
1090            .collect();
1091        assert_eq!(included.len(), 1);
1092        assert_eq!(included[0].name(), "c");
1093        let id_attr = included[0].attributes()
1094            .find(|a| a.name() == "id")
1095            .map(|a| a.value());
1096        assert_eq!(id_attr, Some("c2"));
1097    }
1098
1099    /// `element(NAME)` — fragment-id lookup by `id` attribute.
1100    #[test]
1101    fn xinclude_xpointer_element_fragment_id() {
1102        let mut docs = HashMap::new();
1103        docs.insert(
1104            "part.xml".to_string(),
1105            b"<a><b id='hit'><inner/></b><b id='miss'/></a>".to_vec(),
1106        );
1107        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
1108            <xi:include href="part.xml" xpointer="element(hit)"/>
1109        </root>"#;
1110        let doc = parse(xml);
1111        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1112        let included: Vec<_> = out.root().children()
1113            .filter(|c| c.kind == NodeKind::Element)
1114            .collect();
1115        assert_eq!(included.len(), 1);
1116        assert_eq!(included[0].name(), "b");
1117        // The returned <b id='hit'> has its <inner/> child.
1118        let has_inner = included[0].children()
1119            .any(|c| c.is_element() && c.name() == "inner");
1120        assert!(has_inner);
1121    }
1122
1123    /// Bare-name xpointer is shorthand for fragment-id lookup.
1124    #[test]
1125    fn xinclude_xpointer_bare_name_is_id_lookup() {
1126        let mut docs = HashMap::new();
1127        docs.insert(
1128            "part.xml".to_string(),
1129            b"<a><b id='target'>hit</b><b id='other'/></a>".to_vec(),
1130        );
1131        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
1132            <xi:include href="part.xml" xpointer="target"/>
1133        </root>"#;
1134        let doc = parse(xml);
1135        let out = process_xincludes(&doc, &opts_with_resolver(docs)).unwrap();
1136        let included: Vec<_> = out.root().children()
1137            .filter(|c| c.kind == NodeKind::Element)
1138            .collect();
1139        assert_eq!(included.len(), 1);
1140        assert_eq!(included[0].text_content(), Some("hit"));
1141    }
1142
1143    /// An xpointer that matches nothing surfaces an error (caller
1144    /// can wrap in `xi:fallback` for graceful degradation).
1145    #[test]
1146    fn xinclude_xpointer_no_match_is_error() {
1147        let mut docs = HashMap::new();
1148        docs.insert(
1149            "part.xml".to_string(),
1150            b"<a><b/></a>".to_vec(),
1151        );
1152        let xml = r#"<root xmlns:xi="http://www.w3.org/2001/XInclude">
1153            <xi:include href="part.xml" xpointer="xpointer(/no-such-node)"/>
1154        </root>"#;
1155        let doc = parse(xml);
1156        let result = process_xincludes(&doc, &opts_with_resolver(docs));
1157        assert!(result.is_err(),
1158            "xpointer matching nothing should error so fallback can fire");
1159    }
1160}