Skip to main content

sup_xml_xslt/
output.rs

1//! Output serialisers — turn a [`ResultTree`] into bytes per
2//! `xsl:output`'s method (XML / HTML / text).
3//!
4//! XSLT 1.0 §16 specifies three output methods.  The differences:
5//!
6//! * **XML** (the default): standard XML serialisation.  Empty
7//!   elements use self-closing syntax (`<br/>`).  Standard
8//!   `<`/`>`/`&` escaping in text; also `"` inside attribute
9//!   values.  XML declaration is emitted iff `omit-xml-declaration`
10//!   is `no` or the version is non-1.0.
11//! * **HTML**: HTML5-ish.  Void elements (`<br>`, `<img>`, etc.)
12//!   emit no closing tag *and* no self-closing slash.  No
13//!   escaping inside `<script>` / `<style>`.  Attribute minimisation
14//!   is allowed (e.g. `selected` instead of `selected="selected"`)
15//!   but we keep them fully written for safety.
16//! * **Text**: concatenate every text node, no markup, no
17//!   escaping.
18
19use std::fmt::Write;
20
21use crate::ast::{OutputSpec, QName, Standalone};
22use crate::error::XsltError;
23use crate::result_tree::{ResultNode, ResultTree};
24
25/// Pick the serialisation method based on the stylesheet's
26/// effective `<xsl:output>` settings, with the XSLT 1.0 default
27/// fallback: if no method was specified and the result tree's
28/// first child is `<html>`, use HTML; otherwise XML.  (Real
29/// libxslt does this fallback dance too.)
30fn effective_method(tree: &ResultTree) -> &str {
31    if let Some(m) = tree.output.method.as_deref() { return m; }
32    if let Some(ResultNode::Element { name, .. }) = tree.children.iter()
33        .find(|n| matches!(n, ResultNode::Element { .. }))
34    {
35        if name.local.eq_ignore_ascii_case("html") && name.uri.is_empty() {
36            return "html";
37        }
38    }
39    "xml"
40}
41
42impl ResultTree {
43    /// Serialise the result tree to a string using the effective
44    /// output method.  XSLT 1.0 §16 — method = xml | html | xhtml |
45    /// text.
46    ///
47    /// Method-dependent defaults follow the XSLT/XQuery Serialization
48    /// spec: `indent`, `escape-uri-attributes`, and
49    /// `include-content-type` all default to `yes` for the html and
50    /// xhtml output methods, and to `no` / not-applicable for xml.
51    pub fn to_string(&self) -> Result<String, XsltError> {
52        let method = effective_method(self);
53        let html_family = matches!(method, "html" | "xhtml");
54        let indent = self.output.indent.unwrap_or(html_family);
55        let escape_uri = html_family
56            && self.output.escape_uri_attributes.unwrap_or(true);
57        let content_type = html_family
58            && self.output.include_content_type.unwrap_or(true);
59
60        // include-content-type: splice a <meta http-equiv> into the
61        // <head>.  Only clone the child list when an injection is
62        // actually performed.
63        let owned;
64        let children: &[ResultNode] = if content_type {
65            owned = with_content_type_meta(&self.children, &self.output, method == "xhtml");
66            &owned
67        } else {
68            &self.children
69        };
70
71        let mut out = match method {
72            "html"  => serialize_html(children, &self.output, indent, escape_uri),
73            "text"  => serialize_text(children),
74            // The xhtml output method uses XML syntax with the
75            // html-family parameter defaults applied above.
76            _       => serialize_xml(children, &self.output, &self.character_map,
77                                     indent, escape_uri),
78        };
79        // `byte-order-mark="yes"` (XSLT 2.0 §20) prefixes the output
80        // with U+FEFF, ahead of any XML declaration.
81        if self.output.byte_order_mark == Some(true) {
82            out.insert(0, '\u{feff}');
83        }
84        Ok(out)
85    }
86
87    /// Write the serialised result to any [`io::Write`] sink.
88    pub fn write_to(&self, w: &mut dyn std::io::Write) -> Result<(), XsltError> {
89        let s = self.to_string()?;
90        w.write_all(s.as_bytes())
91            .map_err(|e| XsltError::InvalidStylesheet(format!("write failed: {e}")))
92    }
93}
94
95// ── XML serialiser ────────────────────────────────────────────────
96
97pub fn serialize_xml(
98    children:   &[ResultNode],
99    output:     &OutputSpec,
100    cmap:       &[(char, String)],
101    indent:     bool,
102    escape_uri: bool,
103) -> String {
104    let mut out = String::new();
105    if should_emit_xml_decl(output) {
106        let _ = write!(out, r#"<?xml version="{}" encoding="{}""#,
107            output.version.as_deref().unwrap_or("1.0"),
108            output.encoding.as_deref().unwrap_or("UTF-8"),
109        );
110        // `standalone="omit"` (and an absent attribute) suppress the
111        // pseudo-attribute entirely; only yes/no are emitted.
112        match output.standalone {
113            Some(Standalone::Yes) => { let _ = write!(out, r#" standalone="yes""#); }
114            Some(Standalone::No)  => { let _ = write!(out, r#" standalone="no""#); }
115            Some(Standalone::Omit) | None => {}
116        }
117        out.push_str("?>\n");
118    }
119    if let Some(dt_sys) = output.doctype_system.as_deref() {
120        if let Some(root) = first_element_name(children) {
121            if let Some(pubid) = output.doctype_public.as_deref() {
122                let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}" "{dt_sys}">"#);
123            } else {
124                let _ = writeln!(out, r#"<!DOCTYPE {root} SYSTEM "{dt_sys}">"#);
125            }
126        }
127    }
128    // `indent="yes"` (XSLT 1.0 §16.1): pretty-print element-only
129    // content. Mixed content (any text-node child) suppresses
130    // formatting for that element and its whole subtree so text is
131    // preserved verbatim.
132    for child in children {
133        serialize_xml_node(child, &mut out, output, "", cmap, indent, escape_uri, 0);
134    }
135    if indent && !out.ends_with('\n') {
136        out.push('\n');
137    }
138    out
139}
140
141/// Two spaces per nesting level — libxslt's default indent step.
142fn push_indent(out: &mut String, level: usize) {
143    for _ in 0..level {
144        out.push_str("  ");
145    }
146}
147
148fn should_emit_xml_decl(output: &OutputSpec) -> bool {
149    // libxslt default: emit for xml method unless omit=yes.
150    !output.omit_xml_declaration.unwrap_or(false)
151}
152
153fn first_element_name(nodes: &[ResultNode]) -> Option<String> {
154    nodes.iter().find_map(|n| match n {
155        ResultNode::Element { name, .. } => Some(name.to_qname_string()),
156        _ => None,
157    })
158}
159
160/// Serialize one result-tree node.
161///
162/// `parent_default_ns` is the URI bound to the default namespace in
163/// the surrounding scope (`""` if none) — used to suppress redundant
164/// `xmlns=""` declarations on elements whose surrounding scope
165/// already has no default namespace.
166#[allow(clippy::too_many_arguments)]
167fn serialize_xml_node(
168    node:        &ResultNode,
169    out:         &mut String,
170    opts:        &OutputSpec,
171    parent_default_ns: &str,
172    cmap:        &[(char, String)],
173    format:      bool,
174    escape_uri:  bool,
175    level:       usize,
176) {
177    let xml_11   = opts.version.as_deref() == Some("1.1");
178    let enc_cap  = encoding_capability(opts.encoding.as_deref());
179    match node {
180        ResultNode::Element { name, namespaces, attributes, children, .. } => {
181            let q = name.to_qname_string();
182            out.push('<');
183            out.push_str(&q);
184            // Compute the default namespace this element actually
185            // contributes (used both to suppress redundant decls here
186            // and to thread down to children).  When the element has
187            // no explicit default-namespace binding in `namespaces`,
188            // it inherits the parent's.
189            let mut child_default_ns: &str = parent_default_ns;
190            for (prefix, uri) in namespaces {
191                match prefix {
192                    // The `xml` prefix is bound by the XML spec itself
193                    // to the XML namespace URI; redeclaration is
194                    // forbidden by XML Namespaces § 3 ("Prefix `xml`
195                    // is by definition bound to ..."). Suppress it
196                    // here so result trees that carry the binding
197                    // through (e.g. `xml:space`-bearing elements)
198                    // don't emit a redundant decl.
199                    Some(p) if p == "xml"
200                        && uri == "http://www.w3.org/XML/1998/namespace" => {}
201                    Some(p) => { let _ = write!(out, r#" xmlns:{p}="{}""#, escape_attr(uri, xml_11, enc_cap)); }
202                    None => {
203                        // Suppress a default-namespace declaration that
204                        // would have no observable effect — same URI as
205                        // the surrounding scope already has bound.
206                        // Notably an `xmlns=""` undeclaration when the
207                        // surrounding default is already empty.
208                        if uri != parent_default_ns {
209                            let _ = write!(out, r#" xmlns="{}""#, escape_attr(uri, xml_11, enc_cap));
210                        }
211                        child_default_ns = uri.as_str();
212                    }
213                }
214            }
215            for (aname, value) in attributes {
216                let _ = write!(out, r#" {}="{}""#,
217                    aname.to_qname_string(),
218                    render_attr_value(name, aname, value, escape_uri, xml_11, enc_cap, cmap));
219            }
220            if children.is_empty() {
221                out.push_str("/>");
222                return;
223            }
224            out.push('>');
225            // CDATA-section elements: text children of these are
226            // wrapped in <![CDATA[...]]> rather than escaped.
227            let is_cdata = opts.cdata_section_elements.iter()
228                .any(|q| q.uri == name.uri && q.local == name.local);
229            // Only element-only content is indented; the presence of a
230            // text child collapses formatting for this element so its
231            // character data round-trips unchanged.
232            let child_format = format
233                && !children.iter().any(|c| matches!(c, ResultNode::Text { .. }));
234            for c in children {
235                if child_format {
236                    out.push('\n');
237                    push_indent(out, level + 1);
238                }
239                if is_cdata {
240                    if let ResultNode::Text { content, .. } = c {
241                        out.push_str("<![CDATA[");
242                        out.push_str(&content.replace("]]>", "]]]]><![CDATA[>"));
243                        out.push_str("]]>");
244                        continue;
245                    }
246                }
247                serialize_xml_node(c, out, opts, child_default_ns, cmap, child_format, escape_uri, level + 1);
248            }
249            if child_format {
250                out.push('\n');
251                push_indent(out, level);
252            }
253            out.push_str("</");
254            out.push_str(&q);
255            out.push('>');
256        }
257        ResultNode::Text { content, dose } => {
258            if *dose {
259                out.push_str(content);
260            } else {
261                out.push_str(&escape_text_with_map(content, xml_11, enc_cap, cmap));
262            }
263        }
264        ResultNode::Comment(s) => {
265            let _ = write!(out, "<!--{}-->", s);
266        }
267        ResultNode::ProcessingInstruction { target, data } => {
268            if data.is_empty() {
269                let _ = write!(out, "<?{target}?>");
270            } else {
271                let _ = write!(out, "<?{target} {data}?>");
272            }
273        }
274        // A parentless attribute has no serialization in element
275        // content (XSLT consumes it via copy-of / apply-templates);
276        // emit nothing rather than malformed output.
277        ResultNode::Attribute { .. } => {}
278    }
279}
280
281/// XML 1.1 § 2.11 restricted chars, plus NEL (#x85) and LSEP
282/// (#x2028).  The restricted set MUST be NCR-escaped in serialized
283/// output per the XML 1.1 spec; NEL and LSEP are technically
284/// allowed unescaped but the XML 1.1 input parser normalises both
285/// to LF, so a round-trip needs them as NCRs.  Tab/LF/CR are not in
286/// this set — `escape_attr` handles those independently.
287#[inline]
288fn xml_11_must_escape(c: char) -> bool {
289    matches!(c as u32,
290        0x01..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F |
291        0x7F..=0x84 | 0x85 | 0x86..=0x9F |
292        0x2028
293    )
294}
295
296/// Coverage of a named output encoding: the largest Unicode codepoint
297/// that the encoding can represent directly.  Codepoints above this
298/// must be emitted as numeric character references (XSLT 1.0 §16
299/// "output that uses a character encoding cannot directly represent
300/// ... must escape").  `None` means UTF-8 / UTF-16 / unknown — every
301/// codepoint passes through unescaped.
302fn encoding_capability(enc: Option<&str>) -> Option<u32> {
303    let name = enc.unwrap_or("UTF-8").to_ascii_lowercase();
304    let norm: String = name.chars().filter(|c| !matches!(c, '-' | '_' | ' ')).collect();
305    match norm.as_str() {
306        // 7-bit ASCII — only codepoints ≤ 0x7F are representable.
307        "ascii" | "usascii" | "iso646us" => Some(0x7F),
308        // Single-byte ISO/Windows family — represent ≤ 0xFF (the high
309        // half maps to a code-page-specific character, but a numeric
310        // reference is always safe and round-trippable).
311        "iso88591" | "latin1" | "l1" | "cp1252" | "windows1252" => Some(0xFF),
312        // Multi-byte encodings that cover the full BMP and beyond
313        // (UTF-8, UTF-16, UTF-32) — no escape needed for content
314        // characters at all.
315        _ => None,
316    }
317}
318
319#[inline]
320fn must_ncr_escape(c: char, enc_cap: Option<u32>) -> bool {
321    matches!(enc_cap, Some(max) if c as u32 > max)
322}
323
324fn escape_text(s: &str, xml_11: bool, enc_cap: Option<u32>) -> String {
325    escape_text_with_map(s, xml_11, enc_cap, &[])
326}
327
328fn escape_attr(s: &str, xml_11: bool, enc_cap: Option<u32>) -> String {
329    escape_attr_with_map(s, xml_11, enc_cap, &[])
330}
331
332/// Look up `c` in the (small) character-map list.  Linear scan;
333/// the map sizes encountered in XSLT 2.0 stylesheets are tiny
334/// (typically <10 entries) and the lookup happens once per
335/// character of serialized output.
336#[inline]
337fn cmap_lookup<'a>(c: char, cmap: &'a [(char, String)]) -> Option<&'a str> {
338    for (k, v) in cmap {
339        if *k == c { return Some(v.as_str()); }
340    }
341    None
342}
343
344fn escape_text_with_map(
345    s: &str,
346    xml_11: bool,
347    enc_cap: Option<u32>,
348    cmap: &[(char, String)],
349) -> String {
350    let mut out = String::with_capacity(s.len());
351    for c in s.chars() {
352        if let Some(replacement) = cmap_lookup(c, cmap) {
353            out.push_str(replacement);
354            continue;
355        }
356        match c {
357            '<' => out.push_str("&lt;"),
358            '>' => out.push_str("&gt;"),
359            '&' => out.push_str("&amp;"),
360            // Literal `\r` would be rewritten to `\n` by the receiving
361            // parser's XML § 2.11 end-of-line normalization, so always
362            // escape it as a character reference for round-trip.
363            '\r' => out.push_str("&#xD;"),
364            c if xml_11 && xml_11_must_escape(c) => {
365                let _ = write!(out, "&#{};", c as u32);
366            }
367            c if must_ncr_escape(c, enc_cap) => {
368                let _ = write!(out, "&#{};", c as u32);
369            }
370            _   => out.push(c),
371        }
372    }
373    out
374}
375
376fn escape_attr_with_map(
377    s: &str,
378    xml_11: bool,
379    enc_cap: Option<u32>,
380    cmap: &[(char, String)],
381) -> String {
382    let mut out = String::with_capacity(s.len());
383    for c in s.chars() {
384        if let Some(replacement) = cmap_lookup(c, cmap) {
385            out.push_str(replacement);
386            continue;
387        }
388        match c {
389            '<' => out.push_str("&lt;"),
390            '>' => out.push_str("&gt;"),
391            '&' => out.push_str("&amp;"),
392            '"' => out.push_str("&quot;"),
393            '\n' => out.push_str("&#10;"),
394            '\r' => out.push_str("&#13;"),
395            '\t' => out.push_str("&#9;"),
396            c if xml_11 && xml_11_must_escape(c) => {
397                let _ = write!(out, "&#{};", c as u32);
398            }
399            c if must_ncr_escape(c, enc_cap) => {
400                let _ = write!(out, "&#{};", c as u32);
401            }
402            _   => out.push(c),
403        }
404    }
405    out
406}
407
408// ── URI-attribute escaping (html / xhtml output methods) ──────────
409
410/// Render an attribute value, applying `fn:escape-html-uri` first
411/// when `escape_uri` is in effect and the attribute is URI-valued
412/// (Serialization spec, Appendix "List of URI Attributes").  The
413/// `%HH`-escaped result is then passed through ordinary attribute
414/// escaping so reserved markup characters are still protected.
415#[allow(clippy::too_many_arguments)]
416fn render_attr_value(
417    element:    &QName,
418    attr:       &QName,
419    value:      &str,
420    escape_uri: bool,
421    xml_11:     bool,
422    enc_cap:    Option<u32>,
423    cmap:       &[(char, String)],
424) -> String {
425    if escape_uri
426        && attr.uri.is_empty()
427        && is_uri_attribute(&element.local.to_ascii_lowercase(),
428                            &attr.local.to_ascii_lowercase())
429    {
430        escape_attr_with_map(&escape_html_uri(value), xml_11, enc_cap, cmap)
431    } else {
432        escape_attr_with_map(value, xml_11, enc_cap, cmap)
433    }
434}
435
436/// `fn:escape-html-uri` (XPath/XQuery F&O §6.4): printable ASCII
437/// (`#x20`–`#x7E`) is left untouched; every other character is
438/// percent-escaped, one `%HH` per byte of its UTF-8 encoding.  This
439/// is deliberately confined to non-ASCII characters because escaping
440/// ASCII characters in a URI is not always appropriate.
441fn escape_html_uri(s: &str) -> String {
442    let mut out = String::with_capacity(s.len());
443    let mut buf = [0u8; 4];
444    for c in s.chars() {
445        if matches!(c as u32, 0x20..=0x7E) {
446            out.push(c);
447        } else {
448            for b in c.encode_utf8(&mut buf).bytes() {
449                let _ = write!(out, "%{b:02X}");
450            }
451        }
452    }
453    out
454}
455
456/// Whether `(element, attr)` (both lowercase, no namespace) is a
457/// URI-valued attribute per the Serialization spec, Appendix "List
458/// of URI Attributes".  The list is element-specific: e.g. `value`
459/// is a URI only on `input`, `name` only on `a`.
460fn is_uri_attribute(element: &str, attr: &str) -> bool {
461    match attr {
462        "action"     => element == "form",
463        "archive"    => element == "object",
464        "background" => element == "body",
465        "cite"       => matches!(element, "blockquote" | "del" | "ins" | "q"),
466        "classid"    => element == "object",
467        "codebase"   => matches!(element, "applet" | "object"),
468        "data"       => element == "object",
469        "datasrc"    => matches!(element,
470            "button" | "div" | "input" | "object" | "select" | "span" | "table" | "textarea"),
471        "for"        => element == "script",
472        "formaction" => matches!(element, "button" | "input"),
473        "href"       => matches!(element, "a" | "area" | "base" | "link"),
474        "icon"       => element == "command",
475        "longdesc"   => matches!(element, "frame" | "iframe" | "img"),
476        "manifest"   => element == "html",
477        "name"       => element == "a",
478        "poster"     => element == "video",
479        "profile"    => element == "head",
480        "src"        => matches!(element,
481            "audio" | "embed" | "frame" | "iframe" | "img" | "input" | "script"
482            | "source" | "track" | "video"),
483        "usemap"     => matches!(element, "img" | "input" | "object"),
484        "value"      => element == "input",
485        _ => false,
486    }
487}
488
489// ── include-content-type (html / xhtml output methods) ────────────
490
491/// Return the result tree's children with a
492/// `<meta http-equiv="Content-Type" content="…; charset=…">` element
493/// spliced in as the first child of the `head` element (Serialization
494/// spec §6 / libxslt behavior).  Any existing content-type `meta` in
495/// that head is removed first.  When there is no `head` element the
496/// children are returned with no meta added.
497fn with_content_type_meta(children: &[ResultNode], output: &OutputSpec, xhtml: bool) -> Vec<ResultNode> {
498    let charset = output.encoding.as_deref().unwrap_or("UTF-8");
499    let media   = output.media_type.as_deref().unwrap_or("text/html");
500    let content = format!("{media}; charset={charset}");
501    // The meta SHOULD share the head's namespace: no namespace for
502    // the html output method, the XHTML namespace for xhtml.
503    let ns = if xhtml { "http://www.w3.org/1999/xhtml" } else { "" };
504    let mut result = children.to_vec();
505    inject_content_type(&mut result, &content, ns);
506    result
507}
508
509/// Walk `nodes` for the first `head` element in namespace `ns`,
510/// replacing any content-type meta among its children with a fresh
511/// one.  Returns whether a head was found.
512fn inject_content_type(nodes: &mut [ResultNode], content: &str, ns: &str) -> bool {
513    for node in nodes.iter_mut() {
514        if let ResultNode::Element { name, children, .. } = node {
515            if name.local.eq_ignore_ascii_case("head") && name.uri == ns {
516                children.retain(|c| !is_content_type_meta(c));
517                children.insert(0, content_type_meta(content, ns));
518                return true;
519            }
520            if inject_content_type(children, content, ns) {
521                return true;
522            }
523        }
524    }
525    false
526}
527
528/// Whether `node` is a `<meta http-equiv="Content-Type" …>` element
529/// (the http-equiv name is matched case-insensitively, as HTML
530/// requires).
531fn is_content_type_meta(node: &ResultNode) -> bool {
532    matches!(node, ResultNode::Element { name, attributes, .. }
533        if name.local.eq_ignore_ascii_case("meta")
534        && attributes.iter().any(|(a, v)|
535            a.local.eq_ignore_ascii_case("http-equiv")
536            && v.eq_ignore_ascii_case("content-type")))
537}
538
539fn content_type_meta(content: &str, ns: &str) -> ResultNode {
540    let attr = |local: &str| QName { prefix: None, local: local.into(), uri: String::new() };
541    ResultNode::Element {
542        name: QName { prefix: None, local: "meta".into(), uri: ns.into() },
543        namespaces: Vec::new(),
544        attributes: vec![
545            (attr("http-equiv"), "Content-Type".into()),
546            (attr("content"),    content.into()),
547        ],
548        children: Vec::new(),
549        schema_type: None,
550        attr_types: Vec::new(),
551    }
552}
553
554// ── HTML serialiser ───────────────────────────────────────────────
555
556/// HTML5 void elements — emitted without a closing tag and
557/// without `/>`.  The list is from the HTML5 spec § "Void
558/// elements"; lowercase canonical form.
559const VOID_ELEMENTS: &[&str] = &[
560    "area", "base", "br", "col", "embed", "hr", "img", "input",
561    "link", "meta", "param", "source", "track", "wbr",
562];
563
564/// Elements whose text content must NOT be escaped (XSLT 1.0 §16
565/// HTML output method).
566const RAW_TEXT_ELEMENTS: &[&str] = &["script", "style"];
567
568pub fn serialize_html(
569    children:   &[ResultNode],
570    output:     &OutputSpec,
571    indent:     bool,
572    escape_uri: bool,
573) -> String {
574    let mut out = String::new();
575    if let Some(dt_sys) = output.doctype_system.as_deref() {
576        if let Some(root) = first_element_name(children) {
577            if let Some(pubid) = output.doctype_public.as_deref() {
578                let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}" "{dt_sys}">"#);
579            } else {
580                let _ = writeln!(out, r#"<!DOCTYPE {root} SYSTEM "{dt_sys}">"#);
581            }
582        }
583    } else if let Some(pubid) = output.doctype_public.as_deref() {
584        if let Some(root) = first_element_name(children) {
585            let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}">"#);
586        }
587    }
588    for c in children { serialize_html_node(c, &mut out, indent, escape_uri, 0); }
589    if indent && !out.ends_with('\n') {
590        out.push('\n');
591    }
592    out
593}
594
595fn serialize_html_node(node: &ResultNode, out: &mut String, format: bool, escape_uri: bool, level: usize) {
596    match node {
597        ResultNode::Element { name, namespaces, attributes, children, .. } => {
598            let local_lc = name.local.to_lowercase();
599            let q = name.to_qname_string();
600            out.push('<');
601            out.push_str(&q);
602            for (prefix, uri) in namespaces {
603                match prefix {
604                    Some(p) => { let _ = write!(out, r#" xmlns:{p}="{}""#, escape_attr(uri, false, None)); }
605                    None    => { let _ = write!(out, r#" xmlns="{}""#, escape_attr(uri, false, None)); }
606                }
607            }
608            for (aname, value) in attributes {
609                let _ = write!(out, r#" {}="{}""#,
610                    aname.to_qname_string(),
611                    render_attr_value(name, aname, value, escape_uri, false, None, &[]));
612            }
613            // Void elements: close with `>`, no children, no closing tag.
614            if name.uri.is_empty() && VOID_ELEMENTS.iter().any(|v| *v == local_lc) {
615                out.push('>');
616                return;
617            }
618            out.push('>');
619            let raw_text = name.uri.is_empty()
620                && RAW_TEXT_ELEMENTS.iter().any(|v| *v == local_lc);
621            // As for XML, only element-only content is indented; a text
622            // child (including the unescaped body of script/style)
623            // collapses formatting so content round-trips unchanged.
624            let child_format = format
625                && !children.iter().any(|c| matches!(c, ResultNode::Text { .. }));
626            for c in children {
627                if child_format {
628                    out.push('\n');
629                    push_indent(out, level + 1);
630                }
631                if raw_text {
632                    if let ResultNode::Text { content, .. } = c {
633                        out.push_str(content);
634                        continue;
635                    }
636                }
637                serialize_html_node(c, out, child_format, escape_uri, level + 1);
638            }
639            if child_format {
640                out.push('\n');
641                push_indent(out, level);
642            }
643            out.push_str("</");
644            out.push_str(&q);
645            out.push('>');
646        }
647        ResultNode::Text { content, dose } => {
648            if *dose { out.push_str(content); }
649            else     { out.push_str(&escape_text(content, false, None)); }
650        }
651        ResultNode::Comment(s) => {
652            let _ = write!(out, "<!--{s}-->");
653        }
654        ResultNode::ProcessingInstruction { target, data } => {
655            // HTML5 doesn't really have PIs but XSLT spec says
656            // emit them as `<?target data>` (no `?>`).
657            if data.is_empty() {
658                let _ = write!(out, "<?{target}>");
659            } else {
660                let _ = write!(out, "<?{target} {data}>");
661            }
662        }
663        // Parentless attribute — no element-content serialization.
664        ResultNode::Attribute { .. } => {}
665    }
666}
667
668// ── text serialiser ───────────────────────────────────────────────
669
670pub fn serialize_text(children: &[ResultNode]) -> String {
671    let mut out = String::new();
672    for c in children { append_text(c, &mut out); }
673    out
674}
675
676fn append_text(node: &ResultNode, out: &mut String) {
677    match node {
678        ResultNode::Text { content, .. } => out.push_str(content),
679        ResultNode::Element { children, .. } => {
680            for c in children { append_text(c, out); }
681        }
682        // Comments + PIs are stripped entirely in text output.
683        _ => {}
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use crate::ast::QName;
691
692    fn elt(name: &str, children: Vec<ResultNode>) -> ResultNode {
693        ResultNode::Element {
694            name: QName { prefix: None, local: name.into(), uri: String::new() },
695            namespaces: Vec::new(),
696            attributes: Vec::new(),
697            children,
698            schema_type: None,
699            attr_types: Vec::new(),
700        }
701    }
702
703    fn text(s: &str) -> ResultNode {
704        ResultNode::Text { content: s.into(), dose: false }
705    }
706
707    fn tree_of(nodes: Vec<ResultNode>, method: Option<&str>) -> ResultTree {
708        let mut spec = OutputSpec::default();
709        spec.method = method.map(str::to_string);
710        spec.omit_xml_declaration = Some(true); // simplify tests
711        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
712    }
713
714    // ── XML ─────────────────────────────────────────────────
715
716    #[test]
717    fn xml_empty_element_self_closes() {
718        let t = tree_of(vec![elt("br", vec![])], None);
719        assert_eq!(t.to_string().unwrap(), "<br/>");
720    }
721
722    #[test]
723    fn xml_escapes_text_specials() {
724        let t = tree_of(
725            vec![elt("p", vec![text("a < b && c > d")])],
726            None,
727        );
728        assert_eq!(t.to_string().unwrap(), "<p>a &lt; b &amp;&amp; c &gt; d</p>");
729    }
730
731    #[test]
732    fn xml_escapes_attr_quote_and_specials() {
733        let t = tree_of(vec![ResultNode::Element {
734            name: QName { prefix: None, local: "a".into(), uri: String::new() },
735            namespaces: Vec::new(),
736            attributes: vec![(
737                QName { prefix: None, local: "href".into(), uri: String::new() },
738                r#"x"&y<z"#.to_string(),
739            )],
740            children: Vec::new(),
741            schema_type: None,
742            attr_types: Vec::new(),
743        }],None);
744        assert_eq!(t.to_string().unwrap(), r#"<a href="x&quot;&amp;y&lt;z"/>"#);
745    }
746
747    #[test]
748    fn xml_text_dose_skips_escape() {
749        let t = tree_of(
750            vec![elt("p", vec![ResultNode::Text { content: "<raw/>".into(), dose: true }])],
751            None,
752        );
753        assert_eq!(t.to_string().unwrap(), "<p><raw/></p>");
754    }
755
756    fn tree_indented(nodes: Vec<ResultNode>) -> ResultTree {
757        let mut spec = OutputSpec::default();
758        spec.omit_xml_declaration = Some(true);
759        spec.indent = Some(true);
760        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
761    }
762
763    #[test]
764    fn xml_indent_yes_pretty_prints_element_only_content() {
765        let t = tree_indented(vec![elt("root", vec![
766            elt("a", vec![elt("b", vec![])]),
767            elt("c", vec![]),
768        ])]);
769        assert_eq!(
770            t.to_string().unwrap(),
771            "<root>\n  <a>\n    <b/>\n  </a>\n  <c/>\n</root>\n",
772        );
773    }
774
775    #[test]
776    fn xml_indent_yes_preserves_mixed_content() {
777        // An element with a text child is left untouched, and that
778        // collapses formatting for its whole subtree.
779        let t = tree_indented(vec![elt("root", vec![
780            elt("p", vec![text("hello "), elt("b", vec![text("world")])]),
781        ])]);
782        assert_eq!(
783            t.to_string().unwrap(),
784            "<root>\n  <p>hello <b>world</b></p>\n</root>\n",
785        );
786    }
787
788    #[test]
789    fn xml_indent_off_by_default() {
790        let t = tree_of(vec![elt("root", vec![elt("a", vec![])])], None);
791        assert_eq!(t.to_string().unwrap(), "<root><a/></root>");
792    }
793
794    // ── HTML ────────────────────────────────────────────────
795
796    #[test]
797    fn html_void_elements_get_no_close_no_slash() {
798        let t = tree_of(vec![
799            elt("html", vec![
800                elt("head", vec![ elt("meta", vec![]) ]),
801                elt("body", vec![ elt("br", vec![]), elt("img", vec![]) ]),
802            ]),
803        ], Some("html"));
804        let s = t.to_string().unwrap();
805        assert!(s.contains("<meta>"),  "got: {s}");
806        assert!(s.contains("<br>"),    "got: {s}");
807        assert!(s.contains("<img>"),   "got: {s}");
808        assert!(!s.contains("<br/>"),  "got: {s}");
809        assert!(!s.contains("<meta/>"), "got: {s}");
810    }
811
812    #[test]
813    fn html_script_content_not_escaped() {
814        let t = tree_of(vec![ elt("script", vec![ text("if (a < b) alert('x');") ]) ],
815            Some("html"));
816        let s = t.to_string().unwrap();
817        assert!(s.contains("if (a < b)"), "script body should be raw: {s}");
818    }
819
820    #[test]
821    fn html_default_detected_by_root_html_element() {
822        // No method= set, root is <html> → HTML serialiser.
823        let t = tree_of(vec![ elt("html", vec![ elt("br", vec![]) ]) ], None);
824        let s = t.to_string().unwrap();
825        // HTML default emits `<br>` not `<br/>`.
826        assert!(s.contains("<br>"), "got: {s}");
827        assert!(!s.contains("<br/>"));
828    }
829
830    fn tree_indented_method(nodes: Vec<ResultNode>, method: &str) -> ResultTree {
831        let mut spec = OutputSpec::default();
832        spec.method = Some(method.into());
833        spec.indent = Some(true);
834        // Keep these indentation-focused tests independent of the
835        // include-content-type meta injection (exercised separately).
836        spec.include_content_type = Some(false);
837        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
838    }
839
840    #[test]
841    fn html_indent_yes_pretty_prints_element_only_content() {
842        let t = tree_indented_method(vec![
843            elt("html", vec![
844                elt("head", vec![elt("meta", vec![])]),
845                elt("body", vec![elt("p", vec![text("hi")])]),
846            ]),
847        ], "html");
848        assert_eq!(
849            t.to_string().unwrap(),
850            "<html>\n  <head>\n    <meta>\n  </head>\n  <body>\n    <p>hi</p>\n  </body>\n</html>\n",
851        );
852    }
853
854    #[test]
855    fn html_indent_yes_preserves_mixed_content_and_raw_text() {
856        let t = tree_indented_method(vec![
857            elt("body", vec![
858                elt("p", vec![text("a "), elt("b", vec![text("c")]), text(" d")]),
859                elt("script", vec![text("if (a < b) x();")]),
860            ]),
861        ], "html");
862        assert_eq!(
863            t.to_string().unwrap(),
864            "<body>\n  <p>a <b>c</b> d</p>\n  <script>if (a < b) x();</script>\n</body>\n",
865        );
866    }
867
868    // ── text ────────────────────────────────────────────────
869
870    #[test]
871    fn text_strips_markup() {
872        let t = tree_of(vec![ elt("p", vec![
873            text("Hello, "),
874            elt("b", vec![text("world")]),
875            text("!"),
876        ]) ], Some("text"));
877        assert_eq!(t.to_string().unwrap(), "Hello, world!");
878    }
879
880    #[test]
881    fn text_strips_comments_and_pis() {
882        let t = tree_of(vec![
883            ResultNode::Comment("ignored".into()),
884            elt("p", vec![text("kept")]),
885            ResultNode::ProcessingInstruction { target: "pi".into(), data: "ignored".into() },
886        ], Some("text"));
887        assert_eq!(t.to_string().unwrap(), "kept");
888    }
889
890    // ── write_to ────────────────────────────────────────────────
891
892    #[test]
893    fn write_to_io_writer() {
894        let t = tree_of(vec![elt("r", vec![text("hi")])], None);
895        let mut buf = Vec::<u8>::new();
896        t.write_to(&mut buf).unwrap();
897        assert_eq!(buf, b"<r>hi</r>");
898    }
899
900    // ── XML declaration ─────────────────────────────────────────
901
902    #[test]
903    fn xml_decl_emitted_when_not_omitted() {
904        let mut spec = OutputSpec::default();
905        spec.omit_xml_declaration = Some(false);
906        spec.version = Some("1.0".into());
907        spec.encoding = Some("UTF-8".into());
908        let t = ResultTree {
909            children: vec![elt("r", vec![])],
910            output: spec,
911            character_map: Vec::new(),
912            secondary: Vec::new(),
913        };
914        let s = t.to_string().unwrap();
915        assert!(s.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#), "got: {s}");
916    }
917
918    #[test]
919    fn xml_decl_emits_standalone_yes() {
920        let mut spec = OutputSpec::default();
921        spec.omit_xml_declaration = Some(false);
922        spec.standalone = Some(Standalone::Yes);
923        let t = ResultTree {
924            children: vec![elt("r", vec![])],
925            output: spec,
926            character_map: Vec::new(),
927            secondary: Vec::new(),
928        };
929        let s = t.to_string().unwrap();
930        assert!(s.contains(r#"standalone="yes""#), "got: {s}");
931    }
932
933    #[test]
934    fn xml_decl_emits_standalone_no() {
935        let mut spec = OutputSpec::default();
936        spec.omit_xml_declaration = Some(false);
937        spec.standalone = Some(Standalone::No);
938        let t = ResultTree {
939            children: vec![elt("r", vec![])],
940            output: spec,
941            character_map: Vec::new(),
942            secondary: Vec::new(),
943        };
944        let s = t.to_string().unwrap();
945        assert!(s.contains(r#"standalone="no""#), "got: {s}");
946    }
947
948    // ── XML DOCTYPE ─────────────────────────────────────────────
949
950    #[test]
951    fn xml_doctype_system() {
952        let mut spec = OutputSpec::default();
953        spec.omit_xml_declaration = Some(true);
954        spec.doctype_system = Some("foo.dtd".into());
955        let t = ResultTree {
956            children: vec![elt("r", vec![])],
957            output: spec,
958            character_map: Vec::new(),
959            secondary: Vec::new(),
960        };
961        let s = t.to_string().unwrap();
962        assert!(s.contains(r#"<!DOCTYPE r SYSTEM "foo.dtd">"#), "got: {s}");
963    }
964
965    #[test]
966    fn xml_doctype_public() {
967        let mut spec = OutputSpec::default();
968        spec.omit_xml_declaration = Some(true);
969        spec.doctype_system = Some("foo.dtd".into());
970        spec.doctype_public = Some("-//ID//PUB".into());
971        let t = ResultTree {
972            children: vec![elt("r", vec![])],
973            output: spec,
974            character_map: Vec::new(),
975            secondary: Vec::new(),
976        };
977        let s = t.to_string().unwrap();
978        assert!(s.contains(r#"<!DOCTYPE r PUBLIC "-//ID//PUB" "foo.dtd">"#), "got: {s}");
979    }
980
981    // ── XML namespace declarations ──────────────────────────────
982
983    #[test]
984    fn xml_emits_namespace_declarations() {
985        let t = tree_of(vec![ResultNode::Element {
986            name: QName { prefix: Some("xs".into()), local: "schema".into(), uri: "http://www.w3.org/2001/XMLSchema".into() },
987            namespaces: vec![
988                (Some("xs".into()), "http://www.w3.org/2001/XMLSchema".into()),
989                (None, "http://example.com/default".into()),
990            ],
991            attributes: Vec::new(),
992            children: Vec::new(),
993            schema_type: None,
994            attr_types: Vec::new(),
995        }],None);
996        let s = t.to_string().unwrap();
997        assert!(s.contains(r#"xmlns:xs="http://www.w3.org/2001/XMLSchema""#), "got: {s}");
998        assert!(s.contains(r#"xmlns="http://example.com/default""#), "got: {s}");
999    }
1000
1001    // ── XML comments & PIs ──────────────────────────────────────
1002
1003    #[test]
1004    fn xml_serializes_comment() {
1005        let t = tree_of(vec![ResultNode::Comment(" hello ".into())], None);
1006        assert_eq!(t.to_string().unwrap(), "<!-- hello -->");
1007    }
1008
1009    #[test]
1010    fn xml_serializes_pi_no_data() {
1011        let t = tree_of(vec![
1012            ResultNode::ProcessingInstruction { target: "pi".into(), data: String::new() },
1013        ], None);
1014        assert_eq!(t.to_string().unwrap(), "<?pi?>");
1015    }
1016
1017    #[test]
1018    fn xml_serializes_pi_with_data() {
1019        let t = tree_of(vec![
1020            ResultNode::ProcessingInstruction {
1021                target: "xml-stylesheet".into(),
1022                data: r#"href="s.xsl""#.into(),
1023            },
1024        ], None);
1025        assert_eq!(t.to_string().unwrap(),
1026            r#"<?xml-stylesheet href="s.xsl"?>"#);
1027    }
1028
1029    // ── CDATA-section elements ──────────────────────────────────
1030
1031    #[test]
1032    fn xml_cdata_section_elements_wrap_text_children() {
1033        let mut spec = OutputSpec::default();
1034        spec.omit_xml_declaration = Some(true);
1035        spec.cdata_section_elements = vec![
1036            QName { prefix: None, local: "raw".into(), uri: String::new() },
1037        ];
1038        let t = ResultTree {
1039            children: vec![elt("raw", vec![text("a < b & c")])],
1040            output: spec,
1041            character_map: Vec::new(),
1042            secondary: Vec::new(),
1043        };
1044        let s = t.to_string().unwrap();
1045        assert!(s.contains("<![CDATA[a < b & c]]>"), "got: {s}");
1046    }
1047
1048    #[test]
1049    fn xml_cdata_section_splits_embedded_close_seq() {
1050        // "]]>" inside the text must be split across two CDATA blocks.
1051        let mut spec = OutputSpec::default();
1052        spec.omit_xml_declaration = Some(true);
1053        spec.cdata_section_elements = vec![
1054            QName { prefix: None, local: "raw".into(), uri: String::new() },
1055        ];
1056        let t = ResultTree {
1057            children: vec![elt("raw", vec![text("end ]]> here")])],
1058            output: spec,
1059            character_map: Vec::new(),
1060            secondary: Vec::new(),
1061        };
1062        let s = t.to_string().unwrap();
1063        // Implementation splits ]]> into "]]]]><![CDATA[>".
1064        assert!(s.contains("]]]]><![CDATA[>"), "got: {s}");
1065    }
1066
1067    // ── escape_attr full coverage ───────────────────────────────
1068
1069    #[test]
1070    fn xml_attr_escapes_newline_tab_cr() {
1071        let t = tree_of(vec![ResultNode::Element {
1072            name: QName { prefix: None, local: "a".into(), uri: String::new() },
1073            namespaces: Vec::new(),
1074            attributes: vec![(
1075                QName { prefix: None, local: "v".into(), uri: String::new() },
1076                "x\ny\tz\rw".to_string(),
1077            )],
1078            children: Vec::new(),
1079            schema_type: None,
1080            attr_types: Vec::new(),
1081        }],None);
1082        let s = t.to_string().unwrap();
1083        assert!(s.contains("&#10;"), "got: {s}");
1084        assert!(s.contains("&#9;"),  "got: {s}");
1085        assert!(s.contains("&#13;"), "got: {s}");
1086    }
1087
1088    // ── HTML DOCTYPE ────────────────────────────────────────────
1089
1090    #[test]
1091    fn html_doctype_system_only() {
1092        let mut spec = OutputSpec::default();
1093        spec.method = Some("html".into());
1094        spec.doctype_system = Some("about:legacy-compat".into());
1095        let t = ResultTree {
1096            children: vec![elt("html", vec![])],
1097            output: spec,
1098            character_map: Vec::new(),
1099            secondary: Vec::new(),
1100        };
1101        let s = t.to_string().unwrap();
1102        assert!(s.contains(r#"<!DOCTYPE html SYSTEM "about:legacy-compat">"#), "got: {s}");
1103    }
1104
1105    #[test]
1106    fn html_doctype_public_only() {
1107        // PUBLIC without SYSTEM → emit `<!DOCTYPE root PUBLIC "...">`.
1108        let mut spec = OutputSpec::default();
1109        spec.method = Some("html".into());
1110        spec.doctype_public = Some("-//W3C//DTD HTML 4.01//EN".into());
1111        let t = ResultTree {
1112            children: vec![elt("html", vec![])],
1113            output: spec,
1114            character_map: Vec::new(),
1115            secondary: Vec::new(),
1116        };
1117        let s = t.to_string().unwrap();
1118        assert!(s.contains(r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">"#),
1119                "got: {s}");
1120    }
1121
1122    #[test]
1123    fn html_doctype_public_and_system() {
1124        let mut spec = OutputSpec::default();
1125        spec.method = Some("html".into());
1126        spec.doctype_public = Some("-//W3C//DTD HTML 4.01//EN".into());
1127        spec.doctype_system = Some("http://www.w3.org/TR/html4/strict.dtd".into());
1128        let t = ResultTree {
1129            children: vec![elt("html", vec![])],
1130            output: spec,
1131            character_map: Vec::new(),
1132            secondary: Vec::new(),
1133        };
1134        let s = t.to_string().unwrap();
1135        assert!(s.contains(r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#),
1136                "got: {s}");
1137    }
1138
1139    // ── HTML namespaces / comments / PIs ────────────────────────
1140
1141    #[test]
1142    fn html_emits_namespace_declarations() {
1143        let t = tree_of(vec![ResultNode::Element {
1144            name: QName { prefix: None, local: "html".into(), uri: String::new() },
1145            namespaces: vec![
1146                (Some("svg".into()), "http://www.w3.org/2000/svg".into()),
1147                (None, "http://www.w3.org/1999/xhtml".into()),
1148            ],
1149            attributes: Vec::new(),
1150            children: Vec::new(),
1151            schema_type: None,
1152            attr_types: Vec::new(),
1153        }],Some("html"));
1154        let s = t.to_string().unwrap();
1155        assert!(s.contains(r#"xmlns:svg="http://www.w3.org/2000/svg""#), "got: {s}");
1156        assert!(s.contains(r#"xmlns="http://www.w3.org/1999/xhtml""#), "got: {s}");
1157    }
1158
1159    #[test]
1160    fn html_serializes_comment() {
1161        let t = tree_of(vec![
1162            elt("html", vec![ResultNode::Comment(" hi ".into())]),
1163        ], Some("html"));
1164        let s = t.to_string().unwrap();
1165        assert!(s.contains("<!-- hi -->"), "got: {s}");
1166    }
1167
1168    #[test]
1169    fn html_serializes_pi_with_and_without_data() {
1170        let t = tree_of(vec![
1171            elt("html", vec![
1172                ResultNode::ProcessingInstruction { target: "a".into(), data: String::new() },
1173                ResultNode::ProcessingInstruction { target: "b".into(), data: "x".into() },
1174            ]),
1175        ], Some("html"));
1176        let s = t.to_string().unwrap();
1177        // HTML PIs end with > (not ?>).
1178        assert!(s.contains("<?a>"), "got: {s}");
1179        assert!(s.contains("<?b x>"), "got: {s}");
1180    }
1181
1182    #[test]
1183    fn html_text_with_dose_skips_escape() {
1184        let t = tree_of(vec![
1185            elt("html", vec![
1186                ResultNode::Text { content: "<raw>".into(), dose: true },
1187            ]),
1188        ], Some("html"));
1189        let s = t.to_string().unwrap();
1190        assert!(s.contains("<raw>"), "got: {s}");
1191    }
1192
1193    #[test]
1194    fn html_default_method_when_no_root_html() {
1195        // No method specified, root isn't <html> → falls back to XML.
1196        let t = tree_of(vec![elt("r", vec![])], None);
1197        let s = t.to_string().unwrap();
1198        // XML serializer emits self-closing.
1199        assert_eq!(s, "<r/>");
1200    }
1201
1202    // ── text method with dose ───────────────────────────────────
1203
1204    #[test]
1205    fn text_method_concatenates_all_text() {
1206        let t = tree_of(vec![
1207            elt("a", vec![
1208                text("one"),
1209                elt("b", vec![text("two")]),
1210                ResultNode::Text { content: "three".into(), dose: true }, // dose ignored in text mode
1211            ]),
1212        ], Some("text"));
1213        assert_eq!(t.to_string().unwrap(), "onetwothree");
1214    }
1215
1216    // ── serialization-parameter defaults (XSLT 2.0 §20) ─────────
1217    //
1218    // Defaults are method-dependent: `indent`, `escape-uri-attributes`,
1219    // and `include-content-type` default to `yes` for the html and
1220    // xhtml output methods and to `no` / not-applicable for xml.
1221
1222    fn out_tree(nodes: Vec<ResultNode>, spec: OutputSpec) -> ResultTree {
1223        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
1224    }
1225
1226    fn elt_attrs(name: &str, attrs: &[(&str, &str)], children: Vec<ResultNode>) -> ResultNode {
1227        ResultNode::Element {
1228            name: QName { prefix: None, local: name.into(), uri: String::new() },
1229            namespaces: Vec::new(),
1230            attributes: attrs.iter().map(|(k, v)| (
1231                QName { prefix: None, local: (*k).into(), uri: String::new() },
1232                (*v).to_string(),
1233            )).collect(),
1234            children,
1235            schema_type: None,
1236            attr_types: Vec::new(),
1237        }
1238    }
1239
1240    // standalone -----------------------------------------------------
1241
1242    #[test]
1243    fn standalone_omit_suppresses_pseudo_attribute() {
1244        let spec = OutputSpec { standalone: Some(Standalone::Omit), ..Default::default() };
1245        let s = out_tree(vec![elt("r", vec![])], spec).to_string().unwrap();
1246        assert!(s.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#), "got: {s}");
1247        assert!(!s.contains("standalone"), "omit must not emit a standalone pseudo-attr: {s}");
1248    }
1249
1250    #[test]
1251    fn standalone_absent_suppresses_pseudo_attribute() {
1252        let s = out_tree(vec![elt("r", vec![])], OutputSpec::default()).to_string().unwrap();
1253        assert!(!s.contains("standalone"), "got: {s}");
1254    }
1255
1256    // indent ---------------------------------------------------------
1257
1258    #[test]
1259    fn indent_defaults_to_no_for_xml_method() {
1260        let spec = OutputSpec {
1261            method: Some("xml".into()),
1262            omit_xml_declaration: Some(true),
1263            ..Default::default()
1264        };
1265        let s = out_tree(vec![elt("root", vec![elt("a", vec![])])], spec).to_string().unwrap();
1266        assert_eq!(s, "<root><a/></root>");
1267    }
1268
1269    #[test]
1270    fn indent_defaults_to_yes_for_html_method() {
1271        let spec = OutputSpec {
1272            method: Some("html".into()),
1273            include_content_type: Some(false), // isolate from meta injection
1274            ..Default::default()
1275        };
1276        let s = out_tree(vec![elt("html", vec![
1277            elt("body", vec![elt("p", vec![])]),
1278        ])], spec).to_string().unwrap();
1279        assert!(s.contains("<html>\n  <body>\n    <p>"), "html should indent by default: {s}");
1280    }
1281
1282    #[test]
1283    fn indent_defaults_to_yes_for_xhtml_method() {
1284        let spec = OutputSpec {
1285            method: Some("xhtml".into()),
1286            omit_xml_declaration: Some(true),
1287            include_content_type: Some(false),
1288            ..Default::default()
1289        };
1290        let s = out_tree(vec![elt("root", vec![elt("a", vec![])])], spec).to_string().unwrap();
1291        assert_eq!(s, "<root>\n  <a/>\n</root>\n");
1292    }
1293
1294    #[test]
1295    fn indent_defaults_to_yes_when_html_method_auto_detected() {
1296        // No explicit method; a root <html> selects the html method,
1297        // which carries the indent=yes default.
1298        let spec = OutputSpec { include_content_type: Some(false), ..Default::default() };
1299        let s = out_tree(vec![elt("html", vec![elt("body", vec![elt("p", vec![])])])], spec)
1300            .to_string().unwrap();
1301        assert!(s.contains("<html>\n  <body>"), "got: {s}");
1302    }
1303
1304    #[test]
1305    fn indent_no_overrides_html_default() {
1306        let spec = OutputSpec {
1307            method: Some("html".into()),
1308            indent: Some(false),
1309            include_content_type: Some(false),
1310            ..Default::default()
1311        };
1312        let s = out_tree(vec![elt("html", vec![elt("body", vec![])])], spec).to_string().unwrap();
1313        assert_eq!(s, "<html><body></body></html>");
1314    }
1315
1316    // escape-uri-attributes -----------------------------------------
1317
1318    #[test]
1319    fn escape_uri_attributes_default_escapes_non_ascii_in_href() {
1320        let spec = OutputSpec {
1321            method: Some("html".into()),
1322            include_content_type: Some(false),
1323            ..Default::default()
1324        };
1325        // é (U+00E9) → %C3%A9; ASCII characters (incl. space, ?) are
1326        // left intact per fn:escape-html-uri.
1327        let node = elt_attrs("a", &[("href", "/caf\u{e9}?x=1 2")], vec![]);
1328        let s = out_tree(vec![node], spec).to_string().unwrap();
1329        assert!(s.contains(r#"href="/caf%C3%A9?x=1 2""#), "got: {s}");
1330    }
1331
1332    #[test]
1333    fn escape_uri_attributes_no_disables_escaping() {
1334        let spec = OutputSpec {
1335            method: Some("html".into()),
1336            escape_uri_attributes: Some(false),
1337            include_content_type: Some(false),
1338            ..Default::default()
1339        };
1340        let node = elt_attrs("a", &[("href", "/caf\u{e9}")], vec![]);
1341        let s = out_tree(vec![node], spec).to_string().unwrap();
1342        assert!(s.contains("href=\"/caf\u{e9}\""), "got: {s}");
1343    }
1344
1345    #[test]
1346    fn escape_uri_attributes_only_applies_to_uri_valued_attributes() {
1347        // `title` is not a URI attribute → never escaped.
1348        let spec = OutputSpec {
1349            method: Some("html".into()),
1350            include_content_type: Some(false),
1351            ..Default::default()
1352        };
1353        let node = elt_attrs("a", &[("title", "caf\u{e9}")], vec![]);
1354        let s = out_tree(vec![node], spec).to_string().unwrap();
1355        assert!(s.contains("title=\"caf\u{e9}\""), "got: {s}");
1356    }
1357
1358    #[test]
1359    fn escape_uri_attributes_is_element_specific() {
1360        // `href` is a URI attribute on <a> but not on an arbitrary
1361        // element, so it is escaped on <a> and left alone elsewhere.
1362        let spec = OutputSpec {
1363            method: Some("html".into()),
1364            include_content_type: Some(false),
1365            ..Default::default()
1366        };
1367        let s = out_tree(vec![elt("body", vec![
1368            elt_attrs("a",   &[("href", "/\u{e9}")], vec![]),
1369            elt_attrs("span", &[("href", "/\u{e9}")], vec![]),
1370        ])], spec).to_string().unwrap();
1371        assert!(s.contains("<a href=\"/%C3%A9\">"), "a/href escaped: {s}");
1372        assert!(s.contains("<span href=\"/\u{e9}\">"), "span/href untouched: {s}");
1373    }
1374
1375    #[test]
1376    fn escape_uri_attributes_not_applied_for_xml_method() {
1377        let spec = OutputSpec {
1378            method: Some("xml".into()),
1379            omit_xml_declaration: Some(true),
1380            ..Default::default()
1381        };
1382        let node = elt_attrs("a", &[("href", "/caf\u{e9}")], vec![]);
1383        let s = out_tree(vec![node], spec).to_string().unwrap();
1384        assert!(s.contains("href=\"/caf\u{e9}\""), "got: {s}");
1385    }
1386
1387    // include-content-type ------------------------------------------
1388
1389    #[test]
1390    fn include_content_type_default_inserts_meta_into_head() {
1391        let spec = OutputSpec { method: Some("html".into()), ..Default::default() };
1392        let s = out_tree(vec![elt("html", vec![
1393            elt("head", vec![elt("title", vec![text("T")])]),
1394            elt("body", vec![]),
1395        ])], spec).to_string().unwrap();
1396        assert!(
1397            s.contains(r#"<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">"#),
1398            "got: {s}");
1399        // Spec: inserted as the FIRST child of head, before <title>.
1400        assert!(s.find("http-equiv").unwrap() < s.find("<title>").unwrap(),
1401            "meta must precede title: {s}");
1402    }
1403
1404    #[test]
1405    fn include_content_type_uses_media_type_and_encoding() {
1406        let spec = OutputSpec {
1407            method: Some("html".into()),
1408            media_type: Some("application/xhtml+xml".into()),
1409            encoding: Some("ISO-8859-1".into()),
1410            ..Default::default()
1411        };
1412        let s = out_tree(vec![elt("html", vec![elt("head", vec![])])], spec).to_string().unwrap();
1413        assert!(s.contains(r#"content="application/xhtml+xml; charset=ISO-8859-1""#), "got: {s}");
1414    }
1415
1416    #[test]
1417    fn include_content_type_replaces_existing_content_type_meta() {
1418        let spec = OutputSpec { method: Some("html".into()), ..Default::default() };
1419        let existing = elt_attrs("meta",
1420            &[("http-equiv", "Content-Type"), ("content", "text/html; charset=stale")], vec![]);
1421        let s = out_tree(vec![elt("html", vec![elt("head", vec![existing])])], spec)
1422            .to_string().unwrap();
1423        assert!(!s.contains("charset=stale"), "stale meta must be removed: {s}");
1424        assert_eq!(s.matches("http-equiv").count(), 1, "exactly one content-type meta: {s}");
1425    }
1426
1427    #[test]
1428    fn include_content_type_no_disables_meta() {
1429        let spec = OutputSpec {
1430            method: Some("html".into()),
1431            include_content_type: Some(false),
1432            ..Default::default()
1433        };
1434        let s = out_tree(vec![elt("html", vec![elt("head", vec![])])], spec).to_string().unwrap();
1435        assert!(!s.contains("http-equiv"), "got: {s}");
1436    }
1437
1438    #[test]
1439    fn include_content_type_noop_without_head() {
1440        let spec = OutputSpec { method: Some("html".into()), ..Default::default() };
1441        let s = out_tree(vec![elt("html", vec![elt("body", vec![])])], spec).to_string().unwrap();
1442        assert!(!s.contains("http-equiv"), "no head → no meta: {s}");
1443    }
1444
1445    #[test]
1446    fn include_content_type_not_applied_for_xml_method() {
1447        let spec = OutputSpec {
1448            method: Some("xml".into()),
1449            omit_xml_declaration: Some(true),
1450            ..Default::default()
1451        };
1452        let s = out_tree(vec![elt("html", vec![elt("head", vec![])])], spec).to_string().unwrap();
1453        assert!(!s.contains("http-equiv"), "xml method must not inject meta: {s}");
1454    }
1455}