fix(xslt): match template, key, attribute-set, decimal-format names by expanded QName (#255)
XSLT 1.0 names every QName-keyed declaration by expanded name, so two prefixes
bound to the same namespace name the same thing. mode and variable names were
already resolved this way; named templates, xsl:key, xsl:attribute-set, and
xsl:decimal-format names were still keyed and compared as raw prefix strings.
A declaration name now resolves to {uri}local at parse time, and the references
resolve their prefix against the in-scope namespaces the same way: call-template
and use-attribute-sets at parse time, and the key() name argument and
format-number's format name at evaluation (a shared Library.expandedName helper
covers the two runtime sites). The parse-time expansion reuses a factored
expandedQName helper shared with mode and variable.
Closes Apache Xalan conformance cases namedtemplate16, attribset48, idkey53.
Full suite 1632 tests green in debug and release; the Xalan suite passes.
This commit is contained in:
parent
771fa4b457
commit
cac09eef01
@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Named templates, `xsl:key` names, `xsl:attribute-set` names, and `xsl:decimal-format` names are now matched by expanded QName, like mode and variable names already were. XSLT 1.0 compares all of these by expanded name, so two prefixes bound to the same namespace name the same thing, but they were keyed and compared as raw prefix strings. A declaration name now resolves to `{uri}local` at parse time, and the runtime references (`call-template`/`use-attribute-sets` at parse, `key()` and `format-number`'s format name at evaluation) resolve their prefix against the in-scope namespaces the same way. Closes Apache Xalan conformance cases `namedtemplate16`, `attribset48`, and `idkey53`.
|
||||
|
||||
- `xsl:copy` of the root node now honors `use-attribute-sets`. Copying the root produces no element of its own, so the attribute-set attributes have nowhere to attach on the copy and instead join the enclosing result element (XSLT 1.0 7.5); they were silently dropped. Closes Apache Xalan conformance cases `attribset27` and `attribset29`.
|
||||
|
||||
- `xsl:element` with an unusable name now passes its content through instead of creating a bad element. XSLT 1.0 7.1.2 makes a name that is not a QName an error; the recovery (pinned `element-name-not-QName` "pass-through") emits the element's content without the wrapper. The check rejected only an empty or multi-colon name, so an undeclared prefix (`none:foo`) or a name that is not an NCName (`this is a string`) still produced a malformed element. Closes Apache Xalan conformance cases `namespace40`, `namespace42`, `namespace43`, `namespace60`, `namespace73`, and `namespace79`.
|
||||
|
||||
@ -201,8 +201,9 @@ extension PureXML.XSLT {
|
||||
) -> PureXML.XPath.FunctionTable {
|
||||
PureXML.XPath.FunctionTable()
|
||||
.adding("current") { _, _ in .nodeSet([current]) }
|
||||
.adding("format-number") { arguments, _ in
|
||||
let name = arguments.count > 2 ? arguments[2].string : ""
|
||||
.adding("format-number") { arguments, context in
|
||||
// The decimal-format name is matched by expanded QName.
|
||||
let name = expandedName(arguments.count > 2 ? arguments[2].string : "", context.namespaces)
|
||||
return .string(FormatNumber.format(
|
||||
arguments.first?.number ?? .nan,
|
||||
arguments.count > 1 ? arguments[1].string : "",
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
extension PureXML.XSLT.Library {
|
||||
/// A QName-valued function argument (a key or decimal-format name) resolved
|
||||
/// to expanded form `{uri}local`, matching the expansion applied to the
|
||||
/// corresponding declaration's name. The prefix resolves against the
|
||||
/// expression's in-scope namespaces; an unprefixed or unbound name is left
|
||||
/// as is.
|
||||
static func expandedName(_ raw: String, _ namespaces: [String: String]) -> String {
|
||||
guard let colon = raw.firstIndex(of: ":"), let uri = namespaces[String(raw[..<colon])] else { return raw }
|
||||
return "{\(uri)}\(raw[raw.index(after: colon)...])"
|
||||
}
|
||||
|
||||
/// The XPath `id()` definition exactly: only attributes the DTD
|
||||
/// declares as type ID identify elements; a document without ID
|
||||
/// declarations yields the empty set.
|
||||
@ -66,7 +76,9 @@ extension PureXML.XSLT.Library {
|
||||
_ context: PureXML.XPath.EvaluationContext,
|
||||
_ keys: (PureXML.Model.TreeNode) -> PureXML.XSLT.KeyIndex,
|
||||
) -> PureXML.XPath.Value {
|
||||
let name = arguments.first?.string ?? ""
|
||||
// The key name is matched by expanded QName (XSLT 1.0 12.2), matching
|
||||
// the expansion applied to the xsl:key declaration name.
|
||||
let name = expandedName(arguments.first?.string ?? "", context.namespaces)
|
||||
guard arguments.count > 1 else { return .nodeSet([]) }
|
||||
let values: [String] = if let nodes = arguments[1].nodes {
|
||||
nodes.map(\.stringValue)
|
||||
|
||||
@ -122,7 +122,7 @@ extension PureXML.XSLT {
|
||||
case "strip-space": parts.stripSpace.formUnion(elementNames(child))
|
||||
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 "decimal-format": parts.decimalFormats[expandedDeclaredName(child)] = decimalFormat(child)
|
||||
case "namespace-alias": addNamespaceAlias(child, into: &parts)
|
||||
default: return false
|
||||
}
|
||||
@ -174,7 +174,9 @@ extension PureXML.XSLT {
|
||||
|
||||
private static func key(_ node: XSLTTree) -> Key {
|
||||
Key(
|
||||
name: XSLTNode.attribute(node, "name") ?? "",
|
||||
// Keyed by expanded QName; the key() function expands its name
|
||||
// argument the same way before looking the index up.
|
||||
name: expandedDeclaredName(node),
|
||||
match: XSLTNode.attribute(node, "match") ?? "",
|
||||
use: XSLTNode.attribute(node, "use") ?? ".",
|
||||
)
|
||||
@ -192,7 +194,7 @@ extension PureXML.XSLT {
|
||||
.compactMap(instruction)
|
||||
return Template(
|
||||
match: match,
|
||||
name: XSLTNode.attribute(node, "name"),
|
||||
name: XSLTNode.attribute(node, "name").map { expandedQName($0, at: node) },
|
||||
mode: expandedMode(node),
|
||||
priority: priority,
|
||||
importPrecedence: precedence,
|
||||
@ -282,7 +284,7 @@ extension PureXML.XSLT {
|
||||
)
|
||||
case "copy-of": .copyOf(select: XSLTNode.attribute(node, "select") ?? "")
|
||||
case "call-template": .callTemplate(
|
||||
name: XSLTNode.attribute(node, "name") ?? "",
|
||||
name: expandedDeclaredName(node),
|
||||
parameters: withParameters(node),
|
||||
)
|
||||
case "text": .literalText(
|
||||
@ -328,7 +330,11 @@ extension PureXML.XSLT {
|
||||
|
||||
/// The whitespace-separated names of `[xsl:]use-attribute-sets` on `node`.
|
||||
static func useAttributeSets(_ node: XSLTTree) -> [String] {
|
||||
(XSLTNode.attribute(node, "use-attribute-sets") ?? "").split(whereSeparator: \.isWhitespace).map(String.init)
|
||||
// Each referenced set is keyed by expanded QName, matching the
|
||||
// expansion applied to the attribute-set declaration's name.
|
||||
(XSLTNode.attribute(node, "use-attribute-sets") ?? "")
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.map { expandedQName(String($0), at: node) }
|
||||
}
|
||||
|
||||
private static func caseOrder(_ node: XSLTTree) -> PureXML.XSLT.CaseOrder? {
|
||||
|
||||
@ -31,36 +31,40 @@ extension PureXML.XSLT.XSLTParser {
|
||||
/// A `mode` attribute as expanded name `{uri}local` (bare local when
|
||||
/// unprefixed): a mode is compared by expanded name (XSLT 1.0 5.7), so two
|
||||
/// prefixes bound to one namespace are the same mode. nil when absent.
|
||||
static func expandedMode(_ node: XSLTTree) -> String? {
|
||||
guard let mode = XSLTNode.attribute(node, "mode") else { return nil }
|
||||
guard let colon = mode.firstIndex(of: ":") else { return mode }
|
||||
let prefix = String(mode[..<colon])
|
||||
let local = String(mode[mode.index(after: colon)...])
|
||||
guard let uri = resolvePrefix(prefix, at: node) else { return mode }
|
||||
/// A QName resolved to expanded form `{uri}local`, or the bare name when it
|
||||
/// is unprefixed (the null namespace) or its prefix is unbound at `node`.
|
||||
/// XSLT 1.0 compares mode, variable, template, key, attribute-set, and
|
||||
/// decimal-format names by expanded name, so two prefixes bound to the same
|
||||
/// namespace name the same thing.
|
||||
static func expandedQName(_ raw: String, at node: XSLTTree) -> String {
|
||||
guard let colon = raw.firstIndex(of: ":") else { return raw }
|
||||
let prefix = String(raw[..<colon])
|
||||
let local = String(raw[raw.index(after: colon)...])
|
||||
guard let uri = resolvePrefix(prefix, at: node) else { return raw }
|
||||
return "{\(uri)}\(local)"
|
||||
}
|
||||
|
||||
static func expandedMode(_ node: XSLTTree) -> String? {
|
||||
XSLTNode.attribute(node, "mode").map { expandedQName($0, at: node) }
|
||||
}
|
||||
|
||||
/// A variable or parameter `name` as an expanded name `{uri}local` when it
|
||||
/// carries a prefix, or the bare name when unprefixed. A variable is named by
|
||||
/// expanded QName (XSLT 1.0 11.1), so two prefixes bound to one namespace name
|
||||
/// the same variable; the XPath evaluator falls back to the same expansion on
|
||||
/// a reference, so only a prefixed name changes and unprefixed names are kept.
|
||||
static func expandedDeclaredName(_ node: XSLTTree) -> String {
|
||||
let raw = XSLTNode.attribute(node, "name") ?? ""
|
||||
guard let colon = raw.firstIndex(of: ":") else { return raw }
|
||||
let prefix = String(raw[..<colon])
|
||||
let local = String(raw[raw.index(after: colon)...])
|
||||
guard let uri = resolvePrefix(prefix, at: node) else { return raw }
|
||||
return "{\(uri)}\(local)"
|
||||
expandedQName(XSLTNode.attribute(node, "name") ?? "", at: node)
|
||||
}
|
||||
|
||||
static func addAttributeSet(_ child: XSLTTree, into parts: inout Parts) {
|
||||
guard let name = XSLTNode.attribute(child, "name") else { return }
|
||||
guard XSLTNode.attribute(child, "name") != nil else { return }
|
||||
// Same-name attribute sets merge (7.1.4) as ordered definitions:
|
||||
// each expands its used sets before its own attributes, and a later
|
||||
// definition's attributes override earlier same-named ones.
|
||||
// definition's attributes override earlier same-named ones. The name is
|
||||
// keyed by expanded QName so use-attribute-sets resolves it identically.
|
||||
let addition = PureXML.XSLT.AttributeSet(attributes: body(child), use: useAttributeSets(child))
|
||||
parts.attributeSets[name, default: []].append(addition)
|
||||
parts.attributeSets[expandedDeclaredName(child), default: []].append(addition)
|
||||
}
|
||||
|
||||
/// Reads an `xsl:decimal-format`'s symbol overrides; each unset attribute
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
# one category/name per line, exact in both directions: a fixed case
|
||||
# must leave this list and a regression shows up as a new failure.
|
||||
attribset/attribset44
|
||||
attribset/attribset48
|
||||
attribvaltemplate/attribvaltemplate08
|
||||
axes/axes116
|
||||
conflictres/conflictres29
|
||||
@ -15,7 +14,6 @@ idkey/idkey45
|
||||
idkey/idkey46
|
||||
idkey/idkey47
|
||||
idkey/idkey48
|
||||
idkey/idkey53
|
||||
impincl/impincl08
|
||||
impincl/impincl27
|
||||
mdocs/mdocs07
|
||||
@ -24,7 +22,6 @@ mdocs/mdocs13
|
||||
mdocs/mdocs15
|
||||
mdocs/mdocs19
|
||||
namedtemplate/namedtemplate12
|
||||
namedtemplate/namedtemplate16
|
||||
namespace/namespace113
|
||||
namespace/namespace19
|
||||
namespace/namespace23
|
||||
|
||||
58
Tests/XSLTExpandedQNameTests.swift
Normal file
58
Tests/XSLTExpandedQNameTests.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import Testing
|
||||
@testable import PureXML
|
||||
|
||||
/// XSLT 1.0 names every QName-keyed declaration by expanded name, so two
|
||||
/// prefixes bound to the same namespace name the same thing. Declared with one
|
||||
/// prefix and referenced with another (both bound to `urn:q`), each must still
|
||||
/// resolve (Apache Xalan namedtemplate16, attribset48, idkey53).
|
||||
@Suite("XSLT expanded-name QName declarations")
|
||||
struct XSLTExpandedQNameTests {
|
||||
private let namespaces = "xmlns:a=\"urn:q\" xmlns:b=\"urn:q\""
|
||||
private let xsl = "xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\""
|
||||
|
||||
private func run(_ body: String, _ extra: String = "") throws -> String {
|
||||
try PureXML.XSLT.transform(
|
||||
stylesheet: """
|
||||
<xsl:stylesheet version="1.0" \(xsl) \(namespaces) exclude-result-prefixes="a b">
|
||||
<xsl:output omit-xml-declaration="yes"/>
|
||||
\(extra)
|
||||
<xsl:template match="/"><out>\(body)</out></xsl:template>
|
||||
</xsl:stylesheet>
|
||||
""",
|
||||
source: "<doc><item k=\"K\">V</item></doc>",
|
||||
)
|
||||
}
|
||||
|
||||
@Test("call-template matches a template named with a different same-namespace prefix")
|
||||
func test_namedTemplate() throws {
|
||||
let out = try run("<xsl:call-template name=\"a:t\"/>", "<xsl:template name=\"b:t\">T</xsl:template>")
|
||||
#expect(out == "<out>T</out>")
|
||||
}
|
||||
|
||||
@Test("use-attribute-sets matches an attribute-set named with a different prefix")
|
||||
func test_attributeSet() throws {
|
||||
let out = try run(
|
||||
"<e xsl:use-attribute-sets=\"a:s\"/>",
|
||||
"<xsl:attribute-set name=\"b:s\"><xsl:attribute name=\"x\">1</xsl:attribute></xsl:attribute-set>",
|
||||
)
|
||||
#expect(out == "<out><e x=\"1\"/></out>")
|
||||
}
|
||||
|
||||
@Test("key() matches an xsl:key named with a different prefix")
|
||||
func test_key() throws {
|
||||
let out = try run(
|
||||
"<xsl:value-of select=\"key('a:k', 'K')\"/>",
|
||||
"<xsl:key name=\"b:k\" match=\"item\" use=\"@k\"/>",
|
||||
)
|
||||
#expect(out == "<out>V</out>")
|
||||
}
|
||||
|
||||
@Test("format-number matches an xsl:decimal-format named with a different prefix")
|
||||
func test_decimalFormat() throws {
|
||||
let out = try run(
|
||||
"<xsl:value-of select=\"format-number(5, '0!', 'a:f')\"/>",
|
||||
"<xsl:decimal-format name=\"b:f\" digit=\"!\"/>",
|
||||
)
|
||||
#expect(out == "<out>5</out>")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user