From 9b55767b392c626e9170205da726fa773d69f320 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 02:32:39 +0200 Subject: [PATCH] feat(emitting): add an incremental XML writer (xmlTextWriter) Add PureXML.Emitting.Writer, the libxml2 xmlTextWriter model: emit a document with writeStartElement/writeAttribute/writeString/writeEndElement and friends, never building a tree. Escaping is extracted into a shared Emitting.Escaping helper used by both the writer and the serializer, so compact writer output is byte-identical to serializing the equivalent tree. Pretty mode indents element children (forward-only, so prefer compact for mixed content). Closes #32. Reimplemented from the libxml2 model; no upstream code copied. 6 tests; 90 total. --- CHANGELOG.md | 4 + README.md | 2 +- Sources/Emitting/Escaping.swift | 38 +++++++++ Sources/Emitting/Serializer.swift | 38 +-------- Sources/Emitting/Writer.swift | 133 ++++++++++++++++++++++++++++++ Tests/WriterTests.swift | 92 +++++++++++++++++++++ 6 files changed, 270 insertions(+), 37 deletions(-) create mode 100644 Sources/Emitting/Escaping.swift create mode 100644 Sources/Emitting/Writer.swift create mode 100644 Tests/WriterTests.swift 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 ? "" : "" + 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 += ">" + return + } + if options.prettyPrint, line == .elements { + output += "\n" + pad(open.count) + } + output += "" + } + + /// 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 + } +}