Skip to content

Commit 22a3613

Browse files
Add regexp/use-ignore-case rule (#345)
* Added case variation logic * Support flags-only fixes for regex literals * Add `regexp/use-ignore-case` rule
1 parent 38e6456 commit 22a3613

File tree

9 files changed

+527
-65
lines changed

9 files changed

+527
-65
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
159159
| [regexp/prefer-regexp-test](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-regexp-test.html) | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec` | :wrench: |
160160
| [regexp/require-unicode-regexp](https://ota-meshi.github.io/eslint-plugin-regexp/rules/require-unicode-regexp.html) | enforce the use of the `u` flag | :wrench: |
161161
| [regexp/sort-alternatives](https://ota-meshi.github.io/eslint-plugin-regexp/rules/sort-alternatives.html) | sort alternatives if order doesn't matter | :wrench: |
162+
| [regexp/use-ignore-case](https://ota-meshi.github.io/eslint-plugin-regexp/rules/use-ignore-case.html) | use the `i` flag if it simplifies the pattern | :wrench: |
162163

163164
### Stylistic Issues
164165

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
6868
| [regexp/prefer-regexp-test](./prefer-regexp-test.md) | enforce that `RegExp#test` is used instead of `String#match` and `RegExp#exec` | :wrench: |
6969
| [regexp/require-unicode-regexp](./require-unicode-regexp.md) | enforce the use of the `u` flag | :wrench: |
7070
| [regexp/sort-alternatives](./sort-alternatives.md) | sort alternatives if order doesn't matter | :wrench: |
71+
| [regexp/use-ignore-case](./use-ignore-case.md) | use the `i` flag if it simplifies the pattern | :wrench: |
7172

7273
### Stylistic Issues
7374

docs/rules/use-ignore-case.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/use-ignore-case"
5+
description: "use the `i` flag if it simplifies the pattern"
6+
---
7+
# regexp/use-ignore-case
8+
9+
> use the `i` flag if it simplifies the pattern
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 regular expressions that can be simplified by adding the `i` flag.
17+
18+
<eslint-code-block fix>
19+
20+
```js
21+
/* eslint regexp/use-ignore-case: "error" */
22+
23+
/* ✓ GOOD */
24+
var foo = /\w\d+a/;
25+
var foo = /\b0x[a-fA-F0-9]+\b/;
26+
27+
/* ✗ BAD */
28+
var foo = /[a-zA-Z]/;
29+
var foo = /\b0[xX][a-fA-F0-9]+\b/;
30+
```
31+
32+
</eslint-code-block>
33+
34+
## :wrench: Options
35+
36+
Nothing.
37+
38+
## :mag: Implementation
39+
40+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/use-ignore-case.ts)
41+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/use-ignore-case.ts)

lib/rules/no-useless-flag.ts

Lines changed: 3 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { createTypeTracker } from "../utils/type-tracker"
2323
import type { RuleListener } from "../types"
2424
import type { Rule } from "eslint"
25-
import { toCharSet } from "regexp-ast-analysis"
25+
import { isCaseVariant } from "../utils/regexp-ast/case-variation"
2626

2727
type CodePathStack = {
2828
codePathId: string
@@ -200,50 +200,9 @@ function createUselessIgnoreCaseFlagVisitor(context: Rule.RuleContext) {
200200
return {}
201201
}
202202

203-
const flagsNoI = { ...flags, ignoreCase: false }
204-
205-
let unnecessary = true
206203
return {
207-
onAssertionEnter(aNode) {
208-
if (unnecessary) {
209-
if (aNode.kind === "word" && flags.unicode) {
210-
// \b is defined similarly to \w.
211-
// same reason as for \w
212-
unnecessary = false
213-
}
214-
}
215-
},
216-
onCharacterEnter(cNode) {
217-
if (unnecessary) {
218-
// all characters only accept themselves except if they
219-
// are case sensitive
220-
if (toCharSet(cNode, flags).size > 1) {
221-
unnecessary = false
222-
}
223-
}
224-
},
225-
onCharacterSetEnter(cNode) {
226-
if (unnecessary) {
227-
if (cNode.kind === "word" && flags.unicode) {
228-
// \w is defined as [0-9A-Za-z_] and this character
229-
// class is case invariant in UTF16 (non-Unicode)
230-
// mode. However, Unicode mode changes however
231-
// ignore case works and this causes `/\w/u` and
232-
// `/\w/iu` to accept different characters,
233-
unnecessary = false
234-
}
235-
if (cNode.kind === "property") {
236-
const caseInsensitive = toCharSet(cNode, flags)
237-
const caseSensitive = toCharSet(cNode, flagsNoI)
238-
239-
if (!caseInsensitive.equals(caseSensitive)) {
240-
unnecessary = false
241-
}
242-
}
243-
}
244-
},
245-
onPatternLeave() {
246-
if (unnecessary) {
204+
onPatternLeave(pattern) {
205+
if (!isCaseVariant(pattern, flags, false)) {
247206
context.report({
248207
node: regexpNode,
249208
loc: getFlagLocation("i"),

lib/rules/use-ignore-case.ts

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import type { CharSet } from "refa"
2+
import { Chars, toCharSet } from "regexp-ast-analysis"
3+
import type { CharacterClass, CharacterClassElement } from "regexpp/ast"
4+
import type { RegExpVisitor } from "regexpp/visitor"
5+
import type { RegExpContext } from "../utils"
6+
import { createRule, defineRegexpVisitor } from "../utils"
7+
import {
8+
getIgnoreCaseFlags,
9+
isCaseVariant,
10+
} from "../utils/regexp-ast/case-variation"
11+
import { mention } from "../utils/mention"
12+
import type {
13+
PatternSource,
14+
PatternRange,
15+
} from "../utils/ast-utils/pattern-source"
16+
import type { Rule } from "eslint"
17+
import { UsageOfPattern } from "../utils/get-usage-of-pattern"
18+
19+
const ELEMENT_ORDER: Record<CharacterClassElement["type"], number> = {
20+
Character: 1,
21+
CharacterClassRange: 2,
22+
CharacterSet: 3,
23+
}
24+
25+
/**
26+
* Finds all character class elements that do not contribute to the whole.
27+
*/
28+
function findUseless(
29+
elements: readonly CharacterClassElement[],
30+
getCharSet: (e: CharacterClassElement) => CharSet,
31+
other: CharSet,
32+
): Set<CharacterClassElement> {
33+
const cache = new Map<CharacterClassElement, CharSet>()
34+
35+
/** A cached version of `getCharSet` */
36+
function get(e: CharacterClassElement): CharSet {
37+
let cached = cache.get(e)
38+
if (cached === undefined) {
39+
cached = getCharSet(e)
40+
cache.set(e, cached)
41+
}
42+
return cached
43+
}
44+
45+
// When searching for useless elements, we want to first
46+
// search for useless characters, then useless ranges, and
47+
// finally useless sets.
48+
49+
const sortedElements = [...elements]
50+
.reverse()
51+
.sort((a, b) => ELEMENT_ORDER[a.type] - ELEMENT_ORDER[b.type])
52+
53+
const useless = new Set<CharacterClassElement>()
54+
55+
for (const e of sortedElements) {
56+
const cs = get(e)
57+
if (cs.isSubsetOf(other)) {
58+
useless.add(e)
59+
continue
60+
}
61+
62+
// the total of all other elements
63+
const otherElements = elements.filter((o) => o !== e && !useless.has(o))
64+
const total = other.union(...otherElements.map(get))
65+
if (cs.isSubsetOf(total)) {
66+
useless.add(e)
67+
continue
68+
}
69+
}
70+
71+
return useless
72+
}
73+
74+
/** Returns all elements not in the given set */
75+
function without<T>(iter: Iterable<T>, set: ReadonlySet<T>): T[] {
76+
const result: T[] = []
77+
for (const item of iter) {
78+
if (!set.has(item)) {
79+
result.push(item)
80+
}
81+
}
82+
return result
83+
}
84+
85+
/**
86+
* Removes all the given ranges from the given pattern.
87+
*
88+
* This assumes that all ranges are disjoint
89+
*/
90+
function removeAll(
91+
fixer: Rule.RuleFixer,
92+
patternSource: PatternSource,
93+
ranges: readonly PatternRange[],
94+
) {
95+
const sorted = [...ranges].sort((a, b) => b.start - a.start)
96+
let pattern = patternSource.value
97+
98+
for (const { start, end } of sorted) {
99+
pattern = pattern.slice(0, start) + pattern.slice(end)
100+
}
101+
102+
const range = patternSource.getReplaceRange({
103+
start: 0,
104+
end: patternSource.value.length,
105+
})
106+
if (range) {
107+
return range.replace(fixer, pattern)
108+
}
109+
return null
110+
}
111+
112+
export default createRule("use-ignore-case", {
113+
meta: {
114+
docs: {
115+
description: "use the `i` flag if it simplifies the pattern",
116+
category: "Best Practices",
117+
// TODO Switch to recommended in the major version.
118+
// recommended: true,
119+
recommended: false,
120+
},
121+
fixable: "code",
122+
schema: [],
123+
messages: {
124+
unexpected:
125+
"The character class(es) {{ classes }} can be simplified using the `i` flag.",
126+
},
127+
type: "suggestion",
128+
},
129+
create(context) {
130+
/**
131+
* Create visitor
132+
*/
133+
function createVisitor(
134+
regexpContext: RegExpContext,
135+
): RegExpVisitor.Handlers {
136+
const {
137+
node,
138+
flags,
139+
ownsFlags,
140+
flagsString,
141+
patternAst,
142+
patternSource,
143+
getUsageOfPattern,
144+
getFlagsLocation,
145+
fixReplaceFlags,
146+
} = regexpContext
147+
148+
if (!ownsFlags || flagsString === null) {
149+
// It's not (statically) fixable
150+
return {}
151+
}
152+
if (flags.ignoreCase) {
153+
// We can't suggest the i flag if it's already used
154+
return {}
155+
}
156+
if (getUsageOfPattern() === UsageOfPattern.partial) {
157+
// Adding flags to partial patterns isn't a good idea
158+
return {}
159+
}
160+
if (isCaseVariant(patternAst, flags)) {
161+
// We can't add the i flag
162+
return {}
163+
}
164+
165+
const uselessElements: CharacterClassElement[] = []
166+
const ccs: CharacterClass[] = []
167+
168+
return {
169+
onCharacterClassEnter(ccNode) {
170+
const invariantElement = ccNode.elements.filter(
171+
(e) => !isCaseVariant(e, flags),
172+
)
173+
if (invariantElement.length === ccNode.elements.length) {
174+
// all elements are case invariant
175+
return
176+
}
177+
178+
const invariant = Chars.empty(flags).union(
179+
...invariantElement.map((e) => toCharSet(e, flags)),
180+
)
181+
182+
let variantElements = without(
183+
ccNode.elements,
184+
new Set(invariantElement),
185+
)
186+
187+
// find all elements that are useless even without
188+
// the i flag
189+
const alwaysUseless = findUseless(
190+
variantElements,
191+
(e) => toCharSet(e, flags),
192+
invariant,
193+
)
194+
195+
// remove useless elements
196+
variantElements = without(variantElements, alwaysUseless)
197+
198+
// find useless elements
199+
const iFlags = getIgnoreCaseFlags(flags)
200+
const useless = findUseless(
201+
variantElements,
202+
(e) => toCharSet(e, iFlags),
203+
invariant,
204+
)
205+
206+
uselessElements.push(...useless)
207+
ccs.push(ccNode)
208+
},
209+
210+
onPatternLeave() {
211+
if (uselessElements.length === 0) {
212+
return
213+
}
214+
215+
context.report({
216+
node,
217+
loc: getFlagsLocation(),
218+
messageId: "unexpected",
219+
data: {
220+
classes: ccs.map((cc) => mention(cc)).join(", "),
221+
},
222+
fix(fixer) {
223+
const patternFix = removeAll(
224+
fixer,
225+
patternSource,
226+
uselessElements,
227+
)
228+
if (!patternFix) {
229+
return null
230+
}
231+
232+
const flagsFix = fixReplaceFlags(
233+
`${flagsString}i`,
234+
false,
235+
)(fixer)
236+
if (!flagsFix) {
237+
return null
238+
}
239+
240+
const fix = [patternFix]
241+
if (Array.isArray(flagsFix)) {
242+
fix.push(...flagsFix)
243+
} else {
244+
fix.push(flagsFix)
245+
}
246+
247+
return fix
248+
},
249+
})
250+
},
251+
}
252+
}
253+
254+
return defineRegexpVisitor(context, {
255+
createVisitor,
256+
})
257+
},
258+
})

0 commit comments

Comments
 (0)