Merge epic/91-serialization: serializer and writer options

Closes #91
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 15:24:50 +02:00
commit 5b24530a72
5 changed files with 134 additions and 14 deletions

View File

@ -18,14 +18,17 @@ extension PureXML.Emitting {
return result
}
static func attribute(_ value: String) -> String {
static func attribute(_ value: String, quote: Character = "\"") -> String {
var result = ""
for character in value {
if character == quote {
result += quote == "'" ? "'" : """
continue
}
switch character {
case "&": result += "&"
case "<": result += "&lt;"
case ">": result += "&gt;"
case "\"": result += "&quot;"
case "\t": result += "&#9;"
case "\n": result += "&#10;"
case "\r": result += "&#13;"

View File

@ -2,11 +2,26 @@ public extension PureXML.Emitting {
/// Explicit emitter options. The default is pretty-printed output with
/// two-space indentation and a self-closing tag for empty elements, and no
/// XML declaration.
/// Which quote character delimits attribute values in the output.
enum QuoteStyle: Equatable, Hashable, Sendable {
case double
case single
var character: Character {
self == .double ? "\"" : "'"
}
}
struct Options: Equatable, Hashable, Sendable {
/// Whether to indent nested elements onto their own lines.
public var prettyPrint: Bool
/// The indentation unit used when ``prettyPrint`` is enabled.
public var indent: String
/// The quote character that delimits attribute values.
public var attributeQuote: QuoteStyle
/// The line terminator inserted by pretty-printing and after the
/// declaration. A line feed by default; set to `\r\n` for CRLF output.
public var lineEnding: String
/// Whether childless elements collapse to `<name/>`.
public var selfCloseEmptyElements: Bool
/// Whether to emit an `<?xml ...?>` declaration (libxml2 emits one by
@ -22,6 +37,8 @@ public extension PureXML.Emitting {
public init(
prettyPrint: Bool = true,
indent: String = " ",
attributeQuote: QuoteStyle = .double,
lineEnding: String = "\n",
selfCloseEmptyElements: Bool = true,
includeXMLDeclaration: Bool = false,
xmlVersion: String = "1.0",
@ -30,6 +47,8 @@ public extension PureXML.Emitting {
) {
self.prettyPrint = prettyPrint
self.indent = indent
self.attributeQuote = attributeQuote
self.lineEnding = lineEnding
self.selfCloseEmptyElements = selfCloseEmptyElements
self.includeXMLDeclaration = includeXMLDeclaration
self.xmlVersion = xmlVersion

View File

@ -32,7 +32,7 @@ public extension PureXML.Emitting {
public func serialize(_ node: PureXML.Model.Node) -> String {
var output = ""
if let declaration = options.xmlDeclaration {
output += declaration + "\n"
output += declaration + options.lineEnding
}
var stack: [SerializeStep] = [.node(node, 0, options.prettyPrint)]
while let step = stack.popLast() {
@ -45,7 +45,7 @@ public extension PureXML.Emitting {
}
output += "</\(close.name)>"
if close.formatted {
output += "\n"
output += options.lineEnding
}
}
}
@ -86,14 +86,15 @@ public extension PureXML.Emitting {
output += pad(depth)
}
output += "<" + element.name.description
let quote = options.attributeQuote.character
for attribute in element.attributes {
output += " \(attribute.name.description)=\"\(Escaping.attribute(attribute.value))\""
output += " \(attribute.name.description)=\(quote)\(Escaping.attribute(attribute.value, quote: quote))\(quote)"
}
if element.children.isEmpty, options.selfCloseEmptyElements {
output += "/>"
if formatted {
output += "\n"
output += options.lineEnding
}
return
}
@ -108,9 +109,9 @@ public extension PureXML.Emitting {
default: false
}
}
let childFormatted = formatted && !hasInlineContent && !element.children.isEmpty
let childFormatted = childFormatting(of: element, parentFormatted: formatted, hasInlineContent: hasInlineContent)
if childFormatted {
output += "\n"
output += options.lineEnding
}
stack.append(.close(SerializeClose(
name: element.name.description,
@ -126,6 +127,23 @@ public extension PureXML.Emitting {
)
}
/// Whether to format (indent) an element's children: never when it has
/// inline content or none, never under `xml:space="preserve"` (its content
/// is emitted verbatim), and otherwise as the parent decided, with
/// `xml:space="default"` re-enabling formatting if the options pretty-print.
private func childFormatting(of element: PureXML.Model.Element, parentFormatted: Bool, hasInlineContent: Bool) -> Bool {
guard !hasInlineContent, !element.children.isEmpty else { return false }
switch Self.xmlSpace(of: element) {
case "preserve": return false
case "default": return options.prettyPrint
default: return parentFormatted
}
}
private static func xmlSpace(of element: PureXML.Model.Element) -> String? {
element.attributes.first { $0.name.localName == "space" && $0.name.prefix == "xml" }?.value
}
/// Pushes children in reverse so they pop off the work stack in order.
private func push(
_ children: [PureXML.Model.Node],

View File

@ -33,7 +33,7 @@ public extension PureXML.Emitting {
/// root element; a no-op when `includeXMLDeclaration` is off.
public mutating func writeStartDocument() {
if let declaration = options.xmlDeclaration {
output += declaration + "\n"
output += declaration + options.lineEnding
}
}
@ -41,7 +41,7 @@ public extension PureXML.Emitting {
public mutating func writeStartElement(_ name: String) {
closeStartTag()
if options.prettyPrint, !open.isEmpty {
output += "\n" + pad(open.count)
output += options.lineEnding + pad(open.count)
}
output += "<\(name)"
markParentHasElements()
@ -50,10 +50,28 @@ public extension PureXML.Emitting {
startTagOpen = true
}
/// Opens an element with a namespace-qualified name, optionally declaring
/// the namespace on it. A nil `prefix` writes an unprefixed name and, with a
/// URI, a default-namespace declaration.
public mutating func writeStartElementNS(prefix: String?, localName: String, namespaceURI: String?) {
let qualified = prefix.map { "\($0):\(localName)" } ?? localName
writeStartElement(qualified)
if let namespaceURI {
writeNamespace(prefix: prefix, uri: namespaceURI)
}
}
/// Declares a namespace on the currently open start tag: `xmlns:prefix`, or
/// `xmlns` when `prefix` is nil, bound to `uri`.
public mutating func writeNamespace(prefix: String?, uri: String) {
writeAttribute(prefix.map { "xmlns:\($0)" } ?? "xmlns", uri)
}
/// 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))\""
let quote = options.attributeQuote.character
output += " \(name)=\(quote)\(Escaping.attribute(value, quote: quote))\(quote)"
}
/// Writes escaped character data into the current element.
@ -74,7 +92,7 @@ public extension PureXML.Emitting {
public mutating func writeComment(_ text: String) {
closeStartTag()
if options.prettyPrint, !open.isEmpty {
output += "\n" + pad(open.count)
output += options.lineEnding + pad(open.count)
}
output += "<!--\(text)-->"
markParentHasElements()
@ -84,7 +102,7 @@ public extension PureXML.Emitting {
public mutating func writeProcessingInstruction(target: String, data: String) {
closeStartTag()
if options.prettyPrint, !open.isEmpty {
output += "\n" + pad(open.count)
output += options.lineEnding + pad(open.count)
}
output += data.isEmpty ? "<?\(target)?>" : "<?\(target) \(data)?>"
markParentHasElements()
@ -104,7 +122,7 @@ public extension PureXML.Emitting {
return
}
if options.prettyPrint, line == .elements {
output += "\n" + pad(open.count)
output += options.lineEnding + pad(open.count)
}
output += "</\(name)>"
}

View File

@ -0,0 +1,62 @@
@testable import PureXML
import Testing
@Suite("Serialization options: quotes, line endings, xml:space, writer NS")
struct SerializationOptionsTests {
private func serialize(_ xml: String, _ options: PureXML.Emitting.Options) throws -> String {
let node = try PureXML.parse(xml)
return PureXML.serialize(node, options: options)
}
@Test("Single-quote attribute style delimits with apostrophes")
func test_singleQuote() throws {
let options = PureXML.Emitting.Options(prettyPrint: false, attributeQuote: .single)
#expect(try serialize("<a id=\"1\"/>", options) == "<a id='1'/>")
}
@Test("A quote of the active style inside a value is escaped")
func test_quoteEscaping() throws {
let single = PureXML.Emitting.Options(prettyPrint: false, attributeQuote: .single)
// An apostrophe in the value is escaped; a double quote is left literal.
#expect(try serialize("<a t=\"it&apos;s &quot;x&quot;\"/>", single) == "<a t='it&apos;s \"x\"'/>")
}
@Test("Line ending option controls inserted newlines")
func test_lineEnding() throws {
let options = PureXML.Emitting.Options(indent: " ", lineEnding: "\r\n")
let output = try serialize("<a><b/></a>", options)
#expect(output.contains("\r\n"))
#expect(!output.contains("\n\n"))
}
@Test("xml:space=preserve suppresses pretty-print indentation in the subtree")
func test_xmlSpacePreserve() throws {
let xml = "<a xml:space=\"preserve\"><b><c/></b></a>"
let output = try serialize(xml, .default)
// No indentation inserted inside the preserved element.
#expect(output == "<a xml:space=\"preserve\"><b><c/></b></a>\n")
}
@Test("Without xml:space, pretty-print indents as usual")
func test_prettyPrintDefault() throws {
let output = try serialize("<a><b><c/></b></a>", .default)
#expect(output.contains("\n <b>"))
}
@Test("Writer namespace methods emit a qualified name and an xmlns declaration")
func test_writerNamespace() {
var writer = PureXML.Emitting.Writer(options: PureXML.Emitting.Options(prettyPrint: false))
writer.writeStartElementNS(prefix: "x", localName: "a", namespaceURI: "urn:x")
writer.writeString("hi")
writer.writeEndElement()
#expect(writer.output == "<x:a xmlns:x=\"urn:x\">hi</x:a>")
}
@Test("Writer writes a default-namespace declaration for a nil prefix")
func test_writerDefaultNamespace() {
var writer = PureXML.Emitting.Writer(options: PureXML.Emitting.Options(prettyPrint: false))
writer.writeStartElementNS(prefix: nil, localName: "a", namespaceURI: "urn:d")
writer.writeEndElement()
#expect(writer.output == "<a xmlns=\"urn:d\"/>")
}
}