feat(xpath): eval-time namespace context and XPointer xmlns()/xpath1() schemes

- Eval-time prefix bindings: Query.evaluate(over:namespaces:) and
  value(at:...:namespaces:) supply prefix to URI bindings, so a name test
  like q:item resolves q to a URI and matches by namespace regardless of
  the document's own prefix. The match is namespace-aware only when
  bindings are present, so existing unprefixed/prefix-string queries are
  unchanged.
- XPointer schemes: xpath1() evaluates an XPath expression (like
  xpointer()), and xmlns(p=uri) binds a prefix for the expression parts
  that follow it, threaded into the XPath evaluation.

The range() scheme (and so XInclude range inclusion) remains.

Part of #88
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 16:00:15 +02:00
parent 0c9aa47233
commit 2a77b56d2f
6 changed files with 158 additions and 30 deletions

View File

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

View File

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

View File

@ -18,7 +18,7 @@ extension PureXML.XPath.Evaluator {
var seen: Set<Node> = []
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[..<colon])
if let uri = namespaces[prefix] {
let local = String(name[name.index(after: colon)...])
return qualified.localName == local && (qualified.namespaceURI ?? "") == uri
}
}
return qualified.description == name || qualified.localName == name
}
private static func wildcardMatches(_ node: Node, _ kind: PrincipalKind) -> Bool {
switch kind {
case .element: return isTreeKind(node, in: [.element])

View File

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

View File

@ -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[..<equals]).trimmingXMLWhitespace()
let uri = String(data[data.index(after: equals)...]).trimmingXMLWhitespace()
guard !prefix.isEmpty else { throw XPointerError.malformed }
return .namespace(prefix: prefix, uri: uri)
}
/// Translates an `element()` scheme datum (`id/1/2`, `/1/2`, `id`) into the
/// equivalent XPath: an id() start or the document root, then child-element
/// positions.
@ -123,29 +141,38 @@ private struct PointerScanner {
}
public extension PureXML.XPointer {
/// A compiled XPointer over the `element()` and `xpointer()` schemes, plus the
/// shorthand bare-name form (`name`, equivalent to `id('name')`). A pointer
/// may chain several scheme parts; they are tried in order and the first that
/// selects a non-empty node-set wins (the XPointer fallback rule).
/// A compiled XPointer over the `element()`, `xpointer()`, `xpath1()`, and
/// `xmlns()` schemes, plus the shorthand bare-name form (`name`, equivalent to
/// `id('name')`). A pointer may chain several scheme parts; selection parts are
/// tried in order and the first that selects a non-empty node-set wins (the
/// XPointer fallback rule).
///
/// `element(id/2/3)` navigates by child-element position from an id (or the
/// document root for a leading `/`); `xpointer(expr)` evaluates a full XPath
/// expression. Reimplemented over ``PureXML/XPath``.
/// document root for a leading `/`); `xpointer(expr)` and `xpath1(expr)`
/// evaluate a full XPath expression; `xmlns(p=uri)` binds the prefix `p` for the
/// expression parts that follow it. Reimplemented over ``PureXML/XPath``.
struct Pointer: Sendable {
private let parts: [String]
private let parts: [PointerPart]
/// Compiles an XPointer string.
public init(_ pointer: String) throws {
parts = try PointerScanner.parts(of: pointer)
}
/// Evaluates the pointer over a node, returning the first scheme part's
/// non-empty selection in document order, or an empty list.
/// Evaluates the pointer over a node, returning the first selection scheme
/// part's non-empty result in document order, or an empty list. An
/// `xmlns()` part binds a prefix for the expression parts that follow it.
public func evaluate(over node: PureXML.Model.Node) -> [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 []
}

View File

@ -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("<d:root xmlns:d=\"urn:x\"><d:item/></d:root>")
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("<d:root xmlns:d=\"urn:x\"><d:item/></d:root>")
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("<root><item/><item/></root>")
#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("<root><item/><item/></root>")
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("<d:root xmlns:d=\"urn:x\"><d:item/></d:root>")
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("<root><item/></root>")
// 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)
}
}