Skip to content

Commit

Permalink
Add no-named-default rule (#2538)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <[email protected]>
  • Loading branch information
fisker and sindresorhus authored Jan 24, 2025
1 parent 2985ecc commit ed8da1b
Show file tree
Hide file tree
Showing 11 changed files with 923 additions and 43 deletions.
71 changes: 71 additions & 0 deletions docs/rules/no-named-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Disallow named usage of default import and export

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

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

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

Enforces the use of the [`default import`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import) and [`default export`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/export#using_the_default_export) syntax instead of named syntax.

## Examples

```js
//
import {default as foo} from 'foo';

//
import foo from 'foo';
```

```js
//
import {default as foo, bar} from 'foo';

//
import foo, {bar} from 'foo';
```

```js
//
export {foo as default};

//
export default foo;
```

```js
//
export {foo as default, bar};

//
export default foo;
export {bar};
```

```js
//
import foo, {default as anotherFoo} from 'foo';

function bar(foo) {
doSomeThing(anotherFoo, foo);
}

//
import foo from 'foo';
import anotherFoo from 'foo';

function bar(foo) {
doSomeThing(anotherFoo, foo);
}

//
import foo from 'foo';

const anotherFoo = foo;

function bar(foo) {
doSomeThing(anotherFoo, foo);
}
```
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default [
| [no-length-as-slice-end](docs/rules/no-length-as-slice-end.md) | Disallow using `.length` as the `end` argument of `{Array,String,TypedArray}#slice()`. || 🔧 | |
| [no-lonely-if](docs/rules/no-lonely-if.md) | Disallow `if` statements as the only statement in `if` blocks without `else`. || 🔧 | |
| [no-magic-array-flat-depth](docs/rules/no-magic-array-flat-depth.md) | Disallow a magic number as the `depth` argument in `Array#flat(…).` || | |
| [no-named-default](docs/rules/no-named-default.md) | Disallow named usage of default import and export. || 🔧 | |
| [no-negated-condition](docs/rules/no-negated-condition.md) | Disallow negated conditions. || 🔧 | |
| [no-negation-in-equality-check](docs/rules/no-negation-in-equality-check.md) | Disallow negated expression in equality check. || | 💡 |
| [no-nested-ternary](docs/rules/no-nested-ternary.md) | Disallow nested ternary expressions. || 🔧 | |
Expand Down
1 change: 1 addition & 0 deletions rules/fix/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {default as replaceReferenceIdentifier} from './replace-reference-identif
export {default as renameVariable} from './rename-variable.js';
export {default as replaceNodeOrTokenAndSpacesBefore} from './replace-node-or-token-and-spaces-before.js';
export {default as removeSpacesAfter} from './remove-spaces-after.js';
export {default as removeSpecifier} from './remove-specifier.js';
export {default as fixSpaceAroundKeyword} from './fix-space-around-keywords.js';
export {default as replaceStringRaw} from './replace-string-raw.js';
export {default as addParenthesizesToReturnOrThrowExpression} from './add-parenthesizes-to-return-or-throw-expression.js';
46 changes: 46 additions & 0 deletions rules/fix/remove-specifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {isCommaToken, isOpeningBraceToken} from '@eslint-community/eslint-utils';

export default function * removeSpecifier(specifier, fixer, sourceCode, keepDeclaration = false) {
const declaration = specifier.parent;
const {specifiers} = declaration;

if (specifiers.length === 1 && !keepDeclaration) {
yield fixer.remove(declaration);
return;
}

switch (specifier.type) {
case 'ImportSpecifier': {
const isTheOnlyNamedImport = specifiers.every(node => specifier === node || specifier.type !== node.type);
if (isTheOnlyNamedImport) {
const fromToken = sourceCode.getTokenAfter(specifier, token => token.type === 'Identifier' && token.value === 'from');

const hasDefaultImport = specifiers.some(node => node.type === 'ImportDefaultSpecifier');
const startToken = sourceCode.getTokenBefore(specifier, hasDefaultImport ? isCommaToken : isOpeningBraceToken);
const tokenBefore = sourceCode.getTokenBefore(startToken);

yield fixer.replaceTextRange(
[startToken.range[0], fromToken.range[0]],
tokenBefore.range[1] === startToken.range[0] ? ' ' : '',
);
return;
}
// Fallthrough
}

case 'ExportSpecifier':
case 'ImportNamespaceSpecifier':
case 'ImportDefaultSpecifier': {
yield fixer.remove(specifier);

const tokenAfter = sourceCode.getTokenAfter(specifier);
if (isCommaToken(tokenAfter)) {
yield fixer.remove(tokenAfter);
}

break;
}

// No default
}
}
2 changes: 2 additions & 0 deletions rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import noKeywordPrefix from './no-keyword-prefix.js';
import noLengthAsSliceEnd from './no-length-as-slice-end.js';
import noLonelyIf from './no-lonely-if.js';
import noMagicArrayFlatDepth from './no-magic-array-flat-depth.js';
import noNamedDefault from './no-named-default.js';
import noNegatedCondition from './no-negated-condition.js';
import noNegationInEqualityCheck from './no-negation-in-equality-check.js';
import noNestedTernary from './no-nested-ternary.js';
Expand Down Expand Up @@ -166,6 +167,7 @@ const rules = {
'no-length-as-slice-end': createRule(noLengthAsSliceEnd, 'no-length-as-slice-end'),
'no-lonely-if': createRule(noLonelyIf, 'no-lonely-if'),
'no-magic-array-flat-depth': createRule(noMagicArrayFlatDepth, 'no-magic-array-flat-depth'),
'no-named-default': createRule(noNamedDefault, 'no-named-default'),
'no-negated-condition': createRule(noNegatedCondition, 'no-negated-condition'),
'no-negation-in-equality-check': createRule(noNegationInEqualityCheck, 'no-negation-in-equality-check'),
'no-nested-ternary': createRule(noNestedTernary, 'no-nested-ternary'),
Expand Down
98 changes: 98 additions & 0 deletions rules/no-named-default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {removeSpecifier} from './fix/index.js';
import assertToken from './utils/assert-token.js';

const MESSAGE_ID = 'no-named-default';
const messages = {
[MESSAGE_ID]: 'Prefer using the default {{type}} over named {{type}}.',
};

const isValueImport = node => !node.importKind || node.importKind === 'value';
const isValueExport = node => !node.exportKind || node.exportKind === 'value';

const fixImportSpecifier = (importSpecifier, {sourceCode}) => function * (fixer) {
const declaration = importSpecifier.parent;

yield * removeSpecifier(importSpecifier, fixer, sourceCode, /* keepDeclaration */ true);

const nameText = sourceCode.getText(importSpecifier.local);
const hasDefaultImport = declaration.specifiers.some(({type}) => type === 'ImportDefaultSpecifier');

// Insert a new `ImportDeclaration`
if (hasDefaultImport) {
const fromToken = sourceCode.getTokenBefore(declaration.source, token => token.type === 'Identifier' && token.value === 'from');
const text = `import ${nameText} ${sourceCode.text.slice(fromToken.range[0], declaration.range[1])}`;
yield fixer.insertTextBefore(declaration, `${text}\n`);

return;
}

const importToken = sourceCode.getFirstToken(declaration);
assertToken(importToken, {
expected: {type: 'Keyword', value: 'import'},
ruleId: 'no-named-default',
});

const shouldAddComma = declaration.specifiers.some(specifier => specifier !== importSpecifier && specifier.type === importSpecifier.type);
yield fixer.insertTextAfter(importToken, ` ${nameText}${shouldAddComma ? ',' : ''}`);
};

const fixExportSpecifier = (exportSpecifier, {sourceCode}) => function * (fixer) {
const declaration = exportSpecifier.parent;
yield * removeSpecifier(exportSpecifier, fixer, sourceCode);

const text = `export default ${sourceCode.getText(exportSpecifier.local)};`;
yield fixer.insertTextBefore(declaration, `${text}\n`);
};

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => ({
ImportSpecifier(specifier) {
if (!(
isValueImport(specifier)
&& specifier.imported.name === 'default'
&& isValueImport(specifier.parent)
)) {
return;
}

return {
node: specifier,
messageId: MESSAGE_ID,
data: {type: 'import'},
fix: fixImportSpecifier(specifier, context),
};
},
ExportSpecifier(specifier) {
if (!(
isValueExport(specifier)
&& specifier.exported.name === 'default'
&& isValueExport(specifier.parent)
&& !specifier.parent.source
)) {
return;
}

return {
node: specifier,
messageId: MESSAGE_ID,
data: {type: 'export'},
fix: fixExportSpecifier(specifier, context),
};
},
});

/** @type {import('eslint').Rule.RuleModule} */
const config = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow named usage of default import and export.',
recommended: true,
},
fixable: 'code',
messages,
},
};

export default config;
45 changes: 2 additions & 43 deletions rules/prefer-export-from.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {isCommaToken, isOpeningBraceToken, isClosingBraceToken} from '@eslint-community/eslint-utils';
import {isOpeningBraceToken} from '@eslint-community/eslint-utils';
import {isStringLiteral} from './ast/index.js';
import {removeSpecifier} from './fix/index.js';

const MESSAGE_ID_ERROR = 'error';
const MESSAGE_ID_SUGGESTION = 'suggestion';
Expand Down Expand Up @@ -29,48 +30,6 @@ const isTypeExport = specifier => specifier.exportKind === 'type' || specifier.p

const isTypeImport = specifier => specifier.importKind === 'type' || specifier.parent.importKind === 'type';

function * removeSpecifier(node, fixer, sourceCode) {
const {parent} = node;
const {specifiers} = parent;

if (specifiers.length === 1) {
yield * removeImportOrExport(parent, fixer, sourceCode);
return;
}

switch (node.type) {
case 'ImportSpecifier': {
const hasOtherSpecifiers = specifiers.some(specifier => specifier !== node && specifier.type === node.type);
if (!hasOtherSpecifiers) {
const closingBraceToken = sourceCode.getTokenAfter(node, isClosingBraceToken);

// If there are other specifiers, they have to be the default import specifier
// And the default import has to write before the named import specifiers
// So there must be a comma before
const commaToken = sourceCode.getTokenBefore(node, isCommaToken);
yield fixer.replaceTextRange([commaToken.range[0], closingBraceToken.range[1]], '');
return;
}
// Fallthrough
}

case 'ExportSpecifier':
case 'ImportNamespaceSpecifier':
case 'ImportDefaultSpecifier': {
yield fixer.remove(node);

const tokenAfter = sourceCode.getTokenAfter(node);
if (isCommaToken(tokenAfter)) {
yield fixer.remove(tokenAfter);
}

break;
}

// No default
}
}

function * removeImportOrExport(node, fixer, sourceCode) {
switch (node.type) {
case 'ImportSpecifier':
Expand Down
77 changes: 77 additions & 0 deletions test/no-named-default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import outdent from 'outdent';
import {getTester, parsers} from './utils/test.js';

const {test} = getTester(import.meta);

// Imports
test.snapshot({
valid: [
'import named from "foo";',
'import "foo";',
'import * as named from "foo";',
...[
'import type {default as named} from "foo";',
'import {type default as named} from "foo";',
].map(code => ({code, languageOptions: {parser: parsers.typescript}})),
],
invalid: [
'import {default as named} from "foo";',
'import {default as named,} from "foo";',
'import {default as named, bar} from "foo";',
'import {default as named, bar,} from "foo";',
'import defaultExport, {default as named} from "foo";',
'import defaultExport, {default as named,} from "foo";',
'import defaultExport, {default as named, bar} from "foo";',
'import defaultExport, {default as named, bar,} from "foo";',
'import{default as named}from"foo";',
'import {default as named}from"foo";',
'import{default as named} from"foo";',
'import{default as named,}from"foo";',
'import/*comment*/{default as named}from"foo";',
'import /*comment*/{default as named}from"foo";',
'import{default as named}/*comment*/from"foo";',
'import defaultExport,{default as named}from "foo";',
'import defaultExport, {default as named} from "foo" with {type: "json"};',
'import defaultExport, {default as named} from "foo" with {type: "json"}',
'import {default as named1, default as named2,} from "foo";',
],
});

// Exports
test.snapshot({
valid: [
'export {foo as default} from "foo";',
'export * as default from "foo";',
...[
'export type {foo as default};',
'export {type foo as default};',
].map(code => ({code, languageOptions: {parser: parsers.typescript}})),
],
invalid: [
...[
'export {foo as default};',
'export {foo as default,};',
'export {foo as default, bar};',
'export {foo as default, bar,};',
'export{foo as default};',
].map(code => outdent`
const foo = 1, bar = 2;
${code}
`),
// Invalid, but typescript allow
...[
'export{foo as default, bar as default};',
outdent`
export default foo;
export {foo as default};
`,
outdent`
export default bar;
export {foo as default};
`,
].map(code => ({
code,
languageOptions: {parser: parsers.typescript},
})),
],
});
1 change: 1 addition & 0 deletions test/package.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const RULES_WITHOUT_PASS_FAIL_SECTIONS = new Set([
'consistent-existence-index-check',
'prefer-global-this',
'no-instanceof-builtin-object',
'no-named-default',
'consistent-assert',
'no-accessor-recursion',
]);
Expand Down
Loading

0 comments on commit ed8da1b

Please sign in to comment.