PureXML/Sources/XSLT/XSLTParser.swift
Mihaela Mihaljevic 23db1289ed feat(xslt): add xsl:include/import, xsl:output, comment, and processing-instruction
Round out the XSLT 1.0 stylesheet and output controls:

- xsl:include folds another stylesheet's templates, keys, globals, and output
  at the same import precedence; xsl:import folds them one precedence lower,
  so the importing stylesheet's rules win conflicts. Both resolve their href
  through the injected document loader, never the filesystem.
- xsl:output drives serialization: method (xml or text), indent,
  omit-xml-declaration, encoding, version, and standalone, layered over the
  caller's emitter options. The text method emits character data only.
- xsl:comment and xsl:processing-instruction produce comment and PI nodes
  from their instantiated bodies.

Templates now carry an import precedence that the matcher ranks ahead of
priority and document order.

Closes #64
2026-06-08 08:08:38 +02:00

304 lines
13 KiB
Swift

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, loader: (String) -> String? = { _ in nil }) throws -> Stylesheet {
let root = try PureXML.parseTree(xsl)
guard let top = stylesheetElement(root) else { throw XSLTError.notAStylesheet }
return compile(top, loader: loader, precedence: 0)
}
private static func stylesheetElement(_ root: Tree) -> Tree? {
guard let top = XSLTNode.elementChildren(root).first, XSLTNode.isXSL(top),
XSLTNode.localName(top) == "stylesheet" || XSLTNode.localName(top) == "transform"
else {
return nil
}
return top
}
/// Compiles a stylesheet element, recursively folding in `xsl:include`
/// (same import precedence) and `xsl:import` (one lower) resolved through
/// `loader`.
private static func compile(_ top: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet {
var templates: [Template] = []
var globals: [Instruction] = []
var keys: [Key] = []
var output = Output()
for child in XSLTNode.elementChildren(top) where XSLTNode.isXSL(child) {
switch XSLTNode.localName(child) {
case "template": templates.append(template(child, precedence: precedence))
case "variable", "param": globals.append(variable(child))
case "key": keys.append(key(child))
case "output": output = output.merged(with: parseOutput(child))
case "include":
guard let sub = load(child, loader: loader, precedence: precedence) else { break }
templates += sub.templates
globals += sub.globals
keys += sub.keys
output = output.merged(with: sub.output)
case "import":
guard let sub = load(child, loader: loader, precedence: precedence - 1) else { break }
templates += sub.templates
globals = sub.globals + globals
keys += sub.keys
output = sub.output.merged(with: output)
default: break
}
}
return Stylesheet(templates: templates, globals: globals, keys: keys, output: output)
}
private static func load(_ node: Tree, loader: (String) -> String?, precedence: Int) -> Stylesheet? {
guard let href = XSLTNode.attribute(node, "href"), let text = loader(href),
let root = try? PureXML.parseTree(text), let top = stylesheetElement(root)
else {
return nil
}
return compile(top, loader: loader, precedence: precedence)
}
private static func parseOutput(_ node: Tree) -> Output {
Output(
method: XSLTNode.attribute(node, "method"),
indent: XSLTNode.attribute(node, "indent").map { $0 == "yes" },
omitXMLDeclaration: XSLTNode.attribute(node, "omit-xml-declaration").map { $0 == "yes" },
encoding: XSLTNode.attribute(node, "encoding"),
version: XSLTNode.attribute(node, "version"),
standalone: XSLTNode.attribute(node, "standalone").map { $0 == "yes" },
)
}
private static func key(_ node: Tree) -> Key {
Key(
name: XSLTNode.attribute(node, "name") ?? "",
match: XSLTNode.attribute(node, "match") ?? "",
use: XSLTNode.attribute(node, "use") ?? ".",
)
}
// MARK: Templates
private static func template(_ node: Tree, precedence: Int) -> Template {
let match = XSLTNode.attribute(node, "match")
let priority = XSLTNode.attribute(node, "priority").flatMap(Double.init)
?? match.map(defaultPriority) ?? 0
let parameters = XSLTNode.children(node, named: "param").map(binding)
let body = node.children
.filter { !(XSLTNode.isXSL($0) && XSLTNode.localName($0) == "param") }
.compactMap(instruction)
return Template(
match: match,
name: XSLTNode.attribute(node, "name"),
mode: XSLTNode.attribute(node, "mode"),
priority: priority,
importPrecedence: precedence,
parameters: parameters,
body: body,
)
}
private static func binding(_ node: Tree) -> Binding {
Binding(
name: XSLTNode.attribute(node, "name") ?? "",
select: XSLTNode.attribute(node, "select"),
body: body(node),
)
}
private static func withParameters(_ node: Tree) -> [Binding] {
XSLTNode.children(node, named: "with-param").map(binding)
}
/// 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"),
mode: XSLTNode.attribute(node, "mode"),
sorts: sorts(node),
parameters: withParameters(node),
)
case "copy-of": .copyOf(select: XSLTNode.attribute(node, "select") ?? "")
case "call-template": .callTemplate(
name: XSLTNode.attribute(node, "name") ?? "",
parameters: withParameters(node),
)
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))
case "number":
.number(
count: XSLTNode.attribute(node, "count"),
from: XSLTNode.attribute(node, "from"),
format: XSLTNode.attribute(node, "format") ?? "1",
)
case "comment":
.comment(body: body(node))
case "processing-instruction":
.processingInstruction(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 = ""
let 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
}
}
}