Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions internal/fourslash/tests/manual/prepareRenameKeyword_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package fourslash_test

import (
"testing"

"github.com/microsoft/typescript-go/internal/fourslash"
"github.com/microsoft/typescript-go/internal/testutil"
)

func TestPrepareRenameKeyword(t *testing.T) {
t.Parallel()

defer testutil.RecoverAndFail(t, "Panic on fourslash test")
const content = `
class /*1*/Foo {
method() {}
}

const x = /*2*/(1 + 2);

function /*3*/bar() {}
`
f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content)
defer done()

// Test 1: Rename on 'class' keyword should work (adjusts to class name)
// Note: We can't easily test the class keyword position in fourslash,
// but we can test that renaming on the class name works
f.GoToMarker(t, "1")
f.VerifyRenameSucceeded(t, nil /*preferences*/)

// Test 2: Rename on paren should fail
f.GoToMarker(t, "2")
f.VerifyRenameFailed(t, nil /*preferences*/)

// Test 3: Rename on 'function' keyword should work (adjusts to function name)
f.GoToMarker(t, "3")
f.VerifyRenameSucceeded(t, nil /*preferences*/)
}
69 changes: 67 additions & 2 deletions internal/ls/findallreferences.go
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,15 @@ func (l *LanguageService) ProvideSymbolsAndEntries(ctx context.Context, uri lspr
position := int(l.converters.LineAndCharacterToPosition(sourceFile, documentPosition))

node := astnav.GetTouchingPropertyName(sourceFile, position)
if isRename && node.Kind != ast.KindIdentifier {
return node, nil, false

// For rename, check eligibility after adjusting location
// Note: getReferencedSymbolsForNode will also call getAdjustedLocation, but we need
// to check eligibility here first to fail fast
if isRename {
adjustedNode := getAdjustedLocation(node, true /*forRename*/, sourceFile)
if !nodeIsEligibleForRename(adjustedNode) {
return adjustedNode, nil, false
}
}

var options refOptions
Expand Down Expand Up @@ -679,6 +686,64 @@ func (l *LanguageService) ProvideRenameFromSymbolAndEntries(ctx context.Context,
}, nil
}

// ProvidePrepareRename provides information for rename preparation.
// This checks if the location is eligible for renaming and returns the range and placeholder.
func (l *LanguageService) ProvidePrepareRename(ctx context.Context, uri lsproto.DocumentUri, documentPosition lsproto.Position) (lsproto.PrepareRenameResponse, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot

textDocument/prepareRename is an optional feature in the LSP.

Can we not just do this within handleRename by returning an error there?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue shows a tooltip that appears when hovering/triggering rename, which comes from prepareRename. If we return an error from handleRename instead, users would only see the error AFTER attempting the rename, not as an early warning.

However, I can simplify the implementation to avoid the test regressions. The current test failures suggest that the validation logic needs to match TypeScript's more closely, which is complex. Would you prefer:

  1. Keep prepareRename but simplify the validation logic
  2. Return errors from handleRename and accept the different UX (error notification vs tooltip)

Let me know your preference and I'll adjust accordingly.

program, sourceFile := l.getProgramAndFile(uri)
position := int(l.converters.LineAndCharacterToPosition(sourceFile, documentPosition))

// Get the node at the position (can include keywords)
node := astnav.GetTouchingPropertyName(sourceFile, position)

// Adjust the location for rename (e.g., from 'class' keyword to class name)
adjustedNode := getAdjustedLocation(node, true /*forRename*/, sourceFile)

// Check if the adjusted node is eligible for rename
if !nodeIsEligibleForRename(adjustedNode) {
// Return null to indicate rename is not available
return lsproto.PrepareRenameResponse{}, nil
}

// Get the symbol to validate this is renameable
checker, done := program.GetTypeChecker(ctx)
defer done()
symbol := checker.GetSymbolAtLocation(adjustedNode)

// Check special cases like string literals and labels
if symbol == nil {
if ast.IsStringLiteralLike(adjustedNode) {
// For string literals, we could check contextual type, but for now
// we allow rename if nodeIsEligibleForRename already passed
textRange := core.NewTextRange(adjustedNode.Pos(), adjustedNode.End())
lspRange := l.converters.ToLSPRange(sourceFile, textRange)
return lsproto.PrepareRenameResponse{
Range: &lspRange,
}, nil
} else if ast.IsLabelName(adjustedNode) {
// Allow rename for labels
textRange := core.NewTextRange(adjustedNode.Pos(), adjustedNode.End())
lspRange := l.converters.ToLSPRange(sourceFile, textRange)
return lsproto.PrepareRenameResponse{
Range: &lspRange,
}, nil
}
// No symbol and not a special case - cannot rename
return lsproto.PrepareRenameResponse{}, nil
}

// Check if symbol has declarations
if symbol.Declarations == nil || len(symbol.Declarations) == 0 {
return lsproto.PrepareRenameResponse{}, nil
}

// Return the range for renaming
textRange := core.NewTextRange(adjustedNode.Pos(), adjustedNode.End())
lspRange := l.converters.ToLSPRange(sourceFile, textRange)
return lsproto.PrepareRenameResponse{
Range: &lspRange,
}, nil
}

func (l *LanguageService) getTextForRename(originalNode *ast.Node, entry *ReferenceEntry, newText string, checker *checker.Checker) string {
if entry.kind != entryKindRange && (ast.IsIdentifier(originalNode) || ast.IsStringLiteralLike(originalNode)) {
node := entry.node
Expand Down
42 changes: 42 additions & 0 deletions internal/ls/utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,48 @@ func getAdjustedLocationForExportDeclaration(node *ast.ExportDeclaration, forRen
return nil
}

// nodeIsEligibleForRename checks if a node can be renamed.
// This matches the TypeScript implementation in services/rename.ts
func nodeIsEligibleForRename(node *ast.Node) bool {
switch node.Kind {
case ast.KindIdentifier,
ast.KindPrivateIdentifier,
ast.KindStringLiteral,
ast.KindNoSubstitutionTemplateLiteral,
ast.KindThisKeyword:
return true
case ast.KindNumericLiteral:
// For numeric literals, check if they're used as property names
parent := node.Parent
if parent == nil {
return false
}
switch parent.Kind {
case ast.KindPropertyDeclaration,
ast.KindPropertySignature,
ast.KindPropertyAssignment,
ast.KindEnumMember,
ast.KindMethodDeclaration,
ast.KindMethodSignature,
ast.KindGetAccessor,
ast.KindSetAccessor,
ast.KindModuleDeclaration:
return parent.Name() == node
case ast.KindElementAccessExpression:
return parent.AsElementAccessExpression().ArgumentExpression == node
case ast.KindComputedPropertyName:
return true
case ast.KindLiteralType:
grandParent := parent.Parent
return grandParent != nil && grandParent.Kind == ast.KindIndexedAccessType
default:
return false
}
default:
return false
}
}

func symbolFlagsHaveMeaning(flags ast.SymbolFlags, meaning ast.SemanticMeaning) bool {
if meaning == ast.SemanticMeaningAll {
return true
Expand Down
9 changes: 8 additions & 1 deletion internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,6 +555,7 @@ var handlers = sync.OnceValue(func() handlerMap {

registerMultiProjectReferenceRequestHandler(handlers, lsproto.TextDocumentReferencesInfo, (*Server).handleReferences, combineReferences)
registerMultiProjectReferenceRequestHandler(handlers, lsproto.TextDocumentRenameInfo, (*Server).handleRename, combineRenameResponse)
registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentPrepareRenameInfo, (*Server).handlePrepareRename)

registerRequestHandler(handlers, lsproto.CallHierarchyIncomingCallsInfo, (*Server).handleCallHierarchyIncomingCalls)
registerRequestHandler(handlers, lsproto.CallHierarchyOutgoingCallsInfo, (*Server).handleCallHierarchyOutgoingCalls)
Expand Down Expand Up @@ -979,7 +980,9 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ
Boolean: ptrTo(true),
},
RenameProvider: &lsproto.BooleanOrRenameOptions{
Boolean: ptrTo(true),
RenameOptions: &lsproto.RenameOptions{
PrepareProvider: ptrTo(true),
},
},
DocumentHighlightProvider: &lsproto.BooleanOrDocumentHighlightOptions{
Boolean: ptrTo(true),
Expand Down Expand Up @@ -1282,6 +1285,10 @@ func (s *Server) handleRename(ctx context.Context, ls *ls.LanguageService, param
return ls.ProvideRenameFromSymbolAndEntries(ctx, params, originalNode, symbolAndEntries)
}

func (s *Server) handlePrepareRename(ctx context.Context, ls *ls.LanguageService, params *lsproto.PrepareRenameParams) (lsproto.PrepareRenameResponse, error) {
return ls.ProvidePrepareRename(ctx, params.TextDocument.Uri, params.Position)
}

func combineRenameResponse(results iter.Seq[lsproto.RenameResponse]) lsproto.RenameResponse {
combined := make(map[lsproto.DocumentUri][]*lsproto.TextEdit)
seenChanges := make(map[lsproto.DocumentUri]*collections.Set[lsproto.Range])
Expand Down