Skip to content

Commit

Permalink
add throw naming rule
Browse files Browse the repository at this point in the history
  • Loading branch information
cedeber committed Sep 8, 2024
1 parent 2233fc0 commit b2be938
Show file tree
Hide file tree
Showing 10 changed files with 241 additions and 87 deletions.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ A plugin for ESLint to enforce naming conventions and JSDoc annotations for func

- Support `@throws` but without a type. Optional.
- Check if a `@throws` tag is set, but not required.
- Support of anonymous functions
- Support of async function
- Function Naming

## Installation

Expand Down Expand Up @@ -45,6 +45,7 @@ You can customize the behavior of this plugin by adjusting the rule settings:
plugins: {
"throw-aware": pluginThrowAware
},
// Recommended configuration
rules: {
"throw-aware/throw-function-naming": ["error", { suffix: "OrThrow" }],
"throw-aware/require-throws-doc": ["warn"]
Expand Down
9 changes: 4 additions & 5 deletions lib/index.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import fs from "fs";
import throwDocumentation from "./rules/throw-documentation.mjs";
import throwNaming from "./rules/throw-naming.mjs";

const pkg = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")
);
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));

/**
* @type {import("eslint").ESLint.Plugin}
Expand All @@ -16,7 +15,7 @@ const plugin = {
configs: {},
rules: {
"require-throws-doc": throwDocumentation,
// "throw-function-naming": ,
"throw-function-naming": throwNaming,
},
processors: {},
};
Expand All @@ -28,7 +27,7 @@ Object.assign(plugin.configs, {
},
rules: {
"throw-aware/require-throws-doc": "warn",
// "throw-aware/throw-function-naming": ["error", { suffix: "OrThrow" }],
"throw-aware/throw-function-naming": "error",
},
},
});
Expand Down
Empty file.
81 changes: 3 additions & 78 deletions lib/rules/throw-documentation.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { getThrowTypes } from "./utils.mjs";

/**
* @type {import("eslint").Rule.RuleModule}
*/
Expand All @@ -7,91 +9,14 @@ export default {
docs: {
description: "enforce JSDoc @throws tag for functions that throw exceptions",
category: "Best Practices",
recommended: "error",
recommended: "warn",
},
messages: {
missingThrows: "Function throws '{{type}}' but lacks a @throws tag in JSDoc.",
},
schema: [], // no options
},
create(context) {
/** @param {import("estree").Statement[]} block */
function getThrowTypes(block = []) {
const throwTypes = new Set();

/** @param {import("estree").Statement[]} block */
function checkAndAddThrowType(block = []) {
const handlerThrowTypes = getThrowTypes(block);
if (handlerThrowTypes.size > 0) {
throwTypes.add(...handlerThrowTypes);
}
}

// block may be another function?
if (Array.isArray(block)) {
for (const statement of block) {
if (statement.type === "ThrowStatement") {
// Assuming the argument is an Expression that can be evaluated to get the error type
if (
statement.argument.type === "NewExpression" &&
statement.argument.callee.name === "Error"
) {
throwTypes.add("Error"); // Generic Error or customize based on arguments if possible
} else if (statement.argument.type === "Identifier") {
throwTypes.add(statement.argument.name); // Assuming the identifier is an error type
} else {
throwTypes.add("Unknown"); // For other types of throws
}
}

// Handle TryStatement
if (statement.type === "TryStatement") {
if (statement.handler) {
checkAndAddThrowType(statement.handler.body.body);
}

if (statement.finalizer) {
checkAndAddThrowType(statement.finalizer.body);
}
}

// Handle IfStatement
if (statement.type === "IfStatement") {
checkAndAddThrowType([statement.consequent]);
statement.alternate && checkAndAddThrowType([statement.alternate]);
}

// Handle DoWhileStatement and WhileStatement
if (statement.type === "DoWhileStatement" || statement.type === "WhileStatement") {
checkAndAddThrowType([statement.body]);
}

// Handle ForStatement and ForInStatement
if (
statement.type === "ForStatement" ||
statement.type === "ForInStatement" ||
statement.type === "ForOfStatement"
) {
checkAndAddThrowType([statement.body]);
}

// Handle SwitchStatement
if (statement.type === "SwitchStatement") {
for (const switchCase of statement.cases) {
checkAndAddThrowType(switchCase.consequent);
}
}

// Handle BlockStatement
if (statement.type === "BlockStatement") {
checkAndAddThrowType(statement.body);
}
}
}

return throwTypes;
}

/** @param {(import("estree").ArrowFunctionExpression | (import("estree").FunctionDeclaration)) & import("eslint").Rule.NodeParentExtension} node */
function checkThrows(node) {
const sourceCode = context.sourceCode;
Expand Down
59 changes: 59 additions & 0 deletions lib/rules/throw-naming.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { hasThrowInBlock } from "./utils.mjs";

/**
* @type {import("eslint").Rule.RuleModule}
*/
export default {
meta: {
type: "suggestion",
docs: {
description:
"enforce function names to end with a specified suffix (default to 'OrThrow') if they throw exceptions",
category: "Best Practices",
recommended: "error",
},
messages: {
missingSuffix:
"Function '{{name}}' throws an exception but its name does not end with '{{suffix}}'.",
},
schema: [
{
type: "object",
properties: {
suffix: {
type: "string",
default: "OrThrow",
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] ?? {};
const suffix = options.suffix ?? "OrThrow";

/** @param {(import("estree").ArrowFunctionExpression | (import("estree").FunctionDeclaration)) & import("eslint").Rule.NodeParentExtension} node */
function checkFunctionName(node) {
const hasThrow = hasThrowInBlock(node.body.body);

if (hasThrow) {
const functionName = node.id?.name;

if (functionName && !functionName.endsWith(suffix)) {
context.report({
node: node.id,
messageId: "missingSuffix",
data: { name: functionName, suffix },
});
}
}
}

return {
FunctionDeclaration: checkFunctionName,
FunctionExpression: checkFunctionName,
ArrowFunctionExpression: checkFunctionName,
};
},
};
128 changes: 128 additions & 0 deletions lib/rules/utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/** @param {import("estree").Statement[]} block */
export function getThrowTypes(block = []) {
/** @type {Set<string>} */
const throwTypes = new Set();

/** @param {import("estree").Statement[]} block */
function checkAndAddThrowType(block = []) {
const handlerThrowTypes = getThrowTypes(block);
if (handlerThrowTypes.size > 0) {
throwTypes.add(...handlerThrowTypes);
}
}

// block may be another function?
if (Array.isArray(block)) {
for (const statement of block) {
if (statement.type === "ThrowStatement") {
// Assuming the argument is an Expression that can be evaluated to get the error type
if (
statement.argument.type === "NewExpression" &&
statement.argument.callee.name === "Error"
) {
throwTypes.add("Error"); // Generic Error or customize based on arguments if possible
} else if (statement.argument.type === "Identifier") {
throwTypes.add(statement.argument.name); // Assuming the identifier is an error type
} else {
throwTypes.add("Unknown"); // For other types of throws
}
}

// Handle TryStatement
if (statement.type === "TryStatement") {
if (statement.handler) {
checkAndAddThrowType(statement.handler.body.body);
}

if (statement.finalizer) {
checkAndAddThrowType(statement.finalizer.body);
}
}

// Handle IfStatement
if (statement.type === "IfStatement") {
checkAndAddThrowType([statement.consequent]);
statement.alternate && checkAndAddThrowType([statement.alternate]);
}

// Handle DoWhileStatement and WhileStatement
if (statement.type === "DoWhileStatement" || statement.type === "WhileStatement") {
checkAndAddThrowType([statement.body]);
}

// Handle ForStatement and ForInStatement
if (
statement.type === "ForStatement" ||
statement.type === "ForInStatement" ||
statement.type === "ForOfStatement"
) {
checkAndAddThrowType([statement.body]);
}

// Handle SwitchStatement
if (statement.type === "SwitchStatement") {
for (const switchCase of statement.cases) {
checkAndAddThrowType(switchCase.consequent);
}
}

// Handle BlockStatement
if (statement.type === "BlockStatement") {
checkAndAddThrowType(statement.body);
}
}
}

return throwTypes;
}

/** @param {import("estree").Statement[]} block */
export function hasThrowInBlock(block = []) {
if (!Array.isArray(block)) return false;

return block.some((statement) => {
if (statement.type === "ThrowStatement") return true;

// Handle TryStatement
if (statement.type === "TryStatement") {
return (
(statement.handler && hasThrowInBlock(statement.handler.body.body)) ||
(statement.finalizer && hasThrowInBlock(statement.finalizer.body))
);
}

// Handle IfStatement
if (statement.type === "IfStatement") {
return (
hasThrowInBlock([statement.consequent]) ||
(statement.alternate && hasThrowInBlock([statement.alternate]))
);
}

// Handle DoWhileStatement and WhileStatement
if (statement.type === "DoWhileStatement" || statement.type === "WhileStatement") {
return hasThrowInBlock([statement.body]);
}

// Handle ForStatement and ForInStatement
if (
statement.type === "ForStatement" ||
statement.type === "ForInStatement" ||
statement.type === "ForOfStatement"
) {
return hasThrowInBlock([statement.body]);
}

// Handle SwitchStatement
if (statement.type === "SwitchStatement") {
return statement.cases.some((switchCase) => hasThrowInBlock(switchCase.consequent));
}

// Handle BlockStatement
if (statement.type === "BlockStatement") {
return hasThrowInBlock(statement.body);
}

return false;
});
}
2 changes: 2 additions & 0 deletions lib/tests/throw-documentation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const ruleTester = new AvaRuleTester(test, {
});

ruleTester.run("throw-documentation", rule, {
/** @type {import("eslint").RuleTester.ValidTestCase[]} */
valid: [
// Function Declaration
{
Expand Down Expand Up @@ -90,6 +91,7 @@ ruleTester.run("throw-documentation", rule, {
`,
},
],
/** @type {import("eslint").RuleTester.InvalidTestCase[]} */
invalid: [
{
code: `
Expand Down
40 changes: 40 additions & 0 deletions lib/tests/throw-naming.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import test from "ava";
import AvaRuleTester from "eslint-ava-rule-tester";
import rule from "../rules/throw-naming.mjs";

const ruleTester = new AvaRuleTester(test, {
languageOptions: { ecmaVersion: 2021, sourceType: "module" },
});

ruleTester.run("throw-function-naming", rule, {
/** @type {import("eslint").RuleTester.ValidTestCase[]} */
valid: [
{
code: `
function testOrThrow() {
throw new Error('test');
}
`,
},
],
/** @type {import("eslint").RuleTester.InvalidTestCase[]} */
invalid: [
{
code: `
function test() {
throw new Error('test');
}
`,
errors: [{ messageId: "missingSuffix", data: { name: "test", suffix: "OrThrow" } }],
},
{
code: `
function test() {
throw new Error('test');
}
`,
options: [{ suffix: "MayFail" }],
errors: [{ messageId: "missingSuffix", data: { name: "test", suffix: "MayFail" } }],
},
],
});
Loading

0 comments on commit b2be938

Please sign in to comment.