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:
parent
f1d9015e80
commit
d83e5a88ce
@ -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
|
||||
|
||||
12
CHANGELOG.md
12
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`,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
287
Sources/XPath/CompilerPaths.swift
Normal file
287
Sources/XPath/CompilerPaths.swift
Normal 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
|
||||
}
|
||||
}
|
||||
30
Sources/XPath/CoreFunctions.swift
Normal file
30
Sources/XPath/CoreFunctions.swift
Normal 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)
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
58
Sources/XPath/EvaluationContext.swift
Normal file
58
Sources/XPath/EvaluationContext.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
113
Sources/XPath/EvaluatorOperators.swift
Normal file
113
Sources/XPath/EvaluatorOperators.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Sources/XPath/EvaluatorSteps.swift
Normal file
117
Sources/XPath/EvaluatorSteps.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
43
Sources/XPath/Expression.swift
Normal file
43
Sources/XPath/Expression.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
87
Sources/XPath/Value.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
126
Tests/XPathExpressionTests.swift
Normal file
126
Tests/XPathExpressionTests.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user