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