Skip to content

Commit b59ce50

Browse files
Add support for v flag to regexp/no-dupe-disjunctions (#612)
* Add support for `v` flag to `regexp/no-dupe-disjunctions` * Create beige-fireants-dress.md
1 parent 19f5830 commit b59ce50

File tree

5 files changed

+178
-45
lines changed

5 files changed

+178
-45
lines changed

.changeset/beige-fireants-dress.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-regexp": patch
3+
---
4+
5+
Add support for `v` flag to `regexp/no-dupe-disjunctions`

lib/rules/no-dupe-disjunctions.ts

+46-15
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { RegExpVisitor } from "@eslint-community/regexpp/visitor"
22
import type {
33
Alternative,
44
CapturingGroup,
5+
CharacterClassElement,
56
Group,
67
LookaroundAssertion,
78
Node,
@@ -15,6 +16,7 @@ import {
1516
fixRemoveCharacterClassElement,
1617
fixRemoveAlternative,
1718
assertValidFlags,
19+
fixRemoveStringAlternative,
1820
} from "../utils"
1921
import { getParser, isCoveredNode, isEqualNodes } from "../utils/regexp-ast"
2022
import type { Expression, FiniteAutomaton, NoParent, ReadonlyNFA } from "refa"
@@ -221,21 +223,46 @@ function* iterateNestedAlternatives(
221223

222224
if (e.type === "CharacterClass" && !e.negate) {
223225
const nested: NestedAlternative[] = []
224-
for (const charElement of e.elements) {
225-
if (charElement.type === "CharacterClassRange") {
226-
const min = charElement.min
227-
const max = charElement.max
228-
if (min.value === max.value) {
226+
227+
// eslint-disable-next-line func-style -- x
228+
const addToNested = (charElement: CharacterClassElement) => {
229+
switch (charElement.type) {
230+
case "CharacterClassRange": {
231+
const min = charElement.min
232+
const max = charElement.max
233+
if (min.value === max.value) {
234+
nested.push(charElement)
235+
} else if (min.value + 1 === max.value) {
236+
nested.push(min, max)
237+
} else {
238+
nested.push(charElement, min, max)
239+
}
240+
break
241+
}
242+
case "ClassStringDisjunction": {
243+
nested.push(...charElement.alternatives)
244+
break
245+
}
246+
case "CharacterClass": {
247+
if (!charElement.negate) {
248+
charElement.elements.forEach(addToNested)
249+
} else {
250+
nested.push(charElement)
251+
}
252+
break
253+
}
254+
case "Character":
255+
case "CharacterSet":
256+
case "ExpressionCharacterClass": {
229257
nested.push(charElement)
230-
} else if (min.value + 1 === max.value) {
231-
nested.push(min, max)
232-
} else {
233-
nested.push(charElement, min, max)
258+
break
234259
}
235-
} else {
236-
nested.push(charElement)
260+
default:
261+
throw assertNever(charElement)
237262
}
238263
}
264+
e.elements.forEach(addToNested)
265+
239266
if (nested.length > 1) yield* nested
240267
}
241268
}
@@ -825,7 +852,7 @@ function deduplicateResults(
825852
}
826853

827854
function mentionNested(nested: NestedAlternative): string {
828-
if (nested.type === "Alternative") {
855+
if (nested.type === "Alternative" || nested.type === "StringAlternative") {
829856
return mention(nested)
830857
}
831858
return mentionChar(nested)
@@ -842,9 +869,15 @@ function fixRemoveNestedAlternative(
842869
case "Alternative":
843870
return fixRemoveAlternative(context, alternative)
844871

872+
case "StringAlternative":
873+
return fixRemoveStringAlternative(context, alternative)
874+
845875
case "Character":
846876
case "CharacterClassRange":
847-
case "CharacterSet": {
877+
case "CharacterSet":
878+
case "CharacterClass":
879+
case "ExpressionCharacterClass":
880+
case "ClassStringDisjunction": {
848881
if (alternative.parent.type !== "CharacterClass") {
849882
// This isn't supposed to happen. We can't just remove the only
850883
// alternative of its parent
@@ -855,8 +888,6 @@ function fixRemoveNestedAlternative(
855888
}
856889

857890
default:
858-
// FIXME: TS Error
859-
// @ts-expect-error -- FIXME
860891
throw assertNever(alternative)
861892
}
862893
}

lib/utils/index.ts

+32-6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
Node,
1010
Pattern,
1111
Quantifier,
12+
StringAlternative,
1213
} from "@eslint-community/regexpp/ast"
1314
import { RegExpParser, visitRegExpAST } from "@eslint-community/regexpp"
1415
import {
@@ -1206,14 +1207,13 @@ export function fixRemoveCharacterClassElement(
12061207
return null
12071208
}
12081209

1210+
const elements: CharacterClassElement[] = cc.elements
1211+
const elementIndex = elements.indexOf(element)
1212+
12091213
const elementBefore: CharacterClassElement | undefined =
1210-
// FIXME: TS Error
1211-
// @ts-expect-error -- FIXME
1212-
cc.elements[cc.elements.indexOf(element) - 1]
1214+
cc.elements[elementIndex - 1]
12131215
const elementAfter: CharacterClassElement | undefined =
1214-
// FIXME: TS Error
1215-
// @ts-expect-error -- FIXME
1216-
cc.elements[cc.elements.indexOf(element) + 1]
1216+
cc.elements[elementIndex + 1]
12171217

12181218
if (
12191219
elementBefore &&
@@ -1275,6 +1275,32 @@ export function fixRemoveAlternative(
12751275
})
12761276
}
12771277

1278+
export function fixRemoveStringAlternative(
1279+
context: RegExpContext,
1280+
alternative: StringAlternative,
1281+
): (fixer: Rule.RuleFixer) => Rule.Fix | null {
1282+
const { parent } = alternative
1283+
if (parent.alternatives.length === 1) {
1284+
// We have to remove the string disjunction instead.
1285+
// We replace it with `[]` to avoid situations like `[&\q{a}&]` -> `[&&]`
1286+
return context.fixReplaceNode(parent, "[]")
1287+
}
1288+
1289+
return context.fixReplaceNode(parent, () => {
1290+
let { start, end } = alternative
1291+
if (parent.alternatives[0] === alternative) {
1292+
end++
1293+
} else {
1294+
start--
1295+
}
1296+
1297+
const before = parent.raw.slice(0, start - parent.start)
1298+
const after = parent.raw.slice(end - parent.start)
1299+
1300+
return before + after
1301+
})
1302+
}
1303+
12781304
/**
12791305
* Check the siblings to see if the regex doesn't change when unwrapped.
12801306
*/

lib/utils/partial-parser.ts

+81-23
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@ import { JS } from "refa"
99
import type { AST } from "@eslint-community/regexpp"
1010
import { assertNever } from "./util"
1111

12-
export type NestedAlternative = AST.Alternative | AST.CharacterClassElement
12+
export type NestedAlternative =
13+
| AST.Alternative
14+
| AST.CharacterClassElement
15+
| AST.StringAlternative
1316

1417
class Context {
18+
/**
19+
* The ancestor nodes of the alternative + the alternative itself.
20+
*/
1521
public readonly ancestors: ReadonlySet<AST.Node>
1622

1723
public readonly alternative: NestedAlternative
@@ -27,15 +33,14 @@ class Context {
2733
}
2834
}
2935

36+
type Parsable = JS.ParsableElement | AST.CharacterClassElement
37+
3038
export class PartialParser {
3139
private readonly parser: JS.Parser
3240

3341
private readonly options: JS.ParseOptions
3442

35-
private readonly nativeCache = new WeakMap<
36-
JS.ParsableElement,
37-
NoParent<Element>
38-
>()
43+
private readonly nativeCache = new WeakMap<Parsable, NoParent<Element>>()
3944

4045
public constructor(parser: JS.Parser, options: JS.ParseOptions = {}) {
4146
this.parser = parser
@@ -117,8 +122,34 @@ export class PartialParser {
117122
}
118123
}
119124

125+
private parseStringAlternatives(
126+
alternatives: readonly AST.StringAlternative[],
127+
context: Context,
128+
): NoParent<Concatenation>[] {
129+
const ancestor = alternatives.find((a) => context.ancestors.has(a))
130+
if (ancestor) {
131+
return [this.parseStringAlternative(ancestor)]
132+
}
133+
134+
return alternatives.map((a) => this.parseStringAlternative(a))
135+
}
136+
137+
private parseStringAlternative(
138+
alternative: AST.StringAlternative,
139+
): NoParent<Concatenation> {
140+
return {
141+
type: "Concatenation",
142+
elements: alternative.elements.map((e) =>
143+
this.nativeParseElement(e),
144+
),
145+
}
146+
}
147+
120148
private parseElement(
121-
element: AST.Element,
149+
element:
150+
| AST.Element
151+
| AST.CharacterClassRange
152+
| AST.ClassStringDisjunction,
122153
context: Context,
123154
): NoParent<Element> {
124155
if (!context.ancestors.has(element)) {
@@ -130,11 +161,29 @@ export class PartialParser {
130161
case "Backreference":
131162
case "Character":
132163
case "CharacterSet":
164+
case "ExpressionCharacterClass":
165+
return this.nativeParseElement(element)
166+
167+
case "CharacterClassRange":
168+
if (context.alternative === element.min) {
169+
return this.nativeParseElement(context.alternative)
170+
} else if (context.alternative === element.max) {
171+
return this.nativeParseElement(context.alternative)
172+
}
133173
return this.nativeParseElement(element)
134174

135175
case "CharacterClass":
136176
return this.parseCharacterClass(element, context)
137177

178+
case "ClassStringDisjunction":
179+
return {
180+
type: "Alternation",
181+
alternatives: this.parseStringAlternatives(
182+
element.alternatives,
183+
context,
184+
),
185+
}
186+
138187
case "Group":
139188
case "CapturingGroup":
140189
return {
@@ -175,8 +224,6 @@ export class PartialParser {
175224
}
176225

177226
default:
178-
// FIXME: TS Error
179-
// @ts-expect-error -- FIXME
180227
throw assertNever(element)
181228
}
182229
}
@@ -193,22 +240,17 @@ export class PartialParser {
193240
return this.nativeParseElement(cc)
194241
}
195242

196-
if (context.alternative.type === "CharacterClassRange") {
197-
const range: CharRange = {
198-
min: context.alternative.min.value,
199-
max: context.alternative.max.value,
200-
}
201-
return {
202-
type: "CharacterClass",
203-
characters: JS.createCharSet([range], this.parser.flags),
243+
for (const e of cc.elements) {
244+
if (context.ancestors.has(e)) {
245+
return this.parseElement(e, context)
204246
}
205247
}
206-
// FIXME: TS Error
207-
// @ts-expect-error -- FIXME
208-
return this.nativeParseElement(context.alternative)
248+
249+
// this means that cc === context.alternative
250+
return this.nativeParseElement(cc)
209251
}
210252

211-
private nativeParseElement(element: JS.ParsableElement): NoParent<Element> {
253+
private nativeParseElement(element: Parsable): NoParent<Element> {
212254
let cached = this.nativeCache.get(element)
213255
if (!cached) {
214256
cached = this.nativeParseElementUncached(element)
@@ -217,9 +259,25 @@ export class PartialParser {
217259
return cached
218260
}
219261

220-
private nativeParseElementUncached(
221-
element: JS.ParsableElement,
222-
): NoParent<Element> {
262+
private nativeParseElementUncached(element: Parsable): NoParent<Element> {
263+
if (element.type === "CharacterClassRange") {
264+
const range: CharRange = {
265+
min: element.min.value,
266+
max: element.max.value,
267+
}
268+
return {
269+
type: "CharacterClass",
270+
characters: JS.createCharSet([range], this.parser.flags),
271+
}
272+
} else if (element.type === "ClassStringDisjunction") {
273+
return {
274+
type: "Alternation",
275+
alternatives: element.alternatives.map((a) =>
276+
this.parseStringAlternative(a),
277+
),
278+
}
279+
}
280+
223281
const { expression } = this.parser.parseElement(element, this.options)
224282

225283
if (expression.alternatives.length === 1) {

tests/lib/rules/no-dupe-disjunctions.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import rule from "../../../lib/rules/no-dupe-disjunctions"
33

44
const tester = new RuleTester({
55
parserOptions: {
6-
ecmaVersion: 2020,
6+
ecmaVersion: "latest",
77
sourceType: "module",
88
},
99
})
@@ -19,6 +19,7 @@ tester.run("no-dupe-disjunctions", rule as any, {
1919
`/(?:js|json)?abc/`,
2020
`/<("[^"]*"|'[^']*'|[^'">])*>/g`,
2121
`/c+|[a-f]/`,
22+
`/c+|[a-f]/v`,
2223
String.raw`/b+(?:\w+|[+-]?\d+)/`,
2324
String.raw`/\d*\.\d+_|\d+\.\d*_/`,
2425
String.raw`/\d*\.\d+|\d+\.\d*/`,
@@ -149,6 +150,12 @@ tester.run("no-dupe-disjunctions", rule as any, {
149150
"Unexpected duplicate alternative. This alternative can be removed.",
150151
],
151152
},
153+
{
154+
code: `/((?:ab|ba)|(?:ab|ba))/v`,
155+
errors: [
156+
"Unexpected duplicate alternative. This alternative can be removed.",
157+
],
158+
},
152159
{
153160
code: `/((?:ab|ba)|(?:ba|ab))/`,
154161
errors: [
@@ -514,6 +521,12 @@ tester.run("no-dupe-disjunctions", rule as any, {
514521
"Unexpected useless alternative. This alternative is a strict subset of 'A*' and can be removed.",
515522
],
516523
},
524+
{
525+
code: String.raw`/[\q{a|bb}]|bb/v`,
526+
errors: [
527+
"Unexpected useless alternative. This alternative is a strict subset of '[\\q{a|bb}]' and can be removed.",
528+
],
529+
},
517530
{
518531
// reportExponentialBacktracking: 'potential'
519532
code: String.raw`

0 commit comments

Comments
 (0)