PureXML/Sources/Pattern/Matcher.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

119 lines
4.7 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
}
}
}