Broaden the serializer and writer options from the audit: - attributeQuote selects single or double quotes; the active quote is escaped in values (apostrophe as ' in single-quote mode). - lineEnding controls the inserted newline (set to CRLF for \r\n output). - xml:space=preserve suppresses pretty-print indentation for an element and its subtree; xml:space=default re-enables it. - Writer gains writeStartElementNS(prefix:localName:namespaceURI:) and writeNamespace(prefix:uri:), so callers no longer hand-format prefixes and xmlns attributes. CDATA-vs-escape and entity re-escaping strategies are left as a follow-up; the current escaping is the safe default. Closes #91
160 lines
6.0 KiB
Swift
160 lines
6.0 KiB
Swift
/// 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
|
|
}
|
|
|
|
/// Emits the XML declaration if the options request one. Call before the
|
|
/// root element; a no-op when `includeXMLDeclaration` is off.
|
|
public mutating func writeStartDocument() {
|
|
if let declaration = options.xmlDeclaration {
|
|
output += declaration + options.lineEnding
|
|
}
|
|
}
|
|
|
|
/// Opens an element. Attributes may follow until any content or close.
|
|
public mutating func writeStartElement(_ name: String) {
|
|
closeStartTag()
|
|
if options.prettyPrint, !open.isEmpty {
|
|
output += options.lineEnding + pad(open.count)
|
|
}
|
|
output += "<\(name)"
|
|
markParentHasElements()
|
|
open.append(name)
|
|
content.append(.empty)
|
|
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 }
|
|
let quote = options.attributeQuote.character
|
|
output += " \(name)=\(quote)\(Escaping.attribute(value, quote: quote))\(quote)"
|
|
}
|
|
|
|
/// 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 += options.lineEnding + 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 += options.lineEnding + 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 += options.lineEnding + 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)
|
|
}
|
|
}
|
|
}
|