feat(xslt): transformation engine completing XSLT 1.0

Add the XSLT execution engine: template matching against the source tree
(highest priority, then last declared, with the built-in rules), the
apply-templates processing model, and instantiation of the sequence
constructor. Implements apply-templates with sort, value-of, for-each, if,
choose, element, attribute, copy, copy-of, call-template, variable, literal
result elements, and attribute value templates, over the XPath engine. The
identity transform round-trips a document including attributes. Collapse the
completed Transformation roadmap block into the shipped summaries.

Closes #15
Closes #16
Closes #17
Closes #18
Closes #3
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 07:06:49 +02:00
parent ec81b69e69
commit 75e511481c
4 changed files with 447 additions and 17 deletions

View File

@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- XSLT 1.0 transformation engine, completing #3. `PureXML.XSLT.transform`
applies a stylesheet to a source document by the XSLT processing model: it
matches each node to the highest-priority template (or the built-in rules) and
instantiates the template's sequence constructor. Implements `apply-templates`
(with template dispatch and `sort`), `value-of`, `for-each`, `if`, `choose`,
`element`, `attribute`, `copy`, `copy-of`, `call-template`, `variable`, literal
result elements, and attribute value templates, all over the existing XPath
engine. The identity transform round-trips a document including its attributes.
- XSLT 1.0 stylesheet model and parser (toward #3). `PureXML.XSLT` gains a
stylesheet model (template rules with match patterns and computed default
priorities, the instruction sequence constructor, attribute value templates)

View File

@ -69,6 +69,8 @@ flowchart TB
Reader["#33 xmlTextReader with validation"]:::done
Save["#34 Serialization option parity"]:::done
Chars["#35 Character and name validity"]:::done
Query["Query + validation shipped: #21 #22 #23 query, #30 regex, #2 XSD/RELAX NG, #25 Schematron"]:::done
Transform["Transform shipped: #3 XSLT, #24 XInclude, #26 C14N, #27 Catalog"]:::done
Core --> Push
Core --> Sax
Core --> Html
@ -81,23 +83,6 @@ flowchart TB
Core --> Chars
```
Transformation and integrity:
```mermaid
flowchart TB
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
Validation["Validation shipped: #21 #22 #23 query, #30 regex, #2 XSD/RELAX NG, #25 Schematron"]:::done
Xslt["#3 XSLT 1.0"]:::epic
XInclude["#24 XInclude, URI, xml:base"]:::done
C14n["#26 Canonical XML"]:::done
Catalog["#27 XML Catalog"]:::done
Xslt --> C14n
XInclude --> Catalog
```
## Status
PureXML is a working, dependency-free XML library today: parse, emit, validate,

View File

@ -0,0 +1,322 @@
/// 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]
}
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
fileprivate func run() -> PureXML.Model.Node {
var variables: [String: PureXML.XPath.Value] = [:]
let baseContext = XSLTContext(node: root, position: 1, size: 1, variables: variables)
for global in stylesheet.globals {
if case let .variable(name, select, body) = global {
variables[name] = variableValue(select, body, baseContext)
}
}
let context = XSLTContext(node: root, position: 1, size: 1, variables: variables)
return .document(applyTemplates(to: [root], context).compactMap(Self.nodeOf))
}
// MARK: Template matching
fileprivate func bestTemplate(for node: PureXML.Model.TreeNode) -> Template? {
stylesheet.templates.enumerated()
.filter { $0.element.match.map { matches(node, $0) } ?? false }
.max { lhs, rhs in (lhs.element.priority, lhs.offset) < (rhs.element.priority, rhs.offset) }?
.element
}
private func matches(_ node: PureXML.Model.TreeNode, _ pattern: String) -> Bool {
for branch in pattern.split(separator: "|") {
let trimmed = branch.trimmingXMLWhitespace()
let path = trimmed.hasPrefix("/") ? trimmed : "//" + trimmed
if let query = try? PureXML.XPath.Query(path), query.nodes(over: root).contains(where: { $0 === node }) {
return true
}
}
return false
}
// MARK: apply-templates and built-in rules
fileprivate func applyTemplates(to nodes: [PureXML.Model.TreeNode], _ context: XSLTContext) -> [ResultItem] {
var items: [ResultItem] = []
for (offset, node) in nodes.enumerated() {
let nodeContext = XSLTContext(node: node, position: offset + 1, size: nodes.count, variables: context.variables)
if let template = bestTemplate(for: node) {
items += instantiate(template.body, nodeContext)
} else {
items += builtInRule(node, nodeContext)
}
}
return items
}
private func builtInRule(_ node: PureXML.Model.TreeNode, _ context: XSLTContext) -> [ResultItem] {
switch node.kind {
case .element, .document:
applyTemplates(to: node.children, context)
case .text, .cdata:
[.node(.text(node.value))]
default:
[]
}
}
// MARK: Instantiation
fileprivate func instantiate(_ body: [Instruction], _ context: XSLTContext) -> [ResultItem] {
var items: [ResultItem] = []
var context = context
for instruction in body {
if case let .variable(name, select, varBody) = instruction {
context.variables[name] = variableValue(select, varBody, context)
} else {
items += evaluate(instruction, context)
}
}
return items
}
private func variableValue(_ select: String?, _ body: [Instruction], _ context: XSLTContext) -> PureXML.XPath.Value {
if let select { return value(select, context) ?? .string("") }
return .string(Self.text(of: instantiate(body, context)))
}
// MARK: XPath helpers
fileprivate func value(_ expression: String, _ context: XSLTContext) -> PureXML.XPath.Value? {
guard let query = try? PureXML.XPath.Query(expression) else { return nil }
return try? query.value(
at: context.node,
position: context.position,
size: context.size,
variables: context.variables,
)
}
fileprivate func selectNodes(_ expression: String, _ context: XSLTContext) -> [PureXML.Model.TreeNode] {
value(expression, context)?.nodes?.compactMap(\.treeNode) ?? []
}
fileprivate func string(_ expression: String, _ context: XSLTContext) -> String {
value(expression, context)?.string ?? ""
}
fileprivate func boolean(_ expression: String, _ context: XSLTContext) -> Bool {
value(expression, context)?.boolean ?? false
}
// MARK: Result helpers
fileprivate static func nodeOf(_ item: ResultItem) -> PureXML.Model.Node? {
if case let .node(node) = item { return node }
return nil
}
fileprivate 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 {
switch node {
case let .text(value), let .cdata(value): value
case let .element(element): element.children.reduce(into: "") { $0 += stringValue($1) }
case let .document(children): children.reduce(into: "") { $0 += stringValue($1) }
default: ""
}
}
}
}
extension PureXML.XSLT.Transformer {
// MARK: Instruction evaluation
fileprivate func evaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] {
simpleEvaluate(instruction, context) ?? structuralEvaluate(instruction, context)
}
private func simpleEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem]? {
switch instruction {
case let .literalText(text): [.node(.text(text))]
case let .valueOf(select): [.node(.text(string(select, context)))]
case let .applyTemplates(select, sorts):
applyTemplates(to: sorted(selectNodes(select ?? "node()", context), sorts), context)
case let .forEach(select, sorts, body): forEach(select, sorts, body, context)
case let .ifInstruction(test, body): boolean(test, context) ? instantiate(body, context) : []
case let .choose(whens, otherwise): chooseInstruction(whens, otherwise, context)
case let .copyOf(select): copyOf(select, context)
case let .callTemplate(name): callTemplate(name, context)
case .variable: []
default: nil
}
}
private func structuralEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] {
switch instruction {
case let .literalElement(name, attributes, body):
[buildElement(name: name, literalAttributes: attributes, body: body, context)]
case let .element(nameTemplate, body):
[buildElement(name: .init(avt(nameTemplate, context)), literalAttributes: [], body: body, context)]
case let .attribute(nameTemplate, body):
[.attribute(.init(avt(nameTemplate, context), Self.text(of: instantiate(body, context))))]
case let .copy(body):
copyInstruction(body, context)
default:
[]
}
}
private func forEach(
_ select: String,
_ sorts: [PureXML.XSLT.Sort],
_ body: [PureXML.XSLT.Instruction],
_ context: XSLTContext,
) -> [ResultItem] {
let nodes = sorted(selectNodes(select, context), sorts)
var items: [ResultItem] = []
for (offset, node) in nodes.enumerated() {
items += instantiate(body, XSLTContext(node: node, position: offset + 1, size: nodes.count, variables: context.variables))
}
return items
}
private func chooseInstruction(
_ whens: [PureXML.XSLT.Branch],
_ otherwise: [PureXML.XSLT.Instruction],
_ context: XSLTContext,
) -> [ResultItem] {
for branch in whens where boolean(branch.test, context) {
return instantiate(branch.body, context)
}
return instantiate(otherwise, context)
}
private func callTemplate(_ name: String, _ context: XSLTContext) -> [ResultItem] {
guard let template = stylesheet.templates.first(where: { $0.name == name }) else { return [] }
return instantiate(template.body, context)
}
private func copyOf(_ select: String, _ context: XSLTContext) -> [ResultItem] {
guard let nodes = value(select, context)?.nodes else {
// A non-node-set result copies as its string value.
return value(select, context).map { [.node(.text($0.string))] } ?? []
}
return nodes.map { xnode in
switch xnode {
case let .tree(tree): .node(tree.node)
case let .attribute(_, attribute): .attribute(attribute)
case let .namespace(_, prefix, uri):
.attribute(.init(prefix.isEmpty ? "xmlns" : "xmlns:\(prefix)", uri))
}
}
}
private func copyInstruction(_ body: [PureXML.XSLT.Instruction], _ context: XSLTContext) -> [ResultItem] {
switch context.node.kind {
case .element:
[buildElement(name: context.node.name ?? .init(""), literalAttributes: [], body: body, context)]
case .text, .cdata:
[.node(.text(context.node.value))]
default:
instantiate(body, context)
}
}
// MARK: Building elements
private func buildElement(
name: PureXML.Model.QualifiedName,
literalAttributes: [PureXML.XSLT.LiteralAttribute],
body: [PureXML.XSLT.Instruction],
_ context: XSLTContext,
) -> ResultItem {
var attributes = literalAttributes.map { PureXML.Model.Attribute(name: $0.name, value: avt($0.value, context)) }
var children: [PureXML.Model.Node] = []
for item in instantiate(body, context) {
switch item {
case let .attribute(attribute): attributes.append(attribute)
case let .node(node): children.append(node)
}
}
return .node(.element(.init(name: name, attributes: attributes, children: children)))
}
private 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)
}
}
}
// MARK: Sorting
private func sorted(_ nodes: [PureXML.Model.TreeNode], _ sorts: [PureXML.XSLT.Sort]) -> [PureXML.Model.TreeNode] {
guard !sorts.isEmpty else { return nodes }
return nodes.enumerated().sorted { lhs, rhs in
for sort in sorts {
let order = compareKeys(lhs.element, rhs.element, sort)
if order != 0 { return order < 0 }
}
return lhs.offset < rhs.offset
}.map(\.element)
}
private func compareKeys(_ lhs: PureXML.Model.TreeNode, _ rhs: PureXML.Model.TreeNode, _ sort: PureXML.XSLT.Sort) -> Int {
let left = keyValue(sort.select, lhs)
let right = keyValue(sort.select, rhs)
var order: Int
if sort.numeric {
let leftNumber = PureXML.XPath.Value.parseNumber(left)
let rightNumber = PureXML.XPath.Value.parseNumber(right)
order = leftNumber == rightNumber ? 0 : (leftNumber < rightNumber ? -1 : 1)
} else {
order = left == right ? 0 : (left < right ? -1 : 1)
}
return sort.descending ? -order : order
}
private func keyValue(_ expression: String, _ node: PureXML.Model.TreeNode) -> String {
guard let query = try? PureXML.XPath.Query(expression) else { return "" }
return (try? query.value(at: node).string) ?? ""
}
}
public extension PureXML.XSLT {
/// Transforms `source` with `stylesheet`, returning the serialized result.
static func transform(
stylesheet: String,
source: String,
options: PureXML.Emitting.Options = .compact,
) throws -> String {
try PureXML.serialize(transformToNode(stylesheet: stylesheet, source: source), options: options)
}
/// Transforms `source` with `stylesheet`, returning the result tree.
static func transformToNode(stylesheet: String, source: String) throws -> PureXML.Model.Node {
let sheet = try XSLTParser.parse(stylesheet)
let root = try PureXML.parseTree(source)
return Transformer(stylesheet: sheet, root: root).run()
}
}

115
Tests/XSLTTests.swift Normal file
View File

@ -0,0 +1,115 @@
@testable import PureXML
import Testing
@Suite("XSLT transform")
struct XSLTTests {
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
private func transform(_ stylesheet: String, _ source: String) throws -> String {
try PureXML.XSLT.transform(stylesheet: stylesheet, source: source)
}
@Test("The identity transform reproduces the input")
func test_identity() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="*">
<xsl:copy><xsl:copy-of select="@*"/><xsl:apply-templates/></xsl:copy>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<a id=\"1\"><b>text</b><c/></a>"
#expect(try transform(stylesheet, source) == source)
}
@Test("value-of outputs the string value of an expression")
func test_valueOf() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<result><xsl:value-of select="doc/name"/></result>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<doc><name>Ada</name></doc>") == "<result>Ada</result>")
}
@Test("apply-templates dispatches to matching templates")
func test_applyTemplates() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/"><ul><xsl:apply-templates select="list/item"/></ul></xsl:template>
<xsl:template match="item"><li><xsl:value-of select="."/></li></xsl:template>
</xsl:stylesheet>
"""
let source = "<list><item>a</item><item>b</item></list>"
#expect(try transform(stylesheet, source) == "<ul><li>a</li><li>b</li></ul>")
}
@Test("for-each iterates a node-set")
func test_forEach() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:for-each select="r/x"><v><xsl:value-of select="@n"/></v></xsl:for-each></out>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<r><x n=\"1\"/><x n=\"2\"/></r>"
#expect(try transform(stylesheet, source) == "<out><v>1</v><v>2</v></out>")
}
@Test("if and choose select conditionally")
func test_conditionals() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<xsl:for-each select="nums/n">
<xsl:choose>
<xsl:when test=". > 0"><pos><xsl:value-of select="."/></pos></xsl:when>
<xsl:otherwise><neg/></xsl:otherwise>
</xsl:choose>
</xsl:for-each>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<nums><n>5</n><n>-3</n></nums>"
#expect(try transform(stylesheet, source) == "<pos>5</pos><neg/>")
}
@Test("xsl:attribute and xsl:element build the result dynamically")
func test_dynamicElement() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<xsl:element name="box"><xsl:attribute name="w"><xsl:value-of select="size/@w"/></xsl:attribute></xsl:element>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<size w=\"10\"/>") == "<box w=\"10\"/>")
}
@Test("xsl:sort orders the selected nodes")
func test_sort() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:apply-templates select="r/n"><xsl:sort select="." data-type="number"/></xsl:apply-templates></out>
</xsl:template>
<xsl:template match="n"><v><xsl:value-of select="."/></v></xsl:template>
</xsl:stylesheet>
"""
let source = "<r><n>3</n><n>1</n><n>2</n></r>"
#expect(try transform(stylesheet, source) == "<out><v>1</v><v>2</v><v>3</v></out>")
}
@Test("Attribute value templates substitute expressions")
func test_attributeValueTemplate() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/"><a href="id-{doc/@k}">link</a></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<doc k=\"7\"/>") == "<a href=\"id-7\">link</a>")
}
}