diff --git a/cli/js/40_lint.js b/cli/js/40_lint.js index 9606f787b3..30b1f86884 100644 --- a/cli/js/40_lint.js +++ b/cli/js/40_lint.js @@ -2,6 +2,11 @@ // @ts-check +import { + compileSelector, + parseSelector, + splitSelectors, +} from "ext:cli/40_lint_selector.js"; import { core, internals } from "ext:core/mod.js"; const { op_lint_create_serialized_ast, @@ -13,6 +18,7 @@ const { const AST_PROP_TYPE = 0; const AST_PROP_PARENT = 1; const AST_PROP_RANGE = 2; +const AST_PROP_LENGTH = 3; // Keep in sync with Rust // 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").NodeFacade} NodeFacade */ /** @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").TestReportData} TestReportData */ +/** @typedef {import("./40_lint_types.d.ts").TransformFn} TransformFn */ +/** @typedef {import("./40_lint_types.d.ts").MatchContext} MatchContext */ /** @type {LintState} */ const state = { @@ -99,7 +105,6 @@ export function installPlugin(plugin) { */ function getNode(ctx, offset) { if (offset === 0) return null; - const cached = ctx.nodes.get(offset); if (cached !== undefined) return cached; @@ -297,9 +302,10 @@ function readValue(ctx, offset, search) { if (offset === -1) return undefined; const kind = buf[offset + 1]; + offset += 2; if (kind === PropFlags.Ref) { - const value = readU32(buf, offset + 2); + const value = readU32(buf, offset); return getNode(ctx, value); } else if (kind === PropFlags.RefArr) { const len = readU32(buf, offset); @@ -353,6 +359,303 @@ function getString(strTable, id) { 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 {AstContext} buf @@ -433,6 +736,7 @@ function createAstContext(buf) { strByType, typeByStr, propByStr, + matcher: new MatchCtx(buf, strTable), }; setNodeGetters(ctx); @@ -456,7 +760,7 @@ const NOOP = (_node) => {}; export function runPluginsForFile(fileName, serializedAst) { const ctx = createAstContext(serializedAst); - /** @type {Map} */ + /** @type {Map}>} */ const bySelector = new Map(); const destroyFns = []; @@ -486,32 +790,38 @@ export function runPluginsForFile(fileName, serializedAst) { key = key.slice(0, -":exit".length); } - let info = bySelector.get(key); - if (info === undefined) { - info = { enter: NOOP, exit: NOOP }; - bySelector.set(key, info); - } - const prevFn = isExit ? info.exit : info.enter; + const selectors = splitSelectors(key); - /** - * @param {*} node - */ - const wrapped = (node) => { - prevFn(node); + for (let j = 0; j < selectors.length; j++) { + const key = selectors[j]; - try { - fn(node); - } catch (err) { - throw new Error(`Visitor "${name}" of plugin "${id}" errored`, { - cause: err, - }); + let info = bySelector.get(key); + if (info === undefined) { + info = { enter: NOOP, exit: NOOP }; + bySelector.set(key, info); } - }; + const prevFn = isExit ? info.exit : info.enter; - if (isExit) { - info.exit = wrapped; - } else { - info.enter = wrapped; + /** + * @param {*} node + */ + 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[]} */ const visitors = []; for (const [sel, info] of bySelector.entries()) { - // This will make more sense once selectors land as it's faster - // to precompile them once upfront. + // Selectors are already split here. + // 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 - // 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; - }, - }); + visitors.push({ info, matcher }); } // 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 if (offset === 0) return; + const originalOffset = offset; + const { buf } = ctx; /** @type {VisitorFn[] | null} */ @@ -580,7 +894,7 @@ function traverse(ctx, visitors, offset) { for (let i = 0; i < visitors.length; i++) { const v = visitors[i]; - if (v.matcher(offset)) { + if (v.matcher(ctx.matcher, offset)) { if (v.info.exit !== NOOP) { if (exits === null) { exits = [v.info.exit]; @@ -633,7 +947,7 @@ function traverse(ctx, visitors, offset) { } finally { if (exits !== null) { for (let i = 0; i < exits.length; i++) { - const node = /** @type {*} */ (getNode(ctx, offset)); + const node = /** @type {*} */ (getNode(ctx, originalOffset)); exits[i](node); } } diff --git a/cli/js/40_lint_selector.js b/cli/js/40_lint_selector.js new file mode 100644 index 0000000000..b78f7a5d0e --- /dev/null +++ b/cli/js/40_lint_selector.js @@ -0,0 +1,1014 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +// @ts-check + +/** @typedef {import("./40_lint_types.d.ts").LintState} LintState */ +/** @typedef {import("./40_lint_types.d.ts").AstContext} AstContext */ +/** @typedef {import("./40_lint_types.d.ts").MatchContext} MatchCtx */ +/** @typedef {import("./40_lint_types.d.ts").AttrExists} AttrExists */ +/** @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").PseudoNthChild} PseudoNthChild */ +/** @typedef {import("./40_lint_types.d.ts").PseudoHas} PseudoHas */ +/** @typedef {import("./40_lint_types.d.ts").PseudoNot} PseudoNot */ +/** @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 */ + +const Char = { + Tab: 9, + Space: 32, + Bang: 33, + DoubleQuote: 34, + Quote: 39, + BraceOpen: 40, + BraceClose: 41, + Plus: 43, + Comma: 44, + Minus: 45, + Dot: 46, + Slash: 47, + n0: 49, + n9: 57, + Colon: 58, + Less: 60, + Equal: 61, + Greater: 62, + A: 65, + Z: 90, + BracketOpen: 91, + BackSlash: 92, + BracketClose: 93, + Underscore: 95, + a: 97, + z: 122, + Tilde: 126, +}; + +export const Token = { + EOF: 0, + Word: 1, + Space: 2, + Op: 3, + Colon: 4, + Comma: 7, + BraceOpen: 8, + BraceClose: 9, + BracketOpen: 10, + BracketClose: 11, + String: 12, + Number: 13, + Bool: 14, + Null: 15, + Undefined: 16, + Dot: 17, + Minus: 17, +}; + +export const BinOp = { + /** [attr="value"] or [attr=value] */ + Equal: 1, + /** [attr!="value"] or [attr!=value] */ + NotEqual: 2, + /** [attr>1] */ + Greater: 3, + /** [attr>=1] */ + GreaterThan: 4, + /** [attr<1] */ + Less: 5, + /** [attr<=1] */ + LessThan: 6, + Tilde: 7, + Plus: 8, + Space: 9, +}; + +/** + * @param {string} s + * @returns {number} + */ +function getAttrOp(s) { + switch (s) { + case "=": + return BinOp.Equal; + case "!=": + return BinOp.NotEqual; + case ">": + return BinOp.Greater; + case ">=": + return BinOp.GreaterThan; + case "<": + return BinOp.Less; + case "<=": + return BinOp.LessThan; + case "~": + return BinOp.Tilde; + case "+": + return BinOp.Plus; + default: + throw new Error(`Unknown attribute operator: '${s}'`); + } +} + +export class Lexer { + token = Token.Word; + start = 0; + end = 0; + ch = 0; + i = -1; + + value = ""; + + /** + * @param {string} input + */ + constructor(input) { + this.input = input; + this.step(); + this.next(); + } + + /** + * @param {number} token + */ + expect(token) { + if (this.token !== token) { + throw new Error( + `Expected token '${token}', but got '${this.token}'.\n\n${this.input}\n${ + " ".repeat(this.i) + }^`, + ); + } + } + + /** + * @param {number} token + */ + readAsWordUntil(token) { + const s = this.i; + while (this.token !== Token.EOF && this.token !== token) { + this.next(); + } + + this.start = s; + this.end = this.i - 1; + this.value = this.getSlice(); + } + + getSlice() { + return this.input.slice(this.start, this.end); + } + + step() { + this.i++; + if (this.i >= this.input.length) { + this.ch = -1; + } else { + this.ch = this.input.charCodeAt(this.i); + } + } + + next() { + this.value = ""; + + if (this.i >= this.input.length) { + this.token = Token.EOF; + return; + } + + // console.log( + // "NEXT", + // this.input, + // this.i, + // JSON.stringify(String.fromCharCode(this.ch)), + // ); + + while (true) { + switch (this.ch) { + case Char.Space: + while (this.isWhiteSpace()) { + this.step(); + } + + // Check if space preceeded operator + if (this.isOpContinue()) { + continue; + } + + this.token = Token.Space; + return; + case Char.BracketOpen: + this.token = Token.BracketOpen; + this.step(); + return; + case Char.BracketClose: + this.token = Token.BracketClose; + this.step(); + return; + case Char.BraceOpen: + this.token = Token.BraceOpen; + this.step(); + return; + case Char.BraceClose: + this.token = Token.BraceClose; + this.step(); + return; + case Char.Colon: + this.token = Token.Colon; + this.step(); + return; + case Char.Comma: + this.token = Token.Comma; + this.step(); + return; + case Char.Dot: + this.token = Token.Dot; + this.step(); + return; + case Char.Minus: + this.token = Token.Minus; + this.step(); + return; + + case Char.Plus: + case Char.Tilde: + case Char.Greater: + case Char.Equal: + case Char.Less: + case Char.Bang: { + this.token = Token.Op; + this.start = this.i; + this.step(); + + while (this.isOpContinue()) { + this.step(); + } + + this.end = this.i; + this.value = this.getSlice(); + + // Consume remaining space + while (this.isWhiteSpace()) { + this.step(); + } + + return; + } + + case Char.Quote: + case Char.DoubleQuote: { + this.token = Token.String; + const ch = this.ch; + + this.step(); + this.start = this.i; + + while (this.ch > 0 && this.ch !== ch) { + this.step(); + } + + this.end = this.i; + this.value = this.getSlice(); + this.step(); + + return; + } + + default: + this.start = this.i; + this.step(); + + while (this.isWordContinue()) { + this.step(); + } + + this.end = this.i; + this.value = this.getSlice(); + this.token = Token.Word; + return; + } + } + } + + isWordContinue() { + const ch = this.ch; + switch (ch) { + case Char.Minus: + case Char.Underscore: + return true; + default: + return (ch >= Char.a && ch <= Char.z) || + (ch >= Char.A && ch <= Char.Z) || + (ch >= Char.n0 && ch <= Char.n9); + } + } + + isOpContinue() { + const ch = this.ch; + switch (ch) { + case Char.Equal: + case Char.Bang: + case Char.Greater: + case Char.Less: + case Char.Tilde: + case Char.Plus: + return true; + default: + return false; + } + } + + isWhiteSpace() { + return this.ch === Char.Space || this.ch === Char.Tab; + } +} + +const NUMBER_REG = /^(\d+\.)?\d+$/; +const BIGINT_REG = /^\d+n$/; + +/** + * @param {string} raw + * @returns {any} + */ +function getFromRawValue(raw) { + switch (raw) { + case "true": + return true; + case "false": + return false; + case "null": + return null; + case "undefined": + return undefined; + default: + if (raw.startsWith("'") && raw.endsWith("'")) { + if (raw.length === 2) return ""; + return raw.slice(1, -1); + } else if (raw.startsWith('"') && raw.endsWith('"')) { + if (raw.length === 2) return ""; + return raw.slice(1, -1); + } else if (raw.startsWith("/")) { + const end = raw.lastIndexOf("/"); + if (end === -1) throw new Error(`Invalid RegExp pattern: ${raw}`); + const pattern = raw.slice(1, end); + const flags = end < raw.length - 1 ? raw.slice(end + 1) : undefined; + return new RegExp(pattern, flags); + } else if (NUMBER_REG.test(raw)) { + return Number(raw); + } else if (BIGINT_REG.test(raw)) { + return BigInt(raw.slice(0, -1)); + } + + return raw; + } +} + +export const ELEM_NODE = 1; +export const RELATION_NODE = 2; +export const ATTR_EXISTS_NODE = 3; +export const ATTR_BIN_NODE = 4; +export const PSEUDO_NTH_CHILD = 5; +export const PSEUDO_HAS = 6; +export const PSEUDO_NOT = 7; +export const PSEUDO_FIRST_CHILD = 8; +export const PSEUDO_LAST_CHILD = 9; + +/** + * Parse out all unique selectors of a selector list. + * @param {string} input + * @returns {string[]} + */ +export function splitSelectors(input) { + /** @type {string[]} */ + const out = []; + + let last = 0; + let depth = 0; + for (let i = 0; i < input.length; i++) { + const ch = input.charCodeAt(i); + switch (ch) { + case Char.BraceOpen: + depth++; + break; + case Char.BraceClose: + depth--; + break; + case Char.Comma: + if (depth === 0) { + out.push(input.slice(last, i).trim()); + last = i + 1; + } + break; + } + } + + if (last < input.length - 1) { + out.push(input.slice(last).trim()); + } + + return out; +} + +/** + * @param {string} input + * @param {Transformer} toElem + * @param {Transformer} toAttr + * @returns {Selector[]} + */ +export function parseSelector(input, toElem, toAttr) { + /** @type {Selector[]} */ + const result = []; + + /** @type {Selector[]} */ + const stack = [[]]; + + const lex = new Lexer(input); + + // Some subselectors like `:nth-child(.. of )` must have + // a single selector instead of selector list. + let throwOnComma = false; + + while (lex.token !== Token.EOF) { + const current = /** @type {Selector} */ (stack.at(-1)); + + if (lex.token === Token.Word) { + const value = lex.value; + const wildcard = value === "*"; + + const elem = !wildcard ? toElem(value) : 0; + current.push({ + type: ELEM_NODE, + elem, + wildcard, + }); + lex.next(); + + continue; + } else if (lex.token === Token.Space) { + lex.next(); + + if (lex.token === Token.Word) { + current.push({ + type: RELATION_NODE, + op: BinOp.Space, + }); + } + + continue; + } else if (lex.token === Token.BracketOpen) { + lex.next(); + lex.expect(Token.Word); + + // Check for value comparison + const prop = [toAttr(lex.value)]; + lex.next(); + + while (lex.token === Token.Dot) { + lex.next(); + lex.expect(Token.Word); + + prop.push(toAttr(lex.value)); + lex.next(); + } + + if (lex.token === Token.Op) { + const op = getAttrOp(lex.value); + lex.readAsWordUntil(Token.BracketClose); + + const value = getFromRawValue(lex.value); + current.push({ type: ATTR_BIN_NODE, prop, op, value }); + } else { + current.push({ + type: ATTR_EXISTS_NODE, + prop, + }); + } + + lex.expect(Token.BracketClose); + lex.next(); + continue; + } else if (lex.token === Token.Colon) { + lex.next(); + lex.expect(Token.Word); + + switch (lex.value) { + case "first-child": + current.push({ + type: PSEUDO_FIRST_CHILD, + }); + break; + case "last-child": + current.push({ + type: PSEUDO_LAST_CHILD, + }); + break; + case "nth-child": { + lex.next(); + lex.expect(Token.BraceOpen); + lex.next(); + + let mul = 1; + let repeat = false; + let step = 0; + if (lex.token === Token.Minus) { + mul = -1; + lex.next(); + } + + lex.expect(Token.Word); + const value = lex.getSlice(); + + if (value.endsWith("n")) { + repeat = true; + step = +value.slice(0, -1) * mul; + } else { + step = +value * mul; + } + + lex.next(); + + /** @type {PseudoNthChild} */ + const node = { + type: PSEUDO_NTH_CHILD, + of: null, + op: null, + step, + stepOffset: 0, + repeat, + }; + current.push(node); + + if (lex.token === Token.Space) lex.next(); + + if (lex.token !== Token.BraceClose) { + if (lex.token === Token.Op) { + node.op = lex.value; + lex.next(); + + if (lex.token === Token.Space) lex.next(); + } else if (lex.token === Token.Minus) { + node.op = "-"; + lex.next(); + + if (lex.token === Token.Space) { + lex.next(); + } + } + + lex.expect(Token.Word); + node.stepOffset = +lex.value; + lex.next(); + + if (lex.token !== Token.BraceClose) { + lex.next(); // Space + + if (lex.token === Token.Word) { + if (/** @type {string} */ (lex.value) !== "of") { + throw new Error( + `Expected 'of' keyword in ':nth-child' but got: ${lex.value}`, + ); + } + + lex.next(); + lex.expect(Token.Space); + lex.next(); + throwOnComma = true; + stack.push([]); + } + + continue; + } + + lex.expect(Token.BraceClose); + } else if (!node.repeat) { + // :nth-child(2) -> step is actually stepOffset + node.stepOffset = node.step - 1; + node.step = 0; + } + + lex.next(); + + continue; + } + + case "has": + case "where": + case "is": { + lex.next(); + lex.expect(Token.BraceOpen); + lex.next(); + + current.push({ + type: PSEUDO_HAS, + selectors: [], + }); + stack.push([]); + + continue; + } + case "not": { + lex.next(); + lex.expect(Token.BraceOpen); + lex.next(); + + current.push({ + type: PSEUDO_NOT, + selectors: [], + }); + stack.push([]); + + continue; + } + default: + throw new Error(`Unknown pseudo selector: '${lex.value}'`); + } + } else if (lex.token === Token.Comma) { + if (throwOnComma) { + throw new Error(`Multiple selector arguments not supported here`); + } + + lex.next(); + if (lex.token === Token.Space) { + lex.next(); + } + + popSelector(result, stack); + stack.push([]); + continue; + } else if (lex.token === Token.BraceClose) { + throwOnComma = false; + popSelector(result, stack); + } else if (lex.token === Token.Op) { + current.push({ + type: RELATION_NODE, + op: getAttrOp(lex.value), + }); + } + + lex.next(); + } + + if (stack.length > 0) { + result.push(stack[0]); + } + + return result; +} + +/** + * @param {Selector[]} result + * @param {Selector[]} stack + */ +function popSelector(result, stack) { + const sel = /** @type {Selector} */ (stack.pop()); + + if (stack.length === 0) { + result.push(sel); + stack.push([]); + } else { + const prev = /** @type {Selector} */ (stack.at(-1)); + if (prev.length === 0) { + throw new Error(`Empty selector`); + } + + const node = prev.at(-1); + if (node === undefined) { + throw new Error(`Empty node`); + } + + if (node.type === PSEUDO_NTH_CHILD) { + node.of = sel; + } else if (node.type === PSEUDO_HAS || node.type === PSEUDO_NOT) { + node.selectors.push(sel); + } else { + throw new Error(`Multiple selectors not allowed here`); + } + } +} + +const TRUE_FN = () => { + return true; +}; + +/** + * @param {Selector} selector + * @returns {MatcherFn} + */ +export function compileSelector(selector) { + /** @type {MatcherFn} */ + let fn = TRUE_FN; + + for (let i = 0; i < selector.length; i++) { + const node = selector[i]; + + switch (node.type) { + case ELEM_NODE: + fn = matchElem(node, fn); + break; + case RELATION_NODE: + switch (node.op) { + case BinOp.Space: + fn = matchDescendant(fn); + break; + case BinOp.Greater: + fn = matchChild(fn); + break; + case BinOp.Plus: + fn = matchAdjacent(fn); + break; + case BinOp.Tilde: + fn = matchFollowing(fn); + break; + default: + throw new Error(`Unknown relation op ${node.op}`); + } + break; + case ATTR_EXISTS_NODE: + fn = matchAttrExists(node, fn); + break; + case ATTR_BIN_NODE: + fn = matchAttrBin(node, fn); + break; + case PSEUDO_FIRST_CHILD: + fn = matchFirstChild(fn); + break; + case PSEUDO_LAST_CHILD: + fn = matchLastChild(fn); + break; + case PSEUDO_NTH_CHILD: + fn = matchNthChild(node, fn); + break; + case PSEUDO_HAS: + // FIXME + // fn = matchIs(part, fn); + throw new Error("TODO: :has"); + case PSEUDO_NOT: + fn = matchNot(node.selectors, fn); + break; + default: + // @ts-ignore error handling + // deno-lint-ignore no-console + console.log(node); + throw new Error(`Unknown selector node`); + } + } + + return fn; +} + +/** + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchFirstChild(next) { + return (ctx, id) => { + const parent = ctx.getParent(id); + const first = ctx.getFirstChild(parent); + return first === id && next(ctx, first); + }; +} + +/** + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchLastChild(next) { + return (ctx, id) => { + const parent = ctx.getParent(id); + const last = ctx.getLastChild(parent); + return last === id && next(ctx, id); + }; +} + +/** + * @param {PseudoNthChild} node + * @param {number} i + * @returns {number} + */ +function getNthAnB(node, i) { + const n = node.step * i; + + if (node.op === null) return n; + + switch (node.op) { + case "+": + return n + node.stepOffset; + case "-": + return n - node.stepOffset; + default: + throw new Error("Not supported nth-child operator: " + node.op); + } +} + +/** + * @param {PseudoNthChild} node + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchNthChild(node, next) { + const ofSelector = node.of !== null ? compileSelector(node.of) : TRUE_FN; + + // TODO(@marvinhagemeister): we should probably cache results here + + return (ctx, id) => { + const siblings = ctx.getSiblings(id); + const idx = siblings.indexOf(id); + + if (!node.repeat) { + return idx === node.stepOffset && next(ctx, id); + } + + for (let i = 0; i < siblings.length; i++) { + const n = getNthAnB(node, i); + + if (n > siblings.length - 1) return false; + + const search = siblings[n]; + if (id === search) { + if (node.of !== null && !ofSelector(ctx, id)) { + continue; + } else if (next(ctx, id)) { + return true; + } + } else if (n > idx) { + return false; + } + } + + return false; + }; +} + +/** + * @param {Selector[]} selectors + * @param {NextFn} 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)); + } + + return (ctx, id) => { + for (let i = 0; i < compiled.length; i++) { + const fn = compiled[i]; + if (fn(ctx, id)) { + return false; + } + } + + return next(ctx, id); + }; +} + +/** + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchDescendant(next) { + // TODO(@marvinhagemeister): we should probably cache results here + return (ctx, id) => { + let current = ctx.getParent(id); + while (current > 0) { + if (next(ctx, current)) { + return true; + } + + current = ctx.getParent(current); + } + + return false; + }; +} + +/** + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchChild(next) { + return (ctx, id) => { + const parent = ctx.getParent(id); + if (parent < 0) return false; + + return next(ctx, parent); + }; +} + +/** + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchAdjacent(next) { + return (ctx, id) => { + const siblings = ctx.getSiblings(id); + const idx = siblings.indexOf(id) - 1; + + if (idx < 0) return false; + + const prev = siblings[idx]; + return next(ctx, prev); + }; +} + +/** + * @param {NextFn} next + * @returns {MatcherFn} + */ +function matchFollowing(next) { + return (ctx, id) => { + const siblings = ctx.getSiblings(id); + const idx = siblings.indexOf(id) - 1; + + if (idx < 0) return false; + + for (let i = idx; i >= 0; i--) { + const sib = siblings[i]; + if (next(ctx, sib)) return true; + } + + return false; + }; +} + +/** + * @param {ElemSelector} part + * @param {MatcherFn} next + * @returns {MatcherFn} + */ +function matchElem(part, next) { + return (ctx, id) => { + // Placeholder node cannot be matched + if (id === 0) return false; + // Wildcard always matches + else if (part.wildcard) return next(ctx, id); + // 0 means it's the placeholder node which + // can never be matched. + else if (part.elem === 0) return false; + + const type = ctx.getType(id); + if (type > 0 && type === part.elem) return next(ctx, id); + + return false; + }; +} + +/** + * @param {AttrExists} attr + * @param {MatcherFn} next + * @returns {MatcherFn} + */ +function matchAttrExists(attr, next) { + return (ctx, id) => { + return ctx.hasAttrPath(id, attr.prop, 0) ? next(ctx, id) : false; + }; +} + +/** + * @param {AttrBin} attr + * @param {MatcherFn} next + * @returns {MatcherFn} + */ +function matchAttrBin(attr, next) { + return (ctx, id) => { + if (!ctx.hasAttrPath(id, attr.prop, 0)) return false; + const value = ctx.getAttrPathValue(id, attr.prop, 0); + if (!matchAttrValue(attr, value)) return false; + return next(ctx, id); + }; +} + +/** + * @param {AttrBin} attr + * @param {*} value + * @returns {boolean} + */ +function matchAttrValue(attr, value) { + switch (attr.op) { + case BinOp.Equal: + return value === attr.value; + case BinOp.NotEqual: + return value !== attr.value; + case BinOp.Greater: + return typeof value === "number" && typeof attr.value === "number" && + value > attr.value; + case BinOp.GreaterThan: + return typeof value === "number" && typeof attr.value === "number" && + value >= attr.value; + case BinOp.Less: + return typeof value === "number" && typeof attr.value === "number" && + value < attr.value; + case BinOp.LessThan: + return typeof value === "number" && typeof attr.value === "number" && + value <= attr.value; + default: + return false; + } +} diff --git a/cli/js/40_lint_types.d.ts b/cli/js/40_lint_types.d.ts index 8c252f10ad..7b06e36098 100644 --- a/cli/js/40_lint_types.d.ts +++ b/cli/js/40_lint_types.d.ts @@ -16,6 +16,7 @@ export interface AstContext { strByProp: number[]; typeByStr: Map; propByStr: Map; + matcher: MatchContext; } // TODO(@marvinhagemeister) Remove once we land "official" types @@ -43,8 +44,89 @@ export interface LintState { export type VisitorFn = (node: unknown) => void; export interface CompiledVisitor { - matcher: (offset: number) => boolean; + matcher: (ctx: MatchContext, offset: number) => boolean; 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 {}; diff --git a/cli/tools/lint/ast_buffer/buffer.rs b/cli/tools/lint/ast_buffer/buffer.rs index c440b73ccd..f37041eff2 100644 --- a/cli/tools/lint/ast_buffer/buffer.rs +++ b/cli/tools/lint/ast_buffer/buffer.rs @@ -215,11 +215,13 @@ impl SerializeCtx { let type_str = ctx.str_table.insert("type"); let parent_str = ctx.str_table.insert("parent"); 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 ctx.prop_map[0] = type_str; ctx.prop_map[1] = parent_str; ctx.prop_map[2] = range_str; + ctx.prop_map[3] = length_str; ctx } diff --git a/cli/tools/lint/ast_buffer/ts_estree.rs b/cli/tools/lint/ast_buffer/ts_estree.rs index af5fea4b46..599499aa8d 100644 --- a/cli/tools/lint/ast_buffer/ts_estree.rs +++ b/cli/tools/lint/ast_buffer/ts_estree.rs @@ -205,6 +205,7 @@ pub enum AstProp { Type, Parent, Range, + Length, // Not used in AST, but can be used in attr selectors // Starting from here the order doesn't matter. // Following are all possible AST node properties. @@ -320,6 +321,7 @@ impl Display for AstProp { AstProp::Parent => "parent", AstProp::Range => "range", AstProp::Type => "type", + AstProp::Length => "length", AstProp::Abstract => "abstract", AstProp::Accessibility => "accessibility", AstProp::Alternate => "alternate", diff --git a/cli/worker.rs b/cli/worker.rs index 6b87b5966a..7653e72b75 100644 --- a/cli/worker.rs +++ b/cli/worker.rs @@ -657,6 +657,8 @@ impl CliMainWorkerFactory { "40_test.js", "40_bench.js", "40_jupyter.js", + // TODO(bartlomieju): probably shouldn't include these files here? + "40_lint_selector.js", "40_lint.js" ); } diff --git a/tests/integration/js_unit_tests.rs b/tests/integration/js_unit_tests.rs index 899329b319..afb97a3458 100644 --- a/tests/integration/js_unit_tests.rs +++ b/tests/integration/js_unit_tests.rs @@ -52,6 +52,7 @@ util::unit_test_factory!( kv_queue_test, kv_queue_undelivered_test, link_test, + lint_selectors_test, lint_plugin_test, make_temp_test, message_channel_test, diff --git a/tests/unit/lint_plugin_test.ts b/tests/unit/lint_plugin_test.ts index 649c8bde9e..9506c3e0a8 100644 --- a/tests/unit/lint_plugin_test.ts +++ b/tests/unit/lint_plugin_test.ts @@ -51,22 +51,38 @@ function testPlugin( return runLintPlugin(plugin, "source.tsx", source); } -function testVisit(source: string, ...selectors: string[]): string[] { - const log: string[] = []; +interface VisitResult { + 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, { create() { const visitor: LintVisitor = {}; 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 log; + return result; } function testLintNode(source: string, ...selectors: string[]) { @@ -91,14 +107,188 @@ function testLintNode(source: string, ...selectors: string[]) { } Deno.test("Plugin - visitor enter/exit", () => { - const enter = testVisit("foo", "Identifier"); - assertEquals(enter, ["Identifier"]); + const enter = testVisit( + "foo", + "Identifier", + ); + assertEquals(enter[0].node.type, "Identifier"); - const exit = testVisit("foo", "Identifier:exit"); - assertEquals(exit, ["Identifier:exit"]); + const exit = testVisit( + "foo", + "Identifier:exit", + ); + assertEquals(exit[0].node.type, "Identifier"); 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", () => { diff --git a/tests/unit/lint_selectors_test.ts b/tests/unit/lint_selectors_test.ts new file mode 100644 index 0000000000..0909a4907a --- /dev/null +++ b/tests/unit/lint_selectors_test.ts @@ -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 = { Foo: 1, Bar: 2, FooBar: 3 }; +const Attrs: Record = { 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, + }, + ]]); +}); diff --git a/tools/core_import_map.json b/tools/core_import_map.json index bc0674277e..d38221eb4c 100644 --- a/tools/core_import_map.json +++ b/tools/core_import_map.json @@ -250,6 +250,7 @@ "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/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/tar": "../tests/util/std/archive/tar.ts", "@std/archive/untar": "../tests/util/std/archive/untar.ts",