PureXML/Sources/XSLT/XSLTTransformer.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

277 lines
13 KiB
Swift

/// The transformer's runtime types are defined in XSLTRuntime.swift (nested in
/// the namespace); these file-private aliases keep them unqualified here.
typealias ResultItem = PureXML.XSLT.ResultItem
typealias XSLTContext = PureXML.XSLT.XSLTContext
private typealias Termination = PureXML.XSLT.Termination
private typealias MatchCache = PureXML.XSLT.MatchCache
extension PureXML.XSLT {
/// Runs a compiled stylesheet against a source tree, producing a result tree
/// by the XSLT 1.0 processing model: apply templates from the root, match each
/// node to the highest-priority template (or the built-in rules), and
/// instantiate the matched template's sequence constructor.
struct Transformer {
let stylesheet: Stylesheet
let root: PureXML.Model.TreeNode
let documentLoader: (String) -> String?
private let keyIndexes = PureXML.XSLT.KeyIndexCache()
let termination = Termination()
let recursionGuard = PureXML.XSLT.RecursionGuard()
/// The deepest template-instantiation nesting allowed before the transform
/// fails gracefully rather than overflowing the stack. The default clears
/// the parser's max source depth (so an identity transform of the deepest
/// permitted source still runs) while staying within an 8 MB stack; raise
/// it (with a correspondingly larger stack) for deeper legitimate recursion.
let maxTemplateDepth: Int
private let matchCache = MatchCache()
private let documentCache = PureXML.XSLT.DocumentCache()
let numberingCache = XSLTNumbering.SiblingPositionCache()
/// The `xsl:message terminate="yes"` text, if one fired during `run()`.
var terminationMessage: String? {
termination.message
}
/// Whether template recursion hit `maxTemplateDepth` during `run()`, so the
/// transform was stopped before it could overflow the stack.
var recursionLimitExceeded: Bool {
recursionGuard.exceeded
}
/// Caller-supplied top-level parameter values, overriding xsl:param defaults.
let parameters: [String: String]
/// The stylesheet's own parsed document, returned by `document('')` (12.1).
let stylesheetDocument: PureXML.Model.Node?
/// The source DTD's unparsed entities by name to system URI, for `unparsed-entity-uri` (12.4).
let unparsedEntityURIs: [String: String]
init(
stylesheet: Stylesheet,
root: PureXML.Model.TreeNode,
documentLoader: @escaping (String) -> String? = { _ in nil },
idAttributes: [String: Set<String>] = [:],
parameters: [String: String] = [:],
stylesheetDocument: PureXML.Model.Node? = nil,
unparsedEntityURIs: [String: String] = [:],
baseURI: String = "",
maxTemplateDepth: Int = PureXML.XSLT.defaultMaxTemplateDepth,
) {
self.parameters = parameters
self.maxTemplateDepth = maxTemplateDepth
self.stylesheet = stylesheet
self.root = root
self.documentLoader = documentLoader
self.stylesheetDocument = stylesheetDocument
self.unparsedEntityURIs = unparsedEntityURIs
if !idAttributes.isEmpty {
documentCache.idAttributes[ObjectIdentifier(root)] = idAttributes
}
documentCache.registerSelfDocument(stylesheetDocument, at: baseURI)
// One table for all pattern matching: matches() runs per (template,
// node), so a fresh table per call would allocate millions of times.
let caches = keyIndexes
patternTable = PureXML.XSLT.Library.table(
current: .tree(root),
keys: { documentRoot in
let identity = ObjectIdentifier(documentRoot)
if let cached = caches.indexes[identity] {
return cached
}
let built = PureXML.XSLT.Library.buildKeyIndex(stylesheet: stylesheet, root: documentRoot)
caches.indexes[identity] = built
return built
},
loader: documentLoader,
decimalFormats: stylesheet.decimalFormats,
documents: documentCache,
selfDocument: stylesheetDocument,
unparsedEntities: unparsedEntityURIs,
)
}
/// The key index for `documentRoot`, built on first use (keys apply
/// per document: the source and each document() load have their own).
private func keyIndex(for documentRoot: PureXML.Model.TreeNode) -> PureXML.XSLT.KeyIndex {
let identity = ObjectIdentifier(documentRoot)
if let cached = keyIndexes.indexes[identity] {
return cached
}
let built = PureXML.XSLT.Library.buildKeyIndex(stylesheet: stylesheet, root: documentRoot)
keyIndexes.indexes[identity] = built
return built
}
func run() -> PureXML.Model.Node {
let globals = evaluatedGlobals()
matchCache.globalVariables = globals // pattern predicates see globals (5.2)
let context = XSLTContext(node: root, position: 1, size: 1, variables: globals)
return .document(applyTemplates(to: [.tree(root)], mode: nil, parameters: [], context).compactMap(Self.nodeOf))
}
func bestTemplate(for node: PureXML.XPath.Node, mode: String?, below ceiling: Int = .max, atLeast floor: Int = .min) -> Template? {
stylesheet.templates.enumerated()
.filter { entry in
entry.element.mode == mode && entry.element.importPrecedence < ceiling
&& entry.element.importPrecedence >= floor
&& (entry.element.match.map { matches(node, $0, entry.element.namespaces) } ?? false)
}
.max { lhs, rhs in
(lhs.element.importPrecedence, lhs.element.priority, lhs.offset)
< (rhs.element.importPrecedence, rhs.element.priority, rhs.offset)
}?
.element
}
func matches(_ node: PureXML.Model.TreeNode, _ pattern: String, _ namespaces: [String: String] = [:]) -> Bool {
let documentRoot = Self.documentRoot(of: node)
return matchCache.nodes(matching: pattern, over: documentRoot, functions: patternFunctions(), namespaces: namespaces).contains(ObjectIdentifier(node))
}
/// Pattern membership for any XPath node kind.
func matches(_ node: PureXML.XPath.Node, _ pattern: String, _ namespaces: [String: String] = [:]) -> Bool {
switch node {
case let .tree(tree):
matches(tree, pattern, namespaces)
case let .attribute(owner, attribute):
matchCache.attributes(matching: pattern, over: Self.documentRoot(of: owner), functions: patternFunctions(), namespaces: namespaces)
.contains(PureXML.XSLT.AttributeIdentity(owner: ObjectIdentifier(owner), name: attribute.name.description))
case .namespace:
false
}
}
/// The function table match patterns evaluate with (`key()`/`id()`
/// patterns), rooted at the document and built once in init.
private let patternTable: PureXML.XPath.FunctionTable
private func patternFunctions() -> PureXML.XPath.FunctionTable {
patternTable
}
func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value {
if let select {
return value(select, context) ?? .string("")
}
// XSLT 1.0 11.2: a variable with no select and empty content is the
// empty string, not an RTF (so boolean() is false); only content is.
if body.isEmpty {
return .string("")
}
// A body variable is a result-tree fragment: a queryable document node.
let children = instantiate(body, context).compactMap(Self.nodeOf).map(PureXML.Model.TreeNode.init)
return .nodeSet([.tree(PureXML.Model.TreeNode.document(children: children))])
}
func value(_ expression: String, _ context: XSLTContext) -> PureXML.XPath.Value? {
guard let query = try? PureXML.XPath.Query(expression) else { return nil }
return try? query.value(
atNode: context.focus,
position: context.position,
size: context.size,
variables: context.variables,
functions: PureXML.XSLT.Library.table(
current: context.focus,
keys: { keyIndex(for: $0) },
loader: documentLoader,
decimalFormats: stylesheet.decimalFormats,
documents: documentCache,
selfDocument: stylesheetDocument,
unparsedEntities: unparsedEntityURIs,
baseURI: context.baseURI,
),
namespaces: context.namespaces,
)
}
/// Selects the nodes an expression yields, keeping attribute and namespace
/// nodes, which templates apply to as well.
func selectXPathNodes(_ expression: String, _ context: XSLTContext) -> [PureXML.XPath.Node] {
value(expression, context)?.nodes ?? []
}
/// The element a context is built around for `xnode`: the node itself
/// when it is a tree node, otherwise its owner element.
static func ownerNode(_ xnode: PureXML.XPath.Node) -> PureXML.Model.TreeNode? {
switch xnode {
case let .tree(node): node
case let .attribute(owner, _), let .namespace(owner, _, _): owner
}
}
func string(_ expression: String, _ context: XSLTContext) -> String {
// Raw-output markers do not survive string extraction (16.4: escaping is disabled only for text written
// directly to the result tree), so a fragment round-trip re-enables escaping. Gated so source text in a
// stylesheet without disable-output-escaping passes through untouched.
let extracted = value(expression, context)?.string ?? ""
return stylesheet.usesRawText ? PureXML.XSLT.RawText.stripped(extracted) : extracted
}
func boolean(_ expression: String, _ context: XSLTContext) -> Bool {
value(expression, context)?.boolean ?? false
}
fileprivate static func nodeOf(_ item: ResultItem) -> PureXML.Model.Node? {
if case let .node(node) = item {
return node
}
return nil
}
static func text(of items: [ResultItem]) -> String {
items.reduce(into: "") { result, item in
if case let .node(node) = item {
result += Self.stringValue(node)
}
}
}
private static func stringValue(_ node: PureXML.Model.Node) -> String {
// Iterative pre-order walk so a deeply-nested result tree does not
// overflow the stack; only text and CDATA contribute.
var result = ""
var stack: [PureXML.Model.Node] = [node]
while let current = stack.popLast() {
switch current {
case let .text(value), let .cdata(value):
result += value
case let .element(element):
stack.append(contentsOf: element.children.reversed())
case let .document(children):
stack.append(contentsOf: children.reversed())
default:
break
}
}
return result
}
}
}
extension PureXML.XSLT.Transformer {
/// The attributes contributed by `names` and the attribute sets they include,
/// lower precedence first, with a `visiting` guard against recursive includes.
func attributeSetAttributes(_ names: [String], _ context: XSLTContext, visiting: Set<String>) -> [PureXML.Model.Attribute] {
var result: [PureXML.Model.Attribute] = []
for name in names where !visiting.contains(name) {
for definition in stylesheet.attributeSets[name] ?? [] {
result += attributeSetAttributes(definition.use, context, visiting: visiting.union([name]))
for item in instantiate(definition.attributes, context) {
if case let .attribute(attribute) = item {
result.append(attribute)
}
}
}
}
return result
}
func avt(_ template: PureXML.XSLT.ValueTemplate, _ context: XSLTContext) -> String {
template.reduce(into: "") { result, part in
switch part {
case let .literal(text): result += text
case let .expression(expression): result += string(expression, context)
}
}
}
}