Skip to content

Commit 99009f3

Browse files
Add prefer-predefined-assertion rule (#171)
1 parent 0850093 commit 99009f3

File tree

6 files changed

+369
-0
lines changed

6 files changed

+369
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
125125
| [regexp/prefer-escape-replacement-dollar-char](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-escape-replacement-dollar-char.html) | enforces escape of replacement `$` character (`$$`). | |
126126
| [regexp/prefer-named-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-named-backreference.html) | enforce using named backreferences | :wrench: |
127127
| [regexp/prefer-plus-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-plus-quantifier.html) | enforce using `+` quantifier | :star::wrench: |
128+
| [regexp/prefer-predefined-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-predefined-assertion.html) | prefer predefined assertion over equivalent lookarounds | :wrench: |
128129
| [regexp/prefer-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-quantifier.html) | enforce using quantifier | :wrench: |
129130
| [regexp/prefer-question-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-question-quantifier.html) | enforce using `?` quantifier | :star::wrench: |
130131
| [regexp/prefer-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-range.html) | enforce using character class range | :wrench: |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
5353
| [regexp/prefer-escape-replacement-dollar-char](./prefer-escape-replacement-dollar-char.md) | enforces escape of replacement `$` character (`$$`). | |
5454
| [regexp/prefer-named-backreference](./prefer-named-backreference.md) | enforce using named backreferences | :wrench: |
5555
| [regexp/prefer-plus-quantifier](./prefer-plus-quantifier.md) | enforce using `+` quantifier | :star::wrench: |
56+
| [regexp/prefer-predefined-assertion](./prefer-predefined-assertion.md) | prefer predefined assertion over equivalent lookarounds | :wrench: |
5657
| [regexp/prefer-quantifier](./prefer-quantifier.md) | enforce using quantifier | :wrench: |
5758
| [regexp/prefer-question-quantifier](./prefer-question-quantifier.md) | enforce using `?` quantifier | :star::wrench: |
5859
| [regexp/prefer-range](./prefer-range.md) | enforce using character class range | :wrench: |
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/prefer-predefined-assertion"
5+
description: "prefer predefined assertion over equivalent lookarounds"
6+
---
7+
# regexp/prefer-predefined-assertion
8+
9+
> prefer predefined assertion over equivalent lookarounds
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+
All predefined assertions (`\b`, `\B`, `^`, and `$`) can be expressed as lookaheads and lookbehinds. E.g. `/a$/` is the same as `/a(?![^])/`.
17+
18+
In most cases, it's better to use the predefined assertions because they are better known.
19+
20+
<eslint-code-block fix>
21+
22+
```js
23+
/* eslint regexp/prefer-predefined-assertion: "error" */
24+
25+
/* ✓ GOOD */
26+
var foo = /a(?=\W)/;
27+
28+
/* ✗ BAD */
29+
var foo = /a(?![^])/;
30+
var foo = /a(?!\w)/;
31+
var foo = /a+(?!\w)(?:\s|bc+)+/;
32+
```
33+
34+
</eslint-code-block>
35+
36+
## :wrench: Options
37+
38+
Nothing.
39+
40+
## :mag: Implementation
41+
42+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/prefer-predefined-assertion.ts)
43+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/prefer-predefined-assertion.ts)
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import type { RegExpVisitor } from "regexpp/visitor"
2+
import type {
3+
CharacterClass,
4+
CharacterSet,
5+
LookaroundAssertion,
6+
} from "regexpp/ast"
7+
import type { RegExpContext } from "../utils"
8+
import { createRule, defineRegexpVisitor } from "../utils"
9+
import {
10+
Chars,
11+
getFirstCharAfter,
12+
getMatchingDirectionFromAssertionKind,
13+
invertMatchingDirection,
14+
} from "regexp-ast-analysis"
15+
16+
/**
17+
* If the lookaround only consists of a single character, character set, or
18+
* character class, then this single character will be returned.
19+
*/
20+
function getCharacters(
21+
lookaround: LookaroundAssertion,
22+
): CharacterSet | CharacterClass | null {
23+
if (lookaround.alternatives.length === 1) {
24+
const alt = lookaround.alternatives[0]
25+
if (alt.elements.length === 1) {
26+
const first = alt.elements[0]
27+
if (
28+
first.type === "CharacterSet" ||
29+
first.type === "CharacterClass"
30+
) {
31+
return first
32+
}
33+
}
34+
}
35+
return null
36+
}
37+
38+
export default createRule("prefer-predefined-assertion", {
39+
meta: {
40+
docs: {
41+
description:
42+
"prefer predefined assertion over equivalent lookarounds",
43+
// TODO Switch to recommended in the major version.
44+
// recommended: true,
45+
recommended: false,
46+
},
47+
fixable: "code",
48+
schema: [],
49+
messages: {
50+
replace:
51+
"This lookaround assertion can be replaced with {{kind}} ('{{expr}}').",
52+
},
53+
type: "suggestion", // "problem",
54+
},
55+
create(context) {
56+
/**
57+
* Create visitor
58+
*/
59+
function createVisitor(
60+
regexpContext: RegExpContext,
61+
): RegExpVisitor.Handlers {
62+
const {
63+
node,
64+
flags,
65+
getRegexpLocation,
66+
toCharSet,
67+
fixReplaceNode,
68+
} = regexpContext
69+
70+
const word = Chars.word(flags)
71+
const nonWord = Chars.word(flags).negate()
72+
73+
// /\b/ == /(?<!\w)(?=\w)|(?<=\w)(?!\w)/
74+
// /\B/ == /(?<=\w)(?=\w)|(?<!\w)(?!\w)/
75+
76+
/**
77+
* Tries to replace the given assertion with a word boundary
78+
* assertion
79+
*/
80+
function replaceWordAssertion(
81+
aNode: LookaroundAssertion,
82+
wordNegated: boolean,
83+
): void {
84+
const direction = getMatchingDirectionFromAssertionKind(
85+
aNode.kind,
86+
)
87+
88+
/**
89+
* Whether the lookaround is equivalent to (?!\w) / (?<!\w) or (?=\w) / (?<=\w)
90+
*/
91+
let lookaroundNegated = aNode.negate
92+
if (wordNegated) {
93+
// if the lookaround only contains a \W, then we have to negate the lookaround, so it only
94+
// contains a \w. This is only possible iff we know that the pattern requires at least one
95+
// character after the lookaround (in the direction of the lookaround).
96+
//
97+
// Examples:
98+
// (?=\W) == (?!\w|$) ; Here we need to eliminate the $ which can be done by proving that the
99+
// pattern matches another character after the lookahead. Example:
100+
// (?=\W).+ == (?!\w).+ ; Since we know that the lookahead is always followed by a dot, we
101+
// eliminate the $ alternative because it will always reject.
102+
// (?!\W).+ == (?=\w|$).+ == (?=\w).+
103+
104+
const after = getFirstCharAfter(aNode, direction, flags)
105+
106+
const hasNextCharacter = !after.edge
107+
if (hasNextCharacter) {
108+
// we can successfully negate the lookaround
109+
lookaroundNegated = !lookaroundNegated
110+
} else {
111+
// we couldn't negate the \W, so it's not possible to convert the lookaround into a
112+
// predefined assertion
113+
return
114+
}
115+
}
116+
117+
const before = getFirstCharAfter(
118+
aNode,
119+
invertMatchingDirection(direction),
120+
flags,
121+
)
122+
if (before.edge) {
123+
// to do the branch elimination necessary, we need to know the previous/next character
124+
return
125+
}
126+
127+
let otherNegated
128+
if (before.char.isSubsetOf(word)) {
129+
// we can think of the previous/next character as \w
130+
otherNegated = false
131+
} else if (before.char.isSubsetOf(nonWord)) {
132+
// we can think of the previous/next character as \W
133+
otherNegated = true
134+
} else {
135+
// the previous/next character is a subset of neither \w nor \W, so we can't do anything here
136+
return
137+
}
138+
139+
let kind = undefined
140+
let replacement = undefined
141+
if (lookaroundNegated === otherNegated) {
142+
// \B
143+
kind = "a negated word boundary assertion"
144+
replacement = "\\B"
145+
} else {
146+
// \b
147+
kind = "a word boundary assertion"
148+
replacement = "\\b"
149+
}
150+
151+
if (kind && replacement) {
152+
context.report({
153+
node,
154+
loc: getRegexpLocation(aNode),
155+
messageId: "replace",
156+
data: { kind, expr: replacement },
157+
fix: fixReplaceNode(aNode, replacement),
158+
})
159+
}
160+
}
161+
162+
/**
163+
* Tries to replace the given assertion with a edge assertion
164+
*/
165+
function replaceEdgeAssertion(
166+
aNode: LookaroundAssertion,
167+
lineAssertion: boolean,
168+
): void {
169+
if (!aNode.negate) {
170+
return
171+
}
172+
if (flags.multiline === lineAssertion) {
173+
const replacement = aNode.kind === "lookahead" ? "$" : "^"
174+
175+
context.report({
176+
node,
177+
loc: getRegexpLocation(aNode),
178+
messageId: "replace",
179+
data: { kind: "an edge assertion", expr: replacement },
180+
fix: fixReplaceNode(aNode, replacement),
181+
})
182+
}
183+
}
184+
185+
return {
186+
onAssertionEnter(aNode) {
187+
if (
188+
aNode.kind !== "lookahead" &&
189+
aNode.kind !== "lookbehind"
190+
) {
191+
// this rule doesn't affect predefined assertions
192+
return
193+
}
194+
195+
const chars = getCharacters(aNode)
196+
if (chars === null) {
197+
return
198+
}
199+
200+
if (chars.type === "CharacterSet") {
201+
if (chars.kind === "word") {
202+
replaceWordAssertion(aNode, chars.negate)
203+
return
204+
}
205+
if (chars.kind === "any") {
206+
replaceEdgeAssertion(aNode, !flags.dotAll)
207+
return
208+
}
209+
}
210+
211+
const charSet = toCharSet(chars)
212+
if (charSet.isAll) {
213+
replaceEdgeAssertion(aNode, false)
214+
} else if (charSet.equals(word)) {
215+
replaceWordAssertion(aNode, false)
216+
} else if (charSet.equals(nonWord)) {
217+
replaceWordAssertion(aNode, true)
218+
}
219+
},
220+
}
221+
}
222+
223+
return defineRegexpVisitor(context, {
224+
createVisitor,
225+
})
226+
},
227+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import preferD from "../rules/prefer-d"
4141
import preferEscapeReplacementDollarChar from "../rules/prefer-escape-replacement-dollar-char"
4242
import preferNamedBackreference from "../rules/prefer-named-backreference"
4343
import preferPlusQuantifier from "../rules/prefer-plus-quantifier"
44+
import preferPredefinedAssertion from "../rules/prefer-predefined-assertion"
4445
import preferQuantifier from "../rules/prefer-quantifier"
4546
import preferQuestionQuantifier from "../rules/prefer-question-quantifier"
4647
import preferRange from "../rules/prefer-range"
@@ -96,6 +97,7 @@ export const rules = [
9697
preferEscapeReplacementDollarChar,
9798
preferNamedBackreference,
9899
preferPlusQuantifier,
100+
preferPredefinedAssertion,
99101
preferQuantifier,
100102
preferQuestionQuantifier,
101103
preferRange,
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { RuleTester } from "eslint"
2+
import rule from "../../../lib/rules/prefer-predefined-assertion"
3+
4+
const tester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 2020,
7+
sourceType: "module",
8+
},
9+
})
10+
11+
tester.run("prefer-predefined-assertion", rule as any, {
12+
valid: [String.raw`/a(?=\W)/`],
13+
invalid: [
14+
{
15+
code: String.raw`/a(?=\w)/`,
16+
output: String.raw`/a\B/`,
17+
errors: [
18+
"This lookaround assertion can be replaced with a negated word boundary assertion ('\\B').",
19+
],
20+
},
21+
{
22+
code: String.raw`/a(?!\w)/`,
23+
output: String.raw`/a\b/`,
24+
errors: [
25+
"This lookaround assertion can be replaced with a word boundary assertion ('\\b').",
26+
],
27+
},
28+
{
29+
code: String.raw`/(?<=\w)a/`,
30+
output: String.raw`/\Ba/`,
31+
errors: [
32+
"This lookaround assertion can be replaced with a negated word boundary assertion ('\\B').",
33+
],
34+
},
35+
{
36+
code: String.raw`/(?<!\w)a/`,
37+
output: String.raw`/\ba/`,
38+
errors: [
39+
"This lookaround assertion can be replaced with a word boundary assertion ('\\b').",
40+
],
41+
},
42+
43+
{
44+
code: String.raw`/a(?=\W)./`,
45+
output: String.raw`/a\b./`,
46+
errors: [
47+
"This lookaround assertion can be replaced with a word boundary assertion ('\\b').",
48+
],
49+
},
50+
{
51+
code: String.raw`/a(?!\W)./`,
52+
output: String.raw`/a\B./`,
53+
errors: [
54+
"This lookaround assertion can be replaced with a negated word boundary assertion ('\\B').",
55+
],
56+
},
57+
{
58+
code: String.raw`/.(?<=\W)a/`,
59+
output: String.raw`/.\ba/`,
60+
errors: [
61+
"This lookaround assertion can be replaced with a word boundary assertion ('\\b').",
62+
],
63+
},
64+
{
65+
code: String.raw`/.(?<!\W)a/`,
66+
output: String.raw`/.\Ba/`,
67+
errors: [
68+
"This lookaround assertion can be replaced with a negated word boundary assertion ('\\B').",
69+
],
70+
},
71+
72+
{
73+
code: String.raw`/a+(?!\w)(?:\s|bc+)+/`,
74+
output: String.raw`/a+\b(?:\s|bc+)+/`,
75+
errors: [
76+
"This lookaround assertion can be replaced with a word boundary assertion ('\\b').",
77+
],
78+
},
79+
80+
{
81+
code: String.raw`/(?!.)(?![^])/`,
82+
output: String.raw`/(?!.)$/`,
83+
errors: [
84+
"This lookaround assertion can be replaced with an edge assertion ('$').",
85+
],
86+
},
87+
{
88+
code: String.raw`/(?<!.)(?<![^])/m`,
89+
output: String.raw`/^(?<![^])/m`,
90+
errors: [
91+
"This lookaround assertion can be replaced with an edge assertion ('^').",
92+
],
93+
},
94+
],
95+
})

0 commit comments

Comments
 (0)