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