commit
5b24530a72
@ -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 += " "
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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)>"
|
||||
}
|
||||
|
||||
62
Tests/SerializationOptionsTests.swift
Normal file
62
Tests/SerializationOptionsTests.swift
Normal 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's "x"\"/>", single) == "<a t='it'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\"/>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user