Skip to content

Commit 97c1bea

Browse files
authored
Add regexp/letter-case rule (#35)
1 parent 1c05d4e commit 97c1bea

File tree

7 files changed

+460
-4
lines changed

7 files changed

+460
-4
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
7979

8080
| Rule ID | Description | |
8181
|:--------|:------------|:---|
82+
| [regexp/letter-case](https://ota-meshi.github.io/eslint-plugin-regexp/rules/letter-case.html) | enforce into your favorite case | :wrench: |
8283
| [regexp/match-any](https://ota-meshi.github.io/eslint-plugin-regexp/rules/match-any.html) | enforce match any character style | :star::wrench: |
8384
| [regexp/no-assertion-capturing-group](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-assertion-capturing-group.html) | disallow capturing group that captures assertions. | :star: |
8485
| [regexp/no-dupe-characters-character-class](https://ota-meshi.github.io/eslint-plugin-regexp/rules/no-dupe-characters-character-class.html) | disallow duplicate characters in the RegExp character class | :star: |

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ The rules with the following star :star: are included in the `plugin:regexp/reco
1111

1212
| Rule ID | Description | |
1313
|:--------|:------------|:---|
14+
| [regexp/letter-case](./letter-case.md) | enforce into your favorite case | :wrench: |
1415
| [regexp/match-any](./match-any.md) | enforce match any character style | :star::wrench: |
1516
| [regexp/no-assertion-capturing-group](./no-assertion-capturing-group.md) | disallow capturing group that captures assertions. | :star: |
1617
| [regexp/no-dupe-characters-character-class](./no-dupe-characters-character-class.md) | disallow duplicate characters in the RegExp character class | :star: |

docs/rules/letter-case.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
pageClass: "rule-details"
3+
sidebarDepth: 0
4+
title: "regexp/letter-case"
5+
description: "enforce into your favorite case"
6+
---
7+
# regexp/letter-case
8+
9+
> enforce into your favorite case
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 aims to unify the case of letters.
17+
18+
<eslint-code-block fix>
19+
20+
```js
21+
/* eslint regexp/letter-case: "error" */
22+
23+
/* ✓ GOOD */
24+
var foo = /a/i
25+
var foo = /\u000a/
26+
27+
/* ✗ BAD */
28+
var foo = /A/i
29+
var foo = /\u000A/
30+
```
31+
32+
</eslint-code-block>
33+
34+
## :wrench: Options
35+
36+
```json5
37+
{
38+
"regexp/letter-case": ["error", {
39+
"caseInsensitive": "lowercase", // or "uppercase" or "ignore"
40+
"unicodeEscape": "lowercase" // or "uppercase" or "ignore"
41+
}]
42+
}
43+
```
44+
45+
- String options
46+
- `"lowercase"` ... Enforce lowercase letters. This is default.
47+
- `"uppercase"` ... Enforce uppercase letters.
48+
- `"ignore"` ... Does not force case.
49+
- Properties
50+
- `caseInsensitive` ... Specifies the letter case when the `i` flag is present.
51+
- `unicodeEscape` ... Specifies the letter case when the unicode escapes.
52+
53+
## :mag: Implementation
54+
55+
- [Rule source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/lib/rules/letter-case.ts)
56+
- [Test source](https://github.com/ota-meshi/eslint-plugin-regexp/blob/master/tests/lib/rules/letter-case.ts)

lib/rules/letter-case.ts

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import type { Expression } from "estree"
2+
import type { RegExpVisitor } from "regexpp/visitor"
3+
import type { Character, CharacterClassRange } from "regexpp/ast"
4+
import {
5+
createRule,
6+
defineRegexpVisitor,
7+
getRegexpLocation,
8+
getRegexpRange,
9+
isLetter,
10+
isLowercaseLetter,
11+
isUppercaseLetter,
12+
} from "../utils"
13+
14+
const CASE_SCHEMA = ["lowercase", "uppercase", "ignore"] as const
15+
type Case = typeof CASE_SCHEMA[number]
16+
17+
/** Parse option */
18+
function parseOptions(option?: {
19+
caseInsensitive?: Case
20+
unicodeEscape?: Case
21+
}): { caseInsensitive: Case; unicodeEscape: Case } {
22+
if (!option) {
23+
return { caseInsensitive: "lowercase", unicodeEscape: "lowercase" }
24+
}
25+
return {
26+
caseInsensitive: option.caseInsensitive || "lowercase",
27+
unicodeEscape: option.unicodeEscape || "lowercase",
28+
}
29+
}
30+
31+
const CODE_POINT_CASE_CHECKER = {
32+
lowercase: isLowercaseLetter,
33+
uppercase: isUppercaseLetter,
34+
}
35+
const STRING_CASE_CHECKER = {
36+
lowercase: (s: string) => s.toLowerCase() === s,
37+
uppercase: (s: string) => s.toUpperCase() === s,
38+
}
39+
const CONVERTER = {
40+
lowercase: (s: string) => s.toLowerCase(),
41+
uppercase: (s: string) => s.toUpperCase(),
42+
}
43+
44+
export default createRule("letter-case", {
45+
meta: {
46+
docs: {
47+
description: "enforce into your favorite case",
48+
recommended: false,
49+
},
50+
fixable: "code",
51+
schema: [
52+
{
53+
type: "object",
54+
properties: {
55+
caseInsensitive: { enum: CASE_SCHEMA },
56+
unicodeEscape: { enum: CASE_SCHEMA },
57+
},
58+
additionalProperties: false,
59+
},
60+
],
61+
messages: {
62+
unexpected: "'{{char}}' is not in {{case}}",
63+
},
64+
type: "layout", // "problem",
65+
},
66+
create(context) {
67+
const options = parseOptions(context.options[0])
68+
const sourceCode = context.getSourceCode()
69+
70+
/**
71+
* Report
72+
*/
73+
function report(
74+
node: Expression,
75+
reportNode: CharacterClassRange | Character,
76+
letterCase: "lowercase" | "uppercase",
77+
convertText: (converter: (s: string) => string) => string,
78+
) {
79+
context.report({
80+
node,
81+
loc: getRegexpLocation(sourceCode, node, reportNode),
82+
messageId: "unexpected",
83+
data: {
84+
char: reportNode.raw,
85+
case: letterCase,
86+
},
87+
fix(fixer) {
88+
const range = getRegexpRange(sourceCode, node, reportNode)
89+
if (range == null) {
90+
return null
91+
}
92+
const newText = convertText(CONVERTER[letterCase])
93+
return fixer.replaceTextRange(range, newText)
94+
},
95+
})
96+
}
97+
98+
/** Verify for Character in case insensitive */
99+
function verifyCharacterInCaseInsensitive(
100+
node: Expression,
101+
cNode: Character,
102+
) {
103+
if (
104+
cNode.parent.type === "CharacterClassRange" ||
105+
options.caseInsensitive === "ignore"
106+
) {
107+
return
108+
}
109+
if (
110+
CODE_POINT_CASE_CHECKER[options.caseInsensitive](cNode.value) ||
111+
!isLetter(cNode.value)
112+
) {
113+
return
114+
}
115+
116+
report(node, cNode, options.caseInsensitive, (converter) =>
117+
converter(String.fromCodePoint(cNode.value)),
118+
)
119+
}
120+
121+
/** Verify for CharacterClassRange in case insensitive */
122+
function verifyCharacterClassRangeInCaseInsensitive(
123+
node: Expression,
124+
ccrNode: CharacterClassRange,
125+
) {
126+
if (options.caseInsensitive === "ignore") {
127+
return
128+
}
129+
if (
130+
CODE_POINT_CASE_CHECKER[options.caseInsensitive](
131+
ccrNode.min.value,
132+
) ||
133+
!isLetter(ccrNode.min.value) ||
134+
CODE_POINT_CASE_CHECKER[options.caseInsensitive](
135+
ccrNode.max.value,
136+
) ||
137+
!isLetter(ccrNode.max.value)
138+
) {
139+
return
140+
}
141+
report(
142+
node,
143+
ccrNode,
144+
options.caseInsensitive,
145+
(converter) =>
146+
`${converter(
147+
String.fromCodePoint(ccrNode.min.value),
148+
)}-${converter(String.fromCodePoint(ccrNode.max.value))}`,
149+
)
150+
}
151+
152+
/** Verify for Character in unicode escape */
153+
function verifyCharacterInUnicodeEscape(
154+
node: Expression,
155+
cNode: Character,
156+
) {
157+
if (options.unicodeEscape === "ignore") {
158+
return
159+
}
160+
const parts = /(\\u\{?)(.*)(\}?)/u.exec(cNode.raw)!
161+
if (STRING_CASE_CHECKER[options.unicodeEscape](parts[2])) {
162+
return
163+
}
164+
report(
165+
node,
166+
cNode,
167+
options.unicodeEscape,
168+
(converter) => `${parts[1]}${converter(parts[2])}${parts[3]}`,
169+
)
170+
}
171+
172+
/**
173+
* Create visitor
174+
* @param node
175+
*/
176+
function createVisitor(
177+
node: Expression,
178+
_pattern: string,
179+
flags: string,
180+
): RegExpVisitor.Handlers {
181+
return {
182+
onCharacterEnter(cNode) {
183+
if (flags.includes("i")) {
184+
verifyCharacterInCaseInsensitive(node, cNode)
185+
}
186+
if (cNode.raw.startsWith("\\u")) {
187+
verifyCharacterInUnicodeEscape(node, cNode)
188+
}
189+
},
190+
...(flags.includes("i")
191+
? {
192+
onCharacterClassRangeEnter(ccrNode) {
193+
verifyCharacterClassRangeInCaseInsensitive(
194+
node,
195+
ccrNode,
196+
)
197+
},
198+
}
199+
: {}),
200+
}
201+
}
202+
203+
return defineRegexpVisitor(context, {
204+
createVisitor,
205+
})
206+
},
207+
})

lib/utils/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { RuleModule } from "../types"
2+
import letterCase from "../rules/letter-case"
23
import matchAny from "../rules/match-any"
34
import noAssertionCapturingGroup from "../rules/no-assertion-capturing-group"
45
import noDupeCharactersCharacterClass from "../rules/no-dupe-characters-character-class"
@@ -24,6 +25,7 @@ import preferUnicodeCodepointEscapes from "../rules/prefer-unicode-codepoint-esc
2425
import preferW from "../rules/prefer-w"
2526

2627
export const rules = [
28+
letterCase,
2729
matchAny,
2830
noAssertionCapturingGroup,
2931
noDupeCharactersCharacterClass,

lib/utils/unicode.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,29 @@ function isCodePointInRange(
8989
export function isDigit(codePoint: number): boolean {
9090
return isCodePointInRange(codePoint, CP_RANGE_DIGIT)
9191
}
92+
/**
93+
* Checks if the given code point is lowercase.
94+
* @param codePoint The code point to check
95+
* @returns {boolean} `true` if the given code point is lowercase.
96+
*/
97+
export function isLowercaseLetter(codePoint: number): boolean {
98+
return isCodePointInRange(codePoint, CP_RANGE_SMALL_LETTER)
99+
}
100+
/**
101+
* Checks if the given code point is uppercase.
102+
* @param codePoint The code point to check
103+
* @returns {boolean} `true` if the given code point is uppercase.
104+
*/
105+
export function isUppercaseLetter(codePoint: number): boolean {
106+
return isCodePointInRange(codePoint, CP_RANGE_CAPITAL_LETTER)
107+
}
92108
/**
93109
* Checks if the given code point is letter.
94110
* @param codePoint The code point to check
95111
* @returns {boolean} `true` if the given code point is letter.
96112
*/
97113
export function isLetter(codePoint: number): boolean {
98-
return (
99-
isCodePointInRange(codePoint, CP_RANGE_SMALL_LETTER) ||
100-
isCodePointInRange(codePoint, CP_RANGE_CAPITAL_LETTER)
101-
)
114+
return isLowercaseLetter(codePoint) || isUppercaseLetter(codePoint)
102115
}
103116
/**
104117
* Checks if the given code point is symbol.

0 commit comments

Comments
 (0)