PureXML/Sources/Pattern/Matcher.swift
Mihaela Mihaljevic f7fe89023c feat(pattern): streaming xmlPattern matching
Add the PureXML.Pattern namespace: a compiler for the streamable XPath
subset (names, *, prefix:*, /, //, leading absolute /, trailing @attr) into
a Matcher, with predicates and . / .. rejected. The backward-matching
algorithm tests an element's ancestor-or-self path. Pattern.matches(_:in:)
streams a document through the pull parser and returns matching node paths
in document order from the open-element stack alone, no tree built.

Closes #44
Closes #45
Closes #22
2026-06-08 05:49:13 +02:00

115 lines
4.6 KiB
Swift

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