diff --git a/CHANGELOG.md b/CHANGELOG.md
index c876548..8913f18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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:)`
diff --git a/README.md b/README.md
index af3ecc2..916954f 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift
new file mode 100644
index 0000000..1a18f3c
--- /dev/null
+++ b/Sources/Emitting/Escaping.swift
@@ -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 += "<"
+ case ">": result += ">"
+ default: result.append(character)
+ }
+ }
+ return result
+ }
+
+ static func attribute(_ value: String) -> String {
+ var result = ""
+ for character in value {
+ switch character {
+ case "&": result += "&"
+ case "<": result += "<"
+ case ">": result += ">"
+ case "\"": result += """
+ case "\t": result += " "
+ case "\n": result += "
"
+ case "\r": result += "
"
+ default: result.append(character)
+ }
+ }
+ return result
+ }
+ }
+}
diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift
index 0ff0cd4..883a853 100644
--- a/Sources/Emitting/Serializer.swift
+++ b/Sources/Emitting/Serializer.swift
@@ -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 += ""
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 += "&"
- case "<": result += "<"
- case ">": result += ">"
- 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 += "&"
- case "<": result += "<"
- case ">": result += ">"
- case "\"": result += """
- case "\t": result += " "
- case "\n": result += "
"
- case "\r": result += "
"
- default: result.append(character)
- }
- }
- return result
- }
}
}
diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift
new file mode 100644
index 0000000..e89509f
--- /dev/null
+++ b/Sources/Emitting/Writer.swift
@@ -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 `` section.
+ public mutating func writeCData(_ text: String) {
+ closeStartTag()
+ output += ""
+ setTopContent(.text)
+ }
+
+ /// Writes a comment.
+ public mutating func writeComment(_ text: String) {
+ closeStartTag()
+ if options.prettyPrint, !open.isEmpty {
+ output += "\n" + pad(open.count)
+ }
+ output += ""
+ 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)
+ }
+ }
+}
diff --git a/Tests/WriterTests.swift b/Tests/WriterTests.swift
new file mode 100644
index 0000000..28e402e
--- /dev/null
+++ b/Tests/WriterTests.swift
@@ -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 == "A & B < C")
+
+ 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 == "\n Guide\n")
+ }
+
+ @Test("An empty element self-closes")
+ func test_emptyElement() {
+ var writer = PureXML.Emitting.Writer(options: .compact)
+ writer.writeStartElement("br")
+ writer.writeEndElement()
+ #expect(writer.output == "
")
+ }
+
+ @Test("CDATA, comments, and processing instructions are written")
+ func test_miscNodes() {
+ var writer = PureXML.Emitting.Writer(options: .compact)
+ writer.writeStartElement("r")
+ writer.writeCData("a")
+ }
+
+ @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 == "x")
+ }
+}
+
+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
+ }
+}