Merge epic/33-textreader: xmlTextReader pull cursor
This commit is contained in:
commit
93fd1f7815
@ -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 `<a/>` and `<a></a>`. `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`,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)`.
|
||||
|
||||
160
Sources/Parsing/TextReader.swift
Normal file
160
Sources/Parsing/TextReader.swift
Normal file
@ -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 `<a/>` or `<a></a>`; 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 `<!DOCTYPE>` 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
99
Tests/TextReaderTests.swift
Normal file
99
Tests/TextReaderTests.swift
Normal file
@ -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("<r/>")
|
||||
#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("<r><a>hi</a></r>")
|
||||
#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("<r><a/></r>")
|
||||
#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("<r></r>")
|
||||
#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("<r a=\"1\" b=\"2\"/>")
|
||||
#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("<r><![CDATA[x]]><!--c--><?pi d?></r>")
|
||||
#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("<r a=\"1\">text</r>")
|
||||
#expect(try reader.read())
|
||||
#expect(reader.attributeCount == 1)
|
||||
#expect(try reader.read())
|
||||
#expect(reader.nodeKind == .text)
|
||||
#expect(reader.attributeCount == 0)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user