sup-xml-xslt 1.2.0

XSLT 1.0 transformation engine built on sup-xml's XPath core
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
//! Lightweight intermediate result-tree representation.
//!
//! The XSLT evaluator builds a [`ResultTree`] in memory; the
//! output serialisers in [`crate::output`] consume it and emit
//! XML / HTML / text bytes per the effective `<xsl:output>` settings.
//!
//! We deliberately don't reuse `sup_xml_tree::dom` here — that's
//! tuned for libxml2 ABI shape and arena allocation, and XSLT
//! result trees are typically short-lived and ad-hoc.  A
//! straightforward `Vec`-backed tree is easier to build
//! incrementally (the eval engine pushes nodes into a "current
//! container" stack) and easier to serialise.

use crate::ast::QName;

/// One node in an in-progress result tree.  Element children
/// recurse via the `children` vec; attributes hang off the
/// element separately because XPath / serialisation treat them
/// differently from content children.
#[derive(Clone, Debug)]
pub enum ResultNode {
    Element {
        name:       QName,
        /// (prefix, URI) namespace declarations to emit on this
        /// element.  May be empty — by default we inherit from the
        /// parent and only emit declarations that introduce
        /// changes.
        namespaces: Vec<(Option<String>, String)>,
        attributes: Vec<(QName, String)>,
        children:   Vec<ResultNode>,
        /// Schema-aware: the expanded name `(ns, local)` of the type
        /// this element was constructed as — from a `type=` / `xsl:type=`
        /// attribute or a `validation=` mode.  `None` for the ordinary
        /// untyped case.  Carried through RTF indexing so a constructed
        /// node's typed value is recoverable by `data()` /
        /// `instance of` (XSLT 2.0 §5.7.1, annotation-for-constructed-
        /// element).
        schema_type: Option<Box<(String, String)>>,
        /// Schema-aware: governing type `(ns, local)` for any of this
        /// element's attributes constructed with a `type=` annotation,
        /// keyed by attribute name.  Sparse — empty for the ordinary
        /// untyped case.  Recorded into the RTF PSVI table alongside the
        /// attribute node so `data(@a)` / `@a instance of …` see the
        /// declared type.
        attr_types: Vec<(QName, Box<(String, String)>)>,
    },
    Text {
        content: String,
        /// `disable-output-escaping` — when `true`, the serialiser
        /// emits the content verbatim (no `&amp;` / `&lt;`).  Used
        /// by `<xsl:text disable-output-escaping="yes"/>` and
        /// `<xsl:value-of disable-output-escaping="yes"/>`.
        dose:    bool,
    },
    Comment(String),
    ProcessingInstruction { target: String, data: String },
    /// A parentless attribute node — the value of an `xsl:attribute`
    /// evaluated with no element under construction (XSLT 2.0 §5.7.1
    /// sequence constructors, e.g. an `as="attribute()*"` variable).
    /// `xsl:copy-of` / `xsl:apply-templates` consume it; it never
    /// appears among an element's children (element attributes live in
    /// the `attributes` vec).
    Attribute { name: QName, value: String },
}

/// The complete result of a transformation.  Carries the
/// serialised top-level children plus the effective
/// `<xsl:output>` settings the serialiser will honour.
#[derive(Clone, Debug, Default)]
pub struct ResultTree {
    pub children: Vec<ResultNode>,
    pub output:   crate::ast::OutputSpec,
    /// Flattened `xsl:character-map` substitutions selected by
    /// `xsl:output use-character-maps="…"` (XSLT 2.0 §20).  Empty
    /// for stylesheets that don't use character maps; populated at
    /// apply time after composing every referenced map.  The
    /// serializer consults this list per emitted character.
    pub character_map: Vec<(char, String)>,
    /// Secondary result documents written by `xsl:result-document
    /// href="…"` (XSLT 2.0 §19.1): `(resolved-href, document)`.  Empty
    /// unless the stylesheet produced secondary output.
    pub secondary: Vec<(String, ResultTree)>,
}

/// Builder helper — maintains the "currently being built" element
/// stack so the evaluator can emit nodes one at a time without
/// constructing the tree bottom-up.
#[derive(Debug, Default)]
pub struct ResultBuilder {
    /// Stack of in-progress elements.  Each frame is the
    /// element under construction; closing it pops the frame and
    /// pushes the completed element as a child of the next frame
    /// (or into the top-level if the stack is now empty).
    stack:    Vec<ResultNode>,
    /// Top-level result nodes accumulated so far.
    pub top:  Vec<ResultNode>,
    /// XSLT 2.0 §5.7.2 sequence normalisation merges adjacent
    /// text fragments when building the result tree of a template
    /// or a no-`as` RTF — that's the default behaviour.  Variable
    /// bodies declared `as="item()*"` (or any sequence type) skip
    /// normalisation per §9.3: each `xsl:text` contributes its
    /// own text-node item, three of them stay three items, and
    /// `count($var)` answers `3`.  Setting this flag disables the
    /// merge.
    pub no_text_merge: bool,
    /// First sequence-construction error observed (XSLT 2.0 §5.7.1 —
    /// e.g. XTDE0410, an attribute or namespace node landing in an
    /// element's content after a non-attribute/non-namespace node).
    /// Stashed here because the affected builder methods are infallible
    /// at their call sites; the surrounding `apply` consults this
    /// after the construction unwinds and surfaces it as a real error.
    pub deferred_error: Option<String>,
    /// True only for the builder that constructs the *principal* result
    /// document.  Sub-builders that materialise variable bodies / RTFs
    /// / temp trees stay `false`, because XSLT 2.0 §5.7.1 permits a
    /// parentless attribute or namespace node in a sequence-constructor
    /// context (e.g. `as="attribute()*"`).  Only on the principal
    /// builder does an attribute / namespace at the top level violate
    /// XTDE0420 (a document node's content sequence is forbidden from
    /// containing one).
    pub is_principal_document: bool,
    /// XSLT 2.0 §5.7.2 sequence-normalisation step 2: adjacent atomic
    /// values in a sequence constructor get a single space inserted
    /// between them.  `last_was_atomic` records whether the most
    /// recent emission was an atomic-derived text; it resets on
    /// any non-atomic emit (literal text, element, comment, …) and
    /// at element open/close (each element body is its own
    /// sequence constructor).
    pub last_was_atomic: bool,
}

impl ResultBuilder {
    pub fn new() -> Self { Self::default() }

    /// Open a new element.  Until the matching [`close_element`]
    /// call, every emitted child/attribute attaches to this one.
    ///
    /// XSLT 1.0 namespace fixup: when the new element is in no
    /// namespace but the inherited default namespace is non-empty,
    /// record an `xmlns=""` undeclaration so the serialiser doesn't
    /// silently fold the element back into the inherited default.
    pub fn open_element(&mut self, name: QName) {
        let needs_default_undecl = name.uri.is_empty()
            && name.prefix.is_none()
            && self.inherited_default_namespace()
                .is_some_and(|d| !d.is_empty());
        self.stack.push(ResultNode::Element {
            name,
            namespaces: if needs_default_undecl {
                vec![(None, String::new())]
            } else {
                Vec::new()
            },
            attributes: Vec::new(),
            children:   Vec::new(),
            schema_type: None,
            attr_types: Vec::new(),
        });
        // A new element body opens a fresh sequence-constructor scope
        // for atomic-separator purposes.
        self.last_was_atomic = false;
    }

    /// Record the schema type `(ns, local)` of the element currently
    /// under construction (the top of the build stack) — set from an
    /// `xsl:type=` / `type=` attribute (XSLT 2.0 §5.7.1).  No-op when
    /// no element is open.
    pub fn set_current_element_type(&mut self, ty: (String, String)) {
        if let Some(ResultNode::Element { schema_type, .. }) = self.stack.last_mut() {
            *schema_type = Some(Box::new(ty));
        }
    }

    /// Record the schema type `(ns, local)` of an attribute named `name`
    /// on the element currently under construction (from an
    /// `<xsl:attribute type="…">`).  No-op when no element is open.
    pub fn set_current_attr_type(&mut self, name: QName, ty: (String, String)) {
        if let Some(ResultNode::Element { attr_types, .. }) = self.stack.last_mut() {
            attr_types.retain(|(n, _)| n.uri != name.uri || n.local != name.local);
            attr_types.push((name, Box::new(ty)));
        }
    }

    /// The default namespace currently in scope, walking outwards
    /// from the innermost open element.  `None` when no default
    /// declaration has been emitted on any ancestor.
    fn inherited_default_namespace(&self) -> Option<&str> {
        for n in self.stack.iter().rev() {
            if let ResultNode::Element { namespaces, .. } = n {
                if let Some((_, u)) = namespaces.iter().find(|(p, _)| p.is_none()) {
                    return Some(u);
                }
            }
        }
        None
    }

    /// Close the current element and attach it to its parent (or
    /// to the top-level if there's no parent).  Panics if no
    /// element is open — the evaluator must balance these calls.
    pub fn close_element(&mut self) {
        let done = self.stack.pop().expect("close_element with empty stack");
        self.push_node(done);
        // The closed element itself counts as a non-atomic emission
        // in the parent's sequence constructor.
        self.last_was_atomic = false;
    }

    /// Append an attribute to the current element.  Per XSLT 1.0
    /// §7.1.3, emitting `xsl:attribute` after content children is
    /// legal — we just ignore the spec's "should be before children"
    /// note and accept any order.  If an attribute of the same name
    /// already exists, the later one wins (also per spec).
    ///
    /// XSLT 1.0 namespace fixup for attributes: an attribute with a
    /// non-empty namespace URI but no prefix can't be serialised as
    /// XML (the default namespace doesn't apply to attributes — XML
    /// Names §6.2).  Synthesize a fresh prefix (`ns0`, `ns1`, …) and
    /// declare it on the owning element so the serialiser produces
    /// well-formed output.
    pub fn push_attribute(&mut self, mut name: QName, value: String) {
        // Parentless attribute: no element is under construction (the
        // attribute is being produced directly into a sequence /
        // variable body).  Emit it as a standalone node rather than
        // dropping it — copy-of / apply-templates will consume it.
        if !matches!(self.stack.last(), Some(ResultNode::Element { .. })) {
            // XSLT 2.0 §5.7.1 / XTDE0420 — on the *principal* result
            // document, the content sequence may not contain attribute
            // (or namespace) nodes.  Sub-builders for variable bodies
            // legitimately collect parentless attributes.
            if self.is_principal_document && self.deferred_error.is_none() {
                self.deferred_error = Some(format!(
                    "result document content contains attribute '{}' \
                     (XTDE0420)", name.local));
            }
            self.top.push(ResultNode::Attribute { name, value });
            return;
        }
        // XSLT 2.0 §5.7.1 / XTDE0410 — once non-attribute / non-
        // namespace content has been emitted into an element, no
        // further attributes may be added to it.
        if matches!(self.stack.last(),
            Some(ResultNode::Element { children, .. }) if !children.is_empty())
            && self.deferred_error.is_none()
        {
            self.deferred_error = Some(format!(
                "result sequence places attribute '{}' after element \
                 content (XTDE0410)", name.local));
        }
        if name.prefix.is_none() && !name.uri.is_empty() {
            let chosen = self.synthesize_prefix_for(&name.uri);
            self.push_namespace_decl(Some(chosen.clone()), name.uri.clone());
            name.prefix = Some(chosen);
        } else if let Some(prefix) = &name.prefix {
            // XML Names §6.2: a prefixed attribute's prefix MUST be
            // in scope on its owning element.  When the attribute is
            // being copied onto an element constructed by
            // `xsl:element` / `xsl:copy` that doesn't itself declare
            // the prefix (or worse, shadows it with a different
            // URI), emit the missing `xmlns:prefix` here.
            if !name.uri.is_empty() && prefix != "xml" {
                let in_scope: Option<String> = self.stack.iter().rev().find_map(|n| match n {
                    ResultNode::Element { namespaces, .. } => namespaces
                        .iter()
                        .find(|(p, _)| p.as_deref() == Some(prefix.as_str()))
                        .map(|(_, u)| u.clone()),
                    _ => None,
                });
                match in_scope.as_deref() {
                    Some(u) if u == name.uri => {} // already bound, nothing to do
                    None => {
                        // Prefix unused — bring it into scope.
                        self.push_namespace_decl(Some(prefix.clone()), name.uri.clone());
                    }
                    Some(_) => {
                        // XSLT 2.0 §5.7.3 namespace-fixup: the requested
                        // prefix is bound to a *different* URI here, so
                        // we can't re-bind it without breaking the
                        // colliding declaration.  Mint a `prefix_N` that
                        // isn't taken on this element and use it instead.
                        let fresh = self.synthesize_prefix_like(prefix, &name.uri);
                        self.push_namespace_decl(Some(fresh.clone()), name.uri.clone());
                        name.prefix = Some(fresh);
                    }
                }
            }
        }
        let Some(ResultNode::Element { attributes, .. }) = self.stack.last_mut() else {
            // Attribute outside any element — XSLT says this is an
            // error, but we tolerate it by silently dropping.  The
            // engine catches structural cases earlier.
            return;
        };
        // Replace existing attribute with same expanded name.
        if let Some(slot) = attributes.iter_mut()
            .find(|(n, _)| n.uri == name.uri && n.local == name.local)
        {
            slot.1 = value;
        } else {
            attributes.push((name, value));
        }
    }

    /// Find a prefix that already maps to `uri` anywhere on the
    /// open-element stack, or coin a fresh `nsN` that doesn't
    /// collide with any in-scope prefix.
    fn synthesize_prefix_for(&self, uri: &str) -> String {
        // Reuse an existing binding when one is in scope.
        for n in self.stack.iter().rev() {
            if let ResultNode::Element { namespaces, .. } = n {
                for (p, u) in namespaces {
                    if let Some(p) = p {
                        if u == uri { return p.clone(); }
                    }
                }
            }
        }
        // Coin a fresh prefix that isn't already declared in scope.
        let used: std::collections::HashSet<String> = self.stack.iter()
            .filter_map(|n| match n {
                ResultNode::Element { namespaces, .. } => Some(namespaces),
                _ => None,
            })
            .flat_map(|ns| ns.iter().filter_map(|(p, _)| p.clone()))
            .collect();
        (0..)
            .map(|i| format!("ns{i}"))
            .find(|p| !used.contains(p))
            .expect("infinite iterator finds an unused prefix")
    }

    /// Like [`synthesize_prefix_for`] but derives the candidate from a
    /// user-supplied `hint` — `prefix_1`, `prefix_2`, … — so the new
    /// binding stays visually related to the prefix the stylesheet
    /// asked for.  Used by namespace-fixup when an `xsl:attribute`
    /// requests a prefix already bound to a different URI: we can't
    /// re-bind without conflict, so we mint a fresh `hint_N`.
    fn synthesize_prefix_like(&self, hint: &str, uri: &str) -> String {
        // Reuse an existing binding when one is in scope (same logic
        // as synthesize_prefix_for — a prefix already mapping to this
        // URI is the cheapest valid choice).
        for n in self.stack.iter().rev() {
            if let ResultNode::Element { namespaces, .. } = n {
                for (p, u) in namespaces {
                    if let Some(p) = p {
                        if u == uri { return p.clone(); }
                    }
                }
            }
        }
        let used: std::collections::HashSet<String> = self.stack.iter()
            .filter_map(|n| match n {
                ResultNode::Element { namespaces, .. } => Some(namespaces),
                _ => None,
            })
            .flat_map(|ns| ns.iter().filter_map(|(p, _)| p.clone()))
            .collect();
        (1..)
            .map(|i| format!("{hint}_{i}"))
            .find(|p| !used.contains(p))
            .expect("infinite iterator finds an unused prefix")
    }

    /// Declare a namespace on the current element.  Inherited
    /// bindings — those whose nearest ancestor declaration is
    /// already this same `(prefix, uri)` pair — are skipped so the
    /// serialiser doesn't repeat them on every child.  A closer
    /// declaration with a *different* URI shadows the outer one, in
    /// which case this binding has to be re-emitted to undo the
    /// shadowing.
    /// Namespace URI of the element currently being built (the innermost
    /// open element), or `None` when no element is open.  Used to detect
    /// a default-namespace declaration on a no-namespace element
    /// (XTDE0440).
    pub fn current_element_uri(&self) -> Option<&str> {
        self.stack.iter().rev().find_map(|n| match n {
            ResultNode::Element { name, .. } => Some(name.uri.as_str()),
            _ => None,
        })
    }

    pub fn push_namespace_decl(&mut self, prefix: Option<String>, uri: String) {
        // Find the nearest in-scope binding for `prefix`; the
        // declaration is redundant only when that nearest binding
        // already matches `uri`.
        let nearest: Option<&str> = self.stack.iter().rev().find_map(|n| match n {
            ResultNode::Element { namespaces, .. } =>
                namespaces.iter().find(|(p, _)| *p == prefix).map(|(_, u)| u.as_str()),
            _ => None,
        });
        if nearest == Some(uri.as_str()) {
            return;
        }
        // XSLT 2.0 §5.7.1 / XTDE0410 — once non-attribute / non-
        // namespace content has been emitted, no further namespace
        // declarations may be added.  Same condition as push_attribute.
        if matches!(self.stack.last(),
            Some(ResultNode::Element { children, .. }) if !children.is_empty())
            && self.deferred_error.is_none()
        {
            let pref_disp = prefix.as_deref().unwrap_or("");
            self.deferred_error = Some(format!(
                "result sequence places namespace declaration '{pref_disp}' \
                 after element content (XTDE0410)"));
        }
        let Some(ResultNode::Element { namespaces, .. }) = self.stack.last_mut() else {
            return;
        };
        // A prefix already bound to a *different* URI is left alone —
        // the implicit-binding path (e.g. xsl:attribute fixing up its
        // own prefix for an explicit namespace=) relies on the runtime
        // synthesising a unique prefix elsewhere.  The strict XTDE0430
        // check lives on `push_namespace_decl_explicit` below, which
        // only xsl:namespace calls.
        if namespaces.iter().any(|(p, _)| *p == prefix) {
            return;
        }
        namespaces.push((prefix, uri));
    }

    /// As [`push_namespace_decl`], but enforces XSLT 2.0 §5.7.3 /
    /// XTDE0430 — binding the same prefix to two different URIs on
    /// one element is a dynamic error.  Called from the xsl:namespace
    /// instruction so explicit user declarations are validated, while
    /// the implicit fixups push_attribute performs (which may collide
    /// with an in-scope prefix by design) stay non-fatal.
    pub fn push_namespace_decl_explicit(
        &mut self, prefix: Option<String>, uri: String,
    ) {
        let conflict = matches!(self.stack.last(),
            Some(ResultNode::Element { namespaces, .. })
                if namespaces.iter().any(|(p, u)| *p == prefix && *u != uri));
        if conflict && self.deferred_error.is_none() {
            let pref_disp = prefix.as_deref().unwrap_or("");
            self.deferred_error = Some(format!(
                "result sequence binds the namespace prefix '{pref_disp}' \
                 to two different URIs on one element (XTDE0430)"));
            return;
        }
        self.push_namespace_decl(prefix, uri);
    }

    /// Emit a text node.  Adjacent text nodes are merged
    /// automatically — XSLT 1.0 §7.2 says contiguous character data
    /// in the result tree is treated as a single text node.
    pub fn push_text(&mut self, content: String, dose: bool) {
        if content.is_empty() { return; }
        // `no_text_merge` only suppresses merging at the OUTER level
        // (no element open) — inside an element body, XSLT 2.0
        // §5.7.2's text-node merging still applies (an LRE inside
        // an `as="element()*"` variable body normalises its own
        // content), so we always merge under an open element.
        let allow_merge = !self.no_text_merge || !self.stack.is_empty();
        if allow_merge {
            if let Some(slot) = self.current_children_mut() {
                if let Some(ResultNode::Text { content: last, dose: last_dose }) = slot.last_mut() {
                    if *last_dose == dose {
                        last.push_str(&content);
                        // Any literal/copied text breaks the
                        // atomic-adjacency rule (text node, not value).
                        self.last_was_atomic = false;
                        return;
                    }
                }
            }
        }
        self.last_was_atomic = false;
        self.push_node(ResultNode::Text { content, dose });
    }

    /// Emit a text node whose content is an atomised XPath value
    /// (number, string, boolean, typed atomic).  XSLT 2.0 §5.7.2
    /// sequence normalisation inserts a single space between
    /// adjacent atomic values in a sequence constructor — copies of
    /// real text/element nodes and literal `xsl:text` don't count.
    /// Tracking the "last emit was atomic" flag on the builder lets
    /// us slot that space in without reshaping the value model.
    pub fn push_atomic_text(&mut self, content: String) {
        if content.is_empty() {
            // An empty atomic still counts — `("", "")` should
            // normalise to a single space — so flip the flag.
            self.last_was_atomic = true;
            return;
        }
        if self.last_was_atomic {
            // Same merging rules as a literal text node, but
            // prefixed with a single space.
            self.push_text(format!(" {content}"), false);
        } else {
            self.push_text(content, false);
        }
        self.last_was_atomic = true;
    }

    pub fn push_comment(&mut self, content: String) {
        self.push_node(ResultNode::Comment(content));
    }

    pub fn push_pi(&mut self, target: String, data: String) {
        self.push_node(ResultNode::ProcessingInstruction { target, data });
    }

    fn push_node(&mut self, node: ResultNode) {
        match self.stack.last_mut() {
            Some(ResultNode::Element { children, .. }) => children.push(node),
            _ => self.top.push(node),
        }
    }

    /// Splice an already-built result node into the output at the
    /// current position (respecting any open element).  Used to replay
    /// a captured sub-tree — e.g. the retained content of
    /// `xsl:where-populated`.
    pub fn push_built_node(&mut self, node: ResultNode) {
        self.push_node(node);
    }

    fn current_children_mut(&mut self) -> Option<&mut Vec<ResultNode>> {
        match self.stack.last_mut() {
            Some(ResultNode::Element { children, .. }) => Some(children),
            _ => Some(&mut self.top),
        }
    }

    /// Consume the builder, returning the final list of top-level
    /// result-tree children.  Any still-open elements are silently
    /// closed — the evaluator should never leave an unclosed element
    /// on a normal-completion path; this guard prevents data loss
    /// on the error paths.
    pub fn finish(mut self) -> Vec<ResultNode> {
        while !self.stack.is_empty() {
            self.close_element();
        }
        self.top
    }

    /// True when nothing has been written to this builder yet — no
    /// completed top-level nodes and no element currently open.  Used to
    /// decide whether an `xsl:result-document` targeting the principal
    /// URI is the sole writer of that destination.
    pub fn is_empty(&self) -> bool {
        self.top.is_empty() && self.stack.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ast::QName;

    fn qn(local: &str) -> QName {
        QName { prefix: None, local: local.to_string(), uri: String::new() }
    }

    #[test]
    fn empty_builder_finishes_with_no_nodes() {
        let b = ResultBuilder::new();
        assert!(b.finish().is_empty());
    }

    #[test]
    fn nested_elements_attach_to_parent() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("outer"));
        b.open_element(qn("inner"));
        b.close_element();
        b.close_element();
        let nodes = b.finish();
        assert_eq!(nodes.len(), 1);
        match &nodes[0] {
            ResultNode::Element { children, .. } => assert_eq!(children.len(), 1),
            _ => panic!(),
        }
    }

    #[test]
    fn adjacent_text_merges() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("p"));
        b.push_text("Hello, ".into(), false);
        b.push_text("world!".into(), false);
        b.close_element();
        let nodes = b.finish();
        match &nodes[0] {
            ResultNode::Element { children, .. } => {
                assert_eq!(children.len(), 1, "should have merged two texts");
                if let ResultNode::Text { content, .. } = &children[0] {
                    assert_eq!(content, "Hello, world!");
                }
            }
            _ => panic!(),
        }
    }

    #[test]
    fn unbalanced_open_is_recovered_in_finish() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("oops"));
        let nodes = b.finish();
        assert_eq!(nodes.len(), 1);
    }

    #[test]
    fn push_attribute_outside_element_emits_standalone_node() {
        let mut b = ResultBuilder::new();
        // No open element — the attribute becomes a parentless
        // attribute node (consumed later by copy-of / apply-templates)
        // rather than being dropped.
        b.push_attribute(qn("id"), "x".into());
        let top = b.finish();
        assert!(matches!(top.as_slice(),
            [ResultNode::Attribute { name, value }]
                if name.local == "id" && value == "x"));
    }

    #[test]
    fn push_attribute_replaces_same_expanded_name() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("r"));
        b.push_attribute(qn("id"), "first".into());
        b.push_attribute(qn("id"), "second".into());
        b.close_element();
        let nodes = b.finish();
        match &nodes[0] {
            ResultNode::Element { attributes, .. } => {
                assert_eq!(attributes.len(), 1, "duplicate names should merge");
                assert_eq!(attributes[0].1, "second", "later attribute wins");
            }
            _ => panic!(),
        }
    }

    #[test]
    fn push_namespace_decl_outside_element_is_silent_noop() {
        let mut b = ResultBuilder::new();
        b.push_namespace_decl(Some("foo".into()), "urn:foo".into());
        assert!(b.finish().is_empty());
    }

    #[test]
    fn push_namespace_decl_dedupes_same_prefix() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("r"));
        b.push_namespace_decl(Some("ns".into()), "urn:n1".into());
        // Same prefix again → should be deduped (first declaration wins).
        b.push_namespace_decl(Some("ns".into()), "urn:n2".into());
        b.close_element();
        let nodes = b.finish();
        match &nodes[0] {
            ResultNode::Element { namespaces, .. } => {
                assert_eq!(namespaces.len(), 1);
                assert_eq!(namespaces[0].1, "urn:n1");
            }
            _ => panic!(),
        }
    }

    #[test]
    fn push_text_empty_is_noop() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("r"));
        b.push_text(String::new(), false);
        b.close_element();
        match &b.finish()[0] {
            ResultNode::Element { children, .. } => assert!(children.is_empty()),
            _ => panic!(),
        }
    }

    #[test]
    fn push_text_with_different_dose_does_not_merge() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("r"));
        b.push_text("a".into(), false);
        b.push_text("b".into(), true);  // different dose → don't merge
        b.close_element();
        match &b.finish()[0] {
            ResultNode::Element { children, .. } => assert_eq!(children.len(), 2),
            _ => panic!(),
        }
    }

    #[test]
    fn push_text_at_top_level_when_no_element_open() {
        // current_children_mut() returns &mut self.top when stack is empty.
        let mut b = ResultBuilder::new();
        b.push_text("hello".into(), false);
        b.push_text(" world".into(), false);
        let nodes = b.finish();
        // Two texts at top level should merge (same dose) — verifies the
        // current_children_mut top-level branch is taken.
        assert_eq!(nodes.len(), 1);
        match &nodes[0] {
            ResultNode::Text { content, .. } => assert_eq!(content, "hello world"),
            _ => panic!(),
        }
    }

    #[test]
    fn push_comment_at_top_level() {
        let mut b = ResultBuilder::new();
        b.push_comment(" hi ".into());
        let nodes = b.finish();
        match &nodes[0] {
            ResultNode::Comment(s) => assert_eq!(s, " hi "),
            _ => panic!(),
        }
    }

    #[test]
    fn push_pi_at_top_level() {
        let mut b = ResultBuilder::new();
        b.push_pi("target".into(), "data".into());
        let nodes = b.finish();
        match &nodes[0] {
            ResultNode::ProcessingInstruction { target, data } => {
                assert_eq!(target, "target");
                assert_eq!(data, "data");
            }
            _ => panic!(),
        }
    }

    #[test]
    fn push_pi_inside_element() {
        let mut b = ResultBuilder::new();
        b.open_element(qn("r"));
        b.push_pi("php".into(), "echo".into());
        b.close_element();
        match &b.finish()[0] {
            ResultNode::Element { children, .. } => {
                assert!(matches!(&children[0],
                    ResultNode::ProcessingInstruction { target, .. } if target == "php"));
            }
            _ => panic!(),
        }
    }
}