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 += "" 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 ? "" : "" 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 += "" } 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 == "") + } +}