Skip to content

Commit 5cf5038

Browse files
bradzacherljharb
authored andcommitted
[New] consistent-type-specifier-style: add rule
1 parent c4f3cc4 commit 5cf5038

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
@@ -15,6 +15,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange
1515
- [`no-extraneous-dependencies`]: Add `includeInternal` option ([#2541], thanks [@bdwain])
1616
- [`no-extraneous-dependencies`]: Add `includeTypes` option ([#2543], thanks [@bdwain])
1717
- [`order`]: new `alphabetize.orderImportKind` option to sort imports with same path based on their kind (`type`, `typeof`) ([#2544], thanks [@stropho])
18+
- [`consistent-type-specifier-style`]: add rule ([#2473], thanks [@bradzacher])
1819

1920
### Fixed
2021
- [`order`]: move nested imports closer to main import entry ([#2396], thanks [@pri1311])
@@ -965,6 +966,7 @@ for info on changes for earlier releases.
965966
[`import/external-module-folders` setting]: ./README.md#importexternal-module-folders
966967
[`internal-regex` setting]: ./README.md#importinternal-regex
967968

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