380 lines
15 KiB
Go
380 lines
15 KiB
Go
package ls
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/microsoft/typescript-go/internal/ast"
|
|
"github.com/microsoft/typescript-go/internal/astnav"
|
|
"github.com/microsoft/typescript-go/internal/checker"
|
|
"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/ls/lsutil"
|
|
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
|
|
"github.com/microsoft/typescript-go/internal/module"
|
|
"github.com/microsoft/typescript-go/internal/tspath"
|
|
)
|
|
|
|
// RenameInfo represents the result of a rename validation check.
|
|
// It is used by the `textDocument/prepareRename` LSP handler.
|
|
type RenameInfo struct {
|
|
CanRename bool
|
|
LocalizedErrorMessage string
|
|
DisplayName string
|
|
TriggerSpan lsproto.Range
|
|
FileToRename string
|
|
NewFileName string
|
|
}
|
|
|
|
func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.RenameParams, orchestrator CrossProjectOrchestrator) (lsproto.WorkspaceEditOrNull, error) {
|
|
return handleCrossProject(
|
|
l,
|
|
ctx,
|
|
params,
|
|
orchestrator,
|
|
(*LanguageService).symbolAndEntriesToRename,
|
|
combineRenameResponse,
|
|
true, /*isRename*/
|
|
false, /*implementations*/
|
|
symbolEntryTransformOptions{},
|
|
)
|
|
}
|
|
|
|
func (l *LanguageService) GetRenameInfo(ctx context.Context, newName string, documentURI lsproto.DocumentUri, position lsproto.Position) RenameInfo {
|
|
program, sourceFile := l.getProgramAndFile(documentURI)
|
|
pos := int(l.converters.LineAndCharacterToPosition(sourceFile, position))
|
|
|
|
node := astnav.GetTouchingPropertyName(sourceFile, pos)
|
|
node = getAdjustedLocation(node, true /*forRename*/, sourceFile)
|
|
|
|
if nodeIsEligibleForRename(node) {
|
|
if renameInfo, ok := l.getRenameInfoForNode(ctx, newName, node, sourceFile, program); ok {
|
|
return renameInfo
|
|
}
|
|
}
|
|
return getRenameInfoError(ctx, diagnostics.You_cannot_rename_this_element)
|
|
}
|
|
|
|
func (l *LanguageService) symbolAndEntriesToRename(ctx context.Context, params *lsproto.RenameParams, data SymbolAndEntriesData, options symbolEntryTransformOptions) (lsproto.WorkspaceEditOrNull, error) {
|
|
if !nodeIsEligibleForRename(data.OriginalNode) {
|
|
return lsproto.WorkspaceEditOrNull{}, nil
|
|
}
|
|
|
|
program := l.GetProgram()
|
|
|
|
// Defense-in-depth: validate rename eligibility even if the client skipped prepareRename.
|
|
// Use getRenameInfoForNode directly with the already-resolved node to avoid
|
|
// re-resolving the position and polluting state baselines.
|
|
sourceFile := ast.GetSourceFileOfNode(data.OriginalNode)
|
|
if info, ok := l.getRenameInfoForNode(ctx, params.NewName, data.OriginalNode, sourceFile, program); !ok || !info.CanRename {
|
|
return lsproto.WorkspaceEditOrNull{}, nil
|
|
}
|
|
|
|
entries := core.FlatMap(data.SymbolsAndEntries, func(s *SymbolAndEntries) []*ReferenceEntry { return s.references })
|
|
changes := make(map[lsproto.DocumentUri][]*lsproto.TextEdit)
|
|
ch, done := program.GetTypeChecker(ctx)
|
|
defer done()
|
|
|
|
quotePreference := lsutil.GetQuotePreference(sourceFile, l.UserPreferences())
|
|
useAliasesForRename := l.UserPreferences().UseAliasesForRename.IsTrueOrUnknown()
|
|
|
|
for _, entry := range entries {
|
|
uri := l.getFileNameOfEntry(entry)
|
|
if l.UserPreferences().AllowRenameOfImportPath != core.TSTrue && entry.node != nil && ast.IsStringLiteralLike(entry.node) && ast.TryGetImportFromModuleSpecifier(entry.node) != nil {
|
|
continue
|
|
}
|
|
textEdit := &lsproto.TextEdit{
|
|
Range: l.getRangeOfEntry(entry),
|
|
NewText: l.getTextForRename(data.OriginalNode, entry, params.NewName, ch, quotePreference, useAliasesForRename),
|
|
}
|
|
changes[uri] = append(changes[uri], textEdit)
|
|
}
|
|
return lsproto.WorkspaceEditOrNull{
|
|
WorkspaceEdit: &lsproto.WorkspaceEdit{
|
|
Changes: &changes,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// getRenameInfoForNode performs detailed validation for a rename operation on a specific node.
|
|
func (l *LanguageService) getRenameInfoForNode(ctx context.Context, newName string, node *ast.Node, sourceFile *ast.SourceFile, program *compiler.Program) (RenameInfo, bool) {
|
|
ch, done := program.GetTypeChecker(ctx)
|
|
defer done()
|
|
|
|
symbol := ch.GetSymbolAtLocation(node)
|
|
if symbol == nil {
|
|
if ast.IsStringLiteralLike(node) {
|
|
// Allow renaming of string literal types with contextual string literal types
|
|
typ := getContextualTypeFromParentOrAncestorTypeNode(node, ch)
|
|
if typ != nil && (typ.IsStringLiteral() ||
|
|
(typ.IsUnion() && core.Every(typ.Types(), func(t *checker.Type) bool {
|
|
return t.IsStringLiteral()
|
|
}))) {
|
|
return getRenameInfoSuccess(node, sourceFile, node.Text(), l.converters), true
|
|
}
|
|
} else if ast.IsLabelName(node) {
|
|
name := node.Text()
|
|
return getRenameInfoSuccess(node, sourceFile, name, l.converters), true
|
|
}
|
|
return RenameInfo{}, false
|
|
}
|
|
|
|
// Only allow a symbol to be renamed if it actually has at least one declaration.
|
|
if len(symbol.Declarations) == 0 {
|
|
return RenameInfo{}, false
|
|
}
|
|
|
|
if msg := l.renameBlockedReason(sourceFile, node, symbol, ch, program); msg != nil {
|
|
return getRenameInfoError(ctx, msg), true
|
|
}
|
|
|
|
if ast.IsStringLiteralLike(node) && ast.TryGetImportFromModuleSpecifier(node) != nil {
|
|
if l.UserPreferences().AllowRenameOfImportPath.IsTrue() {
|
|
return l.getRenameInfoForModule(ctx, newName, node, sourceFile, symbol)
|
|
}
|
|
return RenameInfo{}, false
|
|
}
|
|
|
|
return getRenameInfoSuccess(node, sourceFile, ch.SymbolToString(symbol), l.converters), true
|
|
}
|
|
|
|
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:
|
|
return isLiteralNameOfPropertyDeclarationOrIndexAccess(node)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// renameBlockedReason returns a non-nil diagnostic message if the rename should be blocked
|
|
// because the symbol is a library definition, a default keyword, or would cross node_modules boundaries.
|
|
func (l *LanguageService) renameBlockedReason(sourceFile *ast.SourceFile, node *ast.Node, symbol *ast.Symbol, ch *checker.Checker, program *compiler.Program) *diagnostics.Message {
|
|
for _, declaration := range symbol.Declarations {
|
|
if isDefinedInLibraryFile(program, declaration) {
|
|
return diagnostics.You_cannot_rename_elements_that_are_defined_in_the_standard_TypeScript_library
|
|
}
|
|
}
|
|
|
|
// Cannot rename `default` as in `import { default as foo } from "./someModule"`
|
|
if ast.IsIdentifier(node) && node.Text() == "default" && symbol.Parent != nil && symbol.Parent.Flags&ast.SymbolFlagsModule != 0 {
|
|
return diagnostics.You_cannot_rename_this_element
|
|
}
|
|
|
|
if msg := wouldRenameInOtherNodeModules(sourceFile, symbol, ch, l.UserPreferences()); msg != nil {
|
|
return msg
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isDefinedInLibraryFile checks if a declaration is from a default library file (e.g., lib.d.ts).
|
|
func isDefinedInLibraryFile(program *compiler.Program, declaration *ast.Node) bool {
|
|
declSourceFile := ast.GetSourceFileOfNode(declaration)
|
|
return program.IsSourceFileDefaultLibrary(declSourceFile.Path()) && tspath.IsDeclarationFileName(declSourceFile.FileName())
|
|
}
|
|
|
|
// wouldRenameInOtherNodeModules checks if renaming the symbol would affect node_modules.
|
|
func wouldRenameInOtherNodeModules(originalFile *ast.SourceFile, symbol *ast.Symbol, ch *checker.Checker, preferences lsutil.UserPreferences) *diagnostics.Message {
|
|
sym := symbol
|
|
if !preferences.UseAliasesForRename.IsTrueOrUnknown() && sym.Flags&ast.SymbolFlagsAlias != 0 {
|
|
importSpecifier := core.Find(sym.Declarations, ast.IsImportSpecifier)
|
|
if importSpecifier != nil && importSpecifier.AsImportSpecifier().PropertyName == nil {
|
|
sym = ch.GetAliasedSymbol(sym)
|
|
}
|
|
}
|
|
|
|
declarations := sym.Declarations
|
|
if len(declarations) == 0 {
|
|
return nil
|
|
}
|
|
|
|
originalPackage := module.ParseNodeModuleFromPath(originalFile.FileName(), false /*isFolder*/)
|
|
if originalPackage == "" {
|
|
// Original source file is not in node_modules.
|
|
for _, declaration := range declarations {
|
|
if isInsideNodeModules(ast.GetSourceFileOfNode(declaration).FileName()) {
|
|
return diagnostics.You_cannot_rename_elements_that_are_defined_in_a_node_modules_folder
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Original source file is in node_modules.
|
|
for _, declaration := range declarations {
|
|
declPackage := module.ParseNodeModuleFromPath(ast.GetSourceFileOfNode(declaration).FileName(), false /*isFolder*/)
|
|
if declPackage != "" && declPackage != originalPackage {
|
|
return diagnostics.You_cannot_rename_elements_that_are_defined_in_another_node_modules_folder
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ClientSupportsWillRenameFiles(ctx context.Context) bool {
|
|
return lsproto.GetClientCapabilities(ctx).Workspace.FileOperations.WillRename
|
|
}
|
|
|
|
func ClientSupportsDocumentChanges(ctx context.Context) bool {
|
|
return lsproto.GetClientCapabilities(ctx).Workspace.WorkspaceEdit.DocumentChanges
|
|
}
|
|
|
|
func ClientSupportsRenameResourceOperations(ctx context.Context) bool {
|
|
return slices.Contains(lsproto.GetClientCapabilities(ctx).Workspace.WorkspaceEdit.ResourceOperations, lsproto.ResourceOperationKindRename)
|
|
}
|
|
|
|
// getRenameInfoForModule handles rename validation for module specifiers.
|
|
func (l *LanguageService) getRenameInfoForModule(ctx context.Context, newName string, specifier *ast.StringLiteralLike, sourceFile *ast.SourceFile, moduleSymbol *ast.Symbol) (RenameInfo, bool) {
|
|
if !tspath.IsExternalModuleNameRelative(specifier.Text()) {
|
|
return getRenameInfoError(ctx, diagnostics.You_cannot_rename_a_module_via_a_global_import), true
|
|
}
|
|
if !ClientSupportsDocumentChanges(ctx) || !ClientSupportsRenameResourceOperations(ctx) {
|
|
return getRenameInfoError(ctx, diagnostics.File_rename_is_not_supported_by_the_editor), true
|
|
}
|
|
|
|
moduleSourceFile := core.Find(moduleSymbol.Declarations, ast.IsSourceFile)
|
|
if moduleSourceFile == nil {
|
|
return RenameInfo{}, false
|
|
}
|
|
|
|
fileName := moduleSourceFile.AsSourceFile().FileName()
|
|
withoutIndex := ""
|
|
if !strings.HasSuffix(specifier.Text(), "/index") && !strings.HasSuffix(specifier.Text(), "/index.js") {
|
|
candidate := tspath.RemoveFileExtension(fileName)
|
|
if trimmed, ok := strings.CutSuffix(candidate, "/index"); ok {
|
|
withoutIndex = trimmed
|
|
}
|
|
}
|
|
|
|
displayName := fileName
|
|
if withoutIndex != "" {
|
|
displayName = withoutIndex
|
|
}
|
|
newFileName := l.getNewFileNameForModuleRename(displayName, specifier.Text(), newName)
|
|
|
|
// Span should only be the last component of the path. + 1 to account for the quote character.
|
|
indexAfterLastSlash := strings.LastIndex(specifier.Text(), "/") + 1
|
|
start := specifier.Pos() + 1 + indexAfterLastSlash
|
|
length := len(specifier.Text()) - indexAfterLastSlash
|
|
|
|
return RenameInfo{
|
|
CanRename: true,
|
|
DisplayName: specifier.Text()[indexAfterLastSlash:],
|
|
TriggerSpan: l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)),
|
|
FileToRename: displayName,
|
|
NewFileName: newFileName,
|
|
}, true
|
|
}
|
|
|
|
// Adjust the new name based on the old path that an import specifier resolves to.
|
|
// For example, if specifier "a.js" resolves to file a.ts, renaming "a.js" -> "b.js" should mean file rename a.ts -> b.ts.
|
|
func (l *LanguageService) getNewFileNameForModuleRename(oldPath, specifierText, newName string) string {
|
|
newPath := tspath.CombinePaths(tspath.GetDirectoryPath(oldPath), newName)
|
|
ignoreCase := !l.host.UseCaseSensitiveFileNames()
|
|
var oldExt string
|
|
if tspath.IsDeclarationFileName(oldPath) {
|
|
oldExt = tspath.GetDeclarationFileExtension(oldPath)
|
|
} else {
|
|
oldExt = tspath.GetAnyExtensionFromPath(oldPath, nil /*extensions*/, ignoreCase)
|
|
}
|
|
if !tspath.HasExtension(newPath) {
|
|
newPath = newPath + oldExt
|
|
} else if tspath.GetAnyExtensionFromPath(newPath, nil /*extensions*/, ignoreCase) == tspath.GetAnyExtensionFromPath(specifierText, nil /*extensions*/, ignoreCase) {
|
|
newPath = tspath.ChangeAnyExtension(newPath, oldExt, nil /*extensions*/, ignoreCase)
|
|
}
|
|
return newPath
|
|
}
|
|
|
|
func (l *LanguageService) getTextForRename(originalNode *ast.Node, entry *ReferenceEntry, newText string, ch *checker.Checker, quotePreference lsutil.QuotePreference, useAliasesForRename bool) string {
|
|
if useAliasesForRename && entry.kind != entryKindRange && (ast.IsIdentifier(originalNode) || ast.IsStringLiteralLike(originalNode)) {
|
|
node := ast.GetReparsedNodeForNode(entry.node)
|
|
kind := entry.kind
|
|
parent := node.Parent
|
|
name := originalNode.Text()
|
|
isShorthandAssignment := ast.IsShorthandPropertyAssignment(parent)
|
|
switch {
|
|
case isShorthandAssignment || (isObjectBindingElementWithoutPropertyName(parent) && parent.Name() == node && parent.AsBindingElement().DotDotDotToken == nil):
|
|
if kind == entryKindSearchedLocalFoundProperty {
|
|
return name + ": " + newText
|
|
}
|
|
if kind == entryKindSearchedPropertyFoundLocal {
|
|
return newText + ": " + name
|
|
}
|
|
// In `const o = { x }; o.x`, symbolAtLocation at `x` in `{ x }` is the property symbol.
|
|
// For a binding element `const { x } = o;`, symbolAtLocation at `x` is the property symbol.
|
|
if isShorthandAssignment {
|
|
grandParent := parent.Parent
|
|
if ast.IsObjectLiteralExpression(grandParent) && ast.IsBinaryExpression(grandParent.Parent) && ast.IsModuleExportsAccessExpression(grandParent.Parent.AsBinaryExpression().Left) {
|
|
return name + ": " + newText
|
|
}
|
|
return newText + ": " + name
|
|
}
|
|
return name + ": " + newText
|
|
case ast.IsImportSpecifier(parent) && parent.PropertyName() == nil:
|
|
// If the original symbol was using this alias, just rename the alias.
|
|
var originalSymbol *ast.Symbol
|
|
if ast.IsExportSpecifier(originalNode.Parent) {
|
|
originalSymbol = ch.GetExportSpecifierLocalTargetSymbol(originalNode.Parent)
|
|
} else {
|
|
originalSymbol = ch.GetSymbolAtLocation(originalNode)
|
|
}
|
|
if originalSymbol != nil && slices.Contains(originalSymbol.Declarations, parent) {
|
|
return name + " as " + newText
|
|
}
|
|
return newText
|
|
case ast.IsExportSpecifier(parent) && parent.PropertyName() == nil:
|
|
// If the symbol for the node is same as declared node symbol use prefix text
|
|
if originalNode == entry.node || ch.GetSymbolAtLocation(originalNode) == ch.GetSymbolAtLocation(entry.node) {
|
|
return name + " as " + newText
|
|
}
|
|
return newText + " as " + name
|
|
}
|
|
}
|
|
|
|
// If the node is a numerical indexing literal, then add quotes around the property access.
|
|
if entry.kind != entryKindRange && ast.IsNumericLiteral(entry.node) && ast.IsAccessExpression(entry.node.Parent) {
|
|
quote := getQuoteFromPreference(quotePreference)
|
|
return quote + newText + quote
|
|
}
|
|
|
|
return newText
|
|
}
|
|
|
|
func getQuoteFromPreference(quotePreference lsutil.QuotePreference) string {
|
|
if quotePreference == lsutil.QuotePreferenceSingle {
|
|
return "'"
|
|
}
|
|
return `"`
|
|
}
|
|
|
|
func getRenameInfoError(ctx context.Context, message *diagnostics.Message) RenameInfo {
|
|
return RenameInfo{
|
|
CanRename: false,
|
|
LocalizedErrorMessage: message.Localize(locale.FromContext(ctx)),
|
|
}
|
|
}
|
|
|
|
func getRenameInfoSuccess(node *ast.Node, sourceFile *ast.SourceFile, displayName string, converters *lsconv.Converters) RenameInfo {
|
|
start := astnav.GetStartOfNode(node, sourceFile, false /*includeJSDoc*/)
|
|
end := node.End()
|
|
if ast.IsStringLiteralLike(node) {
|
|
// Exclude the quotes
|
|
start++
|
|
end--
|
|
}
|
|
return RenameInfo{
|
|
CanRename: true,
|
|
DisplayName: displayName,
|
|
TriggerSpan: converters.ToLSPRange(sourceFile, core.NewTextRange(start, end)),
|
|
}
|
|
}
|