diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1465e9..c876548 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 0034280..af3ecc2 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Sources/API.swift b/Sources/API.swift
index cc5ef31..42418d0 100644
--- a/Sources/API.swift
+++ b/Sources/API.swift
@@ -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 {
diff --git a/Sources/Parsing/Parser.swift b/Sources/Parsing/Parser.swift
index d9cc0fd..ab1e7c2 100644
--- a/Sources/Parsing/Parser.swift
+++ b/Sources/Parsing/Parser.swift
@@ -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] = []
diff --git a/Sources/Parsing/SAXHandler.swift b/Sources/Parsing/SAXHandler.swift
new file mode 100644
index 0000000..60050db
--- /dev/null
+++ b/Sources/Parsing/SAXHandler.swift
@@ -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
+ }
+ }
+}
diff --git a/Tests/SAXTests.swift b/Tests/SAXTests.swift
new file mode 100644
index 0000000..8321415
--- /dev/null
+++ b/Tests/SAXTests.swift
@@ -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("hi", 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("", 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("", sax: handler)
+ #expect(uri == "http://x")
+ }
+
+ @Test("A handler with no callbacks set parses without crashing")
+ func test_emptyHandler() throws {
+ try PureXML.parse("text", 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())
+ }
+ }
+}