407 lines
10 KiB
JavaScript
407 lines
10 KiB
JavaScript
'use strict';
|
|
const {
|
|
isCommaToken,
|
|
isOpeningBraceToken,
|
|
isClosingBraceToken,
|
|
} = require('@eslint-community/eslint-utils');
|
|
|
|
const MESSAGE_ID_ERROR = 'error';
|
|
const MESSAGE_ID_SUGGESTION = 'suggestion';
|
|
const messages = {
|
|
[MESSAGE_ID_ERROR]: 'Use `export…from` to re-export `{{exported}}`.',
|
|
[MESSAGE_ID_SUGGESTION]: 'Switch to `export…from`.',
|
|
};
|
|
|
|
// Default import/export can be `Identifier`, have to use `Symbol.for`
|
|
const DEFAULT_SPECIFIER_NAME = Symbol.for('default');
|
|
const NAMESPACE_SPECIFIER_NAME = Symbol('NAMESPACE_SPECIFIER_NAME');
|
|
|
|
const getSpecifierName = node => {
|
|
switch (node.type) {
|
|
case 'Identifier': {
|
|
return Symbol.for(node.name);
|
|
}
|
|
|
|
case 'Literal': {
|
|
return node.value;
|
|
}
|
|
// No default
|
|
}
|
|
};
|
|
|
|
const isTypeExport = specifier => specifier.exportKind === 'type' || specifier.parent.exportKind === 'type';
|
|
|
|
const isTypeImport = specifier => specifier.importKind === 'type' || specifier.parent.importKind === 'type';
|
|
|
|
function * removeSpecifier(node, fixer, sourceCode) {
|
|
const {parent} = node;
|
|
const {specifiers} = parent;
|
|
|
|
if (specifiers.length === 1) {
|
|
yield * removeImportOrExport(parent, fixer, sourceCode);
|
|
return;
|
|
}
|
|
|
|
switch (node.type) {
|
|
case 'ImportSpecifier': {
|
|
const hasOtherSpecifiers = specifiers.some(specifier => specifier !== node && specifier.type === node.type);
|
|
if (!hasOtherSpecifiers) {
|
|
const closingBraceToken = sourceCode.getTokenAfter(node, isClosingBraceToken);
|
|
|
|
// If there are other specifiers, they have to be the default import specifier
|
|
// And the default import has to write before the named import specifiers
|
|
// So there must be a comma before
|
|
const commaToken = sourceCode.getTokenBefore(node, isCommaToken);
|
|
yield fixer.replaceTextRange([commaToken.range[0], closingBraceToken.range[1]], '');
|
|
return;
|
|
}
|
|
// Fallthrough
|
|
}
|
|
|
|
case 'ExportSpecifier':
|
|
case 'ImportNamespaceSpecifier':
|
|
case 'ImportDefaultSpecifier': {
|
|
yield fixer.remove(node);
|
|
|
|
const tokenAfter = sourceCode.getTokenAfter(node);
|
|
if (isCommaToken(tokenAfter)) {
|
|
yield fixer.remove(tokenAfter);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// No default
|
|
}
|
|
}
|
|
|
|
function * removeImportOrExport(node, fixer, sourceCode) {
|
|
switch (node.type) {
|
|
case 'ImportSpecifier':
|
|
case 'ExportSpecifier':
|
|
case 'ImportDefaultSpecifier':
|
|
case 'ImportNamespaceSpecifier': {
|
|
yield * removeSpecifier(node, fixer, sourceCode);
|
|
return;
|
|
}
|
|
|
|
case 'ImportDeclaration':
|
|
case 'ExportDefaultDeclaration':
|
|
case 'ExportNamedDeclaration': {
|
|
yield fixer.remove(node);
|
|
}
|
|
|
|
// No default
|
|
}
|
|
}
|
|
|
|
function getSourceAndAssertionsText(declaration, sourceCode) {
|
|
const keywordFromToken = sourceCode.getTokenBefore(
|
|
declaration.source,
|
|
token => token.type === 'Identifier' && token.value === 'from',
|
|
);
|
|
const [start] = keywordFromToken.range;
|
|
const [, end] = declaration.range;
|
|
return sourceCode.text.slice(start, end);
|
|
}
|
|
|
|
function getFixFunction({
|
|
sourceCode,
|
|
imported,
|
|
exported,
|
|
exportDeclarations,
|
|
program,
|
|
}) {
|
|
const importDeclaration = imported.declaration;
|
|
const sourceNode = importDeclaration.source;
|
|
const sourceValue = sourceNode.value;
|
|
const shouldExportAsType = imported.isTypeImport || exported.isTypeExport;
|
|
|
|
let exportDeclaration;
|
|
if (shouldExportAsType) {
|
|
// If a type export declaration already exists, reuse it, else use a value export declaration with an inline type specifier.
|
|
exportDeclaration = exportDeclarations.find(({source, exportKind}) => source.value === sourceValue && exportKind === 'type');
|
|
}
|
|
|
|
if (!exportDeclaration) {
|
|
exportDeclaration = exportDeclarations.find(({source, exportKind}) => source.value === sourceValue && exportKind !== 'type');
|
|
}
|
|
|
|
/** @param {import('eslint').Rule.RuleFixer} fixer */
|
|
return function * (fixer) {
|
|
if (imported.name === NAMESPACE_SPECIFIER_NAME) {
|
|
yield fixer.insertTextAfter(
|
|
program,
|
|
`\nexport * as ${exported.text} ${getSourceAndAssertionsText(importDeclaration, sourceCode)}`,
|
|
);
|
|
} else {
|
|
let specifierText = exported.name === imported.name
|
|
? exported.text
|
|
: `${imported.text} as ${exported.text}`;
|
|
|
|
// Add an inline type specifier if the value is a type and the export deceleration is a value deceleration
|
|
if (shouldExportAsType && (!exportDeclaration || exportDeclaration.exportKind !== 'type')) {
|
|
specifierText = `type ${specifierText}`;
|
|
}
|
|
|
|
if (exportDeclaration) {
|
|
const lastSpecifier = exportDeclaration.specifiers[exportDeclaration.specifiers.length - 1];
|
|
|
|
// `export {} from 'foo';`
|
|
if (lastSpecifier) {
|
|
yield fixer.insertTextAfter(lastSpecifier, `, ${specifierText}`);
|
|
} else {
|
|
const openingBraceToken = sourceCode.getFirstToken(exportDeclaration, isOpeningBraceToken);
|
|
yield fixer.insertTextAfter(openingBraceToken, specifierText);
|
|
}
|
|
} else {
|
|
yield fixer.insertTextAfter(
|
|
program,
|
|
`\nexport {${specifierText}} ${getSourceAndAssertionsText(importDeclaration, sourceCode)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (imported.variable.references.length === 1) {
|
|
yield * removeImportOrExport(imported.node, fixer, sourceCode);
|
|
}
|
|
|
|
yield * removeImportOrExport(exported.node, fixer, sourceCode);
|
|
};
|
|
}
|
|
|
|
function getExported(identifier, context, sourceCode) {
|
|
const {parent} = identifier;
|
|
switch (parent.type) {
|
|
case 'ExportDefaultDeclaration': {
|
|
return {
|
|
node: parent,
|
|
name: DEFAULT_SPECIFIER_NAME,
|
|
text: 'default',
|
|
isTypeExport: isTypeExport(parent),
|
|
};
|
|
}
|
|
|
|
case 'ExportSpecifier': {
|
|
return {
|
|
node: parent,
|
|
name: getSpecifierName(parent.exported),
|
|
text: sourceCode.getText(parent.exported),
|
|
isTypeExport: isTypeExport(parent),
|
|
};
|
|
}
|
|
|
|
case 'VariableDeclarator': {
|
|
if (
|
|
parent.init === identifier
|
|
&& parent.id.type === 'Identifier'
|
|
&& !parent.id.typeAnnotation
|
|
&& parent.parent.type === 'VariableDeclaration'
|
|
&& parent.parent.kind === 'const'
|
|
&& parent.parent.declarations.length === 1
|
|
&& parent.parent.declarations[0] === parent
|
|
&& parent.parent.parent.type === 'ExportNamedDeclaration'
|
|
&& isVariableUnused(parent, context)
|
|
) {
|
|
return {
|
|
node: parent.parent.parent,
|
|
name: Symbol.for(parent.id.name),
|
|
text: sourceCode.getText(parent.id),
|
|
};
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// No default
|
|
}
|
|
}
|
|
|
|
function isVariableUnused(node, context) {
|
|
const variables = context.getDeclaredVariables(node);
|
|
|
|
/* c8 ignore next 3 */
|
|
if (variables.length !== 1) {
|
|
return false;
|
|
}
|
|
|
|
const [{identifiers, references}] = variables;
|
|
return identifiers.length === 1
|
|
&& identifiers[0] === node.id
|
|
&& references.length === 1
|
|
&& references[0].identifier === node.id;
|
|
}
|
|
|
|
function getImported(variable, sourceCode) {
|
|
const specifier = variable.defs[0].node;
|
|
const result = {
|
|
node: specifier,
|
|
declaration: specifier.parent,
|
|
variable,
|
|
isTypeImport: isTypeImport(specifier),
|
|
};
|
|
|
|
switch (specifier.type) {
|
|
case 'ImportDefaultSpecifier': {
|
|
return {
|
|
name: DEFAULT_SPECIFIER_NAME,
|
|
text: 'default',
|
|
...result,
|
|
};
|
|
}
|
|
|
|
case 'ImportSpecifier': {
|
|
return {
|
|
name: getSpecifierName(specifier.imported),
|
|
text: sourceCode.getText(specifier.imported),
|
|
...result,
|
|
};
|
|
}
|
|
|
|
case 'ImportNamespaceSpecifier': {
|
|
return {
|
|
name: NAMESPACE_SPECIFIER_NAME,
|
|
text: '*',
|
|
...result,
|
|
};
|
|
}
|
|
|
|
// No default
|
|
}
|
|
}
|
|
|
|
function getExports(imported, context, sourceCode) {
|
|
const exports = [];
|
|
for (const {identifier} of imported.variable.references) {
|
|
const exported = getExported(identifier, context, sourceCode);
|
|
|
|
if (!exported) {
|
|
continue;
|
|
}
|
|
|
|
/*
|
|
There is no substitution for:
|
|
|
|
```js
|
|
import * as foo from 'foo';
|
|
export default foo;
|
|
```
|
|
*/
|
|
if (imported.name === NAMESPACE_SPECIFIER_NAME && exported.name === DEFAULT_SPECIFIER_NAME) {
|
|
continue;
|
|
}
|
|
|
|
exports.push(exported);
|
|
}
|
|
|
|
return exports;
|
|
}
|
|
|
|
const schema = [
|
|
{
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
properties: {
|
|
ignoreUsedVariables: {
|
|
type: 'boolean',
|
|
default: false,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
/** @param {import('eslint').Rule.RuleContext} context */
|
|
function create(context) {
|
|
const sourceCode = context.getSourceCode();
|
|
const {ignoreUsedVariables} = {ignoreUsedVariables: false, ...context.options[0]};
|
|
const importDeclarations = new Set();
|
|
const exportDeclarations = [];
|
|
|
|
return {
|
|
'ImportDeclaration[specifiers.length>0]'(node) {
|
|
importDeclarations.add(node);
|
|
},
|
|
// `ExportAllDeclaration` and `ExportDefaultDeclaration` can't be reused
|
|
'ExportNamedDeclaration[source.type="Literal"]'(node) {
|
|
exportDeclarations.push(node);
|
|
},
|
|
* 'Program:exit'(program) {
|
|
for (const importDeclaration of importDeclarations) {
|
|
let variables = context.getDeclaredVariables(importDeclaration);
|
|
|
|
if (variables.some(variable => variable.defs.length !== 1 || variable.defs[0].parent !== importDeclaration)) {
|
|
continue;
|
|
}
|
|
|
|
variables = variables.map(variable => {
|
|
const imported = getImported(variable, sourceCode);
|
|
const exports = getExports(imported, context, sourceCode);
|
|
|
|
return {
|
|
variable,
|
|
imported,
|
|
exports,
|
|
};
|
|
});
|
|
|
|
if (
|
|
ignoreUsedVariables
|
|
&& variables.some(({variable, exports}) => variable.references.length !== exports.length)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const shouldUseSuggestion = ignoreUsedVariables
|
|
&& variables.some(({variable}) => variable.references.length === 0);
|
|
|
|
for (const {imported, exports} of variables) {
|
|
for (const exported of exports) {
|
|
const problem = {
|
|
node: exported.node,
|
|
messageId: MESSAGE_ID_ERROR,
|
|
data: {
|
|
exported: exported.text,
|
|
},
|
|
};
|
|
const fix = getFixFunction({
|
|
sourceCode,
|
|
imported,
|
|
exported,
|
|
exportDeclarations,
|
|
program,
|
|
});
|
|
|
|
if (shouldUseSuggestion) {
|
|
problem.suggest = [
|
|
{
|
|
messageId: MESSAGE_ID_SUGGESTION,
|
|
fix,
|
|
},
|
|
];
|
|
} else {
|
|
problem.fix = fix;
|
|
}
|
|
|
|
yield problem;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/** @type {import('eslint').Rule.RuleModule} */
|
|
module.exports = {
|
|
create,
|
|
meta: {
|
|
type: 'suggestion',
|
|
docs: {
|
|
description: 'Prefer `export…from` when re-exporting.',
|
|
},
|
|
fixable: 'code',
|
|
hasSuggestions: true,
|
|
schema,
|
|
messages,
|
|
},
|
|
};
|