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
This commit is contained in:
Mihaela Mihaljevic 2026-06-08 08:08:38 +02:00
parent 8350b13896
commit 23db1289ed
6 changed files with 292 additions and 70 deletions

View File

@ -183,3 +183,18 @@ extension PureXML.XSLT {
}
}
}
extension PureXML.Emitting.Options {
/// These options with an `xsl:output`'s settings layered over them. Only an
/// explicit setting overrides; unspecified `xsl:output` attributes leave the
/// caller's corresponding option untouched.
func applying(_ output: PureXML.XSLT.Output) -> Self {
var copy = self
if let indent = output.indent { copy.prettyPrint = indent }
if let omit = output.omitXMLDeclaration { copy.includeXMLDeclaration = !omit }
if let encoding = output.encoding { copy.encodingName = encoding }
if let version = output.version { copy.xmlVersion = version }
if let standalone = output.standalone { copy.standalone = standalone }
return copy
}
}

View File

@ -54,6 +54,51 @@ public extension PureXML.XSLT {
case variable(name: String, select: String?, body: [Instruction])
/// `xsl:number`: a generated sequence number for the context node.
case number(count: String?, from: String?, format: String)
/// `xsl:comment`: a comment node whose text is the instantiated body.
case comment(body: [Instruction])
/// `xsl:processing-instruction` whose target is `name` and whose data is
/// the instantiated body.
case processingInstruction(name: ValueTemplate, body: [Instruction])
}
/// The `xsl:output` controls that shape serialization: the output method, and
/// the declaration and indentation settings. Each is nil when unspecified, so
/// the serializer falls back to the caller's options.
struct Output: Sendable {
public var method: String?
public var indent: Bool?
public var omitXMLDeclaration: Bool?
public var encoding: String?
public var version: String?
public var standalone: Bool?
public init(
method: String? = nil,
indent: Bool? = nil,
omitXMLDeclaration: Bool? = nil,
encoding: String? = nil,
version: String? = nil,
standalone: Bool? = nil,
) {
self.method = method
self.indent = indent
self.omitXMLDeclaration = omitXMLDeclaration
self.encoding = encoding
self.version = version
self.standalone = standalone
}
/// This output's settings with `other`'s non-nil settings layered over them.
func merged(with other: Output) -> Output {
Output(
method: other.method ?? method,
indent: other.indent ?? indent,
omitXMLDeclaration: other.omitXMLDeclaration ?? omitXMLDeclaration,
encoding: other.encoding ?? encoding,
version: other.version ?? version,
standalone: other.standalone ?? standalone,
)
}
}
/// An `xsl:key` declaration: a name, the nodes it indexes (a match pattern),
@ -79,20 +124,24 @@ public extension PureXML.XSLT {
}
/// A template rule: a match pattern and/or a name, a mode, a priority, its
/// declared parameters, and a body.
/// import precedence (higher wins, used to resolve `xsl:import`), its declared
/// parameters, and a body.
struct Template: Sendable {
public var match: String?
public var name: String?
public var mode: String?
public var priority: Double
public var importPrecedence: Int
public var parameters: [Binding]
public var body: [Instruction]
}
/// A compiled stylesheet: its template rules, global variables, and keys.
/// A compiled stylesheet: its template rules, global variables, keys, and
/// output controls (with `xsl:include`/`xsl:import` already folded in).
struct Stylesheet: Sendable {
public var templates: [Template]
public var globals: [Instruction]
public var keys: [Key]
public var output: Output
}
}

View File

@ -44,25 +44,71 @@ extension PureXML.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 {
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 {
throw XSLTError.notAStylesheet
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))
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)
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 {
@ -75,7 +121,7 @@ extension PureXML.XSLT {
// MARK: Templates
private static func template(_ node: Tree) -> Template {
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
@ -88,6 +134,7 @@ extension PureXML.XSLT {
name: XSLTNode.attribute(node, "name"),
mode: XSLTNode.attribute(node, "mode"),
priority: priority,
importPrecedence: precedence,
parameters: parameters,
body: body,
)
@ -174,6 +221,10 @@ extension PureXML.XSLT {
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
}

View File

@ -48,7 +48,10 @@ extension PureXML.XSLT {
fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?) -> Template? {
stylesheet.templates.enumerated()
.filter { $0.element.mode == mode && ($0.element.match.map { matches(node, $0) } ?? false) }
.max { lhs, rhs in (lhs.element.priority, lhs.offset) < (rhs.element.priority, rhs.offset) }?
.max { lhs, rhs in
(lhs.element.importPrecedence, lhs.element.priority, lhs.offset)
< (rhs.element.importPrecedence, rhs.element.priority, rhs.offset)
}?
.element
}
@ -222,6 +225,10 @@ extension PureXML.XSLT.Transformer {
copyInstruction(body, context)
case let .number(count, _, format):
[.node(.text(PureXML.XSLT.Numbering.value(of: context.node, count: count, format: format)))]
case let .comment(body):
[.node(.comment(Self.text(of: instantiate(body, context))))]
case let .processingInstruction(name, body):
[.node(.processingInstruction(target: avt(name, context), data: Self.text(of: instantiate(body, context))))]
default:
[]
}
@ -356,10 +363,11 @@ public extension PureXML.XSLT {
options: PureXML.Emitting.Options = .compact,
documentLoader: @escaping (String) -> String? = { _ in nil },
) throws -> String {
try PureXML.serialize(
transformToNode(stylesheet: stylesheet, source: source, documentLoader: documentLoader),
options: options,
)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
let result = Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run()
if sheet.output.method == "text" { return textValue(of: result) }
return PureXML.serialize(result, options: options.applying(sheet.output))
}
/// Transforms `source` with `stylesheet`, returning the result tree.
@ -368,8 +376,19 @@ public extension PureXML.XSLT {
source: String,
documentLoader: @escaping (String) -> String? = { _ in nil },
) throws -> PureXML.Model.Node {
let sheet = try XSLTParser.parse(stylesheet)
let sheet = try XSLTParser.parse(stylesheet, loader: documentLoader)
let root = try PureXML.parseTree(source)
return Transformer(stylesheet: sheet, root: root, documentLoader: documentLoader).run()
}
/// The concatenated text content of a result tree, for the `text` output
/// method: character data only, with markup, comments, and PIs dropped.
private static func textValue(of node: PureXML.Model.Node) -> String {
switch node {
case let .text(value), let .cdata(value): value
case let .document(children): children.map(textValue).joined()
case let .element(element): element.children.map(textValue).joined()
case .comment, .processingInstruction: ""
}
}
}

145
Tests/XSLTParityTests.swift Normal file
View File

@ -0,0 +1,145 @@
@testable import PureXML
import Testing
@Suite("XSLT keys, numbering, output")
struct XSLTParityTests {
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("xsl:key indexes nodes and key() retrieves them")
func test_key() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:key name="byCat" match="item" use="@cat"/>
<xsl:template match="/">
<out><xsl:for-each select="key('byCat', 'fruit')"><n><xsl:value-of select="."/></n></xsl:for-each></out>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<r><item cat=\"fruit\">apple</item><item cat=\"veg\">pea</item><item cat=\"fruit\">pear</item></r>"
#expect(try transform(stylesheet, source) == "<out><n>apple</n><n>pear</n></out>")
}
@Test("format-number renders per the picture string")
func test_formatNumber() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out>
<a><xsl:value-of select="format-number(1234.5, '#,##0.00')"/></a>
<b><xsl:value-of select="format-number(0.25, '0%')"/></b>
</out>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<x/>") == "<out><a>1,234.50</a><b>25%</b></out>")
}
@Test("xsl:number generates the position of the context node")
func test_number() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/"><out><xsl:apply-templates select="r/i"/></out></xsl:template>
<xsl:template match="i"><n><xsl:number format="i"/></n></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<r><i/><i/><i/></r>") == "<out><n>i</n><n>ii</n><n>iii</n></out>")
}
@Test("document() loads external source through the injected loader")
func test_document() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:value-of select="document('ext.xml')/data/v"/></out>
</xsl:template>
</xsl:stylesheet>
"""
let result = try PureXML.XSLT.transform(
stylesheet: stylesheet,
source: "<x/>",
documentLoader: { uri in uri == "ext.xml" ? "<data><v>loaded</v></data>" : nil },
)
#expect(result == "<out>loaded</out>")
}
@Test("xsl:comment and xsl:processing-instruction emit their nodes")
func test_commentAndPI() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:comment>note</xsl:comment><xsl:processing-instruction name="go">run</xsl:processing-instruction></out>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<x/>") == "<out><!--note--><?go run?></out>")
}
@Test("xsl:output text method drops markup and yields character data")
func test_outputText() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output method="text"/>
<xsl:template match="/"><a>x</a><b>y</b></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<r/>") == "xy")
}
@Test("xsl:output controls the XML declaration and indentation")
func test_outputDeclaration() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:output omit-xml-declaration="no" encoding="UTF-8"/>
<xsl:template match="/"><out>z</out></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<r/>") == "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<out>z</out>")
}
@Test("xsl:include folds in another stylesheet's templates")
func test_include() throws {
let included = """
<xsl:stylesheet \(xsl)>
<xsl:template match="i"><hit><xsl:value-of select="."/></hit></xsl:template>
</xsl:stylesheet>
"""
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:include href="lib.xsl"/>
<xsl:template match="/"><out><xsl:apply-templates select="r/i"/></out></xsl:template>
</xsl:stylesheet>
"""
let result = try PureXML.XSLT.transform(
stylesheet: stylesheet,
source: "<r><i>a</i></r>",
documentLoader: { $0 == "lib.xsl" ? included : nil },
)
#expect(result == "<out><hit>a</hit></out>")
}
@Test("xsl:import is overridden by the importing stylesheet")
func test_importPrecedence() throws {
let base = """
<xsl:stylesheet \(xsl)>
<xsl:template match="i"><low><xsl:value-of select="."/></low></xsl:template>
</xsl:stylesheet>
"""
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:import href="base.xsl"/>
<xsl:template match="/"><out><xsl:apply-templates select="r/i"/></out></xsl:template>
<xsl:template match="i"><high><xsl:value-of select="."/></high></xsl:template>
</xsl:stylesheet>
"""
let result = try PureXML.XSLT.transform(
stylesheet: stylesheet,
source: "<r><i>a</i></r>",
documentLoader: { $0 == "base.xsl" ? base : nil },
)
#expect(result == "<out><high>a</high></out>")
}
}

View File

@ -60,63 +60,6 @@ struct XSLTTests {
#expect(try transform(stylesheet, source) == "<toc><li>A</li><li>B</li></toc><body><h1>A</h1><h1>B</h1></body>")
}
@Test("xsl:key indexes nodes and key() retrieves them")
func test_key() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:key name="byCat" match="item" use="@cat"/>
<xsl:template match="/">
<out><xsl:for-each select="key('byCat', 'fruit')"><n><xsl:value-of select="."/></n></xsl:for-each></out>
</xsl:template>
</xsl:stylesheet>
"""
let source = "<r><item cat=\"fruit\">apple</item><item cat=\"veg\">pea</item><item cat=\"fruit\">pear</item></r>"
#expect(try transform(stylesheet, source) == "<out><n>apple</n><n>pear</n></out>")
}
@Test("format-number renders per the picture string")
func test_formatNumber() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out>
<a><xsl:value-of select="format-number(1234.5, '#,##0.00')"/></a>
<b><xsl:value-of select="format-number(0.25, '0%')"/></b>
</out>
</xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<x/>") == "<out><a>1,234.50</a><b>25%</b></out>")
}
@Test("xsl:number generates the position of the context node")
func test_number() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/"><out><xsl:apply-templates select="r/i"/></out></xsl:template>
<xsl:template match="i"><n><xsl:number format="i"/></n></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(stylesheet, "<r><i/><i/><i/></r>") == "<out><n>i</n><n>ii</n><n>iii</n></out>")
}
@Test("document() loads external source through the injected loader")
func test_document() throws {
let stylesheet = """
<xsl:stylesheet \(xsl)>
<xsl:template match="/">
<out><xsl:value-of select="document('ext.xml')/data/v"/></out>
</xsl:template>
</xsl:stylesheet>
"""
let result = try PureXML.XSLT.transform(
stylesheet: stylesheet,
source: "<x/>",
documentLoader: { uri in uri == "ext.xml" ? "<data><v>loaded</v></data>" : nil },
)
#expect(result == "<out>loaded</out>")
}
@Test("The identity transform reproduces the input")
func test_identity() throws {
let stylesheet = """