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:
parent
a31cdd5401
commit
ef682808d4
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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] = []
|
||||
|
||||
36
Sources/Parsing/SAXHandler.swift
Normal file
36
Sources/Parsing/SAXHandler.swift
Normal 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
53
Tests/SAXTests.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user