Skip to content

Return false from matches() when the node-set argument selects nothing - #141

Merged
zhengchun merged 1 commit into
antchfx:masterfrom
youdie006:matches-empty-nodeset
Sep 24, 2026
Merged

zhengchun merged 1 commit into
antchfx:masterfrom
youdie006:matches-empty-nodeset

Conversation

@youdie006

Copy link
Copy Markdown
Contributor

starts-with, ends-with and contains all return false when their node-set argument selects no node (func.go:354, :381, :408). matches() at func.go:436 returned "", so a boolean function handed back a string.

notFunc (func.go:638) has no case string and falls through to default: return false, so the string is swallowed rather than negated:

not(matches(//no-such, 'x'))       false     <- the three siblings all give true
not(starts-with(//no-such, 'x'))   true
not(contains(//no-such, 'x'))      true
not(ends-with(//no-such, 'x'))     true

boolean(matches(//no-such, 'x'))   false     <- these two are the same X
not(not(matches(//no-such, 'x')))  true

In practice //a[not(matches(@href, '^https'))] drops every <a> that has no href, while the starts-with spelling keeps them. A missing attribute reaches the same branch.

It went unnoticed because asBool (func.go:257) maps "" to false, so a bare predicate like //*[matches(@nope,'x')] is corrected on the way out. not() is the caller that is not defensive.

There is a second way to close this, and it is worth saying out loud: leaving the early return out and letting the regex run against "" also removes the string return, and is arguably closer to string(empty node-set) being "". It differs observably - matches(//no-such, '^$') becomes true rather than false. I went with false because it is what the three sibling functions already do, but the type bug is there under either, so say the word if you prefer the other.

Verification

Base 487996fb. New case sits next to Test_func_matches in xpath_function_test.go; no new file.

row func.go md5 -run Test_func_matches
pristine 8c32ba3520ab162f1c4a2171b389a9a5 FAIL (Test_func_matches_empty_nodeset)
this PR fce2401a8e2c34dc9e76ec94f464dea0 ok
return true instead f1d1a2f9698a37defb59a11fe24598b3 FAIL
revert 8c32ba3520ab162f1c4a2171b389a9a5 FAIL

The pre-existing Test_func_matches passes in every row, including pristine - it never reaches the nil-node branch, so nothing in the suite currently pins this either way. The over-correction is caught only by the new case; I am not claiming it breaks an existing test.

go test ./... ok, go vet ./... silent, gofmt -l clean for both changed files. gofmt -l . does list func_go110.go and func_pre_go110.go, which are unformatted on 487996fb as well and are untouched here.

The same four-line case query: block appears in normalizespaceFunc at func.go:463; "" is right there, matching substringFunc and the convention #136 established for the string functions. Only matchesFunc is changed.

Written with AI assistance (Claude); the measurements above were run locally and I have reviewed the change.

starts-with, ends-with and contains all return false when their node-set
argument selects no node (func.go:354, :381, :408). matches() returned ""
instead, so a boolean function handed back a string.

notFunc has no case for string and falls through to its default, so
not(matches(//no-such, 'x')) answered false where the three siblings answer
true. Idiomatically //a[not(matches(@href, '^https'))] dropped every <a> with
no href. boolean(X) and not(not(X)) also disagreed for the same X.

asBool maps "" to false, which is why a bare predicate looked right and this
went unnoticed.
@coveralls

Copy link
Copy Markdown

Coverage Status

coverage: 84.163% (+0.4%) from 83.798% — youdie006:matches-empty-nodeset into antchfx:master

@zhengchun
zhengchun merged commit 4c88efd into antchfx:master Sep 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants