Skip to content

Commit c035216

Browse files
authored
Add no-anonymous-default-export rule (#2273)
1 parent d76f8a2 commit c035216

19 files changed

+2051
-12
lines changed

configs/recommended.js

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
'unicorn/import-style': 'error',
1515
'unicorn/new-for-builtins': 'error',
1616
'unicorn/no-abusive-eslint-disable': 'error',
17+
'unicorn/no-anonymous-default-export': 'error',
1718
'unicorn/no-array-callback-reference': 'error',
1819
'unicorn/no-array-for-each': 'error',
1920
'unicorn/no-array-method-this-argument': 'error',
+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Disallow anonymous functions and classes as the default export
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs).
4+
5+
💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Naming default exports improves codebase searchability by ensuring consistent identifier use for a module's default export, both where it's declared and where it's imported.
11+
12+
## Fail
13+
14+
```js
15+
export default class {}
16+
```
17+
18+
```js
19+
export default function () {}
20+
```
21+
22+
```js
23+
export default () => {};
24+
```
25+
26+
```js
27+
module.exports = class {};
28+
```
29+
30+
```js
31+
module.exports = function () {};
32+
```
33+
34+
```js
35+
module.exports = () => {};
36+
```
37+
38+
## Pass
39+
40+
```js
41+
export default class Foo {}
42+
```
43+
44+
```js
45+
export default function foo () {}
46+
```
47+
48+
```js
49+
const foo = () => {};
50+
export default foo;
51+
```
52+
53+
```js
54+
module.exports = class Foo {};
55+
```
56+
57+
```js
58+
module.exports = function foo () {};
59+
```
60+
61+
```js
62+
const foo = () => {};
63+
module.exports = foo;
64+
```

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@
134134
]
135135
}
136136
],
137-
"import/order": "off"
137+
"import/order": "off",
138+
"func-names": "off"
138139
},
139140
"overrides": [
140141
{

readme.md

+1
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c
124124
| [import-style](docs/rules/import-style.md) | Enforce specific import styles per module. || | |
125125
| [new-for-builtins](docs/rules/new-for-builtins.md) | Enforce the use of `new` for all builtins, except `String`, `Number`, `Boolean`, `Symbol` and `BigInt`. || 🔧 | |
126126
| [no-abusive-eslint-disable](docs/rules/no-abusive-eslint-disable.md) | Enforce specifying rules to disable in `eslint-disable` comments. || | |
127+
| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md) | Disallow anonymous functions and classes as the default export. || | 💡 |
127128
| [no-array-callback-reference](docs/rules/no-array-callback-reference.md) | Prevent passing a function reference directly to iterator methods. || | 💡 |
128129
| [no-array-for-each](docs/rules/no-array-for-each.md) | Prefer `for…of` over the `forEach` method. || 🔧 | 💡 |
129130
| [no-array-method-this-argument](docs/rules/no-array-method-this-argument.md) | Disallow using the `this` argument in array methods. || 🔧 | 💡 |

rules/no-anonymous-default-export.js

+212
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
'use strict';
2+
3+
const path = require('node:path');
4+
const {
5+
getFunctionHeadLocation,
6+
getFunctionNameWithKind,
7+
isOpeningParenToken,
8+
} = require('@eslint-community/eslint-utils');
9+
const {
10+
isIdentifierName,
11+
} = require('@babel/helper-validator-identifier');
12+
const getClassHeadLocation = require('./utils/get-class-head-location.js');
13+
const {upperFirst, camelCase} = require('./utils/lodash.js');
14+
const {getParenthesizedRange} = require('./utils/parentheses.js');
15+
const {
16+
getScopes,
17+
avoidCapture,
18+
} = require('./utils/index.js');
19+
const {isMemberExpression} = require('./ast/index.js');
20+
21+
const MESSAGE_ID_ERROR = 'no-anonymous-default-export/error';
22+
const MESSAGE_ID_SUGGESTION = 'no-anonymous-default-export/suggestion';
23+
const messages = {
24+
[MESSAGE_ID_ERROR]: 'The {{description}} should be named.',
25+
[MESSAGE_ID_SUGGESTION]: 'Name it as `{{name}}`.',
26+
};
27+
28+
const isClassKeywordToken = token => token.type === 'Keyword' && token.value === 'class';
29+
const isAnonymousClassOrFunction = node =>
30+
(
31+
(
32+
node.type === 'FunctionDeclaration'
33+
|| node.type === 'FunctionExpression'
34+
|| node.type === 'ClassDeclaration'
35+
|| node.type === 'ClassExpression'
36+
)
37+
&& !node.id
38+
)
39+
|| node.type === 'ArrowFunctionExpression';
40+
41+
function getSuggestionName(node, filename, sourceCode) {
42+
if (filename === '<input>' || filename === '<text>') {
43+
return;
44+
}
45+
46+
let [name] = path.basename(filename).split('.');
47+
name = camelCase(name);
48+
49+
if (!isIdentifierName(name)) {
50+
return;
51+
}
52+
53+
name = node.type === 'ClassDeclaration' ? upperFirst(name) : name;
54+
name = avoidCapture(name, getScopes(sourceCode.getScope(node)));
55+
56+
return name;
57+
}
58+
59+
function addName(fixer, node, name, sourceCode) {
60+
switch (node.type) {
61+
case 'ClassDeclaration':
62+
case 'ClassExpression': {
63+
const lastDecorator = node.decorators?.at(-1);
64+
const classToken = lastDecorator
65+
? sourceCode.getTokenAfter(lastDecorator, isClassKeywordToken)
66+
: sourceCode.getFirstToken(node, isClassKeywordToken);
67+
return fixer.insertTextAfter(classToken, ` ${name}`);
68+
}
69+
70+
case 'FunctionDeclaration':
71+
case 'FunctionExpression': {
72+
const openingParenthesisToken = sourceCode.getFirstToken(
73+
node,
74+
isOpeningParenToken,
75+
);
76+
return fixer.insertTextBefore(
77+
openingParenthesisToken,
78+
`${sourceCode.text.charAt(openingParenthesisToken.range[0] - 1) === ' ' ? '' : ' '}${name} `,
79+
);
80+
}
81+
82+
case 'ArrowFunctionExpression': {
83+
const [exportDeclarationStart, exportDeclarationEnd]
84+
= node.parent.type === 'ExportDefaultDeclaration'
85+
? node.parent.range
86+
: node.parent.parent.range;
87+
const [arrowFunctionStart, arrowFunctionEnd] = getParenthesizedRange(node, sourceCode);
88+
89+
let textBefore = sourceCode.text.slice(exportDeclarationStart, arrowFunctionStart);
90+
let textAfter = sourceCode.text.slice(arrowFunctionEnd, exportDeclarationEnd);
91+
92+
textBefore = `\n${textBefore}`;
93+
if (!/\s$/.test(textBefore)) {
94+
textBefore = `${textBefore} `;
95+
}
96+
97+
if (!textAfter.endsWith(';')) {
98+
textAfter = `${textAfter};`;
99+
}
100+
101+
return [
102+
fixer.replaceTextRange(
103+
[exportDeclarationStart, arrowFunctionStart],
104+
`const ${name} = `,
105+
),
106+
fixer.replaceTextRange(
107+
[arrowFunctionEnd, exportDeclarationEnd],
108+
';',
109+
),
110+
fixer.insertTextAfterRange(
111+
[exportDeclarationEnd, exportDeclarationEnd],
112+
`${textBefore}${name}${textAfter}`,
113+
),
114+
];
115+
}
116+
117+
// No default
118+
}
119+
}
120+
121+
function getProblem(node, context) {
122+
const {sourceCode, physicalFilename} = context;
123+
124+
const suggestionName = getSuggestionName(node, physicalFilename, sourceCode);
125+
126+
let loc;
127+
let description;
128+
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
129+
loc = getClassHeadLocation(node, sourceCode);
130+
description = 'class';
131+
} else {
132+
loc = getFunctionHeadLocation(node, sourceCode);
133+
// [TODO: @fisker]: Ask `@eslint-community/eslint-utils` to expose `getFunctionKind`
134+
const nameWithKind = getFunctionNameWithKind(node);
135+
description = nameWithKind.replace(/ '.*?'$/, '');
136+
}
137+
138+
const problem = {
139+
node,
140+
loc,
141+
messageId: MESSAGE_ID_ERROR,
142+
data: {
143+
description,
144+
},
145+
};
146+
147+
if (!suggestionName) {
148+
return problem;
149+
}
150+
151+
problem.suggest = [
152+
{
153+
messageId: MESSAGE_ID_SUGGESTION,
154+
data: {
155+
name: suggestionName,
156+
},
157+
fix: fixer => addName(fixer, node, suggestionName, sourceCode),
158+
},
159+
];
160+
161+
return problem;
162+
}
163+
164+
/** @param {import('eslint').Rule.RuleContext} context */
165+
const create = context => {
166+
context.on('ExportDefaultDeclaration', node => {
167+
if (!isAnonymousClassOrFunction(node.declaration)) {
168+
return;
169+
}
170+
171+
return getProblem(node.declaration, context);
172+
});
173+
174+
context.on('AssignmentExpression', node => {
175+
if (
176+
!isAnonymousClassOrFunction(node.right)
177+
|| !(
178+
node.parent.type === 'ExpressionStatement'
179+
&& node.parent.expression === node
180+
)
181+
|| !(
182+
isMemberExpression(node.left, {
183+
object: 'module',
184+
property: 'exports',
185+
computed: false,
186+
optional: false,
187+
})
188+
|| (
189+
node.left.type === 'Identifier',
190+
node.left.name === 'exports'
191+
)
192+
)
193+
) {
194+
return;
195+
}
196+
197+
return getProblem(node.right, context);
198+
});
199+
};
200+
201+
/** @type {import('eslint').Rule.RuleModule} */
202+
module.exports = {
203+
create,
204+
meta: {
205+
type: 'suggestion',
206+
docs: {
207+
description: 'Disallow anonymous functions and classes as the default export.',
208+
},
209+
hasSuggestions: true,
210+
messages,
211+
},
212+
};

rules/utils/avoid-capture.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ Useful when you want to rename a variable (or create a new variable) while being
129129
@param {isSafe} [isSafe] - Rule-specific name check function.
130130
@returns {string} - Either `name` as is, or a string like `${name}_` suffixed with underscores to make the name unique.
131131
*/
132-
module.exports = (name, scopes, isSafe = alwaysTrue) => {
132+
module.exports = function avoidCapture(name, scopes, isSafe = alwaysTrue) {
133133
if (!isValidIdentifier(name)) {
134134
name += '_';
135135

@@ -144,3 +144,4 @@ module.exports = (name, scopes, isSafe = alwaysTrue) => {
144144

145145
return name;
146146
};
147+

rules/utils/cartesian-product-samples.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict';
22

3-
module.exports = (combinations, length = Number.POSITIVE_INFINITY) => {
3+
module.exports = function cartesianProductSamples(combinations, length = Number.POSITIVE_INFINITY) {
44
const total = combinations.reduce((total, {length}) => total * length, 1);
55

66
const samples = Array.from({length: Math.min(total, length)}, (_, sampleIndex) => {

rules/utils/escape-string.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Escape string and wrap the result in quotes.
99
@param {string} [quote] - The quote character.
1010
@returns {string} - The quoted and escaped string.
1111
*/
12-
module.exports = (string, quote = '\'') => {
12+
module.exports = function escapeString(string, quote = '\'') {
1313
/* c8 ignore start */
1414
if (typeof string !== 'string') {
1515
throw new TypeError('Unexpected string.');
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

3-
module.exports = string => string.replaceAll(
3+
const escapeTemplateElementRaw = string => string.replaceAll(
44
/(?<=(?:^|[^\\])(?:\\\\)*)(?<symbol>(?:`|\$(?={)))/g,
55
'\\$<symbol>',
66
);
7+
module.exports = escapeTemplateElementRaw;

rules/utils/get-documentation-url.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const packageJson = require('../../package.json');
44

55
const repoUrl = 'https://github.com/sindresorhus/eslint-plugin-unicorn';
66

7-
module.exports = filename => {
7+
module.exports = function getDocumentationUrl(filename) {
88
const ruleName = path.basename(filename, '.js');
99
return `${repoUrl}/blob/v${packageJson.version}/docs/rules/${ruleName}.md`;
1010
};
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use strict';
22

33
// Get identifiers of given variable
4-
module.exports = ({identifiers, references}) => [...new Set([
4+
const getVariableIdentifiers = ({identifiers, references}) => [...new Set([
55
...identifiers,
66
...references.map(({identifier}) => identifier),
77
])];
8+
module.exports = getVariableIdentifiers;

rules/utils/has-same-range.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
'use strict';
22

3-
module.exports = (node1, node2) =>
3+
const hasSameRange = (node1, node2) =>
44
node1
55
&& node2
66
&& node1.range[0] === node2.range[0]
77
&& node1.range[1] === node2.range[1];
8+
module.exports = hasSameRange;

rules/utils/is-object-method.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
'use strict';
2-
module.exports = (node, object, method) => {
2+
module.exports = function isObjectMethod(node, object, method) {
33
const {callee} = node;
44
return (
55
callee.type === 'MemberExpression'

rules/utils/is-value-not-usable.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22

33
const {isExpressionStatement} = require('../ast/index.js');
44

5-
module.exports = node => isExpressionStatement(node.parent);
5+
const isValueNotUsable = node => isExpressionStatement(node.parent);
6+
module.exports = isValueNotUsable;

rules/utils/resolve-variable-name.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Finds a variable named `name` in the scope `scope` (or it's parents).
77
@param {Scope} scope - The scope to look for the variable in.
88
@returns {Variable?} - The found variable, if any.
99
*/
10-
module.exports = (name, scope) => {
10+
module.exports = function resolveVariableName(name, scope) {
1111
while (scope) {
1212
const variable = scope.set.get(name);
1313

0 commit comments

Comments
 (0)