diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b78a38..d5ec8b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/README.md b/README.md index c7570bd..dea5bf4 100644 --- a/README.md +++ b/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 diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index c01ff29..a3876b8 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -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] } diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index 7c587ee..47b485a 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -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)) diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index 29c32dc..165a1cd 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -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] { diff --git a/Tests/XSLTTests.swift b/Tests/XSLTTests.swift index ed67070..87078c8 100644 --- a/Tests/XSLTTests.swift +++ b/Tests/XSLTTests.swift @@ -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 = """ + + + + + + + + + + + + + """ + #expect(try transform(stylesheet, "") == "Adaworld") + } + + @Test("apply-templates passes parameters to the matched template") + func test_applyTemplatesParams() throws { + let stylesheet = """ + + + + + + + + + + """ + #expect(try transform(stylesheet, "ab") == "XaXb") + } + + @Test("Modes route nodes to mode-specific templates") + func test_modes() throws { + let stylesheet = """ + + + + + +
  • +

    +
    + """ + let source = "AB" + #expect(try transform(stylesheet, source) == "
  • A
  • B
  • A

    B

    ") + } + @Test("The identity transform reproduces the input") func test_identity() throws { let stylesheet = """