diff --git a/README.md b/README.md index b43813c..4837327 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,35 @@ export type Options = { export default myFunction(parameters: Options) {...} ``` + +### no-use-prefix-for-non-hook + +Custom hooks are identified using a `use` prefix, naming normal functions, variables or others with a `use` prefix can cause confusion. + +This rule forbids functions and variables being prefixed with `use` if they do not contain other hooks. + +Examples of valid code + +```js +const useCustom = () => { + const [state, setState] = useState(""); + + return { state, setState }; +}; + +const useCustom = () => useState(""); + +const useCustom = useState; +``` + +Examples of invalid code + +```js +const useCustom = () => { + return "Hello world"; +}; + +const useCustom = () => new Date(); + +const useCustom = new Date(); +``` diff --git a/package.json b/package.json index 9830d43..195e400 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "name": "@meitner/eslint-plugin", - "type": "module", "license": "MIT", "repository": { "type": "git", diff --git a/src/rules/index.ts b/src/rules/index.ts index 5b730ec..e923801 100644 --- a/src/rules/index.ts +++ b/src/rules/index.ts @@ -1,10 +1,12 @@ import { noInlineFunctionParameterTypeAnnotation } from "./noInlineFunctionParameterTypeAnnotation"; import { noMixedExports } from "./noMixedExports"; +import { noUsePrefixForNonHook } from "./noUsePrefixForNonHook"; const rules = { "no-inline-function-parameter-type-annotation": noInlineFunctionParameterTypeAnnotation, "no-mixed-exports": noMixedExports, + "no-use-prefix-for-non-hook": noUsePrefixForNonHook, }; export { rules }; diff --git a/src/rules/noUsePrefixForNonHook.ts b/src/rules/noUsePrefixForNonHook.ts new file mode 100644 index 0000000..7d51ad5 --- /dev/null +++ b/src/rules/noUsePrefixForNonHook.ts @@ -0,0 +1,195 @@ +import { + ArrowFunctionExpression, + FunctionDeclaration, + LeftHandSideExpression, +} from "@typescript-eslint/types/dist/generated/ast-spec"; +import { ESLintUtils } from "@typescript-eslint/utils"; + +const PREFIX_REGEX = /^use[A-Z]/; + +function hasUsePrefix(name: string) { + return name.match(PREFIX_REGEX) !== null; +} + +function calleeHasUsePrefix(callee: LeftHandSideExpression) { + if (callee.type === "Identifier") { + return hasUsePrefix(callee.name); + } + + if (callee.type === "MemberExpression") { + return callee.property.type === "Identifier" + ? hasUsePrefix(callee.property.name) + : false; + } + + return false; +} + +function hasVariableDeclarationWithUsePrefix( + node: ArrowFunctionExpression | FunctionDeclaration +) { + return ( + node.body.type === "BlockStatement" && + node.body.body.some( + (statement) => + statement.type === "VariableDeclaration" && + statement.declarations.some( + (declaration) => + declaration.init && + declaration.init.type === "CallExpression" && + calleeHasUsePrefix(declaration.init.callee) + ) + ) + ); +} + +function hasReturnStatementWithUsePrefix( + node: ArrowFunctionExpression | FunctionDeclaration +) { + return ( + node.body.type === "BlockStatement" && + node.body.body.some( + (statement) => + statement.type === "ReturnStatement" && + statement.argument && + statement.argument.type === "CallExpression" && + calleeHasUsePrefix(statement.argument.callee) + ) + ); +} + +function hasFunctionInvokationWithUsePrefix( + node: ArrowFunctionExpression | FunctionDeclaration +) { + return ( + node.body.type === "BlockStatement" && + node.body.body.some( + (statement) => + statement.type === "ExpressionStatement" && + statement.expression.type === "CallExpression" && + calleeHasUsePrefix(statement.expression.callee) + ) + ); +} + +function arrowFunctionHasImplicitReturnWithUsePrefix( + node: ArrowFunctionExpression +) { + return ( + node.body.type !== "BlockStatement" && + node.body.type === "CallExpression" && + node.body.callee.type === "Identifier" && + hasUsePrefix(node.body.callee.name) + ); +} + +function getArrowFunctionName(node: ArrowFunctionExpression) { + if (!("id" in node.parent)) { + return; + } + + if (!node.parent.id) { + return; + } + + if (!("name" in node.parent.id)) { + return; + } + + return node.parent.id.name; +} + +export const noUsePrefixForNonHook = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + return { + ArrowFunctionExpression(node) { + const name = getArrowFunctionName(node); + + if (!name || !hasUsePrefix(name)) { + return; + } + + // Check if the function uses hooks + if ( + hasVariableDeclarationWithUsePrefix(node) || + hasReturnStatementWithUsePrefix(node) || + hasFunctionInvokationWithUsePrefix(node) || + arrowFunctionHasImplicitReturnWithUsePrefix(node) + ) { + return; + } + + context.report({ + node, + messageId: "noUsePrefixForNonHook", + }); + }, + FunctionDeclaration(node) { + const name = node.id?.name; + + if (!name || !hasUsePrefix(name)) { + return; + } + + // Check if the function uses hooks + if ( + hasVariableDeclarationWithUsePrefix(node) || + hasReturnStatementWithUsePrefix(node) || + hasFunctionInvokationWithUsePrefix(node) + ) { + return; + } + + context.report({ + node, + messageId: "noUsePrefixForNonHook", + }); + }, + VariableDeclaration(node) { + const declaration = node.declarations[0]; + + if (!declaration) { + return; + } + + const name = + "name" in declaration.id ? declaration?.id.name : ""; + + if (!name || !hasUsePrefix(name)) { + return; + } + + // Check if the variable is assigned an arrow function + if ( + declaration.init && + declaration.init.type === "ArrowFunctionExpression" + ) { + // We check arrow functions in the ArrowFunctionExpression visitor + return; + } + + if ( + declaration.init && + declaration.init.type === "Identifier" && + hasUsePrefix(declaration.init.name) + ) { + return; + } + + context.report({ + node, + messageId: "noUsePrefixForNonHook", + }); + }, + }; + }, + meta: { + messages: { + noUsePrefixForNonHook: + "Do not use 'use' prefix for non-hook functions.", + }, + type: "problem", + schema: [], + }, + defaultOptions: [], +}); diff --git a/src/tests/noUsePrefixForNonHook.test.ts b/src/tests/noUsePrefixForNonHook.test.ts new file mode 100644 index 0000000..4bc544a --- /dev/null +++ b/src/tests/noUsePrefixForNonHook.test.ts @@ -0,0 +1,109 @@ +import { RuleTester } from "@typescript-eslint/rule-tester"; +import * as vitest from "vitest"; +import { noUsePrefixForNonHook } from "../rules/noUsePrefixForNonHook"; + +RuleTester.afterAll = vitest.afterAll; +RuleTester.it = vitest.it; +RuleTester.itOnly = vitest.it.only; +RuleTester.describe = vitest.describe; + +const ruleTester = new RuleTester({ + parser: "@typescript-eslint/parser", +}); + +ruleTester.run("noUsePrefixForNonHook", noUsePrefixForNonHook, { + valid: [ + 'const useCustom = () => {const [state, setState] = useState(""); return {state, setState};};', + 'const useCustom = () => {const [state, setState] = useState(""); return {state, setState};};', + "const useCustom = () => {const query = Queries.useUserQuery(); return query};", + 'const useCustom = () => {return useState("");};', + "const useCustom = () => {useEffect(() => {});};", + 'const useCustom = () => useState("");', + "const useCustom = useState;", + "const myFunction = () => {};", + "const userFunction = () => {};", + 'function useCustom() {const [state, setState] = useState(""); return {state, setState};}', + "function useCustom() {const query = Queries.useUserQuery(); return query};", + 'function useCustom() {return useState("");}', + "function useCustom() {useEffect(() => {});}", + "function myFunction() {}", + "function userFunction() {}", + "const user = null;", + "const myVariable = null;", + "const data = useUserData();", + ], + invalid: [ + { + code: "const useCustom = () => {};", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "const useCustom = () => {return userFunction();};", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "const useCustom = () => {const data = userFunction(); return data;};", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "function useCustom() {}", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "function useCustom() {return userFunction();}", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "function useCustom() {const data = userFunction(); return data;}", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "const useCustom = null;", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "function myFunction() {}; const useCustom = myFunction;", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + { + code: "const myVariable = null; const useCustom = myVariable;", + errors: [ + { + messageId: "noUsePrefixForNonHook", + }, + ], + }, + ], +});