From 4fe8771fb9f76a62bbf3e1a3af93f6faff4c669a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 05:40:52 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++++++ README.md | 2 +- Sources/XPath/Evaluator.swift | 20 ++++++++++++++++++ Sources/XPath/Query.swift | 14 +++++++++++++ Tests/XPathContextTests.swift | 39 +++++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 Tests/XPathContextTests.swift 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") + } +}