Skip to content

Commit

Permalink
feat!: regex names (#63)
Browse files Browse the repository at this point in the history
* feat: add support for regex matchers

* docs: names can be regexes
  • Loading branch information
schoero authored Jan 19, 2025
1 parent a633e62 commit a9cc425
Show file tree
Hide file tree
Showing 9 changed files with 158 additions and 15 deletions.
3 changes: 2 additions & 1 deletion docs/concepts/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<br/>

Expand Down Expand Up @@ -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.

<br/>

Expand Down
66 changes: 66 additions & 0 deletions src/parsers/es.test.ts
Original file line number Diff line number Diff line change
@@ -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: `<script>testStyles(" lint ");</script>`,
svelteOutput: `<script>testStyles("lint");</script>`,
vue: `<script>testStyles(" lint ");</script>`,
vueOutput: `<script>testStyles("lint");</script>`
}
]
});
});

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: `<script>const testStyles = " lint ";</script>`,
svelteOutput: `<script>const testStyles = "lint";</script>`,
vue: `<script>const testStyles = " lint ";</script>`,
vueOutput: `<script>const testStyles = "lint";</script>`
}
]
});
});

it("should match classAttributes via regex", () => {
lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, {
invalid: [
{
errors: 1,
jsx: `<img testStyles=" lint " />`,
jsxOutput: `<img testStyles="lint" />`,
options: [{
classAttributes: ["^.*Styles$"]
}],
svelte: `<img testStyles=" lint " />`,
svelteOutput: `<img testStyles="lint" />`,
vue: `<template><img testStyles=" lint " /> </template>`,
vueOutput: `<template><img testStyles="lint" /> </template>`
}
]
});
});

});
10 changes: 5 additions & 5 deletions src/parsers/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,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 {
Expand Down Expand Up @@ -45,12 +45,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]));
}

Expand All @@ -67,12 +67,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]));
}

Expand Down
6 changes: 3 additions & 3 deletions src/parsers/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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]));
}

Expand Down
6 changes: 3 additions & 3 deletions src/parsers/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -62,12 +62,12 @@ export function getLiteralsBySvelteClassAttribute(ctx: Rule.RuleContext, attribu

const literals = classAttributes.reduce<Literal[]>((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]));
}

Expand Down
13 changes: 13 additions & 0 deletions src/parsers/vue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<template><img v-bind:testStyles="['c b a']" /></template>`,
vueOutput: `<template><img v-bind:testStyles="['a b c']" /></template>`
}
]
});
});

});
6 changes: 3 additions & 3 deletions src/parsers/vue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -42,12 +42,12 @@ export function getLiteralsByVueClassAttribute(ctx: Rule.RuleContext, attribute:

const literals = classAttributes.reduce<Literal[]>((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]));
}

Expand Down
57 changes: 57 additions & 0 deletions src/utils/matchers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<script>testStyles(" lint ");</script>`,
svelteOutput: `<script>testStyles("lint");</script>`,
vue: `<script>testStyles(" lint ");</script>`,
vueOutput: `<script>testStyles("lint");</script>`
}
]
});
});

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: `<script>const testStyles = " lint ";</script>`,
svelteOutput: `<script>const testStyles = "lint";</script>`,
vue: `<script>const testStyles = " lint ";</script>`,
vueOutput: `<script>const testStyles = "lint";</script>`
}
]
});
});

it("should match classAttributes via regex", () => {
lint(tailwindNoUnnecessaryWhitespace, TEST_SYNTAXES, {
invalid: [
{
errors: 1,
jsx: `<img testStyles=" lint " />`,
jsxOutput: `<img testStyles="lint" />`,
options: [{
classAttributes: [["^.*Styles$", [{ match: MatcherType.String }]]]
}],
svelte: `<img testStyles=" lint " />`,
svelteOutput: `<img testStyles="lint" />`,
vue: `<template><img testStyles=" lint " /> </template>`,
vueOutput: `<template><img testStyles="lint" /> </template>`
}
]
});
});

});

describe("variables", () => {
Expand Down
6 changes: 6 additions & 0 deletions src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down

0 comments on commit a9cc425

Please sign in to comment.