Skip to main content

sup_xml_xslt/
result_tree.rs

1//! Lightweight intermediate result-tree representation.
2//!
3//! The XSLT evaluator builds a [`ResultTree`] in memory; the
4//! output serialisers in [`crate::output`] consume it and emit
5//! XML / HTML / text bytes per the effective `<xsl:output>` settings.
6//!
7//! We deliberately don't reuse `sup_xml_tree::dom` here — that's
8//! tuned for libxml2 ABI shape and arena allocation, and XSLT
9//! result trees are typically short-lived and ad-hoc.  A
10//! straightforward `Vec`-backed tree is easier to build
11//! incrementally (the eval engine pushes nodes into a "current
12//! container" stack) and easier to serialise.
13
14use crate::ast::QName;
15
16/// One node in an in-progress result tree.  Element children
17/// recurse via the `children` vec; attributes hang off the
18/// element separately because XPath / serialisation treat them
19/// differently from content children.
20#[derive(Clone, Debug)]
21pub enum ResultNode {
22    Element {
23        name:       QName,
24        /// (prefix, URI) namespace declarations to emit on this
25        /// element.  May be empty — by default we inherit from the
26        /// parent and only emit declarations that introduce
27        /// changes.
28        namespaces: Vec<(Option<String>, String)>,
29        attributes: Vec<(QName, String)>,
30        children:   Vec<ResultNode>,
31        /// Schema-aware: the expanded name `(ns, local)` of the type
32        /// this element was constructed as — from a `type=` / `xsl:type=`
33        /// attribute or a `validation=` mode.  `None` for the ordinary
34        /// untyped case.  Carried through RTF indexing so a constructed
35        /// node's typed value is recoverable by `data()` /
36        /// `instance of` (XSLT 2.0 §5.7.1, annotation-for-constructed-
37        /// element).
38        schema_type: Option<Box<(String, String)>>,
39        /// Schema-aware: governing type `(ns, local)` for any of this
40        /// element's attributes constructed with a `type=` annotation,
41        /// keyed by attribute name.  Sparse — empty for the ordinary
42        /// untyped case.  Recorded into the RTF PSVI table alongside the
43        /// attribute node so `data(@a)` / `@a instance of …` see the
44        /// declared type.
45        attr_types: Vec<(QName, Box<(String, String)>)>,
46    },
47    Text {
48        content: String,
49        /// `disable-output-escaping` — when `true`, the serialiser
50        /// emits the content verbatim (no `&amp;` / `&lt;`).  Used
51        /// by `<xsl:text disable-output-escaping="yes"/>` and
52        /// `<xsl:value-of disable-output-escaping="yes"/>`.
53        dose:    bool,
54    },
55    Comment(String),
56    ProcessingInstruction { target: String, data: String },
57    /// A parentless attribute node — the value of an `xsl:attribute`
58    /// evaluated with no element under construction (XSLT 2.0 §5.7.1
59    /// sequence constructors, e.g. an `as="attribute()*"` variable).
60    /// `xsl:copy-of` / `xsl:apply-templates` consume it; it never
61    /// appears among an element's children (element attributes live in
62    /// the `attributes` vec).
63    Attribute { name: QName, value: String },
64}
65
66/// The complete result of a transformation.  Carries the
67/// serialised top-level children plus the effective
68/// `<xsl:output>` settings the serialiser will honour.
69#[derive(Clone, Debug, Default)]
70pub struct ResultTree {
71    pub children: Vec<ResultNode>,
72    pub output:   crate::ast::OutputSpec,
73    /// Flattened `xsl:character-map` substitutions selected by
74    /// `xsl:output use-character-maps="…"` (XSLT 2.0 §20).  Empty
75    /// for stylesheets that don't use character maps; populated at
76    /// apply time after composing every referenced map.  The
77    /// serializer consults this list per emitted character.
78    pub character_map: Vec<(char, String)>,
79    /// Secondary result documents written by `xsl:result-document
80    /// href="…"` (XSLT 2.0 §19.1): `(resolved-href, document)`.  Empty
81    /// unless the stylesheet produced secondary output.
82    pub secondary: Vec<(String, ResultTree)>,
83}
84
85/// Builder helper — maintains the "currently being built" element
86/// stack so the evaluator can emit nodes one at a time without
87/// constructing the tree bottom-up.
88#[derive(Debug, Default)]
89pub struct ResultBuilder {
90    /// Stack of in-progress elements.  Each frame is the
91    /// element under construction; closing it pops the frame and
92    /// pushes the completed element as a child of the next frame
93    /// (or into the top-level if the stack is now empty).
94    stack:    Vec<ResultNode>,
95    /// Top-level result nodes accumulated so far.
96    pub top:  Vec<ResultNode>,
97    /// XSLT 2.0 §5.7.2 sequence normalisation merges adjacent
98    /// text fragments when building the result tree of a template
99    /// or a no-`as` RTF — that's the default behaviour.  Variable
100    /// bodies declared `as="item()*"` (or any sequence type) skip
101    /// normalisation per §9.3: each `xsl:text` contributes its
102    /// own text-node item, three of them stay three items, and
103    /// `count($var)` answers `3`.  Setting this flag disables the
104    /// merge.
105    pub no_text_merge: bool,
106    /// First sequence-construction error observed (XSLT 2.0 §5.7.1 —
107    /// e.g. XTDE0410, an attribute or namespace node landing in an
108    /// element's content after a non-attribute/non-namespace node).
109    /// Stashed here because the affected builder methods are infallible
110    /// at their call sites; the surrounding `apply` consults this
111    /// after the construction unwinds and surfaces it as a real error.
112    pub deferred_error: Option<String>,
113    /// True only for the builder that constructs the *principal* result
114    /// document.  Sub-builders that materialise variable bodies / RTFs
115    /// / temp trees stay `false`, because XSLT 2.0 §5.7.1 permits a
116    /// parentless attribute or namespace node in a sequence-constructor
117    /// context (e.g. `as="attribute()*"`).  Only on the principal
118    /// builder does an attribute / namespace at the top level violate
119    /// XTDE0420 (a document node's content sequence is forbidden from
120    /// containing one).
121    pub is_principal_document: bool,
122    /// XSLT 2.0 §5.7.2 sequence-normalisation step 2: adjacent atomic
123    /// values in a sequence constructor get a single space inserted
124    /// between them.  `last_was_atomic` records whether the most
125    /// recent emission was an atomic-derived text; it resets on
126    /// any non-atomic emit (literal text, element, comment, …) and
127    /// at element open/close (each element body is its own
128    /// sequence constructor).
129    pub last_was_atomic: bool,
130}
131
132impl ResultBuilder {
133    pub fn new() -> Self { Self::default() }
134
135    /// Open a new element.  Until the matching [`close_element`]
136    /// call, every emitted child/attribute attaches to this one.
137    ///
138    /// XSLT 1.0 namespace fixup: when the new element is in no
139    /// namespace but the inherited default namespace is non-empty,
140    /// record an `xmlns=""` undeclaration so the serialiser doesn't
141    /// silently fold the element back into the inherited default.
142    pub fn open_element(&mut self, name: QName) {
143        let needs_default_undecl = name.uri.is_empty()
144            && name.prefix.is_none()
145            && self.inherited_default_namespace()
146                .is_some_and(|d| !d.is_empty());
147        self.stack.push(ResultNode::Element {
148            name,
149            namespaces: if needs_default_undecl {
150                vec![(None, String::new())]
151            } else {
152                Vec::new()
153            },
154            attributes: Vec::new(),
155            children:   Vec::new(),
156            schema_type: None,
157            attr_types: Vec::new(),
158        });
159        // A new element body opens a fresh sequence-constructor scope
160        // for atomic-separator purposes.
161        self.last_was_atomic = false;
162    }
163
164    /// Record the schema type `(ns, local)` of the element currently
165    /// under construction (the top of the build stack) — set from an
166    /// `xsl:type=` / `type=` attribute (XSLT 2.0 §5.7.1).  No-op when
167    /// no element is open.
168    pub fn set_current_element_type(&mut self, ty: (String, String)) {
169        if let Some(ResultNode::Element { schema_type, .. }) = self.stack.last_mut() {
170            *schema_type = Some(Box::new(ty));
171        }
172    }
173
174    /// Record the schema type `(ns, local)` of an attribute named `name`
175    /// on the element currently under construction (from an
176    /// `<xsl:attribute type="…">`).  No-op when no element is open.
177    pub fn set_current_attr_type(&mut self, name: QName, ty: (String, String)) {
178        if let Some(ResultNode::Element { attr_types, .. }) = self.stack.last_mut() {
179            attr_types.retain(|(n, _)| n.uri != name.uri || n.local != name.local);
180            attr_types.push((name, Box::new(ty)));
181        }
182    }
183
184    /// The default namespace currently in scope, walking outwards
185    /// from the innermost open element.  `None` when no default
186    /// declaration has been emitted on any ancestor.
187    fn inherited_default_namespace(&self) -> Option<&str> {
188        for n in self.stack.iter().rev() {
189            if let ResultNode::Element { namespaces, .. } = n {
190                if let Some((_, u)) = namespaces.iter().find(|(p, _)| p.is_none()) {
191                    return Some(u);
192                }
193            }
194        }
195        None
196    }
197
198    /// Close the current element and attach it to its parent (or
199    /// to the top-level if there's no parent).  Panics if no
200    /// element is open — the evaluator must balance these calls.
201    pub fn close_element(&mut self) {
202        let done = self.stack.pop().expect("close_element with empty stack");
203        self.push_node(done);
204        // The closed element itself counts as a non-atomic emission
205        // in the parent's sequence constructor.
206        self.last_was_atomic = false;
207    }
208
209    /// Append an attribute to the current element.  Per XSLT 1.0
210    /// §7.1.3, emitting `xsl:attribute` after content children is
211    /// legal — we just ignore the spec's "should be before children"
212    /// note and accept any order.  If an attribute of the same name
213    /// already exists, the later one wins (also per spec).
214    ///
215    /// XSLT 1.0 namespace fixup for attributes: an attribute with a
216    /// non-empty namespace URI but no prefix can't be serialised as
217    /// XML (the default namespace doesn't apply to attributes — XML
218    /// Names §6.2).  Synthesize a fresh prefix (`ns0`, `ns1`, …) and
219    /// declare it on the owning element so the serialiser produces
220    /// well-formed output.
221    pub fn push_attribute(&mut self, mut name: QName, value: String) {
222        // Parentless attribute: no element is under construction (the
223        // attribute is being produced directly into a sequence /
224        // variable body).  Emit it as a standalone node rather than
225        // dropping it — copy-of / apply-templates will consume it.
226        if !matches!(self.stack.last(), Some(ResultNode::Element { .. })) {
227            // XSLT 2.0 §5.7.1 / XTDE0420 — on the *principal* result
228            // document, the content sequence may not contain attribute
229            // (or namespace) nodes.  Sub-builders for variable bodies
230            // legitimately collect parentless attributes.
231            if self.is_principal_document && self.deferred_error.is_none() {
232                self.deferred_error = Some(format!(
233                    "result document content contains attribute '{}' \
234                     (XTDE0420)", name.local));
235            }
236            self.top.push(ResultNode::Attribute { name, value });
237            return;
238        }
239        // XSLT 2.0 §5.7.1 / XTDE0410 — once non-attribute / non-
240        // namespace content has been emitted into an element, no
241        // further attributes may be added to it.
242        if matches!(self.stack.last(),
243            Some(ResultNode::Element { children, .. }) if !children.is_empty())
244            && self.deferred_error.is_none()
245        {
246            self.deferred_error = Some(format!(
247                "result sequence places attribute '{}' after element \
248                 content (XTDE0410)", name.local));
249        }
250        if name.prefix.is_none() && !name.uri.is_empty() {
251            let chosen = self.synthesize_prefix_for(&name.uri);
252            self.push_namespace_decl(Some(chosen.clone()), name.uri.clone());
253            name.prefix = Some(chosen);
254        } else if let Some(prefix) = &name.prefix {
255            // XML Names §6.2: a prefixed attribute's prefix MUST be
256            // in scope on its owning element.  When the attribute is
257            // being copied onto an element constructed by
258            // `xsl:element` / `xsl:copy` that doesn't itself declare
259            // the prefix (or worse, shadows it with a different
260            // URI), emit the missing `xmlns:prefix` here.
261            if !name.uri.is_empty() && prefix != "xml" {
262                let in_scope: Option<String> = self.stack.iter().rev().find_map(|n| match n {
263                    ResultNode::Element { namespaces, .. } => namespaces
264                        .iter()
265                        .find(|(p, _)| p.as_deref() == Some(prefix.as_str()))
266                        .map(|(_, u)| u.clone()),
267                    _ => None,
268                });
269                match in_scope.as_deref() {
270                    Some(u) if u == name.uri => {} // already bound, nothing to do
271                    None => {
272                        // Prefix unused — bring it into scope.
273                        self.push_namespace_decl(Some(prefix.clone()), name.uri.clone());
274                    }
275                    Some(_) => {
276                        // XSLT 2.0 §5.7.3 namespace-fixup: the requested
277                        // prefix is bound to a *different* URI here, so
278                        // we can't re-bind it without breaking the
279                        // colliding declaration.  Mint a `prefix_N` that
280                        // isn't taken on this element and use it instead.
281                        let fresh = self.synthesize_prefix_like(prefix, &name.uri);
282                        self.push_namespace_decl(Some(fresh.clone()), name.uri.clone());
283                        name.prefix = Some(fresh);
284                    }
285                }
286            }
287        }
288        let Some(ResultNode::Element { attributes, .. }) = self.stack.last_mut() else {
289            // Attribute outside any element — XSLT says this is an
290            // error, but we tolerate it by silently dropping.  The
291            // engine catches structural cases earlier.
292            return;
293        };
294        // Replace existing attribute with same expanded name.
295        if let Some(slot) = attributes.iter_mut()
296            .find(|(n, _)| n.uri == name.uri && n.local == name.local)
297        {
298            slot.1 = value;
299        } else {
300            attributes.push((name, value));
301        }
302    }
303
304    /// Find a prefix that already maps to `uri` anywhere on the
305    /// open-element stack, or coin a fresh `nsN` that doesn't
306    /// collide with any in-scope prefix.
307    fn synthesize_prefix_for(&self, uri: &str) -> String {
308        // Reuse an existing binding when one is in scope.
309        for n in self.stack.iter().rev() {
310            if let ResultNode::Element { namespaces, .. } = n {
311                for (p, u) in namespaces {
312                    if let Some(p) = p {
313                        if u == uri { return p.clone(); }
314                    }
315                }
316            }
317        }
318        // Coin a fresh prefix that isn't already declared in scope.
319        let used: std::collections::HashSet<String> = self.stack.iter()
320            .filter_map(|n| match n {
321                ResultNode::Element { namespaces, .. } => Some(namespaces),
322                _ => None,
323            })
324            .flat_map(|ns| ns.iter().filter_map(|(p, _)| p.clone()))
325            .collect();
326        (0..)
327            .map(|i| format!("ns{i}"))
328            .find(|p| !used.contains(p))
329            .expect("infinite iterator finds an unused prefix")
330    }
331
332    /// Like [`synthesize_prefix_for`] but derives the candidate from a
333    /// user-supplied `hint` — `prefix_1`, `prefix_2`, … — so the new
334    /// binding stays visually related to the prefix the stylesheet
335    /// asked for.  Used by namespace-fixup when an `xsl:attribute`
336    /// requests a prefix already bound to a different URI: we can't
337    /// re-bind without conflict, so we mint a fresh `hint_N`.
338    fn synthesize_prefix_like(&self, hint: &str, uri: &str) -> String {
339        // Reuse an existing binding when one is in scope (same logic
340        // as synthesize_prefix_for — a prefix already mapping to this
341        // URI is the cheapest valid choice).
342        for n in self.stack.iter().rev() {
343            if let ResultNode::Element { namespaces, .. } = n {
344                for (p, u) in namespaces {
345                    if let Some(p) = p {
346                        if u == uri { return p.clone(); }
347                    }
348                }
349            }
350        }
351        let used: std::collections::HashSet<String> = self.stack.iter()
352            .filter_map(|n| match n {
353                ResultNode::Element { namespaces, .. } => Some(namespaces),
354                _ => None,
355            })
356            .flat_map(|ns| ns.iter().filter_map(|(p, _)| p.clone()))
357            .collect();
358        (1..)
359            .map(|i| format!("{hint}_{i}"))
360            .find(|p| !used.contains(p))
361            .expect("infinite iterator finds an unused prefix")
362    }
363
364    /// Declare a namespace on the current element.  Inherited
365    /// bindings — those whose nearest ancestor declaration is
366    /// already this same `(prefix, uri)` pair — are skipped so the
367    /// serialiser doesn't repeat them on every child.  A closer
368    /// declaration with a *different* URI shadows the outer one, in
369    /// which case this binding has to be re-emitted to undo the
370    /// shadowing.
371    /// Namespace URI of the element currently being built (the innermost
372    /// open element), or `None` when no element is open.  Used to detect
373    /// a default-namespace declaration on a no-namespace element
374    /// (XTDE0440).
375    pub fn current_element_uri(&self) -> Option<&str> {
376        self.stack.iter().rev().find_map(|n| match n {
377            ResultNode::Element { name, .. } => Some(name.uri.as_str()),
378            _ => None,
379        })
380    }
381
382    pub fn push_namespace_decl(&mut self, prefix: Option<String>, uri: String) {
383        // Find the nearest in-scope binding for `prefix`; the
384        // declaration is redundant only when that nearest binding
385        // already matches `uri`.
386        let nearest: Option<&str> = self.stack.iter().rev().find_map(|n| match n {
387            ResultNode::Element { namespaces, .. } =>
388                namespaces.iter().find(|(p, _)| *p == prefix).map(|(_, u)| u.as_str()),
389            _ => None,
390        });
391        if nearest == Some(uri.as_str()) {
392            return;
393        }
394        // XSLT 2.0 §5.7.1 / XTDE0410 — once non-attribute / non-
395        // namespace content has been emitted, no further namespace
396        // declarations may be added.  Same condition as push_attribute.
397        if matches!(self.stack.last(),
398            Some(ResultNode::Element { children, .. }) if !children.is_empty())
399            && self.deferred_error.is_none()
400        {
401            let pref_disp = prefix.as_deref().unwrap_or("");
402            self.deferred_error = Some(format!(
403                "result sequence places namespace declaration '{pref_disp}' \
404                 after element content (XTDE0410)"));
405        }
406        let Some(ResultNode::Element { namespaces, .. }) = self.stack.last_mut() else {
407            return;
408        };
409        // A prefix already bound to a *different* URI is left alone —
410        // the implicit-binding path (e.g. xsl:attribute fixing up its
411        // own prefix for an explicit namespace=) relies on the runtime
412        // synthesising a unique prefix elsewhere.  The strict XTDE0430
413        // check lives on `push_namespace_decl_explicit` below, which
414        // only xsl:namespace calls.
415        if namespaces.iter().any(|(p, _)| *p == prefix) {
416            return;
417        }
418        namespaces.push((prefix, uri));
419    }
420
421    /// As [`push_namespace_decl`], but enforces XSLT 2.0 §5.7.3 /
422    /// XTDE0430 — binding the same prefix to two different URIs on
423    /// one element is a dynamic error.  Called from the xsl:namespace
424    /// instruction so explicit user declarations are validated, while
425    /// the implicit fixups push_attribute performs (which may collide
426    /// with an in-scope prefix by design) stay non-fatal.
427    pub fn push_namespace_decl_explicit(
428        &mut self, prefix: Option<String>, uri: String,
429    ) {
430        let conflict = matches!(self.stack.last(),
431            Some(ResultNode::Element { namespaces, .. })
432                if namespaces.iter().any(|(p, u)| *p == prefix && *u != uri));
433        if conflict && self.deferred_error.is_none() {
434            let pref_disp = prefix.as_deref().unwrap_or("");
435            self.deferred_error = Some(format!(
436                "result sequence binds the namespace prefix '{pref_disp}' \
437                 to two different URIs on one element (XTDE0430)"));
438            return;
439        }
440        self.push_namespace_decl(prefix, uri);
441    }
442
443    /// Emit a text node.  Adjacent text nodes are merged
444    /// automatically — XSLT 1.0 §7.2 says contiguous character data
445    /// in the result tree is treated as a single text node.
446    pub fn push_text(&mut self, content: String, dose: bool) {
447        if content.is_empty() { return; }
448        // `no_text_merge` only suppresses merging at the OUTER level
449        // (no element open) — inside an element body, XSLT 2.0
450        // §5.7.2's text-node merging still applies (an LRE inside
451        // an `as="element()*"` variable body normalises its own
452        // content), so we always merge under an open element.
453        let allow_merge = !self.no_text_merge || !self.stack.is_empty();
454        if allow_merge {
455            if let Some(slot) = self.current_children_mut() {
456                if let Some(ResultNode::Text { content: last, dose: last_dose }) = slot.last_mut() {
457                    if *last_dose == dose {
458                        last.push_str(&content);
459                        // Any literal/copied text breaks the
460                        // atomic-adjacency rule (text node, not value).
461                        self.last_was_atomic = false;
462                        return;
463                    }
464                }
465            }
466        }
467        self.last_was_atomic = false;
468        self.push_node(ResultNode::Text { content, dose });
469    }
470
471    /// Emit a text node whose content is an atomised XPath value
472    /// (number, string, boolean, typed atomic).  XSLT 2.0 §5.7.2
473    /// sequence normalisation inserts a single space between
474    /// adjacent atomic values in a sequence constructor — copies of
475    /// real text/element nodes and literal `xsl:text` don't count.
476    /// Tracking the "last emit was atomic" flag on the builder lets
477    /// us slot that space in without reshaping the value model.
478    pub fn push_atomic_text(&mut self, content: String) {
479        if content.is_empty() {
480            // An empty atomic still counts — `("", "")` should
481            // normalise to a single space — so flip the flag.
482            self.last_was_atomic = true;
483            return;
484        }
485        if self.last_was_atomic {
486            // Same merging rules as a literal text node, but
487            // prefixed with a single space.
488            self.push_text(format!(" {content}"), false);
489        } else {
490            self.push_text(content, false);
491        }
492        self.last_was_atomic = true;
493    }
494
495    pub fn push_comment(&mut self, content: String) {
496        self.push_node(ResultNode::Comment(content));
497    }
498
499    pub fn push_pi(&mut self, target: String, data: String) {
500        self.push_node(ResultNode::ProcessingInstruction { target, data });
501    }
502
503    fn push_node(&mut self, node: ResultNode) {
504        match self.stack.last_mut() {
505            Some(ResultNode::Element { children, .. }) => children.push(node),
506            _ => self.top.push(node),
507        }
508    }
509
510    /// Splice an already-built result node into the output at the
511    /// current position (respecting any open element).  Used to replay
512    /// a captured sub-tree — e.g. the retained content of
513    /// `xsl:where-populated`.
514    pub fn push_built_node(&mut self, node: ResultNode) {
515        self.push_node(node);
516    }
517
518    fn current_children_mut(&mut self) -> Option<&mut Vec<ResultNode>> {
519        match self.stack.last_mut() {
520            Some(ResultNode::Element { children, .. }) => Some(children),
521            _ => Some(&mut self.top),
522        }
523    }
524
525    /// Consume the builder, returning the final list of top-level
526    /// result-tree children.  Any still-open elements are silently
527    /// closed — the evaluator should never leave an unclosed element
528    /// on a normal-completion path; this guard prevents data loss
529    /// on the error paths.
530    pub fn finish(mut self) -> Vec<ResultNode> {
531        while !self.stack.is_empty() {
532            self.close_element();
533        }
534        self.top
535    }
536
537    /// True when nothing has been written to this builder yet — no
538    /// completed top-level nodes and no element currently open.  Used to
539    /// decide whether an `xsl:result-document` targeting the principal
540    /// URI is the sole writer of that destination.
541    pub fn is_empty(&self) -> bool {
542        self.top.is_empty() && self.stack.is_empty()
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::ast::QName;
550
551    fn qn(local: &str) -> QName {
552        QName { prefix: None, local: local.to_string(), uri: String::new() }
553    }
554
555    #[test]
556    fn empty_builder_finishes_with_no_nodes() {
557        let b = ResultBuilder::new();
558        assert!(b.finish().is_empty());
559    }
560
561    #[test]
562    fn nested_elements_attach_to_parent() {
563        let mut b = ResultBuilder::new();
564        b.open_element(qn("outer"));
565        b.open_element(qn("inner"));
566        b.close_element();
567        b.close_element();
568        let nodes = b.finish();
569        assert_eq!(nodes.len(), 1);
570        match &nodes[0] {
571            ResultNode::Element { children, .. } => assert_eq!(children.len(), 1),
572            _ => panic!(),
573        }
574    }
575
576    #[test]
577    fn adjacent_text_merges() {
578        let mut b = ResultBuilder::new();
579        b.open_element(qn("p"));
580        b.push_text("Hello, ".into(), false);
581        b.push_text("world!".into(), false);
582        b.close_element();
583        let nodes = b.finish();
584        match &nodes[0] {
585            ResultNode::Element { children, .. } => {
586                assert_eq!(children.len(), 1, "should have merged two texts");
587                if let ResultNode::Text { content, .. } = &children[0] {
588                    assert_eq!(content, "Hello, world!");
589                }
590            }
591            _ => panic!(),
592        }
593    }
594
595    #[test]
596    fn unbalanced_open_is_recovered_in_finish() {
597        let mut b = ResultBuilder::new();
598        b.open_element(qn("oops"));
599        let nodes = b.finish();
600        assert_eq!(nodes.len(), 1);
601    }
602
603    #[test]
604    fn push_attribute_outside_element_emits_standalone_node() {
605        let mut b = ResultBuilder::new();
606        // No open element — the attribute becomes a parentless
607        // attribute node (consumed later by copy-of / apply-templates)
608        // rather than being dropped.
609        b.push_attribute(qn("id"), "x".into());
610        let top = b.finish();
611        assert!(matches!(top.as_slice(),
612            [ResultNode::Attribute { name, value }]
613                if name.local == "id" && value == "x"));
614    }
615
616    #[test]
617    fn push_attribute_replaces_same_expanded_name() {
618        let mut b = ResultBuilder::new();
619        b.open_element(qn("r"));
620        b.push_attribute(qn("id"), "first".into());
621        b.push_attribute(qn("id"), "second".into());
622        b.close_element();
623        let nodes = b.finish();
624        match &nodes[0] {
625            ResultNode::Element { attributes, .. } => {
626                assert_eq!(attributes.len(), 1, "duplicate names should merge");
627                assert_eq!(attributes[0].1, "second", "later attribute wins");
628            }
629            _ => panic!(),
630        }
631    }
632
633    #[test]
634    fn push_namespace_decl_outside_element_is_silent_noop() {
635        let mut b = ResultBuilder::new();
636        b.push_namespace_decl(Some("foo".into()), "urn:foo".into());
637        assert!(b.finish().is_empty());
638    }
639
640    #[test]
641    fn push_namespace_decl_dedupes_same_prefix() {
642        let mut b = ResultBuilder::new();
643        b.open_element(qn("r"));
644        b.push_namespace_decl(Some("ns".into()), "urn:n1".into());
645        // Same prefix again → should be deduped (first declaration wins).
646        b.push_namespace_decl(Some("ns".into()), "urn:n2".into());
647        b.close_element();
648        let nodes = b.finish();
649        match &nodes[0] {
650            ResultNode::Element { namespaces, .. } => {
651                assert_eq!(namespaces.len(), 1);
652                assert_eq!(namespaces[0].1, "urn:n1");
653            }
654            _ => panic!(),
655        }
656    }
657
658    #[test]
659    fn push_text_empty_is_noop() {
660        let mut b = ResultBuilder::new();
661        b.open_element(qn("r"));
662        b.push_text(String::new(), false);
663        b.close_element();
664        match &b.finish()[0] {
665            ResultNode::Element { children, .. } => assert!(children.is_empty()),
666            _ => panic!(),
667        }
668    }
669
670    #[test]
671    fn push_text_with_different_dose_does_not_merge() {
672        let mut b = ResultBuilder::new();
673        b.open_element(qn("r"));
674        b.push_text("a".into(), false);
675        b.push_text("b".into(), true);  // different dose → don't merge
676        b.close_element();
677        match &b.finish()[0] {
678            ResultNode::Element { children, .. } => assert_eq!(children.len(), 2),
679            _ => panic!(),
680        }
681    }
682
683    #[test]
684    fn push_text_at_top_level_when_no_element_open() {
685        // current_children_mut() returns &mut self.top when stack is empty.
686        let mut b = ResultBuilder::new();
687        b.push_text("hello".into(), false);
688        b.push_text(" world".into(), false);
689        let nodes = b.finish();
690        // Two texts at top level should merge (same dose) — verifies the
691        // current_children_mut top-level branch is taken.
692        assert_eq!(nodes.len(), 1);
693        match &nodes[0] {
694            ResultNode::Text { content, .. } => assert_eq!(content, "hello world"),
695            _ => panic!(),
696        }
697    }
698
699    #[test]
700    fn push_comment_at_top_level() {
701        let mut b = ResultBuilder::new();
702        b.push_comment(" hi ".into());
703        let nodes = b.finish();
704        match &nodes[0] {
705            ResultNode::Comment(s) => assert_eq!(s, " hi "),
706            _ => panic!(),
707        }
708    }
709
710    #[test]
711    fn push_pi_at_top_level() {
712        let mut b = ResultBuilder::new();
713        b.push_pi("target".into(), "data".into());
714        let nodes = b.finish();
715        match &nodes[0] {
716            ResultNode::ProcessingInstruction { target, data } => {
717                assert_eq!(target, "target");
718                assert_eq!(data, "data");
719            }
720            _ => panic!(),
721        }
722    }
723
724    #[test]
725    fn push_pi_inside_element() {
726        let mut b = ResultBuilder::new();
727        b.open_element(qn("r"));
728        b.push_pi("php".into(), "echo".into());
729        b.close_element();
730        match &b.finish()[0] {
731            ResultNode::Element { children, .. } => {
732                assert!(matches!(&children[0],
733                    ResultNode::ProcessingInstruction { target, .. } if target == "php"));
734            }
735            _ => panic!(),
736        }
737    }
738}