Repository: denoland/deno
Pull request: fix(unstable): lint plugin fix `:has()`, `:is/where/matches` and `:not()` selectors

This PR adds support for `:has/:is/:where()` and `:not()`. The latter was already present, but found a bunch of issues with it and I'd say that it didn't really work before this PR.


Fixes https://github.com/denoland/deno/issues/28335

Changed files:
filename: cli/js/40_lint.js
patch: @@ -82,6 +82,7 @@ const PropFlags = {
 /** @typedef {import("./40_lint_types.d.ts").LintState} LintState */
 /** @typedef {import("./40_lint_types.d.ts").TransformFn} TransformFn */
 /** @typedef {import("./40_lint_types.d.ts").MatchContext} MatchContext */
+/** @typedef {import("./40_lint_types.d.ts").MatcherFn} MatcherFn */
 
 /** @type {LintState} */
 const state = {
@@ -770,18 +771,23 @@ function getString(strTable, id) {
 
 /** @implements {MatchContext} */
 class MatchCtx {
+  parentLimitIdx = 0;
+
   /**
    * @param {AstContext} ctx
+   * @param {CancellationToken} cancellationToken
    */
-  constructor(ctx) {
+  constructor(ctx, cancellationToken) {
     this.ctx = ctx;
+    this.cancellationToken = cancellationToken;
   }
 
   /**
    * @param {number} idx
    * @returns {number}
    */
   getParent(idx) {
+    if (idx === this.parentLimitIdx) return AST_IDX_INVALID;
     const parent = readParent(this.ctx.buf, idx);
 
     const parentType = readType(this.ctx.buf, parent);
@@ -953,13 +959,31 @@ class MatchCtx {
 
     return out;
   }
+
+  /**
+   * Used for `:has()` and `:not()`
+   * @param {MatcherFn[]} selectors
+   * @param {number} idx
+   * @returns {boolean}
+   */
+  subSelect(selectors, idx) {
+    const prevLimit = this.parentLimitIdx;
+    this.parentLimitIdx = idx;
+
+    try {
+      return subTraverse(this.ctx, selectors, idx, idx, this.cancellationToken);
+    } finally {
+      this.parentLimitIdx = prevLimit;
+    }
+  }
 }
 
 /**
  * @param {Uint8Array} buf
+ * @param {CancellationToken} token
  * @returns {AstContext}
  */
-function createAstContext(buf) {
+function createAstContext(buf, token) {
   /** @type {Map<number, string>} */
   const strTable = new Map();
 
@@ -1039,7 +1063,7 @@ function createAstContext(buf) {
     propByStr,
     matcher: /** @type {*} */ (null),
   };
-  ctx.matcher = new MatchCtx(ctx);
+  ctx.matcher = new MatchCtx(ctx, token);
 
   setNodeGetters(ctx);
 
@@ -1060,7 +1084,8 @@ const NOOP = (_node) => {};
  * @param {Uint8Array} serializedAst
  */
 export function runPluginsForFile(fileName, serializedAst) {
-  const ctx = createAstContext(serializedAst);
+  const token = new CancellationToken();
+  const ctx = createAstContext(serializedAst, token);
 
   /** @type {Map<string, CompiledVisitor["info"]>}>} */
   const bySelector = new Map();
@@ -1169,7 +1194,6 @@ export function runPluginsForFile(fileName, serializedAst) {
     visitors.push({ info, matcher });
   }
 
-  const token = new CancellationToken();
   // Traverse ast with all visitors at the same time to avoid traversing
   // multiple times.
   try {
@@ -1191,11 +1215,12 @@ export function runPluginsForFile(fileName, serializedAst) {
  * @param {CancellationToken} cancellationToken
  */
 function traverse(ctx, visitors, idx, cancellationToken) {
+  const { buf } = ctx;
+
   while (idx !== AST_IDX_INVALID) {
     if (cancellationToken.isCancellationRequested()) return;
 
-    const { buf } = ctx;
-    const nodeType = readType(ctx.buf, idx);
+    const nodeType = readType(buf, idx);
 
     /** @type {VisitorFn[] | null} */
     let exits = null;
@@ -1240,6 +1265,51 @@ function traverse(ctx, visitors, idx, cancellationToken) {
   }
 }
 
+/**
+ * Used for subqueries in `:has()` and `:not()`
+ * @param {AstContext} ctx
+ * @param {MatcherFn[]} selectors
+ * @param {number} rootIdx
+ * @param {number} idx
+ * @param {CancellationToken} cancellationToken
+ * @returns {boolean}
+ */
+function subTraverse(ctx, selectors, rootIdx, idx, cancellationToken) {
+  const { buf } = ctx;
+
+  while (idx > AST_IDX_INVALID) {
+    if (cancellationToken.isCancellationRequested()) return false;
+
+    const nodeType = readType(buf, idx);
+
+    if (nodeType !== AST_GROUP_TYPE) {
+      for (let i = 0; i < selectors.length; i++) {
+        const sel = selectors[i];
+
+        if (sel(ctx.matcher, idx)) {
+          return true;
+        }
+      }
+    }
+
+    const childIdx = readChild(buf, idx);
+    if (
+      childIdx > AST_IDX_INVALID &&
+      subTraverse(ctx, selectors, rootIdx, childIdx, cancellationToken)
+    ) {
+      return true;
+    }
+
+    if (idx === rootIdx) {
+      break;
+    }
+
+    idx = readNext(buf, idx);
+  }
+
+  return false;
+}
+
 /**
  * This is useful debugging helper to display the buffer's contents.
  * @param {AstContext} ctx

filename: cli/js/40_lint_selector.js
patch: @@ -16,7 +16,6 @@
 /** @typedef {import("./40_lint_types.d.ts").Relation} SRelation */
 /** @typedef {import("./40_lint_types.d.ts").Selector} Selector */
 /** @typedef {import("./40_lint_types.d.ts").SelectorParseCtx} SelectorParseCtx */
-/** @typedef {import("./40_lint_types.d.ts").NextFn} NextFn */
 /** @typedef {import("./40_lint_types.d.ts").MatcherFn} MatcherFn */
 /** @typedef {import("./40_lint_types.d.ts").TransformFn} Transformer */
 
@@ -173,6 +172,31 @@ export class Lexer {
     }
   }
 
+  peek() {
+    const value = this.value;
+    const start = this.start;
+    const end = this.end;
+    const i = this.i;
+    const ch = this.ch;
+    const token = this.token;
+
+    this.next();
+
+    const result = {
+      token: this.token,
+      value: this.value,
+    };
+
+    this.vaue = value;
+    this.start = start;
+    this.end = end;
+    this.i = i;
+    this.ch = ch;
+    this.token = token;
+
+    return result;
+  }
+
   next() {
     this.value = "";
 
@@ -378,6 +402,7 @@ export const PSEUDO_NOT = 7;
 export const PSEUDO_FIRST_CHILD = 8;
 export const PSEUDO_LAST_CHILD = 9;
 export const FIELD_NODE = 10;
+export const PSEUDO_IS = 11;
 
 /**
  * Parse out all unique selectors of a selector list.
@@ -459,6 +484,19 @@ export function parseSelector(input, toElem, toAttr) {
           type: RELATION_NODE,
           op: BinOp.Space,
         });
+      } else if (lex.token === Token.Colon) {
+        const peeked = lex.peek();
+
+        if (
+          peeked.token === Token.Word &&
+          (peeked.value === "is" || peeked.value === "where" ||
+            peeked.value === "matches")
+        ) {
+          current.push({
+            type: RELATION_NODE,
+            op: BinOp.Space,
+          });
+        }
       }
 
       continue;
@@ -617,14 +655,26 @@ export function parseSelector(input, toElem, toAttr) {
 
           continue;
         }
-
-        case "has":
         case "where":
+        case "matches":
         case "is": {
           lex.next();
           lex.expect(Token.BraceOpen);
           lex.next();
 
+          current.push({
+            type: PSEUDO_IS,
+            selectors: [],
+          });
+          stack.push([]);
+
+          continue;
+        }
+        case "has": {
+          lex.next();
+          lex.expect(Token.BraceOpen);
+          lex.next();
+
           current.push({
             type: PSEUDO_HAS,
             selectors: [],
@@ -705,7 +755,10 @@ function popSelector(result, stack) {
 
     if (node.type === PSEUDO_NTH_CHILD) {
       node.of = sel;
-    } else if (node.type === PSEUDO_HAS || node.type === PSEUDO_NOT) {
+    } else if (
+      node.type === PSEUDO_HAS || node.type === PSEUDO_IS ||
+      node.type === PSEUDO_NOT
+    ) {
       node.selectors.push(sel);
     } else {
       throw new Error(`Multiple selectors not allowed here`);
@@ -769,8 +822,11 @@ export function compileSelector(selector) {
         fn = matchNthChild(node, fn);
         break;
       case PSEUDO_HAS:
-        // TODO(@marvinhagemeister)
-        throw new Error("TODO: :has");
+        fn = matchHas(node.selectors, fn);
+        break;
+      case PSEUDO_IS:
+        fn = matchIs(node.selectors, fn);
+        break;
       case PSEUDO_NOT:
         fn = matchNot(node.selectors, fn);
         break;
@@ -786,7 +842,7 @@ export function compileSelector(selector) {
 }
 
 /**
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchFirstChild(next) {
@@ -797,7 +853,7 @@ function matchFirstChild(next) {
 }
 
 /**
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchLastChild(next) {
@@ -829,7 +885,7 @@ function getNthAnB(node, i) {
 
 /**
  * @param {PseudoNthChild} node
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchNthChild(node, next) {
@@ -868,10 +924,10 @@ function matchNthChild(node, next) {
 
 /**
  * @param {Selector[]} selectors
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
-function matchNot(selectors, next) {
+function matchIs(selectors, next) {
   /** @type {MatcherFn[]} */
   const compiled = [];
 
@@ -882,18 +938,82 @@ function matchNot(selectors, next) {
 
   return (ctx, id) => {
     for (let i = 0; i < compiled.length; i++) {
-      const fn = compiled[i];
-      if (fn(ctx, id)) {
-        return false;
+      const sel = compiled[i];
+      if (sel(ctx, id)) return next(ctx, id);
+    }
+
+    return false;
+  };
+}
+
+/**
+ * @param {Selector[]} selectors
+ * @param {MatcherFn} next
+ * @returns {MatcherFn}
+ */
+function matchHas(selectors, next) {
+  /** @type {MatcherFn[]} */
+  const compiled = [];
+
+  for (let i = 0; i < selectors.length; i++) {
+    const sel = selectors[i];
+    compiled.push(compileSelector(sel));
+  }
+
+  /** @type {Map<number, boolean>} */
+  const cache = new Map();
+
+  return (ctx, id) => {
+    if (next(ctx, id)) {
+      const cached = cache.get(id);
+      if (cached !== undefined) return cached;
+
+      const match = ctx.subSelect(compiled, id);
+      cache.set(id, match);
+      if (match) {
+        return true;
       }
     }
 
-    return next(ctx, id);
+    return false;
+  };
+}
+
+/**
+ * @param {Selector[]} selectors
+ * @param {MatcherFn} next
+ * @returns {MatcherFn}
+ */
+function matchNot(selectors, next) {
+  /** @type {MatcherFn[]} */
+  const compiled = [];
+
+  for (let i = 0; i < selectors.length; i++) {
+    const sel = selectors[i];
+    compiled.push(compileSelector(sel));
+  }
+
+  /** @type {Map<number, boolean>} */
+  const cache = new Map();
+
+  return (ctx, id) => {
+    if (next(ctx, id)) {
+      const cached = cache.get(id);
+      if (cached !== undefined) return cached;
+
+      const match = ctx.subSelect(compiled, id);
+      cache.set(id, !match);
+      if (!match) {
+        return true;
+      }
+    }
+
+    return false;
   };
 }
 
 /**
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchDescendant(next) {
@@ -913,7 +1033,7 @@ function matchDescendant(next) {
 }
 
 /**
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchChild(next) {
@@ -926,7 +1046,7 @@ function matchChild(next) {
 }
 
 /**
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchAdjacent(next) {
@@ -942,7 +1062,7 @@ function matchAdjacent(next) {
 }
 
 /**
- * @param {NextFn} next
+ * @param {MatcherFn} next
  * @returns {MatcherFn}
  */
 function matchFollowing(next) {

filename: cli/js/40_lint_types.d.ts
patch: @@ -25,7 +25,7 @@ export interface LintState {
 export type VisitorFn = (node: unknown) => void;
 
 export interface CompiledVisitor {
-  matcher: (ctx: MatchContext, offset: number) => boolean;
+  matcher: MatcherFn;
   info: { enter: VisitorFn; exit: VisitorFn };
 }
 
@@ -68,6 +68,10 @@ export interface PseudoHas {
   type: 6;
   selectors: Selector[];
 }
+export interface PseudoIs {
+  type: 11;
+  selectors: Selector[];
+}
 export interface PseudoNot {
   type: 7;
   selectors: Selector[];
@@ -93,6 +97,7 @@ export type Selector = Array<
   | PseudoNthChild
   | PseudoNot
   | PseudoHas
+  | PseudoIs
   | PseudoFirstChild
   | PseudoLastChild
 >;
@@ -103,6 +108,8 @@ export interface SelectorParseCtx {
 }
 
 export interface MatchContext {
+  /** Used for `:has()` and `:not()` */
+  subSelect(selectors: MatcherFn[], idx: number): boolean;
   getFirstChild(id: number): number;
   getLastChild(id: number): number;
   getSiblings(id: number): number[];
@@ -112,7 +119,6 @@ export interface MatchContext {
   getAttrPathValue(id: number, propIds: number[], idx: number): unknown;
 }
 
-export type NextFn = (ctx: MatchContext, id: number) => boolean;
 export type MatcherFn = (ctx: MatchContext, id: number) => boolean;
 export type TransformFn = (value: string) => number;
 

filename: tests/unit/lint_plugin_test.ts
patch: @@ -361,6 +361,89 @@ Deno.test("Plugin - visitor :nth-child", () => {
   assertEquals(result[1].node.name, "foobar");
 });
 
+Deno.test("Plugin - visitor :has()", () => {
+  let result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement:has(Identifier[name='bar'])",
+  );
+  assertEquals(result[0].node.type, "BlockStatement");
+
+  // Multiple sub queries
+  result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement:has(CallExpression, Identifier[name='bar'])",
+  );
+  assertEquals(result[0].node.type, "BlockStatement");
+
+  // This should not match
+  result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement:has(CallExpression, Identifier[name='baz'])",
+  );
+  assertEquals(result, []);
+
+  // Attr match
+  result = testVisit(
+    "{ foo, bar }",
+    "Identifier:has([name='bar'])",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "bar");
+});
+
+Deno.test("Plugin - visitor :is()/:where()/:matches()", () => {
+  let result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement :is(Identifier[name='bar'])",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "bar");
+
+  result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement :where(Identifier[name='bar'])",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "bar");
+
+  result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement :matches(Identifier[name='bar'])",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "bar");
+});
+
+Deno.test("Plugin - visitor :not", () => {
+  let result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement:not(Identifier[name='baz'])",
+  );
+  assertEquals(result[0].node.type, "BlockStatement");
+
+  // Multiple sub queries
+  result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement:not(Identifier[name='baz'], CallExpression)",
+  );
+  assertEquals(result[0].node.type, "BlockStatement");
+
+  // This should not match
+  result = testVisit(
+    "{ foo, bar }",
+    "BlockStatement:not(CallExpression, Identifier)",
+  );
+  assertEquals(result, []);
+
+  // Attr match
+  result = testVisit(
+    "{ foo, bar }",
+    "Identifier:not([name='foo'])",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "bar");
+});
+
 Deno.test("Plugin - Program", async (t) => {
   await testSnapshot(t, "", "Program");
 });

filename: tests/unit/lint_selectors_test.ts
patch: @@ -11,6 +11,7 @@ import {
   parseSelector,
   PSEUDO_FIRST_CHILD,
   PSEUDO_HAS,
+  PSEUDO_IS,
   PSEUDO_LAST_CHILD,
   PSEUDO_NOT,
   PSEUDO_NTH_CHILD,
@@ -554,7 +555,7 @@ Deno.test("Parser - Pseudo nth-child", () => {
   assertThrows(() => testParse(":nth-child(2n - 1 foo)"));
 });
 
-Deno.test("Parser - Pseudo has/is/where", () => {
+Deno.test("Parser - Pseudo :has()", () => {
   assertEquals(testParse(":has(Foo:has(Foo), Bar)"), [[
     {
       type: PSEUDO_HAS,
@@ -574,14 +575,36 @@ Deno.test("Parser - Pseudo has/is/where", () => {
       ],
     },
   ]]);
+});
+
+Deno.test("Parser - Pseudo :is()/:where()/:matches()", () => {
+  assertEquals(testParse(":is(Foo:is(Foo), Bar)"), [[
+    {
+      type: PSEUDO_IS,
+      selectors: [
+        [
+          { type: ELEM_NODE, elem: 1, wildcard: false },
+          {
+            type: PSEUDO_IS,
+            selectors: [
+              [{ type: ELEM_NODE, elem: 1, wildcard: false }],
+            ],
+          },
+        ],
+        [
+          { type: ELEM_NODE, elem: 2, wildcard: false },
+        ],
+      ],
+    },
+  ]]);
   assertEquals(testParse(":where(Foo:where(Foo), Bar)"), [[
     {
-      type: PSEUDO_HAS,
+      type: PSEUDO_IS,
       selectors: [
         [
           { type: ELEM_NODE, elem: 1, wildcard: false },
           {
-            type: PSEUDO_HAS,
+            type: PSEUDO_IS,
             selectors: [
               [{ type: ELEM_NODE, elem: 1, wildcard: false }],
             ],
@@ -593,14 +616,14 @@ Deno.test("Parser - Pseudo has/is/where", () => {
       ],
     },
   ]]);
-  assertEquals(testParse(":is(Foo:is(Foo), Bar)"), [[
+  assertEquals(testParse(":matches(Foo:matches(Foo), Bar)"), [[
     {
-      type: PSEUDO_HAS,
+      type: PSEUDO_IS,
       selectors: [
         [
           { type: ELEM_NODE, elem: 1, wildcard: false },
           {
-            type: PSEUDO_HAS,
+            type: PSEUDO_IS,
             selectors: [
               [{ type: ELEM_NODE, elem: 1, wildcard: false }],
             ],
@@ -612,6 +635,31 @@ Deno.test("Parser - Pseudo has/is/where", () => {
       ],
     },
   ]]);
+
+  assertEquals(testParse("Foo:is(Bar)"), [[
+    { type: ELEM_NODE, elem: 1, wildcard: false },
+    {
+      type: PSEUDO_IS,
+      selectors: [
+        [
+          { type: ELEM_NODE, elem: 2, wildcard: false },
+        ],
+      ],
+    },
+  ]]);
+
+  assertEquals(testParse("Foo :is(Bar)"), [[
+    { type: ELEM_NODE, elem: 1, wildcard: false },
+    { type: RELATION_NODE, op: BinOp.Space },
+    {
+      type: PSEUDO_IS,
+      selectors: [
+        [
+          { type: ELEM_NODE, elem: 2, wildcard: false },
+        ],
+      ],
+    },
+  ]]);
 });
 
 Deno.test("Parser - Pseudo not", () => {