forked from import-js/eslint-plugin-import
-
-
Notifications
You must be signed in to change notification settings - Fork 24
/
Copy pathconsistent-type-specifier-style.ts
272 lines (245 loc) · 8.73 KB
/
consistent-type-specifier-style.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
import type { TSESLint, TSESTree } from '@typescript-eslint/utils'
import { createRule, getValue } from '../utils'
function isComma(token: TSESTree.Token): token is TSESTree.PunctuatorToken {
return token.type === 'Punctuator' && token.value === ','
}
function removeSpecifiers(
fixes: TSESLint.RuleFix[],
fixer: TSESLint.RuleFixer,
sourceCode: Readonly<TSESLint.SourceCode>,
specifiers: TSESTree.ImportSpecifier[],
) {
for (const specifier of specifiers) {
// remove the trailing comma
const token = sourceCode.getTokenAfter(specifier)
if (token && isComma(token)) {
fixes.push(fixer.remove(token))
}
fixes.push(fixer.remove(specifier))
}
}
function getImportText(
node: TSESTree.ImportDeclaration,
sourceCode: Readonly<TSESLint.SourceCode>,
specifiers: TSESTree.ImportSpecifier[],
kind: 'type' | 'typeof',
) {
const sourceString = sourceCode.getText(node.source)
if (specifiers.length === 0) {
return ''
}
const names = specifiers.map(s => {
const importedName = getValue(s.imported)
if (importedName === s.local.name) {
return importedName
}
return `${importedName} as ${s.local.name}`
})
// insert a fresh top-level import
return `import ${kind} {${names.join(', ')}} from ${sourceString};`
}
type Options = 'prefer-inline' | 'prefer-top-level'
type MessageId = 'inline' | 'topLevel'
export = createRule<[Options?], MessageId>({
name: 'consistent-type-specifier-style',
meta: {
type: 'suggestion',
docs: {
category: 'Style guide',
description:
'Enforce or ban the use of inline type-only markers for named imports.',
},
fixable: 'code',
schema: [
{
type: 'string',
enum: ['prefer-inline', 'prefer-top-level'],
default: 'prefer-inline',
},
],
messages: {
inline:
'Prefer using inline {{kind}} specifiers instead of a top-level {{kind}}-only import.',
topLevel:
'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.',
},
},
defaultOptions: [],
create(context) {
const { sourceCode } = context
if (context.options[0] === 'prefer-inline') {
return {
ImportDeclaration(node) {
if (node.importKind === 'value' || node.importKind == null) {
// top-level value / unknown is valid
return
}
if (
// no specifiers (import type {} from '') have no specifiers to mark as inline
node.specifiers.length === 0 ||
(node.specifiers.length === 1 &&
// default imports are both "inline" and "top-level"
(node.specifiers[0].type === 'ImportDefaultSpecifier' ||
// namespace imports are both "inline" and "top-level"
node.specifiers[0].type === 'ImportNamespaceSpecifier'))
) {
return
}
context.report({
node,
messageId: 'inline',
data: {
kind: node.importKind,
},
fix(fixer) {
const kindToken = sourceCode.getFirstToken(node, { skip: 1 })
return [
kindToken ? fixer.remove(kindToken) : [],
node.specifiers.map(specifier =>
fixer.insertTextBefore(specifier, `${node.importKind} `),
),
].flat()
},
})
},
}
}
// prefer-top-level
return {
ImportDeclaration(node) {
if (
// already top-level is valid
node.importKind === 'type' ||
// @ts-expect-error - flow type
node.importKind === 'typeof' ||
// no specifiers (import {} from '') cannot have inline - so is valid
node.specifiers.length === 0 ||
(node.specifiers.length === 1 &&
// default imports are both "inline" and "top-level"
(node.specifiers[0].type === 'ImportDefaultSpecifier' ||
// namespace imports are both "inline" and "top-level"
node.specifiers[0].type === 'ImportNamespaceSpecifier'))
) {
return
}
const typeSpecifiers: TSESTree.ImportSpecifier[] = []
const typeofSpecifiers: TSESTree.ImportSpecifier[] = []
const valueSpecifiers: TSESTree.ImportSpecifier[] = []
let defaultSpecifier: TSESTree.ImportDefaultSpecifier | null = null
for (const specifier of node.specifiers) {
if (specifier.type === 'ImportDefaultSpecifier') {
defaultSpecifier = specifier
continue
}
if (!('importKind' in specifier)) {
continue
}
if (specifier.importKind === 'type') {
typeSpecifiers.push(specifier)
} else if (
// @ts-expect-error - flow type
specifier.importKind === 'typeof'
) {
typeofSpecifiers.push(specifier)
} else if (
specifier.importKind === 'value' ||
specifier.importKind == null
) {
valueSpecifiers.push(specifier)
}
}
const typeImport = getImportText(
node,
sourceCode,
typeSpecifiers,
'type',
)
const typeofImport = getImportText(
node,
sourceCode,
typeofSpecifiers,
'typeof',
)
const newImports = `${typeImport}\n${typeofImport}`.trim()
if (
typeSpecifiers.length + typeofSpecifiers.length ===
node.specifiers.length
) {
// all specifiers have inline specifiers - so we replace the entire import
const kind = [
typeSpecifiers.length > 0 ? ('type' as const) : [],
typeofSpecifiers.length > 0 ? ('typeof' as const) : [],
].flat()
context.report({
node,
messageId: 'topLevel',
data: {
kind: kind.join('/'),
},
fix(fixer) {
return fixer.replaceText(node, newImports)
},
})
} else {
// remove specific specifiers and insert new imports for them
for (const specifier of [...typeSpecifiers, ...typeofSpecifiers]) {
context.report({
node: specifier,
messageId: 'topLevel',
data: {
kind: specifier.importKind,
},
fix(fixer) {
const fixes: TSESLint.RuleFix[] = []
// if there are no value specifiers, then the other report fixer will be called, not this one
if (valueSpecifiers.length > 0) {
// import { Value, type Type } from 'mod';
// we can just remove the type specifiers
removeSpecifiers(fixes, fixer, sourceCode, typeSpecifiers)
removeSpecifiers(fixes, fixer, sourceCode, typeofSpecifiers)
// make the import nicely formatted by also removing the trailing comma after the last value import
// eg
// import { Value, type Type } from 'mod';
// to
// import { Value } from 'mod';
// not
// import { Value, } from 'mod';
const maybeComma = sourceCode.getTokenAfter(
valueSpecifiers[valueSpecifiers.length - 1],
)!
if (isComma(maybeComma)) {
fixes.push(fixer.remove(maybeComma))
}
} else if (defaultSpecifier) {
// import Default, { type Type } from 'mod';
// remove the entire curly block so we don't leave an empty one behind
// NOTE - the default specifier *must* be the first specifier always!
// so a comma exists that we also have to clean up or else it's bad syntax
const comma = sourceCode.getTokenAfter(
defaultSpecifier,
isComma,
)
const closingBrace = sourceCode.getTokenAfter(
node.specifiers[node.specifiers.length - 1],
token => token.type === 'Punctuator' && token.value === '}',
)
fixes.push(
fixer.removeRange([
comma!.range[0],
closingBrace!.range[1],
]),
)
}
return [
...fixes,
// insert the new imports after the old declaration
fixer.insertTextAfter(node, `\n${newImports}`),
]
},
})
}
}
},
}
},
})