Skip to content

Commit 559fa11

Browse files
authored
Add regexp/order-in-character-class rule (#48)
1 parent 417a68d commit 559fa11

File tree

6 files changed

+486
-0
lines changed

6 files changed

+486
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
9898
| [regexp/no-useless-non-greedy](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-non-greedy.html) | disallow unnecessary quantifier non-greedy (`?`) | :wrench: |
9999
| [regexp/no-useless-range](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-range.html) | disallow unnecessary range of characters by using a hyphen | :wrench: |
100100
| [regexp/no-useless-two-nums-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-useless-two-nums-quantifier.html) | disallow unnecessary `{n,m}` quantifier | :star: |
101+
| [regexp/order-in-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/order-in-character-class.html) | enforces elements order in character class | :wrench: |
101102
| [regexp/prefer-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-character-class.html) | enforce using character class | :wrench: |
102103
| [regexp/prefer-d](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-d.html) | enforce using `\d` | :star::wrench: |
103104
| [regexp/prefer-plus-quantifier](https://ota-meshi.github.io/eslint-plugin-regexp/rules/prefer-plus-quantifier.html) | enforce using `+` quantifier | :star::wrench: |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
3030
| [regexp/no-useless-non-greedy](./no-useless-non-greedy.md) | disallow unnecessary quantifier non-greedy (`?`) | :wrench: |
3131
| [regexp/no-useless-range](./no-useless-range.md) | disallow unnecessary range of characters by using a hyphen | :wrench: |
3232
| [regexp/no-useless-two-nums-quantifier](./no-useless-two-nums-quantifier.md) | disallow unnecessary `{n,m}` quantifier | :star: |
33+
| [regexp/order-in-character-class](./order-in-character-class.md) | enforces elements order in character class | :wrench: |
3334
| [regexp/prefer-character-class](./prefer-character-class.md) | enforce using character class | :wrench: |
3435
| [regexp/prefer-d](./prefer-d.md) | enforce using `\d` | :star::wrench: |
3536
| [regexp/prefer-plus-quantifier](./prefer-plus-quantifier.md) | enforce using `+` quantifier | :star::wrench: |
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/order-in-character-class"
5+
description: "enforces elements order in character class"
6+
---
7+
# regexp/order-in-character-class
8+
9+
> enforces elements order in character class
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 checks elements of character classes are sorted.
17+
18+
<eslint-code-block fix>
19+
20+
```js
21+
/* eslint regexp/order-in-character-class: "error" */
22+
23+
/* ✓ GOOD */
24+
var foo = /[abcdef]/
25+
var foo = /[ab-f]/
26+
27+
/* ✗ BAD */
28+
var foo = /[bcdefa]/
29+
var foo = /[b-fa]/
30+
```
31+
32+
</eslint-code-block>
33+
34+
## :wrench: Options
35+
36+
```json5
37+
{
38+
"regexp/order-in-character-class": ["error", {
39+
"order": [
40+
"\\s", // \s or \S
41+
"\\w", // \w or \W
42+
"\\d", // \d or \D
43+
"\\p", // \p{...} or \P{...}
44+
"*", // Others (A character or range of characters or an element you did not specify.)
45+
]
46+
}]
47+
}
48+
```
49+
50+
- `"order"` ... An array of your preferred order. The default is `["\\s", "\\w", "\\d", "\\p", "*",]`.
51+
52+
## :mag: Implementation
53+
54+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/order-in-character-class.ts)
55+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/order-in-character-class.ts)

lib/rules/order-in-character-class.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import type { Expression } from "estree"
2+
import type { RegExpVisitor } from "regexpp/visitor"
3+
import type {
4+
CharacterClassElement,
5+
UnicodePropertyCharacterSet,
6+
} from "regexpp/ast"
7+
import {
8+
CP_DIGIT_ZERO,
9+
CP_SPACE,
10+
createRule,
11+
defineRegexpVisitor,
12+
getRegexpLocation,
13+
getRegexpRange,
14+
} from "../utils"
15+
16+
type CharacterClassElementKind = "\\w" | "\\d" | "\\s" | "\\p" | "*"
17+
const DEFAULT_ORDER: CharacterClassElementKind[] = [
18+
"\\s",
19+
"\\w",
20+
"\\d",
21+
"\\p",
22+
"*",
23+
]
24+
25+
/**
26+
* Get kind of CharacterClassElement for given CharacterClassElement
27+
*/
28+
function getCharacterClassElementKind(
29+
node: CharacterClassElement,
30+
): CharacterClassElementKind {
31+
if (node.type === "CharacterSet") {
32+
return node.kind === "word"
33+
? "\\w"
34+
: node.kind === "digit"
35+
? "\\d"
36+
: node.kind === "space"
37+
? "\\s"
38+
: "\\p"
39+
}
40+
return "*"
41+
}
42+
43+
export default createRule("order-in-character-class", {
44+
meta: {
45+
docs: {
46+
description: "enforces elements order in character class",
47+
recommended: false,
48+
},
49+
fixable: "code",
50+
schema: [
51+
{
52+
type: "object",
53+
properties: {
54+
order: {
55+
type: "array",
56+
items: { enum: ["\\w", "\\d", "\\s", "\\p", "*"] },
57+
},
58+
},
59+
additionalProperties: false,
60+
},
61+
],
62+
messages: {
63+
sortElements:
64+
"Expected character class elements to be in ascending order. '{{next}}' should be before '{{prev}}'.",
65+
},
66+
type: "layout",
67+
},
68+
create(context) {
69+
const sourceCode = context.getSourceCode()
70+
const orderOption: {
71+
"*": number
72+
"\\w"?: number
73+
"\\d"?: number
74+
"\\s"?: number
75+
"\\p"?: number
76+
} = { "*": Infinity }
77+
78+
;((context.options[0]?.order ??
79+
DEFAULT_ORDER) as CharacterClassElementKind[]).forEach((o, i) => {
80+
orderOption[o] = i + 1
81+
})
82+
83+
/**
84+
* Create visitor
85+
* @param node
86+
*/
87+
function createVisitor(node: Expression): RegExpVisitor.Handlers {
88+
return {
89+
onCharacterClassEnter(ccNode) {
90+
const prevList: CharacterClassElement[] = []
91+
for (const next of ccNode.elements) {
92+
if (prevList.length) {
93+
const prev = prevList[0]
94+
if (!isValidOrder(prev, next)) {
95+
let moveTarget = prev
96+
for (const p of prevList) {
97+
if (isValidOrder(p, next)) {
98+
break
99+
} else {
100+
moveTarget = p
101+
}
102+
}
103+
context.report({
104+
node,
105+
loc: getRegexpLocation(
106+
sourceCode,
107+
node,
108+
next,
109+
),
110+
messageId: "sortElements",
111+
data: {
112+
next: next.raw,
113+
prev: moveTarget.raw,
114+
},
115+
*fix(fixer) {
116+
const nextRange = getRegexpRange(
117+
sourceCode,
118+
node,
119+
next,
120+
)
121+
const targetRange = getRegexpRange(
122+
sourceCode,
123+
node,
124+
moveTarget,
125+
)
126+
if (!targetRange || !nextRange) {
127+
return
128+
}
129+
yield fixer.insertTextBeforeRange(
130+
targetRange,
131+
next.raw,
132+
)
133+
134+
yield fixer.removeRange(nextRange)
135+
},
136+
})
137+
}
138+
}
139+
prevList.unshift(next)
140+
}
141+
},
142+
}
143+
}
144+
145+
/* eslint-disable complexity -- X( */
146+
/**
147+
* Check that the two given CharacterClassElements are in a valid order.
148+
*/
149+
function isValidOrder(
150+
/* eslint-enable complexity -- X( */
151+
prev: CharacterClassElement,
152+
next: CharacterClassElement,
153+
) {
154+
const prevKind = getCharacterClassElementKind(prev)
155+
const nextKind = getCharacterClassElementKind(next)
156+
const prevOrder = orderOption[prevKind] ?? orderOption["*"]
157+
const nextOrder = orderOption[nextKind] ?? orderOption["*"]
158+
if (prevOrder < nextOrder) {
159+
return true
160+
} else if (prevOrder > nextOrder) {
161+
return false
162+
}
163+
if (prev.type === "CharacterSet" && prev.kind === "property") {
164+
if (next.type === "CharacterSet") {
165+
if (next.kind === "property") {
166+
return isValidOrderForUnicodePropertyCharacterSet(
167+
prev,
168+
next,
169+
)
170+
}
171+
// e.g. /[\p{ASCII}\d]/
172+
return false
173+
}
174+
// e.g. /[\p{ASCII}a]/
175+
return true
176+
} else if (
177+
next.type === "CharacterSet" &&
178+
next.kind === "property"
179+
) {
180+
if (prev.type === "CharacterSet") {
181+
// e.g. /[\d\p{ASCII}]/
182+
return true
183+
}
184+
// e.g. /[a\p{ASCII}]/
185+
return false
186+
}
187+
if (prev.type === "CharacterSet" && next.type === "CharacterSet") {
188+
if (prev.kind === "word" && next.kind === "digit") {
189+
return true
190+
}
191+
if (prev.kind === "digit" && next.kind === "word") {
192+
return false
193+
}
194+
}
195+
const prevCP = getTargetCodePoint(prev)
196+
const nextCP = getTargetCodePoint(next)
197+
if (prevCP <= nextCP) {
198+
return true
199+
}
200+
return false
201+
}
202+
203+
/**
204+
* Check that the two given UnicodePropertyCharacterSet are in a valid order.
205+
*/
206+
function isValidOrderForUnicodePropertyCharacterSet(
207+
prev: UnicodePropertyCharacterSet,
208+
next: UnicodePropertyCharacterSet,
209+
) {
210+
if (prev.key < next.key) {
211+
return true
212+
} else if (prev.key > next.key) {
213+
return false
214+
}
215+
if (prev.value) {
216+
if (next.value) {
217+
if (prev.value <= next.value) {
218+
return true
219+
}
220+
return false
221+
}
222+
return false
223+
}
224+
return true
225+
}
226+
227+
/**
228+
* Gets the target code point for a given element.
229+
*/
230+
function getTargetCodePoint(
231+
node: Exclude<CharacterClassElement, UnicodePropertyCharacterSet>,
232+
) {
233+
if (node.type === "CharacterSet") {
234+
if (node.kind === "digit" || node.kind === "word") {
235+
return CP_DIGIT_ZERO
236+
}
237+
if (node.kind === "space") {
238+
return CP_SPACE
239+
}
240+
return Infinity
241+
}
242+
if (node.type === "CharacterClassRange") {
243+
return node.min.value
244+
}
245+
return node.value
246+
}
247+
248+
return defineRegexpVisitor(context, {
249+
createVisitor,
250+
})
251+
},
252+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import noUselessNonCapturingGroup from "../rules/no-useless-non-capturing-group"
1818
import noUselessNonGreedy from "../rules/no-useless-non-greedy"
1919
import noUselessRange from "../rules/no-useless-range"
2020
import noUselessTwoNumsQuantifier from "../rules/no-useless-two-nums-quantifier"
21+
import orderInCharacterClass from "../rules/order-in-character-class"
2122
import preferCharacterClass from "../rules/prefer-character-class"
2223
import preferD from "../rules/prefer-d"
2324
import preferPlusQuantifier from "../rules/prefer-plus-quantifier"
@@ -51,6 +52,7 @@ export const rules = [
5152
noUselessNonGreedy,
5253
noUselessRange,
5354
noUselessTwoNumsQuantifier,
55+
orderInCharacterClass,
5456
preferCharacterClass,
5557
preferD,
5658
preferPlusQuantifier,

0 commit comments

Comments
 (0)