Merge epic/62-xslt-params: XSLT template parameters and modes

This commit is contained in:
Mihaela Mihaljevic 2026-06-08 07:48:06 +02:00
commit 781c58895d
6 changed files with 156 additions and 22 deletions

View File

@ -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`

View File

@ -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

View File

@ -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]
}

View File

@ -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))

View File

@ -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] {

View File

@ -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 = """