UNPKG

@typescript-eslint/eslint-plugin

Version:
336 lines (335 loc) • 16.5 kB
"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) { return undefined; } const aliasedSymbol = tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Alias) ? checker.getAliasedSymbol(symbol) : symbol; if (checker.isUnknownSymbol(aliasedSymbol)) { return undefined; } return !(aliasedSymbol.flags & ts.SymbolFlags.Value); } 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') { if (sourceExports.typeOnlyNamedExport == null) { // The export is a type export sourceExports.typeOnlyNamedExport = node; } } 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 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}` : ''}`; }