Merge epic/32-xmltextwriter: incremental XML writer (#32)
This commit is contained in:
commit
135e464652
@ -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:)`
|
||||
|
||||
@ -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
|
||||
|
||||
38
Sources/Emitting/Escaping.swift
Normal file
38
Sources/Emitting/Escaping.swift
Normal 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 += "<"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 += "&"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
133
Sources/Emitting/Writer.swift
Normal file
133
Sources/Emitting/Writer.swift
Normal 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
92
Tests/WriterTests.swift
Normal 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 & B < 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user