Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: CallExpression, ArrayExpression, ObjectExpression #61

Merged
merged 14 commits into from
Aug 5, 2024
5 changes: 5 additions & 0 deletions .changeset/itchy-knives-teach.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-rtl-friendly": patch
---

feat: CallExpression, ArrayExpression, ObjectExpression
9 changes: 6 additions & 3 deletions .github/workflows/release-canary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ jobs:
run: |
npm config set //registry.npmjs.org/:_authToken ${{ secrets.NPM_TOKEN }}
pnpm changeset version --snapshot ${{ github.event.number }}
npm publish --access public --tag canary --no-git-checks
# npm publish --access public --tag canary --no-git-checks
echo "published=true" >> "$GITHUB_OUTPUT"
echo "version=$(npm pkg get version | sed -e 's/^"//;s/"$//')" >> "$GITHUB_OUTPUT"
env:
Expand All @@ -56,11 +56,14 @@ jobs:
🚀 A new Canary version has been released

You can install it by running:

\`\`\`bash
pnpm add eslint-plugin-rtl-friendly@${{ steps.publish.outputs.version }} -D
\`\`\`
`.replace(/\s+/g, ' ')
})
`.split("\n")
.map(l => l.trim())
.join("\n")
})

github.issues.removeLabel({
issue_number,
Expand Down
6 changes: 0 additions & 6 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import js from "@eslint/js";
import eslintPlugin from "eslint-plugin-eslint-plugin";
import globals from "globals";
import { config, configs } from "typescript-eslint";

import rtlFriendly from "./dist/index.js";
Expand All @@ -11,11 +10,6 @@ export default config(
{
ignores: ["dist/**/*"],
},
{
languageOptions: {
globals: globals.node,
},
},
eslintPlugin.configs["flat/recommended"],
js.configs.recommended,
...configs.recommended,
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"lint": "eslint .",
"test": "vitest --run",
"test:watch": "vitest",
"test:coverage": "vitest --coverage",
"test:coverage": "vitest --run --coverage",
"prepublishOnly": "npm run build",
"gen-e2e": "tsx scripts/generate-e2e"
},
Expand Down
84 changes: 83 additions & 1 deletion src/rules/no-phyisical-properties/test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vitest from "vitest"
import { RuleTester } from "@typescript-eslint/rule-tester";
import * as vitest from "vitest";
import { NO_PHYSICAL_CLASSESS, noPhysicalProperties } from "./rule";

RuleTester.afterAll = vitest.afterAll;
Expand Down Expand Up @@ -130,6 +130,88 @@ tester.run("no-physical-properties", noPhysicalProperties, {
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: '{cn("...")}',
code: `<div className={cn("pl-1 text-right mr-2")} />`,
output: `<div className={cn("ps-1 text-end me-2")} />`,
errors: [{ messageId: NO_PHYSICAL_CLASSESS }],
},
{
name: '{cn(isCondition && "...")}',
code: `<div className={cn(isCondition && "pl-1 text-right mr-2")} />`,
output: `<div className={cn(isCondition && "ps-1 text-end me-2")} />`,
errors: [{ messageId: NO_PHYSICAL_CLASSESS }],
},
{
name: '{cn(isCondition ? "..." : "...")}',
code: '<div className={cn(isCondition ? "pl-1 text-left" : `pr-1 text-right`)} />',
output:
'<div className={cn(isCondition ? "ps-1 text-start" : `pe-1 text-end`)} />',
errors: [
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: '{cn("...", isCondition && "...")}',
code: `<div className={cn("rounded-l-md", isCondition && "pl-1 text-right mr-2")} />`,
output: `<div className={cn("rounded-s-md", isCondition && "ps-1 text-end me-2")} />`,
errors: [
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: '{cn(["...", "..."])}',
code: `<div className={cn(["pl-1 text-right", "mr-2"])} />`,
output: `<div className={cn(["ps-1 text-end", "me-2"])} />`,
errors: [
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: '{cn(["...", ...["..."]])}',
code: `<div className={cn(["pl-1"], [["left-0"]], ...["text-right", "mr-2"])} />`,
output: `<div className={cn(["ps-1"], [["start-0"]], ...["text-end", "me-2"])} />`,
errors: [
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: '{cn({"...": true})}',
code: `<div className={cn({"pl-1 text-right": true})} />`,
output: `<div className={cn({"ps-1 text-end": true})} />`,
errors: [{ messageId: NO_PHYSICAL_CLASSESS }],
},
{
name: '{cn({"...": "..."}, isCondition && {"...": "..."})}',
code: `<div className={cn({"pl-1 text-right": "mr-2"}, isCondition && {"pl-2": "text-left"})} />`,
output: `<div className={cn({"ps-1 text-end": "me-2"}, isCondition && {"ps-2": "text-start"})} />`,
errors: [
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: "clsx('...', [1 && '...', { ...: false, ...: null }, is && ['...', ['...']]], '...')",
code: `<div className={clsx('pl-1', [1 && 'text-right', { 'text-left': false, 'mr-2': null }, is && ['pr-2', ['pl-2']]], 'mr-1')} />`,
output: `<div className={clsx('ps-1', [1 && 'text-end', { 'text-start': false, 'me-2': null }, is && ['pe-2', ['ps-2']]], 'me-1')} />`,
errors: [
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
{ messageId: NO_PHYSICAL_CLASSESS },
],
},
{
name: "should report if physical margin properties are used and fix them",
code: `<div className="ml-1 mr-2">text</div>`,
Expand Down
165 changes: 132 additions & 33 deletions src/utils/ast.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { TSESTree } from "@typescript-eslint/utils";
import { TSESTree } from "@typescript-eslint/utils";

const unimplemented = new Set<string>();

export type Token = (
| TSESTree.JSXAttribute
Expand All @@ -14,26 +16,27 @@ export function extractTokenFromNode(
runner: "checker" | "fixer"
): (Token | undefined | null)[] {
// value: Literal | JSXExpressionContainer | JSXElement | JSXFragment | null
const type = node.value?.type;
if (!type) return [];

const nodeValue = node.value;
const value = node.value;
if (!value) return [];

if (isStringLiteral(nodeValue))
return format(
nodeValue,
(n) => n.value,
(n) => n.raw
);
if (value?.type === "Literal") {
if (typeof value.value !== "string") return []; // boolean, number, null, undefined, etc...
return format(value, value.value, value.raw);
}

if (type === "JSXExpressionContainer") {
const expression = node.value?.expression;
if (value.type === "JSXExpressionContainer") {
const expression = value?.expression;

if (!expression || expression?.type === "JSXEmptyExpression") return [];

return extractTokenFromExpression(expression, runner);
}

if (value.type === "JSXElement" || value.type === "JSXSpreadChild") {
// JSXElement is like =>
return [];
}

return [];
}

Expand All @@ -54,34 +57,117 @@ function extractTokenFromExpression(
};

// const isFixer = runner === "fixer";
const type = exp.type;

if (isStringLiteral(exp))
if (is(exp, "Literal")) {
if (typeof exp.value !== "string") return []; // boolean, number, null, undefined, etc...

return format(
exp,
() => exp.value,
() => exp.raw
);
}

if (exp?.type === "TemplateLiteral") {
if (is(exp, "TemplateLiteral")) {
return format(
exp.quasis,
(q) => q.value.cooked,
(q) => `\`${q.value.raw}\``
);
}

if (exp.type === "LogicalExpression") {
if (is(exp, "LogicalExpression")) {
// isCondition && "..."
return rerun(exp.right);
}

if (exp.type === "ConditionalExpression") {
if (is(exp, "ConditionalExpression")) {
return [...rerun(exp.consequent), ...rerun(exp.alternate)];
}

if (is(exp, "ArrayExpression")) {
return exp.elements.flatMap((el) => {
if (!el) return [];

if (el.type === "SpreadElement") return rerun(el.argument);

return rerun(el);
});
}

// console.log("UNIMPLEMENTED: ", type);
if (is(exp, "ObjectExpression")) {
return exp.properties.flatMap((prop) => {
if (prop.type === "SpreadElement") return rerun(prop.argument);

return [prop.key, prop.value].flatMap((el) => {
if (
el.type === "AssignmentPattern" ||
el.type === "TSEmptyBodyFunctionExpression"
)
return [];

return rerun(el);
});
});
}

if (is(exp, "CallExpression")) {
return exp.arguments.flatMap((arg) => {
if (arg.type === "SpreadElement") {
return rerun(arg.argument);
}

return rerun(arg);
});
}

// if (
// is(exp, "BinaryExpression") ||
// is(exp, "Identifier") ||
// is(exp, "MemberExpression") ||
// is(exp, "TaggedTemplateExpression")
// ) {
// // Will be implemented
// return [];
// }

// if ((unsupported as typeof exp.type[]).includes(exp.type)) {
// if (
// is(exp, "ArrayPattern") ||
// is(exp, "ObjectPattern") ||
// is(exp, "ArrowFunctionExpression") ||
// is(exp, "AssignmentExpression") ||
// is(exp, "AwaitExpression") ||
// is(exp, "ChainExpression") ||
// is(exp, "ClassExpression") ||
// is(exp, "FunctionExpression") ||
// is(exp, "ImportExpression") ||
// is(exp, "JSXElement") ||
// is(exp, "JSXFragment") ||
// is(exp, "MetaProperty") ||
// is(exp, "NewExpression") ||
// is(exp, "SequenceExpression") ||
// is(exp, "Super") ||
// is(exp, "ThisExpression") ||
// is(exp, "UnaryExpression") ||
// is(exp, "UpdateExpression") ||
// is(exp, "VariableDeclaration") ||
// is(exp, "VariableDeclarator") ||
// is(exp, "WhileStatement") ||
// is(exp, "YieldExpression") ||
// is(exp, "TSAsExpression") ||
// is(exp, "TSInstantiationExpression") ||
// is(exp, "TSNonNullExpression") ||
// is(exp, "TSSatisfiesExpression") ||
// is(exp, "TSTypeAssertion")
// ) {
// return [];
// }

if (!unimplemented.has(exp.type)) {
console.log("Unimplemented: ", exp.type, exp);
unimplemented.add(exp.type);
}

// if (expression.type === "BinaryExpression") {
// result.push(...extractFromExpression(expression.left));
Expand Down Expand Up @@ -113,29 +199,42 @@ function format<
| TSESTree.Expression
| TSESTree.TemplateElement,
>(
nodeOrToken: T | T[],
getValue: (t: T) => string,
getRaw: (t: T) => string
token: T | T[],
getValue: string | ((t: T) => string),
getRaw: string | ((t: T) => string)
): (T & { getValue: () => string; getRaw: () => string })[] {
if (Array.isArray(nodeOrToken)) {
return nodeOrToken.map((t) => ({
if (Array.isArray(token)) {
return token.map((t) => ({
...t,
getValue: () => getValue(t),
getRaw: getRaw ? () => getRaw(t) : () => getValue(t),
getValue: () => callOrValue(getValue, t),
getRaw: () => callOrValue(getRaw, t),
}));
}

return [
{
...nodeOrToken,
getValue: () => getValue(nodeOrToken),
getRaw: () => (getRaw ?? getValue)(nodeOrToken),
...token,
getValue: () => callOrValue(getValue, token),
getRaw: () => callOrValue(getRaw, token),
},
] as const;
}

function isStringLiteral(
value: TSESTree.JSXAttribute["value"] | TSESTree.Expression
): value is TSESTree.StringLiteral {
return value?.type === "Literal" && typeof value?.value === "string";
function callOrValue<T extends string>(func: T | (() => T)): T;
function callOrValue<T extends string, P>(
func: T | ((arg: P) => T),
param: P
): T;
function callOrValue<T extends string, P>(
func: T | ((arg: P) => T),
param?: P
): T {
return typeof func === "function" ? func(param!) : func;
}

function is<T extends TSESTree.AST_NODE_TYPES>(
exp: TSESTree.Expression,
type: `${T}`
): exp is Extract<TSESTree.Expression, { type: T }> {
return exp.type === type;
}
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
cacheDir: path.resolve(__dirname, "./node_modules/.cache/vitest"),
test: {
include: ["src/**/test.ts"]
include: ["src/**/test.ts"],
},
});
Loading