diff --git a/cli/js/40_lint.js b/cli/js/40_lint.js index 98358eee30..943b726f53 100644 --- a/cli/js/40_lint.js +++ b/cli/js/40_lint.js @@ -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,11 +771,15 @@ 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; } /** @@ -782,6 +787,7 @@ class MatchCtx { * @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} */ 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}>} */ 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 diff --git a/cli/js/40_lint_selector.js b/cli/js/40_lint_selector.js index d24440a72e..b39ee2435a 100644 --- a/cli/js/40_lint_selector.js +++ b/cli/js/40_lint_selector.js @@ -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,7 +924,64 @@ function matchNthChild(node, next) { /** * @param {Selector[]} selectors - * @param {NextFn} next + * @param {MatcherFn} next + * @returns {MatcherFn} + */ +function matchIs(selectors, next) { + /** @type {MatcherFn[]} */ + const compiled = []; + + for (let i = 0; i < selectors.length; i++) { + const sel = selectors[i]; + compiled.push(compileSelector(sel)); + } + + return (ctx, id) => { + for (let i = 0; i < compiled.length; i++) { + 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} */ + 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 {Selector[]} selectors + * @param {MatcherFn} next * @returns {MatcherFn} */ function matchNot(selectors, next) { @@ -880,20 +993,27 @@ function matchNot(selectors, next) { compiled.push(compileSelector(sel)); } + /** @type {Map} */ + const cache = new Map(); + return (ctx, id) => { - for (let i = 0; i < compiled.length; i++) { - const fn = compiled[i]; - if (fn(ctx, id)) { - return false; + 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 {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) { diff --git a/cli/js/40_lint_types.d.ts b/cli/js/40_lint_types.d.ts index f0e1cee0c9..3d55773175 100644 --- a/cli/js/40_lint_types.d.ts +++ b/cli/js/40_lint_types.d.ts @@ -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; diff --git a/tests/unit/lint_plugin_test.ts b/tests/unit/lint_plugin_test.ts index 5dab1f2071..399f1d5127 100644 --- a/tests/unit/lint_plugin_test.ts +++ b/tests/unit/lint_plugin_test.ts @@ -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"); }); diff --git a/tests/unit/lint_selectors_test.ts b/tests/unit/lint_selectors_test.ts index 9828343a79..566ee81e30 100644 --- a/tests/unit/lint_selectors_test.ts +++ b/tests/unit/lint_selectors_test.ts @@ -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,17 @@ Deno.test("Parser - Pseudo has/is/where", () => { ], }, ]]); - assertEquals(testParse(":where(Foo:where(Foo), Bar)"), [[ +}); + +Deno.test("Parser - Pseudo :is()/:where()/:matches()", () => { + assertEquals(testParse(":is(Foo:is(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 +597,14 @@ Deno.test("Parser - Pseudo has/is/where", () => { ], }, ]]); - assertEquals(testParse(":is(Foo:is(Foo), Bar)"), [[ + 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 }], ], @@ -612,6 +616,50 @@ Deno.test("Parser - Pseudo has/is/where", () => { ], }, ]]); + assertEquals(testParse(":matches(Foo:matches(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("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", () => {