1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2025-01-21 04:52:26 -05:00

feat(unstable): support selectors in JS lint plugins (#27452)

This PR adds support for using selectors in the JS linting plugin API.
Supported at the moment are:

- `Foo Bar` (descendant)
- `Foo > Bar` (child combinator)
- `Foo + Foo` (next sibling)
- `Foo ~ Foo` (subsequent sibling)
- `[attr]`, `[attr=value]` (attribute selectors, supported operators:
`=`, `!=`, `<`, `>`, `<=`, `>=`)
- `:first-child`
- `:last-child`
- `:nth-child(2)`, `:nth-child(2n + 1)`
This commit is contained in:
Marvin Hagemeister 2024-12-23 08:45:47 +01:00 committed by GitHub
parent 3cc861cdca
commit 1a809b8115
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2272 additions and 54 deletions

View file

@ -2,6 +2,11 @@
// @ts-check // @ts-check
import {
compileSelector,
parseSelector,
splitSelectors,
} from "ext:cli/40_lint_selector.js";
import { core, internals } from "ext:core/mod.js"; import { core, internals } from "ext:core/mod.js";
const { const {
op_lint_create_serialized_ast, op_lint_create_serialized_ast,
@ -13,6 +18,7 @@ const {
const AST_PROP_TYPE = 0; const AST_PROP_TYPE = 0;
const AST_PROP_PARENT = 1; const AST_PROP_PARENT = 1;
const AST_PROP_RANGE = 2; const AST_PROP_RANGE = 2;
const AST_PROP_LENGTH = 3;
// Keep in sync with Rust // Keep in sync with Rust
// Each node property is tagged with this enum to denote // Each node property is tagged with this enum to denote
@ -43,8 +49,8 @@ const PropFlags = {
/** @typedef {import("./40_lint_types.d.ts").RuleContext} RuleContext */ /** @typedef {import("./40_lint_types.d.ts").RuleContext} RuleContext */
/** @typedef {import("./40_lint_types.d.ts").NodeFacade} NodeFacade */ /** @typedef {import("./40_lint_types.d.ts").NodeFacade} NodeFacade */
/** @typedef {import("./40_lint_types.d.ts").LintPlugin} LintPlugin */ /** @typedef {import("./40_lint_types.d.ts").LintPlugin} LintPlugin */
/** @typedef {import("./40_lint_types.d.ts").LintReportData} LintReportData */ /** @typedef {import("./40_lint_types.d.ts").TransformFn} TransformFn */
/** @typedef {import("./40_lint_types.d.ts").TestReportData} TestReportData */ /** @typedef {import("./40_lint_types.d.ts").MatchContext} MatchContext */
/** @type {LintState} */ /** @type {LintState} */
const state = { const state = {
@ -99,7 +105,6 @@ export function installPlugin(plugin) {
*/ */
function getNode(ctx, offset) { function getNode(ctx, offset) {
if (offset === 0) return null; if (offset === 0) return null;
const cached = ctx.nodes.get(offset); const cached = ctx.nodes.get(offset);
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
@ -297,9 +302,10 @@ function readValue(ctx, offset, search) {
if (offset === -1) return undefined; if (offset === -1) return undefined;
const kind = buf[offset + 1]; const kind = buf[offset + 1];
offset += 2;
if (kind === PropFlags.Ref) { if (kind === PropFlags.Ref) {
const value = readU32(buf, offset + 2); const value = readU32(buf, offset);
return getNode(ctx, value); return getNode(ctx, value);
} else if (kind === PropFlags.RefArr) { } else if (kind === PropFlags.RefArr) {
const len = readU32(buf, offset); const len = readU32(buf, offset);
@ -353,6 +359,303 @@ function getString(strTable, id) {
return name; return name;
} }
/**
* @param {AstContext["buf"]} buf
* @param {number} child
* @returns {null | [number, number]}
*/
function findChildOffset(buf, child) {
let offset = readU32(buf, child + 1);
// type + parentId + SpanLo + SpanHi
offset += 1 + 4 + 4 + 4;
const propCount = buf[offset++];
for (let i = 0; i < propCount; i++) {
const _prop = buf[offset++];
const kind = buf[offset++];
switch (kind) {
case PropFlags.Ref: {
const start = offset;
const value = readU32(buf, offset);
offset += 4;
if (value === child) {
return [start, -1];
}
break;
}
case PropFlags.RefArr: {
const start = offset;
const len = readU32(buf, offset);
offset += 4;
for (let j = 0; j < len; j++) {
const value = readU32(buf, offset);
offset += 4;
if (value === child) {
return [start, j];
}
}
break;
}
case PropFlags.String:
offset += 4;
break;
case PropFlags.Bool:
offset++;
break;
case PropFlags.Null:
case PropFlags.Undefined:
break;
}
}
return null;
}
/** @implements {MatchContext} */
class MatchCtx {
/**
* @param {AstContext["buf"]} buf
* @param {AstContext["strTable"]} strTable
*/
constructor(buf, strTable) {
this.buf = buf;
this.strTable = strTable;
}
/**
* @param {number} offset
* @returns {number}
*/
getParent(offset) {
return readU32(this.buf, offset + 1);
}
/**
* @param {number} offset
* @returns {number}
*/
getType(offset) {
return this.buf[offset];
}
/**
* @param {number} offset
* @param {number[]} propIds
* @param {number} idx
* @returns {unknown}
*/
getAttrPathValue(offset, propIds, idx) {
const { buf } = this;
offset = findPropOffset(buf, offset, propIds[idx]);
if (offset === -1) return undefined;
const _prop = buf[offset++];
const kind = buf[offset++];
if (kind === PropFlags.Ref) {
const value = readU32(buf, offset);
// Checks need to end with a value, not a node
if (idx === propIds.length - 1) return undefined;
return this.getAttrPathValue(value, propIds, idx + 1);
} else if (kind === PropFlags.RefArr) {
const count = readU32(buf, offset);
offset += 4;
if (idx < propIds.length - 1 && propIds[idx + 1] === AST_PROP_LENGTH) {
return count;
}
// TODO(@marvinhagemeister): Allow traversing into array children?
}
// Cannot traverse into primitives further
if (idx < propIds.length - 1) return undefined;
if (kind === PropFlags.String) {
const s = readU32(buf, offset);
return getString(this.strTable, s);
} else if (kind === PropFlags.Bool) {
return buf[offset] === 1;
} else if (kind === PropFlags.Null) {
return null;
} else if (kind === PropFlags.Undefined) {
return undefined;
}
return undefined;
}
/**
* @param {number} offset
* @param {number[]} propIds
* @param {number} idx
* @returns {boolean}
*/
hasAttrPath(offset, propIds, idx) {
const { buf } = this;
offset = findPropOffset(buf, offset, propIds[idx]);
if (offset === -1) return false;
if (idx === propIds.length - 1) return true;
const _prop = buf[offset++];
const kind = buf[offset++];
if (kind === PropFlags.Ref) {
const value = readU32(buf, offset);
return this.hasAttrPath(value, propIds, idx + 1);
} else if (kind === PropFlags.RefArr) {
const _count = readU32(buf, offset);
offset += 4;
if (idx < propIds.length - 1 && propIds[idx + 1] === AST_PROP_LENGTH) {
return true;
}
// TODO(@marvinhagemeister): Allow traversing into array children?
}
// Primitives cannot be traversed further. This means we
// didn't found the attribute.
if (idx < propIds.length - 1) return false;
return true;
}
/**
* @param {number} offset
* @returns {number}
*/
getFirstChild(offset) {
const { buf } = this;
// type + parentId + SpanLo + SpanHi
offset += 1 + 4 + 4 + 4;
const count = buf[offset++];
for (let i = 0; i < count; i++) {
const _prop = buf[offset++];
const kind = buf[offset++];
switch (kind) {
case PropFlags.Ref: {
const v = readU32(buf, offset);
offset += 4;
return v;
}
case PropFlags.RefArr: {
const len = readU32(buf, offset);
offset += 4;
for (let j = 0; j < len; j++) {
const v = readU32(buf, offset);
offset += 4;
return v;
}
return len;
}
case PropFlags.String:
offset += 4;
break;
case PropFlags.Bool:
offset++;
break;
case PropFlags.Null:
case PropFlags.Undefined:
break;
}
}
return -1;
}
/**
* @param {number} offset
* @returns {number}
*/
getLastChild(offset) {
const { buf } = this;
// type + parentId + SpanLo + SpanHi
offset += 1 + 4 + 4 + 4;
let last = -1;
const count = buf[offset++];
for (let i = 0; i < count; i++) {
const _prop = buf[offset++];
const kind = buf[offset++];
switch (kind) {
case PropFlags.Ref: {
const v = readU32(buf, offset);
offset += 4;
last = v;
break;
}
case PropFlags.RefArr: {
const len = readU32(buf, offset);
offset += 4;
for (let j = 0; j < len; j++) {
const v = readU32(buf, offset);
last = v;
offset += 4;
}
break;
}
case PropFlags.String:
offset += 4;
break;
case PropFlags.Bool:
offset++;
break;
case PropFlags.Null:
case PropFlags.Undefined:
break;
}
}
return last;
}
/**
* @param {number} id
* @returns {number[]}
*/
getSiblings(id) {
const { buf } = this;
const result = findChildOffset(buf, id);
// Happens for program nodes
if (result === null) return [];
if (result[1] === -1) {
return [id];
}
let offset = result[0];
const count = readU32(buf, offset);
offset += 4;
/** @type {number[]} */
const out = [];
for (let i = 0; i < count; i++) {
const v = readU32(buf, offset);
offset += 4;
out.push(v);
}
return out;
}
}
/** /**
* @param {Uint8Array} buf * @param {Uint8Array} buf
* @param {AstContext} buf * @param {AstContext} buf
@ -433,6 +736,7 @@ function createAstContext(buf) {
strByType, strByType,
typeByStr, typeByStr,
propByStr, propByStr,
matcher: new MatchCtx(buf, strTable),
}; };
setNodeGetters(ctx); setNodeGetters(ctx);
@ -456,7 +760,7 @@ const NOOP = (_node) => {};
export function runPluginsForFile(fileName, serializedAst) { export function runPluginsForFile(fileName, serializedAst) {
const ctx = createAstContext(serializedAst); const ctx = createAstContext(serializedAst);
/** @type {Map<string, { enter: VisitorFn, exit: VisitorFn}>} */ /** @type {Map<string, CompiledVisitor["info"]>}>} */
const bySelector = new Map(); const bySelector = new Map();
const destroyFns = []; const destroyFns = [];
@ -486,32 +790,38 @@ export function runPluginsForFile(fileName, serializedAst) {
key = key.slice(0, -":exit".length); key = key.slice(0, -":exit".length);
} }
let info = bySelector.get(key); const selectors = splitSelectors(key);
if (info === undefined) {
info = { enter: NOOP, exit: NOOP };
bySelector.set(key, info);
}
const prevFn = isExit ? info.exit : info.enter;
/** for (let j = 0; j < selectors.length; j++) {
* @param {*} node const key = selectors[j];
*/
const wrapped = (node) => {
prevFn(node);
try { let info = bySelector.get(key);
fn(node); if (info === undefined) {
} catch (err) { info = { enter: NOOP, exit: NOOP };
throw new Error(`Visitor "${name}" of plugin "${id}" errored`, { bySelector.set(key, info);
cause: err,
});
} }
}; const prevFn = isExit ? info.exit : info.enter;
if (isExit) { /**
info.exit = wrapped; * @param {*} node
} else { */
info.enter = wrapped; const wrapped = (node) => {
prevFn(node);
try {
fn(node);
} catch (err) {
throw new Error(`Visitor "${name}" of plugin "${id}" errored`, {
cause: err,
});
}
};
if (isExit) {
info.exit = wrapped;
} else {
info.enter = wrapped;
}
} }
} }
@ -528,25 +838,27 @@ export function runPluginsForFile(fileName, serializedAst) {
} }
} }
// Create selectors
/** @type {TransformFn} */
const toElem = (str) => {
const id = ctx.typeByStr.get(str);
return id === undefined ? 0 : id;
};
/** @type {TransformFn} */
const toAttr = (str) => {
const id = ctx.propByStr.get(str);
return id === undefined ? 0 : id;
};
/** @type {CompiledVisitor[]} */ /** @type {CompiledVisitor[]} */
const visitors = []; const visitors = [];
for (const [sel, info] of bySelector.entries()) { for (const [sel, info] of bySelector.entries()) {
// This will make more sense once selectors land as it's faster // Selectors are already split here.
// to precompile them once upfront. // TODO(@marvinhagemeister): Avoid array allocation (not sure if that matters)
const parsed = parseSelector(sel, toElem, toAttr)[0];
const matcher = compileSelector(parsed);
// Convert the visiting element name to a number. This number visitors.push({ info, matcher });
// is part of the serialized buffer and comparing a single number
// is quicker than strings.
const elemId = ctx.typeByStr.get(sel) ?? -1;
visitors.push({
info,
// Check if we should call this visitor
matcher: (offset) => {
const type = ctx.buf[offset];
return type === elemId;
},
});
} }
// Traverse ast with all visitors at the same time to avoid traversing // Traverse ast with all visitors at the same time to avoid traversing
@ -572,6 +884,8 @@ function traverse(ctx, visitors, offset) {
// The 0 offset is used to denote an empty/placeholder node // The 0 offset is used to denote an empty/placeholder node
if (offset === 0) return; if (offset === 0) return;
const originalOffset = offset;
const { buf } = ctx; const { buf } = ctx;
/** @type {VisitorFn[] | null} */ /** @type {VisitorFn[] | null} */
@ -580,7 +894,7 @@ function traverse(ctx, visitors, offset) {
for (let i = 0; i < visitors.length; i++) { for (let i = 0; i < visitors.length; i++) {
const v = visitors[i]; const v = visitors[i];
if (v.matcher(offset)) { if (v.matcher(ctx.matcher, offset)) {
if (v.info.exit !== NOOP) { if (v.info.exit !== NOOP) {
if (exits === null) { if (exits === null) {
exits = [v.info.exit]; exits = [v.info.exit];
@ -633,7 +947,7 @@ function traverse(ctx, visitors, offset) {
} finally { } finally {
if (exits !== null) { if (exits !== null) {
for (let i = 0; i < exits.length; i++) { for (let i = 0; i < exits.length; i++) {
const node = /** @type {*} */ (getNode(ctx, offset)); const node = /** @type {*} */ (getNode(ctx, originalOffset));
exits[i](node); exits[i](node);
} }
} }

1014
cli/js/40_lint_selector.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ export interface AstContext {
strByProp: number[]; strByProp: number[];
typeByStr: Map<string, number>; typeByStr: Map<string, number>;
propByStr: Map<string, number>; propByStr: Map<string, number>;
matcher: MatchContext;
} }
// TODO(@marvinhagemeister) Remove once we land "official" types // TODO(@marvinhagemeister) Remove once we land "official" types
@ -43,8 +44,89 @@ export interface LintState {
export type VisitorFn = (node: unknown) => void; export type VisitorFn = (node: unknown) => void;
export interface CompiledVisitor { export interface CompiledVisitor {
matcher: (offset: number) => boolean; matcher: (ctx: MatchContext, offset: number) => boolean;
info: { enter: VisitorFn; exit: VisitorFn }; info: { enter: VisitorFn; exit: VisitorFn };
} }
export interface AttrExists {
type: 3;
prop: number[];
}
export interface AttrBin {
type: 4;
prop: number[];
op: number;
// deno-lint-ignore no-explicit-any
value: any;
}
export type AttrSelector = AttrExists | AttrBin;
export interface ElemSelector {
type: 1;
wildcard: boolean;
elem: number;
}
export interface PseudoNthChild {
type: 5;
op: string | null;
step: number;
stepOffset: number;
of: Selector | null;
repeat: boolean;
}
export interface PseudoHas {
type: 6;
selectors: Selector[];
}
export interface PseudoNot {
type: 7;
selectors: Selector[];
}
export interface PseudoFirstChild {
type: 8;
}
export interface PseudoLastChild {
type: 9;
}
export interface Relation {
type: 2;
op: number;
}
export type Selector = Array<
| ElemSelector
| Relation
| AttrExists
| AttrBin
| PseudoNthChild
| PseudoNot
| PseudoHas
| PseudoFirstChild
| PseudoLastChild
>;
export interface SelectorParseCtx {
root: Selector;
current: Selector;
}
export interface MatchContext {
getFirstChild(id: number): number;
getLastChild(id: number): number;
getSiblings(id: number): number[];
getParent(id: number): number;
getType(id: number): number;
hasAttrPath(id: number, propIds: number[], idx: number): boolean;
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;
export {}; export {};

View file

@ -215,11 +215,13 @@ impl SerializeCtx {
let type_str = ctx.str_table.insert("type"); let type_str = ctx.str_table.insert("type");
let parent_str = ctx.str_table.insert("parent"); let parent_str = ctx.str_table.insert("parent");
let range_str = ctx.str_table.insert("range"); let range_str = ctx.str_table.insert("range");
let length_str = ctx.str_table.insert("length");
// These values are expected to be in this order on the JS side // These values are expected to be in this order on the JS side
ctx.prop_map[0] = type_str; ctx.prop_map[0] = type_str;
ctx.prop_map[1] = parent_str; ctx.prop_map[1] = parent_str;
ctx.prop_map[2] = range_str; ctx.prop_map[2] = range_str;
ctx.prop_map[3] = length_str;
ctx ctx
} }

View file

@ -205,6 +205,7 @@ pub enum AstProp {
Type, Type,
Parent, Parent,
Range, Range,
Length, // Not used in AST, but can be used in attr selectors
// Starting from here the order doesn't matter. // Starting from here the order doesn't matter.
// Following are all possible AST node properties. // Following are all possible AST node properties.
@ -320,6 +321,7 @@ impl Display for AstProp {
AstProp::Parent => "parent", AstProp::Parent => "parent",
AstProp::Range => "range", AstProp::Range => "range",
AstProp::Type => "type", AstProp::Type => "type",
AstProp::Length => "length",
AstProp::Abstract => "abstract", AstProp::Abstract => "abstract",
AstProp::Accessibility => "accessibility", AstProp::Accessibility => "accessibility",
AstProp::Alternate => "alternate", AstProp::Alternate => "alternate",

View file

@ -657,6 +657,8 @@ impl CliMainWorkerFactory {
"40_test.js", "40_test.js",
"40_bench.js", "40_bench.js",
"40_jupyter.js", "40_jupyter.js",
// TODO(bartlomieju): probably shouldn't include these files here?
"40_lint_selector.js",
"40_lint.js" "40_lint.js"
); );
} }

View file

@ -52,6 +52,7 @@ util::unit_test_factory!(
kv_queue_test, kv_queue_test,
kv_queue_undelivered_test, kv_queue_undelivered_test,
link_test, link_test,
lint_selectors_test,
lint_plugin_test, lint_plugin_test,
make_temp_test, make_temp_test,
message_channel_test, message_channel_test,

View file

@ -51,22 +51,38 @@ function testPlugin(
return runLintPlugin(plugin, "source.tsx", source); return runLintPlugin(plugin, "source.tsx", source);
} }
function testVisit(source: string, ...selectors: string[]): string[] { interface VisitResult {
const log: string[] = []; selector: string;
kind: "enter" | "exit";
// deno-lint-ignore no-explicit-any
node: any;
}
function testVisit(
source: string,
...selectors: string[]
): VisitResult[] {
const result: VisitResult[] = [];
testPlugin(source, { testPlugin(source, {
create() { create() {
const visitor: LintVisitor = {}; const visitor: LintVisitor = {};
for (const s of selectors) { for (const s of selectors) {
visitor[s] = () => log.push(s); visitor[s] = (node) => {
result.push({
kind: s.endsWith(":exit") ? "exit" : "enter",
selector: s,
node,
});
};
} }
return visitor; return visitor;
}, },
}); });
return log; return result;
} }
function testLintNode(source: string, ...selectors: string[]) { function testLintNode(source: string, ...selectors: string[]) {
@ -91,14 +107,188 @@ function testLintNode(source: string, ...selectors: string[]) {
} }
Deno.test("Plugin - visitor enter/exit", () => { Deno.test("Plugin - visitor enter/exit", () => {
const enter = testVisit("foo", "Identifier"); const enter = testVisit(
assertEquals(enter, ["Identifier"]); "foo",
"Identifier",
);
assertEquals(enter[0].node.type, "Identifier");
const exit = testVisit("foo", "Identifier:exit"); const exit = testVisit(
assertEquals(exit, ["Identifier:exit"]); "foo",
"Identifier:exit",
);
assertEquals(exit[0].node.type, "Identifier");
const both = testVisit("foo", "Identifier", "Identifier:exit"); const both = testVisit("foo", "Identifier", "Identifier:exit");
assertEquals(both, ["Identifier", "Identifier:exit"]); assertEquals(both.map((t) => t.selector), ["Identifier", "Identifier:exit"]);
});
Deno.test("Plugin - visitor descendant", () => {
let result = testVisit(
"if (false) foo; if (false) bar()",
"IfStatement CallExpression",
);
assertEquals(result[0].node.type, "CallExpression");
assertEquals(result[0].node.callee.name, "bar");
result = testVisit(
"if (false) foo; foo()",
"IfStatement IfStatement",
);
assertEquals(result, []);
result = testVisit(
"if (false) foo; foo()",
"* CallExpression",
);
assertEquals(result[0].node.type, "CallExpression");
});
Deno.test("Plugin - visitor child combinator", () => {
let result = testVisit(
"if (false) foo; if (false) { bar; }",
"IfStatement > ExpressionStatement > Identifier",
);
assertEquals(result[0].node.name, "foo");
result = testVisit(
"if (false) foo; foo()",
"IfStatement IfStatement",
);
assertEquals(result, []);
});
Deno.test("Plugin - visitor next sibling", () => {
const result = testVisit(
"if (false) foo; if (false) bar;",
"IfStatement + IfStatement Identifier",
);
assertEquals(result[0].node.name, "bar");
});
Deno.test("Plugin - visitor subsequent sibling", () => {
const result = testVisit(
"if (false) foo; if (false) bar; if (false) baz;",
"IfStatement ~ IfStatement Identifier",
);
assertEquals(result.map((r) => r.node.name), ["bar", "baz"]);
});
Deno.test("Plugin - visitor attr", () => {
let result = testVisit(
"for (const a of b) {}",
"[await]",
);
assertEquals(result[0].node.await, false);
result = testVisit(
"for await (const a of b) {}",
"[await=true]",
);
assertEquals(result[0].node.await, true);
result = testVisit(
"for await (const a of b) {}",
"ForOfStatement[await=true]",
);
assertEquals(result[0].node.await, true);
result = testVisit(
"for (const a of b) {}",
"ForOfStatement[await != true]",
);
assertEquals(result[0].node.await, false);
result = testVisit(
"async function *foo() {}",
"FunctionDeclaration[async=true][generator=true]",
);
assertEquals(result[0].node.type, "FunctionDeclaration");
result = testVisit(
"foo",
"[name='foo']",
);
assertEquals(result[0].node.name, "foo");
});
Deno.test("Plugin - visitor attr length special case", () => {
let result = testVisit(
"foo(1); foo(1, 2);",
"CallExpression[arguments.length=2]",
);
assertEquals(result[0].node.arguments.length, 2);
result = testVisit(
"foo(1); foo(1, 2);",
"CallExpression[arguments.length>1]",
);
assertEquals(result[0].node.arguments.length, 2);
result = testVisit(
"foo(1); foo(1, 2);",
"CallExpression[arguments.length<2]",
);
assertEquals(result[0].node.arguments.length, 1);
result = testVisit(
"foo(1); foo(1, 2);",
"CallExpression[arguments.length<=3]",
);
assertEquals(result[0].node.arguments.length, 1);
assertEquals(result[1].node.arguments.length, 2);
result = testVisit(
"foo(1); foo(1, 2);",
"CallExpression[arguments.length>=1]",
);
assertEquals(result[0].node.arguments.length, 1);
assertEquals(result[1].node.arguments.length, 2);
});
Deno.test("Plugin - visitor :first-child", () => {
const result = testVisit(
"{ foo; bar }",
"BlockStatement ExpressionStatement:first-child Identifier",
);
assertEquals(result[0].node.name, "foo");
});
Deno.test("Plugin - visitor :last-child", () => {
const result = testVisit(
"{ foo; bar }",
"BlockStatement ExpressionStatement:last-child Identifier",
);
assertEquals(result[0].node.name, "bar");
});
Deno.test("Plugin - visitor :nth-child", () => {
let result = testVisit(
"{ foo; bar; baz; foobar; }",
"BlockStatement ExpressionStatement:nth-child(2) Identifier",
);
assertEquals(result[0].node.name, "bar");
result = testVisit(
"{ foo; bar; baz; foobar; }",
"BlockStatement ExpressionStatement:nth-child(2n) Identifier",
);
assertEquals(result[0].node.name, "foo");
assertEquals(result[1].node.name, "baz");
result = testVisit(
"{ foo; bar; baz; foobar; }",
"BlockStatement ExpressionStatement:nth-child(2n + 1) Identifier",
);
assertEquals(result[0].node.name, "bar");
assertEquals(result[1].node.name, "foobar");
result = testVisit(
"{ foo; bar; baz; foobar; }",
"BlockStatement *:nth-child(2n + 1 of ExpressionStatement) Identifier",
);
assertEquals(result[0].node.name, "bar");
assertEquals(result[1].node.name, "foobar");
}); });
Deno.test("Plugin - Program", () => { Deno.test("Plugin - Program", () => {

View file

@ -0,0 +1,610 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { assertEquals } from "@std/assert/equals";
import {
ATTR_BIN_NODE,
ATTR_EXISTS_NODE,
BinOp,
ELEM_NODE,
Lexer,
parseSelector,
PSEUDO_FIRST_CHILD,
PSEUDO_HAS,
PSEUDO_LAST_CHILD,
PSEUDO_NOT,
PSEUDO_NTH_CHILD,
RELATION_NODE,
splitSelectors,
Token,
} from "../../cli/js/40_lint_selector.js";
import { assertThrows } from "@std/assert";
Deno.test("splitSelectors", () => {
assertEquals(splitSelectors("foo"), ["foo"]);
assertEquals(splitSelectors("foo, bar"), ["foo", "bar"]);
assertEquals(splitSelectors("foo:f(bar, baz)"), ["foo:f(bar, baz)"]);
assertEquals(splitSelectors("foo:f(bar, baz), foobar"), [
"foo:f(bar, baz)",
"foobar",
]);
});
interface LexState {
token: number;
value: string;
}
function testLexer(input: string): LexState[] {
const out: LexState[] = [];
const l = new Lexer(input);
while (l.token !== Token.EOF) {
out.push({ token: l.token, value: l.value });
l.next();
}
return out;
}
const Tags: Record<string, number> = { Foo: 1, Bar: 2, FooBar: 3 };
const Attrs: Record<string, number> = { foo: 1, bar: 2, foobar: 3, attr: 4 };
const toTag = (name: string): number => Tags[name];
const toAttr = (name: string): number => Attrs[name];
const testParse = (input: string) => parseSelector(input, toTag, toAttr);
Deno.test("Lexer - Elem", () => {
assertEquals(testLexer("Foo"), [
{ token: Token.Word, value: "Foo" },
]);
assertEquals(testLexer("foo-bar"), [
{ token: Token.Word, value: "foo-bar" },
]);
assertEquals(testLexer("foo_bar"), [
{ token: Token.Word, value: "foo_bar" },
]);
assertEquals(testLexer("Foo Bar Baz"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Space, value: "" },
{ token: Token.Word, value: "Bar" },
{ token: Token.Space, value: "" },
{ token: Token.Word, value: "Baz" },
]);
assertEquals(testLexer("Foo Bar Baz"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Space, value: "" },
{ token: Token.Word, value: "Bar" },
{ token: Token.Space, value: "" },
{ token: Token.Word, value: "Baz" },
]);
});
Deno.test("Lexer - Relation >", () => {
assertEquals(testLexer("Foo > Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Op, value: ">" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer("Foo>Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Op, value: ">" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer(">Bar"), [
{ token: Token.Op, value: ">" },
{ token: Token.Word, value: "Bar" },
]);
});
Deno.test("Lexer - Relation +", () => {
assertEquals(testLexer("Foo + Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Op, value: "+" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer("Foo+Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Op, value: "+" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer("+Bar"), [
{ token: Token.Op, value: "+" },
{ token: Token.Word, value: "Bar" },
]);
});
Deno.test("Lexer - Relation ~", () => {
assertEquals(testLexer("Foo ~ Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Op, value: "~" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer("Foo~Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Op, value: "~" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer("~Bar"), [
{ token: Token.Op, value: "~" },
{ token: Token.Word, value: "Bar" },
]);
assertEquals(testLexer("Foo Bar ~ Bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Space, value: "" },
{ token: Token.Word, value: "Bar" },
{ token: Token.Op, value: "~" },
{ token: Token.Word, value: "Bar" },
]);
});
Deno.test("Lexer - Attr", () => {
assertEquals(testLexer("[attr]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr=1]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: "=" },
{ token: Token.Word, value: "1" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr='foo']"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: "=" },
{ token: Token.String, value: "foo" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr>=2]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: ">=" },
{ token: Token.Word, value: "2" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr<=2]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: "<=" },
{ token: Token.Word, value: "2" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr>2]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: ">" },
{ token: Token.Word, value: "2" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr<2]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: "<" },
{ token: Token.Word, value: "2" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr!=2]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Op, value: "!=" },
{ token: Token.Word, value: "2" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr.foo=1]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.Dot, value: "" },
{ token: Token.Word, value: "foo" },
{ token: Token.Op, value: "=" },
{ token: Token.Word, value: "1" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("[attr] [attr]"), [
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.BracketClose, value: "" },
{ token: Token.Space, value: "" },
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.BracketClose, value: "" },
]);
assertEquals(testLexer("Foo[attr][attr2=1]"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr" },
{ token: Token.BracketClose, value: "" },
{ token: Token.BracketOpen, value: "" },
{ token: Token.Word, value: "attr2" },
{ token: Token.Op, value: "=" },
{ token: Token.Word, value: "1" },
{ token: Token.BracketClose, value: "" },
]);
});
Deno.test("Lexer - Pseudo", () => {
assertEquals(testLexer(":foo-bar"), [
{ token: Token.Colon, value: "" },
{ token: Token.Word, value: "foo-bar" },
]);
assertEquals(testLexer("Foo:foo-bar"), [
{ token: Token.Word, value: "Foo" },
{ token: Token.Colon, value: "" },
{ token: Token.Word, value: "foo-bar" },
]);
assertEquals(testLexer(":foo-bar(baz)"), [
{ token: Token.Colon, value: "" },
{ token: Token.Word, value: "foo-bar" },
{ token: Token.BraceOpen, value: "" },
{ token: Token.Word, value: "baz" },
{ token: Token.BraceClose, value: "" },
]);
assertEquals(testLexer(":foo-bar(2n + 1)"), [
{ token: Token.Colon, value: "" },
{ token: Token.Word, value: "foo-bar" },
{ token: Token.BraceOpen, value: "" },
{ token: Token.Word, value: "2n" },
{ token: Token.Op, value: "+" },
{ token: Token.Word, value: "1" },
{ token: Token.BraceClose, value: "" },
]);
});
Deno.test("Parser - Elem", () => {
assertEquals(testParse("Foo"), [[
{
type: ELEM_NODE,
elem: 1,
wildcard: false,
},
]]);
});
Deno.test("Parser - Relation (descendant)", () => {
assertEquals(testParse("Foo Bar"), [[
{
type: ELEM_NODE,
elem: 1,
wildcard: false,
},
{
type: RELATION_NODE,
op: BinOp.Space,
},
{
type: ELEM_NODE,
elem: 2,
wildcard: false,
},
]]);
});
Deno.test("Parser - Relation", () => {
assertEquals(testParse("Foo > Bar"), [[
{
type: ELEM_NODE,
elem: 1,
wildcard: false,
},
{
type: RELATION_NODE,
op: BinOp.Greater,
},
{
type: ELEM_NODE,
elem: 2,
wildcard: false,
},
]]);
assertEquals(testParse("Foo ~ Bar"), [[
{
type: ELEM_NODE,
elem: 1,
wildcard: false,
},
{
type: RELATION_NODE,
op: BinOp.Tilde,
},
{
type: ELEM_NODE,
elem: 2,
wildcard: false,
},
]]);
assertEquals(testParse("Foo + Bar"), [[
{
type: ELEM_NODE,
elem: 1,
wildcard: false,
},
{
type: RELATION_NODE,
op: BinOp.Plus,
},
{
type: ELEM_NODE,
elem: 2,
wildcard: false,
},
]]);
});
Deno.test("Parser - Attr", () => {
assertEquals(testParse("[foo]"), [[
{
type: ATTR_EXISTS_NODE,
prop: [1],
},
]]);
assertEquals(testParse("[foo][bar]"), [[
{
type: ATTR_EXISTS_NODE,
prop: [1],
},
{
type: ATTR_EXISTS_NODE,
prop: [2],
},
]]);
assertEquals(testParse("[foo=1]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: 1,
},
]]);
assertEquals(testParse("[foo=true]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: true,
},
]]);
assertEquals(testParse("[foo=false]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: false,
},
]]);
assertEquals(testParse("[foo=null]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: null,
},
]]);
assertEquals(testParse("[foo='str']"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: "str",
},
]]);
assertEquals(testParse('[foo="str"]'), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: "str",
},
]]);
assertEquals(testParse("[foo=/str/]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: /str/,
},
]]);
assertEquals(testParse("[foo=/str/g]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1],
value: /str/g,
},
]]);
});
Deno.test("Parser - Attr nested", () => {
assertEquals(testParse("[foo.bar]"), [[
{
type: ATTR_EXISTS_NODE,
prop: [1, 2],
},
]]);
assertEquals(testParse("[foo.bar = 2]"), [[
{
type: ATTR_BIN_NODE,
op: BinOp.Equal,
prop: [1, 2],
value: 2,
},
]]);
});
Deno.test("Parser - Pseudo no value", () => {
assertEquals(testParse(":first-child"), [[
{
type: PSEUDO_FIRST_CHILD,
},
]]);
assertEquals(testParse(":last-child"), [[
{
type: PSEUDO_LAST_CHILD,
},
]]);
});
Deno.test("Parser - Pseudo nth-child", () => {
assertEquals(testParse(":nth-child(2)"), [[
{
type: PSEUDO_NTH_CHILD,
of: null,
op: null,
step: 0,
stepOffset: 1,
repeat: false,
},
]]);
assertEquals(testParse(":nth-child(2n)"), [[
{
type: PSEUDO_NTH_CHILD,
of: null,
op: null,
step: 2,
stepOffset: 0,
repeat: true,
},
]]);
assertEquals(testParse(":nth-child(-2n)"), [[
{
type: PSEUDO_NTH_CHILD,
of: null,
op: null,
step: -2,
stepOffset: 0,
repeat: true,
},
]]);
assertEquals(testParse(":nth-child(2n + 1)"), [[
{
type: PSEUDO_NTH_CHILD,
of: null,
op: "+",
step: 2,
stepOffset: 1,
repeat: true,
},
]]);
assertEquals(testParse(":nth-child(2n + 1 of Foo[attr])"), [[
{
type: PSEUDO_NTH_CHILD,
of: [
{ type: ELEM_NODE, elem: 1, wildcard: false },
{ type: ATTR_EXISTS_NODE, prop: [4] },
],
op: "+",
step: 2,
stepOffset: 1,
repeat: true,
},
]]);
// Invalid selectors
assertThrows(() => testParse(":nth-child(2n + 1 of Foo[attr], Bar)"));
assertThrows(() => testParse(":nth-child(2n - 1 foo)"));
});
Deno.test("Parser - Pseudo has/is/where", () => {
assertEquals(testParse(":has(Foo:has(Foo), Bar)"), [[
{
type: PSEUDO_HAS,
selectors: [
[
{ type: ELEM_NODE, elem: 1, wildcard: false },
{
type: PSEUDO_HAS,
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,
selectors: [
[
{ type: ELEM_NODE, elem: 1, wildcard: false },
{
type: PSEUDO_HAS,
selectors: [
[{ type: ELEM_NODE, elem: 1, wildcard: false }],
],
},
],
[
{ type: ELEM_NODE, elem: 2, wildcard: false },
],
],
},
]]);
assertEquals(testParse(":is(Foo:is(Foo), Bar)"), [[
{
type: PSEUDO_HAS,
selectors: [
[
{ type: ELEM_NODE, elem: 1, wildcard: false },
{
type: PSEUDO_HAS,
selectors: [
[{ type: ELEM_NODE, elem: 1, wildcard: false }],
],
},
],
[
{ type: ELEM_NODE, elem: 2, wildcard: false },
],
],
},
]]);
});
Deno.test("Parser - Pseudo not", () => {
assertEquals(testParse(":not(Foo:not(Foo), Bar)"), [[
{
type: PSEUDO_NOT,
selectors: [
[
{ type: ELEM_NODE, elem: 1, wildcard: false },
{
type: PSEUDO_NOT,
selectors: [
[{ type: ELEM_NODE, elem: 1, wildcard: false }],
],
},
],
[
{ type: ELEM_NODE, elem: 2, wildcard: false },
],
],
},
]]);
});
Deno.test("Parser - mixed", () => {
assertEquals(testParse("Foo[foo=true] Bar"), [[
{
type: ELEM_NODE,
elem: 1,
wildcard: false,
},
{ type: ATTR_BIN_NODE, op: BinOp.Equal, prop: [1], value: true },
{ type: RELATION_NODE, op: BinOp.Space },
{
type: ELEM_NODE,
elem: 2,
wildcard: false,
},
]]);
});

View file

@ -250,6 +250,7 @@
"ext:deno_node/_util/std_fmt_colors.ts": "../ext/node/polyfills/_util/std_fmt_colors.ts", "ext:deno_node/_util/std_fmt_colors.ts": "../ext/node/polyfills/_util/std_fmt_colors.ts",
"ext:deno_telemetry/telemetry.ts": "../ext/deno_telemetry/telemetry.ts", "ext:deno_telemetry/telemetry.ts": "../ext/deno_telemetry/telemetry.ts",
"ext:deno_telemetry/util.ts": "../ext/deno_telemetry/util.ts", "ext:deno_telemetry/util.ts": "../ext/deno_telemetry/util.ts",
"ext:cli/40_lint_selector.js": "../cli/js/40_lint_selector.js",
"@std/archive": "../tests/util/std/archive/mod.ts", "@std/archive": "../tests/util/std/archive/mod.ts",
"@std/archive/tar": "../tests/util/std/archive/tar.ts", "@std/archive/tar": "../tests/util/std/archive/tar.ts",
"@std/archive/untar": "../tests/util/std/archive/untar.ts", "@std/archive/untar": "../tests/util/std/archive/untar.ts",