Merge: XSLT xsl:apply-imports (#81)

This commit is contained in:
Mihaela Mihaljevic 2026-06-09 01:49:34 +02:00
commit b9e2cd9fa5
6 changed files with 124 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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],

View File

@ -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 = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:template match="item">[base:<xsl:value-of select="."/>]</xsl:template>
</xsl:stylesheet>
"""
let main = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:import href="base.xsl"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:apply-templates select="r/item"/></out></xsl:template>
<xsl:template match="item">main(<xsl:apply-imports/>)</xsl:template>
</xsl:stylesheet>
"""
let output = try transform(main, "<r><item>x</item></r>") { $0 == "base.xsl" ? base : nil }
#expect(output == "<out>main([base:x])</out>")
}
@Test("apply-imports with no lower-precedence match falls back to the built-in rule")
func test_applyImportsBuiltIn() throws {
let base = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:template match="other">never</xsl:template>
</xsl:stylesheet>
"""
let main = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:import href="base.xsl"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><out><xsl:apply-templates select="r/item"/></out></xsl:template>
<xsl:template match="item">main[<xsl:apply-imports/>]</xsl:template>
</xsl:stylesheet>
"""
// 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, "<r><item>y</item></r>") { $0 == "base.xsl" ? base : nil }
#expect(output == "<out>main[y]</out>")
}
}