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;
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 | text.
45    pub fn to_string(&self) -> Result<String, XsltError> {
46        match effective_method(self) {
47            "html" => Ok(serialize_html(self)),
48            "text" => Ok(serialize_text(self)),
49            _      => Ok(serialize_xml(self)),
50        }
51    }
52
53    /// Write the serialised result to any [`io::Write`] sink.
54    pub fn write_to(&self, w: &mut dyn std::io::Write) -> Result<(), XsltError> {
55        let s = self.to_string()?;
56        w.write_all(s.as_bytes())
57            .map_err(|e| XsltError::InvalidStylesheet(format!("write failed: {e}")))
58    }
59}
60
61// ── XML serialiser ────────────────────────────────────────────────
62
63pub fn serialize_xml(tree: &ResultTree) -> String {
64    let mut out = String::new();
65    if should_emit_xml_decl(&tree.output) {
66        let _ = write!(out, r#"<?xml version="{}" encoding="{}""#,
67            tree.output.version.as_deref().unwrap_or("1.0"),
68            tree.output.encoding.as_deref().unwrap_or("UTF-8"),
69        );
70        if let Some(s) = tree.output.standalone {
71            let _ = write!(out, r#" standalone="{}""#, if s { "yes" } else { "no" });
72        }
73        out.push_str("?>\n");
74    }
75    if let Some(dt_sys) = tree.output.doctype_system.as_deref() {
76        if let Some(root) = first_element_name(&tree.children) {
77            if let Some(pubid) = tree.output.doctype_public.as_deref() {
78                let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}" "{dt_sys}">"#);
79            } else {
80                let _ = writeln!(out, r#"<!DOCTYPE {root} SYSTEM "{dt_sys}">"#);
81            }
82        }
83    }
84    // `indent="yes"` (XSLT 1.0 §16.1): pretty-print element-only
85    // content. Mixed content (any text-node child) suppresses
86    // formatting for that element and its whole subtree so text is
87    // preserved verbatim.
88    let format = tree.output.indent == Some(true);
89    for child in &tree.children {
90        serialize_xml_node(child, &mut out, &tree.output, "", &tree.character_map, format, 0);
91    }
92    if format && !out.ends_with('\n') {
93        out.push('\n');
94    }
95    out
96}
97
98/// Two spaces per nesting level — libxslt's default indent step.
99fn push_indent(out: &mut String, level: usize) {
100    for _ in 0..level {
101        out.push_str("  ");
102    }
103}
104
105fn should_emit_xml_decl(output: &OutputSpec) -> bool {
106    // libxslt default: emit for xml method unless omit=yes.
107    !output.omit_xml_declaration.unwrap_or(false)
108}
109
110fn first_element_name(nodes: &[ResultNode]) -> Option<String> {
111    nodes.iter().find_map(|n| match n {
112        ResultNode::Element { name, .. } => Some(name.to_qname_string()),
113        _ => None,
114    })
115}
116
117/// Serialize one result-tree node.
118///
119/// `parent_default_ns` is the URI bound to the default namespace in
120/// the surrounding scope (`""` if none) — used to suppress redundant
121/// `xmlns=""` declarations on elements whose surrounding scope
122/// already has no default namespace.
123fn serialize_xml_node(
124    node:        &ResultNode,
125    out:         &mut String,
126    opts:        &OutputSpec,
127    parent_default_ns: &str,
128    cmap:        &[(char, String)],
129    format:      bool,
130    level:       usize,
131) {
132    let xml_11   = opts.version.as_deref() == Some("1.1");
133    let enc_cap  = encoding_capability(opts.encoding.as_deref());
134    match node {
135        ResultNode::Element { name, namespaces, attributes, children, .. } => {
136            let q = name.to_qname_string();
137            out.push('<');
138            out.push_str(&q);
139            // Compute the default namespace this element actually
140            // contributes (used both to suppress redundant decls here
141            // and to thread down to children).  When the element has
142            // no explicit default-namespace binding in `namespaces`,
143            // it inherits the parent's.
144            let mut child_default_ns: &str = parent_default_ns;
145            for (prefix, uri) in namespaces {
146                match prefix {
147                    // The `xml` prefix is bound by the XML spec itself
148                    // to the XML namespace URI; redeclaration is
149                    // forbidden by XML Namespaces § 3 ("Prefix `xml`
150                    // is by definition bound to ..."). Suppress it
151                    // here so result trees that carry the binding
152                    // through (e.g. `xml:space`-bearing elements)
153                    // don't emit a redundant decl.
154                    Some(p) if p == "xml"
155                        && uri == "http://www.w3.org/XML/1998/namespace" => {}
156                    Some(p) => { let _ = write!(out, r#" xmlns:{p}="{}""#, escape_attr(uri, xml_11, enc_cap)); }
157                    None => {
158                        // Suppress a default-namespace declaration that
159                        // would have no observable effect — same URI as
160                        // the surrounding scope already has bound.
161                        // Notably an `xmlns=""` undeclaration when the
162                        // surrounding default is already empty.
163                        if uri != parent_default_ns {
164                            let _ = write!(out, r#" xmlns="{}""#, escape_attr(uri, xml_11, enc_cap));
165                        }
166                        child_default_ns = uri.as_str();
167                    }
168                }
169            }
170            for (aname, value) in attributes {
171                let _ = write!(out, r#" {}="{}""#,
172                    aname.to_qname_string(),
173                    escape_attr_with_map(value, xml_11, enc_cap, cmap));
174            }
175            if children.is_empty() {
176                out.push_str("/>");
177                return;
178            }
179            out.push('>');
180            // CDATA-section elements: text children of these are
181            // wrapped in <![CDATA[...]]> rather than escaped.
182            let is_cdata = opts.cdata_section_elements.iter()
183                .any(|q| q.uri == name.uri && q.local == name.local);
184            // Only element-only content is indented; the presence of a
185            // text child collapses formatting for this element so its
186            // character data round-trips unchanged.
187            let child_format = format
188                && !children.iter().any(|c| matches!(c, ResultNode::Text { .. }));
189            for c in children {
190                if child_format {
191                    out.push('\n');
192                    push_indent(out, level + 1);
193                }
194                if is_cdata {
195                    if let ResultNode::Text { content, .. } = c {
196                        out.push_str("<![CDATA[");
197                        out.push_str(&content.replace("]]>", "]]]]><![CDATA[>"));
198                        out.push_str("]]>");
199                        continue;
200                    }
201                }
202                serialize_xml_node(c, out, opts, child_default_ns, cmap, child_format, level + 1);
203            }
204            if child_format {
205                out.push('\n');
206                push_indent(out, level);
207            }
208            out.push_str("</");
209            out.push_str(&q);
210            out.push('>');
211        }
212        ResultNode::Text { content, dose } => {
213            if *dose {
214                out.push_str(content);
215            } else {
216                out.push_str(&escape_text_with_map(content, xml_11, enc_cap, cmap));
217            }
218        }
219        ResultNode::Comment(s) => {
220            let _ = write!(out, "<!--{}-->", s);
221        }
222        ResultNode::ProcessingInstruction { target, data } => {
223            if data.is_empty() {
224                let _ = write!(out, "<?{target}?>");
225            } else {
226                let _ = write!(out, "<?{target} {data}?>");
227            }
228        }
229        // A parentless attribute has no serialization in element
230        // content (XSLT consumes it via copy-of / apply-templates);
231        // emit nothing rather than malformed output.
232        ResultNode::Attribute { .. } => {}
233    }
234}
235
236/// XML 1.1 § 2.11 restricted chars, plus NEL (#x85) and LSEP
237/// (#x2028).  The restricted set MUST be NCR-escaped in serialized
238/// output per the XML 1.1 spec; NEL and LSEP are technically
239/// allowed unescaped but the XML 1.1 input parser normalises both
240/// to LF, so a round-trip needs them as NCRs.  Tab/LF/CR are not in
241/// this set — `escape_attr` handles those independently.
242#[inline]
243fn xml_11_must_escape(c: char) -> bool {
244    matches!(c as u32,
245        0x01..=0x08 | 0x0B..=0x0C | 0x0E..=0x1F |
246        0x7F..=0x84 | 0x85 | 0x86..=0x9F |
247        0x2028
248    )
249}
250
251/// Coverage of a named output encoding: the largest Unicode codepoint
252/// that the encoding can represent directly.  Codepoints above this
253/// must be emitted as numeric character references (XSLT 1.0 §16
254/// "output that uses a character encoding cannot directly represent
255/// ... must escape").  `None` means UTF-8 / UTF-16 / unknown — every
256/// codepoint passes through unescaped.
257fn encoding_capability(enc: Option<&str>) -> Option<u32> {
258    let name = enc.unwrap_or("UTF-8").to_ascii_lowercase();
259    let norm: String = name.chars().filter(|c| !matches!(c, '-' | '_' | ' ')).collect();
260    match norm.as_str() {
261        // 7-bit ASCII — only codepoints ≤ 0x7F are representable.
262        "ascii" | "usascii" | "iso646us" => Some(0x7F),
263        // Single-byte ISO/Windows family — represent ≤ 0xFF (the high
264        // half maps to a code-page-specific character, but a numeric
265        // reference is always safe and round-trippable).
266        "iso88591" | "latin1" | "l1" | "cp1252" | "windows1252" => Some(0xFF),
267        // Multi-byte encodings that cover the full BMP and beyond
268        // (UTF-8, UTF-16, UTF-32) — no escape needed for content
269        // characters at all.
270        _ => None,
271    }
272}
273
274#[inline]
275fn must_ncr_escape(c: char, enc_cap: Option<u32>) -> bool {
276    matches!(enc_cap, Some(max) if c as u32 > max)
277}
278
279fn escape_text(s: &str, xml_11: bool, enc_cap: Option<u32>) -> String {
280    escape_text_with_map(s, xml_11, enc_cap, &[])
281}
282
283fn escape_attr(s: &str, xml_11: bool, enc_cap: Option<u32>) -> String {
284    escape_attr_with_map(s, xml_11, enc_cap, &[])
285}
286
287/// Look up `c` in the (small) character-map list.  Linear scan;
288/// the map sizes encountered in XSLT 2.0 stylesheets are tiny
289/// (typically <10 entries) and the lookup happens once per
290/// character of serialized output.
291#[inline]
292fn cmap_lookup<'a>(c: char, cmap: &'a [(char, String)]) -> Option<&'a str> {
293    for (k, v) in cmap {
294        if *k == c { return Some(v.as_str()); }
295    }
296    None
297}
298
299fn escape_text_with_map(
300    s: &str,
301    xml_11: bool,
302    enc_cap: Option<u32>,
303    cmap: &[(char, String)],
304) -> String {
305    let mut out = String::with_capacity(s.len());
306    for c in s.chars() {
307        if let Some(replacement) = cmap_lookup(c, cmap) {
308            out.push_str(replacement);
309            continue;
310        }
311        match c {
312            '<' => out.push_str("&lt;"),
313            '>' => out.push_str("&gt;"),
314            '&' => out.push_str("&amp;"),
315            // Literal `\r` would be rewritten to `\n` by the receiving
316            // parser's XML § 2.11 end-of-line normalization, so always
317            // escape it as a character reference for round-trip.
318            '\r' => out.push_str("&#xD;"),
319            c if xml_11 && xml_11_must_escape(c) => {
320                let _ = write!(out, "&#{};", c as u32);
321            }
322            c if must_ncr_escape(c, enc_cap) => {
323                let _ = write!(out, "&#{};", c as u32);
324            }
325            _   => out.push(c),
326        }
327    }
328    out
329}
330
331fn escape_attr_with_map(
332    s: &str,
333    xml_11: bool,
334    enc_cap: Option<u32>,
335    cmap: &[(char, String)],
336) -> String {
337    let mut out = String::with_capacity(s.len());
338    for c in s.chars() {
339        if let Some(replacement) = cmap_lookup(c, cmap) {
340            out.push_str(replacement);
341            continue;
342        }
343        match c {
344            '<' => out.push_str("&lt;"),
345            '>' => out.push_str("&gt;"),
346            '&' => out.push_str("&amp;"),
347            '"' => out.push_str("&quot;"),
348            '\n' => out.push_str("&#10;"),
349            '\r' => out.push_str("&#13;"),
350            '\t' => out.push_str("&#9;"),
351            c if xml_11 && xml_11_must_escape(c) => {
352                let _ = write!(out, "&#{};", c as u32);
353            }
354            c if must_ncr_escape(c, enc_cap) => {
355                let _ = write!(out, "&#{};", c as u32);
356            }
357            _   => out.push(c),
358        }
359    }
360    out
361}
362
363// ── HTML serialiser ───────────────────────────────────────────────
364
365/// HTML5 void elements — emitted without a closing tag and
366/// without `/>`.  The list is from the HTML5 spec § "Void
367/// elements"; lowercase canonical form.
368const VOID_ELEMENTS: &[&str] = &[
369    "area", "base", "br", "col", "embed", "hr", "img", "input",
370    "link", "meta", "param", "source", "track", "wbr",
371];
372
373/// Elements whose text content must NOT be escaped (XSLT 1.0 §16
374/// HTML output method).
375const RAW_TEXT_ELEMENTS: &[&str] = &["script", "style"];
376
377pub fn serialize_html(tree: &ResultTree) -> String {
378    let mut out = String::new();
379    if let Some(dt_sys) = tree.output.doctype_system.as_deref() {
380        if let Some(root) = first_element_name(&tree.children) {
381            if let Some(pubid) = tree.output.doctype_public.as_deref() {
382                let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}" "{dt_sys}">"#);
383            } else {
384                let _ = writeln!(out, r#"<!DOCTYPE {root} SYSTEM "{dt_sys}">"#);
385            }
386        }
387    } else if let Some(pubid) = tree.output.doctype_public.as_deref() {
388        if let Some(root) = first_element_name(&tree.children) {
389            let _ = writeln!(out, r#"<!DOCTYPE {root} PUBLIC "{pubid}">"#);
390        }
391    }
392    let format = tree.output.indent == Some(true);
393    for c in &tree.children { serialize_html_node(c, &mut out, format, 0); }
394    if format && !out.ends_with('\n') {
395        out.push('\n');
396    }
397    out
398}
399
400fn serialize_html_node(node: &ResultNode, out: &mut String, format: bool, level: usize) {
401    match node {
402        ResultNode::Element { name, namespaces, attributes, children, .. } => {
403            let local_lc = name.local.to_lowercase();
404            let q = name.to_qname_string();
405            out.push('<');
406            out.push_str(&q);
407            for (prefix, uri) in namespaces {
408                match prefix {
409                    Some(p) => { let _ = write!(out, r#" xmlns:{p}="{}""#, escape_attr(uri, false, None)); }
410                    None    => { let _ = write!(out, r#" xmlns="{}""#, escape_attr(uri, false, None)); }
411                }
412            }
413            for (aname, value) in attributes {
414                let _ = write!(out, r#" {}="{}""#,
415                    aname.to_qname_string(), escape_attr(value, false, None));
416            }
417            // Void elements: close with `>`, no children, no closing tag.
418            if name.uri.is_empty() && VOID_ELEMENTS.iter().any(|v| *v == local_lc) {
419                out.push('>');
420                return;
421            }
422            out.push('>');
423            let raw_text = name.uri.is_empty()
424                && RAW_TEXT_ELEMENTS.iter().any(|v| *v == local_lc);
425            // As for XML, only element-only content is indented; a text
426            // child (including the unescaped body of script/style)
427            // collapses formatting so content round-trips unchanged.
428            let child_format = format
429                && !children.iter().any(|c| matches!(c, ResultNode::Text { .. }));
430            for c in children {
431                if child_format {
432                    out.push('\n');
433                    push_indent(out, level + 1);
434                }
435                if raw_text {
436                    if let ResultNode::Text { content, .. } = c {
437                        out.push_str(content);
438                        continue;
439                    }
440                }
441                serialize_html_node(c, out, child_format, level + 1);
442            }
443            if child_format {
444                out.push('\n');
445                push_indent(out, level);
446            }
447            out.push_str("</");
448            out.push_str(&q);
449            out.push('>');
450        }
451        ResultNode::Text { content, dose } => {
452            if *dose { out.push_str(content); }
453            else     { out.push_str(&escape_text(content, false, None)); }
454        }
455        ResultNode::Comment(s) => {
456            let _ = write!(out, "<!--{s}-->");
457        }
458        ResultNode::ProcessingInstruction { target, data } => {
459            // HTML5 doesn't really have PIs but XSLT spec says
460            // emit them as `<?target data>` (no `?>`).
461            if data.is_empty() {
462                let _ = write!(out, "<?{target}>");
463            } else {
464                let _ = write!(out, "<?{target} {data}>");
465            }
466        }
467        // Parentless attribute — no element-content serialization.
468        ResultNode::Attribute { .. } => {}
469    }
470}
471
472// ── text serialiser ───────────────────────────────────────────────
473
474pub fn serialize_text(tree: &ResultTree) -> String {
475    let mut out = String::new();
476    for c in &tree.children { append_text(c, &mut out); }
477    out
478}
479
480fn append_text(node: &ResultNode, out: &mut String) {
481    match node {
482        ResultNode::Text { content, .. } => out.push_str(content),
483        ResultNode::Element { children, .. } => {
484            for c in children { append_text(c, out); }
485        }
486        // Comments + PIs are stripped entirely in text output.
487        _ => {}
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::ast::QName;
495
496    fn elt(name: &str, children: Vec<ResultNode>) -> ResultNode {
497        ResultNode::Element {
498            name: QName { prefix: None, local: name.into(), uri: String::new() },
499            namespaces: Vec::new(),
500            attributes: Vec::new(),
501            children,
502            schema_type: None,
503            attr_types: Vec::new(),
504        }
505    }
506
507    fn text(s: &str) -> ResultNode {
508        ResultNode::Text { content: s.into(), dose: false }
509    }
510
511    fn tree_of(nodes: Vec<ResultNode>, method: Option<&str>) -> ResultTree {
512        let mut spec = OutputSpec::default();
513        spec.method = method.map(str::to_string);
514        spec.omit_xml_declaration = Some(true); // simplify tests
515        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
516    }
517
518    // ── XML ─────────────────────────────────────────────────
519
520    #[test]
521    fn xml_empty_element_self_closes() {
522        let t = tree_of(vec![elt("br", vec![])], None);
523        assert_eq!(t.to_string().unwrap(), "<br/>");
524    }
525
526    #[test]
527    fn xml_escapes_text_specials() {
528        let t = tree_of(
529            vec![elt("p", vec![text("a < b && c > d")])],
530            None,
531        );
532        assert_eq!(t.to_string().unwrap(), "<p>a &lt; b &amp;&amp; c &gt; d</p>");
533    }
534
535    #[test]
536    fn xml_escapes_attr_quote_and_specials() {
537        let t = tree_of(vec![ResultNode::Element {
538            name: QName { prefix: None, local: "a".into(), uri: String::new() },
539            namespaces: Vec::new(),
540            attributes: vec![(
541                QName { prefix: None, local: "href".into(), uri: String::new() },
542                r#"x"&y<z"#.to_string(),
543            )],
544            children: Vec::new(),
545            schema_type: None,
546            attr_types: Vec::new(),
547        }],None);
548        assert_eq!(t.to_string().unwrap(), r#"<a href="x&quot;&amp;y&lt;z"/>"#);
549    }
550
551    #[test]
552    fn xml_text_dose_skips_escape() {
553        let t = tree_of(
554            vec![elt("p", vec![ResultNode::Text { content: "<raw/>".into(), dose: true }])],
555            None,
556        );
557        assert_eq!(t.to_string().unwrap(), "<p><raw/></p>");
558    }
559
560    fn tree_indented(nodes: Vec<ResultNode>) -> ResultTree {
561        let mut spec = OutputSpec::default();
562        spec.omit_xml_declaration = Some(true);
563        spec.indent = Some(true);
564        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
565    }
566
567    #[test]
568    fn xml_indent_yes_pretty_prints_element_only_content() {
569        let t = tree_indented(vec![elt("root", vec![
570            elt("a", vec![elt("b", vec![])]),
571            elt("c", vec![]),
572        ])]);
573        assert_eq!(
574            t.to_string().unwrap(),
575            "<root>\n  <a>\n    <b/>\n  </a>\n  <c/>\n</root>\n",
576        );
577    }
578
579    #[test]
580    fn xml_indent_yes_preserves_mixed_content() {
581        // An element with a text child is left untouched, and that
582        // collapses formatting for its whole subtree.
583        let t = tree_indented(vec![elt("root", vec![
584            elt("p", vec![text("hello "), elt("b", vec![text("world")])]),
585        ])]);
586        assert_eq!(
587            t.to_string().unwrap(),
588            "<root>\n  <p>hello <b>world</b></p>\n</root>\n",
589        );
590    }
591
592    #[test]
593    fn xml_indent_off_by_default() {
594        let t = tree_of(vec![elt("root", vec![elt("a", vec![])])], None);
595        assert_eq!(t.to_string().unwrap(), "<root><a/></root>");
596    }
597
598    // ── HTML ────────────────────────────────────────────────
599
600    #[test]
601    fn html_void_elements_get_no_close_no_slash() {
602        let t = tree_of(vec![
603            elt("html", vec![
604                elt("head", vec![ elt("meta", vec![]) ]),
605                elt("body", vec![ elt("br", vec![]), elt("img", vec![]) ]),
606            ]),
607        ], Some("html"));
608        let s = t.to_string().unwrap();
609        assert!(s.contains("<meta>"),  "got: {s}");
610        assert!(s.contains("<br>"),    "got: {s}");
611        assert!(s.contains("<img>"),   "got: {s}");
612        assert!(!s.contains("<br/>"),  "got: {s}");
613        assert!(!s.contains("<meta/>"), "got: {s}");
614    }
615
616    #[test]
617    fn html_script_content_not_escaped() {
618        let t = tree_of(vec![ elt("script", vec![ text("if (a < b) alert('x');") ]) ],
619            Some("html"));
620        let s = t.to_string().unwrap();
621        assert!(s.contains("if (a < b)"), "script body should be raw: {s}");
622    }
623
624    #[test]
625    fn html_default_detected_by_root_html_element() {
626        // No method= set, root is <html> → HTML serialiser.
627        let t = tree_of(vec![ elt("html", vec![ elt("br", vec![]) ]) ], None);
628        let s = t.to_string().unwrap();
629        // HTML default emits `<br>` not `<br/>`.
630        assert!(s.contains("<br>"), "got: {s}");
631        assert!(!s.contains("<br/>"));
632    }
633
634    fn tree_indented_method(nodes: Vec<ResultNode>, method: &str) -> ResultTree {
635        let mut spec = OutputSpec::default();
636        spec.method = Some(method.into());
637        spec.indent = Some(true);
638        ResultTree { children: nodes, output: spec, character_map: Vec::new(), secondary: Vec::new() }
639    }
640
641    #[test]
642    fn html_indent_yes_pretty_prints_element_only_content() {
643        let t = tree_indented_method(vec![
644            elt("html", vec![
645                elt("head", vec![elt("meta", vec![])]),
646                elt("body", vec![elt("p", vec![text("hi")])]),
647            ]),
648        ], "html");
649        assert_eq!(
650            t.to_string().unwrap(),
651            "<html>\n  <head>\n    <meta>\n  </head>\n  <body>\n    <p>hi</p>\n  </body>\n</html>\n",
652        );
653    }
654
655    #[test]
656    fn html_indent_yes_preserves_mixed_content_and_raw_text() {
657        let t = tree_indented_method(vec![
658            elt("body", vec![
659                elt("p", vec![text("a "), elt("b", vec![text("c")]), text(" d")]),
660                elt("script", vec![text("if (a < b) x();")]),
661            ]),
662        ], "html");
663        assert_eq!(
664            t.to_string().unwrap(),
665            "<body>\n  <p>a <b>c</b> d</p>\n  <script>if (a < b) x();</script>\n</body>\n",
666        );
667    }
668
669    #[test]
670    fn html_indent_off_by_default() {
671        let t = tree_of(vec![elt("html", vec![elt("body", vec![elt("br", vec![])])])],
672            Some("html"));
673        assert_eq!(t.to_string().unwrap(), "<html><body><br></body></html>");
674    }
675
676    // ── text ────────────────────────────────────────────────
677
678    #[test]
679    fn text_strips_markup() {
680        let t = tree_of(vec![ elt("p", vec![
681            text("Hello, "),
682            elt("b", vec![text("world")]),
683            text("!"),
684        ]) ], Some("text"));
685        assert_eq!(t.to_string().unwrap(), "Hello, world!");
686    }
687
688    #[test]
689    fn text_strips_comments_and_pis() {
690        let t = tree_of(vec![
691            ResultNode::Comment("ignored".into()),
692            elt("p", vec![text("kept")]),
693            ResultNode::ProcessingInstruction { target: "pi".into(), data: "ignored".into() },
694        ], Some("text"));
695        assert_eq!(t.to_string().unwrap(), "kept");
696    }
697
698    // ── write_to ────────────────────────────────────────────────
699
700    #[test]
701    fn write_to_io_writer() {
702        let t = tree_of(vec![elt("r", vec![text("hi")])], None);
703        let mut buf = Vec::<u8>::new();
704        t.write_to(&mut buf).unwrap();
705        assert_eq!(buf, b"<r>hi</r>");
706    }
707
708    // ── XML declaration ─────────────────────────────────────────
709
710    #[test]
711    fn xml_decl_emitted_when_not_omitted() {
712        let mut spec = OutputSpec::default();
713        spec.omit_xml_declaration = Some(false);
714        spec.version = Some("1.0".into());
715        spec.encoding = Some("UTF-8".into());
716        let t = ResultTree {
717            children: vec![elt("r", vec![])],
718            output: spec,
719            character_map: Vec::new(),
720            secondary: Vec::new(),
721        };
722        let s = t.to_string().unwrap();
723        assert!(s.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#), "got: {s}");
724    }
725
726    #[test]
727    fn xml_decl_emits_standalone_yes() {
728        let mut spec = OutputSpec::default();
729        spec.omit_xml_declaration = Some(false);
730        spec.standalone = Some(true);
731        let t = ResultTree {
732            children: vec![elt("r", vec![])],
733            output: spec,
734            character_map: Vec::new(),
735            secondary: Vec::new(),
736        };
737        let s = t.to_string().unwrap();
738        assert!(s.contains(r#"standalone="yes""#), "got: {s}");
739    }
740
741    #[test]
742    fn xml_decl_emits_standalone_no() {
743        let mut spec = OutputSpec::default();
744        spec.omit_xml_declaration = Some(false);
745        spec.standalone = Some(false);
746        let t = ResultTree {
747            children: vec![elt("r", vec![])],
748            output: spec,
749            character_map: Vec::new(),
750            secondary: Vec::new(),
751        };
752        let s = t.to_string().unwrap();
753        assert!(s.contains(r#"standalone="no""#), "got: {s}");
754    }
755
756    // ── XML DOCTYPE ─────────────────────────────────────────────
757
758    #[test]
759    fn xml_doctype_system() {
760        let mut spec = OutputSpec::default();
761        spec.omit_xml_declaration = Some(true);
762        spec.doctype_system = Some("foo.dtd".into());
763        let t = ResultTree {
764            children: vec![elt("r", vec![])],
765            output: spec,
766            character_map: Vec::new(),
767            secondary: Vec::new(),
768        };
769        let s = t.to_string().unwrap();
770        assert!(s.contains(r#"<!DOCTYPE r SYSTEM "foo.dtd">"#), "got: {s}");
771    }
772
773    #[test]
774    fn xml_doctype_public() {
775        let mut spec = OutputSpec::default();
776        spec.omit_xml_declaration = Some(true);
777        spec.doctype_system = Some("foo.dtd".into());
778        spec.doctype_public = Some("-//ID//PUB".into());
779        let t = ResultTree {
780            children: vec![elt("r", vec![])],
781            output: spec,
782            character_map: Vec::new(),
783            secondary: Vec::new(),
784        };
785        let s = t.to_string().unwrap();
786        assert!(s.contains(r#"<!DOCTYPE r PUBLIC "-//ID//PUB" "foo.dtd">"#), "got: {s}");
787    }
788
789    // ── XML namespace declarations ──────────────────────────────
790
791    #[test]
792    fn xml_emits_namespace_declarations() {
793        let t = tree_of(vec![ResultNode::Element {
794            name: QName { prefix: Some("xs".into()), local: "schema".into(), uri: "http://www.w3.org/2001/XMLSchema".into() },
795            namespaces: vec![
796                (Some("xs".into()), "http://www.w3.org/2001/XMLSchema".into()),
797                (None, "http://example.com/default".into()),
798            ],
799            attributes: Vec::new(),
800            children: Vec::new(),
801            schema_type: None,
802            attr_types: Vec::new(),
803        }],None);
804        let s = t.to_string().unwrap();
805        assert!(s.contains(r#"xmlns:xs="http://www.w3.org/2001/XMLSchema""#), "got: {s}");
806        assert!(s.contains(r#"xmlns="http://example.com/default""#), "got: {s}");
807    }
808
809    // ── XML comments & PIs ──────────────────────────────────────
810
811    #[test]
812    fn xml_serializes_comment() {
813        let t = tree_of(vec![ResultNode::Comment(" hello ".into())], None);
814        assert_eq!(t.to_string().unwrap(), "<!-- hello -->");
815    }
816
817    #[test]
818    fn xml_serializes_pi_no_data() {
819        let t = tree_of(vec![
820            ResultNode::ProcessingInstruction { target: "pi".into(), data: String::new() },
821        ], None);
822        assert_eq!(t.to_string().unwrap(), "<?pi?>");
823    }
824
825    #[test]
826    fn xml_serializes_pi_with_data() {
827        let t = tree_of(vec![
828            ResultNode::ProcessingInstruction {
829                target: "xml-stylesheet".into(),
830                data: r#"href="s.xsl""#.into(),
831            },
832        ], None);
833        assert_eq!(t.to_string().unwrap(),
834            r#"<?xml-stylesheet href="s.xsl"?>"#);
835    }
836
837    // ── CDATA-section elements ──────────────────────────────────
838
839    #[test]
840    fn xml_cdata_section_elements_wrap_text_children() {
841        let mut spec = OutputSpec::default();
842        spec.omit_xml_declaration = Some(true);
843        spec.cdata_section_elements = vec![
844            QName { prefix: None, local: "raw".into(), uri: String::new() },
845        ];
846        let t = ResultTree {
847            children: vec![elt("raw", vec![text("a < b & c")])],
848            output: spec,
849            character_map: Vec::new(),
850            secondary: Vec::new(),
851        };
852        let s = t.to_string().unwrap();
853        assert!(s.contains("<![CDATA[a < b & c]]>"), "got: {s}");
854    }
855
856    #[test]
857    fn xml_cdata_section_splits_embedded_close_seq() {
858        // "]]>" inside the text must be split across two CDATA blocks.
859        let mut spec = OutputSpec::default();
860        spec.omit_xml_declaration = Some(true);
861        spec.cdata_section_elements = vec![
862            QName { prefix: None, local: "raw".into(), uri: String::new() },
863        ];
864        let t = ResultTree {
865            children: vec![elt("raw", vec![text("end ]]> here")])],
866            output: spec,
867            character_map: Vec::new(),
868            secondary: Vec::new(),
869        };
870        let s = t.to_string().unwrap();
871        // Implementation splits ]]> into "]]]]><![CDATA[>".
872        assert!(s.contains("]]]]><![CDATA[>"), "got: {s}");
873    }
874
875    // ── escape_attr full coverage ───────────────────────────────
876
877    #[test]
878    fn xml_attr_escapes_newline_tab_cr() {
879        let t = tree_of(vec![ResultNode::Element {
880            name: QName { prefix: None, local: "a".into(), uri: String::new() },
881            namespaces: Vec::new(),
882            attributes: vec![(
883                QName { prefix: None, local: "v".into(), uri: String::new() },
884                "x\ny\tz\rw".to_string(),
885            )],
886            children: Vec::new(),
887            schema_type: None,
888            attr_types: Vec::new(),
889        }],None);
890        let s = t.to_string().unwrap();
891        assert!(s.contains("&#10;"), "got: {s}");
892        assert!(s.contains("&#9;"),  "got: {s}");
893        assert!(s.contains("&#13;"), "got: {s}");
894    }
895
896    // ── HTML DOCTYPE ────────────────────────────────────────────
897
898    #[test]
899    fn html_doctype_system_only() {
900        let mut spec = OutputSpec::default();
901        spec.method = Some("html".into());
902        spec.doctype_system = Some("about:legacy-compat".into());
903        let t = ResultTree {
904            children: vec![elt("html", vec![])],
905            output: spec,
906            character_map: Vec::new(),
907            secondary: Vec::new(),
908        };
909        let s = t.to_string().unwrap();
910        assert!(s.contains(r#"<!DOCTYPE html SYSTEM "about:legacy-compat">"#), "got: {s}");
911    }
912
913    #[test]
914    fn html_doctype_public_only() {
915        // PUBLIC without SYSTEM → emit `<!DOCTYPE root PUBLIC "...">`.
916        let mut spec = OutputSpec::default();
917        spec.method = Some("html".into());
918        spec.doctype_public = Some("-//W3C//DTD HTML 4.01//EN".into());
919        let t = ResultTree {
920            children: vec![elt("html", vec![])],
921            output: spec,
922            character_map: Vec::new(),
923            secondary: Vec::new(),
924        };
925        let s = t.to_string().unwrap();
926        assert!(s.contains(r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">"#),
927                "got: {s}");
928    }
929
930    #[test]
931    fn html_doctype_public_and_system() {
932        let mut spec = OutputSpec::default();
933        spec.method = Some("html".into());
934        spec.doctype_public = Some("-//W3C//DTD HTML 4.01//EN".into());
935        spec.doctype_system = Some("http://www.w3.org/TR/html4/strict.dtd".into());
936        let t = ResultTree {
937            children: vec![elt("html", vec![])],
938            output: spec,
939            character_map: Vec::new(),
940            secondary: Vec::new(),
941        };
942        let s = t.to_string().unwrap();
943        assert!(s.contains(r#"<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">"#),
944                "got: {s}");
945    }
946
947    // ── HTML namespaces / comments / PIs ────────────────────────
948
949    #[test]
950    fn html_emits_namespace_declarations() {
951        let t = tree_of(vec![ResultNode::Element {
952            name: QName { prefix: None, local: "html".into(), uri: String::new() },
953            namespaces: vec![
954                (Some("svg".into()), "http://www.w3.org/2000/svg".into()),
955                (None, "http://www.w3.org/1999/xhtml".into()),
956            ],
957            attributes: Vec::new(),
958            children: Vec::new(),
959            schema_type: None,
960            attr_types: Vec::new(),
961        }],Some("html"));
962        let s = t.to_string().unwrap();
963        assert!(s.contains(r#"xmlns:svg="http://www.w3.org/2000/svg""#), "got: {s}");
964        assert!(s.contains(r#"xmlns="http://www.w3.org/1999/xhtml""#), "got: {s}");
965    }
966
967    #[test]
968    fn html_serializes_comment() {
969        let t = tree_of(vec![
970            elt("html", vec![ResultNode::Comment(" hi ".into())]),
971        ], Some("html"));
972        let s = t.to_string().unwrap();
973        assert!(s.contains("<!-- hi -->"), "got: {s}");
974    }
975
976    #[test]
977    fn html_serializes_pi_with_and_without_data() {
978        let t = tree_of(vec![
979            elt("html", vec![
980                ResultNode::ProcessingInstruction { target: "a".into(), data: String::new() },
981                ResultNode::ProcessingInstruction { target: "b".into(), data: "x".into() },
982            ]),
983        ], Some("html"));
984        let s = t.to_string().unwrap();
985        // HTML PIs end with > (not ?>).
986        assert!(s.contains("<?a>"), "got: {s}");
987        assert!(s.contains("<?b x>"), "got: {s}");
988    }
989
990    #[test]
991    fn html_text_with_dose_skips_escape() {
992        let t = tree_of(vec![
993            elt("html", vec![
994                ResultNode::Text { content: "<raw>".into(), dose: true },
995            ]),
996        ], Some("html"));
997        let s = t.to_string().unwrap();
998        assert!(s.contains("<raw>"), "got: {s}");
999    }
1000
1001    #[test]
1002    fn html_default_method_when_no_root_html() {
1003        // No method specified, root isn't <html> → falls back to XML.
1004        let t = tree_of(vec![elt("r", vec![])], None);
1005        let s = t.to_string().unwrap();
1006        // XML serializer emits self-closing.
1007        assert_eq!(s, "<r/>");
1008    }
1009
1010    // ── text method with dose ───────────────────────────────────
1011
1012    #[test]
1013    fn text_method_concatenates_all_text() {
1014        let t = tree_of(vec![
1015            elt("a", vec![
1016                text("one"),
1017                elt("b", vec![text("two")]),
1018                ResultNode::Text { content: "three".into(), dose: true }, // dose ignored in text mode
1019            ]),
1020        ], Some("text"));
1021        assert_eq!(t.to_string().unwrap(), "onetwothree");
1022    }
1023}