feat(xpath): expression engine and the four-type model

Compile and evaluate full XPath 1.0 expressions, not just location paths.
Add the four-type Value (node-set, boolean, number, string) with the spec's
coercion rules and canonical number formatting, a recursive-descent parser
for the operator grammar (or/and, equality, relational, additive,
multiplicative, unary minus, union) whose grammatical position disambiguates
* and the operator keywords, function calls, variable references, and filter
expressions. Predicates are now arbitrary expressions (a numeric predicate
is a position test). Comparison follows the node-set quantification rules.
Query gains value/number/string/boolean accessors and a $variable binding
map; the core functions last, position, count, not, true, false, boolean,
number, and string ship with it.

Closes #42
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 05:33:13 +02:00
parent f1d9015e80
commit d83e5a88ce
17 changed files with 1145 additions and 365 deletions

View File

@ -22,3 +22,7 @@
# PureXML uses OpenAPIKit-style `test_...` method names with Swift Testing.
--disable swiftTestingTestCaseNames
# The recursive `indirect enum Expression` and its `Step` need explicit Sendable
# that the compiler does not synthesize; redundantSendable wrongly strips it.
--disable redundantSendable

View File

@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- The XPath expression engine and four-type model (toward #21 full XPath 1.0).
`PureXML.XPath.Query` now compiles and evaluates full expressions, not just
location paths: the operator grammar (`or`, `and`, `=`, `!=`, `<`, `<=`, `>`,
`>=`, `+`, `-`, `*`, `div`, `mod`, unary `-`, `|`) with correct precedence,
number and string literals, function calls, variable references, and filter
expressions, all over `PureXML.XPath.Value` (node-set, boolean, number,
string) with the spec's coercion rules and canonical number formatting.
Predicates are now arbitrary expressions (a numeric predicate is a position
test). Query gains `value(over:variables:)`, `number`, `string`, and `boolean`
accessors, and a binding map for `$variables`. The core functions `last`,
`position`, `count`, `not`, `true`, `false`, `boolean`, `number`, and `string`
ship with it.
- All thirteen XPath axes (toward #21 full XPath 1.0). `PureXML.XPath.Query` now
navigates `child`, `descendant`, `parent`, `ancestor`, `following-sibling`,
`preceding-sibling`, `following`, `preceding`, `attribute`, `namespace`,

View File

@ -1,263 +1,179 @@
extension PureXML.XPath {
/// Compiles an XPath location path into a list of steps. A small
/// recursive-descent parser over the characters supporting the full axis set:
/// the thirteen named axes (`ancestor::`, `following-sibling::`, …), the
/// abbreviations `.`, `..`, `@`, `//`, the node tests (name, `*`, `text()`,
/// `node()`, `comment()`, `processing-instruction()`), and the predicate
/// subset `[n]`, `[@a]`, `[@a='v']`, `[child]`, `[child='v']`.
/// Compiles an XPath expression into an ``Expression`` tree. A recursive
/// descent parser whose grammatical position disambiguates the tokens XPath
/// overloads: `*` is a wildcard in a node test but multiplication after an
/// operand, and `div`/`mod`/`and`/`or` are operator names only where an
/// operator is expected. The path, primary, and token parsing live in a
/// companion file. Reimplemented from the XPath 1.0 grammar.
struct Compiler {
private let chars: [Character]
private var index = 0
let chars: [Character]
var index = 0
private init(_ path: String) {
chars = Array(path)
}
static func compile(_ path: String) throws -> (absolute: Bool, steps: [Step]) {
static func compile(_ path: String) throws -> Expression {
var compiler = Compiler(path)
return try compiler.parse()
}
private static func descendantOrSelfStep() -> Step {
Step(axis: .descendantOrSelf, test: .node, predicates: [])
}
private mutating func parse() throws -> (absolute: Bool, steps: [Step]) {
skipSpace()
guard !isAtEnd else { throw QueryError.empty }
var absolute = false
var steps: [Step] = []
if consume("/") {
absolute = true
if consume("/") {
steps.append(Self.descendantOrSelfStep())
try steps.append(parseStep())
} else if isAtEnd {
return (true, [])
} else {
try steps.append(parseStep())
}
} else {
try steps.append(parseStep())
compiler.skipSpace()
guard !compiler.isAtEnd else { throw QueryError.empty }
let expression = try compiler.parseExpression()
compiler.skipSpace()
guard compiler.isAtEnd else {
throw QueryError.unexpectedToken(String(compiler.peek() ?? " "))
}
try parseTrailingSteps(into: &steps)
return (absolute, steps)
return expression
}
private mutating func parseTrailingSteps(into steps: inout [Step]) throws {
// MARK: Operator precedence ladder
mutating func parseExpression() throws -> Expression {
try parseOr()
}
private mutating func parseOr() throws -> Expression {
var left = try parseAnd()
while peekKeyword("or") {
advance(by: 2)
left = try .binary(.logicalOr, left, parseAnd())
}
return left
}
private mutating func parseAnd() throws -> Expression {
var left = try parseEquality()
while peekKeyword("and") {
advance(by: 3)
left = try .binary(.logicalAnd, left, parseEquality())
}
return left
}
private mutating func parseEquality() throws -> Expression {
var left = try parseRelational()
while true {
skipSpace()
if isAtEnd { return }
guard consume("/") else {
throw QueryError.unexpectedToken(String(peek() ?? " "))
if consume("!=") {
left = try .binary(.notEqual, left, parseRelational())
} else if peek() == "=" {
advance()
left = try .binary(.equal, left, parseRelational())
} else {
return left
}
if consume("/") {
steps.append(Self.descendantOrSelfStep())
}
try steps.append(parseStep())
}
}
private mutating func parseStep() throws -> Step {
private mutating func parseRelational() throws -> Expression {
var left = try parseAdditive()
while let look = relationalOperator() {
left = try .binary(look, left, parseAdditive())
}
return left
}
private mutating func relationalOperator() -> BinaryOperator? {
skipSpace()
if matches("..") {
if consume("<=") { return .lessOrEqual }
if consume(">=") { return .greaterOrEqual }
if peek() == "<" {
advance()
advance()
return try Step(axis: .parent, test: .node, predicates: parsePredicates())
return .lessThan
}
if peek() == ".", peek(1) != "." {
if peek() == ">" {
advance()
return try Step(axis: .selfAxis, test: .node, predicates: parsePredicates())
return .greaterThan
}
let axis = try parseAxis()
let test = try parseNodeTest()
return try Step(axis: axis, test: test, predicates: parsePredicates())
return nil
}
private mutating func parseAxis() throws -> Axis {
if consume("@") {
return .attribute
}
let save = index
let word = parseName()
if !word.isEmpty, matches("::") {
advance()
advance()
return try Self.namedAxis(word)
}
index = save
return .child
}
private static func namedAxis(_ name: String) throws -> Axis {
if let axis = verticalAxis(name) ?? lateralAxis(name) {
return axis
}
throw QueryError.unsupportedAxis(name)
}
private static func verticalAxis(_ name: String) -> Axis? {
switch name {
case "child": .child
case "descendant": .descendant
case "descendant-or-self": .descendantOrSelf
case "parent": .parent
case "ancestor": .ancestor
case "ancestor-or-self": .ancestorOrSelf
case "self": .selfAxis
default: nil
private mutating func parseAdditive() throws -> Expression {
var left = try parseMultiplicative()
while true {
skipSpace()
if peek() == "+" {
advance()
left = try .binary(.add, left, parseMultiplicative())
} else if peek() == "-" {
advance()
left = try .binary(.subtract, left, parseMultiplicative())
} else {
return left
}
}
}
private static func lateralAxis(_ name: String) -> Axis? {
switch name {
case "following-sibling": .followingSibling
case "preceding-sibling": .precedingSibling
case "following": .following
case "preceding": .preceding
case "attribute": .attribute
case "namespace": .namespace
default: nil
private mutating func parseMultiplicative() throws -> Expression {
var left = try parseUnary()
while let look = multiplicativeOperator() {
left = try .binary(look, left, parseUnary())
}
return left
}
private mutating func parseNodeTest() throws -> NodeTest {
private mutating func multiplicativeOperator() -> BinaryOperator? {
skipSpace()
if peek() == "*" {
advance()
return .wildcard
return .multiply
}
let name = parseName()
guard !name.isEmpty else { throw QueryError.expectedNodeTest }
guard peek() == "(" else {
return .name(name)
if peekKeyword("div") {
advance(by: 3)
return .divide
}
return try parseNodeTypeTest(name)
if peekKeyword("mod") {
advance(by: 3)
return .modulo
}
return nil
}
private mutating func parseNodeTypeTest(_ name: String) throws -> NodeTest {
advance()
private mutating func parseUnary() throws -> Expression {
skipSpace()
if name == "processing-instruction", peek() != ")" {
let target = try parseLiteral()
if peek() == "-" {
advance()
return try .negate(parseUnary())
}
return try parseUnion()
}
private mutating func parseUnion() throws -> Expression {
var left = try parsePath()
while true {
skipSpace()
try expect(")")
return .processingInstruction(target: target)
}
try expect(")")
switch name {
case "text": return .text
case "node": return .node
case "comment": return .comment
case "processing-instruction": return .processingInstruction(target: nil)
default: throw QueryError.unexpectedToken("\(name)()")
}
}
private mutating func parsePredicates() throws -> [Predicate] {
var predicates: [Predicate] = []
while peek() == "[" {
guard peek() == "|" else { return left }
advance()
try predicates.append(parsePredicate())
left = try .union(left, parsePath())
}
return predicates
}
private mutating func parsePredicate() throws -> Predicate {
skipSpace()
let predicate = try parsePredicateBody()
skipSpace()
guard peek() == "]" else { throw QueryError.unterminatedPredicate }
advance()
return predicate
}
// MARK: Cursor
private mutating func parsePredicateBody() throws -> Predicate {
if let digit = peek(), digit.isNumber {
return .position(parseNumber())
}
if peek() == "@" {
advance()
let name = parseName()
return try parseEquality(
makeEquals: { .attributeEquals(name: name, value: $0) },
makeExists: { .hasAttribute(name) },
)
}
let name = parseName()
guard !name.isEmpty else { throw QueryError.unsupportedPredicate("empty") }
return try parseEquality(
makeEquals: { .childEquals(name: name, value: $0) },
makeExists: { .hasChild(name) },
)
}
private mutating func parseEquality(
makeEquals: (String) -> Predicate,
makeExists: () -> Predicate,
) throws -> Predicate {
skipSpace()
guard peek() == "=" else { return makeExists() }
advance()
skipSpace()
return try makeEquals(parseLiteral())
}
private mutating func parseLiteral() throws -> String {
guard let quote = peek(), quote == "\"" || quote == "'" else {
throw QueryError.unsupportedPredicate("expected a quoted value")
}
advance()
var value = ""
while let character = peek(), character != quote {
value.append(character)
advance()
}
guard peek() == quote else { throw QueryError.unsupportedPredicate("unterminated value") }
advance()
return value
}
private mutating func parseName() -> String {
var name = ""
while let character = peek(), character.isXMLNameContinuation {
// A `::` is the axis separator, not part of a QName, so stop
// before it even though a single colon is a valid name character.
if character == ":", peek(1) == ":" { break }
name.append(character)
advance()
}
return name
}
private mutating func parseNumber() -> Int {
var digits = ""
while let character = peek(), character.isNumber {
digits.append(character)
advance()
}
return Int(digits) ?? 0
}
private mutating func expect(_ character: Character) throws {
mutating func expect(_ character: Character) throws {
guard peek() == character else {
throw QueryError.unexpectedToken(String(peek() ?? " "))
}
advance()
}
private var isAtEnd: Bool {
var isAtEnd: Bool {
index >= chars.count
}
private func peek(_ ahead: Int = 0) -> Character? {
func peek(_ ahead: Int = 0) -> Character? {
let target = index + ahead
return target < chars.count ? chars[target] : nil
}
private func matches(_ literal: String) -> Bool {
/// Whether `word` appears next as a whole token (not followed by a name
/// character), used to read operator keywords in operator position.
func peekKeyword(_ word: String) -> Bool {
guard matches(word) else { return false }
if let after = peek(word.count), after.isXMLNameContinuation { return false }
return true
}
func matches(_ literal: String) -> Bool {
let target = Array(literal)
guard index + target.count <= chars.count else { return false }
for (offset, character) in target.enumerated() where chars[index + offset] != character {
@ -267,18 +183,18 @@ extension PureXML.XPath {
}
@discardableResult
private mutating func consume(_ literal: String) -> Bool {
mutating func consume(_ literal: String) -> Bool {
guard matches(literal) else { return false }
index += literal.count
return true
}
private mutating func advance() {
if index < chars.count { index += 1 }
mutating func advance(by count: Int = 1) {
index = Swift.min(index + count, chars.count)
}
private mutating func skipSpace() {
while let character = peek(), character == " " || character == "\t" {
mutating func skipSpace() {
while let character = peek(), character == " " || character == "\t" || character == "\n" || character == "\r" {
advance()
}
}

View File

@ -0,0 +1,287 @@
extension PureXML.XPath.Compiler {
typealias Expression = PureXML.XPath.Expression
typealias Step = PureXML.XPath.Step
typealias Axis = PureXML.XPath.Axis
typealias NodeTest = PureXML.XPath.NodeTest
typealias QueryError = PureXML.XPath.QueryError
// MARK: Paths and primaries
mutating func parsePath() throws -> Expression {
skipSpace()
guard try isPrimaryStart() else {
let (absolute, steps) = try parseLocationPath()
return .path(absolute: absolute, steps: steps)
}
let primary = try parsePrimary()
let predicates = try parsePredicates()
var steps: [Step] = []
skipSpace()
if peek() == "/" {
advance()
if consume("/") {
steps.append(Self.descendantOrSelfStep())
}
try steps.append(parseStep())
try parseTrailingSteps(into: &steps)
}
if predicates.isEmpty, steps.isEmpty {
return primary
}
return .filter(primary: primary, predicates: predicates, steps: steps)
}
private mutating func isPrimaryStart() throws -> Bool {
guard let character = peek() else { return false }
if character == "$" || character == "(" || character == "\"" || character == "'" {
return true
}
if character.isNumber { return true }
if character == ".", peek(1)?.isNumber == true { return true }
return functionCallAhead()
}
private mutating func functionCallAhead() -> Bool {
let save = index
let word = parseName()
skipSpace()
let isCall = peek() == "("
index = save
return isCall && !word.isEmpty && !Self.isNodeType(word)
}
private static func isNodeType(_ name: String) -> Bool {
["node", "text", "comment", "processing-instruction"].contains(name)
}
private mutating func parsePrimary() throws -> Expression {
skipSpace()
if peek() == "$" {
advance()
return .variable(parseName())
}
if consume("(") {
let expression = try parseExpression()
skipSpace()
try expect(")")
return expression
}
if let quote = peek(), quote == "\"" || quote == "'" {
return try .string(parseStringLiteral())
}
if let character = peek(), character.isNumber || character == "." {
return .number(parseNumberLiteral())
}
return try parseFunctionCall()
}
private mutating func parseFunctionCall() throws -> Expression {
let name = parseName()
skipSpace()
try expect("(")
var arguments: [Expression] = []
skipSpace()
if peek() != ")" {
try arguments.append(parseExpression())
while skipSpaceThenComma() {
try arguments.append(parseExpression())
}
}
skipSpace()
try expect(")")
return .function(name: name, arguments: arguments)
}
private mutating func skipSpaceThenComma() -> Bool {
skipSpace()
return consume(",")
}
// MARK: Location paths
static func descendantOrSelfStep() -> Step {
Step(axis: .descendantOrSelf, test: .node, predicates: [])
}
private mutating func parseLocationPath() throws -> (absolute: Bool, steps: [Step]) {
var absolute = false
var steps: [Step] = []
if consume("/") {
absolute = true
if consume("/") {
steps.append(Self.descendantOrSelfStep())
try steps.append(parseStep())
} else if isAtEnd {
return (true, [])
} else {
try steps.append(parseStep())
}
} else {
try steps.append(parseStep())
}
try parseTrailingSteps(into: &steps)
return (absolute, steps)
}
mutating func parseTrailingSteps(into steps: inout [Step]) throws {
while true {
skipSpace()
guard peek() == "/" else { return }
advance()
if consume("/") {
steps.append(Self.descendantOrSelfStep())
}
try steps.append(parseStep())
}
}
private mutating func parseStep() throws -> Step {
skipSpace()
if matches("..") {
advance(by: 2)
return try Step(axis: .parent, test: .node, predicates: parsePredicates())
}
if peek() == ".", peek(1) != "." {
advance()
return try Step(axis: .selfAxis, test: .node, predicates: parsePredicates())
}
let axis = try parseAxis()
let test = try parseNodeTest()
return try Step(axis: axis, test: test, predicates: parsePredicates())
}
private mutating func parseAxis() throws -> Axis {
if consume("@") {
return .attribute
}
let save = index
let word = parseName()
if !word.isEmpty, matches("::") {
advance(by: 2)
return try Self.namedAxis(word)
}
index = save
return .child
}
private static func namedAxis(_ name: String) throws -> Axis {
if let axis = verticalAxis(name) ?? lateralAxis(name) {
return axis
}
throw QueryError.unsupportedAxis(name)
}
private static func verticalAxis(_ name: String) -> Axis? {
switch name {
case "child": .child
case "descendant": .descendant
case "descendant-or-self": .descendantOrSelf
case "parent": .parent
case "ancestor": .ancestor
case "ancestor-or-self": .ancestorOrSelf
case "self": .selfAxis
default: nil
}
}
private static func lateralAxis(_ name: String) -> Axis? {
switch name {
case "following-sibling": .followingSibling
case "preceding-sibling": .precedingSibling
case "following": .following
case "preceding": .preceding
case "attribute": .attribute
case "namespace": .namespace
default: nil
}
}
private mutating func parseNodeTest() throws -> NodeTest {
skipSpace()
if peek() == "*" {
advance()
return .wildcard
}
let name = parseName()
guard !name.isEmpty else { throw QueryError.expectedNodeTest }
guard peek() == "(" else {
return .name(name)
}
return try parseNodeTypeTest(name)
}
private mutating func parseNodeTypeTest(_ name: String) throws -> NodeTest {
advance()
skipSpace()
if name == "processing-instruction", peek() != ")" {
let target = try parseStringLiteral()
skipSpace()
try expect(")")
return .processingInstruction(target: target)
}
try expect(")")
switch name {
case "text": return .text
case "node": return .node
case "comment": return .comment
case "processing-instruction": return .processingInstruction(target: nil)
default: throw QueryError.unexpectedToken("\(name)()")
}
}
private mutating func parsePredicates() throws -> [Expression] {
var predicates: [Expression] = []
while true {
skipSpace()
guard peek() == "[" else { return predicates }
advance()
try predicates.append(parseExpression())
skipSpace()
guard consume("]") else { throw QueryError.unterminatedPredicate }
}
}
// MARK: Tokens
mutating func parseStringLiteral() throws -> String {
guard let quote = peek(), quote == "\"" || quote == "'" else {
throw QueryError.unsupportedPredicate("expected a quoted value")
}
advance()
var value = ""
while let character = peek(), character != quote {
value.append(character)
advance()
}
guard peek() == quote else { throw QueryError.unsupportedPredicate("unterminated value") }
advance()
return value
}
private mutating func parseNumberLiteral() -> Double {
var text = ""
while let character = peek(), character.isNumber {
text.append(character)
advance()
}
if peek() == "." {
text.append(".")
advance()
while let character = peek(), character.isNumber {
text.append(character)
advance()
}
}
return Double(text) ?? .nan
}
mutating func parseName() -> String {
var name = ""
while let character = peek(), character.isXMLNameContinuation {
if character == ":", peek(1) == ":" { break }
name.append(character)
advance()
}
return name
}
}

View File

@ -0,0 +1,30 @@
extension PureXML.XPath {
/// The core XPath functions tied to the four-type model and predicates:
/// `last`, `position`, `count`, `not`, `true`, `false`, `boolean`, `number`,
/// and `string`. The broader library (string, node, and number functions)
/// extends this table.
enum CoreFunctions {
static let table = FunctionTable([
"last": { _, context in .number(Double(context.size)) },
"position": { _, context in .number(Double(context.position)) },
"count": { arguments, _ in
guard let nodes = arguments.first?.nodes else {
throw PureXML.XPath.QueryError.invalidArguments("count() requires a node-set")
}
return .number(Double(nodes.count))
},
"not": { arguments, _ in .boolean(!(arguments.first?.boolean ?? false)) },
"true": { _, _ in .boolean(true) },
"false": { _, _ in .boolean(false) },
"boolean": { arguments, _ in .boolean(arguments.first?.boolean ?? false) },
"number": { arguments, context in
if let first = arguments.first { return .number(first.number) }
return .number(PureXML.XPath.Value.parseNumber(context.node.stringValue))
},
"string": { arguments, context in
if let first = arguments.first { return .string(first.string) }
return .string(context.node.stringValue)
},
])
}
}

View File

@ -0,0 +1,58 @@
extension PureXML.XPath {
/// One XPath function: it receives its already-evaluated argument values and
/// the evaluation context, and returns a value.
typealias FunctionImplementation = @Sendable (_ arguments: [Value], _ context: EvaluationContext) throws -> Value
/// The evaluation context an expression is evaluated against: the context
/// node, its position and size within the current node-set, the variable
/// bindings, and the function library. Predicates and the `position()` and
/// `last()` functions read the position and size.
struct EvaluationContext {
var node: Node
var position: Int
var size: Int
var variables: [String: Value]
var functions: FunctionTable
/// A copy positioned on `node` at one-based `position` within a node-set of
/// `size`, keeping the same variables and functions.
func focused(on node: Node, position: Int, size: Int) -> EvaluationContext {
EvaluationContext(
node: node,
position: position,
size: size,
variables: variables,
functions: functions,
)
}
}
/// The XPath function library: a name-to-implementation table. The core set is
/// built in; further functions extend it.
struct FunctionTable: Sendable {
private var table: [String: FunctionImplementation]
init(_ table: [String: FunctionImplementation] = [:]) {
self.table = table
}
/// Returns a table with `implementation` registered under `name`.
func adding(_ name: String, _ implementation: @escaping FunctionImplementation) -> FunctionTable {
var copy = table
copy[name] = implementation
return FunctionTable(copy)
}
/// Merges another table's entries over this one's.
func merging(_ other: FunctionTable) -> FunctionTable {
FunctionTable(table.merging(other.table) { _, new in new })
}
func call(_ name: String, _ arguments: [Value], _ context: EvaluationContext) throws -> Value {
guard let implementation = table[name] else {
throw QueryError.unknownFunction(name)
}
return try implementation(arguments, context)
}
}
}

View File

@ -1,130 +1,107 @@
extension PureXML.XPath {
/// Evaluates compiled steps over a node, returning the selected node-set in
/// document order. The provided node is the starting context and, for an
/// absolute path, the root.
/// Evaluates a compiled ``Expression`` against a document, producing an XPath
/// ``Value``. Location paths run over the parent-aware tree; the operator
/// grammar, functions, and variables build on the four-type model.
enum Evaluator {
static func evaluate(
steps: [Step],
/// Evaluates an expression and returns its node-set as selections in
/// document order. A non-node-set result yields an empty list; use
/// ``value(_:over:variables:)`` for typed results.
static func evaluate(_ expression: Expression, over node: PureXML.Model.Node) -> [Selection] {
let context = rootContext(node, variables: [:])
guard let value = try? eval(expression, context), case let .nodeSet(nodes) = value else {
return []
}
return orderUnique(nodes).map(selection)
}
/// Evaluates an expression to a typed value with optional variable bindings.
static func value(
_ expression: Expression,
over node: PureXML.Model.Node,
) -> [Selection] {
variables: [String: Value],
) throws -> Value {
try eval(expression, rootContext(node, variables: variables))
}
private static func rootContext(_ node: PureXML.Model.Node, variables: [String: Value]) -> EvaluationContext {
let root = PureXML.Model.TreeNode(node)
var context: [Node] = [.tree(root)]
for step in steps {
context = self.step(step, over: context)
}
return order(context).map(selection)
return EvaluationContext(
node: .tree(root),
position: 1,
size: 1,
variables: variables,
functions: CoreFunctions.table,
)
}
private static func step(_ step: Step, over context: [Node]) -> [Node] {
var result: [Node] = []
static func eval(_ expression: Expression, _ context: EvaluationContext) throws -> Value {
switch expression {
case let .number(value):
return .number(value)
case let .string(value):
return .string(value)
case let .variable(name):
guard let value = context.variables[name] else { throw QueryError.undefinedVariable(name) }
return value
case let .negate(inner):
return try .number(-eval(inner, context).number)
case let .function(name, arguments):
let values = try arguments.map { try eval($0, context) }
return try context.functions.call(name, values, context)
case let .union(left, right):
return try .nodeSet(orderUnique(nodeSet(eval(left, context)) + nodeSet(eval(right, context))))
case let .binary(oper, left, right):
return try evalBinary(oper, left, right, context)
case let .path(absolute, steps):
return try evalPath(absolute: absolute, steps: steps, context)
case let .filter(primary, predicates, steps):
return try evalFilter(primary, predicates, steps, context)
}
}
private static func evalPath(absolute: Bool, steps: [Step], _ context: EvaluationContext) throws -> Value {
let start: Node = absolute ? root(of: context.node) : context.node
return .nodeSet(orderUnique(evaluateSteps(steps, from: [start], context)))
}
private static func evalFilter(
_ primary: Expression,
_ predicates: [Expression],
_ steps: [Step],
_ context: EvaluationContext,
) throws -> Value {
var nodes = try orderUnique(nodeSet(eval(primary, context)))
nodes = try applyPredicates(predicates, to: nodes, context)
if !steps.isEmpty {
nodes = orderUnique(evaluateSteps(steps, from: nodes, context))
}
return .nodeSet(nodes)
}
// MARK: Node-set helpers
static func nodeSet(_ value: Value) throws -> [Node] {
guard case let .nodeSet(nodes) = value else {
throw QueryError.invalidArguments("a node-set was expected")
}
return nodes
}
static func orderUnique(_ nodes: [Node]) -> [Node] {
var seen: Set<Node> = []
for contextNode in context {
let axisNodes = AxisNavigation.nodes(on: step.axis, from: contextNode)
.filter { matches($0, step.test, on: step.axis) }
for node in applyPredicates(step.predicates, to: axisNodes) where seen.insert(node).inserted {
result.append(node)
}
var unique: [Node] = []
for node in nodes where seen.insert(node).inserted {
unique.append(node)
}
return result
return unique.sorted(by: Node.precedes)
}
// MARK: Node tests
private static func matches(_ node: Node, _ test: NodeTest, on axis: Axis) -> Bool {
switch test {
case let .name(name):
nameMatches(node, name, axis.principalKind)
case .wildcard:
wildcardMatches(node, axis.principalKind)
case .text:
isTreeKind(node, in: [.text, .cdata])
case .node:
true
case .comment:
isTreeKind(node, in: [.comment])
case let .processingInstruction(target):
processingInstructionMatches(node, target)
private static func root(of node: Node) -> Node {
var current = node
while let parent = current.parent {
current = parent
}
}
private static func nameMatches(_ node: Node, _ name: String, _ kind: PrincipalKind) -> 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
case .attribute:
guard case let .attribute(_, attribute) = node else { return false }
return attribute.name.description == name || attribute.name.localName == name
case .namespace:
guard case let .namespace(_, prefix, _) = node else { return false }
return prefix == name
}
}
private static func wildcardMatches(_ node: Node, _ kind: PrincipalKind) -> Bool {
switch kind {
case .element: return isTreeKind(node, in: [.element])
case .attribute: if case .attribute = node { return true }
return false
case .namespace: if case .namespace = node { return true }
return false
}
}
private static func processingInstructionMatches(_ node: Node, _ target: String?) -> Bool {
guard case let .tree(tree) = node, tree.kind == .processingInstruction else { return false }
guard let target else { return true }
return tree.name?.description == target
}
private static func isTreeKind(_ node: Node, in kinds: [PureXML.Model.TreeNodeKind]) -> Bool {
guard case let .tree(tree) = node else { return false }
return kinds.contains(tree.kind)
}
// MARK: Predicates
private static func applyPredicates(_ predicates: [Predicate], to nodes: [Node]) -> [Node] {
var result = nodes
for predicate in predicates {
result = apply(predicate, to: result)
}
return result
}
private static func apply(_ predicate: Predicate, to nodes: [Node]) -> [Node] {
switch predicate {
case let .position(index):
(index >= 1 && index <= nodes.count) ? [nodes[index - 1]] : []
case let .hasAttribute(name):
nodes.filter { attributeValue($0, name) != nil }
case let .attributeEquals(name, value):
nodes.filter { attributeValue($0, name) == value }
case let .hasChild(name):
nodes.filter { childElement($0, name) != nil }
case let .childEquals(name, value):
nodes.filter { childElement($0, name)?.stringValue == value }
}
}
private static func attributeValue(_ node: Node, _ name: String) -> String? {
guard case let .tree(tree) = node, tree.kind == .element else { return nil }
return tree.attributes.first { $0.name.description == name || $0.name.localName == name }?.value
}
private static func childElement(_ node: Node, _ name: String) -> PureXML.Model.TreeNode? {
guard case let .tree(tree) = node else { return nil }
return tree.children.first { child in
child.kind == .element && (child.name?.description == name || child.name?.localName == name)
}
}
// MARK: Result ordering and mapping
private static func order(_ nodes: [Node]) -> [Node] {
nodes.sorted(by: Node.precedes)
return current
}
private static func selection(_ node: Node) -> Selection {

View File

@ -0,0 +1,113 @@
extension PureXML.XPath.Evaluator {
typealias BinaryOperator = PureXML.XPath.BinaryOperator
static func evalBinary(
_ oper: BinaryOperator,
_ left: Expression,
_ right: Expression,
_ context: EvaluationContext,
) throws -> Value {
switch oper {
case .logicalOr:
try .boolean(eval(left, context).boolean || eval(right, context).boolean)
case .logicalAnd:
try .boolean(eval(left, context).boolean && eval(right, context).boolean)
case .add, .subtract, .multiply, .divide, .modulo:
try .number(arithmetic(oper, eval(left, context).number, eval(right, context).number))
case .equal, .notEqual:
try .boolean(equality(eval(left, context), eval(right, context), negate: oper == .notEqual))
case .lessThan, .lessOrEqual, .greaterThan, .greaterOrEqual:
try .boolean(relational(eval(left, context), eval(right, context), oper))
}
}
private static func arithmetic(_ oper: BinaryOperator, _ lhs: Double, _ rhs: Double) -> Double {
switch oper {
case .add: lhs + rhs
case .subtract: lhs - rhs
case .multiply: lhs * rhs
case .divide: lhs / rhs
case .modulo: lhs.truncatingRemainder(dividingBy: rhs)
default: .nan
}
}
// MARK: Equality
private static func equality(_ lhs: Value, _ rhs: Value, negate: Bool) -> Bool {
switch (lhs, rhs) {
case let (.nodeSet(left), .nodeSet(right)):
nodeSetEquality(left, right, negate: negate)
case let (.nodeSet(nodes), scalar):
nodeSetScalarEquality(nodes, scalar, negate: negate)
case let (scalar, .nodeSet(nodes)):
nodeSetScalarEquality(nodes, scalar, negate: negate)
default:
scalarEquality(lhs, rhs, negate: negate)
}
}
private static func nodeSetEquality(_ left: [Node], _ right: [Node], negate: Bool) -> Bool {
let rightStrings = right.map(\.stringValue)
for value in left.map(\.stringValue) {
for other in rightStrings where (value == other) != negate {
return true
}
}
return false
}
private static func nodeSetScalarEquality(_ nodes: [Node], _ scalar: Value, negate: Bool) -> Bool {
switch scalar {
case let .boolean(flag):
(!nodes.isEmpty == flag) != negate
case let .number(number):
nodes.contains { (PureXML.XPath.Value.parseNumber($0.stringValue) == number) != negate }
case let .string(text):
nodes.contains { ($0.stringValue == text) != negate }
case .nodeSet:
false
}
}
private static func scalarEquality(_ lhs: Value, _ rhs: Value, negate: Bool) -> Bool {
let equal: Bool = if case .boolean = lhs { lhs.boolean == rhs.boolean } else if case .boolean = rhs {
lhs.boolean == rhs.boolean
} else if case .number = lhs { lhs.number == rhs.number } else if case .number = rhs {
lhs.number == rhs.number
} else {
lhs.string == rhs.string
}
return equal != negate
}
// MARK: Relational
private static func relational(_ lhs: Value, _ rhs: Value, _ oper: BinaryOperator) -> Bool {
let lefts = numbers(of: lhs)
let rights = numbers(of: rhs)
for left in lefts {
for right in rights where numericCompare(left, right, oper) {
return true
}
}
return false
}
private static func numbers(of value: Value) -> [Double] {
if case let .nodeSet(nodes) = value {
return nodes.map { PureXML.XPath.Value.parseNumber($0.stringValue) }
}
return [value.number]
}
private static func numericCompare(_ lhs: Double, _ rhs: Double, _ oper: BinaryOperator) -> Bool {
switch oper {
case .lessThan: lhs < rhs
case .lessOrEqual: lhs <= rhs
case .greaterThan: lhs > rhs
case .greaterOrEqual: lhs >= rhs
default: false
}
}
}

View File

@ -0,0 +1,117 @@
extension PureXML.XPath.Evaluator {
typealias Node = PureXML.XPath.Node
typealias Step = PureXML.XPath.Step
typealias NodeTest = PureXML.XPath.NodeTest
typealias Axis = PureXML.XPath.Axis
typealias Expression = PureXML.XPath.Expression
typealias EvaluationContext = PureXML.XPath.EvaluationContext
typealias Value = PureXML.XPath.Value
typealias PrincipalKind = PureXML.XPath.PrincipalKind
/// Walks the steps from a starting node-set, threading each step's result into
/// the next. Predicates run with proximity position within each context node's
/// axis result.
static func evaluateSteps(_ steps: [Step], from start: [Node], _ context: EvaluationContext) -> [Node] {
var current = start
for step in steps {
var result: [Node] = []
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) }
let filtered = (try? applyPredicates(step.predicates, to: matched, context)) ?? matched
for node in filtered where seen.insert(node).inserted {
result.append(node)
}
}
current = result
}
return current
}
/// Filters nodes through each predicate in turn. A numeric predicate is a
/// position test; any other is taken as a boolean.
static func applyPredicates(
_ predicates: [Expression],
to nodes: [Node],
_ context: EvaluationContext,
) throws -> [Node] {
var current = nodes
for predicate in predicates {
var kept: [Node] = []
let size = current.count
for (offset, node) in current.enumerated() {
let focused = context.focused(on: node, position: offset + 1, size: size)
if try keeps(predicate, focused, position: offset + 1) {
kept.append(node)
}
}
current = kept
}
return current
}
private static func keeps(_ predicate: Expression, _ context: EvaluationContext, position: Int) throws -> Bool {
let value = try eval(predicate, context)
if case let .number(number) = value {
return Int(number) == position
}
return value.boolean
}
// MARK: Node tests
static func matches(_ node: Node, _ test: NodeTest, on axis: Axis) -> Bool {
switch test {
case let .name(name):
nameMatches(node, name, axis.principalKind)
case .wildcard:
wildcardMatches(node, axis.principalKind)
case .text:
isTreeKind(node, in: [.text, .cdata])
case .node:
true
case .comment:
isTreeKind(node, in: [.comment])
case let .processingInstruction(target):
processingInstructionMatches(node, target)
}
}
private static func nameMatches(_ node: Node, _ name: String, _ kind: PrincipalKind) -> 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
case .attribute:
guard case let .attribute(_, attribute) = node else { return false }
return attribute.name.description == name || attribute.name.localName == name
case .namespace:
guard case let .namespace(_, prefix, _) = node else { return false }
return prefix == name
}
}
private static func wildcardMatches(_ node: Node, _ kind: PrincipalKind) -> Bool {
switch kind {
case .element: return isTreeKind(node, in: [.element])
case .attribute: if case .attribute = node { return true }
return false
case .namespace: if case .namespace = node { return true }
return false
}
}
private static func processingInstructionMatches(_ node: Node, _ target: String?) -> Bool {
guard case let .tree(tree) = node, tree.kind == .processingInstruction else { return false }
guard let target else { return true }
return tree.name?.description == target
}
private static func isTreeKind(_ node: Node, in kinds: [PureXML.Model.TreeNodeKind]) -> Bool {
guard case let .tree(tree) = node else { return false }
return kinds.contains(tree.kind)
}
}

View File

@ -0,0 +1,43 @@
extension PureXML.XPath {
/// A binary operator of the XPath expression language, grouped by the
/// precedence level its parser method handles.
enum BinaryOperator: Equatable {
case logicalOr
case logicalAnd
case equal
case notEqual
case lessThan
case lessOrEqual
case greaterThan
case greaterOrEqual
case add
case subtract
case multiply
case divide
case modulo
}
/// A compiled XPath expression. A bare location path is ``path``; the operator
/// grammar, function calls, variables, and filter expressions build on it.
indirect enum Expression: Equatable, Sendable {
/// A location path: absolute when rooted, with its ordered steps.
case path(absolute: Bool, steps: [Step])
/// A binary operation.
case binary(BinaryOperator, Expression, Expression)
/// Unary minus.
case negate(Expression)
/// A node-set union (`|`).
case union(Expression, Expression)
/// A numeric literal.
case number(Double)
/// A string literal.
case string(String)
/// A function call.
case function(name: String, arguments: [Expression])
/// A variable reference (`$name`).
case variable(String)
/// A primary expression with predicates and an optional trailing path
/// (`(expr)[p]/step`, `id('x')/step`).
case filter(primary: Expression, predicates: [Expression], steps: [Step])
}
}

View File

@ -1,17 +0,0 @@
extension PureXML.XPath {
/// A step predicate (`[...]`). A supported, practical subset: a position, an
/// attribute existence or equality test, or a child-element existence or
/// equality test.
enum Predicate: Equatable {
/// A one-based position, as in `book[1]`.
case position(Int)
/// `[@id]`: the context element has the named attribute.
case hasAttribute(String)
/// `[@id='x']`: the named attribute equals the value.
case attributeEquals(name: String, value: String)
/// `[title]`: the context element has a child element with the name.
case hasChild(String)
/// `[title='x']`: a child element with the name has the string value.
case childEquals(name: String, value: String)
}
}

View File

@ -1,28 +1,23 @@
public extension PureXML.XPath {
/// A compiled XPath location-path query: all thirteen axes (`child`,
/// `descendant`, `parent`, `ancestor`, `following-sibling`,
/// `preceding-sibling`, `following`, `preceding`, `attribute`, `namespace`,
/// `self`, `descendant-or-self`, `ancestor-or-self`) with their abbreviations
/// (`.`, `..`, `@`, `//`), the node tests (name, `*`, `text()`, `node()`,
/// `comment()`, `processing-instruction()`), and the predicate subset `[n]`,
/// `[@a]`, `[@a='v']`, `[child]`, `[child='v']`.
///
/// The expression language (operators, the full function library, variables)
/// arrives in later steps. Reimplemented from the XPath 1.0 specification.
/// A compiled XPath 1.0 expression: all thirteen axes with their
/// abbreviations, the node tests, the operator grammar (`or`, `and`, `=`,
/// `!=`, `<`, `<=`, `>`, `>=`, `+`, `-`, `*`, `div`, `mod`, unary `-`, `|`),
/// number and string literals, variable references, function calls, and
/// filter expressions, all over the four-type model (node-set, boolean,
/// number, string). Reimplemented from the XPath 1.0 specification.
struct Query: Sendable {
let absolute: Bool
let steps: [Step]
let expression: Expression
/// Compiles an XPath location path, throwing ``QueryError`` on a path
/// outside the supported grammar.
/// Compiles an XPath expression, throwing ``QueryError`` on a syntax error.
public init(_ path: String) throws {
(absolute, steps) = try Compiler.compile(path)
expression = try Compiler.compile(path)
}
/// Evaluates the query over a node, returning the selected node-set in
/// document order. The node is the starting context.
/// document order. A non-node-set expression yields an empty list; use
/// ``value(over:variables:)`` for typed results.
public func evaluate(over node: PureXML.Model.Node) -> [Selection] {
Evaluator.evaluate(steps: steps, over: node)
Evaluator.evaluate(expression, over: node)
}
/// Evaluates the query and returns the selected elements only.
@ -34,5 +29,28 @@ public extension PureXML.XPath {
public func strings(over node: PureXML.Model.Node) -> [String] {
evaluate(over: node).map(\.stringValue)
}
/// Evaluates the query to a typed ``Value`` with optional variable bindings.
public func value(
over node: PureXML.Model.Node,
variables: [String: Value] = [:],
) throws -> Value {
try Evaluator.value(expression, over: node, 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
}
/// Evaluates the query and coerces the result to a string.
public func string(over node: PureXML.Model.Node, variables: [String: Value] = [:]) throws -> String {
try value(over: node, variables: variables).string
}
/// Evaluates the query and coerces the result to a boolean.
public func boolean(over node: PureXML.Model.Node, variables: [String: Value] = [:]) throws -> Bool {
try value(over: node, variables: variables).boolean
}
}
}

View File

@ -1,5 +1,5 @@
public extension PureXML.XPath {
/// An error compiling or evaluating an XPath query in the supported subset.
/// An error compiling or evaluating an XPath query.
enum QueryError: Swift.Error, Equatable, Sendable, CustomStringConvertible {
case empty
case unexpectedToken(String)
@ -8,6 +8,12 @@ public extension PureXML.XPath {
case unsupportedPredicate(String)
/// An axis name was not one of the thirteen XPath axes.
case unsupportedAxis(String)
/// A function was called that is not in the library.
case unknownFunction(String)
/// A `$name` reference had no binding in the evaluation context.
case undefinedVariable(String)
/// A function received the wrong number or type of arguments.
case invalidArguments(String)
public var description: String {
switch self {
@ -17,6 +23,9 @@ public extension PureXML.XPath {
case .unterminatedPredicate: "unterminated predicate"
case let .unsupportedPredicate(detail): "unsupported predicate: \(detail)"
case let .unsupportedAxis(axis): "unsupported axis: \(axis)"
case let .unknownFunction(name): "unknown function '\(name)()'"
case let .undefinedVariable(name): "variable '$\(name)' is not bound"
case let .invalidArguments(detail): "invalid arguments: \(detail)"
}
}
}

View File

@ -1,9 +1,9 @@
extension PureXML.XPath {
/// One compiled location step: an axis, a node test, and zero or more
/// predicates applied in order.
struct Step: Equatable {
/// predicate expressions applied in order.
struct Step: Equatable, Sendable {
let axis: Axis
let test: NodeTest
let predicates: [Predicate]
let predicates: [Expression]
}
}

87
Sources/XPath/Value.swift Normal file
View File

@ -0,0 +1,87 @@
public extension PureXML.XPath {
/// An XPath 1.0 value: one of the four types the expression language operates
/// on. Every expression evaluates to one of these, and the conversion methods
/// implement the spec's coercion rules used by operators and functions.
/// A runtime value (a node-set holds live tree nodes), not a `Sendable`.
enum Value: Equatable {
case nodeSet([Node])
case boolean(Bool)
case number(Double)
case string(String)
/// The boolean() coercion: a node-set is true when non-empty, a number
/// when non-zero and not NaN, a string when non-empty.
public var boolean: Bool {
switch self {
case let .nodeSet(nodes): !nodes.isEmpty
case let .boolean(value): value
case let .number(value): value != 0 && !value.isNaN
case let .string(value): !value.isEmpty
}
}
/// The number() coercion: booleans map to 1/0, strings parse per the XPath
/// grammar (else NaN), and a node-set converts through its string-value.
public var number: Double {
switch self {
case .nodeSet: Self.parseNumber(string)
case let .boolean(value): value ? 1 : 0
case let .number(value): value
case let .string(value): Self.parseNumber(value)
}
}
/// The string() coercion: booleans render as `true`/`false`, numbers in the
/// XPath canonical form, and a node-set as the string-value of its first
/// node in document order (empty when the set is empty).
public var string: String {
switch self {
case let .nodeSet(nodes):
guard let first = nodes.min(by: Node.precedes) else { return "" }
return first.stringValue
case let .boolean(value): return value ? "true" : "false"
case let .number(value): return Self.format(value)
case let .string(value): return value
}
}
/// The node-set, or nil when the value is not a node-set.
public var nodes: [Node]? {
guard case let .nodeSet(nodes) = self else { return nil }
return nodes
}
/// Formats a number in the XPath canonical form: `NaN`, `Infinity`, an
/// integer without a decimal point, or the shortest round-tripping decimal.
static func format(_ value: Double) -> String {
if value.isNaN { return "NaN" }
if value.isInfinite { return value < 0 ? "-Infinity" : "Infinity" }
if value == value.rounded(), abs(value) < 1e15 {
return String(Int64(value))
}
return String(value)
}
/// Parses a string to a number per the XPath `Number` grammar: optional
/// surrounding whitespace, an optional sign, and digits with an optional
/// single decimal point. Anything else is NaN.
static func parseNumber(_ raw: String) -> Double {
let trimmed = raw.trimmingXMLWhitespace()
guard !trimmed.isEmpty else { return .nan }
var seenDot = false
var digits = 0
for (offset, character) in trimmed.enumerated() {
if character == "-", offset == 0 { continue }
if character == "." {
if seenDot { return .nan }
seenDot = true
continue
}
guard character.isNumber else { return .nan }
digits += 1
}
guard digits > 0 else { return .nan }
return Double(trimmed) ?? .nan
}
}
}

View File

@ -1,4 +1,4 @@
extension PureXML.XPath {
public extension PureXML.XPath {
/// An XPath node. Most nodes wrap a parent-aware ``PureXML/Model/TreeNode``
/// (element, text, CDATA, comment, processing instruction, or the document
/// root). Attributes and namespaces are not tree nodes, so XPath surfaces them
@ -12,7 +12,7 @@ extension PureXML.XPath {
case attribute(owner: PureXML.Model.TreeNode, PureXML.Model.Attribute)
case namespace(owner: PureXML.Model.TreeNode, prefix: String, uri: String)
static func == (lhs: Node, rhs: Node) -> Bool {
public static func == (lhs: Node, rhs: Node) -> Bool {
switch (lhs, rhs) {
case let (.tree(left), .tree(right)):
left === right
@ -25,7 +25,7 @@ extension PureXML.XPath {
}
}
func hash(into hasher: inout Hasher) {
public func hash(into hasher: inout Hasher) {
switch self {
case let .tree(node):
hasher.combine(0)

View File

@ -0,0 +1,126 @@
@testable import PureXML
import Testing
@Suite("XPath expressions")
struct XPathExpressionTests {
private func shop() throws -> PureXML.Model.Node {
try PureXML.parse(
"<shop>"
+ "<book id=\"b1\"><title>A</title><price>10</price></book>"
+ "<book id=\"b2\"><title>B</title><price>30</price></book>"
+ "<book id=\"b3\"><title>C</title><price>50</price></book>"
+ "</shop>",
)
}
private func number(_ path: String) throws -> Double {
try PureXML.XPath.Query(path).number(over: shop())
}
private func string(_ path: String) throws -> String {
try PureXML.XPath.Query(path).string(over: shop())
}
private func boolean(_ path: String) throws -> Bool {
try PureXML.XPath.Query(path).boolean(over: shop())
}
// MARK: Arithmetic and precedence
@Test("Arithmetic honors precedence")
func test_arithmetic() throws {
#expect(try number("2 + 3 * 4") == 14)
#expect(try number("(2 + 3) * 4") == 20)
#expect(try number("7 mod 3") == 1)
#expect(try number("10 div 4") == 2.5)
#expect(try number("-5 + 2") == -3)
}
// MARK: The four types and coercion
@Test("Numbers render in the XPath canonical form")
func test_numberFormatting() throws {
#expect(try string("3") == "3")
#expect(try string("1 div 2") == "0.5")
#expect(try string("1 div 0") == "Infinity")
#expect(try string("0 div 0") == "NaN")
}
@Test("Booleans coerce to strings and numbers")
func test_booleanCoercion() throws {
#expect(try string("1 = 1") == "true")
#expect(try string("1 = 2") == "false")
#expect(try number("true()") == 1)
}
@Test("count and node-set string-value")
func test_nodeSetFunctions() throws {
#expect(try number("count(//book)") == 3)
#expect(try string("//title") == "A")
}
// MARK: Comparison
@Test("Relational comparison over node-sets is existential")
func test_relational() throws {
#expect(try boolean("//price > 40"))
#expect(try boolean("//price < 20"))
#expect(try !boolean("//price > 100"))
}
@Test("Equality against a node-set matches any member")
func test_nodeSetEquality() throws {
#expect(try boolean("//book/@id = 'b2'"))
#expect(try !boolean("//book/@id = 'zz'"))
}
@Test("Boolean operators and not()")
func test_booleanLogic() throws {
#expect(try boolean("1 = 1 and 2 = 2"))
#expect(try !boolean("1 = 1 and 2 = 3"))
#expect(try boolean("1 = 2 or 3 = 3"))
#expect(try boolean("not(1 = 2)"))
}
// MARK: Predicates as expressions
@Test("Numeric and function predicates select positionally")
func test_predicates() throws {
#expect(try PureXML.XPath.Query("//book[position()=2]/@id").strings(over: shop()) == ["b2"])
#expect(try PureXML.XPath.Query("//book[last()]/@id").strings(over: shop()) == ["b3"])
#expect(try PureXML.XPath.Query("//book[price>20]/@id").strings(over: shop()) == ["b2", "b3"])
}
// MARK: Union
@Test("Union merges node-sets in document order without duplicates")
func test_union() throws {
let titles = try PureXML.XPath.Query("//title | //price").evaluate(over: shop())
#expect(titles.count == 6)
}
// MARK: Variables
@Test("Variable references resolve from the binding map")
func test_variables() throws {
let query = try PureXML.XPath.Query("$base + count(//book)")
let value = try query.number(over: shop(), variables: ["base": .number(10)])
#expect(value == 13)
}
@Test("An unbound variable is an error")
func test_unboundVariable() throws {
let query = try PureXML.XPath.Query("$missing")
#expect(throws: PureXML.XPath.QueryError.self) {
_ = try query.value(over: shop())
}
}
@Test("An unknown function is an error")
func test_unknownFunction() throws {
let query = try PureXML.XPath.Query("bogus()")
#expect(throws: PureXML.XPath.QueryError.self) {
_ = try query.value(over: shop())
}
}
}