Skip to content

Commit 51027f7

Browse files
authored
Add regexp/no-optional-assertion rule (#135)
1 parent fe6eced commit 51027f7

File tree

7 files changed

+284
-3
lines changed

7 files changed

+284
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
9999
| [regexp/no-legacy-features](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-legacy-features.html) | disallow legacy RegExp features | |
100100
| [regexp/no-obscure-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-obscure-range.html) | disallow obscure character ranges | |
101101
| [regexp/no-octal](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-octal.html) | disallow octal escape sequence | :star: |
102+
| [regexp/no-optional-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-optional-assertion.html) | disallow optional assertions | |
102103
| [regexp/no-potentially-useless-backreference](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-potentially-useless-backreference.html) | disallow backreferences that reference a group that might not be matched | |
103104
| [regexp/no-trivially-nested-assertion](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-trivially-nested-assertion.html) | disallow trivially nested assertions | :wrench: |
104105
| [regexp/no-unused-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-unused-capturing-group.html) | disallow unused capturing group | |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
2727
| [regexp/no-legacy-features](./no-legacy-features.md) | disallow legacy RegExp features | |
2828
| [regexp/no-obscure-range](./no-obscure-range.md) | disallow obscure character ranges | |
2929
| [regexp/no-octal](./no-octal.md) | disallow octal escape sequence | :star: |
30+
| [regexp/no-optional-assertion](./no-optional-assertion.md) | disallow optional assertions | |
3031
| [regexp/no-potentially-useless-backreference](./no-potentially-useless-backreference.md) | disallow backreferences that reference a group that might not be matched | |
3132
| [regexp/no-trivially-nested-assertion](./no-trivially-nested-assertion.md) | disallow trivially nested assertions | :wrench: |
3233
| [regexp/no-unused-capturing-group](./no-unused-capturing-group.md) | disallow unused capturing group | |

docs/rules/no-obscure-range.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ It allows all values that the [allowedCharacterRanges] setting allows.
5959

6060
### `"allowed": "alphanumeric"`
6161

62-
<eslint-code-block fix>
62+
<eslint-code-block>
6363

6464
```js
6565
/* eslint regexp/no-obscure-range: ["error", { "allowed": "alphanumeric" }] */
@@ -80,7 +80,7 @@ var foo = /[😀-😄]/u;
8080

8181
### `"allowed": "all"`
8282

83-
<eslint-code-block fix>
83+
<eslint-code-block>
8484

8585
```js
8686
/* eslint regexp/no-obscure-range: ["error", { "allowed": "all" }] */
@@ -101,7 +101,7 @@ var foo = /[\41-\x45]/;
101101

102102
### `"allowed": [ "alphanumeric", "😀-😏" ]`
103103

104-
<eslint-code-block fix>
104+
<eslint-code-block>
105105

106106
```js
107107
/* eslint regexp/no-obscure-range: ["error", { "allowed": [ "alphanumeric", "😀-😏" ] }] */

docs/rules/no-optional-assertion.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/no-optional-assertion"
5+
description: "disallow optional assertions"
6+
---
7+
# regexp/no-optional-assertion
8+
9+
> disallow optional assertions
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+
13+
## :book: Rule Details
14+
15+
Assertions that are quantified (directly or indirectly) can be considered optional if the quantifier has a minimum of zero.
16+
17+
A simple example is the following pattern: `/a(?:$)*b/`. The `$` assertion will reject but if that happens, it will simply be ignored because of the `*` quantifier. The assertion is optional, serving no function whatsoever.
18+
19+
More generally, an assertion is optional, if there exists a parent quantifier with a minimum of zero such that all possible paths of the quantified element that contain the assertion do not consume characters.
20+
21+
Here's an example: `a(?:foo|(?<!-)(?:-|\b))*b`. The `\b` is optional. However, the lookbehind `(?<!-)` is not optional because the group `(?:-|\b)` right after it can consume a character.
22+
23+
Optional assertions don't affect the pattern in any way. They are essentially dead code.
24+
25+
<eslint-code-block>
26+
27+
```js
28+
/* eslint regexp/no-optional-assertion: "error" */
29+
30+
/* ✓ GOOD */
31+
var foo = /\w+(?::|\b)/;
32+
33+
/* ✗ BAD */
34+
var foo = /a(?:$)*b/;
35+
var foo = /a(?:foo|(?<!-)(?:-|\b))*b/; // The `\b` is optional.
36+
var foo = /(?:^)?\w+/; // warns about `^`
37+
var foo = /\w+(?::|$)?/; // warns about `$`
38+
```
39+
40+
</eslint-code-block>
41+
42+
## :wrench: Options
43+
44+
Nothing.
45+
46+
## :heart: Compatibility
47+
48+
This rule was taken from [eslint-plugin-clean-regex].
49+
This rule is compatible with [clean-regex/no-optional-assertion] rule.
50+
51+
[eslint-plugin-clean-regex]: https://github.com/RunDevelopment/eslint-plugin-clean-regex
52+
[clean-regex/no-optional-assertion]: https://github.com/RunDevelopment/eslint-plugin-clean-regex/blob/master/docs/rules/no-optional-assertion.md
53+
54+
## :mag: Implementation
55+
56+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/no-optional-assertion.ts)
57+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/no-optional-assertion.ts)

lib/rules/no-optional-assertion.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import type { Expression } from "estree"
2+
import type { RegExpVisitor } from "regexpp/visitor"
3+
import type {
4+
Alternative,
5+
Assertion,
6+
CapturingGroup,
7+
Group,
8+
Quantifier,
9+
} from "regexpp/ast"
10+
import { createRule, defineRegexpVisitor, getRegexpLocation } from "../utils"
11+
import { isZeroLength } from "regexp-ast-analysis"
12+
13+
type ZeroQuantifier = Quantifier & { min: 0 }
14+
15+
/**
16+
* Checks whether the given quantifier is quantifier with a minimum of 0.
17+
*/
18+
function isZeroQuantifier(node: Quantifier): node is ZeroQuantifier {
19+
return node.min === 0
20+
}
21+
22+
/**
23+
* Returns whether the given assertion is optional in regard to the given quantifier with a minimum of 0.
24+
*
25+
* Optional means that all paths in the element if the quantifier which contain the given assertion also have do not
26+
* consume characters. For more information and examples on optional assertions, see the documentation page of this
27+
* rule.
28+
*/
29+
function isOptional(assertion: Assertion, quantifier: ZeroQuantifier): boolean {
30+
let element: Assertion | Quantifier | Group | CapturingGroup = assertion
31+
while (element.parent !== quantifier) {
32+
const parent: Quantifier | Alternative = element.parent
33+
if (parent.type === "Alternative") {
34+
// make sure that all element before and after are zero length
35+
for (const e of parent.elements) {
36+
if (e === element) {
37+
continue // we will ignore this element.
38+
}
39+
40+
if (!isZeroLength(e)) {
41+
// Some element around our target element can possibly consume characters.
42+
// This means, we found a path from or to the assertion which can consume characters.
43+
return false
44+
}
45+
}
46+
47+
if (parent.parent.type === "Pattern") {
48+
throw new Error(
49+
"The given assertion is not a descendant of the given quantifier.",
50+
)
51+
}
52+
element = parent.parent
53+
} else {
54+
// parent.type === "Quantifier"
55+
if (parent.max > 1 && !isZeroLength(parent)) {
56+
// If an ascendant quantifier of the element has maximum of 2 or more, we have to check whether
57+
// the quantifier itself has zero length.
58+
// E.g. in /(?:a|(\b|-){2})?/ the \b is not optional
59+
return false
60+
}
61+
62+
element = parent
63+
}
64+
}
65+
// We reached the top.
66+
// If we made it this far, we could not disprove that the assertion is optional, so it has to optional.
67+
return true
68+
}
69+
70+
export default createRule("no-optional-assertion", {
71+
meta: {
72+
docs: {
73+
description: "disallow optional assertions",
74+
// TODO Switch to recommended in the major version.
75+
// recommended: true,
76+
recommended: false,
77+
},
78+
schema: [],
79+
messages: {
80+
optionalAssertion:
81+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '{{quantifier}}'.",
82+
},
83+
type: "problem",
84+
},
85+
create(context) {
86+
const sourceCode = context.getSourceCode()
87+
88+
/**
89+
* Create visitor
90+
* @param node
91+
*/
92+
function createVisitor(node: Expression): RegExpVisitor.Handlers {
93+
// The closest quantifier with a minimum of 0 is stored at index = 0.
94+
const zeroQuantifierStack: ZeroQuantifier[] = []
95+
return {
96+
onQuantifierEnter(q) {
97+
if (isZeroQuantifier(q)) {
98+
zeroQuantifierStack.unshift(q)
99+
}
100+
},
101+
onQuantifierLeave(q) {
102+
if (zeroQuantifierStack[0] === q) {
103+
zeroQuantifierStack.shift()
104+
}
105+
},
106+
onAssertionEnter(assertion) {
107+
const q = zeroQuantifierStack[0]
108+
109+
if (q && isOptional(assertion, q)) {
110+
context.report({
111+
node,
112+
loc: getRegexpLocation(sourceCode, node, assertion),
113+
messageId: "optionalAssertion",
114+
data: {
115+
quantifier: q.raw.substr(q.element.raw.length),
116+
},
117+
})
118+
}
119+
},
120+
}
121+
}
122+
123+
return defineRegexpVisitor(context, {
124+
createVisitor,
125+
})
126+
},
127+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import noLazyEnds from "../rules/no-lazy-ends"
1515
import noLegacyFeatures from "../rules/no-legacy-features"
1616
import noObscureRange from "../rules/no-obscure-range"
1717
import noOctal from "../rules/no-octal"
18+
import noOptionalAssertion from "../rules/no-optional-assertion"
1819
import noPotentiallyUselessBackreference from "../rules/no-potentially-useless-backreference"
1920
import noTriviallyNestedAssertion from "../rules/no-trivially-nested-assertion"
2021
import noUnusedCapturingGroup from "../rules/no-unused-capturing-group"
@@ -60,6 +61,7 @@ export const rules = [
6061
noLegacyFeatures,
6162
noObscureRange,
6263
noOctal,
64+
noOptionalAssertion,
6365
noPotentiallyUselessBackreference,
6466
noTriviallyNestedAssertion,
6567
noUnusedCapturingGroup,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { RuleTester } from "eslint"
2+
import rule from "../../../lib/rules/no-optional-assertion"
3+
4+
const tester = new RuleTester({
5+
parserOptions: {
6+
ecmaVersion: 2020,
7+
sourceType: "module",
8+
},
9+
})
10+
11+
tester.run("no-optional-assertion", rule as any, {
12+
valid: [
13+
String.raw`/fo(?:o\b)?/`,
14+
String.raw`/(?:a|(\b|-){2})?/`,
15+
String.raw`/(?:a|(?:\b|a)+)?/`,
16+
String.raw`/fo(?:o\b)/`,
17+
String.raw`/fo(?:o\b){1}/`,
18+
],
19+
invalid: [
20+
{
21+
code: String.raw`/(?:\b|(?=a))?/`,
22+
errors: [
23+
{
24+
message:
25+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '?'.",
26+
line: 1,
27+
column: 5,
28+
},
29+
{
30+
message:
31+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '?'.",
32+
line: 1,
33+
column: 8,
34+
},
35+
],
36+
},
37+
{
38+
code: String.raw`/(?:\b|a)?/`,
39+
errors: [
40+
{
41+
message:
42+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '?'.",
43+
line: 1,
44+
column: 5,
45+
},
46+
],
47+
},
48+
{
49+
code: String.raw`/(?:^|a)*/`,
50+
errors: [
51+
{
52+
message:
53+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '*'.",
54+
line: 1,
55+
column: 5,
56+
},
57+
],
58+
},
59+
{
60+
code: String.raw`/(?:((?:(\b|a)))|b)?/`,
61+
errors: [
62+
{
63+
message:
64+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '?'.",
65+
line: 1,
66+
column: 10,
67+
},
68+
],
69+
},
70+
{
71+
code: String.raw`/(?:((?:(\b|a)))|b)*/`,
72+
errors: [
73+
{
74+
message:
75+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '*'.",
76+
line: 1,
77+
column: 10,
78+
},
79+
],
80+
},
81+
{
82+
code: String.raw`/((\b)+){0,}/`,
83+
errors: [
84+
{
85+
message:
86+
"This assertion effectively optional and does not change the pattern. Either remove the assertion or change the parent quantifier '{0,}'.",
87+
line: 1,
88+
column: 4,
89+
},
90+
],
91+
},
92+
],
93+
})

0 commit comments

Comments
 (0)