Merge epic/62-xslt-params: XSLT template parameters and modes
This commit is contained in:
commit
781c58895d
@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- XSLT template parameters and modes (toward full parity, #61). `xsl:param`
|
||||
declarations (with defaults), `xsl:with-param` on `apply-templates` and
|
||||
`call-template`, and `mode` on templates and `apply-templates` are now honored:
|
||||
a passed parameter overrides the declared default, and a mode routes nodes only
|
||||
to templates declared in that mode (the built-in rules recurse in the same
|
||||
mode).
|
||||
- Push / feed streaming API (#1), the last epic. A resumable scanner reports a
|
||||
token only once its terminator is fully buffered and otherwise signals
|
||||
need-more (the Expat `XML_TOK_PARTIAL` model), so `PureXML.Parsing.PushParser`
|
||||
|
||||
12
README.md
12
README.md
@ -53,7 +53,17 @@ remaining libxml2 surface is tracked as epics. Deliberate non-goals: network
|
||||
fetching (`nanohttp`/`nanoftp`) and the threading/memory infrastructure stay out,
|
||||
and external resolution is opt-in through an injected resolver so XXE stays closed.
|
||||
|
||||
Every tracked epic is now shipped; the diagram above summarizes the surface.
|
||||
The original epics are all shipped (summarized above). The remaining work is
|
||||
closing the documented within-subsystem gaps to reach exhaustive libxml2 parity:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
classDef done fill:#ddf9e4,stroke:#34c759,color:#111827
|
||||
classDef review fill:#fff7d6,stroke:#ffcc00,color:#111827
|
||||
classDef epic fill:#f2e5ff,stroke:#af52de,color:#111827
|
||||
classDef todo fill:#f2f4f7,stroke:#8e8e93,color:#111827
|
||||
Parity["#61 Full parity completion: XSLT params/keys/output, XSD union/import/identity, RELAX NG compact, HTML5 tree construction, C14N 2.0"]:::epic
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
|
||||
@ -29,8 +29,9 @@ public extension PureXML.XSLT {
|
||||
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:apply-templates` with an optional node-set selection, mode, sorts,
|
||||
/// and passed parameters.
|
||||
case applyTemplates(select: String?, mode: String?, sorts: [Sort], parameters: [Binding])
|
||||
/// `xsl:for-each` over a node-set.
|
||||
case forEach(select: String, sorts: [Sort], body: [Instruction])
|
||||
/// `xsl:if`.
|
||||
@ -47,8 +48,8 @@ public extension PureXML.XSLT {
|
||||
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:call-template` by name with passed parameters.
|
||||
case callTemplate(name: String, parameters: [Binding])
|
||||
/// `xsl:variable` bound for the rest of the sequence constructor.
|
||||
case variable(name: String, select: String?, body: [Instruction])
|
||||
}
|
||||
@ -59,11 +60,22 @@ public extension PureXML.XSLT {
|
||||
public var body: [Instruction]
|
||||
}
|
||||
|
||||
/// A template rule: a match pattern and/or a name, a priority, and a body.
|
||||
/// A name binding: an `xsl:param` declaration (with a default) or an
|
||||
/// `xsl:with-param` value, supplied either by `select` or by a body.
|
||||
struct Binding: Sendable {
|
||||
public var name: String
|
||||
public var select: String?
|
||||
public var body: [Instruction]
|
||||
}
|
||||
|
||||
/// A template rule: a match pattern and/or a name, a mode, a priority, 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 parameters: [Binding]
|
||||
public var body: [Instruction]
|
||||
}
|
||||
|
||||
|
||||
@ -69,14 +69,32 @@ extension PureXML.XSLT {
|
||||
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,
|
||||
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 }
|
||||
@ -110,9 +128,17 @@ extension PureXML.XSLT {
|
||||
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 "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") ?? "")
|
||||
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))
|
||||
|
||||
@ -31,14 +31,14 @@ extension PureXML.XSLT {
|
||||
}
|
||||
}
|
||||
let context = XSLTContext(node: root, position: 1, size: 1, variables: variables)
|
||||
return .document(applyTemplates(to: [root], context).compactMap(Self.nodeOf))
|
||||
return .document(applyTemplates(to: [root], mode: nil, parameters: [], context).compactMap(Self.nodeOf))
|
||||
}
|
||||
|
||||
// MARK: Template matching
|
||||
|
||||
fileprivate func bestTemplate(for node: PureXML.Model.TreeNode) -> Template? {
|
||||
fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?) -> Template? {
|
||||
stylesheet.templates.enumerated()
|
||||
.filter { $0.element.match.map { matches(node, $0) } ?? false }
|
||||
.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) }?
|
||||
.element
|
||||
}
|
||||
@ -56,23 +56,47 @@ extension PureXML.XSLT {
|
||||
|
||||
// MARK: apply-templates and built-in rules
|
||||
|
||||
fileprivate func applyTemplates(to nodes: [PureXML.Model.TreeNode], _ context: XSLTContext) -> [ResultItem] {
|
||||
fileprivate func applyTemplates(
|
||||
to nodes: [PureXML.Model.TreeNode],
|
||||
mode: String?,
|
||||
parameters: [Binding],
|
||||
_ context: XSLTContext,
|
||||
) -> [ResultItem] {
|
||||
var items: [ResultItem] = []
|
||||
for (offset, node) in nodes.enumerated() {
|
||||
let nodeContext = XSLTContext(node: node, position: offset + 1, size: nodes.count, variables: context.variables)
|
||||
if let template = bestTemplate(for: node) {
|
||||
items += instantiate(template.body, nodeContext)
|
||||
if let template = bestTemplate(for: node, mode: mode) {
|
||||
items += instantiateTemplate(template, nodeContext, passing: parameters, from: context)
|
||||
} else {
|
||||
items += builtInRule(node, nodeContext)
|
||||
items += builtInRule(node, mode: mode, nodeContext)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private func builtInRule(_ node: PureXML.Model.TreeNode, _ context: XSLTContext) -> [ResultItem] {
|
||||
/// Binds the template's parameters (a passed `with-param` wins over the
|
||||
/// declared default) and instantiates its body.
|
||||
fileprivate func instantiateTemplate(
|
||||
_ template: Template,
|
||||
_ context: XSLTContext,
|
||||
passing parameters: [Binding],
|
||||
from caller: XSLTContext,
|
||||
) -> [ResultItem] {
|
||||
var context = context
|
||||
for parameter in template.parameters {
|
||||
if let passed = parameters.first(where: { $0.name == parameter.name }) {
|
||||
context.variables[parameter.name] = variableValue(passed.select, passed.body, caller)
|
||||
} else {
|
||||
context.variables[parameter.name] = variableValue(parameter.select, parameter.body, context)
|
||||
}
|
||||
}
|
||||
return instantiate(template.body, context)
|
||||
}
|
||||
|
||||
private func builtInRule(_ node: PureXML.Model.TreeNode, mode: String?, _ context: XSLTContext) -> [ResultItem] {
|
||||
switch node.kind {
|
||||
case .element, .document:
|
||||
applyTemplates(to: node.children, context)
|
||||
applyTemplates(to: node.children, mode: mode, parameters: [], context)
|
||||
case .text, .cdata:
|
||||
[.node(.text(node.value))]
|
||||
default:
|
||||
@ -159,13 +183,18 @@ extension PureXML.XSLT.Transformer {
|
||||
switch instruction {
|
||||
case let .literalText(text): [.node(.text(text))]
|
||||
case let .valueOf(select): [.node(.text(string(select, context)))]
|
||||
case let .applyTemplates(select, sorts):
|
||||
applyTemplates(to: sorted(selectNodes(select ?? "node()", context), sorts), context)
|
||||
case let .applyTemplates(select, mode, sorts, parameters):
|
||||
applyTemplates(
|
||||
to: sorted(selectNodes(select ?? "node()", context), sorts),
|
||||
mode: mode,
|
||||
parameters: parameters,
|
||||
context,
|
||||
)
|
||||
case let .forEach(select, sorts, body): forEach(select, sorts, body, context)
|
||||
case let .ifInstruction(test, body): boolean(test, context) ? instantiate(body, context) : []
|
||||
case let .choose(whens, otherwise): chooseInstruction(whens, otherwise, context)
|
||||
case let .copyOf(select): copyOf(select, context)
|
||||
case let .callTemplate(name): callTemplate(name, context)
|
||||
case let .callTemplate(name, parameters): callTemplate(name, parameters, context)
|
||||
case .variable: []
|
||||
default: nil
|
||||
}
|
||||
@ -211,9 +240,9 @@ extension PureXML.XSLT.Transformer {
|
||||
return instantiate(otherwise, context)
|
||||
}
|
||||
|
||||
private func callTemplate(_ name: String, _ context: XSLTContext) -> [ResultItem] {
|
||||
private func callTemplate(_ name: String, _ parameters: [PureXML.XSLT.Binding], _ context: XSLTContext) -> [ResultItem] {
|
||||
guard let template = stylesheet.templates.first(where: { $0.name == name }) else { return [] }
|
||||
return instantiate(template.body, context)
|
||||
return instantiateTemplate(template, context, passing: parameters, from: context)
|
||||
}
|
||||
|
||||
private func copyOf(_ select: String, _ context: XSLTContext) -> [ResultItem] {
|
||||
|
||||
@ -9,6 +9,57 @@ struct XSLTTests {
|
||||
try PureXML.XSLT.transform(stylesheet: stylesheet, source: source)
|
||||
}
|
||||
|
||||
@Test("call-template passes with-param values, falling back to param defaults")
|
||||
func test_callTemplateParams() throws {
|
||||
let stylesheet = """
|
||||
<xsl:stylesheet \(xsl)>
|
||||
<xsl:template match="/">
|
||||
<out>
|
||||
<xsl:call-template name="greet"><xsl:with-param name="who" select="'Ada'"/></xsl:call-template>
|
||||
<xsl:call-template name="greet"/>
|
||||
</out>
|
||||
</xsl:template>
|
||||
<xsl:template name="greet">
|
||||
<xsl:param name="who" select="'world'"/>
|
||||
<hi><xsl:value-of select="$who"/></hi>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
#expect(try transform(stylesheet, "<x/>") == "<out><hi>Ada</hi><hi>world</hi></out>")
|
||||
}
|
||||
|
||||
@Test("apply-templates passes parameters to the matched template")
|
||||
func test_applyTemplatesParams() throws {
|
||||
let stylesheet = """
|
||||
<xsl:stylesheet \(xsl)>
|
||||
<xsl:template match="/">
|
||||
<xsl:apply-templates select="r/i"><xsl:with-param name="p" select="'X'"/></xsl:apply-templates>
|
||||
</xsl:template>
|
||||
<xsl:template match="i">
|
||||
<xsl:param name="p" select="'-'"/>
|
||||
<v><xsl:value-of select="$p"/><xsl:value-of select="."/></v>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
#expect(try transform(stylesheet, "<r><i>a</i><i>b</i></r>") == "<v>Xa</v><v>Xb</v>")
|
||||
}
|
||||
|
||||
@Test("Modes route nodes to mode-specific templates")
|
||||
func test_modes() throws {
|
||||
let stylesheet = """
|
||||
<xsl:stylesheet \(xsl)>
|
||||
<xsl:template match="/">
|
||||
<toc><xsl:apply-templates select="doc/h" mode="toc"/></toc>
|
||||
<body><xsl:apply-templates select="doc/h"/></body>
|
||||
</xsl:template>
|
||||
<xsl:template match="h" mode="toc"><li><xsl:value-of select="."/></li></xsl:template>
|
||||
<xsl:template match="h"><h1><xsl:value-of select="."/></h1></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
"""
|
||||
let source = "<doc><h>A</h><h>B</h></doc>"
|
||||
#expect(try transform(stylesheet, source) == "<toc><li>A</li><li>B</li></toc><body><h1>A</h1><h1>B</h1></body>")
|
||||
}
|
||||
|
||||
@Test("The identity transform reproduces the input")
|
||||
func test_identity() throws {
|
||||
let stylesheet = """
|
||||
|
||||
Loading…
Reference in New Issue
Block a user