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]") + } +}