@@ -4,15 +4,22 @@ import type { PackageJson } from 'type-fest'
4
4
5
5
import type { RuleContext } from '../types'
6
6
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
+ }
7
20
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
+ } )
16
23
17
24
type Options = {
18
25
considerQueryString ?: boolean
@@ -25,112 +32,109 @@ function checkImports(
25
32
imported : Map < string , TSESTree . ImportDeclaration [ ] > ,
26
33
context : RuleContext < MessageId , [ Options ?] > ,
27
34
) {
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
+ }
33
41
42
+ for ( let i = 0 , len = nodes . length ; i < len ; i ++ ) {
43
+ const node = nodes [ i ]
34
44
context . report ( {
35
- node : first . source ,
45
+ node : node . source ,
36
46
messageId : 'duplicate' ,
37
47
data : {
38
48
module,
39
49
} ,
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 ,
41
52
} )
42
-
43
- for ( const node of rest ) {
44
- context . report ( {
45
- node : node . source ,
46
- messageId : 'duplicate' ,
47
- data : {
48
- module,
49
- } ,
50
- } )
51
- }
52
53
}
53
- }
54
+ } )
54
55
}
55
56
56
57
function getFix (
57
- first : TSESTree . ImportDeclaration ,
58
- rest : TSESTree . ImportDeclaration [ ] ,
58
+ nodes : TSESTree . ImportDeclaration [ ] ,
59
59
sourceCode : TSESLint . SourceCode ,
60
60
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 ]
71
63
72
64
// Adjusting the first import might make it multiline, which could break
73
65
// `eslint-disable-next-line` comments and similar, so bail if the first
74
66
// import has comments. Also, if the first import is `import * as ns from
75
67
// './foo'` there's nothing we can do.
76
68
if ( hasProblematicComments ( first , sourceCode ) || hasNamespace ( first ) ) {
77
- return
69
+ return null
78
70
}
79
71
80
72
const defaultImportNames = new Set (
81
- [ first , ... rest ] . flatMap ( x => getDefaultImportName ( x ) || [ ] ) ,
73
+ nodes . flatMap ( x => getDefaultImportName ( x ) || [ ] ) ,
82
74
)
83
75
84
76
// Bail if there are multiple different default import names – it's up to the
85
77
// user to choose which one to keep.
86
78
if ( defaultImportNames . size > 1 ) {
87
- return
79
+ return null
88
80
}
89
81
82
+ const rest = nodes . slice ( 1 )
83
+
90
84
// Leave it to the user to handle comments. Also skip `import * as ns from
91
85
// './foo'` imports, since they cannot be merged into another import.
92
- const restWithoutComments = rest . filter (
86
+ const restWithoutCommentsAndNamespaces = rest . filter (
93
87
node => ! hasProblematicComments ( node , sourceCode ) && ! hasNamespace ( node ) ,
94
88
)
95
89
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
+ }
105
107
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 ] ,
113
114
} )
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 ) ,
123
123
)
124
124
125
- const shouldAddDefault =
126
- getDefaultImportName ( first ) == null && defaultImportNames . size === 1
127
125
const shouldAddSpecifiers = specifiers . length > 0
128
126
const shouldRemoveUnnecessary = unnecessaryImports . length > 0
127
+ const shouldAddDefault = lazy (
128
+ ( ) => getDefaultImportName ( first ) == null && defaultImportNames . size === 1 ,
129
+ )
129
130
130
- if ( ! ( shouldAddDefault || shouldAddSpecifiers || shouldRemoveUnnecessary ) ) {
131
- return
131
+ if ( ! shouldAddSpecifiers && ! shouldRemoveUnnecessary && ! shouldAddDefault ( ) ) {
132
+ return null
132
133
}
133
134
135
+ // pre-caculate preferInline before actual fix function
136
+ const preferInline = context . options [ 0 ] && context . options [ 0 ] [ 'prefer-inline' ]
137
+
134
138
return ( fixer : TSESLint . RuleFixer ) => {
135
139
const tokens = sourceCode . getTokens ( first )
136
140
const openBrace = tokens . find ( token => isPunctuator ( token , '{' ) ) !
@@ -157,14 +161,7 @@ function getFix(
157
161
'importNode' in specifier &&
158
162
specifier . importNode . importKind === 'type'
159
163
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 ( ) ) {
168
165
throw new Error (
169
166
'Your version of TypeScript does not support inline type imports.' ,
170
167
)
@@ -203,27 +200,35 @@ function getFix(
203
200
204
201
const fixes = [ ]
205
202
206
- if ( shouldAddDefault && openBrace == null && shouldAddSpecifiers ) {
203
+ if ( openBrace == null && shouldAddSpecifiers && shouldAddDefault ( ) ) {
207
204
// `import './foo'` → `import def, {...} from './foo'`
208
205
fixes . push (
209
206
fixer . insertTextAfter (
210
207
firstToken ,
211
208
` ${ defaultImportName } , {${ specifiersText } } from` ,
212
209
) ,
213
210
)
214
- } else if ( shouldAddDefault && openBrace == null && ! shouldAddSpecifiers ) {
211
+ } else if (
212
+ openBrace == null &&
213
+ ! shouldAddSpecifiers &&
214
+ shouldAddDefault ( )
215
+ ) {
215
216
// `import './foo'` → `import def from './foo'`
216
217
fixes . push (
217
218
fixer . insertTextAfter ( firstToken , ` ${ defaultImportName } from` ) ,
218
219
)
219
- } else if ( shouldAddDefault && openBrace != null && closeBrace != null ) {
220
+ } else if ( openBrace != null && closeBrace != null && shouldAddDefault ( ) ) {
220
221
// `import {...} from './foo'` → `import def, {...} from './foo'`
221
222
fixes . push ( fixer . insertTextAfter ( firstToken , ` ${ defaultImportName } ,` ) )
222
223
if ( shouldAddSpecifiers ) {
223
224
// `import def, {...} from './foo'` → `import def, {..., ...} from './foo'`
224
225
fixes . push ( fixer . insertTextBefore ( closeBrace , specifiersText ) )
225
226
}
226
- } else if ( ! shouldAddDefault && openBrace == null && shouldAddSpecifiers ) {
227
+ } else if (
228
+ openBrace == null &&
229
+ shouldAddSpecifiers &&
230
+ ! shouldAddDefault ( )
231
+ ) {
227
232
if ( first . specifiers . length === 0 ) {
228
233
// `import './foo'` → `import {...} from './foo'`
229
234
fixes . push (
@@ -235,7 +240,7 @@ function getFix(
235
240
fixer . insertTextAfter ( first . specifiers [ 0 ] , `, {${ specifiersText } }` ) ,
236
241
)
237
242
}
238
- } else if ( ! shouldAddDefault && openBrace != null && closeBrace != null ) {
243
+ } else if ( openBrace != null && closeBrace != null && ! shouldAddDefault ( ) ) {
239
244
// `import {...} './foo'` → `import {..., ...} from './foo'`
240
245
fixes . push ( fixer . insertTextBefore ( closeBrace , specifiersText ) )
241
246
}
@@ -287,23 +292,19 @@ function getDefaultImportName(node: TSESTree.ImportDeclaration) {
287
292
const defaultSpecifier = node . specifiers . find (
288
293
specifier => specifier . type === 'ImportDefaultSpecifier' ,
289
294
)
290
- return defaultSpecifier == null ? undefined : defaultSpecifier . local . name
295
+ return defaultSpecifier ? .local . name
291
296
}
292
297
293
298
// Checks whether `node` has a namespace import.
294
299
function hasNamespace ( node : TSESTree . ImportDeclaration ) {
295
- const specifiers = node . specifiers . filter (
300
+ return node . specifiers . some (
296
301
specifier => specifier . type === 'ImportNamespaceSpecifier' ,
297
302
)
298
- return specifiers . length > 0
299
303
}
300
304
301
305
// Checks whether `node` has any non-default specifiers.
302
306
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' )
307
308
}
308
309
309
310
// It's not obvious what the user wants to do with comments associated with
@@ -395,6 +396,8 @@ export = createRule<[Options?], MessageId>({
395
396
} ,
396
397
defaultOptions : [ ] ,
397
398
create ( context ) {
399
+ const preferInline = context . options [ 0 ] ?. [ 'prefer-inline' ]
400
+
398
401
// Prepare the resolver from options.
399
402
const considerQueryStringOption = context . options [ 0 ] ?. considerQueryString
400
403
const defaultResolver = ( sourcePath : string ) =>
@@ -421,16 +424,19 @@ export = createRule<[Options?], MessageId>({
421
424
422
425
function getImportMap ( n : TSESTree . ImportDeclaration ) {
423
426
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 = {
426
432
imported : new Map ( ) ,
427
433
nsImported : new Map ( ) ,
428
434
defaultTypesImported : new Map ( ) ,
429
435
namedTypesImported : new Map ( ) ,
430
- } )
436
+ }
437
+ moduleMaps . set ( parent , map )
431
438
}
432
- const map = moduleMaps . get ( parent ) !
433
- const preferInline = context . options [ 0 ] ?. [ 'prefer-inline' ]
439
+
434
440
if ( ! preferInline && n . importKind === 'type' ) {
435
441
return n . specifiers . length > 0 &&
436
442
n . specifiers [ 0 ] . type === 'ImportDefaultSpecifier'
0 commit comments