fix(xpath): parse a lone / as the root node before a terminator (#267)

The XPath grammar makes the relative part after / optional, but the compiler
only treated / as the root location path when it was at the end of the
expression (isAtEnd). When a / was followed by ), ], |, or an operator (as in
count(/), generate-id(/), or / = .), it fell through and tried to parse a step
from the terminator, yielding an empty node-set. Recognize / as the root
whenever no location step follows.

Closes Apache Xalan conformance cases position106 and mdocs15.
This commit is contained in:
Mihaela Mihaljevic 2026-06-24 04:21:46 +02:00 committed by GitHub
parent 70fdcab840
commit 9070ccc3f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 29 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
- A lone `/` (the root node) is now parsed correctly when it is followed by something other than the end of the expression, such as a `)`, `]`, `|`, or an operator. The XPath grammar makes the relative part after `/` optional, but the compiler only treated `/` as the root path at the very end of the expression, so `count(/)`, `generate-id(/)`, and `/ = .` tried to parse a step from the following token and yielded an empty node-set. The compiler now recognizes a `/` as the root whenever no location step follows it. Closes Apache Xalan conformance cases `position106` and `mdocs15`.
- 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`.

View File

@ -111,10 +111,13 @@ extension PureXML.XPath.Compiler {
if consume("/") {
steps.append(Self.descendantOrSelfStep())
try steps.append(parseStep())
} else if isAtEnd {
return (true, [])
} else {
} else if startsStep() {
try steps.append(parseStep())
} else {
// The relative part after `/` is optional, so a lone `/` is the
// root node even when a `)`, `]`, `|`, or an operator follows it
// rather than the end of the expression (e.g. `count(/)`).
return (true, [])
}
} else {
try steps.append(parseStep())
@ -135,6 +138,15 @@ extension PureXML.XPath.Compiler {
}
}
/// Whether the next token begins a location step (an axis, `@`, `.`/`..`, a
/// `*` wildcard, or a name/node-test), as opposed to ending the path (`)`,
/// `]`, `|`, an operator, or the end). Used to recognize a lone `/` as root.
private mutating func startsStep() -> Bool {
skipSpace()
guard let character = peek() else { return false }
return character.isLetter || character == "_" || character == "@" || character == "." || character == "*"
}
private mutating func parseStep() throws -> Step {
skipSpace()
if matches("..") {

View File

@ -14,7 +14,6 @@ idkey/idkey48
impincl/impincl08
impincl/impincl27
mdocs/mdocs07
mdocs/mdocs15
mdocs/mdocs19
namedtemplate/namedtemplate12
namespace/namespace113
@ -29,7 +28,6 @@ output/output63
output/output70
output/output73
output/output86
position/position106
processorinfo/processorinfo03
sort/sort02
sort/sort10

View File

@ -25,6 +25,18 @@ struct XPathExpressionTests {
try PureXML.XPath.Query(path).boolean(over: shop())
}
@Test("a lone / is the root node, even before a closing paren or operator")
func test_rootPathBeforeTerminator() throws {
// The relative part after `/` is optional, so `/` followed by `)`, `|`, or
// an operator is the root node, not a parse that swallows the terminator
// (Apache Xalan position106, mdocs15).
#expect(try number("count(/)") == 1)
#expect(try number("count(/ | /)") == 1)
#expect(try boolean("/ = /") == true)
// Regression: `/` followed by a `*` wildcard still parses as a step.
#expect(try number("count(/*)") == 1)
}
// MARK: Arithmetic and precedence
@Test("Arithmetic honors precedence")