Merge epic/22-pattern: streaming xmlPattern matching

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 05:49:14 +02:00
commit ab7d924901
8 changed files with 403 additions and 1 deletions

View File

@ -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,

View File

@ -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

View 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
}
}
}

View 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 }
}
}
}

View 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)"
}
}
}
}

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

View File

@ -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
View 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("")
}
}
}