Skip to content

Count positional *Until() stops across the document, as jQuery does - #131

Merged
jakejackson1 merged 1 commit into
mainfrom
fix/issue-118
Oct 26, 2026
Merged

jakejackson1 merged 1 commit into
mainfrom
fix/issue-118

Conversation

@gravitypdf-bot

@gravitypdf-bot gravitypdf-bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Behaviour change. The walk-and-stop methods tested each node against the selector on its own. A positional pseudo-class therefore only ever saw a one-element set:

qp($xml, '#a')->nextUntil(':first');   // before: nothing (every sibling is :first of itself); jQuery: every sibling
qp($xml, '#u1')->closest(':first');    // before: #u1 itself; jQuery: nothing

What jQuery does, and which option this takes

The issue offered two options: batch each walk into one candidate set, or document the per-node behaviour. jQuery 3.7.1 does neither:

  • The *Until() stop is tested with jQuery(elem).is(until). For a positional selector (rneedsContext), .is() evaluates jQuery(selector) against the whole document. So nextUntil(':eq(3)') stops at the document's fourth element, wherever that is.
  • closest() skips positional selectors outright. Its source says "Positional selectors never match, since there's no selection context".
  • next(), prev() and parent() look at the adjacent node only and filter the combined results.

Batching (option 1) would match none of these. It would make nextUntil(':eq(1)') stop at the walk's second sibling, which jQuery never does. This PR follows jQuery instead:

  • In nextUntil(), prevUntil() and parentsUntil(), walk() tests the stop with the new NodeMatcher::matchesInDocument(). For a positional selector it runs the selector once from the document element through DOMTraverser, the same path replaceAll() uses, on the first node a walk reaches, and answers from that set. Other selectors fall through to the memoized per-node matches(), so the common path costs nothing extra. The positional check is the existing, memoized Util::selectorHasPositionalPseudoClass(). It ignores positions inside :has(), which count per element (Make :has() match in find() #125).
  • closest() returns nothing for a positional selector.
  • next(), prev() and parent() keep the per-node test (option 2: documented in their DocBlocks). QueryPath's next($selector) walks to the nearest matching sibling, unlike jQuery's, so there is no jQuery result set to filter. For a single starting element, per-node gives the same answer as jQuery: :first matches and :eq(1) doesn't.

The "positional selectors need a set" rule lives in NodeMatcher, next to the per-node test it corrects, so walk() has one stop test. The one exception is closest()'s early return. No change to src/CSS/.

jQuery vs before vs after

Document: <r><i#a/><i#b.k/><i#c/><i#d.k/><i#e/></r>, with document order r, a, b, c, d, e. The ancestor document is r > s#s1 > t#t1 > u#u1 and r > s#s2 > t#t2 > u#u2.

Call jQuery 3.7.1 before after
#a.nextUntil(':eq(1)') (issue example) b, c, d, e b, c, d, e b, c, d, e
#a.nextUntil(':first') b, c, d, e (empty) b, c, d, e
#a.nextUntil(':eq(3)') b b, c, d, e b
[a, b].nextUntil(':last') b, c, d (empty) b, c, d
#e.prevUntil(':eq(2)') d, c d, c, b, a d, c
#e.prevUntil('.k:first') d, c d, c, b, a d, c
#u2.parentsUntil(':first') t2, s2 (empty) t2, s2
#u2.parentsUntil(':eq(4)') t2 t2, s2, r t2
#u1.closest(':first') (empty) u1 (empty)
#u1.closest('t:first') (empty) t1 (empty)
#a.next(':first') b b b
[c, a].next(':first') d d, b d, b (documented)

Interplay with #117

The set methods (prevAll(), parents(), siblings(), …) count a positional selector within the set in document order (#66). That does not depend on the result order from #117. Issue118Test::testSetMethodsCountPositionsInDocumentOrder pins those results under the #117 ordering. jQuery counts in walk order instead: $('#e').prevAll(':first') is #d, while QueryPath returns #a. Making the two agree would mean changing how DOMTraverser orders positional filters, so it is left out of this PR (#134).

Tests

  • tests/Issues/Issue118Test.php has seven tests: the issue example, one per *Until() method, closest(), per-node next()/prev()/parent(), and set-method positions. Every expected value comes from jQuery 3.7.1 on jsdom, except the two cases marked as QueryPath-specific. Five of the seven fail on main.
  • vendor/bin/phpunit: 705 tests, 1976 assertions, 0 failures (1 skipped). composer run lint and composer run lint:min-php are clean.

Rebased onto #130

Rebased onto the updated #130. The document-wide stop moved from a documentMatches() helper and a second stop set in walk() into NodeMatcher::matchesInDocument(), which also builds the set only once a walk reaches a candidate. Issue118Test uses the shared selectIds()/idList() helpers from TestCase.

QueryPath's own is() still counts a positional selector within the match set, where jQuery's .is() counts across the document, so nextUntil(':eq(3)') and is(':eq(3)') use different counts. That is outside this PR.

Fixes #118.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.23%. Comparing base (6ae6acf) to head (5d673e9).
⚠️ Report is 18 commits behind head on main.

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #131      +/-   ##
============================================
+ Coverage     90.69%   91.23%   +0.54%     
- Complexity     1424     1441      +17     
============================================
  Files            25       26       +1     
  Lines          3170     3183      +13     
============================================
+ Hits           2875     2904      +29     
+ Misses          295      279      -16     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The stop selector of nextUntil(), prevUntil() and parentsUntil() was
tested one node at a time, so a positional pseudo-class saw a
one-element set. jQuery tests it with .is(), which counts across the
document, and closest() never matches a positional selector.

Fixes #118.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jakejackson1
jakejackson1 merged commit add0705 into main Oct 26, 2026
14 checks passed
@jakejackson1
jakejackson1 deleted the fix/issue-118 branch October 26, 2026 05:50
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.

Positional selectors in closest(), next() and the *Until() methods see a one-element set

2 participants