From 4f11bafa2862a382e0d0bf8b12ff7b7091e15a1a Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 01:49:33 +0200 Subject: [PATCH] feat(xslt): implement xsl:apply-imports Re-applies templates to the current node in the current mode, considering only templates of lower import precedence than the one being instantiated, falling back to the built-in rule when none match. Threads the current mode and import precedence through the evaluation context, and moves the transformer runtime types into XSLTRuntime.swift (namespaced, via private aliases) to stay within the file-length and namespacing gates. --- CHANGELOG.md | 4 +++ Sources/XSLT/XSLTModel.swift | 3 ++ Sources/XSLT/XSLTParser.swift | 1 + Sources/XSLT/XSLTRuntime.swift | 44 ++++++++++++++++++++++++++ Sources/XSLT/XSLTTransformer.swift | 45 ++++++++++++-------------- Tests/XSLTApplyImportsTests.swift | 51 ++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 24 deletions(-) create mode 100644 Sources/XSLT/XSLTRuntime.swift create mode 100644 Tests/XSLTApplyImportsTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b5542..d4a261a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XSLT `xsl:apply-imports` (#81). Re-applies templates to the current node in the + current mode, considering only templates of lower import precedence than the + one being instantiated (so an overriding template can extend the imported one), + falling back to the built-in rule when none match. - XSLT `xsl:attribute-set` (#81). Named attribute sets are applied to a literal result element or `xsl:element` via `use-attribute-sets`; sets may include other sets (recursively, with a cycle guard), set attributes are lowest precedence so diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index 0314514..00cfffb 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -64,6 +64,9 @@ public extension PureXML.XSLT { /// `xsl:message`: instantiates its body as a diagnostic. `terminate` ends /// the transformation with that text; it produces no result-tree output. case message(terminate: Bool, body: [Instruction]) + /// `xsl:apply-imports`: re-applies templates to the current node, in the + /// current mode, considering only templates of lower import precedence. + case applyImports } /// The `xsl:output` controls that shape serialization: the output method, and diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index ff99cd5..010076e 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -246,6 +246,7 @@ extension PureXML.XSLT { case "variable", "param": variable(node) case "copy": .copy(body: body(node)) case "message": .message(terminate: XSLTNode.attribute(node, "terminate") == "yes", body: body(node)) + case "apply-imports": .applyImports default: nil } } diff --git a/Sources/XSLT/XSLTRuntime.swift b/Sources/XSLT/XSLTRuntime.swift new file mode 100644 index 0000000..558c7b7 --- /dev/null +++ b/Sources/XSLT/XSLTRuntime.swift @@ -0,0 +1,44 @@ +extension PureXML.XSLT { + /// One produced item: a node for the result tree, or an attribute to attach to + /// the enclosing element. + enum ResultItem { + case node(PureXML.Model.Node) + case attribute(PureXML.Model.Attribute) + } + + /// The evaluation context during instantiation. + struct XSLTContext { + var node: PureXML.Model.TreeNode + var position: Int + var size: Int + var variables: [String: PureXML.XPath.Value] + /// The mode of the template currently being instantiated, so `apply-imports` + /// re-applies in the same mode. + var mode: String? + /// The import precedence of the template currently being instantiated, so + /// `apply-imports` considers only templates below it. `.max` outside a template. + var importPrecedence: Int + + init( + node: PureXML.Model.TreeNode, + position: Int, + size: Int, + variables: [String: PureXML.XPath.Value], + mode: String? = nil, + importPrecedence: Int = .max, + ) { + self.node = node + self.position = position + self.size = size + self.variables = variables + self.mode = mode + self.importPrecedence = importPrecedence + } + } + + /// Shared, mutable signal that an `xsl:message terminate="yes"` fired, carrying + /// its text. A reference type so the value-semantics transformer can set it. + final class Termination { + var message: String? + } +} diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index cb18924..c51c8b8 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -1,24 +1,8 @@ -/// One produced item: a node for the result tree, or an attribute to attach to -/// the enclosing element. File-scope and private. -private enum ResultItem { - case node(PureXML.Model.Node) - case attribute(PureXML.Model.Attribute) -} - -/// The evaluation context during instantiation. File-scope and private. -private struct XSLTContext { - var node: PureXML.Model.TreeNode - var position: Int - var size: Int - var variables: [String: PureXML.XPath.Value] -} - -/// Shared, mutable signal that an `xsl:message terminate="yes"` fired, carrying -/// its text. A reference type so the value-semantics transformer can set it. -/// File-scope and private. -private final class Termination { - var message: String? -} +/// The transformer's runtime types are defined in XSLTRuntime.swift (nested in +/// the namespace); these file-private aliases keep them unqualified here. +private typealias ResultItem = PureXML.XSLT.ResultItem +private typealias XSLTContext = PureXML.XSLT.XSLTContext +private typealias Termination = PureXML.XSLT.Termination extension PureXML.XSLT { /// Runs a compiled stylesheet against a source tree, producing a result tree @@ -58,9 +42,9 @@ extension PureXML.XSLT { // MARK: Template matching - fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?) -> Template? { + fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?, below ceiling: Int = .max) -> Template? { stylesheet.templates.enumerated() - .filter { $0.element.mode == mode && ($0.element.match.map { matches(node, $0) } ?? false) } + .filter { $0.element.mode == mode && $0.element.importPrecedence < ceiling && ($0.element.match.map { matches(node, $0) } ?? false) } .max { lhs, rhs in (lhs.element.importPrecedence, lhs.element.priority, lhs.offset) < (rhs.element.importPrecedence, rhs.element.priority, rhs.offset) @@ -89,7 +73,7 @@ extension PureXML.XSLT { ) -> [ResultItem] { var items: [ResultItem] = [] for (offset, node) in nodes.enumerated() { - let nodeContext = XSLTContext(node: node, position: offset + 1, size: nodes.count, variables: context.variables) + let nodeContext = XSLTContext(node: node, position: offset + 1, size: nodes.count, variables: context.variables, mode: mode) if let template = bestTemplate(for: node, mode: mode) { items += instantiateTemplate(template, nodeContext, passing: parameters, from: context) } else { @@ -108,6 +92,7 @@ extension PureXML.XSLT { from caller: XSLTContext, ) -> [ResultItem] { var context = context + context.importPrecedence = template.importPrecedence for parameter in template.parameters { if let passed = parameters.first(where: { $0.name == parameter.name }) { context.variables[parameter.name] = variableValue(passed.select, passed.body, caller) @@ -254,11 +239,23 @@ extension PureXML.XSLT.Transformer { [.node(.processingInstruction(target: avt(name, context), data: Self.text(of: instantiate(body, context))))] case let .message(terminate, body): message(terminate, body, context) + case .applyImports: + applyImports(context) default: [] } } + /// Re-applies templates to the current node in the current mode, considering + /// only those below the current template's import precedence, falling back to + /// the built-in rule when none match. + private func applyImports(_ context: XSLTContext) -> [ResultItem] { + if let template = bestTemplate(for: context.node, mode: context.mode, below: context.importPrecedence) { + return instantiateTemplate(template, context, passing: [], from: context) + } + return builtInRule(context.node, mode: context.mode, context) + } + private func forEach( _ select: String, _ sorts: [PureXML.XSLT.Sort], diff --git a/Tests/XSLTApplyImportsTests.swift b/Tests/XSLTApplyImportsTests.swift new file mode 100644 index 0000000..3b98021 --- /dev/null +++ b/Tests/XSLTApplyImportsTests.swift @@ -0,0 +1,51 @@ +@testable import PureXML +import Testing + +@Suite("XSLT xsl:apply-imports") +struct XSLTApplyImportsTests { + private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\"" + + private func transform(_ stylesheet: String, _ source: String, loader: @escaping (String) -> String?) throws -> String { + try PureXML.XSLT.transform(stylesheet: stylesheet, source: source, documentLoader: loader) + } + + @Test("apply-imports invokes the lower-precedence imported template") + func test_applyImports() throws { + let base = """ + + [base:] + + """ + let main = """ + + + + + main() + + """ + let output = try transform(main, "x") { $0 == "base.xsl" ? base : nil } + #expect(output == "main([base:x])") + } + + @Test("apply-imports with no lower-precedence match falls back to the built-in rule") + func test_applyImportsBuiltIn() throws { + let base = """ + + never + + """ + let main = """ + + + + + main[] + + """ + // No imported template matches "item", so apply-imports uses the built-in + // rule, which outputs the element's text value. + let output = try transform(main, "y") { $0 == "base.xsl" ? base : nil } + #expect(output == "main[y]") + } +}