Skip to content

Commit 96a494e

Browse files
committed
feat: port rule no-empty-character-class
1 parent 1e5a9c6 commit 96a494e

File tree

7 files changed

+254
-0
lines changed

7 files changed

+254
-0
lines changed

internal/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ import (
123123
"github.com/web-infra-dev/rslint/internal/rules/no_loss_of_precision"
124124
"github.com/web-infra-dev/rslint/internal/rules/no_sparse_arrays"
125125
"github.com/web-infra-dev/rslint/internal/rules/no_template_curly_in_string"
126+
"github.com/web-infra-dev/rslint/internal/rules/no_empty_character_class"
126127
)
127128

128129
// RslintConfig represents the top-level configuration array
@@ -422,6 +423,7 @@ func registerAllCoreEslintRules() {
422423
GlobalRuleRegistry.Register("no-loss-of-precision", no_loss_of_precision.NoLossOfPrecisionRule)
423424
GlobalRuleRegistry.Register("no-template-curly-in-string", no_template_curly_in_string.NoTemplateCurlyInString)
424425
GlobalRuleRegistry.Register("no-sparse-arrays", no_sparse_arrays.NoSparseArraysRule)
426+
GlobalRuleRegistry.Register("no-empty-character-class", no_empty_character_class.NoEmptyCharacterClassRule)
425427
}
426428

427429
// getAllTypeScriptEslintPluginRules returns all rules from the global registry.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package no_empty_character_class
2+
3+
import (
4+
"strings"
5+
6+
"github.com/microsoft/typescript-go/shim/ast"
7+
"github.com/web-infra-dev/rslint/internal/rule"
8+
)
9+
10+
// https://eslint.org/docs/latest/rules/no-empty-character-class
11+
var NoEmptyCharacterClassRule = rule.Rule{
12+
Name: "no-empty-character-class",
13+
Run: func(ctx rule.RuleContext, options any) rule.RuleListeners {
14+
return rule.RuleListeners{
15+
ast.KindRegularExpressionLiteral: func(node *ast.Node) {
16+
text := node.Text()
17+
pattern := extractPattern(text)
18+
if hasEmptyCharacterClass(pattern) {
19+
ctx.ReportNode(node, rule.RuleMessage{
20+
Id: "unexpected",
21+
Description: "Empty class.",
22+
})
23+
}
24+
},
25+
}
26+
},
27+
}
28+
29+
// extractPattern extracts the regex pattern from a RegularExpressionLiteral text.
30+
// The text is in the form /pattern/flags, so we strip the leading / and trailing /flags.
31+
func extractPattern(text string) string {
32+
if len(text) < 2 || text[0] != '/' {
33+
return ""
34+
}
35+
// Find the last '/' which separates the pattern from flags
36+
lastSlash := strings.LastIndex(text[1:], "/")
37+
if lastSlash == -1 {
38+
return text[1:]
39+
}
40+
return text[1 : lastSlash+1]
41+
}
42+
43+
// hasEmptyCharacterClass scans the regex pattern for empty character classes [].
44+
// It uses a simple state machine:
45+
// - outside: normal scanning
46+
// - insideStart: just entered a character class with [
47+
// - insideAfterCaret: inside a negated class [^, next char decides
48+
// - inside: inside a character class, waiting for ]
49+
func hasEmptyCharacterClass(pattern string) bool {
50+
const (
51+
outside = 0
52+
insideStart = 1
53+
insideAfterCaret = 2
54+
inside = 3
55+
)
56+
57+
state := outside
58+
i := 0
59+
for i < len(pattern) {
60+
ch := pattern[i]
61+
switch state {
62+
case outside:
63+
if ch == '\\' {
64+
// Skip escaped character
65+
i += 2
66+
continue
67+
}
68+
if ch == '[' {
69+
state = insideStart
70+
}
71+
case insideStart:
72+
if ch == ']' {
73+
// Found empty character class []
74+
return true
75+
}
76+
if ch == '^' {
77+
state = insideAfterCaret
78+
} else {
79+
state = inside
80+
}
81+
case insideAfterCaret:
82+
if ch == ']' {
83+
// This is [^], which is allowed (matches any character)
84+
state = outside
85+
} else {
86+
state = inside
87+
}
88+
case inside:
89+
if ch == '\\' {
90+
// Skip escaped character inside class
91+
i += 2
92+
continue
93+
}
94+
if ch == ']' {
95+
state = outside
96+
}
97+
}
98+
i++
99+
}
100+
return false
101+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# no-empty-character-class
2+
3+
## Rule Details
4+
5+
Disallows empty character classes `[]` in regular expression literals. An empty character class in a regular expression does not match anything and is almost certainly a mistake. Note that `[^]` (a negated empty class) is allowed since it matches any character.
6+
7+
Examples of **incorrect** code for this rule:
8+
9+
```javascript
10+
var foo = /^abc[]/;
11+
var foo = /foo[]bar/;
12+
var foo = /[]]/;
13+
```
14+
15+
Examples of **correct** code for this rule:
16+
17+
```javascript
18+
var foo = /^abc[a-zA-Z]/;
19+
var foo = /[^]/;
20+
var foo = /[\\[]/;
21+
var foo = /\\[]/;
22+
```
23+
24+
## Original Documentation
25+
26+
- [ESLint no-empty-character-class](https://eslint.org/docs/latest/rules/no-empty-character-class)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package no_empty_character_class
2+
3+
import (
4+
"testing"
5+
6+
"github.com/web-infra-dev/rslint/internal/plugins/typescript/rules/fixtures"
7+
"github.com/web-infra-dev/rslint/internal/rule_tester"
8+
)
9+
10+
func TestNoEmptyCharacterClassRule(t *testing.T) {
11+
rule_tester.RunRuleTester(
12+
fixtures.GetRootDir(),
13+
"tsconfig.json",
14+
t,
15+
&NoEmptyCharacterClassRule,
16+
// Valid cases - ported from ESLint
17+
[]rule_tester.ValidTestCase{
18+
{Code: `var foo = /^abc[a-zA-Z]/;`},
19+
{Code: `var regExp = new RegExp("^abc[]");`},
20+
{Code: `var foo = /^abc/;`},
21+
{Code: `var foo = /[\[]/;`},
22+
{Code: `var foo = /[\]]/;`},
23+
{Code: `var foo = /[^]/;`},
24+
{Code: `var foo = /\[]/`},
25+
},
26+
// Invalid cases - ported from ESLint
27+
[]rule_tester.InvalidTestCase{
28+
{
29+
Code: `var foo = /^abc[]/;`,
30+
Errors: []rule_tester.InvalidTestCaseError{
31+
{MessageId: "unexpected", Line: 1, Column: 11},
32+
},
33+
},
34+
{
35+
Code: `var foo = /foo[]bar/;`,
36+
Errors: []rule_tester.InvalidTestCaseError{
37+
{MessageId: "unexpected", Line: 1, Column: 11},
38+
},
39+
},
40+
{
41+
Code: `var foo = /[]]/;`,
42+
Errors: []rule_tester.InvalidTestCaseError{
43+
{MessageId: "unexpected", Line: 1, Column: 11},
44+
},
45+
},
46+
{
47+
Code: `var foo = /\[[]/;`,
48+
Errors: []rule_tester.InvalidTestCaseError{
49+
{MessageId: "unexpected", Line: 1, Column: 11},
50+
},
51+
},
52+
},
53+
)
54+
}

packages/rslint-test-tools/rstest.config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default defineConfig({
2020
'./tests/eslint/rules/no-empty-pattern.test.ts',
2121
'./tests/eslint/rules/getter-return.test.ts',
2222
'./tests/eslint/rules/no-loss-of-precision.test.ts',
23+
'./tests/eslint/rules/no-empty-character-class.test.ts',
2324

2425
// eslint-plugin-import
2526
'./tests/eslint-plugin-import/rules/no-self-import.test.ts',
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Rstest Snapshot v1
2+
3+
exports[`no-empty-character-class > invalid 1`] = `
4+
{
5+
"code": "var foo = /^abc[]/;",
6+
"diagnostics": [
7+
{
8+
"message": "Empty class.",
9+
"messageId": "unexpected",
10+
"range": {
11+
"end": {
12+
"column": 19,
13+
"line": 1,
14+
},
15+
"start": {
16+
"column": 11,
17+
"line": 1,
18+
},
19+
},
20+
"ruleName": "no-empty-character-class",
21+
},
22+
],
23+
"errorCount": 1,
24+
"fileCount": 1,
25+
"ruleCount": 1,
26+
}
27+
`;
28+
29+
exports[`no-empty-character-class > invalid 2`] = `
30+
{
31+
"code": "var foo = /foo[]bar/;",
32+
"diagnostics": [
33+
{
34+
"message": "Empty class.",
35+
"messageId": "unexpected",
36+
"range": {
37+
"end": {
38+
"column": 21,
39+
"line": 1,
40+
},
41+
"start": {
42+
"column": 11,
43+
"line": 1,
44+
},
45+
},
46+
"ruleName": "no-empty-character-class",
47+
},
48+
],
49+
"errorCount": 1,
50+
"fileCount": 1,
51+
"ruleCount": 1,
52+
}
53+
`;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { RuleTester } from '../rule-tester';
2+
3+
const ruleTester = new RuleTester();
4+
5+
ruleTester.run('no-empty-character-class', {
6+
valid: ['var foo = /^abc[a-zA-Z]/;', 'var foo = /[^]/;'],
7+
invalid: [
8+
{
9+
code: 'var foo = /^abc[]/;',
10+
errors: [{ messageId: 'unexpected' }],
11+
},
12+
{
13+
code: 'var foo = /foo[]bar/;',
14+
errors: [{ messageId: 'unexpected' }],
15+
},
16+
],
17+
});

0 commit comments

Comments
 (0)