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