Skip to content

Commit db0b84d

Browse files
Fixed match-any false negatives and improved fixer (#103)
1 parent 9b0a1f9 commit db0b84d

File tree

3 files changed

+97
-176
lines changed

3 files changed

+97
-176
lines changed

lib/rules/match-any.ts

Lines changed: 60 additions & 174 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,27 @@
11
import type { Expression } from "estree"
22
import type { RegExpVisitor } from "regexpp/visitor"
33
import type { Rule } from "eslint"
4-
import type {
5-
CharacterClass,
6-
EscapeCharacterSet,
7-
UnicodePropertyCharacterSet,
8-
Node as RegExpNode,
9-
CharacterClassRange,
10-
} from "regexpp/ast"
4+
import type { CharacterClass, Node as RegExpNode } from "regexpp/ast"
115
import {
126
createRule,
137
defineRegexpVisitor,
14-
FLAG_DOTALL,
158
getRegexpLocation,
169
getRegexpRange,
1710
fixerApplyEscape,
11+
parseFlags,
1812
} from "../utils"
13+
import type { ReadonlyFlags } from "regexp-ast-analysis"
14+
import { matchesAllCharacters } from "regexp-ast-analysis"
1915

2016
const OPTION_SS1 = "[\\s\\S]" as const
2117
const OPTION_SS2 = "[\\S\\s]" as const
2218
const OPTION_CARET = "[^]" as const
2319
const OPTION_DOTALL = "dotAll" as const
24-
25-
/**
26-
* Get the key of the given CharacterSet node
27-
* @param node
28-
*/
29-
function getCharacterSetKey(
30-
node: EscapeCharacterSet | UnicodePropertyCharacterSet,
31-
) {
32-
if (node.kind === "property") {
33-
return `\\p{${
34-
node.kind +
35-
(node.value == null ? node.key : `${node.key}=${node.value}`)
36-
}}`
37-
}
38-
39-
return node.kind
40-
}
20+
type Allowed =
21+
| typeof OPTION_SS1
22+
| typeof OPTION_SS2
23+
| typeof OPTION_CARET
24+
| typeof OPTION_DOTALL
4125

4226
export default createRule("match-any", {
4327
meta: {
@@ -75,18 +59,13 @@ export default createRule("match-any", {
7559
},
7660
create(context) {
7761
const sourceCode = context.getSourceCode()
78-
const allowList: (
79-
| "[\\s\\S]"
80-
| "[\\S\\s]"
81-
| "[^]"
82-
| "dotAll"
83-
)[] = context.options[0]?.allows ?? [OPTION_SS1, OPTION_DOTALL]
62+
const allowList: Allowed[] = context.options[0]?.allows ?? [
63+
OPTION_SS1,
64+
OPTION_DOTALL,
65+
]
66+
const allows = new Set<string>(allowList)
8467

85-
const allows: Set<"[\\s\\S]" | "[\\S\\s]" | "[^]" | "dotAll"> = new Set(
86-
allowList,
87-
)
88-
89-
const prefer = (allowList[0] !== OPTION_DOTALL && allowList[0]) || null
68+
const preference: Allowed | null = allowList[0] || null
9069

9170
/**
9271
* Fix source code
@@ -96,8 +75,13 @@ export default createRule("match-any", {
9675
fixer: Rule.RuleFixer,
9776
node: Expression,
9877
regexpNode: RegExpNode,
78+
flags: ReadonlyFlags,
9979
) {
100-
if (!prefer) {
80+
if (!preference) {
81+
return null
82+
}
83+
if (preference === OPTION_DOTALL && !flags.dotAll) {
84+
// since we can't just add flags, we cannot fix this
10185
return null
10286
}
10387
const range = getRegexpRange(sourceCode, node, regexpNode)
@@ -107,16 +91,20 @@ export default createRule("match-any", {
10791

10892
if (
10993
regexpNode.type === "CharacterClass" &&
110-
prefer.startsWith("[") &&
111-
prefer.endsWith("]")
94+
preference.startsWith("[") &&
95+
preference.endsWith("]")
11296
) {
11397
return fixer.replaceTextRange(
11498
[range[0] + 1, range[1] - 1],
115-
fixerApplyEscape(prefer.slice(1, -1), node),
99+
fixerApplyEscape(preference.slice(1, -1), node),
116100
)
117101
}
118102

119-
return fixer.replaceTextRange(range, fixerApplyEscape(prefer, node))
103+
const replacement = preference === OPTION_DOTALL ? "." : preference
104+
return fixer.replaceTextRange(
105+
range,
106+
fixerApplyEscape(replacement, node),
107+
)
120108
}
121109

122110
/**
@@ -126,150 +114,48 @@ export default createRule("match-any", {
126114
function createVisitor(
127115
node: Expression,
128116
_pattern: string,
129-
flags: string,
117+
flagsStr: string,
130118
): RegExpVisitor.Handlers {
131-
let characterClassData: {
132-
node: CharacterClass
133-
charSets: Map<
134-
string,
135-
EscapeCharacterSet | UnicodePropertyCharacterSet
136-
>
137-
reported: boolean
138-
} | null = null
119+
const flags = parseFlags(flagsStr)
120+
139121
return {
140122
onCharacterSetEnter(csNode) {
141-
if (csNode.kind === "any") {
142-
if (flags.includes(FLAG_DOTALL)) {
143-
if (!allows.has(OPTION_DOTALL)) {
144-
context.report({
145-
node,
146-
loc: getRegexpLocation(
147-
sourceCode,
148-
node,
149-
csNode,
150-
),
151-
messageId: "unexpected",
152-
data: {
153-
expr: ".",
154-
},
155-
fix(fixer) {
156-
return fix(fixer, node, csNode)
157-
},
158-
})
159-
}
160-
}
161-
return
162-
}
163123
if (
164-
characterClassData &&
165-
!characterClassData.reported &&
166-
!characterClassData.node.negate
124+
csNode.kind === "any" &&
125+
flags.dotAll &&
126+
!allows.has(OPTION_DOTALL)
167127
) {
168-
const key = getCharacterSetKey(csNode)
169-
const alreadyCharSet = characterClassData.charSets.get(
170-
key,
171-
)
172-
if (
173-
alreadyCharSet != null &&
174-
alreadyCharSet.negate === !csNode.negate
175-
) {
176-
const ccNode = characterClassData.node
177-
if (
178-
!ccNode.negate &&
179-
ccNode.elements.length === 2 &&
180-
alreadyCharSet.kind === "space"
181-
) {
182-
if (!alreadyCharSet.negate) {
183-
if (allows.has(OPTION_SS1)) {
184-
return
185-
}
186-
} else {
187-
if (allows.has(OPTION_SS2)) {
188-
return
189-
}
190-
}
191-
}
192-
context.report({
193-
node,
194-
loc: getRegexpLocation(
195-
sourceCode,
196-
node,
197-
ccNode,
198-
),
199-
messageId: "unexpected",
200-
data: {
201-
expr: ccNode.raw,
202-
},
203-
fix(fixer) {
204-
return fix(fixer, node, ccNode)
205-
},
206-
})
207-
characterClassData.reported = true
208-
} else {
209-
characterClassData.charSets.set(key, csNode)
210-
}
128+
context.report({
129+
node,
130+
loc: getRegexpLocation(sourceCode, node, csNode),
131+
messageId: "unexpected",
132+
data: {
133+
expr: ".",
134+
},
135+
fix(fixer) {
136+
return fix(fixer, node, csNode, flags)
137+
},
138+
})
211139
}
212140
},
213-
onCharacterClassRangeEnter(ccrNode: CharacterClassRange) {
141+
onCharacterClassEnter(ccNode: CharacterClass) {
214142
if (
215-
ccrNode.min.value === 0 &&
216-
ccrNode.max.value === 65535
143+
matchesAllCharacters(ccNode, flags) &&
144+
!allows.has(ccNode.raw as never)
217145
) {
218-
if (
219-
characterClassData &&
220-
!characterClassData.reported &&
221-
!characterClassData.node.negate
222-
) {
223-
const ccNode = characterClassData.node
224-
context.report({
225-
node,
226-
loc: getRegexpLocation(
227-
sourceCode,
228-
node,
229-
ccNode,
230-
),
231-
messageId: "unexpected",
232-
data: {
233-
expr: ccNode.raw,
234-
},
235-
fix(fixer) {
236-
return fix(fixer, node, ccNode)
237-
},
238-
})
239-
characterClassData.reported = true
240-
}
241-
}
242-
},
243-
onCharacterClassEnter(ccNode: CharacterClass) {
244-
if (ccNode.elements.length === 0) {
245-
if (ccNode.negate && !allows.has(OPTION_CARET)) {
246-
context.report({
247-
node,
248-
loc: getRegexpLocation(
249-
sourceCode,
250-
node,
251-
ccNode,
252-
),
253-
messageId: "unexpected",
254-
data: {
255-
expr: "[^]",
256-
},
257-
fix(fixer) {
258-
return fix(fixer, node, ccNode)
259-
},
260-
})
261-
}
262-
return
263-
}
264-
characterClassData = {
265-
node: ccNode,
266-
charSets: new Map(),
267-
reported: false,
146+
context.report({
147+
node,
148+
loc: getRegexpLocation(sourceCode, node, ccNode),
149+
messageId: "unexpected",
150+
data: {
151+
expr: ccNode.raw,
152+
},
153+
fix(fixer) {
154+
return fix(fixer, node, ccNode, flags)
155+
},
156+
})
268157
}
269158
},
270-
onCharacterClassLeave() {
271-
characterClassData = null
272-
},
273159
}
274160
}
275161

lib/utils/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import type { Rule, AST, SourceCode } from "eslint"
1919
import { parseStringTokens } from "./string-literal-parser"
2020
import { findVariable } from "./ast-utils"
21+
import type { ReadonlyFlags } from "regexp-ast-analysis"
2122
export * from "./unicode"
2223

2324
type RegexpRule = {
@@ -43,6 +44,28 @@ export const FLAG_MULTILINE = "m"
4344
export const FLAG_STICKY = "y"
4445
export const FLAG_UNICODE = "u"
4546

47+
const flagsCache = new Map<string, ReadonlyFlags>()
48+
/**
49+
* Given some flags, this will return a parsed flags object.
50+
*
51+
* Non-standard flags will be ignored.
52+
*/
53+
export function parseFlags(flags: string): ReadonlyFlags {
54+
let cached = flagsCache.get(flags)
55+
if (cached === undefined) {
56+
cached = {
57+
dotAll: flags.includes(FLAG_DOTALL),
58+
global: flags.includes(FLAG_GLOBAL),
59+
ignoreCase: flags.includes(FLAG_IGNORECASE),
60+
multiline: flags.includes(FLAG_MULTILINE),
61+
sticky: flags.includes(FLAG_STICKY),
62+
unicode: flags.includes(FLAG_UNICODE),
63+
}
64+
flagsCache.set(flags, cached)
65+
}
66+
return cached
67+
}
68+
4669
/**
4770
* Define the rule.
4871
* @param ruleName ruleName

tests/lib/rules/match-any.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ tester.run("match-any", rule as any, {
109109
],
110110
},
111111
{
112-
code: "/[\\s\\S][\\S\\s][^]./s",
113-
output: null,
112+
code: "/[\\s\\S] [\\S\\s] [^] ./s",
113+
output: "/. . . ./s",
114114
options: [{ allows: ["dotAll"] }],
115115
errors: [
116116
'Unexpected using "[\\s\\S]" to match any character.',
@@ -168,5 +168,17 @@ tester.run("match-any", rule as any, {
168168
'Unexpected using "[\\s\\S\\0-\\uFFFF]" to match any character.',
169169
],
170170
},
171+
{
172+
code: "/[\\w\\D]/",
173+
output: "/[\\s\\S]/",
174+
errors: ['Unexpected using "[\\w\\D]" to match any character.'],
175+
},
176+
{
177+
code: "/[\\P{ASCII}\\w\\0-AZ-\\xFF]/u",
178+
output: "/[\\s\\S]/u",
179+
errors: [
180+
'Unexpected using "[\\P{ASCII}\\w\\0-AZ-\\xFF]" to match any character.',
181+
],
182+
},
171183
],
172184
})

0 commit comments

Comments
 (0)