PureXML/Sources/XPath/NodeFunctions.swift
Mihaela Mihaljevic d1ae56acbd 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
2026-06-08 05:38:39 +02:00

101 lines
4.6 KiB
Swift

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
}
}
}