Skip to content

Commit 81c34ab

Browse files
committed
[Fix]: no-duplicates with type imports
1 parent a257df9 commit 81c34ab

File tree

2 files changed

+553
-45
lines changed

2 files changed

+553
-45
lines changed

src/rules/no-duplicates.js

Lines changed: 217 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,59 @@ function checkImports(imported, context) {
2323
fix, // Attach the autofix (if any) to the first import.
2424
});
2525

26+
for (const node of rest) {
27+
context.report({
28+
node: node.source,
29+
message,
30+
});
31+
}
32+
33+
}
34+
}
35+
}
36+
37+
function checkTypeImports(imported, context) {
38+
for (const [module, nodes] of imported.entries()) {
39+
const typeImports = nodes.filter((node) => node.importKind === 'type');
40+
if (nodes.length > 1) {
41+
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));
42+
if (typeImports.length > 0 && someInlineTypeImports.length > 0) {
43+
const message = `'${module}' imported multiple times.`;
44+
const sourceCode = context.getSourceCode();
45+
const fix = getTypeFix(nodes, sourceCode, context);
46+
47+
const [first, ...rest] = nodes;
48+
context.report({
49+
node: first.source,
50+
message,
51+
fix, // Attach the autofix (if any) to the first import.
52+
});
53+
54+
for (const node of rest) {
55+
context.report({
56+
node: node.source,
57+
message,
58+
});
59+
}
60+
}
61+
}
62+
}
63+
}
64+
65+
function checkInlineTypeImports(imported, context) {
66+
for (const [module, nodes] of imported.entries()) {
67+
if (nodes.length > 1) {
68+
const message = `'${module}' imported multiple times.`;
69+
const sourceCode = context.getSourceCode();
70+
const fix = getInlineTypeFix(nodes, sourceCode);
71+
72+
const [first, ...rest] = nodes;
73+
context.report({
74+
node: first.source,
75+
message,
76+
fix, // Attach the autofix (if any) to the first import.
77+
});
78+
2679
for (const node of rest) {
2780
context.report({
2881
node: node.source,
@@ -33,7 +86,141 @@ function checkImports(imported, context) {
3386
}
3487
}
3588

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

116303
const [specifiersText] = specifiers.reduce(
117304
([result, needsComma, existingIdentifiers], specifier) => {
118-
const isTypeSpecifier = specifier.importNode.importKind === 'type';
119-
120-
const preferInline = context.options[0] && context.options[0]['prefer-inline'];
121-
// 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.
122-
if (preferInline && (!typescriptPkg || !semver.satisfies(typescriptPkg.version, '>= 4.5'))) {
123-
throw new Error('Your version of TypeScript does not support inline type imports.');
124-
}
125-
126305
// Add *only* the new identifiers that don't already exist, and track any new identifiers so we don't add them again in the next loop
127306
const [specifierText, updatedExistingIdentifiers] = specifier.identifiers.reduce(([text, set], cur) => {
128307
const trimmed = cur.trim(); // Trim whitespace before/after to compare to our set of existing identifiers
129-
const curWithType = trimmed.length > 0 && preferInline && isTypeSpecifier ? `type ${cur}` : cur;
130308
if (existingIdentifiers.has(trimmed)) {
131309
return [text, set];
132310
}
133-
return [text.length > 0 ? `${text},${curWithType}` : curWithType, set.add(trimmed)];
311+
return [text.length > 0 ? `${text},${cur}` : cur, set.add(trimmed)];
134312
}, ['', existingIdentifiers]);
135313

136314
return [
@@ -169,7 +347,7 @@ function getFix(first, rest, sourceCode, context) {
169347
// `import def from './foo'` → `import def, {...} from './foo'`
170348
fixes.push(fixer.insertTextAfter(first.specifiers[0], `, {${specifiersText}}`));
171349
}
172-
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
350+
} else if (!shouldAddDefault && openBrace != null && closeBrace != null && specifiersText) {
173351
// `import {...} './foo'` → `import {..., ...} from './foo'`
174352
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText));
175353
}
@@ -314,15 +492,18 @@ module.exports = {
314492
nsImported: new Map(),
315493
defaultTypesImported: new Map(),
316494
namedTypesImported: new Map(),
495+
inlineTypesImported: new Map(),
317496
});
318497
}
319498
const map = moduleMaps.get(n.parent);
320-
const preferInline = context.options[0] && context.options[0]['prefer-inline'];
321-
if (!preferInline && n.importKind === 'type') {
499+
if (n.importKind === 'type') {
500+
// import type Foo | import type { foo }
322501
return n.specifiers.length > 0 && n.specifiers[0].type === 'ImportDefaultSpecifier' ? map.defaultTypesImported : map.namedTypesImported;
323502
}
324-
if (!preferInline && n.specifiers.some((spec) => spec.importKind === 'type')) {
325-
return map.namedTypesImported;
503+
504+
if (n.specifiers.some((spec) => spec.importKind === 'type')) {
505+
// import { type foo }
506+
return map.inlineTypesImported;
326507
}
327508

328509
return hasNamespace(n) ? map.nsImported : map.imported;
@@ -347,6 +528,26 @@ module.exports = {
347528
checkImports(map.nsImported, context);
348529
checkImports(map.defaultTypesImported, context);
349530
checkImports(map.namedTypesImported, context);
531+
532+
const duplicatedImports = new Map([...map.inlineTypesImported]);
533+
map.imported.forEach((value, key) => {
534+
if (duplicatedImports.has(key)) {
535+
duplicatedImports.get(key).push(...value);
536+
} else {
537+
duplicatedImports.set(key, [value]);
538+
}
539+
});
540+
checkInlineTypeImports(duplicatedImports, context);
541+
542+
const duplicatedTypeImports = new Map([...map.inlineTypesImported]);
543+
map.namedTypesImported.forEach((value, key) => {
544+
if (duplicatedTypeImports.has(key)) {
545+
duplicatedTypeImports.get(key).push(...value);
546+
} else {
547+
duplicatedTypeImports.set(key, value);
548+
}
549+
});
550+
checkTypeImports(duplicatedTypeImports, context);
350551
}
351552
},
352553
};

0 commit comments

Comments
 (0)