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