From 9070ccc3f1b24c284de3b90ca0985f34bf92ef1d Mon Sep 17 00:00:00 2001 From: Mihaela Mihaljevic Date: Wed, 24 Jun 2026 04:21:46 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 2 ++ Sources/XPath/CompilerPaths.swift | 18 +++++++++++++++--- Tests/Fixtures/xalan-baseline.txt | 2 -- Tests/XPathExpressionTests.swift | 12 ++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be1781..5d840e6 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 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`. diff --git a/Sources/XPath/CompilerPaths.swift b/Sources/XPath/CompilerPaths.swift index c4f3655..b9fce1e 100644 --- a/Sources/XPath/CompilerPaths.swift +++ b/Sources/XPath/CompilerPaths.swift @@ -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("..") { diff --git a/Tests/Fixtures/xalan-baseline.txt b/Tests/Fixtures/xalan-baseline.txt index 0f51ce1..f0f6fe9 100644 --- a/Tests/Fixtures/xalan-baseline.txt +++ b/Tests/Fixtures/xalan-baseline.txt @@ -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 diff --git a/Tests/XPathExpressionTests.swift b/Tests/XPathExpressionTests.swift index 4d8487e..1376eba 100644 --- a/Tests/XPathExpressionTests.swift +++ b/Tests/XPathExpressionTests.swift @@ -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")