Skip to content

Commit

Permalink
feat(eslint-plugin): [consistent-type-exports] support TS4.5 inline e…
Browse files Browse the repository at this point in the history
…xport specifiers (#4236)
  • Loading branch information
bradzacher committed Dec 6, 2021
1 parent 96b7e8e commit be4d976
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 56 deletions.
49 changes: 48 additions & 1 deletion packages/eslint-plugin/docs/rules/consistent-type-exports.md
Expand Up @@ -11,6 +11,51 @@ This rule aims to standardize the use of type exports style across a codebase.

Given a class `Button`, and an interface `ButtonProps`, examples of code:

## Options

```ts
interface Options {
fixMixedExportsWithInlineTypeSpecifier?: boolean;
}

const defaultOptions: Options = {
fixMixedExportsWithInlineTypeSpecifier: false,
};
```

### `fixMixedExportsWithInlineTypeSpecifier`

When this is set to true, the rule will autofix "mixed" export cases using TS 4.5's "inline type specifier".
If you are using a TypeScript version less than 4.5, then you will not be able to use this option.

For example the following code:

```ts
const x = 1;
type T = number;

export { x, T };
```

With `{fixMixedExportsWithInlineTypeSpecifier: true}` will be fixed to:

```ts
const x = 1;
type T = number;

export { x, type T };
```

With `{fixMixedExportsWithInlineTypeSpecifier: false}` will be fixed to:

```ts
const x = 1;
type T = number;

export type { T };
export { x };
```

<!--tabs-->

### ❌ Incorrect
Expand All @@ -23,7 +68,9 @@ export type { ButtonProps } from 'some-library';
### ✅ Correct

```ts
export { Button, ButtonProps } from 'some-library';
export { Button } from 'some-library';
export type { ButtonProps } from 'some-library';
export { Button, type ButtonProps } from 'some-library';
```

## When Not To Use It
Expand Down
161 changes: 111 additions & 50 deletions packages/eslint-plugin/src/rules/consistent-type-exports.ts
Expand Up @@ -7,7 +7,11 @@ import {
import { SymbolFlags } from 'typescript';
import * as util from '../util';

type Options = [];
type Options = [
{
fixMixedExportsWithInlineTypeSpecifier: boolean;
},
];

interface SourceExports {
source: string;
Expand All @@ -18,8 +22,9 @@ interface SourceExports {

interface ReportValueExport {
node: TSESTree.ExportNamedDeclaration;
typeSpecifiers: TSESTree.ExportSpecifier[];
typeBasedSpecifiers: TSESTree.ExportSpecifier[];
valueSpecifiers: TSESTree.ExportSpecifier[];
inlineTypeSpecifiers: TSESTree.ExportSpecifier[];
}

type MessageIds =
Expand All @@ -29,7 +34,6 @@ type MessageIds =

export default util.createRule<Options, MessageIds>({
name: 'consistent-type-exports',
defaultOptions: [],
meta: {
type: 'suggestion',
docs: {
Expand All @@ -46,11 +50,26 @@ export default util.createRule<Options, MessageIds>({
multipleExportsAreTypes:
'Type exports {{exportNames}} are not values and should be exported using `export type`.',
},
schema: [],
schema: [
{
type: 'object',
properties: {
fixMixedExportsWithInlineTypeSpecifier: {
type: 'boolean',
},
},
additionalProperties: false,
},
],
fixable: 'code',
},
defaultOptions: [
{
fixMixedExportsWithInlineTypeSpecifier: false,
},
],

create(context) {
create(context, [{ fixMixedExportsWithInlineTypeSpecifier }]) {
const sourceCode = context.getSourceCode();
const sourceExportsMap: { [key: string]: SourceExports } = {};
const parserServices = util.getParserServices(context);
Expand All @@ -69,27 +88,33 @@ export default util.createRule<Options, MessageIds>({
// Cache the first encountered exports for the package. We will need to come
// back to these later when fixing the problems.
if (node.exportKind === 'type') {
if (!sourceExports.typeOnlyNamedExport) {
if (sourceExports.typeOnlyNamedExport == null) {
// The export is a type export
sourceExports.typeOnlyNamedExport = node;
}
} else if (!sourceExports.valueOnlyNamedExport) {
} else if (sourceExports.valueOnlyNamedExport == null) {
// The export is a value export
sourceExports.valueOnlyNamedExport = node;
}

// Next for the current export, we will separate type/value specifiers.
const typeSpecifiers: TSESTree.ExportSpecifier[] = [];
const typeBasedSpecifiers: TSESTree.ExportSpecifier[] = [];
const inlineTypeSpecifiers: TSESTree.ExportSpecifier[] = [];
const valueSpecifiers: TSESTree.ExportSpecifier[] = [];

// Note: it is valid to export values as types. We will avoid reporting errors
// when this is encountered.
if (node.exportKind !== 'type') {
for (const specifier of node.specifiers) {
if (specifier.exportKind === 'type') {
inlineTypeSpecifiers.push(specifier);
continue;
}

const isTypeBased = isSpecifierTypeBased(parserServices, specifier);

if (isTypeBased === true) {
typeSpecifiers.push(specifier);
typeBasedSpecifiers.push(specifier);
} else if (isTypeBased === false) {
// When isTypeBased is undefined, we should avoid reporting them.
valueSpecifiers.push(specifier);
Expand All @@ -98,13 +123,14 @@ export default util.createRule<Options, MessageIds>({
}

if (
(node.exportKind === 'value' && typeSpecifiers.length) ||
(node.exportKind === 'value' && typeBasedSpecifiers.length) ||
(node.exportKind === 'type' && valueSpecifiers.length)
) {
sourceExports.reportValueExports.push({
node,
typeSpecifiers,
typeBasedSpecifiers,
valueSpecifiers,
inlineTypeSpecifiers,
});
}
},
Expand All @@ -117,44 +143,53 @@ export default util.createRule<Options, MessageIds>({
}

for (const report of sourceExports.reportValueExports) {
if (!report.valueSpecifiers.length) {
// Export is all type-only; convert the entire export to `export type`.
if (report.valueSpecifiers.length === 0) {
// Export is all type-only with no type specifiers; convert the entire export to `export type`.
context.report({
node: report.node,
messageId: 'typeOverValue',
*fix(fixer) {
yield* fixExportInsertType(fixer, sourceCode, report.node);
},
});
} else {
// We have both type and value violations.
const allExportNames = report.typeSpecifiers.map(
specifier => `${specifier.local.name}`,
);

if (allExportNames.length === 1) {
const exportNames = allExportNames[0];

context.report({
node: report.node,
messageId: 'singleExportIsType',
data: { exportNames },
*fix(fixer) {
continue;
}

// We have both type and value violations.
const allExportNames = report.typeBasedSpecifiers.map(
specifier => `${specifier.local.name}`,
);

if (allExportNames.length === 1) {
const exportNames = allExportNames[0];

context.report({
node: report.node,
messageId: 'singleExportIsType',
data: { exportNames },
*fix(fixer) {
if (fixMixedExportsWithInlineTypeSpecifier) {
yield* fixAddTypeSpecifierToNamedExports(fixer, report);
} else {
yield* fixSeparateNamedExports(fixer, sourceCode, report);
},
});
} else {
const exportNames = util.formatWordList(allExportNames);

context.report({
node: report.node,
messageId: 'multipleExportsAreTypes',
data: { exportNames },
*fix(fixer) {
}
},
});
} else {
const exportNames = util.formatWordList(allExportNames);

context.report({
node: report.node,
messageId: 'multipleExportsAreTypes',
data: { exportNames },
*fix(fixer) {
if (fixMixedExportsWithInlineTypeSpecifier) {
yield* fixAddTypeSpecifierToNamedExports(fixer, report);
} else {
yield* fixSeparateNamedExports(fixer, sourceCode, report);
},
});
}
}
},
});
}
}
}
Expand Down Expand Up @@ -205,6 +240,23 @@ function* fixExportInsertType(
);

yield fixer.insertTextAfter(exportToken, ' type');

for (const specifier of node.specifiers) {
if (specifier.exportKind === 'type') {
const kindToken = util.nullThrows(
sourceCode.getFirstToken(specifier),
util.NullThrowsReasons.MissingToken('export', specifier.type),
);
const firstTokenAfter = util.nullThrows(
sourceCode.getTokenAfter(kindToken, {
includeComments: true,
}),
'Missing token following the export kind.',
);

yield fixer.removeRange([kindToken.range[0], firstTokenAfter.range[0]]);
}
}
}

/**
Expand All @@ -217,21 +269,19 @@ function* fixSeparateNamedExports(
sourceCode: Readonly<TSESLint.SourceCode>,
report: ReportValueExport,
): IterableIterator<TSESLint.RuleFix> {
const { node, typeSpecifiers, valueSpecifiers } = report;
const { node, typeBasedSpecifiers, inlineTypeSpecifiers, valueSpecifiers } =
report;
const typeSpecifiers = typeBasedSpecifiers.concat(inlineTypeSpecifiers);
const source = getSourceFromExport(node);
const separateTypes = node.exportKind !== 'type';
const specifiersToSeparate = separateTypes ? typeSpecifiers : valueSpecifiers;
const specifierNames = specifiersToSeparate.map(getSpecifierText).join(', ');
const specifierNames = typeSpecifiers.map(getSpecifierText).join(', ');

const exportToken = util.nullThrows(
sourceCode.getFirstToken(node),
util.NullThrowsReasons.MissingToken('export', node.type),
);

// Filter the bad exports from the current line.
const filteredSpecifierNames = (
separateTypes ? valueSpecifiers : typeSpecifiers
)
const filteredSpecifierNames = valueSpecifiers
.map(getSpecifierText)
.join(', ');
const openToken = util.nullThrows(
Expand All @@ -252,12 +302,23 @@ function* fixSeparateNamedExports(
// Insert the bad exports into a new export line above.
yield fixer.insertTextBefore(
exportToken,
`export ${separateTypes ? 'type ' : ''}{ ${specifierNames} }${
source ? ` from '${source}'` : ''
};\n`,
`export type { ${specifierNames} }${source ? ` from '${source}'` : ''};\n`,
);
}

function* fixAddTypeSpecifierToNamedExports(
fixer: TSESLint.RuleFixer,
report: ReportValueExport,
): IterableIterator<TSESLint.RuleFix> {
if (report.node.exportKind === 'type') {
return;
}

for (const specifier of report.typeBasedSpecifiers) {
yield fixer.insertTextBefore(specifier, 'type ');
}
}

/**
* Returns the source of the export, or undefined if the named export has no source.
*/
Expand Down

0 comments on commit be4d976

Please sign in to comment.