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:
parent
8350b13896
commit
23db1289ed
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
145
Tests/XSLTParityTests.swift
Normal 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>")
|
||||
}
|
||||
}
|
||||
@ -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 = """
|
||||
|
||||
Loading…
Reference in New Issue
Block a user