From 96eb85f1fcbbf27259a2886524d6df405d13eb4f Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Mon, 8 Jun 2026 06:57:11 +0200 Subject: [PATCH] feat(xslt): stylesheet model and parser Add the PureXML.XSLT namespace with a stylesheet model (template rules with match patterns and computed default priorities, the instruction sequence constructor, attribute value templates) and a parser compiling an xsl:stylesheet document into it. Recognizes the XSLT vocabulary by namespace or xsl prefix; other elements become literal result elements. Covers apply-templates, value-of, for-each, if, choose, element, attribute, copy, copy-of, call-template, variable, text, and sort. Closes #14 --- CHANGELOG.md | 8 ++ Sources/PureXML.swift | 3 + Sources/XSLT/XSLTModel.swift | 75 ++++++++++++ Sources/XSLT/XSLTParser.swift | 210 ++++++++++++++++++++++++++++++++++ Tests/XSLTParserTests.swift | 73 ++++++++++++ 5 files changed, 369 insertions(+) create mode 100644 Sources/XSLT/XSLTModel.swift create mode 100644 Sources/XSLT/XSLTParser.swift create mode 100644 Tests/XSLTParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 0595a59..1b0a0a2 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 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) + and a parser that compiles an `xsl:stylesheet` document into it. Recognizes the + XSLT vocabulary by namespace or `xsl` prefix; other elements are literal result + elements. Supports `template`, `apply-templates`, `value-of`, `for-each`, `if`, + `choose`, `element`, `attribute`, `copy`, `copy-of`, `call-template`, + `variable`, `text`, and `sort`. - Schema conformance fixtures, completing XSD and RELAX NG validation (#2). A cross-cutting suite validates realistic schemas (a patterned, bounded, ID-keyed XSD purchase order and a RELAX NG contact card using interleave, diff --git a/Sources/PureXML.swift b/Sources/PureXML.swift index 9a77205..b762c8c 100644 --- a/Sources/PureXML.swift +++ b/Sources/PureXML.swift @@ -45,4 +45,7 @@ public enum PureXML { /// XML Schema (XSD) and RELAX NG validation types. public enum Schema {} + + /// XSLT 1.0 transformation types. + public enum XSLT {} } diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift new file mode 100644 index 0000000..c01ff29 --- /dev/null +++ b/Sources/XSLT/XSLTModel.swift @@ -0,0 +1,75 @@ +public extension PureXML.XSLT { + /// One part of an attribute value template: literal text, or an embedded XPath + /// expression written `{expr}`. + enum ValuePart: Sendable { + case literal(String) + case expression(String) + } + + /// An attribute value template (a string with `{expr}` substitutions). + typealias ValueTemplate = [ValuePart] + + /// A literal-result-element attribute, whose value is an attribute value + /// template. + struct LiteralAttribute: Sendable { + public var name: PureXML.Model.QualifiedName + public var value: ValueTemplate + } + + /// A sort specification for `apply-templates`/`for-each`. + struct Sort: Sendable { + public var select: String + public var descending: Bool + public var numeric: Bool + } + + /// One instruction of a template body (a sequence constructor item). + indirect enum Instruction: Sendable { + /// Literal character data copied to the result. + case literalText(String) + /// `xsl:value-of`: the string value of an XPath expression. + case valueOf(select: String) + /// `xsl:apply-templates` with an optional node-set selection and sorts. + case applyTemplates(select: String?, sorts: [Sort]) + /// `xsl:for-each` over a node-set. + case forEach(select: String, sorts: [Sort], body: [Instruction]) + /// `xsl:if`. + case ifInstruction(test: String, body: [Instruction]) + /// `xsl:choose` with its `when` branches and optional `otherwise`. + case choose(whens: [Branch], otherwise: [Instruction]) + /// A literal result element, copied to the output with its attributes. + case literalElement(name: PureXML.Model.QualifiedName, attributes: [LiteralAttribute], body: [Instruction]) + /// `xsl:element` whose name is an attribute value template. + case element(name: ValueTemplate, body: [Instruction]) + /// `xsl:attribute` whose name is an attribute value template. + case attribute(name: ValueTemplate, body: [Instruction]) + /// `xsl:copy`: a shallow copy of the context node. + case copy(body: [Instruction]) + /// `xsl:copy-of`: a deep copy of a selected node-set. + case copyOf(select: String) + /// `xsl:call-template` by name. + case callTemplate(name: String) + /// `xsl:variable` bound for the rest of the sequence constructor. + case variable(name: String, select: String?, body: [Instruction]) + } + + /// One `xsl:when` branch of a choose. + struct Branch: Sendable { + public var test: String + public var body: [Instruction] + } + + /// A template rule: a match pattern and/or a name, a priority, and a body. + struct Template: Sendable { + public var match: String? + public var name: String? + public var priority: Double + public var body: [Instruction] + } + + /// A compiled stylesheet: its template rules and global variables. + struct Stylesheet: Sendable { + public var templates: [Template] + public var globals: [Instruction] + } +} diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift new file mode 100644 index 0000000..e7e9e3d --- /dev/null +++ b/Sources/XSLT/XSLTParser.swift @@ -0,0 +1,210 @@ +private typealias Tree = PureXML.Model.TreeNode + +/// Tree helpers for the XSLT parser. File-scope and private. +private enum XSLTNode { + static let namespace = "http://www.w3.org/1999/XSL/Transform" + + static func localName(_ node: Tree) -> String? { + node.name?.localName + } + + static func isXSL(_ node: Tree) -> Bool { + node.kind == .element && (node.name?.namespaceURI == namespace || node.name?.prefix == "xsl") + } + + static func attribute(_ node: Tree, _ name: String) -> String? { + node.attributes.first { $0.name.localName == name }?.value + } + + static func elementChildren(_ node: Tree) -> [Tree] { + node.children.filter { $0.kind == .element } + } + + static func children(_ node: Tree, named name: String) -> [Tree] { + elementChildren(node).filter { localName($0) == name } + } +} + +public extension PureXML.XSLT { + /// Errors compiling a stylesheet. + enum XSLTError: Swift.Error, Equatable, Sendable, CustomStringConvertible { + case notAStylesheet + + public var description: String { + switch self { + case .notAStylesheet: "the document is not an xsl:stylesheet" + } + } + } +} + +extension PureXML.XSLT { + /// Parses an XSLT 1.0 stylesheet document into a ``Stylesheet``: its template + /// rules (with computed default priorities) and global variables. The XSLT + /// vocabulary is recognized by namespace or the `xsl` prefix; other elements + /// are literal result elements. + enum XSLTParser { + static func parse(_ xsl: String) throws -> Stylesheet { + let root = try PureXML.parseTree(xsl) + guard let top = XSLTNode.elementChildren(root).first, XSLTNode.isXSL(top), + XSLTNode.localName(top) == "stylesheet" || XSLTNode.localName(top) == "transform" + else { + throw XSLTError.notAStylesheet + } + var templates: [Template] = [] + var globals: [Instruction] = [] + for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) { + switch XSLTNode.localName(child) { + case "template": templates.append(template(child)) + case "variable", "param": globals.append(variable(child)) + default: break + } + } + return Stylesheet(templates: templates, globals: globals) + } + + // MARK: Templates + + private static func template(_ node: Tree) -> Template { + let match = XSLTNode.attribute(node, "match") + let priority = XSLTNode.attribute(node, "priority").flatMap(Double.init) + ?? match.map(defaultPriority) ?? 0 + return Template( + match: match, + name: XSLTNode.attribute(node, "name"), + priority: priority, + body: body(node), + ) + } + + /// The XSLT default-priority rules for a match pattern. + static func defaultPriority(_ pattern: String) -> Double { + if pattern.contains("/") || pattern.contains("[") { return 0.5 } + if ["*", "@*", "node()", "text()", "comment()"].contains(pattern) { return -0.5 } + if pattern.hasSuffix(":*") { return -0.25 } + return 0 + } + + // MARK: Bodies and instructions + + private static func body(_ node: Tree) -> [Instruction] { + node.children.compactMap(instruction) + } + + private static func instruction(_ node: Tree) -> Instruction? { + switch node.kind { + case .text, .cdata: + let value = node.value + return value.allSatisfy(\.isWhitespace) ? nil : .literalText(value) + case .element: + return XSLTNode.isXSL(node) ? xslInstruction(node) : literalElement(node) + default: + return nil + } + } + + private static func xslInstruction(_ node: Tree) -> Instruction? { + simpleInstruction(node) ?? structuralInstruction(node) + } + + private static func simpleInstruction(_ node: Tree) -> Instruction? { + switch XSLTNode.localName(node) { + case "value-of": .valueOf(select: XSLTNode.attribute(node, "select") ?? "") + case "apply-templates": .applyTemplates(select: XSLTNode.attribute(node, "select"), sorts: sorts(node)) + case "copy-of": .copyOf(select: XSLTNode.attribute(node, "select") ?? "") + case "call-template": .callTemplate(name: XSLTNode.attribute(node, "name") ?? "") + case "text": .literalText(node.stringValue) + case "variable", "param": variable(node) + case "copy": .copy(body: body(node)) + default: nil + } + } + + private static func structuralInstruction(_ node: Tree) -> Instruction? { + switch XSLTNode.localName(node) { + case "for-each": + .forEach(select: XSLTNode.attribute(node, "select") ?? "", sorts: sorts(node), body: body(node)) + case "if": + .ifInstruction(test: XSLTNode.attribute(node, "test") ?? "", body: body(node)) + case "choose": + choose(node) + case "element": + .element(name: valueTemplate(XSLTNode.attribute(node, "name") ?? ""), body: body(node)) + case "attribute": + .attribute(name: valueTemplate(XSLTNode.attribute(node, "name") ?? ""), body: body(node)) + default: + nil + } + } + + private static func variable(_ node: Tree) -> Instruction { + .variable(name: XSLTNode.attribute(node, "name") ?? "", select: XSLTNode.attribute(node, "select"), body: body(node)) + } + + private static func choose(_ node: Tree) -> Instruction { + let whens = XSLTNode.children(node, named: "when").map { branch in + Branch(test: XSLTNode.attribute(branch, "test") ?? "", body: body(branch)) + } + let otherwise = XSLTNode.children(node, named: "otherwise").first.map(body) ?? [] + return .choose(whens: whens, otherwise: otherwise) + } + + private static func literalElement(_ node: Tree) -> Instruction { + guard let name = node.name else { return .literalText("") } + let attributes = node.attributes + .filter { $0.name.prefix != "xmlns" && !($0.name.prefix == nil && $0.name.localName == "xmlns") } + .map { LiteralAttribute(name: $0.name, value: valueTemplate($0.value)) } + return .literalElement(name: name, attributes: attributes, body: body(node)) + } + + private static func sorts(_ node: Tree) -> [Sort] { + XSLTNode.children(node, named: "sort").map { sort in + Sort( + select: XSLTNode.attribute(sort, "select") ?? ".", + descending: XSLTNode.attribute(sort, "order") == "descending", + numeric: XSLTNode.attribute(sort, "data-type") == "number", + ) + } + } + + // MARK: Attribute value templates + + static func valueTemplate(_ string: String) -> ValueTemplate { + var parts: [ValuePart] = [] + var literal = "" + var characters = Array(string) + var index = 0 + while index < characters.count { + let character = characters[index] + if character == "{", index + 1 < characters.count, characters[index + 1] == "{" { + literal.append("{") + index += 2 + continue + } + if character == "}", index + 1 < characters.count, characters[index + 1] == "}" { + literal.append("}") + index += 2 + continue + } + if character == "{" { + if !literal.isEmpty { parts.append(.literal(literal)) + literal = "" + } + index += 1 + var expression = "" + while index < characters.count, characters[index] != "}" { + expression.append(characters[index]) + index += 1 + } + index += 1 + parts.append(.expression(expression)) + continue + } + literal.append(character) + index += 1 + } + if !literal.isEmpty { parts.append(.literal(literal)) } + return parts + } + } +} diff --git a/Tests/XSLTParserTests.swift b/Tests/XSLTParserTests.swift new file mode 100644 index 0000000..43783bd --- /dev/null +++ b/Tests/XSLTParserTests.swift @@ -0,0 +1,73 @@ +@testable import PureXML +import Testing + +@Suite("XSLT parser") +struct XSLTParserTests { + private let stylesheet = """ + + + + + +

+
+ + + +
+ """ + + @Test("Templates are extracted with their match and priority") + func test_templates() throws { + let sheet = try PureXML.XSLT.XSLTParser.parse(stylesheet) + #expect(sheet.templates.count == 3) + #expect(sheet.templates.map(\.match) == ["/", "book/title", "*"]) + #expect(sheet.templates[1].priority == 2) + } + + @Test("Default priorities follow the XSLT rules") + func test_defaultPriority() { + let priority = PureXML.XSLT.XSLTParser.defaultPriority + #expect(priority("book") == 0) + #expect(priority("book/title") == 0.5) + #expect(priority("*") == -0.5) + #expect(priority("ns:*") == -0.25) + } + + @Test("A literal result element carries its children and apply-templates") + func test_literalElement() throws { + let sheet = try PureXML.XSLT.XSLTParser.parse(stylesheet) + guard case let .literalElement(name, _, body) = sheet.templates[0].body.first else { + Issue.record("expected a literal element") + return + } + #expect(name.localName == "html") + #expect(!body.isEmpty) + } + + @Test("for-each and value-of are parsed") + func test_forEach() throws { + let sheet = try PureXML.XSLT.XSLTParser.parse(stylesheet) + guard case let .forEach(select, _, body) = sheet.templates[2].body.first else { + Issue.record("expected for-each") + return + } + #expect(select == "item") + guard case .valueOf(select: "@id") = body.first else { + Issue.record("expected value-of") + return + } + } + + @Test("Attribute value templates split literals and expressions") + func test_valueTemplate() { + let parts = PureXML.XSLT.XSLTParser.valueTemplate("id-{@n}-{{x}}") + guard parts.count == 3 else { + Issue.record("expected three parts, got \(parts.count)") + return + } + if case let .literal(text) = parts[0] { #expect(text == "id-") } else { Issue.record("part 0") } + if case let .expression(expr) = parts[1] { #expect(expr == "@n") } else { Issue.record("part 1") } + if case let .literal(text) = parts[2] { #expect(text == "-{x}") } else { Issue.record("part 2") } + } +}