From 209b6f1538adaef36062f253ab2bda70b351cb76 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 00:31:05 +0200 Subject: [PATCH] refactor(emitter): make the serializer iterative and fix mixed content Reimplement the serializer as an explicit work-stack walk instead of recursion, mirroring libxml2's xmlNodeDumpOutputInternal, so it is no longer the depth bottleneck. Reimplemented in Swift; no upstream code copied. Also fix a mixed-content correctness bug: formatting is now suppressed for any element with text or CDATA children (previously only when ALL children were text), so pretty-printing never injects whitespace around significant text. Note surfaced while testing: the remaining depth ceiling is the recursive Model.Node indirect enum (recursive ARC teardown overflows at extreme depth), which is why the parser must cap nesting depth (next). --- Sources/Emitting/Serializer.swift | 118 ++++++++++++++++++++++++------ Tests/SerializerTests.swift | 26 +++++++ 2 files changed, 120 insertions(+), 24 deletions(-) diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift index f663280..0ff0cd4 100644 --- a/Sources/Emitting/Serializer.swift +++ b/Sources/Emitting/Serializer.swift @@ -1,9 +1,26 @@ +/// One step of the explicit serialization walk: either a node to write, or a +/// pending close tag to emit once a node's children are done. File-scope and +/// private: an internal detail of the iterative serializer. +private enum SerializeStep { + case node(PureXML.Model.Node, Int, Bool) + case close(SerializeClose) +} + +/// The deferred close tag for an element, carried on the work stack. +private struct SerializeClose { + let name: String + let depth: Int + let formatted: Bool + let indentClose: Bool +} + public extension PureXML.Emitting { /// Serializes a ``PureXML/Model/Node`` tree back into XML text. /// - /// Unlike the parser, the serializer is implemented: it is the working half - /// of the round-trip and lets callers build trees programmatically and emit - /// well-formed XML today. + /// The walk is iterative, not recursive: it drives an explicit work stack so + /// that arbitrarily deep documents cannot overflow the call stack (the + /// approach libxml2 takes in `xmlNodeDumpOutputInternal`). Reimplemented in + /// Swift; no upstream code is copied. struct Serializer: Sendable { public var options: Options @@ -14,16 +31,36 @@ public extension PureXML.Emitting { /// Serializes a node into an XML string. public func serialize(_ node: PureXML.Model.Node) -> String { var output = "" - write(node, depth: 0, into: &output) + var stack: [SerializeStep] = [.node(node, 0, options.prettyPrint)] + while let step = stack.popLast() { + switch step { + case let .node(node, depth, formatted): + write(node, depth: depth, formatted: formatted, output: &output, stack: &stack) + case let .close(close): + if close.indentClose { + output += pad(close.depth) + } + output += "" + if close.formatted { + output += "\n" + } + } + } return output } - private func write(_ node: PureXML.Model.Node, depth: Int, into output: inout String) { + private func write( + _ node: PureXML.Model.Node, + depth: Int, + formatted: Bool, + output: inout String, + stack: inout [SerializeStep], + ) { switch node { case let .document(children): - writeChildren(children, depth: depth, into: &output) + push(children, depth: depth, formatted: formatted, into: &stack) case let .element(element): - write(element, depth: depth, into: &output) + write(element, depth: depth, formatted: formatted, output: &output, stack: &stack) case let .text(value): output += Self.escapeText(value) case let .cdata(value): @@ -35,40 +72,73 @@ public extension PureXML.Emitting { } } - private func write(_ element: PureXML.Model.Element, depth: Int, into output: inout String) { - let pad = options.prettyPrint ? String(repeating: options.indent, count: depth) : "" - output += pad + "<" + element.name.description + private func write( + _ element: PureXML.Model.Element, + depth: Int, + formatted: Bool, + output: inout String, + stack: inout [SerializeStep], + ) { + if formatted { + output += pad(depth) + } + output += "<" + element.name.description for attribute in element.attributes { output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\"" } if element.children.isEmpty, options.selfCloseEmptyElements { output += "/>" - if options.prettyPrint { output += "\n" } + if formatted { + output += "\n" + } return } output += ">" - let onlyText = element.children.allSatisfy { if case .text = $0 { true } else { false } } - if options.prettyPrint, !onlyText { - output += "\n" - writeChildren(element.children, depth: depth + 1, into: &output) - output += pad - } else { - for child in element.children { - write(child, depth: 0, into: &output) + // Suppress formatting when the element has text or CDATA children: + // re-indenting mixed content would change its significant whitespace. + // libxml2 makes the same choice in xmlNodeDumpOutputInternal. + let hasInlineContent = element.children.contains { child in + switch child { + case .text, .cdata: true + default: false } } - output += "" - if options.prettyPrint { output += "\n" } + let childFormatted = formatted && !hasInlineContent && !element.children.isEmpty + if childFormatted { + output += "\n" + } + stack.append(.close(SerializeClose( + name: element.name.description, + depth: depth, + formatted: formatted, + indentClose: childFormatted, + ))) + push( + element.children, + depth: childFormatted ? depth + 1 : depth, + formatted: childFormatted, + into: &stack, + ) } - private func writeChildren(_ children: [PureXML.Model.Node], depth: Int, into output: inout String) { - for child in children { - write(child, depth: depth, into: &output) + /// Pushes children in reverse so they pop off the work stack in order. + private func push( + _ children: [PureXML.Model.Node], + depth: Int, + formatted: Bool, + into stack: inout [SerializeStep], + ) { + for child in children.reversed() { + stack.append(.node(child, depth, formatted)) } } + private func pad(_ depth: Int) -> String { + String(repeating: options.indent, count: depth) + } + private static func escapeText(_ value: String) -> String { var result = "" for character in value { diff --git a/Tests/SerializerTests.swift b/Tests/SerializerTests.swift index 8a02210..6044717 100644 --- a/Tests/SerializerTests.swift +++ b/Tests/SerializerTests.swift @@ -37,6 +37,32 @@ struct SerializerTests { #expect(xml == "\n Guide\n\n") } + @Test("Mixed content is emitted inline, not reformatted") + func test_mixedContentStaysInline() { + // Pretty-printing must not inject whitespace around text in mixed content. + let element = PureXML.Model.Element( + "p", + children: [.text("hi "), .element(.init("b", children: [.text("x")])), .text(" bye")], + ) + let xml = PureXML.serialize(.element(element)) + #expect(xml == "

hi x bye

\n") + } + + @Test("Nested trees serialize iteratively and correctly") + func test_nestedSerializesCorrectly() { + // The serializer uses an explicit work stack (not recursion), so it is + // not the depth bottleneck. The remaining ceiling is the recursive + // Model.Node value itself, which is why the parser caps nesting depth. + var node = PureXML.Model.Node.text("leaf") + for _ in 0 ..< 200 { + node = .element(.init("n", children: [node])) + } + let xml = PureXML.serialize(node, options: .compact) + #expect(xml.hasPrefix("")) + #expect(xml.hasSuffix("")) + #expect(xml.contains("leaf")) + } + @Test("CDATA and comments round-trip verbatim") func test_emitsCDATAAndComments() { #expect(PureXML.serialize(.cdata("a