feat(unstable): lint plugins support field selectors (#28324)

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
This commit is contained in:
Marvin Hagemeister 2025-02-28 15:10:02 +01:00 committed by GitHub
parent b4aa3e6d1e
commit 3a1f3455b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 161 additions and 1 deletions

View file

@ -791,6 +791,37 @@ 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
@ -798,7 +829,7 @@ class MatchCtx {
* @returns {unknown}
*/
getAttrPathValue(idx, propIds, propIdx) {
if (idx === 0) throw -1;
if (idx === AST_IDX_INVALID) throw -1;
const { buf, strTable, strByType } = this.ctx;

View file

@ -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

View file

@ -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;
}

View file

@ -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) {}",

View file

@ -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]"), [[
{