Skip to content

Commit 591c83d

Browse files
bradzacherljharb
authored andcommitted
[New] consistent-type-specifier-style: add rule
1 parent 395e26b commit 591c83d

File tree

9 files changed

+730
-4
lines changed

9 files changed

+730
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
77
## [Unreleased]
88

99
### Added
10+
- [`consistent-type-specifier-style`]: add rule ([#2473], thanks [@bradzacher])
1011
- [`newline-after-import`]: add `considerComments` option ([#2399], thanks [@pri1311])
1112
- [`no-cycle`]: add `allowUnsafeDynamicCyclicDependency` option ([#2387], thanks [@GerkinDev])
1213
- [`no-restricted-paths`]: support arrays for `from` and `target` options ([#2466], thanks [@AdriAt360])
@@ -964,6 +965,7 @@ for info on changes for earlier releases.
964965
[`import/external-module-folders` setting]: ./README.md#importexternal-module-folders
965966
[`internal-regex` setting]: ./README.md#importinternal-regex
966967

968+
[`consistent-type-specifier-style`]: ./docs/rules/consistent-type-specifier-style.md
967969
[`default`]: ./docs/rules/default.md
968970
[`dynamic-import-chunkname`]: ./docs/rules/dynamic-import-chunkname.md
969971
[`export`]: ./docs/rules/export.md
@@ -1016,6 +1018,7 @@ for info on changes for earlier releases.
10161018
[#2506]: https://github.com/import-js/eslint-plugin-import/pull/2506
10171019
[#2503]: https://github.com/import-js/eslint-plugin-import/pull/2503
10181020
[#2490]: https://github.com/import-js/eslint-plugin-import/pull/2490
1021+
[#2473]: https://github.com/import-js/eslint-plugin-import/pull/2473
10191022
[#2466]: https://github.com/import-js/eslint-plugin-import/pull/2466
10201023
[#2440]: https://github.com/import-js/eslint-plugin-import/pull/2440
10211024
[#2438]: https://github.com/import-js/eslint-plugin-import/pull/2438

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a
9797
* Forbid anonymous values as default exports ([`no-anonymous-default-export`])
9898
* Prefer named exports to be grouped together in a single export declaration ([`group-exports`])
9999
* Enforce a leading comment with the webpackChunkName for dynamic imports ([`dynamic-import-chunkname`])
100+
* Enforce or ban the use of inline type-only markers for named imports ([`consistent-type-specifier-style`])
100101

101102
[`first`]: ./docs/rules/first.md
102103
[`exports-last`]: ./docs/rules/exports-last.md
@@ -114,6 +115,7 @@ This plugin intends to support linting of ES2015+ (ES6+) import/export syntax, a
114115
[`no-default-export`]: ./docs/rules/no-default-export.md
115116
[`no-named-export`]: ./docs/rules/no-named-export.md
116117
[`dynamic-import-chunkname`]: ./docs/rules/dynamic-import-chunkname.md
118+
[`consistent-type-specifier-style`]: ./docs/rules/consistent-type-specifier-style.md
117119

118120
## `eslint-plugin-import` for enterprise
119121

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# import/consistent-type-specifier-style
2+
3+
In both Flow and TypeScript you can mark an import as a type-only import by adding a "kind" marker to the import. Both languages support two positions for marker.
4+
5+
**At the top-level** which marks all names in the import as type-only and applies to named, default, and namespace (for TypeScript) specifiers:
6+
7+
```ts
8+
import type Foo from 'Foo';
9+
import type {Bar} from 'Bar';
10+
// ts only
11+
import type * as Bam from 'Bam';
12+
// flow only
13+
import typeof Baz from 'Baz';
14+
```
15+
16+
**Inline** with to the named import, which marks just the specific name in the import as type-only. An inline specifier is only valid for named specifiers, and not for default or namespace specifiers:
17+
18+
```ts
19+
import {type Foo} from 'Foo';
20+
// flow only
21+
import {typeof Bar} from 'Bar';
22+
```
23+
24+
## Rule Details
25+
26+
This rule either enforces or bans the use of inline type-only markers for named imports.
27+
28+
This rule includes a fixer that will automatically convert your specifiers to the correct form - however the fixer will not respect your preferences around de-duplicating imports. If this is important to you, consider using the [`import/no-duplicates`] rule.
29+
30+
[`import/no-duplicates`]: ./no-duplicates.md
31+
32+
## Options
33+
34+
The rule accepts a single string option which may be one of:
35+
36+
- `'prefer-inline'` - enforces that named type-only specifiers are only ever written with an inline marker; and never as part of a top-level, type-only import.
37+
- `'prefer-top-level'` - enforces that named type-only specifiers only ever written as part of a top-level, type-only import; and never with an inline marker.
38+
39+
By default the rule will use the `prefer-inline` option.
40+
41+
## Examples
42+
43+
### `prefer-top-level`
44+
45+
❌ Invalid with `["error", "prefer-top-level"]`
46+
47+
```ts
48+
import {type Foo} from 'Foo';
49+
import Foo, {type Bar} from 'Foo';
50+
// flow only
51+
import {typeof Foo} from 'Foo';
52+
```
53+
54+
✅ Valid with `["error", "prefer-top-level"]`
55+
56+
```ts
57+
import type {Foo} from 'Foo';
58+
import type Foo, {Bar} from 'Foo';
59+
// flow only
60+
import typeof {Foo} from 'Foo';
61+
```
62+
63+
### `prefer-inline`
64+
65+
❌ Invalid with `["error", "prefer-inline"]`
66+
67+
```ts
68+
import type {Foo} from 'Foo';
69+
import type Foo, {Bar} from 'Foo';
70+
// flow only
71+
import typeof {Foo} from 'Foo';
72+
```
73+
74+
✅ Valid with `["error", "prefer-inline"]`
75+
76+
```ts
77+
import {type Foo} from 'Foo';
78+
import Foo, {type Bar} from 'Foo';
79+
// flow only
80+
import {typeof Foo} from 'Foo';
81+
```
82+
83+
## When Not To Use It
84+
85+
If you aren't using Flow or TypeScript 4.5+, then this rule does not apply and need not be used.
86+
87+
If you don't care about, and don't want to standardize how named specifiers are imported then you should not use this rule.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@
9292
"safe-publish-latest": "^2.0.0",
9393
"semver": "^6.3.0",
9494
"sinon": "^2.4.1",
95-
"typescript": "^2.8.1 || ~3.9.5",
95+
"typescript": "^2.8.1 || ~3.9.5 || ~4.5.2",
9696
"typescript-eslint-parser": "^15 || ^20 || ^22"
9797
},
9898
"peerDependencies": {

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const rules = {
1212
'group-exports': require('./rules/group-exports'),
1313
'no-relative-packages': require('./rules/no-relative-packages'),
1414
'no-relative-parent-imports': require('./rules/no-relative-parent-imports'),
15+
'consistent-type-specifier-style': require('./rules/consistent-type-specifier-style'),
1516

1617
'no-self-import': require('./rules/no-self-import'),
1718
'no-cycle': require('./rules/no-cycle'),
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import docsUrl from '../docsUrl';
2+
3+
function isComma(token) {
4+
return token.type === 'Punctuator' && token.value === ',';
5+
}
6+
7+
function removeSpecifiers(fixes, fixer, sourceCode, specifiers) {
8+
for (const specifier of specifiers) {
9+
// remove the trailing comma
10+
const comma = sourceCode.getTokenAfter(specifier, isComma);
11+
if (comma) {
12+
fixes.push(fixer.remove(comma));
13+
}
14+
fixes.push(fixer.remove(specifier));
15+
}
16+
}
17+
18+
module.exports = {
19+
meta: {
20+
type: 'suggestion',
21+
docs: {
22+
description: 'Enforce or ban the use of inline type-only markers for named imports',
23+
url: docsUrl('consistent-type-specifier-style'),
24+
},
25+
fixable: 'code',
26+
schema: [
27+
{
28+
type: 'string',
29+
enum: ['prefer-inline', 'prefer-top-level'],
30+
default: 'prefer-inline',
31+
},
32+
],
33+
},
34+
35+
create(context) {
36+
const sourceCode = context.getSourceCode();
37+
38+
if (context.options[0] === 'prefer-inline') {
39+
return {
40+
ImportDeclaration(node) {
41+
if (node.importKind === 'value' || node.importKind == null) {
42+
// top-level value / unknown is valid
43+
return;
44+
}
45+
46+
if (
47+
// no specifiers (import type {} from '') have no specifiers to mark as inline
48+
node.specifiers.length === 0 ||
49+
(node.specifiers.length === 1 &&
50+
// default imports are both "inline" and "top-level"
51+
(node.specifiers[0].type === 'ImportDefaultSpecifier' ||
52+
// namespace imports are both "inline" and "top-level"
53+
node.specifiers[0].type === 'ImportNamespaceSpecifier'))
54+
) {
55+
return;
56+
}
57+
58+
context.report({
59+
node,
60+
message: 'Prefer using inline {{kind}} specifiers instead of a top-level {{kind}}-only import.',
61+
data: {
62+
kind: node.importKind,
63+
},
64+
fix(fixer) {
65+
const kindToken = sourceCode.getFirstToken(node, { skip: 1 });
66+
67+
return [].concat(
68+
kindToken ? fixer.remove(kindToken) : [],
69+
node.specifiers.map((specifier) => fixer.insertTextBefore(specifier, `${node.importKind} `)),
70+
);
71+
},
72+
});
73+
},
74+
};
75+
}
76+
77+
// prefer-top-level
78+
return {
79+
ImportDeclaration(node) {
80+
if (
81+
// already top-level is valid
82+
node.importKind === 'type' ||
83+
node.importKind === 'typeof' ||
84+
// no specifiers (import {} from '') cannot have inline - so is valid
85+
node.specifiers.length === 0 ||
86+
(node.specifiers.length === 1 &&
87+
// default imports are both "inline" and "top-level"
88+
(node.specifiers[0].type === 'ImportDefaultSpecifier' ||
89+
// namespace imports are both "inline" and "top-level"
90+
node.specifiers[0].type === 'ImportNamespaceSpecifier'))
91+
) {
92+
return;
93+
}
94+
95+
const typeSpecifiers = [];
96+
const typeofSpecifiers = [];
97+
const valueSpecifiers = [];
98+
let defaultSpecifier = null;
99+
for (const specifier of node.specifiers) {
100+
if (specifier.type === 'ImportDefaultSpecifier') {
101+
defaultSpecifier = specifier;
102+
continue;
103+
} else if (specifier.type !== 'ImportSpecifier') {
104+
continue;
105+
}
106+
107+
if (specifier.importKind === 'type') {
108+
typeSpecifiers.push(specifier);
109+
} else if (specifier.importKind === 'typeof') {
110+
typeofSpecifiers.push(specifier);
111+
} else if (specifier.importKind === 'value' || specifier.importKind == null) {
112+
valueSpecifiers.push(specifier);
113+
}
114+
}
115+
116+
const typeImport = getImportText(typeSpecifiers, 'type');
117+
const typeofImport = getImportText(typeofSpecifiers, 'typeof');
118+
const newImports = `${typeImport}\n${typeofImport}`.trim();
119+
120+
if (typeSpecifiers.length + typeofSpecifiers.length === node.specifiers.length) {
121+
// all specifiers have inline specifiers - so we replace the entire import
122+
const kind = [].concat(
123+
typeSpecifiers.length > 0 ? 'type' : [],
124+
typeofSpecifiers.length > 0 ? 'typeof' : [],
125+
);
126+
127+
context.report({
128+
node,
129+
message: 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.',
130+
data: {
131+
kind: kind.join('/'),
132+
},
133+
fix(fixer) {
134+
return fixer.replaceText(node, newImports);
135+
},
136+
});
137+
} else {
138+
// remove specific specifiers and insert new imports for them
139+
for (const specifier of typeSpecifiers.concat(typeofSpecifiers)) {
140+
context.report({
141+
node: specifier,
142+
message: 'Prefer using a top-level {{kind}}-only import instead of inline {{kind}} specifiers.',
143+
data: {
144+
kind: specifier.importKind,
145+
},
146+
fix(fixer) {
147+
const fixes = [];
148+
149+
// if there are no value specifiers, then the other report fixer will be called, not this one
150+
151+
if (valueSpecifiers.length > 0) {
152+
// import { Value, type Type } from 'mod';
153+
154+
// we can just remove the type specifiers
155+
removeSpecifiers(fixes, fixer, sourceCode, typeSpecifiers);
156+
removeSpecifiers(fixes, fixer, sourceCode, typeofSpecifiers);
157+
158+
// make the import nicely formatted by also removing the trailing comma after the last value import
159+
// eg
160+
// import { Value, type Type } from 'mod';
161+
// to
162+
// import { Value } from 'mod';
163+
// not
164+
// import { Value, } from 'mod';
165+
const maybeComma = sourceCode.getTokenAfter(valueSpecifiers[valueSpecifiers.length - 1]);
166+
if (isComma(maybeComma)) {
167+
fixes.push(fixer.remove(maybeComma));
168+
}
169+
} else if (defaultSpecifier) {
170+
// import Default, { type Type } from 'mod';
171+
172+
// remove the entire curly block so we don't leave an empty one behind
173+
// NOTE - the default specifier *must* be the first specifier always!
174+
// so a comma exists that we also have to clean up or else it's bad syntax
175+
const comma = sourceCode.getTokenAfter(defaultSpecifier, isComma);
176+
const closingBrace = sourceCode.getTokenAfter(
177+
node.specifiers[node.specifiers.length - 1],
178+
token => token.type === 'Punctuator' && token.value === '}',
179+
);
180+
fixes.push(fixer.removeRange([
181+
comma.range[0],
182+
closingBrace.range[1],
183+
]));
184+
}
185+
186+
return fixes.concat(
187+
// insert the new imports after the old declaration
188+
fixer.insertTextAfter(node, `\n${newImports}`),
189+
);
190+
},
191+
});
192+
}
193+
}
194+
195+
function getImportText(
196+
specifiers,
197+
kind,
198+
) {
199+
const sourceString = sourceCode.getText(node.source);
200+
if (specifiers.length === 0) {
201+
return '';
202+
}
203+
204+
const names = specifiers.map(s => {
205+
if (s.imported.name === s.local.name) {
206+
return s.imported.name;
207+
}
208+
return `${s.imported.name} as ${s.local.name}`;
209+
});
210+
// insert a fresh top-level import
211+
return `import ${kind} {${names.join(', ')}} from ${sourceString};`;
212+
}
213+
},
214+
};
215+
},
216+
};

tests/src/core/getExports.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect } from 'chai';
22
import semver from 'semver';
33
import sinon from 'sinon';
44
import eslintPkg from 'eslint/package.json';
5+
import typescriptPkg from 'typescript/package.json';
56
import * as tsConfigLoader from 'tsconfig-paths/lib/tsconfig-loader';
67
import ExportMap from '../../../src/ExportMap';
78

@@ -351,7 +352,7 @@ describe('ExportMap', function () {
351352
configs.push(['array form', { '@typescript-eslint/parser': ['.ts', '.tsx'] }]);
352353
}
353354

354-
if (semver.satisfies(eslintPkg.version, '<6')) {
355+
if (semver.satisfies(eslintPkg.version, '<6') && semver.satisfies(typescriptPkg.version, '<4')) {
355356
configs.push(['array form', { 'typescript-eslint-parser': ['.ts', '.tsx'] }]);
356357
}
357358

0 commit comments

Comments
 (0)