Skip to content

Commit 3faf922

Browse files
authored
feat: add codeactions for autofix (#139)
1 parent b5b4fc8 commit 3faf922

File tree

10 files changed

+590
-20
lines changed

10 files changed

+590
-20
lines changed

cmd/rslint/lsp.go

Lines changed: 281 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,16 @@ func ptrTo[T any](v T) *T {
3030

3131
// LSP Server implementation
3232
type LSPServer struct {
33-
conn *jsonrpc2.Conn
34-
rootURI string
35-
documents map[lsproto.DocumentUri]string // URI -> content
33+
conn *jsonrpc2.Conn
34+
rootURI string
35+
documents map[lsproto.DocumentUri]string // URI -> content
36+
diagnostics map[lsproto.DocumentUri][]rule.RuleDiagnostic // URI -> diagnostics
3637
}
3738

3839
func NewLSPServer() *LSPServer {
3940
return &LSPServer{
40-
documents: make(map[lsproto.DocumentUri]string),
41+
documents: make(map[lsproto.DocumentUri]string),
42+
diagnostics: make(map[lsproto.DocumentUri][]rule.RuleDiagnostic),
4143
}
4244
}
4345

@@ -69,6 +71,8 @@ func (s *LSPServer) Handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrp
6971
case "textDocument/diagnostic":
7072
s.handleDidSave(ctx, req)
7173
return nil, nil
74+
case "textDocument/codeAction":
75+
return s.handleCodeAction(ctx, req)
7276
case "shutdown":
7377
return s.handleShutdown(ctx, req)
7478

@@ -102,6 +106,9 @@ func (s *LSPServer) handleInitialize(ctx context.Context, req *jsonrpc2.Request)
102106
TextDocumentSync: &lsproto.TextDocumentSyncOptionsOrKind{
103107
Kind: ptrTo(lsproto.TextDocumentSyncKindFull),
104108
},
109+
CodeActionProvider: &lsproto.BooleanOrCodeActionOptions{
110+
Boolean: ptrTo(true),
111+
},
105112
},
106113
}
107114

@@ -155,6 +162,61 @@ func (s *LSPServer) handleShutdown(ctx context.Context, req *jsonrpc2.Request) (
155162
return nil, nil
156163
}
157164

165+
func (s *LSPServer) handleCodeAction(ctx context.Context, req *jsonrpc2.Request) (interface{}, error) {
166+
var params lsproto.CodeActionParams
167+
if err := json.Unmarshal(*req.Params, &params); err != nil {
168+
return nil, &jsonrpc2.Error{
169+
Code: jsonrpc2.CodeParseError,
170+
Message: "Failed to parse code action params",
171+
}
172+
}
173+
174+
uri := params.TextDocument.Uri
175+
176+
// Get stored diagnostics for this document
177+
ruleDiagnostics, exists := s.diagnostics[uri]
178+
if !exists {
179+
return []*lsproto.CodeAction{}, nil
180+
}
181+
182+
var codeActions []*lsproto.CodeAction
183+
184+
// Find diagnostics that overlap with the requested range
185+
for _, ruleDiag := range ruleDiagnostics {
186+
// Check if diagnostic range overlaps with requested range
187+
diagStartLine, diagStartChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.Pos())
188+
diagEndLine, diagEndChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.End())
189+
190+
diagRange := lsproto.Range{
191+
Start: lsproto.Position{Line: uint32(diagStartLine), Character: uint32(diagStartChar)},
192+
End: lsproto.Position{Line: uint32(diagEndLine), Character: uint32(diagEndChar)},
193+
}
194+
195+
if rangesOverlap(diagRange, params.Range) {
196+
// Add code action for fixes
197+
codeAction := createCodeActionFromRuleDiagnostic(ruleDiag, uri)
198+
if codeAction != nil {
199+
codeActions = append(codeActions, codeAction)
200+
}
201+
// add extract disable rule actions
202+
disableActions := createDisableRuleActions(ruleDiag, uri)
203+
codeActions = append(codeActions, disableActions...)
204+
205+
// Add code actions for suggestions
206+
if ruleDiag.Suggestions != nil {
207+
for _, suggestion := range *ruleDiag.Suggestions {
208+
suggestionAction := createCodeActionFromSuggestion(ruleDiag, suggestion, uri)
209+
if suggestionAction != nil {
210+
codeActions = append(codeActions, suggestionAction)
211+
}
212+
}
213+
}
214+
}
215+
}
216+
217+
return codeActions, nil
218+
}
219+
158220
func (s *LSPServer) runDiagnostics(ctx context.Context, uri lsproto.DocumentUri, content string) {
159221
uriString := string(uri)
160222

@@ -256,6 +318,9 @@ func (s *LSPServer) runDiagnostics(ctx context.Context, uri lsproto.DocumentUri,
256318
log.Printf("Error running lint: %v", err)
257319
}
258320

321+
// Store rule diagnostics for code actions
322+
s.diagnostics[uri] = rule_diags
323+
259324
for _, diagnostic := range rule_diags {
260325
lspDiag := convertRuleDiagnosticToLSP(diagnostic, content)
261326
lsp_diagnostics = append(lsp_diagnostics, lspDiag)
@@ -382,3 +447,215 @@ func runLintWithPrograms(uri lsproto.DocumentUri, programs []*compiler.Program,
382447
}
383448
return diagnostics, nil
384449
}
450+
451+
// Helper function to check if two ranges overlap
452+
func rangesOverlap(a, b lsproto.Range) bool {
453+
return !(a.End.Line < b.Start.Line ||
454+
(a.End.Line == b.Start.Line && a.End.Character < b.Start.Character) ||
455+
b.End.Line < a.Start.Line ||
456+
(b.End.Line == a.Start.Line && b.End.Character < a.Start.Character))
457+
}
458+
459+
// Helper function to create a code action from a rule diagnostic
460+
func createCodeActionFromRuleDiagnostic(ruleDiag rule.RuleDiagnostic, uri lsproto.DocumentUri) *lsproto.CodeAction {
461+
fixes := ruleDiag.Fixes()
462+
if len(fixes) == 0 {
463+
return nil
464+
}
465+
466+
// Convert rule fixes to LSP text edits
467+
var textEdits []*lsproto.TextEdit
468+
for _, fix := range fixes {
469+
startLine, startChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, fix.Range.Pos())
470+
endLine, endChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, fix.Range.End())
471+
472+
textEdit := &lsproto.TextEdit{
473+
Range: lsproto.Range{
474+
Start: lsproto.Position{Line: uint32(startLine), Character: uint32(startChar)},
475+
End: lsproto.Position{Line: uint32(endLine), Character: uint32(endChar)},
476+
},
477+
NewText: fix.Text,
478+
}
479+
textEdits = append(textEdits, textEdit)
480+
}
481+
482+
// Create workspace edit
483+
workspaceEdit := &lsproto.WorkspaceEdit{
484+
Changes: &map[lsproto.DocumentUri][]*lsproto.TextEdit{
485+
uri: textEdits,
486+
},
487+
}
488+
489+
// Create the corresponding LSP diagnostic for reference
490+
diagStartLine, diagStartChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.Pos())
491+
diagEndLine, diagEndChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.End())
492+
493+
lspDiagnostic := &lsproto.Diagnostic{
494+
Range: lsproto.Range{
495+
Start: lsproto.Position{Line: uint32(diagStartLine), Character: uint32(diagStartChar)},
496+
End: lsproto.Position{Line: uint32(diagEndLine), Character: uint32(diagEndChar)},
497+
},
498+
Severity: ptrTo(lsproto.DiagnosticSeverity(ruleDiag.Severity.Int())),
499+
Source: ptrTo("rslint"),
500+
Message: fmt.Sprintf("[%s] %s", ruleDiag.RuleName, ruleDiag.Message.Description),
501+
}
502+
503+
return &lsproto.CodeAction{
504+
Title: fmt.Sprintf("Fix: %s", ruleDiag.Message.Description),
505+
Kind: ptrTo(lsproto.CodeActionKind("quickfix")),
506+
Edit: workspaceEdit,
507+
Diagnostics: &[]*lsproto.Diagnostic{lspDiagnostic},
508+
IsPreferred: ptrTo(true), // Mark auto-fixes as preferred
509+
}
510+
}
511+
512+
// Helper function to create a code action from a rule suggestion
513+
func createCodeActionFromSuggestion(ruleDiag rule.RuleDiagnostic, suggestion rule.RuleSuggestion, uri lsproto.DocumentUri) *lsproto.CodeAction {
514+
fixes := suggestion.Fixes()
515+
if len(fixes) == 0 {
516+
return nil
517+
}
518+
519+
// Convert rule fixes to LSP text edits
520+
var textEdits []*lsproto.TextEdit
521+
for _, fix := range fixes {
522+
startLine, startChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, fix.Range.Pos())
523+
endLine, endChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, fix.Range.End())
524+
525+
textEdit := &lsproto.TextEdit{
526+
Range: lsproto.Range{
527+
Start: lsproto.Position{Line: uint32(startLine), Character: uint32(startChar)},
528+
End: lsproto.Position{Line: uint32(endLine), Character: uint32(endChar)},
529+
},
530+
NewText: fix.Text,
531+
}
532+
textEdits = append(textEdits, textEdit)
533+
}
534+
535+
// Create workspace edit
536+
workspaceEdit := &lsproto.WorkspaceEdit{
537+
Changes: &map[lsproto.DocumentUri][]*lsproto.TextEdit{
538+
uri: textEdits,
539+
},
540+
}
541+
542+
// Create the corresponding LSP diagnostic for reference
543+
diagStartLine, diagStartChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.Pos())
544+
diagEndLine, diagEndChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.End())
545+
546+
lspDiagnostic := &lsproto.Diagnostic{
547+
Range: lsproto.Range{
548+
Start: lsproto.Position{Line: uint32(diagStartLine), Character: uint32(diagStartChar)},
549+
End: lsproto.Position{Line: uint32(diagEndLine), Character: uint32(diagEndChar)},
550+
},
551+
Severity: ptrTo(lsproto.DiagnosticSeverity(ruleDiag.Severity.Int())),
552+
Source: ptrTo("rslint"),
553+
Message: fmt.Sprintf("[%s] %s", ruleDiag.RuleName, ruleDiag.Message.Description),
554+
}
555+
556+
return &lsproto.CodeAction{
557+
Title: fmt.Sprintf("Suggestion: %s", suggestion.Message.Description),
558+
Kind: ptrTo(lsproto.CodeActionKind("quickfix")),
559+
Edit: workspaceEdit,
560+
Diagnostics: &[]*lsproto.Diagnostic{lspDiagnostic},
561+
IsPreferred: ptrTo(false), // Mark suggestions as not preferred
562+
}
563+
}
564+
565+
// Helper function to create disable rule actions for diagnostics without fixes
566+
func createDisableRuleActions(ruleDiag rule.RuleDiagnostic, uri lsproto.DocumentUri) []*lsproto.CodeAction {
567+
var actions []*lsproto.CodeAction
568+
569+
// Create the corresponding LSP diagnostic for reference
570+
diagStartLine, diagStartChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.Pos())
571+
diagEndLine, diagEndChar := scanner.GetLineAndCharacterOfPosition(ruleDiag.SourceFile, ruleDiag.Range.End())
572+
573+
lspDiagnostic := &lsproto.Diagnostic{
574+
Range: lsproto.Range{
575+
Start: lsproto.Position{Line: uint32(diagStartLine), Character: uint32(diagStartChar)},
576+
End: lsproto.Position{Line: uint32(diagEndLine), Character: uint32(diagEndChar)},
577+
},
578+
Severity: ptrTo(lsproto.DiagnosticSeverity(ruleDiag.Severity.Int())),
579+
Source: ptrTo("rslint"),
580+
Message: fmt.Sprintf("[%s] %s", ruleDiag.RuleName, ruleDiag.Message.Description),
581+
}
582+
583+
// Action 1: Disable rule for this line
584+
disableLineAction := createDisableRuleForLineAction(ruleDiag, uri, lspDiagnostic)
585+
if disableLineAction != nil {
586+
actions = append(actions, disableLineAction)
587+
}
588+
589+
// Action 2: Disable rule for entire file
590+
disableFileAction := createDisableRuleForFileAction(ruleDiag, uri, lspDiagnostic)
591+
if disableFileAction != nil {
592+
actions = append(actions, disableFileAction)
593+
}
594+
595+
return actions
596+
}
597+
598+
// Helper function to create a "disable rule for this line" action
599+
func createDisableRuleForLineAction(ruleDiag rule.RuleDiagnostic, uri lsproto.DocumentUri, lspDiagnostic *lsproto.Diagnostic) *lsproto.CodeAction {
600+
// Get the line where the diagnostic occurs
601+
lineStart := lspDiagnostic.Range.Start.Line
602+
603+
// Create text edit to add eslint-disable-next-line comment
604+
disableComment := fmt.Sprintf("// eslint-disable-next-line %s\n", ruleDiag.RuleName)
605+
606+
// Find the start of the line to insert the comment
607+
lineStartPos := lsproto.Position{Line: lineStart, Character: 0}
608+
609+
textEdit := &lsproto.TextEdit{
610+
Range: lsproto.Range{
611+
Start: lineStartPos,
612+
End: lineStartPos,
613+
},
614+
NewText: disableComment,
615+
}
616+
617+
workspaceEdit := &lsproto.WorkspaceEdit{
618+
Changes: &map[lsproto.DocumentUri][]*lsproto.TextEdit{
619+
uri: {textEdit},
620+
},
621+
}
622+
623+
return &lsproto.CodeAction{
624+
Title: fmt.Sprintf("Disable %s for this line", ruleDiag.RuleName),
625+
Kind: ptrTo(lsproto.CodeActionKind("quickfix")),
626+
Edit: workspaceEdit,
627+
Diagnostics: &[]*lsproto.Diagnostic{lspDiagnostic},
628+
IsPreferred: ptrTo(false),
629+
}
630+
}
631+
632+
// Helper function to create a "disable rule for entire file" action
633+
func createDisableRuleForFileAction(ruleDiag rule.RuleDiagnostic, uri lsproto.DocumentUri, lspDiagnostic *lsproto.Diagnostic) *lsproto.CodeAction {
634+
// Create text edit to add eslint-disable comment at the top of the file
635+
disableComment := fmt.Sprintf("/* eslint-disable %s */\n", ruleDiag.RuleName)
636+
637+
// Insert at the very beginning of the file
638+
fileStartPos := lsproto.Position{Line: 0, Character: 0}
639+
640+
textEdit := &lsproto.TextEdit{
641+
Range: lsproto.Range{
642+
Start: fileStartPos,
643+
End: fileStartPos,
644+
},
645+
NewText: disableComment,
646+
}
647+
648+
workspaceEdit := &lsproto.WorkspaceEdit{
649+
Changes: &map[lsproto.DocumentUri][]*lsproto.TextEdit{
650+
uri: {textEdit},
651+
},
652+
}
653+
654+
return &lsproto.CodeAction{
655+
Title: fmt.Sprintf("Disable %s for entire file", ruleDiag.RuleName),
656+
Kind: ptrTo(lsproto.CodeActionKind("quickfix")),
657+
Edit: workspaceEdit,
658+
Diagnostics: &[]*lsproto.Diagnostic{lspDiagnostic},
659+
IsPreferred: ptrTo(false),
660+
}
661+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ replace (
1919
)
2020

2121
require (
22+
github.com/bmatcuk/doublestar/v4 v4.9.1
2223
github.com/fatih/color v1.18.0
2324
github.com/microsoft/typescript-go/shim/ast v0.0.0
2425
github.com/microsoft/typescript-go/shim/bundled v0.0.0
@@ -40,7 +41,6 @@ require (
4041
)
4142

4243
require (
43-
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
4444
github.com/google/go-cmp v0.7.0 // indirect
4545
github.com/mattn/go-colorable v0.1.13 // indirect
4646
github.com/mattn/go-isatty v0.0.20 // indirect
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Test file for auto fix code actions
2+
const someValue: string = 'hello';
3+
4+
// This should trigger no-unnecessary-type-assertion rule (has auto fix)
5+
const result = (someValue as string).toUpperCase();
6+
7+
// Another example that should trigger a fix
8+
function example(): number {
9+
return 42 as number; // unnecessary type assertion
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Test file for disable rule code actions
2+
// These should trigger unsafe rules (no auto fix available)
3+
4+
const obj: any = {};
5+
const value = obj.someProperty.nested; // no-unsafe-member-access
6+
7+
function takesString(s: string) {}
8+
const anyValue: any = 'hello';
9+
takesString(anyValue); // no-unsafe-argument
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Test file for disable rule code actions
2+
// These should trigger unsafe rules (no auto fix available)
3+
4+
const obj: any = {};
5+
const value = obj.someProperty.nested; // no-unsafe-member-access
6+
7+
function takesString(s: string) {}
8+
const anyValue: any = 'hello';
9+
takesString(anyValue); // no-unsafe-argument

0 commit comments

Comments
 (0)