Skip to content

Commit 8236e24

Browse files
committed
no-duplicates: Add autofix
1 parent af976b9 commit 8236e24

File tree

3 files changed

+404
-7
lines changed

3 files changed

+404
-7
lines changed

docs/rules/no-duplicates.md

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# import/no-duplicates
22

33
Reports if a resolved path is imported more than once.
4+
+(fixable) The `--fix` option on the [command line] automatically fixes some problems reported by this rule.
45

56
ESLint core has a similar rule ([`no-duplicate-imports`](http://eslint.org/docs/rules/no-duplicate-imports)), but this version
67
is different in two key ways:

src/rules/no-duplicates.js

+178-6
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,193 @@ import resolve from 'eslint-module-utils/resolve'
22
import docsUrl from '../docsUrl'
33

44
function checkImports(imported, context) {
5-
for (let [module, nodes] of imported.entries()) {
6-
if (nodes.size > 1) {
7-
for (let node of nodes) {
8-
context.report(node, `'${module}' imported multiple times.`)
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+
const defaultImportNames = new Set(
30+
[first, ...rest].map(getDefaultImportName).filter(Boolean)
31+
)
32+
33+
// Bail if there are multiple different default import names – it's up to the
34+
// user to choose which one to keep.
35+
if (defaultImportNames.size > 1) {
36+
return undefined
37+
}
38+
39+
// It's not obvious what the user wants to do with comments associated with
40+
// duplicate imports, so skip imports with comments when autofixing.
41+
const restWithoutComments = rest.filter(node => !(
42+
hasCommentBefore(node, sourceCode) ||
43+
hasCommentAfter(node, sourceCode) ||
44+
hasCommentInsideNonSpecifiers(node, sourceCode)
45+
))
46+
47+
const specifiers = restWithoutComments
48+
.map(node => {
49+
const tokens = sourceCode.getTokens(node)
50+
const openBrace = tokens.find(token => isPunctuator(token, '{'))
51+
const closeBrace = tokens.find(token => isPunctuator(token, '}'))
52+
53+
if (openBrace == null || closeBrace == null) {
54+
return undefined
55+
}
56+
57+
return {
58+
importNode: node,
59+
text: sourceCode.text.slice(openBrace.range[1], closeBrace.range[0]),
60+
hasTrailingComma: isPunctuator(sourceCode.getTokenBefore(closeBrace), ','),
61+
isEmpty: !hasSpecifiers(node),
62+
}
63+
})
64+
.filter(Boolean)
65+
66+
const unnecessaryImports = restWithoutComments.filter(node =>
67+
!hasSpecifiers(node) &&
68+
!specifiers.some(specifier => specifier.importNode === node)
69+
)
70+
71+
const shouldAddDefault = getDefaultImportName(first) == null && defaultImportNames.size === 1
72+
const shouldAddSpecifiers = specifiers.length > 0
73+
const shouldRemoveUnnecessary = unnecessaryImports.length > 0
74+
75+
if (!(shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary)) {
76+
return undefined
77+
}
78+
79+
return function* (fixer) {
80+
const tokens = sourceCode.getTokens(first)
81+
const openBrace = tokens.find(token => isPunctuator(token, '{'))
82+
const closeBrace = tokens.find(token => isPunctuator(token, '}'))
83+
const firstToken = sourceCode.getFirstToken(first)
84+
const [defaultImportName] = defaultImportNames
85+
86+
const firstHasTrailingComma =
87+
closeBrace != null &&
88+
isPunctuator(sourceCode.getTokenBefore(closeBrace), ',')
89+
const firstIsEmpty = !hasSpecifiers(first)
90+
91+
const [specifiersText] = specifiers.reduce(
92+
([result, needsComma], specifier) => {
93+
return [
94+
needsComma && !specifier.isEmpty
95+
? `${result},${specifier.text}`
96+
: `${result}${specifier.text}`,
97+
specifier.isEmpty ? needsComma : true,
98+
]
99+
},
100+
['', !firstHasTrailingComma && !firstIsEmpty]
101+
)
102+
103+
if (shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
104+
// `import './foo'` → `import def, {...} from './foo'`
105+
yield fixer.insertTextAfter(firstToken, ` ${defaultImportName}, {${specifiersText}} from`)
106+
} else if (shouldAddDefault && openBrace == null && !shouldAddSpecifiers) {
107+
// `import './foo'` → `import def from './foo'`
108+
yield fixer.insertTextAfter(firstToken, ` ${defaultImportName} from`)
109+
} else if (shouldAddDefault && openBrace != null && closeBrace != null) {
110+
// `import {...} from './foo'` → `import def, {...} from './foo'`
111+
yield fixer.insertTextAfter(firstToken, ` ${defaultImportName},`)
112+
if (shouldAddSpecifiers) {
113+
// `import def, {...} from './foo'` → `import def, {..., ...} from './foo'`
114+
yield fixer.insertTextBefore(closeBrace, specifiersText)
9115
}
116+
} else if (!shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
117+
// `import './foo'` → `import {...} from './foo'`
118+
yield fixer.insertTextAfter(firstToken, ` {${specifiersText}} from`)
119+
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
120+
// `import {...} './foo'` → `import {..., ...} from './foo'`
121+
yield fixer.insertTextBefore(closeBrace, specifiersText)
122+
}
123+
124+
// Remove imports whose specifiers have been moved into the first import.
125+
for (const specifier of specifiers) {
126+
yield fixer.remove(specifier.importNode)
127+
}
128+
129+
// Remove imports whose default import has been moved to the first import,
130+
// and side-effect-only imports that are unnecessary due to the first
131+
// import.
132+
for (const node of unnecessaryImports) {
133+
yield fixer.remove(node)
10134
}
11135
}
12136
}
13137

138+
function isPunctuator(node, value) {
139+
return node.type === 'Punctuator' && node.value === value
140+
}
141+
142+
// Get the name of the default import of `node`, if any.
143+
function getDefaultImportName(node) {
144+
const defaultSpecifier = node.specifiers
145+
.find(specifier => specifier.type === 'ImportDefaultSpecifier')
146+
return defaultSpecifier != null ? defaultSpecifier.local.name : undefined
147+
}
148+
149+
// Checks whether `node` has any non-default specifiers.
150+
function hasSpecifiers(node) {
151+
const specifiers = node.specifiers
152+
.filter(specifier => specifier.type === 'ImportSpecifier')
153+
return specifiers.length > 0
154+
}
155+
156+
// Checks whether `node` has a comment (that ends) on the previous line or on
157+
// the same line as `node` (starts).
158+
function hasCommentBefore(node, sourceCode) {
159+
return sourceCode.getCommentsBefore(node)
160+
.some(comment => comment.loc.end.line >= node.loc.start.line - 1)
161+
}
162+
163+
// Checks whether `node` has a comment (that starts) on the same line as `node`
164+
// (ends).
165+
function hasCommentAfter(node, sourceCode) {
166+
return sourceCode.getCommentsAfter(node)
167+
.some(comment => comment.loc.start.line === node.loc.end.line)
168+
}
169+
170+
// Checks whether `node` has any comments _inside,_ except inside the `{...}`
171+
// part (if any).
172+
function hasCommentInsideNonSpecifiers(node, sourceCode) {
173+
const tokens = sourceCode.getTokens(node)
174+
const openBraceIndex = tokens.findIndex(token => isPunctuator(token, '{'))
175+
const closeBraceIndex = tokens.findIndex(token => isPunctuator(token, '}'))
176+
// Slice away the first token, since we're no looking for comments _before_
177+
// `node` (only inside). If there's a `{...}` part, look for comments before
178+
// the `{`, but not before the `}` (hence the `+1`s).
179+
const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0
180+
? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1))
181+
: tokens.slice(1)
182+
return someTokens.some(token => sourceCode.getCommentsBefore(token).length > 0)
183+
}
184+
14185
module.exports = {
15186
meta: {
16187
type: 'problem',
17188
docs: {
18189
url: docsUrl('no-duplicates'),
19190
},
191+
fixable: 'code',
20192
},
21193

22194
create: function (context) {
@@ -29,9 +201,9 @@ module.exports = {
29201
const importMap = n.importKind === 'type' ? typesImported : imported
30202

31203
if (importMap.has(resolvedPath)) {
32-
importMap.get(resolvedPath).add(n.source)
204+
importMap.get(resolvedPath).push(n)
33205
} else {
34-
importMap.set(resolvedPath, new Set([n.source]))
206+
importMap.set(resolvedPath, [n])
35207
}
36208
},
37209

0 commit comments

Comments
 (0)