feat(parsing): add SAX2 push-callback API

Add PureXML.Parsing.SAXHandler (a struct of optional callbacks) and
PureXML.parse(_:sax:), which drives the handler from the streaming event core,
delivering start/end document, start/end element, characters, CDATA, comment,
and processing-instruction callbacks. Element names are namespace-resolved.
Closes the SAX2 epic (#19). Reimplemented from the libxml2 SAX2 model; no
upstream code copied. 5 tests; 84 total.
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 02:26:04 +02:00
parent a31cdd5401
commit ef682808d4
6 changed files with 132 additions and 1 deletions

View File

@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- SAX2 push-callback parsing (the libxml2 SAX2 model). `PureXML.Parsing.SAXHandler`
is a struct of optional callbacks (start/end document, start/end element,
characters, CDATA, comment, processing instruction); `PureXML.parse(_:sax:)`
drives them from the streaming core. Element names are namespace-resolved, so
the URI is available on each callback.
- DTD ID/IDREF validation. Attributes typed `ID` must be unique across the
document; `IDREF` and `IDREFS` values must each resolve to a declared `ID`
(forward references included, via a second pass). Completes the structural DTD

View File

@ -57,7 +57,7 @@ flowchart TB
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Core["Streaming core + DTD validation (shipped)"]:::done
Push["#1 Push / feed streaming API"]:::epic
Sax["#19 SAX2 push callbacks"]:::epic
Sax["#19 SAX2 push callbacks"]:::done
Html["#20 HTML parser and serializer"]:::epic
Enc["#28 Full encoding support"]:::epic
Ent["#29 External and parameter entities"]:::epic

View File

@ -30,6 +30,12 @@ public extension PureXML {
Parsing.EventReader(xml, limits: limits)
}
/// Parses a document, delivering SAX-style callbacks (the libxml2 SAX2 model)
/// instead of building a tree.
static func parse(_ xml: String, sax handler: Parsing.SAXHandler, limits: Parsing.Limits = .default) throws {
try Parsing.Parser().parse(xml, sax: handler, limits: limits)
}
/// Returns a streaming ``Parsing/EventReader`` over raw bytes, detecting the
/// encoding before streaming events.
static func events(bytes: [UInt8], limits: Parsing.Limits = .default) throws -> Parsing.EventReader {

View File

@ -56,6 +56,37 @@ public extension PureXML.Parsing {
return try build(EventReader(pulling: { decoder.next() }, limits: limits)).node
}
/// Parses a document, delivering SAX-style callbacks instead of building a
/// tree. The handler's callbacks fire as the parse streams.
public func parse(_ xml: String, sax handler: SAXHandler, limits: Limits = .default) throws {
var reader = EventReader(xml, limits: limits)
handler.startDocument?()
var produced = false
while let event = try reader.next() {
produced = true
deliver(event, to: handler)
}
guard produced else { throw ParseError.emptyDocument }
handler.endDocument?()
}
private func deliver(_ event: Event, to handler: SAXHandler) {
switch event {
case let .startElement(name, attributes):
handler.startElement?(name, attributes)
case let .endElement(name):
handler.endElement?(name)
case let .characters(text):
handler.characters?(text)
case let .cdata(text):
handler.cdata?(text)
case let .comment(text):
handler.comment?(text)
case let .processingInstruction(target, data):
handler.processingInstruction?(target, data)
}
}
private func build(_ source: EventReader) throws -> (node: PureXML.Model.Node, documentType: DocumentType) {
var reader = source
var roots: [PureXML.Model.Node] = []

View File

@ -0,0 +1,36 @@
public extension PureXML.Parsing {
/// A SAX-style set of callbacks, delivered as a document is parsed rather than
/// building a tree (the libxml2 SAX2 model). Each callback is optional; set
/// only the ones you need. Names are namespace-resolved ``Model/QualifiedName``
/// values, so the URI is available on the callback.
struct SAXHandler {
public var startDocument: (() -> Void)?
public var endDocument: (() -> Void)?
public var startElement: ((PureXML.Model.QualifiedName, [PureXML.Model.Attribute]) -> Void)?
public var endElement: ((PureXML.Model.QualifiedName) -> Void)?
public var characters: ((String) -> Void)?
public var cdata: ((String) -> Void)?
public var comment: ((String) -> Void)?
public var processingInstruction: ((_ target: String, _ data: String) -> Void)?
public init(
startDocument: (() -> Void)? = nil,
endDocument: (() -> Void)? = nil,
startElement: ((PureXML.Model.QualifiedName, [PureXML.Model.Attribute]) -> Void)? = nil,
endElement: ((PureXML.Model.QualifiedName) -> Void)? = nil,
characters: ((String) -> Void)? = nil,
cdata: ((String) -> Void)? = nil,
comment: ((String) -> Void)? = nil,
processingInstruction: ((_ target: String, _ data: String) -> Void)? = nil,
) {
self.startDocument = startDocument
self.endDocument = endDocument
self.startElement = startElement
self.endElement = endElement
self.characters = characters
self.cdata = cdata
self.comment = comment
self.processingInstruction = processingInstruction
}
}
}

53
Tests/SAXTests.swift Normal file
View File

@ -0,0 +1,53 @@
@testable import PureXML
import Testing
@Suite("SAX")
struct SAXTests {
@Test("Callbacks fire in document order, bracketed by start/end document")
func test_order() throws {
var log: [String] = []
let handler = PureXML.Parsing.SAXHandler(
startDocument: { log.append("start-doc") },
endDocument: { log.append("end-doc") },
startElement: { name, _ in log.append("start:\(name.localName)") },
endElement: { name in log.append("end:\(name.localName)") },
characters: { log.append("text:\($0)") },
)
try PureXML.parse("<a>hi<b/></a>", sax: handler)
#expect(log == ["start-doc", "start:a", "text:hi", "start:b", "end:b", "end:a", "end-doc"])
}
@Test("CDATA, comments, and processing instructions are delivered")
func test_miscNodes() throws {
var kinds: [String] = []
let handler = PureXML.Parsing.SAXHandler(
cdata: { _ in kinds.append("cdata") },
comment: { _ in kinds.append("comment") },
processingInstruction: { _, _ in kinds.append("pi") },
)
try PureXML.parse("<r><![CDATA[x]]><!--c--><?pi go?></r>", sax: handler)
#expect(kinds == ["cdata", "comment", "pi"])
}
@Test("Namespace URI is resolved on SAX callbacks")
func test_namespace() throws {
var uri: String?
let handler = PureXML.Parsing.SAXHandler(startElement: { name, _ in
if name.localName == "a" { uri = name.namespaceURI }
})
try PureXML.parse("<a xmlns=\"http://x\"/>", sax: handler)
#expect(uri == "http://x")
}
@Test("A handler with no callbacks set parses without crashing")
func test_emptyHandler() throws {
try PureXML.parse("<a><b/>text</a>", sax: PureXML.Parsing.SAXHandler())
}
@Test("An empty document is an error")
func test_emptyDocument() {
#expect(throws: PureXML.Parsing.ParseError.emptyDocument) {
try PureXML.parse("", sax: PureXML.Parsing.SAXHandler())
}
}
}