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 `&` / `<`). 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}