Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
epidemian
GitHub Repository: epidemian/eslint-plugin-import
Path: blob/main/src/rules/no-duplicates.js
829 views
1
import resolve from 'eslint-module-utils/resolve';
2
import docsUrl from '../docsUrl';
3
4
function checkImports(imported, context) {
5
for (const [module, nodes] of imported.entries()) {
6
if (nodes.length > 1) {
7
const message = `'${module}' imported multiple times.`;
8
const [first, ...rest] = nodes;
9
const sourceCode = context.getSourceCode();
10
const fix = getFix(first, rest, sourceCode);
11
12
context.report({
13
node: first.source,
14
message,
15
fix, // Attach the autofix (if any) to the first import.
16
});
17
18
for (const node of rest) {
19
context.report({
20
node: node.source,
21
message,
22
});
23
}
24
}
25
}
26
}
27
28
function getFix(first, rest, sourceCode) {
29
// Sorry ESLint <= 3 users, no autofix for you. Autofixing duplicate imports
30
// requires multiple `fixer.whatever()` calls in the `fix`: We both need to
31
// update the first one, and remove the rest. Support for multiple
32
// `fixer.whatever()` in a single `fix` was added in ESLint 4.1.
33
// `sourceCode.getCommentsBefore` was added in 4.0, so that's an easy thing to
34
// check for.
35
if (typeof sourceCode.getCommentsBefore !== 'function') {
36
return undefined;
37
}
38
39
// Adjusting the first import might make it multiline, which could break
40
// `eslint-disable-next-line` comments and similar, so bail if the first
41
// import has comments. Also, if the first import is `import * as ns from
42
// './foo'` there's nothing we can do.
43
if (hasProblematicComments(first, sourceCode) || hasNamespace(first)) {
44
return undefined;
45
}
46
47
const defaultImportNames = new Set(
48
[first, ...rest].map(getDefaultImportName).filter(Boolean),
49
);
50
51
// Bail if there are multiple different default import names – it's up to the
52
// user to choose which one to keep.
53
if (defaultImportNames.size > 1) {
54
return undefined;
55
}
56
57
// Leave it to the user to handle comments. Also skip `import * as ns from
58
// './foo'` imports, since they cannot be merged into another import.
59
const restWithoutComments = rest.filter(node => !(
60
hasProblematicComments(node, sourceCode) ||
61
hasNamespace(node)
62
));
63
64
const specifiers = restWithoutComments
65
.map(node => {
66
const tokens = sourceCode.getTokens(node);
67
const openBrace = tokens.find(token => isPunctuator(token, '{'));
68
const closeBrace = tokens.find(token => isPunctuator(token, '}'));
69
70
if (openBrace == null || closeBrace == null) {
71
return undefined;
72
}
73
74
return {
75
importNode: node,
76
text: sourceCode.text.slice(openBrace.range[1], closeBrace.range[0]),
77
hasTrailingComma: isPunctuator(sourceCode.getTokenBefore(closeBrace), ','),
78
isEmpty: !hasSpecifiers(node),
79
};
80
})
81
.filter(Boolean);
82
83
const unnecessaryImports = restWithoutComments.filter(node =>
84
!hasSpecifiers(node) &&
85
!hasNamespace(node) &&
86
!specifiers.some(specifier => specifier.importNode === node),
87
);
88
89
const shouldAddDefault = getDefaultImportName(first) == null && defaultImportNames.size === 1;
90
const shouldAddSpecifiers = specifiers.length > 0;
91
const shouldRemoveUnnecessary = unnecessaryImports.length > 0;
92
93
if (!(shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary)) {
94
return undefined;
95
}
96
97
return fixer => {
98
const tokens = sourceCode.getTokens(first);
99
const openBrace = tokens.find(token => isPunctuator(token, '{'));
100
const closeBrace = tokens.find(token => isPunctuator(token, '}'));
101
const firstToken = sourceCode.getFirstToken(first);
102
const [defaultImportName] = defaultImportNames;
103
104
const firstHasTrailingComma =
105
closeBrace != null &&
106
isPunctuator(sourceCode.getTokenBefore(closeBrace), ',');
107
const firstIsEmpty = !hasSpecifiers(first);
108
109
const [specifiersText] = specifiers.reduce(
110
([result, needsComma], specifier) => {
111
return [
112
needsComma && !specifier.isEmpty
113
? `${result},${specifier.text}`
114
: `${result}${specifier.text}`,
115
specifier.isEmpty ? needsComma : true,
116
];
117
},
118
['', !firstHasTrailingComma && !firstIsEmpty],
119
);
120
121
const fixes = [];
122
123
if (shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
124
// `import './foo'` → `import def, {...} from './foo'`
125
fixes.push(
126
fixer.insertTextAfter(firstToken, ` ${defaultImportName}, {${specifiersText}} from`),
127
);
128
} else if (shouldAddDefault && openBrace == null && !shouldAddSpecifiers) {
129
// `import './foo'` → `import def from './foo'`
130
fixes.push(fixer.insertTextAfter(firstToken, ` ${defaultImportName} from`));
131
} else if (shouldAddDefault && openBrace != null && closeBrace != null) {
132
// `import {...} from './foo'` → `import def, {...} from './foo'`
133
fixes.push(fixer.insertTextAfter(firstToken, ` ${defaultImportName},`));
134
if (shouldAddSpecifiers) {
135
// `import def, {...} from './foo'` → `import def, {..., ...} from './foo'`
136
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText));
137
}
138
} else if (!shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
139
if (first.specifiers.length === 0) {
140
// `import './foo'` → `import {...} from './foo'`
141
fixes.push(fixer.insertTextAfter(firstToken, ` {${specifiersText}} from`));
142
} else {
143
// `import def from './foo'` → `import def, {...} from './foo'`
144
fixes.push(fixer.insertTextAfter(first.specifiers[0], `, {${specifiersText}}`));
145
}
146
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
147
// `import {...} './foo'` → `import {..., ...} from './foo'`
148
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText));
149
}
150
151
// Remove imports whose specifiers have been moved into the first import.
152
for (const specifier of specifiers) {
153
const importNode = specifier.importNode;
154
fixes.push(fixer.remove(importNode));
155
156
const charAfterImportRange = [importNode.range[1], importNode.range[1] + 1];
157
const charAfterImport = sourceCode.text.substring(charAfterImportRange[0], charAfterImportRange[1]);
158
if (charAfterImport === '\n') {
159
fixes.push(fixer.removeRange(charAfterImportRange));
160
}
161
}
162
163
// Remove imports whose default import has been moved to the first import,
164
// and side-effect-only imports that are unnecessary due to the first
165
// import.
166
for (const node of unnecessaryImports) {
167
fixes.push(fixer.remove(node));
168
169
const charAfterImportRange = [node.range[1], node.range[1] + 1];
170
const charAfterImport = sourceCode.text.substring(charAfterImportRange[0], charAfterImportRange[1]);
171
if (charAfterImport === '\n') {
172
fixes.push(fixer.removeRange(charAfterImportRange));
173
}
174
}
175
176
return fixes;
177
};
178
}
179
180
function isPunctuator(node, value) {
181
return node.type === 'Punctuator' && node.value === value;
182
}
183
184
// Get the name of the default import of `node`, if any.
185
function getDefaultImportName(node) {
186
const defaultSpecifier = node.specifiers
187
.find(specifier => specifier.type === 'ImportDefaultSpecifier');
188
return defaultSpecifier != null ? defaultSpecifier.local.name : undefined;
189
}
190
191
// Checks whether `node` has a namespace import.
192
function hasNamespace(node) {
193
const specifiers = node.specifiers
194
.filter(specifier => specifier.type === 'ImportNamespaceSpecifier');
195
return specifiers.length > 0;
196
}
197
198
// Checks whether `node` has any non-default specifiers.
199
function hasSpecifiers(node) {
200
const specifiers = node.specifiers
201
.filter(specifier => specifier.type === 'ImportSpecifier');
202
return specifiers.length > 0;
203
}
204
205
// It's not obvious what the user wants to do with comments associated with
206
// duplicate imports, so skip imports with comments when autofixing.
207
function hasProblematicComments(node, sourceCode) {
208
return (
209
hasCommentBefore(node, sourceCode) ||
210
hasCommentAfter(node, sourceCode) ||
211
hasCommentInsideNonSpecifiers(node, sourceCode)
212
);
213
}
214
215
// Checks whether `node` has a comment (that ends) on the previous line or on
216
// the same line as `node` (starts).
217
function hasCommentBefore(node, sourceCode) {
218
return sourceCode.getCommentsBefore(node)
219
.some(comment => comment.loc.end.line >= node.loc.start.line - 1);
220
}
221
222
// Checks whether `node` has a comment (that starts) on the same line as `node`
223
// (ends).
224
function hasCommentAfter(node, sourceCode) {
225
return sourceCode.getCommentsAfter(node)
226
.some(comment => comment.loc.start.line === node.loc.end.line);
227
}
228
229
// Checks whether `node` has any comments _inside,_ except inside the `{...}`
230
// part (if any).
231
function hasCommentInsideNonSpecifiers(node, sourceCode) {
232
const tokens = sourceCode.getTokens(node);
233
const openBraceIndex = tokens.findIndex(token => isPunctuator(token, '{'));
234
const closeBraceIndex = tokens.findIndex(token => isPunctuator(token, '}'));
235
// Slice away the first token, since we're no looking for comments _before_
236
// `node` (only inside). If there's a `{...}` part, look for comments before
237
// the `{`, but not before the `}` (hence the `+1`s).
238
const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0
239
? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1))
240
: tokens.slice(1);
241
return someTokens.some(token => sourceCode.getCommentsBefore(token).length > 0);
242
}
243
244
module.exports = {
245
meta: {
246
type: 'problem',
247
docs: {
248
url: docsUrl('no-duplicates'),
249
},
250
fixable: 'code',
251
schema: [
252
{
253
type: 'object',
254
properties: {
255
considerQueryString: {
256
type: 'boolean',
257
},
258
},
259
additionalProperties: false,
260
},
261
],
262
},
263
264
create(context) {
265
// Prepare the resolver from options.
266
const considerQueryStringOption = context.options[0] &&
267
context.options[0]['considerQueryString'];
268
const defaultResolver = sourcePath => resolve(sourcePath, context) || sourcePath;
269
const resolver = considerQueryStringOption ? (sourcePath => {
270
const parts = sourcePath.match(/^([^?]*)\?(.*)$/);
271
if (!parts) {
272
return defaultResolver(sourcePath);
273
}
274
return defaultResolver(parts[1]) + '?' + parts[2];
275
}) : defaultResolver;
276
277
const imported = new Map();
278
const nsImported = new Map();
279
const defaultTypesImported = new Map();
280
const namedTypesImported = new Map();
281
282
function getImportMap(n) {
283
if (n.importKind === 'type') {
284
return n.specifiers.length > 0 && n.specifiers[0].type === 'ImportDefaultSpecifier' ? defaultTypesImported : namedTypesImported;
285
}
286
287
return hasNamespace(n) ? nsImported : imported;
288
}
289
290
return {
291
ImportDeclaration(n) {
292
// resolved path will cover aliased duplicates
293
const resolvedPath = resolver(n.source.value);
294
const importMap = getImportMap(n);
295
296
if (importMap.has(resolvedPath)) {
297
importMap.get(resolvedPath).push(n);
298
} else {
299
importMap.set(resolvedPath, [n]);
300
}
301
},
302
303
'Program:exit': function () {
304
checkImports(imported, context);
305
checkImports(nsImported, context);
306
checkImports(defaultTypesImported, context);
307
checkImports(namedTypesImported, context);
308
},
309
};
310
},
311
};
312
313