diff --git a/CHANGELOG.md b/CHANGELOG.md
index a30d656..6924793 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
+- `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`.
diff --git a/Sources/XSLT/XSLTFunctions.swift b/Sources/XSLT/XSLTFunctions.swift
index db852b0..7e20a09 100644
--- a/Sources/XSLT/XSLTFunctions.swift
+++ b/Sources/XSLT/XSLTFunctions.swift
@@ -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) {
diff --git a/Sources/XSLT/XSLTLookups.swift b/Sources/XSLT/XSLTLookups.swift
index b9a94ba..02304ae 100644
--- a/Sources/XSLT/XSLTLookups.swift
+++ b/Sources/XSLT/XSLTLookups.swift
@@ -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.
diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt
index 7fb8525..eea285c 100644
--- a/Tests/Fixtures/xalan-baseline.txt
+++ b/Tests/Fixtures/xalan-baseline.txt
@@ -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
diff --git a/Tests/XSLTKeyNodeSetTests.swift b/Tests/XSLTKeyNodeSetTests.swift
index 214a992..f41f30d 100644
--- a/Tests/XSLTKeyNodeSetTests.swift
+++ b/Tests/XSLTKeyNodeSetTests.swift
@@ -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 = """
+
+
+
+ ,
+
+ """
+ let source = ""
+ #expect(try transform(style, source) == "a,b,")
+ }
}