From cac09eef010b7050532d66c4400b139dbdc631d7 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 00:44:27 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + Sources/XSLT/XSLTFunctions.swift | 5 +- Sources/XSLT/XSLTLookups.swift | 14 +++++- Sources/XSLT/XSLTParser.swift | 16 +++++-- Sources/XSLT/XSLTParserDeclarations.swift | 34 +++++++------ Tests/Fixtures/xalan-baseline.txt | 3 -- Tests/XSLTExpandedQNameTests.swift | 58 +++++++++++++++++++++++ 7 files changed, 106 insertions(+), 26 deletions(-) create mode 100644 Tests/XSLTExpandedQNameTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1748d11..7c9f90b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift index 80fbf8a..3e27ae1 100644 --- a/Sources/XSLT/XSLTFunctions.swift +++ b/Sources/XSLT/XSLTFunctions.swift @@ -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 : "", diff --git a/Sources/XSLT/XSLTLookups.swift b/Sources/XSLT/XSLTLookups.swift index b808e3e..f38feac 100644 --- a/Sources/XSLT/XSLTLookups.swift +++ b/Sources/XSLT/XSLTLookups.swift @@ -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[.. 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) diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index df78610..8c2cee3 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -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? { diff --git a/Sources/XSLT/XSLTParserDeclarations.swift b/Sources/XSLT/XSLTParserDeclarations.swift index 527db64..25b581f 100644 --- a/Sources/XSLT/XSLTParserDeclarations.swift +++ b/Sources/XSLT/XSLTParserDeclarations.swift @@ -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[.. String { + guard let colon = raw.firstIndex(of: ":") else { return raw } + let prefix = String(raw[.. 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[.. String { + try PureXML.XSLT.transform( + stylesheet: """ + + + \(extra) + \(body) + + """, + source: "V", + ) + } + + @Test("call-template matches a template named with a different same-namespace prefix") + func test_namedTemplate() throws { + let out = try run("", "T") + #expect(out == "T") + } + + @Test("use-attribute-sets matches an attribute-set named with a different prefix") + func test_attributeSet() throws { + let out = try run( + "", + "1", + ) + #expect(out == "") + } + + @Test("key() matches an xsl:key named with a different prefix") + func test_key() throws { + let out = try run( + "", + "", + ) + #expect(out == "V") + } + + @Test("format-number matches an xsl:decimal-format named with a different prefix") + func test_decimalFormat() throws { + let out = try run( + "", + "", + ) + #expect(out == "5") + } +}