Skip to content

Commit 25a31f5

Browse files
authored
feat: support inline configuration comment (#157)
1 parent a8069f5 commit 25a31f5

File tree

7 files changed

+355
-9
lines changed

7 files changed

+355
-9
lines changed

internal/linter/linter.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,20 @@ func RunLinter(programs []*compiler.Program, singleThreaded bool, allowFiles []s
5757
registeredListeners := make(map[ast.Kind][](func(node *ast.Node)), 20)
5858
{
5959
rules := getRulesForFile(file)
60+
// Create disable manager for this file
61+
disableManager := rule.NewDisableManager(file)
62+
6063
for _, r := range rules {
6164
ctx := rule.RuleContext{
62-
SourceFile: file,
63-
Program: program,
64-
TypeChecker: checker,
65+
SourceFile: file,
66+
Program: program,
67+
TypeChecker: checker,
68+
DisableManager: disableManager,
6569
ReportRange: func(textRange core.TextRange, msg rule.RuleMessage) {
70+
// Check if rule is disabled at this position
71+
if disableManager.IsRuleDisabled(r.Name, textRange.Pos()) {
72+
return
73+
}
6674
onDiagnostic(rule.RuleDiagnostic{
6775
RuleName: r.Name,
6876
Range: textRange,
@@ -72,6 +80,10 @@ func RunLinter(programs []*compiler.Program, singleThreaded bool, allowFiles []s
7280
})
7381
},
7482
ReportRangeWithSuggestions: func(textRange core.TextRange, msg rule.RuleMessage, suggestions ...rule.RuleSuggestion) {
83+
// Check if rule is disabled at this position
84+
if disableManager.IsRuleDisabled(r.Name, textRange.Pos()) {
85+
return
86+
}
7587
onDiagnostic(rule.RuleDiagnostic{
7688
RuleName: r.Name,
7789
Range: textRange,
@@ -82,6 +94,10 @@ func RunLinter(programs []*compiler.Program, singleThreaded bool, allowFiles []s
8294
})
8395
},
8496
ReportNode: func(node *ast.Node, msg rule.RuleMessage) {
97+
// Check if rule is disabled at this position
98+
if disableManager.IsRuleDisabled(r.Name, node.Pos()) {
99+
return
100+
}
85101
onDiagnostic(rule.RuleDiagnostic{
86102
RuleName: r.Name,
87103
Range: utils.TrimNodeTextRange(file, node),
@@ -91,6 +107,10 @@ func RunLinter(programs []*compiler.Program, singleThreaded bool, allowFiles []s
91107
})
92108
},
93109
ReportNodeWithFixes: func(node *ast.Node, msg rule.RuleMessage, fixes ...rule.RuleFix) {
110+
// Check if rule is disabled at this position
111+
if disableManager.IsRuleDisabled(r.Name, node.Pos()) {
112+
return
113+
}
94114
onDiagnostic(rule.RuleDiagnostic{
95115
RuleName: r.Name,
96116
Range: utils.TrimNodeTextRange(file, node),
@@ -102,6 +122,10 @@ func RunLinter(programs []*compiler.Program, singleThreaded bool, allowFiles []s
102122
},
103123

104124
ReportNodeWithSuggestions: func(node *ast.Node, msg rule.RuleMessage, suggestions ...rule.RuleSuggestion) {
125+
// Check if rule is disabled at this position
126+
if disableManager.IsRuleDisabled(r.Name, node.Pos()) {
127+
return
128+
}
105129
onDiagnostic(rule.RuleDiagnostic{
106130
RuleName: r.Name,
107131
Range: utils.TrimNodeTextRange(file, node),

internal/rule/disable_manager.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package rule
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/microsoft/typescript-go/shim/ast"
8+
"github.com/microsoft/typescript-go/shim/scanner"
9+
)
10+
11+
// ESLintDirective represents an ESLint disable/enable directive
12+
type ESLintDirective struct {
13+
Kind ESLintDirectiveKind
14+
Line int
15+
RuleNames []string
16+
}
17+
18+
type ESLintDirectiveKind int
19+
20+
const (
21+
ESLintDirectiveDisable ESLintDirectiveKind = iota
22+
ESLintDirectiveEnable
23+
ESLintDirectiveDisableLine
24+
ESLintDirectiveDisableNextLine
25+
)
26+
27+
// DisableManager tracks which rules are disabled at different locations in a file
28+
type DisableManager struct {
29+
sourceFile *ast.SourceFile
30+
disabledRules map[string]bool // Rules disabled for the entire file
31+
lineDisabledRules map[int][]string // Rules disabled for specific lines
32+
nextLineDisabledRules map[int][]string // Rules disabled for the next line
33+
}
34+
35+
// NewDisableManager creates a new DisableManager for the given source file
36+
func NewDisableManager(sourceFile *ast.SourceFile) *DisableManager {
37+
dm := &DisableManager{
38+
sourceFile: sourceFile,
39+
disabledRules: make(map[string]bool),
40+
lineDisabledRules: make(map[int][]string),
41+
nextLineDisabledRules: make(map[int][]string),
42+
}
43+
44+
dm.parseESLintDirectives()
45+
return dm
46+
}
47+
48+
// parseESLintDirectives parses ESLint-style disable/enable comments from the source text
49+
func (dm *DisableManager) parseESLintDirectives() {
50+
if dm.sourceFile.Text() == "" {
51+
return
52+
}
53+
54+
text := dm.sourceFile.Text()
55+
lines := strings.Split(text, "\n")
56+
57+
// Regular expressions to match ESLint directives
58+
eslintDisableLineRe := regexp.MustCompile(`//\s*eslint-disable-line(?:\s+([^/\r\n]+))?`)
59+
eslintDisableNextLineRe := regexp.MustCompile(`//\s*eslint-disable-next-line(?:\s+([^/\r\n]+))?`)
60+
eslintDisableRe := regexp.MustCompile(`/\*\s*eslint-disable(?:\s+([^*]+))?\s*\*/`)
61+
eslintEnableRe := regexp.MustCompile(`/\*\s*eslint-enable(?:\s+([^*]+))?\s*\*/`)
62+
63+
for i, line := range lines {
64+
lineNum := i // 0-based line numbers
65+
66+
// Check for eslint-disable-line
67+
if matches := eslintDisableLineRe.FindStringSubmatch(line); matches != nil {
68+
rules := parseRuleNames(matches[1])
69+
if len(rules) == 0 {
70+
dm.lineDisabledRules[lineNum] = append(dm.lineDisabledRules[lineNum], "*")
71+
} else {
72+
dm.lineDisabledRules[lineNum] = append(dm.lineDisabledRules[lineNum], rules...)
73+
}
74+
}
75+
76+
// Check for eslint-disable-next-line
77+
if matches := eslintDisableNextLineRe.FindStringSubmatch(line); matches != nil {
78+
rules := parseRuleNames(matches[1])
79+
nextLineNum := lineNum + 1
80+
if len(rules) == 0 {
81+
dm.nextLineDisabledRules[nextLineNum] = append(dm.nextLineDisabledRules[nextLineNum], "*")
82+
} else {
83+
dm.nextLineDisabledRules[nextLineNum] = append(dm.nextLineDisabledRules[nextLineNum], rules...)
84+
}
85+
}
86+
87+
// Check for eslint-disable (block comments)
88+
if matches := eslintDisableRe.FindStringSubmatch(line); matches != nil {
89+
rules := parseRuleNames(matches[1])
90+
if len(rules) == 0 {
91+
dm.disabledRules["*"] = true
92+
} else {
93+
for _, rule := range rules {
94+
dm.disabledRules[rule] = true
95+
}
96+
}
97+
}
98+
99+
// Check for eslint-enable (block comments)
100+
if matches := eslintEnableRe.FindStringSubmatch(line); matches != nil {
101+
rules := parseRuleNames(matches[1])
102+
if len(rules) == 0 {
103+
// Enable all rules
104+
for key := range dm.disabledRules {
105+
delete(dm.disabledRules, key)
106+
}
107+
} else {
108+
for _, rule := range rules {
109+
delete(dm.disabledRules, rule)
110+
}
111+
}
112+
}
113+
}
114+
}
115+
116+
// parseRuleNames parses rule names from a string like "rule1, rule2, rule3"
117+
func parseRuleNames(rulesStr string) []string {
118+
if rulesStr == "" {
119+
return nil
120+
}
121+
122+
var rules []string
123+
for _, rule := range strings.Split(rulesStr, ",") {
124+
rule = strings.TrimSpace(rule)
125+
if rule != "" {
126+
rules = append(rules, rule)
127+
}
128+
}
129+
return rules
130+
}
131+
132+
// IsRuleDisabled checks if a rule is disabled at the given position
133+
func (dm *DisableManager) IsRuleDisabled(ruleName string, pos int) bool {
134+
// Check if rule is disabled for the entire file
135+
if dm.disabledRules[ruleName] || dm.disabledRules["*"] {
136+
return true
137+
}
138+
139+
// Get the line number for the position
140+
line, _ := scanner.GetLineAndCharacterOfPosition(dm.sourceFile, pos)
141+
142+
// Check if rule is disabled for this specific line
143+
if lineRules, exists := dm.lineDisabledRules[line]; exists {
144+
for _, disabledRule := range lineRules {
145+
if disabledRule == ruleName || disabledRule == "*" {
146+
return true
147+
}
148+
}
149+
}
150+
151+
// Check if rule is disabled for this line via next-line directive
152+
if nextLineRules, exists := dm.nextLineDisabledRules[line]; exists {
153+
for _, disabledRule := range nextLineRules {
154+
if disabledRule == ruleName || disabledRule == "*" {
155+
return true
156+
}
157+
}
158+
}
159+
160+
return false
161+
}

internal/rule/disable_manager_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package rule
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestParseRuleNames(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
input string
12+
expected []string
13+
}{
14+
{
15+
name: "single rule",
16+
input: "no-unused-vars",
17+
expected: []string{"no-unused-vars"},
18+
},
19+
{
20+
name: "multiple rules",
21+
input: "no-unused-vars, no-console, no-debugger",
22+
expected: []string{"no-unused-vars", "no-console", "no-debugger"},
23+
},
24+
{
25+
name: "rules with extra spaces",
26+
input: " no-unused-vars , no-console ",
27+
expected: []string{"no-unused-vars", "no-console"},
28+
},
29+
{
30+
name: "empty string",
31+
input: "",
32+
expected: nil,
33+
},
34+
{
35+
name: "whitespace only",
36+
input: " ",
37+
expected: nil,
38+
},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
result := parseRuleNames(tt.input)
44+
45+
if len(result) != len(tt.expected) {
46+
t.Errorf("Expected %d rules, got %d", len(tt.expected), len(result))
47+
return
48+
}
49+
50+
for i, expected := range tt.expected {
51+
if result[i] != expected {
52+
t.Errorf("Expected rule %d to be %q, got %q", i, expected, result[i])
53+
}
54+
}
55+
})
56+
}
57+
}
58+
59+
func TestESLintDirectiveRegexPatterns(t *testing.T) {
60+
tests := []struct {
61+
name string
62+
line string
63+
shouldMatch string
64+
rules string
65+
}{
66+
{
67+
name: "eslint-disable-line with rule",
68+
line: "// eslint-disable-line no-unused-vars",
69+
shouldMatch: "disable-line",
70+
rules: "no-unused-vars",
71+
},
72+
{
73+
name: "eslint-disable-next-line with multiple rules",
74+
line: "// eslint-disable-next-line no-console, no-debugger",
75+
shouldMatch: "disable-next-line",
76+
rules: "no-console, no-debugger",
77+
},
78+
{
79+
name: "eslint-disable block comment",
80+
line: "/* eslint-disable no-unused-vars */",
81+
shouldMatch: "disable",
82+
rules: "no-unused-vars",
83+
},
84+
{
85+
name: "eslint-enable block comment",
86+
line: "/* eslint-enable no-unused-vars */",
87+
shouldMatch: "enable",
88+
rules: "no-unused-vars",
89+
},
90+
{
91+
name: "eslint-disable-line without rules",
92+
line: "// eslint-disable-line",
93+
shouldMatch: "disable-line",
94+
rules: "",
95+
},
96+
}
97+
98+
for _, tt := range tests {
99+
t.Run(tt.name, func(t *testing.T) {
100+
switch tt.shouldMatch {
101+
case "disable-line":
102+
if !strings.Contains(tt.line, "eslint-disable-line") {
103+
t.Errorf("Line should contain eslint-disable-line directive")
104+
}
105+
case "disable-next-line":
106+
if !strings.Contains(tt.line, "eslint-disable-next-line") {
107+
t.Errorf("Line should contain eslint-disable-next-line directive")
108+
}
109+
case "disable":
110+
if !strings.Contains(tt.line, "eslint-disable") || strings.Contains(tt.line, "eslint-disable-") {
111+
t.Errorf("Line should contain eslint-disable directive (not disable-line or disable-next-line)")
112+
}
113+
case "enable":
114+
if !strings.Contains(tt.line, "eslint-enable") {
115+
t.Errorf("Line should contain eslint-enable directive")
116+
}
117+
}
118+
119+
if tt.rules != "" {
120+
parsed := parseRuleNames(tt.rules)
121+
if len(parsed) == 0 {
122+
t.Errorf("Should have parsed rules from %q", tt.rules)
123+
}
124+
}
125+
})
126+
}
127+
}
128+
129+
func TestDisableManagerBasicFunctionality(t *testing.T) {
130+
dm := &DisableManager{
131+
sourceFile: nil,
132+
disabledRules: make(map[string]bool),
133+
lineDisabledRules: make(map[int][]string),
134+
nextLineDisabledRules: make(map[int][]string),
135+
}
136+
137+
dm.disabledRules["no-console"] = true
138+
dm.lineDisabledRules[5] = []string{"no-unused-vars"}
139+
dm.nextLineDisabledRules[10] = []string{"no-debugger"}
140+
141+
if !dm.disabledRules["no-console"] {
142+
t.Error("Expected no-console to be disabled")
143+
}
144+
145+
if len(dm.lineDisabledRules[5]) != 1 || dm.lineDisabledRules[5][0] != "no-unused-vars" {
146+
t.Error("Expected no-unused-vars to be disabled for line 5")
147+
}
148+
149+
if len(dm.nextLineDisabledRules[10]) != 1 || dm.nextLineDisabledRules[10][0] != "no-debugger" {
150+
t.Error("Expected no-debugger to be disabled for next line after 10")
151+
}
152+
}

internal/rule/rule.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ type RuleContext struct {
158158
SourceFile *ast.SourceFile
159159
Program *compiler.Program
160160
TypeChecker *checker.Checker
161+
DisableManager *DisableManager
161162
ReportRange func(textRange core.TextRange, msg RuleMessage)
162163
ReportRangeWithSuggestions func(textRange core.TextRange, msg RuleMessage, suggestions ...RuleSuggestion)
163164
ReportNode func(node *ast.Node, msg RuleMessage)

packages/rslint-test-tools/tests/cli/basic.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ describe('CLI Configuration Tests', () => {
203203
.split('\n')
204204
.filter(line => line.trim());
205205
for (const line of lines) {
206+
// eslint-disable-next-line
206207
expect(() => JSON.parse(line)).not.toThrow();
207208
}
208209
} finally {

0 commit comments

Comments
 (0)