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

383 lines
15 KiB
Go

package ls
import (
"context"
"slices"
"github.com/microsoft/typescript-go/internal/ast"
"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/ls/change"
"github.com/microsoft/typescript-go/internal/ls/lsconv"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/modulespecifiers"
"github.com/microsoft/typescript-go/internal/scanner"
"github.com/microsoft/typescript-go/internal/tsoptions"
"github.com/microsoft/typescript-go/internal/tspath"
)
type pathUpdater func(path string) (string, bool)
type toImport struct {
newFileName string
updated bool
}
func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lsproto.DocumentUri, newURI lsproto.DocumentUri) []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile {
program := l.GetProgram()
oldPath := oldURI.FileName()
newPath := newURI.FileName()
oldToNew := l.createPathUpdater(oldPath, newPath)
changeTracker := change.NewTracker(ctx, program.Options(), l.FormatOptions(), l.converters)
l.updateTsconfigFiles(program, changeTracker, oldToNew, oldPath, newPath)
l.updateImportsForFileRename(program, changeTracker, oldToNew)
var documentChanges []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile
// When renaming e.g. `foo.d.css.ts` -> `bar.d.css.ts`, also rename `foo.css` -> `bar.css` if it exists.
if tspath.IsDeclarationFileName(oldPath) && tspath.IsDeclarationFileName(newPath) {
dtsExt := tspath.GetDeclarationFileExtension(oldPath)
originalExtensions := tspath.GetPossibleOriginalInputExtensionForExtension(dtsExt)
for _, ext := range originalExtensions {
oldOriginalPath := tspath.ChangeFullExtension(oldPath, ext)
if l.host.FileExists(oldOriginalPath) {
newDtsExt := tspath.GetDeclarationFileExtension(oldPath)
newOriginalExtensions := tspath.GetPossibleOriginalInputExtensionForExtension(newDtsExt)
if slices.Contains(newOriginalExtensions, ext) {
newOriginalPath := tspath.ChangeFullExtension(newPath, ext)
documentChanges = append(documentChanges, lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{
RenameFile: &lsproto.RenameFile{
OldUri: lsconv.FileNameToDocumentURI(oldOriginalPath),
NewUri: lsconv.FileNameToDocumentURI(newOriginalPath),
},
})
}
}
}
}
for fileName, edits := range changeTracker.GetChanges() {
uri := lsconv.FileNameToDocumentURI(fileName)
lspEdits := make([]lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit, 0, len(edits))
for _, edit := range edits {
lspEdits = append(lspEdits, lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit{
TextEdit: edit,
})
}
documentChanges = append(documentChanges, lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{
TextDocumentEdit: &lsproto.TextDocumentEdit{
TextDocument: lsproto.OptionalVersionedTextDocumentIdentifier{Uri: uri},
Edits: lspEdits,
},
})
}
return documentChanges
}
func (l *LanguageService) createPathUpdater(oldPath string, newPath string) pathUpdater {
compareOptions := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: l.UseCaseSensitiveFileNames()}
return func(path string) (string, bool) {
if tspath.ComparePaths(path, oldPath, compareOptions) == 0 {
return newPath, true
}
if tspath.StartsWithDirectory(path, oldPath, l.UseCaseSensitiveFileNames()) {
return newPath + path[len(oldPath):], true
}
return "", false
}
}
func (l *LanguageService) updateTsconfigFiles(program *compiler.Program, changeTracker *change.Tracker, oldToNew pathUpdater, oldPath string, newPath string) {
commandLine := program.CommandLine()
if commandLine == nil || commandLine.ConfigFile == nil {
return
}
configFile := commandLine.ConfigFile.SourceFile
if configFile == nil {
return
}
configDir := tspath.GetDirectoryPath(configFile.FileName())
jsonObjectLiteral := getTsConfigObjectLiteralExpression(configFile)
if jsonObjectLiteral == nil {
return
}
forEachObjectProperty(jsonObjectLiteral, func(property *ast.PropertyAssignment, propertyName string) {
switch propertyName {
case "files", "include", "exclude":
foundExactMatch := updatePathsProperty(configFile, configDir, property, changeTracker, oldToNew, l.converters, l.UseCaseSensitiveFileNames())
if foundExactMatch || propertyName != "include" || !ast.IsArrayLiteralExpression(property.Initializer) {
return
}
if oldSpec, isDefault := commandLine.GetMatchedIncludeSpec(oldPath); oldSpec != "" && !isDefault {
if newSpec, _ := commandLine.GetMatchedIncludeSpec(newPath); newSpec == "" {
elements := property.Initializer.Elements()
if len(elements) > 0 {
changeTracker.InsertNodeAfter(
configFile,
elements[len(elements)-1],
changeTracker.NodeFactory.NewStringLiteral(relativePathFromDirectory(configDir, newPath, l.UseCaseSensitiveFileNames()), ast.TokenFlagsNone),
)
}
}
}
case "compilerOptions":
if !ast.IsObjectLiteralExpression(property.Initializer) {
return
}
forEachObjectProperty(property.Initializer.AsObjectLiteralExpression(), func(property *ast.PropertyAssignment, propertyName string) {
option := tsoptions.CommandLineCompilerOptionsMap.Get(propertyName)
if option != nil {
elementOption := option.Elements()
if option.IsFilePath || (option.Kind == tsoptions.CommandLineOptionTypeList && elementOption != nil && elementOption.IsFilePath) {
updatePathsProperty(configFile, configDir, property, changeTracker, oldToNew, l.converters, l.UseCaseSensitiveFileNames())
return
}
}
if propertyName != "paths" || !ast.IsObjectLiteralExpression(property.Initializer) {
return
}
forEachObjectProperty(property.Initializer.AsObjectLiteralExpression(), func(pathsProperty *ast.PropertyAssignment, _ string) {
if !ast.IsArrayLiteralExpression(pathsProperty.Initializer) {
return
}
for _, element := range pathsProperty.Initializer.Elements() {
tryUpdateConfigString(configFile, configDir, element, changeTracker, oldToNew, l.converters, l.UseCaseSensitiveFileNames())
}
})
})
}
})
}
func updatePathsProperty(configFile *ast.SourceFile, configDir string, property *ast.PropertyAssignment, changeTracker *change.Tracker, oldToNew pathUpdater, converters *lsconv.Converters, useCaseSensitiveFileNames bool) bool {
elements := []*ast.Node{property.Initializer}
if ast.IsArrayLiteralExpression(property.Initializer) {
elements = property.Initializer.Elements()
}
foundExactMatch := false
for _, element := range elements {
foundExactMatch = tryUpdateConfigString(configFile, configDir, element, changeTracker, oldToNew, converters, useCaseSensitiveFileNames) || foundExactMatch
}
return foundExactMatch
}
func tryUpdateConfigString(configFile *ast.SourceFile, configDir string, element *ast.Node, changeTracker *change.Tracker, oldToNew pathUpdater, converters *lsconv.Converters, useCaseSensitiveFileNames bool) bool {
if !ast.IsStringLiteral(element) {
return false
}
elementFileName := tspath.NormalizePath(tspath.CombinePaths(configDir, element.Text()))
updated, ok := oldToNew(elementFileName)
if !ok {
return false
}
changeTracker.ReplaceRangeWithText(configFile, lsproto.Range{
Start: converters.PositionToLineAndCharacter(configFile, core.TextPos(scanner.GetTokenPosOfNode(element, configFile, false)+1)),
End: converters.PositionToLineAndCharacter(configFile, core.TextPos(element.End()-1)),
}, relativePathFromDirectory(configDir, updated, useCaseSensitiveFileNames))
return true
}
func (l *LanguageService) updateRelativePath(oldToNew pathUpdater, oldImportFromPath, newImportFromPath, relativeSpecifier string) string {
oldAbsolute := tspath.NormalizePath(tspath.CombinePaths(tspath.GetDirectoryPath(oldImportFromPath), relativeSpecifier))
newAbsolute, ok := oldToNew(oldAbsolute)
if !ok {
newAbsolute = oldAbsolute
}
return relativeImportPathFromDirectory(tspath.GetDirectoryPath(newImportFromPath), newAbsolute, l.UseCaseSensitiveFileNames())
}
func (l *LanguageService) updateImportsForFileRename(program *compiler.Program, changeTracker *change.Tracker, oldToNew pathUpdater) {
allFiles := program.GetSourceFiles()
checker, done := program.GetTypeChecker(context.Background())
defer done()
moduleSpecifierPreferences := l.UserPreferences().ModuleSpecifierPreferences()
for _, sourceFile := range allFiles {
oldFileName := sourceFile.FileName()
newFromOld, fileMoved := oldToNew(sourceFile.FileName())
newImportFromPath := sourceFile.FileName()
if fileMoved {
newImportFromPath = newFromOld
}
for _, ref := range sourceFile.ReferencedFiles {
if !tspath.IsExternalModuleNameRelative(ref.FileName) {
continue
}
updated := l.updateRelativePath(oldToNew, oldFileName, newImportFromPath, ref.FileName)
if updated != ref.FileName {
changeTracker.ReplaceRangeWithText(sourceFile, l.converters.ToLSPRange(sourceFile, ref.TextRange), updated)
}
}
for _, importStringLiteral := range sourceFile.Imports() {
updated := l.getUpdatedImportSpecifier(program, checker, sourceFile, importStringLiteral, oldToNew, newImportFromPath, fileMoved, moduleSpecifierPreferences)
if updated != "" && updated != importStringLiteral.Text() {
changeTracker.ReplaceRangeWithText(sourceFile, l.converters.ToLSPRange(sourceFile, createStringTextRange(sourceFile, importStringLiteral)), updated)
}
}
}
}
// We assume the source file did not move to a different program.
func (l *LanguageService) getUpdatedImportSpecifier(
program *compiler.Program,
checker *checker.Checker,
sourceFile *ast.SourceFile, // old importing source file
importLiteral *ast.StringLiteralLike,
oldToNew pathUpdater,
newImportFromPath string,
importingSourceFileMoved bool,
userPreferences modulespecifiers.UserPreferences,
) string {
importedModuleSymbol := checker.GetSymbolAtLocation(importLiteral)
if isAmbientModuleSymbol(importedModuleSymbol) {
return ""
}
target := getSourceFileToImport(program, sourceFile, importLiteral, oldToNew)
if target == nil {
// First fall back: try every file in the program to see if any of them would match the import specifier, and if so, obtain the updated specifier for that file.
if updated := getUpdatedImportSpecifierFromMovedSourceFiles(program, sourceFile, importLiteral, oldToNew, newImportFromPath, userPreferences); updated != "" && updated != importLiteral.Text() {
return updated
}
// Fall back to a regular path update for unresolved module.
if tspath.IsExternalModuleNameRelative(importLiteral.Text()) {
return l.updateRelativePath(oldToNew, sourceFile.FileName(), newImportFromPath, importLiteral.Text())
}
return ""
}
// Optimization: neither the importing or imported file changed.
if !target.updated && !(importingSourceFileMoved && tspath.IsExternalModuleNameRelative(importLiteral.Text())) {
return ""
}
updated := modulespecifiers.UpdateModuleSpecifier(
program.Options(),
program,
sourceFile,
newImportFromPath,
importLiteral.Text(),
target.newFileName,
userPreferences,
modulespecifiers.ModuleSpecifierOptions{
OverrideImportMode: program.GetModeForUsageLocation(sourceFile, importLiteral),
},
)
return updated
}
func getSourceFileToImport(
program *compiler.Program,
sourceFile *ast.SourceFile,
importLiteral *ast.StringLiteralLike,
oldToNew pathUpdater,
) *toImport {
if resolved := program.GetResolvedModuleFromModuleSpecifier(sourceFile, importLiteral); resolved != nil && resolved.ResolvedFileName != "" {
oldFileName := resolved.ResolvedFileName
if newFileName, ok := oldToNew(oldFileName); ok {
return &toImport{newFileName: newFileName, updated: true}
}
return &toImport{newFileName: oldFileName, updated: false}
}
return nil
}
// As a fall back for unresolved modules, we'll check all files in the program to see if any of them would match
// the import specifier, and if so, we'll obtain the updated specifier for that file.
func getUpdatedImportSpecifierFromMovedSourceFiles(program *compiler.Program, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, importingSourceFileName string, userPreferences modulespecifiers.UserPreferences) string {
resolutionMode := program.GetModeForUsageLocation(sourceFile, importLiteral)
for _, candidate := range program.GetSourceFiles() {
newFileName, ok := oldToNew(candidate.FileName())
if !ok {
continue
}
oldSpecifier := modulespecifiers.UpdateModuleSpecifier(
program.Options(),
program,
sourceFile,
importingSourceFileName,
importLiteral.Text(),
candidate.FileName(),
userPreferences,
modulespecifiers.ModuleSpecifierOptions{
OverrideImportMode: resolutionMode,
},
)
if oldSpecifier != importLiteral.Text() {
continue
}
return modulespecifiers.UpdateModuleSpecifier(
program.Options(),
program,
sourceFile,
importingSourceFileName,
importLiteral.Text(),
newFileName,
userPreferences,
modulespecifiers.ModuleSpecifierOptions{
OverrideImportMode: resolutionMode,
},
)
}
return ""
}
func createStringTextRange(sourceFile *ast.SourceFile, node *ast.LiteralLikeNode) core.TextRange {
return core.NewTextRange(scanner.GetTokenPosOfNode(node, sourceFile, false)+1, node.End()-1)
}
func getTsConfigObjectLiteralExpression(tsConfigSourceFile *ast.SourceFile) *ast.ObjectLiteralExpression {
if tsConfigSourceFile != nil && tsConfigSourceFile.Statements != nil && len(tsConfigSourceFile.Statements.Nodes) > 0 {
expression := tsConfigSourceFile.Statements.Nodes[0].Expression()
if ast.IsObjectLiteralExpression(expression) {
return expression.AsObjectLiteralExpression()
}
}
return nil
}
func forEachObjectProperty(objectLiteral *ast.ObjectLiteralExpression, cb func(property *ast.PropertyAssignment, propertyName string)) {
if objectLiteral == nil {
return
}
for _, property := range objectLiteral.Properties.Nodes {
if !ast.IsPropertyAssignment(property) {
continue
}
if name, ok := ast.TryGetTextOfPropertyName(property.Name()); ok {
cb(property.AsPropertyAssignment(), name)
}
}
}
func relativePathFromDirectory(fromDirectory string, to string, useCaseSensitiveFileNames bool) string {
return tspath.GetRelativePathFromDirectory(fromDirectory, to, tspath.ComparePathsOptions{UseCaseSensitiveFileNames: useCaseSensitiveFileNames})
}
func relativeImportPathFromDirectory(fromDirectory string, to string, useCaseSensitiveFileNames bool) string {
return tspath.EnsurePathIsNonModuleName(relativePathFromDirectory(fromDirectory, to, useCaseSensitiveFileNames))
}
func isAmbientModuleSymbol(symbol *ast.Symbol) bool {
if symbol == nil {
return false
}
return slices.ContainsFunc(symbol.Declarations, ast.IsModuleWithStringLiteralName)
}