diff --git a/CHANGELOG.md b/CHANGELOG.md
index 11a6ac1..dd244ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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`,
diff --git a/README.md b/README.md
index 5bc4ce5..bb3415a 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift
index 48ca137..2394017 100644
--- a/Sources/XPath/Evaluator.swift
+++ b/Sources/XPath/Evaluator.swift
@@ -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(
diff --git a/Sources/XPath/Query.swift b/Sources/XPath/Query.swift
index 29f6415..70e7771 100644
--- a/Sources/XPath/Query.swift
+++ b/Sources/XPath/Query.swift
@@ -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
diff --git a/Tests/XPathContextTests.swift b/Tests/XPathContextTests.swift
new file mode 100644
index 0000000..0f7ebcd
--- /dev/null
+++ b/Tests/XPathContextTests.swift
@@ -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("deepother")
+ 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("")
+ 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("")
+ 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("")
+ 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")
+ }
+}