diff --git a/CHANGELOG.md b/CHANGELOG.md index c668d7b..11a6ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`, `=`, `!=`, `<`, `<=`, `>`, diff --git a/Sources/XPath/Evaluator.swift b/Sources/XPath/Evaluator.swift index c9ef8f3..48ca137 100644 --- a/Sources/XPath/Evaluator.swift +++ b/Sources/XPath/Evaluator.swift @@ -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, ) } diff --git a/Sources/XPath/NodeFunctions.swift b/Sources/XPath/NodeFunctions.swift new file mode 100644 index 0000000..5186259 --- /dev/null +++ b/Sources/XPath/NodeFunctions.swift @@ -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 { + 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, 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 + } + } +} diff --git a/Sources/XPath/StringFunctions.swift b/Sources/XPath/StringFunctions.swift new file mode 100644 index 0000000..00cd28f --- /dev/null +++ b/Sources/XPath/StringFunctions.swift @@ -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[.. 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) + } + } +} diff --git a/Tests/XPathFunctionTests.swift b/Tests/XPathFunctionTests.swift new file mode 100644 index 0000000..6a14e2c --- /dev/null +++ b/Tests/XPathFunctionTests.swift @@ -0,0 +1,96 @@ +@testable import PureXML +import Testing + +@Suite("XPath functions") +struct XPathFunctionTests { + private func doc() throws -> PureXML.Model.Node { + try PureXML.parse( + "" + + " hello world " + + "12" + + "8" + + "x" + + "", + ) + } + + 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) + } +}