Repository: eslint/eslint
Pull request #14287 (2021-04-02 04:50:13)

Chore: Remove lodash
<!--
    Thank you for contributing!

    ESLint adheres to the [JS Foundation Code of Conduct](https://js.foundation/community/code-of-conduct).
-->

#### Prerequisites checklist

- [x] I have read the [contributing guidelines](https://github.com/eslint/eslint/blob/master/CONTRIBUTING.md).

#### What is the purpose of this pull request? (put an "X" next to an item)

[ ] Documentation update
[ ] Bug fix ([template](https://raw.githubusercontent.com/eslint/eslint/master/templates/bug-report.md))
[ ] New rule ([template](https://raw.githubusercontent.com/eslint/eslint/master/templates/rule-proposal.md))
[ ] Changes an existing rule ([template](https://raw.githubusercontent.com/eslint/eslint/master/templates/rule-change-proposal.md))
[ ] Add autofixing to a rule
[ ] Add a CLI option
[ ] Add something to the core
[x] Other, please explain: _Reduce dependencies_

<!--
    If the item you've checked above has a template, please paste the template questions below and answer them. (If this pull request is addressing an issue, you can just paste a link to the issue here instead.)
-->

<!--
    Please ensure your pull request is ready:

    - Read the pull request guide (https://eslint.org/docs/developer-guide/contributing/pull-requests)
    - Include tests for this change
    - Update documentation for this change (if appropriate)
-->

<!--
    The following is required for all pull requests:
-->

#### What changes did you make? (Give an overview)

Addresses part of #14098. Removes lodash entirely from the project, replacing all uses with native code, custom code, and a handful of other tiny modules.

<details>
<summary>node_modules size comparison</summary>

```diff
~/git/eslint-test-master$ echo '{ "dependencies": { "eslint": "eslint/eslint" } }' > package.json
~/git/eslint-test-master$ npm install >/dev/null
~/git/eslint-test-master$ du -sh node_modules
22M	node_modules

~/git/eslint-test-stephenwade$ echo '{ "dependencies": { "eslint": "stephenwade/eslint#remove-lodash-3" } }' > package.json
~/git/eslint-test-stephenwade$ npm install >/dev/null
~/git/eslint-test-stephenwade$ du -sh node_modules
17M	node_modules

~/git$ diff -U0 <(cd eslint-test-master; du -hd1 node_modules) <(cd eslint-test-stephenwade; du -hd1 node_modules) | grep "^[-+]\d"
-4.9M	node_modules/lodash
-16K	node_modules/escape-string-regexp
+20K	node_modules/escape-string-regexp
+28K	node_modules/deep-extend
-240K	node_modules/@babel
+256K	node_modules/@babel
-22M	node_modules
+17M	node_modules
```
</details>

#### Is there anything you'd like reviewers to focus on?

There is one fairly major change in here. I replaced lodash.template by creating a JS file in place of each template. Let me know if I should take a different approach with that or if I should split it out into a separate PR.

eslint-github-bot[bot]: Hi @stephenwade!, thanks for the Pull Request

The pull request title isn't properly formatted. We ask that you update the message to match this format, as we use it to generate changelogs and automate releases.

- The commit message tag must be one of the following:

  The `Tag` is one of the following:

  - Fix - for a bug fix.
  - Update - either for a backwards-compatible enhancement or for a rule change that adds reported problems.
  - New - implements a new feature.
  - Breaking - for a backwards-incompatible enhancement or feature.
  - Docs - changes to documentation only.
  - Build - changes to build process only.
  - Upgrade - for a dependency upgrade.
  - Chore - for anything that isn't user-facing (for example, refactoring, adding tests, etc.).

  You can use the [labels of the issue you are working on](https://eslint.org/docs/developer-guide/contributing/working-on-issues#issue-labels) to determine the best tag.

- There should be a space following the initial tag and colon, for example 'New: Message'.

Read more about contributing to ESLint [here](https://eslint.org/docs/developer-guide/contributing/)

stephenwade: I'll take a look at the CI failure tomorrow. Looks like I missed a .flat call

Patch:
diff --git a/bin/eslint.js b/bin/eslint.js
index 86291b0f5..5fa576682 100755
--- a/bin/eslint.js
+++ b/bin/eslint.js
@@ -66,11 +66,8 @@ function readStdin() {
  */
 function getErrorMessage(error) {
 
-    // Lazy loading because those are used only if error happened.
-    const fs = require("fs");
-    const path = require("path");
+    // Lazy loading because this is used only if an error happened.
     const util = require("util");
-    const lodash = require("lodash");
 
     // Foolproof -- thirdparty module might throw non-object.
     if (typeof error !== "object" || error === null) {
@@ -80,14 +77,7 @@ function getErrorMessage(error) {
     // Use templates if `error.messageTemplate` is present.
     if (typeof error.messageTemplate === "string") {
         try {
-            const templateFilePath = path.resolve(
-                __dirname,
-                `../messages/${error.messageTemplate}.txt`
-            );
-
-            // Use sync API because Node.js should exit at this tick.
-            const templateText = fs.readFileSync(templateFilePath, "utf-8");
-            const template = lodash.template(templateText);
+            const template = require(`../messages/${error.messageTemplate}.js`);
 
             return template(error.messageData || {});
         } catch {
diff --git a/docs/developer-guide/code-path-analysis.md b/docs/developer-guide/code-path-analysis.md
index 7762bc5d6..cd3fc2936 100644
--- a/docs/developer-guide/code-path-analysis.md
+++ b/docs/developer-guide/code-path-analysis.md
@@ -195,8 +195,6 @@ bar();
 ### To check whether or not this is reachable
 
 ```js
-var last = require("lodash").last;
-
 function isReachable(segment) {
     return segment.reachable;
 }
@@ -215,7 +213,7 @@ module.exports = function(context) {
 
         // Checks reachable or not.
         "ExpressionStatement": function(node) {
-            var codePath = last(codePathStack);
+            var codePath = codePathStack[codePathStack.length - 1];
 
             // Checks the current code path segments.
             if (!codePath.currentSegments.some(isReachable)) {
@@ -239,8 +237,6 @@ So a rule must not modify those instances.
 Please use a map of information instead.
 
 ```js
-var last = require("lodash").last;
-
 function hasCb(node, context) {
     if (node.type.indexOf("Function") !== -1) {
         return context.getDeclaredVariables(node).some(function(v) {
@@ -285,8 +281,10 @@ module.exports = function(context) {
 
         // Manages state of code paths.
         "onCodePathSegmentStart": function(segment) {
+            var funcInfo = funcInfoStack[funcInfoStack.length - 1];
+
             // Ignores if `cb` doesn't exist.
-            if (!last(funcInfoStack).hasCb) {
+            if (!funcInfo.hasCb) {
                 return;
             }
 
@@ -304,7 +302,7 @@ module.exports = function(context) {
 
         // Checks reachable or not.
         "CallExpression": function(node) {
-            var funcInfo = last(funcInfoStack);
+            var funcInfo = funcInfoStack[funcInfoStack.length - 1];
 
             // Ignores if `cb` doesn't exist.
             if (!funcInfo.hasCb) {
diff --git a/docs/developer-guide/code-path-analysis/README.md b/docs/developer-guide/code-path-analysis/README.md
index c283d51bf..1c84b2e1f 100644
--- a/docs/developer-guide/code-path-analysis/README.md
+++ b/docs/developer-guide/code-path-analysis/README.md
@@ -195,8 +195,6 @@ bar();
 ### To check whether or not this is reachable
 
 ```js
-var last = require("lodash").last;
-
 function isReachable(segment) {
     return segment.reachable;
 }
@@ -215,7 +213,7 @@ module.exports = function(context) {
 
         // Checks reachable or not.
         "ExpressionStatement": function(node) {
-            var codePath = last(codePathStack);
+            var codePath = codePathStack[codePathStack.length - 1];
 
             // Checks the current code path segments.
             if (!codePath.currentSegments.some(isReachable)) {
@@ -239,8 +237,6 @@ So a rule must not modify those instances.
 Please use a map of information instead.
 
 ```js
-var last = require("lodash").last;
-
 function hasCb(node, context) {
     if (node.type.indexOf("Function") !== -1) {
         return context.getDeclaredVariables(node).some(function(v) {
@@ -285,8 +281,10 @@ module.exports = function(context) {
 
         // Manages state of code paths.
         "onCodePathSegmentStart": function(segment) {
+            var funcInfo = funcInfoStack[funcInfoStack - 1];
+
             // Ignores if `cb` doesn't exist.
-            if (!last(funcInfoStack).hasCb) {
+            if (!funcInfo.hasCb) {
                 return;
             }
 
@@ -304,7 +302,7 @@ module.exports = function(context) {
 
         // Checks reachable or not.
         "CallExpression": function(node) {
-            var funcInfo = last(funcInfoStack);
+            var funcInfo = funcInfoStack[funcInfoStack - 1];
 
             // Ignores if `cb` doesn't exist.
             if (!funcInfo.hasCb) {
diff --git a/docs/rules/no-restricted-imports.md b/docs/rules/no-restricted-imports.md
index 0f6841340..f1577d7d8 100644
--- a/docs/rules/no-restricted-imports.md
+++ b/docs/rules/no-restricted-imports.md
@@ -75,7 +75,21 @@ or like this if you need to restrict only certain imports from a module:
 }]
 ```
 
-The custom message will be appended to the default error message. Please note that you may not specify custom error messages for restricted patterns as a particular import may match more than one pattern.
+or like this if you want to apply a custom message to pattern matches:
+
+```json
+"no-restricted-imports": ["error", {
+    "patterns": [{
+      "group": ["import1/private/*"],
+      "message": "usage of import1 private modules not allowed."
+    }, {
+      "group": ["import2/*", "!import2/good"],
+      "message": "import2 is deprecated, except the modules in import2/good."
+    }]
+}]
+```
+
+The custom message will be appended to the default error message.
 
 To restrict the use of all Node.js core imports (via https://github.com/nodejs/node/tree/master/lib):
 
@@ -149,6 +163,15 @@ import { DisallowedObject as AllowedObject } from "foo";
 import * as Foo from "foo";
 ```
 
+```js
+/*eslint no-restricted-imports: ["error", { patterns: [{
+    group: ["lodash/*"],
+    message: "Please use the default import from 'lodash' instead."
+}]}]*/
+
+import pick from 'lodash/pick';
+```
+
 Examples of **correct** code for this rule:
 
 ```js
@@ -182,6 +205,15 @@ import DisallowedObject from "foo"
 import { AllowedObject as DisallowedObject } from "foo";
 ```
 
+```js
+/*eslint no-restricted-imports: ["error", { patterns: [{
+    group: ["lodash/*"],
+    message: "Please use the default import from 'lodash' instead."
+}]}]*/
+
+import lodash from 'lodash';
+```
+
 ## When Not To Use It
 
 Don't use this rule or don't include a module in the list for this rule if you want to be able to import a module in your project without an ESLint error or warning.
diff --git a/lib/cli-engine/file-enumerator.js b/lib/cli-engine/file-enumerator.js
index bd89ec733..ade28517b 100644
--- a/lib/cli-engine/file-enumerator.js
+++ b/lib/cli-engine/file-enumerator.js
@@ -38,7 +38,7 @@ const fs = require("fs");
 const path = require("path");
 const getGlobParent = require("glob-parent");
 const isGlob = require("is-glob");
-const { escapeRegExp } = require("lodash");
+const escapeRegExp = require("escape-string-regexp");
 const { Minimatch } = require("minimatch");
 
 const {
diff --git a/lib/cli-engine/formatters/html-template-message.html b/lib/cli-engine/formatters/html-template-message.html
deleted file mode 100644
index 93795a1bd..000000000
--- a/lib/cli-engine/formatters/html-template-message.html
+++ /dev/null
@@ -1,8 +0,0 @@
-<tr style="display:none" class="f-<%= parentIndex %>">
-    <td><%= lineNumber %>:<%= columnNumber %></td>
-    <td class="clr-<%= severityNumber %>"><%= severityName %></td>
-    <td><%- message %></td>
-    <td>
-        <a href="<%= ruleUrl %>" target="_blank" rel="noopener noreferrer"><%= ruleId %></a>
-    </td>
-</tr>
diff --git a/lib/cli-engine/formatters/html-template-message.js b/lib/cli-engine/formatters/html-template-message.js
new file mode 100644
index 000000000..c259618bf
--- /dev/null
+++ b/lib/cli-engine/formatters/html-template-message.js
@@ -0,0 +1,25 @@
+"use strict";
+
+module.exports = function(it, encodeHTML) {
+    const {
+        parentIndex,
+        lineNumber,
+        columnNumber,
+        severityNumber,
+        severityName,
+        message,
+        ruleUrl,
+        ruleId
+    } = it;
+
+    return `
+<tr style="display:none" class="f-${parentIndex}">
+    <td>${lineNumber}:${columnNumber}</td>
+    <td class="clr-${severityNumber}">${severityName}</td>
+    <td>${encodeHTML(message)}</td>
+    <td>
+        <a href="${ruleUrl ? ruleUrl : ""}" target="_blank" rel="noopener noreferrer">${ruleId ? ruleId : ""}</a>
+    </td>
+</tr>
+`.trimLeft();
+};
diff --git a/lib/cli-engine/formatters/html-template-page.html b/lib/cli-engine/formatters/html-template-page.js
similarity index 92%
rename from lib/cli-engine/formatters/html-template-page.html
rename to lib/cli-engine/formatters/html-template-page.js
index 4016576fa..e37a71e2d 100644
--- a/lib/cli-engine/formatters/html-template-page.html
+++ b/lib/cli-engine/formatters/html-template-page.js
@@ -1,3 +1,9 @@
+"use strict";
+
+module.exports = function(it) {
+    const { reportColor, reportSummary, date, results } = it;
+
+    return `
 <!DOCTYPE html>
 <html>
     <head>
@@ -88,15 +94,15 @@
         </style>
     </head>
     <body>
-        <div id="overview" class="bg-<%= reportColor %>">
+        <div id="overview" class="bg-${reportColor}">
             <h1>ESLint Report</h1>
             <div>
-                <span><%= reportSummary %></span> - Generated on <%= date %>
+                <span>${reportSummary}</span> - Generated on ${date}
             </div>
         </div>
         <table>
             <tbody>
-                <%= results %>
+                ${results}
             </tbody>
         </table>
         <script type="text/javascript">
@@ -113,3 +119,5 @@
         </script>
     </body>
 </html>
+`.trimLeft();
+};
diff --git a/lib/cli-engine/formatters/html-template-result.html b/lib/cli-engine/formatters/html-template-result.html
deleted file mode 100644
index f4a55933c..000000000
--- a/lib/cli-engine/formatters/html-template-result.html
+++ /dev/null
@@ -1,6 +0,0 @@
-<tr class="bg-<%- color %>" data-group="f-<%- index %>">
-    <th colspan="4">
-        [+] <%- filePath %>
-        <span><%- summary %></span>
-    </th>
-</tr>
diff --git a/lib/cli-engine/formatters/html-template-result.js b/lib/cli-engine/formatters/html-template-result.js
new file mode 100644
index 000000000..5048f72e9
--- /dev/null
+++ b/lib/cli-engine/formatters/html-template-result.js
@@ -0,0 +1,14 @@
+"use strict";
+
+module.exports = function(it, encodeHTML) {
+    const { color, index, filePath, summary } = it;
+
+    return `
+<tr class="bg-${color}" data-group="f-${index}">
+    <th colspan="4">
+        [+] ${encodeHTML(filePath)}
+        <span>${encodeHTML(summary)}</span>
+    </th>
+</tr>
+`.trimLeft();
+};
diff --git a/lib/cli-engine/formatters/html.js b/lib/cli-engine/formatters/html.js
index 5d4b7e560..b9739f05e 100644
--- a/lib/cli-engine/formatters/html.js
+++ b/lib/cli-engine/formatters/html.js
@@ -4,17 +4,30 @@
  */
 "use strict";
 
-const lodash = require("lodash");
-const fs = require("fs");
-const path = require("path");
-
 //------------------------------------------------------------------------------
 // Helpers
 //------------------------------------------------------------------------------
 
-const pageTemplate = lodash.template(fs.readFileSync(path.join(__dirname, "html-template-page.html"), "utf-8"));
-const messageTemplate = lodash.template(fs.readFileSync(path.join(__dirname, "html-template-message.html"), "utf-8"));
-const resultTemplate = lodash.template(fs.readFileSync(path.join(__dirname, "html-template-result.html"), "utf-8"));
+const encodeHTML = (function() {
+    const encodeHTMLRules = {
+        "&": "&#38;",
+        "<": "&#60;",
+        ">": "&#62;",
+        '"': "&#34;",
+        "'": "&#39;"
+    };
+    const matchHTML = /[&<>"']/ug;
+
+    return function(code) {
+        return code
+            ? code.toString().replace(matchHTML, m => encodeHTMLRules[m] || m)
+            : "";
+    };
+}());
+
+const pageTemplate = require("./html-template-page.js");
+const messageTemplate = require("./html-template-message.js");
+const resultTemplate = require("./html-template-result.js");
 
 /**
  * Given a word and a count, append an s if count is not one.
@@ -80,7 +93,9 @@ function renderMessages(messages, parentIndex, rulesMeta) {
         if (rulesMeta) {
             const meta = rulesMeta[message.ruleId];
 
-            ruleUrl = lodash.get(meta, "docs.url", null);
+            if (meta && meta.docs && meta.docs.url) {
+                ruleUrl = meta.docs.url;
+            }
         }
 
         return messageTemplate({
@@ -92,7 +107,7 @@ function renderMessages(messages, parentIndex, rulesMeta) {
             message: message.message,
             ruleId: message.ruleId,
             ruleUrl
-        });
+        }, encodeHTML);
     }).join("\n");
 }
 
@@ -108,8 +123,7 @@ function renderResults(results, rulesMeta) {
         color: renderColor(result.errorCount, result.warningCount),
         filePath: result.filePath,
         summary: renderSummary(result.errorCount, result.warningCount)
-
-    }) + renderMessages(result.messages, index, rulesMeta)).join("\n");
+    }, encodeHTML) + renderMessages(result.messages, index, rulesMeta)).join("\n");
 }
 
 //------------------------------------------------------------------------------
diff --git a/lib/init/autoconfig.js b/lib/init/autoconfig.js
index 19d016a54..054c53849 100644
--- a/lib/init/autoconfig.js
+++ b/lib/init/autoconfig.js
@@ -9,7 +9,7 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash"),
+const equal = require("fast-deep-equal"),
     recConfig = require("../../conf/eslint-recommended"),
     ConfigOps = require("@eslint/eslintrc/lib/shared/config-ops"),
     { Linter } = require("../linter"),
@@ -329,7 +329,7 @@ function extendFromRecommended(config) {
     const recRules = Object.keys(recConfig.rules).filter(ruleId => ConfigOps.isErrorSeverity(recConfig.rules[ruleId]));
 
     recRules.forEach(ruleId => {
-        if (lodash.isEqual(recConfig.rules[ruleId], newConfig.rules[ruleId])) {
+        if (equal(recConfig.rules[ruleId], newConfig.rules[ruleId])) {
             delete newConfig.rules[ruleId];
         }
     });
diff --git a/lib/linter/apply-disable-directives.js b/lib/linter/apply-disable-directives.js
index 41d6934ab..0ba69ca9c 100644
--- a/lib/linter/apply-disable-directives.js
+++ b/lib/linter/apply-disable-directives.js
@@ -5,8 +5,6 @@
 
 "use strict";
 
-const lodash = require("lodash");
-
 /**
  * Compares the locations of two objects in a source file
  * @param {{line: number, column: number}} itemA The first object
@@ -124,7 +122,21 @@ module.exports = ({ directives, problems, reportUnusedDisableDirectives = "off"
         .map(directive => Object.assign({}, directive, { unprocessedDirective: directive }))
         .sort(compareLocations);
 
-    const lineDirectives = lodash.flatMap(directives, directive => {
+    /**
+     * Returns a new array formed by applying a given callback function to each element of the array, and then flattening the result by one level.
+     * TODO(stephenwade): Replace this with array.flatMap when we drop support for Node v10
+     * @param {any[]} array The array to process
+     * @param {Function} fn The function to use
+     * @returns {any[]} The result array
+     */
+    function flatMap(array, fn) {
+        const mapped = array.map(fn);
+        const flattened = [].concat(...mapped);
+
+        return flattened;
+    }
+
+    const lineDirectives = flatMap(directives, directive => {
         switch (directive.type) {
             case "disable":
             case "enable":
diff --git a/lib/linter/linter.js b/lib/linter/linter.js
index 576816b5b..bdc6c1b1d 100644
--- a/lib/linter/linter.js
+++ b/lib/linter/linter.js
@@ -15,7 +15,7 @@ const
     eslintScope = require("eslint-scope"),
     evk = require("eslint-visitor-keys"),
     espree = require("espree"),
-    lodash = require("lodash"),
+    merge = require("lodash.merge"),
     BuiltInEnvironments = require("@eslint/eslintrc/conf/environments"),
     pkg = require("../../package.json"),
     astUtils = require("../shared/ast-utils"),
@@ -529,8 +529,8 @@ function normalizeVerifyOptions(providedOptions, config) {
 function resolveParserOptions(parserName, providedOptions, enabledEnvironments) {
     const parserOptionsFromEnv = enabledEnvironments
         .filter(env => env.parserOptions)
-        .reduce((parserOptions, env) => lodash.merge(parserOptions, env.parserOptions), {});
-    const mergedParserOptions = lodash.merge(parserOptionsFromEnv, providedOptions || {});
+        .reduce((parserOptions, env) => merge(parserOptions, env.parserOptions), {});
+    const mergedParserOptions = merge(parserOptionsFromEnv, providedOptions || {});
     const isModule = mergedParserOptions.sourceType === "module";
 
     if (isModule) {
@@ -1286,7 +1286,9 @@ class Linter {
         const filenameToExpose = normalizeFilename(filename);
         const text = ensureText(textOrSourceCode);
         const preprocess = options.preprocess || (rawText => [rawText]);
-        const postprocess = options.postprocess || lodash.flatten;
+
+        // TODO(stephenwade): Replace this with array.flat() when we drop support for Node v10
+        const postprocess = options.postprocess || (array => [].concat(...array));
         const filterCodeBlock =
             options.filterCodeBlock ||
             (blockFilename => blockFilename.endsWith(".js"));
diff --git a/lib/linter/node-event-generator.js b/lib/linter/node-event-generator.js
index 0b4e50fc4..8b619fdff 100644
--- a/lib/linter/node-event-generator.js
+++ b/lib/linter/node-event-generator.js
@@ -10,7 +10,6 @@
 //------------------------------------------------------------------------------
 
 const esquery = require("esquery");
-const lodash = require("lodash");
 
 //------------------------------------------------------------------------------
 // Typedefs
@@ -32,6 +31,35 @@ const lodash = require("lodash");
 // Helpers
 //------------------------------------------------------------------------------
 
+/**
+ * Computes the union of one or more arrays
+ * @param {...any[]} arrays One or more arrays to union
+ * @returns {any[]} The union of the input arrays
+ */
+function union(...arrays) {
+
+    // TODO(stephenwade): Replace this with arrays.flat() when we drop support for Node v10
+    return [...new Set([].concat(...arrays))];
+}
+
+/**
+ * Computes the intersection of one or more arrays
+ * @param {...any[]} arrays One or more arrays to intersect
+ * @returns {any[]} The intersection of the input arrays
+ */
+function intersection(...arrays) {
+    if (arrays.length === 0) {
+        return [];
+    }
+
+    let result = [...new Set(arrays[0])];
+
+    for (const array of arrays.slice(1)) {
+        result = result.filter(x => array.includes(x));
+    }
+    return result;
+}
+
 /**
  * Gets the possible types of a selector
  * @param {Object} parsedSelector An object (from esquery) describing the matching behavior of the selector
@@ -46,7 +74,7 @@ function getPossibleTypes(parsedSelector) {
             const typesForComponents = parsedSelector.selectors.map(getPossibleTypes);
 
             if (typesForComponents.every(Boolean)) {
-                return lodash.union(...typesForComponents);
+                return union(...typesForComponents);
             }
             return null;
         }
@@ -63,7 +91,7 @@ function getPossibleTypes(parsedSelector) {
              * If at least one of the components could only match a particular type, the compound could only match
              * the intersection of those types.
              */
-            return lodash.intersection(...typesForComponents);
+            return intersection(...typesForComponents);
         }
 
         case "child":
@@ -166,15 +194,21 @@ function tryParseSelector(rawSelector) {
     }
 }
 
+const selectorCache = new Map();
+
 /**
  * Parses a raw selector string, and returns the parsed selector along with specificity and type information.
  * @param {string} rawSelector A raw AST selector
  * @returns {ASTSelector} A selector descriptor
  */
-const parseSelector = lodash.memoize(rawSelector => {
+function parseSelector(rawSelector) {
+    if (selectorCache.has(rawSelector)) {
+        return selectorCache.get(rawSelector);
+    }
+
     const parsedSelector = tryParseSelector(rawSelector);
 
-    return {
+    const result = {
         rawSelector,
         isExit: rawSelector.endsWith(":exit"),
         parsedSelector,
@@ -182,7 +216,10 @@ const parseSelector = lodash.memoize(rawSelector => {
         attributeCount: countClassAttributes(parsedSelector),
         identifierCount: countIdentifiers(parsedSelector)
     };
-});
+
+    selectorCache.set(rawSelector, result);
+    return result;
+}
 
 //------------------------------------------------------------------------------
 // Public Interface
diff --git a/lib/rules/comma-dangle.js b/lib/rules/comma-dangle.js
index e22b7f355..798c111ec 100644
--- a/lib/rules/comma-dangle.js
+++ b/lib/rules/comma-dangle.js
@@ -9,7 +9,6 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
 const astUtils = require("./utils/ast-utils");
 
 //------------------------------------------------------------------------------
@@ -144,23 +143,33 @@ module.exports = {
          * @returns {ASTNode|null} The last node or null.
          */
         function getLastItem(node) {
+
+            /**
+             * Returns the last element of an array
+             * @param {any[]} array The input array
+             * @returns {any} The last element
+             */
+            function last(array) {
+                return array[array.length - 1];
+            }
+
             switch (node.type) {
                 case "ObjectExpression":
                 case "ObjectPattern":
-                    return lodash.last(node.properties);
+                    return last(node.properties);
                 case "ArrayExpression":
                 case "ArrayPattern":
-                    return lodash.last(node.elements);
+                    return last(node.elements);
                 case "ImportDeclaration":
                 case "ExportNamedDeclaration":
-                    return lodash.last(node.specifiers);
+                    return last(node.specifiers);
                 case "FunctionDeclaration":
                 case "FunctionExpression":
                 case "ArrowFunctionExpression":
-                    return lodash.last(node.params);
+                    return last(node.params);
                 case "CallExpression":
                 case "NewExpression":
-                    return lodash.last(node.arguments);
+                    return last(node.arguments);
                 default:
                     return null;
             }
@@ -316,7 +325,7 @@ module.exports = {
             "always-multiline": forceTrailingCommaIfMultiline,
             "only-multiline": allowTrailingCommaIfMultiline,
             never: forbidTrailingComma,
-            ignore: lodash.noop
+            ignore: () => {}
         };
 
         return {
diff --git a/lib/rules/complexity.js b/lib/rules/complexity.js
index 5d62c6ff4..116c8ad0a 100644
--- a/lib/rules/complexity.js
+++ b/lib/rules/complexity.js
@@ -10,9 +10,8 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
-
 const astUtils = require("./utils/ast-utils");
+const { upperCaseFirst } = require("../shared/string-utils");
 
 //------------------------------------------------------------------------------
 // Rule Definition
@@ -95,7 +94,7 @@ module.exports = {
          * @private
          */
         function endFunction(node) {
-            const name = lodash.upperFirst(astUtils.getFunctionNameWithKind(node));
+            const name = upperCaseFirst(astUtils.getFunctionNameWithKind(node));
             const complexity = fns.pop();
 
             if (complexity > THRESHOLD) {
diff --git a/lib/rules/consistent-return.js b/lib/rules/consistent-return.js
index 94db253d2..a250430cb 100644
--- a/lib/rules/consistent-return.js
+++ b/lib/rules/consistent-return.js
@@ -8,8 +8,8 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
 const astUtils = require("./utils/ast-utils");
+const { upperCaseFirst } = require("../shared/string-utils");
 
 //------------------------------------------------------------------------------
 // Helpers
@@ -164,7 +164,7 @@ module.exports = {
                     funcInfo.data = {
                         name: funcInfo.node.type === "Program"
                             ? "Program"
-                            : lodash.upperFirst(astUtils.getFunctionNameWithKind(funcInfo.node))
+                            : upperCaseFirst(astUtils.getFunctionNameWithKind(funcInfo.node))
                     };
                 } else if (funcInfo.hasReturnValue !== hasReturnValue) {
                     context.report({
diff --git a/lib/rules/eol-last.js b/lib/rules/eol-last.js
index 89c76acb2..24b0c9279 100644
--- a/lib/rules/eol-last.js
+++ b/lib/rules/eol-last.js
@@ -4,12 +4,6 @@
  */
 "use strict";
 
-//------------------------------------------------------------------------------
-// Requirements
-//------------------------------------------------------------------------------
-
-const lodash = require("lodash");
-
 //------------------------------------------------------------------------------
 // Rule Definition
 //------------------------------------------------------------------------------
@@ -48,8 +42,9 @@ module.exports = {
             Program: function checkBadEOF(node) {
                 const sourceCode = context.getSourceCode(),
                     src = sourceCode.getText(),
+                    lastLine = sourceCode.lines[sourceCode.lines.length - 1],
                     location = {
-                        column: lodash.last(sourceCode.lines).length,
+                        column: lastLine.length,
                         line: sourceCode.lines.length
                     },
                     LF = "\n",
diff --git a/lib/rules/indent.js b/lib/rules/indent.js
index 8f4079d31..b1af2a73b 100644
--- a/lib/rules/indent.js
+++ b/lib/rules/indent.js
@@ -12,10 +12,10 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
-const astUtils = require("./utils/ast-utils");
 const createTree = require("functional-red-black-tree");
 
+const astUtils = require("./utils/ast-utils");
+
 //------------------------------------------------------------------------------
 // Rule Definition
 //------------------------------------------------------------------------------
@@ -1068,7 +1068,7 @@ module.exports = {
         const baseOffsetListeners = {
             "ArrayExpression, ArrayPattern"(node) {
                 const openingBracket = sourceCode.getFirstToken(node);
-                const closingBracket = sourceCode.getTokenAfter(lodash.findLast(node.elements) || openingBracket, astUtils.isClosingBracketToken);
+                const closingBracket = sourceCode.getTokenAfter([...node.elements].reverse().find(_ => _) || openingBracket, astUtils.isClosingBracketToken);
 
                 addElementListIndent(node.elements, openingBracket, closingBracket, options.ArrayExpression);
             },
@@ -1560,8 +1560,9 @@ module.exports = {
          * 2. Don't set any offsets against the first token of the node.
          * 3. Call `ignoreNode` on the node sometime after exiting it and before validating offsets.
          */
-        const offsetListeners = lodash.mapValues(
-            baseOffsetListeners,
+        const offsetListeners = {};
+
+        for (const [selector, listener] of Object.entries(baseOffsetListeners)) {
 
             /*
              * Offset listener calls are deferred until traversal is finished, and are called as
@@ -1579,10 +1580,8 @@ module.exports = {
              * To avoid this, the `Identifier` listener isn't called until traversal finishes and all
              * ignored nodes are known.
              */
-            listener =>
-                node =>
-                    listenerCallQueue.push({ listener, node })
-        );
+            offsetListeners[selector] = node => listenerCallQueue.push({ listener, node });
+        }
 
         // For each ignored node selector, set up a listener to collect it into the `ignoredNodes` set.
         const ignoredNodes = new Set();
diff --git a/lib/rules/max-lines-per-function.js b/lib/rules/max-lines-per-function.js
index de70cecb4..60e2e879f 100644
--- a/lib/rules/max-lines-per-function.js
+++ b/lib/rules/max-lines-per-function.js
@@ -9,8 +9,7 @@
 //------------------------------------------------------------------------------
 
 const astUtils = require("./utils/ast-utils");
-
-const lodash = require("lodash");
+const { upperCaseFirst } = require("../shared/string-utils");
 
 //------------------------------------------------------------------------------
 // Constants
@@ -191,7 +190,7 @@ module.exports = {
             }
 
             if (lineCount > maxLines) {
-                const name = lodash.upperFirst(astUtils.getFunctionNameWithKind(funcNode));
+                const name = upperCaseFirst(astUtils.getFunctionNameWithKind(funcNode));
 
                 context.report({
                     node,
diff --git a/lib/rules/max-lines.js b/lib/rules/max-lines.js
index ceb014aff..8bd5a1c95 100644
--- a/lib/rules/max-lines.js
+++ b/lib/rules/max-lines.js
@@ -8,9 +8,22 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
 const astUtils = require("./utils/ast-utils");
 
+//------------------------------------------------------------------------------
+// Helpers
+//------------------------------------------------------------------------------
+
+/**
+ * Creates an array of numbers from `start` up to, but not including, `end`
+ * @param {number} start The start of the range
+ * @param {number} end The end of the range
+ * @returns {number[]} The range of numbers
+ */
+function range(start, end) {
+    return [...Array(end - start).keys()].map(x => x + start);
+}
+
 //------------------------------------------------------------------------------
 // Rule Definition
 //------------------------------------------------------------------------------
@@ -119,11 +132,25 @@ module.exports = {
             }
 
             if (start <= end) {
-                return lodash.range(start, end + 1);
+                return range(start, end + 1);
             }
             return [];
         }
 
+        /**
+         * Returns a new array formed by applying a given callback function to each element of the array, and then flattening the result by one level.
+         * TODO(stephenwade): Replace this with array.flatMap when we drop support for Node v10
+         * @param {any[]} array The array to process
+         * @param {Function} fn The function to use
+         * @returns {any[]} The result array
+         */
+        function flatMap(array, fn) {
+            const mapped = array.map(fn);
+            const flattened = [].concat(...mapped);
+
+            return flattened;
+        }
+
         return {
             "Program:exit"() {
                 let lines = sourceCode.lines.map((text, i) => ({
@@ -135,7 +162,7 @@ module.exports = {
                  * If file ends with a linebreak, `sourceCode.lines` will have one extra empty line at the end.
                  * That isn't a real line, so we shouldn't count it.
                  */
-                if (lines.length > 1 && lodash.last(lines).text === "") {
+                if (lines.length > 1 && lines[lines.length - 1].text === "") {
                     lines.pop();
                 }
 
@@ -146,9 +173,7 @@ module.exports = {
                 if (skipComments) {
                     const comments = sourceCode.getAllComments();
 
-                    const commentLines = lodash.flatten(
-                        comments.map(comment => getLinesWithoutCode(comment))
-                    );
+                    const commentLines = flatMap(comments, comment => getLinesWithoutCode(comment));
 
                     lines = lines.filter(
                         l => !commentLines.includes(l.lineNumber)
@@ -163,7 +188,7 @@ module.exports = {
                         },
                         end: {
                             line: sourceCode.lines.length,
-                            column: lodash.last(sourceCode.lines).length
+                            column: sourceCode.lines[sourceCode.lines.length - 1].length
                         }
                     };
 
diff --git a/lib/rules/max-params.js b/lib/rules/max-params.js
index 4eebe2d95..8fb798401 100644
--- a/lib/rules/max-params.js
+++ b/lib/rules/max-params.js
@@ -9,9 +9,8 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
-
 const astUtils = require("./utils/ast-utils");
+const { upperCaseFirst } = require("../shared/string-utils");
 
 //------------------------------------------------------------------------------
 // Rule Definition
@@ -85,7 +84,7 @@ module.exports = {
                     node,
                     messageId: "exceed",
                     data: {
-                        name: lodash.upperFirst(astUtils.getFunctionNameWithKind(node)),
+                        name: upperCaseFirst(astUtils.getFunctionNameWithKind(node)),
                         count: node.params.length,
                         max: numParams
                     }
diff --git a/lib/rules/max-statements.js b/lib/rules/max-statements.js
index 437b393a5..65d553955 100644
--- a/lib/rules/max-statements.js
+++ b/lib/rules/max-statements.js
@@ -9,9 +9,8 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
-
 const astUtils = require("./utils/ast-utils");
+const { upperCaseFirst } = require("../shared/string-utils");
 
 //------------------------------------------------------------------------------
 // Rule Definition
@@ -97,7 +96,7 @@ module.exports = {
          */
         function reportIfTooManyStatements(node, count, max) {
             if (count > max) {
-                const name = lodash.upperFirst(astUtils.getFunctionNameWithKind(node));
+                const name = upperCaseFirst(astUtils.getFunctionNameWithKind(node));
 
                 context.report({
                     node,
diff --git a/lib/rules/no-fallthrough.js b/lib/rules/no-fallthrough.js
index dd1f3ed9d..e8016e93e 100644
--- a/lib/rules/no-fallthrough.js
+++ b/lib/rules/no-fallthrough.js
@@ -4,12 +4,6 @@
  */
 "use strict";
 
-//------------------------------------------------------------------------------
-// Requirements
-//------------------------------------------------------------------------------
-
-const lodash = require("lodash");
-
 //------------------------------------------------------------------------------
 // Helpers
 //------------------------------------------------------------------------------
@@ -25,7 +19,7 @@ const DEFAULT_FALLTHROUGH_COMMENT = /falls?\s?through/iu;
  */
 function hasFallthroughComment(node, context, fallthroughCommentPattern) {
     const sourceCode = context.getSourceCode();
-    const comment = lodash.last(sourceCode.getCommentsBefore(node));
+    const comment = sourceCode.getCommentsBefore(node).pop();
 
     return Boolean(comment && fallthroughCommentPattern.test(comment.value));
 }
@@ -133,7 +127,7 @@ module.exports = {
                  */
                 if (currentCodePath.currentSegments.some(isReachable) &&
                     (node.consequent.length > 0 || hasBlankLinesBetween(node, nextToken)) &&
-                    lodash.last(node.parent.cases) !== node) {
+                    node.parent.cases[node.parent.cases.length - 1] !== node) {
                     fallthroughCase = node;
                 }
             }
diff --git a/lib/rules/no-restricted-imports.js b/lib/rules/no-restricted-imports.js
index c205dad8b..414164d29 100644
--- a/lib/rules/no-restricted-imports.js
+++ b/lib/rules/no-restricted-imports.js
@@ -10,12 +10,6 @@
 
 const ignore = require("ignore");
 
-const arrayOfStrings = {
-    type: "array",
-    items: { type: "string" },
-    uniqueItems: true
-};
-
 const arrayOfStringsOrObjects = {
     type: "array",
     items: {
@@ -44,6 +38,41 @@ const arrayOfStringsOrObjects = {
     uniqueItems: true
 };
 
+const arrayOfStringsOrObjectPatterns = {
+    anyOf: [
+        {
+            type: "array",
+            items: {
+                type: "string"
+            },
+            uniqueItems: true
+        },
+        {
+            type: "array",
+            items: {
+                type: "object",
+                properties: {
+                    group: {
+                        type: "array",
+                        items: {
+                            type: "string"
+                        },
+                        minItems: 1,
+                        uniqueItems: true
+                    },
+                    message: {
+                        type: "string",
+                        minLength: 1
+                    }
+                },
+                additionalProperties: false,
+                required: ["group"]
+            },
+            uniqueItems: true
+        }
+    ]
+};
+
 module.exports = {
     meta: {
         type: "suggestion",
@@ -61,6 +90,8 @@ module.exports = {
             pathWithCustomMessage: "'{{importSource}}' import is restricted from being used. {{customMessage}}",
 
             patterns: "'{{importSource}}' import is restricted from being used by a pattern.",
+            // eslint-disable-next-line eslint-plugin/report-message-format
+            patternWithCustomMessage: "'{{importSource}}' import is restricted from being used by a pattern. {{customMessage}}",
 
             everything: "* import is invalid because '{{importNames}}' from '{{importSource}}' is restricted.",
             // eslint-disable-next-line eslint-plugin/report-message-format
@@ -80,7 +111,7 @@ module.exports = {
                         type: "object",
                         properties: {
                             paths: arrayOfStringsOrObjects,
-                            patterns: arrayOfStrings
+                            patterns: arrayOfStringsOrObjectPatterns
                         },
                         additionalProperties: false
                     }],
@@ -98,13 +129,6 @@ module.exports = {
             (Object.prototype.hasOwnProperty.call(options[0], "paths") || Object.prototype.hasOwnProperty.call(options[0], "patterns"));
 
         const restrictedPaths = (isPathAndPatternsObject ? options[0].paths : context.options) || [];
-        const restrictedPatterns = (isPathAndPatternsObject ? options[0].patterns : []) || [];
-
-        // if no imports are restricted we don"t need to check
-        if (Object.keys(restrictedPaths).length === 0 && restrictedPatterns.length === 0) {
-            return {};
-        }
-
         const restrictedPathMessages = restrictedPaths.reduce((memo, importSource) => {
             if (typeof importSource === "string") {
                 memo[importSource] = { message: null };
@@ -117,7 +141,16 @@ module.exports = {
             return memo;
         }, {});
 
-        const restrictedPatternsMatcher = ignore().add(restrictedPatterns);
+        // Handle patterns too, either as strings or groups
+        const restrictedPatterns = (isPathAndPatternsObject ? options[0].patterns : []) || [];
+        const restrictedPatternGroups = restrictedPatterns.length > 0 && typeof restrictedPatterns[0] === "string"
+            ? [{ matcher: ignore().add(restrictedPatterns) }]
+            : restrictedPatterns.map(({ group, message }) => ({ matcher: ignore().add(group), customMessage: message }));
+
+        // if no imports are restricted we don"t need to check
+        if (Object.keys(restrictedPaths).length === 0 && restrictedPatternGroups.length === 0) {
+            return {};
+        }
 
         /**
          * Report a restricted path.
@@ -184,17 +217,19 @@ module.exports = {
         /**
          * Report a restricted path specifically for patterns.
          * @param {node} node representing the restricted path reference
+         * @param {Object} group contains a Ignore instance for paths, and the customMessage to show if it fails
          * @returns {void}
          * @private
          */
-        function reportPathForPatterns(node) {
+        function reportPathForPatterns(node, group) {
             const importSource = node.source.value.trim();
 
             context.report({
                 node,
-                messageId: "patterns",
+                messageId: group.customMessage ? "patternWithCustomMessage" : "patterns",
                 data: {
-                    importSource
+                    importSource,
+                    customMessage: group.customMessage
                 }
             });
         }
@@ -202,11 +237,12 @@ module.exports = {
         /**
          * Check if the given importSource is restricted by a pattern.
          * @param {string} importSource path of the import
+         * @param {Object} group contains a Ignore instance for paths, and the customMessage to show if it fails
          * @returns {boolean} whether the variable is a restricted pattern or not
          * @private
          */
-        function isRestrictedPattern(importSource) {
-            return restrictedPatterns.length > 0 && restrictedPatternsMatcher.ignores(importSource);
+        function isRestrictedPattern(importSource, group) {
+            return group.matcher.ignores(importSource);
         }
 
         /**
@@ -249,10 +285,11 @@ module.exports = {
             }
 
             checkRestrictedPathAndReport(importSource, importNames, node);
-
-            if (isRestrictedPattern(importSource)) {
-                reportPathForPatterns(node);
-            }
+            restrictedPatternGroups.forEach(group => {
+                if (isRestrictedPattern(importSource, group)) {
+                    reportPathForPatterns(node, group);
+                }
+            });
         }
 
         return {
diff --git a/lib/rules/no-useless-backreference.js b/lib/rules/no-useless-backreference.js
index 958e3d5dd..529c16439 100644
--- a/lib/rules/no-useless-backreference.js
+++ b/lib/rules/no-useless-backreference.js
@@ -11,7 +11,6 @@
 
 const { CALL, CONSTRUCT, ReferenceTracker, getStringIfConstant } = require("eslint-utils");
 const { RegExpParser, visitRegExpAST } = require("regexpp");
-const lodash = require("lodash");
 
 //------------------------------------------------------------------------------
 // Helpers
@@ -137,7 +136,7 @@ module.exports = {
 
                             // the opposite of the previous when the regex is matching backward in a lookbehind context.
                             messageId = "backward";
-                        } else if (lodash.last(groupCut).type === "Alternative") {
+                        } else if (groupCut[groupCut.length - 1].type === "Alternative") {
 
                             // group's and bref's ancestor nodes below the lowest common ancestor are sibling alternatives => they're disjunctive.
                             messageId = "disjunctive";
diff --git a/lib/rules/no-useless-computed-key.js b/lib/rules/no-useless-computed-key.js
index e0505a318..a1cacc296 100644
--- a/lib/rules/no-useless-computed-key.js
+++ b/lib/rules/no-useless-computed-key.js
@@ -8,7 +8,6 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
 const astUtils = require("./utils/ast-utils");
 
 //------------------------------------------------------------------------------
@@ -95,9 +94,16 @@ module.exports = {
             }
         }
 
+        /**
+         * A no-op function to act as placeholder for checking a node when the `enforceForClassMembers` option is `false`.
+         * @returns {void}
+         * @private
+         */
+        function noop() {}
+
         return {
             Property: check,
-            MethodDefinition: enforceForClassMembers ? check : lodash.noop
+            MethodDefinition: enforceForClassMembers ? check : noop
         };
     }
 };
diff --git a/lib/rules/no-warning-comments.js b/lib/rules/no-warning-comments.js
index 0691a31f7..e5f702bc7 100644
--- a/lib/rules/no-warning-comments.js
+++ b/lib/rules/no-warning-comments.js
@@ -5,7 +5,7 @@
 
 "use strict";
 
-const { escapeRegExp } = require("lodash");
+const escapeRegExp = require("escape-string-regexp");
 const astUtils = require("./utils/ast-utils");
 
 const CHAR_LIMIT = 40;
diff --git a/lib/rules/object-curly-newline.js b/lib/rules/object-curly-newline.js
index 9b64a1b5c..1fbea00c5 100644
--- a/lib/rules/object-curly-newline.js
+++ b/lib/rules/object-curly-newline.js
@@ -10,7 +10,6 @@
 //------------------------------------------------------------------------------
 
 const astUtils = require("./utils/ast-utils");
-const lodash = require("lodash");
 
 //------------------------------------------------------------------------------
 // Helpers
@@ -69,6 +68,24 @@ function normalizeOptionValue(value) {
     return { multiline, minProperties, consistent };
 }
 
+/**
+ * Checks if a value is an object.
+ * @param {any} value The value to check
+ * @returns {boolean} `true` if the value is an object, otherwise `false`
+ */
+function isObject(value) {
+    return typeof value === "object" && value !== null;
+}
+
+/**
+ * Checks if an option is a node-specific option
+ * @param {any} option The option to check
+ * @returns {boolean} `true` if the option is node-specific, otherwise `false`
+ */
+function isNodeSpecificOption(option) {
+    return isObject(option) || typeof option === "string";
+}
+
 /**
  * Normalizes a given option value.
  * @param {string|Object|undefined} options An option value to parse.
@@ -80,9 +97,7 @@ function normalizeOptionValue(value) {
  * }} Normalized option object.
  */
 function normalizeOptions(options) {
-    const isNodeSpecificOption = lodash.overSome([lodash.isPlainObject, lodash.isString]);
-
-    if (lodash.isPlainObject(options) && Object.values(options).some(isNodeSpecificOption)) {
+    if (isObject(options) && Object.values(options).some(isNodeSpecificOption)) {
         return {
             ObjectExpression: normalizeOptionValue(options.ObjectExpression),
             ObjectPattern: normalizeOptionValue(options.ObjectPattern),
diff --git a/lib/rules/spaced-comment.js b/lib/rules/spaced-comment.js
index d3221f0ea..226a2d447 100644
--- a/lib/rules/spaced-comment.js
+++ b/lib/rules/spaced-comment.js
@@ -4,7 +4,7 @@
  */
 "use strict";
 
-const lodash = require("lodash");
+const escapeRegExp = require("escape-string-regexp");
 const astUtils = require("./utils/ast-utils");
 
 //------------------------------------------------------------------------------
@@ -17,7 +17,7 @@ const astUtils = require("./utils/ast-utils");
  * @returns {string} An escaped string.
  */
 function escape(s) {
-    return `(?:${lodash.escapeRegExp(s)})`;
+    return `(?:${escapeRegExp(s)})`;
 }
 
 /**
diff --git a/lib/rules/utils/ast-utils.js b/lib/rules/utils/ast-utils.js
index 679eebb4c..6b8530011 100644
--- a/lib/rules/utils/ast-utils.js
+++ b/lib/rules/utils/ast-utils.js
@@ -11,7 +11,7 @@
 
 const esutils = require("esutils");
 const espree = require("espree");
-const lodash = require("lodash");
+const escapeRegExp = require("escape-string-regexp");
 const {
     breakableTypePattern,
     createGlobalLinebreakMatcher,
@@ -1756,7 +1756,7 @@ module.exports = {
      * @returns {SourceLocation} The `loc` object.
      */
     getNameLocationInGlobalDirectiveComment(sourceCode, comment, name) {
-        const namePattern = new RegExp(`[\\s,]${lodash.escapeRegExp(name)}(?:$|[\\s,:])`, "gu");
+        const namePattern = new RegExp(`[\\s,]${escapeRegExp(name)}(?:$|[\\s,:])`, "gu");
 
         // To ignore the first text "global".
         namePattern.lastIndex = comment.value.indexOf("global") + 6;
diff --git a/lib/shared/deprecation-warnings.js b/lib/shared/deprecation-warnings.js
index 1438eaa69..1a0501ab0 100644
--- a/lib/shared/deprecation-warnings.js
+++ b/lib/shared/deprecation-warnings.js
@@ -9,7 +9,6 @@
 //------------------------------------------------------------------------------
 
 const path = require("path");
-const lodash = require("lodash");
 
 //------------------------------------------------------------------------------
 // Private
@@ -28,6 +27,8 @@ const deprecationWarningMessages = {
         "projects in order to avoid loading '~/.eslintrc.*' accidentally."
 };
 
+const sourceFileErrorCache = new Set();
+
 /**
  * Emits a deprecation warning containing a given filepath. A new deprecation warning is emitted
  * for each unique file path, but repeated invocations with the same file path have no effect.
@@ -36,7 +37,15 @@ const deprecationWarningMessages = {
  * @param {string} errorCode The warning message to show.
  * @returns {void}
  */
-const emitDeprecationWarning = lodash.memoize((source, errorCode) => {
+function emitDeprecationWarning(source, errorCode) {
+    const cacheKey = JSON.stringify({ source, errorCode });
+
+    if (sourceFileErrorCache.has(cacheKey)) {
+        return;
+    }
+
+    sourceFileErrorCache.add(cacheKey);
+
     const rel = path.relative(process.cwd(), source);
     const message = deprecationWarningMessages[errorCode];
 
@@ -45,7 +54,7 @@ const emitDeprecationWarning = lodash.memoize((source, errorCode) => {
         "DeprecationWarning",
         errorCode
     );
-}, (...args) => JSON.stringify(args));
+}
 
 //------------------------------------------------------------------------------
 // Public Interface
diff --git a/lib/shared/string-utils.js b/lib/shared/string-utils.js
new file mode 100644
index 000000000..e4a55d799
--- /dev/null
+++ b/lib/shared/string-utils.js
@@ -0,0 +1,22 @@
+/**
+ * @fileoverview Utilities to operate on strings.
+ * @author Stephen Wade
+ */
+
+"use strict";
+
+/**
+ * Converts the first letter of a string to uppercase.
+ * @param {string} string The string to operate on
+ * @returns {string} The converted string
+ */
+function upperCaseFirst(string) {
+    if (string.length <= 1) {
+        return string.toUpperCase();
+    }
+    return string[0].toUpperCase() + string.slice(1);
+}
+
+module.exports = {
+    upperCaseFirst
+};
diff --git a/lib/source-code/source-code.js b/lib/source-code/source-code.js
index 6b20495b6..c13ce29b8 100644
--- a/lib/source-code/source-code.js
+++ b/lib/source-code/source-code.js
@@ -12,8 +12,7 @@ const
     { isCommentToken } = require("eslint-utils"),
     TokenStore = require("./token-store"),
     astUtils = require("../shared/ast-utils"),
-    Traverser = require("../shared/traverser"),
-    lodash = require("lodash");
+    Traverser = require("../shared/traverser");
 
 //------------------------------------------------------------------------------
 // Private
@@ -531,10 +530,12 @@ class SourceCode extends TokenStore {
         }
 
         /*
-         * To figure out which line rangeIndex is on, determine the last index at which rangeIndex could
-         * be inserted into lineIndices to keep the list sorted.
+         * To figure out which line index is on, determine the last place at which index could
+         * be inserted into lineStartIndices to keep the list sorted.
          */
-        const lineNumber = lodash.sortedLastIndex(this.lineStartIndices, index);
+        const lineNumber = index >= this.lineStartIndices[this.lineStartIndices.length - 1]
+            ? this.lineStartIndices.length
+            : this.lineStartIndices.findIndex(el => index < el);
 
         return { line: lineNumber, column: index - this.lineStartIndices[lineNumber - 1] };
     }
diff --git a/lib/source-code/token-store/utils.js b/lib/source-code/token-store/utils.js
index 21e1d6ff7..a2bd77de7 100644
--- a/lib/source-code/token-store/utils.js
+++ b/lib/source-code/token-store/utils.js
@@ -4,12 +4,6 @@
  */
 "use strict";
 
-//------------------------------------------------------------------------------
-// Requirements
-//------------------------------------------------------------------------------
-
-const lodash = require("lodash");
-
 //------------------------------------------------------------------------------
 // Helpers
 //------------------------------------------------------------------------------
@@ -29,18 +23,16 @@ function getStartLocation(token) {
 //------------------------------------------------------------------------------
 
 /**
- * Binary-searches the index of the first token which is after the given location.
+ * Finds the index of the first token which is after the given location.
  * If it was not found, this returns `tokens.length`.
  * @param {(Token|Comment)[]} tokens It searches the token in this list.
  * @param {number} location The location to search.
  * @returns {number} The found index or `tokens.length`.
  */
 exports.search = function search(tokens, location) {
-    return lodash.sortedIndexBy(
-        tokens,
-        { range: [location] },
-        getStartLocation
-    );
+    const index = tokens.findIndex(el => location <= getStartLocation(el));
+
+    return index === -1 ? tokens.length : index;
 };
 
 /**
diff --git a/messages/all-files-ignored.txt b/messages/all-files-ignored.js
similarity index 51%
rename from messages/all-files-ignored.txt
rename to messages/all-files-ignored.js
index 3f4c8ced4..d85828d36 100644
--- a/messages/all-files-ignored.txt
+++ b/messages/all-files-ignored.js
@@ -1,8 +1,16 @@
-You are linting "<%= pattern %>", but all of the files matching the glob pattern "<%= pattern %>" are ignored.
+"use strict";
 
-If you don't want to lint these files, remove the pattern "<%= pattern %>" from the list of arguments passed to ESLint.
+module.exports = function(it) {
+    const { pattern } = it;
+
+    return `
+You are linting "${pattern}", but all of the files matching the glob pattern "${pattern}" are ignored.
+
+If you don't want to lint these files, remove the pattern "${pattern}" from the list of arguments passed to ESLint.
 
 If you do want to lint these files, try the following solutions:
 
 * Check your .eslintignore file, or the eslintIgnore property in package.json, to ensure that the files are not configured to be ignored.
 * Explicitly list the files from this glob that you'd like to lint on the command-line, rather than providing a glob as an argument.
+`.trimLeft();
+};
diff --git a/messages/extend-config-missing.js b/messages/extend-config-missing.js
new file mode 100644
index 000000000..db8a5c64b
--- /dev/null
+++ b/messages/extend-config-missing.js
@@ -0,0 +1,13 @@
+"use strict";
+
+module.exports = function(it) {
+    const { configName, importerName } = it;
+
+    return `
+ESLint couldn't find the config "${configName}" to extend from. Please check that the name of the config is correct.
+
+The config "${configName}" was referenced from the config file in "${importerName}".
+
+If you still have problems, please stop by https://eslint.org/chat/help to chat with the team.
+`.trimLeft();
+};
diff --git a/messages/extend-config-missing.txt b/messages/extend-config-missing.txt
deleted file mode 100644
index 4defd7ac4..000000000
--- a/messages/extend-config-missing.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-ESLint couldn't find the config "<%- configName %>" to extend from. Please check that the name of the config is correct.
-
-The config "<%- configName %>" was referenced from the config file in "<%- importerName %>".
-
-If you still have problems, please stop by https://eslint.org/chat/help to chat with the team.
diff --git a/messages/failed-to-read-json.js b/messages/failed-to-read-json.js
new file mode 100644
index 000000000..5114de309
--- /dev/null
+++ b/messages/failed-to-read-json.js
@@ -0,0 +1,11 @@
+"use strict";
+
+module.exports = function(it) {
+    const { path, message } = it;
+
+    return `
+Failed to read JSON file at ${path}:
+
+${message}
+`.trimLeft();
+};
diff --git a/messages/failed-to-read-json.txt b/messages/failed-to-read-json.txt
deleted file mode 100644
index b5e2b861c..000000000
--- a/messages/failed-to-read-json.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-Failed to read JSON file at <%= path %>:
-
-<%= message %>
diff --git a/messages/file-not-found.js b/messages/file-not-found.js
new file mode 100644
index 000000000..26a5d57ef
--- /dev/null
+++ b/messages/file-not-found.js
@@ -0,0 +1,10 @@
+"use strict";
+
+module.exports = function(it) {
+    const { pattern, globDisabled } = it;
+
+    return `
+No files matching the pattern "${pattern}"${globDisabled ? " (with disabling globs)" : ""} were found.
+Please check for typing mistakes in the pattern.
+`.trimLeft();
+};
diff --git a/messages/file-not-found.txt b/messages/file-not-found.txt
deleted file mode 100644
index 639498eb5..000000000
--- a/messages/file-not-found.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-No files matching the pattern "<%= pattern %>"<% if (globDisabled) { %> (with disabling globs)<% } %> were found.
-Please check for typing mistakes in the pattern.
diff --git a/messages/no-config-found.txt b/messages/no-config-found.js
similarity index 52%
rename from messages/no-config-found.txt
rename to messages/no-config-found.js
index b46a7e5a7..1042143f9 100644
--- a/messages/no-config-found.txt
+++ b/messages/no-config-found.js
@@ -1,7 +1,15 @@
+"use strict";
+
+module.exports = function(it) {
+    const { directoryPath } = it;
+
+    return `
 ESLint couldn't find a configuration file. To set up a configuration file for this project, please run:
 
     eslint --init
 
-ESLint looked for configuration files in <%= directoryPath %> and its ancestors. If it found none, it then looked in your home directory.
+ESLint looked for configuration files in ${directoryPath} and its ancestors. If it found none, it then looked in your home directory.
 
 If you think you already have a configuration file or if you need more help, please stop by the ESLint chat room: https://eslint.org/chat/help
+`.trimLeft();
+};
diff --git a/messages/plugin-conflict.js b/messages/plugin-conflict.js
new file mode 100644
index 000000000..c8c060e2f
--- /dev/null
+++ b/messages/plugin-conflict.js
@@ -0,0 +1,22 @@
+"use strict";
+
+module.exports = function(it) {
+    const { pluginId, plugins } = it;
+
+    let result = `ESLint couldn't determine the plugin "${pluginId}" uniquely.
+`;
+
+    for (const { filePath, importerName } of plugins) {
+        result += `
+- ${filePath} (loaded in "${importerName}")`;
+    }
+
+    result += `
+
+Please remove the "plugins" setting from either config or remove either plugin installation.
+
+If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
+`;
+
+    return result;
+};
diff --git a/messages/plugin-conflict.txt b/messages/plugin-conflict.txt
deleted file mode 100644
index 3ab4b340e..000000000
--- a/messages/plugin-conflict.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-ESLint couldn't determine the plugin "<%- pluginId %>" uniquely.
-<% for (const { filePath, importerName } of plugins) { %>
-- <%= filePath %> (loaded in "<%= importerName %>")<% } %>
-
-Please remove the "plugins" setting from either config or remove either plugin installation.
-
-If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
diff --git a/messages/plugin-invalid.js b/messages/plugin-invalid.js
new file mode 100644
index 000000000..7913576f0
--- /dev/null
+++ b/messages/plugin-invalid.js
@@ -0,0 +1,16 @@
+"use strict";
+
+module.exports = function(it) {
+    const { configName, importerName } = it;
+
+    return `
+"${configName}" is invalid syntax for a config specifier.
+
+* If your intention is to extend from a configuration exported from the plugin, add the configuration name after a slash: e.g. "${configName}/myConfig".
+* If this is the name of a shareable config instead of a plugin, remove the "plugin:" prefix: i.e. "${configName.slice("plugin:".length)}".
+
+"${configName}" was referenced from the config file in "${importerName}".
+
+If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
+`.trimLeft();
+};
diff --git a/messages/plugin-invalid.txt b/messages/plugin-invalid.txt
deleted file mode 100644
index 3ee251821..000000000
--- a/messages/plugin-invalid.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-"<%- configName %>" is invalid syntax for a config specifier.
-
-* If your intention is to extend from a configuration exported from the plugin, add the configuration name after a slash: e.g. "<%- configName %>/myConfig".
-* If this is the name of a shareable config instead of a plugin, remove the "plugin:" prefix: i.e. "<%- configName.slice("plugin:".length) %>".
-
-"<%- configName %>" was referenced from the config file in "<%- importerName %>".
-
-If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
diff --git a/messages/plugin-missing.js b/messages/plugin-missing.js
new file mode 100644
index 000000000..f58c78ceb
--- /dev/null
+++ b/messages/plugin-missing.js
@@ -0,0 +1,19 @@
+"use strict";
+
+module.exports = function(it) {
+    const { pluginName, resolvePluginsRelativeTo, importerName } = it;
+
+    return `
+ESLint couldn't find the plugin "${pluginName}".
+
+(The package "${pluginName}" was not found when loaded as a Node module from the directory "${resolvePluginsRelativeTo}".)
+
+It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:
+
+    npm install ${pluginName}@latest --save-dev
+
+The plugin "${pluginName}" was referenced from the config file in "${importerName}".
+
+If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
+`.trimLeft();
+};
diff --git a/messages/plugin-missing.txt b/messages/plugin-missing.txt
deleted file mode 100644
index aa25f59ac..000000000
--- a/messages/plugin-missing.txt
+++ /dev/null
@@ -1,11 +0,0 @@
-ESLint couldn't find the plugin "<%- pluginName %>".
-
-(The package "<%- pluginName %>" was not found when loaded as a Node module from the directory "<%- resolvePluginsRelativeTo %>".)
-
-It's likely that the plugin isn't installed correctly. Try reinstalling by running the following:
-
-    npm install <%- pluginName %>@latest --save-dev
-
-The plugin "<%- pluginName %>" was referenced from the config file in "<%- importerName %>".
-
-If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
diff --git a/messages/print-config-with-directory-path.txt b/messages/print-config-with-directory-path.js
similarity index 70%
rename from messages/print-config-with-directory-path.txt
rename to messages/print-config-with-directory-path.js
index 1afc9b1e8..6a5d571dd 100644
--- a/messages/print-config-with-directory-path.txt
+++ b/messages/print-config-with-directory-path.js
@@ -1,2 +1,8 @@
+"use strict";
+
+module.exports = function() {
+    return `
 The '--print-config' CLI option requires a path to a source code file rather than a directory.
 See also: https://eslint.org/docs/user-guide/command-line-interface#--print-config
+`.trimLeft();
+};
diff --git a/messages/whitespace-found.js b/messages/whitespace-found.js
new file mode 100644
index 000000000..4ce49ca3a
--- /dev/null
+++ b/messages/whitespace-found.js
@@ -0,0 +1,11 @@
+"use strict";
+
+module.exports = function(it) {
+    const { pluginName } = it;
+
+    return `
+ESLint couldn't find the plugin "${pluginName}". because there is whitespace in the name. Please check your configuration and remove all whitespace from the plugin name.
+
+If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
+`.trimLeft();
+};
diff --git a/messages/whitespace-found.txt b/messages/whitespace-found.txt
deleted file mode 100644
index 3eed1af58..000000000
--- a/messages/whitespace-found.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-ESLint couldn't find the plugin "<%- pluginName %>". because there is whitespace in the name. Please check your configuration and remove all whitespace from the plugin name.
-
-If you still can't figure out the problem, please stop by https://eslint.org/chat/help to chat with the team.
diff --git a/package.json b/package.json
index 51a76ec31..31d96fe28 100644
--- a/package.json
+++ b/package.json
@@ -51,12 +51,14 @@
     "debug": "^4.0.1",
     "doctrine": "^3.0.0",
     "enquirer": "^2.3.5",
+    "escape-string-regexp": "^4.0.0",
     "eslint-scope": "^5.1.1",
     "eslint-utils": "^2.1.0",
     "eslint-visitor-keys": "^2.0.0",
     "espree": "^7.3.1",
     "esquery": "^1.4.0",
     "esutils": "^2.0.2",
+    "fast-deep-equal": "^3.1.3",
     "file-entry-cache": "^6.0.1",
     "functional-red-black-tree": "^1.0.1",
     "glob-parent": "^5.0.0",
@@ -68,7 +70,7 @@
     "js-yaml": "^3.13.1",
     "json-stable-stringify-without-jsonify": "^1.0.1",
     "levn": "^0.4.1",
-    "lodash": "^4.17.21",
+    "lodash.merge": "^4.6.2",
     "minimatch": "^3.0.4",
     "natural-compare": "^1.4.0",
     "optionator": "^0.9.1",
@@ -77,7 +79,7 @@
     "semver": "^7.2.1",
     "strip-ansi": "^6.0.0",
     "strip-json-comments": "^3.1.0",
-    "table": "^6.0.4",
+    "table": "^6.0.9",
     "text-table": "^0.2.0",
     "v8-compile-cache": "^2.0.3"
   },
@@ -91,7 +93,6 @@
     "core-js": "^3.1.3",
     "dateformat": "^3.0.3",
     "ejs": "^3.0.2",
-    "escape-string-regexp": "^3.0.0",
     "eslint": "file:.",
     "eslint-config-eslint": "file:packages/eslint-config-eslint",
     "eslint-plugin-eslint-plugin": "^2.2.1",
diff --git a/tools/eslint-fuzzer.js b/tools/eslint-fuzzer.js
index 4104b8516..c9705200e 100644
--- a/tools/eslint-fuzzer.js
+++ b/tools/eslint-fuzzer.js
@@ -10,13 +10,25 @@
 //------------------------------------------------------------------------------
 
 const assert = require("assert");
-const lodash = require("lodash");
 const eslump = require("eslump");
 const espree = require("espree");
 const SourceCodeFixer = require("../lib/linter/source-code-fixer");
 const ruleConfigs = require("../lib/init/config-rule").createCoreRuleConfigs(true);
 const sampleMinimizer = require("./code-sample-minimizer");
 
+//------------------------------------------------------------------------------
+// Helpers
+//------------------------------------------------------------------------------
+
+/**
+ * Gets a random item from an array
+ * @param {any[]} array The array to sample
+ * @returns {any} The random item
+ */
+function sample(array) {
+    return array[Math.floor(Math.random() * array.length)];
+}
+
 //------------------------------------------------------------------------------
 // Public API
 //------------------------------------------------------------------------------
@@ -125,10 +137,16 @@ function fuzz(options) {
     }
 
     for (let i = 0; i < options.count; progressCallback(problems.length), i++) {
-        const sourceType = lodash.sample(["script", "module"]);
+        const rules = {};
+
+        for (const [id, configs] of Object.entries(ruleConfigs)) {
+            rules[id] = sample(configs);
+        }
+
+        const sourceType = sample(["script", "module"]);
         const text = codeGenerator({ sourceType });
         const config = {
-            rules: lodash.mapValues(ruleConfigs, lodash.sample),
+            rules,
             parserOptions: {
                 sourceType,
                 ecmaVersion: espree.latestEcmaVersion
diff --git a/webpack.config.js b/webpack.config.js
index b256f4424..a22c99bf8 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -39,8 +39,7 @@ module.exports = {
                             targets: ">0.5%, not chrome 49, not ie 11, not safari 5.1"
                         }]
                     ]
-                },
-                exclude: /node_modules[\\/]lodash/u
+                }
             }
         ]
     },


Test patch:
diff --git a/lib/rule-tester/rule-tester.js b/lib/rule-tester/rule-tester.js
index 23b78fba2..b08303c62 100644
--- a/lib/rule-tester/rule-tester.js
+++ b/lib/rule-tester/rule-tester.js
@@ -44,7 +44,8 @@ const
     assert = require("assert"),
     path = require("path"),
     util = require("util"),
-    lodash = require("lodash"),
+    merge = require("lodash.merge"),
+    equal = require("fast-deep-equal"),
     Traverser = require("../../lib/shared/traverser"),
     { getRuleOptionsSchema, validate } = require("../shared/config-validator"),
     { Linter, SourceCodeFixer, interpolate } = require("../linter");
@@ -324,10 +325,9 @@ class RuleTester {
          * configuration and the default configuration.
          * @type {Object}
          */
-        this.testerConfig = lodash.merge(
-
-            // we have to clone because merge uses the first argument for recipient
-            lodash.cloneDeep(defaultConfig),
+        this.testerConfig = merge(
+            {},
+            defaultConfig,
             testerConfig,
             { rules: { "rule-tester/validate-ast": "error" } }
         );
@@ -369,7 +369,7 @@ class RuleTester {
      * @returns {void}
      */
     static resetDefaultConfig() {
-        defaultConfig = lodash.cloneDeep(testerDefaultConfig);
+        defaultConfig = merge({}, testerDefaultConfig);
     }
 
 
@@ -465,7 +465,7 @@ class RuleTester {
          * @private
          */
         function runRuleForItem(item) {
-            let config = lodash.cloneDeep(testerConfig),
+            let config = merge({}, testerConfig),
                 code, filename, output, beforeAST, afterAST;
 
             if (typeof item === "string") {
@@ -477,13 +477,17 @@ class RuleTester {
                  * Assumes everything on the item is a config except for the
                  * parameters used by this tester
                  */
-                const itemConfig = lodash.omit(item, RuleTesterParameters);
+                const itemConfig = { ...item };
+
+                for (const parameter of RuleTesterParameters) {
+                    delete itemConfig[parameter];
+                }
 
                 /*
                  * Create the config object from the tester config and this item
                  * specific configurations.
                  */
-                config = lodash.merge(
+                config = merge(
                     config,
                     itemConfig
                 );
@@ -589,7 +593,7 @@ class RuleTester {
          * @private
          */
         function assertASTDidntChange(beforeAST, afterAST) {
-            if (!lodash.isEqual(beforeAST, afterAST)) {
+            if (!equal(beforeAST, afterAST)) {
                 assert.fail("Rule should not modify AST.");
             }
         }
diff --git a/tests/bench/large.js b/tests/bench/large.js
index c94e88568..a3db1ac6c 100644
--- a/tests/bench/large.js
+++ b/tests/bench/large.js
@@ -57078,7 +57078,7 @@ var JSHINT = (function () {
   FutureReservedWord("transient");
   FutureReservedWord("volatile");
 
-  // this function is used to determine wether a squarebracket or a curlybracket
+  // this function is used to determine whether a squarebracket or a curlybracket
   // expression is a comprehension array, destructuring assignment or a json value.
 
   var lookupBlockType = function () {
@@ -60568,4 +60568,4 @@ exports.yui = {
 },{}]},{},["nr+AlQ"])
 JSHINT = require('jshint').JSHINT;
 if (typeof exports === 'object' && exports) exports.JSHINT = JSHINT;
-}());
\ No newline at end of file
+}());
diff --git a/tests/lib/rule-tester/rule-tester.js b/tests/lib/rule-tester/rule-tester.js
index 3f2393621..3aaf6f91f 100644
--- a/tests/lib/rule-tester/rule-tester.js
+++ b/tests/lib/rule-tester/rule-tester.js
@@ -11,8 +11,7 @@ const sinon = require("sinon"),
     EventEmitter = require("events"),
     { RuleTester } = require("../../../lib/rule-tester"),
     assert = require("chai").assert,
-    nodeAssert = require("assert"),
-    { noop } = require("lodash");
+    nodeAssert = require("assert");
 
 const NODE_ASSERT_STRICT_EQUAL_OPERATOR = (() => {
     try {
@@ -23,6 +22,15 @@ const NODE_ASSERT_STRICT_EQUAL_OPERATOR = (() => {
     throw new Error("unexpected successful assertion");
 })();
 
+/**
+ * Do nothing.
+ * @returns {void}
+ */
+function noop() {
+
+    // do nothing.
+}
+
 //------------------------------------------------------------------------------
 // Rewire Things
 //------------------------------------------------------------------------------
diff --git a/tests/lib/rules/no-invalid-this.js b/tests/lib/rules/no-invalid-this.js
index 6e1b757a7..3b19d6369 100644
--- a/tests/lib/rules/no-invalid-this.js
+++ b/tests/lib/rules/no-invalid-this.js
@@ -9,7 +9,8 @@
 // Requirements
 //------------------------------------------------------------------------------
 
-const lodash = require("lodash");
+const merge = require("lodash.merge");
+
 const rule = require("../../../lib/rules/no-invalid-this");
 const { RuleTester } = require("../../../lib/rule-tester");
 
@@ -69,7 +70,7 @@ function extractPatterns(patterns, type) {
 
     // Clone and apply the pattern environment.
     const patternsList = patterns.map(pattern => pattern[type].map(applyCondition => {
-        const thisPattern = lodash.cloneDeep(pattern);
+        const thisPattern = merge({}, pattern);
 
         applyCondition(thisPattern);
 
@@ -79,7 +80,10 @@ function extractPatterns(patterns, type) {
             thisPattern.code += " /* should error */";
         }
 
-        return lodash.omit(thisPattern, ["valid", "invalid"]);
+        delete thisPattern.valid;
+        delete thisPattern.invalid;
+
+        return thisPattern;
     }));
 
     // Flatten.
diff --git a/tests/lib/rules/no-restricted-imports.js b/tests/lib/rules/no-restricted-imports.js
index 363229712..f54d4df03 100644
--- a/tests/lib/rules/no-restricted-imports.js
+++ b/tests/lib/rules/no-restricted-imports.js
@@ -37,6 +37,10 @@ ruleTester.run("no-restricted-imports", rule, {
             code: "import withGitignores from \"foo/bar\";",
             options: [{ patterns: ["foo/*", "!foo/bar"] }]
         },
+        {
+            code: "import withPatterns from \"foo/bar\";",
+            options: [{ patterns: [{ group: ["foo/*", "!foo/bar"], message: "foo is forbidden, use bar instead" }] }]
+        },
         {
             code: "import AllowedObject from \"foo\";",
             options: [{
@@ -241,6 +245,36 @@ ruleTester.run("no-restricted-imports", rule, {
             column: 1,
             endColumn: 36
         }]
+    }, {
+        code: "import withPatterns from \"foo/baz\";",
+        options: [{ patterns: [{ group: ["foo/*", "!foo/bar"], message: "foo is forbidden, use foo/bar instead" }] }],
+        errors: [{
+            message: "'foo/baz' import is restricted from being used by a pattern. foo is forbidden, use foo/bar instead",
+            type: "ImportDeclaration",
+            line: 1,
+            column: 1,
+            endColumn: 36
+        }]
+    }, {
+        code: "import withPatterns from \"foo/baz\";",
+        options: [{ patterns: [{ group: ["foo/bar", "foo/baz"], message: "some foo subimports are restricted" }] }],
+        errors: [{
+            message: "'foo/baz' import is restricted from being used by a pattern. some foo subimports are restricted",
+            type: "ImportDeclaration",
+            line: 1,
+            column: 1,
+            endColumn: 36
+        }]
+    }, {
+        code: "import withPatterns from \"foo/bar\";",
+        options: [{ patterns: [{ group: ["foo/bar"] }] }],
+        errors: [{
+            message: "'foo/bar' import is restricted from being used by a pattern.",
+            type: "ImportDeclaration",
+            line: 1,
+            column: 1,
+            endColumn: 36
+        }]
     }, {
         code: "import withGitignores from \"foo/bar\";",
         options: [{ patterns: ["foo/*", "!foo/baz"] }],
diff --git a/tests/lib/shared/string-utils.js b/tests/lib/shared/string-utils.js
new file mode 100644
index 000000000..bc48afaab
--- /dev/null
+++ b/tests/lib/shared/string-utils.js
@@ -0,0 +1,41 @@
+/**
+ * @fileoverview Tests for string utils.
+ * @author Stephen Wade
+ */
+
+"use strict";
+
+//------------------------------------------------------------------------------
+// Requirements
+//------------------------------------------------------------------------------
+
+const assert = require("chai").assert;
+
+const { upperCaseFirst } = require("../../../lib/shared/string-utils");
+
+//------------------------------------------------------------------------------
+// Tests
+//------------------------------------------------------------------------------
+
+describe("upperCaseFirst", () => {
+    it("uppercases the first letter of a string", () => {
+        assert(upperCaseFirst("e") === "E");
+        assert(upperCaseFirst("alphabet") === "Alphabet");
+        assert(upperCaseFirst("one two three") === "One two three");
+    });
+
+    it("only changes the case of the first letter", () => {
+        assert(upperCaseFirst("alphaBet") === "AlphaBet");
+        assert(upperCaseFirst("one TWO three") === "One TWO three");
+    });
+
+    it("does not change the case if the first letter is already uppercase", () => {
+        assert(upperCaseFirst("E") === "E");
+        assert(upperCaseFirst("Alphabet") === "Alphabet");
+        assert(upperCaseFirst("One Two Three") === "One Two Three");
+    });
+
+    it("properly handles an empty string", () => {
+        assert(upperCaseFirst("") === "");
+    });
+});