diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 50021d0716d..6b4d998ae50 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -478,6 +478,9 @@ func GetDefaultCapabilities() *lsproto.ClientCapabilities { HoverVerbosityLevel: ptrTrue, }, TextDocument: &lsproto.TextDocumentClientCapabilities{ + CodeAction: &lsproto.CodeActionClientCapabilities{ + DisabledSupport: ptrTrue, + }, Completion: &lsproto.CompletionClientCapabilities{ CompletionItem: &lsproto.ClientCompletionItemOptions{ SnippetSupport: ptrTrue, @@ -1980,6 +1983,275 @@ func (f *FourslashTest) getAllQuickFixActions(t *testing.T, errorCode ...int) [] return actions } +func (f *FourslashTest) getRefactorActions(t *testing.T) []*lsproto.CodeAction { + t.Helper() + return f.getRefactorActionsWithOptions(t, nil, nil) +} + +func (f *FourslashTest) getRefactorActionsWithTrigger(t *testing.T, triggerKind *lsproto.CodeActionTriggerKind) []*lsproto.CodeAction { + t.Helper() + return f.getRefactorActionsWithOptions(t, nil, triggerKind) +} + +func (f *FourslashTest) getRefactorActionsWithOnly(t *testing.T, only *[]lsproto.CodeActionKind) []*lsproto.CodeAction { + t.Helper() + return f.getRefactorActionsWithOptions(t, only, nil) +} + +func (f *FourslashTest) getRefactorActionsWithOptions(t *testing.T, only *[]lsproto.CodeActionKind, triggerKind *lsproto.CodeActionTriggerKind) []*lsproto.CodeAction { + t.Helper() + + endPos := f.currentCaretPosition + if f.selectionEnd != nil { + endPos = *f.selectionEnd + } + + params := &lsproto.CodeActionParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Range: lsproto.Range{ + Start: f.currentCaretPosition, + End: endPos, + }, + Context: &lsproto.CodeActionContext{ + Diagnostics: []*lsproto.Diagnostic{}, + Only: only, + TriggerKind: triggerKind, + }, + } + + result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params) + + var actions []*lsproto.CodeAction + + if result.CommandOrCodeActionArray != nil { + for _, item := range *result.CommandOrCodeActionArray { + if item.CodeAction != nil && item.CodeAction.Kind != nil && + isRefactoringKind(*item.CodeAction.Kind) && item.CodeAction.Disabled == nil { + actions = append(actions, item.CodeAction) + } + } + } + + return actions +} + +// VerifyRefactorOptions contains options for VerifyRefactor. +type VerifyRefactorOptions struct { + TriggerKind *lsproto.CodeActionTriggerKind + ApplyChanges bool + Title string + NewFileContent string +} + +// VerifyRefactor verifies that a refactoring code action matching the given title is available, +// and optionally verifies the file content after applying the edit. +func (f *FourslashTest) VerifyRefactor(t *testing.T, options VerifyRefactorOptions) { + t.Helper() + actions := f.getRefactorActionsWithTrigger(t, options.TriggerKind) + + var matchingAction *lsproto.CodeAction + + for _, action := range actions { + if action.Title == options.Title { + matchingAction = action + break + } + } + + if matchingAction == nil { + t.Fatalf("Expected refactoring %q to be available, but it was not. Got: %v", options.Title, actionTitles(actions)) + } + + if options.NewFileContent != "" { + actual := f.applyRefactorEdits(t, matchingAction, options.ApplyChanges) + assert.Equal(t, options.NewFileContent, actual, "File content after applying refactoring did not match expected content.") + } +} + +func (f *FourslashTest) applyRefactorEdits(t *testing.T, action *lsproto.CodeAction, applyChanges bool) string { + t.Helper() + actual := f.getScriptInfo(f.activeFilename).content + + if action.Edit == nil || action.Edit.Changes == nil { + return actual + } + + expectedURI := lsconv.FileNameToDocumentURI(f.activeFilename) + for uri, edits := range *action.Edit.Changes { + if uri != expectedURI { + t.Fatalf("Refactoring returned edits for unexpected URI %q (expected %q)", uri, expectedURI) + } + + if applyChanges { + f.applyTextEdits(t, edits) + } else { + actual = f.applyEditsToContent(actual, edits) + } + } + + if applyChanges { + actual = f.getScriptInfo(f.activeFilename).content + } + + return actual +} + +// VerifyRefactorAvailable verifies that a refactoring code action with the given title is available. +func (f *FourslashTest) VerifyRefactorAvailable(t *testing.T, title string) { + t.Helper() + f.VerifyRefactor(t, VerifyRefactorOptions{Title: title}) +} + +// VerifyRefactorAvailableForTriggerReason verifies that a refactoring code action +// with the given title is available when requested with the given trigger reason. +// "implicit" maps to the automatic trigger, "invoked" to the invoked trigger. +func (f *FourslashTest) VerifyRefactorAvailableForTriggerReason(t *testing.T, triggerReason string, title string) { + t.Helper() + actions := f.getRefactorActionsWithTrigger(t, triggerKind(triggerReason)) + + for _, action := range actions { + if action.Title == title { + return + } + } + + t.Fatalf("Expected refactoring %q to be available for trigger reason %q, but it was not. Got: %v", title, triggerReason, actionTitles(actions)) +} + +// VerifyRefactorNotAvailableForTriggerReason verifies that a refactoring code action +// with the given title is not available when requested with the given trigger reason. +func (f *FourslashTest) VerifyRefactorNotAvailableForTriggerReason(t *testing.T, triggerReason string, title string) { + t.Helper() + actions := f.getRefactorActionsWithTrigger(t, triggerKind(triggerReason)) + + for _, action := range actions { + if action.Title == title { + t.Fatalf("Expected refactoring %q to not be available for trigger reason %q, but it was", title, triggerReason) + } + } +} + +func triggerKind(triggerReason string) *lsproto.CodeActionTriggerKind { + kind := lsproto.CodeActionTriggerKindInvoked + if triggerReason == "implicit" { + kind = lsproto.CodeActionTriggerKindAutomatic + } + + return &kind +} + +// VerifyRefactorNotAvailable verifies that a refactoring code action with the given title is NOT available. +func (f *FourslashTest) VerifyRefactorNotAvailable(t *testing.T, title string) { + t.Helper() + actions := f.getRefactorActions(t) + + for _, action := range actions { + if action.Title == title { + t.Fatalf("Expected refactoring %q to not be available, but it was", title) + } + } +} + +// VerifyRefactorDisabled verifies that a refactoring code action with the given title IS returned +// but with its Disabled field set (i.e. the action is not applicable). +func (f *FourslashTest) VerifyRefactorDisabled(t *testing.T, title string) { + t.Helper() + actions := f.getRefactorActionsIncludingDisabled(t) + + for _, action := range actions { + if action.Title == title && action.Disabled == nil { + t.Fatalf("Expected refactoring %q to be disabled, but it was enabled", title) + } + + if action.Title == title { + return + } + } + + t.Fatalf("Expected refactoring %q to be present (disabled), but it was not found. Got: %v", title, actionTitles(actions)) +} + +func (f *FourslashTest) getRefactorActionsIncludingDisabled(t *testing.T) []*lsproto.CodeAction { + t.Helper() + + endPos := f.currentCaretPosition + if f.selectionEnd != nil { + endPos = *f.selectionEnd + } + + params := &lsproto.CodeActionParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Range: lsproto.Range{ + Start: f.currentCaretPosition, + End: endPos, + }, + Context: &lsproto.CodeActionContext{ + Diagnostics: []*lsproto.Diagnostic{}, + }, + } + + result := sendRequest(t, f, lsproto.TextDocumentCodeActionInfo, params) + + var actions []*lsproto.CodeAction + + if result.CommandOrCodeActionArray != nil { + for _, item := range *result.CommandOrCodeActionArray { + if item.CodeAction != nil && item.CodeAction.Kind != nil && + isRefactoringKind(*item.CodeAction.Kind) { + actions = append(actions, item.CodeAction) + } + } + } + + return actions +} + +// VerifyRefactorWithOnlyAvailable verifies that a refactoring action with the given title IS available +// when the given Only filter is sent in the request context. +func (f *FourslashTest) VerifyRefactorWithOnlyAvailable(t *testing.T, title string, only []lsproto.CodeActionKind) { + t.Helper() + actions := f.getRefactorActionsWithOnly(t, &only) + + for _, action := range actions { + if action.Title == title { + return + } + } + + t.Fatalf("Expected refactoring %q to be available with Only=%v, but it was not (got: %v)", title, only, actionTitles(actions)) +} + +// VerifyRefactorWithOnlyNotAvailable verifies that a refactoring action with the given title is NOT available +// when the given Only filter is sent in the request context. +func (f *FourslashTest) VerifyRefactorWithOnlyNotAvailable(t *testing.T, title string, only []lsproto.CodeActionKind) { + t.Helper() + actions := f.getRefactorActionsWithOnly(t, &only) + + for _, action := range actions { + if action.Title == title { + t.Fatalf("Expected refactoring %q to not be available with Only=%v, but it was", title, only) + } + } +} + +func actionTitles(actions []*lsproto.CodeAction) []string { + titles := make([]string, len(actions)) + for i, a := range actions { + titles[i] = a.Title + } + return titles +} + +func isRefactoringKind(kind lsproto.CodeActionKind) bool { + return kind == lsproto.CodeActionKindRefactor || + string(kind) == "refactor" || + strings.HasPrefix(string(kind), string(lsproto.CodeActionKindRefactor)+".") +} + func (f *FourslashTest) updateTextRangeForTextEdits(textRange core.TextRange, edits []*lsproto.TextEdit) core.TextRange { script := f.getScriptInfo(f.activeFilename) spans := make([]textEditSpan, 0, len(edits)) @@ -2014,7 +2286,7 @@ func (f *FourslashTest) updateTextRangeForTextEdits(textRange core.TextRange, ed // applyEditsToContent applies text edits to a content string without mutating the file. func (f *FourslashTest) applyEditsToContent(content string, edits []*lsproto.TextEdit) string { script := f.getScriptInfo(f.activeFilename) - slices.SortFunc(edits, func(a, b *lsproto.TextEdit) int { + slices.SortStableFunc(edits, func(a, b *lsproto.TextEdit) int { aStart := f.converters.LineAndCharacterToPosition(script, a.Range.Start) bStart := f.converters.LineAndCharacterToPosition(script, b.Range.Start) return int(aStart) - int(bStart) @@ -3869,7 +4141,7 @@ func (f *FourslashTest) getSelection() core.TextRange { // Updates f.currentCaretPosition func (f *FourslashTest) applyTextEdits(t *testing.T, edits []*lsproto.TextEdit) int { script := f.getScriptInfo(f.activeFilename) - slices.SortFunc(edits, func(a, b *lsproto.TextEdit) int { + slices.SortStableFunc(edits, func(a, b *lsproto.TextEdit) int { aStart := f.converters.LineAndCharacterToPosition(script, a.Range.Start) bStart := f.converters.LineAndCharacterToPosition(script, b.Range.Start) return int(aStart) - int(bStart) diff --git a/internal/ls/codeactions.go b/internal/ls/codeactions.go index d04f64c73d0..0b97e97bde8 100644 --- a/internal/ls/codeactions.go +++ b/internal/ls/codeactions.go @@ -37,10 +37,14 @@ type CodeFixContext struct { // CodeAction represents a single code action fix type CodeAction struct { + Kind lsproto.CodeActionKind Description string Changes []*lsproto.TextEdit FixID string FixAllDescription string + RenameFilename string + RenameLocation int + DisabledReason string } // Compare defines a total ordering for CodeAction values, comparing description @@ -154,6 +158,10 @@ func (l *LanguageService) ProvideCodeActions(ctx context.Context, params *lsprot actions = append(actions, fixAllActions...) } + if err := l.provideRefactorActions(ctx, params, program, file, &actions); err != nil { + return lsproto.CodeActionResponse{}, err + } + return lsproto.CommandOrCodeActionArrayOrNull{CommandOrCodeActionArray: &actions}, nil } diff --git a/internal/ls/codeactions_refactor.go b/internal/ls/codeactions_refactor.go new file mode 100644 index 00000000000..a88271563a8 --- /dev/null +++ b/internal/ls/codeactions_refactor.go @@ -0,0 +1,219 @@ +package ls + +import ( + "context" + "fmt" + "slices" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/ls/lsconv" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" +) + +// RefactorActionFactory is a function that produces refactoring code actions. +type RefactorActionFactory func(ctx context.Context, refactorContext *RefactorContext, refactorID string) ([]*CodeAction, error) + +// RefactorAction describes a single refactoring action offered by a RefactorProvider. +type RefactorAction struct { + ID string + Title string + Kinds []lsproto.CodeActionKind + Factory RefactorActionFactory +} + +// RefactorProvider provides refactoring code actions. +type RefactorProvider struct { + RefactorActions []RefactorAction +} + +// RefactorContext contains the context needed to generate refactoring actions. +type RefactorContext struct { + SourceFile *ast.SourceFile + Range core.TextRange + Program *compiler.Program + LS *LanguageService + Params *lsproto.CodeActionParams +} + +// CodeActionTriggerKind returns the trigger kind of the code action request, or nil when not provided. +func (c *RefactorContext) CodeActionTriggerKind() *lsproto.CodeActionTriggerKind { + if c.Params == nil || c.Params.Context == nil { + return nil + } + + return c.Params.Context.TriggerKind +} + +var refactorProviders = []*RefactorProvider{} + +// provideRefactorActions adds contextual (non-diagnostic-driven) refactoring code actions for the given range. +func (l *LanguageService) provideRefactorActions(ctx context.Context, params *lsproto.CodeActionParams, program *compiler.Program, file *ast.SourceFile, actions *[]lsproto.CommandOrCodeAction) error { + if params.Context == nil || !wantsRefactors(params.Context.Only) { + return nil + } + + refactorContext := &RefactorContext{ + SourceFile: file, + Range: core.NewTextRange( + int(l.converters.LineAndCharacterToPosition(file, params.Range.Start)), + int(l.converters.LineAndCharacterToPosition(file, params.Range.End)), + ), + Program: program, + LS: l, + Params: params, + } + + return l.provideRefactorActionsForProviders(ctx, refactorContext, params.Context.Only, actions) +} + +func (l *LanguageService) provideRefactorActionsForProviders(ctx context.Context, refactorContext *RefactorContext, only *[]lsproto.CodeActionKind, actions *[]lsproto.CommandOrCodeAction) error { + for _, provider := range refactorProviders { + for _, action := range provider.RefactorActions { + lspActions, err := l.convertRefactorAction(ctx, refactorContext, action, only) + if err != nil { + return err + } + + *actions = append(*actions, lspActions...) + } + } + + return nil +} + +func (l *LanguageService) convertRefactorAction(ctx context.Context, refactorContext *RefactorContext, action RefactorAction, only *[]lsproto.CodeActionKind) ([]lsproto.CommandOrCodeAction, error) { + if !refactorActionMatchesOnly(action, only) { + return nil, nil + } + + providerActions, err := action.Factory(ctx, refactorContext, action.ID) + if err != nil { + return nil, fmt.Errorf("refactoring action %q: %w", action.ID, err) + } + + showDisabled := showNotApplicableReasons(ctx, l) + file := refactorContext.SourceFile + uri := refactorContext.Params.TextDocument.Uri + + var lspActions []lsproto.CommandOrCodeAction + + for _, a := range providerActions { + if a.DisabledReason != "" && !showDisabled { + continue + } + + if a.RenameFilename != "" && lsconv.FileNameToDocumentURI(a.RenameFilename) != uri { + return nil, fmt.Errorf("refactoring action %q: rename target %q does not match requested document %q", action.ID, a.RenameFilename, uri) + } + + lspActions = append(lspActions, convertRefactorToLSPCodeAction(l, file, a, uri)) + } + + return lspActions, nil +} + +func showNotApplicableReasons(ctx context.Context, l *LanguageService) bool { + return l.UserPreferences().ProvideRefactorNotApplicableReason.IsTrue() && + lsproto.GetClientCapabilities(ctx).TextDocument.CodeAction.DisabledSupport +} + +func wantsRefactors(only *[]lsproto.CodeActionKind) bool { + if only == nil || len(*only) == 0 { + return true + } + + for _, kind := range *only { + if kind == lsproto.CodeActionKindEmpty || codeActionKindContains(lsproto.CodeActionKindRefactor, kind) { + return true + } + } + + return false +} + +func refactorActionMatchesOnly(action RefactorAction, only *[]lsproto.CodeActionKind) bool { + if only == nil || len(*only) == 0 { + return true + } + + for _, requestedKind := range *only { + for _, actionKind := range action.Kinds { + if codeActionKindContains(requestedKind, actionKind) { + return true + } + } + } + + return false +} + +func convertRefactorToLSPCodeAction(l *LanguageService, file *ast.SourceFile, action *CodeAction, uri lsproto.DocumentUri) lsproto.CommandOrCodeAction { + kind := action.Kind + if kind == "" { + kind = lsproto.CodeActionKindRefactorRewrite + } + + lspAction := &lsproto.CodeAction{ + Title: action.Description, + Kind: &kind, + } + + if action.DisabledReason != "" { + lspAction.Disabled = &lsproto.CodeActionDisabled{Reason: action.DisabledReason} + return lsproto.CommandOrCodeAction{CodeAction: lspAction} + } + + setRefactorEdit(l, file, action, uri, lspAction) + + return lsproto.CommandOrCodeAction{ + CodeAction: lspAction, + } +} + +func setRefactorEdit(l *LanguageService, file *ast.SourceFile, action *CodeAction, uri lsproto.DocumentUri, lspAction *lsproto.CodeAction) { + changes := map[lsproto.DocumentUri][]*lsproto.TextEdit{ + uri: action.Changes, + } + + lspAction.Edit = &lsproto.WorkspaceEdit{Changes: &changes} + + if action.RenameFilename != "" { + lspAction.Command = refactorToRenameCommand(l, file, action) + } +} + +func refactorToRenameCommand(l *LanguageService, file *ast.SourceFile, action *CodeAction) *lsproto.Command { + // The rename location is an offset into the file after the action's edits have been applied. + postEditText := applyTextEdits(file, action.Changes, l) + renamePosition := l.converters.PositionToLineAndCharacterForText(postEditText, core.TextPos(action.RenameLocation)) + + return &lsproto.Command{ + Title: "", + Command: "editor.action.rename", + Arguments: &[]any{ + []any{lsconv.FileNameToDocumentURI(action.RenameFilename), renamePosition}, + }, + } +} + +func applyTextEdits(file *ast.SourceFile, edits []*lsproto.TextEdit, l *LanguageService) string { + changes := make([]core.TextChange, 0, len(edits)) + + for _, edit := range edits { + changes = append(changes, core.TextChange{ + TextRange: core.NewTextRange( + int(l.converters.LineAndCharacterToPosition(file, edit.Range.Start)), + int(l.converters.LineAndCharacterToPosition(file, edit.Range.End)), + ), + NewText: edit.NewText, + }) + } + + slices.SortStableFunc(changes, func(a, b core.TextChange) int { + return a.Pos() - b.Pos() + }) + + return core.ApplyBulkEdits(file.Text(), changes) +} diff --git a/internal/ls/codeactions_refactor_test.go b/internal/ls/codeactions_refactor_test.go new file mode 100644 index 00000000000..c1883c0ae17 --- /dev/null +++ b/internal/ls/codeactions_refactor_test.go @@ -0,0 +1,348 @@ +package ls + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/ls/lsconv" + "github.com/microsoft/typescript-go/internal/ls/lsutil" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/parser" +) + +func TestConvertRefactorToLSPCodeAction_Rename(t *testing.T) { + t.Parallel() + + text := "const x: { a: number } = { a: 1 };\n" + sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ + FileName: "/index.ts", + Path: "/index.ts", + }, text, core.ScriptKindTS) + + converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF16, func(string) *lsconv.LSPLineMap { + return lsconv.ComputeLSPLineStarts(text) + }) + l := &LanguageService{converters: converters} + + action := &CodeAction{ + Description: "Extract to type alias", + Kind: lsproto.CodeActionKindRefactorExtract, + Changes: []*lsproto.TextEdit{ + {Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 0}, End: lsproto.Position{Line: 0, Character: 0}}, NewText: "type NewType = { a: number };\n"}, + {Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 10}, End: lsproto.Position{Line: 0, Character: 23}}, NewText: "NewType"}, + }, + RenameFilename: "/index.ts", + RenameLocation: 5, + } + + converted := convertRefactorToLSPCodeAction(l, sourceFile, action, "file:///index.ts") + codeAction := converted.CodeAction + + switch { + case codeAction.Command == nil: + t.Fatal("expected a rename command to be attached to the code action") + case codeAction.Command.Command != "editor.action.rename": + t.Errorf("expected command %q, got %q", "editor.action.rename", codeAction.Command.Command) + case codeAction.Command.Arguments == nil || len(*codeAction.Command.Arguments) != 1: + t.Fatalf("expected a single argument, got %v", codeAction.Command.Arguments) + } + + args, ok := (*codeAction.Command.Arguments)[0].([]any) + if !ok || len(args) != 2 { + t.Fatalf("expected argument to be [uri, position], got %v", (*codeAction.Command.Arguments)[0]) + } + if got, ok := args[0].(lsproto.DocumentUri); !ok || got != "file:///index.ts" { + t.Errorf("expected rename uri %q, got %v", "file:///index.ts", args[0]) + } + + expectedPos := lsproto.Position{Line: 0, Character: 5} + if args[1] != expectedPos { + t.Errorf("expected rename position %v, got %v", expectedPos, args[1]) + } +} + +func TestConvertRefactorToLSPCodeAction_NoRename(t *testing.T) { + t.Parallel() + + text := "const x: { a: number } = { a: 1 };\n" + sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ + FileName: "/index.ts", + Path: "/index.ts", + }, text, core.ScriptKindTS) + + converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF16, func(string) *lsconv.LSPLineMap { + return lsconv.ComputeLSPLineStarts(text) + }) + l := &LanguageService{converters: converters} + + action := &CodeAction{ + Description: "Infer return type", + Changes: []*lsproto.TextEdit{ + {Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 10}, End: lsproto.Position{Line: 0, Character: 23}}, NewText: "NewType"}, + }, + } + + converted := convertRefactorToLSPCodeAction(l, sourceFile, action, "file:///index.ts") + codeAction := converted.CodeAction + switch { + case codeAction.Command != nil: + t.Errorf("expected no command, got %v", codeAction.Command) + case codeAction.Edit == nil: + t.Fatal("expected an edit to be attached to the code action") + } +} + +func TestConvertRefactorToLSPCodeAction_Disabled(t *testing.T) { + t.Parallel() + + text := "const x: { a: number } = { a: 1 };\n" + sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ + FileName: "/index.ts", + Path: "/index.ts", + }, text, core.ScriptKindTS) + + converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF16, func(string) *lsconv.LSPLineMap { + return lsconv.ComputeLSPLineStarts(text) + }) + l := &LanguageService{converters: converters} + + action := &CodeAction{ + Description: "Extract to type alias", + DisabledReason: "Selection is not a valid type node", + RenameFilename: "/index.ts", + RenameLocation: 5, + } + + converted := convertRefactorToLSPCodeAction(l, sourceFile, action, "file:///index.ts") + codeAction := converted.CodeAction + + switch { + case codeAction.Disabled == nil || codeAction.Disabled.Reason != "Selection is not a valid type node": + t.Errorf("expected disabled reason, got %v", codeAction.Disabled) + case codeAction.Edit != nil: + t.Errorf("expected no edit for a disabled action, got %v", codeAction.Edit) + case codeAction.Command != nil: + t.Errorf("expected no rename command for a disabled action, got %v", codeAction.Command) + } +} + +var errRefactorFactoryFailed = errors.New("refactor factory failed") + +func newRefactorPipelineTestLS(t *testing.T, text string) (*LanguageService, *ast.SourceFile) { + t.Helper() + + sourceFile := parser.ParseSourceFile(ast.SourceFileParseOptions{ + FileName: "/index.ts", + Path: "/index.ts", + }, text, core.ScriptKindTS) + converters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF16, func(string) *lsconv.LSPLineMap { + return lsconv.ComputeLSPLineStarts(text) + }) + + return &LanguageService{converters: converters, activeConfig: lsutil.NewDefaultUserPreferences()}, sourceFile +} + +func setRefactorProviders(t *testing.T, providers []*RefactorProvider) { + t.Helper() + + old := refactorProviders + + refactorProviders = providers + + t.Cleanup(func() { refactorProviders = old }) +} + +func refactorPipelineParams() *RefactorContext { + return &RefactorContext{ + SourceFile: nil, + Params: &lsproto.CodeActionParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: "file:///index.ts"}, + Context: &lsproto.CodeActionContext{}, + }, + } +} + +func TestProvideRefactorActionsForProviders_OnlyFilter(t *testing.T) { + text := "const x: { a: number } = { a: 1 };\n" + l, sourceFile := newRefactorPipelineTestLS(t, text) + refactorContext := refactorPipelineParams() + + refactorContext.SourceFile = sourceFile + + extractFactoryCalled := false + + setRefactorProviders(t, []*RefactorProvider{{ + RefactorActions: []RefactorAction{ + { + ID: "extract-type", + Title: "Extract type", + Kinds: []lsproto.CodeActionKind{lsproto.CodeActionKindRefactorExtract}, + Factory: func(ctx context.Context, refactorContext *RefactorContext, refactorID string) ([]*CodeAction, error) { + extractFactoryCalled = true + if refactorID != "extract-type" { + t.Errorf("expected refactorID %q, got %q", "extract-type", refactorID) + } + return []*CodeAction{ + {Description: "Extract to type alias", Changes: []*lsproto.TextEdit{{ + Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 10}, End: lsproto.Position{Line: 0, Character: 23}}, + NewText: "NewType", + }}}, + }, nil + }, + }, + { + ID: "infer-return-type", + Title: "Infer return type", + Kinds: []lsproto.CodeActionKind{lsproto.CodeActionKindRefactorRewrite}, + Factory: func(ctx context.Context, refactorContext *RefactorContext, refactorID string) ([]*CodeAction, error) { + t.Errorf("factory for %q should not have been called", refactorID) + return nil, nil + }, + }, + }, + }}) + + only := &[]lsproto.CodeActionKind{lsproto.CodeActionKindRefactorExtract} + + var actions []lsproto.CommandOrCodeAction + + if err := l.provideRefactorActionsForProviders(context.Background(), refactorContext, only, &actions); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !extractFactoryCalled { + t.Fatal("expected the matching refactor factory to be called") + } + if len(actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(actions)) + } + if got := actions[0].CodeAction.Title; got != "Extract to type alias" { + t.Errorf("expected title %q, got %q", "Extract to type alias", got) + } +} + +func TestProvideRefactorActionsForProviders_DisabledGating(t *testing.T) { + text := "const x: { a: number } = { a: 1 };\n" + l, sourceFile := newRefactorPipelineTestLS(t, text) + refactorContext := refactorPipelineParams() + + refactorContext.SourceFile = sourceFile + + setRefactorProviders(t, []*RefactorProvider{{ + RefactorActions: []RefactorAction{{ + ID: "extract-type", + Title: "Extract type", + Kinds: []lsproto.CodeActionKind{lsproto.CodeActionKindRefactorExtract}, + Factory: func(ctx context.Context, refactorContext *RefactorContext, refactorID string) ([]*CodeAction, error) { + return []*CodeAction{ + {Description: "Extract to type alias", Changes: []*lsproto.TextEdit{{ + Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 10}, End: lsproto.Position{Line: 0, Character: 23}}, + NewText: "NewType", + }}}, + {Description: "Extract to type alias", DisabledReason: "Selection is not a valid type node"}, + }, nil + }, + }}, + }}) + + only := &[]lsproto.CodeActionKind{lsproto.CodeActionKindRefactorExtract} + disabledCtx := lsproto.WithClientCapabilities(context.Background(), &lsproto.ResolvedClientCapabilities{ + TextDocument: lsproto.ResolvedTextDocumentClientCapabilities{ + CodeAction: lsproto.ResolvedCodeActionClientCapabilities{DisabledSupport: true}, + }, + }) + + var actions []lsproto.CommandOrCodeAction + if err := l.provideRefactorActionsForProviders(disabledCtx, refactorContext, only, &actions); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(actions) != 2 { + t.Fatalf("expected 2 actions when the client supports disabled reasons, got %d", len(actions)) + } + if actions[1].CodeAction.Disabled == nil { + t.Fatal("expected the disabled action to carry a Disabled reason") + } + + actions = nil + if err := l.provideRefactorActionsForProviders(context.Background(), refactorContext, only, &actions); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(actions) != 1 { + t.Fatalf("expected 1 action when the client does not support disabled reasons, got %d", len(actions)) + } + + l.activeConfig.ProvideRefactorNotApplicableReason = core.TSFalse + actions = nil + if err := l.provideRefactorActionsForProviders(disabledCtx, refactorContext, only, &actions); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(actions) != 1 { + t.Fatalf("expected 1 action when the preference is disabled, got %d", len(actions)) + } +} + +func TestProvideRefactorActionsForProviders_FactoryError(t *testing.T) { + text := "const x: { a: number } = { a: 1 };\n" + l, sourceFile := newRefactorPipelineTestLS(t, text) + refactorContext := refactorPipelineParams() + + refactorContext.SourceFile = sourceFile + + setRefactorProviders(t, []*RefactorProvider{{ + RefactorActions: []RefactorAction{{ + ID: "extract-type", + Title: "Extract type", + Kinds: []lsproto.CodeActionKind{lsproto.CodeActionKindRefactorExtract}, + Factory: func(ctx context.Context, refactorContext *RefactorContext, refactorID string) ([]*CodeAction, error) { + return nil, errRefactorFactoryFailed + }, + }}, + }}) + + var actions []lsproto.CommandOrCodeAction + + err := l.provideRefactorActionsForProviders(context.Background(), refactorContext, nil, &actions) + if !errors.Is(err, errRefactorFactoryFailed) { + t.Fatalf("expected factory error, got %v", err) + } + if !strings.Contains(err.Error(), "extract-type") { + t.Errorf("expected error to reference the action ID, got %v", err) + } +} + +func TestProvideRefactorActionsForProviders_RejectsCrossFileRename(t *testing.T) { + text := "const x: { a: number } = { a: 1 };\n" + l, sourceFile := newRefactorPipelineTestLS(t, text) + refactorContext := refactorPipelineParams() + + refactorContext.SourceFile = sourceFile + + setRefactorProviders(t, []*RefactorProvider{{ + RefactorActions: []RefactorAction{{ + ID: "extract-type", + Title: "Extract type", + Kinds: []lsproto.CodeActionKind{lsproto.CodeActionKindRefactorExtract}, + Factory: func(ctx context.Context, refactorContext *RefactorContext, refactorID string) ([]*CodeAction, error) { + return []*CodeAction{{ + Description: "Extract to type alias", + Changes: []*lsproto.TextEdit{{Range: lsproto.Range{Start: lsproto.Position{Line: 0, Character: 10}, End: lsproto.Position{Line: 0, Character: 23}}, NewText: "NewType"}}, + RenameFilename: "/other.ts", + RenameLocation: 5, + }}, nil + }, + }}, + }}) + + var actions []lsproto.CommandOrCodeAction + + err := l.provideRefactorActionsForProviders(context.Background(), refactorContext, nil, &actions) + if err == nil { + t.Fatal("expected a cross-file rename target to be rejected") + } + if !strings.Contains(err.Error(), "other.ts") { + t.Errorf("expected error to reference the rename target, got %v", err) + } +} diff --git a/internal/ls/lsconv/converters.go b/internal/ls/lsconv/converters.go index 0ff39bea16a..e1ed5184b3d 100644 --- a/internal/ls/lsconv/converters.go +++ b/internal/ls/lsconv/converters.go @@ -192,11 +192,13 @@ func (c *Converters) LineAndCharacterToPosition(script Script, lineAndCharacter } func (c *Converters) PositionToLineAndCharacter(script Script, position core.TextPos) lsproto.Position { - // UTF-8 offset to UTF-8/16 0-indexed line and character + return c.positionToLineAndCharacter(script.Text(), c.getLineMap(script.FileName()), position) +} - position = max(0, min(position, core.TextPos(len(script.Text())))) +func (c *Converters) positionToLineAndCharacter(text string, lineMap *LSPLineMap, position core.TextPos) lsproto.Position { + // UTF-8 offset to UTF-8/16 0-indexed line and character - lineMap := c.getLineMap(script.FileName()) + position = max(0, min(position, core.TextPos(len(text)))) line, isLineStart := slices.BinarySearch(lineMap.LineStarts, position) if !isLineStart { @@ -213,7 +215,7 @@ func (c *Converters) PositionToLineAndCharacter(script Script, position core.Tex character = position - start } else { // We need to rescan the text as UTF-16 to find the character offset. - for _, r := range script.Text()[start:position] { + for _, r := range text[start:position] { character += core.TextPos(utf16.RuneLen(r)) } } @@ -224,6 +226,12 @@ func (c *Converters) PositionToLineAndCharacter(script Script, position core.Tex } } +// PositionToLineAndCharacterForText converts an offset in the given text to an LSP position using this Converters' position encoding. +// Unlike PositionToLineAndCharacter, it does not require the text to correspond to a file registered in the line map. +func (c *Converters) PositionToLineAndCharacterForText(text string, position core.TextPos) lsproto.Position { + return c.positionToLineAndCharacter(text, ComputeLSPLineStarts(text), position) +} + type diagnosticOptions struct { reportStyleChecksAsWarnings bool relatedInformation bool diff --git a/internal/lsp/server.go b/internal/lsp/server.go index f04c2c4f9b4..14ec61c9608 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1190,6 +1190,7 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ lsproto.CodeActionKindSourceRemoveUnusedImports, lsproto.CodeActionKindSourceSortImports, lsproto.CodeActionKindSourceFixAll, + lsproto.CodeActionKindRefactor, }, }, },