Merge epic/22-pattern: streaming xmlPattern matching
This commit is contained in:
commit
ab7d924901
@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Streaming pattern matching (#22, the libxml2 `pattern.h` model). The new
|
||||
`PureXML.Pattern` namespace compiles the streamable XPath subset (element
|
||||
names, `*`, `prefix:*`, `/`, `//`, a leading absolute `/`, and a trailing
|
||||
attribute step) into a `Matcher`, rejecting predicates and `.`/`..` as outside
|
||||
the subset. `PureXML.Pattern.matches(_:in:)` streams a document through the
|
||||
pull parser and returns the paths of matching nodes (`/a/b/c`, `/a/@id`) in
|
||||
document order, deciding each match from the open-element stack alone, so no
|
||||
tree is built. `Matcher.matchesElement(path:)` reuses a compiled pattern.
|
||||
- The XPath evaluation context, completing full XPath 1.0 (#21).
|
||||
`Query.value(at:position:size:variables:)` evaluates an expression against an
|
||||
explicit context node already in a tree, with its proximity position, size,
|
||||
|
||||
@ -90,7 +90,7 @@ flowchart TB
|
||||
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
|
||||
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
|
||||
XPathFull["#21 Full XPath 1.0"]:::done
|
||||
Pattern["#22 xmlPattern streaming match"]:::epic
|
||||
Pattern["#22 xmlPattern streaming match"]:::done
|
||||
XPointer["#23 XPointer"]:::epic
|
||||
XPathFull --> Pattern
|
||||
XPathFull --> XPointer
|
||||
|
||||
114
Sources/Pattern/Matcher.swift
Normal file
114
Sources/Pattern/Matcher.swift
Normal file
@ -0,0 +1,114 @@
|
||||
extension PureXML.Pattern {
|
||||
/// How a step connects to the step on its left (its parent in the pattern).
|
||||
enum Gap: Equatable, Sendable {
|
||||
/// The first step must be the document root element.
|
||||
case root
|
||||
/// The immediate parent (a single `/`).
|
||||
case child
|
||||
/// Any ancestor (a `//`, or a relative pattern's floating start).
|
||||
case descendant
|
||||
}
|
||||
|
||||
/// A name test in a pattern step.
|
||||
enum NameMatch: Equatable, Sendable {
|
||||
case wildcard
|
||||
case name(String)
|
||||
case prefixWildcard(String)
|
||||
|
||||
func matches(_ name: PureXML.Model.QualifiedName) -> Bool {
|
||||
switch self {
|
||||
case .wildcard:
|
||||
return true
|
||||
case let .prefixWildcard(prefix):
|
||||
return name.prefix == prefix
|
||||
case let .name(text):
|
||||
if text.contains(":") { return name.description == text }
|
||||
return name.description == text || name.localName == text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One compiled pattern step.
|
||||
struct PatternStep: Equatable, Sendable {
|
||||
let gap: Gap
|
||||
let test: NameMatch
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Pattern {
|
||||
/// A compiled streaming pattern (the libxml2 `pattern.h` XPath subset): element
|
||||
/// names, `*`, `prefix:*`, `/`, `//`, an absolute leading `/`, and an optional
|
||||
/// trailing attribute step. Predicates, `.`/`..`, and the general expression
|
||||
/// language are out of the streamable subset and are rejected at compile time.
|
||||
///
|
||||
/// A pattern is matched against an element's ancestor-or-self path (root first),
|
||||
/// which a streaming reader knows from its open-element stack, so no tree is
|
||||
/// needed.
|
||||
struct Matcher: Sendable {
|
||||
let elementSteps: [PatternStep]
|
||||
let attributeTest: NameMatch?
|
||||
|
||||
/// Compiles a pattern string.
|
||||
public init(_ pattern: String) throws {
|
||||
(elementSteps, attributeTest) = try PatternCompiler.compile(pattern)
|
||||
}
|
||||
|
||||
/// Whether this pattern selects attributes rather than elements.
|
||||
public var matchesAttributes: Bool {
|
||||
attributeTest != nil
|
||||
}
|
||||
|
||||
/// Whether the element at the end of `path` (root first) matches an
|
||||
/// element pattern. Always false for an attribute pattern.
|
||||
public func matchesElement(path: [PureXML.Model.QualifiedName]) -> Bool {
|
||||
guard attributeTest == nil, !path.isEmpty else { return false }
|
||||
return matchesElementPath(path)
|
||||
}
|
||||
|
||||
/// The attributes of the element at the end of `path` that this pattern
|
||||
/// selects. Empty for an element pattern.
|
||||
public func matchingAttributes(
|
||||
path: [PureXML.Model.QualifiedName],
|
||||
attributes: [PureXML.Model.Attribute],
|
||||
) -> [PureXML.Model.Attribute] {
|
||||
guard let attributeTest, !path.isEmpty, matchesElementPath(path) else { return [] }
|
||||
return attributes.filter { attributeTest.matches($0.name) }
|
||||
}
|
||||
|
||||
/// Whether the element steps match `path` ending at the last element.
|
||||
private func matchesElementPath(_ path: [PureXML.Model.QualifiedName]) -> Bool {
|
||||
guard !elementSteps.isEmpty else { return true }
|
||||
return matchUpTo(elementSteps.count - 1, path.count - 1, path)
|
||||
}
|
||||
|
||||
/// Whether `elementSteps[0...stepIndex]` match the path ending at
|
||||
/// `pathIndex`, with `elementSteps[stepIndex]` matching `path[pathIndex]`.
|
||||
private func matchUpTo(_ stepIndex: Int, _ pathIndex: Int, _ path: [PureXML.Model.QualifiedName]) -> Bool {
|
||||
guard pathIndex >= 0 else { return false }
|
||||
let step = elementSteps[stepIndex]
|
||||
guard step.test.matches(path[pathIndex]) else { return false }
|
||||
if stepIndex == 0 {
|
||||
return step.gap == .root ? pathIndex == 0 : true
|
||||
}
|
||||
switch step.gap {
|
||||
case .child:
|
||||
return matchUpTo(stepIndex - 1, pathIndex - 1, path)
|
||||
case .descendant:
|
||||
return ancestorMatches(stepIndex - 1, below: pathIndex, path)
|
||||
case .root:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `elementSteps[0...stepIndex]` match some ancestor strictly above
|
||||
/// `pathIndex` (the `//` gap allows any number of intervening ancestors).
|
||||
private func ancestorMatches(_ stepIndex: Int, below pathIndex: Int, _ path: [PureXML.Model.QualifiedName]) -> Bool {
|
||||
var candidate = pathIndex - 1
|
||||
while candidate >= 0 {
|
||||
if matchUpTo(stepIndex, candidate, path) { return true }
|
||||
candidate -= 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
122
Sources/Pattern/PatternCompiler.swift
Normal file
122
Sources/Pattern/PatternCompiler.swift
Normal file
@ -0,0 +1,122 @@
|
||||
/// One parsed step before element/attribute separation. File-scope and private:
|
||||
/// an internal detail of ``PureXML/Pattern/PatternCompiler``.
|
||||
private struct RawStep {
|
||||
let gap: PureXML.Pattern.Gap
|
||||
let isAttribute: Bool
|
||||
let test: PureXML.Pattern.NameMatch
|
||||
}
|
||||
|
||||
extension PureXML.Pattern {
|
||||
/// Compiles a streaming-pattern string into element steps and an optional
|
||||
/// trailing attribute test. A small recursive-descent parser; constructs
|
||||
/// outside the streamable subset are rejected.
|
||||
struct PatternCompiler {
|
||||
private let chars: [Character]
|
||||
private var index = 0
|
||||
|
||||
private init(_ pattern: String) {
|
||||
chars = Array(pattern)
|
||||
}
|
||||
|
||||
static func compile(_ pattern: String) throws -> (elementSteps: [PatternStep], attributeTest: NameMatch?) {
|
||||
var compiler = PatternCompiler(pattern)
|
||||
return try compiler.parse()
|
||||
}
|
||||
|
||||
private mutating func parse() throws -> (elementSteps: [PatternStep], attributeTest: NameMatch?) {
|
||||
guard !chars.isEmpty else { throw PatternError.empty }
|
||||
var steps: [RawStep] = []
|
||||
try steps.append(parseStep(gap: leadingGap()))
|
||||
while !isAtEnd {
|
||||
let gap: Gap
|
||||
if consume("//") {
|
||||
gap = .descendant
|
||||
} else if consume("/") {
|
||||
gap = .child
|
||||
} else {
|
||||
throw PatternError.unsupported(String(peek() ?? " "))
|
||||
}
|
||||
try steps.append(parseStep(gap: gap))
|
||||
}
|
||||
return try assemble(steps)
|
||||
}
|
||||
|
||||
private mutating func leadingGap() -> Gap {
|
||||
if consume("//") { return .descendant }
|
||||
if consume("/") { return .root }
|
||||
return .descendant
|
||||
}
|
||||
|
||||
private func assemble(_ steps: [RawStep]) throws -> (elementSteps: [PatternStep], attributeTest: NameMatch?) {
|
||||
for (offset, step) in steps.enumerated() where step.isAttribute && offset != steps.count - 1 {
|
||||
throw PatternError.unsupported("an attribute step must be last")
|
||||
}
|
||||
if let last = steps.last, last.isAttribute {
|
||||
let elements = steps.dropLast().map { PatternStep(gap: $0.gap, test: $0.test) }
|
||||
return (Array(elements), last.test)
|
||||
}
|
||||
return (steps.map { PatternStep(gap: $0.gap, test: $0.test) }, nil)
|
||||
}
|
||||
|
||||
private mutating func parseStep(gap: Gap) throws -> RawStep {
|
||||
guard let character = peek() else { throw PatternError.expectedStep }
|
||||
if character == "[" { throw PatternError.unsupported("predicate") }
|
||||
if character == "." { throw PatternError.unsupported(".") }
|
||||
let isAttribute = consume("@")
|
||||
let test = try parseTest()
|
||||
if peek() == "[" { throw PatternError.unsupported("predicate") }
|
||||
return RawStep(gap: gap, isAttribute: isAttribute, test: test)
|
||||
}
|
||||
|
||||
private mutating func parseTest() throws -> NameMatch {
|
||||
if peek() == "*" {
|
||||
advance()
|
||||
return .wildcard
|
||||
}
|
||||
let name = parseName()
|
||||
guard !name.isEmpty else { throw PatternError.expectedStep }
|
||||
if name.hasSuffix(":"), peek() == "*" {
|
||||
advance()
|
||||
return .prefixWildcard(String(name.dropLast()))
|
||||
}
|
||||
return .name(name)
|
||||
}
|
||||
|
||||
private mutating func parseName() -> String {
|
||||
var name = ""
|
||||
while let character = peek(), character.isXMLNameContinuation {
|
||||
name.append(character)
|
||||
advance()
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
private var isAtEnd: Bool {
|
||||
index >= chars.count
|
||||
}
|
||||
|
||||
private func peek() -> Character? {
|
||||
index < chars.count ? chars[index] : nil
|
||||
}
|
||||
|
||||
private func matches(_ literal: String) -> Bool {
|
||||
let target = Array(literal)
|
||||
guard index + target.count <= chars.count else { return false }
|
||||
for (offset, character) in target.enumerated() where chars[index + offset] != character {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private mutating func consume(_ literal: String) -> Bool {
|
||||
guard matches(literal) else { return false }
|
||||
index += literal.count
|
||||
return true
|
||||
}
|
||||
|
||||
private mutating func advance() {
|
||||
if index < chars.count { index += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Sources/Pattern/PatternError.swift
Normal file
18
Sources/Pattern/PatternError.swift
Normal file
@ -0,0 +1,18 @@
|
||||
public extension PureXML.Pattern {
|
||||
/// An error compiling a streaming pattern.
|
||||
enum PatternError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
|
||||
case empty
|
||||
case expectedStep
|
||||
/// A construct outside the streamable subset (a predicate, an attribute
|
||||
/// step that is not last, or `.`/`..`).
|
||||
case unsupported(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .empty: "the pattern is empty"
|
||||
case .expectedStep: "expected a step after '/'"
|
||||
case let .unsupported(detail): "unsupported pattern construct: \(detail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Sources/Pattern/PatternStream.swift
Normal file
54
Sources/Pattern/PatternStream.swift
Normal file
@ -0,0 +1,54 @@
|
||||
public extension PureXML.Pattern {
|
||||
/// Streams `xml` through the pull parser and returns the paths of every node
|
||||
/// that matches `pattern`, in document order, without building a tree. Each
|
||||
/// path is rendered root-first as `/a/b/c`, and an attribute match as
|
||||
/// `/a/b/@id`. The match is decided from the open-element stack alone, so the
|
||||
/// document is never held whole.
|
||||
static func matches(
|
||||
_ pattern: String,
|
||||
in xml: String,
|
||||
limits: PureXML.Parsing.Limits = .default,
|
||||
) throws -> [String] {
|
||||
let matcher = try Matcher(pattern)
|
||||
return try matcher.matches(in: xml, limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
public extension PureXML.Pattern.Matcher {
|
||||
/// Streams `xml` and returns the matching node paths in document order.
|
||||
func matches(
|
||||
in xml: String,
|
||||
limits: PureXML.Parsing.Limits = .default,
|
||||
) throws -> [String] {
|
||||
var reader = PureXML.Parsing.EventReader(xml, limits: limits)
|
||||
var stack: [PureXML.Model.QualifiedName] = []
|
||||
var results: [String] = []
|
||||
while let event = try reader.next() {
|
||||
switch event {
|
||||
case let .startElement(name, attributes):
|
||||
stack.append(name)
|
||||
collect(stack: stack, attributes: attributes, into: &results)
|
||||
case .endElement:
|
||||
if !stack.isEmpty { stack.removeLast() }
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private func collect(
|
||||
stack: [PureXML.Model.QualifiedName],
|
||||
attributes: [PureXML.Model.Attribute],
|
||||
into results: inout [String],
|
||||
) {
|
||||
let base = "/" + stack.map(\.description).joined(separator: "/")
|
||||
if matchesAttributes {
|
||||
for attribute in matchingAttributes(path: stack, attributes: attributes) {
|
||||
results.append(base + "/@" + attribute.name.description)
|
||||
}
|
||||
} else if matchesElement(path: stack) {
|
||||
results.append(base)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -23,4 +23,7 @@ public enum PureXML {
|
||||
|
||||
/// XPath query types.
|
||||
public enum XPath {}
|
||||
|
||||
/// Streaming pattern types (the libxml2 `pattern.h` XPath subset).
|
||||
public enum Pattern {}
|
||||
}
|
||||
|
||||
83
Tests/PatternTests.swift
Normal file
83
Tests/PatternTests.swift
Normal file
@ -0,0 +1,83 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("Streaming patterns")
|
||||
struct PatternTests {
|
||||
private let xml = "<r>"
|
||||
+ "<a id=\"1\"><b><c/></b></a>"
|
||||
+ "<a id=\"2\"><b/></a>"
|
||||
+ "<d><b/></d>"
|
||||
+ "</r>"
|
||||
|
||||
private func match(_ pattern: String) throws -> [String] {
|
||||
try PureXML.Pattern.matches(pattern, in: xml)
|
||||
}
|
||||
|
||||
@Test("A relative name matches that element at any depth")
|
||||
func test_relativeName() throws {
|
||||
#expect(try match("b") == ["/r/a/b", "/r/a/b", "/r/d/b"])
|
||||
}
|
||||
|
||||
@Test("A child path requires the immediate parent")
|
||||
func test_childPath() throws {
|
||||
#expect(try match("a/b") == ["/r/a/b", "/r/a/b"])
|
||||
}
|
||||
|
||||
@Test("An absolute path is anchored at the root")
|
||||
func test_absolutePath() throws {
|
||||
#expect(try match("/r/a/b") == ["/r/a/b", "/r/a/b"])
|
||||
#expect(try match("/a/b").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A descendant step matches at any depth below")
|
||||
func test_descendant() throws {
|
||||
#expect(try match("a//c") == ["/r/a/b/c"])
|
||||
#expect(try match("//c") == ["/r/a/b/c"])
|
||||
}
|
||||
|
||||
@Test("The wildcard matches any element name")
|
||||
func test_wildcard() throws {
|
||||
#expect(try match("r/*") == ["/r/a", "/r/a", "/r/d"])
|
||||
}
|
||||
|
||||
@Test("An attribute pattern matches attributes of matching elements")
|
||||
func test_attributePattern() throws {
|
||||
#expect(try match("a/@id") == ["/r/a/@id", "/r/a/@id"])
|
||||
#expect(try match("//@id") == ["/r/a/@id", "/r/a/@id"])
|
||||
}
|
||||
|
||||
@Test("A non-matching pattern yields nothing")
|
||||
func test_noMatch() throws {
|
||||
#expect(try match("x").isEmpty)
|
||||
#expect(try match("d/c").isEmpty)
|
||||
}
|
||||
|
||||
@Test("The compiled matcher tests an explicit path")
|
||||
func test_explicitPath() throws {
|
||||
let matcher = try PureXML.Pattern.Matcher("a/b")
|
||||
let path = [
|
||||
PureXML.Model.QualifiedName("r"),
|
||||
PureXML.Model.QualifiedName("a"),
|
||||
PureXML.Model.QualifiedName("b"),
|
||||
]
|
||||
#expect(matcher.matchesElement(path: path))
|
||||
#expect(!matcher.matchesElement(path: Array(path.dropLast())))
|
||||
}
|
||||
|
||||
@Test("Predicates and parent steps are rejected as unsupported")
|
||||
func test_unsupported() {
|
||||
#expect(throws: PureXML.Pattern.PatternError.self) {
|
||||
_ = try PureXML.Pattern.Matcher("a[1]")
|
||||
}
|
||||
#expect(throws: PureXML.Pattern.PatternError.self) {
|
||||
_ = try PureXML.Pattern.Matcher("..")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An empty pattern is rejected")
|
||||
func test_empty() {
|
||||
#expect(throws: PureXML.Pattern.PatternError.empty) {
|
||||
_ = try PureXML.Pattern.Matcher("")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user