feat(xpath): explicit evaluation context entry point

Add Query.value(at:position:size:variables:) to evaluate an expression
against a context node already in a tree, with its proximity position, size,
and variable bindings. position(), last(), and the upward axes resolve from
that node. This completes full XPath 1.0: the per-node entry point that
downstream engines (XSLT, Schematron) drive.

Closes #43
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 05:40:52 +02:00
parent cef46fe3ae
commit 4fe8771fb9
5 changed files with 80 additions and 1 deletions

View File

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- 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,
and variable bindings, so `position()`, `last()`, and the upward axes resolve
from that node. This is the per-node entry point downstream engines (XSLT,
Schematron) drive.
- The full XPath 1.0 function library (toward #21 full XPath 1.0). Beyond the
core functions, the string family (`concat`, `starts-with`, `contains`,
`substring-before`, `substring-after`, `substring`, `string-length`,

View File

@ -89,7 +89,7 @@ flowchart TB
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
XPathFull["#21 Full XPath 1.0"]:::epic
XPathFull["#21 Full XPath 1.0"]:::done
Pattern["#22 xmlPattern streaming match"]:::epic
XPointer["#23 XPointer"]:::epic
XPathFull --> Pattern

View File

@ -29,6 +29,26 @@ extension PureXML.XPath {
try eval(expression, rootContext(node, variables: variables))
}
/// Evaluates an expression against an explicit context: a node already in a
/// tree, its proximity position and size, and variable bindings. This is
/// the entry point downstream engines (XSLT, Schematron) drive per node.
static func value(
_ expression: Expression,
at node: PureXML.Model.TreeNode,
position: Int,
size: Int,
variables: [String: Value],
) throws -> Value {
let context = EvaluationContext(
node: .tree(node),
position: position,
size: size,
variables: variables,
functions: library,
)
return try eval(expression, context)
}
private static func rootContext(_ node: PureXML.Model.Node, variables: [String: Value]) -> EvaluationContext {
let root = PureXML.Model.TreeNode(node)
return EvaluationContext(

View File

@ -38,6 +38,20 @@ public extension PureXML.XPath {
try Evaluator.value(expression, over: node, variables: variables)
}
/// Evaluates the query against an explicit context: a node already in a
/// tree (``PureXML/Model/TreeNode``), its one-based proximity `position`
/// within a node-set of `size`, and variable bindings. Downstream engines
/// (XSLT, Schematron) drive this per context node; `position()` and
/// `last()` reflect the supplied values.
public func value(
at node: PureXML.Model.TreeNode,
position: Int = 1,
size: Int = 1,
variables: [String: Value] = [:],
) throws -> Value {
try Evaluator.value(expression, at: node, position: position, size: size, variables: variables)
}
/// Evaluates the query and coerces the result to a number.
public func number(over node: PureXML.Model.Node, variables: [String: Value] = [:]) throws -> Double {
try value(over: node, variables: variables).number

View File

@ -0,0 +1,39 @@
@testable import PureXML
import Testing
@Suite("XPath evaluation context")
struct XPathContextTests {
@Test("A relative query evaluates against an explicit context node")
func test_contextNode() throws {
let tree = try PureXML.parseTree("<r><a><b>deep</b></a><a><b>other</b></a></r>")
let firstA = tree.firstChild?.firstChild
let value = try #require(firstA)
let result = try PureXML.XPath.Query("b").value(at: value)
#expect(result.string == "deep")
}
@Test("position() and last() reflect the supplied context")
func test_positionAndSize() throws {
let tree = try PureXML.parseTree("<r/>")
let node = try #require(tree.firstChild)
#expect(try PureXML.XPath.Query("position()").value(at: node, position: 3, size: 7).number == 3)
#expect(try PureXML.XPath.Query("last()").value(at: node, position: 3, size: 7).number == 7)
}
@Test("Variable bindings are visible to the evaluated expression")
func test_variableBindings() throws {
let tree = try PureXML.parseTree("<r/>")
let node = try #require(tree.firstChild)
let value = try PureXML.XPath.Query("$x * 2").value(at: node, variables: ["x": .number(21)])
#expect(value.number == 42)
}
@Test("Upward axes resolve from the context node's place in the tree")
func test_upwardFromContext() throws {
let tree = try PureXML.parseTree("<r id=\"root\"><a><b/></a></r>")
let deepB = tree.firstChild?.firstChild?.firstChild
let node = try #require(deepB)
let result = try PureXML.XPath.Query("ancestor::r/@id").value(at: node)
#expect(result.string == "root")
}
}