feat(xpath): full XPath 1.0 function library
Add the string functions (concat, starts-with, contains, substring-before, substring-after, substring, string-length, normalize-space, translate), the number functions (sum, floor, ceiling, round), and the node functions (local-name, name, namespace-uri, id, lang), merged into the evaluation library. All pure Swift, no Foundation: substring search is hand-rolled. Without a DTD, id() matches an attribute named id or xml:id. Closes #41
This commit is contained in:
parent
6c2d3d6948
commit
d1ae56acbd
@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- The full XPath 1.0 function library (toward #21 full XPath 1.0). Beyond the
|
||||
core functions, the string family (`concat`, `starts-with`, `contains`,
|
||||
`substring-before`, `substring-after`, `substring`, `string-length`,
|
||||
`normalize-space`, `translate`), the number family (`sum`, `floor`, `ceiling`,
|
||||
`round`), and the node family (`local-name`, `name`, `namespace-uri`, `id`,
|
||||
`lang`). All pure Swift with no Foundation. Without a DTD, `id()` matches by an
|
||||
attribute named `id` or `xml:id`.
|
||||
- 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`, `=`, `!=`, `<`, `<=`, `>`,
|
||||
|
||||
@ -3,6 +3,12 @@ extension PureXML.XPath {
|
||||
/// ``Value``. Location paths run over the parent-aware tree; the operator
|
||||
/// grammar, functions, and variables build on the four-type model.
|
||||
enum Evaluator {
|
||||
/// The full XPath 1.0 function library: the core functions plus the
|
||||
/// string, node, and number families.
|
||||
static let library = CoreFunctions.table
|
||||
.merging(StringFunctions.table)
|
||||
.merging(NodeFunctions.table)
|
||||
|
||||
/// 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.
|
||||
@ -30,7 +36,7 @@ extension PureXML.XPath {
|
||||
position: 1,
|
||||
size: 1,
|
||||
variables: variables,
|
||||
functions: CoreFunctions.table,
|
||||
functions: library,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
100
Sources/XPath/NodeFunctions.swift
Normal file
100
Sources/XPath/NodeFunctions.swift
Normal file
@ -0,0 +1,100 @@
|
||||
extension PureXML.XPath {
|
||||
/// The XPath 1.0 node-set, boolean, and number functions beyond the core set:
|
||||
/// `id`, `local-name`, `namespace-uri`, `name`, `lang`, `sum`, `floor`,
|
||||
/// `ceiling`, and `round`.
|
||||
///
|
||||
/// Without a DTD the XPath context cannot know which attributes are of type
|
||||
/// ID, so `id()` matches elements by an attribute named `id` or `xml:id`, the
|
||||
/// near-universal convention.
|
||||
enum NodeFunctions {
|
||||
static let table = FunctionTable([
|
||||
"local-name": { arguments, context in .string(named(arguments, context)?.localName ?? "") },
|
||||
"name": { arguments, context in .string(named(arguments, context)?.description ?? "") },
|
||||
"namespace-uri": { arguments, context in .string(named(arguments, context)?.namespaceURI ?? "") },
|
||||
"id": { arguments, context in .nodeSet(identified(arguments.first, context)) },
|
||||
"lang": { arguments, context in .boolean(matchesLang(context.node, arguments.first?.string ?? "")) },
|
||||
"sum": { arguments, _ in
|
||||
guard let nodes = arguments.first?.nodes else {
|
||||
throw PureXML.XPath.QueryError.invalidArguments("sum() requires a node-set")
|
||||
}
|
||||
return .number(nodes.reduce(0) { $0 + PureXML.XPath.Value.parseNumber($1.stringValue) })
|
||||
},
|
||||
"floor": { arguments, _ in .number((arguments.first?.number ?? .nan).rounded(.down)) },
|
||||
"ceiling": { arguments, _ in .number((arguments.first?.number ?? .nan).rounded(.up)) },
|
||||
"round": { arguments, _ in .number(PureXML.XPath.StringFunctions.rounded(arguments.first?.number ?? .nan)) },
|
||||
])
|
||||
|
||||
private static func named(_ arguments: [Value], _ context: EvaluationContext) -> PureXML.Model.QualifiedName? {
|
||||
target(arguments, context)?.qualifiedName
|
||||
}
|
||||
|
||||
private static func target(_ arguments: [Value], _ context: EvaluationContext) -> Node? {
|
||||
if arguments.isEmpty { return context.node }
|
||||
return arguments.first?.nodes?.min(by: Node.precedes)
|
||||
}
|
||||
|
||||
private static func identified(_ argument: Value?, _ context: EvaluationContext) -> [Node] {
|
||||
let tokens = idTokens(argument)
|
||||
guard !tokens.isEmpty, let root = rootTreeNode(of: context.node) else { return [] }
|
||||
var result: [Node] = []
|
||||
collect(root, tokens: tokens, into: &result)
|
||||
return result
|
||||
}
|
||||
|
||||
private static func idTokens(_ argument: Value?) -> Set<String> {
|
||||
guard let argument else { return [] }
|
||||
let strings: [String] = if case let .nodeSet(nodes) = argument {
|
||||
nodes.map(\.stringValue)
|
||||
} else {
|
||||
[argument.string]
|
||||
}
|
||||
return Set(strings.flatMap { $0.split(whereSeparator: \.isWhitespace).map(String.init) })
|
||||
}
|
||||
|
||||
private static func collect(_ tree: PureXML.Model.TreeNode, tokens: Set<String>, into result: inout [Node]) {
|
||||
if tree.kind == .element, let identifier = idValue(of: tree), tokens.contains(identifier) {
|
||||
result.append(.tree(tree))
|
||||
}
|
||||
for child in tree.children {
|
||||
collect(child, tokens: tokens, into: &result)
|
||||
}
|
||||
}
|
||||
|
||||
private static func idValue(of tree: PureXML.Model.TreeNode) -> String? {
|
||||
tree.attributes.first { attribute in
|
||||
let name = attribute.name
|
||||
return name.description == "id" || name.description == "xml:id"
|
||||
}?.value
|
||||
}
|
||||
|
||||
private static func matchesLang(_ node: Node, _ wanted: String) -> Bool {
|
||||
guard let language = inheritedLang(node) else { return false }
|
||||
let lowered = language.lowercased()
|
||||
let target = wanted.lowercased()
|
||||
return lowered == target || lowered.hasPrefix(target + "-")
|
||||
}
|
||||
|
||||
private static func inheritedLang(_ node: Node) -> String? {
|
||||
var current: Node? = node
|
||||
while let here = current {
|
||||
if case let .tree(tree) = here, let language = langAttribute(of: tree) {
|
||||
return language
|
||||
}
|
||||
current = here.parent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func langAttribute(of tree: PureXML.Model.TreeNode) -> String? {
|
||||
tree.attributes.first { $0.name.description == "xml:lang" }?.value
|
||||
}
|
||||
|
||||
private static func rootTreeNode(of node: Node) -> PureXML.Model.TreeNode? {
|
||||
var current = node
|
||||
while let parent = current.parent {
|
||||
current = parent
|
||||
}
|
||||
return current.treeNode
|
||||
}
|
||||
}
|
||||
}
|
||||
100
Sources/XPath/StringFunctions.swift
Normal file
100
Sources/XPath/StringFunctions.swift
Normal file
@ -0,0 +1,100 @@
|
||||
extension PureXML.XPath {
|
||||
/// The XPath 1.0 string functions: `concat`, `starts-with`, `contains`,
|
||||
/// `substring-before`, `substring-after`, `substring`, `string-length`,
|
||||
/// `normalize-space`, and `translate`.
|
||||
enum StringFunctions {
|
||||
static let table = FunctionTable([
|
||||
"concat": { arguments, _ in .string(arguments.map(\.string).joined()) },
|
||||
"starts-with": { arguments, _ in
|
||||
.boolean(argument(arguments, 0).hasPrefix(argument(arguments, 1)))
|
||||
},
|
||||
"contains": { arguments, _ in
|
||||
.boolean(search(Array(argument(arguments, 1)), in: Array(argument(arguments, 0))) != nil)
|
||||
},
|
||||
"substring-before": { arguments, _ in .string(substringBefore(arguments)) },
|
||||
"substring-after": { arguments, _ in .string(substringAfter(arguments)) },
|
||||
"substring": { arguments, _ in .string(substring(arguments)) },
|
||||
"string-length": { arguments, context in
|
||||
.number(Double(stringOrContext(arguments, context).count))
|
||||
},
|
||||
"normalize-space": { arguments, context in
|
||||
.string(normalizeSpace(stringOrContext(arguments, context)))
|
||||
},
|
||||
"translate": { arguments, _ in .string(translate(arguments)) },
|
||||
])
|
||||
|
||||
private static func argument(_ arguments: [Value], _ index: Int) -> String {
|
||||
index < arguments.count ? arguments[index].string : ""
|
||||
}
|
||||
|
||||
private static func stringOrContext(_ arguments: [Value], _ context: EvaluationContext) -> String {
|
||||
arguments.first?.string ?? context.node.stringValue
|
||||
}
|
||||
|
||||
private static func substringBefore(_ arguments: [Value]) -> String {
|
||||
let haystack = Array(argument(arguments, 0))
|
||||
let needle = Array(argument(arguments, 1))
|
||||
guard !needle.isEmpty, let start = search(needle, in: haystack) else { return "" }
|
||||
return String(haystack[..<start])
|
||||
}
|
||||
|
||||
private static func substringAfter(_ arguments: [Value]) -> String {
|
||||
let haystack = Array(argument(arguments, 0))
|
||||
let needle = Array(argument(arguments, 1))
|
||||
guard !needle.isEmpty, let start = search(needle, in: haystack) else { return "" }
|
||||
return String(haystack[(start + needle.count)...])
|
||||
}
|
||||
|
||||
/// The index of the first occurrence of `needle` in `haystack`, or nil.
|
||||
private static func search(_ needle: [Character], in haystack: [Character]) -> Int? {
|
||||
guard !needle.isEmpty else { return 0 }
|
||||
for start in haystack.indices where haystack[start...].starts(with: needle) {
|
||||
return start
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func substring(_ arguments: [Value]) -> String {
|
||||
let characters = Array(argument(arguments, 0))
|
||||
let from = arguments.count > 1 ? rounded(arguments[1].number) : 1
|
||||
let limit: Double = arguments.count > 2 ? from + rounded(arguments[2].number) : .infinity
|
||||
var result = ""
|
||||
for (offset, character) in characters.enumerated() {
|
||||
let position = Double(offset + 1)
|
||||
if position >= from, position < limit {
|
||||
result.append(character)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func normalizeSpace(_ value: String) -> String {
|
||||
value.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" || $0 == "\r" })
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private static func translate(_ arguments: [Value]) -> String {
|
||||
let source = argument(arguments, 0)
|
||||
let from = Array(argument(arguments, 1))
|
||||
let replacements = Array(argument(arguments, 2))
|
||||
var result = ""
|
||||
for character in source {
|
||||
guard let index = from.firstIndex(of: character) else {
|
||||
result.append(character)
|
||||
continue
|
||||
}
|
||||
if index < replacements.count {
|
||||
result.append(replacements[index])
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// XPath rounding: half rounds toward positive infinity, with NaN and
|
||||
/// infinities passed through.
|
||||
static func rounded(_ value: Double) -> Double {
|
||||
if value.isNaN || value.isInfinite { return value }
|
||||
return (value + 0.5).rounded(.down)
|
||||
}
|
||||
}
|
||||
}
|
||||
96
Tests/XPathFunctionTests.swift
Normal file
96
Tests/XPathFunctionTests.swift
Normal file
@ -0,0 +1,96 @@
|
||||
@testable import PureXML
|
||||
import Testing
|
||||
|
||||
@Suite("XPath functions")
|
||||
struct XPathFunctionTests {
|
||||
private func doc() throws -> PureXML.Model.Node {
|
||||
try PureXML.parse(
|
||||
"<doc xml:lang=\"en\">"
|
||||
+ "<item id=\"a\"> hello world </item>"
|
||||
+ "<item id=\"b\">12</item>"
|
||||
+ "<item id=\"c\">8</item>"
|
||||
+ "<ns:tag xmlns:ns=\"urn:x\">x</ns:tag>"
|
||||
+ "</doc>",
|
||||
)
|
||||
}
|
||||
|
||||
private func string(_ path: String) throws -> String {
|
||||
try PureXML.XPath.Query(path).string(over: doc())
|
||||
}
|
||||
|
||||
private func number(_ path: String) throws -> Double {
|
||||
try PureXML.XPath.Query(path).number(over: doc())
|
||||
}
|
||||
|
||||
private func boolean(_ path: String) throws -> Bool {
|
||||
try PureXML.XPath.Query(path).boolean(over: doc())
|
||||
}
|
||||
|
||||
// MARK: String functions
|
||||
|
||||
@Test("concat, starts-with, contains")
|
||||
func test_stringPredicates() throws {
|
||||
#expect(try string("concat('a', 'b', 'c')") == "abc")
|
||||
#expect(try boolean("starts-with('hello', 'he')"))
|
||||
#expect(try boolean("contains('hello', 'ell')"))
|
||||
#expect(try !boolean("contains('hello', 'z')"))
|
||||
}
|
||||
|
||||
@Test("substring-before and substring-after")
|
||||
func test_substringAround() throws {
|
||||
#expect(try string("substring-before('1999/04', '/')") == "1999")
|
||||
#expect(try string("substring-after('1999/04', '/')") == "04")
|
||||
}
|
||||
|
||||
@Test("substring honors one-based positions and length")
|
||||
func test_substring() throws {
|
||||
#expect(try string("substring('hello', 2)") == "ello")
|
||||
#expect(try string("substring('hello', 2, 3)") == "ell")
|
||||
#expect(try string("substring('hello', 0, 3)") == "he")
|
||||
}
|
||||
|
||||
@Test("string-length and normalize-space")
|
||||
func test_lengthAndNormalize() throws {
|
||||
#expect(try number("string-length('hello')") == 5)
|
||||
#expect(try string("normalize-space(//item[@id='a'])") == "hello world")
|
||||
}
|
||||
|
||||
@Test("translate maps and deletes characters")
|
||||
func test_translate() throws {
|
||||
#expect(try string("translate('bar', 'abc', 'ABC')") == "BAr")
|
||||
#expect(try string("translate('--aaa--', 'abc-', 'ABC')") == "AAA")
|
||||
}
|
||||
|
||||
// MARK: Number functions
|
||||
|
||||
@Test("sum, floor, ceiling, round")
|
||||
func test_numberFunctions() throws {
|
||||
#expect(try number("sum(//item[@id='b' or @id='c'])") == 20)
|
||||
#expect(try number("floor(2.7)") == 2)
|
||||
#expect(try number("ceiling(2.1)") == 3)
|
||||
#expect(try number("round(2.5)") == 3)
|
||||
#expect(try number("round(-2.5)") == -2)
|
||||
}
|
||||
|
||||
// MARK: Node functions
|
||||
|
||||
@Test("local-name, name, namespace-uri")
|
||||
func test_nodeNaming() throws {
|
||||
#expect(try string("local-name(//ns:tag)") == "tag")
|
||||
#expect(try string("name(//ns:tag)") == "ns:tag")
|
||||
#expect(try string("namespace-uri(//ns:tag)") == "urn:x")
|
||||
}
|
||||
|
||||
@Test("id selects elements by their id attribute")
|
||||
func test_id() throws {
|
||||
#expect(try PureXML.XPath.Query("id('b')").strings(over: doc()) == ["12"])
|
||||
#expect(try PureXML.XPath.Query("id('a c')").evaluate(over: doc()).count == 2)
|
||||
}
|
||||
|
||||
@Test("lang tests the inherited xml:lang")
|
||||
func test_lang() throws {
|
||||
// lang() reads the context node, so it is exercised inside a predicate.
|
||||
#expect(try PureXML.XPath.Query("//item[lang('en')]").evaluate(over: doc()).count == 3)
|
||||
#expect(try PureXML.XPath.Query("//item[lang('fr')]").evaluate(over: doc()).isEmpty)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user