Skip to content

Commit c50194d

Browse files
committed
fix: enforce naming conventions for top-level functions and improve error messages
1 parent 5a1c652 commit c50194d

File tree

1 file changed

+139
-35
lines changed

1 file changed

+139
-35
lines changed

src/rules/top-level-functions.js

Lines changed: 139 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
/* eslint-disable unicorn/prefer-module */
2+
3+
/**
4+
* @type {Object}
5+
* @property {string} type - The type of the rule, in this case, 'suggestion'.
6+
* @property {Object} docs - Documentation related to the rule.
7+
* @property {string} docs.description - A brief description of the rule.
8+
* @property {string} docs.category - The category of the rule, 'Stylistic Issues'.
9+
* @property {boolean} docs.recommended - Indicates if the rule is recommended.
10+
* @property {string} docs.url - The URL to the documentation of the rule.
11+
* @property {string} fixable - Indicates if the rule is fixable, 'code'.
12+
* @property {Array} schema - The schema for the rule options.
13+
*/
114
const meta = {
215
type: 'suggestion',
316
docs: {
@@ -10,45 +23,127 @@ const meta = {
1023
schema: [],
1124
};
1225

26+
/**
27+
* Build a replacement code string for an arrow function:
28+
*
29+
* @param {string} funcName - The name of the new function.
30+
* @param {ArrowFunctionExpression} arrowNode - The ArrowFunctionExpression node.
31+
* @param {import('eslint').SourceCode} sourceCode - The ESLint SourceCode object.
32+
* @param {boolean} isExport - Whether or not this function is exported.
33+
* @returns {string} The replacement code.
34+
*/
35+
function buildArrowFunctionReplacement(functionName, arrowNode, sourceCode, isExport) {
36+
const parametersText = arrowNode.params.map(parameter => sourceCode.getText(parameter)).join(', ');
37+
38+
let bodyText;
39+
if (arrowNode.body.type === 'BlockStatement') {
40+
bodyText = sourceCode.getText(arrowNode.body);
41+
} else {
42+
const expressionText = sourceCode.getText(arrowNode.body);
43+
bodyText = `{ return ${expressionText}; }`;
44+
}
45+
46+
const exportKeyword = isExport ? 'export ' : '';
47+
return `${exportKeyword}function ${functionName}(${parametersText}) ${bodyText}`;
48+
}
49+
50+
/**
51+
* Build a replacement code string for a function expression:
52+
*
53+
* @param {string} funcName - The name of the new function.
54+
* @param {FunctionExpression} funcExprNode - The FunctionExpression node.
55+
* @param {import('eslint').SourceCode} sourceCode - The ESLint SourceCode object.
56+
* @param {boolean} isExport - Whether or not this function is exported.
57+
* @returns {string} The replacement code.
58+
*/
59+
function buildFunctionExpressionReplacement(functionName, functionExprNode, sourceCode, isExport) {
60+
const parametersText = functionExprNode.params.map(parameter => sourceCode.getText(parameter)).join(', ');
61+
const bodyText = sourceCode.getText(functionExprNode.body);
62+
63+
const exportKeyword = isExport ? 'export ' : '';
64+
return `${exportKeyword}function ${functionName}(${parametersText}) ${bodyText}`;
65+
}
66+
67+
/**
68+
* Build a replacement for an anonymous top-level FunctionDeclaration.
69+
*
70+
* @param {import('eslint').SourceCode} sourceCode
71+
* @param {import('estree').FunctionDeclaration} node
72+
* @param {string} [funcName='defaultFunction']
73+
*/
74+
function buildAnonymousFunctionDeclarationReplacement(sourceCode, node, functionName = 'defaultFunction') {
75+
const originalText = sourceCode.getText(node);
76+
77+
const fixedText = originalText.replace(
78+
/^(\s*function\s*)\(/,
79+
`$1${functionName}(`,
80+
);
81+
return fixedText;
82+
}
83+
84+
/**
85+
* ESLint rule to enforce naming conventions for top-level functions.
86+
*
87+
* @param {Object} context - The rule context provided by ESLint.
88+
* @returns {Object} An object containing visitor methods for AST nodes.
89+
*/
1390
function create(context) {
91+
const sourceCode = context.getSourceCode();
92+
1493
return {
1594
VariableDeclarator(node) {
16-
if (node.parent.parent.type !== 'Program') {
95+
const declParent = node.parent;
96+
const grandParent = declParent.parent;
97+
98+
const isTopLevel
99+
= grandParent.type === 'Program'
100+
|| grandParent.type === 'ExportNamedDeclaration'
101+
|| grandParent.type === 'ExportDefaultDeclaration';
102+
103+
if (!isTopLevel) {
17104
return;
18105
}
19106

20-
const sourceCode = context.getSourceCode();
107+
const isExport
108+
= grandParent.type === 'ExportNamedDeclaration'
109+
|| grandParent.type === 'ExportDefaultDeclaration';
21110

22-
if (node.init && node.init.type === 'ArrowFunctionExpression') {
23-
const functionName = node.id.name;
24-
const functionText = sourceCode.getText(node.init);
111+
if (!node.init) {
112+
return;
113+
}
25114

115+
const functionName = node.id && node.id.name;
116+
if (!functionName) {
117+
return;
118+
}
119+
120+
if (node.init.type === 'ArrowFunctionExpression') {
26121
context.report({
27122
node: node.init,
28-
message: 'Top-level functions must be named/regular functions.',
123+
message: 'Top-level arrow functions must be named/regular functions.',
29124
fix(fixer) {
30-
const isSingleExpression = node.init.body.type !== 'BlockStatement';
31-
const functionBody = isSingleExpression
32-
? `{ return ${functionText.slice(functionText.indexOf('=>') + 3)}; }`
33-
: functionText.slice(functionText.indexOf('{'));
34-
const functionParameters = functionText.slice(0, functionText.indexOf('=>')).trim();
35-
36-
const fixedCode = `function ${functionName}${functionParameters} ${functionBody}`;
37-
return fixer.replaceText(node.parent, fixedCode);
125+
const replacement = buildArrowFunctionReplacement(
126+
functionName,
127+
node.init,
128+
sourceCode,
129+
isExport,
130+
);
131+
132+
return fixer.replaceText(grandParent.type === 'Program' ? declParent : grandParent, replacement);
38133
},
39134
});
40-
}
41-
42-
if (node.init && node.init.type === 'FunctionExpression') {
43-
const functionName = node.id.name;
44-
const functionText = sourceCode.getText(node.init);
45-
135+
} else if (node.init.type === 'FunctionExpression') {
46136
context.report({
47137
node: node.init,
48-
message: 'Top-level functions must be named/regular functions.',
138+
message: 'Top-level function expressions must be named/regular functions.',
49139
fix(fixer) {
50-
const fixedCode = `function ${functionName}${functionText.slice(functionText.indexOf('('))}`;
51-
return fixer.replaceText(node.parent, fixedCode);
140+
const replacement = buildFunctionExpressionReplacement(
141+
functionName,
142+
node.init,
143+
sourceCode,
144+
isExport,
145+
);
146+
return fixer.replaceText(grandParent.type === 'Program' ? declParent : grandParent, replacement);
52147
},
53148
});
54149
}
@@ -59,20 +154,29 @@ function create(context) {
59154
return;
60155
}
61156

62-
if (node.parent.type === 'Program') {
63-
context.report({
64-
node,
65-
message: 'Top-level functions must be named.',
66-
fix(fixer) {
67-
const functionName = 'defaultFunction';
68-
const sourceCode = context.getSourceCode();
69-
const functionText = sourceCode.getText(node);
70-
const fixedCode = functionText.replace('function (', `function ${functionName}(`);
157+
const parent = node.parent;
71158

72-
return fixer.replaceText(node, fixedCode);
73-
},
74-
});
159+
const isTopLevel = parent.type === 'Program'
160+
|| parent.type === 'ExportNamedDeclaration'
161+
|| parent.type === 'ExportDefaultDeclaration';
162+
163+
if (!isTopLevel) {
164+
return;
75165
}
166+
167+
context.report({
168+
node,
169+
message: 'Top-level anonymous function declarations must be named.',
170+
fix(fixer) {
171+
const newName = 'defaultFunction';
172+
const replacement = buildAnonymousFunctionDeclarationReplacement(sourceCode, node, newName);
173+
174+
return fixer.replaceText(
175+
parent.type === 'Program' ? node : parent,
176+
replacement,
177+
);
178+
},
179+
});
76180
},
77181
};
78182
}

0 commit comments

Comments
 (0)