Repository: typescript-eslint/tslint-to-eslint-config (ts)

Issue:
"do not have an ESLint equivalent" is misleading
### 🐛 Bug Report

-   `tslint-to-eslint-config` version: 0.4.0
-   ESLint version: N/A
-   Node version: N/A

#### Actual Behavior

When a TSLint configuration contains a rule not recognized by `tslint-to-eslint-config`, the message is in the format:

```
some-tslint-rule does not yet have an ESLint equivalent
```

This is actually kind of misleading; the issue could alternately be that this tool doesn't yet know of the ESLint equivalent. 🙁 

#### Expected Behavior

The messaging should be something like, maybe...

```
tslint-to-eslint-config does not know the ESLint equivalent for TSLint's some-tslint-rule
```

#### Reproduction

Create a `tslint.json` file with:

```json
{
    "rules": { "my-custom-rule": true }
}
```

Then run:

```shell
npx tslint-to-eslint-config@0.4.0
```

Pull request description:
fix(reporting): "do not have an ESLint equivalent" is misleading #299
<!--
👋 Hi, thanks for sending a PR to tslint-to-eslint-config! 💖
Please fill out all fields below to ensure your PR is reviewed quickly.
-->

## PR Checklist

-   [x] Addresses an existing issue: fixes #299
-   [x] That issue was marked as [`status: accepting prs`](https://github.com/typescript-eslint/tslint-to-eslint-config/labels/status%3A%20accepting%20prs)

## Overview

see the issue description #299

Patch:
diff --git a/src/reporting/reportConversionResults.ts b/src/reporting/reportConversionResults.ts
index af5e167..6488911 100644
--- a/src/reporting/reportConversionResults.ts
+++ b/src/reporting/reportConversionResults.ts
@@ -29,7 +29,7 @@ export const reportConversionResults = (
         logMissingConversionTarget(
             "rule",
             (setting: TSLintRuleOptions) =>
-                `${setting.ruleName} does not yet have an ESLint equivalent.${EOL}`,
+                `tslint-to-eslint-config does not know the ESLint equivalent for TSLint's "${setting.ruleName}"${EOL}`,
             ruleConversionResults.missing,
             dependencies.logger,
             [
diff --git a/src/reporting/reportEditorSettingConversionResults.ts b/src/reporting/reportEditorSettingConversionResults.ts
index 12ebf1a..938988a 100644
--- a/src/reporting/reportEditorSettingConversionResults.ts
+++ b/src/reporting/reportEditorSettingConversionResults.ts
@@ -28,7 +28,8 @@ export const reportEditorSettingConversionResults = (
     if (editorSettingConversionResults.missing.length !== 0) {
         const missingEditorSettingOutputMapping = (
             editorSetting: Pick<EditorSetting, "editorSettingName">,
-        ) => `${editorSetting.editorSettingName} does not yet have an ESLint equivalent.${EOL}`;
+        ) =>
+            `tslint-to-eslint-config does not know the ESLint equivalent for TSLint's "${editorSetting.editorSettingName}"${EOL}`;
         logMissingConversionTarget(
             "editor setting",
             missingEditorSettingOutputMapping,
diff --git a/src/rules/converters/import-blacklist.ts b/src/rules/converters/import-blacklist.ts
new file mode 100644
index 0000000..0f0a7f2
--- /dev/null
+++ b/src/rules/converters/import-blacklist.ts
@@ -0,0 +1,60 @@
+import { RuleConverter } from "../converter";
+import { RequireAtLeastOne } from "../../utils";
+
+type ESLintOptionPath = {
+    name: string;
+    importNames: string[];
+    message?: string;
+};
+type ESLintSimpleOption = string[];
+type ESLintComplexOption = RequireAtLeastOne<{
+    paths: (string | ESLintOptionPath)[];
+    patterns: string[];
+}>;
+type ESLintOptions = ESLintSimpleOption | ESLintComplexOption;
+
+const NOTICE_MATCH_PATTERNS =
+    "ESLint and TSLint use different strategies to match patterns. TSLint uses standard regular expressions, but ESLint .gitignore spec.";
+
+export const convertImportBlacklist: RuleConverter = tslintRule => {
+    let ruleArguments: ESLintOptions = [];
+    const notices = [];
+
+    if (tslintRule.ruleArguments.every(isString)) {
+        ruleArguments = tslintRule.ruleArguments;
+    } else {
+        const objectOption = tslintRule.ruleArguments.reduce((rules, rule) => {
+            if (!Array.isArray(rule)) {
+                const eslintRule = isString(rule)
+                    ? rule
+                    : {
+                          name: Object.keys(rule)[0],
+                          importNames: Object.values(rule)[0] as string[],
+                      };
+                return { ...rules, paths: [...(rules.paths || []), eslintRule] };
+            }
+
+            return { ...rules, patterns: [...(rules.patterns || []), ...rule] };
+        }, {} as ESLintComplexOption);
+
+        if ("patterns" in objectOption && objectOption.patterns.length > 0) {
+            notices.push(NOTICE_MATCH_PATTERNS);
+        }
+
+        ruleArguments = [objectOption];
+    }
+
+    return {
+        rules: [
+            {
+                ruleArguments,
+                ...(notices.length > 0 && { notices }),
+                ruleName: "no-restricted-imports",
+            },
+        ],
+    };
+};
+
+function isString(value: string): boolean {
+    return typeof value === "string";
+}
diff --git a/src/rules/rulesConverters.ts b/src/rules/rulesConverters.ts
index 084e91c..4ac20ea 100644
--- a/src/rules/rulesConverters.ts
+++ b/src/rules/rulesConverters.ts
@@ -18,6 +18,7 @@ import { convertEofline } from "./converters/eofline";
 import { convertFileNameCasing } from "./converters/file-name-casing";
 import { convertForin } from "./converters/forin";
 import { convertFunctionConstructor } from "./converters/function-constructor";
+import { convertImportBlacklist } from "./converters/import-blacklist";
 import { convertIncrementDecrement } from "./converters/increment-decrement";
 import { convertIndent } from "./converters/indent";
 import { convertInterfaceName } from "./converters/interface-name";
@@ -155,6 +156,7 @@ export const rulesConverters = new Map([
     ["file-name-casing", convertFileNameCasing],
     ["forin", convertForin],
     ["function-constructor", convertFunctionConstructor],
+    ["import-blacklist", convertImportBlacklist],
     ["increment-decrement", convertIncrementDecrement],
     ["indent", convertIndent],
     ["interface-name", convertInterfaceName],
@@ -275,7 +277,6 @@ export const rulesConverters = new Map([
 
     // TSLint core rules:
     // ["ban", convertBan], // no-restricted-properties
-    // ["import-blacklist", convertImportBlacklist], // no-restricted-imports
 
     // tslint-microsoft-contrib rules:
     // ["max-func-body-length", convertMaxFuncBodyLength],
diff --git a/src/utils.ts b/src/utils.ts
index 65139d3..c69d6f9 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -8,6 +8,11 @@ export type RemoveErrors<Items> = {
 
 export type PromiseValue<T> = T extends Promise<infer R> ? R : never;
 
+export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>> &
+    {
+        [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
+    }[Keys];
+
 export const uniqueFromSources = <T>(...sources: (T | T[] | undefined)[]) => {
     const items: T[] = [];
 


Test patch:
diff --git a/src/reporting/reportConversionResults.test.ts b/src/reporting/reportConversionResults.test.ts
index 1ef8a27..0cbdb4c 100644
--- a/src/reporting/reportConversionResults.test.ts
+++ b/src/reporting/reportConversionResults.test.ts
@@ -172,7 +172,7 @@ describe("reportConversionResults", () => {
         );
         expectEqualWrites(
             logger.info.write,
-            "tslint-rule-one does not yet have an ESLint equivalent.",
+            'tslint-to-eslint-config does not know the ESLint equivalent for TSLint\'s "tslint-rule-one"',
         );
     });
 
@@ -206,8 +206,8 @@ describe("reportConversionResults", () => {
         );
         expectEqualWrites(
             logger.info.write,
-            "tslint-rule-one does not yet have an ESLint equivalent.",
-            "tslint-rule-two does not yet have an ESLint equivalent.",
+            'tslint-to-eslint-config does not know the ESLint equivalent for TSLint\'s "tslint-rule-one"',
+            'tslint-to-eslint-config does not know the ESLint equivalent for TSLint\'s "tslint-rule-two"',
         );
     });
 
diff --git a/src/reporting/reportEditorSettingConversionResults.test.ts b/src/reporting/reportEditorSettingConversionResults.test.ts
index c7df319..9bbff13 100644
--- a/src/reporting/reportEditorSettingConversionResults.test.ts
+++ b/src/reporting/reportEditorSettingConversionResults.test.ts
@@ -126,7 +126,7 @@ describe("reportEditorSettingConversionResults", () => {
         );
         expectEqualWrites(
             logger.info.write,
-            "tslint-editor-setting-one does not yet have an ESLint equivalent.",
+            'tslint-to-eslint-config does not know the ESLint equivalent for TSLint\'s "tslint-editor-setting-one"',
         );
     });
 
@@ -156,8 +156,8 @@ describe("reportEditorSettingConversionResults", () => {
         );
         expectEqualWrites(
             logger.info.write,
-            "tslint-editor-setting-one does not yet have an ESLint equivalent.",
-            "tslint-editor-setting-two does not yet have an ESLint equivalent.",
+            'tslint-to-eslint-config does not know the ESLint equivalent for TSLint\'s "tslint-editor-setting-one"',
+            'tslint-to-eslint-config does not know the ESLint equivalent for TSLint\'s "tslint-editor-setting-two"',
         );
     });
 });
diff --git a/src/rules/converters/tests/import-blacklist.test.ts b/src/rules/converters/tests/import-blacklist.test.ts
new file mode 100644
index 0000000..7edf8c8
--- /dev/null
+++ b/src/rules/converters/tests/import-blacklist.test.ts
@@ -0,0 +1,74 @@
+import { convertImportBlacklist } from "../import-blacklist";
+
+describe(convertImportBlacklist, () => {
+    test.each([
+        [[], []],
+        [["rxjs"], ["rxjs"]],
+        [
+            ["rxjs", "lodash"],
+            ["rxjs", "lodash"],
+        ],
+        [
+            [".*\\.temp$", ".*\\.tmp$"],
+            [".*\\.temp$", ".*\\.tmp$"],
+        ],
+        [
+            ["moment", "date-fns", ["eslint/*"]],
+            [{ patterns: ["eslint/*"], paths: ["moment", "date-fns"] }],
+        ],
+        [
+            [{ lodash: ["pullAll", "pull"] }],
+            [
+                {
+                    paths: [
+                        {
+                            name: "lodash",
+                            importNames: ["pullAll", "pull"],
+                        },
+                    ],
+                },
+            ],
+        ],
+        [
+            [
+                "rxjs",
+                [".*\\.temp$", ".*\\.tmp$"],
+                { lodash: ["pullAll", "pull"] },
+                { dummy: ["default"] },
+            ],
+            [
+                {
+                    paths: [
+                        "rxjs",
+                        {
+                            name: "lodash",
+                            importNames: ["pullAll", "pull"],
+                        },
+                        {
+                            name: "dummy",
+                            importNames: ["default"],
+                        },
+                    ],
+                    patterns: [".*\\.temp$", ".*\\.tmp$"],
+                },
+            ],
+        ],
+    ] as any[][])("convert %j", (ruleArguments: any[], expected: any[]) => {
+        const result = convertImportBlacklist({ ruleArguments });
+        const hasPatterns = typeof expected[0] === "object" && "patterns" in expected[0];
+
+        expect(result).toEqual({
+            rules: [
+                {
+                    ruleArguments: expected,
+                    ...(hasPatterns && {
+                        notices: [
+                            "ESLint and TSLint use different strategies to match patterns. TSLint uses standard regular expressions, but ESLint .gitignore spec.",
+                        ],
+                    }),
+                    ruleName: "no-restricted-imports",
+                },
+            ],
+        });
+    });
+});