Repository: ota-meshi/eslint-plugin-regexp
Pull request #333 (2021-09-22 11:40:39)

Add `regexp/no-control-character` rule
Relates to #325.

Changes to ESLint's rule:

- There are suggestions for the characters `\0\r\n\t\v\f`.
- This rule allows `\0\r\n\t\v\f` in `RegExp` constructor strings. E.g. `RegExp("\n")` is allowed.
- The rule detects escaped control characters. E.g. `RegExp("\\\x0a")` is reported.


Patch:
diff --git a/README.md b/README.md
index 9e6def0..fe71aae 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | Rule ID | Description |    |
 |:--------|:------------|:---|
 | [regexp/no-contradiction-with-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-contradiction-with-assertion.html) | disallow elements that contradict assertions |  |
+| [regexp/no-control-character](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-control-character.html) | disallow control characters |  |
 | [regexp/no-dupe-disjunctions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-disjunctions.html) | disallow duplicate disjunctions | :star: |
 | [regexp/no-empty-alternative](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-alternative.html) | disallow alternatives without elements | :star: |
 | [regexp/no-empty-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-capturing-group.html) | disallow capturing group that captures empty. | :star: |
diff --git a/docs/rules/README.md b/docs/rules/README.md
index 51f234e..8be8267 100644
--- a/docs/rules/README.md
+++ b/docs/rules/README.md
@@ -14,6 +14,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
 | Rule ID | Description |    |
 |:--------|:------------|:---|
 | [regexp/no-contradiction-with-assertion](./no-contradiction-with-assertion.md) | disallow elements that contradict assertions |  |
+| [regexp/no-control-character](./no-control-character.md) | disallow control characters |  |
 | [regexp/no-dupe-disjunctions](./no-dupe-disjunctions.md) | disallow duplicate disjunctions | :star: |
 | [regexp/no-empty-alternative](./no-empty-alternative.md) | disallow alternatives without elements | :star: |
 | [regexp/no-empty-capturing-group](./no-empty-capturing-group.md) | disallow capturing group that captures empty. | :star: |
diff --git a/docs/rules/no-control-character.md b/docs/rules/no-control-character.md
new file mode 100644
index 0000000..15481e0
--- /dev/null
+++ b/docs/rules/no-control-character.md
@@ -0,0 +1,49 @@
+---
+pageClass: "rule-details"
+sidebarDepth: 0
+title: "regexp/no-control-character"
+description: "disallow control characters"
+---
+# regexp/no-control-character
+
+> disallow control characters
+
+- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
+
+## :book: Rule Details
+
+This rule reports control characters.
+
+This rule is inspired by the [no-control-regex] rule. The positions of reports are improved over the core rule and suggestions are provided in some cases.
+
+<eslint-code-block>
+
+```js
+/* eslint regexp/no-control-character: "error" */
+
+/* ✓ GOOD */
+var foo = /\n/;
+var foo = RegExp("\n");
+
+/* ✗ BAD */
+var foo = /\x1f/;
+var foo = /\x0a/;
+var foo = RegExp('\x0a');
+```
+
+</eslint-code-block>
+
+## :wrench: Options
+
+Nothing.
+
+## :books: Further reading
+
+- [no-control-regex]
+
+[no-control-regex]: https://eslint.org/docs/rules/no-control-regex
+
+## :mag: Implementation
+
+- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-control-character.ts)
+- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-control-character.ts)
diff --git a/lib/rules/no-control-character.ts b/lib/rules/no-control-character.ts
new file mode 100644
index 0000000..278277f
--- /dev/null
+++ b/lib/rules/no-control-character.ts
@@ -0,0 +1,125 @@
+import { mentionChar, mention } from "../utils/mention"
+import { CP_TAB, CP_LF, CP_VT, CP_FF, CP_CR } from "../utils/unicode"
+import type { RegExpVisitor } from "regexpp/visitor"
+import type { RegExpContext } from "../utils"
+import { createRule, defineRegexpVisitor } from "../utils"
+import type { Character } from "regexpp/ast"
+import type { Rule } from "eslint"
+
+const CONTROL_CHARS = new Map<number, string>([
+    [0, "\\0"],
+    [CP_TAB, "\\t"],
+    [CP_LF, "\\n"],
+    [CP_VT, "\\v"],
+    [CP_FF, "\\f"],
+    [CP_CR, "\\r"],
+])
+
+const ALLOWED_CONTROL_CHARS = /^\\[0fnrtv]$/
+
+export default createRule("no-control-character", {
+    meta: {
+        docs: {
+            description: "disallow control characters",
+            category: "Possible Errors",
+            recommended: false,
+        },
+        schema: [],
+        messages: {
+            unexpected: "Unexpected control character {{ char }}.",
+
+            // suggestions
+
+            escape: "Use {{ escape }} instead.",
+        },
+        type: "suggestion",
+        hasSuggestions: true,
+    },
+    create(context) {
+        /**
+         * Create visitor
+         */
+        function createVisitor(
+            regexpContext: RegExpContext,
+        ): RegExpVisitor.Handlers {
+            const {
+                node,
+                patternSource,
+                getRegexpLocation,
+                fixReplaceNode,
+            } = regexpContext
+
+            /** */
+            function isBadEscapeRaw(raw: string, cp: number): boolean {
+                return (
+                    raw.codePointAt(0)! === cp ||
+                    raw.startsWith("\\x") ||
+                    raw.startsWith("\\u")
+                )
+            }
+
+            /** */
+            function isAllowedEscapeRaw(raw: string): boolean {
+                return (
+                    ALLOWED_CONTROL_CHARS.test(raw) ||
+                    (raw.startsWith("\\") &&
+                        ALLOWED_CONTROL_CHARS.test(raw.slice(1)))
+                )
+            }
+
+            /**
+             * Whether the given char is represented using an unwanted escape
+             * sequence.
+             */
+            function isBadEscape(char: Character): boolean {
+                // We are only interested in control escapes in RegExp literals
+                const range = patternSource.getReplaceRange(char)?.range
+                const sourceRaw = range
+                    ? context.getSourceCode().text.slice(...range)
+                    : char.raw
+
+                if (
+                    isAllowedEscapeRaw(char.raw) ||
+                    isAllowedEscapeRaw(sourceRaw)
+                ) {
+                    return false
+                }
+
+                return (
+                    isBadEscapeRaw(char.raw, char.value) ||
+                    (char.raw.startsWith("\\") &&
+                        isBadEscapeRaw(char.raw.slice(1), char.value))
+                )
+            }
+
+            return {
+                onCharacterEnter(cNode) {
+                    if (cNode.value <= 0x1f && isBadEscape(cNode)) {
+                        const suggest: Rule.SuggestionReportDescriptor[] = []
+
+                        const allowedEscape = CONTROL_CHARS.get(cNode.value)
+                        if (allowedEscape !== undefined) {
+                            suggest.push({
+                                messageId: "escape",
+                                data: { escape: mention(allowedEscape) },
+                                fix: fixReplaceNode(cNode, allowedEscape),
+                            })
+                        }
+
+                        context.report({
+                            node,
+                            loc: getRegexpLocation(cNode),
+                            messageId: "unexpected",
+                            data: { char: mentionChar(cNode) },
+                            suggest,
+                        })
+                    }
+                },
+            }
+        }
+
+        return defineRegexpVisitor(context, {
+            createVisitor,
+        })
+    },
+})
diff --git a/lib/utils/rules.ts b/lib/utils/rules.ts
index 1804a47..51d2daa 100644
--- a/lib/utils/rules.ts
+++ b/lib/utils/rules.ts
@@ -7,6 +7,7 @@ import matchAny from "../rules/match-any"
 import negation from "../rules/negation"
 import noAssertionCapturingGroup from "../rules/no-assertion-capturing-group"
 import noContradictionWithAssertion from "../rules/no-contradiction-with-assertion"
+import noControlCharacter from "../rules/no-control-character"
 import noDupeCharactersCharacterClass from "../rules/no-dupe-characters-character-class"
 import noDupeDisjunctions from "../rules/no-dupe-disjunctions"
 import noEmptyAlternative from "../rules/no-empty-alternative"
@@ -81,6 +82,7 @@ export const rules = [
     negation,
     noAssertionCapturingGroup,
     noContradictionWithAssertion,
+    noControlCharacter,
     noDupeCharactersCharacterClass,
     noDupeDisjunctions,
     noEmptyAlternative,


Test patch:
diff --git a/tests/lib/rules/no-control-character.ts b/tests/lib/rules/no-control-character.ts
new file mode 100644
index 0000000..22518da
--- /dev/null
+++ b/tests/lib/rules/no-control-character.ts
@@ -0,0 +1,106 @@
+import { RuleTester } from "eslint"
+import rule from "../../../lib/rules/no-control-character"
+
+const tester = new RuleTester({
+    parserOptions: {
+        ecmaVersion: 2020,
+        sourceType: "module",
+    },
+})
+
+tester.run("no-control-character", rule as any, {
+    valid: [
+        String.raw`/x1f/`,
+        String.raw`/\\x1f/`,
+        String.raw`new RegExp('x1f')`,
+        String.raw`RegExp('x1f')`,
+        String.raw`new RegExp('[')`,
+        String.raw`RegExp('[')`,
+        String.raw`new (function foo(){})('\x1f')`,
+        String.raw`new RegExp('\n')`,
+        String.raw`new RegExp('\\n')`,
+    ],
+    invalid: [
+        {
+            code: String.raw`/\x1f/`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`/\\\x1f\\x1e/`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`/\\\x1fFOO\\x00/`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`/FOO\\\x1fFOO\\x1f/`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`new RegExp('\x1f\x1e')`,
+            errors: [
+                { messageId: "unexpected", suggestions: [] },
+                { messageId: "unexpected", suggestions: [] },
+            ],
+        },
+        {
+            code: String.raw`new RegExp('\x1fFOO\x00')`,
+            errors: [
+                { messageId: "unexpected", suggestions: [] },
+                {
+                    messageId: "unexpected",
+                    suggestions: [
+                        { output: String.raw`new RegExp('\x1fFOO\\0')` },
+                    ],
+                },
+            ],
+        },
+        {
+            code: String.raw`new RegExp('FOO\x1fFOO\x1f')`,
+            errors: [
+                { messageId: "unexpected", suggestions: [] },
+                { messageId: "unexpected", suggestions: [] },
+            ],
+        },
+        {
+            code: String.raw`RegExp('\x1f')`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`RegExp('\\x1f')`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`RegExp('\\\x1f')`,
+            errors: [{ messageId: "unexpected", suggestions: [] }],
+        },
+        {
+            code: String.raw`RegExp('\x0a')`,
+            errors: [
+                {
+                    messageId: "unexpected",
+                    suggestions: [{ output: String.raw`RegExp('\\n')` }],
+                },
+            ],
+        },
+        {
+            code: String.raw`RegExp('\\x0a')`,
+            errors: [
+                {
+                    messageId: "unexpected",
+                    suggestions: [{ output: String.raw`RegExp('\\n')` }],
+                },
+            ],
+        },
+        {
+            code: String.raw`RegExp('\\\x0a')`,
+            errors: [
+                {
+                    messageId: "unexpected",
+                    suggestions: [{ output: String.raw`RegExp('\\n')` }],
+                },
+            ],
+        },
+    ],
+})