Skip to content

Commit e163036

Browse files
Add regexp/no-control-character rule (#333)
1 parent 26a3aa7 commit e163036

File tree

6 files changed

+284
-0
lines changed

6 files changed

+284
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
105105
| Rule ID | Description | |
106106
|:--------|:------------|:---|
107107
| [regexp/no-contradiction-with-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-contradiction-with-assertion.html) | disallow elements that contradict assertions | |
108+
| [regexp/no-control-character](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-control-character.html) | disallow control characters | |
108109
| [regexp/no-dupe-disjunctions](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-disjunctions.html) | disallow duplicate disjunctions | :star: |
109110
| [regexp/no-empty-alternative](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-empty-alternative.html) | disallow alternatives without elements | :star: |
110111
| [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: |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
1414
| Rule ID | Description | |
1515
|:--------|:------------|:---|
1616
| [regexp/no-contradiction-with-assertion](./no-contradiction-with-assertion.md) | disallow elements that contradict assertions | |
17+
| [regexp/no-control-character](./no-control-character.md) | disallow control characters | |
1718
| [regexp/no-dupe-disjunctions](./no-dupe-disjunctions.md) | disallow duplicate disjunctions | :star: |
1819
| [regexp/no-empty-alternative](./no-empty-alternative.md) | disallow alternatives without elements | :star: |
1920
| [regexp/no-empty-capturing-group](./no-empty-capturing-group.md) | disallow capturing group that captures empty. | :star: |

docs/rules/no-control-character.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/no-control-character"
5+
description: "disallow control characters"
6+
---
7+
# regexp/no-control-character
8+
9+
> disallow control characters
10+
11+
- :exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
12+
13+
## :book: Rule Details
14+
15+
This rule reports control characters.
16+
17+
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.
18+
19+
<eslint-code-block>
20+
21+
```js
22+
/* eslint regexp/no-control-character: "error" */
23+
24+
/* ✓ GOOD */
25+
var foo = /\n/;
26+
var foo = RegExp("\n");
27+
28+
/* ✗ BAD */
29+
var foo = /\x1f/;
30+
var foo = /\x0a/;
31+
var foo = RegExp('\x0a');
32+
```
33+
34+
</eslint-code-block>
35+
36+
## :wrench: Options
37+
38+
Nothing.
39+
40+
## :books: Further reading
41+
42+
- [no-control-regex]
43+
44+
[no-control-regex]: https://eslint.org/docs/rules/no-control-regex
45+
46+
## :mag: Implementation
47+
48+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-control-character.ts)
49+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-control-character.ts)

lib/rules/no-control-character.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { mentionChar, mention } from "../utils/mention"
2+
import { CP_TAB, CP_LF, CP_VT, CP_FF, CP_CR } from "../utils/unicode"
3+
import type { RegExpVisitor } from "regexpp/visitor"
4+
import type { RegExpContext } from "../utils"
5+
import { createRule, defineRegexpVisitor } from "../utils"
6+
import type { Character } from "regexpp/ast"
7+
import type { Rule } from "eslint"
8+
9+
const CONTROL_CHARS = new Map<number, string>([
10+
[0, "\\0"],
11+
[CP_TAB, "\\t"],
12+
[CP_LF, "\\n"],
13+
[CP_VT, "\\v"],
14+
[CP_FF, "\\f"],
15+
[CP_CR, "\\r"],
16+
])
17+
18+
const ALLOWED_CONTROL_CHARS = /^\\[0fnrtv]$/
19+
20+
export default createRule("no-control-character", {
21+
meta: {
22+
docs: {
23+
description: "disallow control characters",
24+
category: "Possible Errors",
25+
recommended: false,
26+
},
27+
schema: [],
28+
messages: {
29+
unexpected: "Unexpected control character {{ char }}.",
30+
31+
// suggestions
32+
33+
escape: "Use {{ escape }} instead.",
34+
},
35+
type: "suggestion",
36+
hasSuggestions: true,
37+
},
38+
create(context) {
39+
/**
40+
* Create visitor
41+
*/
42+
function createVisitor(
43+
regexpContext: RegExpContext,
44+
): RegExpVisitor.Handlers {
45+
const {
46+
node,
47+
patternSource,
48+
getRegexpLocation,
49+
fixReplaceNode,
50+
} = regexpContext
51+
52+
/** */
53+
function isBadEscapeRaw(raw: string, cp: number): boolean {
54+
return (
55+
raw.codePointAt(0)! === cp ||
56+
raw.startsWith("\\x") ||
57+
raw.startsWith("\\u")
58+
)
59+
}
60+
61+
/** */
62+
function isAllowedEscapeRaw(raw: string): boolean {
63+
return (
64+
ALLOWED_CONTROL_CHARS.test(raw) ||
65+
(raw.startsWith("\\") &&
66+
ALLOWED_CONTROL_CHARS.test(raw.slice(1)))
67+
)
68+
}
69+
70+
/**
71+
* Whether the given char is represented using an unwanted escape
72+
* sequence.
73+
*/
74+
function isBadEscape(char: Character): boolean {
75+
// We are only interested in control escapes in RegExp literals
76+
const range = patternSource.getReplaceRange(char)?.range
77+
const sourceRaw = range
78+
? context.getSourceCode().text.slice(...range)
79+
: char.raw
80+
81+
if (
82+
isAllowedEscapeRaw(char.raw) ||
83+
isAllowedEscapeRaw(sourceRaw)
84+
) {
85+
return false
86+
}
87+
88+
return (
89+
isBadEscapeRaw(char.raw, char.value) ||
90+
(char.raw.startsWith("\\") &&
91+
isBadEscapeRaw(char.raw.slice(1), char.value))
92+
)
93+
}
94+
95+
return {
96+
onCharacterEnter(cNode) {
97+
if (cNode.value <= 0x1f && isBadEscape(cNode)) {
98+
const suggest: Rule.SuggestionReportDescriptor[] = []
99+
100+
const allowedEscape = CONTROL_CHARS.get(cNode.value)
101+
if (allowedEscape !== undefined) {
102+
suggest.push({
103+
messageId: "escape",
104+
data: { escape: mention(allowedEscape) },
105+
fix: fixReplaceNode(cNode, allowedEscape),
106+
})
107+
}
108+
109+
context.report({
110+
node,
111+
loc: getRegexpLocation(cNode),
112+
messageId: "unexpected",
113+
data: { char: mentionChar(cNode) },
114+
suggest,
115+
})
116+
}
117+
},
118+
}
119+
}
120+
121+
return defineRegexpVisitor(context, {
122+
createVisitor,
123+
})
124+
},
125+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import matchAny from "../rules/match-any"
77
import negation from "../rules/negation"
88
import noAssertionCapturingGroup from "../rules/no-assertion-capturing-group"
99
import noContradictionWithAssertion from "../rules/no-contradiction-with-assertion"
10+
import noControlCharacter from "../rules/no-control-character"
1011
import noDupeCharactersCharacterClass from "../rules/no-dupe-characters-character-class"
1112
import noDupeDisjunctions from "../rules/no-dupe-disjunctions"
1213
import noEmptyAlternative from "../rules/no-empty-alternative"
@@ -81,6 +82,7 @@ export const rules = [
8182
negation,
8283
noAssertionCapturingGroup,
8384
noContradictionWithAssertion,
85+
noControlCharacter,
8486
noDupeCharactersCharacterClass,
8587
noDupeDisjunctions,
8688
noEmptyAlternative,
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { RuleTester } from "eslint"
2+
import rule from "../../../lib/rules/no-control-character"
3+
4+
const tester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 2020,
7+
sourceType: "module",
8+
},
9+
})
10+
11+
tester.run("no-control-character", rule as any, {
12+
valid: [
13+
String.raw`/x1f/`,
14+
String.raw`/\\x1f/`,
15+
String.raw`new RegExp('x1f')`,
16+
String.raw`RegExp('x1f')`,
17+
String.raw`new RegExp('[')`,
18+
String.raw`RegExp('[')`,
19+
String.raw`new (function foo(){})('\x1f')`,
20+
String.raw`new RegExp('\n')`,
21+
String.raw`new RegExp('\\n')`,
22+
],
23+
invalid: [
24+
{
25+
code: String.raw`/\x1f/`,
26+
errors: [{ messageId: "unexpected", suggestions: [] }],
27+
},
28+
{
29+
code: String.raw`/\\\x1f\\x1e/`,
30+
errors: [{ messageId: "unexpected", suggestions: [] }],
31+
},
32+
{
33+
code: String.raw`/\\\x1fFOO\\x00/`,
34+
errors: [{ messageId: "unexpected", suggestions: [] }],
35+
},
36+
{
37+
code: String.raw`/FOO\\\x1fFOO\\x1f/`,
38+
errors: [{ messageId: "unexpected", suggestions: [] }],
39+
},
40+
{
41+
code: String.raw`new RegExp('\x1f\x1e')`,
42+
errors: [
43+
{ messageId: "unexpected", suggestions: [] },
44+
{ messageId: "unexpected", suggestions: [] },
45+
],
46+
},
47+
{
48+
code: String.raw`new RegExp('\x1fFOO\x00')`,
49+
errors: [
50+
{ messageId: "unexpected", suggestions: [] },
51+
{
52+
messageId: "unexpected",
53+
suggestions: [
54+
{ output: String.raw`new RegExp('\x1fFOO\\0')` },
55+
],
56+
},
57+
],
58+
},
59+
{
60+
code: String.raw`new RegExp('FOO\x1fFOO\x1f')`,
61+
errors: [
62+
{ messageId: "unexpected", suggestions: [] },
63+
{ messageId: "unexpected", suggestions: [] },
64+
],
65+
},
66+
{
67+
code: String.raw`RegExp('\x1f')`,
68+
errors: [{ messageId: "unexpected", suggestions: [] }],
69+
},
70+
{
71+
code: String.raw`RegExp('\\x1f')`,
72+
errors: [{ messageId: "unexpected", suggestions: [] }],
73+
},
74+
{
75+
code: String.raw`RegExp('\\\x1f')`,
76+
errors: [{ messageId: "unexpected", suggestions: [] }],
77+
},
78+
{
79+
code: String.raw`RegExp('\x0a')`,
80+
errors: [
81+
{
82+
messageId: "unexpected",
83+
suggestions: [{ output: String.raw`RegExp('\\n')` }],
84+
},
85+
],
86+
},
87+
{
88+
code: String.raw`RegExp('\\x0a')`,
89+
errors: [
90+
{
91+
messageId: "unexpected",
92+
suggestions: [{ output: String.raw`RegExp('\\n')` }],
93+
},
94+
],
95+
},
96+
{
97+
code: String.raw`RegExp('\\\x0a')`,
98+
errors: [
99+
{
100+
messageId: "unexpected",
101+
suggestions: [{ output: String.raw`RegExp('\\n')` }],
102+
},
103+
],
104+
},
105+
],
106+
})

0 commit comments

Comments
 (0)