336 lines
16 KiB
Plaintext
336 lines
16 KiB
Plaintext
"use strict";
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || (function () {
|
|
var ownKeys = function(o) {
|
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
var ar = [];
|
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
return ar;
|
|
};
|
|
return ownKeys(o);
|
|
};
|
|
return function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
})();
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
const utils_1 = require("@typescript-eslint/utils");
|
|
const tsutils = __importStar(require("ts-api-utils"));
|
|
const ts = __importStar(require("typescript"));
|
|
const util_1 = require("../util");
|
|
exports.default = (0, util_1.createRule)({
|
|
name: 'consistent-type-exports',
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'Enforce consistent usage of type exports',
|
|
requiresTypeChecking: true,
|
|
},
|
|
fixable: 'code',
|
|
messages: {
|
|
multipleExportsAreTypes: 'Type exports {{exportNames}} are not values and should be exported using `export type`.',
|
|
singleExportIsType: 'Type export {{exportNames}} is not a value and should be exported using `export type`.',
|
|
typeOverValue: 'All exports in the declaration are only used as types. Use `export type`.',
|
|
},
|
|
schema: [
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
fixMixedExportsWithInlineTypeSpecifier: {
|
|
type: 'boolean',
|
|
description: 'Whether the rule will autofix "mixed" export cases using TS inline type specifiers.',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
defaultOptions: [
|
|
{
|
|
fixMixedExportsWithInlineTypeSpecifier: false,
|
|
},
|
|
],
|
|
create(context, [{ fixMixedExportsWithInlineTypeSpecifier }]) {
|
|
const sourceExportsMap = {};
|
|
const services = (0, util_1.getParserServices)(context);
|
|
const checker = services.program.getTypeChecker();
|
|
/**
|
|
* Helper for identifying if a symbol resolves to a
|
|
* JavaScript value or a TypeScript type.
|
|
*
|
|
* @returns True/false if is a type or not, or undefined if the specifier
|
|
* can't be resolved.
|
|
*/
|
|
function isSymbolTypeBased(symbol) {
|
|
if (!symbol || checker.isUnknownSymbol(symbol)) {
|
|
return undefined;
|
|
}
|
|
if (symbol.getDeclarations()?.some(ts.isTypeOnlyImportOrExportDeclaration)) {
|
|
return true;
|
|
}
|
|
if (tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Value)) {
|
|
return false;
|
|
}
|
|
return tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Alias)
|
|
? isSymbolTypeBased(checker.getImmediateAliasedSymbol(symbol))
|
|
: true;
|
|
}
|
|
return {
|
|
ExportAllDeclaration(node) {
|
|
if (node.exportKind === 'type') {
|
|
return;
|
|
}
|
|
const sourceModule = ts.resolveModuleName(node.source.value, context.filename, services.program.getCompilerOptions(), ts.sys);
|
|
if (sourceModule.resolvedModule == null) {
|
|
return;
|
|
}
|
|
const sourceFile = services.program.getSourceFile(sourceModule.resolvedModule.resolvedFileName);
|
|
if (sourceFile == null) {
|
|
return;
|
|
}
|
|
const sourceFileSymbol = checker.getSymbolAtLocation(sourceFile);
|
|
if (sourceFileSymbol == null) {
|
|
return;
|
|
}
|
|
const sourceFileType = checker.getTypeOfSymbol(sourceFileSymbol);
|
|
// Module can explicitly export types or values, and it's not difficult
|
|
// to distinguish one from the other, since we can get the flags of
|
|
// the exported symbols or check if symbol export declaration has
|
|
// the "type" keyword in it.
|
|
//
|
|
// Things get a lot more complicated when we're dealing with
|
|
// export * from './module-with-type-only-exports'
|
|
// export type * from './module-with-type-and-value-exports'
|
|
//
|
|
// TS checker has an internal function getExportsOfModuleWorker that
|
|
// recursively visits all module exports, including "export *". It then
|
|
// puts type-only-star-exported symbols into the typeOnlyExportStarMap
|
|
// property of sourceFile's SymbolLinks. Since symbol links aren't
|
|
// exposed outside the checker, we cannot access it directly.
|
|
//
|
|
// Therefore, to filter out value properties, we use the following hack:
|
|
// checker.getPropertiesOfType returns all exports that were originally
|
|
// values, but checker.getPropertyOfType returns undefined for
|
|
// properties that are mentioned in the typeOnlyExportStarMap.
|
|
const isThereAnyExportedValue = checker
|
|
.getPropertiesOfType(sourceFileType)
|
|
.some(propertyTypeSymbol => checker.getPropertyOfType(sourceFileType, propertyTypeSymbol.escapedName.toString()) != null);
|
|
if (isThereAnyExportedValue) {
|
|
return;
|
|
}
|
|
context.report({
|
|
node,
|
|
messageId: 'typeOverValue',
|
|
fix(fixer) {
|
|
const asteriskToken = (0, util_1.nullThrows)(context.sourceCode.getFirstToken(node, token => token.type === utils_1.AST_TOKEN_TYPES.Punctuator &&
|
|
token.value === '*'), util_1.NullThrowsReasons.MissingToken('asterisk', 'export all declaration'));
|
|
return fixer.insertTextBefore(asteriskToken, 'type ');
|
|
},
|
|
});
|
|
},
|
|
ExportNamedDeclaration(node) {
|
|
// Coerce the source into a string for use as a lookup entry.
|
|
const source = getSourceFromExport(node) ?? 'undefined';
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
const sourceExports = (sourceExportsMap[source] ||= {
|
|
reportValueExports: [],
|
|
source,
|
|
typeOnlyNamedExport: null,
|
|
valueOnlyNamedExport: null,
|
|
});
|
|
// 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') {
|
|
// The export is a type export
|
|
sourceExports.typeOnlyNamedExport ??= node;
|
|
}
|
|
else {
|
|
// The export is a value export
|
|
sourceExports.valueOnlyNamedExport ??= node;
|
|
}
|
|
// Next for the current export, we will separate type/value specifiers.
|
|
const typeBasedSpecifiers = [];
|
|
const inlineTypeSpecifiers = [];
|
|
const valueSpecifiers = [];
|
|
// 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 = isSymbolTypeBased(services.getSymbolAtLocation(specifier.exported));
|
|
if (isTypeBased === true) {
|
|
typeBasedSpecifiers.push(specifier);
|
|
}
|
|
else if (isTypeBased === false) {
|
|
// When isTypeBased is undefined, we should avoid reporting them.
|
|
valueSpecifiers.push(specifier);
|
|
}
|
|
}
|
|
}
|
|
if ((node.exportKind === 'value' && typeBasedSpecifiers.length) ||
|
|
(node.exportKind === 'type' && valueSpecifiers.length)) {
|
|
sourceExports.reportValueExports.push({
|
|
node,
|
|
inlineTypeSpecifiers,
|
|
typeBasedSpecifiers,
|
|
valueSpecifiers,
|
|
});
|
|
}
|
|
},
|
|
'Program:exit'() {
|
|
for (const sourceExports of Object.values(sourceExportsMap)) {
|
|
// If this export has no issues, move on.
|
|
if (sourceExports.reportValueExports.length === 0) {
|
|
continue;
|
|
}
|
|
for (const report of sourceExports.reportValueExports) {
|
|
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, context.sourceCode, report.node);
|
|
},
|
|
});
|
|
continue;
|
|
}
|
|
// We have both type and value violations.
|
|
const allExportNames = report.typeBasedSpecifiers.map(specifier => specifier.local.type === utils_1.AST_NODE_TYPES.Identifier
|
|
? specifier.local.name
|
|
: specifier.local.value);
|
|
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, context.sourceCode, report);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
else {
|
|
const exportNames = (0, util_1.formatWordList)(allExportNames);
|
|
context.report({
|
|
node: report.node,
|
|
messageId: 'multipleExportsAreTypes',
|
|
data: { exportNames },
|
|
*fix(fixer) {
|
|
if (fixMixedExportsWithInlineTypeSpecifier) {
|
|
yield* fixAddTypeSpecifierToNamedExports(fixer, report);
|
|
}
|
|
else {
|
|
yield* fixSeparateNamedExports(fixer, context.sourceCode, report);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
},
|
|
});
|
|
/**
|
|
* Inserts "type" into an export.
|
|
*
|
|
* Example:
|
|
*
|
|
* export type { Foo } from 'foo';
|
|
* ^^^^
|
|
*/
|
|
function* fixExportInsertType(fixer, sourceCode, node) {
|
|
const exportToken = (0, util_1.nullThrows)(sourceCode.getFirstToken(node), util_1.NullThrowsReasons.MissingToken('export', node.type));
|
|
yield fixer.insertTextAfter(exportToken, ' type');
|
|
for (const specifier of node.specifiers) {
|
|
if (specifier.exportKind === 'type') {
|
|
const kindToken = (0, util_1.nullThrows)(sourceCode.getFirstToken(specifier), util_1.NullThrowsReasons.MissingToken('export', specifier.type));
|
|
const firstTokenAfter = (0, util_1.nullThrows)(sourceCode.getTokenAfter(kindToken, {
|
|
includeComments: true,
|
|
}), 'Missing token following the export kind.');
|
|
yield fixer.removeRange([kindToken.range[0], firstTokenAfter.range[0]]);
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Separates the exports which mismatch the kind of export the given
|
|
* node represents. For example, a type export's named specifiers which
|
|
* represent values will be inserted in a separate `export` statement.
|
|
*/
|
|
function* fixSeparateNamedExports(fixer, sourceCode, report) {
|
|
const { node, inlineTypeSpecifiers, typeBasedSpecifiers, valueSpecifiers } = report;
|
|
const typeSpecifiers = [...typeBasedSpecifiers, ...inlineTypeSpecifiers];
|
|
const source = getSourceFromExport(node);
|
|
const specifierNames = typeSpecifiers.map(getSpecifierText).join(', ');
|
|
const exportToken = (0, util_1.nullThrows)(sourceCode.getFirstToken(node), util_1.NullThrowsReasons.MissingToken('export', node.type));
|
|
// Filter the bad exports from the current line.
|
|
const filteredSpecifierNames = valueSpecifiers
|
|
.map(getSpecifierText)
|
|
.join(', ');
|
|
const openToken = (0, util_1.nullThrows)(sourceCode.getFirstToken(node, util_1.isOpeningBraceToken), util_1.NullThrowsReasons.MissingToken('{', node.type));
|
|
const closeToken = (0, util_1.nullThrows)(sourceCode.getLastToken(node, util_1.isClosingBraceToken), util_1.NullThrowsReasons.MissingToken('}', node.type));
|
|
// Remove exports from the current line which we're going to re-insert.
|
|
yield fixer.replaceTextRange([openToken.range[1], closeToken.range[0]], ` ${filteredSpecifierNames} `);
|
|
// Insert the bad exports into a new export line above.
|
|
yield fixer.insertTextBefore(exportToken, `export type { ${specifierNames} }${source ? ` from '${source}'` : ''};\n`);
|
|
}
|
|
function* fixAddTypeSpecifierToNamedExports(fixer, report) {
|
|
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.
|
|
*/
|
|
function getSourceFromExport(node) {
|
|
if (node.source?.type === utils_1.AST_NODE_TYPES.Literal &&
|
|
typeof node.source.value === 'string') {
|
|
return node.source.value;
|
|
}
|
|
return undefined;
|
|
}
|
|
/**
|
|
* Returns the specifier text for the export. If it is aliased, we take care to return
|
|
* the proper formatting.
|
|
*/
|
|
function getSpecifierText(specifier) {
|
|
const exportedName = specifier.exported.type === utils_1.AST_NODE_TYPES.Literal
|
|
? specifier.exported.raw
|
|
: specifier.exported.name;
|
|
const localName = specifier.local.type === utils_1.AST_NODE_TYPES.Literal
|
|
? specifier.local.raw
|
|
: specifier.local.name;
|
|
return `${localName}${exportedName !== localName ? ` as ${exportedName}` : ''}`;
|
|
}
|