fix(xslt): distribute // over union branches in an xsl:key match pattern (#272)

The xsl:key match was turned into a path by prepending //, but for a union
match like "Level1 | Level2" the // applied only to the first branch
(//Level1 | Level2), so the second branch matched relative to the root and
found nothing, dropping those nodes from the key. Distribute // over each
top-level union branch (// inside a predicate or function call is left alone).

Closes Apache Xalan conformance cases idkey45, idkey46, idkey47, idkey48.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 15:27:36 +02:00 committed by GitHub
parent 12af2fbc24
commit fa3ab76c6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 52 additions and 5 deletions

View File

@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `xsl:key` with a union `match` pattern now indexes every branch. The match was turned into a path by prepending `//`, but for a union like `match="Level1 | Level2"` the `//` applied only to the first branch (`//Level1 | Level2`), so the second branch became relative to the root and matched nothing, leaving nodes of that type out of the key. The `//` is now distributed over each top-level union branch (`//Level1 | //Level2`), with `|` inside a predicate or function call left intact. Closes Apache Xalan conformance cases `idkey45`, `idkey46`, `idkey47`, and `idkey48`.
- `xml:space="preserve"` on a literal result element (or an ancestor) in the stylesheet now keeps that element's whitespace-only text instead of stripping it (XSLT 1.0 3.4). The stylesheet whitespace stripping dropped every whitespace-only run unconditionally, ignoring an in-scope `xml:space="preserve"`; the nearest ancestor-or-self `xml:space` is now consulted, so `preserve` retains the run while `default` (or none) still strips it. Closes Apache Xalan conformance case `whitespace20`.
- `xsl:number level="single"` now counts the element that is the `from` boundary when that element also matches the `count` pattern (XSLT 1.0 7.7). The search for the count node stopped at the nearest `from`-matching ancestor before testing it against `count`, so a pattern like `count="a|b" from="a"` produced an empty number for an element inside an `a` (the count node is that `a`, which is also the boundary). The count pattern is now tested before the boundary stops the walk. Closes Apache Xalan conformance case `numbering63`.

View File

@ -350,7 +350,7 @@ extension PureXML.XSLT {
static func buildKeyIndex(stylesheet: Stylesheet, root: PureXML.Model.TreeNode) -> KeyIndex {
var index: KeyIndex = [:]
for key in stylesheet.keys {
let path = key.match.hasPrefix("/") ? key.match : "//" + key.match
let path = keyMatchPath(key.match)
guard let matchQuery = try? PureXML.XPath.Query(path),
let useQuery = try? PureXML.XPath.Query(key.use) else { continue }
for node in matchQuery.nodes(over: root) {

View File

@ -31,6 +31,39 @@ extension PureXML.XSLT.Library {
base.isEmpty ? reference : PureXML.XInclude.URIReference.resolve(reference, against: base)
}
/// The `xsl:key` match pattern as a path selecting every matching node: `//`
/// is distributed over each top-level union branch (`a | b` becomes `//a |
/// //b`, not `//a | b`), since the match is a pattern matched anywhere, not a
/// single relative path.
static func keyMatchPath(_ match: String) -> String {
splitUnion(match).map { branch in
let trimmed = branch.drop { $0 == " " }
return trimmed.hasPrefix("/") ? String(trimmed) : "//" + trimmed
}.joined(separator: " | ")
}
/// Splits a pattern on its top-level `|`, ignoring `|` inside a predicate
/// `[...]` or a function call `(...)`.
private static func splitUnion(_ pattern: String) -> [Substring] {
var parts: [Substring] = []
var depth = 0
var start = pattern.startIndex
var index = pattern.startIndex
while index < pattern.endIndex {
switch pattern[index] {
case "[", "(": depth += 1
case "]", ")": depth -= 1
case "|" where depth == 0:
parts.append(pattern[start ..< index])
start = pattern.index(after: index)
default: break
}
index = pattern.index(after: index)
}
parts.append(pattern[start...])
return parts
}
/// The XPath `id()` definition exactly: only attributes the DTD
/// declares as type ID identify elements; a document without ID
/// declarations yields the empty set.

View File

@ -7,10 +7,6 @@ extend/extend01
extend/extend02
extend/extend04
idkey/idkey31
idkey/idkey45
idkey/idkey46
idkey/idkey47
idkey/idkey48
impincl/impincl08
impincl/impincl27
mdocs/mdocs07

View File

@ -53,4 +53,20 @@ struct XSLTKeyNodeSetTests {
"""
#expect(try transform(style, source) == "2")
}
@Test("xsl:key with a union match pattern indexes every branch")
func test_unionMatchPattern() throws {
// match="a | b" indexes both a and b anywhere; the // must distribute over
// the union (previously only the first branch was matched anywhere, so a
// nested b was missed). Apache Xalan idkey45-48.
let style = """
<xsl:stylesheet version="1.0" \(xsl)>
<xsl:output omit-xml-declaration="yes"/>
<xsl:key name="k" match="a | b" use="@id"/>
<xsl:template match="/"><out><xsl:for-each select="key('k','x')"><xsl:value-of select="name()"/>,</xsl:for-each></out></xsl:template>
</xsl:stylesheet>
"""
let source = "<doc><a id=\"x\"/><wrap><b id=\"x\"/></wrap></doc>"
#expect(try transform(style, source) == "<out>a,b,</out>")
}
}