Skip to content

Commit 5760df4

Browse files
authored
Add regexp/no-useless-dollar-replacements and regexp/prefer-escape-replacement-dollar-char rules (#62)
* Add `regexp/no-useless-dollar-replacements` rule * update doc * Add regexp/prefer-escape-replacement-dollar-char rule * fix
1 parent 7e21be1 commit 5760df4

15 files changed

+814
-29
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
9797
| [regexp/no-octal](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-octal.html) | disallow octal escape sequence | :star: |
9898
| [regexp/no-useless-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-backreference.html) | disallow useless backreferences in regular expressions | |
9999
| [regexp/no-useless-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-character-class.html) | disallow character class with one character | :wrench: |
100+
| [regexp/no-useless-dollar-replacements](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-dollar-replacements.html) | disallow useless `$` replacements in replacement string | |
100101
| [regexp/no-useless-escape](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-escape.html) | disallow unnecessary escape characters in RegExp | |
101102
| [regexp/no-useless-exactly-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-exactly-quantifier.html) | disallow unnecessary exactly quantifier | :star: |
102103
| [regexp/no-useless-non-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-non-capturing-group.html) | disallow unnecessary Non-capturing group | :wrench: |
@@ -106,6 +107,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
106107
| [regexp/order-in-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/order-in-character-class.html) | enforces elements order in character class | :wrench: |
107108
| [regexp/prefer-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-character-class.html) | enforce using character class | :wrench: |
108109
| [regexp/prefer-d](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-d.html) | enforce using `\d` | :star::wrench: |
110+
| [regexp/prefer-escape-replacement-dollar-char](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-escape-replacement-dollar-char.html) | enforces escape of replacement `$` character (`$$`). | |
109111
| [regexp/prefer-plus-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-plus-quantifier.html) | enforce using `+` quantifier | :star::wrench: |
110112
| [regexp/prefer-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-quantifier.html) | enforce using quantifier | :wrench: |
111113
| [regexp/prefer-question-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-question-quantifier.html) | enforce using `?` quantifier | :star::wrench: |

docs/rules/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
2525
| [regexp/no-octal](./no-octal.md) | disallow octal escape sequence | :star: |
2626
| [regexp/no-useless-backreference](./no-useless-backreference.md) | disallow useless backreferences in regular expressions | |
2727
| [regexp/no-useless-character-class](./no-useless-character-class.md) | disallow character class with one character | :wrench: |
28+
| [regexp/no-useless-dollar-replacements](./no-useless-dollar-replacements.md) | disallow useless `$` replacements in replacement string | |
2829
| [regexp/no-useless-escape](./no-useless-escape.md) | disallow unnecessary escape characters in RegExp | |
2930
| [regexp/no-useless-exactly-quantifier](./no-useless-exactly-quantifier.md) | disallow unnecessary exactly quantifier | :star: |
3031
| [regexp/no-useless-non-capturing-group](./no-useless-non-capturing-group.md) | disallow unnecessary Non-capturing group | :wrench: |
@@ -34,6 +35,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
3435
| [regexp/order-in-character-class](./order-in-character-class.md) | enforces elements order in character class | :wrench: |
3536
| [regexp/prefer-character-class](./prefer-character-class.md) | enforce using character class | :wrench: |
3637
| [regexp/prefer-d](./prefer-d.md) | enforce using `\d` | :star::wrench: |
38+
| [regexp/prefer-escape-replacement-dollar-char](./prefer-escape-replacement-dollar-char.md) | enforces escape of replacement `$` character (`$$`). | |
3739
| [regexp/prefer-plus-quantifier](./prefer-plus-quantifier.md) | enforce using `+` quantifier | :star::wrench: |
3840
| [regexp/prefer-quantifier](./prefer-quantifier.md) | enforce using quantifier | :wrench: |
3941
| [regexp/prefer-question-quantifier](./prefer-question-quantifier.md) | enforce using `?` quantifier | :star::wrench: |
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/no-useless-dollar-replacements"
5+
description: "disallow useless `$` replacements in replacement string"
6+
---
7+
# regexp/no-useless-dollar-replacements
8+
9+
> disallow useless `$` replacements in replacement string
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 aims to detect and disallow useless `$` replacements in regular expression replacements.
16+
17+
<eslint-code-block>
18+
19+
```js
20+
/* eslint regexp/no-useless-dollar-replacements: "error" */
21+
const str = 'John Smith';
22+
23+
/* ✓ GOOD */
24+
var newStr = str.replace(/(\w+)\s(\w+)/, '$2, $1');
25+
// newStr = "Smith, John"
26+
27+
var newStr = str.replace(/(?<first>\w+)\s(?<last>\w+)/, '$<last>, $<first>');
28+
// newStr = "Smith, John"
29+
30+
'123456789012'.replaceAll(/(.)../g, '$1**'); // "1**4**7**0**"
31+
32+
/* ✗ BAD */
33+
var newStr = str.replace(/(\w+)\s(\w+)/, '$3, $1 $2');
34+
// newStr = "$3, John Smith"
35+
36+
var newStr = str.replace(/(?<first>\w+)\s(?<last>\w+)/, '$<last>, $<first> $<middle>');
37+
// newStr = "Smith, John "
38+
39+
var newStr = str.replace(/(\w+)\s(\w+)/, '$<last>, $<first>');
40+
// newStr = "$<last>, $<first>"
41+
42+
'123456789012'.replaceAll(/.(.)./g, '*$2*'); // "*$2**$2**$2**$2*"
43+
```
44+
45+
</eslint-code-block>
46+
47+
## :wrench: Options
48+
49+
Nothing.
50+
51+
## :couple: Related rules
52+
53+
- [regexp/prefer-escape-replacement-dollar-char](./prefer-escape-replacement-dollar-char.md)
54+
55+
## :books: Further reading
56+
57+
- [MDN Web Docs - String.prototype.replace() > Specifying a string as a parameter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_a_parameter)
58+
59+
## :mag: Implementation
60+
61+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-useless-dollar-replacements.ts)
62+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-useless-dollar-replacements.ts)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/prefer-escape-replacement-dollar-char"
5+
description: "enforces escape of replacement `$` character (`$$`)."
6+
---
7+
# regexp/prefer-escape-replacement-dollar-char
8+
9+
> enforces escape of replacement `$` character (`$$`).
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 aims to enforce escape when using the `$` character in replacement pattern of string replacement.
16+
17+
<eslint-code-block>
18+
19+
```js
20+
/* eslint regexp/prefer-escape-replacement-dollar-char: "error" */
21+
22+
/* ✓ GOOD */
23+
'€1,234'.replace(//, '$$'); // "$1,234"
24+
25+
26+
/* ✗ BAD */
27+
'€1,234'.replace(//, '$'); // "$1,234"
28+
```
29+
30+
</eslint-code-block>
31+
32+
## :wrench: Options
33+
34+
Nothing.
35+
36+
## :couple: Related rules
37+
38+
- [regexp/no-useless-dollar-replacements](./no-useless-dollar-replacements.md)
39+
40+
## :books: Further reading
41+
42+
- [MDN Web Docs - String.prototype.replace() > Specifying a string as a parameter](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_a_parameter)
43+
44+
## :mag: Implementation
45+
46+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-escape-replacement-dollar-char.ts)
47+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-escape-replacement-dollar-char.ts)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import type { CallExpression, Literal } from "estree"
2+
import { createRule } from "../utils"
3+
import { createTypeTracker } from "../utils/type-tracker"
4+
import type { RegExpLiteral, Pattern } from "regexpp/ast"
5+
import type { Rule } from "eslint"
6+
import type { ReferenceElement } from "../utils/ast-utils"
7+
import { isKnownMethodCall, parseReplacements } from "../utils/ast-utils"
8+
import {
9+
extractCaptures,
10+
getRegExpNodeFromExpression,
11+
} from "../utils/regexp-ast"
12+
13+
/**
14+
* Extract `$` replacements
15+
*/
16+
function extractDollarReplacements(context: Rule.RuleContext, node: Literal) {
17+
return parseReplacements(context, node).filter(
18+
(e): e is ReferenceElement => e.type === "ReferenceElement",
19+
)
20+
}
21+
22+
export default createRule("no-useless-dollar-replacements", {
23+
meta: {
24+
docs: {
25+
description:
26+
"disallow useless `$` replacements in replacement string",
27+
recommended: false,
28+
},
29+
schema: [],
30+
messages: {
31+
numberRef:
32+
"'${{ refText }}' replacement will insert '${{ refText }}' because there are less than {{ num }} capturing groups. Use '$$' if you want to escape '$'.",
33+
numberRefCapturingNotFound:
34+
"'${{ refText }}' replacement will insert '${{ refText }}' because capturing group does not found. Use '$$' if you want to escape '$'.",
35+
namedRef:
36+
"'$<{{ refText }}>' replacement will be ignored because the named capturing group is not found. Use '$$' if you want to escape '$'.",
37+
namedRefNamedCapturingNotFound:
38+
"'$<{{ refText }}>' replacement will insert '$<{{ refText }}>' because named capturing group does not found. Use '$$' if you want to escape '$'.",
39+
},
40+
type: "suggestion", // "problem",
41+
},
42+
create(context) {
43+
const typeTracer = createTypeTracker(context)
44+
const sourceCode = context.getSourceCode()
45+
46+
/** Verify */
47+
function verify(
48+
patternNode: Pattern | RegExpLiteral,
49+
replacement: Literal,
50+
) {
51+
const captures = extractCaptures(patternNode)
52+
for (const dollarReplacement of extractDollarReplacements(
53+
context,
54+
replacement,
55+
)) {
56+
if (typeof dollarReplacement.ref === "number") {
57+
if (captures.count < dollarReplacement.ref) {
58+
context.report({
59+
node: replacement,
60+
loc: {
61+
start: sourceCode.getLocFromIndex(
62+
dollarReplacement.range[0],
63+
),
64+
end: sourceCode.getLocFromIndex(
65+
dollarReplacement.range[1],
66+
),
67+
},
68+
messageId:
69+
captures.count > 0
70+
? "numberRef"
71+
: "numberRefCapturingNotFound",
72+
data: {
73+
refText: dollarReplacement.refText,
74+
num: String(dollarReplacement.ref),
75+
},
76+
})
77+
}
78+
} else {
79+
if (!captures.names.has(dollarReplacement.ref)) {
80+
context.report({
81+
node: replacement,
82+
loc: {
83+
start: sourceCode.getLocFromIndex(
84+
dollarReplacement.range[0],
85+
),
86+
end: sourceCode.getLocFromIndex(
87+
dollarReplacement.range[1],
88+
),
89+
},
90+
messageId:
91+
captures.names.size > 0
92+
? "namedRef"
93+
: "namedRefNamedCapturingNotFound",
94+
data: {
95+
refText: dollarReplacement.refText,
96+
},
97+
})
98+
}
99+
}
100+
}
101+
}
102+
103+
return {
104+
CallExpression(node: CallExpression) {
105+
if (!isKnownMethodCall(node, { replace: 2, replaceAll: 2 })) {
106+
return
107+
}
108+
const mem = node.callee
109+
const replacementTextNode = node.arguments[1]
110+
if (
111+
replacementTextNode.type !== "Literal" ||
112+
typeof replacementTextNode.value !== "string"
113+
) {
114+
return
115+
}
116+
const patternNode = getRegExpNodeFromExpression(
117+
node.arguments[0],
118+
context,
119+
)
120+
if (!patternNode) {
121+
return
122+
}
123+
if (!typeTracer.isString(mem.object)) {
124+
return
125+
}
126+
verify(patternNode, replacementTextNode)
127+
},
128+
}
129+
},
130+
})
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { CallExpression, Literal } from "estree"
2+
import { createRule } from "../utils"
3+
import { isKnownMethodCall, parseReplacements } from "../utils/ast-utils"
4+
import { createTypeTracker } from "../utils/type-tracker"
5+
6+
export default createRule("prefer-escape-replacement-dollar-char", {
7+
meta: {
8+
docs: {
9+
description: "enforces escape of replacement `$` character (`$$`).",
10+
recommended: false,
11+
},
12+
schema: [],
13+
messages: {
14+
unexpected:
15+
"Unexpected replacement `$` character without escaping. Use `$$` instead.",
16+
},
17+
type: "suggestion", // "problem",
18+
},
19+
create(context) {
20+
const typeTracer = createTypeTracker(context)
21+
const sourceCode = context.getSourceCode()
22+
23+
/** Verify */
24+
function verify(replacement: Literal) {
25+
for (const element of parseReplacements(context, replacement)) {
26+
if (
27+
element.type === "CharacterElement" &&
28+
element.value === "$"
29+
) {
30+
context.report({
31+
node: replacement,
32+
loc: {
33+
start: sourceCode.getLocFromIndex(element.range[0]),
34+
end: sourceCode.getLocFromIndex(element.range[1]),
35+
},
36+
messageId: "unexpected",
37+
})
38+
}
39+
}
40+
}
41+
42+
return {
43+
CallExpression(node: CallExpression) {
44+
if (!isKnownMethodCall(node, { replace: 2, replaceAll: 2 })) {
45+
return
46+
}
47+
const mem = node.callee
48+
const replacementTextNode = node.arguments[1]
49+
if (
50+
replacementTextNode.type !== "Literal" ||
51+
typeof replacementTextNode.value !== "string"
52+
) {
53+
return
54+
}
55+
if (!typeTracer.isRegExp(node.arguments[0])) {
56+
return
57+
}
58+
if (!typeTracer.isString(mem.object)) {
59+
return
60+
}
61+
verify(replacementTextNode)
62+
},
63+
}
64+
},
65+
})

lib/rules/prefer-regexp-exec.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { CallExpression } from "estree"
22
import { createRule } from "../utils"
33
import { createTypeTracker } from "../utils/type-tracker"
44
import { getStaticValue } from "eslint-utils"
5+
import { isKnownMethodCall } from "../utils/ast-utils"
56

67
// Inspired by https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/prefer-regexp-exec.md
78
export default createRule("prefer-regexp-exec", {
@@ -22,16 +23,7 @@ export default createRule("prefer-regexp-exec", {
2223

2324
return {
2425
CallExpression(node: CallExpression) {
25-
if (node.arguments.length !== 1) {
26-
return
27-
}
28-
if (
29-
node.callee.type !== "MemberExpression" ||
30-
node.callee.computed ||
31-
node.callee.property.type !== "Identifier" ||
32-
node.callee.property.name !== "match" ||
33-
node.callee.object.type === "Super"
34-
) {
26+
if (!isKnownMethodCall(node, { match: 1 })) {
3527
return
3628
}
3729
const arg = node.arguments[0]

lib/rules/prefer-regexp-test.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type * as ES from "estree"
22
import { hasSideEffect, isOpeningParenToken } from "eslint-utils"
33
import { createRule } from "../utils"
44
import { createTypeTracker } from "../utils/type-tracker"
5+
import { isKnownMethodCall } from "../utils/ast-utils"
56

67
// Inspired by https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/docs/rules/prefer-regexp-test.md
78
export default createRule("prefer-regexp-test", {
@@ -25,21 +26,7 @@ export default createRule("prefer-regexp-test", {
2526

2627
return {
2728
CallExpression(node: ES.CallExpression) {
28-
if (node.arguments.length !== 1) {
29-
return
30-
}
31-
if (
32-
node.callee.type !== "MemberExpression" ||
33-
node.callee.computed ||
34-
node.callee.property.type !== "Identifier" ||
35-
node.callee.object.type === "Super"
36-
) {
37-
return
38-
}
39-
if (
40-
node.callee.property.name !== "match" &&
41-
node.callee.property.name !== "exec"
42-
) {
29+
if (!isKnownMethodCall(node, { match: 1, exec: 1 })) {
4330
return
4431
}
4532
if (!isUseBoolean(node)) {

0 commit comments

Comments
 (0)