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).
This commit is contained in:
parent
5a7a52b000
commit
209b6f1538
@ -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 += "</\(close.name)>"
|
||||
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 += "</\(element.name.description)>"
|
||||
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 {
|
||||
|
||||
@ -37,6 +37,32 @@ struct SerializerTests {
|
||||
#expect(xml == "<book>\n <title>Guide</title>\n</book>\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 == "<p>hi <b>x</b> bye</p>\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("<n><n>"))
|
||||
#expect(xml.hasSuffix("</n></n>"))
|
||||
#expect(xml.contains("<n>leaf</n>"))
|
||||
}
|
||||
|
||||
@Test("CDATA and comments round-trip verbatim")
|
||||
func test_emitsCDATAAndComments() {
|
||||
#expect(PureXML.serialize(.cdata("a<b")) == "<![CDATA[a<b]]>")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user