PureXML/Sources/XSLT/XSLTLookups.swift
Mihaela Mihaljevic 380160cc2b refactor(validation): decompose XSD instance validation into composable rules
The two monolithic XSD content/identity rules only satisfied the OpenAPIKit
validation idiom at the wrong granularity: no per-constraint removability,
no isolable failing/near-miss tests. Split them into 32 named rules, each
finding tagged at its origin and cached per XSDContext so the walk still
runs once per document (the #101 decision against duplicating XSD's
recursive type resolution still holds). Wired the streaming path's ID/IDREF
and identity checks through the same framework instead of calling the
validators imperatively, and added the unwrapAndLookup combinator the rule
already named as required.

Also includes the mandated repo-wide swiftformat/swiftlint pass.
2026-07-12 14:12:17 +02:00

170 lines
7.7 KiB
Swift

extension PureXML.XSLT.Library {
/// A QName-valued function argument (a key or decimal-format name) resolved
/// to expanded form `{uri}local`, matching the expansion applied to the
/// corresponding declaration's name. The prefix resolves against the
/// expression's in-scope namespaces; an unprefixed or unbound name is left
/// as is.
static func expandedName(_ raw: String, _ namespaces: [String: String]) -> String {
guard let colon = raw.firstIndex(of: ":"), let uri = namespaces[String(raw[..<colon])] else { return raw }
return "{\(uri)}\(raw[raw.index(after: colon)...])"
}
/// The base URI against which a relative `document()` reference taken from
/// this node resolves (XSLT 1.0 12.1): the URI the node's document was loaded
/// from. The source document is absent from the map, so its base is the empty
/// string, which leaves resolution to the loader's own base handling.
static func baseURI(of node: PureXML.XPath.Node, _ documents: PureXML.XSLT.DocumentCache) -> String {
let owner: PureXML.Model.TreeNode? = switch node {
case let .tree(tree): tree
case let .attribute(tree, _), let .namespace(tree, _, _): tree
}
guard var root = owner else { return "" }
while let parent = root.parent {
root = parent
}
return documents.baseURIs[ObjectIdentifier(root)] ?? ""
}
/// One `document()` reference resolved against its base URI; an empty base
/// defers to the loader, which applies the source or stylesheet base itself.
static func resolveDocumentReference(_ reference: String, against base: String) -> String {
base.isEmpty ? reference : PureXML.XInclude.URIReference.resolve(reference, against: base)
}
/// The `xsl:key` match pattern as a path selecting every matching node: `//`
/// is distributed over each top-level union branch (`a | b` becomes `//a |
/// //b`, not `//a | b`), since the match is a pattern matched anywhere, not a
/// single relative path.
static func keyMatchPath(_ match: String) -> String {
splitUnion(match).map { branch in
let trimmed = branch.drop { $0 == " " }
return trimmed.hasPrefix("/") ? String(trimmed) : "//" + trimmed
}.joined(separator: " | ")
}
/// Splits a pattern on its top-level `|`, ignoring `|` inside a predicate
/// `[...]` or a function call `(...)`.
private static func splitUnion(_ pattern: String) -> [Substring] {
var parts: [Substring] = []
var depth = 0
var start = pattern.startIndex
var index = pattern.startIndex
while index < pattern.endIndex {
switch pattern[index] {
case "[", "(": depth += 1
case "]", ")": depth -= 1
case "|" where depth == 0:
parts.append(pattern[start ..< index])
start = pattern.index(after: index)
default: break
}
index = pattern.index(after: index)
}
parts.append(pattern[start...])
return parts
}
/// The XPath `id()` definition exactly: only attributes the DTD
/// declares as type ID identify elements; a document without ID
/// declarations yields the empty set.
static func idLookup(
_ arguments: [PureXML.XPath.Value],
_ context: PureXML.XPath.EvaluationContext,
_ documents: PureXML.XSLT.DocumentCache,
) -> PureXML.XPath.Value {
let strings: [String] = if let nodes = arguments.first?.nodes {
nodes.map(\.stringValue)
} else {
[arguments.first?.string ?? ""]
}
let tokens = Set(strings.flatMap { $0.split(whereSeparator: \.isWhitespace).map(String.init) })
let owner: PureXML.Model.TreeNode? = switch context.node {
case let .tree(tree): tree
case let .attribute(treeOwner, _), let .namespace(treeOwner, _, _): treeOwner
}
guard !tokens.isEmpty, var documentRoot = owner else { return .nodeSet([]) }
while let parent = documentRoot.parent {
documentRoot = parent
}
let rootIdentity = ObjectIdentifier(documentRoot)
guard let declared = documents.idAttributes[rootIdentity], !declared.isEmpty else {
return .nodeSet([])
}
// The index is built once per document, not re-walked per call.
let index: [String: PureXML.Model.TreeNode]
if let cached = documents.idIndexes[rootIdentity] {
index = cached
} else {
var built: [String: PureXML.Model.TreeNode] = [:]
buildIDIndex(documentRoot, declared: declared, into: &built)
documents.idIndexes[rootIdentity] = built
index = built
}
// Look each requested token up directly. Scanning the whole index and
// filtering by `tokens.contains` was O(index) per `id()` call, so a
// stylesheet calling `id()` across a wide document was quadratic; the
// tokens already are the keys, so the index lookup is O(tokens). The
// result is the same set of nodes (each ID value maps to one element),
// and `sortedByDocumentOrder()` gives the same order.
let matched = tokens.compactMap { index[$0] }
.map { PureXML.XPath.Node.tree($0) }
.sortedByDocumentOrder()
return .nodeSet(matched)
}
/// Walks the document once, indexing every DTD-identified element by
/// its ID value; the first occurrence in document order wins.
private static func buildIDIndex(
_ node: PureXML.Model.TreeNode,
declared: [String: Set<String>],
into index: inout [String: PureXML.Model.TreeNode],
) {
if node.kind == .element, let name = node.name?.description, let idNames = declared[name] {
for attribute in node.attributes where idNames.contains(attribute.name.description) {
if index[attribute.value] == nil {
index[attribute.value] = node
}
}
}
for child in node.children {
buildIDIndex(child, declared: declared, into: &index)
}
}
/// The `key()` implementation: the index belongs to the current
/// node's document; a node-set second argument unions the matches for
/// each member's string value; results come back in document order.
static func keyLookup(
_ arguments: [PureXML.XPath.Value],
_ context: PureXML.XPath.EvaluationContext,
_ keys: (PureXML.Model.TreeNode) -> PureXML.XSLT.KeyIndex,
) -> PureXML.XPath.Value {
// The key name is matched by expanded QName (XSLT 1.0 12.2), matching
// the expansion applied to the xsl:key declaration name.
let name = expandedName(arguments.first?.string ?? "", context.namespaces)
guard arguments.count > 1 else { return .nodeSet([]) }
let values: [String] = if let nodes = arguments[1].nodes {
nodes.map(\.stringValue)
} else {
[arguments[1].string]
}
let owner: PureXML.Model.TreeNode? = switch context.node {
case let .tree(tree): tree
case let .attribute(treeOwner, _), let .namespace(treeOwner, _, _): treeOwner
}
guard var documentRoot = owner else { return .nodeSet([]) }
while let parent = documentRoot.parent {
documentRoot = parent
}
let index = keys(documentRoot)
var matched: [PureXML.Model.TreeNode] = []
var seen: Set<ObjectIdentifier> = []
for value in values {
for node in index[name]?[value] ?? [] where seen.insert(ObjectIdentifier(node)).inserted {
matched.append(node)
}
}
return .nodeSet(matched.map { PureXML.XPath.Node.tree($0) }.sortedByDocumentOrder())
}
}