Skip to content

Commit

Permalink
Add no-anonymous-default-export rule (#2273)
Browse files Browse the repository at this point in the history
  • Loading branch information
fisker authored Feb 9, 2024
1 parent d76f8a2 commit c035216
Show file tree
Hide file tree
Showing 19 changed files with 2,051 additions and 12 deletions.
1 change: 1 addition & 0 deletions configs/recommended.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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` -->

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.

## 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
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@
]
}
],
"import/order": "off"
"import/order": "off",
"func-names": "off"
},
"overrides": [
{
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit c035216

Please sign in to comment.