diff --git a/CHANGELOG.md b/CHANGELOG.md index 032535c..cce051a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Serialization option parity for the XML declaration (the libxml2 save-option + model). `PureXML.Emitting.Options` gains `includeXMLDeclaration`, `xmlVersion`, + `encodingName`, and `standalone`; when requested, both the tree serializer and + the incremental writer prepend an `` declaration from a single shared + computation. `Writer.writeStartDocument()` emits it. Defaults stay off so + fragment output is unchanged. - Spec-exact XML character classification (`PureXML.Parsing.XMLCharacter`, the libxml2 `chvalid.h` parity): the XML 1.0 (Fifth Edition) `Char`, `NameStartChar`, and `NameChar` productions as public predicates over Unicode diff --git a/README.md b/README.md index 2e91349..1ece76e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ flowchart TB TreeApi["#31 Tree manipulation API"]:::epic Writer["#32 xmlTextWriter"]:::done Reader["#33 xmlTextReader with validation"]:::epic - Save["#34 Serialization option parity"]:::epic + Save["#34 Serialization option parity"]:::done Chars["#35 Character and name validity"]:::done Core --> Push Core --> Sax diff --git a/Sources/Emitting/Options.swift b/Sources/Emitting/Options.swift index a2932fb..ebaee59 100644 --- a/Sources/Emitting/Options.swift +++ b/Sources/Emitting/Options.swift @@ -1,6 +1,7 @@ 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. + /// two-space indentation and a self-closing tag for empty elements, and no + /// XML declaration. struct Options: Equatable, Hashable, Sendable { /// Whether to indent nested elements onto their own lines. public var prettyPrint: Bool @@ -8,15 +9,32 @@ public extension PureXML.Emitting { public var indent: String /// Whether childless elements collapse to ``. public var selfCloseEmptyElements: Bool + /// Whether to emit an `` declaration (libxml2 emits one by + /// default when saving a document; PureXML defaults to off for fragments). + public var includeXMLDeclaration: Bool + /// The version written in the declaration. + public var xmlVersion: String + /// The encoding name written in the declaration, or nil to omit it. + public var encodingName: String? + /// The `standalone` flag written in the declaration, or nil to omit it. + public var standalone: Bool? public init( prettyPrint: Bool = true, indent: String = " ", selfCloseEmptyElements: Bool = true, + includeXMLDeclaration: Bool = false, + xmlVersion: String = "1.0", + encodingName: String? = "UTF-8", + standalone: Bool? = nil, ) { self.prettyPrint = prettyPrint self.indent = indent self.selfCloseEmptyElements = selfCloseEmptyElements + self.includeXMLDeclaration = includeXMLDeclaration + self.xmlVersion = xmlVersion + self.encodingName = encodingName + self.standalone = standalone } /// Pretty-printed output with two-space indentation. @@ -24,5 +42,18 @@ public extension PureXML.Emitting { /// Single-line output with no inserted whitespace. public static let compact = Options(prettyPrint: false) + + /// The `` declaration string, or nil when not requested. + var xmlDeclaration: String? { + guard includeXMLDeclaration else { return nil } + var declaration = "" + } } } diff --git a/Sources/Emitting/Serializer.swift b/Sources/Emitting/Serializer.swift index 883a853..768c4e3 100644 --- a/Sources/Emitting/Serializer.swift +++ b/Sources/Emitting/Serializer.swift @@ -31,6 +31,9 @@ public extension PureXML.Emitting { /// Serializes a node into an XML string. public func serialize(_ node: PureXML.Model.Node) -> String { var output = "" + if let declaration = options.xmlDeclaration { + output += declaration + "\n" + } var stack: [SerializeStep] = [.node(node, 0, options.prettyPrint)] while let step = stack.popLast() { switch step { diff --git a/Sources/Emitting/Writer.swift b/Sources/Emitting/Writer.swift index e89509f..782e8fc 100644 --- a/Sources/Emitting/Writer.swift +++ b/Sources/Emitting/Writer.swift @@ -29,6 +29,14 @@ public extension PureXML.Emitting { 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 + "\n" + } + } + /// Opens an element. Attributes may follow until any content or close. public mutating func writeStartElement(_ name: String) { closeStartTag() diff --git a/Tests/SaveOptionsTests.swift b/Tests/SaveOptionsTests.swift new file mode 100644 index 0000000..7f5261c --- /dev/null +++ b/Tests/SaveOptionsTests.swift @@ -0,0 +1,38 @@ +@testable import PureXML +import Testing + +@Suite("Save options") +struct SaveOptionsTests { + @Test("The serializer emits the XML declaration when requested") + func test_declaration() { + let options = PureXML.Emitting.Options(prettyPrint: false, includeXMLDeclaration: true) + let xml = PureXML.serialize(.element(.init("r")), options: options) + #expect(xml == "\n") + } + + @Test("No declaration is emitted by default") + func test_noDeclarationByDefault() { + #expect(PureXML.serialize(.element(.init("r")), options: .compact) == "") + } + + @Test("Standalone and an omitted encoding are honored") + func test_declarationVariants() { + let options = PureXML.Emitting.Options( + prettyPrint: false, + includeXMLDeclaration: true, + encodingName: nil, + standalone: true, + ) + let xml = PureXML.serialize(.element(.init("r")), options: options) + #expect(xml == "\n") + } + + @Test("The writer emits the declaration via writeStartDocument") + func test_writerDeclaration() { + var writer = PureXML.Emitting.Writer(options: .init(prettyPrint: false, includeXMLDeclaration: true)) + writer.writeStartDocument() + writer.writeStartElement("r") + writer.writeEndElement() + #expect(writer.output == "\n") + } +}