Skip to content

Commit ab7a2d8

Browse files
committed
[Fix]: no-duplicates with type imports
1 parent 87a6096 commit ab7a2d8

File tree

3 files changed

+564
-54
lines changed

3 files changed

+564
-54
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
"watch": "npm run tests-only -- -- --watch",
2626
"pretest": "linklocal",
2727
"posttest": "eslint . && npm run update:eslint-docs -- --check",
28-
"mocha": "cross-env BABEL_ENV=test nyc mocha",
29-
"tests-only": "npm run mocha tests/src",
28+
"mocha": "cross-env BABEL_ENV=test nyc mocha --watch",
29+
"tests-only": "npm run mocha tests/src/rules/no-duplicates",
3030
"test": "npm run tests-only",
3131
"test-compiled": "npm run prepublish && BABEL_ENV=testCompiled mocha --compilers js:babel-register tests/src",
3232
"test-all": "node --require babel-register ./scripts/testAll",

src/rules/no-duplicates.js

Lines changed: 226 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,198 @@ function checkImports(imported, context) {
2727
message,
2828
});
2929
}
30+
31+
}
32+
}
33+
}
34+
35+
function checkTypeImports(imported, context) {
36+
for (const [module, nodes] of imported.entries()) {
37+
const typeImports = nodes.filter((node) => node.importKind === 'type');
38+
if (nodes.length > 1) {
39+
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));
40+
if (typeImports.length > 0 && someInlineTypeImports.length > 0) {
41+
const message = `'${module}' imported multiple times.`;
42+
const sourceCode = context.getSourceCode();
43+
const fix = getTypeFix(nodes, sourceCode, context);
44+
45+
const [first, ...rest] = nodes;
46+
context.report({
47+
node: first.source,
48+
message,
49+
fix, // Attach the autofix (if any) to the first import.
50+
});
51+
52+
for (const node of rest) {
53+
context.report({
54+
node: node.source,
55+
message,
56+
});
57+
}
58+
}
3059
}
3160
}
3261
}
3362

34-
function getFix(first, rest, sourceCode, context) {
63+
function checkInlineTypeImports(imported, context) {
64+
for (const [module, nodes] of imported.entries()) {
65+
if (nodes.length > 1) {
66+
const message = `'${module}' imported multiple times.`;
67+
const sourceCode = context.getSourceCode();
68+
const fix = getInlineTypeFix(nodes, sourceCode);
69+
70+
const [first, ...rest] = nodes;
71+
context.report({
72+
node: first.source,
73+
message,
74+
fix, // Attach the autofix (if any) to the first import.
75+
});
76+
77+
for (const node of rest) {
78+
context.report({
79+
node: node.source,
80+
message,
81+
});
82+
}
83+
}
84+
}
85+
}
86+
87+
function isComma(token) {
88+
return token.type === 'Punctuator' && token.value === ',';
89+
}
90+
91+
function getInlineTypeFix(nodes, sourceCode) {
92+
return fixer => {
93+
const fixes = [];
94+
95+
// if (!semver.satisfies(typescriptPkg.version, '>= 4.5')) {
96+
// throw new Error('Your version of TypeScript does not support inline type imports.');
97+
// }
98+
99+
// push to first import
100+
let [firstImport, ...rest] = nodes;
101+
const valueImport = nodes.find((n) => n.specifiers.every((spec) => spec.importKind === 'value')) || nodes.find((n) => n.specifiers.some((spec) => spec.type === 'ImportDefaultSpecifier'));
102+
if (valueImport) {
103+
firstImport = valueImport;
104+
rest = nodes.filter((n) => n !== firstImport);
105+
}
106+
107+
const nodeTokens = sourceCode.getTokens(firstImport);
108+
// we are moving the rest of the Type or Inline Type imports here.
109+
const nodeClosingBrace = nodeTokens.find(token => isPunctuator(token, '}'));
110+
// const preferInline = context.options[0] && context.options[0]['prefer-inline'];
111+
if (nodeClosingBrace) {
112+
for (const node of rest) {
113+
// these will be all Type imports, no Value specifiers
114+
// then add inline type specifiers to importKind === 'type' import
115+
for (const specifier of node.specifiers) {
116+
if (specifier.importKind === 'type') {
117+
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, type ${specifier.local.name}`));
118+
} else {
119+
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, ${specifier.local.name}`));
120+
}
121+
}
122+
123+
fixes.push(fixer.remove(node));
124+
}
125+
} else {
126+
// we have a default import only
127+
const defaultSpecifier = firstImport.specifiers.find((spec) => spec.type === 'ImportDefaultSpecifier');
128+
const inlineTypeImports = [];
129+
for (const node of rest) {
130+
// these will be all Type imports, no Value specifiers
131+
// then add inline type specifiers to importKind === 'type' import
132+
for (const specifier of node.specifiers) {
133+
if (specifier.importKind === 'type') {
134+
inlineTypeImports.push(`type ${specifier.local.name}`);
135+
} else {
136+
inlineTypeImports.push(specifier.local.name);
137+
}
138+
}
139+
140+
fixes.push(fixer.remove(node));
141+
}
142+
143+
fixes.push(fixer.insertTextAfter(defaultSpecifier, `, {${inlineTypeImports.join(', ')}}`));
144+
}
145+
146+
return fixes;
147+
};
148+
}
149+
150+
function getTypeFix(nodes, sourceCode, context) {
151+
return fixer => {
152+
const fixes = [];
153+
154+
const preferInline = context.options[0] && context.options[0]['prefer-inline'];
155+
156+
if (preferInline) {
157+
if (!semver.satisfies(typescriptPkg.version, '>= 4.5')) {
158+
throw new Error('Your version of TypeScript does not support inline type imports.');
159+
}
160+
161+
// collapse all type imports to the inline type import
162+
const typeImports = nodes.filter((node) => node.importKind === 'type');
163+
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));
164+
// push to first import
165+
const firstImport = someInlineTypeImports[0];
166+
167+
if (firstImport) {
168+
const nodeTokens = sourceCode.getTokens(firstImport);
169+
// we are moving the rest of the Type imports here
170+
const nodeClosingBrace = nodeTokens.find(token => isPunctuator(token, '}'));
171+
172+
for (const node of typeImports) {
173+
for (const specifier of node.specifiers) {
174+
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, type ${specifier.local.name}`));
175+
}
176+
177+
fixes.push(fixer.remove(node));
178+
}
179+
}
180+
} else {
181+
// move inline types to type imports
182+
const typeImports = nodes.filter((node) => node.importKind === 'type');
183+
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));
184+
185+
const firstImport = typeImports[0];
186+
187+
if (firstImport) {
188+
const nodeTokens = sourceCode.getTokens(firstImport);
189+
// we are moving the rest of the Type imports here
190+
const nodeClosingBrace = nodeTokens.find(token => isPunctuator(token, '}'));
191+
192+
for (const node of someInlineTypeImports) {
193+
for (const specifier of node.specifiers) {
194+
if (specifier.importKind === 'type') {
195+
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, ${specifier.local.name}`));
196+
}
197+
}
198+
199+
if (node.specifiers.every((spec) => spec.importKind === 'type')) {
200+
fixes.push(fixer.remove(node));
201+
} else {
202+
for (const specifier of node.specifiers) {
203+
if (specifier.importKind === 'type') {
204+
const maybeComma = sourceCode.getTokenAfter(specifier);
205+
if (isComma(maybeComma)) {
206+
fixes.push(fixer.remove(maybeComma));
207+
}
208+
// TODO: remove `type`?
209+
fixes.push(fixer.remove(specifier));
210+
}
211+
}
212+
}
213+
}
214+
}
215+
}
216+
217+
return fixes;
218+
};
219+
}
220+
221+
function getFix(first, rest, sourceCode) {
35222
// Sorry ESLint <= 3 users, no autofix for you. Autofixing duplicate imports
36223
// requires multiple `fixer.whatever()` calls in the `fix`: We both need to
37224
// update the first one, and remove the rest. Support for multiple
@@ -112,26 +299,18 @@ function getFix(first, rest, sourceCode, context) {
112299
isPunctuator(sourceCode.getTokenBefore(closeBrace), ',');
113300
const firstIsEmpty = !hasSpecifiers(first);
114301

115-
const [specifiersText] = specifiers.reduce(
116-
([result, needsComma], specifier) => {
117-
const isTypeSpecifier = specifier.importNode.importKind === 'type';
118-
119-
const preferInline = context.options[0] && context.options[0]['prefer-inline'];
120-
// a user might set prefer-inline but not have a supporting TypeScript version. Flow does not support inline types so this should fail in that case as well.
121-
if (preferInline && (!typescriptPkg || !semver.satisfies(typescriptPkg.version, '>= 4.5'))) {
122-
throw new Error('Your version of TypeScript does not support inline type imports.');
123-
}
124-
125-
const insertText = `${preferInline && isTypeSpecifier ? 'type ' : ''}${specifier.text}`;
126-
return [
127-
needsComma && !specifier.isEmpty
128-
? `${result},${insertText}`
129-
: `${result}${insertText}`,
130-
specifier.isEmpty ? needsComma : true,
131-
];
132-
},
133-
['', !firstHasTrailingComma && !firstIsEmpty],
134-
);
302+
const [specifiersText] = specifiers
303+
.reduce(
304+
([result, needsComma], specifier) => {
305+
return [
306+
needsComma && !specifier.isEmpty
307+
? `${result},${specifier.text}`
308+
: `${result}${specifier.text}`,
309+
specifier.isEmpty ? needsComma : true,
310+
];
311+
},
312+
['', !firstHasTrailingComma && !firstIsEmpty],
313+
);
135314

136315
const fixes = [];
137316

@@ -158,7 +337,7 @@ function getFix(first, rest, sourceCode, context) {
158337
// `import def from './foo'` → `import def, {...} from './foo'`
159338
fixes.push(fixer.insertTextAfter(first.specifiers[0], `, {${specifiersText}}`));
160339
}
161-
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
340+
} else if (!shouldAddDefault && openBrace != null && closeBrace != null && specifiersText) {
162341
// `import {...} './foo'` → `import {..., ...} from './foo'`
163342
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText));
164343
}
@@ -303,14 +482,18 @@ module.exports = {
303482
nsImported: new Map(),
304483
defaultTypesImported: new Map(),
305484
namedTypesImported: new Map(),
485+
inlineTypesImported: new Map(),
306486
});
307487
}
308488
const map = moduleMaps.get(n.parent);
309489
if (n.importKind === 'type') {
490+
// import type Foo | import type { foo }
310491
return n.specifiers.length > 0 && n.specifiers[0].type === 'ImportDefaultSpecifier' ? map.defaultTypesImported : map.namedTypesImported;
311492
}
493+
312494
if (n.specifiers.some((spec) => spec.importKind === 'type')) {
313-
return map.namedTypesImported;
495+
// import { type foo }
496+
return map.inlineTypesImported;
314497
}
315498

316499
return hasNamespace(n) ? map.nsImported : map.imported;
@@ -335,6 +518,26 @@ module.exports = {
335518
checkImports(map.nsImported, context);
336519
checkImports(map.defaultTypesImported, context);
337520
checkImports(map.namedTypesImported, context);
521+
522+
const duplicatedImports = new Map([...map.inlineTypesImported]);
523+
map.imported.forEach((value, key) => {
524+
if (duplicatedImports.has(key)) {
525+
duplicatedImports.get(key).push(...value);
526+
} else {
527+
duplicatedImports.set(key, [value]);
528+
}
529+
});
530+
checkInlineTypeImports(duplicatedImports, context);
531+
532+
const duplicatedTypeImports = new Map([...map.inlineTypesImported]);
533+
map.namedTypesImported.forEach((value, key) => {
534+
if (duplicatedTypeImports.has(key)) {
535+
duplicatedTypeImports.get(key).push(...value);
536+
} else {
537+
duplicatedTypeImports.set(key, value);
538+
}
539+
});
540+
checkTypeImports(duplicatedTypeImports, context);
338541
}
339542
},
340543
};

0 commit comments

Comments
 (0)