Files
kjol/tools/tsgo/internal/ls/codeactions.go
2026-07-09 16:50:43 -04:00

400 lines
12 KiB
Go

package ls
import (
"cmp"
"context"
"slices"
"strings"
"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/collections"
"github.com/microsoft/typescript-go/internal/compiler"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/locale"
"github.com/microsoft/typescript-go/internal/ls/lsconv"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
)
// CodeFixProvider represents a provider for a specific type of code fix
type CodeFixProvider struct {
ErrorCodes []int32
GetCodeActions func(ctx context.Context, fixContext *CodeFixContext) ([]*CodeAction, error)
FixIds []string
GetAllCodeActions func(ctx context.Context, fixContext *CodeFixContext) (*CombinedCodeActions, error)
}
// CodeFixContext contains the context needed to generate code fixes
type CodeFixContext struct {
SourceFile *ast.SourceFile
Span core.TextRange
ErrorCode int32
Program *compiler.Program
LS *LanguageService
Diagnostic *lsproto.Diagnostic
Params *lsproto.CodeActionParams
}
// CodeAction represents a single code action fix
type CodeAction struct {
Description string
Changes []*lsproto.TextEdit
FixID string
FixAllDescription string
}
// Compare defines a total ordering for CodeAction values, comparing description
// then text edits lexicographically. Used with slices.BinarySearchFunc.
func (a *CodeAction) Compare(b *CodeAction) int {
if c := strings.Compare(a.Description, b.Description); c != 0 {
return c
}
if c := cmp.Compare(len(a.Changes), len(b.Changes)); c != 0 {
return c
}
for i, edit := range a.Changes {
if c := edit.Compare(b.Changes[i]); c != 0 {
return c
}
}
return 0
}
// CombinedCodeActions represents combined code actions for fix-all scenarios
type CombinedCodeActions struct {
Description string
Changes []*lsproto.TextEdit
}
// codeFixProviders is the list of all registered code fix providers
var codeFixProviders = []*CodeFixProvider{
ImportFixProvider,
IsolatedDeclarationsFixProvider,
FixClassIncorrectlyImplementsInterfaceProvider,
// Add more code fix providers here as they are implemented
}
// ProvideCodeActions returns code actions for the given range and context
func (l *LanguageService) ProvideCodeActions(ctx context.Context, params *lsproto.CodeActionParams) (lsproto.CodeActionResponse, error) {
program, file := l.getProgramAndFile(params.TextDocument.Uri)
var actions []lsproto.CommandOrCodeAction
if params.Context != nil && params.Context.Only != nil {
for _, kind := range *params.Context.Only {
matchingKinds := getOrganizeImportsActionsForKind(kind)
for _, matchingKind := range matchingKinds {
organizeAction := l.createOrganizeImportsAction(ctx, program, file, matchingKind)
actions = append(actions, *organizeAction)
}
if isFixAllKind(kind) {
fixAllAction, err := l.createFixAllAction(ctx, program, file, params.TextDocument.Uri)
if err != nil {
return lsproto.CodeActionResponse{}, err
}
if fixAllAction != nil {
actions = append(actions, *fixAllAction)
}
}
}
}
if params.Context != nil && params.Context.Diagnostics != nil && wantsQuickFixes(params.Context.Only) {
fixIdSeen := make(map[string]*CodeFixProvider)
var seen []*CodeAction // sorted for binary search dedup, dedup across all diagnostics and providers so if multiple diags produce the same codefix, only one is returned
for _, diag := range params.Context.Diagnostics {
if diag.Code == nil || diag.Code.Integer == nil {
continue
}
errorCode := *diag.Code.Integer
for _, provider := range codeFixProviders {
if !containsErrorCode(provider.ErrorCodes, errorCode) {
continue
}
position := l.converters.LineAndCharacterToPosition(file, diag.Range.Start)
endPosition := l.converters.LineAndCharacterToPosition(file, diag.Range.End)
fixContext := &CodeFixContext{
SourceFile: file,
Span: core.NewTextRange(int(position), int(endPosition)),
ErrorCode: errorCode,
Program: program,
LS: l,
Diagnostic: diag,
Params: params,
}
providerActions, err := provider.GetCodeActions(ctx, fixContext)
if err != nil {
return lsproto.CodeActionResponse{}, err
}
for _, action := range providerActions {
i, found := slices.BinarySearchFunc(seen, action, (*CodeAction).Compare)
if found {
continue
}
seen = slices.Insert(seen, i, action)
actions = append(actions, convertToLSPCodeAction(action, diag, params.TextDocument.Uri))
if action.FixID != "" {
fixIdSeen[action.FixID] = provider
}
}
}
}
fixAllActions, err := l.getFixAllQuickFixes(ctx, program, file, params.TextDocument.Uri, fixIdSeen)
if err != nil {
return lsproto.CodeActionResponse{}, err
}
actions = append(actions, fixAllActions...)
}
return lsproto.CommandOrCodeActionArrayOrNull{CommandOrCodeActionArray: &actions}, nil
}
// getFixAllQuickFixes returns per-provider "Fix all in file" quickfix entries for providers
// that matched at least 2 diagnostics in the full file.
func (l *LanguageService) getFixAllQuickFixes(
ctx context.Context,
program *compiler.Program,
file *ast.SourceFile,
uri lsproto.DocumentUri,
fixIdSeen map[string]*CodeFixProvider,
) ([]lsproto.CommandOrCodeAction, error) {
var actions []lsproto.CommandOrCodeAction
// Deduplicate providers; multiple fixIds may map to the same provider.
var seen collections.Set[*CodeFixProvider]
for _, provider := range fixIdSeen {
if seen.Has(provider) {
continue
}
seen.Add(provider)
if provider.GetAllCodeActions == nil {
continue
}
if !hasMultipleFixableDiagnostics(ctx, program, file, provider.ErrorCodes) {
continue
}
fixContext := &CodeFixContext{
SourceFile: file,
Program: program,
LS: l,
}
combined, err := provider.GetAllCodeActions(ctx, fixContext)
if err != nil {
return nil, err
}
if combined != nil && len(combined.Changes) > 0 {
kind := lsproto.CodeActionKindQuickFix
changes := map[lsproto.DocumentUri][]*lsproto.TextEdit{
uri: combined.Changes,
}
actions = append(actions, lsproto.CommandOrCodeAction{
CodeAction: &lsproto.CodeAction{
Title: combined.Description,
Kind: &kind,
Edit: &lsproto.WorkspaceEdit{Changes: &changes},
},
})
}
}
return actions, nil
}
// hasMultipleFixableDiagnostics returns true if the file has at least 2 diagnostics
// matching the given error codes. Checks all diagnostic sources (semantic,
// syntactic, suggestion, declaration) to match ProvideDiagnostics.
func hasMultipleFixableDiagnostics(ctx context.Context, program *compiler.Program, file *ast.SourceFile, errorCodes []int32) bool {
allDiags := getAllDiagnostics(ctx, program, file)
count := 0
for _, d := range allDiags {
if containsErrorCode(errorCodes, d.Code()) {
count++
if count >= 2 {
return true
}
}
}
return false
}
// codeActionKindContains returns true if the requested kind equals or is a
// hierarchical parent of actionKind, using '.' as the separator. This matches
// the semantics of VS Code's HierarchicalKind.contains.
func codeActionKindContains(requestedKind, actionKind lsproto.CodeActionKind) bool {
return requestedKind == actionKind ||
requestedKind == "" ||
strings.HasPrefix(string(actionKind), string(requestedKind)+".")
}
// isFixAllKind returns true if the requested kind matches source.fixAll
func isFixAllKind(kind lsproto.CodeActionKind) bool {
return codeActionKindContains(kind, lsproto.CodeActionKindSourceFixAll)
}
// wantsQuickFixes returns true if the Only filter is nil/empty (meaning all kinds are wanted)
// or explicitly includes the quickfix kind.
func wantsQuickFixes(only *[]lsproto.CodeActionKind) bool {
if only == nil || len(*only) == 0 {
return true
}
for _, kind := range *only {
if codeActionKindContains(kind, lsproto.CodeActionKindQuickFix) {
return true
}
}
return false
}
// createFixAllAction creates a source.fixAll code action that applies all auto-fixable
// code fixes across the file.
func (l *LanguageService) createFixAllAction(
ctx context.Context,
program *compiler.Program,
file *ast.SourceFile,
uri lsproto.DocumentUri,
) (*lsproto.CommandOrCodeAction, error) {
kind := lsproto.CodeActionKindSourceFixAll
lspChanges := make(map[lsproto.DocumentUri][]*lsproto.TextEdit)
for _, provider := range codeFixProviders {
if provider.GetAllCodeActions == nil {
continue
}
fixContext := &CodeFixContext{
SourceFile: file,
Program: program,
LS: l,
}
combined, err := provider.GetAllCodeActions(ctx, fixContext)
if err != nil {
return nil, err
}
if combined != nil && len(combined.Changes) > 0 {
lspChanges[uri] = append(lspChanges[uri], combined.Changes...)
}
}
if len(lspChanges) == 0 {
return nil, nil
}
return &lsproto.CommandOrCodeAction{
CodeAction: &lsproto.CodeAction{
Title: diagnostics.Fix_All.Localize(locale.FromContext(ctx)),
Kind: &kind,
Edit: &lsproto.WorkspaceEdit{Changes: &lspChanges},
},
}, nil
}
// getOrganizeImportsActionTitle returns the appropriate title for the given organize imports kind
func getOrganizeImportsActionTitle(ctx context.Context, kind lsproto.CodeActionKind) string {
loc := locale.FromContext(ctx)
switch kind {
case lsproto.CodeActionKindSourceRemoveUnusedImports:
return diagnostics.Remove_Unused_Imports.Localize(loc)
case lsproto.CodeActionKindSourceSortImports:
return diagnostics.Sort_Imports.Localize(loc)
default:
return diagnostics.Organize_Imports.Localize(loc)
}
}
// getOrganizeImportsActionsForKind returns the organize imports code action kinds that should be
// returned for the given requested kind.
func getOrganizeImportsActionsForKind(requestedKind lsproto.CodeActionKind) []lsproto.CodeActionKind {
organizeImportsKinds := []lsproto.CodeActionKind{
lsproto.CodeActionKindSourceOrganizeImports,
lsproto.CodeActionKindSourceRemoveUnusedImports,
lsproto.CodeActionKindSourceSortImports,
}
var result []lsproto.CodeActionKind
for _, organizeKind := range organizeImportsKinds {
if codeActionKindContains(requestedKind, organizeKind) {
result = append(result, organizeKind)
}
}
if slices.Contains(result, requestedKind) {
return []lsproto.CodeActionKind{requestedKind}
}
return result
}
// createOrganizeImportsAction creates the organize imports code action
func (l *LanguageService) createOrganizeImportsAction(
ctx context.Context,
program *compiler.Program,
file *ast.SourceFile,
kind lsproto.CodeActionKind,
) *lsproto.CommandOrCodeAction {
title := getOrganizeImportsActionTitle(ctx, kind)
changes := l.OrganizeImports(
ctx,
file,
program,
kind,
)
if len(changes) == 0 {
return &lsproto.CommandOrCodeAction{
CodeAction: &lsproto.CodeAction{
Title: title,
Kind: &kind,
Edit: &lsproto.WorkspaceEdit{Changes: &map[lsproto.DocumentUri][]*lsproto.TextEdit{}},
},
}
}
lspChanges := make(map[lsproto.DocumentUri][]*lsproto.TextEdit)
for fileName, edits := range changes {
fileURI := lsconv.FileNameToDocumentURI(fileName)
lspChanges[fileURI] = edits
}
return &lsproto.CommandOrCodeAction{
CodeAction: &lsproto.CodeAction{
Title: title,
Kind: &kind,
Edit: &lsproto.WorkspaceEdit{Changes: &lspChanges},
},
}
}
// containsErrorCode checks if the error code is in the list
func containsErrorCode(codes []int32, code int32) bool {
return slices.Contains(codes, code)
}
// convertToLSPCodeAction converts an internal CodeAction to an LSP CodeAction
func convertToLSPCodeAction(action *CodeAction, diag *lsproto.Diagnostic, uri lsproto.DocumentUri) lsproto.CommandOrCodeAction {
kind := lsproto.CodeActionKindQuickFix
changes := map[lsproto.DocumentUri][]*lsproto.TextEdit{
uri: action.Changes,
}
diagnostics := []*lsproto.Diagnostic{diag}
return lsproto.CommandOrCodeAction{
CodeAction: &lsproto.CodeAction{
Title: action.Description,
Kind: &kind,
Edit: &lsproto.WorkspaceEdit{Changes: &changes},
Diagnostics: &diagnostics,
},
}
}