From 610b7d0ca094035ea4610a8de17d62a3ef28113d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Tue, 9 Jun 2026 02:10:16 +0200 Subject: [PATCH] feat(xslt): implement xsl:namespace-alias, completing #81 A literal result element and its attributes in a stylesheet namespace bound by stylesheet-prefix are rewritten on output to the namespace and prefix bound by result-prefix (#default supported), so a stylesheet can emit literal xsl: elements. Prefixes resolve through the in-scope xmlns declarations; aliases fold across include/import. Splits AVT parsing into XSLTAVT.swift and trims the transformer to stay within length budgets. --- CHANGELOG.md | 5 ++ Sources/XSLT/XSLTAVT.swift | 41 +++++++++++++++++ Sources/XSLT/XSLTElements.swift | 7 +++ Sources/XSLT/XSLTModel.swift | 17 +++++++ Sources/XSLT/XSLTParser.swift | 71 +++++++++++++---------------- Sources/XSLT/XSLTTransformer.swift | 25 +++++----- Tests/XSLTNamespaceAliasTests.swift | 51 +++++++++++++++++++++ 7 files changed, 166 insertions(+), 51 deletions(-) create mode 100644 Sources/XSLT/XSLTAVT.swift create mode 100644 Tests/XSLTNamespaceAliasTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 392ddc1..35be9be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- XSLT `xsl:namespace-alias` (#81), completing the XSLT top-level-element audit. A + literal result element (and its attributes) in a stylesheet namespace bound by + `stylesheet-prefix` is rewritten on output to the namespace and prefix bound by + `result-prefix` (`#default` supported), so a stylesheet can emit literal `xsl:` + elements. Folded across `xsl:include`/`xsl:import`. - XSLT `xsl:decimal-format` (#81). `format-number` now honors named and default decimal formats: the decimal separator, grouping separator, percent, minus sign, zero-digit (so non-Latin digit sets render), NaN, and infinity symbols diff --git a/Sources/XSLT/XSLTAVT.swift b/Sources/XSLT/XSLTAVT.swift new file mode 100644 index 0000000..8233691 --- /dev/null +++ b/Sources/XSLT/XSLTAVT.swift @@ -0,0 +1,41 @@ +extension PureXML.XSLT.XSLTParser { + /// Parses an attribute value template: literal text with `{expr}` embedded + /// XPath expressions, where `{{` and `}}` are literal braces. + static func valueTemplate(_ string: String) -> PureXML.XSLT.ValueTemplate { + var parts: [PureXML.XSLT.ValuePart] = [] + var literal = "" + let characters = Array(string) + var index = 0 + while index < characters.count { + let character = characters[index] + if character == "{", index + 1 < characters.count, characters[index + 1] == "{" { + literal.append("{") + index += 2 + continue + } + if character == "}", index + 1 < characters.count, characters[index + 1] == "}" { + literal.append("}") + index += 2 + continue + } + if character == "{" { + if !literal.isEmpty { parts.append(.literal(literal)) + literal = "" + } + index += 1 + var expression = "" + while index < characters.count, characters[index] != "}" { + expression.append(characters[index]) + index += 1 + } + index += 1 + parts.append(.expression(expression)) + continue + } + literal.append(character) + index += 1 + } + if !literal.isEmpty { parts.append(.literal(literal)) } + return parts + } +} diff --git a/Sources/XSLT/XSLTElements.swift b/Sources/XSLT/XSLTElements.swift index 79d6871..1a960c5 100644 --- a/Sources/XSLT/XSLTElements.swift +++ b/Sources/XSLT/XSLTElements.swift @@ -1,4 +1,11 @@ extension PureXML.XSLT.Transformer { + /// Rewrites a literal name in an `xsl:namespace-alias`ed stylesheet namespace + /// to its result namespace and prefix; other names pass through unchanged. + func aliased(_ name: PureXML.Model.QualifiedName) -> PureXML.Model.QualifiedName { + guard let alias = stylesheet.namespaceAliases[name.namespaceURI ?? ""] else { return name } + return PureXML.Model.QualifiedName(prefix: alias.prefix, localName: name.localName, namespaceURI: alias.uri) + } + /// The attributes with duplicates by name removed, keeping the last (so a /// later attribute overrides one from an attribute set or an earlier source). static func deduplicated(_ attributes: [PureXML.Model.Attribute]) -> [PureXML.Model.Attribute] { diff --git a/Sources/XSLT/XSLTModel.swift b/Sources/XSLT/XSLTModel.swift index 336871b..638ab88 100644 --- a/Sources/XSLT/XSLTModel.swift +++ b/Sources/XSLT/XSLTModel.swift @@ -112,6 +112,18 @@ public extension PureXML.XSLT { } } + /// An `xsl:namespace-alias` target: the result namespace (and its prefix) that + /// a literal result element's stylesheet namespace is rewritten to on output. + struct NamespaceAlias: Sendable { + public var uri: String? + public var prefix: String? + + public init(uri: String?, prefix: String?) { + self.uri = uri + self.prefix = prefix + } + } + /// The symbols an `xsl:decimal-format` sets for `format-number`: those used in /// the picture (digit places, separators, percent) and in the output. The /// defaults are the XSLT standard format. @@ -194,6 +206,9 @@ public extension PureXML.XSLT { /// `xsl:decimal-format` declarations for `format-number`, keyed by name; the /// empty key is the default (unnamed) format. public var decimalFormats: [String: DecimalFormat] + /// `xsl:namespace-alias` rewrites, keyed by the stylesheet namespace URI + /// (the empty key is the no-namespace/default case). + public var namespaceAliases: [String: NamespaceAlias] public init( templates: [Template], @@ -204,6 +219,7 @@ public extension PureXML.XSLT { preserveSpace: Set = [], attributeSets: [String: AttributeSet] = [:], decimalFormats: [String: DecimalFormat] = [:], + namespaceAliases: [String: NamespaceAlias] = [:], ) { self.templates = templates self.globals = globals @@ -213,6 +229,7 @@ public extension PureXML.XSLT { self.preserveSpace = preserveSpace self.attributeSets = attributeSets self.decimalFormats = decimalFormats + self.namespaceAliases = namespaceAliases } } } diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index d042b31..58c7d1f 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -12,6 +12,7 @@ private struct Parts { var preserveSpace: Set = [] var attributeSets: [String: PureXML.XSLT.AttributeSet] = [:] var decimalFormats: [String: PureXML.XSLT.DecimalFormat] = [:] + var namespaceAliases: [String: PureXML.XSLT.NamespaceAlias] = [:] var stylesheet: PureXML.XSLT.Stylesheet { PureXML.XSLT.Stylesheet( @@ -23,6 +24,7 @@ private struct Parts { preserveSpace: preserveSpace, attributeSets: attributeSets, decimalFormats: decimalFormats, + namespaceAliases: namespaceAliases, ) } @@ -36,6 +38,7 @@ private struct Parts { preserveSpace.formUnion(sub.preserveSpace) attributeSets.merge(sub.attributeSets) { mine, _ in mine } decimalFormats.merge(sub.decimalFormats) { mine, _ in mine } + namespaceAliases.merge(sub.namespaceAliases) { mine, _ in mine } globals = isImport ? sub.globals + globals : globals + sub.globals output = isImport ? sub.output.merged(with: output) : output.merged(with: sub.output) } @@ -135,6 +138,7 @@ extension PureXML.XSLT { case "preserve-space": parts.preserveSpace.formUnion(elementNames(child)) case "attribute-set": addAttributeSet(child, into: &parts) case "decimal-format": parts.decimalFormats[XSLTNode.attribute(child, "name") ?? ""] = decimalFormat(child) + case "namespace-alias": addNamespaceAlias(child, into: &parts) default: return false } return true @@ -327,52 +331,39 @@ extension PureXML.XSLT { ) } } - - // MARK: Attribute value templates - - static func valueTemplate(_ string: String) -> ValueTemplate { - var parts: [ValuePart] = [] - var literal = "" - let characters = Array(string) - var index = 0 - while index < characters.count { - let character = characters[index] - if character == "{", index + 1 < characters.count, characters[index + 1] == "{" { - literal.append("{") - index += 2 - continue - } - if character == "}", index + 1 < characters.count, characters[index + 1] == "}" { - literal.append("}") - index += 2 - continue - } - if character == "{" { - if !literal.isEmpty { parts.append(.literal(literal)) - literal = "" - } - index += 1 - var expression = "" - while index < characters.count, characters[index] != "}" { - expression.append(characters[index]) - index += 1 - } - index += 1 - parts.append(.expression(expression)) - continue - } - literal.append(character) - index += 1 - } - if !literal.isEmpty { parts.append(.literal(literal)) } - return parts - } } } /// Top-level declaration helpers for ``XSLTParser``, kept in an extension so the /// parser enum stays within the type-body length budget. private extension PureXML.XSLT.XSLTParser { + /// Records an `xsl:namespace-alias`: the namespace bound to `stylesheet-prefix` + /// is rewritten to the one bound to `result-prefix` (with that prefix) on output. + static func addNamespaceAlias(_ child: Tree, into parts: inout Parts) { + let stylePrefix = XSLTNode.attribute(child, "stylesheet-prefix") ?? "#default" + let resultPrefix = XSLTNode.attribute(child, "result-prefix") ?? "#default" + let key = resolvePrefix(stylePrefix, at: child) ?? "" + parts.namespaceAliases[key] = PureXML.XSLT.NamespaceAlias( + uri: resolvePrefix(resultPrefix, at: child), + prefix: resultPrefix == "#default" ? nil : resultPrefix, + ) + } + + /// Resolves a namespace prefix (or `#default`) to its URI from the `xmlns` + /// declarations in scope at `node`, walking up to the stylesheet element. + static func resolvePrefix(_ prefix: String, at node: Tree) -> String? { + var current: Tree? = node + while let element = current { + for attribute in element.attributes { + let isDefault = prefix == "#default" && attribute.name.prefix == nil && attribute.name.localName == "xmlns" + let isPrefixed = attribute.name.prefix == "xmlns" && attribute.name.localName == prefix + if isDefault || isPrefixed { return attribute.value } + } + current = element.parent + } + return nil + } + static func addAttributeSet(_ child: Tree, into parts: inout Parts) { guard let name = XSLTNode.attribute(child, "name") else { return } parts.attributeSets[name] = PureXML.XSLT.AttributeSet(attributes: body(child), use: useAttributeSets(child)) diff --git a/Sources/XSLT/XSLTTransformer.swift b/Sources/XSLT/XSLTTransformer.swift index ad5a28a..88e35bf 100644 --- a/Sources/XSLT/XSLTTransformer.swift +++ b/Sources/XSLT/XSLTTransformer.swift @@ -40,8 +40,6 @@ extension PureXML.XSLT { return .document(applyTemplates(to: [root], mode: nil, parameters: [], context).compactMap(Self.nodeOf)) } - // MARK: Template matching - fileprivate func bestTemplate(for node: PureXML.Model.TreeNode, mode: String?, below ceiling: Int = .max) -> Template? { stylesheet.templates.enumerated() .filter { $0.element.mode == mode && $0.element.importPrecedence < ceiling && ($0.element.match.map { matches(node, $0) } ?? false) } @@ -63,8 +61,6 @@ extension PureXML.XSLT { return false } - // MARK: apply-templates and built-in rules - fileprivate func applyTemplates( to nodes: [PureXML.Model.TreeNode], mode: String?, @@ -138,8 +134,6 @@ extension PureXML.XSLT { return .string(Self.text(of: instantiate(body, context))) } - // MARK: XPath helpers - fileprivate func value(_ expression: String, _ context: XSLTContext) -> PureXML.XPath.Value? { guard let query = try? PureXML.XPath.Query(expression) else { return nil } return try? query.value( @@ -163,8 +157,6 @@ extension PureXML.XSLT { value(expression, context)?.boolean ?? false } - // MARK: Result helpers - fileprivate static func nodeOf(_ item: ResultItem) -> PureXML.Model.Node? { if case let .node(node) = item { return node } return nil @@ -224,10 +216,23 @@ extension PureXML.XSLT.Transformer { return [] } + /// Builds a literal result element, rewriting its name and attribute names + /// through any `xsl:namespace-alias` in effect. + private func literalResult( + _ name: PureXML.Model.QualifiedName, + _ attributes: [PureXML.XSLT.LiteralAttribute], + _ useAttributeSets: [String], + _ body: [PureXML.XSLT.Instruction], + _ context: XSLTContext, + ) -> ResultItem { + let aliasedAttributes = attributes.map { PureXML.XSLT.LiteralAttribute(name: aliased($0.name), value: $0.value) } + return buildElement(name: aliased(name), literalAttributes: aliasedAttributes, useAttributeSets: useAttributeSets, body: body, context) + } + private func structuralEvaluate(_ instruction: PureXML.XSLT.Instruction, _ context: XSLTContext) -> [ResultItem] { switch instruction { case let .literalElement(name, attributes, useAttributeSets, body): - [buildElement(name: name, literalAttributes: attributes, useAttributeSets: useAttributeSets, body: body, context)] + [literalResult(name, attributes, useAttributeSets, body, context)] case let .element(nameTemplate, useAttributeSets, body): [buildElement(name: .init(avt(nameTemplate, context)), literalAttributes: [], useAttributeSets: useAttributeSets, body: body, context)] case let .attribute(nameTemplate, body): @@ -362,8 +367,6 @@ extension PureXML.XSLT.Transformer { } } - // MARK: Sorting - private func sorted(_ nodes: [PureXML.Model.TreeNode], _ sorts: [PureXML.XSLT.Sort]) -> [PureXML.Model.TreeNode] { guard !sorts.isEmpty else { return nodes } return nodes.enumerated().sorted { lhs, rhs in diff --git a/Tests/XSLTNamespaceAliasTests.swift b/Tests/XSLTNamespaceAliasTests.swift new file mode 100644 index 0000000..6894300 --- /dev/null +++ b/Tests/XSLTNamespaceAliasTests.swift @@ -0,0 +1,51 @@ +@testable import PureXML +import Testing + +@Suite("XSLT xsl:namespace-alias") +struct XSLTNamespaceAliasTests { + 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("A literal element in the aliased namespace is rewritten to the result namespace") + func test_rewrite() throws { + let style = """ + + + + + + """ + // The element's prefix is rewritten from the stylesheet alias to the result. + #expect(try transform(style, "") == "") + } + + @Test("A non-aliased literal element is untouched") + func test_notAliased() throws { + let style = """ + + + + + + """ + #expect(try transform(style, "") == "") + } + + @Test("namespace-alias can produce literal xsl: elements (stylesheet generation)") + func test_generateXSL() throws { + let style = """ + + + + + + """ + let out = try transform(style, "") + // The aliased axsl: element is emitted as a literal xsl: element, not run. + #expect(out.contains("xsl:value-of")) + #expect(!out.contains("axsl")) + } +}