From 75e511481c28f1903c3580f4feab057fb59e2770 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 07:06:49 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 8 + README.md | 19 +- Sources/XSLT/XSLTTransformer.swift | 322 +++++++++++++++++++++++++++++ Tests/XSLTTests.swift | 115 +++++++++++ 4 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 Sources/XSLT/XSLTTransformer.swift create mode 100644 Tests/XSLTTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b0a0a2..9c6edc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 91bf6c9..d368c3c 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift new file mode 100644 index 0000000..29c32dc --- /dev/null +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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() + } +} diff --git a/Tests/XSLTTests.swift b/Tests/XSLTTests.swift new file mode 100644 index 0000000..ed67070 --- /dev/null +++ b/Tests/XSLTTests.swift @@ -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 = """ + + + + + + """ + let source = "text" + #expect(try transform(stylesheet, source) == source) + } + + @Test("value-of outputs the string value of an expression") + func test_valueOf() throws { + let stylesheet = """ + + + + + + """ + #expect(try transform(stylesheet, "Ada") == "Ada") + } + + @Test("apply-templates dispatches to matching templates") + func test_applyTemplates() throws { + let stylesheet = """ + +
+
  • +
    + """ + let source = "ab" + #expect(try transform(stylesheet, source) == "") + } + + @Test("for-each iterates a node-set") + func test_forEach() throws { + let stylesheet = """ + + + + + + """ + let source = "" + #expect(try transform(stylesheet, source) == "12") + } + + @Test("if and choose select conditionally") + func test_conditionals() throws { + let stylesheet = """ + + + + + + + + + + + """ + let source = "5-3" + #expect(try transform(stylesheet, source) == "5") + } + + @Test("xsl:attribute and xsl:element build the result dynamically") + func test_dynamicElement() throws { + let stylesheet = """ + + + + + + """ + #expect(try transform(stylesheet, "") == "") + } + + @Test("xsl:sort orders the selected nodes") + func test_sort() throws { + let stylesheet = """ + + + + + + + """ + let source = "312" + #expect(try transform(stylesheet, source) == "123") + } + + @Test("Attribute value templates substitute expressions") + func test_attributeValueTemplate() throws { + let stylesheet = """ + + link + + """ + #expect(try transform(stylesheet, "") == "link") + } +}