Skip to content

Commit ff43745

Browse files
authored
Add prefer-export-from rule (#1453)
1 parent 9c03a78 commit ff43745

File tree

8 files changed

+1688
-1
lines changed

8 files changed

+1688
-1
lines changed

configs/recommended.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ module.exports = {
6363
'unicorn/prefer-dom-node-dataset': 'error',
6464
'unicorn/prefer-dom-node-remove': 'error',
6565
'unicorn/prefer-dom-node-text-content': 'error',
66+
'unicorn/prefer-export-from': 'error',
6667
'unicorn/prefer-includes': 'error',
6768
'unicorn/prefer-keyboard-event-key': 'error',
6869
'unicorn/prefer-math-trunc': 'error',

docs/rules/prefer-export-from.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Prefer `export…from` when re-exporting
2+
3+
When re-exporting from a module, it's unnecessary to import and then export. It can be done in a single `export…from` declaration.
4+
5+
This rule is fixable.
6+
7+
## Fail
8+
9+
```js
10+
import defaultExport from './foo.js';
11+
export default defaultExport;
12+
```
13+
14+
```js
15+
import {named} from './foo.js';
16+
export {named};
17+
```
18+
19+
```js
20+
import * as namespace from './foo.js';
21+
export {namespace};
22+
```
23+
24+
```js
25+
import defaultExport, {named} from './foo.js';
26+
export default defaultExport;
27+
export {
28+
defaultExport as renamedDefault,
29+
named,
30+
named as renamedNamed,
31+
};
32+
```
33+
34+
## Pass
35+
36+
```js
37+
export {default} from './foo.js';
38+
```
39+
40+
```js
41+
export {named} from './foo.js';
42+
```
43+
44+
```js
45+
export * as namespace from './foo.js';
46+
```
47+
48+
```js
49+
export {
50+
default,
51+
default as renamedDefault,
52+
named,
53+
named as renamedNamed,
54+
} from './foo.js';
55+
```
56+
57+
```js
58+
// There is no substitution
59+
import * as namespace from './foo.js';
60+
export default namespace;
61+
```

readme.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ Configure it in `package.json`.
9595
"unicorn/prefer-dom-node-dataset": "error",
9696
"unicorn/prefer-dom-node-remove": "error",
9797
"unicorn/prefer-dom-node-text-content": "error",
98+
"unicorn/prefer-export-from": "error",
9899
"unicorn/prefer-includes": "error",
99100
"unicorn/prefer-keyboard-event-key": "error",
100101
"unicorn/prefer-math-trunc": "error",
@@ -216,6 +217,7 @@ Each rule has emojis denoting:
216217
| [prefer-dom-node-dataset](docs/rules/prefer-dom-node-dataset.md) | Prefer using `.dataset` on DOM elements over `.setAttribute(…)`. || 🔧 | |
217218
| [prefer-dom-node-remove](docs/rules/prefer-dom-node-remove.md) | Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`. || 🔧 | 💡 |
218219
| [prefer-dom-node-text-content](docs/rules/prefer-dom-node-text-content.md) | Prefer `.textContent` over `.innerText`. || | 💡 |
220+
| [prefer-export-from](docs/rules/prefer-export-from.md) | Prefer `export…from` when re-exporting. || 🔧 | |
219221
| [prefer-includes](docs/rules/prefer-includes.md) | Prefer `.includes()` over `.indexOf()` and `Array#some()` when checking for existence or non-existence. || 🔧 | 💡 |
220222
| [prefer-keyboard-event-key](docs/rules/prefer-keyboard-event-key.md) | Prefer `KeyboardEvent#key` over `KeyboardEvent#keyCode`. || 🔧 | |
221223
| [prefer-math-trunc](docs/rules/prefer-math-trunc.md) | Enforce the use of `Math.trunc` instead of bitwise operators. || 🔧 | 💡 |

rules/prefer-export-from.js

Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
'use strict';
2+
const {
3+
isCommaToken,
4+
isOpeningBraceToken,
5+
isClosingBraceToken,
6+
} = require('eslint-utils');
7+
8+
const MESSAGE_ID = 'prefer-export-from';
9+
const messages = {
10+
[MESSAGE_ID]: 'Use `export…from` to re-export `{{exported}}`.',
11+
};
12+
13+
function * removeSpecifier(node, fixer, sourceCode) {
14+
const {parent} = node;
15+
const {specifiers} = parent;
16+
17+
if (specifiers.length === 1) {
18+
yield * removeImportOrExport(parent, fixer, sourceCode);
19+
return;
20+
}
21+
22+
switch (node.type) {
23+
case 'ImportSpecifier': {
24+
const hasOtherSpecifiers = specifiers.some(specifier => specifier !== node && specifier.type === node.type);
25+
if (!hasOtherSpecifiers) {
26+
const closingBraceToken = sourceCode.getTokenAfter(node, isClosingBraceToken);
27+
28+
// If there are other specifiers, they have to be the default import specifier
29+
// And the default import has to write before the named import specifiers
30+
// So there must be a comma before
31+
const commaToken = sourceCode.getTokenBefore(node, isCommaToken);
32+
yield fixer.replaceTextRange([commaToken.range[0], closingBraceToken.range[1]], '');
33+
return;
34+
}
35+
// Fallthrough
36+
}
37+
38+
case 'ExportSpecifier':
39+
case 'ImportNamespaceSpecifier':
40+
case 'ImportDefaultSpecifier': {
41+
yield fixer.remove(node);
42+
43+
const tokenAfter = sourceCode.getTokenAfter(node);
44+
if (isCommaToken(tokenAfter)) {
45+
yield fixer.remove(tokenAfter);
46+
}
47+
48+
break;
49+
}
50+
51+
// No default
52+
}
53+
}
54+
55+
function * removeImportOrExport(node, fixer, sourceCode) {
56+
switch (node.type) {
57+
case 'ImportSpecifier':
58+
case 'ExportSpecifier':
59+
case 'ImportDefaultSpecifier':
60+
case 'ImportNamespaceSpecifier': {
61+
yield * removeSpecifier(node, fixer, sourceCode);
62+
return;
63+
}
64+
65+
case 'ImportDeclaration':
66+
case 'ExportDefaultDeclaration':
67+
case 'ExportNamedDeclaration': {
68+
yield fixer.remove(node);
69+
}
70+
71+
// No default
72+
}
73+
}
74+
75+
function fix({
76+
context,
77+
imported,
78+
exported,
79+
exportDeclarations,
80+
program,
81+
}) {
82+
const sourceCode = context.getSourceCode();
83+
const sourceNode = imported.declaration.source;
84+
const sourceValue = sourceNode.value;
85+
const sourceText = sourceCode.getText(sourceNode);
86+
const exportDeclaration = exportDeclarations.find(({source}) => source.value === sourceValue);
87+
88+
/** @param {import('eslint').Rule.RuleFixer} fixer */
89+
return function * (fixer) {
90+
if (imported.name === '*') {
91+
yield fixer.insertTextAfter(
92+
program,
93+
`\nexport * as ${exported.name} from ${sourceText};`,
94+
);
95+
} else {
96+
const specifier = exported.name === imported.name
97+
? exported.name
98+
: `${imported.name} as ${exported.name}`;
99+
100+
if (exportDeclaration) {
101+
const lastSpecifier = exportDeclaration.specifiers[exportDeclaration.specifiers.length - 1];
102+
103+
// `export {} from 'foo';`
104+
if (lastSpecifier) {
105+
yield fixer.insertTextAfter(lastSpecifier, `, ${specifier}`);
106+
} else {
107+
const openingBraceToken = sourceCode.getFirstToken(exportDeclaration, isOpeningBraceToken);
108+
yield fixer.insertTextAfter(openingBraceToken, specifier);
109+
}
110+
} else {
111+
yield fixer.insertTextAfter(
112+
program,
113+
`\nexport {${specifier}} from ${sourceText};`,
114+
);
115+
}
116+
}
117+
118+
if (imported.variable.references.length === 1) {
119+
yield * removeImportOrExport(imported.node, fixer, sourceCode);
120+
}
121+
122+
yield * removeImportOrExport(exported.node, fixer, sourceCode);
123+
};
124+
}
125+
126+
function getImportedName(specifier) {
127+
switch (specifier.type) {
128+
case 'ImportDefaultSpecifier':
129+
return 'default';
130+
131+
case 'ImportSpecifier':
132+
return specifier.imported.name;
133+
134+
case 'ImportNamespaceSpecifier':
135+
return '*';
136+
137+
// No default
138+
}
139+
}
140+
141+
function getExported(identifier, context) {
142+
const {parent} = identifier;
143+
switch (parent.type) {
144+
case 'ExportDefaultDeclaration':
145+
return {
146+
node: parent,
147+
name: 'default',
148+
};
149+
150+
case 'ExportSpecifier':
151+
return {
152+
node: parent,
153+
name: parent.exported.name,
154+
};
155+
156+
case 'VariableDeclarator': {
157+
if (
158+
parent.init === identifier
159+
&& parent.id.type === 'Identifier'
160+
&& parent.parent.type === 'VariableDeclaration'
161+
&& parent.parent.kind === 'const'
162+
&& parent.parent.declarations.length === 1
163+
&& parent.parent.declarations[0] === parent
164+
&& parent.parent.parent.type === 'ExportNamedDeclaration'
165+
&& isVariableUnused(parent, context)
166+
) {
167+
return {
168+
node: parent.parent.parent,
169+
name: parent.id.name,
170+
};
171+
}
172+
173+
break;
174+
}
175+
176+
// No default
177+
}
178+
}
179+
180+
function isVariableUnused(node, context) {
181+
const variables = context.getDeclaredVariables(node);
182+
183+
/* istanbul ignore next */
184+
if (variables.length !== 1) {
185+
return false;
186+
}
187+
188+
const [{identifiers, references}] = variables;
189+
return identifiers.length === 1
190+
&& identifiers[0] === node.id
191+
&& references.length === 1
192+
&& references[0].identifier === node.id;
193+
}
194+
195+
function * getProblems({
196+
context,
197+
variable,
198+
program,
199+
exportDeclarations,
200+
}) {
201+
const {identifiers, references} = variable;
202+
203+
if (identifiers.length !== 1 || references.length === 0) {
204+
return;
205+
}
206+
207+
const specifier = identifiers[0].parent;
208+
209+
const imported = {
210+
name: getImportedName(specifier),
211+
node: specifier,
212+
declaration: specifier.parent,
213+
variable,
214+
};
215+
216+
for (const {identifier} of references) {
217+
const exported = getExported(identifier, context);
218+
219+
if (!exported) {
220+
continue;
221+
}
222+
223+
/*
224+
There is no substitution for:
225+
226+
```js
227+
import * as foo from 'foo';
228+
export default foo;
229+
```
230+
*/
231+
if (imported.name === '*' && exported.name === 'default') {
232+
return;
233+
}
234+
235+
yield {
236+
node: exported.node,
237+
messageId: MESSAGE_ID,
238+
data: {
239+
exported: exported.name,
240+
},
241+
fix: fix({
242+
context,
243+
imported,
244+
exported,
245+
exportDeclarations,
246+
program,
247+
}),
248+
};
249+
}
250+
}
251+
252+
/** @param {import('eslint').Rule.RuleContext} context */
253+
function create(context) {
254+
const variables = [];
255+
const exportDeclarations = [];
256+
257+
return {
258+
'ImportDeclaration[specifiers.length>0]'(node) {
259+
variables.push(...context.getDeclaredVariables(node));
260+
},
261+
// `ExportAllDeclaration` and `ExportDefaultDeclaration` can't be reused
262+
'ExportNamedDeclaration[source.type="Literal"]'(node) {
263+
exportDeclarations.push(node);
264+
},
265+
* 'Program:exit'(program) {
266+
for (const variable of variables) {
267+
yield * getProblems({
268+
context,
269+
variable,
270+
exportDeclarations,
271+
program,
272+
});
273+
}
274+
},
275+
};
276+
}
277+
278+
module.exports = {
279+
create,
280+
meta: {
281+
type: 'suggestion',
282+
docs: {
283+
description: 'Prefer `export…from` when re-exporting.',
284+
},
285+
fixable: 'code',
286+
messages,
287+
},
288+
};

0 commit comments

Comments
 (0)