Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix: no-duplicate-imports allow unmergeable (fixes #12758, fixes #12760) #14238

Merged
merged 9 commits into from Jun 4, 2021
Merged
23 changes: 22 additions & 1 deletion docs/rules/no-duplicate-imports.md
Expand Up @@ -12,7 +12,9 @@ import { find } from 'module';

## Rule Details

This rule requires that all imports from a single module exists in a single `import` statement.
An import that can be merged with another is a duplicate of that other.
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved

This rule requires that all imports from a single module that can be merged exists in a single `import` statement.
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved

Example of **incorrect** code for this rule:

Expand All @@ -33,6 +35,14 @@ import { merge, find } from 'module';
import something from 'another-module';
```

Example of **correct** code for this rule:

```js
// not mergable, as they would require new nodes to be created.
import { merge } from 'module';
import * as something from 'module';
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved
```

## Options

This rule takes one optional argument, an object with a single key, `includeExports` which is a `boolean`. It defaults to `false`.
Expand All @@ -58,3 +68,14 @@ import { merge, find } from 'module';

export { find };
```

There is a special case even the export is duplicate we ignore it, because it can't be merged with another import/export from the same source, it's when we have export with type export *.
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved

Example of **correct** code for this rule with the `{ "includeExports": true }` option:

```js

import { merge, find } from 'module';

export * from 'module';
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved
```
280 changes: 214 additions & 66 deletions lib/rules/no-duplicate-imports.js
Expand Up @@ -4,92 +4,225 @@
*/
"use strict";

//------------------------------------------------------------------------------
// Helpers
//------------------------------------------------------------------------------

const NAMED_TYPES = ["ImportSpecifier", "ExportSpecifier"];
const NAMESPACE_TYPES = [
"ImportNamespaceSpecifier",
"ExportNamespaceSpecifier"
];

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/**
* Returns the name of the module imported or re-exported.
* Check if (import|export) type is belong to (ImportSpecifier|ExportSpecifier) or (ImportNamespaceSpecifier|ExportNamespaceSpecifier).
* @param {string} importExportType An import type to get.
* @param {string} type Specifier type can be namespace or specifier
* @returns {boolean} True if (import|export) type is belong to (ImportSpecifier|ExportSpecifier) or (ImportNamespaceSpecifier|ExportNamespaceSpecifier) and false if it doesn't.
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved
*/
function isImportExportSpecifier(importExportType, type) {
const arrayToCheck = type === "named" ? NAMED_TYPES : NAMESPACE_TYPES;

return arrayToCheck.includes(importExportType);
}

/**
* Return the type of (import|export).
* @param {ASTNode} node A node to get.
* @returns {string} the name of the module, or empty string if no name.
* @returns {string} The type of the (import|export).
*/
function getValue(node) {
if (node && node.source && node.source.value) {
return node.source.value.trim();
function getImportExportType(node) {
if (node.specifiers && node.specifiers.length > 0) {
const nodeSpecifiers = node.specifiers;
const index = nodeSpecifiers.findIndex(
({ type }) =>
isImportExportSpecifier(type, "named") ||
isImportExportSpecifier(type, "namespace")
);
const i = index > -1 ? index : 0;

return nodeSpecifiers[i].type;
}
if (node.type === "ExportAllDeclaration") {
if (node.exported) {
return "ExportNamespaceSpecifier";
}
return "ExportAll";
}
return "SideEffectImport";
}

return "";
/**
* Returns a boolean indicates if two (import|export) can be merged
* @param {ASTNode} node1 A node to get.
* @param {ASTNode} node2 A node to get.
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved
* @returns {boolean} True if two (import|export) can be merged, false if they can't.
*/
function isImportExportCanBeMerged(node1, node2) {
const importExportType1 = getImportExportType(node1);
const importExportType2 = getImportExportType(node2);

if (
(importExportType1 === "ExportAll" &&
importExportType2 !== "ExportAll" &&
importExportType2 !== "SideEffectImport") ||
(importExportType1 !== "ExportAll" &&
importExportType1 !== "SideEffectImport" &&
importExportType2 === "ExportAll")
) {
return false;
}
if (
(isImportExportSpecifier(importExportType1, "namespace") &&
isImportExportSpecifier(importExportType2, "named")) ||
(isImportExportSpecifier(importExportType2, "namespace") &&
isImportExportSpecifier(importExportType1, "named"))
) {
return false;
}
return true;
}

/**
* Checks if the name of the import or export exists in the given array, and reports if so.
* @param {RuleContext} context The ESLint rule context object.
* @param {ASTNode} node A node to get.
* @param {string} value The name of the imported or exported module.
* @param {string[]} array The array containing other imports or exports in the file.
* @param {string} messageId A messageId to be reported after the name of the module
*
* @returns {void} No return value
* Returns a boolean if we should report (import/export).
* @param {ASTNode} node A node to be reported or not.
* @param {[ASTNode]} previousNodes An array contains previous nodes of the module imported or exported.
* @returns {boolean} True if the (import/export) should be reported.
*/
function checkAndReport(context, node, value, array, messageId) {
if (array.indexOf(value) !== -1) {
context.report({
node,
messageId,
data: {
module: value
}
});
function shouldReportImportExport(node, previousNodes) {
let i = 0;

while (i < previousNodes.length) {
if (isImportExportCanBeMerged(node, previousNodes[i])) {
return true;
}
i++;
}
return false;
}

/**
* @callback nodeCallback
* @param {ASTNode} node A node to handle.
* Returns array contains only nodes with declarations types equal to type.
* @param {[{node: ASTNode, declarationType: string}]} nodes An array contains objects, each object contains a node and a declaration type.
* @param {string} type Declaration type.
* @returns {[ASTNode]} An array contains only nodes with declarations types equal to type, if the nodes are null we return [].
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved
*/
function getNodesByDeclarationType(nodes, type) {
return nodes
.filter(({ declarationType }) => declarationType === type)
.map(({ node }) => node);
}

/**
* Returns the name of the module imported or re-exported.
* @param {ASTNode} node A node to get.
* @returns {string} The name of the module, or empty string if no name.
*/
function getModule(node) {
if (node && node.source && node.source.value) {
return node.source.value.trim();
}
return "";
}

/**
* Returns a function handling the imports of a given file
* Checks if the (import|export) can be merged with at least one import and one export, and reports if so.
mdjermanovic marked this conversation as resolved.
Show resolved Hide resolved
* @param {RuleContext} context The ESLint rule context object.
* @param {ASTNode} node A node to get.
* @param {Map} modules A Map object contains as a key a module name and as value an array contains objects, each object contains a node and a declaration type.
* @param {string} declarationType A declaration type can be an import or export.
* @param {boolean} includeExports Whether or not to check for exports in addition to imports.
* @param {string[]} importsInFile The array containing other imports in the file.
* @param {string[]} exportsInFile The array containing other exports in the file.
*
* @returns {nodeCallback} A function passed to ESLint to handle the statement.
* @returns {void} No return value.
*/
function handleImports(context, includeExports, importsInFile, exportsInFile) {
return function(node) {
const value = getValue(node);
function checkAndReport(
context,
node,
modules,
declarationType,
includeExports
) {
const module = getModule(node);

if (value) {
checkAndReport(context, node, value, importsInFile, "import");
if (modules.has(module)) {
const previousNodes = modules.get(module);
const messagesIds = [];
const importNodes = getNodesByDeclarationType(previousNodes, "import");
let exportNodes;

if (includeExports) {
exportNodes = getNodesByDeclarationType(previousNodes, "export");
}
if (declarationType === "import") {
if (shouldReportImportExport(node, importNodes)) {
messagesIds.push("import");
}
if (includeExports) {
checkAndReport(context, node, value, exportsInFile, "importAs");
if (shouldReportImportExport(node, exportNodes)) {
messagesIds.push("importAs");
}
}
} else if (declarationType === "export") {
if (shouldReportImportExport(node, exportNodes)) {
messagesIds.push("export");
}
if (shouldReportImportExport(node, importNodes)) {
messagesIds.push("exportAs");
}

importsInFile.push(value);
}
};
messagesIds.forEach(messageId =>
context.report({
node,
messageId,
data: {
module
}
}));
}
}

/**
* Returns a function handling the exports of a given file
* @callback nodeCallback
* @param {ASTNode} node A node to handle.
*/

/**
* Returns a function handling the (imports|exports) of a given file
* @param {RuleContext} context The ESLint rule context object.
* @param {string[]} importsInFile The array containing other imports in the file.
* @param {string[]} exportsInFile The array containing other exports in the file.
*
* @param {Map} modules A Map object contains as a key a module name and as value an array contains objects, each object contains a node and a declaration type.
* @param {string} declarationType A declaration type can be an import or export.
* @param {boolean} includeExports Whether or not to check for exports in addition to imports.
* @returns {nodeCallback} A function passed to ESLint to handle the statement.
*/
function handleExports(context, importsInFile, exportsInFile) {
function handleImportsExports(
context,
modules,
declarationType,
includeExports
) {
return function(node) {
const value = getValue(node);
const module = getModule(node);

if (module) {
checkAndReport(
context,
node,
modules,
declarationType,
includeExports
);
const currentNode = { node, declarationType };
let nodes = [currentNode];

if (value) {
checkAndReport(context, node, value, exportsInFile, "export");
checkAndReport(context, node, value, importsInFile, "exportAs");
if (modules.has(module)) {
const previousNodes = modules.get(module);

exportsInFile.push(value);
nodes = [...previousNodes, currentNode];
}
modules.set(module, nodes);
}
};
}
Expand All @@ -105,16 +238,19 @@ module.exports = {
url: "https://eslint.org/docs/rules/no-duplicate-imports"
},

schema: [{
type: "object",
properties: {
includeExports: {
type: "boolean",
default: false
}
},
additionalProperties: false
}],
schema: [
{
type: "object",
properties: {
includeExports: {
type: "boolean",
default: false
}
},
additionalProperties: false
}
],

messages: {
import: "'{{module}}' import is duplicated.",
importAs: "'{{module}}' import is duplicated as export.",
Expand All @@ -125,18 +261,30 @@ module.exports = {

create(context) {
const includeExports = (context.options[0] || {}).includeExports,
importsInFile = [],
exportsInFile = [];

modules = new Map();
const handlers = {
ImportDeclaration: handleImports(context, includeExports, importsInFile, exportsInFile)
ImportDeclaration: handleImportsExports(
context,
modules,
"import",
includeExports
)
};

if (includeExports) {
handlers.ExportNamedDeclaration = handleExports(context, importsInFile, exportsInFile);
handlers.ExportAllDeclaration = handleExports(context, importsInFile, exportsInFile);
handlers.ExportNamedDeclaration = handleImportsExports(
context,
modules,
"export",
includeExports
);
handlers.ExportAllDeclaration = handleImportsExports(
context,
modules,
"export",
includeExports
);
}

return handlers;
}
};