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:
parent
26ebeed155
commit
610b7d0ca0
@ -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
|
||||
|
||||
41
Sources/XSLT/XSLTAVT.swift
Normal file
41
Sources/XSLT/XSLTAVT.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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] {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
51
Tests/XSLTNamespaceAliasTests.swift
Normal file
51
Tests/XSLTNamespaceAliasTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user