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

Add no-anonymous-default-export rule #2273

Merged
merged 25 commits into from Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions configs/recommended.js
Expand Up @@ -14,6 +14,7 @@ module.exports = {
'unicorn/import-style': 'error',
'unicorn/new-for-builtins': 'error',
'unicorn/no-abusive-eslint-disable': 'error',
'unicorn/no-anonymous-default-export': 'error',
'unicorn/no-array-callback-reference': 'error',
'unicorn/no-array-for-each': 'error',
'unicorn/no-array-method-this-argument': 'error',
Expand Down
64 changes: 64 additions & 0 deletions docs/rules/no-anonymous-default-export.md
@@ -0,0 +1,64 @@
# Disallow anonymous functions and classes as the default export

💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs).

💡 This rule is manually fixable by [editor suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

Ensuring that default exports are named helps improve the grepability of the codebase by encouraging the re-use of the same identifier for the module's default export at its declaration site and at its import sites.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


## Fail

```js
export default class {}
```

```js
export default function () {}
```

```js
export default () => {};
```

```js
module.exports = class {};
```

```js
module.exports = function () {};
```

```js
module.exports = () => {};
```

## Pass

```js
export default class Foo {}
```

```js
export default function foo () {}
```

```js
const foo = () => {};
export default foo;
```

```js
module.exports = class Foo {};
```

```js
module.exports = function foo () {};
```

```js
const foo = () => {};
module.exports = foo;
```
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -134,7 +134,8 @@
]
}
],
"import/order": "off"
"import/order": "off",
"func-names": "off"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rule not allow

module.exports = function eslintDonotAllowNameHere() {}

},
"overrides": [
{
Expand Down
1 change: 1 addition & 0 deletions readme.md
Expand Up @@ -124,6 +124,7 @@ If you don't use the preset, ensure you use the same `env` and `parserOptions` c
| [import-style](docs/rules/import-style.md) | Enforce specific import styles per module. | ✅ | | |
| [new-for-builtins](docs/rules/new-for-builtins.md) | Enforce the use of `new` for all builtins, except `String`, `Number`, `Boolean`, `Symbol` and `BigInt`. | ✅ | 🔧 | |
| [no-abusive-eslint-disable](docs/rules/no-abusive-eslint-disable.md) | Enforce specifying rules to disable in `eslint-disable` comments. | ✅ | | |
| [no-anonymous-default-export](docs/rules/no-anonymous-default-export.md) | Disallow anonymous functions and classes as the default export. | ✅ | | 💡 |
| [no-array-callback-reference](docs/rules/no-array-callback-reference.md) | Prevent passing a function reference directly to iterator methods. | ✅ | | 💡 |
| [no-array-for-each](docs/rules/no-array-for-each.md) | Prefer `for…of` over the `forEach` method. | ✅ | 🔧 | 💡 |
| [no-array-method-this-argument](docs/rules/no-array-method-this-argument.md) | Disallow using the `this` argument in array methods. | ✅ | 🔧 | 💡 |
Expand Down
212 changes: 212 additions & 0 deletions rules/no-anonymous-default-export.js
@@ -0,0 +1,212 @@
'use strict';

const path = require('node:path');
const {
getFunctionHeadLocation,
getFunctionNameWithKind,
isOpeningParenToken,
} = require('@eslint-community/eslint-utils');
const {
isIdentifierName,
} = require('@babel/helper-validator-identifier');
const getClassHeadLocation = require('./utils/get-class-head-location.js');
const {upperFirst, camelCase} = require('./utils/lodash.js');
const {getParenthesizedRange} = require('./utils/parentheses.js');
const {
getScopes,
avoidCapture,
} = require('./utils/index.js');
const {isMemberExpression} = require('./ast/index.js');

const MESSAGE_ID_ERROR = 'no-anonymous-default-export/error';
const MESSAGE_ID_SUGGESTION = 'no-anonymous-default-export/suggestion';
const messages = {
[MESSAGE_ID_ERROR]: 'The {{description}} should be named.',
[MESSAGE_ID_SUGGESTION]: 'Name it as `{{name}}`.',
};

const isClassKeywordToken = token => token.type === 'Keyword' && token.value === 'class';
const isAnonymousClassOrFunction = node =>
(
(
node.type === 'FunctionDeclaration'
|| node.type === 'FunctionExpression'
|| node.type === 'ClassDeclaration'
|| node.type === 'ClassExpression'
)
&& !node.id
)
|| node.type === 'ArrowFunctionExpression';

function getSuggestionName(node, filename, sourceCode) {
if (filename === '<input>' || filename === '<text>') {
return;
}

let [name] = path.basename(filename).split('.');
name = camelCase(name);

if (!isIdentifierName(name)) {
return;
}

name = node.type === 'ClassDeclaration' ? upperFirst(name) : name;
name = avoidCapture(name, getScopes(sourceCode.getScope(node)));

return name;
}

function addName(fixer, node, name, sourceCode) {
switch (node.type) {
case 'ClassDeclaration':
case 'ClassExpression': {
const lastDecorator = node.decorators?.at(-1);
const classToken = lastDecorator
? sourceCode.getTokenAfter(lastDecorator, isClassKeywordToken)
: sourceCode.getFirstToken(node, isClassKeywordToken);
return fixer.insertTextAfter(classToken, ` ${name}`);
}

case 'FunctionDeclaration':
case 'FunctionExpression': {
const openingParenthesisToken = sourceCode.getFirstToken(
node,
isOpeningParenToken,
);
return fixer.insertTextBefore(
openingParenthesisToken,
`${sourceCode.text.charAt(openingParenthesisToken.range[0] - 1) === ' ' ? '' : ' '}${name} `,
);
}

case 'ArrowFunctionExpression': {
const [exportDeclarationStart, exportDeclarationEnd]
= node.parent.type === 'ExportDefaultDeclaration'
? node.parent.range
: node.parent.parent.range;
const [arrowFunctionStart, arrowFunctionEnd] = getParenthesizedRange(node, sourceCode);

let textBefore = sourceCode.text.slice(exportDeclarationStart, arrowFunctionStart);
let textAfter = sourceCode.text.slice(arrowFunctionEnd, exportDeclarationEnd);

textBefore = `\n${textBefore}`;
if (!/\s$/.test(textBefore)) {
textBefore = `${textBefore} `;
}

if (!textAfter.endsWith(';')) {
textAfter = `${textAfter};`;
}

return [
fixer.replaceTextRange(
[exportDeclarationStart, arrowFunctionStart],
`const ${name} = `,
),
fixer.replaceTextRange(
[arrowFunctionEnd, exportDeclarationEnd],
';',
),
fixer.insertTextAfterRange(
[exportDeclarationEnd, exportDeclarationEnd],
`${textBefore}${name}${textAfter}`,
),
];
}

// No default
}
}

function getProblem(node, context) {
const {sourceCode, physicalFilename} = context;

const suggestionName = getSuggestionName(node, physicalFilename, sourceCode);

let loc;
let description;
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
loc = getClassHeadLocation(node, sourceCode);
description = 'class';
} else {
loc = getFunctionHeadLocation(node, sourceCode);
// [TODO: @fisker]: Ask `@eslint-community/eslint-utils` to expose `getFunctionKind`
const nameWithKind = getFunctionNameWithKind(node);
description = nameWithKind.replace(/ '.*?'$/, '');
}

const problem = {
node,
loc,
messageId: MESSAGE_ID_ERROR,
data: {
description,
},
};

if (!suggestionName) {
return problem;
}

problem.suggest = [
{
messageId: MESSAGE_ID_SUGGESTION,
data: {
name: suggestionName,
},
fix: fixer => addName(fixer, node, suggestionName, sourceCode),
},
];

return problem;
}

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
context.on('ExportDefaultDeclaration', node => {
if (!isAnonymousClassOrFunction(node.declaration)) {
return;
}

return getProblem(node.declaration, context);
});

context.on('AssignmentExpression', node => {
if (
!isAnonymousClassOrFunction(node.right)
|| !(
node.parent.type === 'ExpressionStatement'
&& node.parent.expression === node
)
|| !(
isMemberExpression(node.left, {
object: 'module',
property: 'exports',
computed: false,
optional: false,
})
|| (
node.left.type === 'Identifier',
node.left.name === 'exports'
)
)
) {
return;
}

return getProblem(node.right, context);
});
};

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow anonymous functions and classes as the default export.',
},
hasSuggestions: true,
messages,
},
};
3 changes: 2 additions & 1 deletion rules/utils/avoid-capture.js
Expand Up @@ -129,7 +129,7 @@ Useful when you want to rename a variable (or create a new variable) while being
@param {isSafe} [isSafe] - Rule-specific name check function.
@returns {string} - Either `name` as is, or a string like `${name}_` suffixed with underscores to make the name unique.
*/
module.exports = (name, scopes, isSafe = alwaysTrue) => {
module.exports = function avoidCapture(name, scopes, isSafe = alwaysTrue) {
if (!isValidIdentifier(name)) {
name += '_';

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

return name;
};

2 changes: 1 addition & 1 deletion rules/utils/cartesian-product-samples.js
@@ -1,6 +1,6 @@
'use strict';

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

const samples = Array.from({length: Math.min(total, length)}, (_, sampleIndex) => {
Expand Down
2 changes: 1 addition & 1 deletion rules/utils/escape-string.js
Expand Up @@ -9,7 +9,7 @@ Escape string and wrap the result in quotes.
@param {string} [quote] - The quote character.
@returns {string} - The quoted and escaped string.
*/
module.exports = (string, quote = '\'') => {
module.exports = function escapeString(string, quote = '\'') {
/* c8 ignore start */
if (typeof string !== 'string') {
throw new TypeError('Unexpected string.');
Expand Down
3 changes: 2 additions & 1 deletion rules/utils/escape-template-element-raw.js
@@ -1,6 +1,7 @@
'use strict';

module.exports = string => string.replaceAll(
const escapeTemplateElementRaw = string => string.replaceAll(
/(?<=(?:^|[^\\])(?:\\\\)*)(?<symbol>(?:`|\$(?={)))/g,
'\\$<symbol>',
);
module.exports = escapeTemplateElementRaw;
2 changes: 1 addition & 1 deletion rules/utils/get-documentation-url.js
Expand Up @@ -4,7 +4,7 @@ const packageJson = require('../../package.json');

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

module.exports = filename => {
module.exports = function getDocumentationUrl(filename) {
const ruleName = path.basename(filename, '.js');
return `${repoUrl}/blob/v${packageJson.version}/docs/rules/${ruleName}.md`;
};
3 changes: 2 additions & 1 deletion rules/utils/get-variable-identifiers.js
@@ -1,7 +1,8 @@
'use strict';

// Get identifiers of given variable
module.exports = ({identifiers, references}) => [...new Set([
const getVariableIdentifiers = ({identifiers, references}) => [...new Set([
...identifiers,
...references.map(({identifier}) => identifier),
])];
module.exports = getVariableIdentifiers;
3 changes: 2 additions & 1 deletion rules/utils/has-same-range.js
@@ -1,7 +1,8 @@
'use strict';

module.exports = (node1, node2) =>
const hasSameRange = (node1, node2) =>
node1
&& node2
&& node1.range[0] === node2.range[0]
&& node1.range[1] === node2.range[1];
module.exports = hasSameRange;
2 changes: 1 addition & 1 deletion rules/utils/is-object-method.js
@@ -1,5 +1,5 @@
'use strict';
module.exports = (node, object, method) => {
module.exports = function isObjectMethod(node, object, method) {
const {callee} = node;
return (
callee.type === 'MemberExpression'
Expand Down
3 changes: 2 additions & 1 deletion rules/utils/is-value-not-usable.js
Expand Up @@ -2,4 +2,5 @@

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

module.exports = node => isExpressionStatement(node.parent);
const isValueNotUsable = node => isExpressionStatement(node.parent);
module.exports = isValueNotUsable;
2 changes: 1 addition & 1 deletion rules/utils/resolve-variable-name.js
Expand Up @@ -7,7 +7,7 @@ Finds a variable named `name` in the scope `scope` (or it's parents).
@param {Scope} scope - The scope to look for the variable in.
@returns {Variable?} - The found variable, if any.
*/
module.exports = (name, scope) => {
module.exports = function resolveVariableName(name, scope) {
while (scope) {
const variable = scope.set.get(name);

Expand Down