From d83e5a88ced564e32a7d5cf70cfca1913663cd25 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 05:33:13 +0200 Subject: [PATCH] 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 --- .swiftformat | 4 + CHANGELOG.md | 12 + Sources/XPath/Compiler.swift | 332 +++++++++---------------- Sources/XPath/CompilerPaths.swift | 287 +++++++++++++++++++++ Sources/XPath/CoreFunctions.swift | 30 +++ Sources/XPath/EvaluationContext.swift | 58 +++++ Sources/XPath/Evaluator.swift | 209 +++++++--------- Sources/XPath/EvaluatorOperators.swift | 113 +++++++++ Sources/XPath/EvaluatorSteps.swift | 117 +++++++++ Sources/XPath/Expression.swift | 43 ++++ Sources/XPath/Predicate.swift | 17 -- Sources/XPath/Query.swift | 52 ++-- Sources/XPath/QueryError.swift | 11 +- Sources/XPath/Step.swift | 6 +- Sources/XPath/Value.swift | 87 +++++++ Sources/XPath/XPathNode.swift | 6 +- Tests/XPathExpressionTests.swift | 126 ++++++++++ 17 files changed, 1145 insertions(+), 365 deletions(-) create mode 100644 Sources/XPath/CompilerPaths.swift create mode 100644 Sources/XPath/CoreFunctions.swift create mode 100644 Sources/XPath/EvaluationContext.swift create mode 100644 Sources/XPath/EvaluatorOperators.swift create mode 100644 Sources/XPath/EvaluatorSteps.swift create mode 100644 Sources/XPath/Expression.swift delete mode 100644 Sources/XPath/Predicate.swift create mode 100644 Sources/XPath/Value.swift create mode 100644 Tests/XPathExpressionTests.swift diff --git a/.swiftformat b/.swiftformat index 90a35cd..544a7c0 100644 --- a/.swiftformat +++ b/.swiftformat @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b106d..c668d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, diff --git a/Sources/XPath/Compiler.swift b/Sources/XPath/Compiler.swift index 107b1ca..7e84b9a 100644 --- a/Sources/XPath/Compiler.swift +++ b/Sources/XPath/Compiler.swift @@ -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() } } diff --git a/Sources/XPath/CompilerPaths.swift b/Sources/XPath/CompilerPaths.swift new file mode 100644 index 0000000..b0010d9 --- /dev/null +++ b/Sources/XPath/CompilerPaths.swift @@ -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 + } +} diff --git a/Sources/XPath/CoreFunctions.swift b/Sources/XPath/CoreFunctions.swift new file mode 100644 index 0000000..ec6a4ba --- /dev/null +++ b/Sources/XPath/CoreFunctions.swift @@ -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) + }, + ]) + } +} diff --git a/Sources/XPath/EvaluationContext.swift b/Sources/XPath/EvaluationContext.swift new file mode 100644 index 0000000..6621445 --- /dev/null +++ b/Sources/XPath/EvaluationContext.swift @@ -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) + } + } +} diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index b8bcf2d..c9ef8f3 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -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 = [] - 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 { diff --git a/Sources/XPath/EvaluatorOperators.swift b/Sources/XPath/EvaluatorOperators.swift new file mode 100644 index 0000000..b115a6e --- /dev/null +++ b/Sources/XPath/EvaluatorOperators.swift @@ -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 + } + } +} diff --git a/Sources/XPath/EvaluatorSteps.swift b/Sources/XPath/EvaluatorSteps.swift new file mode 100644 index 0000000..da7d23e --- /dev/null +++ b/Sources/XPath/EvaluatorSteps.swift @@ -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 = [] + 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) + } +} diff --git a/Sources/XPath/Expression.swift b/Sources/XPath/Expression.swift new file mode 100644 index 0000000..887bba4 --- /dev/null +++ b/Sources/XPath/Expression.swift @@ -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]) + } +} diff --git a/Sources/XPath/Predicate.swift b/Sources/XPath/Predicate.swift deleted file mode 100644 index ac97cc1..0000000 --- a/Sources/XPath/Predicate.swift +++ /dev/null @@ -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) - } -} diff --git a/Sources/XPath/Query.swift b/Sources/XPath/Query.swift index 04e886a..29f6415 100644 --- a/Sources/XPath/Query.swift +++ b/Sources/XPath/Query.swift @@ -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 + } } } diff --git a/Sources/XPath/QueryError.swift b/Sources/XPath/QueryError.swift index 642c614..7c2a8d4 100644 --- a/Sources/XPath/QueryError.swift +++ b/Sources/XPath/QueryError.swift @@ -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)" } } } diff --git a/Sources/XPath/Step.swift b/Sources/XPath/Step.swift index abfe10d..b9e9fdb 100644 --- a/Sources/XPath/Step.swift +++ b/Sources/XPath/Step.swift @@ -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] } } diff --git a/Sources/XPath/Value.swift b/Sources/XPath/Value.swift new file mode 100644 index 0000000..e0dda11 --- /dev/null +++ b/Sources/XPath/Value.swift @@ -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 + } + } +} diff --git a/Sources/XPath/XPathNode.swift b/Sources/XPath/XPathNode.swift index 45cea09..ff0ff41 100644 --- a/Sources/XPath/XPathNode.swift +++ b/Sources/XPath/XPathNode.swift @@ -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) diff --git a/Tests/XPathExpressionTests.swift b/Tests/XPathExpressionTests.swift new file mode 100644 index 0000000..fed79be --- /dev/null +++ b/Tests/XPathExpressionTests.swift @@ -0,0 +1,126 @@ +@testable import PureXML +import Testing + +@Suite("XPath expressions") +struct XPathExpressionTests { + private func shop() throws -> PureXML.Model.Node { + try PureXML.parse( + "" + + "A10" + + "B30" + + "C50" + + "", + ) + } + + 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()) + } + } +}