@reteps/tree-sitter-htmlmustache 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,14 +10,21 @@
10
10
  * Supported user-facing syntax:
11
11
  * - Tag names (`div`), universal (`*`), classes (`.foo`), ids (`#foo`)
12
12
  * - Attributes: `[attr]`, `[attr=v]`, `[attr^=v]`, `[attr*=v]`, `[attr$=v]`, `[attr~=v]`
13
- * - Descendant (space) and child (`>`) combinators
13
+ * - Descendant (space), child (`>`), adjacent-sibling (`+`), and
14
+ * general-sibling (`~`) combinators. Sibling combinators skip over text /
15
+ * whitespace nodes (CSS semantics) and work across HTML and Mustache
16
+ * constructs: e.g. `label + input`, `{{foo}} + p`, `h2 ~ {{#items}}`.
14
17
  * - Mustache variables: `{{path}}` and `{{{path}}}` (raw)
15
18
  * - Mustache sections: `{{#name}}` and `{{^name}}` (inverted)
16
19
  * - Mustache comments: `{{!content}}`
17
20
  * - Mustache partials: `{{>name}}`
18
21
  * - Glob wildcard `*` inside the argument: `{{options.*}}`, `{{*.deprecated}}`, `{{*}}`
19
22
  * - `:has(selector)` — element has a matching descendant
20
- * - `:not(...)` over any attribute/class/id/:has form
23
+ * - `:not(...)` negation. Accepts attributes/class/id/`:has` (folded into
24
+ * the outer compound), plus any other selector (including Mustache
25
+ * literals and type selectors) as a whole-selector check against the
26
+ * node itself. Example: `{{*}}:not({{internal.*}})` matches any
27
+ * interpolation whose path does not start with `internal.`.
21
28
  * - `:root` — the tree-sitter fragment root (the whole document). Unlike
22
29
  * browser CSS where `:root` matches `<html>`, this matches the parse-tree
23
30
  * root so it works on partials/fragments too. Useful as a document-scoped
@@ -26,15 +33,19 @@
26
33
  * `pl-answer-panel` is. Cannot combine with tag/class/id/attribute in the
27
34
  * same compound (only with `:has` / `:not(:has(...))`). Inside `:has(...)`,
28
35
  * `:root` refers to the element being checked, not the document.
36
+ * - `:is(a, b, ...)` — matches if any alternative matches. Expanded at parse
37
+ * time into the Cartesian product of alternatives, so `:is(a, b) :is(c, d)`
38
+ * is equivalent to `a c, a d, b c, b d`. Alternatives inside `:is` that
39
+ * contain combinators are only allowed when the `:is(...)` stands alone in
40
+ * its compound (e.g. `:is(div > span, p)` works; `x:is(div > span, p)`
41
+ * does not, since a combinator can't be merged into another compound).
29
42
  * - Comma-separated alternatives
30
43
  *
31
44
  * Unsupported (parseSelector returns null, rule is skipped):
32
- * - Sibling combinators (`+`, `~`)
33
45
  * - `[attr|=v]`, case-insensitive `i` flag
34
46
  * - Mixed HTML + Mustache kinds in one compound (e.g. `img{{foo}}`)
35
47
  * - `{{/end}}` (end tags aren't standalone nodes)
36
48
  * - `{{=<% %>=}}` (delimiter changes aren't grammar-tracked)
37
- * - Mustache literals inside `:not(...)` (only attribute/class/id/:has)
38
49
  */
39
50
 
40
51
  import { parse as parselParse, type AST, type Token, type AttributeToken, type ClassToken, type IdToken } from 'parsel-js';
@@ -73,6 +84,8 @@ export interface DescendantCheck {
73
84
  negated: boolean; // true for :not(:has(...))
74
85
  }
75
86
 
87
+ export type Combinator = 'descendant' | 'child' | 'adjacent-sibling' | 'general-sibling';
88
+
76
89
  export interface Segment {
77
90
  kind: SegmentKind;
78
91
  rootOnly: boolean; // true for `:root` — matches only the tree-sitter fragment root
@@ -80,7 +93,8 @@ export interface Segment {
80
93
  pathRegex?: RegExp; // compiled glob when `name` contains `*`
81
94
  attributes: AttributeConstraint[];
82
95
  descendantChecks: DescendantCheck[];
83
- combinator: 'descendant' | 'child';
96
+ selfNegations: ParsedSelector[]; // :not(X) where X is a full sub-selector tested against the node itself
97
+ combinator: Combinator;
84
98
  }
85
99
 
86
100
  /** A parsed selector is a list of alternatives (from comma-separated parts). */
@@ -208,17 +222,101 @@ export function parseSelector(raw: string): ParsedSelector | null {
208
222
  const tops = ast.type === 'list' ? ast.list : [ast];
209
223
  const alts: Segment[][] = [];
210
224
  for (const top of tops) {
211
- const segments: Segment[] = [];
212
- if (!collectSegments(top, 'descendant', segments)) return null;
213
- if (segments.length === 0) return null;
214
- alts.push(segments);
225
+ const expanded = expandIs(top);
226
+ if (expanded === null) return null;
227
+ for (const exp of expanded) {
228
+ const segments: Segment[] = [];
229
+ if (!collectSegments(exp, 'descendant', segments)) return null;
230
+ if (segments.length === 0) return null;
231
+ alts.push(segments);
232
+ }
215
233
  }
216
234
  return alts.length > 0 ? alts : null;
217
235
  }
218
236
 
237
+ /**
238
+ * Expand `:is(...)` pseudo-classes into explicit alternatives. Returns an
239
+ * array of equivalent ASTs with every `:is` removed (Cartesian product over
240
+ * alternatives), or `null` if the expansion contains an unsupported shape
241
+ * (e.g. complex alternatives inside a mixed compound).
242
+ */
243
+ function expandIs(ast: AST): AST[] | null {
244
+ switch (ast.type) {
245
+ case 'list': {
246
+ const out: AST[] = [];
247
+ for (const alt of ast.list) {
248
+ const expanded = expandIs(alt);
249
+ if (expanded === null) return null;
250
+ out.push(...expanded);
251
+ }
252
+ return out;
253
+ }
254
+ case 'complex': {
255
+ const lefts = expandIs(ast.left);
256
+ if (lefts === null) return null;
257
+ const rights = expandIs(ast.right);
258
+ if (rights === null) return null;
259
+ const out: AST[] = [];
260
+ for (const l of lefts) for (const r of rights) {
261
+ out.push({ ...ast, left: l, right: r });
262
+ }
263
+ return out;
264
+ }
265
+ case 'compound': {
266
+ // A compound whose sole token is `:is(...)` can be replaced by any
267
+ // shape (including complex alternatives). Mixed compounds require each
268
+ // alternative to be mergeable token-by-token.
269
+ if (ast.list.length === 1) {
270
+ const tok = ast.list[0];
271
+ if (tok.type === 'pseudo-class' && tok.name === 'is') {
272
+ if (!tok.subtree) return null;
273
+ return expandIs(tok.subtree);
274
+ }
275
+ }
276
+ return expandCompoundWithIs(ast.list);
277
+ }
278
+ default:
279
+ if (ast.type === 'pseudo-class' && ast.name === 'is') {
280
+ if (!ast.subtree) return null;
281
+ return expandIs(ast.subtree);
282
+ }
283
+ return [ast];
284
+ }
285
+ }
286
+
287
+ function expandCompoundWithIs(tokens: Token[]): AST[] | null {
288
+ let variants: Token[][] = [[]];
289
+ for (const tok of tokens) {
290
+ if (tok.type === 'pseudo-class' && tok.name === 'is') {
291
+ if (!tok.subtree) return null;
292
+ const alts = expandIs(tok.subtree);
293
+ if (alts === null) return null;
294
+ const next: Token[][] = [];
295
+ for (const base of variants) {
296
+ for (const alt of alts) {
297
+ if (alt.type === 'compound') {
298
+ next.push([...base, ...alt.list]);
299
+ } else if (alt.type === 'complex' || alt.type === 'list' || alt.type === 'relative') {
300
+ // Can't splice a combinator-bearing selector into a compound.
301
+ return null;
302
+ } else {
303
+ next.push([...base, alt]);
304
+ }
305
+ }
306
+ }
307
+ variants = next;
308
+ } else {
309
+ variants = variants.map(v => [...v, tok]);
310
+ }
311
+ }
312
+ return variants.map(list =>
313
+ list.length === 1 ? (list[0] as AST) : ({ type: 'compound', list } as AST),
314
+ );
315
+ }
316
+
219
317
  function collectSegments(
220
318
  ast: AST,
221
- combinator: 'descendant' | 'child',
319
+ combinator: Combinator,
222
320
  out: Segment[],
223
321
  ): boolean {
224
322
  if (ast.type === 'complex') {
@@ -236,10 +334,12 @@ function collectSegments(
236
334
  return true;
237
335
  }
238
336
 
239
- function mapCombinator(c: string): 'descendant' | 'child' | null {
337
+ function mapCombinator(c: string): Combinator | null {
240
338
  const trimmed = c.trim();
241
339
  if (trimmed === '') return 'descendant';
242
340
  if (trimmed === '>') return 'child';
341
+ if (trimmed === '+') return 'adjacent-sibling';
342
+ if (trimmed === '~') return 'general-sibling';
243
343
  return null;
244
344
  }
245
345
 
@@ -252,6 +352,7 @@ function segmentFromCompound(ast: AST): Segment | null {
252
352
  let rootOnly = false;
253
353
  const attributes: AttributeConstraint[] = [];
254
354
  const descendantChecks: DescendantCheck[] = [];
355
+ const selfNegations: ParsedSelector[] = [];
255
356
 
256
357
  // Once a Mustache kind is picked, no other kind tokens may appear.
257
358
  const forbidChange = (requested: SegmentKind): boolean => {
@@ -310,7 +411,7 @@ function segmentFromCompound(ast: AST): Segment | null {
310
411
  break;
311
412
  }
312
413
  if (token.name === 'not') {
313
- if (!applyNegatedSubtree(token.subtree, attributes, descendantChecks)) return null;
414
+ if (!applyNegatedSubtree(token.subtree, attributes, descendantChecks, selfNegations)) return null;
314
415
  break;
315
416
  }
316
417
  if (token.name === 'root') {
@@ -338,7 +439,7 @@ function segmentFromCompound(ast: AST): Segment | null {
338
439
 
339
440
  const isHtml = kind === 'html';
340
441
  const finalAttrs = isHtml ? attributes : [];
341
- return { kind, rootOnly, name, pathRegex, attributes: finalAttrs, descendantChecks, combinator: 'descendant' };
442
+ return { kind, rootOnly, name, pathRegex, attributes: finalAttrs, descendantChecks, selfNegations, combinator: 'descendant' };
342
443
  }
343
444
 
344
445
  function mustacheKindFromMarker(name: string): SegmentKind | null {
@@ -401,6 +502,7 @@ function applyNegatedSubtree(
401
502
  subtree: AST | undefined,
402
503
  attributes: AttributeConstraint[],
403
504
  descendantChecks: DescendantCheck[],
505
+ selfNegations: ParsedSelector[],
404
506
  ): boolean {
405
507
  if (!subtree) return false;
406
508
  if (subtree.type === 'attribute') {
@@ -423,6 +525,13 @@ function applyNegatedSubtree(
423
525
  descendantChecks.push({ selector: sel, negated: true });
424
526
  return true;
425
527
  }
528
+ // Fall back to a whole-selector negation: `:not(X)` where X is a Mustache
529
+ // literal or any other parseable selector, tested against the node itself.
530
+ const sel = subtreeToSelector(subtree);
531
+ if (sel) {
532
+ selfNegations.push(sel);
533
+ return true;
534
+ }
426
535
  return false;
427
536
  }
428
537
 
@@ -455,6 +564,8 @@ interface AncestorEntry {
455
564
  kind: AncestorKind;
456
565
  name: string; // lowercased
457
566
  node: BalanceNode;
567
+ siblings: BalanceNode[]; // the node's parent's children; empty for root
568
+ indexInSiblings: number; // 0 for root
458
569
  }
459
570
 
460
571
  type AncestorKind = 'html' | 'section' | 'inverted' | 'root';
@@ -534,12 +645,25 @@ function checkDescendants(node: BalanceNode, checks: DescendantCheck[]): boolean
534
645
  }
535
646
 
536
647
  function hasDescendantMatch(node: BalanceNode, selector: ParsedSelector): boolean {
537
- for (const child of node.children) {
538
- if (matchSelector(child, selector).length > 0) return true;
648
+ for (let i = 0; i < node.children.length; i++) {
649
+ if (matchSelector(node.children[i], selector, node.children, i).length > 0) return true;
539
650
  }
540
651
  return false;
541
652
  }
542
653
 
654
+ function checkSelfNegations(node: BalanceNode, negations: ParsedSelector[], rootNode: BalanceNode): boolean {
655
+ for (const sel of negations) {
656
+ for (const alt of sel) {
657
+ // A selfNegation selector is a single-segment check against the node itself.
658
+ // Multi-segment alternatives (e.g. `:not(a b)`) aren't sensibly testable
659
+ // against a single node, so they're treated as never matching => pass.
660
+ if (alt.length !== 1) continue;
661
+ if (nodeMatchesSegment(node, alt[0], rootNode)) return false;
662
+ }
663
+ }
664
+ return true;
665
+ }
666
+
543
667
  function matchesName(actual: string | null, segment: Segment): boolean {
544
668
  if (segment.name === null) return true; // wildcard
545
669
  if (actual === null) return false;
@@ -550,59 +674,92 @@ function matchesName(actual: string | null, segment: Segment): boolean {
550
674
  function nodeMatchesSegment(node: BalanceNode, segment: Segment, rootNode: BalanceNode): boolean {
551
675
  if (segment.rootOnly) {
552
676
  if (node !== rootNode) return false;
553
- return checkDescendants(node, segment.descendantChecks);
554
- }
555
- switch (segment.kind) {
556
- case 'html': {
557
- if (!HTML_ELEMENT_TYPES.has(node.type)) return false;
558
- if (segment.name !== null) {
559
- const tagName = getTagName(node)?.toLowerCase();
560
- if (tagName !== segment.name) return false;
677
+ return checkDescendants(node, segment.descendantChecks)
678
+ && checkSelfNegations(node, segment.selfNegations, rootNode);
679
+ }
680
+ const baseMatches = (() => {
681
+ switch (segment.kind) {
682
+ case 'html': {
683
+ if (!HTML_ELEMENT_TYPES.has(node.type)) return false;
684
+ if (segment.name !== null) {
685
+ const tagName = getTagName(node)?.toLowerCase();
686
+ if (tagName !== segment.name) return false;
687
+ }
688
+ return checkAttributes(node, segment.attributes) && checkDescendants(node, segment.descendantChecks);
561
689
  }
562
- return checkAttributes(node, segment.attributes) && checkDescendants(node, segment.descendantChecks);
690
+ case 'section':
691
+ if (node.type !== 'mustache_section') return false;
692
+ if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
693
+ return checkDescendants(node, segment.descendantChecks);
694
+ case 'inverted':
695
+ if (node.type !== 'mustache_inverted_section') return false;
696
+ if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
697
+ return checkDescendants(node, segment.descendantChecks);
698
+ case 'variable':
699
+ if (node.type !== 'mustache_interpolation') return false;
700
+ if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
701
+ return checkDescendants(node, segment.descendantChecks);
702
+ case 'raw':
703
+ if (node.type !== 'mustache_triple') return false;
704
+ if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
705
+ return checkDescendants(node, segment.descendantChecks);
706
+ case 'comment':
707
+ if (node.type !== 'mustache_comment') return false;
708
+ if (!matchesName(getCommentContent(node)?.toLowerCase() ?? null, segment)) return false;
709
+ return checkDescendants(node, segment.descendantChecks);
710
+ case 'partial':
711
+ if (node.type !== 'mustache_partial') return false;
712
+ if (!matchesName(getPartialName(node)?.toLowerCase() ?? null, segment)) return false;
713
+ return checkDescendants(node, segment.descendantChecks);
563
714
  }
564
- case 'section':
565
- if (node.type !== 'mustache_section') return false;
566
- if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
567
- return checkDescendants(node, segment.descendantChecks);
568
- case 'inverted':
569
- if (node.type !== 'mustache_inverted_section') return false;
570
- if (!matchesName(getSectionName(node)?.toLowerCase() ?? null, segment)) return false;
571
- return checkDescendants(node, segment.descendantChecks);
572
- case 'variable':
573
- if (node.type !== 'mustache_interpolation') return false;
574
- if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
575
- return checkDescendants(node, segment.descendantChecks);
576
- case 'raw':
577
- if (node.type !== 'mustache_triple') return false;
578
- if (!matchesName(getInterpolationPath(node)?.toLowerCase() ?? null, segment)) return false;
579
- return checkDescendants(node, segment.descendantChecks);
580
- case 'comment':
581
- if (node.type !== 'mustache_comment') return false;
582
- if (!matchesName(getCommentContent(node)?.toLowerCase() ?? null, segment)) return false;
583
- return checkDescendants(node, segment.descendantChecks);
584
- case 'partial':
585
- if (node.type !== 'mustache_partial') return false;
586
- if (!matchesName(getPartialName(node)?.toLowerCase() ?? null, segment)) return false;
587
- return checkDescendants(node, segment.descendantChecks);
588
- }
589
- }
590
-
591
- /** Does the ancestor stack satisfy remaining segments? */
592
- function checkAncestors(
593
- ancestors: AncestorEntry[],
715
+ })();
716
+ if (!baseMatches) return false;
717
+ return checkSelfNegations(node, segment.selfNegations, rootNode);
718
+ }
719
+
720
+ interface Cursor {
721
+ ancestors: AncestorEntry[]; // nodes on the path from root, excluding the match pointer itself
722
+ siblings: BalanceNode[]; // siblings of the current match pointer
723
+ indexInSiblings: number; // index of the current match pointer in its siblings
724
+ }
725
+
726
+ /** Does the prefix of segments up to segIdx satisfy the path/sibling context? */
727
+ function checkPrefix(
728
+ cursor: Cursor,
594
729
  segments: Segment[],
595
730
  segIdx: number,
596
- childCombinator: 'descendant' | 'child',
731
+ stepCombinator: Combinator,
732
+ rootNode: BalanceNode,
597
733
  ): boolean {
598
734
  if (segIdx < 0) return true;
599
735
  const segment = segments[segIdx];
736
+
737
+ if (stepCombinator === 'adjacent-sibling' || stepCombinator === 'general-sibling') {
738
+ for (let i = cursor.indexInSiblings - 1; i >= 0; i--) {
739
+ const sib = cursor.siblings[i];
740
+ if (!isMatchableNode(sib)) continue;
741
+ if (!nodeMatchesSegment(sib, segment, rootNode)) {
742
+ if (stepCombinator === 'adjacent-sibling') return false;
743
+ continue;
744
+ }
745
+ const newCursor: Cursor = {
746
+ ancestors: cursor.ancestors,
747
+ siblings: cursor.siblings,
748
+ indexInSiblings: i,
749
+ };
750
+ if (checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
751
+ if (stepCombinator === 'adjacent-sibling') return false;
752
+ }
753
+ return false;
754
+ }
755
+
756
+ // descendant or child — walk up the ancestor stack
600
757
  const ancestorKind = ancestorKindForSegment(segment);
601
758
  if (ancestorKind === null) return false; // variable/raw/comment/partial can't be ancestors
602
759
 
603
- if (childCombinator === 'child') {
604
- for (let a = ancestors.length - 1; a >= 0; a--) {
605
- const entry = ancestors[a];
760
+ if (stepCombinator === 'child') {
761
+ for (let a = cursor.ancestors.length - 1; a >= 0; a--) {
762
+ const entry = cursor.ancestors[a];
606
763
  if (entry.kind !== ancestorKind) {
607
764
  // For `:root > X` (ancestorKind='root'), any html ancestor between
608
765
  // X and the document root breaks the direct-child relationship.
@@ -613,24 +770,45 @@ function checkAncestors(
613
770
  if (!matchesName(entry.name, segment)) return false;
614
771
  if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) return false;
615
772
  if (!checkDescendants(entry.node, segment.descendantChecks)) return false;
616
- return checkAncestors(ancestors.slice(0, a), segments, segIdx - 1, segment.combinator);
773
+ if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode)) return false;
774
+ const newCursor: Cursor = {
775
+ ancestors: cursor.ancestors.slice(0, a),
776
+ siblings: entry.siblings,
777
+ indexInSiblings: entry.indexInSiblings,
778
+ };
779
+ return checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode);
617
780
  }
618
781
  return false;
619
782
  }
620
783
 
621
- for (let a = ancestors.length - 1; a >= 0; a--) {
622
- const entry = ancestors[a];
784
+ for (let a = cursor.ancestors.length - 1; a >= 0; a--) {
785
+ const entry = cursor.ancestors[a];
623
786
  if (entry.kind !== ancestorKind) continue;
624
787
  if (!matchesName(entry.name, segment)) continue;
625
788
  if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) continue;
626
789
  if (!checkDescendants(entry.node, segment.descendantChecks)) continue;
627
- if (checkAncestors(ancestors.slice(0, a), segments, segIdx - 1, segment.combinator)) {
628
- return true;
629
- }
790
+ if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode)) continue;
791
+ const newCursor: Cursor = {
792
+ ancestors: cursor.ancestors.slice(0, a),
793
+ siblings: entry.siblings,
794
+ indexInSiblings: entry.indexInSiblings,
795
+ };
796
+ if (checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
630
797
  }
631
798
  return false;
632
799
  }
633
800
 
801
+ /** True for nodes that a segment could ever match — used to skip text/whitespace when walking siblings. */
802
+ function isMatchableNode(node: BalanceNode): boolean {
803
+ return HTML_ELEMENT_TYPES.has(node.type)
804
+ || node.type === 'mustache_section'
805
+ || node.type === 'mustache_inverted_section'
806
+ || node.type === 'mustache_interpolation'
807
+ || node.type === 'mustache_triple'
808
+ || node.type === 'mustache_comment'
809
+ || node.type === 'mustache_partial';
810
+ }
811
+
634
812
  function ancestorKindForSegment(segment: Segment): AncestorKind | null {
635
813
  if (segment.rootOnly) return 'root';
636
814
  if (segment.kind === 'html') return 'html';
@@ -669,15 +847,26 @@ function getReportNode(node: BalanceNode, rootNode?: BalanceNode): BalanceNode {
669
847
  return node;
670
848
  }
671
849
 
672
- function matchAlternative(rootNode: BalanceNode, segments: Segment[]): BalanceNode[] {
850
+ function matchAlternative(
851
+ rootNode: BalanceNode,
852
+ segments: Segment[],
853
+ rootSiblings: BalanceNode[],
854
+ rootIndexInSiblings: number,
855
+ ): BalanceNode[] {
673
856
  const results: BalanceNode[] = [];
674
857
  const lastSegment = segments[segments.length - 1];
675
858
 
676
- function walk(node: BalanceNode, ancestors: AncestorEntry[]) {
859
+ function walk(
860
+ node: BalanceNode,
861
+ ancestors: AncestorEntry[],
862
+ siblings: BalanceNode[],
863
+ indexInSiblings: number,
864
+ ) {
677
865
  if (nodeMatchesSegment(node, lastSegment, rootNode)) {
866
+ const cursor: Cursor = { ancestors, siblings, indexInSiblings };
678
867
  if (
679
868
  segments.length === 1 ||
680
- checkAncestors(ancestors, segments, segments.length - 2, lastSegment.combinator)
869
+ checkPrefix(cursor, segments, segments.length - 2, lastSegment.combinator, rootNode)
681
870
  ) {
682
871
  results.push(getReportNode(node, rootNode));
683
872
  }
@@ -690,25 +879,36 @@ function matchAlternative(rootNode: BalanceNode, segments: Segment[]): BalanceNo
690
879
  ancestorKind === 'html' ? getTagName(node)?.toLowerCase() :
691
880
  getSectionName(node)?.toLowerCase();
692
881
  if (name) {
693
- newAncestors = [...ancestors, { kind: ancestorKind, name, node }];
882
+ newAncestors = [...ancestors, { kind: ancestorKind, name, node, siblings, indexInSiblings }];
694
883
  }
695
884
  }
696
885
 
697
- for (const child of node.children) walk(child, newAncestors);
886
+ for (let i = 0; i < node.children.length; i++) {
887
+ walk(node.children[i], newAncestors, node.children, i);
888
+ }
698
889
  }
699
890
 
700
891
  // Seed the ancestor stack with a root entry so `:root X` / `:root > X`
701
892
  // can find the document root as an ancestor. The root node itself is
702
893
  // never an html/section/inverted node, so it's otherwise never pushed.
703
- walk(rootNode, [{ kind: 'root', name: '', node: rootNode }]);
894
+ const rootEntry: AncestorEntry = {
895
+ kind: 'root', name: '', node: rootNode,
896
+ siblings: rootSiblings, indexInSiblings: rootIndexInSiblings,
897
+ };
898
+ walk(rootNode, [rootEntry], rootSiblings, rootIndexInSiblings);
704
899
  return results;
705
900
  }
706
901
 
707
- export function matchSelector(rootNode: BalanceNode, selector: ParsedSelector): BalanceNode[] {
902
+ export function matchSelector(
903
+ rootNode: BalanceNode,
904
+ selector: ParsedSelector,
905
+ siblings: BalanceNode[] = [],
906
+ indexInSiblings = 0,
907
+ ): BalanceNode[] {
708
908
  const allResults: BalanceNode[] = [];
709
909
  const seen = new Set<BalanceNode>();
710
910
  for (const alt of selector) {
711
- for (const node of matchAlternative(rootNode, alt)) {
911
+ for (const node of matchAlternative(rootNode, alt, siblings, indexInSiblings)) {
712
912
  if (!seen.has(node)) {
713
913
  seen.add(node);
714
914
  allResults.push(node);