diff --git a/CHANGELOG.md b/CHANGELOG.md index dd244ae..e4ffa7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/README.md b/README.md index bb3415a..b7133d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Pattern/Matcher.swift b/Sources/Pattern/Matcher.swift new file mode 100644 index 0000000..55283a3 --- /dev/null +++ b/Sources/Pattern/Matcher.swift @@ -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 + } + } +} diff --git a/Sources/Pattern/PatternCompiler.swift b/Sources/Pattern/PatternCompiler.swift new file mode 100644 index 0000000..46ef437 --- /dev/null +++ b/Sources/Pattern/PatternCompiler.swift @@ -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 } + } + } +} diff --git a/Sources/Pattern/PatternError.swift b/Sources/Pattern/PatternError.swift new file mode 100644 index 0000000..34e3dfe --- /dev/null +++ b/Sources/Pattern/PatternError.swift @@ -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)" + } + } + } +} diff --git a/Sources/Pattern/PatternStream.swift b/Sources/Pattern/PatternStream.swift new file mode 100644 index 0000000..9d0a1e1 --- /dev/null +++ b/Sources/Pattern/PatternStream.swift @@ -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) + } + } +} diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index 6b6e1aa..28da66a 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -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 {} } diff --git a/Tests/PatternTests.swift b/Tests/PatternTests.swift new file mode 100644 index 0000000..b80c79c --- /dev/null +++ b/Tests/PatternTests.swift @@ -0,0 +1,83 @@ +@testable import PureXML +import Testing + +@Suite("Streaming patterns") +struct PatternTests { + private let xml = "" + + "" + + "" + + "" + + "" + + 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("") + } + } +}