Skip to content

Commit 2d45869

Browse files
authored
perf(#103): make no-duplicates way faster (#104)
1 parent a184b20 commit 2d45869

File tree

3 files changed

+125
-96
lines changed

3 files changed

+125
-96
lines changed

.changeset/fifty-toes-report.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-import-x": patch
3+
---
4+
5+
Make `no-duplicates` way faster

src/rules/no-duplicates.ts

Lines changed: 102 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,22 @@ import type { PackageJson } from 'type-fest'
44

55
import type { RuleContext } from '../types'
66
import { createRule, resolve } from '../utils'
7+
import { lazy } from '../utils/lazy-value'
8+
9+
// 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.
10+
// pre-calculate if the TypeScript version is supported
11+
const isTypeScriptVersionSupportPreferInline = lazy(() => {
12+
let typescriptPkg: PackageJson | undefined
13+
14+
try {
15+
// eslint-disable-next-line import-x/no-extraneous-dependencies
16+
typescriptPkg = require('typescript/package.json') as PackageJson
17+
} catch {
18+
//
19+
}
720

8-
let typescriptPkg: PackageJson | undefined
9-
10-
try {
11-
// eslint-disable-next-line import-x/no-extraneous-dependencies
12-
typescriptPkg = require('typescript/package.json') as PackageJson
13-
} catch {
14-
//
15-
}
21+
return !typescriptPkg || !semver.satisfies(typescriptPkg.version!, '>= 4.5')
22+
})
1623

1724
type Options = {
1825
considerQueryString?: boolean
@@ -25,112 +32,109 @@ function checkImports(
2532
imported: Map<string, TSESTree.ImportDeclaration[]>,
2633
context: RuleContext<MessageId, [Options?]>,
2734
) {
28-
for (const [module, nodes] of imported.entries()) {
29-
if (nodes.length > 1) {
30-
const [first, ...rest] = nodes
31-
const { sourceCode } = context
32-
const fix = getFix(first, rest, sourceCode, context)
35+
// eslint-disable-next-line unicorn/no-array-for-each -- Map.forEach is faster than Map.entries
36+
imported.forEach((nodes, module) => {
37+
if (nodes.length <= 1) {
38+
// not enough imports, definitely not duplicates
39+
return
40+
}
3341

42+
for (let i = 0, len = nodes.length; i < len; i++) {
43+
const node = nodes[i]
3444
context.report({
35-
node: first.source,
45+
node: node.source,
3646
messageId: 'duplicate',
3747
data: {
3848
module,
3949
},
40-
fix, // Attach the autofix (if any) to the first import.
50+
// Attach the autofix (if any) to the first import only
51+
fix: i === 0 ? getFix(nodes, context.sourceCode, context) : null,
4152
})
42-
43-
for (const node of rest) {
44-
context.report({
45-
node: node.source,
46-
messageId: 'duplicate',
47-
data: {
48-
module,
49-
},
50-
})
51-
}
5253
}
53-
}
54+
})
5455
}
5556

5657
function getFix(
57-
first: TSESTree.ImportDeclaration,
58-
rest: TSESTree.ImportDeclaration[],
58+
nodes: TSESTree.ImportDeclaration[],
5959
sourceCode: TSESLint.SourceCode,
6060
context: RuleContext<MessageId, [Options?]>,
61-
) {
62-
// Sorry ESLint <= 3 users, no autofix for you. Autofixing duplicate imports
63-
// requires multiple `fixer.whatever()` calls in the `fix`: We both need to
64-
// update the first one, and remove the rest. Support for multiple
65-
// `fixer.whatever()` in a single `fix` was added in ESLint 4.1.
66-
// `sourceCode.getCommentsBefore` was added in 4.0, so that's an easy thing to
67-
// check for.
68-
if (typeof sourceCode.getCommentsBefore !== 'function') {
69-
return
70-
}
61+
): TSESLint.ReportFixFunction | null {
62+
const first = nodes[0]
7163

7264
// Adjusting the first import might make it multiline, which could break
7365
// `eslint-disable-next-line` comments and similar, so bail if the first
7466
// import has comments. Also, if the first import is `import * as ns from
7567
// './foo'` there's nothing we can do.
7668
if (hasProblematicComments(first, sourceCode) || hasNamespace(first)) {
77-
return
69+
return null
7870
}
7971

8072
const defaultImportNames = new Set(
81-
[first, ...rest].flatMap(x => getDefaultImportName(x) || []),
73+
nodes.flatMap(x => getDefaultImportName(x) || []),
8274
)
8375

8476
// Bail if there are multiple different default import names – it's up to the
8577
// user to choose which one to keep.
8678
if (defaultImportNames.size > 1) {
87-
return
79+
return null
8880
}
8981

82+
const rest = nodes.slice(1)
83+
9084
// Leave it to the user to handle comments. Also skip `import * as ns from
9185
// './foo'` imports, since they cannot be merged into another import.
92-
const restWithoutComments = rest.filter(
86+
const restWithoutCommentsAndNamespaces = rest.filter(
9387
node => !hasProblematicComments(node, sourceCode) && !hasNamespace(node),
9488
)
9589

96-
const specifiers = restWithoutComments
97-
.map(node => {
98-
const tokens = sourceCode.getTokens(node)
99-
const openBrace = tokens.find(token => isPunctuator(token, '{'))
100-
const closeBrace = tokens.find(token => isPunctuator(token, '}'))
101-
102-
if (openBrace == null || closeBrace == null) {
103-
return
104-
}
90+
const restWithoutCommentsAndNamespacesHasSpecifiers =
91+
restWithoutCommentsAndNamespaces.map(hasSpecifiers)
92+
93+
const specifiers = restWithoutCommentsAndNamespaces.reduce<
94+
Array<{
95+
importNode: TSESTree.ImportDeclaration
96+
identifiers: string[]
97+
isEmpty: boolean
98+
}>
99+
>((acc, node, nodeIndex) => {
100+
const tokens = sourceCode.getTokens(node)
101+
const openBrace = tokens.find(token => isPunctuator(token, '{'))
102+
const closeBrace = tokens.find(token => isPunctuator(token, '}'))
103+
104+
if (openBrace == null || closeBrace == null) {
105+
return acc
106+
}
105107

106-
return {
107-
importNode: node,
108-
identifiers: sourceCode.text
109-
.slice(openBrace.range[1], closeBrace.range[0])
110-
.split(','), // Split the text into separate identifiers (retaining any whitespace before or after)
111-
isEmpty: !hasSpecifiers(node),
112-
}
108+
acc.push({
109+
importNode: node,
110+
identifiers: sourceCode.text
111+
.slice(openBrace.range[1], closeBrace.range[0])
112+
.split(','), // Split the text into separate identifiers (retaining any whitespace before or after)
113+
isEmpty: !restWithoutCommentsAndNamespacesHasSpecifiers[nodeIndex],
113114
})
114-
.filter(Boolean)
115-
116-
const unnecessaryImports = restWithoutComments.filter(
117-
node =>
118-
!hasSpecifiers(node) &&
119-
!hasNamespace(node) &&
120-
!specifiers.some(
121-
specifier => 'importNode' in specifier && specifier.importNode === node,
122-
),
115+
116+
return acc
117+
}, [])
118+
119+
const unnecessaryImports = restWithoutCommentsAndNamespaces.filter(
120+
(node, nodeIndex) =>
121+
!restWithoutCommentsAndNamespacesHasSpecifiers[nodeIndex] &&
122+
!specifiers.some(specifier => specifier.importNode === node),
123123
)
124124

125-
const shouldAddDefault =
126-
getDefaultImportName(first) == null && defaultImportNames.size === 1
127125
const shouldAddSpecifiers = specifiers.length > 0
128126
const shouldRemoveUnnecessary = unnecessaryImports.length > 0
127+
const shouldAddDefault = lazy(
128+
() => getDefaultImportName(first) == null && defaultImportNames.size === 1,
129+
)
129130

130-
if (!(shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary)) {
131-
return
131+
if (!shouldAddSpecifiers && !shouldRemoveUnnecessary && !shouldAddDefault()) {
132+
return null
132133
}
133134

135+
// pre-caculate preferInline before actual fix function
136+
const preferInline = context.options[0] && context.options[0]['prefer-inline']
137+
134138
return (fixer: TSESLint.RuleFixer) => {
135139
const tokens = sourceCode.getTokens(first)
136140
const openBrace = tokens.find(token => isPunctuator(token, '{'))!
@@ -157,14 +161,7 @@ function getFix(
157161
'importNode' in specifier &&
158162
specifier.importNode.importKind === 'type'
159163

160-
const preferInline =
161-
context.options[0] && context.options[0]['prefer-inline']
162-
// 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.
163-
if (
164-
preferInline &&
165-
(!typescriptPkg ||
166-
!semver.satisfies(typescriptPkg.version!, '>= 4.5'))
167-
) {
164+
if (preferInline && isTypeScriptVersionSupportPreferInline()) {
168165
throw new Error(
169166
'Your version of TypeScript does not support inline type imports.',
170167
)
@@ -203,27 +200,35 @@ function getFix(
203200

204201
const fixes = []
205202

206-
if (shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
203+
if (openBrace == null && shouldAddSpecifiers && shouldAddDefault()) {
207204
// `import './foo'` → `import def, {...} from './foo'`
208205
fixes.push(
209206
fixer.insertTextAfter(
210207
firstToken,
211208
` ${defaultImportName}, {${specifiersText}} from`,
212209
),
213210
)
214-
} else if (shouldAddDefault && openBrace == null && !shouldAddSpecifiers) {
211+
} else if (
212+
openBrace == null &&
213+
!shouldAddSpecifiers &&
214+
shouldAddDefault()
215+
) {
215216
// `import './foo'` → `import def from './foo'`
216217
fixes.push(
217218
fixer.insertTextAfter(firstToken, ` ${defaultImportName} from`),
218219
)
219-
} else if (shouldAddDefault && openBrace != null && closeBrace != null) {
220+
} else if (openBrace != null && closeBrace != null && shouldAddDefault()) {
220221
// `import {...} from './foo'` → `import def, {...} from './foo'`
221222
fixes.push(fixer.insertTextAfter(firstToken, ` ${defaultImportName},`))
222223
if (shouldAddSpecifiers) {
223224
// `import def, {...} from './foo'` → `import def, {..., ...} from './foo'`
224225
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText))
225226
}
226-
} else if (!shouldAddDefault && openBrace == null && shouldAddSpecifiers) {
227+
} else if (
228+
openBrace == null &&
229+
shouldAddSpecifiers &&
230+
!shouldAddDefault()
231+
) {
227232
if (first.specifiers.length === 0) {
228233
// `import './foo'` → `import {...} from './foo'`
229234
fixes.push(
@@ -235,7 +240,7 @@ function getFix(
235240
fixer.insertTextAfter(first.specifiers[0], `, {${specifiersText}}`),
236241
)
237242
}
238-
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
243+
} else if (openBrace != null && closeBrace != null && !shouldAddDefault()) {
239244
// `import {...} './foo'` → `import {..., ...} from './foo'`
240245
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText))
241246
}
@@ -287,23 +292,19 @@ function getDefaultImportName(node: TSESTree.ImportDeclaration) {
287292
const defaultSpecifier = node.specifiers.find(
288293
specifier => specifier.type === 'ImportDefaultSpecifier',
289294
)
290-
return defaultSpecifier == null ? undefined : defaultSpecifier.local.name
295+
return defaultSpecifier?.local.name
291296
}
292297

293298
// Checks whether `node` has a namespace import.
294299
function hasNamespace(node: TSESTree.ImportDeclaration) {
295-
const specifiers = node.specifiers.filter(
300+
return node.specifiers.some(
296301
specifier => specifier.type === 'ImportNamespaceSpecifier',
297302
)
298-
return specifiers.length > 0
299303
}
300304

301305
// Checks whether `node` has any non-default specifiers.
302306
function hasSpecifiers(node: TSESTree.ImportDeclaration) {
303-
const specifiers = node.specifiers.filter(
304-
specifier => specifier.type === 'ImportSpecifier',
305-
)
306-
return specifiers.length > 0
307+
return node.specifiers.some(specifier => specifier.type === 'ImportSpecifier')
307308
}
308309

309310
// It's not obvious what the user wants to do with comments associated with
@@ -395,6 +396,8 @@ export = createRule<[Options?], MessageId>({
395396
},
396397
defaultOptions: [],
397398
create(context) {
399+
const preferInline = context.options[0]?.['prefer-inline']
400+
398401
// Prepare the resolver from options.
399402
const considerQueryStringOption = context.options[0]?.considerQueryString
400403
const defaultResolver = (sourcePath: string) =>
@@ -421,16 +424,19 @@ export = createRule<[Options?], MessageId>({
421424

422425
function getImportMap(n: TSESTree.ImportDeclaration) {
423426
const parent = n.parent!
424-
if (!moduleMaps.has(parent)) {
425-
moduleMaps.set(parent, {
427+
let map
428+
if (moduleMaps.has(parent)) {
429+
map = moduleMaps.get(parent)!
430+
} else {
431+
map = {
426432
imported: new Map(),
427433
nsImported: new Map(),
428434
defaultTypesImported: new Map(),
429435
namedTypesImported: new Map(),
430-
})
436+
}
437+
moduleMaps.set(parent, map)
431438
}
432-
const map = moduleMaps.get(parent)!
433-
const preferInline = context.options[0]?.['prefer-inline']
439+
434440
if (!preferInline && n.importKind === 'type') {
435441
return n.specifiers.length > 0 &&
436442
n.specifiers[0].type === 'ImportDefaultSpecifier'

src/utils/lazy-value.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* When a value is expensive to generate, w/ this utility you can delay the computation until the value is needed. And once the value is computed, it will be cached for future calls.
3+
*/
4+
export const lazy = <T>(cb: () => T): LazyValue<T> => {
5+
let isCalled = false
6+
let result: T | undefined
7+
8+
return (() => {
9+
if (!isCalled) {
10+
isCalled = true
11+
result = cb()
12+
}
13+
14+
return result
15+
}) as LazyValue<T>
16+
}
17+
18+
export type LazyValue<T> = () => Readonly<T>

0 commit comments

Comments
 (0)