Skip to content

Commit f75cbba

Browse files
Add regexp/no-useless-set-operand rule (#625)
* Add `regexp/no-useless-set-operand` rule * Added docs * Create nervous-yaks-destroy.md * Apply suggestions from code review Co-authored-by: Yosuke Ota <otameshiyo23@gmail.com> * npm run update --------- Co-authored-by: Yosuke Ota <otameshiyo23@gmail.com>
1 parent fb15338 commit f75cbba

File tree

8 files changed

+412
-0
lines changed

8 files changed

+412
-0
lines changed

.changeset/nervous-yaks-destroy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-regexp": major
3+
---
4+
5+
Add `regexp/no-useless-set-operand` rule

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ The `plugin:regexp/all` config enables all rules. It's meant for testing, not fo
155155
| [no-useless-lazy](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-lazy.html) | disallow unnecessarily non-greedy quantifiers || | 🔧 | |
156156
| [no-useless-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-quantifier.html) | disallow quantifiers that can be removed || | 🔧 | 💡 |
157157
| [no-useless-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-range.html) | disallow unnecessary character ranges || | 🔧 | |
158+
| [no-useless-set-operand](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-set-operand.html) | disallow unnecessary elements in expression character classes || | 🔧 | |
158159
| [no-useless-two-nums-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-two-nums-quantifier.html) | disallow unnecessary `{n,m}` quantifier || | 🔧 | |
159160
| [no-zero-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-zero-quantifier.html) | disallow quantifiers with a maximum of zero || | | 💡 |
160161
| [optimal-lookaround-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/optimal-lookaround-quantifier.html) | disallow the alternatives of lookarounds that end with a non-constant quantifier | || | 💡 |

docs/rules/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ sidebarDepth: 0
6262
| [no-useless-lazy](no-useless-lazy.md) | disallow unnecessarily non-greedy quantifiers || | 🔧 | |
6363
| [no-useless-quantifier](no-useless-quantifier.md) | disallow quantifiers that can be removed || | 🔧 | 💡 |
6464
| [no-useless-range](no-useless-range.md) | disallow unnecessary character ranges || | 🔧 | |
65+
| [no-useless-set-operand](no-useless-set-operand.md) | disallow unnecessary elements in expression character classes || | 🔧 | |
6566
| [no-useless-two-nums-quantifier](no-useless-two-nums-quantifier.md) | disallow unnecessary `{n,m}` quantifier || | 🔧 | |
6667
| [no-zero-quantifier](no-zero-quantifier.md) | disallow quantifiers with a maximum of zero || | | 💡 |
6768
| [optimal-lookaround-quantifier](optimal-lookaround-quantifier.md) | disallow the alternatives of lookarounds that end with a non-constant quantifier | || | 💡 |

docs/rules/no-useless-set-operand.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/no-useless-set-operand"
5+
description: "disallow unnecessary elements in expression character classes"
6+
---
7+
# regexp/no-useless-set-operand
8+
9+
💼 This rule is enabled in the ✅ `plugin:regexp/recommended` config.
10+
11+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
12+
13+
<!-- end auto-generated rule header -->
14+
15+
> disallow unnecessary elements in expression character classes
16+
17+
## :book: Rule Details
18+
19+
The `v` flag added set operations for character classes, e.g. `[\w&&\D]` and `[\w--\d]`, but there are no limitations on what operands can be used. This rule reports any unnecessary operands.
20+
21+
<eslint-code-block fix>
22+
23+
```js
24+
/* eslint regexp/no-useless-set-operand: "error" */
25+
26+
/* ✓ GOOD */
27+
foo = /[\w--\d]/v
28+
foo = /[\w--[\d_]]/v
29+
30+
/* ✗ BAD */
31+
foo = /[\w--[\d$]]/v
32+
foo = /[\w&&\d]/v
33+
foo = /[\w&&\s]/v
34+
foo = /[\w&&[\d\s]]/v
35+
foo = /[\w&&[^\d\s]]/v
36+
foo = /[\w--\s]/v
37+
foo = /[\d--\w]/v
38+
foo = /[\w--[\d\s]]/v
39+
foo = /[\w--[^\d\s]]/v
40+
41+
```
42+
43+
</eslint-code-block>
44+
45+
## :wrench: Options
46+
47+
Nothing.
48+
49+
## :rocket: Version
50+
51+
:exclamation: <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
52+
53+
## :mag: Implementation
54+
55+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-useless-set-operand.ts)
56+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-useless-set-operand.ts)

lib/configs/recommended.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const rules = {
5050
"regexp/no-useless-non-capturing-group": "error",
5151
"regexp/no-useless-quantifier": "error",
5252
"regexp/no-useless-range": "error",
53+
"regexp/no-useless-set-operand": "error",
5354
"regexp/no-useless-two-nums-quantifier": "error",
5455
"regexp/no-zero-quantifier": "error",
5556
"regexp/optimal-lookaround-quantifier": "warn",

lib/rules/no-useless-set-operand.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
2+
import type {
3+
CharacterClassElement,
4+
ClassSetOperand,
5+
ExpressionCharacterClass,
6+
Node,
7+
StringAlternative,
8+
} from "@eslint-community/regexpp/ast"
9+
import type { RegExpContext } from "../utils"
10+
import { createRule, defineRegexpVisitor } from "../utils"
11+
import { toUnicodeSet } from "regexp-ast-analysis"
12+
13+
type FlatElement = CharacterClassElement | StringAlternative
14+
15+
function getFlatElements(
16+
node: ClassSetOperand | ExpressionCharacterClass["expression"],
17+
): readonly FlatElement[] {
18+
if (node.type === "ClassStringDisjunction") {
19+
return node.alternatives
20+
}
21+
if (node.type === "CharacterClass") {
22+
const nested: FlatElement[] = []
23+
// eslint-disable-next-line func-style -- x
24+
const addElement = (element: CharacterClassElement) => {
25+
if (element.type === "ClassStringDisjunction") {
26+
nested.push(...element.alternatives)
27+
} else if (element.type === "CharacterClass") {
28+
if (!element.negate) {
29+
nested.push(...element.elements)
30+
}
31+
nested.push(element)
32+
} else {
33+
nested.push(element)
34+
}
35+
}
36+
node.elements.forEach(addElement)
37+
return nested
38+
}
39+
40+
return []
41+
}
42+
43+
function removeDescendant(root: Node, e: FlatElement): string {
44+
let { start, end } = e
45+
46+
if (e.type === "StringAlternative") {
47+
if (e.parent.alternatives.length === 1) {
48+
// we have to remove the whole string disjunction
49+
// eslint-disable-next-line no-param-reassign -- x
50+
e = e.parent
51+
start = e.start
52+
end = e.end
53+
} else {
54+
// remove one adjacent | symbol
55+
if (e.parent.alternatives.at(-1) === e) {
56+
start--
57+
} else {
58+
end++
59+
}
60+
}
61+
}
62+
63+
const before = root.raw.slice(0, start - root.start)
64+
const after = root.raw.slice(end - root.start)
65+
return before + after
66+
}
67+
68+
export default createRule("no-useless-set-operand", {
69+
meta: {
70+
docs: {
71+
description:
72+
"disallow unnecessary elements in expression character classes",
73+
category: "Best Practices",
74+
recommended: true,
75+
},
76+
schema: [],
77+
messages: {
78+
intersectionDisjoint:
79+
"'{{left}}' and '{{right}}' are disjoint, so the result of the intersection is always going to be the empty set.",
80+
intersectionSubset:
81+
"'{{sub}}' is a subset of '{{super}}', so the result of the intersection is always going to be '{{sub}}'.",
82+
intersectionRemove:
83+
"'{{expr}}' can be removed without changing the result of the intersection.",
84+
subtractionDisjoint:
85+
"'{{left}}' and '{{right}}' are disjoint, so the subtraction doesn't do anything.",
86+
subtractionSubset:
87+
"'{{left}}' is a subset of '{{right}}', so the result of the subtraction is always going to be the empty set.",
88+
subtractionRemove:
89+
"'{{expr}}' can be removed without changing the result of the subtraction.",
90+
},
91+
fixable: "code",
92+
type: "suggestion",
93+
},
94+
create(context) {
95+
function createVisitor(
96+
regexpContext: RegExpContext,
97+
): RegExpVisitor.Handlers {
98+
const { node, flags, getRegexpLocation, fixReplaceNode } =
99+
regexpContext
100+
101+
if (!flags.unicodeSets) {
102+
// set operations are only available with the `v` flag
103+
return {}
104+
}
105+
106+
function fixRemoveExpression(
107+
expr: ExpressionCharacterClass["expression"],
108+
) {
109+
if (expr.parent.type === "ExpressionCharacterClass") {
110+
const cc = expr.parent
111+
return fixReplaceNode(cc, cc.negate ? "[^]" : "[]")
112+
}
113+
return fixReplaceNode(expr, "[]")
114+
}
115+
116+
return {
117+
onClassIntersectionEnter(iNode) {
118+
const leftSet = toUnicodeSet(iNode.left, flags)
119+
const rightSet = toUnicodeSet(iNode.right, flags)
120+
121+
if (leftSet.isDisjointWith(rightSet)) {
122+
context.report({
123+
node,
124+
loc: getRegexpLocation(iNode),
125+
messageId: "intersectionDisjoint",
126+
data: {
127+
left: iNode.left.raw,
128+
right: iNode.right.raw,
129+
},
130+
fix: fixRemoveExpression(iNode),
131+
})
132+
return
133+
}
134+
135+
if (leftSet.isSubsetOf(rightSet)) {
136+
context.report({
137+
node,
138+
loc: getRegexpLocation(iNode),
139+
messageId: "intersectionSubset",
140+
data: {
141+
sub: iNode.left.raw,
142+
super: iNode.right.raw,
143+
},
144+
fix: fixReplaceNode(iNode, iNode.left.raw),
145+
})
146+
return
147+
}
148+
if (rightSet.isSubsetOf(leftSet)) {
149+
context.report({
150+
node,
151+
loc: getRegexpLocation(iNode),
152+
messageId: "intersectionSubset",
153+
data: {
154+
sub: iNode.right.raw,
155+
super: iNode.left.raw,
156+
},
157+
fix: fixReplaceNode(iNode, iNode.right.raw),
158+
})
159+
return
160+
}
161+
162+
const toRemoveRight = getFlatElements(iNode.right).filter(
163+
(e) => leftSet.isDisjointWith(toUnicodeSet(e, flags)),
164+
)
165+
const toRemoveLeft = getFlatElements(iNode.left).filter(
166+
(e) => rightSet.isDisjointWith(toUnicodeSet(e, flags)),
167+
)
168+
for (const e of [...toRemoveRight, ...toRemoveLeft]) {
169+
context.report({
170+
node,
171+
loc: getRegexpLocation(e),
172+
messageId: "subtractionRemove",
173+
data: {
174+
expr: e.raw,
175+
},
176+
fix: fixReplaceNode(
177+
iNode,
178+
removeDescendant(iNode, e),
179+
),
180+
})
181+
}
182+
},
183+
onClassSubtractionEnter(sNode) {
184+
const leftSet = toUnicodeSet(sNode.left, flags)
185+
const rightSet = toUnicodeSet(sNode.right, flags)
186+
187+
if (leftSet.isDisjointWith(rightSet)) {
188+
context.report({
189+
node,
190+
loc: getRegexpLocation(sNode),
191+
messageId: "subtractionDisjoint",
192+
data: {
193+
left: sNode.left.raw,
194+
right: sNode.right.raw,
195+
},
196+
fix: fixReplaceNode(sNode, sNode.left.raw),
197+
})
198+
return
199+
}
200+
201+
if (leftSet.isSubsetOf(rightSet)) {
202+
context.report({
203+
node,
204+
loc: getRegexpLocation(sNode),
205+
messageId: "subtractionSubset",
206+
data: {
207+
left: sNode.left.raw,
208+
right: sNode.right.raw,
209+
},
210+
fix: fixRemoveExpression(sNode),
211+
})
212+
return
213+
}
214+
215+
const toRemove = getFlatElements(sNode.right).filter((e) =>
216+
leftSet.isDisjointWith(toUnicodeSet(e, flags)),
217+
)
218+
for (const e of toRemove) {
219+
context.report({
220+
node,
221+
loc: getRegexpLocation(e),
222+
messageId: "subtractionRemove",
223+
data: {
224+
expr: e.raw,
225+
},
226+
fix: fixReplaceNode(
227+
sNode,
228+
removeDescendant(sNode, e),
229+
),
230+
})
231+
}
232+
},
233+
}
234+
}
235+
236+
return defineRegexpVisitor(context, {
237+
createVisitor,
238+
})
239+
},
240+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import noUselessNonCapturingGroup from "../rules/no-useless-non-capturing-group"
4747
import noUselessNonGreedy from "../rules/no-useless-non-greedy"
4848
import noUselessQuantifier from "../rules/no-useless-quantifier"
4949
import noUselessRange from "../rules/no-useless-range"
50+
import noUselessSetOperand from "../rules/no-useless-set-operand"
5051
import noUselessTwoNumsQuantifier from "../rules/no-useless-two-nums-quantifier"
5152
import noZeroQuantifier from "../rules/no-zero-quantifier"
5253
import optimalLookaroundQuantifier from "../rules/optimal-lookaround-quantifier"
@@ -130,6 +131,7 @@ export const rules = [
130131
noUselessNonGreedy,
131132
noUselessQuantifier,
132133
noUselessRange,
134+
noUselessSetOperand,
133135
noUselessTwoNumsQuantifier,
134136
noZeroQuantifier,
135137
optimalLookaroundQuantifier,

0 commit comments

Comments
 (0)