diff --git a/Sources/XPath/EvaluationContext.swift b/Sources/XPath/EvaluationContext.swift index 796d80d..e5b24e0 100644 --- a/Sources/XPath/EvaluationContext.swift +++ b/Sources/XPath/EvaluationContext.swift @@ -13,9 +13,14 @@ extension PureXML.XPath { var size: Int var variables: [String: Value] var functions: FunctionTable + /// Prefix-to-URI bindings supplied at evaluation time, so a name test like + /// `x:foo` resolves `x` to a URI and matches by namespace rather than by the + /// document's own prefix. Empty by default, in which case matching falls + /// back to the in-document prefix string. + var namespaces: [String: String] = [:] /// A copy positioned on `node` at one-based `position` within a node-set of - /// `size`, keeping the same variables and functions. + /// `size`, keeping the same variables, functions, and namespace bindings. func focused(on node: Node, position: Int, size: Int) -> EvaluationContext { EvaluationContext( node: node, @@ -23,6 +28,7 @@ extension PureXML.XPath { size: size, variables: variables, functions: functions, + namespaces: namespaces, ) } } diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index ba5985a..e065535 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -60,6 +60,7 @@ extension PureXML.XPath { size: Int, variables: [String: Value], functions: FunctionTable = FunctionTable(), + namespaces: [String: String] = [:], ) throws -> Value { let context = EvaluationContext( node: .tree(node), @@ -67,11 +68,30 @@ extension PureXML.XPath { size: size, variables: variables, functions: library.merging(functions), + namespaces: namespaces, ) return try eval(expression, context) } - private static func rootContext(_ node: PureXML.Model.Node, variables: [String: Value]) -> EvaluationContext { + /// Evaluates a node-set expression over a value tree with eval-time prefix + /// bindings, returning the selected nodes in document order. + static func evaluate( + _ expression: Expression, + over node: PureXML.Model.Node, + namespaces: [String: String], + ) -> [Selection] { + let context = rootContext(node, variables: [:], namespaces: namespaces) + guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else { + return [] + } + return orderUnique(nodes).map(selection) + } + + private static func rootContext( + _ node: PureXML.Model.Node, + variables: [String: Value], + namespaces: [String: String] = [:], + ) -> EvaluationContext { let root = PureXML.Model.TreeNode(node) return EvaluationContext( node: .tree(root), @@ -79,6 +99,7 @@ extension PureXML.XPath { size: 1, variables: variables, functions: library, + namespaces: namespaces, ) } diff --git a/Sources/XPath/EvaluatorSteps.swift b/Sources/XPath/EvaluatorSteps.swift index da7d23e..d2dd3f9 100644 --- a/Sources/XPath/EvaluatorSteps.swift +++ b/Sources/XPath/EvaluatorSteps.swift @@ -18,7 +18,7 @@ extension PureXML.XPath.Evaluator { var seen: Set = [] for contextNode in current { let matched = PureXML.XPath.AxisNavigation.nodes(on: step.axis, from: contextNode) - .filter { matches($0, step.test, on: step.axis) } + .filter { matches($0, step.test, on: step.axis, namespaces: context.namespaces) } let filtered = (try? applyPredicates(step.predicates, to: matched, context)) ?? matched for node in filtered where seen.insert(node).inserted { result.append(node) @@ -61,10 +61,10 @@ extension PureXML.XPath.Evaluator { // MARK: Node tests - static func matches(_ node: Node, _ test: NodeTest, on axis: Axis) -> Bool { + static func matches(_ node: Node, _ test: NodeTest, on axis: Axis, namespaces: [String: String] = [:]) -> Bool { switch test { case let .name(name): - nameMatches(node, name, axis.principalKind) + nameMatches(node, name, axis.principalKind, namespaces) case .wildcard: wildcardMatches(node, axis.principalKind) case .text: @@ -78,22 +78,37 @@ extension PureXML.XPath.Evaluator { } } - private static func nameMatches(_ node: Node, _ name: String, _ kind: PrincipalKind) -> Bool { + private static func nameMatches(_ node: Node, _ name: String, _ kind: PrincipalKind, _ namespaces: [String: String]) -> Bool { switch kind { case .element: guard case let .tree(tree) = node, tree.kind == .element, let qualified = tree.name else { return false } - return qualified.description == name || qualified.localName == name + return qualifiedMatches(qualified, name, namespaces) case .attribute: guard case let .attribute(_, attribute) = node else { return false } - return attribute.name.description == name || attribute.name.localName == name + return qualifiedMatches(attribute.name, name, namespaces) case .namespace: guard case let .namespace(_, prefix, _) = node else { return false } return prefix == name } } + /// Matches a node's qualified name against a test name. When an eval-time + /// prefix binding covers the test's prefix, the match is by namespace URI and + /// local name; otherwise it falls back to the in-document prefix string (the + /// behavior when no bindings are supplied). + private static func qualifiedMatches(_ qualified: PureXML.Model.QualifiedName, _ name: String, _ namespaces: [String: String]) -> Bool { + if !namespaces.isEmpty, let colon = name.firstIndex(of: ":") { + let prefix = String(name[.. Bool { switch kind { case .element: return isTreeKind(node, in: [.element]) diff --git a/Sources/XPath/Query.swift b/Sources/XPath/Query.swift index ff8bf93..70f7178 100644 --- a/Sources/XPath/Query.swift +++ b/Sources/XPath/Query.swift @@ -20,6 +20,13 @@ public extension PureXML.XPath { Evaluator.evaluate(expression, over: node) } + /// Evaluates the query with eval-time prefix bindings, so a name test like + /// `x:foo` resolves `x` to its URI and matches by namespace regardless of + /// the prefix the document uses. + public func evaluate(over node: PureXML.Model.Node, namespaces: [String: String]) -> [Selection] { + Evaluator.evaluate(expression, over: node, namespaces: namespaces) + } + /// Evaluates the query and returns the selected elements only. public func elements(over node: PureXML.Model.Node) -> [PureXML.Model.Element] { evaluate(over: node).compactMap(\.element) @@ -56,8 +63,9 @@ public extension PureXML.XPath { position: Int = 1, size: Int = 1, variables: [String: Value] = [:], + namespaces: [String: String] = [:], ) throws -> Value { - try Evaluator.value(expression, at: node, position: position, size: size, variables: variables) + try Evaluator.value(expression, at: node, position: position, size: size, variables: variables, namespaces: namespaces) } /// Like ``value(at:position:size:variables:)`` but with an extra function diff --git a/Sources/XPointer/Pointer.swift b/Sources/XPointer/Pointer.swift index e724a1f..872e749 100644 --- a/Sources/XPointer/Pointer.swift +++ b/Sources/XPointer/Pointer.swift @@ -1,6 +1,14 @@ +/// One part of an XPointer: either a selection expression (already translated to +/// XPath) or a namespace binding from an `xmlns()` scheme that applies to the +/// expression parts that follow it. File-scope and private. +private enum PointerPart { + case expression(String) + case namespace(prefix: String, uri: String) +} + /// Scans an XPointer into its scheme parts, each already translated to an XPath -/// expression. File-scope and private: an internal detail of -/// ``PureXML/XPointer/Pointer``. +/// expression or a namespace binding. File-scope and private: an internal detail +/// of ``PureXML/XPointer/Pointer``. private struct PointerScanner { typealias XPointerError = PureXML.XPointer.XPointerError @@ -11,19 +19,19 @@ private struct PointerScanner { chars = Array(pointer) } - static func parts(of pointer: String) throws -> [String] { + static func parts(of pointer: String) throws -> [PointerPart] { let trimmed = pointer.trimmingXMLWhitespace() guard !trimmed.isEmpty else { throw XPointerError.empty } // A bare name with no scheme parentheses is a shorthand pointer: id(name). if !trimmed.contains("(") { - return ["id('\(trimmed)')"] + return [.expression("id('\(trimmed)')")] } var scanner = PointerScanner(trimmed) return try scanner.scanSchemes() } - private mutating func scanSchemes() throws -> [String] { - var parts: [String] = [] + private mutating func scanSchemes() throws -> [PointerPart] { + var parts: [PointerPart] = [] while true { skipSpace() guard !isAtEnd else { return parts } @@ -63,14 +71,24 @@ private struct PointerScanner { throw XPointerError.malformed } - private func translate(scheme: String, data: String) throws -> String { + private func translate(scheme: String, data: String) throws -> PointerPart { switch scheme { - case "xpointer": data - case "element": Self.elementToXPath(data) + case "xpointer", "xpath1": .expression(data) + case "element": .expression(Self.elementToXPath(data)) + case "xmlns": try Self.namespaceBinding(data) default: throw XPointerError.unknownScheme(scheme) } } + /// Parses an `xmlns(prefix=uri)` datum into a namespace binding. + private static func namespaceBinding(_ data: String) throws -> PointerPart { + guard let equals = data.firstIndex(of: "=") else { throw XPointerError.malformed } + let prefix = String(data[.. [PureXML.XPath.Selection] { + var namespaces: [String: String] = [:] for part in parts { - guard let query = try? PureXML.XPath.Query(part) else { continue } - let result = query.evaluate(over: node) - if !result.isEmpty { return result } + switch part { + case let .namespace(prefix, uri): + namespaces[prefix] = uri + case let .expression(xpath): + guard let query = try? PureXML.XPath.Query(xpath) else { continue } + let result = query.evaluate(over: node, namespaces: namespaces) + if !result.isEmpty { return result } + } } return [] } diff --git a/Tests/XPathNamespaceTests.swift b/Tests/XPathNamespaceTests.swift new file mode 100644 index 0000000..f218555 --- /dev/null +++ b/Tests/XPathNamespaceTests.swift @@ -0,0 +1,51 @@ +@testable import PureXML +import Testing + +@Suite("XPath eval-time namespaces and XPointer schemes") +struct XPathNamespaceTests { + @Test("A name test resolves through eval-time prefix bindings regardless of document prefix") + func test_evalTimeNamespace() throws { + // The document uses prefix 'd'; the query uses 'q' bound to the same URI. + let node = try PureXML.parse("") + let query = try PureXML.XPath.Query("//q:item") + let bound = query.evaluate(over: node, namespaces: ["q": "urn:x"]) + #expect(bound.count == 1) + // Without the binding, the query prefix 'q' does not match the document's 'd'. + #expect(query.evaluate(over: node).isEmpty) + } + + @Test("A binding to the wrong URI does not match") + func test_wrongNamespace() throws { + let node = try PureXML.parse("") + let query = try PureXML.XPath.Query("//q:item") + #expect(query.evaluate(over: node, namespaces: ["q": "urn:other"]).isEmpty) + } + + @Test("Existing unprefixed queries are unaffected by an empty binding set") + func test_noBindingUnchanged() throws { + let node = try PureXML.parse("") + #expect(try PureXML.XPath.Query("//item").evaluate(over: node).count == 2) + } + + @Test("XPointer xpath1() evaluates an XPath expression") + func test_xpath1Scheme() throws { + let node = try PureXML.parse("") + let result = try PureXML.XPointer.evaluate("xpath1(//item)", over: node) + #expect(result.count == 2) + } + + @Test("XPointer xmlns() binds a prefix for the following xpointer part") + func test_xmlnsScheme() throws { + let node = try PureXML.parse("") + let result = try PureXML.XPointer.evaluate("xmlns(q=urn:x)xpointer(//q:item)", over: node) + #expect(result.count == 1) + } + + @Test("XPointer falls through schemes until one selects") + func test_schemeFallback() throws { + let node = try PureXML.parse("") + // The first xpointer selects nothing; the second selects the item. + let result = try PureXML.XPointer.evaluate("xpointer(//missing)xpointer(//item)", over: node) + #expect(result.count == 1) + } +}