Skip to content

Commit 9c3e37a

Browse files
authored
Add regexp/prefer-named-replacement rule (#350)
1 parent 903cced commit 9c3e37a

10 files changed

+263
-9
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
175175
| [regexp/prefer-lookaround](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-lookaround.html) | prefer lookarounds over capturing group that do not replace | :wrench: |
176176
| [regexp/prefer-named-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-backreference.html) | enforce using named backreferences | :wrench: |
177177
| [regexp/prefer-named-capture-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-capture-group.html) | enforce using named capture groups | |
178+
| [regexp/prefer-named-replacement](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-replacement.html) | enforce using named replacement | :wrench: |
178179
| [regexp/prefer-plus-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-plus-quantifier.html) | enforce using `+` quantifier | :star::wrench: |
179180
| [regexp/prefer-question-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-question-quantifier.html) | enforce using `?` quantifier | :star::wrench: |
180181
| [regexp/prefer-star-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-star-quantifier.html) | enforce using `*` quantifier | :star::wrench: |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
8484
| [regexp/prefer-lookaround](./prefer-lookaround.md) | prefer lookarounds over capturing group that do not replace | :wrench: |
8585
| [regexp/prefer-named-backreference](./prefer-named-backreference.md) | enforce using named backreferences | :wrench: |
8686
| [regexp/prefer-named-capture-group](./prefer-named-capture-group.md) | enforce using named capture groups | |
87+
| [regexp/prefer-named-replacement](./prefer-named-replacement.md) | enforce using named replacement | :wrench: |
8788
| [regexp/prefer-plus-quantifier](./prefer-plus-quantifier.md) | enforce using `+` quantifier | :star::wrench: |
8889
| [regexp/prefer-question-quantifier](./prefer-question-quantifier.md) | enforce using `?` quantifier | :star::wrench: |
8990
| [regexp/prefer-star-quantifier](./prefer-star-quantifier.md) | enforce using `*` quantifier | :star::wrench: |

docs/rules/prefer-named-backreference.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ var foo = /(?<foo>a)\1/
3434

3535
Nothing.
3636

37+
## :couple: Related rules
38+
39+
- [regexp/prefer-named-capture-group]
40+
- [regexp/prefer-named-replacement]
41+
42+
[regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
43+
[regexp/prefer-named-replacement]: ./prefer-named-replacement.md
44+
3745
## :rocket: Version
3846

3947
This rule was introduced in eslint-plugin-regexp v0.9.0

docs/rules/prefer-named-capture-group.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ var foo = /\b(foo)+\b/;
3434

3535
Nothing.
3636

37+
## :couple: Related rules
38+
39+
- [regexp/prefer-named-backreference]
40+
- [regexp/prefer-named-replacement]
41+
42+
[regexp/prefer-named-backreference]: ./prefer-named-backreference.md
43+
[regexp/prefer-named-replacement]: ./prefer-named-replacement.md
44+
3745
## :books: Further reading
3846

3947
- [prefer-named-capture-group]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/prefer-named-replacement"
5+
description: "enforce using named replacement"
6+
---
7+
# regexp/prefer-named-replacement
8+
9+
> enforce using named replacement
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+
- :wrench: The `--fix` option on the [command line](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some of the problems reported by this rule.
13+
14+
## :book: Rule Details
15+
16+
This rule reports and fixes `$n` parameter in replacement string that do not use the name of their referenced capturing group.
17+
18+
<eslint-code-block fix>
19+
20+
```js
21+
/* eslint regexp/prefer-named-replacement: "error" */
22+
23+
/* ✓ GOOD */
24+
"abc".replace(/a(?<foo>b)c/, '$<foo>');
25+
"abc".replace(/a(b)c/, '$1');
26+
27+
/* ✗ BAD */
28+
"abc".replace(/a(?<foo>b)c/, '$1');
29+
```
30+
31+
</eslint-code-block>
32+
33+
## :wrench: Options
34+
35+
```json
36+
{
37+
"regexp/prefer-named-replacement": ["error", {
38+
"strictTypes": true
39+
}]
40+
}
41+
```
42+
43+
- `strictTypes` ... If `true`, strictly check the type of object to determine if the string instance was used in `replace()` and `replaceAll()`. Default is `true`.
44+
This option is always on when using TypeScript.
45+
46+
## :couple: Related rules
47+
48+
- [regexp/prefer-named-backreference]
49+
- [regexp/prefer-named-capture-group]
50+
51+
[regexp/prefer-named-backreference]: ./prefer-named-backreference.md
52+
[regexp/prefer-named-capture-group]: ./prefer-named-capture-group.md
53+
54+
## :mag: Implementation
55+
56+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-named-replacement.ts)
57+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-named-replacement.ts)

lib/rules/prefer-named-replacement.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { RegExpVisitor } from "regexpp/visitor"
2+
import type { RegExpContext } from "../utils"
3+
import { createRule, defineRegexpVisitor } from "../utils"
4+
5+
export default createRule("prefer-named-replacement", {
6+
meta: {
7+
docs: {
8+
description: "enforce using named replacement",
9+
category: "Stylistic Issues",
10+
recommended: false,
11+
},
12+
fixable: "code",
13+
schema: [
14+
{
15+
type: "object",
16+
properties: {
17+
strictTypes: { type: "boolean" },
18+
},
19+
additionalProperties: false,
20+
},
21+
],
22+
messages: {
23+
unexpected: "Unexpected indexed reference in replacement string.",
24+
},
25+
type: "suggestion", // "problem",
26+
},
27+
create(context) {
28+
const strictTypes = context.options[0]?.strictTypes ?? true
29+
const sourceCode = context.getSourceCode()
30+
31+
/**
32+
* Create visitor
33+
*/
34+
function createVisitor(
35+
regexpContext: RegExpContext,
36+
): RegExpVisitor.Handlers {
37+
const {
38+
node,
39+
getAllCapturingGroups,
40+
getCapturingGroupReferences,
41+
} = regexpContext
42+
43+
const capturingGroups = getAllCapturingGroups()
44+
if (!capturingGroups.length) {
45+
return {}
46+
}
47+
48+
for (const ref of getCapturingGroupReferences({ strictTypes })) {
49+
if (
50+
ref.type === "ReplacementRef" &&
51+
ref.kind === "index" &&
52+
ref.range
53+
) {
54+
const cgNode = capturingGroups[ref.ref - 1]
55+
if (cgNode && cgNode.name) {
56+
context.report({
57+
node,
58+
loc: {
59+
start: sourceCode.getLocFromIndex(ref.range[0]),
60+
end: sourceCode.getLocFromIndex(ref.range[1]),
61+
},
62+
messageId: "unexpected",
63+
fix(fixer) {
64+
return fixer.replaceTextRange(
65+
ref.range!,
66+
`$<${cgNode.name}>`,
67+
)
68+
},
69+
})
70+
}
71+
}
72+
}
73+
74+
return {}
75+
}
76+
77+
return defineRegexpVisitor(context, {
78+
createVisitor,
79+
})
80+
},
81+
})

lib/utils/extract-capturing-group-references.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,9 @@ export type CapturingGroupReference =
131131

132132
type ExtractCapturingGroupReferencesContext = {
133133
flags: ReadonlyFlags
134-
typeTracer: TypeTracker
135134
countOfCapturingGroup: number
136135
context: Rule.RuleContext
136+
isString: (node: Expression) => boolean
137137
}
138138

139139
/**
@@ -145,12 +145,17 @@ export function* extractCapturingGroupReferences(
145145
typeTracer: TypeTracker,
146146
countOfCapturingGroup: number,
147147
context: Rule.RuleContext,
148+
options: {
149+
strictTypes: boolean
150+
},
148151
): Iterable<CapturingGroupReference> {
149-
const ctx = {
152+
const ctx: ExtractCapturingGroupReferencesContext = {
150153
flags,
151-
typeTracer,
152154
countOfCapturingGroup,
153155
context,
156+
isString: options.strictTypes
157+
? (n) => typeTracer.isString(n)
158+
: (n) => typeTracer.maybeString(n),
154159
}
155160
for (const ref of extractExpressionReferences(node, context)) {
156161
if (ref.type === "argument") {
@@ -187,7 +192,7 @@ function* iterateForArgument(
187192
if (callExpression.arguments[0] !== argument) {
188193
return
189194
}
190-
if (!ctx.typeTracer.isString(callExpression.callee.object)) {
195+
if (!ctx.isString(callExpression.callee.object)) {
191196
yield {
192197
type: "UnknownUsage",
193198
node: argument,

lib/utils/index.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,9 @@ type RegExpContextBase = {
9191
/**
9292
* Returns the capturing group references
9393
*/
94-
getCapturingGroupReferences: () => CapturingGroupReference[]
94+
getCapturingGroupReferences: (options?: {
95+
strictTypes?: boolean // default true
96+
}) => CapturingGroupReference[]
9597

9698
/**
9799
* Returns a list of all capturing groups in the order of their numbers.
@@ -610,7 +612,10 @@ function buildRegExpContextBase({
610612
const sourceCode = context.getSourceCode()
611613

612614
let cacheUsageOfPattern: UsageOfPattern | null = null
613-
let cacheCapturingGroupReference: CapturingGroupReference[] | null = null
615+
const cacheCapturingGroupReferenceMap = new Map<
616+
boolean /* strictTypes */,
617+
CapturingGroupReference[]
618+
>()
614619
let cacheAllCapturingGroups: CapturingGroup[] | null = null
615620
return {
616621
getRegexpLocation: (range, offsets) => {
@@ -643,21 +648,33 @@ function buildRegExpContextBase({
643648
},
644649
getUsageOfPattern: () =>
645650
(cacheUsageOfPattern ??= getUsageOfPattern(regexpNode, context)),
646-
getCapturingGroupReferences: () => {
651+
getCapturingGroupReferences: (options?: {
652+
strictTypes?: boolean // default true
653+
}) => {
654+
const strictTypes = Boolean(options?.strictTypes ?? true)
655+
const cacheCapturingGroupReference = cacheCapturingGroupReferenceMap.get(
656+
strictTypes,
657+
)
647658
if (cacheCapturingGroupReference) {
648659
return cacheCapturingGroupReference
649660
}
650661
const countOfCapturingGroup = getAllCapturingGroupsWithCache()
651662
.length
652-
return (cacheCapturingGroupReference = [
663+
const capturingGroupReferences = [
653664
...extractCapturingGroupReferences(
654665
regexpNode,
655666
flags,
656667
createTypeTracker(context),
657668
countOfCapturingGroup,
658669
context,
670+
{ strictTypes },
659671
),
660-
])
672+
]
673+
cacheCapturingGroupReferenceMap.set(
674+
strictTypes,
675+
capturingGroupReferences,
676+
)
677+
return capturingGroupReferences
661678
},
662679
getAllCapturingGroups: getAllCapturingGroupsWithCache,
663680

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import preferEscapeReplacementDollarChar from "../rules/prefer-escape-replacemen
5555
import preferLookaround from "../rules/prefer-lookaround"
5656
import preferNamedBackreference from "../rules/prefer-named-backreference"
5757
import preferNamedCaptureGroup from "../rules/prefer-named-capture-group"
58+
import preferNamedReplacement from "../rules/prefer-named-replacement"
5859
import preferPlusQuantifier from "../rules/prefer-plus-quantifier"
5960
import preferPredefinedAssertion from "../rules/prefer-predefined-assertion"
6061
import preferQuantifier from "../rules/prefer-quantifier"
@@ -131,6 +132,7 @@ export const rules = [
131132
preferLookaround,
132133
preferNamedBackreference,
133134
preferNamedCaptureGroup,
135+
preferNamedReplacement,
134136
preferPlusQuantifier,
135137
preferPredefinedAssertion,
136138
preferQuantifier,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { RuleTester } from "eslint"
2+
import rule from "../../../lib/rules/prefer-named-replacement"
3+
4+
const tester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 2020,
7+
sourceType: "module",
8+
},
9+
})
10+
11+
tester.run("prefer-named-replacement", rule as any, {
12+
valid: [
13+
`"str".replace(/regexp/, "foo")`,
14+
`"str".replace(/a(b)c/, "_$1_")`,
15+
`"str".replaceAll(/a(b)c/, "_$1_")`,
16+
`"str".replace(/a(?<foo>b)c/, "_$<foo>_")`,
17+
`"str".replaceAll(/a(?<foo>b)c/, "_$<foo>_")`,
18+
`"str".replace(/a(?<foo>b)c/, "_$0_")`,
19+
`"str".replace(/(a)(?<foo>b)c/, "_$1_")`,
20+
`"str".replace(/a(b)c/, "_$2_")`,
21+
`unknown.replace(/a(?<foo>b)c/, "_$1_")`,
22+
`unknown.replaceAll(/a(?<foo>b)c/, "_$1_")`,
23+
],
24+
invalid: [
25+
{
26+
code: `"str".replace(/a(?<foo>b)c/, "_$1_")`,
27+
output: `"str".replace(/a(?<foo>b)c/, "_$<foo>_")`,
28+
errors: [
29+
{
30+
message:
31+
"Unexpected indexed reference in replacement string.",
32+
line: 1,
33+
column: 32,
34+
},
35+
],
36+
},
37+
{
38+
code: `"str".replaceAll(/a(?<foo>b)c/, "_$1_")`,
39+
output: `"str".replaceAll(/a(?<foo>b)c/, "_$<foo>_")`,
40+
errors: [
41+
{
42+
message:
43+
"Unexpected indexed reference in replacement string.",
44+
line: 1,
45+
column: 35,
46+
},
47+
],
48+
},
49+
{
50+
code: `"str".replace(/(a)(?<foo>b)c/, "_$1$2_")`,
51+
output: `"str".replace(/(a)(?<foo>b)c/, "_$1$<foo>_")`,
52+
errors: [
53+
{
54+
message:
55+
"Unexpected indexed reference in replacement string.",
56+
line: 1,
57+
column: 36,
58+
},
59+
],
60+
},
61+
{
62+
code: `unknown.replace(/a(?<foo>b)c/, "_$1_")`,
63+
output: `unknown.replace(/a(?<foo>b)c/, "_$<foo>_")`,
64+
options: [{ strictTypes: false }],
65+
errors: ["Unexpected indexed reference in replacement string."],
66+
},
67+
{
68+
code: `unknown.replaceAll(/a(?<foo>b)c/, "_$1_")`,
69+
output: `unknown.replaceAll(/a(?<foo>b)c/, "_$<foo>_")`,
70+
options: [{ strictTypes: false }],
71+
errors: ["Unexpected indexed reference in replacement string."],
72+
},
73+
],
74+
})

0 commit comments

Comments
 (0)