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:
parent
ac2f8f228d
commit
96eb85f1fc
@ -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,
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
75
Sources/XSLT/XSLTModel.swift
Normal file
75
Sources/XSLT/XSLTModel.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
210
Sources/XSLT/XSLTParser.swift
Normal file
210
Sources/XSLT/XSLTParser.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
73
Tests/XSLTParserTests.swift
Normal file
73
Tests/XSLTParserTests.swift
Normal 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") }
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user