Skip to content

Commit

Permalink
no-duplicates: Add autofix
Browse files Browse the repository at this point in the history
  • Loading branch information
lydell committed Mar 30, 2019
1 parent af976b9 commit 8236e24
Show file tree
Hide file tree
Showing 3 changed files with 404 additions and 7 deletions.
1 change: 1 addition & 0 deletions docs/rules/no-duplicates.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# import/no-duplicates

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

ESLint core has a similar rule ([`no-duplicate-imports`](http://eslint.org/docs/rules/no-duplicate-imports)), but this version
is different in two key ways:
Expand Down
184 changes: 178 additions & 6 deletions src/rules/no-duplicates.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,193 @@ import resolve from 'eslint-module-utils/resolve'
import docsUrl from '../docsUrl'

function checkImports(imported, context) {
for (let [module, nodes] of imported.entries()) {
if (nodes.size > 1) {
for (let node of nodes) {
context.report(node, `'${module}' imported multiple times.`)
for (const [module, nodes] of imported.entries()) {
if (nodes.length > 1) {
const message = `'${module}' imported multiple times.`
const [first, ...rest] = nodes
const sourceCode = context.getSourceCode()
const fix = getFix(first, rest, sourceCode)

context.report({
node: first.source,
message,
fix, // Attach the autofix (if any) to the first import.
})

for (const node of rest) {
context.report({
node: node.source,
message,
})
}
}
}
}

function getFix(first, rest, sourceCode) {
const defaultImportNames = new Set(
[first, ...rest].map(getDefaultImportName).filter(Boolean)
)

// Bail if there are multiple different default import names – it's up to the
// user to choose which one to keep.
if (defaultImportNames.size > 1) {
return undefined
}

// It's not obvious what the user wants to do with comments associated with
// duplicate imports, so skip imports with comments when autofixing.
const restWithoutComments = rest.filter(node => !(
hasCommentBefore(node, sourceCode) ||
hasCommentAfter(node, sourceCode) ||
hasCommentInsideNonSpecifiers(node, sourceCode)
))

const specifiers = restWithoutComments
.map(node => {
const tokens = sourceCode.getTokens(node)
const openBrace = tokens.find(token => isPunctuator(token, '{'))
const closeBrace = tokens.find(token => isPunctuator(token, '}'))

if (openBrace == null || closeBrace == null) {
return undefined
}

return {
importNode: node,
text: sourceCode.text.slice(openBrace.range[1], closeBrace.range[0]),
hasTrailingComma: isPunctuator(sourceCode.getTokenBefore(closeBrace), ','),
isEmpty: !hasSpecifiers(node),
}
})
.filter(Boolean)

const unnecessaryImports = restWithoutComments.filter(node =>
!hasSpecifiers(node) &&
!specifiers.some(specifier => specifier.importNode === node)
)

const shouldAddDefault = getDefaultImportName(first) == null && defaultImportNames.size === 1
const shouldAddSpecifiers = specifiers.length > 0
const shouldRemoveUnnecessary = unnecessaryImports.length > 0

if (!(shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary)) {
return undefined
}

return function* (fixer) {
const tokens = sourceCode.getTokens(first)
const openBrace = tokens.find(token => isPunctuator(token, '{'))
const closeBrace = tokens.find(token => isPunctuator(token, '}'))
const firstToken = sourceCode.getFirstToken(first)
const [defaultImportName] = defaultImportNames

const firstHasTrailingComma =
closeBrace != null &&
isPunctuator(sourceCode.getTokenBefore(closeBrace), ',')
const firstIsEmpty = !hasSpecifiers(first)

const [specifiersText] = specifiers.reduce(
([result, needsComma], specifier) => {
return [
needsComma && !specifier.isEmpty
? `${result},${specifier.text}`
: `${result}${specifier.text}`,
specifier.isEmpty ? needsComma : true,
]
},
['', !firstHasTrailingComma && !firstIsEmpty]
)

if (shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
// `import './foo'` → `import def, {...} from './foo'`
yield fixer.insertTextAfter(firstToken, ` ${defaultImportName}, {${specifiersText}} from`)
} else if (shouldAddDefault && openBrace == null && !shouldAddSpecifiers) {
// `import './foo'` → `import def from './foo'`
yield fixer.insertTextAfter(firstToken, ` ${defaultImportName} from`)
} else if (shouldAddDefault && openBrace != null && closeBrace != null) {
// `import {...} from './foo'` → `import def, {...} from './foo'`
yield fixer.insertTextAfter(firstToken, ` ${defaultImportName},`)
if (shouldAddSpecifiers) {
// `import def, {...} from './foo'` → `import def, {..., ...} from './foo'`
yield fixer.insertTextBefore(closeBrace, specifiersText)
}
} else if (!shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
// `import './foo'` → `import {...} from './foo'`
yield fixer.insertTextAfter(firstToken, ` {${specifiersText}} from`)
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
// `import {...} './foo'` → `import {..., ...} from './foo'`
yield fixer.insertTextBefore(closeBrace, specifiersText)
}

// Remove imports whose specifiers have been moved into the first import.
for (const specifier of specifiers) {
yield fixer.remove(specifier.importNode)
}

// Remove imports whose default import has been moved to the first import,
// and side-effect-only imports that are unnecessary due to the first
// import.
for (const node of unnecessaryImports) {
yield fixer.remove(node)
}
}
}

function isPunctuator(node, value) {
return node.type === 'Punctuator' && node.value === value
}

// Get the name of the default import of `node`, if any.
function getDefaultImportName(node) {
const defaultSpecifier = node.specifiers
.find(specifier => specifier.type === 'ImportDefaultSpecifier')
return defaultSpecifier != null ? defaultSpecifier.local.name : undefined
}

// Checks whether `node` has any non-default specifiers.
function hasSpecifiers(node) {
const specifiers = node.specifiers
.filter(specifier => specifier.type === 'ImportSpecifier')
return specifiers.length > 0
}

// Checks whether `node` has a comment (that ends) on the previous line or on
// the same line as `node` (starts).
function hasCommentBefore(node, sourceCode) {
return sourceCode.getCommentsBefore(node)
.some(comment => comment.loc.end.line >= node.loc.start.line - 1)
}

// Checks whether `node` has a comment (that starts) on the same line as `node`
// (ends).
function hasCommentAfter(node, sourceCode) {
return sourceCode.getCommentsAfter(node)
.some(comment => comment.loc.start.line === node.loc.end.line)
}

// Checks whether `node` has any comments _inside,_ except inside the `{...}`
// part (if any).
function hasCommentInsideNonSpecifiers(node, sourceCode) {
const tokens = sourceCode.getTokens(node)
const openBraceIndex = tokens.findIndex(token => isPunctuator(token, '{'))
const closeBraceIndex = tokens.findIndex(token => isPunctuator(token, '}'))
// Slice away the first token, since we're no looking for comments _before_
// `node` (only inside). If there's a `{...}` part, look for comments before
// the `{`, but not before the `}` (hence the `+1`s).
const someTokens = openBraceIndex >= 0 && closeBraceIndex >= 0
? tokens.slice(1, openBraceIndex + 1).concat(tokens.slice(closeBraceIndex + 1))
: tokens.slice(1)
return someTokens.some(token => sourceCode.getCommentsBefore(token).length > 0)
}

module.exports = {
meta: {
type: 'problem',
docs: {
url: docsUrl('no-duplicates'),
},
fixable: 'code',
},

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

if (importMap.has(resolvedPath)) {
importMap.get(resolvedPath).add(n.source)
importMap.get(resolvedPath).push(n)
} else {
importMap.set(resolvedPath, new Set([n.source]))
importMap.set(resolvedPath, [n])
}
},

Expand Down
Loading

0 comments on commit 8236e24

Please sign in to comment.