Skip to content

Implement LSP-based code edits with file renaming#3358

Open
Andarist wants to merge 22 commits intomicrosoft:mainfrom
Andarist:will-rename-files
Open

Implement LSP-based code edits with file renaming#3358
Andarist wants to merge 22 commits intomicrosoft:mainfrom
Andarist:will-rename-files

Conversation

@Andarist
Copy link
Copy Markdown
Contributor

@Andarist Andarist commented Apr 7, 2026

Fixes #2244 and fuzzer crash from #3187 (comment).

The core implementation in file_rename.go is mostly a translation from the Strada code, except I deleted some code that seemed to be trying to do the same thing in 3 different ways (resolve a module specifier).
Notable changes include making file rename work when you rename a specifier for an arbitrary extension; see tests with names ending in cssImport.
I also removed Strada's source map supporting code because it didn't seem to work for file renames, only for directory renames, so I thought we might as well declare that scenario as out of scope (one shouldn't be renaming an output directory or output files...).

The overall flow is also new because of how LSP works: the server subscribes to receive willRenameFiles requests from the client for supported extensions. Then, if the user renames a file directly, the server will compute any additional edits and renames needed. If a user invokes regular rename on a module specifier, the server will reply with a file rename document change, and then the client should send a willRenameFiles request, and the server computes edits.

I decided to not support a bunch of cases:

  • Renames that would require following source maps to figure out what original files to rename
  • Renames of files with arbitrary extensions, even if they have a corresponding declaration file: I could either make it so we rename e.g. app.css upon a rename of app.d.css, or the other way around, because supporting both leads to a cycle of willRenameFiles. I decided for the latter, because it naturally supports the scenario where the rename is initiated from renaming an import specifier (which resolves to a declaration file and is the thing we ask the client to rename).
  • If a file being renamed moves to a different program, then it's possible we'll incorrectly update its module specifiers or not respect package.json
  • If the client does not support document changes or file rename in document changes, we (1) don't allow rename of module specifier, but still support file renames via willRenameFiles and only return changes.

@Andarist Andarist force-pushed the will-rename-files branch from 6810db7 to 7615f8a Compare April 7, 2026 09:17
@gabritto gabritto self-assigned this Apr 7, 2026
exhaustiveCaseCompletions7
exhaustiveCaseCompletions8
formatOnEnterInComment
getEditsForFileRename_caseInsensitive
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some tests are manual because we needed to specify case insensitivity, but most are because I changed fourslash such that the expected file content map uses file names after the rename.


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() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This allows us to properly rename in some cases where there's a module resolution error and the import doesn't resolve to a renamed file because of the error. It's kinda inefficient though, so maybe we should just say if you have resolution errors, it's too bad, but I left it for now.

}, true
}

func (l *LanguageService) getNewFileNameForModuleRename(oldPath, specifierText, newName string) string {
Copy link
Copy Markdown
Member

@gabritto gabritto Apr 10, 2026

Choose a reason for hiding this comment

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

This used to be done in the built-in extension.

return ""
}

func getDeclarationEmitExtensionForPath(fileName string) string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This was duplicated.

}
}

//// [/myproject/dependency/FnS.ts] *modified*
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These baselines changed basically because I made fourslash's editScript also update its vfs, to avoid inconsistencies between the vfs and fourslash's script infos.

@gabritto gabritto marked this pull request as ready for review April 10, 2026 23:32
Copilot AI review requested due to automatic review settings April 10, 2026 23:32
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds LSP-driven support for updating imports/configs during file (and directory) renames, including returning RenameFile document changes for module-specifier renames and computing additional workspace edits via workspace/willRenameFiles. It also extends declaration-extension handling to support arbitrary extensions (e.g. .d.css.ts) and expands fourslash tooling/tests to cover the new behavior.

Changes:

  • Implement workspace/willRenameFiles handling and integrate rename of module specifiers with file renames + follow-up edits.
  • Add LanguageService.GetEditsForFileRename to compute import/reference/tsconfig updates (plus companion renames like .d.css.ts -> .css).
  • Update fourslash harness + converter tooling and add/convert tests for file-rename edit computation.

Reviewed changes

Copilot reviewed 64 out of 64 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
testdata/baselines/reference/fourslash/state/findAllRefsDoesNotTryToSearchProjectAfterItsUpdateDoesNotIncludeTheFile.baseline Baseline update reflecting modified-file tracking.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEditEnd.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEdit.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEditEnd.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEdit.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEditEnd.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEdit.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEditEnd.baseline Baseline update for rename state output.
testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEdit.baseline Baseline update for rename state output.
internal/tspath/extension.go Generalize .d.*.ts handling and declaration emit extension selection for arbitrary extensions.
internal/testutil/harnessutil/harnessutil.go Export/expand test-config option parsing (with optional unknown-option allowance).
internal/project/snapshot.go Expose Snapshot.FileExists for LS features needing snapshot FS existence checks.
internal/project/session.go Add helper to construct language services for rename processing.
internal/outputpaths/outputpaths.go Centralize declaration emit extension logic via tspath.
internal/modulespecifiers/specifiers.go Introduce UpdateModuleSpecifier that accepts user preferences (for rename updates).
internal/lsp/server.go Register/handle workspace/willRenameFiles and route textDocument/rename via server to support file renames.
internal/ls/rename.go Extend rename info to include file rename intent; add client capability helpers; compute correct target file for specifier rename.
internal/ls/host.go Add FileExists to LS host interface for rename companion file checks.
internal/ls/file_rename.go New core implementation computing edits (imports/refs/tsconfig) and related file renames for file/directory rename.
internal/ls/crossproject.go Combine rename responses including DocumentChanges (not just Changes).
internal/ls/codeactions_importfixes.go Dedupe identical auto-import fix suggestions.
internal/ls/autoimport/specifiers.go Adjust module specifier generation for symlinked package exports (realpath handling).
internal/fourslash/tests/renameImportSpecifierNoResourceOperations_test.go New test ensuring rename fails when resource operations aren’t supported.
internal/fourslash/tests/manual/getEditsForFileRename_unaffectedNonRelativePath_test.go Manual test for non-relative path stability under rename.
internal/fourslash/tests/manual/getEditsForFileRename_subDir_test.go Manual test for moving file into subdir updates relative imports.
internal/fourslash/tests/manual/getEditsForFileRename_shortenRelativePaths_test.go Manual test for shortening relative paths after rename.
internal/fourslash/tests/manual/getEditsForFileRename_resolveJsonModule_test.go Manual test for JSON module import updates under rename.
internal/fourslash/tests/manual/getEditsForFileRename_preservePathEnding_test.go Manual test preserving specifier endings like ., ./index, explicit extensions.
internal/fourslash/tests/manual/getEditsForFileRename_preferences_test.go Manual test honoring module specifier + quote preferences.
internal/fourslash/tests/manual/getEditsForFileRename_jsExtension_test.go Manual test for .js extension preservation/updates.
internal/fourslash/tests/manual/getEditsForFileRename_directory_up_test.go Manual test for directory rename moving up the tree.
internal/fourslash/tests/manual/getEditsForFileRename_directory_down_test.go Manual test for directory rename moving down the tree.
internal/fourslash/tests/manual/getEditsForFileRename_caseInsensitive_test.go Manual test for case-insensitive FS behavior.
internal/fourslash/tests/getEditsForFileRename_jsRename_test.go New rename test covering .js-style specifier rename -> file rename.
internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go New test intended to cover fallback behavior when file operations aren’t supported.
internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go New test for css import specifier rename behavior.
internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go New test for willRenameFiles edits when renaming .d.css.ts.
internal/fourslash/tests/gen/getEditsForFileRename_unresolvableNodeModule_test.go Converted/generated coverage for rename edits.
internal/fourslash/tests/gen/getEditsForFileRename_unresolvableImport_test.go Converted/generated coverage for rename edits.
internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_test.go Converted/generated coverage for tsconfig path updates.
internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_noChange_test.go Converted/generated coverage for include behavior.
internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_add_test.go Converted/generated coverage for include insertion behavior.
internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_empty_include_test.go Converted/generated coverage for empty include behavior.
internal/fourslash/tests/gen/getEditsForFileRename_test.go Converted/generated base coverage for file rename edits.
internal/fourslash/tests/gen/getEditsForFileRename_symlink_test.go Converted/generated coverage for symlink scenarios.
internal/fourslash/tests/gen/getEditsForFileRename_renameToIndex_test.go Converted/generated coverage for renaming to index.
internal/fourslash/tests/gen/getEditsForFileRename_renameFromIndex_test.go Converted/generated coverage for renaming from index.
internal/fourslash/tests/gen/getEditsForFileRename_notAffectedByJsFile_test.go Converted/generated coverage for TS-vs-JS sibling file behavior.
internal/fourslash/tests/gen/getEditsForFileRename_nodeModuleDirectoryCase_test.go Converted/generated coverage for node_modules dir casing rename.
internal/fourslash/tests/gen/getEditsForFileRename_keepFileExtensions_test.go Converted/generated coverage for keeping .js in specifiers under Node16/Next modes.
internal/fourslash/tests/gen/getEditsForFileRename_js_simple_test.go Converted/generated coverage for basic JS rename edits.
internal/fourslash/tests/gen/getEditsForFileRename_directory_test.go Converted/generated coverage for directory rename edits.
internal/fourslash/tests/gen/getEditsForFileRename_directory_noUpdateNodeModulesImport_test.go Converted/generated coverage ensuring node module imports aren’t rewritten.
internal/fourslash/tests/gen/getEditsForFileRename_casing_test.go Converted/generated coverage for preserving path casing.
internal/fourslash/tests/gen/getEditsForFileRename_amd_test.go Converted/generated coverage for classic/AMD-ish resolution scenario.
internal/fourslash/tests/gen/getEditsForFileRename_ambientModule_test.go Converted/generated coverage ensuring ambient modules unaffected.
internal/fourslash/test_parser.go Allow certain global directives alongside config files (e.g. symlink/useCaseSensitiveFileNames).
internal/fourslash/fourslash.go Major harness updates: apply multi-edits safely, emulate rename + willRenameFiles flows, default capabilities updates.
internal/fourslash/_scripts/unparsedTests.txt Remove now-supported getEditsForFileRename entries from “unparsed”.
internal/fourslash/_scripts/manualTests.txt Add manual test names for conversion workflow.
internal/fourslash/_scripts/failingTests.txt Update failing-test list after symlink handling changes.
internal/fourslash/_scripts/convertFourslash.mts Add conversion support for verify.getEditsForFileRename.
internal/diagnostics/extraDiagnosticMessages.json Add new editor-capability error message for file rename.
internal/diagnostics/diagnostics_generated.go Regenerate diagnostics to include new message code.

Comment on lines +41 to +50
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)
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

In the declaration-file rename companion logic, newDtsExt is derived from oldPath instead of newPath. This breaks the check when the declaration extension changes during rename (e.g. .d.css.ts -> .d.scss.ts), and can incorrectly skip/allow renaming the original file. Use tspath.GetDeclarationFileExtension(newPath) (and consider computing both old/new once outside the loop).

Copilot uses AI. Check for mistakes.
Comment on lines +916 to +933
func (s *Session) GetLanguageServicesForDocuments(ctx context.Context, uris []lsproto.DocumentUri) []*ls.LanguageService {
snapshot := s.getSnapshot(
ctx,
ResourceRequest{Documents: uris},
false, /*callerRef*/
)

activeFile := ""
if len(uris) > 0 {
activeFile = uris[0].FileName()
}

projects := snapshot.ProjectCollection.Projects()
services := make([]*ls.LanguageService, 0, len(projects))
for _, project := range projects {
services = append(services, ls.NewLanguageService(project.configFilePath, project.GetProgram(), snapshot, activeFile))
}
return services
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

GetLanguageServicesForDocuments currently creates a LanguageService for every project in the snapshot, even if none of the uris belong to most projects. In large workspaces this makes willRenameFiles scale with total project count. Consider filtering to only projects that contain any of the provided documents (e.g. via snapshot.GetProjectsContainingFile per URI, then dedupe) before constructing services.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This sounds plausible but doesn't work, because we need to visit all projects that can depend on the project that contains the file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port update imports on file rename/move support

3 participants