Merge epic/34-save-parity: XML declaration save-option parity

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 02:57:32 +02:00
commit 8027010e9e
6 changed files with 88 additions and 2 deletions

View File

@ -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 `<?xml ...?>` 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

View File

@ -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

View File

@ -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 `<name/>`.
public var selfCloseEmptyElements: Bool
/// Whether to emit an `<?xml ...?>` 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 `<?xml ...?>` declaration string, or nil when not requested.
var xmlDeclaration: String? {
guard includeXMLDeclaration else { return nil }
var declaration = "<?xml version=\"\(xmlVersion)\""
if let encodingName {
declaration += " encoding=\"\(encodingName)\""
}
if let standalone {
declaration += " standalone=\"\(standalone ? "yes" : "no")\""
}
return declaration + "?>"
}
}
}

View File

@ -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 {

View File

@ -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()

View File

@ -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 == "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<r/>")
}
@Test("No declaration is emitted by default")
func test_noDeclarationByDefault() {
#expect(PureXML.serialize(.element(.init("r")), options: .compact) == "<r/>")
}
@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 == "<?xml version=\"1.0\" standalone=\"yes\"?>\n<r/>")
}
@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 == "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<r/>")
}
}