commit
bb70798049
@ -34,6 +34,9 @@ public extension PureXML.Parsing {
|
||||
var reachedEnd = false
|
||||
/// The problems found so far, when reading in recovering mode.
|
||||
var diagnostics: [Diagnostic] = []
|
||||
/// The source position where the most recently produced event's node began,
|
||||
/// so a pull cursor can report each node's line and column.
|
||||
private(set) var eventStart: Mark = .start
|
||||
|
||||
/// Creates a reader over a character-pulling source. The closure returns
|
||||
/// the next character or nil at end of input.
|
||||
@ -94,6 +97,7 @@ public extension PureXML.Parsing {
|
||||
guard let lead = reader.peek() else {
|
||||
return nil
|
||||
}
|
||||
eventStart = reader.mark
|
||||
if reader.matches("<!--") {
|
||||
return try scanComment()
|
||||
}
|
||||
@ -129,6 +133,7 @@ public extension PureXML.Parsing {
|
||||
guard let lead = reader.peek() else {
|
||||
throw ParseError.unexpectedEndOfInput(reader.mark)
|
||||
}
|
||||
eventStart = reader.mark
|
||||
if reader.matches("</") { return try scanEndTag() }
|
||||
if reader.matches("<!--") { return try scanComment() }
|
||||
if reader.matches("<![CDATA[") { return try scanCDATA() }
|
||||
|
||||
@ -10,6 +10,9 @@ public extension PureXML.Parsing {
|
||||
case cdata
|
||||
case comment
|
||||
case processingInstruction
|
||||
/// An attribute the cursor was moved onto with ``TextReader/moveToFirstAttribute()``
|
||||
/// and friends. Never produced by ``TextReader/read()``.
|
||||
case attribute
|
||||
}
|
||||
|
||||
/// A pull cursor over an XML document (the libxml2 `xmlTextReader` model).
|
||||
@ -26,8 +29,21 @@ public extension PureXML.Parsing {
|
||||
struct TextReader {
|
||||
private var events: EventReader
|
||||
private var lookahead: Event?
|
||||
private var lookaheadStart: Mark = .start
|
||||
private var finished = false
|
||||
private var currentDepth = 0
|
||||
/// The qualified name of the current element or attribute node, for the
|
||||
/// namespace accessors; nil on a node that has no name.
|
||||
private var currentName: PureXML.Model.QualifiedName?
|
||||
/// The element the cursor last read, so ``moveToElement()`` can return to it.
|
||||
private var elementName: PureXML.Model.QualifiedName?
|
||||
/// The reported depth of that element, restored by ``moveToElement()``.
|
||||
private var elementDepth = 0
|
||||
/// The attribute index the cursor was moved onto, or nil when on the element.
|
||||
private var attributeCursor: Int?
|
||||
/// The in-scope `xml:lang` for each open element, innermost last.
|
||||
private var langStack: [String?] = []
|
||||
private var position: Mark = .start
|
||||
|
||||
/// The kind of the current node, or ``ReaderNodeKind/none`` before the
|
||||
/// first read and after the end.
|
||||
@ -45,6 +61,35 @@ public extension PureXML.Parsing {
|
||||
/// Whether the current node is a childless element reported as a single
|
||||
/// node (no matching end node follows).
|
||||
public private(set) var isEmptyElement = false
|
||||
/// The in-scope `xml:lang` of the current node, inherited from ancestors
|
||||
/// unless the current element overrides it; nil when none is declared.
|
||||
public private(set) var xmlLang: String?
|
||||
|
||||
/// The resolved namespace URI of the current element or attribute, or nil
|
||||
/// when it is in no namespace or the node has no name.
|
||||
public var namespaceURI: String? {
|
||||
currentName?.namespaceURI
|
||||
}
|
||||
|
||||
/// The local part of the current element's or attribute's name.
|
||||
public var localName: String {
|
||||
currentName?.localName ?? name
|
||||
}
|
||||
|
||||
/// The namespace prefix of the current element or attribute, if any.
|
||||
public var prefix: String? {
|
||||
currentName?.prefix
|
||||
}
|
||||
|
||||
/// The one-based line where the current node begins.
|
||||
public var lineNumber: Int {
|
||||
position.line
|
||||
}
|
||||
|
||||
/// The one-based column where the current node begins.
|
||||
public var columnNumber: Int {
|
||||
position.column
|
||||
}
|
||||
|
||||
public init(
|
||||
_ string: String,
|
||||
@ -73,12 +118,59 @@ public extension PureXML.Parsing {
|
||||
attributes.first { $0.name.description == name }?.value
|
||||
}
|
||||
|
||||
/// Moves the cursor onto the current element's first attribute, exposing it
|
||||
/// as an ``ReaderNodeKind/attribute`` node. Returns false when the element
|
||||
/// has no attributes. ``moveToElement()`` returns to the element.
|
||||
@discardableResult
|
||||
public mutating func moveToFirstAttribute() -> Bool {
|
||||
guard !attributes.isEmpty else { return false }
|
||||
showAttribute(0)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Moves the cursor onto the next attribute, returning false past the last.
|
||||
@discardableResult
|
||||
public mutating func moveToNextAttribute() -> Bool {
|
||||
let next = (attributeCursor ?? -1) + 1
|
||||
guard next < attributes.count else { return false }
|
||||
showAttribute(next)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Moves the cursor onto the named attribute, returning false when absent.
|
||||
@discardableResult
|
||||
public mutating func moveToAttribute(_ name: String) -> Bool {
|
||||
guard let index = attributes.firstIndex(where: { $0.name.description == name }) else { return false }
|
||||
showAttribute(index)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Returns the cursor from an attribute node back to its element. Returns
|
||||
/// false when the cursor is not on an attribute.
|
||||
@discardableResult
|
||||
public mutating func moveToElement() -> Bool {
|
||||
guard attributeCursor != nil, let elementName else { return false }
|
||||
attributeCursor = nil
|
||||
currentName = elementName
|
||||
set(.element, name: elementName.description, depth: elementDepth)
|
||||
return true
|
||||
}
|
||||
|
||||
private mutating func showAttribute(_ index: Int) {
|
||||
attributeCursor = index
|
||||
let attribute = attributes[index]
|
||||
currentName = attribute.name
|
||||
set(.attribute, name: attribute.name.description, value: attribute.value, depth: elementDepth + 1)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
attributeCursor = nil
|
||||
guard let (event, start) = try nextEvent() else {
|
||||
resetToEnd()
|
||||
return false
|
||||
}
|
||||
position = start
|
||||
apply(event)
|
||||
return true
|
||||
}
|
||||
@ -87,11 +179,16 @@ public extension PureXML.Parsing {
|
||||
isEmptyElement = false
|
||||
attributes = []
|
||||
value = ""
|
||||
currentName = nil
|
||||
xmlLang = langStack.last ?? nil
|
||||
switch event {
|
||||
case let .startElement(name, attributes):
|
||||
applyStartElement(name: name, attributes: attributes)
|
||||
case let .endElement(name):
|
||||
currentDepth -= 1
|
||||
if !langStack.isEmpty { langStack.removeLast() }
|
||||
currentName = name
|
||||
xmlLang = langStack.last ?? nil
|
||||
set(.endElement, name: name.description, depth: currentDepth)
|
||||
case let .characters(text):
|
||||
set(.text, name: "#text", value: text)
|
||||
@ -109,15 +206,25 @@ public extension PureXML.Parsing {
|
||||
attributes: [PureXML.Model.Attribute],
|
||||
) {
|
||||
self.attributes = attributes
|
||||
currentName = name
|
||||
elementName = name
|
||||
elementDepth = currentDepth
|
||||
xmlLang = Self.xmlLang(of: attributes) ?? (langStack.last ?? nil)
|
||||
if consumeMatchingEnd(of: name) {
|
||||
isEmptyElement = true
|
||||
set(.element, name: name.description, depth: currentDepth)
|
||||
} else {
|
||||
langStack.append(xmlLang)
|
||||
set(.element, name: name.description, depth: currentDepth)
|
||||
currentDepth += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of an `xml:lang` attribute in `attributes`, if present.
|
||||
private static func xmlLang(of attributes: [PureXML.Model.Attribute]) -> String? {
|
||||
attributes.first { $0.name.localName == "lang" && $0.name.prefix == "xml" }?.value
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@ -140,21 +247,26 @@ public extension PureXML.Parsing {
|
||||
value = ""
|
||||
attributes = []
|
||||
isEmptyElement = false
|
||||
currentName = nil
|
||||
attributeCursor = nil
|
||||
xmlLang = nil
|
||||
}
|
||||
|
||||
private mutating func peekEvent() -> Event? {
|
||||
if lookahead == nil, !finished {
|
||||
lookahead = try? events.next()
|
||||
lookaheadStart = events.eventStart
|
||||
}
|
||||
return lookahead
|
||||
}
|
||||
|
||||
private mutating func nextEvent() throws -> Event? {
|
||||
private mutating func nextEvent() throws -> (event: Event, start: Mark)? {
|
||||
if let buffered = lookahead {
|
||||
lookahead = nil
|
||||
return buffered
|
||||
return (buffered, lookaheadStart)
|
||||
}
|
||||
return try events.next()
|
||||
guard let event = try events.next() else { return nil }
|
||||
return (event, events.eventStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
92
Tests/TextReaderBreadthTests.swift
Normal file
92
Tests/TextReaderBreadthTests.swift
Normal file
@ -0,0 +1,92 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("TextReader breadth: attributes, namespaces, lang, position")
|
||||
struct TextReaderBreadthTests {
|
||||
private func reader(_ xml: String) -> PureXML.Parsing.TextReader {
|
||||
PureXML.reader(xml)
|
||||
}
|
||||
|
||||
@Test("Attribute navigation walks each attribute and returns to the element")
|
||||
func test_attributeNavigation() throws {
|
||||
var cursor = reader("<a x=\"1\" y=\"2\"/>")
|
||||
#expect(try cursor.read())
|
||||
#expect(cursor.nodeKind == .element)
|
||||
let toFirst = cursor.moveToFirstAttribute()
|
||||
#expect(toFirst)
|
||||
#expect(cursor.nodeKind == .attribute)
|
||||
#expect(cursor.name == "x")
|
||||
#expect(cursor.value == "1")
|
||||
let toSecond = cursor.moveToNextAttribute()
|
||||
#expect(toSecond)
|
||||
#expect(cursor.name == "y")
|
||||
#expect(cursor.value == "2")
|
||||
let pastLast = cursor.moveToNextAttribute()
|
||||
#expect(!pastLast)
|
||||
let toElement = cursor.moveToElement()
|
||||
#expect(toElement)
|
||||
#expect(cursor.nodeKind == .element)
|
||||
#expect(cursor.name == "a")
|
||||
}
|
||||
|
||||
@Test("moveToAttribute selects by name")
|
||||
func test_moveToAttributeByName() throws {
|
||||
var cursor = reader("<a x=\"1\" y=\"2\"/>")
|
||||
#expect(try cursor.read())
|
||||
let found = cursor.moveToAttribute("y")
|
||||
#expect(found)
|
||||
#expect(cursor.value == "2")
|
||||
let missing = cursor.moveToAttribute("z")
|
||||
#expect(!missing)
|
||||
}
|
||||
|
||||
@Test("namespaceURI, localName, and prefix are exposed on elements")
|
||||
func test_namespaceAccessors() throws {
|
||||
var cursor = reader("<x:a xmlns:x=\"urn:x\"><b/></x:a>")
|
||||
#expect(try cursor.read())
|
||||
#expect(cursor.nodeKind == .element)
|
||||
#expect(cursor.namespaceURI == "urn:x")
|
||||
#expect(cursor.localName == "a")
|
||||
#expect(cursor.prefix == "x")
|
||||
#expect(try cursor.read())
|
||||
#expect(cursor.localName == "b")
|
||||
#expect(cursor.namespaceURI == nil)
|
||||
}
|
||||
|
||||
@Test("xml:lang is inherited by descendants and reset when it goes out of scope")
|
||||
func test_xmlLang() throws {
|
||||
var cursor = reader("<a xml:lang=\"en\"><b>hi</b></a><c/>")
|
||||
#expect(try cursor.read()) // <a>
|
||||
#expect(cursor.xmlLang == "en")
|
||||
#expect(try cursor.read()) // <b>
|
||||
#expect(cursor.xmlLang == "en")
|
||||
#expect(try cursor.read()) // text
|
||||
#expect(cursor.xmlLang == "en")
|
||||
#expect(try cursor.read()) // </b>
|
||||
#expect(try cursor.read()) // </a>
|
||||
#expect(cursor.xmlLang == nil)
|
||||
}
|
||||
|
||||
@Test("A nested xml:lang overrides the inherited value")
|
||||
func test_xmlLangOverride() throws {
|
||||
var cursor = reader("<a xml:lang=\"en\"><b xml:lang=\"fr\"/></a>")
|
||||
#expect(try cursor.read()) // <a>
|
||||
#expect(cursor.xmlLang == "en")
|
||||
#expect(try cursor.read()) // <b>
|
||||
#expect(cursor.xmlLang == "fr")
|
||||
}
|
||||
|
||||
@Test("Each node reports the line and column where it begins")
|
||||
func test_position() throws {
|
||||
var cursor = reader("<a>\n <b/>\n</a>")
|
||||
#expect(try cursor.read()) // <a>
|
||||
#expect(cursor.lineNumber == 1)
|
||||
#expect(cursor.columnNumber == 1)
|
||||
#expect(try cursor.read()) // text "\n "
|
||||
#expect(try cursor.read()) // <b/>
|
||||
#expect(cursor.nodeKind == .element)
|
||||
#expect(cursor.name == "b")
|
||||
#expect(cursor.lineNumber == 2)
|
||||
#expect(cursor.columnNumber == 3)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user