Merge epic/32-xmltextwriter: incremental XML writer (#32)

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 02:32:39 +02:00
commit 135e464652
6 changed files with 270 additions and 37 deletions

View File

@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Incremental XML writer (`PureXML.Emitting.Writer`, the libxml2 `xmlTextWriter`
model): emit a document with start/end and write calls without building a tree.
Escaping matches the tree serializer (extracted into a shared `Escaping` helper),
so compact output is byte-identical to serializing the equivalent tree.
- SAX2 push-callback parsing (the libxml2 SAX2 model). `PureXML.Parsing.SAXHandler`
is a struct of optional callbacks (start/end document, start/end element,
characters, CDATA, comment, processing instruction); `PureXML.parse(_:sax:)`

View File

@ -62,7 +62,7 @@ flowchart TB
Enc["#28 Full encoding support"]:::epic
Ent["#29 External and parameter entities"]:::epic
TreeApi["#31 Tree manipulation API"]:::epic
Writer["#32 xmlTextWriter"]:::epic
Writer["#32 xmlTextWriter"]:::done
Reader["#33 xmlTextReader with validation"]:::epic
Save["#34 Serialization option parity"]:::epic
Chars["#35 Character and name validity"]:::epic

View File

@ -0,0 +1,38 @@
extension PureXML.Emitting {
/// The shared XML output escaping rules, used by both the tree serializer and
/// the incremental writer so their output is identical. Element content
/// escapes the markup characters; attribute values additionally escape the
/// quote and the whitespace characters as numeric references (matching
/// libxml2), so attributes survive attribute-value normalization.
enum Escaping {
static func text(_ value: String) -> String {
var result = ""
for character in value {
switch character {
case "&": result += "&"
case "<": result += "&lt;"
case ">": result += "&gt;"
default: result.append(character)
}
}
return result
}
static func attribute(_ value: String) -> String {
var result = ""
for character in value {
switch character {
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\"": result += "&quot;"
case "\t": result += "&#9;"
case "\n": result += "&#10;"
case "\r": result += "&#13;"
default: result.append(character)
}
}
return result
}
}
}

View File

@ -62,7 +62,7 @@ public extension PureXML.Emitting {
case let .element(element):
write(element, depth: depth, formatted: formatted, output: &output, stack: &stack)
case let .text(value):
output += Self.escapeText(value)
output += Escaping.text(value)
case let .cdata(value):
output += "<![CDATA[\(value)]]>"
case let .comment(value):
@ -84,7 +84,7 @@ public extension PureXML.Emitting {
}
output += "<" + element.name.description
for attribute in element.attributes {
output += " \(attribute.name.description)=\"\(Self.escapeAttribute(attribute.value))\""
output += " \(attribute.name.description)=\"\(Escaping.attribute(attribute.value))\""
}
if element.children.isEmpty, options.selfCloseEmptyElements {
@ -138,39 +138,5 @@ public extension PureXML.Emitting {
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 {
switch character {
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
default: result.append(character)
}
}
return result
}
private static func escapeAttribute(_ value: String) -> String {
// Match the libxml2 attribute escape table: the markup characters plus
// tab/newline/carriage-return as numeric references. Escaping the
// whitespace is what preserves it through a spec-compliant parser's
// attribute-value normalization, so an attribute round-trips intact.
var result = ""
for character in value {
switch character {
case "&": result += "&amp;"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\"": result += "&quot;"
case "\t": result += "&#9;"
case "\n": result += "&#10;"
case "\r": result += "&#13;"
default: result.append(character)
}
}
return result
}
}
}

View File

@ -0,0 +1,133 @@
/// What has been written inside an open element so far, used to decide
/// indentation and self-closing. File-scope and private.
private enum WriterContent {
case empty
case elements
case text
}
public extension PureXML.Emitting {
/// An incremental XML writer (the libxml2 `xmlTextWriter` model): emit a
/// document by calling start/end and write methods, never building a tree.
/// Escaping matches ``Serializer``. In compact mode the output is byte-identical
/// to serializing the equivalent tree; in pretty mode element children are
/// indented, but because the writer is forward-only it cannot retroactively
/// suppress indentation when text appears later, so prefer compact for exact
/// round-tripping of mixed content.
///
/// Methods are forgiving: a `writeAttribute` after content, or an unbalanced
/// `writeEndElement`, is ignored rather than trapping. Read ``output`` for the
/// accumulated XML.
struct Writer {
public private(set) var output = ""
private let options: Options
private var open: [String] = []
private var content: [WriterContent] = []
private var startTagOpen = false
public init(options: Options = .default) {
self.options = options
}
/// Opens an element. Attributes may follow until any content or close.
public mutating func writeStartElement(_ name: String) {
closeStartTag()
if options.prettyPrint, !open.isEmpty {
output += "\n" + pad(open.count)
}
output += "<\(name)"
markParentHasElements()
open.append(name)
content.append(.empty)
startTagOpen = true
}
/// Writes an attribute on the currently open start tag.
public mutating func writeAttribute(_ name: String, _ value: String) {
guard startTagOpen else { return }
output += " \(name)=\"\(Escaping.attribute(value))\""
}
/// Writes escaped character data into the current element.
public mutating func writeString(_ text: String) {
closeStartTag()
output += Escaping.text(text)
setTopContent(.text)
}
/// Writes a `<![CDATA[ ... ]]>` section.
public mutating func writeCData(_ text: String) {
closeStartTag()
output += "<![CDATA[\(text)]]>"
setTopContent(.text)
}
/// Writes a comment.
public mutating func writeComment(_ text: String) {
closeStartTag()
if options.prettyPrint, !open.isEmpty {
output += "\n" + pad(open.count)
}
output += "<!--\(text)-->"
markParentHasElements()
}
/// Writes a processing instruction.
public mutating func writeProcessingInstruction(target: String, data: String) {
closeStartTag()
if options.prettyPrint, !open.isEmpty {
output += "\n" + pad(open.count)
}
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
markParentHasElements()
}
/// Closes the most recently opened element.
public mutating func writeEndElement() {
guard let name = open.popLast() else { return }
let line = content.removeLast()
if startTagOpen {
startTagOpen = false
if options.selfCloseEmptyElements {
output += "/>"
return
}
output += "></\(name)>"
return
}
if options.prettyPrint, line == .elements {
output += "\n" + pad(open.count)
}
output += "</\(name)>"
}
/// Convenience: a complete element with text content.
public mutating func writeElement(_ name: String, text: String) {
writeStartElement(name)
writeString(text)
writeEndElement()
}
private mutating func closeStartTag() {
guard startTagOpen else { return }
output += ">"
startTagOpen = false
}
private mutating func markParentHasElements() {
if let last = content.indices.last, content[last] == .empty {
content[last] = .elements
}
}
private mutating func setTopContent(_ value: WriterContent) {
if !content.isEmpty {
content[content.count - 1] = value
}
}
private func pad(_ depth: Int) -> String {
String(repeating: options.indent, count: depth)
}
}
}

92
Tests/WriterTests.swift Normal file
View File

@ -0,0 +1,92 @@
@testable import PureXML
import Testing
@Suite("Writer")
struct WriterTests {
@Test("Compact writing escapes content and round-trips through the parser")
func test_compactRoundTrip() throws {
var writer = PureXML.Emitting.Writer(options: .compact)
writer.writeStartElement("catalog")
writer.writeStartElement("book")
writer.writeAttribute("id", "bk101")
writer.writeString("A & B < C")
writer.writeEndElement()
writer.writeEndElement()
let xml = writer.output
#expect(xml == "<catalog><book id=\"bk101\">A &amp; B &lt; C</book></catalog>")
let node = try PureXML.parse(xml)
let book = node.firstElement?.children.first?.element
#expect(book?.attributes.first?.value == "bk101")
#expect(book?.text == "A & B < C")
}
@Test("Pretty writing indents element children, inlines text")
func test_pretty() {
var writer = PureXML.Emitting.Writer()
writer.writeStartElement("book")
writer.writeElement("title", text: "Guide")
writer.writeEndElement()
#expect(writer.output == "<book>\n <title>Guide</title>\n</book>")
}
@Test("An empty element self-closes")
func test_emptyElement() {
var writer = PureXML.Emitting.Writer(options: .compact)
writer.writeStartElement("br")
writer.writeEndElement()
#expect(writer.output == "<br/>")
}
@Test("CDATA, comments, and processing instructions are written")
func test_miscNodes() {
var writer = PureXML.Emitting.Writer(options: .compact)
writer.writeStartElement("r")
writer.writeCData("a<b")
writer.writeComment("c")
writer.writeProcessingInstruction(target: "pi", data: "go")
writer.writeEndElement()
#expect(writer.output == "<r><![CDATA[a<b]]><!--c--><?pi go?></r>")
}
@Test("Writer output matches the tree serializer for the same document")
func test_parityWithSerializer() {
let element = PureXML.Model.Element(
"a",
attributes: [.init("x", "1\n2")],
children: [.element(.init("b", children: [.text("hi")]))],
)
let serialized = PureXML.serialize(.element(element), options: .compact)
var writer = PureXML.Emitting.Writer(options: .compact)
writer.writeStartElement("a")
writer.writeAttribute("x", "1\n2")
writer.writeStartElement("b")
writer.writeString("hi")
writer.writeEndElement()
writer.writeEndElement()
#expect(writer.output == serialized)
}
@Test("Misuse is forgiving: late attribute and unbalanced close are ignored")
func test_forgiving() {
var writer = PureXML.Emitting.Writer(options: .compact)
writer.writeStartElement("a")
writer.writeString("x")
writer.writeAttribute("ignored", "y") // after content: ignored
writer.writeEndElement()
writer.writeEndElement() // unbalanced: ignored
#expect(writer.output == "<a>x</a>")
}
}
private extension PureXML.Model.Node {
var firstElement: PureXML.Model.Element? {
guard case let .document(children) = self else { return element }
for child in children where child.element != nil {
return child.element
}
return nil
}
}