Repository: javierbrea/eslint-plugin-boundaries (js)

Issue:
Disallow the usage of only some destructured imports in no-external rule
Currently, the `no-external` rule allows only to forbid the usage of some external modules in specific element types, but some times it is needed to only forbid the usage of some methods of from the external libraries. For example, talking about the `react-router-dom` library, maybe you want to disallow the usage of `Route` in React components, but allow the usage of `Link`:

* allowed
```
import { Link } from "react-router-dom"; 
```

* not allowed
```
import { Route } from "react-router-dom"; 
```

This could be solved adding a new method for configuring to the `no-external` rule. If the library name is expressed as an object, it will contain an array with the forbidden named exports:

```json
{
  "settings": {
    "boundaries/types": ["helpers", "components", "modules"],
  },
  "rules": {
    "boundaries/no-external": [2, {
        "forbid": {
          "helpers": ["react"],
          "components": [
            { "react-router-dom": ["Route", "Switch", "useHistory", "withRouter"] }
           ],
          "modules": []
        }
      }
    ]
  }
}
```

Pull request description:
Disallow the usage of only some destructured imports in no-external rule
### Added
- feat(no-external): Allow forbid importing external libraries specifiers
- fix(no-external): Do not allow importing subfolders of forbidden external libraries

closes #9 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f988f7..d2ddfbf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ## [unreleased]
 ### Added
+- feat(no-external): Allow forbid importing external libraries specifiers
+- fix(no-external): Do not allow importing subfolders of forbidden external libraries
+
 ### Changed
 ### Fixed
 ### Removed
diff --git a/README.md b/README.md
index c093ab6..87f29e0 100644
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@ Read the [docs of the `boundaries/allowed-types` rule](docs/rules/allowed-types.
 
 ### Forbidden external modules
 
-External dependencies used by each type of element in your project can be checked using this rule. For example, you can define that "helpers" can't import `react`, or "components" can't import  `react-router-dom`.
+External dependencies used by each type of element in your project can be checked using this rule. For example, you can define that "helpers" can't import `react`, or "components" can't import  `react-router-dom`, or modules can't import `{ Link } from react-router-dom`.
 
 Read the [docs of the `boundaries/no-external` rule](docs/rules/no-external.md) for further info.
 
@@ -82,7 +82,7 @@ So there are basic rules that have to be followed to make it work:
 * __[boundaries/no-private](docs/rules/no-private.md)__: Enforce elements to not use private elements of another element.
 * __[boundaries/entry-point](docs/rules/entry-point.md)__: Prevent other elements importing a file different from the allowed entry point.
 * __[boundaries/allowed-types](docs/rules/allowed-types.md)__: Prevent elements of one type to import any other element types not specified in the configuration of this rule.
-* __[boundaries/no-external](docs/rules/no-external.md)__: Enforce elements of one type to not use some external dependencies.
+* __[boundaries/no-external](docs/rules/no-external.md)__: Enforce elements of one type to not use some external dependencies or part of them.
 * [boundaries/prefer-recognized-types](docs/rules/prefer-recognized-types.md): Prevent creating files not recognized as any of the element types.
 * [boundaries/no-import-not-recognized-types](docs/rules/no-import-not-recognized-types.md): Prevent importing not recognized elements from the recognized ones.
 * [boundaries/no-import-ignored](docs/rules/no-import-ignored.md): Prevent importing files marked as ignored from the recognized elements.
diff --git a/docs/rules/no-external.md b/docs/rules/no-external.md
index f8e4de5..d5eb3b3 100644
--- a/docs/rules/no-external.md
+++ b/docs/rules/no-external.md
@@ -32,7 +32,12 @@ src/
         "forbid": {
           "helpers": ["react"],
           "components": ["react-router-dom"],
-          "modules": []
+          "modules": [
+            "material-ui",
+            {
+              "react-router-dom": ["Link"],
+            }
+          ]
         }
       }
     ]
@@ -57,9 +62,30 @@ _Components can't import `react-router-dom`:_
 import { withRouter } from "react-router-dom"
 ```
 
+_Modules can't import `material-ui`:_
+
+```js
+// src/modules/module-a/ModuleA.js
+import { Label } from "material-ui/core";
+```
+
+_Modules can't import Link from `react-router-dom`:_
+
+```js
+// src/modules/module-a/ModuleA.js
+import { Link } from "react-router-dom";
+```
+
 ### Examples of **correct** code for this rule:
 
-_Modules can import `react-router-dom`:_
+_Modules can import `react`:_
+
+```js
+// src/modules/module-a/ModuleA.js
+import React from "react"
+```
+
+_Modules can import `withRouter` from `react-router-dom` (Note in the configuration that only `Link` is forbidden for modules)._
 
 ```js
 // src/modules/module-a/ModuleA.js
@@ -75,7 +101,10 @@ import { withRouter } from "react-router-dom"
 ```
 
 * `enabled`: for enabling the rule. 0=off, 1=warn, 2=error.
-* `forbid`: Define forbidden external dependencies for each element type. (Use element type as a key in the object to define its forbidden dependencies)
+* `forbid`: Define forbidden external dependencies for each element type. (Use element type as a key in the object to define its forbidden dependencies as an array)
+  * Dependencies can be defined as:
+    * `<String>`: The name of the library not allowed to be imported.
+    * `{ <String>: [<String>, <String>]}`: An object containing a key with the library name, and and array of forbidden import specifiers as value.
 
 ```json
 {
@@ -84,7 +113,12 @@ import { withRouter } from "react-router-dom"
         "forbid": {
           "helpers": ["react"],
           "components": ["react-router-dom"],
-          "modules": []
+          "modules": [
+            "material-ui",
+            {
+              "react-router-dom": ["Link"],
+            }
+          ]
         }
       }
     ]
diff --git a/src/rules/no-external.js b/src/rules/no-external.js
index a24e999..39b0a31 100644
--- a/src/rules/no-external.js
+++ b/src/rules/no-external.js
@@ -3,6 +3,23 @@ const { RULE_NO_EXTERNAL } = require("../constants/settings");
 const { meta, dependencyLocation, getContextInfo, checkOptions } = require("../helpers/rules");
 const { getDependencyInfo, isNotRecognizedOrIgnored } = require("../helpers/elements");
 
+const forbiddenSpecifiers = (forbiddenSpecifierNames, specifiers) => {
+  if (!forbiddenSpecifierNames) {
+    return [];
+  }
+  const importedSpecifiersNames = specifiers
+    .filter((specifier) => {
+      return specifier.type === "ImportSpecifier" && specifier.imported.name;
+    })
+    .map((specifier) => specifier.imported.name);
+  return forbiddenSpecifierNames.reduce((forbiddenFound, forbiddenSpecifier) => {
+    if (importedSpecifiersNames.includes(forbiddenSpecifier)) {
+      forbiddenFound.push(forbiddenSpecifier);
+    }
+    return forbiddenFound;
+  }, []);
+};
+
 module.exports = {
   ...meta(`Enforce elements of one type to not use some external dependencies`, PLUGIN_NAME, [
     {
@@ -23,23 +40,55 @@ module.exports = {
     }
     checkOptions(context, "forbid", RULE_NO_EXTERNAL);
     const forbidOptions = (context.options[0] && context.options[0].forbid) || {};
+    const currentElementOptions = forbidOptions[currentElementInfo.type];
+    const currentElementDestructuredOptions = {};
+
+    if (currentElementOptions) {
+      currentElementOptions.forEach((forbid) => {
+        const libraryNameAsKey = Object.keys(forbid)[0];
+        if (
+          typeof forbid !== "string" &&
+          libraryNameAsKey &&
+          Array.isArray(forbid[libraryNameAsKey])
+        ) {
+          currentElementDestructuredOptions[libraryNameAsKey] = forbid[libraryNameAsKey];
+        }
+      });
+    }
 
     return {
       ImportDeclaration: (node) => {
         const dependencyInfo = getDependencyInfo(fileName, node.source.value, context.settings);
-        const currentElementOptions = forbidOptions[currentElementInfo.type];
+        const cleanDependencyName = dependencyInfo.name.split("/")[0];
 
-        if (
-          !dependencyInfo.isLocal &&
-          currentElementOptions &&
-          currentElementOptions.includes(dependencyInfo.name)
-        ) {
-          context.report({
-            message: `Usage of external module '${dependencyInfo.name}' is not allowed in '${currentElementInfo.type}'`,
-            type: PLUGIN_NAME,
-            node: node,
-            ...dependencyLocation(node, context),
-          });
+        if (!dependencyInfo.isLocal && currentElementOptions) {
+          if (currentElementOptions.includes(cleanDependencyName)) {
+            // library is not allowed
+            context.report({
+              message: `Usage of external module '${cleanDependencyName}' is not allowed in '${currentElementInfo.type}'`,
+              type: PLUGIN_NAME,
+              node: node,
+              ...dependencyLocation(node, context),
+            });
+          } else {
+            const forbiddenSpecifiersFound = forbiddenSpecifiers(
+              currentElementDestructuredOptions[cleanDependencyName],
+              node.source.parent.specifiers
+            );
+            if (forbiddenSpecifiersFound.length > 0) {
+              // specifier is not allowed
+              context.report({
+                message: `Usage of '${forbiddenSpecifiersFound.join(
+                  ", "
+                )}' from external module '${cleanDependencyName}' is not allowed in '${
+                  currentElementInfo.type
+                }'`,
+                type: PLUGIN_NAME,
+                node: node,
+                ...dependencyLocation(node, context),
+              });
+            }
+          }
         }
       },
     };


Test patch:
diff --git a/test/src/rules/no-external.spec.js b/test/src/rules/no-external.spec.js
index 66f3374..c962b69 100644
--- a/test/src/rules/no-external.spec.js
+++ b/test/src/rules/no-external.spec.js
@@ -8,12 +8,25 @@ const ruleTester = createRuleTester();
 const errorMessage = (elementType, dependencyName) =>
   `Usage of external module '${dependencyName}' is not allowed in '${elementType}'`;
 
+const destructuredErrorMessage = (elementType, imported, dependencyName) =>
+  `Usage of '${imported}' from external module '${dependencyName}' is not allowed in '${elementType}'`;
+
 const options = [
   {
     forbid: {
-      helpers: ["react"],
+      helpers: [
+        "react",
+        {
+          "foo-library": ["Link", "Router"],
+        },
+      ],
       components: ["react-router-dom"],
-      modules: [],
+      modules: [
+        "material-ui",
+        {
+          "react-router-dom": ["Link"],
+        },
+      ],
     },
   },
 ];
@@ -47,6 +60,72 @@ ruleTester.run(RULE, rule, {
       code: "import { withRouter } from 'react-router-dom'",
       options,
     },
+    // Modules can import react
+    {
+      filename: absoluteFilePath("src/modules/module-a/ModuleA.js"),
+      code: "import React from 'react'",
+      options,
+    },
+    // Helpers can import foo-library
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import FooLibrary from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can import foo-library using namespace
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import FooLibrary, * as Namespace from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can import * from foo-library
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import * as Link from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can import * from foo-library
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import * as FooLibrary from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can import Foo from foo-library
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import { Foo } from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
   ],
   invalid: [
     // Helpers can't import react
@@ -73,5 +152,77 @@ ruleTester.run(RULE, rule, {
         },
       ],
     },
+    // Helpers can't import foo-library Link
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import { Link } from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can't import foo-library Link
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import FooLibrary, { Link } from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can't import foo-library Link when there are more specifiers
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import { Link, Foo } from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can't import foo-library Link when specifers are renamed locally
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import { Link as LocalLink, Foo } from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Helpers can't import foo-library Link nor Router
+    {
+      filename: absoluteFilePath("src/helpers/helper-a/HelperA.js"),
+      code: "import { Link as LocalLink, Router } from 'foo-library'",
+      options,
+      errors: [
+        {
+          message: destructuredErrorMessage("helpers", "Link, Router", "foo-library"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
+    // Modules can't import material-ui
+    {
+      filename: absoluteFilePath("src/modules/module-a/ModuleA.js"),
+      code: "import { Label } from 'material-ui/core'",
+      options,
+      errors: [
+        {
+          message: errorMessage("modules", "material-ui"),
+          type: "ImportDeclaration",
+        },
+      ],
+    },
   ],
 });