diff --git a/Sources/Emitting/Escaping.swift b/Sources/Emitting/Escaping.swift
index 1a18f3c..eb04289 100644
--- a/Sources/Emitting/Escaping.swift
+++ b/Sources/Emitting/Escaping.swift
@@ -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 += "<"
case ">": result += ">"
- case "\"": result += """
case "\t": result += " "
case "\n": result += "
"
case "\r": result += "
"
diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift
index ebaee59..ee94c79 100644
--- a/Sources/Emitting/Options.swift
+++ b/Sources/Emitting/Options.swift
@@ -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 ``.
public var selfCloseEmptyElements: Bool
/// Whether to emit an `` 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
diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift
index 768c4e3..36f2740 100644
--- a/Sources/Emitting/Serializer.swift
+++ b/Sources/Emitting/Serializer.swift
@@ -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],
diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift
index 782e8fc..c4c9ed0 100644
--- a/Sources/Emitting/Writer.swift
+++ b/Sources/Emitting/Writer.swift
@@ -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 += ""
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)>"
}
diff --git a/Tests/SerializationOptionsTests.swift b/Tests/SerializationOptionsTests.swift
new file mode 100644
index 0000000..fc12bba
--- /dev/null
+++ b/Tests/SerializationOptionsTests.swift
@@ -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("", options) == "")
+ }
+
+ @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("", single) == "")
+ }
+
+ @Test("Line ending option controls inserted newlines")
+ func test_lineEnding() throws {
+ let options = PureXML.Emitting.Options(indent: " ", lineEnding: "\r\n")
+ let output = try serialize("", 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 = ""
+ let output = try serialize(xml, .default)
+ // No indentation inserted inside the preserved element.
+ #expect(output == "\n")
+ }
+
+ @Test("Without xml:space, pretty-print indents as usual")
+ func test_prettyPrintDefault() throws {
+ let output = try serialize("", .default)
+ #expect(output.contains("\n "))
+ }
+
+ @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 == "hi")
+ }
+
+ @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 == "")
+ }
+}