From 70fdcab84024804ca759a49a8947dbf2575f54d4 Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 04:08:00 +0200 Subject: [PATCH] fix(xslt): give an axis-prefixed match pattern its node-test default priority (#266) XSLT 1.0 5.5: the default priority of a single-step match pattern is set by its node test, regardless of an explicit child:: or attribute:: axis. defaultPriority matched the whole pattern string, so attribute::node() fell through to 0 (a name test) instead of -0.5 (a node() node test), wrongly outranking @*. Strip a leading child::/attribute:: axis before classifying the node test. Closes Apache Xalan conformance cases conflictres29 and conflictres30. --- CHANGELOG.md | 2 ++ Sources/XSLT/XSLTParser.swift | 13 ++++++++++--- Tests/Fixtures/xalan-baseline.txt | 2 -- Tests/XSLTPatternPriorityTests.swift | 29 ++++++++++++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 Tests/XSLTPatternPriorityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b31fd..2be1781 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 +- A template match pattern written with an explicit `child::` or `attribute::` axis now gets the default priority of its node test (XSLT 1.0 5.5), not 0. `attribute::node()` is a `node()` node test, so its default priority is -0.5 (the same as `@*`), but it was treated as a name test and given priority 0, which made it wrongly outrank `@*`. The axis is now stripped before the node test is classified. Closes Apache Xalan conformance cases `conflictres29` and `conflictres30`. + - `xsl:strip-space` and `xsl:preserve-space` now match their `elements` name tests by namespace rather than by prefix (XSLT 1.0 3.4). A specifier `prefix:*` or `prefix:local` resolves the prefix to its namespace URI at parse time, and a source element is matched on its expanded name in order of specificity (an exact `{uri}local` over a namespace wildcard `{uri}*` over `*`), so a stylesheet that strips `ns1:*` strips a source element in that namespace whatever prefix the source bound it to, while leaving another namespace untouched. The tests previously compared the raw prefixed name strings, so a differing prefix did not match. Closes Apache Xalan conformance cases `whitespace06` and `whitespace07`. - `disable-output-escaping` is now ignored when an `xsl:text` or `xsl:value-of` produces the string value of an attribute, comment, or processing-instruction node (XSLT 1.0 16.4: disabling escaping for a value used as something other than a text node is an error, with the recovery being to ignore it). The attribute value built from such content kept its raw-output markers, so the serializer left `<` and `>` unescaped in the attribute; the markers are now stripped for these three node kinds (only when the stylesheet uses the feature, so a private-use character in ordinary data is untouched). Closes Apache Xalan conformance case `output75`. diff --git a/Sources/XSLT/XSLTParser.swift b/Sources/XSLT/XSLTParser.swift index d0bdcd5..ef7f76c 100644 --- a/Sources/XSLT/XSLTParser.swift +++ b/Sources/XSLT/XSLTParser.swift @@ -221,11 +221,18 @@ extension PureXML.XSLT { XSLTNode.children(node, named: "with-param").map(binding) } - /// The XSLT default-priority rules for a match pattern. + /// The XSLT default-priority rules for a match pattern. The priority of a + /// single-step pattern is set by its node test, regardless of an explicit + /// `child::` or `attribute::` axis (XSLT 1.0 5.5), so the axis is stripped + /// before the node test is classified (`attribute::node()` is -0.5, like + /// `node()`, not 0). static func defaultPriority(_ pattern: String) -> Double { if pattern.contains("/") || pattern.contains("[") { return 0.5 } - if ["*", "@*", "node()", "text()", "comment()"].contains(pattern) { return -0.5 } - if pattern.hasSuffix(":*") { return -0.25 } + let test = pattern.hasPrefix("attribute::") ? String(pattern.dropFirst(11)) + : pattern.hasPrefix("child::") ? String(pattern.dropFirst(7)) + : pattern + if ["*", "@*", "node()", "text()", "comment()", "processing-instruction()"].contains(test) { return -0.5 } + if test.hasSuffix(":*") { return -0.25 } return 0 } diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index bbbe92c..0f51ce1 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -3,8 +3,6 @@ # must leave this list and a regression shows up as a new failure. attribset/attribset44 axes/axes116 -conflictres/conflictres29 -conflictres/conflictres30 extend/extend01 extend/extend02 extend/extend04 diff --git a/Tests/XSLTPatternPriorityTests.swift b/Tests/XSLTPatternPriorityTests.swift new file mode 100644 index 0000000..2302524 --- /dev/null +++ b/Tests/XSLTPatternPriorityTests.swift @@ -0,0 +1,29 @@ +import Testing +@testable import PureXML + +/// XSLT 1.0 5.5 default-priority rules for template match patterns. +@Suite("XSLT pattern priority") +struct XSLTPatternPriorityTests { + 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("an axis-prefixed node test ties with @*, not outranks it (5.5)") + func test_axisPatternPriority() throws { + // attribute::node() is a node() node test, default priority -0.5 like @*, + // not a name test (0). On the resulting tie the last template in document + // order wins, so @* (declared last) applies, not attribute::node() which + // was wrongly given priority 0 (Apache Xalan conflictres29, conflictres30). + let style = """ + + + + node + star + + """ + #expect(try transform(style, "") == "star") + } +}