Skip to main content

sup_xml_core/
serializer.rs

1//! Serialize a [`sup_xml_tree::dom::Document`] back to XML text.
2//!
3//! Mirrors the existing [`crate::serializer`] module — same option type
4//! ([`crate::SerializeOptions`]), same escaping rules, same compact and
5//! pretty-print layouts — but walks the arena tree (`Node::children()` /
6//! `Node::attributes()` iterators) rather than the legacy `Vec<Node>` /
7//! `Vec<Attribute>` fields.
8//!
9//! # Example
10//!
11//! ```
12//! use sup_xml_core::{parse_str, serialize_to_string, ParseOptions};
13//! let doc = parse_str("<r><a id='1'/></r>", &ParseOptions::default()).unwrap();
14//! let xml = serialize_to_string(&doc);
15//! assert!(xml.contains(r#"<a id="1"/>"#));
16//! ```
17
18use sup_xml_tree::dom::{Document, Node, NodeKind};
19
20use crate::output::{XmlBuf, OutputCharset};
21
22// ── options ──────────────────────────────────────────────────────────────────
23
24/// Options that control how a [`Document`] is serialized back to XML text.
25///
26/// # Example
27/// ```
28/// use sup_xml_core::{parse_str, serialize_with, SerializeOptions, ParseOptions};
29///
30/// let doc = parse_str("<root><child/></root>", &ParseOptions::default()).unwrap();
31/// let opts = SerializeOptions { format: true, ..SerializeOptions::default() };
32/// println!("{}", serialize_with(&doc, &opts));
33/// ```
34#[derive(Debug, Clone)]
35pub struct SerializeOptions {
36    /// Emit `<?xml version="…" encoding="…"?>` as the first line.
37    /// Default: `true`.  Has no effect when [`html_mode`](Self::html_mode)
38    /// is on (HTML5 has no XML declaration).
39    pub write_xml_decl: bool,
40    /// Pretty-print with newlines and indentation.  When `false` (the
41    /// default), whitespace text nodes are preserved exactly as parsed.
42    /// When `true`, whitespace-only text nodes between elements are dropped
43    /// and fresh indentation is added.  Default: `false`.
44    pub format: bool,
45    /// The string used for one indentation level when `format` is `true`.
46    /// Default: two spaces (`"  "`).
47    pub indent: String,
48    /// Emit HTML5-style serialization rather than XML.  Differences:
49    ///
50    /// - Void elements (`<br>`, `<img>`, `<input>`, …) emit *with no
51    ///   end tag and no self-closing slash* — `<br>` not `<br/>`.
52    /// - Raw-text elements (`<script>`, `<style>`) emit their text
53    ///   content verbatim — no entity escaping.  This is required
54    ///   because the HTML5 tokenizer treats those tags' contents as
55    ///   literal text up to the close tag.
56    /// - Empty non-void elements emit as `<div></div>`, not `<div/>`.
57    ///   HTML5 doesn't support self-closing on non-void elements;
58    ///   browsers parse `<div/>` as `<div>` with no close.
59    /// - Boolean attributes use shorthand: `<input disabled>` instead
60    ///   of `<input disabled="">` or `<input disabled="disabled">`.
61    ///   Detected when the attribute value is empty or matches the
62    ///   attribute name (case-insensitive) — these are
63    ///   spec-equivalent forms in HTML5.
64    /// - The XML declaration is suppressed regardless of
65    ///   [`write_xml_decl`](Self::write_xml_decl).
66    /// - If the document carries `html_metadata` with a captured
67    ///   DOCTYPE, that DOCTYPE is emitted as the first line.
68    ///   For documents without a stored DOCTYPE we don't fabricate
69    ///   one — caller's responsibility to insert `<!DOCTYPE html>`
70    ///   if they want it.
71    ///
72    /// Default: `false`.  Turn on when emitting output meant to be
73    /// consumed by browsers / HTML parsers rather than XML parsers.
74    pub html_mode: bool,
75    /// Serialize per libxml2's `xhtmlNodeDumpOutput` (XML syntax with
76    /// XHTML accommodations), used when the document's DOCTYPE identifies
77    /// it as XHTML.  Distinct from [`html_mode`](Self::html_mode): output
78    /// is still well-formed XML, but a root `<html>` carrying no namespace
79    /// gains `xmlns="http://www.w3.org/1999/xhtml"`, an empty non-void
80    /// element is written `<tag></tag>` (browsers mis-parse `<tag/>`), and
81    /// an empty void element (`br`, `img`, …) is written `<tag />`.
82    ///
83    /// Default: `false`.
84    pub xhtml: bool,
85    /// The charset the output bytes will ultimately be encoded into.
86    /// Characters it cannot represent are written as numeric character
87    /// references in text / attribute content — matching libxml2, where
88    /// a non-UTF-8 output encoding (or no encoding at all, which defaults
89    /// to ASCII) escapes anything outside its repertoire.  Default:
90    /// [`OutputCharset::Utf8`] (no extra escaping).
91    pub out_charset: OutputCharset,
92}
93
94impl Default for SerializeOptions {
95    fn default() -> Self {
96        Self {
97            write_xml_decl: true,
98            format: false,
99            indent: "  ".to_string(),
100            html_mode: false,
101            xhtml: false,
102            out_charset: OutputCharset::Utf8,
103        }
104    }
105}
106
107// HTML5 helpers — duplicated from `crate::serializer` rather than re-exported
108// to keep the two modules independent.  Tiny; if they ever drift apart that's
109// a bug we'd catch in the round-trip tests below.
110fn is_html_void_element(name: &str) -> bool {
111    matches!(name,
112        "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" |
113        "link" | "meta" | "param" | "source" | "track" | "wbr" |
114        "keygen" | "menuitem"
115    )
116}
117fn is_html_raw_text_element(name: &str) -> bool {
118    matches!(name, "script" | "style")
119}
120/// libxml2's set of HTML boolean attributes (`htmlBooleanAttrs`).  When
121/// an attribute's name is one of these, the HTML serializer emits just
122/// the name and drops the value entirely — `selected="bar"` becomes
123/// `selected` — regardless of what the value was.
124fn is_html_boolean_attr(name: &str) -> bool {
125    const BOOLEAN_ATTRS: &[&str] = &[
126        "checked", "compact", "declare", "defer", "disabled", "ismap",
127        "multiple", "nohref", "noresize", "noshade", "nowrap", "readonly",
128        "selected",
129    ];
130    BOOLEAN_ATTRS.iter().any(|b| name.eq_ignore_ascii_case(b))
131}
132
133// ── public entry points ─────────────────────────────────────────────────────
134
135/// Serialize an arena [`Document`] to a compact XML string with default options.
136pub fn serialize_to_string(doc: &Document) -> String {
137    serialize_with(doc, &SerializeOptions::default())
138}
139
140/// Serialize an arena [`Document`] to a compact UTF-8 byte vector.
141pub fn serialize_to_bytes(doc: &Document) -> Vec<u8> {
142    serialize_to_string(doc).into_bytes()
143}
144
145/// Serialize an arena [`Document`] with pretty-printing (newlines + indentation).
146pub fn serialize_formatted(doc: &Document) -> String {
147    serialize_with(doc, &SerializeOptions {
148        write_xml_decl: true,
149        format:         true,
150        indent:         "  ".to_string(),
151        html_mode:      false,
152        xhtml:          false,
153        out_charset:    OutputCharset::Utf8,
154    })
155}
156
157/// Serialize an arena [`Document`] with full control via [`SerializeOptions`].
158pub fn serialize_with(doc: &Document, opts: &SerializeOptions) -> String {
159    let mut s = Serializer { buf: XmlBuf::with_charset(4096, opts.out_charset), opts };
160    s.write_document(doc);
161    s.buf.into_string()
162}
163
164/// Serialize a single [`Node`] (and its descendants) without the
165/// surrounding document machinery (no XML declaration, no DOCTYPE).
166/// Used by the libxml2 ABI shim's `xmlNodeDump` and by callers that
167/// want a subtree string.
168pub fn serialize_node_to_string(node: &Node<'_>, opts: &SerializeOptions) -> String {
169    let mut s = Serializer { buf: XmlBuf::with_charset(256, opts.out_charset), opts };
170    s.write_node(node, 0);
171    s.buf.into_string()
172}
173
174/// Serialize an arena [`Document`] as HTML5.  Emits the DOCTYPE if the
175/// document carries one, never emits an XML declaration, uses HTML5 void-
176/// element / raw-text / boolean-attribute conventions.  Same shape as
177/// [`crate::serialize_html_to_string`] but accepts the arena tree.
178pub fn serialize_html_to_string(doc: &Document) -> String {
179    serialize_with(doc, &SerializeOptions {
180        html_mode:      true,
181        write_xml_decl: false,
182        ..SerializeOptions::default()
183    })
184}
185
186// ── serializer ──────────────────────────────────────────────────────────────
187
188struct Serializer<'o> {
189    buf:  XmlBuf,
190    opts: &'o SerializeOptions,
191}
192
193impl Serializer<'_> {
194    fn write_document(&mut self, doc: &Document) {
195        if self.opts.html_mode {
196            // HTML5: emit DOCTYPE if the document carries one, never an
197            // XML declaration.
198            if let Some(meta) = &doc.html_metadata {
199                if let Some(dt) = &meta.doctype {
200                    self.buf.push_str("<!DOCTYPE ");
201                    self.buf.push_str(&dt.name);
202                    if !dt.public_id.is_empty() {
203                        self.buf.push_str(" PUBLIC \"");
204                        self.buf.push_str(&dt.public_id);
205                        self.buf.push_byte(b'"');
206                        if !dt.system_id.is_empty() {
207                            self.buf.push_str(" \"");
208                            self.buf.push_str(&dt.system_id);
209                            self.buf.push_byte(b'"');
210                        }
211                    } else if !dt.system_id.is_empty() {
212                        self.buf.push_str(" SYSTEM \"");
213                        self.buf.push_str(&dt.system_id);
214                        self.buf.push_byte(b'"');
215                    }
216                    self.buf.push_byte(b'>');
217                    if self.opts.format {
218                        self.buf.push_byte(b'\n');
219                    }
220                }
221            }
222        } else if self.opts.write_xml_decl {
223            self.buf.push_str("<?xml version=\"");
224            self.buf.push_str(&doc.version);
225            self.buf.push_byte(b'"');
226            // Only emit `encoding=` when the doc carries one.
227            // libxml2's serializer behaves the same — when
228            // doc->encoding is NULL (no `<?xml encoding="…"?>`
229            // declaration in source), the attribute is omitted.
230            if !doc.encoding.is_empty() {
231                self.buf.push_str(" encoding=\"");
232                self.buf.push_str(&doc.encoding);
233                self.buf.push_byte(b'"');
234            }
235            if let Some(sa) = doc.standalone {
236                self.buf.push_str(if sa { " standalone=\"yes\"" } else { " standalone=\"no\"" });
237            }
238            self.buf.push_str("?>");
239            if self.opts.format {
240                self.buf.push_byte(b'\n');
241            }
242        }
243        self.write_node(doc.root(), 0);
244        if self.opts.format {
245            self.buf.push_byte(b'\n');
246        }
247    }
248
249    fn write_node(&mut self, node: &Node<'_>, depth: usize) {
250        match node.kind {
251            NodeKind::Element => self.write_element(node, depth),
252            NodeKind::Text    => self.buf.push_escaped_text(node.content()),
253            NodeKind::Comment => {
254                self.buf.push_str("<!--");
255                self.buf.push_str(node.content());
256                self.buf.push_str("-->");
257            }
258            NodeKind::CData => {
259                self.buf.push_str("<![CDATA[");
260                // A literal `]]>` in the content would close the section
261                // early; libxml2 splits it so the `]]` ends one CDATA and
262                // the `>` opens the next (`]]>` → `]]]]><![CDATA[>`).
263                let content = node.content();
264                if content.contains("]]>") {
265                    self.buf.push_str(&content.replace("]]>", "]]]]><![CDATA[>"));
266                } else {
267                    self.buf.push_str(content);
268                }
269                self.buf.push_str("]]>");
270            }
271            NodeKind::Pi => {
272                self.buf.push_str("<?");
273                self.buf.push_str(node.name());
274                // libxml2 emits the separating space whenever `content`
275                // is non-NULL — even for an empty data section — and omits
276                // it for a no-data PI (NULL content).
277                if let Some(c) = node.content_opt() {
278                    self.buf.push_byte(b' ');
279                    self.buf.push_str(c);
280                }
281                self.buf.push_str("?>");
282            }
283            NodeKind::EntityRef => {
284                // `content` was populated with the literal `&name;`
285                // source form by the parser when emitting the ref —
286                // write it verbatim to round-trip without expansion.
287                self.buf.push_str(node.content());
288            }
289            NodeKind::DtdDecl => {
290                // Raw internal-subset markup declarations — emit
291                // verbatim (already newline-terminated per declaration).
292                self.buf.push_str(node.content());
293            }
294            // The internal-subset node itself carries no markup of its
295            // own (the `<!DOCTYPE …>` header is emitted by the compat
296            // serializer / lxml via `doc->intSubset`); skip it when it
297            // appears as a document-level sibling.
298            NodeKind::Dtd => {}
299            // c-abi-only discriminants.  `Attribute` only sits on the
300            // `xmlAttr` struct (which we never reach through a `Node`
301            // pointer).  `Document` shows up when a C-ABI consumer
302            // hands us the document itself cast to a `Node*` — e.g.
303            // libxml2's `xmlNodeDumpOutput(buf, doc, doc_as_node, …)`.
304            // In that case "serialize the node" means "serialize each
305            // child", since the document itself emits no markup.
306            NodeKind::Attribute => unreachable!("Attribute kind never appears on a Node"),
307            NodeKind::Document | NodeKind::DocumentFragment => {
308                // Both are transparent containers — serialize children
309                // without emitting any markup of their own.  Fragment
310                // is produced by `xmlNewDocFragment` in the compat
311                // shim; Document is reached when a C consumer casts
312                // the doc itself to xmlNode* and asks to dump it.
313                for child in node.children() {
314                    self.write_node(child, depth);
315                }
316            }
317        }
318    }
319
320    fn write_element(&mut self, el: &Node<'_>, depth: usize) {
321        let html_mode = self.opts.html_mode;
322        let name = el.name();
323
324        self.buf.push_byte(b'<');
325        // In c-abi mode `Node::name` is the local part only (libxml2
326        // convention); the prefix lives on the namespace.  Re-prepend
327        // `prefix:` here so serialisation reconstructs the QName that
328        // the parser ingested.  Non-c-abi keeps the QName in `name`
329        // directly — no prefix prepend needed.
330        #[cfg(feature = "c-abi")]
331        if let Some(ns) = el.namespace.get() {
332            if let Some(prefix) = ns.prefix() {
333                self.buf.push_str(prefix);
334                self.buf.push_byte(b':');
335            }
336        }
337        self.buf.push_str(name);
338        // XHTML serialization (libxml2 `xhtmlNodeDumpOutput`): a root
339        // `<html>` that carries no namespace is given the XHTML namespace
340        // declaration, matching the spec's strictly-conforming form.
341        #[cfg(feature = "c-abi")]
342        if self.opts.xhtml
343            && name == "html"
344            && el.namespace.get().is_none()
345            && el.ns_def.get().is_none()
346        {
347            self.buf.push_str(" xmlns=\"http://www.w3.org/1999/xhtml\"");
348        }
349        // In the c-abi build, namespace declarations live on the
350        // separate `ns_def` chain (libxml2 convention) rather than
351        // in the attribute list.  Emit them first so the resulting
352        // serialization carries the same `xmlns[:p]="..."` syntax
353        // regardless of which build the consumer parsed through.
354        #[cfg(feature = "c-abi")]
355        {
356            let mut ns_cur = el.ns_def.get();
357            while let Some(ns) = ns_cur {
358                // The `xml` prefix is predefined by XML 1.0 §3.7 and
359                // must never be re-declared in serialization — emitting
360                // `xmlns:xml="…"` is non-conforming output.  Skip it
361                // silently if some consumer pushed one onto ns_def.
362                let skip = matches!(ns.prefix(), Some("xml"))
363                    && ns.href() == "http://www.w3.org/XML/1998/namespace";
364                if skip {
365                    ns_cur = ns.next.get();
366                    continue;
367                }
368                self.buf.push_byte(b' ');
369                match ns.prefix() {
370                    None    => self.buf.push_str("xmlns"),
371                    Some(p) => {
372                        self.buf.push_str("xmlns:");
373                        self.buf.push_str(p);
374                    }
375                }
376                self.buf.push_str("=\"");
377                self.buf.push_escaped_attr(ns.href());
378                self.buf.push_byte(b'"');
379                ns_cur = ns.next.get();
380            }
381        }
382        for attr in el.attributes() {
383            self.buf.push_byte(b' ');
384            // Same prefix reconstruction as element names (c-abi
385            // only): libxml2 stores the attribute's local name in
386            // `name` and the prefix on `ns`.  Without prepending
387            // we'd emit `a="…"` instead of `ns0:a="…"`.
388            #[cfg(feature = "c-abi")]
389            if let Some(ns) = attr.namespace.get() {
390                if let Some(prefix) = ns.prefix() {
391                    self.buf.push_str(prefix);
392                    self.buf.push_byte(b':');
393                }
394            }
395            self.buf.push_str(attr.name());
396            // HTML minimizes an attribute to its name alone when the name
397            // is a known boolean attribute (libxml2's `htmlIsBooleanAttr`,
398            // value dropped regardless) or when it carries no value at all
399            // (`<tag attribute>`, `el.set(name, None)`).
400            if html_mode && (is_html_boolean_attr(attr.name()) || attr.value().is_empty()) {
401                continue;
402            }
403            self.buf.push_str("=\"");
404            self.buf.push_escaped_attr(attr.value());
405            self.buf.push_byte(b'"');
406        }
407
408        let empty = el.first_child.get().is_none();
409
410        if html_mode {
411            // Void elements: no end tag, no self-closing slash.
412            if is_html_void_element(name) {
413                self.buf.push_byte(b'>');
414                return;
415            }
416            // Empty non-void element: explicit `<tag></tag>` (HTML5 forbids
417            // self-closing on non-void elements).
418            if empty {
419                self.buf.push_str("></");
420                self.write_element_qname(el);
421                self.buf.push_byte(b'>');
422                return;
423            }
424        } else if self.opts.xhtml && empty {
425            // XHTML (libxml2 `xhtmlNodeDumpOutput`): a void element is
426            // `<br />`; any other empty element is `<tag></tag>`, since
427            // browsers parse the XML self-closing form `<tag/>` of a
428            // non-void HTML element as an unterminated open tag.
429            if is_html_void_element(name) {
430                self.buf.push_str(" />");
431            } else {
432                self.buf.push_str("></");
433                self.write_element_qname(el);
434                self.buf.push_byte(b'>');
435            }
436            return;
437        } else if empty {
438            // XML: empty element gets `/>` shorthand.
439            self.buf.push_str("/>");
440            return;
441        }
442
443        self.buf.push_byte(b'>');
444
445        // HTML5 raw-text elements: script and style content is verbatim.
446        if html_mode && is_html_raw_text_element(name) {
447            for child in el.children() {
448                if child.kind == NodeKind::Text {
449                    self.buf.push_str(child.content());
450                }
451            }
452            self.buf.push_str("</");
453            self.write_element_qname(el);
454            self.buf.push_byte(b'>');
455            return;
456        }
457
458        if self.opts.format {
459            if !self.opts.html_mode && contains_text_child(el) {
460                // libxml2's XML pretty-print rule: when an element
461                // has *any* text/cdata/entity-ref child, formatting
462                // is disabled inside it — every child is emitted
463                // verbatim (no indent added, no whitespace trimmed,
464                // no whitespace-only nodes dropped).  Matches
465                // libxml2 2.15.3 xmlsave.c lines 1050-1062
466                // (sets `ctxt->format = 0` and remembers the
467                // `unformattedNode`).
468                //
469                // HTML mode has its own gating via
470                // `html_skip_indent` below — keep them separate
471                // because the rules differ in the edge cases.
472                for child in el.children() {
473                    self.write_node(child, depth + 1);
474                }
475            } else if is_inline(el) {
476                // Single non-empty text child rendered inline.
477                let text = el.children().find_map(|c| match c.kind {
478                    NodeKind::Text => Some(c.content().trim()),
479                    _              => None,
480                }).unwrap_or("");
481                self.buf.push_escaped_text(text);
482            } else if self.opts.html_mode && html_skip_indent(el) {
483                // libxml2's HTMLtree.c rule: skip the newline/indent
484                // dance for the opening *and* closing tag when the
485                // element has a single child, when the first/last
486                // child is text/entity-ref, or when the element is a
487                // formatting-sensitive container ("p", "pre", "param").
488                // Matches the htmlNodeDumpInternal logic
489                // (libxml2 2.15.3 HTMLtree.c lines 969-977 and 1085-1091).
490                for child in el.children() {
491                    self.write_node(child, depth + 1);
492                }
493            } else {
494                // libxml2's HTML serializer inserts newlines between
495                // block children but never indentation — leading
496                // whitespace can change how inline content renders, so
497                // HTMLtree.c emits a bare '\n' where xmlsave.c would
498                // also write `level` indent strings.
499                let indent = !self.opts.html_mode;
500                self.buf.push_byte(b'\n');
501                for child in el.children() {
502                    // Skip whitespace-only text nodes when pretty-printing.
503                    if child.kind == NodeKind::Text && child.content().trim().is_empty() {
504                        continue;
505                    }
506                    if indent {
507                        self.write_indent(depth + 1);
508                    }
509                    self.write_node(child, depth + 1);
510                    self.buf.push_byte(b'\n');
511                }
512                if indent {
513                    self.write_indent(depth);
514                }
515            }
516        } else {
517            for child in el.children() {
518                self.write_node(child, depth + 1);
519            }
520        }
521
522        self.buf.push_str("</");
523        self.write_element_qname(el);
524        self.buf.push_byte(b'>');
525    }
526
527    /// Emit `prefix:local` for an element, or just `local` when no
528    /// namespace prefix is set.  In non-c-abi builds where the
529    /// element name still carries the QName, this is equivalent to
530    /// `push_str(el.name())`.
531    #[inline]
532    fn write_element_qname(&mut self, el: &Node<'_>) {
533        #[cfg(feature = "c-abi")]
534        if let Some(ns) = el.namespace.get() {
535            if let Some(prefix) = ns.prefix() {
536                self.buf.push_str(prefix);
537                self.buf.push_byte(b':');
538            }
539        }
540        self.buf.push_str(el.name());
541    }
542
543    fn write_indent(&mut self, depth: usize) {
544        for _ in 0..depth {
545            self.buf.push_str(&self.opts.indent);
546        }
547    }
548}
549
550/// Returns true if `el` has any direct text-like child (text /
551/// CDATA / entity-ref).  Used to mirror libxml2's XML pretty-print
552/// behaviour: as soon as one text child is present in an element,
553/// formatting is disabled for that element's content so existing
554/// whitespace round-trips unchanged.
555fn contains_text_child(el: &Node<'_>) -> bool {
556    el.children().any(|c| matches!(
557        c.kind,
558        NodeKind::Text | NodeKind::CData | NodeKind::EntityRef
559    ))
560}
561
562/// libxml2's HTML serializer skips the newline-and-indent around an
563/// element's content when:
564///
565/// * the element has only one child (so `cur->children == cur->last`), OR
566/// * the first/last child is a text node or entity-ref, OR
567/// * the element is in the "p, pre, param" formatting-sensitive family.
568///
569/// Returns true for any of those, signaling that the caller should
570/// emit `<tag>...children...</tag>` without inserting whitespace.
571/// Matches libxml2 2.15.3 `HTMLtree.c::htmlNodeDumpInternal`
572/// (around lines 969-977 for the opening newline and 1085-1091 for
573/// the closing one — both gates are functionally the same).
574fn html_skip_indent(el: &Node<'_>) -> bool {
575    // p, pre, param — libxml2's `cur->name[0] != 'p'` check covers
576    // every element whose name starts with 'p' (a slight over-match,
577    // but that's what the reference code does).
578    let name = el.name();
579    if name.as_bytes().first().copied() == Some(b'p') {
580        return true;
581    }
582    // Count children and remember first / last.
583    let mut count = 0usize;
584    let mut first: Option<&Node> = None;
585    let mut last:  Option<&Node> = None;
586    for child in el.children() {
587        if first.is_none() { first = Some(child); }
588        last = Some(child);
589        count += 1;
590    }
591    if count <= 1 { return true; }
592    // Multiple children: skip only when first or last is text /
593    // entity-ref (matches libxml2's `children->type != HTML_TEXT_NODE`
594    // and `last->type != HTML_TEXT_NODE` gates).
595    let is_text_like = |n: &Node| matches!(n.kind, NodeKind::Text | NodeKind::CData);
596    if let Some(f) = first { if is_text_like(f) { return true; } }
597    if let Some(l) = last  { if is_text_like(l) { return true; } }
598    false
599}
600
601/// Render inline when the element has exactly one significant child and that
602/// child is text.  Matches `crate::serializer::is_inline` semantics.
603fn is_inline(el: &Node<'_>) -> bool {
604    let mut count = 0;
605    let mut text_only = true;
606    for child in el.children() {
607        let is_ws_text = child.kind == NodeKind::Text && child.content().trim().is_empty();
608        if is_ws_text { continue; }
609        count += 1;
610        if child.kind != NodeKind::Text { text_only = false; }
611        if count > 1 { return false; }
612    }
613    count == 1 && text_only
614}
615
616// ── tests ───────────────────────────────────────────────────────────────────
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use crate::parser::parse_str;
622    use crate::options::ParseOptions;
623
624    fn parse(xml: &str) -> Document {
625        parse_str(xml, &ParseOptions::default()).expect("parse")
626    }
627
628    fn serialize_no_decl(doc: &Document) -> String {
629        serialize_with(doc, &SerializeOptions {
630            write_xml_decl: false,
631            format:         false,
632            indent:         "  ".into(),
633            html_mode:      false,
634            xhtml:          false,
635            out_charset:    OutputCharset::Utf8,
636        })
637    }
638
639    #[test]
640    fn empty_element_self_closing() {
641        let doc = parse("<r/>");
642        assert_eq!(serialize_no_decl(&doc), "<r/>");
643    }
644
645    #[test]
646    fn element_with_text() {
647        let doc = parse("<r>hello</r>");
648        assert_eq!(serialize_no_decl(&doc), "<r>hello</r>");
649    }
650
651    #[test]
652    fn attributes_preserved_in_source_order() {
653        let doc = parse(r#"<el id="1" class="x" data-y="42"/>"#);
654        let out = serialize_no_decl(&doc);
655        assert_eq!(out, r#"<el id="1" class="x" data-y="42"/>"#);
656    }
657
658    #[test]
659    fn nested_elements() {
660        let doc = parse("<a><b><c/></b></a>");
661        assert_eq!(serialize_no_decl(&doc), "<a><b><c/></b></a>");
662    }
663
664    #[test]
665    fn text_special_chars_escaped() {
666        let doc = parse("<r>&lt;hi&amp;there&gt;</r>");
667        // After parse: text content is "<hi&there>".  Re-serialized with escapes.
668        let out = serialize_no_decl(&doc);
669        assert!(out.contains("&lt;hi&amp;there&gt;"), "got: {out}");
670    }
671
672    #[test]
673    fn attribute_quotes_escaped() {
674        let doc = parse(r#"<r a="&quot;x&quot;"/>"#);
675        let out = serialize_no_decl(&doc);
676        assert!(out.contains("&quot;x&quot;"), "got: {out}");
677    }
678
679    #[test]
680    fn cdata_preserved() {
681        let doc = parse("<r><![CDATA[<raw>]]></r>");
682        assert_eq!(serialize_no_decl(&doc), "<r><![CDATA[<raw>]]></r>");
683    }
684
685    #[test]
686    fn comments_preserved() {
687        let doc = parse("<r><!-- hi --></r>");
688        assert_eq!(serialize_no_decl(&doc), "<r><!-- hi --></r>");
689    }
690
691    #[test]
692    fn pi_preserved() {
693        let doc = parse(r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
694        assert_eq!(serialize_no_decl(&doc), r#"<r><?xml-stylesheet href="s.xsl"?></r>"#);
695    }
696
697    #[test]
698    fn xml_decl_emitted_by_default() {
699        let doc = parse("<r/>");
700        let out = serialize_to_string(&doc);
701        // No `<?xml encoding=...?>` declaration in source → emit
702        // only `version` (matches libxml2's behaviour when
703        // doc->encoding is NULL).
704        assert!(out.starts_with("<?xml version=\"1.0\"?>"), "got: {out}");
705    }
706
707    #[test]
708    fn round_trip_preserves_structure() {
709        // Parse → serialize → parse again → compare structure.
710        let original = r#"<feed xmlns="http://www.w3.org/2005/Atom"><entry><title>X</title><id>1</id></entry></feed>"#;
711        let doc1 = parse(original);
712        let xml  = serialize_no_decl(&doc1);
713        let doc2 = parse(&xml);
714        // Element names match
715        assert_eq!(doc1.root().name(), doc2.root().name());
716        // Same number of children at each level
717        let entry1 = doc1.root().first_child.get().unwrap();
718        let entry2 = doc2.root().first_child.get().unwrap();
719        assert_eq!(entry1.children().count(), entry2.children().count());
720    }
721
722    #[test]
723    fn pretty_print_block_layout() {
724        let doc = parse("<r><a/><b/></r>");
725        let out = serialize_formatted(&doc);
726        assert!(out.contains("<r>\n"));
727        assert!(out.contains("  <a/>"));
728        assert!(out.contains("  <b/>"));
729        assert!(out.contains("\n</r>"));
730    }
731
732    #[test]
733    fn pretty_print_inline_text() {
734        let doc = parse("<r><title>Hello</title></r>");
735        let out = serialize_formatted(&doc);
736        // Inline text element stays on one line
737        assert!(out.contains("<title>Hello</title>"), "got: {out}");
738    }
739
740    // ── public entry points ─────────────────────────────────────────────
741
742    #[test]
743    fn serialize_to_bytes_matches_to_string() {
744        let doc = parse("<r/>");
745        let bytes = serialize_to_bytes(&doc);
746        let s = serialize_to_string(&doc);
747        assert_eq!(bytes, s.into_bytes());
748    }
749
750    #[test]
751    fn serialize_node_to_string_emits_just_the_subtree() {
752        // No XML decl, no document wrapping — just the node and its
753        // descendants.
754        let doc = parse("<r><a id='1'><b/></a></r>");
755        let a = doc.root().first_child.get().unwrap();
756        let out = serialize_node_to_string(a, &SerializeOptions {
757            write_xml_decl: false, format: false,
758            indent: "  ".into(), html_mode: false, xhtml: false, out_charset: OutputCharset::Utf8,
759        });
760        assert_eq!(out, r#"<a id="1"><b/></a>"#);
761    }
762
763    // ── XML declaration variants ────────────────────────────────────────
764
765    #[test]
766    fn xml_decl_with_encoding_and_standalone() {
767        let doc = parse(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><r/>"#);
768        let out = serialize_to_string(&doc);
769        assert!(out.starts_with("<?xml version=\"1.0\""), "got: {out}");
770        assert!(out.contains("encoding=\"UTF-8\""), "got: {out}");
771        assert!(out.contains("standalone=\"yes\""), "got: {out}");
772    }
773
774    #[test]
775    fn xml_decl_standalone_no() {
776        let doc = parse(r#"<?xml version="1.0" standalone="no"?><r/>"#);
777        let out = serialize_to_string(&doc);
778        assert!(out.contains("standalone=\"no\""), "got: {out}");
779    }
780
781    // ── HTML mode ───────────────────────────────────────────────────────
782
783    #[cfg(feature = "html")]
784    fn parse_html(html: &str) -> Document {
785        crate::html::parse_html_str(html).expect("parse html")
786    }
787
788    #[cfg(feature = "html")]
789    #[test]
790    fn html_void_elements_emit_no_close_tag() {
791        let doc = parse_html("<html><body><br/><img src='x'/><hr/></body></html>");
792        let out = serialize_html_to_string(&doc);
793        // <br>, <img>, <hr> should NOT self-close and should NOT have a close tag.
794        assert!(out.contains("<br>"), "got: {out}");
795        assert!(out.contains("<img"), "got: {out}");
796        assert!(out.contains("<hr>"), "got: {out}");
797        assert!(!out.contains("</br>"), "got: {out}");
798        assert!(!out.contains("<br/>"), "got: {out}");
799    }
800
801    #[cfg(feature = "html")]
802    #[test]
803    fn html_empty_non_void_elements_emit_explicit_close() {
804        let doc = parse_html("<html><body><div></div></body></html>");
805        let out = serialize_html_to_string(&doc);
806        // <div></div> not <div/>.
807        assert!(out.contains("<div></div>"), "got: {out}");
808        assert!(!out.contains("<div/>"), "got: {out}");
809    }
810
811    #[cfg(feature = "html")]
812    #[test]
813    fn html_boolean_attribute_shorthand() {
814        let doc = parse_html(r#"<html><body><input disabled=""></body></html>"#);
815        let out = serialize_html_to_string(&doc);
816        // Empty-value attr → shorthand `<input disabled>` (no =).
817        assert!(out.contains("<input disabled>") || out.contains("<input disabled "), "got: {out}");
818        assert!(!out.contains("disabled=\""), "got: {out}");
819    }
820
821    #[test]
822    fn html_boolean_attr_recognised_case_insensitive() {
823        // Known boolean attributes minimize regardless of value;
824        // recognition is by name, case-insensitively.
825        assert!(is_html_boolean_attr("disabled"));
826        assert!(is_html_boolean_attr("DISABLED"));
827        assert!(is_html_boolean_attr("selected"));
828        assert!(is_html_boolean_attr("checked"));
829        assert!(!is_html_boolean_attr("href"));
830        assert!(!is_html_boolean_attr("class"));
831    }
832
833    #[cfg(feature = "html")]
834    #[test]
835    fn html_raw_text_elements_emit_verbatim_content() {
836        // <script> and <style> bodies must NOT be entity-escaped.
837        let doc = parse_html(
838            "<html><body><script>if (x < 3 && y > 1) {}</script></body></html>",
839        );
840        let out = serialize_html_to_string(&doc);
841        // Raw content should NOT have entity escapes inside <script>.
842        assert!(out.contains("if (x < 3 && y > 1) {}"), "got: {out}");
843    }
844
845    #[test]
846    fn html_void_element_predicate() {
847        assert!(is_html_void_element("br"));
848        assert!(is_html_void_element("img"));
849        assert!(is_html_void_element("input"));
850        assert!(is_html_void_element("meta"));
851        assert!(is_html_void_element("keygen"));
852        assert!(is_html_void_element("menuitem"));
853        assert!(!is_html_void_element("div"));
854        assert!(!is_html_void_element("span"));
855    }
856
857    #[test]
858    fn html_raw_text_predicate() {
859        assert!(is_html_raw_text_element("script"));
860        assert!(is_html_raw_text_element("style"));
861        assert!(!is_html_raw_text_element("div"));
862    }
863}