Skip to content

Commit 2a8e152

Browse files
fiskersindresorhus
andauthored
Add require-module-specifiers rule (#2686)
Co-authored-by: Sindre Sorhus <[email protected]>
1 parent b051302 commit 2a8e152

File tree

7 files changed

+708
-0
lines changed

7 files changed

+708
-0
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Require non-empty specifier list in import and export statements
2+
3+
💼 This rule is enabled in the ✅ `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#recommended-config).
4+
5+
🔧💡 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) and manually fixable by [editor suggestions](https://eslint.org/docs/latest/use/core-concepts#rule-suggestions).
6+
7+
<!-- end auto-generated rule header -->
8+
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->
9+
10+
Enforce non-empty specifier list in `import` and `export` statements. Use a [side-effect import](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#import_a_module_for_its_side_effects_only) if needed, or remove the statement.
11+
12+
## Examples
13+
14+
```js
15+
//
16+
import {} from 'foo';
17+
18+
//
19+
import 'foo';
20+
```
21+
22+
```js
23+
//
24+
import foo, {} from 'foo';
25+
26+
//
27+
import foo from 'foo';
28+
```
29+
30+
```js
31+
//
32+
export {} from 'foo';
33+
34+
//
35+
import 'foo';
36+
```
37+
38+
```js
39+
//
40+
export {}
41+
```

readme.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export default [
182182
| [prevent-abbreviations](docs/rules/prevent-abbreviations.md) | Prevent abbreviations. || 🔧 | |
183183
| [relative-url-style](docs/rules/relative-url-style.md) | Enforce consistent relative URL style. || 🔧 | 💡 |
184184
| [require-array-join-separator](docs/rules/require-array-join-separator.md) | Enforce using the separator argument with `Array#join()`. || 🔧 | |
185+
| [require-module-specifiers](docs/rules/require-module-specifiers.md) | Require non-empty specifier list in import and export statements. || 🔧 | 💡 |
185186
| [require-number-to-fixed-digits-argument](docs/rules/require-number-to-fixed-digits-argument.md) | Enforce using the digits argument with `Number#toFixed()`. || 🔧 | |
186187
| [require-post-message-target-origin](docs/rules/require-post-message-target-origin.md) | Enforce using the `targetOrigin` argument with `window.postMessage()`. | | | 💡 |
187188
| [string-content](docs/rules/string-content.md) | Enforce better string content. | | 🔧 | 💡 |

rules/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export {default as 'prefer-type-error'} from './prefer-type-error.js';
126126
export {default as 'prevent-abbreviations'} from './prevent-abbreviations.js';
127127
export {default as 'relative-url-style'} from './relative-url-style.js';
128128
export {default as 'require-array-join-separator'} from './require-array-join-separator.js';
129+
export {default as 'require-module-specifiers'} from './require-module-specifiers.js';
129130
export {default as 'require-number-to-fixed-digits-argument'} from './require-number-to-fixed-digits-argument.js';
130131
export {default as 'require-post-message-target-origin'} from './require-post-message-target-origin.js';
131132
export {default as 'string-content'} from './string-content.js';

rules/require-module-specifiers.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import {isClosingBraceToken} from '@eslint-community/eslint-utils';
2+
3+
const MESSAGE_ID_ERROR = 'error';
4+
const MESSAGE_ID_SUGGESTION_REMOVE_DECLARATION = 'suggestion/remove-declaration';
5+
const MESSAGE_ID_SUGGESTION_TO_SIDE_EFFECT_IMPORT = 'suggestion/to-side-effect-import';
6+
const messages = {
7+
[MESSAGE_ID_ERROR]: '{{type}} statement without specifiers is not allowed.',
8+
[MESSAGE_ID_SUGGESTION_REMOVE_DECLARATION]: 'Remove this {{type}} statement.',
9+
[MESSAGE_ID_SUGGESTION_TO_SIDE_EFFECT_IMPORT]: 'Switch to side effect import.',
10+
};
11+
12+
const isFromToken = token => token.type === 'Identifier' && token.value === 'from';
13+
14+
/** @param {import('eslint').Rule.RuleContext} context */
15+
const create = context => {
16+
const {sourceCode} = context;
17+
18+
context.on('ImportDeclaration', importDeclaration => {
19+
const {specifiers} = importDeclaration;
20+
21+
if (specifiers.some(node => node.type === 'ImportSpecifier' || node.type === 'ImportNamespaceSpecifier')) {
22+
return;
23+
}
24+
25+
const {source, importKind} = importDeclaration;
26+
const fromToken = sourceCode.getTokenBefore(source);
27+
if (!isFromToken(fromToken)) {
28+
return;
29+
}
30+
31+
const closingBraceToken = sourceCode.getTokenBefore(fromToken);
32+
if (!isClosingBraceToken(closingBraceToken)) {
33+
return;
34+
}
35+
36+
const openingBraceToken = sourceCode.getTokenBefore(closingBraceToken);
37+
38+
const problem = {
39+
node: importDeclaration,
40+
loc: {
41+
start: sourceCode.getLoc(openingBraceToken).start,
42+
end: sourceCode.getLoc(closingBraceToken).end,
43+
},
44+
messageId: MESSAGE_ID_ERROR,
45+
data: {
46+
type: 'import',
47+
},
48+
};
49+
50+
// If there is a `ImportDefaultSpecifier`, it has to be the first.
51+
const importDefaultSpecifier = specifiers.length === 1 ? specifiers[0] : undefined;
52+
if (importKind === 'type' && !importDefaultSpecifier) {
53+
problem.fix = fixer => fixer.remove(importDeclaration);
54+
return problem;
55+
}
56+
57+
if (importDefaultSpecifier) {
58+
problem.fix = function * (fixer) {
59+
yield fixer.remove(closingBraceToken);
60+
yield fixer.remove(openingBraceToken);
61+
62+
const commaToken = sourceCode.getTokenBefore(openingBraceToken);
63+
yield fixer.remove(commaToken);
64+
65+
if (sourceCode.getRange(closingBraceToken)[1] === sourceCode.getRange(fromToken)[0]) {
66+
yield fixer.insertTextBefore(fromToken, ' ');
67+
}
68+
};
69+
70+
return problem;
71+
}
72+
73+
problem.suggest = [
74+
{
75+
messageId: MESSAGE_ID_SUGGESTION_REMOVE_DECLARATION,
76+
fix: fixer => fixer.remove(importDeclaration),
77+
},
78+
{
79+
messageId: MESSAGE_ID_SUGGESTION_TO_SIDE_EFFECT_IMPORT,
80+
* fix(fixer) {
81+
yield fixer.remove(openingBraceToken);
82+
yield fixer.remove(closingBraceToken);
83+
yield fixer.remove(fromToken);
84+
},
85+
},
86+
];
87+
88+
return problem;
89+
});
90+
91+
context.on('ExportNamedDeclaration', exportDeclaration => {
92+
const {specifiers, declaration} = exportDeclaration;
93+
94+
if (declaration || specifiers.length > 0) {
95+
return;
96+
}
97+
98+
const {source, exportKind} = exportDeclaration;
99+
const fromToken = source ? sourceCode.getTokenBefore(source) : undefined;
100+
const closingBraceToken = fromToken
101+
? sourceCode.getTokenBefore(fromToken)
102+
: sourceCode.getLastToken(exportDeclaration, isClosingBraceToken);
103+
const openingBraceToken = sourceCode.getTokenBefore(closingBraceToken);
104+
105+
const problem = {
106+
node: exportDeclaration,
107+
loc: {
108+
start: sourceCode.getLoc(openingBraceToken).start,
109+
end: sourceCode.getLoc(closingBraceToken).end,
110+
},
111+
messageId: MESSAGE_ID_ERROR,
112+
data: {
113+
type: 'export',
114+
},
115+
};
116+
117+
if (!source || exportKind === 'type') {
118+
problem.fix = fixer => fixer.remove(exportDeclaration);
119+
return problem;
120+
}
121+
122+
problem.suggest = [
123+
{
124+
messageId: MESSAGE_ID_SUGGESTION_REMOVE_DECLARATION,
125+
fix: fixer => fixer.remove(exportDeclaration),
126+
},
127+
{
128+
messageId: MESSAGE_ID_SUGGESTION_TO_SIDE_EFFECT_IMPORT,
129+
* fix(fixer) {
130+
const exportToken = sourceCode.getFirstToken(exportDeclaration);
131+
yield fixer.replaceText(exportToken, 'import');
132+
yield fixer.remove(openingBraceToken);
133+
yield fixer.remove(closingBraceToken);
134+
yield fixer.remove(fromToken);
135+
},
136+
},
137+
];
138+
139+
return problem;
140+
});
141+
};
142+
143+
/** @type {import('eslint').Rule.RuleModule} */
144+
const config = {
145+
create,
146+
meta: {
147+
type: 'suggestion',
148+
docs: {
149+
description: 'Require non-empty specifier list in import and export statements.',
150+
recommended: true,
151+
},
152+
fixable: 'code',
153+
hasSuggestions: true,
154+
messages,
155+
},
156+
};
157+
158+
export default config;

test/require-module-specifiers.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import outdent from 'outdent';
2+
import {getTester, parsers} from './utils/test.js';
3+
4+
const {test} = getTester(import.meta);
5+
6+
const typescriptCode = code => ({
7+
code,
8+
languageOptions: {parser: parsers.typescript},
9+
});
10+
11+
// `import`
12+
test.snapshot({
13+
valid: [
14+
'import "foo"',
15+
'import foo from "foo"',
16+
'import * as foo from "foo"',
17+
'import {foo} from "foo"',
18+
'import foo,{bar} from "foo"',
19+
typescriptCode('import type foo from "foo"'),
20+
typescriptCode('import * as foo from "foo"'),
21+
typescriptCode('import type foo,{bar} from "foo"'),
22+
typescriptCode('import foo,{type bar} from "foo"'),
23+
],
24+
invalid: [
25+
'import {} from "foo";',
26+
'import{}from"foo";',
27+
outdent`
28+
import {
29+
} from "foo";
30+
`,
31+
'import foo, {} from "foo";',
32+
'import foo,{}from "foo";',
33+
outdent`
34+
import foo, {
35+
} from "foo";
36+
`,
37+
'import foo,{}/* comment */from "foo";',
38+
typescriptCode('import type {} from "foo";'),
39+
typescriptCode('import type{}from"foo";'),
40+
typescriptCode('import type foo, {} from "foo";'),
41+
typescriptCode('import type foo,{}from "foo";'),
42+
],
43+
});
44+
45+
// `export`
46+
test.snapshot({
47+
valid: [
48+
outdent`
49+
const foo = 1;
50+
export {foo};
51+
`,
52+
'export {foo} from "foo"',
53+
'export * as foo from "foo"',
54+
typescriptCode('export type {Foo}'),
55+
typescriptCode('export type {foo} from "foo"'),
56+
typescriptCode('export type * as foo from "foo"'),
57+
'export const foo = 1',
58+
'export function foo() {}',
59+
'export class foo {}',
60+
typescriptCode('export type foo = Foo'),
61+
'export const {} = foo',
62+
'export const [] = foo',
63+
],
64+
invalid: [
65+
'export {}',
66+
typescriptCode('export type{}'),
67+
typescriptCode('export type {} from "foo";'),
68+
typescriptCode('declare export type {} from "foo";'),
69+
'export {} from "foo";',
70+
'export{}from"foo";',
71+
outdent`
72+
export {
73+
} from "foo";
74+
`,
75+
'export {} from "foo" with {type: "json"};',
76+
],
77+
});

0 commit comments

Comments
 (0)