From 5fb1fedc7e361cb128943a531df1c17cf2c37435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Sch=C3=B6nb=C3=A4chler?= <42278642+schoero@users.noreply.github.com> Date: Wed, 1 Jan 2025 15:48:44 +0100 Subject: [PATCH 1/2] feat: add support for regex matchers --- src/parsers/es.test.ts | 66 ++++++++++++++++++++++++++++++++++++++ src/parsers/es.ts | 10 +++--- src/parsers/jsx.ts | 6 ++-- src/parsers/svelte.ts | 6 ++-- src/parsers/vue.test.ts | 13 ++++++++ src/parsers/vue.ts | 6 ++-- src/utils/matchers.test.ts | 57 ++++++++++++++++++++++++++++++++ src/utils/utils.ts | 6 ++++ 8 files changed, 156 insertions(+), 14 deletions(-) create mode 100644 src/parsers/es.test.ts diff --git a/src/parsers/es.test.ts b/src/parsers/es.test.ts new file mode 100644 index 0000000..5d2e423 --- /dev/null +++ b/src/parsers/es.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from "vitest"; + +import { tailwindNoUnnecessaryWhitespace } from "readable-tailwind:rules:tailwind-no-unnecessary-whitespace.js"; +import { lint, TEST_SYNTAXES } from "readable-tailwind:tests:utils.js"; + + +describe("es", () => { + + it("should match callees names via regex", () => { + lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + jsx: `testStyles(" lint ");`, + jsxOutput: `testStyles("lint");`, + options: [{ + callees: ["^.*Styles$"] + }], + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: `` + } + ] + }); + }); + + it("should match variable names via regex", () => { + lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + jsx: `const testStyles = " lint ";`, + jsxOutput: `const testStyles = "lint";`, + options: [{ + variables: ["^.*Styles$"] + }], + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: `` + } + ] + }); + }); + + it("should match classAttributes via regex", () => { + lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + jsx: ``, + jsxOutput: ``, + options: [{ + classAttributes: ["^.*Styles$"] + }], + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: `` + } + ] + }); + }); + +}); diff --git a/src/parsers/es.ts b/src/parsers/es.ts index 350bf64..b72a7a9 100644 --- a/src/parsers/es.ts +++ b/src/parsers/es.ts @@ -13,7 +13,7 @@ import { matchesPathPattern } from "readable-tailwind:utils:matchers.js"; import { getLiteralsByESNodeAndRegex } from "readable-tailwind:utils:regex.js"; -import { deduplicateLiterals, getQuotes, getWhitespace } from "readable-tailwind:utils:utils.js"; +import { deduplicateLiterals, getQuotes, getWhitespace, matchesName } from "readable-tailwind:utils:utils.js"; import type { Rule } from "eslint"; import type { @@ -41,12 +41,12 @@ export function getLiteralsByESVariableDeclarator(ctx: Rule.RuleContext, node: E if(!isESVariableSymbol(node.id)){ return literals; } if(isVariableName(variable)){ - if(variable !== node.id.name){ return literals; } + if(!matchesName(variable, node.id.name)){ return literals; } literals.push(...getLiteralsByESExpression(ctx, [node.init])); } else if(isVariableRegex(variable)){ literals.push(...getLiteralsByESNodeAndRegex(ctx, node, variable)); } else if(isVariableMatchers(variable)){ - if(variable[0] !== node.id.name){ return literals; } + if(!matchesName(variable[0], node.id.name)){ return literals; } literals.push(...getLiteralsByESMatchers(ctx, node.init, variable[1])); } @@ -63,12 +63,12 @@ export function getLiteralsByESCallExpression(ctx: Rule.RuleContext, node: ESCal if(!isESCalleeSymbol(node.callee)){ return literals; } if(isCalleeName(callee)){ - if(callee !== node.callee.name){ return literals; } + if(!matchesName(callee, node.callee.name)){ return literals; } literals.push(...getLiteralsByESExpression(ctx, node.arguments)); } else if(isCalleeRegex(callee)){ literals.push(...getLiteralsByESNodeAndRegex(ctx, node, callee)); } else if(isCalleeMatchers(callee)){ - if(callee[0] !== node.callee.name){ return literals; } + if(!matchesName(callee[0], node.callee.name)){ return literals; } literals.push(...getLiteralsByESMatchers(ctx, node, callee[1])); } diff --git a/src/parsers/jsx.ts b/src/parsers/jsx.ts index 359930a..a5d8938 100644 --- a/src/parsers/jsx.ts +++ b/src/parsers/jsx.ts @@ -12,7 +12,7 @@ import { isClassAttributeRegex } from "readable-tailwind:utils:matchers.js"; import { getLiteralsByESNodeAndRegex } from "readable-tailwind:utils:regex.js"; -import { deduplicateLiterals } from "readable-tailwind:utils:utils.js"; +import { deduplicateLiterals, matchesName } from "readable-tailwind:utils:utils.js"; import type { Rule } from "eslint"; import type { TemplateLiteral as ESTemplateLiteral } from "estree"; @@ -30,12 +30,12 @@ export function getLiteralsByJSXClassAttribute(ctx: Rule.RuleContext, attribute: if(!value){ return literals; } if(isClassAttributeName(classAttribute)){ - if(typeof attribute.name.name !== "string" || classAttribute.toLowerCase() !== attribute.name.name.toLowerCase()){ return literals; } + if(typeof attribute.name.name !== "string" || !matchesName(classAttribute.toLowerCase(), attribute.name.name.toLowerCase())){ return literals; } literals.push(...getLiteralsByJSXAttributeValue(ctx, value)); } else if(isClassAttributeRegex(classAttribute)){ literals.push(...getLiteralsByESNodeAndRegex(ctx, attribute, classAttribute)); } else if(isClassAttributeMatchers(classAttribute)){ - if(typeof attribute.name.name !== "string" || classAttribute[0].toLowerCase() !== attribute.name.name.toLowerCase()){ return literals; } + if(typeof attribute.name.name !== "string" || !matchesName(classAttribute[0].toLowerCase(), attribute.name.name.toLowerCase())){ return literals; } literals.push(...getLiteralsByESMatchers(ctx, value, classAttribute[1])); } diff --git a/src/parsers/svelte.ts b/src/parsers/svelte.ts index d6300a5..12ad36e 100644 --- a/src/parsers/svelte.ts +++ b/src/parsers/svelte.ts @@ -17,7 +17,7 @@ import { matchesPathPattern } from "readable-tailwind:utils:matchers.js"; import { getLiteralsByESNodeAndRegex } from "readable-tailwind:utils:regex.js"; -import { deduplicateLiterals, getQuotes, getWhitespace } from "readable-tailwind:utils:utils.js"; +import { deduplicateLiterals, getQuotes, getWhitespace, matchesName } from "readable-tailwind:utils:utils.js"; import type { Rule } from "eslint"; import type { BaseNode as ESBaseNode, Node as ESNode } from "estree"; @@ -62,12 +62,12 @@ export function getLiteralsBySvelteClassAttribute(ctx: Rule.RuleContext, attribu const literals = classAttributes.reduce((literals, classAttribute) => { if(isClassAttributeName(classAttribute)){ - if(classAttribute.toLowerCase() !== attribute.key.name.toLowerCase()){ return literals; } + if(!matchesName(classAttribute.toLowerCase(), attribute.key.name.toLowerCase())){ return literals; } literals.push(...getLiteralsBySvelteLiteralNode(ctx, value)); } else if(isClassAttributeRegex(classAttribute)){ literals.push(...getLiteralsByESNodeAndRegex(ctx, attribute, classAttribute)); } else if(isClassAttributeMatchers(classAttribute)){ - if(classAttribute[0].toLowerCase() !== attribute.key.name.toLowerCase()){ return literals; } + if(!matchesName(classAttribute[0].toLowerCase(), attribute.key.name.toLowerCase())){ return literals; } literals.push(...getLiteralsBySvelteMatchers(ctx, value, classAttribute[1])); } diff --git a/src/parsers/vue.test.ts b/src/parsers/vue.test.ts index 1bc3303..adf68c7 100644 --- a/src/parsers/vue.test.ts +++ b/src/parsers/vue.test.ts @@ -83,4 +83,17 @@ describe(tailwindSortClasses.name, () => { }); }); + it("should match bound classes via regex", () => { + lint(tailwindSortClasses, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + options: [{ classAttributes: [[":.*Styles$", [{ match: MatcherType.String }]]], order: "asc" }], + vue: ``, + vueOutput: `` + } + ] + }); + }); + }); diff --git a/src/parsers/vue.ts b/src/parsers/vue.ts index 1f12d87..4c2dc82 100644 --- a/src/parsers/vue.ts +++ b/src/parsers/vue.ts @@ -17,7 +17,7 @@ import { matchesPathPattern } from "readable-tailwind:utils:matchers.js"; import { getLiteralsByESNodeAndRegex } from "readable-tailwind:utils:regex.js"; -import { deduplicateLiterals, getQuotes, getWhitespace } from "readable-tailwind:utils:utils.js"; +import { deduplicateLiterals, getQuotes, getWhitespace, matchesName } from "readable-tailwind:utils:utils.js"; import type { Rule } from "eslint"; import type { BaseNode as ESBaseNode, Node as ESNode } from "estree"; @@ -42,12 +42,12 @@ export function getLiteralsByVueClassAttribute(ctx: Rule.RuleContext, attribute: const literals = classAttributes.reduce((literals, classAttribute) => { if(isClassAttributeName(classAttribute)){ - if(getVueAttributeName(attribute)?.toLowerCase() !== getVueBoundName(classAttribute).toLowerCase()){ return literals; } + if(!matchesName(getVueBoundName(classAttribute).toLowerCase(), getVueAttributeName(attribute)?.toLowerCase())){ return literals; } literals.push(...getLiteralsByVueLiteralNode(ctx, value)); } else if(isClassAttributeRegex(classAttribute)){ literals.push(...getLiteralsByESNodeAndRegex(ctx, attribute, classAttribute)); } else if(isClassAttributeMatchers(classAttribute)){ - if(getVueAttributeName(attribute)?.toLowerCase() !== getVueBoundName(classAttribute[0]).toLowerCase()){ return literals; } + if(!matchesName(getVueBoundName(classAttribute[0]).toLowerCase(), getVueAttributeName(attribute)?.toLowerCase())){ return literals; } literals.push(...getLiteralsByVueMatchers(ctx, value, classAttribute[1])); } diff --git a/src/utils/matchers.test.ts b/src/utils/matchers.test.ts index a3e83a9..5cd8a16 100644 --- a/src/utils/matchers.test.ts +++ b/src/utils/matchers.test.ts @@ -201,6 +201,63 @@ describe("matchers", () => { }); + it("should match callees names via regex", () => { + lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + jsx: `testStyles(" lint ");`, + jsxOutput: `testStyles("lint");`, + options: [{ + callees: [["^.*Styles$", [{ match: MatcherType.String }]]] + }], + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: `` + } + ] + }); + }); + + it("should match variable names via regex", () => { + lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + jsx: `const testStyles = " lint ";`, + jsxOutput: `const testStyles = "lint";`, + options: [{ + variables: [["^.*Styles$", [{ match: MatcherType.String }]]] + }], + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: `` + } + ] + }); + }); + + it("should match classAttributes via regex", () => { + lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, { + invalid: [ + { + errors: 1, + jsx: ``, + jsxOutput: ``, + options: [{ + classAttributes: [["^.*Styles$", [{ match: MatcherType.String }]]] + }], + svelte: ``, + svelteOutput: ``, + vue: ``, + vueOutput: `` + } + ] + }); + }); + }); describe("variables", () => { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e7ac61e..5121a00 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -58,6 +58,12 @@ export function isLiteral(node: Node): node is Literal { return node.type === "Literal"; } +export function matchesName(pattern: string, name: string | undefined): boolean { + if(!name){ return false; } + + return new RegExp(pattern).test(name); +} + export function deduplicateLiterals(literals: Literal[]): Literal[] { return literals.filter((l1, index) => { return literals.findIndex(l2 => { From 7cf652ed93dbe8bcf4e2d53b6002ea0c208b5263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roger=20Sch=C3=B6nb=C3=A4chler?= <42278642+schoero@users.noreply.github.com> Date: Wed, 1 Jan 2025 16:18:11 +0100 Subject: [PATCH 2/2] docs: names can be regexes --- docs/concepts/concepts.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/concepts/concepts.md b/docs/concepts/concepts.md index d340af4..6859470 100644 --- a/docs/concepts/concepts.md +++ b/docs/concepts/concepts.md @@ -18,7 +18,7 @@ It is possible that you never have to change this configuration, but if you do n ## Name -The simplest form to define string literals to lint is by their name. Callees, variables or class attributes with that name will be linted. +The simplest form to define string literals to lint is by their name. Callees, variables or class attributes with that name will be linted. The name can be a string or a regular expression.
@@ -171,6 +171,7 @@ Matchers are the most powerful way to match string literals. They allow finer co This allows additional filtering, such as literals in conditions or logical expressions. This opens up the possibility to lint any string that may contain tailwindcss classes while also reducing the number of false positives. Matchers are defined as a tuple of a name and a list of configurations for predefined matchers. +The name can be a string or a regular expression.