Repository: denoland/deno
Pull request: feat(unstable): lint plugins support field selectors

This PR adds support for field selectors (`.<field>`) in the lint plugin API. This is supported in ESLint as well, but was missing in our implementation.

```css
/* Only search the test expression of an IfStatement */
IfStatement.test
```

Fixes https://github.com/denoland/deno/issues/28314

Changed files:
filename: cli/js/40_lint.js
patch: @@ -791,14 +791,45 @@ class MatchCtx {
     return readType(this.ctx.buf, idx);
   }
 
+  /**
+   * @param {number} idx
+   * @param {number} propId
+   * @returns {number}
+   */
+  getField(idx, propId) {
+    if (idx === AST_IDX_INVALID) return -1;
+
+    // Bail out on fields that can never point to another node
+    switch (propId) {
+      case AST_PROP_TYPE:
+      case AST_PROP_PARENT:
+      case AST_PROP_RANGE:
+        return -1;
+    }
+
+    const { buf } = this.ctx;
+    let offset = readPropOffset(this.ctx, idx);
+    offset = findPropOffset(buf, offset, propId);
+
+    if (offset === -1) return -1;
+    const _prop = buf[offset++];
+    const kind = buf[offset++];
+
+    if (kind === PropFlags.Ref) {
+      return readU32(buf, offset);
+    }
+
+    return -1;
+  }
+
   /**
    * @param {number} idx - Node idx
    * @param {number[]} propIds
    * @param {number} propIdx
    * @returns {unknown}
    */
   getAttrPathValue(idx, propIds, propIdx) {
-    if (idx === 0) throw -1;
+    if (idx === AST_IDX_INVALID) throw -1;
 
     const { buf, strTable, strByType } = this.ctx;
 

filename: cli/js/40_lint_selector.js
patch: @@ -9,6 +9,7 @@
 /** @typedef {import("./40_lint_types.d.ts").AttrBin} AttrBin */
 /** @typedef {import("./40_lint_types.d.ts").AttrSelector} AttrSelector */
 /** @typedef {import("./40_lint_types.d.ts").ElemSelector} ElemSelector */
+/** @typedef {import("./40_lint_types.d.ts").FieldSelector} FieldSelector */
 /** @typedef {import("./40_lint_types.d.ts").PseudoNthChild} PseudoNthChild */
 /** @typedef {import("./40_lint_types.d.ts").PseudoHas} PseudoHas */
 /** @typedef {import("./40_lint_types.d.ts").PseudoNot} PseudoNot */
@@ -376,6 +377,7 @@ export const PSEUDO_HAS = 6;
 export const PSEUDO_NOT = 7;
 export const PSEUDO_FIRST_CHILD = 8;
 export const PSEUDO_LAST_CHILD = 9;
+export const FIELD_NODE = 10;
 
 /**
  * Parse out all unique selectors of a selector list.
@@ -492,6 +494,26 @@ export function parseSelector(input, toElem, toAttr) {
       lex.expect(Token.BracketClose);
       lex.next();
       continue;
+    } else if (lex.token === Token.Dot) {
+      lex.next();
+      lex.expect(Token.Word);
+
+      const props = [toAttr(lex.value)];
+      lex.next();
+
+      while (lex.token === Token.Dot) {
+        lex.next();
+        lex.expect(Token.Word);
+
+        props.push(toAttr(lex.value));
+        lex.next();
+      }
+
+      current.push({
+        type: FIELD_NODE,
+        props,
+      });
+      continue;
     } else if (lex.token === Token.Colon) {
       lex.next();
       lex.expect(Token.Word);
@@ -710,6 +732,9 @@ export function compileSelector(selector) {
       case ELEM_NODE:
         fn = matchElem(node, fn);
         break;
+      case FIELD_NODE:
+        fn = matchField(node, fn);
+        break;
       case RELATION_NODE:
         switch (node.op) {
           case BinOp.Space:
@@ -960,6 +985,39 @@ function matchElem(part, next) {
   };
 }
 
+/**
+ * @param {FieldSelector} part
+ * @param {MatcherFn} next
+ * @returns {MatcherFn}
+ */
+function matchField(part, next) {
+  return (ctx, id) => {
+    let child = id;
+    let parent = ctx.getParent(id);
+    if (parent === 0) return false;
+
+    // Fields are stored left-ro-right but we need to match
+    // them right-to-left because we're matching selectors
+    // in that direction. Matching right to left is done for
+    // performance and reduces the number of potential mismatches.
+    for (let i = part.props.length - 1; i >= 0; i--) {
+      const prop = part.props[i];
+      const value = ctx.getField(parent, prop);
+
+      if (value === -1) return false;
+      if (value !== child) return false;
+
+      if (i > 0) {
+        child = parent;
+        parent = ctx.getParent(parent);
+        if (parent === 0) return false;
+      }
+    }
+
+    return next(ctx, parent);
+  };
+}
+
 /**
  * @param {AttrExists} attr
  * @param {MatcherFn} next

filename: cli/js/40_lint_types.d.ts
patch: @@ -50,6 +50,11 @@ export interface ElemSelector {
   elem: number;
 }
 
+export interface FieldSelector {
+  type: 10;
+  props: number[];
+}
+
 export interface PseudoNthChild {
   type: 5;
   op: string | null;
@@ -81,6 +86,7 @@ export interface Relation {
 
 export type Selector = Array<
   | ElemSelector
+  | FieldSelector
   | Relation
   | AttrExists
   | AttrBin
@@ -101,6 +107,7 @@ export interface MatchContext {
   getLastChild(id: number): number;
   getSiblings(id: number): number[];
   getParent(id: number): number;
+  getField(id: number, prop: number): number;
   getType(id: number): number;
   getAttrPathValue(id: number, propIds: number[], idx: number): unknown;
 }

filename: tests/unit/lint_plugin_test.ts
patch: @@ -177,6 +177,29 @@ Deno.test("Plugin - visitor subsequent sibling", () => {
   assertEquals(result.map((r) => r.node.name), ["bar", "baz"]);
 });
 
+Deno.test("Plugin - visitor field", () => {
+  let result = testVisit(
+    "if (foo()) {}",
+    "IfStatement.test.callee",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "foo");
+
+  result = testVisit(
+    "if (foo()) {}",
+    "IfStatement .test .callee",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "foo");
+
+  result = testVisit(
+    "if (foo(bar())) {}",
+    "IfStatement.test CallExpression.callee",
+  );
+  assertEquals(result[0].node.type, "Identifier");
+  assertEquals(result[0].node.name, "bar");
+});
+
 Deno.test("Plugin - visitor attr", () => {
   let result = testVisit(
     "for (const a of b) {}",

filename: tests/unit/lint_selectors_test.ts
patch: @@ -6,6 +6,7 @@ import {
   ATTR_EXISTS_NODE,
   BinOp,
   ELEM_NODE,
+  FIELD_NODE,
   Lexer,
   parseSelector,
   PSEUDO_FIRST_CHILD,
@@ -255,6 +256,19 @@ Deno.test("Lexer - Pseudo", () => {
   ]);
 });
 
+Deno.test("Lexer - field", () => {
+  assertEquals(testLexer(".bar"), [
+    { token: Token.Dot, value: "" },
+    { token: Token.Word, value: "bar" },
+  ]);
+  assertEquals(testLexer(".bar.baz"), [
+    { token: Token.Dot, value: "" },
+    { token: Token.Word, value: "bar" },
+    { token: Token.Dot, value: "" },
+    { token: Token.Word, value: "baz" },
+  ]);
+});
+
 Deno.test("Parser - Elem", () => {
   assertEquals(testParse("Foo"), [[
     {
@@ -337,6 +351,33 @@ Deno.test("Parser - Relation", () => {
   ]]);
 });
 
+Deno.test("Parser - Field", () => {
+  assertEquals(testParse("Foo.bar"), [[
+    {
+      type: ELEM_NODE,
+      elem: 1,
+      wildcard: false,
+    },
+    { type: FIELD_NODE, props: [2] },
+  ]]);
+  assertEquals(testParse("Foo .bar"), [[
+    {
+      type: ELEM_NODE,
+      elem: 1,
+      wildcard: false,
+    },
+    { type: FIELD_NODE, props: [2] },
+  ]]);
+  assertEquals(testParse("Foo .foo.bar"), [[
+    {
+      type: ELEM_NODE,
+      elem: 1,
+      wildcard: false,
+    },
+    { type: FIELD_NODE, props: [1, 2] },
+  ]]);
+});
+
 Deno.test("Parser - Attr", () => {
   assertEquals(testParse("[foo]"), [[
     {