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:
parent
0c9aa47233
commit
2a77b56d2f
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 []
|
||||
}
|
||||
|
||||
51
Tests/XPathNamespaceTests.swift
Normal file
51
Tests/XPathNamespaceTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user