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.
This commit is contained in:
Mihaela Mihaljevic 2026-06-09 02:10:16 +02:00
parent 26ebeed155
commit 610b7d0ca0
7 changed files with 166 additions and 51 deletions

View File

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

View File

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

View File

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

View File

@ -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<String> = [],
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
}
}
}

View File

@ -12,6 +12,7 @@ private struct Parts {
var preserveSpace: Set<String> = []
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))

View File

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

View File

@ -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 = """
<xsl:stylesheet version="1.0" \(xsl) xmlns:a="urn:from" xmlns:b="urn:to">
<xsl:namespace-alias stylesheet-prefix="a" result-prefix="b"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><a:thing/></xsl:template>
</xsl:stylesheet>
"""
// The element's prefix is rewritten from the stylesheet alias to the result.
#expect(try transform(style, "<x/>") == "<b:thing/>")
}
@Test("A non-aliased literal element is untouched")
func test_notAliased() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl) xmlns:a="urn:from" xmlns:b="urn:to">
<xsl:namespace-alias stylesheet-prefix="a" result-prefix="b"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><plain/></xsl:template>
</xsl:stylesheet>
"""
#expect(try transform(style, "<x/>") == "<plain/>")
}
@Test("namespace-alias can produce literal xsl: elements (stylesheet generation)")
func test_generateXSL() throws {
let style = """
<xsl:stylesheet version="1.0" \(xsl) xmlns:axsl="urn:alias">
<xsl:namespace-alias stylesheet-prefix="axsl" result-prefix="xsl"/>
<xsl:output method="xml" omit-xml-declaration="yes"/>
<xsl:template match="/"><axsl:value-of select="."/></xsl:template>
</xsl:stylesheet>
"""
let out = try transform(style, "<x/>")
// The aliased axsl: element is emitted as a literal xsl: element, not run.
#expect(out.contains("xsl:value-of"))
#expect(!out.contains("axsl"))
}
}