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
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 06:57:11 +02:00
parent ac2f8f228d
commit 96eb85f1fc
5 changed files with 369 additions and 0 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 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,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,73 @@
@testable import PureXML
import Testing
@Suite("XSLT parser")
struct XSLTParserTests {
private let stylesheet = """
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl:template match="/">
<html><body><xsl:apply-templates/></body></html>
</xsl:template>
<xsl:template match="book/title" priority="2">
<h1><xsl:value-of select="."/></h1>
</xsl:template>
<xsl:template match="*">
<xsl:for-each select="item"><xsl:value-of select="@id"/></xsl:for-each>
</xsl:template>
</xsl: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") }
}
}