Skip to content

Commit 17b86c4

Browse files
Peter Stengerclaude
andcommitted
Fix :not() silently dropping multi-segment selectors; bump v0.9.3
Custom rule selectors like `img:not(pl-overlay img)` were silently ignored: `checkSelfNegations` skipped any `:not(...)` alternative with more than one segment. Thread the cursor through `nodeMatchesSegment` so multi-segment negations can reuse `checkPrefix` to validate the ancestor/sibling chain, matching `Element.matches` semantics. Covers descendant, child, and sibling combinators inside `:not()`, including Mustache sections (e.g. `button:not({{#admin}} button)`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cb13fa9 commit 17b86c4

4 files changed

Lines changed: 130 additions & 26 deletions

File tree

lsp/package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lsp/server/test/selectorMatcher.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,90 @@ describe('matchSelector', () => {
664664
expect(matches[0].text).toContain('id="no"');
665665
});
666666

667+
// --- Multi-segment :not() (descendant / child / sibling context) ---
668+
669+
it(':not(ancestor descendant) excludes nodes under the named ancestor', () => {
670+
const tree = parseText(
671+
'<pl-overlay><pl-background><img src="bg.png"></pl-background></pl-overlay>' +
672+
'<figure><img src="other.png"></figure>' +
673+
'<img src="standalone.png">',
674+
);
675+
const matches = matchSelector(
676+
tree.rootNode,
677+
parseSelector('img:not([style]):not([class]):not(pl-overlay img)')!,
678+
);
679+
// Only the figure img and the standalone img qualify; the pl-overlay img is excluded.
680+
expect(matches).toHaveLength(2);
681+
expect(matches.map(m => m.text).join(' ')).toMatch(/other\.png/);
682+
expect(matches.map(m => m.text).join(' ')).toMatch(/standalone\.png/);
683+
expect(matches.map(m => m.text).join(' ')).not.toMatch(/bg\.png/);
684+
});
685+
686+
it(':not(ancestor descendant) still matches nodes without that ancestor', () => {
687+
const tree = parseText('<section><img></section>');
688+
const matches = matchSelector(
689+
tree.rootNode,
690+
parseSelector('img:not(pl-overlay img)')!,
691+
);
692+
expect(matches).toHaveLength(1);
693+
});
694+
695+
it(':not(parent > child) excludes only direct children', () => {
696+
const tree = parseText(
697+
'<table><thead><td>a</td></thead><tbody><tr><td>b</td></tr></tbody></table>',
698+
);
699+
// Matches the td inside tbody > tr (not a direct child of thead), but excludes the
700+
// td inside thead. Note the tbody-nested td is NOT excluded because the selector
701+
// targets `thead > td`, and the nested td is a child of tr not thead.
702+
const matches = matchSelector(
703+
tree.rootNode,
704+
parseSelector('td:not(thead > td)')!,
705+
);
706+
expect(matches).toHaveLength(1);
707+
expect(matches[0].text).toContain('<td');
708+
});
709+
710+
it(':not(a + b) excludes adjacent-sibling matches', () => {
711+
const tree = parseText(
712+
'<section><h2></h2><p id="after"></p></section>' +
713+
'<section><div></div><p id="lonely"></p></section>',
714+
);
715+
const matches = matchSelector(
716+
tree.rootNode,
717+
parseSelector('p:not(h2 + p)')!,
718+
);
719+
// Only the p that is NOT directly after an h2 should match.
720+
expect(matches).toHaveLength(1);
721+
expect(matches[0].text).toContain('id="lonely"');
722+
});
723+
724+
it(':not(ancestor descendant) nested in :has() composes correctly', () => {
725+
const tree = parseText(
726+
'<section><pl-overlay><img></pl-overlay><img id="raw"></section>' +
727+
'<section><img id="alone"></section>',
728+
);
729+
// Section that contains an img not under pl-overlay.
730+
const matches = matchSelector(
731+
tree.rootNode,
732+
parseSelector('section:has(img:not(pl-overlay img))')!,
733+
);
734+
// Both sections qualify: one has id="raw" outside pl-overlay, the other has id="alone".
735+
expect(matches).toHaveLength(2);
736+
});
737+
738+
it(':not(Mustache-section descendant) excludes nodes inside that section', () => {
739+
const tree = parseText(
740+
'{{#admin}}<button id="delete">x</button>{{/admin}}' +
741+
'<button id="cancel">x</button>',
742+
);
743+
const matches = matchSelector(
744+
tree.rootNode,
745+
parseSelector('button:not({{#admin}} button)')!,
746+
);
747+
expect(matches).toHaveLength(1);
748+
expect(matches[0].text).toContain('id="cancel"');
749+
});
750+
667751
// --- Chained :not() AND ---
668752

669753
it('chained :not() AND together (EDC rule shape)', () => {

package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/core/selectorMatcher.ts

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@
2424
* the outer compound), plus any other selector (including Mustache
2525
* literals and type selectors) as a whole-selector check against the
2626
* node itself. Example: `{{*}}:not({{internal.*}})` matches any
27-
* interpolation whose path does not start with `internal.`.
27+
* interpolation whose path does not start with `internal.`. Multi-segment
28+
* selectors with combinators are supported and tested against the node's
29+
* ancestor/sibling context, matching `Element.matches` semantics — e.g.
30+
* `img:not(pl-overlay img)` matches any `img` that is not a descendant of
31+
* `pl-overlay`, and `td:not(thead td)` matches `td`s outside a `thead`.
2832
* - `:root` — the tree-sitter fragment root (the whole document). Unlike
2933
* browser CSS where `:root` matches `<html>`, this matches the parse-tree
3034
* root so it works on partials/fragments too. Useful as a document-scoped
@@ -651,14 +655,25 @@ function hasDescendantMatch(node: BalanceNode, selector: ParsedSelector): boolea
651655
return false;
652656
}
653657

654-
function checkSelfNegations(node: BalanceNode, negations: ParsedSelector[], rootNode: BalanceNode): boolean {
658+
function checkSelfNegations(
659+
node: BalanceNode,
660+
negations: ParsedSelector[],
661+
rootNode: BalanceNode,
662+
cursor: Cursor,
663+
): boolean {
655664
for (const sel of negations) {
656665
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;
666+
if (alt.length === 0) continue;
667+
const lastSegment = alt[alt.length - 1];
668+
if (!nodeMatchesSegment(node, lastSegment, rootNode, cursor)) continue;
669+
if (alt.length === 1) return false;
670+
// Multi-segment negation (e.g. `:not(ancestor descendant)`): the last
671+
// segment matched the node itself; walk back through the remaining
672+
// segments against the node's ancestor/sibling cursor. If the chain
673+
// also matches, the negation fires.
674+
if (checkPrefix(cursor, alt, alt.length - 2, lastSegment.combinator, rootNode)) {
675+
return false;
676+
}
662677
}
663678
}
664679
return true;
@@ -671,11 +686,16 @@ function matchesName(actual: string | null, segment: Segment): boolean {
671686
return actual === segment.name;
672687
}
673688

674-
function nodeMatchesSegment(node: BalanceNode, segment: Segment, rootNode: BalanceNode): boolean {
689+
function nodeMatchesSegment(
690+
node: BalanceNode,
691+
segment: Segment,
692+
rootNode: BalanceNode,
693+
cursor: Cursor,
694+
): boolean {
675695
if (segment.rootOnly) {
676696
if (node !== rootNode) return false;
677697
return checkDescendants(node, segment.descendantChecks)
678-
&& checkSelfNegations(node, segment.selfNegations, rootNode);
698+
&& checkSelfNegations(node, segment.selfNegations, rootNode, cursor);
679699
}
680700
const baseMatches = (() => {
681701
switch (segment.kind) {
@@ -714,7 +734,7 @@ function nodeMatchesSegment(node: BalanceNode, segment: Segment, rootNode: Balan
714734
}
715735
})();
716736
if (!baseMatches) return false;
717-
return checkSelfNegations(node, segment.selfNegations, rootNode);
737+
return checkSelfNegations(node, segment.selfNegations, rootNode, cursor);
718738
}
719739

720740
interface Cursor {
@@ -738,16 +758,16 @@ function checkPrefix(
738758
for (let i = cursor.indexInSiblings - 1; i >= 0; i--) {
739759
const sib = cursor.siblings[i];
740760
if (!isMatchableNode(sib)) continue;
741-
if (!nodeMatchesSegment(sib, segment, rootNode)) {
742-
if (stepCombinator === 'adjacent-sibling') return false;
743-
continue;
744-
}
745-
const newCursor: Cursor = {
761+
const sibCursor: Cursor = {
746762
ancestors: cursor.ancestors,
747763
siblings: cursor.siblings,
748764
indexInSiblings: i,
749765
};
750-
if (checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
766+
if (!nodeMatchesSegment(sib, segment, rootNode, sibCursor)) {
767+
if (stepCombinator === 'adjacent-sibling') return false;
768+
continue;
769+
}
770+
if (checkPrefix(sibCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
751771
if (stepCombinator === 'adjacent-sibling') return false;
752772
}
753773
return false;
@@ -770,13 +790,13 @@ function checkPrefix(
770790
if (!matchesName(entry.name, segment)) return false;
771791
if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) return false;
772792
if (!checkDescendants(entry.node, segment.descendantChecks)) return false;
773-
if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode)) return false;
774-
const newCursor: Cursor = {
793+
const ancestorCursor: Cursor = {
775794
ancestors: cursor.ancestors.slice(0, a),
776795
siblings: entry.siblings,
777796
indexInSiblings: entry.indexInSiblings,
778797
};
779-
return checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode);
798+
if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode, ancestorCursor)) return false;
799+
return checkPrefix(ancestorCursor, segments, segIdx - 1, segment.combinator, rootNode);
780800
}
781801
return false;
782802
}
@@ -787,13 +807,13 @@ function checkPrefix(
787807
if (!matchesName(entry.name, segment)) continue;
788808
if (segment.kind === 'html' && !checkAttributes(entry.node, segment.attributes)) continue;
789809
if (!checkDescendants(entry.node, segment.descendantChecks)) continue;
790-
if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode)) continue;
791-
const newCursor: Cursor = {
810+
const ancestorCursor: Cursor = {
792811
ancestors: cursor.ancestors.slice(0, a),
793812
siblings: entry.siblings,
794813
indexInSiblings: entry.indexInSiblings,
795814
};
796-
if (checkPrefix(newCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
815+
if (!checkSelfNegations(entry.node, segment.selfNegations, rootNode, ancestorCursor)) continue;
816+
if (checkPrefix(ancestorCursor, segments, segIdx - 1, segment.combinator, rootNode)) return true;
797817
}
798818
return false;
799819
}
@@ -862,8 +882,8 @@ function matchAlternative(
862882
siblings: BalanceNode[],
863883
indexInSiblings: number,
864884
) {
865-
if (nodeMatchesSegment(node, lastSegment, rootNode)) {
866-
const cursor: Cursor = { ancestors, siblings, indexInSiblings };
885+
const cursor: Cursor = { ancestors, siblings, indexInSiblings };
886+
if (nodeMatchesSegment(node, lastSegment, rootNode, cursor)) {
867887
if (
868888
segments.length === 1 ||
869889
checkPrefix(cursor, segments, segments.length - 2, lastSegment.combinator, rootNode)

0 commit comments

Comments
 (0)