diff --git a/CHANGELOG.md b/CHANGELOG.md index 517b7f9..09ea214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Pull-cursor reader (the libxml2 `xmlTextReader` model). + `PureXML.Parsing.TextReader` (via `PureXML.reader(_:)`) walks a document one + node at a time with `read()`, exposing `nodeKind`, `name`, `value`, `depth`, + `attributes`/`attributeCount`/`attribute(_:)`, and `isEmptyElement` at each + step. It layers on the streaming core, so it never holds the whole document. + A childless element is reported once as an empty element (no separate end + node), normalizing `` and ``. `documentType` surfaces the DTD read + so far as the validation hook. - Mutable, parent-aware document tree (the libxml2 `tree.h` model). `PureXML.Model.TreeNode` is a reference type that knows its parent and siblings, so a document can be navigated upward and sideways (`parent`, diff --git a/README.md b/README.md index 68bdcf5..f41e1f2 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ flowchart TB Ent["#29 External and parameter entities"]:::done TreeApi["#31 Tree manipulation API"]:::done Writer["#32 xmlTextWriter"]:::done - Reader["#33 xmlTextReader with validation"]:::epic + Reader["#33 xmlTextReader with validation"]:::done Save["#34 Serialization option parity"]:::done Chars["#35 Character and name validity"]:::done Core --> Push diff --git a/Sources/API.swift b/Sources/API.swift index bb1a8b9..35d08d5 100644 --- a/Sources/API.swift +++ b/Sources/API.swift @@ -107,6 +107,17 @@ public extension PureXML { Emitting.Serializer(options: options).serialize(node) } + /// Returns a pull cursor (the libxml2 `xmlTextReader` model) over an XML + /// string. Call `read()` to advance node by node, reading `nodeKind`, `name`, + /// `value`, `depth`, and `attributes` at each step. + static func reader( + _ xml: String, + limits: Parsing.Limits = .default, + resolver: Parsing.EntityResolver = .refusing, + ) -> Parsing.TextReader { + Parsing.TextReader(xml, limits: limits, resolver: resolver) + } + /// Parses an XML document into a mutable, parent-aware ``Model/TreeNode`` for /// in-place editing (insert, remove, replace, copy, and upward navigation). /// Serialize the result back with `serialize(tree.node)`. diff --git a/Sources/Parsing/TextReader.swift b/Sources/Parsing/TextReader.swift new file mode 100644 index 0000000..7298d90 --- /dev/null +++ b/Sources/Parsing/TextReader.swift @@ -0,0 +1,160 @@ +public extension PureXML.Parsing { + /// The kind of node a ``TextReader`` is positioned on (the libxml2 + /// `xmlReaderTypes` subset PureXML surfaces). + enum ReaderNodeKind: Equatable, Sendable { + /// Before the first ``TextReader/read()`` or after the end of input. + case none + case element + case endElement + case text + case cdata + case comment + case processingInstruction + } + + /// A pull cursor over an XML document (the libxml2 `xmlTextReader` model). + /// + /// Call ``read()`` to advance one node at a time; it returns false at the end + /// of the document. After each successful read the cursor exposes the current + /// node's ``nodeKind``, ``name``, ``value``, ``depth``, and ``attributes``. + /// It is a thin, allocation-light layer over the streaming ``EventReader``: it + /// never holds the whole document, so it drives arbitrarily large input. + /// + /// A childless element is reported once with ``isEmptyElement`` true and no + /// separate end node, whether it was written `` or ``; the + /// streaming core normalizes the two forms. + struct TextReader { + private var events: EventReader + private var lookahead: Event? + private var finished = false + private var currentDepth = 0 + + /// The kind of the current node, or ``ReaderNodeKind/none`` before the + /// first read and after the end. + public private(set) var nodeKind: ReaderNodeKind = .none + /// The current node's name: the element name or PI target, or the markers + /// `#text`, `#cdata-section`, and `#comment`. Empty when on no node. + public private(set) var name = "" + /// The current node's character value: text/CDATA/comment content or PI + /// data. Empty for element and end-element nodes. + public private(set) var value = "" + /// The current node's depth, counting from zero at the document element. + public private(set) var depth = 0 + /// The current element's attributes; empty for non-element nodes. + public private(set) var attributes: [PureXML.Model.Attribute] = [] + /// Whether the current node is a childless element reported as a single + /// node (no matching end node follows). + public private(set) var isEmptyElement = false + + public init( + _ string: String, + limits: Limits = .default, + resolver: EntityResolver = .refusing, + ) { + events = EventReader(string, limits: limits, resolver: resolver) + } + + /// The number of attributes on the current node. + public var attributeCount: Int { + attributes.count + } + + /// The DTD read from the document's `` so far (entities, element + /// models, attribute lists). The validation hook for a streaming cursor: + /// pass it to ``PureXML/Validation/DTDSchema`` to check nodes as you read, + /// or validate the assembled tree afterward. Empty unless DTD processing + /// is enabled with `Limits(allowDoctype: true)`. + public var documentType: DocumentType { + events.documentType + } + + /// Returns the value of the named attribute on the current element, or nil. + public func attribute(_ name: String) -> String? { + attributes.first { $0.name.description == name }?.value + } + + /// Advances to the next node. Returns false at the end of the document. + public mutating func read() throws -> Bool { + guard let event = try nextEvent() else { + resetToEnd() + return false + } + apply(event) + return true + } + + private mutating func apply(_ event: Event) { + isEmptyElement = false + attributes = [] + value = "" + switch event { + case let .startElement(name, attributes): + applyStartElement(name: name, attributes: attributes) + case let .endElement(name): + currentDepth -= 1 + set(.endElement, name: name.description, depth: currentDepth) + case let .characters(text): + set(.text, name: "#text", value: text) + case let .cdata(text): + set(.cdata, name: "#cdata-section", value: text) + case let .comment(text): + set(.comment, name: "#comment", value: text) + case let .processingInstruction(target, data): + set(.processingInstruction, name: target, value: data) + } + } + + private mutating func applyStartElement( + name: PureXML.Model.QualifiedName, + attributes: [PureXML.Model.Attribute], + ) { + self.attributes = attributes + if consumeMatchingEnd(of: name) { + isEmptyElement = true + set(.element, name: name.description, depth: currentDepth) + } else { + set(.element, name: name.description, depth: currentDepth) + currentDepth += 1 + } + } + + /// Consumes a buffered end-element event that immediately closes `name`, + /// marking the start as an empty element. + private mutating func consumeMatchingEnd(of name: PureXML.Model.QualifiedName) -> Bool { + guard case let .endElement(endName)? = peekEvent(), endName == name else { return false } + lookahead = nil + return true + } + + private mutating func set(_ kind: ReaderNodeKind, name: String, value: String = "", depth: Int? = nil) { + nodeKind = kind + self.name = name + self.value = value + self.depth = depth ?? currentDepth + } + + private mutating func resetToEnd() { + finished = true + nodeKind = .none + name = "" + value = "" + attributes = [] + isEmptyElement = false + } + + private mutating func peekEvent() -> Event? { + if lookahead == nil, !finished { + lookahead = try? events.next() + } + return lookahead + } + + private mutating func nextEvent() throws -> Event? { + if let buffered = lookahead { + lookahead = nil + return buffered + } + return try events.next() + } + } +} diff --git a/Tests/TextReaderTests.swift b/Tests/TextReaderTests.swift new file mode 100644 index 0000000..5c665f6 --- /dev/null +++ b/Tests/TextReaderTests.swift @@ -0,0 +1,99 @@ +@testable import PureXML +import Testing + +@Suite("Text reader") +struct TextReaderTests { + private typealias Kind = PureXML.Parsing.ReaderNodeKind + + private struct NodeSnapshot { + let kind: Kind + let name: String + let value: String + let depth: Int + } + + /// Drains a reader into a list of node snapshots. + private func drain(_ xml: String) throws -> [NodeSnapshot] { + var reader = PureXML.reader(xml) + var nodes: [NodeSnapshot] = [] + while try reader.read() { + nodes.append(NodeSnapshot( + kind: reader.nodeKind, + name: reader.name, + value: reader.value, + depth: reader.depth, + )) + } + return nodes + } + + @Test("Before the first read the cursor is on no node") + func test_initialState() { + let reader = PureXML.reader("") + #expect(reader.nodeKind == .none) + #expect(reader.depth == 0) + } + + @Test("Elements, text, and end elements are reported with depth") + func test_walk() throws { + let nodes = try drain("hi") + #expect(nodes.map(\.kind) == [.element, .element, .text, .endElement, .endElement]) + #expect(nodes.map(\.name) == ["r", "a", "#text", "a", "r"]) + #expect(nodes.map(\.depth) == [0, 1, 2, 1, 0]) + #expect(nodes[2].value == "hi") + } + + @Test("A childless element is a single empty-element node") + func test_emptyElement() throws { + var reader = PureXML.reader("") + #expect(try reader.read()) + #expect(reader.nodeKind == .element) + #expect(reader.name == "r") + #expect(!reader.isEmptyElement) + #expect(try reader.read()) + #expect(reader.name == "a") + #expect(reader.isEmptyElement) + // The empty element is not followed by a separate end node. + #expect(try reader.read()) + #expect(reader.nodeKind == .endElement) + #expect(reader.name == "r") + #expect(try !reader.read()) + } + + @Test("An explicit empty pair is normalized to one empty element") + func test_explicitEmptyPair() throws { + let nodes = try drain("") + #expect(nodes.count == 1) + #expect(nodes[0].kind == .element) + } + + @Test("Attributes are exposed on the current element") + func test_attributes() throws { + var reader = PureXML.reader("") + #expect(try reader.read()) + #expect(reader.attributeCount == 2) + #expect(reader.attribute("a") == "1") + #expect(reader.attribute("b") == "2") + #expect(reader.attribute("c") == nil) + } + + @Test("CDATA, comments, and processing instructions are distinguished") + func test_otherNodeKinds() throws { + let nodes = try drain("") + #expect(nodes.map(\.kind) == [.element, .cdata, .comment, .processingInstruction, .endElement]) + #expect(nodes[1].value == "x") + #expect(nodes[2].value == "c") + #expect(nodes[3].name == "pi") + #expect(nodes[3].value == "d") + } + + @Test("Attributes are cleared when moving off an element") + func test_attributesCleared() throws { + var reader = PureXML.reader("text") + #expect(try reader.read()) + #expect(reader.attributeCount == 1) + #expect(try reader.read()) + #expect(reader.nodeKind == .text) + #expect(reader.attributeCount == 0) + } +}