From 99d980dfc2d9ba515956a8f100f867108e64ea44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Mon, 30 Mar 2026 14:07:30 +0200 Subject: [PATCH 01/19] Implement LSP-based code edits with file renaming --- .../fourslash/_scripts/convertFourslash.mts | 255 ++++++++-- internal/fourslash/_scripts/unparsedTests.txt | 29 -- internal/fourslash/fourslash.go | 206 +++++++- internal/fourslash/test_parser.go | 14 +- ...etEditsForFileRename_ambientModule_test.go | 32 ++ .../gen/getEditsForFileRename_amd_test.go | 27 ++ ...EditsForFileRename_caseInsensitive_test.go | 27 ++ .../gen/getEditsForFileRename_casing_test.go | 26 + ...tEditsForFileRename_directory_down_test.go | 64 +++ ...irectory_noUpdateNodeModulesImport_test.go | 24 + .../getEditsForFileRename_directory_test.go | 59 +++ ...getEditsForFileRename_directory_up_test.go | 64 +++ .../getEditsForFileRename_jsExtension_test.go | 27 ++ .../getEditsForFileRename_js_simple_test.go | 27 ++ ...tsForFileRename_keepFileExtensions_test.go | 33 ++ ...FileRename_nodeModuleDirectoryCase_test.go | 24 + ...sForFileRename_notAffectedByJsFile_test.go | 28 ++ .../getEditsForFileRename_preferences_test.go | 31 ++ ...tsForFileRename_preservePathEnding_test.go | 45 ++ ...EditsForFileRename_renameFromIndex_test.go | 47 ++ ...etEditsForFileRename_renameToIndex_test.go | 41 ++ ...itsForFileRename_resolveJsonModule_test.go | 27 ++ ...ForFileRename_shortenRelativePaths_test.go | 26 + .../gen/getEditsForFileRename_subDir_test.go | 26 + .../gen/getEditsForFileRename_symlink_test.go | 26 + .../tests/gen/getEditsForFileRename_test.go | 43 ++ ...rFileRename_tsconfig_empty_include_test.go | 24 + ...ForFileRename_tsconfig_include_add_test.go | 30 ++ ...leRename_tsconfig_include_noChange_test.go | 26 + .../getEditsForFileRename_tsconfig_test.go | 52 ++ ...leRename_unaffectedNonRelativePath_test.go | 26 + ...tsForFileRename_unresolvableImport_test.go | 37 ++ ...rFileRename_unresolvableNodeModule_test.go | 26 + .../tests/importRenameFileFlow_test.go | 218 +++++++++ internal/ls/autoimport/specifiers.go | 14 +- internal/ls/codeactions_importfixes.go | 37 ++ internal/ls/crossproject.go | 31 +- internal/ls/filerename.go | 456 ++++++++++++++++++ internal/ls/rename.go | 27 +- internal/lsp/server.go | 60 +++ internal/module/resolver.go | 3 + internal/module/types.go | 1 + internal/modulespecifiers/specifiers.go | 50 +- internal/project/session.go | 20 + internal/testrunner/test_case_parser.go | 14 +- internal/testutil/harnessutil/harnessutil.go | 15 + .../moduleResolutionWithSymlinks.errors.txt | 10 +- ...duleResolutionWithSymlinks.errors.txt.diff | 34 -- .../moduleResolutionWithSymlinks.trace.json | 15 +- .../moduleResolutionWithSymlinks.types | 16 +- .../moduleResolutionWithSymlinks.types.diff | 42 -- ...onWithSymlinks_notInNodeModules.trace.json | 7 +- ...onWithSymlinks_preserveSymlinks.trace.json | 9 + ...tionWithSymlinks_referenceTypes.trace.json | 12 +- ...solutionWithSymlinks_withOutDir.errors.txt | 10 +- ...ionWithSymlinks_withOutDir.errors.txt.diff | 34 -- ...solutionWithSymlinks_withOutDir.trace.json | 15 +- ...uleResolutionWithSymlinks_withOutDir.types | 16 +- ...solutionWithSymlinks_withOutDir.types.diff | 42 -- 59 files changed, 2406 insertions(+), 301 deletions(-) create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_ambientModule_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_amd_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_casing_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_directory_noUpdateNodeModulesImport_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_directory_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_js_simple_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_keepFileExtensions_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_nodeModuleDirectoryCase_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_notAffectedByJsFile_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_renameFromIndex_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_renameToIndex_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_symlink_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_empty_include_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_add_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_noChange_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_unresolvableImport_test.go create mode 100644 internal/fourslash/tests/gen/getEditsForFileRename_unresolvableNodeModule_test.go create mode 100644 internal/fourslash/tests/importRenameFileFlow_test.go create mode 100644 internal/ls/filerename.go delete mode 100644 testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt.diff delete mode 100644 testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types.diff delete mode 100644 testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt.diff delete mode 100644 testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types.diff diff --git a/internal/fourslash/_scripts/convertFourslash.mts b/internal/fourslash/_scripts/convertFourslash.mts index 9b40f4862a8..30866b9a7ae 100755 --- a/internal/fourslash/_scripts/convertFourslash.mts +++ b/internal/fourslash/_scripts/convertFourslash.mts @@ -101,24 +101,90 @@ function parseTypeScriptFiles(manualTests: Set, folder: string): void { }); } -function parseFileContent(filename: string, content: string): GoTest { - console.error(`Parsing file: ${filename}`); - const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); - const statements = sourceFile.statements; - const goTest: GoTest = { - name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), - content: getTestInput(content), - commands: [], - }; - for (const statement of statements) { - const result = parseFourslashStatement(statement); - goTest.commands.push(...result); - } - if (goTest.commands.length === 0) { - throw new Error(`No commands parsed in file: ${filename}`); - } - return goTest; -} +function parseFileContent(filename: string, content: string): GoTest { + console.error(`Parsing file: ${filename}`); + const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); + const statements = sourceFile.statements; + const commands: Cmd[] = []; + for (const statement of statements) { + const result = parseFourslashStatement(statement); + commands.push(...result); + } + + // File-rename tests from old TS sometimes rely on legacy `baseUrl`-driven + // non-relative specifiers. The current compiler intentionally removes + // `baseUrl` resolution, so for this narrow converted test family we rewrite + // those fixtures to equivalent `paths`-based configs instead of preserving + // the legacy option in generated tests. + const rewrittenContent = commands.some(command => command.kind === "verifyGetEditsForFileRename") + ? rewriteLegacyBaseUrlInRenameTestContent(content) + : content; + const finalContent = filename === "getEditsForFileRename_caseInsensitive.ts" + ? `// @useCaseSensitiveFileNames: false\n${rewrittenContent}` + : rewrittenContent; + + const goTest: GoTest = { + name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), + content: getTestInput(finalContent), + commands, + }; + if (goTest.commands.length === 0) { + throw new Error(`No commands parsed in file: ${filename}`); + } + return goTest; +} + +function rewriteLegacyBaseUrlInRenameTestContent(content: string): string { + const lines = content.split("\n"); + const rewritten: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + rewritten.push(line); + + const match = line.match(/^\/\/\s*@Filename:\s*(.+)$/); + if (!match || !match[1].trim().endsWith("tsconfig.json")) { + continue; + } + + const jsonLines: string[] = []; + let j = i + 1; + while (j < lines.length && lines[j].startsWith("////")) { + jsonLines.push(lines[j].slice(4)); + j++; + } + if (jsonLines.length === 0) { + continue; + } + + const rewrittenJson = rewriteLegacyBaseUrlJson(jsonLines.join("\n")); + rewritten.push(...rewrittenJson.split("\n").map(part => `////${part}`)); + i = j - 1; + } + + return rewritten.join("\n"); +} + +function rewriteLegacyBaseUrlJson(jsonText: string): string { + let parsed: any; + try { + parsed = JSON.parse(jsonText); + } + catch { + return jsonText; + } + + const compilerOptions = parsed?.compilerOptions; + if (!compilerOptions || typeof compilerOptions !== "object" || typeof compilerOptions.baseUrl !== "string" || compilerOptions.paths !== undefined) { + return jsonText; + } + + const baseUrl = compilerOptions.baseUrl; + const wildcardTarget = baseUrl === "." ? "*" : `${baseUrl.replace(/\/$/, "")}/*`; + delete compilerOptions.baseUrl; + compilerOptions.paths = { "*": [wildcardTarget] }; + return JSON.stringify(parsed); +} function getTestInput(content: string): string { const lines = content.split("\n").map(line => line.endsWith("\r") ? line.slice(0, -1) : line); @@ -269,12 +335,14 @@ function parseFourslashStatement(statement: ts.Statement): Cmd[] { return [{ kind: "verifyBaselineLinkedEditing" }]; case "linkedEditing": return parseVerifyLinkedEditing(callExpression.arguments); - case "renameInfoSucceeded": - case "renameInfoFailed": - return parseRenameInfo(func.text, callExpression.arguments); - case "getSemanticDiagnostics": - case "getSuggestionDiagnostics": - case "getSyntacticDiagnostics": + case "renameInfoSucceeded": + case "renameInfoFailed": + return parseRenameInfo(func.text, callExpression.arguments); + case "getEditsForFileRename": + return parseGetEditsForFileRename(callExpression.arguments); + case "getSemanticDiagnostics": + case "getSuggestionDiagnostics": + case "getSyntacticDiagnostics": return parseVerifyDiagnostics(func.text, callExpression.arguments); case "baselineSyntacticDiagnostics": case "baselineSyntacticAndSemanticDiagnostics": @@ -1338,7 +1406,7 @@ function parseBaselineGoToDefinitionArgs( }]; } -function parseRenameInfo(funcName: "renameInfoSucceeded" | "renameInfoFailed", args: readonly ts.Expression[]): [VerifyRenameInfoCmd] { +function parseRenameInfo(funcName: "renameInfoSucceeded" | "renameInfoFailed", args: readonly ts.Expression[]): [VerifyRenameInfoCmd] { let preferences = "nil /*preferences*/"; let prefArg; switch (funcName) { @@ -1360,10 +1428,90 @@ function parseRenameInfo(funcName: "renameInfoSucceeded" | "renameInfoFailed", a const parsedPreferences = parseUserPreferences(prefArg); preferences = parsedPreferences; } - return [{ kind: funcName, preferences }]; -} - -function parseBaselineRenameArgs(funcName: string, args: readonly ts.Expression[]): [VerifyBaselineRenameCmd] { + return [{ kind: funcName, preferences }]; +} + +function parseGetEditsForFileRename(args: readonly ts.Expression[]): [VerifyGetEditsForFileRenameCmd] { + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + throw new Error(`Expected a single object literal argument in verify.getEditsForFileRename, got ${args.map(arg => arg.getText()).join(", ")}`); + } + + let oldPath: string | undefined; + let newPath: string | undefined; + let newFileContents = "map[string]string{}"; + let preferences = "nil /*preferences*/"; + + for (const prop of args[0].properties) { + if (!ts.isPropertyAssignment(prop)) { + continue; + } + const name = prop.name.getText(); + switch (name) { + case "oldPath": { + const value = getStringLiteralLike(prop.initializer); + if (!value) { + throw new Error(`Expected string literal for oldPath, got ${prop.initializer.getText()}`); + } + oldPath = getGoStringLiteral(value.text); + break; + } + case "newPath": { + const value = getStringLiteralLike(prop.initializer); + if (!value) { + throw new Error(`Expected string literal for newPath, got ${prop.initializer.getText()}`); + } + newPath = getGoStringLiteral(value.text); + break; + } + case "newFileContents": { + const obj = getObjectLiteralExpression(prop.initializer); + if (!obj) { + throw new Error(`Expected object literal for newFileContents, got ${prop.initializer.getText()}`); + } + const entries: string[] = []; + for (const entry of obj.properties) { + if (!ts.isPropertyAssignment(entry)) { + continue; + } + const key = getStringLiteralLike(entry.name); + const value = getStringLiteralLike(entry.initializer); + if (!key || !value) { + throw new Error(`Expected string literal key/value in newFileContents, got ${entry.getText()}`); + } + const rewrittenValue = key.text.endsWith("tsconfig.json") + ? rewriteLegacyBaseUrlJson(value.text) + : value.text; + entries.push(`${getGoStringLiteral(key.text)}: ${getGoMultiLineStringLiteral(rewrittenValue)}`); + } + newFileContents = entries.length === 0 + ? "map[string]string{}" + : `map[string]string{\n${entries.join(",\n")},\n}`; + break; + } + case "preferences": { + if (!ts.isObjectLiteralExpression(prop.initializer)) { + throw new Error(`Expected object literal for preferences, got ${prop.initializer.getText()}`); + } + preferences = parseUserPreferences(prop.initializer); + break; + } + } + } + + if (!oldPath || !newPath) { + throw new Error(`Expected oldPath and newPath in verify.getEditsForFileRename`); + } + + return [{ + kind: "verifyGetEditsForFileRename", + oldPath, + newPath, + newFileContents, + preferences, + }]; +} + +function parseBaselineRenameArgs(funcName: string, args: readonly ts.Expression[]): [VerifyBaselineRenameCmd] { let newArgs: string[] = []; let preferences: string | undefined; for (const arg of args) { @@ -3075,14 +3223,22 @@ interface VerifyOrganizeImportsCmd { preferences: string; } -interface VerifyRenameInfoCmd { - kind: "renameInfoSucceeded" | "renameInfoFailed"; - preferences: string; -} - -interface VerifyBaselineLinkedEditingCmd { - kind: "verifyBaselineLinkedEditing"; -} +interface VerifyRenameInfoCmd { + kind: "renameInfoSucceeded" | "renameInfoFailed"; + preferences: string; +} + +interface VerifyGetEditsForFileRenameCmd { + kind: "verifyGetEditsForFileRename"; + oldPath: string; + newPath: string; + newFileContents: string; + preferences: string; +} + +interface VerifyBaselineLinkedEditingCmd { + kind: "verifyBaselineLinkedEditing"; +} interface VerifyLinkedEditingCmd { kind: "verifyLinkedEditing"; ranges: string; @@ -3209,11 +3365,12 @@ type Cmd = | FormatCmd | EditCmd | VerifyContentCmd - | VerifyQuickInfoCmd - | VerifyOrganizeImportsCmd - | VerifyBaselineRenameCmd - | VerifyRenameInfoCmd - | VerifyBaselineLinkedEditingCmd + | VerifyQuickInfoCmd + | VerifyOrganizeImportsCmd + | VerifyBaselineRenameCmd + | VerifyRenameInfoCmd + | VerifyGetEditsForFileRenameCmd + | VerifyBaselineLinkedEditingCmd | VerifyLinkedEditingCmd | VerifyNavToCmd | VerifyNavTreeCmd @@ -3530,12 +3687,14 @@ function generateCmd(cmd: Cmd): string { case "verifyBaselineRename": case "verifyBaselineRenameAtRangesWithText": return generateBaselineRename(cmd); - case "renameInfoSucceeded": - return `f.VerifyRenameSucceeded(t, ${cmd.preferences})`; - case "renameInfoFailed": - return `f.VerifyRenameFailed(t, ${cmd.preferences})`; - case "verifyBaselineInlayHints": - return generateBaselineInlayHints(cmd); + case "renameInfoSucceeded": + return `f.VerifyRenameSucceeded(t, ${cmd.preferences})`; + case "renameInfoFailed": + return `f.VerifyRenameFailed(t, ${cmd.preferences})`; + case "verifyGetEditsForFileRename": + return `f.VerifyWillRenameFilesEdits(t, ${cmd.oldPath}, ${cmd.newPath}, ${cmd.newFileContents}, ${cmd.preferences})`; + case "verifyBaselineInlayHints": + return generateBaselineInlayHints(cmd); case "verifyBaselineLinkedEditing": return `f.VerifyBaselineLinkedEditing(t)`; case "verifyImportFixAtPosition": diff --git a/internal/fourslash/_scripts/unparsedTests.txt b/internal/fourslash/_scripts/unparsedTests.txt index 3d7ba11aa41..b0b4198cc2a 100644 --- a/internal/fourslash/_scripts/unparsedTests.txt +++ b/internal/fourslash/_scripts/unparsedTests.txt @@ -1668,35 +1668,6 @@ fsEditMarkerPositions.ts parse error: "Unrecognized verify content function: tex functionRenamingErrorRecovery.ts parse error: "Unrecognized fourslash statement: verify.not.errorExistsAfterMarker(...)" getCompletionEntryDetails.ts parse error: "Expected property access expression, got check" getCompletionEntryDetails2.ts parse error: "Expected property assignment with identifier name, got exact" -getEditsForFileRename_ambientModule.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_amd.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_caseInsensitive.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_casing.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_directory_down.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_directory_noUpdateNodeModulesImport.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_directory_up.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_directory.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_js_simple.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_jsExtension.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_keepFileExtensions.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_nodeModuleDirectoryCase.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_notAffectedByJsFile.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_preferences.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_preservePathEnding.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_renameFromIndex.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_renameToIndex.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_resolveJsonModule.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_shortenRelativePaths.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_subDir.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_symlink.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_tsconfig_empty_include.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_tsconfig_include_add.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_tsconfig_include_noChange.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_tsconfig.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_unaffectedNonRelativePath.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_unresolvableImport.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename_unresolvableNodeModule.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" -getEditsForFileRename.ts parse error: "Unrecognized fourslash statement: verify.getEditsForFileRename(...)" getEmitOutputDeclarationMultiFiles.ts parse error: "Unrecognized fourslash statement: verify.baselineGetEmitOutput(...)" getEmitOutputDeclarationSingleFile.ts parse error: "Unrecognized fourslash statement: verify.baselineGetEmitOutput(...)" getEmitOutputExternalModule.ts parse error: "Unrecognized fourslash statement: verify.baselineGetEmitOutput(...)" diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 0df43d4daad..50ab3bb3476 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -150,7 +150,9 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten Target: core.ScriptTargetLatestStandard, Jsx: core.JsxEmitPreserve, } + harnessOptions := harnessutil.HarnessOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: rootDir} harnessutil.SetCompilerOptionsFromTestConfig(t, testData.GlobalOptions, compilerOptions, rootDir) + harnessutil.SetHarnessOptionsFromTestConfig(t, testData.GlobalOptions, &harnessOptions, rootDir) if commandLines := testData.GlobalOptions["tsc"]; commandLines != "" { for commandLine := range strings.SplitSeq(commandLines, ",") { tsctests.GetFileMapWithBuild(testfs, strings.Split(commandLine, " ")) @@ -159,7 +161,7 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten harnessutil.SkipUnsupportedCompilerOptions(t, compilerOptions) - fsFromMap := vfstest.FromMap(testfs, true /*useCaseSensitiveFileNames*/) + fsFromMap := vfstest.FromMap(testfs, harnessOptions.UseCaseSensitiveFileNames) fs := bundled.WrapFS(fsFromMap) serverOpts := lsp.ServerOptions{ @@ -2888,6 +2890,31 @@ func (f *FourslashTest) applyTextEdits(t *testing.T, edits []*lsproto.TextEdit) return totalOffset } +func applyTextEditsToContent(content string, edits []*lsproto.TextEdit, _ *lsconv.Converters) string { + script := newScriptInfo("__expected__.ts", content) + contentConverters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(fileName string) *lsconv.LSPLineMap { + return script.lineMap + }) + sorted := slices.Clone(edits) + slices.SortFunc(sorted, func(a, b *lsproto.TextEdit) int { + aStart := contentConverters.LineAndCharacterToPosition(script, a.Range.Start) + bStart := contentConverters.LineAndCharacterToPosition(script, b.Range.Start) + return int(aStart) - int(bStart) + }) + + var b strings.Builder + lastPos := 0 + for _, edit := range sorted { + start := int(contentConverters.LineAndCharacterToPosition(script, edit.Range.Start)) + end := int(contentConverters.LineAndCharacterToPosition(script, edit.Range.End)) + b.WriteString(content[lastPos:start]) + b.WriteString(edit.NewText) + lastPos = end + } + b.WriteString(content[lastPos:]) + return b.String() +} + func (f *FourslashTest) Replace(t *testing.T, start int, length int, text string) { f.baselineState(t) f.replaceWorker(t, start, length, text) @@ -3706,6 +3733,183 @@ func (f *FourslashTest) VerifyRenameSucceeded(t *testing.T, preferences *lsutil. } } +func (f *FourslashTest) RenameAtCaret(t *testing.T, newName string) lsproto.RenameResponse { + t.Helper() + return sendRequest(t, f, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(f.activeFilename), + }, + Position: f.currentCaretPosition, + NewName: newName, + }) +} + +func (f *FourslashTest) WillRenameFiles(t *testing.T, files ...*lsproto.FileRename) lsproto.WillRenameFilesResponse { + t.Helper() + return sendRequest(t, f, lsproto.WorkspaceWillRenameFilesInfo, &lsproto.RenameFilesParams{ + Files: files, + }) +} + +func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, newPath string, expectedFileContents map[string]string, preferences *lsutil.UserPreferences) { + t.Helper() + if preferences != nil { + defer f.ConfigureWithReset(t, preferences)() + } + + result := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI(oldPath)), + NewUri: string(lsconv.FileNameToDocumentURI(newPath)), + }) + if result.WorkspaceEdit == nil { + if len(expectedFileContents) == 0 { + f.renameFileOrDirectory(t, oldPath, newPath) + return + } + t.Fatalf("workspace/willRenameFiles returned nil workspace edit") + } + + actualContents := map[string]string{} + for fileName, expectedContent := range expectedFileContents { + actualContents[fileName] = expectedContent + if script := f.getOrLoadScriptInfo(fileName); script != nil { + actualContents[fileName] = script.content + } + } + if result.WorkspaceEdit.Changes != nil { + for uri, edits := range *result.WorkspaceEdit.Changes { + fileName := uri.FileName() + currentContent, ok := actualContents[fileName] + if !ok { + script := f.getOrLoadScriptInfo(fileName) + if script == nil { + t.Fatalf("workspace/willRenameFiles returned edits for unknown file %s", fileName) + } + currentContent = script.content + } + actualContents[fileName] = applyTextEditsToContent(currentContent, edits, f.converters) + } + } + + for fileName, expectedContent := range expectedFileContents { + actualContent, ok := actualContents[fileName] + if !ok { + t.Fatalf("expected content for %s, but no actual content was available", fileName) + } + assert.Equal(t, actualContent, expectedContent, fmt.Sprintf("File content after workspace/willRenameFiles edits did not match expected content for %s.", fileName)) + } + + f.renameFileOrDirectory(t, oldPath, newPath) +} + +func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newPath string) { + t.Helper() + + pathUpdater := func(path string) (string, bool) { + compareOptions := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: f.vfs.UseCaseSensitiveFileNames()} + if tspath.ComparePaths(path, oldPath, compareOptions) == 0 { + return newPath, true + } + if tspath.StartsWithDirectory(path, oldPath, f.vfs.UseCaseSensitiveFileNames()) { + return newPath + path[len(oldPath):], true + } + return "", false + } + renamedContents := map[string]string{} + + if content, ok := f.vfs.ReadFile(oldPath); ok { + renamedContents[oldPath] = content + } else { + walkErr := f.vfs.WalkDir(oldPath, func(path string, d vfs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + content, ok := f.vfs.ReadFile(path) + if !ok { + return fmt.Errorf("file %s disappeared during rename walk", path) + } + renamedContents[path] = content + return nil + }) + if walkErr != nil { + t.Fatalf("failed to collect files for rename %s -> %s: %v", oldPath, newPath, walkErr) + } + } + + if len(renamedContents) == 0 { + t.Fatalf("rename source %s did not exist in test environment", oldPath) + } + + wasOpen := map[string]bool{} + for oldFileName := range renamedContents { + if _, ok := f.openFiles[oldFileName]; ok { + wasOpen[oldFileName] = true + sendNotification(t, f, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ + TextDocument: lsproto.TextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(oldFileName), + }, + }) + delete(f.openFiles, oldFileName) + } + delete(f.scriptInfos, oldFileName) + } + + changes := make([]*lsproto.FileEvent, 0, len(renamedContents)*2) + for oldFileName, content := range renamedContents { + newFileName, ok := pathUpdater(oldFileName) + if !ok { + t.Fatalf("failed to compute renamed path for %s", oldFileName) + } + if err := f.vfs.WriteFile(newFileName, content); err != nil { + t.Fatalf("failed to write renamed file %s: %v", newFileName, err) + } + f.scriptInfos[newFileName] = newScriptInfo(newFileName, content) + changes = append(changes, &lsproto.FileEvent{ + Uri: lsconv.FileNameToDocumentURI(oldFileName), + Type: lsproto.FileChangeTypeDeleted, + }) + changes = append(changes, &lsproto.FileEvent{ + Uri: lsconv.FileNameToDocumentURI(newFileName), + Type: lsproto.FileChangeTypeCreated, + }) + } + + if err := f.vfs.Remove(oldPath); err != nil { + t.Fatalf("failed to remove old path %s: %v", oldPath, err) + } + + sendNotification(t, f, lsproto.WorkspaceDidChangeWatchedFilesInfo, &lsproto.DidChangeWatchedFilesParams{ + Changes: changes, + }) + + for oldFileName := range wasOpen { + newFileName, ok := pathUpdater(oldFileName) + if !ok { + t.Fatalf("failed to compute reopened path for %s", oldFileName) + } + script := f.getScriptInfo(newFileName) + if script == nil { + t.Fatalf("missing script info for reopened file %s", newFileName) + } + f.activeFilename = newFileName + sendNotification(t, f, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ + TextDocument: &lsproto.TextDocumentItem{ + Uri: lsconv.FileNameToDocumentURI(newFileName), + LanguageId: getLanguageKind(newFileName), + Text: script.content, + }, + }) + f.openFiles[newFileName] = struct{}{} + } + + if updatedActive, ok := pathUpdater(f.activeFilename); ok { + f.activeFilename = updatedActive + } +} + func (f *FourslashTest) VerifyRenameFailed(t *testing.T, preferences *lsutil.UserPreferences) { if preferences != nil { defer f.ConfigureWithReset(t, preferences)() diff --git a/internal/fourslash/test_parser.go b/internal/fourslash/test_parser.go index f926d84c709..87624df4f33 100644 --- a/internal/fourslash/test_parser.go +++ b/internal/fourslash/test_parser.go @@ -153,7 +153,7 @@ func ParseTestData(t *testing.T, contents string, fileName string) TestData { } - if hasTSConfig && len(globalOptions) > 0 && !isStateBaseliningEnabled(globalOptions) { + if hasTSConfig && hasUnsupportedGlobalOptionsWithConfig(globalOptions) && !isStateBaseliningEnabled(globalOptions) { t.Fatalf("It is not allowed to use global options along with config files.") } @@ -167,6 +167,18 @@ func ParseTestData(t *testing.T, contents string, fileName string) TestData { } } +func hasUnsupportedGlobalOptionsWithConfig(globalOptions map[string]string) bool { + for option := range globalOptions { + switch strings.ToLower(option) { + case "symlink", "link", "usecasesensitivefilenames": + continue + default: + return true + } + } + return false +} + func isConfigFile(fileName string) bool { fileName = strings.ToLower(fileName) return strings.HasSuffix(fileName, "tsconfig.json") || strings.HasSuffix(fileName, "jsconfig.json") diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_ambientModule_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_ambientModule_test.go new file mode 100644 index 00000000000..c7e1f3619b9 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_ambientModule_test.go @@ -0,0 +1,32 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_ambientModule" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_ambientModule(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /tsconfig.json +{} +// @Filename: /sub/types.d.ts +// @Symlink: /node_modules/sub/types.d.ts +declare module "sub" { + declare export const abc: number +} +// @Filename: /sub/package.json +// @Symlink: /node_modules/sub/package.json +{ "types": "types.d.ts" } +// @Filename: /a.ts +import { abc } from "sub";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a.ts", "/b.ts", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_amd_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_amd_test.go new file mode 100644 index 00000000000..573aff939e8 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_amd_test.go @@ -0,0 +1,27 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_amd" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_amd(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @moduleResolution: classic +// @Filename: /src/user.ts +import { x } from "old"; +// @Filename: /src/old.ts +export const x = 0;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/new.ts", map[string]string{ + "/src/user.ts": `import { x } from "./new";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go new file mode 100644 index 00000000000..f6736d32ec7 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go @@ -0,0 +1,27 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_caseInsensitive" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_caseInsensitive(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @useCaseSensitiveFileNames: false +// @Filename: /a.ts +export const a = 0; +// @Filename: /b.ts +import { a } from "./A";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a.ts", "/eh.ts", map[string]string{ + "/b.ts": `import { a } from "./eh";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_casing_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_casing_test.go new file mode 100644 index 00000000000..f3cbeadb344 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_casing_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_casing" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_casing(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +import { foo } from "./dir/fOo"; +// @Filename: /dir/fOo.ts +export const foo = 0;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/dir", "/newDir", map[string]string{ + "/a.ts": `import { foo } from "./newDir/fOo";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go new file mode 100644 index 00000000000..f94799e6b18 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go @@ -0,0 +1,64 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_directory_down" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_directory_down(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +/// +import old from "./src/old"; +import old2 from "./src/old/file"; +export default 0; +// @Filename: /src/b.ts +/// +import old from "./old"; +import old2 from "./old/file"; +export default 0; +// @Filename: /src/foo/c.ts +/// +import old from "../old"; +import old2 from "../old/file"; +export default 0; +// @Filename: /src/old/index.ts +import a from "../../a"; +import a2 from "../b"; +import a3 from "../foo/c"; +import f from "./file"; +export default 0; +// @Filename: /src/old/file.ts +export default 0; +// @Filename: /tsconfig.json +{ "files": ["a.ts", "src/b.ts", "src/foo/c.ts", "src/old/index.ts", "src/old/file.ts"] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old", "/src/newDir/new", map[string]string{ + "/a.ts": `/// +import old from "./src/newDir/new"; +import old2 from "./src/newDir/new/file"; +export default 0;`, + "/src/b.ts": `/// +import old from "./newDir/new"; +import old2 from "./newDir/new/file"; +export default 0;`, + "/src/foo/c.ts": `/// +import old from "../newDir/new"; +import old2 from "../newDir/new/file"; +export default 0;`, + "/src/old/index.ts": `import a from "../../../a"; +import a2 from "../../b"; +import a3 from "../../foo/c"; +import f from "./file"; +export default 0;`, + "/tsconfig.json": `{ "files": ["a.ts", "src/b.ts", "src/foo/c.ts", "src/newDir/new/index.ts", "src/newDir/new/file.ts"] }`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_directory_noUpdateNodeModulesImport_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_directory_noUpdateNodeModulesImport_test.go new file mode 100644 index 00000000000..53073fef9a2 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_directory_noUpdateNodeModulesImport_test.go @@ -0,0 +1,24 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_directory_noUpdateNodeModulesImport" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_directory_noUpdateNodeModulesImport(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a/b/file1.ts +import { foo } from "foo"; +// @Filename: /a/b/node_modules/foo/index.d.ts +export const foo = 0;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a/b", "/a/d", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_directory_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_directory_test.go new file mode 100644 index 00000000000..a1ad90abcc0 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_directory_test.go @@ -0,0 +1,59 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_directory" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_directory(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +/// +import old from "./src/old"; +import old2 from "./src/old/file"; +export default 0; +// @Filename: /src/b.ts +/// +import old from "./old"; +import old2 from "./old/file"; +export default 0; +// @Filename: /src/foo/c.ts +/// +import old from "../old"; +import old2 from "../old/file"; +export default 0; +// @Filename: /src/old/index.ts +import a from "../../a"; +import a2 from "../b"; +import a3 from "../foo/c"; +import f from "./file"; +export default 0; +// @Filename: /src/old/file.ts +export default 0; +// @Filename: /tsconfig.json +{ "files": ["a.ts", "src/b.ts", "src/foo/c.ts", "src/old/index.ts", "src/old/file.ts"] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old", "/src/new", map[string]string{ + "/a.ts": `/// +import old from "./src/new"; +import old2 from "./src/new/file"; +export default 0;`, + "/src/b.ts": `/// +import old from "./new"; +import old2 from "./new/file"; +export default 0;`, + "/src/foo/c.ts": `/// +import old from "../new"; +import old2 from "../new/file"; +export default 0;`, + "/tsconfig.json": `{ "files": ["a.ts", "src/b.ts", "src/foo/c.ts", "src/new/index.ts", "src/new/file.ts"] }`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go new file mode 100644 index 00000000000..e7ed9b47fb1 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go @@ -0,0 +1,64 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_directory_up" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_directory_up(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +/// +import old from "./src/old"; +import old2 from "./src/old/file"; +export default 0; +// @Filename: /src/b.ts +/// +import old from "./old"; +import old2 from "./old/file"; +export default 0; +// @Filename: /src/foo/c.ts +/// +import old from "../old"; +import old2 from "../old/file"; +export default 0; +// @Filename: /src/old/index.ts +import a from "../../a"; +import a2 from "../b"; +import a3 from "../foo/c"; +import f from "./file"; +export default 0; +// @Filename: /src/old/file.ts +export default 0; +// @Filename: /tsconfig.json +{ "files": ["a.ts", "src/b.ts", "src/foo/c.ts", "src/old/index.ts", "src/old/file.ts"] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old", "/newDir/new", map[string]string{ + "/a.ts": `/// +import old from "./newDir/new"; +import old2 from "./newDir/new/file"; +export default 0;`, + "/src/b.ts": `/// +import old from "../newDir/new"; +import old2 from "../newDir/new/file"; +export default 0;`, + "/src/foo/c.ts": `/// +import old from "../../newDir/new"; +import old2 from "../../newDir/new/file"; +export default 0;`, + "/src/old/index.ts": `import a from "../../a"; +import a2 from "../../src/b"; +import a3 from "../../src/foo/c"; +import f from "./file"; +export default 0;`, + "/tsconfig.json": `{ "files": ["a.ts", "src/b.ts", "src/foo/c.ts", "newDir/new/index.ts", "newDir/new/file.ts"] }`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go new file mode 100644 index 00000000000..977f50fdb5b --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go @@ -0,0 +1,27 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_jsExtension" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_jsExtension(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @allowJs: true +// @Filename: /src/a.js +export const a = 0; +// @Filename: /b.js +import { a } from "./src/a.js";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/b.js", "/src/b.js", map[string]string{ + "/b.js": `import { a } from "./a.js";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_js_simple_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_js_simple_test.go new file mode 100644 index 00000000000..11f5faeeb07 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_js_simple_test.go @@ -0,0 +1,27 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_js_simple" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_js_simple(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @allowJs: true +// @Filename: /a.js +import b from "./b.js"; +// @Filename: /b.js +module.exports = 1;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/b.js", "/c.js", map[string]string{ + "/a.js": `import b from "./c.js";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_keepFileExtensions_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_keepFileExtensions_test.go new file mode 100644 index 00000000000..21c9a34664c --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_keepFileExtensions_test.go @@ -0,0 +1,33 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_keepFileExtensions" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_keepFileExtensions(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /tsconfig.json +{ + "compilerOptions": { + "module": "Node16", + "rootDirs": ["src"] + } +} +// @Filename: /src/person.ts +export const name = 0; +// @Filename: /src/index.ts +import {name} from "./person.js";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/person.ts", "/src/vip.ts", map[string]string{ + "/src/index.ts": `import {name} from "./vip.js";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_nodeModuleDirectoryCase_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_nodeModuleDirectoryCase_test.go new file mode 100644 index 00000000000..ef1abd077bb --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_nodeModuleDirectoryCase_test.go @@ -0,0 +1,24 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_nodeModuleDirectoryCase" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_nodeModuleDirectoryCase(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a/b/file1.ts +import { foo } from "foo"; +// @Filename: /a/node_modules/foo/index.d.ts +export const foo = 0;` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a/b", "/a/B", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_notAffectedByJsFile_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_notAffectedByJsFile_test.go new file mode 100644 index 00000000000..76e06dc85f1 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_notAffectedByJsFile_test.go @@ -0,0 +1,28 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_notAffectedByJsFile" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_notAffectedByJsFile(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +export const x = 0; +// @Filename: /a.js +exports.x = 0; +// @Filename: /b.ts +import { x } from "./a";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a.ts", "/a2.ts", map[string]string{ + "/b.ts": `import { x } from "./a2";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go new file mode 100644 index 00000000000..2a09b219216 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go @@ -0,0 +1,31 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_preferences" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/ls/lsutil" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_preferences(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /dir/a.ts +export const a = 0; +// @Filename: /dir/b.ts +import {} from "dir/a"; +import {} from 'dir/a'; +// @Filename: /tsconfig.json +{"compilerOptions":{"paths":{"*":["*"]}}}` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/dir/a.ts", "/dir/a1.ts", map[string]string{ + "/dir/b.ts": `import {} from "dir/a1"; +import {} from 'dir/a1';`, + }, &lsutil.UserPreferences{ImportModuleSpecifierPreference: "non-relative", QuotePreference: lsutil.QuotePreference("single")}) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go new file mode 100644 index 00000000000..fc335b8c9c5 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go @@ -0,0 +1,45 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_preservePathEnding" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_preservePathEnding(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @allowJs: true +// @checkJs: true +// @strict: true +// @jsx: preserve +// @resolveJsonModule: true +// @Filename: /index.js +export const x = 0; +// @Filename: /jsx.jsx +export const y = 0; +// @Filename: /j.jonah.json +{ "j": 0 } +// @Filename: /a.js +import { x as x0 } from "."; +import { x as x1 } from "./index"; +import { x as x2 } from "./index.js"; +import { y } from "./jsx.jsx"; +import { j } from "./j.jonah.json";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyNoErrors(t) + f.VerifyWillRenameFilesEdits(t, "/a.js", "/b.js", map[string]string{}, nil /*preferences*/) + f.VerifyWillRenameFilesEdits(t, "/b.js", "/src/b.js", map[string]string{ + "/b.js": `import { x as x0 } from ".."; +import { x as x1 } from "../index"; +import { x as x2 } from "../index.js"; +import { y } from "../jsx.jsx"; +import { j } from "../j.jonah.json";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_renameFromIndex_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_renameFromIndex_test.go new file mode 100644 index 00000000000..f3016446528 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_renameFromIndex_test.go @@ -0,0 +1,47 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_renameFromIndex" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_renameFromIndex(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +/// +import old from "./src"; +import old2 from "./src/index"; +// @Filename: /src/a.ts +/// +import old from "."; +import old2 from "./index"; +// @Filename: /src/foo/a.ts +/// +import old from ".."; +import old2 from "../index"; +// @Filename: /src/index.ts + +// @Filename: /tsconfig.json +{ "files": ["a.ts", "src/a.ts", "src/foo/a.ts", "src/index.ts"] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/index.ts", "/src/new.ts", map[string]string{ + "/a.ts": `/// +import old from "./src/new"; +import old2 from "./src/new";`, + "/src/a.ts": `/// +import old from "./new"; +import old2 from "./new";`, + "/src/foo/a.ts": `/// +import old from "../new"; +import old2 from "../new";`, + "/tsconfig.json": `{ "files": ["a.ts", "src/a.ts", "src/foo/a.ts", "src/new.ts"] }`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_renameToIndex_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_renameToIndex_test.go new file mode 100644 index 00000000000..9328dd1b7b1 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_renameToIndex_test.go @@ -0,0 +1,41 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_renameToIndex" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_renameToIndex(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +/// +import old from "./src/old"; +// @Filename: /src/a.ts +/// +import old from "./old"; +// @Filename: /src/foo/a.ts +/// +import old from "../old"; +// @Filename: /src/old.ts + +// @Filename: /tsconfig.json +{ "files": ["a.ts", "src/a.ts", "src/foo/a.ts", "src/old.ts"] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/index.ts", map[string]string{ + "/a.ts": `/// +import old from "./src";`, + "/src/a.ts": `/// +import old from ".";`, + "/src/foo/a.ts": `/// +import old from "..";`, + "/tsconfig.json": `{ "files": ["a.ts", "src/a.ts", "src/foo/a.ts", "src/index.ts"] }`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go new file mode 100644 index 00000000000..0865a5adcb3 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go @@ -0,0 +1,27 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_resolveJsonModule" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_resolveJsonModule(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @resolveJsonModule: true +// @Filename: /a.ts +import text from "./message.json"; +// @Filename: /message.json +{}` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a.ts", "/src/a.ts", map[string]string{ + "/a.ts": `import text from "../message.json";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go new file mode 100644 index 00000000000..b405057609b --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_shortenRelativePaths" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_shortenRelativePaths(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /src/foo/x.ts + +// @Filename: /src/old.ts +import { x } from "./foo/x";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/foo/new.ts", map[string]string{ + "/src/old.ts": `import { x } from "./x";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go new file mode 100644 index 00000000000..b5ebaad931c --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_subDir" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_subDir(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /src/foo/a.ts + +// @Filename: /src/old.ts +import a from "./foo/a";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/dir/new.ts", map[string]string{ + "/src/old.ts": `import a from "../foo/a";`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_symlink_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_symlink_test.go new file mode 100644 index 00000000000..4f87da1607e --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_symlink_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_symlink" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_symlink(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /foo.ts +// @Symlink: /node_modules/foo/index.ts +export const x = 0; +// @Filename: /user.ts +import { x } from 'foo';` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyNoErrors(t) + f.VerifyWillRenameFilesEdits(t, "/user.ts", "/luser.ts", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_test.go new file mode 100644 index 00000000000..7528b5b9fc3 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_test.go @@ -0,0 +1,43 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a.ts +/// +import old from "./src/old"; +// @Filename: /src/a.ts +/// +import old from "./old"; +// @Filename: /src/foo/a.ts +/// +import old from "../old"; +// @Filename: /unrelated.ts +import { x } from "././src/./foo/./a"; +// @Filename: /src/old.ts +export default 0; +// @Filename: /tsconfig.json +{ "files": ["a.ts", "src/a.ts", "src/foo/a.ts", "src/old.ts"] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/new.ts", map[string]string{ + "/a.ts": `/// +import old from "./src/new";`, + "/src/a.ts": `/// +import old from "./new";`, + "/src/foo/a.ts": `/// +import old from "../new";`, + "/tsconfig.json": `{ "files": ["a.ts", "src/a.ts", "src/foo/a.ts", "src/new.ts"] }`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_empty_include_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_empty_include_test.go new file mode 100644 index 00000000000..a71e229feb0 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_empty_include_test.go @@ -0,0 +1,24 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_tsconfig_empty_include" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_tsconfig_empty_include(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /a/foo.ts +const x = 1 +// @Filename: /a/tsconfig.json +{ "include": [] }` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/a/foo.ts", "/a/bar.ts", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_add_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_add_test.go new file mode 100644 index 00000000000..0f898ac710e --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_add_test.go @@ -0,0 +1,30 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_tsconfig_include_add" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_tsconfig_include_add(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /src/tsconfig.json +{ + "include": ["dir"], +} +// @Filename: /src/dir/a.ts +` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/dir/a.ts", "/src/newDir/b.ts", map[string]string{ + "/src/tsconfig.json": `{ + "include": ["dir", "newDir/b.ts"], +}`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_noChange_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_noChange_test.go new file mode 100644 index 00000000000..bb8fc42dc01 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_include_noChange_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_tsconfig_include_noChange" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_tsconfig_include_noChange(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /src/tsconfig.json +{ + "include": ["dir"], +} +// @Filename: /src/dir/a.ts +` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/dir/a.ts", "/src/dir/b.ts", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_test.go new file mode 100644 index 00000000000..ba4f65a445f --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_tsconfig_test.go @@ -0,0 +1,52 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_tsconfig" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_tsconfig(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /src/tsconfig.json +{ + "compilerOptions": { + "baseUrl": "./old", + "paths": { + "foo": ["old"], + }, + "rootDir": "old", + "rootDirs": ["old"], + "typeRoots": ["old"], + }, + "files": ["old/a.ts"], + "include": ["old/*.ts"], + "exclude": ["old"], +} +// @Filename: /src/old/someFile.ts +` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/src/old", "/src/new", map[string]string{ + "/src/tsconfig.json": `{ + "compilerOptions": { + "baseUrl": "new", + "paths": { + "foo": ["new"], + }, + "rootDir": "new", + "rootDirs": ["new"], + "typeRoots": ["new"], + }, + "files": ["new/a.ts"], + "include": ["new/*.ts"], + "exclude": ["new"], +}`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go new file mode 100644 index 00000000000..1306fe230c8 --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_unaffectedNonRelativePath" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_unaffectedNonRelativePath(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /sub/a.ts +export const a = 1; +// @Filename: /sub/b.ts +import { a } from "sub/a"; +// @Filename: /tsconfig.json +{"compilerOptions":{"paths":{"*":["*"]}}}` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/sub/b.ts", "/sub/c/d.ts", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_unresolvableImport_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_unresolvableImport_test.go new file mode 100644 index 00000000000..53141ade22f --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_unresolvableImport_test.go @@ -0,0 +1,37 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_unresolvableImport" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_unresolvableImport(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /tsconfig.json +{ + "compilerOptions": { + "allowJs": true, + "paths": { + "*": ["./next/src/*"], + "@app": ["./modules/@app/*"], + "@app/*": ["./modules/@app/*"], + "@local": ["./modules/@local/*"], + "@local/*": ["./modules/@local/*"] + } + } +} +// @Filename: /modules/@app/something/index.js +import "@local/some-other-import"; +// @Filename: /modules/@local/index.js +import "@local/some-other-import";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/modules/@app/something", "/modules/@app/something-2", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_unresolvableNodeModule_test.go b/internal/fourslash/tests/gen/getEditsForFileRename_unresolvableNodeModule_test.go new file mode 100644 index 00000000000..23ad2236cda --- /dev/null +++ b/internal/fourslash/tests/gen/getEditsForFileRename_unresolvableNodeModule_test.go @@ -0,0 +1,26 @@ +// Code generated by convertFourslash; DO NOT EDIT. +// To modify this test, run "npm run makemanual getEditsForFileRename_unresolvableNodeModule" + +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_unresolvableNodeModule(t *testing.T) { + fourslash.SkipIfFailing(t) + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @allowJs: true +// @checkJs: true +// @Filename: /modules/@app/something/index.js +import "doesnt-exist"; +// @Filename: /modules/@local/foo.js +import "doesnt-exist"; ` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/modules/@app/something", "/modules/@app/something-2", map[string]string{}, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go new file mode 100644 index 00000000000..c2bb51c2c56 --- /dev/null +++ b/internal/fourslash/tests/importRenameFileFlow_test.go @@ -0,0 +1,218 @@ +package fourslash_test + +import ( + "slices" + "testing" + + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/fourslash" + "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/testutil" + "gotest.tools/v3/assert" +) + +func TestImportPathRenameReturnsRenameFileAndWillRenameEdits(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /src/example.ts +import stuff from './[|stuff|].cts'; +// @Filename: /src/stuff.cts +export = { name: "stuff" }; +` + + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: new(true), + } + + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) + f.GoToRangeStart(t, f.Ranges()[0]) + + renameResult := f.RenameAtCaret(t, "renamed.cts") + assert.Assert(t, renameResult.WorkspaceEdit != nil) + assert.Assert(t, renameResult.WorkspaceEdit.DocumentChanges != nil) + assert.Equal(t, len(*renameResult.WorkspaceEdit.DocumentChanges), 1) + renameChange := (*renameResult.WorkspaceEdit.DocumentChanges)[0].RenameFile + assert.Assert(t, renameChange != nil) + assert.Equal(t, renameChange.OldUri, lsconv.FileNameToDocumentURI("/src/stuff.cts")) + assert.Equal(t, renameChange.NewUri, lsconv.FileNameToDocumentURI("/src/renamed.cts")) + + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/src/stuff.cts")), + NewUri: string(lsconv.FileNameToDocumentURI("/src/renamed.cts")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + edits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/src/example.ts")] + assert.Equal(t, len(edits), 1) + assert.Equal(t, edits[0].NewText, "./renamed.cjs") +} + +func TestImportPathDirectoryRenameReturnsRenameFileAndWillRenameEdits(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /src/example.ts +import dir from './[|dir|]'; +// @Filename: /src/dir/index.ts +export const x = 1; +` + + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: new(true), + } + + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) + f.GoToRangeStart(t, f.Ranges()[0]) + + renameResult := f.RenameAtCaret(t, "renamed") + assert.Assert(t, renameResult.WorkspaceEdit != nil) + assert.Assert(t, renameResult.WorkspaceEdit.DocumentChanges != nil) + assert.Equal(t, len(*renameResult.WorkspaceEdit.DocumentChanges), 1) + renameChange := (*renameResult.WorkspaceEdit.DocumentChanges)[0].RenameFile + assert.Assert(t, renameChange != nil) + assert.Equal(t, renameChange.OldUri, lsconv.FileNameToDocumentURI("/src/dir")) + assert.Equal(t, renameChange.NewUri, lsconv.FileNameToDocumentURI("/src/renamed")) + + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/src/dir")), + NewUri: string(lsconv.FileNameToDocumentURI("/src/renamed")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + edits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/src/example.ts")] + assert.Equal(t, len(edits), 1) + assert.Equal(t, edits[0].NewText, "./renamed") +} + +func TestWillRenameFilesUpdatesTsconfigAndTripleSlashReferences(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /src/app.ts +/// +import { x } from "./old"; +// @Filename: /src/old.ts +export const x = 1; +// @Filename: /tsconfig.json +{ + "files": ["src/app.ts", "src/old.ts"] +} +` + + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: new(true), + } + + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/src/old.ts")), + NewUri: string(lsconv.FileNameToDocumentURI("/src/new.ts")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/src/app.ts")] + assert.Equal(t, len(appEdits), 2) + newTexts := []string{appEdits[0].NewText, appEdits[1].NewText} + slices.Sort(newTexts) + assert.DeepEqual(t, newTexts, []string{"./new", "./new.ts"}) + + tsconfigEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/tsconfig.json")] + assert.Equal(t, len(tsconfigEdits), 1) + assert.Equal(t, tsconfigEdits[0].NewText, "src/new.ts") +} + +func TestImportTypePathRenameReturnsRenameFile(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @module: commonjs +// @Filename: /a.ts +export = 0; +// @Filename: /b.ts +const x: import("[|./a|]") = 0; +` + + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: new(true), + } + + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + + prefsTrue := &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue} + prefsFalse := &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSFalse} + + f.Configure(t, prefsTrue) + f.GoToRangeStart(t, f.Ranges()[0]) + + renameResult := f.RenameAtCaret(t, "renamed.ts") + assert.Assert(t, renameResult.WorkspaceEdit != nil) + assert.Assert(t, renameResult.WorkspaceEdit.DocumentChanges != nil) + assert.Equal(t, len(*renameResult.WorkspaceEdit.DocumentChanges), 1) + renameChange := (*renameResult.WorkspaceEdit.DocumentChanges)[0].RenameFile + assert.Assert(t, renameChange != nil) + assert.Equal(t, renameChange.OldUri, lsconv.FileNameToDocumentURI("/a.ts")) + assert.Equal(t, renameChange.NewUri, lsconv.FileNameToDocumentURI("/renamed.ts")) + + f.Configure(t, prefsFalse) + f.GoToRangeStart(t, f.Ranges()[0]) + f.VerifyRenameFailed(t, prefsFalse) +} + +func TestGlobalImportRenameStillFails(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @allowJs: true +// @module: commonjs +// @Filename: /node_modules/global/index.d.ts +export const x: number; +// @Filename: /c.js +const global = require("/*global*/global"); +` + + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + + prefsTrue := &lsutil.UserPreferences{ + IncludeCompletionsForModuleExports: core.TSTrue, + IncludeCompletionsForImportStatements: core.TSTrue, + AllowRenameOfImportPath: core.TSTrue, + } + + f.Configure(t, prefsTrue) + f.GoToMarker(t, "global") + f.VerifyRenameFailed(t, prefsTrue) +} diff --git a/internal/ls/autoimport/specifiers.go b/internal/ls/autoimport/specifiers.go index 676c4a8d25e..9860e95c78c 100644 --- a/internal/ls/autoimport/specifiers.go +++ b/internal/ls/autoimport/specifiers.go @@ -19,7 +19,17 @@ func (v *View) GetModuleSpecifier( return string(export.ModuleID), modulespecifiers.ResultKindAmbient } - if export.PackageName != "" { + moduleFileName := export.ModuleFileName + isSymlinkedPackageExport := false + if export.PackageName != "" && moduleFileName != "" { + realModuleFileName := v.program.Host().FS().Realpath(moduleFileName) + isSymlinkedPackageExport = realModuleFileName != "" && realModuleFileName != moduleFileName + if isSymlinkedPackageExport { + moduleFileName = realModuleFileName + } + } + + if export.PackageName != "" && !isSymlinkedPackageExport { if entrypoints, ok := v.registry.entrypoints[export.Path]; ok { for _, entrypoint := range entrypoints { if entrypoint.IncludeConditions.IsSubsetOf(v.conditions) && !v.conditions.Intersects(entrypoint.ExcludeConditions) { @@ -53,7 +63,7 @@ func (v *View) GetModuleSpecifier( specifiers, kind := modulespecifiers.GetModuleSpecifiersForFileWithInfo( v.importingFile, - export.ModuleFileName, + moduleFileName, v.program.Options(), v.program, userPreferences, diff --git a/internal/ls/codeactions_importfixes.go b/internal/ls/codeactions_importfixes.go index d450c37a26c..741db29c8d3 100644 --- a/internal/ls/codeactions_importfixes.go +++ b/internal/ls/codeactions_importfixes.go @@ -132,9 +132,46 @@ func getFixInfos(ctx context.Context, fixContext *CodeFixContext, errorCode int3 if view == nil { view = fixContext.LS.getCurrentAutoImportView(fixContext.SourceFile) } + info = dedupeExactFixInfos(info) return sortFixInfo(info, fixContext, view), nil } +func dedupeExactFixInfos(fixes []*fixInfo) []*fixInfo { + if len(fixes) < 2 { + return fixes + } + + type key struct { + symbolName string + fixKind lsproto.AutoImportFixKind + importKind lsproto.ImportKind + addAsTypeOnly lsproto.AddAsTypeOnly + name string + moduleSpecifier string + useRequire bool + } + + seen := make(map[key]struct{}, len(fixes)) + result := make([]*fixInfo, 0, len(fixes)) + for _, info := range fixes { + k := key{ + symbolName: info.symbolName, + fixKind: info.fix.Kind, + importKind: info.fix.ImportKind, + addAsTypeOnly: info.fix.AddAsTypeOnly, + name: info.fix.Name, + moduleSpecifier: info.fix.ModuleSpecifier, + useRequire: info.fix.UseRequire, + } + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + result = append(result, info) + } + return result +} + func getFixesInfoForUMDImport(ctx context.Context, fixContext *CodeFixContext, token *ast.Node, view *autoimport.View) []*fixInfo { ch, done := fixContext.Program.GetTypeChecker(ctx) defer done() diff --git a/internal/ls/crossproject.go b/internal/ls/crossproject.go index b43b9335197..9f7fb2bf515 100644 --- a/internal/ls/crossproject.go +++ b/internal/ls/crossproject.go @@ -324,11 +324,23 @@ func combineImplementations(results iter.Seq[lsproto.ImplementationResponse]) ls 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]) - // !!! this is not used any more so we will skip this part of deduplication and combining - // DocumentChanges *[]TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile `json:"documentChanges,omitzero"` - // ChangeAnnotations *map[string]*ChangeAnnotation `json:"changeAnnotations,omitzero"` + var documentChanges []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile + seenRenames := collections.Set[[2]lsproto.DocumentUri]{} for resp := range results { + if resp.WorkspaceEdit != nil && resp.WorkspaceEdit.DocumentChanges != nil { + for _, change := range *resp.WorkspaceEdit.DocumentChanges { + switch { + case change.RenameFile != nil: + key := [2]lsproto.DocumentUri{change.RenameFile.OldUri, change.RenameFile.NewUri} + if seenRenames.AddIfAbsent(key) { + documentChanges = append(documentChanges, change) + } + default: + documentChanges = append(documentChanges, change) + } + } + } if resp.WorkspaceEdit != nil && resp.WorkspaceEdit.Changes != nil { for doc, changes := range *resp.WorkspaceEdit.Changes { seenSet, ok := seenChanges[doc] @@ -350,11 +362,16 @@ func combineRenameResponse(results iter.Seq[lsproto.RenameResponse]) lsproto.Ren } } } - if len(combined) > 0 { + if len(documentChanges) > 0 || len(combined) > 0 { + workspaceEdit := &lsproto.WorkspaceEdit{} + if len(documentChanges) > 0 { + workspaceEdit.DocumentChanges = &documentChanges + } + if len(combined) > 0 { + workspaceEdit.Changes = &combined + } return lsproto.RenameResponse{ - WorkspaceEdit: &lsproto.WorkspaceEdit{ - Changes: &combined, - }, + WorkspaceEdit: workspaceEdit, } } return lsproto.RenameResponse{} diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go new file mode 100644 index 00000000000..d9e8df01b57 --- /dev/null +++ b/internal/ls/filerename.go @@ -0,0 +1,456 @@ +package ls + +import ( + "context" + "strings" + + "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/change" + "github.com/microsoft/typescript-go/internal/ls/lsconv" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/module" + "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) map[lsproto.DocumentUri][]*lsproto.TextEdit { + program := l.GetProgram() + oldPath := oldURI.FileName() + newPath := newURI.FileName() + + oldToNew := l.createPathUpdater(oldPath, newPath) + newToOld := l.createPathUpdater(newPath, oldPath) + + changeTracker := change.NewTracker(ctx, program.Options(), l.FormatOptions(), l.converters) + l.updateTsconfigFiles(program, changeTracker, oldToNew, oldPath, newPath) + l.updateImportsForFileRename(program, changeTracker, oldToNew, newToOld) + + result := map[lsproto.DocumentUri][]*lsproto.TextEdit{} + for fileName, edits := range changeTracker.GetChanges() { + result[lsconv.FileNameToDocumentURI(fileName)] = edits + } + return result +} + +func (l *LanguageService) createPathUpdater(oldPath string, newPath string) pathUpdater { + compareOptions := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: l.UseCaseSensitiveFileNames()} + getUpdatedPath := 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 + } + + return func(path string) (string, bool) { + if original := l.tryGetSourcePosition(path, 0); original != nil { + if updated, ok := getUpdatedPath(original.FileName); ok { + return makeCorrespondingRelativeChange(original.FileName, updated, path, compareOptions), true + } + } + return getUpdatedPath(path) + } +} + +func makeCorrespondingRelativeChange(a0 string, b0 string, a1 string, compareOptions tspath.ComparePathsOptions) string { + rel := tspath.GetRelativePathFromFile(a0, b0, compareOptions) + return tspath.CombinePaths(tspath.GetDirectoryPath(a1), rel) +} + +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) updateImportsForFileRename(program *compiler.Program, changeTracker *change.Tracker, oldToNew pathUpdater, newToOld pathUpdater) { + allFiles := program.GetSourceFiles() + checker, done := program.GetTypeChecker(context.Background()) + defer done() + moduleSpecifierPreferences := l.UserPreferences().ModuleSpecifierPreferences() + + for _, sourceFile := range allFiles { + newFromOld, hasNewFromOld := oldToNew(sourceFile.FileName()) + oldFromNew, hasOldFromNew := newToOld(sourceFile.FileName()) + newImportFromPath := sourceFile.FileName() + if hasNewFromOld { + newImportFromPath = newFromOld + } + oldImportFromPath := sourceFile.FileName() + if hasOldFromNew { + oldImportFromPath = oldFromNew + } + importingSourceFileMoved := hasNewFromOld || hasOldFromNew + + for _, ref := range sourceFile.ReferencedFiles { + if !tspath.IsExternalModuleNameRelative(ref.FileName) { + continue + } + oldAbsolute := tspath.NormalizePath(tspath.CombinePaths(tspath.GetDirectoryPath(oldImportFromPath), ref.FileName)) + newAbsolute, ok := oldToNew(oldAbsolute) + if !ok { + continue + } + updated := relativeImportPathFromDirectory(tspath.GetDirectoryPath(newImportFromPath), newAbsolute, l.UseCaseSensitiveFileNames()) + 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, (*ast.StringLiteralLike)(importStringLiteral), oldToNew, newToOld, newImportFromPath, oldImportFromPath, importingSourceFileMoved, moduleSpecifierPreferences) + if updated != "" && updated != importStringLiteral.Text() { + changeTracker.ReplaceRangeWithText(sourceFile, l.converters.ToLSPRange(sourceFile, createStringTextRange(sourceFile, importStringLiteral)), updated) + } + } + } +} + +func (l *LanguageService) getUpdatedImportSpecifier(program *compiler.Program, checker interface { + GetSymbolAtLocation(node *ast.Node) *ast.Symbol +}, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, newToOld pathUpdater, newImportFromPath string, oldImportFromPath string, importingSourceFileMoved bool, userPreferences modulespecifiers.UserPreferences) string { + importedModuleSymbol := checker.GetSymbolAtLocation(importLiteral) + if isAmbientModuleSymbol(importedModuleSymbol) { + return "" + } + + if updated := getUpdatedImportSpecifierFromMovedSourceFiles(program, sourceFile, importLiteral, oldToNew, newImportFromPath, userPreferences); updated != "" && updated != importLiteral.Text() { + return updated + } + + var target *toImport + if _, hasOldFromNew := newToOld(sourceFile.FileName()); hasOldFromNew { + resolutionMode := program.GetModeForUsageLocation(sourceFile, importLiteral) + target = getSourceFileToImportFromResolved(importLiteral, program.ResolveModuleName(importLiteral.Text(), oldImportFromPath, resolutionMode), oldToNew, program.GetSourceFiles()) + } else { + target = getSourceFileToImport(program, importedModuleSymbol, sourceFile, importLiteral, oldToNew, userPreferences) + } + + if target == nil { + if importingSourceFileMoved && tspath.IsExternalModuleNameRelative(importLiteral.Text()) { + absoluteTarget := tspath.NormalizePath(tspath.CombinePaths(tspath.GetDirectoryPath(sourceFile.FileName()), importLiteral.Text())) + return relativeImportPathFromDirectory(tspath.GetDirectoryPath(newImportFromPath), absoluteTarget, l.UseCaseSensitiveFileNames()) + } + return "" + } + + 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, importedModuleSymbol *ast.Symbol, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, userPreferences modulespecifiers.UserPreferences) *toImport { + if importedModuleSymbol != nil { + if moduleSourceFile := core.Find(importedModuleSymbol.Declarations, ast.IsSourceFile); moduleSourceFile != nil { + oldFileName := moduleSourceFile.AsSourceFile().FileName() + if newFileName, ok := oldToNew(oldFileName); ok { + return &toImport{newFileName: newFileName, updated: true} + } + return &toImport{newFileName: oldFileName, updated: false} + } + } + + if resolved := program.GetResolvedModuleFromModuleSpecifier(sourceFile, importLiteral); resolved != nil { + return getSourceFileToImportFromResolved(importLiteral, resolved, oldToNew, program.GetSourceFiles()) + } + + resolutionMode := program.GetModeForUsageLocation(sourceFile, importLiteral) + if resolved := program.ResolveModuleName(importLiteral.Text(), sourceFile.FileName(), resolutionMode); resolved != nil { + return getSourceFileToImportFromResolved(importLiteral, resolved, oldToNew, program.GetSourceFiles()) + } + + return getSourceFileToImportFromMovedSourceFiles(program, sourceFile, importLiteral, oldToNew, resolutionMode, userPreferences) +} + +func getSourceFileToImportFromResolved(importLiteral *ast.StringLiteralLike, resolved *module.ResolvedModule, oldToNew pathUpdater, sourceFiles []*ast.SourceFile) *toImport { + if resolved == nil { + return nil + } + + if resolved.IsResolved() { + if result := tryChange(resolved.ResolvedFileName, oldToNew); result != nil { + return result + } + } + + for _, oldFileName := range resolved.FailedLookupLocations { + if result := tryChangeWithIgnoringPackageJSONExisting(oldFileName, oldToNew, sourceFiles); result != nil { + return result + } + } + + if tspath.IsExternalModuleNameRelative(importLiteral.Text()) { + for _, oldFileName := range resolved.FailedLookupLocations { + if result := tryChangeWithIgnoringPackageJSON(oldFileName, oldToNew); result != nil { + return result + } + } + } + + if resolved.IsResolved() { + return &toImport{newFileName: resolved.ResolvedFileName, updated: false} + } + return nil +} + +func tryChangeWithIgnoringPackageJSONExisting(oldFileName string, oldToNew pathUpdater, sourceFiles []*ast.SourceFile) *toImport { + newFileName, ok := oldToNew(oldFileName) + if !ok || !sourceFileExists(sourceFiles, newFileName) { + return nil + } + return tryChangeWithIgnoringPackageJSON(oldFileName, oldToNew) +} + +func tryChangeWithIgnoringPackageJSON(oldFileName string, oldToNew pathUpdater) *toImport { + if strings.HasSuffix(oldFileName, "/package.json") { + return nil + } + return tryChange(oldFileName, oldToNew) +} + +func tryChange(oldFileName string, oldToNew pathUpdater) *toImport { + if newFileName, ok := oldToNew(oldFileName); ok { + return &toImport{newFileName: newFileName, updated: true} + } + return nil +} + +func sourceFileExists(sourceFiles []*ast.SourceFile, fileName string) bool { + for _, sourceFile := range sourceFiles { + if sourceFile.FileName() == fileName { + return true + } + } + return false +} + +func getSourceFileToImportFromMovedSourceFiles(program *compiler.Program, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, resolutionMode core.ResolutionMode, userPreferences modulespecifiers.UserPreferences) *toImport { + for _, candidate := range program.GetSourceFiles() { + newFileName, ok := oldToNew(candidate.FileName()) + if !ok { + continue + } + + moduleSpecifier := modulespecifiers.UpdateModuleSpecifier( + program.Options(), + program, + sourceFile, + sourceFile.FileName(), + importLiteral.Text(), + candidate.FileName(), + userPreferences, + modulespecifiers.ModuleSpecifierOptions{ + OverrideImportMode: resolutionMode, + }, + ) + if moduleSpecifier == importLiteral.Text() { + return &toImport{newFileName: newFileName, updated: true} + } + } + return nil +} + +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 + } + for _, decl := range symbol.Declarations { + if ast.IsModuleWithStringLiteralName(decl) { + return true + } + } + return false +} diff --git a/internal/ls/rename.go b/internal/ls/rename.go index 65dc339bc57..f6e1f372c77 100644 --- a/internal/ls/rename.go +++ b/internal/ls/rename.go @@ -26,9 +26,29 @@ type RenameInfo struct { LocalizedErrorMessage string DisplayName string TriggerSpan lsproto.Range + FileToRename string } func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.RenameParams, orchestrator CrossProjectOrchestrator) (lsproto.WorkspaceEditOrNull, error) { + info := l.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) + if info.CanRename && info.FileToRename != "" { + newPath := tspath.CombinePaths(tspath.GetDirectoryPath(info.FileToRename), params.NewName) + documentChanges := []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ + { + RenameFile: &lsproto.RenameFile{ + Kind: lsproto.StringLiteralRename{}, + OldUri: lsconv.FileNameToDocumentURI(info.FileToRename), + NewUri: lsconv.FileNameToDocumentURI(newPath), + }, + }, + } + return lsproto.WorkspaceEditOrNull{ + WorkspaceEdit: &lsproto.WorkspaceEdit{ + DocumentChanges: &documentChanges, + }, + }, nil + } + return handleCrossProject( l, ctx, @@ -248,9 +268,10 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, node *ast. length := len(node.Text()) - indexAfterLastSlash return RenameInfo{ - CanRename: true, - DisplayName: displayName, - TriggerSpan: l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)), + CanRename: true, + DisplayName: node.Text()[indexAfterLastSlash:], + TriggerSpan: l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)), + FileToRename: displayName, }, true } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index f631de4609c..7e56782ed2f 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -73,6 +73,14 @@ func NewServer(opts *ServerOptions) *Server { } var ( + fileRenameFilters = []*lsproto.FileOperationFilter{ + { + Scheme: new("file"), + Pattern: &lsproto.FileOperationPattern{ + Glob: "**/*", + }, + }, + } _ ata.NpmExecutor = (*Server)(nil) _ project.Client = (*Server)(nil) ) @@ -671,6 +679,7 @@ var handlers = sync.OnceValue(func() handlerMap { registerNotificationHandler(handlers, lsproto.TextDocumentDidCloseInfo, (*Server).handleDidClose) registerNotificationHandler(handlers, lsproto.WorkspaceDidChangeWatchedFilesInfo, (*Server).handleDidChangeWatchedFiles) registerNotificationHandler(handlers, lsproto.SetTraceInfo, (*Server).handleSetTrace) + registerRequestHandler(handlers, lsproto.WorkspaceWillRenameFilesInfo, (*Server).handleWillRenameFiles) registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentDiagnosticInfo, (*Server).handleDocumentDiagnostic) registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentHoverInfo, (*Server).handleHover) @@ -1054,6 +1063,13 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ CallHierarchyProvider: &lsproto.BooleanOrCallHierarchyOptionsOrCallHierarchyRegistrationOptions{ Boolean: new(true), }, + Workspace: &lsproto.WorkspaceOptions{ + FileOperations: &lsproto.FileOperationOptions{ + WillRename: &lsproto.FileOperationRegistrationOptions{ + Filters: fileRenameFilters, + }, + }, + }, }, } @@ -1222,6 +1238,50 @@ func (s *Server) handlePrepareRename(ctx context.Context, languageService *ls.La }, nil } +func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { + if params == nil || len(params.Files) == 0 { + return lsproto.WillRenameFilesResponse{}, nil + } + + uris := make([]lsproto.DocumentUri, 0, len(params.Files)*2) + for _, file := range params.Files { + uris = append(uris, lsproto.DocumentUri(file.OldUri), lsproto.DocumentUri(file.NewUri)) + } + + services := s.session.GetLanguageServicesForDocuments(ctx, uris) + combined := make(map[lsproto.DocumentUri][]*lsproto.TextEdit) + seen := make(map[lsproto.DocumentUri]map[lsproto.Range]string) + + for _, languageService := range services { + for _, file := range params.Files { + for uri, edits := range languageService.GetEditsForFileRename(ctx, lsproto.DocumentUri(file.OldUri), lsproto.DocumentUri(file.NewUri)) { + seenForURI, ok := seen[uri] + if !ok { + seenForURI = map[lsproto.Range]string{} + seen[uri] = seenForURI + } + for _, edit := range edits { + if newText, ok := seenForURI[edit.Range]; ok && newText == edit.NewText { + continue + } + seenForURI[edit.Range] = edit.NewText + combined[uri] = append(combined[uri], edit) + } + } + } + } + + if len(combined) == 0 { + return lsproto.WillRenameFilesResponse{}, nil + } + + return lsproto.WillRenameFilesResponse{ + WorkspaceEdit: &lsproto.WorkspaceEdit{ + Changes: &combined, + }, + }, nil +} + func (s *Server) handleSignatureHelp(ctx context.Context, languageService *ls.LanguageService, params *lsproto.SignatureHelpParams) (lsproto.SignatureHelpResponse, error) { return languageService.ProvideSignatureHelp( ctx, diff --git a/internal/module/resolver.go b/internal/module/resolver.go index 2a269297265..9b18e0c3cb5 100644 --- a/internal/module/resolver.go +++ b/internal/module/resolver.go @@ -84,6 +84,7 @@ type resolutionState struct { candidateIsFromPackageJsonField bool resolvedPackageDirectory bool diagnostics []*ast.Diagnostic + failedLookupLocations []string // Similar to whats on resolver but only done if compilerOptions are for project reference redirect // Cached representation for `core.CompilerOptions.paths`. @@ -1120,6 +1121,7 @@ func (r *resolutionState) createResolvedModuleHandlingSymlink(resolved *resolved func (r *resolutionState) createResolvedModule(resolved *resolved, isExternalLibraryImport bool) *ResolvedModule { var resolvedModule ResolvedModule resolvedModule.ResolutionDiagnostics = r.diagnostics + resolvedModule.FailedLookupLocations = r.failedLookupLocations if resolved != nil { resolvedModule.ResolvedFileName = resolved.path @@ -1543,6 +1545,7 @@ func (r *resolutionState) tryFileLookup(fileName string) bool { } else if r.tracer != nil { r.tracer.write(diagnostics.File_0_does_not_exist, fileName) } + r.failedLookupLocations = append(r.failedLookupLocations, fileName) return false } diff --git a/internal/module/types.go b/internal/module/types.go index 32d18530295..80944ad0001 100644 --- a/internal/module/types.go +++ b/internal/module/types.go @@ -64,6 +64,7 @@ func (p *PackageId) PackageName() string { type ResolvedModule struct { ResolutionDiagnostics []*ast.Diagnostic + FailedLookupLocations []string ResolvedFileName string OriginalPath string Extension string diff --git a/internal/modulespecifiers/specifiers.go b/internal/modulespecifiers/specifiers.go index 9259b65063a..c8f161da408 100644 --- a/internal/modulespecifiers/specifiers.go +++ b/internal/modulespecifiers/specifiers.go @@ -1317,7 +1317,50 @@ func GetModuleSpecifier( toFileName string, options ModuleSpecifierOptions, ) string { - userPreferences := UserPreferences{} + return getModuleSpecifierWithPreferences( + compilerOptions, + host, + importingSourceFile, + importingSourceFileName, + oldImportSpecifier, + toFileName, + UserPreferences{}, + options, + ) +} + +func UpdateModuleSpecifier( + compilerOptions *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + importingSourceFile *ast.SourceFile, + importingSourceFileName string, + oldImportSpecifier string, + toFileName string, + userPreferences UserPreferences, + options ModuleSpecifierOptions, +) string { + return getModuleSpecifierWithPreferences( + compilerOptions, + host, + importingSourceFile, + importingSourceFileName, + oldImportSpecifier, + toFileName, + userPreferences, + options, + ) +} + +func getModuleSpecifierWithPreferences( + compilerOptions *core.CompilerOptions, + host ModuleSpecifierGenerationHost, + importingSourceFile *ast.SourceFile, // !!! | FutureSourceFile + importingSourceFileName string, + oldImportSpecifier string, // used only in updatingModuleSpecifier + toFileName string, + userPreferences UserPreferences, + options ModuleSpecifierOptions, +) string { info := getInfo(importingSourceFileName, host) modulePaths := getAllModulePaths(info, toFileName, host, compilerOptions, userPreferences, options) preferences := getModuleSpecifierPreferences(userPreferences, host, compilerOptions, importingSourceFile, oldImportSpecifier) @@ -1330,9 +1373,8 @@ func GetModuleSpecifier( for _, modulePath := range modulePaths { if firstDefined := tryGetModuleNameAsNodeModule(modulePath, info, importingSourceFile, host, compilerOptions, userPreferences, false /*packageNameOnly*/, options.OverrideImportMode); len(firstDefined) > 0 { return firstDefined - } else if firstDefined := getLocalModuleSpecifier(toFileName, info, compilerOptions, host, resolutionMode, preferences, false); len(firstDefined) > 0 { - return firstDefined } } - return "" + + return getLocalModuleSpecifier(toFileName, info, compilerOptions, host, resolutionMode, preferences, false) } diff --git a/internal/project/session.go b/internal/project/session.go index ec35008fef9..add98b42f47 100644 --- a/internal/project/session.go +++ b/internal/project/session.go @@ -564,6 +564,26 @@ func (s *Session) GetProjectsForFile(ctx context.Context, uri lsproto.DocumentUr return allProjects, nil } +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 +} + func (s *Session) GetLanguageServiceForProjectWithFile(ctx context.Context, project *Project, uri lsproto.DocumentUri) *ls.LanguageService { snapshot := s.getSnapshot( ctx, diff --git a/internal/testrunner/test_case_parser.go b/internal/testrunner/test_case_parser.go index 0346bfb91e3..3cd21723a39 100644 --- a/internal/testrunner/test_case_parser.go +++ b/internal/testrunner/test_case_parser.go @@ -41,7 +41,8 @@ type testCaseContent struct { var optionRegex = regexp.MustCompile(`(?m)^\/{2}\s*@(\w+)\s*:\s*([^\r\n]*)`) // Regex for parsing @link option -var linkRegex = regexp.MustCompile(`(?m)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) +var linkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) +var symlinkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@symlink\s*:\s*([^\r\n]*)`) // File-specific directives used by fourslash tests var fourslashDirectives = []string{"emitthisfile"} @@ -153,7 +154,7 @@ func ParseTestFilesAndSymlinksWithOptions[T any]( globalOptions = make(map[string]string) for _, line := range lines { - ok := parseSymlinkFromTest(line, symlinks) + ok := parseSymlinkFromTest(line, currentFileName, symlinks) if ok { continue } @@ -260,10 +261,15 @@ func extractCompilerSettings(content string) rawCompilerSettings { return opts } -func parseSymlinkFromTest(line string, symlinks map[string]string) bool { +func parseSymlinkFromTest(line string, currentFileName string, symlinks map[string]string) bool { linkMetaData := linkRegex.FindStringSubmatch(line) if len(linkMetaData) == 0 { - return false + symlinkMetadata := symlinkRegex.FindStringSubmatch(line) + if len(symlinkMetadata) == 0 || currentFileName == "" { + return false + } + symlinks[strings.TrimSpace(symlinkMetadata[1])] = currentFileName + return true } symlinks[strings.TrimSpace(linkMetaData[2])] = strings.TrimSpace(linkMetaData[1]) diff --git a/internal/testutil/harnessutil/harnessutil.go b/internal/testutil/harnessutil/harnessutil.go index 44b7433405c..610939726f6 100644 --- a/internal/testutil/harnessutil/harnessutil.go +++ b/internal/testutil/harnessutil/harnessutil.go @@ -280,6 +280,21 @@ func SetCompilerOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration } } +func SetHarnessOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, harnessOptions *HarnessOptions, currentDirectory string) { + for name, value := range testConfig { + if name == "typescriptversion" { + continue + } + + harnessOption := getHarnessOption(name) + if harnessOption == nil { + continue + } + parsedValue := getOptionValue(t, harnessOption, value, currentDirectory) + parseHarnessOption(t, harnessOption.Name, parsedValue, harnessOptions) + } +} + func setOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, compilerOptions *core.CompilerOptions, harnessOptions *HarnessOptions, currentDirectory string) { for name, value := range testConfig { if name == "typescriptversion" { diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt index d9bd0657141..423bfdbe46d 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt @@ -1,14 +1,16 @@ +/src/app.ts(6,5): error TS2454: Variable 'y' is used before being assigned. /src/library-a/index.ts(1,32): error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. -/src/library-b/index.ts(1,23): error TS2307: Cannot find module 'library-a' or its corresponding type declarations. -==== /src/app.ts (0 errors) ==== +==== /src/app.ts (1 errors) ==== import { MyClass } from "./library-a"; import { MyClass2 } from "./library-b"; let x: MyClass; let y: MyClass2; x = y; + ~ +!!! error TS2454: Variable 'y' is used before being assigned. y = x; /* @@ -35,9 +37,7 @@ ~ !!! error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. -==== /src/library-b/index.ts (1 errors) ==== +==== /src/library-b/index.ts (0 errors) ==== import {MyClass} from "library-a"; - ~~~~~~~~~~~ -!!! error TS2307: Cannot find module 'library-a' or its corresponding type declarations. export { MyClass as MyClass2 } \ No newline at end of file diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt.diff b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt.diff deleted file mode 100644 index 4daeeba33c8..00000000000 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.errors.txt.diff +++ /dev/null @@ -1,34 +0,0 @@ ---- old.moduleResolutionWithSymlinks.errors.txt -+++ new.moduleResolutionWithSymlinks.errors.txt -@@= skipped -0, +0 lines =@@ --/src/app.ts(6,5): error TS2454: Variable 'y' is used before being assigned. - /src/library-a/index.ts(1,32): error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. -- -- --==== /src/app.ts (1 errors) ==== -+/src/library-b/index.ts(1,23): error TS2307: Cannot find module 'library-a' or its corresponding type declarations. -+ -+ -+==== /src/app.ts (0 errors) ==== - import { MyClass } from "./library-a"; - import { MyClass2 } from "./library-b"; - - let x: MyClass; - let y: MyClass2; - x = y; -- ~ --!!! error TS2454: Variable 'y' is used before being assigned. - y = x; - - /* -@@= skipped -36, +34 lines =@@ - ~ - !!! error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. - --==== /src/library-b/index.ts (0 errors) ==== -+==== /src/library-b/index.ts (1 errors) ==== - import {MyClass} from "library-a"; -+ ~~~~~~~~~~~ -+!!! error TS2307: Cannot find module 'library-a' or its corresponding type declarations. - export { MyClass as MyClass2 } - \ No newline at end of file diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.trace.json b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.trace.json index 539976fd6f3..f884d587e9d 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.trace.json +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.trace.json @@ -30,11 +30,10 @@ File '/src/package.json' does not exist. File '/package.json' does not exist. Loading module 'library-a' from 'node_modules' folder, target file types: TypeScript, JavaScript, Declaration, JSON. Searching all ancestor node_modules directories for preferred extensions: TypeScript, Declaration. -Directory '/src/library-b/node_modules' does not exist, skipping all lookups in it. -Directory '/src/node_modules' does not exist, skipping all lookups in it. -Directory '/node_modules' does not exist, skipping all lookups in it. -Searching all ancestor node_modules directories for fallback extensions: JavaScript, JSON. -Directory '/src/library-b/node_modules' does not exist, skipping all lookups in it. -Directory '/src/node_modules' does not exist, skipping all lookups in it. -Directory '/node_modules' does not exist, skipping all lookups in it. -======== Module name 'library-a' was not resolved. ======== +File '/src/library-b/node_modules/library-a/package.json' does not exist. +File '/src/library-b/node_modules/library-a.ts' does not exist. +File '/src/library-b/node_modules/library-a.tsx' does not exist. +File '/src/library-b/node_modules/library-a.d.ts' does not exist. +File '/src/library-b/node_modules/library-a/index.ts' exists - use it as a name resolution result. +Resolving real path for '/src/library-b/node_modules/library-a/index.ts', result '/src/library-a/index.ts'. +======== Module name 'library-a' was successfully resolved to '/src/library-a/index.ts'. ======== diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types index 73ee40248b5..9ec9438ecfd 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types @@ -5,22 +5,22 @@ import { MyClass } from "./library-a"; >MyClass : typeof MyClass import { MyClass2 } from "./library-b"; ->MyClass2 : any +>MyClass2 : typeof MyClass let x: MyClass; >x : MyClass let y: MyClass2; ->y : MyClass2 +>y : MyClass x = y; ->x = y : MyClass2 +>x = y : MyClass >x : MyClass ->y : MyClass2 +>y : MyClass y = x; >y = x : MyClass ->y : MyClass2 +>y : MyClass >x : MyClass /* @@ -49,9 +49,9 @@ export class MyClass { private x: number; } === /src/library-b/index.ts === import {MyClass} from "library-a"; ->MyClass : any +>MyClass : typeof MyClass export { MyClass as MyClass2 } ->MyClass : any ->MyClass2 : any +>MyClass : typeof MyClass +>MyClass2 : typeof MyClass diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types.diff b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types.diff deleted file mode 100644 index 44cf6155cca..00000000000 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks.types.diff +++ /dev/null @@ -1,42 +0,0 @@ ---- old.moduleResolutionWithSymlinks.types -+++ new.moduleResolutionWithSymlinks.types -@@= skipped -4, +4 lines =@@ - >MyClass : typeof MyClass - - import { MyClass2 } from "./library-b"; -->MyClass2 : typeof MyClass -+>MyClass2 : any - - let x: MyClass; - >x : MyClass - - let y: MyClass2; -->y : MyClass -+>y : MyClass2 - - x = y; -->x = y : MyClass -+>x = y : MyClass2 - >x : MyClass -->y : MyClass -+>y : MyClass2 - - y = x; - >y = x : MyClass -->y : MyClass -+>y : MyClass2 - >x : MyClass - - /* -@@= skipped -44, +44 lines =@@ - - === /src/library-b/index.ts === - import {MyClass} from "library-a"; -->MyClass : typeof MyClass -+>MyClass : any - - export { MyClass as MyClass2 } -->MyClass : typeof MyClass -->MyClass2 : typeof MyClass -+>MyClass : any -+>MyClass2 : any diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_notInNodeModules.trace.json b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_notInNodeModules.trace.json index 9ba9957f760..bf4a6859570 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_notInNodeModules.trace.json +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_notInNodeModules.trace.json @@ -2,7 +2,12 @@ Module resolution kind is not specified, using 'Bundler'. Resolving in CJS mode with conditions 'require', 'types'. Loading module as file / folder, candidate module location '/src/shared/abc', target file types: TypeScript, JavaScript, Declaration, JSON. -Directory '/src/shared' does not exist, skipping all lookups in it. +File '/src/shared/abc.ts' does not exist. +File '/src/shared/abc.tsx' does not exist. +File '/src/shared/abc.d.ts' does not exist. +File '/src/shared/abc.js' does not exist. +File '/src/shared/abc.jsx' does not exist. +Directory '/src/shared/abc' does not exist, skipping all lookups in it. ======== Module name './shared/abc' was not resolved. ======== ======== Resolving module './shared2/abc' from '/src/app.ts'. ======== Module resolution kind is not specified, using 'Bundler'. diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_preserveSymlinks.trace.json b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_preserveSymlinks.trace.json index 288abd16db9..7211ab490bf 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_preserveSymlinks.trace.json +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_preserveSymlinks.trace.json @@ -4,7 +4,9 @@ Directory '/.src/node_modules/@types' does not exist, skipping all lookups in it Directory '/node_modules/@types' does not exist, skipping all lookups in it. Looking up in 'node_modules' folder, initial location '/app'. Searching all ancestor node_modules directories for preferred extensions: Declaration. +File '/app/node_modules/linked/package.json' does not exist. File '/app/node_modules/linked.d.ts' does not exist. +File '/app/node_modules/linked/index.d.ts' does not exist. Directory '/app/node_modules/@types' does not exist, skipping all lookups in it. Directory '/node_modules' does not exist, skipping all lookups in it. ======== Type reference directive 'linked' was not resolved. ======== @@ -15,14 +17,21 @@ File '/app/package.json' does not exist. File '/package.json' does not exist. Loading module 'linked' from 'node_modules' folder, target file types: TypeScript, JavaScript, Declaration, JSON. Searching all ancestor node_modules directories for preferred extensions: TypeScript, Declaration. +File '/app/node_modules/linked/package.json' does not exist according to earlier cached lookups. File '/app/node_modules/linked.ts' does not exist. File '/app/node_modules/linked.tsx' does not exist. File '/app/node_modules/linked.d.ts' does not exist according to earlier cached lookups. +File '/app/node_modules/linked/index.ts' does not exist. +File '/app/node_modules/linked/index.tsx' does not exist. +File '/app/node_modules/linked/index.d.ts' does not exist according to earlier cached lookups. Directory '/app/node_modules/@types' does not exist, skipping all lookups in it. Directory '/node_modules' does not exist, skipping all lookups in it. Searching all ancestor node_modules directories for fallback extensions: JavaScript, JSON. +File '/app/node_modules/linked/package.json' does not exist according to earlier cached lookups. File '/app/node_modules/linked.js' does not exist. File '/app/node_modules/linked.jsx' does not exist. +File '/app/node_modules/linked/index.js' does not exist. +File '/app/node_modules/linked/index.jsx' does not exist. Directory '/node_modules' does not exist, skipping all lookups in it. ======== Module name 'linked' was not resolved. ======== ======== Resolving module 'linked2' from '/app/app.ts'. ======== diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_referenceTypes.trace.json b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_referenceTypes.trace.json index 42e95cdd9e8..1b576afd1f9 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_referenceTypes.trace.json +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_referenceTypes.trace.json @@ -22,11 +22,9 @@ Resolving real path for '/node_modules/@types/library-b/index.d.ts', result '/no Root directory cannot be determined, skipping primary search paths. Looking up in 'node_modules' folder, initial location '/node_modules/@types/library-b'. Searching all ancestor node_modules directories for preferred extensions: Declaration. -Directory '/node_modules/@types/library-b/node_modules' does not exist, skipping all lookups in it. -Directory '/node_modules/@types/node_modules' does not exist, skipping all lookups in it. -File '/node_modules/library-a.d.ts' does not exist according to earlier cached lookups. -File '/node_modules/@types/library-a/package.json' does not exist according to earlier cached lookups. -File '/node_modules/@types/library-a.d.ts' does not exist according to earlier cached lookups. -File '/node_modules/@types/library-a/index.d.ts' exists - use it as a name resolution result. -Resolving real path for '/node_modules/@types/library-a/index.d.ts', result '/node_modules/@types/library-a/index.d.ts'. +File '/node_modules/@types/library-b/node_modules/library-a.d.ts' does not exist. +File '/node_modules/@types/library-b/node_modules/@types/library-a/package.json' does not exist. +File '/node_modules/@types/library-b/node_modules/@types/library-a.d.ts' does not exist. +File '/node_modules/@types/library-b/node_modules/@types/library-a/index.d.ts' exists - use it as a name resolution result. +Resolving real path for '/node_modules/@types/library-b/node_modules/@types/library-a/index.d.ts', result '/node_modules/@types/library-a/index.d.ts'. ======== Type reference directive 'library-a' was successfully resolved to '/node_modules/@types/library-a/index.d.ts', primary: false. ======== diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt index 7df827d7984..aacaf6ba713 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt @@ -1,14 +1,16 @@ +/src/app.ts(6,5): error TS2454: Variable 'y' is used before being assigned. /src/library-a/index.ts(1,32): error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. -/src/library-b/index.ts(1,23): error TS2307: Cannot find module 'library-a' or its corresponding type declarations. -==== /src/app.ts (0 errors) ==== +==== /src/app.ts (1 errors) ==== import { MyClass } from "./library-a"; import { MyClass2 } from "./library-b"; let x: MyClass; let y: MyClass2; x = y; + ~ +!!! error TS2454: Variable 'y' is used before being assigned. y = x; ==== /src/library-a/index.ts (1 errors) ==== @@ -16,9 +18,7 @@ ~ !!! error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. -==== /src/library-b/index.ts (1 errors) ==== +==== /src/library-b/index.ts (0 errors) ==== import {MyClass} from "library-a"; - ~~~~~~~~~~~ -!!! error TS2307: Cannot find module 'library-a' or its corresponding type declarations. export { MyClass as MyClass2 } \ No newline at end of file diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt.diff b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt.diff deleted file mode 100644 index 3fb9d8d8d55..00000000000 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.errors.txt.diff +++ /dev/null @@ -1,34 +0,0 @@ ---- old.moduleResolutionWithSymlinks_withOutDir.errors.txt -+++ new.moduleResolutionWithSymlinks_withOutDir.errors.txt -@@= skipped -0, +0 lines =@@ --/src/app.ts(6,5): error TS2454: Variable 'y' is used before being assigned. - /src/library-a/index.ts(1,32): error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. -- -- --==== /src/app.ts (1 errors) ==== -+/src/library-b/index.ts(1,23): error TS2307: Cannot find module 'library-a' or its corresponding type declarations. -+ -+ -+==== /src/app.ts (0 errors) ==== - import { MyClass } from "./library-a"; - import { MyClass2 } from "./library-b"; - - let x: MyClass; - let y: MyClass2; - x = y; -- ~ --!!! error TS2454: Variable 'y' is used before being assigned. - y = x; - - ==== /src/library-a/index.ts (1 errors) ==== -@@= skipped -17, +15 lines =@@ - ~ - !!! error TS2564: Property 'x' has no initializer and is not definitely assigned in the constructor. - --==== /src/library-b/index.ts (0 errors) ==== -+==== /src/library-b/index.ts (1 errors) ==== - import {MyClass} from "library-a"; -+ ~~~~~~~~~~~ -+!!! error TS2307: Cannot find module 'library-a' or its corresponding type declarations. - export { MyClass as MyClass2 } - \ No newline at end of file diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.trace.json b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.trace.json index 539976fd6f3..f884d587e9d 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.trace.json +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.trace.json @@ -30,11 +30,10 @@ File '/src/package.json' does not exist. File '/package.json' does not exist. Loading module 'library-a' from 'node_modules' folder, target file types: TypeScript, JavaScript, Declaration, JSON. Searching all ancestor node_modules directories for preferred extensions: TypeScript, Declaration. -Directory '/src/library-b/node_modules' does not exist, skipping all lookups in it. -Directory '/src/node_modules' does not exist, skipping all lookups in it. -Directory '/node_modules' does not exist, skipping all lookups in it. -Searching all ancestor node_modules directories for fallback extensions: JavaScript, JSON. -Directory '/src/library-b/node_modules' does not exist, skipping all lookups in it. -Directory '/src/node_modules' does not exist, skipping all lookups in it. -Directory '/node_modules' does not exist, skipping all lookups in it. -======== Module name 'library-a' was not resolved. ======== +File '/src/library-b/node_modules/library-a/package.json' does not exist. +File '/src/library-b/node_modules/library-a.ts' does not exist. +File '/src/library-b/node_modules/library-a.tsx' does not exist. +File '/src/library-b/node_modules/library-a.d.ts' does not exist. +File '/src/library-b/node_modules/library-a/index.ts' exists - use it as a name resolution result. +Resolving real path for '/src/library-b/node_modules/library-a/index.ts', result '/src/library-a/index.ts'. +======== Module name 'library-a' was successfully resolved to '/src/library-a/index.ts'. ======== diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types index 6483c9d95ca..52a174bc852 100644 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types +++ b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types @@ -5,22 +5,22 @@ import { MyClass } from "./library-a"; >MyClass : typeof MyClass import { MyClass2 } from "./library-b"; ->MyClass2 : any +>MyClass2 : typeof MyClass let x: MyClass; >x : MyClass let y: MyClass2; ->y : MyClass2 +>y : MyClass x = y; ->x = y : MyClass2 +>x = y : MyClass >x : MyClass ->y : MyClass2 +>y : MyClass y = x; >y = x : MyClass ->y : MyClass2 +>y : MyClass >x : MyClass === /src/library-a/index.ts === @@ -30,9 +30,9 @@ export class MyClass { private x: number; } === /src/library-b/index.ts === import {MyClass} from "library-a"; ->MyClass : any +>MyClass : typeof MyClass export { MyClass as MyClass2 } ->MyClass : any ->MyClass2 : any +>MyClass : typeof MyClass +>MyClass2 : typeof MyClass diff --git a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types.diff b/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types.diff deleted file mode 100644 index 6ef239b36be..00000000000 --- a/testdata/baselines/reference/submodule/compiler/moduleResolutionWithSymlinks_withOutDir.types.diff +++ /dev/null @@ -1,42 +0,0 @@ ---- old.moduleResolutionWithSymlinks_withOutDir.types -+++ new.moduleResolutionWithSymlinks_withOutDir.types -@@= skipped -4, +4 lines =@@ - >MyClass : typeof MyClass - - import { MyClass2 } from "./library-b"; -->MyClass2 : typeof MyClass -+>MyClass2 : any - - let x: MyClass; - >x : MyClass - - let y: MyClass2; -->y : MyClass -+>y : MyClass2 - - x = y; -->x = y : MyClass -+>x = y : MyClass2 - >x : MyClass -->y : MyClass -+>y : MyClass2 - - y = x; - >y = x : MyClass -->y : MyClass -+>y : MyClass2 - >x : MyClass - - === /src/library-a/index.ts === -@@= skipped -25, +25 lines =@@ - - === /src/library-b/index.ts === - import {MyClass} from "library-a"; -->MyClass : typeof MyClass -+>MyClass : any - - export { MyClass as MyClass2 } -->MyClass : typeof MyClass -->MyClass2 : typeof MyClass -+>MyClass : any -+>MyClass2 : any From c9b386d0cd4b5ea4437235572fe4b07a482f8fc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 00:07:59 +0200 Subject: [PATCH 02/19] better gate --- internal/fourslash/fourslash.go | 7 ++++-- .../tests/importRenameFileFlow_test.go | 24 ++++++++++++++++++ internal/lsp/server.go | 25 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 50ab3bb3476..2822f000d57 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -3934,14 +3934,17 @@ func (f *FourslashTest) VerifyRenameFailed(t *testing.T, preferences *lsutil.Use t.Fatalf("%sExpected rename to fail, but prepareRename returned a result", prefix) } - // Also verify that textDocument/rename produces no edits, since prepareRename is optional. - renameResult := sendRequest(t, f, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{ + // Also verify that textDocument/rename does not produce usable edits, since prepareRename is optional. + renameMsg, renameResult, _ := lsptestutil.SendRequest(t, f.client, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: lsconv.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, NewName: "RENAME_FAILED_TEST", }) + if renameMsg != nil && renameMsg.AsResponse().Error != nil { + return + } if renameResult.WorkspaceEdit != nil && renameResult.WorkspaceEdit.Changes != nil && len(*renameResult.WorkspaceEdit.Changes) > 0 { t.Fatalf("%sprepareRename returned null but textDocument/rename returned changes", prefix) } diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go index c2bb51c2c56..94884ca4c12 100644 --- a/internal/fourslash/tests/importRenameFileFlow_test.go +++ b/internal/fourslash/tests/importRenameFileFlow_test.go @@ -216,3 +216,27 @@ const global = require("/*global*/global"); f.GoToMarker(t, "global") f.VerifyRenameFailed(t, prefsTrue) } + +func TestImportPathRenameFailsWithoutFileRenameClientSupport(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /src/example.ts +import stuff from './[|stuff|].cts'; +// @Filename: /src/stuff.cts +export = { name: "stuff" }; +` + + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + // Intentionally omit workspace.fileOperations.willRename. + + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) + f.GoToRangeStart(t, f.Ranges()[0]) + f.VerifyRenameFailed(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 7e56782ed2f..34c8d5984c2 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -706,7 +706,7 @@ var handlers = sync.OnceValue(func() handlerMap { registerLanguageServiceDocumentRequestHandler(handlers, lsproto.CustomTextDocumentClosingTagCompletionInfo, (*Server).handleClosingTagCompletion) registerMultiProjectReferenceRequestHandler(handlers, lsproto.TextDocumentReferencesInfo, (*ls.LanguageService).ProvideReferences) - registerMultiProjectReferenceRequestHandler(handlers, lsproto.TextDocumentRenameInfo, (*ls.LanguageService).ProvideRename) + registerRequestHandler(handlers, lsproto.TextDocumentRenameInfo, (*Server).handleRename) registerMultiProjectReferenceRequestHandler(handlers, lsproto.TextDocumentImplementationInfo, (*ls.LanguageService).ProvideImplementations) registerRequestHandler(handlers, lsproto.CallHierarchyIncomingCallsInfo, (*Server).handleCallHierarchyIncomingCalls) @@ -1230,6 +1230,9 @@ func (s *Server) handlePrepareRename(ctx context.Context, languageService *ls.La if !info.CanRename { return lsproto.PrepareRenameResponse{}, userFacingRequestFailedError(info.LocalizedErrorMessage) } + if info.FileToRename != "" && !s.supportsImportPathFileRename() { + return lsproto.PrepareRenameResponse{}, userFacingRequestFailedError("The client doesn't support file rename edits required for import path renames.") + } return lsproto.PrepareRenameResponse{ PrepareRenamePlaceholder: &lsproto.PrepareRenamePlaceholder{ Range: info.TriggerSpan, @@ -1238,6 +1241,26 @@ func (s *Server) handlePrepareRename(ctx context.Context, languageService *ls.La }, nil } +func (s *Server) handleRename(ctx context.Context, params *lsproto.RenameParams, req *lsproto.RequestMessage) (lsproto.RenameResponse, error) { + defaultLs, orchestrator, err := s.getLanguageServiceAndCrossProjectOrchestrator(ctx, params.TextDocument.Uri, req) + if err != nil { + return lsproto.RenameResponse{}, err + } + + info := defaultLs.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) + if info.CanRename && info.FileToRename != "" && !s.supportsImportPathFileRename() { + return lsproto.RenameResponse{}, userFacingRequestFailedError("The client doesn't support file rename edits required for import path renames.") + } + return defaultLs.ProvideRename(ctx, params, orchestrator) +} + +func (s *Server) supportsImportPathFileRename() bool { + workspaceCaps := s.clientCapabilities.Workspace + return workspaceCaps.WorkspaceEdit.DocumentChanges && + slices.Contains(workspaceCaps.WorkspaceEdit.ResourceOperations, lsproto.ResourceOperationKindRename) && + workspaceCaps.FileOperations.WillRename +} + func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { if params == nil || len(params.Files) == 0 { return lsproto.WillRenameFilesResponse{}, nil From 661d7072332f808192f876e04547373683505c85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 09:05:51 +0200 Subject: [PATCH 03/19] add test --- .../tests/importRenameFileFlow_test.go | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go index 94884ca4c12..8778639e20e 100644 --- a/internal/fourslash/tests/importRenameFileFlow_test.go +++ b/internal/fourslash/tests/importRenameFileFlow_test.go @@ -148,6 +148,52 @@ export const x = 1; assert.Equal(t, tsconfigEdits[0].NewText, "src/new.ts") } +func TestWillRenameFilesUpdatesProjectReferenceConsumer(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /solution/b/app.ts +import { x } from "../a/old"; +// @Filename: /solution/a/old.ts +export const x = 1; +// @Filename: /solution/a/tsconfig.json +{ + "compilerOptions": { + "composite": true + }, + "files": ["old.ts"] +} +// @Filename: /solution/b/tsconfig.json +{ + "references": [{ "path": "../a" }], + "files": ["app.ts"] +} +` + + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: new(true), + } + + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/solution/a/old.ts")), + NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/solution/b/app.ts")] + assert.Equal(t, len(appEdits), 1) + assert.Equal(t, appEdits[0].NewText, "../a/new") +} + func TestImportTypePathRenameReturnsRenameFile(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") From d10a51270aba756e5b436e30f6c15bd2980a2f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 10:00:24 +0200 Subject: [PATCH 04/19] add tests --- .../tests/importRenameFileFlow_test.go | 226 +++++++++++++----- 1 file changed, 168 insertions(+), 58 deletions(-) diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go index 8778639e20e..5fc1443541b 100644 --- a/internal/fourslash/tests/importRenameFileFlow_test.go +++ b/internal/fourslash/tests/importRenameFileFlow_test.go @@ -13,6 +13,18 @@ import ( "gotest.tools/v3/assert" ) +func fileRenameCapabilities() *lsproto.ClientCapabilities { + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, + } + capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: new(true), + } + return capabilities +} + func TestImportPathRenameReturnsRenameFileAndWillRenameEdits(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") @@ -23,16 +35,7 @@ import stuff from './[|stuff|].cts'; export = { name: "stuff" }; ` - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ - WillRename: new(true), - } - - f, done := fourslash.NewFourslash(t, capabilities, content) + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) defer done() f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) f.GoToRangeStart(t, f.Ranges()[0]) @@ -68,16 +71,7 @@ import dir from './[|dir|]'; export const x = 1; ` - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ - WillRename: new(true), - } - - f, done := fourslash.NewFourslash(t, capabilities, content) + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) defer done() f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) f.GoToRangeStart(t, f.Ranges()[0]) @@ -118,16 +112,7 @@ export const x = 1; } ` - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ - WillRename: new(true), - } - - f, done := fourslash.NewFourslash(t, capabilities, content) + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) defer done() willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ @@ -152,10 +137,10 @@ func TestWillRenameFilesUpdatesProjectReferenceConsumer(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") - const content = `// @Filename: /solution/b/app.ts -import { x } from "../a/old"; -// @Filename: /solution/a/old.ts + const content = `// @Filename: /solution/a/old.ts export const x = 1; +// @Filename: /solution/b/app.ts +import { x } from "../a/old"; // @Filename: /solution/a/tsconfig.json { "compilerOptions": { @@ -170,16 +155,102 @@ export const x = 1; } ` - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ - WillRename: new(true), - } + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) + defer done() - f, done := fourslash.NewFourslash(t, capabilities, content) + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/solution/a/old.ts")), + NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/solution/b/app.ts")] + assert.Equal(t, len(appEdits), 1) + assert.Equal(t, appEdits[0].NewText, "../a/new") +} + +func TestWillRenameFilesUpdatesSiblingProjectLoadedViaSolutionRoot(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /solution/a/old.ts +export const x = 1; +// @Filename: /solution/b/app.ts +import { x } from "../a/old"; +// @Filename: /solution/tsconfig.json +{ + "files": [], + "references": [ + { "path": "./a" }, + { "path": "./b" } + ] +} +// @Filename: /solution/a/tsconfig.json +{ + "compilerOptions": { + "composite": true + }, + "files": ["old.ts"] +} +// @Filename: /solution/b/tsconfig.json +{ + "files": ["app.ts"] +} +` + + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) + defer done() + + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/solution/a/old.ts")), + NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/solution/b/app.ts")] + assert.Equal(t, len(appEdits), 1) + assert.Equal(t, appEdits[0].NewText, "../a/new") +} + +func TestWillRenameFilesUpdatesSiblingProjectWhenUnrelatedFileIsInitiallyActive(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /solution/c/unrelated.ts +export const active = 1; +// @Filename: /solution/a/old.ts +export const x = 1; +// @Filename: /solution/b/app.ts +import { x } from "../a/old"; +// @Filename: /solution/tsconfig.json +{ + "files": [], + "references": [ + { "path": "./a" }, + { "path": "./b" }, + { "path": "./c" } + ] +} +// @Filename: /solution/a/tsconfig.json +{ + "compilerOptions": { + "composite": true + }, + "files": ["old.ts"] +} +// @Filename: /solution/b/tsconfig.json +{ + "files": ["app.ts"] +} +// @Filename: /solution/c/tsconfig.json +{ + "files": ["unrelated.ts"] +} +` + + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) defer done() willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ @@ -194,6 +265,58 @@ export const x = 1; assert.Equal(t, appEdits[0].NewText, "../a/new") } +func TestWillRenameFilesUpdatesSymlinkedPackageConsumer(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + + const content = `// @Filename: /packages/project-b/old.ts +export const x = 1; +// @Filename: /packages/project-b/package.json +{ + "name": "project-b", + "version": "1.0.0" +} +// @Filename: /packages/project-b/tsconfig.json +{ + "compilerOptions": { + "composite": true, + "module": "commonjs" + }, + "files": ["old.ts"] +} +// @Filename: /packages/project-a/app.ts +import { x } from "project-b/old"; +// @Filename: /packages/project-a/package.json +{ + "name": "project-a", + "dependencies": { + "project-b": "*" + } +} +// @Filename: /packages/project-a/tsconfig.json +{ + "compilerOptions": { + "module": "commonjs" + }, + "files": ["app.ts"] +} +// @link: /packages/project-b -> /packages/project-a/node_modules/project-b` + + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) + defer done() + + willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI("/packages/project-b/old.ts")), + NewUri: string(lsconv.FileNameToDocumentURI("/packages/project-b/new.ts")), + }) + assert.Assert(t, willRenameResult.WorkspaceEdit != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + + appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/packages/project-a/app.ts")] + assert.Equal(t, len(appEdits), 1) + assert.Equal(t, appEdits[0].NewText, "project-b/new") +} + func TestImportTypePathRenameReturnsRenameFile(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") @@ -205,16 +328,7 @@ export = 0; const x: import("[|./a|]") = 0; ` - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ - WillRename: new(true), - } - - f, done := fourslash.NewFourslash(t, capabilities, content) + f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) defer done() prefsTrue := &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue} @@ -273,12 +387,8 @@ import stuff from './[|stuff|].cts'; export = { name: "stuff" }; ` - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - // Intentionally omit workspace.fileOperations.willRename. + capabilities := fileRenameCapabilities() + capabilities.Workspace.FileOperations = nil f, done := fourslash.NewFourslash(t, capabilities, content) defer done() From 0f8d4b5e289e66669ef39fc875822579564d3ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 10:11:08 +0200 Subject: [PATCH 05/19] fmt --- .../fourslash/_scripts/convertFourslash.mts | 414 +++++++++--------- internal/ls/filerename.go | 3 +- internal/testrunner/test_case_parser.go | 6 +- 3 files changed, 213 insertions(+), 210 deletions(-) diff --git a/internal/fourslash/_scripts/convertFourslash.mts b/internal/fourslash/_scripts/convertFourslash.mts index 30866b9a7ae..734ade8380d 100755 --- a/internal/fourslash/_scripts/convertFourslash.mts +++ b/internal/fourslash/_scripts/convertFourslash.mts @@ -101,90 +101,90 @@ function parseTypeScriptFiles(manualTests: Set, folder: string): void { }); } -function parseFileContent(filename: string, content: string): GoTest { - console.error(`Parsing file: ${filename}`); - const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); - const statements = sourceFile.statements; - const commands: Cmd[] = []; - for (const statement of statements) { - const result = parseFourslashStatement(statement); - commands.push(...result); - } - - // File-rename tests from old TS sometimes rely on legacy `baseUrl`-driven - // non-relative specifiers. The current compiler intentionally removes - // `baseUrl` resolution, so for this narrow converted test family we rewrite - // those fixtures to equivalent `paths`-based configs instead of preserving - // the legacy option in generated tests. - const rewrittenContent = commands.some(command => command.kind === "verifyGetEditsForFileRename") - ? rewriteLegacyBaseUrlInRenameTestContent(content) - : content; - const finalContent = filename === "getEditsForFileRename_caseInsensitive.ts" - ? `// @useCaseSensitiveFileNames: false\n${rewrittenContent}` - : rewrittenContent; - - const goTest: GoTest = { - name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), - content: getTestInput(finalContent), - commands, - }; - if (goTest.commands.length === 0) { - throw new Error(`No commands parsed in file: ${filename}`); - } - return goTest; -} - -function rewriteLegacyBaseUrlInRenameTestContent(content: string): string { - const lines = content.split("\n"); - const rewritten: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - rewritten.push(line); - - const match = line.match(/^\/\/\s*@Filename:\s*(.+)$/); - if (!match || !match[1].trim().endsWith("tsconfig.json")) { - continue; - } - - const jsonLines: string[] = []; - let j = i + 1; - while (j < lines.length && lines[j].startsWith("////")) { - jsonLines.push(lines[j].slice(4)); - j++; - } - if (jsonLines.length === 0) { - continue; - } - - const rewrittenJson = rewriteLegacyBaseUrlJson(jsonLines.join("\n")); - rewritten.push(...rewrittenJson.split("\n").map(part => `////${part}`)); - i = j - 1; - } - - return rewritten.join("\n"); -} - -function rewriteLegacyBaseUrlJson(jsonText: string): string { - let parsed: any; - try { - parsed = JSON.parse(jsonText); - } - catch { - return jsonText; - } - - const compilerOptions = parsed?.compilerOptions; - if (!compilerOptions || typeof compilerOptions !== "object" || typeof compilerOptions.baseUrl !== "string" || compilerOptions.paths !== undefined) { - return jsonText; - } - - const baseUrl = compilerOptions.baseUrl; - const wildcardTarget = baseUrl === "." ? "*" : `${baseUrl.replace(/\/$/, "")}/*`; - delete compilerOptions.baseUrl; - compilerOptions.paths = { "*": [wildcardTarget] }; - return JSON.stringify(parsed); -} +function parseFileContent(filename: string, content: string): GoTest { + console.error(`Parsing file: ${filename}`); + const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); + const statements = sourceFile.statements; + const commands: Cmd[] = []; + for (const statement of statements) { + const result = parseFourslashStatement(statement); + commands.push(...result); + } + + // File-rename tests from old TS sometimes rely on legacy `baseUrl`-driven + // non-relative specifiers. The current compiler intentionally removes + // `baseUrl` resolution, so for this narrow converted test family we rewrite + // those fixtures to equivalent `paths`-based configs instead of preserving + // the legacy option in generated tests. + const rewrittenContent = commands.some(command => command.kind === "verifyGetEditsForFileRename") + ? rewriteLegacyBaseUrlInRenameTestContent(content) + : content; + const finalContent = filename === "getEditsForFileRename_caseInsensitive.ts" + ? `// @useCaseSensitiveFileNames: false\n${rewrittenContent}` + : rewrittenContent; + + const goTest: GoTest = { + name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), + content: getTestInput(finalContent), + commands, + }; + if (goTest.commands.length === 0) { + throw new Error(`No commands parsed in file: ${filename}`); + } + return goTest; +} + +function rewriteLegacyBaseUrlInRenameTestContent(content: string): string { + const lines = content.split("\n"); + const rewritten: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + rewritten.push(line); + + const match = line.match(/^\/\/\s*@Filename:\s*(.+)$/); + if (!match || !match[1].trim().endsWith("tsconfig.json")) { + continue; + } + + const jsonLines: string[] = []; + let j = i + 1; + while (j < lines.length && lines[j].startsWith("////")) { + jsonLines.push(lines[j].slice(4)); + j++; + } + if (jsonLines.length === 0) { + continue; + } + + const rewrittenJson = rewriteLegacyBaseUrlJson(jsonLines.join("\n")); + rewritten.push(...rewrittenJson.split("\n").map(part => `////${part}`)); + i = j - 1; + } + + return rewritten.join("\n"); +} + +function rewriteLegacyBaseUrlJson(jsonText: string): string { + let parsed: any; + try { + parsed = JSON.parse(jsonText); + } + catch { + return jsonText; + } + + const compilerOptions = parsed?.compilerOptions; + if (!compilerOptions || typeof compilerOptions !== "object" || typeof compilerOptions.baseUrl !== "string" || compilerOptions.paths !== undefined) { + return jsonText; + } + + const baseUrl = compilerOptions.baseUrl; + const wildcardTarget = baseUrl === "." ? "*" : `${baseUrl.replace(/\/$/, "")}/*`; + delete compilerOptions.baseUrl; + compilerOptions.paths = { "*": [wildcardTarget] }; + return JSON.stringify(parsed); +} function getTestInput(content: string): string { const lines = content.split("\n").map(line => line.endsWith("\r") ? line.slice(0, -1) : line); @@ -335,14 +335,14 @@ function parseFourslashStatement(statement: ts.Statement): Cmd[] { return [{ kind: "verifyBaselineLinkedEditing" }]; case "linkedEditing": return parseVerifyLinkedEditing(callExpression.arguments); - case "renameInfoSucceeded": - case "renameInfoFailed": - return parseRenameInfo(func.text, callExpression.arguments); - case "getEditsForFileRename": - return parseGetEditsForFileRename(callExpression.arguments); - case "getSemanticDiagnostics": - case "getSuggestionDiagnostics": - case "getSyntacticDiagnostics": + case "renameInfoSucceeded": + case "renameInfoFailed": + return parseRenameInfo(func.text, callExpression.arguments); + case "getEditsForFileRename": + return parseGetEditsForFileRename(callExpression.arguments); + case "getSemanticDiagnostics": + case "getSuggestionDiagnostics": + case "getSyntacticDiagnostics": return parseVerifyDiagnostics(func.text, callExpression.arguments); case "baselineSyntacticDiagnostics": case "baselineSyntacticAndSemanticDiagnostics": @@ -1406,7 +1406,7 @@ function parseBaselineGoToDefinitionArgs( }]; } -function parseRenameInfo(funcName: "renameInfoSucceeded" | "renameInfoFailed", args: readonly ts.Expression[]): [VerifyRenameInfoCmd] { +function parseRenameInfo(funcName: "renameInfoSucceeded" | "renameInfoFailed", args: readonly ts.Expression[]): [VerifyRenameInfoCmd] { let preferences = "nil /*preferences*/"; let prefArg; switch (funcName) { @@ -1428,90 +1428,90 @@ function parseRenameInfo(funcName: "renameInfoSucceeded" | "renameInfoFailed", a const parsedPreferences = parseUserPreferences(prefArg); preferences = parsedPreferences; } - return [{ kind: funcName, preferences }]; -} - -function parseGetEditsForFileRename(args: readonly ts.Expression[]): [VerifyGetEditsForFileRenameCmd] { - if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { - throw new Error(`Expected a single object literal argument in verify.getEditsForFileRename, got ${args.map(arg => arg.getText()).join(", ")}`); - } - - let oldPath: string | undefined; - let newPath: string | undefined; - let newFileContents = "map[string]string{}"; - let preferences = "nil /*preferences*/"; - - for (const prop of args[0].properties) { - if (!ts.isPropertyAssignment(prop)) { - continue; - } - const name = prop.name.getText(); - switch (name) { - case "oldPath": { - const value = getStringLiteralLike(prop.initializer); - if (!value) { - throw new Error(`Expected string literal for oldPath, got ${prop.initializer.getText()}`); - } - oldPath = getGoStringLiteral(value.text); - break; - } - case "newPath": { - const value = getStringLiteralLike(prop.initializer); - if (!value) { - throw new Error(`Expected string literal for newPath, got ${prop.initializer.getText()}`); - } - newPath = getGoStringLiteral(value.text); - break; - } - case "newFileContents": { - const obj = getObjectLiteralExpression(prop.initializer); - if (!obj) { - throw new Error(`Expected object literal for newFileContents, got ${prop.initializer.getText()}`); - } - const entries: string[] = []; - for (const entry of obj.properties) { - if (!ts.isPropertyAssignment(entry)) { - continue; - } - const key = getStringLiteralLike(entry.name); - const value = getStringLiteralLike(entry.initializer); - if (!key || !value) { - throw new Error(`Expected string literal key/value in newFileContents, got ${entry.getText()}`); - } - const rewrittenValue = key.text.endsWith("tsconfig.json") - ? rewriteLegacyBaseUrlJson(value.text) - : value.text; - entries.push(`${getGoStringLiteral(key.text)}: ${getGoMultiLineStringLiteral(rewrittenValue)}`); - } - newFileContents = entries.length === 0 - ? "map[string]string{}" - : `map[string]string{\n${entries.join(",\n")},\n}`; - break; - } - case "preferences": { - if (!ts.isObjectLiteralExpression(prop.initializer)) { - throw new Error(`Expected object literal for preferences, got ${prop.initializer.getText()}`); - } - preferences = parseUserPreferences(prop.initializer); - break; - } - } - } - - if (!oldPath || !newPath) { - throw new Error(`Expected oldPath and newPath in verify.getEditsForFileRename`); - } - - return [{ - kind: "verifyGetEditsForFileRename", - oldPath, - newPath, - newFileContents, - preferences, - }]; -} - -function parseBaselineRenameArgs(funcName: string, args: readonly ts.Expression[]): [VerifyBaselineRenameCmd] { + return [{ kind: funcName, preferences }]; +} + +function parseGetEditsForFileRename(args: readonly ts.Expression[]): [VerifyGetEditsForFileRenameCmd] { + if (args.length !== 1 || !ts.isObjectLiteralExpression(args[0])) { + throw new Error(`Expected a single object literal argument in verify.getEditsForFileRename, got ${args.map(arg => arg.getText()).join(", ")}`); + } + + let oldPath: string | undefined; + let newPath: string | undefined; + let newFileContents = "map[string]string{}"; + let preferences = "nil /*preferences*/"; + + for (const prop of args[0].properties) { + if (!ts.isPropertyAssignment(prop)) { + continue; + } + const name = prop.name.getText(); + switch (name) { + case "oldPath": { + const value = getStringLiteralLike(prop.initializer); + if (!value) { + throw new Error(`Expected string literal for oldPath, got ${prop.initializer.getText()}`); + } + oldPath = getGoStringLiteral(value.text); + break; + } + case "newPath": { + const value = getStringLiteralLike(prop.initializer); + if (!value) { + throw new Error(`Expected string literal for newPath, got ${prop.initializer.getText()}`); + } + newPath = getGoStringLiteral(value.text); + break; + } + case "newFileContents": { + const obj = getObjectLiteralExpression(prop.initializer); + if (!obj) { + throw new Error(`Expected object literal for newFileContents, got ${prop.initializer.getText()}`); + } + const entries: string[] = []; + for (const entry of obj.properties) { + if (!ts.isPropertyAssignment(entry)) { + continue; + } + const key = getStringLiteralLike(entry.name); + const value = getStringLiteralLike(entry.initializer); + if (!key || !value) { + throw new Error(`Expected string literal key/value in newFileContents, got ${entry.getText()}`); + } + const rewrittenValue = key.text.endsWith("tsconfig.json") + ? rewriteLegacyBaseUrlJson(value.text) + : value.text; + entries.push(`${getGoStringLiteral(key.text)}: ${getGoMultiLineStringLiteral(rewrittenValue)}`); + } + newFileContents = entries.length === 0 + ? "map[string]string{}" + : `map[string]string{\n${entries.join(",\n")},\n}`; + break; + } + case "preferences": { + if (!ts.isObjectLiteralExpression(prop.initializer)) { + throw new Error(`Expected object literal for preferences, got ${prop.initializer.getText()}`); + } + preferences = parseUserPreferences(prop.initializer); + break; + } + } + } + + if (!oldPath || !newPath) { + throw new Error(`Expected oldPath and newPath in verify.getEditsForFileRename`); + } + + return [{ + kind: "verifyGetEditsForFileRename", + oldPath, + newPath, + newFileContents, + preferences, + }]; +} + +function parseBaselineRenameArgs(funcName: string, args: readonly ts.Expression[]): [VerifyBaselineRenameCmd] { let newArgs: string[] = []; let preferences: string | undefined; for (const arg of args) { @@ -3223,22 +3223,22 @@ interface VerifyOrganizeImportsCmd { preferences: string; } -interface VerifyRenameInfoCmd { - kind: "renameInfoSucceeded" | "renameInfoFailed"; - preferences: string; -} - -interface VerifyGetEditsForFileRenameCmd { - kind: "verifyGetEditsForFileRename"; - oldPath: string; - newPath: string; - newFileContents: string; - preferences: string; -} - -interface VerifyBaselineLinkedEditingCmd { - kind: "verifyBaselineLinkedEditing"; -} +interface VerifyRenameInfoCmd { + kind: "renameInfoSucceeded" | "renameInfoFailed"; + preferences: string; +} + +interface VerifyGetEditsForFileRenameCmd { + kind: "verifyGetEditsForFileRename"; + oldPath: string; + newPath: string; + newFileContents: string; + preferences: string; +} + +interface VerifyBaselineLinkedEditingCmd { + kind: "verifyBaselineLinkedEditing"; +} interface VerifyLinkedEditingCmd { kind: "verifyLinkedEditing"; ranges: string; @@ -3365,12 +3365,12 @@ type Cmd = | FormatCmd | EditCmd | VerifyContentCmd - | VerifyQuickInfoCmd - | VerifyOrganizeImportsCmd - | VerifyBaselineRenameCmd - | VerifyRenameInfoCmd - | VerifyGetEditsForFileRenameCmd - | VerifyBaselineLinkedEditingCmd + | VerifyQuickInfoCmd + | VerifyOrganizeImportsCmd + | VerifyBaselineRenameCmd + | VerifyRenameInfoCmd + | VerifyGetEditsForFileRenameCmd + | VerifyBaselineLinkedEditingCmd | VerifyLinkedEditingCmd | VerifyNavToCmd | VerifyNavTreeCmd @@ -3687,14 +3687,14 @@ function generateCmd(cmd: Cmd): string { case "verifyBaselineRename": case "verifyBaselineRenameAtRangesWithText": return generateBaselineRename(cmd); - case "renameInfoSucceeded": - return `f.VerifyRenameSucceeded(t, ${cmd.preferences})`; - case "renameInfoFailed": - return `f.VerifyRenameFailed(t, ${cmd.preferences})`; - case "verifyGetEditsForFileRename": - return `f.VerifyWillRenameFilesEdits(t, ${cmd.oldPath}, ${cmd.newPath}, ${cmd.newFileContents}, ${cmd.preferences})`; - case "verifyBaselineInlayHints": - return generateBaselineInlayHints(cmd); + case "renameInfoSucceeded": + return `f.VerifyRenameSucceeded(t, ${cmd.preferences})`; + case "renameInfoFailed": + return `f.VerifyRenameFailed(t, ${cmd.preferences})`; + case "verifyGetEditsForFileRename": + return `f.VerifyWillRenameFilesEdits(t, ${cmd.oldPath}, ${cmd.newPath}, ${cmd.newFileContents}, ${cmd.preferences})`; + case "verifyBaselineInlayHints": + return generateBaselineInlayHints(cmd); case "verifyBaselineLinkedEditing": return `f.VerifyBaselineLinkedEditing(t)`; case "verifyImportFixAtPosition": diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go index d9e8df01b57..5911117d05d 100644 --- a/internal/ls/filerename.go +++ b/internal/ls/filerename.go @@ -211,7 +211,8 @@ func (l *LanguageService) updateImportsForFileRename(program *compiler.Program, func (l *LanguageService) getUpdatedImportSpecifier(program *compiler.Program, checker interface { GetSymbolAtLocation(node *ast.Node) *ast.Symbol -}, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, newToOld pathUpdater, newImportFromPath string, oldImportFromPath string, importingSourceFileMoved bool, userPreferences modulespecifiers.UserPreferences) string { +}, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, newToOld pathUpdater, newImportFromPath string, oldImportFromPath string, importingSourceFileMoved bool, userPreferences modulespecifiers.UserPreferences, +) string { importedModuleSymbol := checker.GetSymbolAtLocation(importLiteral) if isAmbientModuleSymbol(importedModuleSymbol) { return "" diff --git a/internal/testrunner/test_case_parser.go b/internal/testrunner/test_case_parser.go index 3cd21723a39..accbe8ed683 100644 --- a/internal/testrunner/test_case_parser.go +++ b/internal/testrunner/test_case_parser.go @@ -41,8 +41,10 @@ type testCaseContent struct { var optionRegex = regexp.MustCompile(`(?m)^\/{2}\s*@(\w+)\s*:\s*([^\r\n]*)`) // Regex for parsing @link option -var linkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) -var symlinkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@symlink\s*:\s*([^\r\n]*)`) +var ( + linkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) + symlinkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@symlink\s*:\s*([^\r\n]*)`) +) // File-specific directives used by fourslash tests var fourslashDirectives = []string{"emitthisfile"} From 7615f8a7b9c4b97ed5ae635a8f4cf282c856a859 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 11:05:05 +0200 Subject: [PATCH 06/19] lint --- internal/fourslash/fourslash.go | 6 +++--- internal/ls/filerename.go | 10 +++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 2822f000d57..cca34fba3b4 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -3827,11 +3827,11 @@ func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newP if d.IsDir() { return nil } - content, ok := f.vfs.ReadFile(path) - if !ok { + fileContent, exists := f.vfs.ReadFile(path) + if !exists { return fmt.Errorf("file %s disappeared during rename walk", path) } - renamedContents[path] = content + renamedContents[path] = fileContent return nil }) if walkErr != nil { diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go index 5911117d05d..c1a5ff05c51 100644 --- a/internal/ls/filerename.go +++ b/internal/ls/filerename.go @@ -2,6 +2,7 @@ package ls import ( "context" + "slices" "strings" "github.com/microsoft/typescript-go/internal/ast" @@ -201,7 +202,7 @@ func (l *LanguageService) updateImportsForFileRename(program *compiler.Program, } for _, importStringLiteral := range sourceFile.Imports() { - updated := l.getUpdatedImportSpecifier(program, checker, sourceFile, (*ast.StringLiteralLike)(importStringLiteral), oldToNew, newToOld, newImportFromPath, oldImportFromPath, importingSourceFileMoved, moduleSpecifierPreferences) + updated := l.getUpdatedImportSpecifier(program, checker, sourceFile, importStringLiteral, oldToNew, newToOld, newImportFromPath, oldImportFromPath, importingSourceFileMoved, moduleSpecifierPreferences) if updated != "" && updated != importStringLiteral.Text() { changeTracker.ReplaceRangeWithText(sourceFile, l.converters.ToLSPRange(sourceFile, createStringTextRange(sourceFile, importStringLiteral)), updated) } @@ -448,10 +449,5 @@ func isAmbientModuleSymbol(symbol *ast.Symbol) bool { if symbol == nil { return false } - for _, decl := range symbol.Declarations { - if ast.IsModuleWithStringLiteralName(decl) { - return true - } - } - return false + return slices.ContainsFunc(symbol.Declarations, ast.IsModuleWithStringLiteralName) } From ba00123ab1a6e8518fc23b8edab54b9cd8a29a44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 11:36:52 +0200 Subject: [PATCH 07/19] updatefailing --- internal/fourslash/_scripts/failingTests.txt | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/fourslash/_scripts/failingTests.txt b/internal/fourslash/_scripts/failingTests.txt index 1b20df5b200..ddd583d95e6 100644 --- a/internal/fourslash/_scripts/failingTests.txt +++ b/internal/fourslash/_scripts/failingTests.txt @@ -229,13 +229,11 @@ TestHoverOverComment TestImportCompletionsPackageJsonImportsPatternRootWildcard TestImportFixesGlobalTypingsCache TestImportMetaCompletionDetails -TestImportNameCodeFix_avoidRelativeNodeModules TestImportNameCodeFix_externalNonRelative1 TestImportNameCodeFix_noDestructureNonObjectLiteral TestImportNameCodeFix_order2 TestImportNameCodeFix_preferBaseUrl TestImportNameCodeFix_reExportDefault -TestImportNameCodeFix_symlink TestImportNameCodeFix_uriStyleNodeCoreModules2 TestImportNameCodeFix_uriStyleNodeCoreModules3 TestImportNameCodeFixDefaultExport4 From 3af10f80735deb96b18e82c92696df7ac14c561d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 12:36:11 +0200 Subject: [PATCH 08/19] dont specialcse baseUrl, just make those test manual --- .../fourslash/_scripts/convertFourslash.mts | 71 ++----------------- internal/fourslash/_scripts/manualTests.txt | 2 + .../getEditsForFileRename_preferences_test.go | 4 -- ...leRename_unaffectedNonRelativePath_test.go | 4 -- 4 files changed, 6 insertions(+), 75 deletions(-) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_preferences_test.go (84%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_unaffectedNonRelativePath_test.go (79%) diff --git a/internal/fourslash/_scripts/convertFourslash.mts b/internal/fourslash/_scripts/convertFourslash.mts index 734ade8380d..3001a003d03 100755 --- a/internal/fourslash/_scripts/convertFourslash.mts +++ b/internal/fourslash/_scripts/convertFourslash.mts @@ -111,17 +111,9 @@ function parseFileContent(filename: string, content: string): GoTest { commands.push(...result); } - // File-rename tests from old TS sometimes rely on legacy `baseUrl`-driven - // non-relative specifiers. The current compiler intentionally removes - // `baseUrl` resolution, so for this narrow converted test family we rewrite - // those fixtures to equivalent `paths`-based configs instead of preserving - // the legacy option in generated tests. - const rewrittenContent = commands.some(command => command.kind === "verifyGetEditsForFileRename") - ? rewriteLegacyBaseUrlInRenameTestContent(content) - : content; - const finalContent = filename === "getEditsForFileRename_caseInsensitive.ts" - ? `// @useCaseSensitiveFileNames: false\n${rewrittenContent}` - : rewrittenContent; + const finalContent = filename === "getEditsForFileRename_caseInsensitive.ts" + ? `// @useCaseSensitiveFileNames: false\n${content}` + : content; const goTest: GoTest = { name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), @@ -134,58 +126,6 @@ function parseFileContent(filename: string, content: string): GoTest { return goTest; } -function rewriteLegacyBaseUrlInRenameTestContent(content: string): string { - const lines = content.split("\n"); - const rewritten: string[] = []; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - rewritten.push(line); - - const match = line.match(/^\/\/\s*@Filename:\s*(.+)$/); - if (!match || !match[1].trim().endsWith("tsconfig.json")) { - continue; - } - - const jsonLines: string[] = []; - let j = i + 1; - while (j < lines.length && lines[j].startsWith("////")) { - jsonLines.push(lines[j].slice(4)); - j++; - } - if (jsonLines.length === 0) { - continue; - } - - const rewrittenJson = rewriteLegacyBaseUrlJson(jsonLines.join("\n")); - rewritten.push(...rewrittenJson.split("\n").map(part => `////${part}`)); - i = j - 1; - } - - return rewritten.join("\n"); -} - -function rewriteLegacyBaseUrlJson(jsonText: string): string { - let parsed: any; - try { - parsed = JSON.parse(jsonText); - } - catch { - return jsonText; - } - - const compilerOptions = parsed?.compilerOptions; - if (!compilerOptions || typeof compilerOptions !== "object" || typeof compilerOptions.baseUrl !== "string" || compilerOptions.paths !== undefined) { - return jsonText; - } - - const baseUrl = compilerOptions.baseUrl; - const wildcardTarget = baseUrl === "." ? "*" : `${baseUrl.replace(/\/$/, "")}/*`; - delete compilerOptions.baseUrl; - compilerOptions.paths = { "*": [wildcardTarget] }; - return JSON.stringify(parsed); -} - function getTestInput(content: string): string { const lines = content.split("\n").map(line => line.endsWith("\r") ? line.slice(0, -1) : line); let testInput: string[] = []; @@ -1478,10 +1418,7 @@ function parseGetEditsForFileRename(args: readonly ts.Expression[]): [VerifyGetE if (!key || !value) { throw new Error(`Expected string literal key/value in newFileContents, got ${entry.getText()}`); } - const rewrittenValue = key.text.endsWith("tsconfig.json") - ? rewriteLegacyBaseUrlJson(value.text) - : value.text; - entries.push(`${getGoStringLiteral(key.text)}: ${getGoMultiLineStringLiteral(rewrittenValue)}`); + entries.push(`${getGoStringLiteral(key.text)}: ${getGoMultiLineStringLiteral(value.text)}`); } newFileContents = entries.length === 0 ? "map[string]string{}" diff --git a/internal/fourslash/_scripts/manualTests.txt b/internal/fourslash/_scripts/manualTests.txt index c464cd66c32..39cdfe628e2 100644 --- a/internal/fourslash/_scripts/manualTests.txt +++ b/internal/fourslash/_scripts/manualTests.txt @@ -48,6 +48,8 @@ exhaustiveCaseCompletions6 exhaustiveCaseCompletions7 exhaustiveCaseCompletions8 formatOnEnterInComment +getEditsForFileRename_preferences +getEditsForFileRename_unaffectedNonRelativePath getOutliningSpans importNameCodeFix_uriStyleNodeCoreModules1 importNameCodeFixDefaultExport7 diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_preferences_test.go similarity index 84% rename from internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_preferences_test.go index 2a09b219216..fd340b7878c 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_preferences_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_preferences_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_preferences" - package fourslash_test import ( @@ -12,7 +9,6 @@ import ( ) func TestGetEditsForFileRename_preferences(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @Filename: /dir/a.ts diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_unaffectedNonRelativePath_test.go similarity index 79% rename from internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_unaffectedNonRelativePath_test.go index 1306fe230c8..fc42b21c1b8 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_unaffectedNonRelativePath_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_unaffectedNonRelativePath_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_unaffectedNonRelativePath" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_unaffectedNonRelativePath(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @Filename: /sub/a.ts From a2723ad018f35addb9f6430b28f5102397b29353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 12:56:02 +0200 Subject: [PATCH 09/19] make another test manual --- .../fourslash/_scripts/convertFourslash.mts | 38 ++++++++----------- internal/fourslash/_scripts/manualTests.txt | 1 + ...EditsForFileRename_caseInsensitive_test.go | 4 -- 3 files changed, 17 insertions(+), 26 deletions(-) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_caseInsensitive_test.go (79%) diff --git a/internal/fourslash/_scripts/convertFourslash.mts b/internal/fourslash/_scripts/convertFourslash.mts index 3001a003d03..b09ba2186df 100755 --- a/internal/fourslash/_scripts/convertFourslash.mts +++ b/internal/fourslash/_scripts/convertFourslash.mts @@ -101,28 +101,22 @@ function parseTypeScriptFiles(manualTests: Set, folder: string): void { }); } -function parseFileContent(filename: string, content: string): GoTest { - console.error(`Parsing file: ${filename}`); - const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); - const statements = sourceFile.statements; - const commands: Cmd[] = []; - for (const statement of statements) { - const result = parseFourslashStatement(statement); - commands.push(...result); - } - - const finalContent = filename === "getEditsForFileRename_caseInsensitive.ts" - ? `// @useCaseSensitiveFileNames: false\n${content}` - : content; - - const goTest: GoTest = { - name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), - content: getTestInput(finalContent), - commands, - }; - if (goTest.commands.length === 0) { - throw new Error(`No commands parsed in file: ${filename}`); - } +function parseFileContent(filename: string, content: string): GoTest { + console.error(`Parsing file: ${filename}`); + const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); + const statements = sourceFile.statements; + const goTest: GoTest = { + name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), + content: getTestInput(content), + commands: [], + }; + for (const statement of statements) { + const result = parseFourslashStatement(statement); + goTest.commands.push(...result); + } + if (goTest.commands.length === 0) { + throw new Error(`No commands parsed in file: ${filename}`); + } return goTest; } diff --git a/internal/fourslash/_scripts/manualTests.txt b/internal/fourslash/_scripts/manualTests.txt index 39cdfe628e2..709553602f8 100644 --- a/internal/fourslash/_scripts/manualTests.txt +++ b/internal/fourslash/_scripts/manualTests.txt @@ -48,6 +48,7 @@ exhaustiveCaseCompletions6 exhaustiveCaseCompletions7 exhaustiveCaseCompletions8 formatOnEnterInComment +getEditsForFileRename_caseInsensitive getEditsForFileRename_preferences getEditsForFileRename_unaffectedNonRelativePath getOutliningSpans diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_caseInsensitive_test.go similarity index 79% rename from internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_caseInsensitive_test.go index f6736d32ec7..651803dde92 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_caseInsensitive_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_caseInsensitive_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_caseInsensitive" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_caseInsensitive(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @useCaseSensitiveFileNames: false From 350f3bc00ba13867772efc347e56f6006d1e1da7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Burzy=C5=84ski?= Date: Tue, 7 Apr 2026 13:06:04 +0200 Subject: [PATCH 10/19] normalize line endings --- .../fourslash/_scripts/convertFourslash.mts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/internal/fourslash/_scripts/convertFourslash.mts b/internal/fourslash/_scripts/convertFourslash.mts index b09ba2186df..ddc08bd5332 100755 --- a/internal/fourslash/_scripts/convertFourslash.mts +++ b/internal/fourslash/_scripts/convertFourslash.mts @@ -101,22 +101,22 @@ function parseTypeScriptFiles(manualTests: Set, folder: string): void { }); } -function parseFileContent(filename: string, content: string): GoTest { - console.error(`Parsing file: ${filename}`); - const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); - const statements = sourceFile.statements; - const goTest: GoTest = { - name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), - content: getTestInput(content), - commands: [], - }; - for (const statement of statements) { - const result = parseFourslashStatement(statement); - goTest.commands.push(...result); - } - if (goTest.commands.length === 0) { - throw new Error(`No commands parsed in file: ${filename}`); - } +function parseFileContent(filename: string, content: string): GoTest { + console.error(`Parsing file: ${filename}`); + const sourceFile = ts.createSourceFile("temp.ts", content, ts.ScriptTarget.Latest, true /*setParentNodes*/); + const statements = sourceFile.statements; + const goTest: GoTest = { + name: filename.replace(".tsx", "").replace(".ts", "").replace(".", ""), + content: getTestInput(content), + commands: [], + }; + for (const statement of statements) { + const result = parseFourslashStatement(statement); + goTest.commands.push(...result); + } + if (goTest.commands.length === 0) { + throw new Error(`No commands parsed in file: ${filename}`); + } return goTest; } From 40acfe8f74821cf4267903578172f652af758ee3 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Tue, 7 Apr 2026 19:10:37 +0000 Subject: [PATCH 11/19] WIP: refactor --- internal/fourslash/_scripts/convertFourslash.mts | 4 ++-- internal/lsp/server.go | 2 +- internal/testrunner/test_case_parser.go | 16 ++++------------ 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/internal/fourslash/_scripts/convertFourslash.mts b/internal/fourslash/_scripts/convertFourslash.mts index ddc08bd5332..b7a704eb252 100755 --- a/internal/fourslash/_scripts/convertFourslash.mts +++ b/internal/fourslash/_scripts/convertFourslash.mts @@ -1377,7 +1377,7 @@ function parseGetEditsForFileRename(args: readonly ts.Expression[]): [VerifyGetE for (const prop of args[0].properties) { if (!ts.isPropertyAssignment(prop)) { - continue; + throw new Error(`Expected property assignment in verify.getEditsForFileRename argument, got ${prop.getText()}`); } const name = prop.name.getText(); switch (name) { @@ -1405,7 +1405,7 @@ function parseGetEditsForFileRename(args: readonly ts.Expression[]): [VerifyGetE const entries: string[] = []; for (const entry of obj.properties) { if (!ts.isPropertyAssignment(entry)) { - continue; + throw new Error(`Expected property assignment in verify.getEditsForFileRename argument, got ${prop.getText()}`); } const key = getStringLiteralLike(entry.name); const value = getStringLiteralLike(entry.initializer); diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 00b79c7b187..752bf3285fa 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1266,7 +1266,7 @@ func (s *Server) supportsImportPathFileRename() bool { } func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { - if params == nil || len(params.Files) == 0 { + if len(params.Files) == 0 { return lsproto.WillRenameFilesResponse{}, nil } diff --git a/internal/testrunner/test_case_parser.go b/internal/testrunner/test_case_parser.go index accbe8ed683..0346bfb91e3 100644 --- a/internal/testrunner/test_case_parser.go +++ b/internal/testrunner/test_case_parser.go @@ -41,10 +41,7 @@ type testCaseContent struct { var optionRegex = regexp.MustCompile(`(?m)^\/{2}\s*@(\w+)\s*:\s*([^\r\n]*)`) // Regex for parsing @link option -var ( - linkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) - symlinkRegex = regexp.MustCompile(`(?im)^\/{2}\s*@symlink\s*:\s*([^\r\n]*)`) -) +var linkRegex = regexp.MustCompile(`(?m)^\/{2}\s*@link\s*:\s*([^\r\n]*)\s*->\s*([^\r\n]*)`) // File-specific directives used by fourslash tests var fourslashDirectives = []string{"emitthisfile"} @@ -156,7 +153,7 @@ func ParseTestFilesAndSymlinksWithOptions[T any]( globalOptions = make(map[string]string) for _, line := range lines { - ok := parseSymlinkFromTest(line, currentFileName, symlinks) + ok := parseSymlinkFromTest(line, symlinks) if ok { continue } @@ -263,15 +260,10 @@ func extractCompilerSettings(content string) rawCompilerSettings { return opts } -func parseSymlinkFromTest(line string, currentFileName string, symlinks map[string]string) bool { +func parseSymlinkFromTest(line string, symlinks map[string]string) bool { linkMetaData := linkRegex.FindStringSubmatch(line) if len(linkMetaData) == 0 { - symlinkMetadata := symlinkRegex.FindStringSubmatch(line) - if len(symlinkMetadata) == 0 || currentFileName == "" { - return false - } - symlinks[strings.TrimSpace(symlinkMetadata[1])] = currentFileName - return true + return false } symlinks[strings.TrimSpace(linkMetaData[2])] = strings.TrimSpace(linkMetaData[1]) From 983aece339ee0b08fe6cab110c4163e9a2fed9d5 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Tue, 7 Apr 2026 21:15:55 +0000 Subject: [PATCH 12/19] refactor fourslash --- internal/fourslash/fourslash.go | 107 ++++++++----------- internal/testutil/harnessutil/harnessutil.go | 43 ++------ 2 files changed, 50 insertions(+), 100 deletions(-) diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index cca34fba3b4..652309ab9b8 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -81,8 +81,8 @@ func newScriptInfo(fileName string, content string) *scriptInfo { } } -func (s *scriptInfo) editContent(start int, end int, newText string) { - s.content = s.content[:start] + newText + s.content[end:] +func (s *scriptInfo) editContent(changes []core.TextChange) { + s.content = core.ApplyBulkEdits(s.content, changes) s.lineMap = lsconv.ComputeLSPLineStarts(s.content) s.version++ } @@ -151,8 +151,7 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten Jsx: core.JsxEmitPreserve, } harnessOptions := harnessutil.HarnessOptions{UseCaseSensitiveFileNames: true, CurrentDirectory: rootDir} - harnessutil.SetCompilerOptionsFromTestConfig(t, testData.GlobalOptions, compilerOptions, rootDir) - harnessutil.SetHarnessOptionsFromTestConfig(t, testData.GlobalOptions, &harnessOptions, rootDir) + harnessutil.SetOptionsFromTestConfig(t, testData.GlobalOptions, compilerOptions, &harnessOptions, rootDir, true /*allowUnknownOptions*/) if commandLines := testData.GlobalOptions["tsc"]; commandLines != "" { for commandLine := range strings.SplitSeq(commandLines, ",") { tsctests.GetFileMapWithBuild(testfs, strings.Split(commandLine, " ")) @@ -2890,31 +2889,6 @@ func (f *FourslashTest) applyTextEdits(t *testing.T, edits []*lsproto.TextEdit) return totalOffset } -func applyTextEditsToContent(content string, edits []*lsproto.TextEdit, _ *lsconv.Converters) string { - script := newScriptInfo("__expected__.ts", content) - contentConverters := lsconv.NewConverters(lsproto.PositionEncodingKindUTF8, func(fileName string) *lsconv.LSPLineMap { - return script.lineMap - }) - sorted := slices.Clone(edits) - slices.SortFunc(sorted, func(a, b *lsproto.TextEdit) int { - aStart := contentConverters.LineAndCharacterToPosition(script, a.Range.Start) - bStart := contentConverters.LineAndCharacterToPosition(script, b.Range.Start) - return int(aStart) - int(bStart) - }) - - var b strings.Builder - lastPos := 0 - for _, edit := range sorted { - start := int(contentConverters.LineAndCharacterToPosition(script, edit.Range.Start)) - end := int(contentConverters.LineAndCharacterToPosition(script, edit.Range.End)) - b.WriteString(content[lastPos:start]) - b.WriteString(edit.NewText) - lastPos = end - } - b.WriteString(content[lastPos:]) - return b.String() -} - func (f *FourslashTest) Replace(t *testing.T, start int, length int, text string) { f.baselineState(t) f.replaceWorker(t, start, length, text) @@ -2971,7 +2945,7 @@ func (f *FourslashTest) typeText(t *testing.T, text string) { // Edits the script and updates marker and range positions accordingly. // This does not update the current caret position. func (f *FourslashTest) editScriptAndUpdateMarkers(t *testing.T, fileName string, editStart int, editEnd int, newText string) { - script := f.editScript(t, fileName, editStart, editEnd, newText) + script := f.editScript(t, fileName, []core.TextChange{{TextRange: core.NewTextRange(editStart, editEnd), NewText: newText}}) for _, marker := range f.testData.Markers { if marker.FileName() == fileName { marker.Position = updatePosition(marker.Position, editStart, editEnd, newText) @@ -3000,28 +2974,43 @@ func updatePosition(pos int, editStart int, editEnd int, newText string) int { return pos + len(newText) - (editEnd - editStart) } -func (f *FourslashTest) editScript(t *testing.T, fileName string, start int, end int, newText string) *scriptInfo { - script := f.getScriptInfo(fileName) - changeRange := f.converters.ToLSPRange(script, core.NewTextRange(start, end)) +func (f *FourslashTest) editScript(t *testing.T, fileName string, changes []core.TextChange) *scriptInfo { + script := f.getOrLoadScriptInfo(fileName) if script == nil { panic(fmt.Sprintf("Script info for file %s not found", fileName)) } - script.editContent(start, end, newText) - sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ - TextDocument: lsproto.VersionedTextDocumentIdentifier{ - Uri: lsconv.FileNameToDocumentURI(fileName), - Version: script.version, - }, - ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{ - { + var changeRange lsproto.Range + if len(changes) == 1 { + changeRange = f.converters.ToLSPRange(script, core.NewTextRange(changes[0].Pos(), changes[0].End())) + } + script.editContent(changes) + if len(changes) == 1 { + sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ + TextDocument: lsproto.VersionedTextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(fileName), + Version: script.version, + }, + ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ Partial: &lsproto.TextDocumentContentChangePartial{ Range: changeRange, - Text: newText, + Text: changes[0].NewText, }, + }}, + }) + } else { + sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ + TextDocument: lsproto.VersionedTextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(fileName), + Version: script.version, }, - }, - }) + ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{ + Text: script.content, + }, + }}, + }) + } return script } @@ -3769,34 +3758,26 @@ func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, t.Fatalf("workspace/willRenameFiles returned nil workspace edit") } - actualContents := map[string]string{} - for fileName, expectedContent := range expectedFileContents { - actualContents[fileName] = expectedContent - if script := f.getOrLoadScriptInfo(fileName); script != nil { - actualContents[fileName] = script.content - } - } if result.WorkspaceEdit.Changes != nil { for uri, edits := range *result.WorkspaceEdit.Changes { fileName := uri.FileName() - currentContent, ok := actualContents[fileName] - if !ok { - script := f.getOrLoadScriptInfo(fileName) - if script == nil { - t.Fatalf("workspace/willRenameFiles returned edits for unknown file %s", fileName) + script := f.getOrLoadScriptInfo(fileName) + changes := core.Map(edits, func(edit *lsproto.TextEdit) core.TextChange { + return core.TextChange{ + TextRange: f.converters.FromLSPRange(script, edit.Range), + NewText: edit.NewText, } - currentContent = script.content - } - actualContents[fileName] = applyTextEditsToContent(currentContent, edits, f.converters) + }) + f.editScript(t, fileName, changes) } } for fileName, expectedContent := range expectedFileContents { - actualContent, ok := actualContents[fileName] - if !ok { - t.Fatalf("expected content for %s, but no actual content was available", fileName) + script := f.getOrLoadScriptInfo(fileName) + if script == nil { + t.Fatalf("Expected script info for %s, but got nil", fileName) } - assert.Equal(t, actualContent, expectedContent, fmt.Sprintf("File content after workspace/willRenameFiles edits did not match expected content for %s.", fileName)) + assert.Equal(t, script.content, expectedContent, fmt.Sprintf("File content after workspace/willRenameFiles edits did not match expected content for %s.", fileName)) } f.renameFileOrDirectory(t, oldPath, newPath) diff --git a/internal/testutil/harnessutil/harnessutil.go b/internal/testutil/harnessutil/harnessutil.go index 610939726f6..a2e6ac4881e 100644 --- a/internal/testutil/harnessutil/harnessutil.go +++ b/internal/testutil/harnessutil/harnessutil.go @@ -104,7 +104,7 @@ func CompileFiles( // Parse harness and compiler options from the test configuration if testConfig != nil { - setOptionsFromTestConfig(t, testConfig, compilerOptions, &harnessOptions, currentDirectory) + SetOptionsFromTestConfig(t, testConfig, compilerOptions, &harnessOptions, currentDirectory, false /*allowUnknownOptions*/) } return CompileFilesEx(t, inputFiles, otherFiles, &harnessOptions, compilerOptions, currentDirectory, symlinks, tsconfig) @@ -232,7 +232,7 @@ func CompileFilesEx( result.Repeat = func(testConfig TestConfiguration) *CompilationResult { newHarnessOptions := *harnessOptions newCompilerOptions := compilerOptions.Clone() - setOptionsFromTestConfig(t, testConfig, newCompilerOptions, &newHarnessOptions, currentDirectory) + SetOptionsFromTestConfig(t, testConfig, newCompilerOptions, &newHarnessOptions, currentDirectory, false /*allowUnknownOptions*/) return CompileFilesEx(t, inputFiles, otherFiles, &newHarnessOptions, newCompilerOptions, currentDirectory, symlinks, tsconfig) } return result @@ -263,39 +263,7 @@ var testLibFolderMap = sync.OnceValue(func() map[string]any { return testfs }) -func SetCompilerOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, compilerOptions *core.CompilerOptions, currentDirectory string) { - for name, value := range testConfig { - if name == "typescriptversion" { - continue - } - - commandLineOption := getCommandLineOption(name) - if commandLineOption != nil { - parsedValue := getOptionValue(t, commandLineOption, value, currentDirectory) - errors := tsoptions.ParseCompilerOptions(commandLineOption.Name, parsedValue, compilerOptions) - if len(errors) > 0 { - t.Fatalf("Error parsing value '%s' for compiler option '%s'.", value, commandLineOption.Name) - } - } - } -} - -func SetHarnessOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, harnessOptions *HarnessOptions, currentDirectory string) { - for name, value := range testConfig { - if name == "typescriptversion" { - continue - } - - harnessOption := getHarnessOption(name) - if harnessOption == nil { - continue - } - parsedValue := getOptionValue(t, harnessOption, value, currentDirectory) - parseHarnessOption(t, harnessOption.Name, parsedValue, harnessOptions) - } -} - -func setOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, compilerOptions *core.CompilerOptions, harnessOptions *HarnessOptions, currentDirectory string) { +func SetOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, compilerOptions *core.CompilerOptions, harnessOptions *HarnessOptions, currentDirectory string, allowUnknownOptions bool) { for name, value := range testConfig { if name == "typescriptversion" { continue @@ -316,8 +284,9 @@ func setOptionsFromTestConfig(t *testing.T, testConfig TestConfiguration, compil parseHarnessOption(t, harnessOption.Name, parsedValue, harnessOptions) continue } - - t.Fatalf("Unknown compiler option '%s'.", name) + if !allowUnknownOptions { + t.Fatalf("Unknown compiler option '%s'.", name) + } } } From 334a32b879e838a408fb925b4d37e0a6cbce293b Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Wed, 8 Apr 2026 21:48:18 +0000 Subject: [PATCH 13/19] refactor; delete unused code --- internal/fourslash/fourslash.go | 12 +- internal/ls/filerename.go | 191 ++++++++------------------------ internal/lsp/server.go | 6 +- internal/module/resolver.go | 1 - internal/module/types.go | 1 - 5 files changed, 60 insertions(+), 151 deletions(-) diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 652309ab9b8..74a780e6cca 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -3783,10 +3783,8 @@ func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, f.renameFileOrDirectory(t, oldPath, newPath) } -func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newPath string) { - t.Helper() - - pathUpdater := func(path string) (string, bool) { +func (f *FourslashTest) getPathUpdater(oldPath, newPath string) func(path string) (string, bool) { + return func(path string) (string, bool) { compareOptions := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: f.vfs.UseCaseSensitiveFileNames()} if tspath.ComparePaths(path, oldPath, compareOptions) == 0 { return newPath, true @@ -3796,6 +3794,12 @@ func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newP } return "", false } +} + +func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newPath string) { + t.Helper() + + pathUpdater := f.getPathUpdater(oldPath, newPath) renamedContents := map[string]string{} if content, ok := f.vfs.ReadFile(oldPath); ok { diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go index c1a5ff05c51..627c5b17eae 100644 --- a/internal/ls/filerename.go +++ b/internal/ls/filerename.go @@ -3,15 +3,14 @@ package ls import ( "context" "slices" - "strings" "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/module" "github.com/microsoft/typescript-go/internal/modulespecifiers" "github.com/microsoft/typescript-go/internal/scanner" "github.com/microsoft/typescript-go/internal/tsoptions" @@ -31,11 +30,10 @@ func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lspr newPath := newURI.FileName() oldToNew := l.createPathUpdater(oldPath, newPath) - newToOld := l.createPathUpdater(newPath, oldPath) changeTracker := change.NewTracker(ctx, program.Options(), l.FormatOptions(), l.converters) l.updateTsconfigFiles(program, changeTracker, oldToNew, oldPath, newPath) - l.updateImportsForFileRename(program, changeTracker, oldToNew, newToOld) + l.updateImportsForFileRename(program, changeTracker, oldToNew) result := map[lsproto.DocumentUri][]*lsproto.TextEdit{} for fileName, edits := range changeTracker.GetChanges() { @@ -167,42 +165,41 @@ func tryUpdateConfigString(configFile *ast.SourceFile, configDir string, element return true } -func (l *LanguageService) updateImportsForFileRename(program *compiler.Program, changeTracker *change.Tracker, oldToNew pathUpdater, newToOld pathUpdater) { +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 { - newFromOld, hasNewFromOld := oldToNew(sourceFile.FileName()) - oldFromNew, hasOldFromNew := newToOld(sourceFile.FileName()) + oldFileName := sourceFile.FileName() + newFromOld, fileMoved := oldToNew(sourceFile.FileName()) newImportFromPath := sourceFile.FileName() - if hasNewFromOld { + if fileMoved { newImportFromPath = newFromOld } - oldImportFromPath := sourceFile.FileName() - if hasOldFromNew { - oldImportFromPath = oldFromNew - } - importingSourceFileMoved := hasNewFromOld || hasOldFromNew for _, ref := range sourceFile.ReferencedFiles { if !tspath.IsExternalModuleNameRelative(ref.FileName) { continue } - oldAbsolute := tspath.NormalizePath(tspath.CombinePaths(tspath.GetDirectoryPath(oldImportFromPath), ref.FileName)) - newAbsolute, ok := oldToNew(oldAbsolute) - if !ok { - continue - } - updated := relativeImportPathFromDirectory(tspath.GetDirectoryPath(newImportFromPath), newAbsolute, l.UseCaseSensitiveFileNames()) + 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, newToOld, newImportFromPath, oldImportFromPath, importingSourceFileMoved, moduleSpecifierPreferences) + 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) } @@ -210,35 +207,37 @@ func (l *LanguageService) updateImportsForFileRename(program *compiler.Program, } } -func (l *LanguageService) getUpdatedImportSpecifier(program *compiler.Program, checker interface { - GetSymbolAtLocation(node *ast.Node) *ast.Symbol -}, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, newToOld pathUpdater, newImportFromPath string, oldImportFromPath string, importingSourceFileMoved bool, userPreferences modulespecifiers.UserPreferences, +// 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 "" } - if updated := getUpdatedImportSpecifierFromMovedSourceFiles(program, sourceFile, importLiteral, oldToNew, newImportFromPath, userPreferences); updated != "" && updated != importLiteral.Text() { - return updated - } - - var target *toImport - if _, hasOldFromNew := newToOld(sourceFile.FileName()); hasOldFromNew { - resolutionMode := program.GetModeForUsageLocation(sourceFile, importLiteral) - target = getSourceFileToImportFromResolved(importLiteral, program.ResolveModuleName(importLiteral.Text(), oldImportFromPath, resolutionMode), oldToNew, program.GetSourceFiles()) - } else { - target = getSourceFileToImport(program, importedModuleSymbol, sourceFile, importLiteral, oldToNew, userPreferences) - } + target := getSourceFileToImport(program, sourceFile, importLiteral, oldToNew) if target == nil { - if importingSourceFileMoved && tspath.IsExternalModuleNameRelative(importLiteral.Text()) { - absoluteTarget := tspath.NormalizePath(tspath.CombinePaths(tspath.GetDirectoryPath(sourceFile.FileName()), importLiteral.Text())) - return relativeImportPathFromDirectory(tspath.GetDirectoryPath(newImportFromPath), absoluteTarget, l.UseCaseSensitiveFileNames()) + // 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 "" } @@ -258,117 +257,25 @@ func (l *LanguageService) getUpdatedImportSpecifier(program *compiler.Program, c return updated } -func getSourceFileToImport(program *compiler.Program, importedModuleSymbol *ast.Symbol, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, userPreferences modulespecifiers.UserPreferences) *toImport { - if importedModuleSymbol != nil { - if moduleSourceFile := core.Find(importedModuleSymbol.Declarations, ast.IsSourceFile); moduleSourceFile != nil { - oldFileName := moduleSourceFile.AsSourceFile().FileName() - if newFileName, ok := oldToNew(oldFileName); ok { - return &toImport{newFileName: newFileName, updated: true} - } - return &toImport{newFileName: oldFileName, updated: false} - } - } - - if resolved := program.GetResolvedModuleFromModuleSpecifier(sourceFile, importLiteral); resolved != nil { - return getSourceFileToImportFromResolved(importLiteral, resolved, oldToNew, program.GetSourceFiles()) - } - - resolutionMode := program.GetModeForUsageLocation(sourceFile, importLiteral) - if resolved := program.ResolveModuleName(importLiteral.Text(), sourceFile.FileName(), resolutionMode); resolved != nil { - return getSourceFileToImportFromResolved(importLiteral, resolved, oldToNew, program.GetSourceFiles()) - } - - return getSourceFileToImportFromMovedSourceFiles(program, sourceFile, importLiteral, oldToNew, resolutionMode, userPreferences) -} - -func getSourceFileToImportFromResolved(importLiteral *ast.StringLiteralLike, resolved *module.ResolvedModule, oldToNew pathUpdater, sourceFiles []*ast.SourceFile) *toImport { - if resolved == nil { - return nil - } - - if resolved.IsResolved() { - if result := tryChange(resolved.ResolvedFileName, oldToNew); result != nil { - return result - } - } - - for _, oldFileName := range resolved.FailedLookupLocations { - if result := tryChangeWithIgnoringPackageJSONExisting(oldFileName, oldToNew, sourceFiles); result != nil { - return result - } - } - - if tspath.IsExternalModuleNameRelative(importLiteral.Text()) { - for _, oldFileName := range resolved.FailedLookupLocations { - if result := tryChangeWithIgnoringPackageJSON(oldFileName, oldToNew); result != nil { - return result - } - } - } - - if resolved.IsResolved() { - return &toImport{newFileName: resolved.ResolvedFileName, updated: false} - } - return nil -} - -func tryChangeWithIgnoringPackageJSONExisting(oldFileName string, oldToNew pathUpdater, sourceFiles []*ast.SourceFile) *toImport { - newFileName, ok := oldToNew(oldFileName) - if !ok || !sourceFileExists(sourceFiles, newFileName) { - return nil - } - return tryChangeWithIgnoringPackageJSON(oldFileName, oldToNew) -} - -func tryChangeWithIgnoringPackageJSON(oldFileName string, oldToNew pathUpdater) *toImport { - if strings.HasSuffix(oldFileName, "/package.json") { - return nil - } - return tryChange(oldFileName, oldToNew) -} - -func tryChange(oldFileName string, oldToNew pathUpdater) *toImport { - if newFileName, ok := oldToNew(oldFileName); ok { - return &toImport{newFileName: newFileName, updated: true} - } - return nil -} - -func sourceFileExists(sourceFiles []*ast.SourceFile, fileName string) bool { - for _, sourceFile := range sourceFiles { - if sourceFile.FileName() == fileName { - return true - } - } - return false -} - -func getSourceFileToImportFromMovedSourceFiles(program *compiler.Program, sourceFile *ast.SourceFile, importLiteral *ast.StringLiteralLike, oldToNew pathUpdater, resolutionMode core.ResolutionMode, userPreferences modulespecifiers.UserPreferences) *toImport { - for _, candidate := range program.GetSourceFiles() { - newFileName, ok := oldToNew(candidate.FileName()) - if !ok { - continue - } - - moduleSpecifier := modulespecifiers.UpdateModuleSpecifier( - program.Options(), - program, - sourceFile, - sourceFile.FileName(), - importLiteral.Text(), - candidate.FileName(), - userPreferences, - modulespecifiers.ModuleSpecifierOptions{ - OverrideImportMode: resolutionMode, - }, - ) - if moduleSpecifier == importLiteral.Text() { +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() { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 752bf3285fa..47113975345 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1270,9 +1270,9 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena return lsproto.WillRenameFilesResponse{}, nil } - uris := make([]lsproto.DocumentUri, 0, len(params.Files)*2) + uris := make([]lsproto.DocumentUri, 0, len(params.Files)) for _, file := range params.Files { - uris = append(uris, lsproto.DocumentUri(file.OldUri), lsproto.DocumentUri(file.NewUri)) + uris = append(uris, lsproto.DocumentUri(file.OldUri)) } services := s.session.GetLanguageServicesForDocuments(ctx, uris) @@ -1280,7 +1280,7 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena seen := make(map[lsproto.DocumentUri]map[lsproto.Range]string) for _, languageService := range services { - for _, file := range params.Files { + for _, file := range params.Files { // !!! TODO: can optimize by batching per language service instead of per file? for uri, edits := range languageService.GetEditsForFileRename(ctx, lsproto.DocumentUri(file.OldUri), lsproto.DocumentUri(file.NewUri)) { seenForURI, ok := seen[uri] if !ok { diff --git a/internal/module/resolver.go b/internal/module/resolver.go index 9b18e0c3cb5..9a69fba2400 100644 --- a/internal/module/resolver.go +++ b/internal/module/resolver.go @@ -1121,7 +1121,6 @@ func (r *resolutionState) createResolvedModuleHandlingSymlink(resolved *resolved func (r *resolutionState) createResolvedModule(resolved *resolved, isExternalLibraryImport bool) *ResolvedModule { var resolvedModule ResolvedModule resolvedModule.ResolutionDiagnostics = r.diagnostics - resolvedModule.FailedLookupLocations = r.failedLookupLocations if resolved != nil { resolvedModule.ResolvedFileName = resolved.path diff --git a/internal/module/types.go b/internal/module/types.go index 80944ad0001..32d18530295 100644 --- a/internal/module/types.go +++ b/internal/module/types.go @@ -64,7 +64,6 @@ func (p *PackageId) PackageName() string { type ResolvedModule struct { ResolutionDiagnostics []*ast.Diagnostic - FailedLookupLocations []string ResolvedFileName string OriginalPath string Extension string From 324aa203d66474f597c6ef106dff1e8f48c484a4 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 10 Apr 2026 02:28:38 +0000 Subject: [PATCH 14/19] fix arbitrary extension case and tests + refactorings --- internal/fourslash/_scripts/manualTests.txt | 7 + internal/fourslash/fourslash.go | 247 +++++++++++------- .../getEditsForFileRename_cssImport1_test.go | 39 +++ .../getEditsForFileRename_cssImport2_test.go | 40 +++ .../getEditsForFileRename_cssImport3_test.go | 42 +++ .../tests/importRenameFileFlow_test.go | 45 ++-- ...tEditsForFileRename_directory_down_test.go | 6 +- ...getEditsForFileRename_directory_up_test.go | 6 +- .../getEditsForFileRename_jsExtension_test.go | 6 +- ...tsForFileRename_preservePathEnding_test.go | 6 +- ...itsForFileRename_resolveJsonModule_test.go | 6 +- ...ForFileRename_shortenRelativePaths_test.go | 6 +- .../getEditsForFileRename_subDir_test.go | 6 +- internal/ls/filerename.go | 37 ++- internal/ls/host.go | 1 + internal/lsp/server.go | 90 +++++-- internal/outputpaths/outputpaths.go | 13 +- internal/project/snapshot.go | 4 + internal/tspath/extension.go | 16 +- 19 files changed, 435 insertions(+), 188 deletions(-) create mode 100644 internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go create mode 100644 internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go create mode 100644 internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_directory_down_test.go (89%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_directory_up_test.go (90%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_jsExtension_test.go (74%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_preservePathEnding_test.go (84%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_resolveJsonModule_test.go (73%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_shortenRelativePaths_test.go (73%) rename internal/fourslash/tests/{gen => manual}/getEditsForFileRename_subDir_test.go (74%) diff --git a/internal/fourslash/_scripts/manualTests.txt b/internal/fourslash/_scripts/manualTests.txt index 709553602f8..45d16d48027 100644 --- a/internal/fourslash/_scripts/manualTests.txt +++ b/internal/fourslash/_scripts/manualTests.txt @@ -49,7 +49,14 @@ exhaustiveCaseCompletions7 exhaustiveCaseCompletions8 formatOnEnterInComment getEditsForFileRename_caseInsensitive +getEditsForFileRename_directory_down +getEditsForFileRename_directory_up +getEditsForFileRename_jsExtension getEditsForFileRename_preferences +getEditsForFileRename_preservePathEnding +getEditsForFileRename_resolveJsonModule +getEditsForFileRename_shortenRelativePaths +getEditsForFileRename_subDir getEditsForFileRename_unaffectedNonRelativePath getOutliningSpans importNameCodeFix_uriStyleNodeCoreModules1 diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 74a780e6cca..5178df498aa 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -81,8 +81,8 @@ func newScriptInfo(fileName string, content string) *scriptInfo { } } -func (s *scriptInfo) editContent(changes []core.TextChange) { - s.content = core.ApplyBulkEdits(s.content, changes) +func (s *scriptInfo) editContent(change core.TextChange) { + s.content = change.ApplyTo(s.content) s.lineMap = lsconv.ComputeLSPLineStarts(s.content) s.version++ } @@ -461,6 +461,12 @@ func GetDefaultCapabilities() *lsproto.ClientCapabilities { }, Workspace: &lsproto.WorkspaceClientCapabilities{ Configuration: ptrTrue, + WorkspaceEdit: &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: ptrTrue, + ResourceOperations: &[]lsproto.ResourceOperationKind{ + lsproto.ResourceOperationKindRename, + }, + }, }, } } @@ -2945,19 +2951,35 @@ func (f *FourslashTest) typeText(t *testing.T, text string) { // Edits the script and updates marker and range positions accordingly. // This does not update the current caret position. func (f *FourslashTest) editScriptAndUpdateMarkers(t *testing.T, fileName string, editStart int, editEnd int, newText string) { - script := f.editScript(t, fileName, []core.TextChange{{TextRange: core.NewTextRange(editStart, editEnd), NewText: newText}}) - for _, marker := range f.testData.Markers { - if marker.FileName() == fileName { - marker.Position = updatePosition(marker.Position, editStart, editEnd, newText) - marker.LSPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(marker.Position)) + f.editScriptAndUpdateMarkersWorker(t, fileName, []core.TextChange{{TextRange: core.NewTextRange(editStart, editEnd), NewText: newText}}) +} + +func (f *FourslashTest) editScriptAndUpdateMarkersWorker(t *testing.T, fileName string, changes []core.TextChange) { + // Sort changes by position (ascending) so we can apply in reverse + sortedChanges := slices.Clone(changes) + slices.SortFunc(sortedChanges, func(a, b core.TextChange) int { + return a.Pos() - b.Pos() + }) + + // Apply changes in reverse order to preserve positions of earlier changes + for i := len(sortedChanges) - 1; i >= 0; i-- { + change := sortedChanges[i] + editStart := change.Pos() + editEnd := change.End() + script := f.editScript(t, fileName, change) + for _, marker := range f.testData.Markers { + if marker.FileName() == fileName { + marker.Position = updatePosition(marker.Position, editStart, editEnd, change.NewText) + marker.LSPosition = f.converters.PositionToLineAndCharacter(script, core.TextPos(marker.Position)) + } } - } - for _, rangeMarker := range f.testData.Ranges { - if rangeMarker.FileName() == fileName { - start := updatePosition(rangeMarker.Range.Pos(), editStart, editEnd, newText) - end := updatePosition(rangeMarker.Range.End(), editStart, editEnd, newText) - rangeMarker.Range = core.NewTextRange(start, end) - rangeMarker.LSRange = f.converters.ToLSPRange(script, rangeMarker.Range) + for _, rangeMarker := range f.testData.Ranges { + if rangeMarker.FileName() == fileName { + start := updatePosition(rangeMarker.Range.Pos(), editStart, editEnd, change.NewText) + end := updatePosition(rangeMarker.Range.End(), editStart, editEnd, change.NewText) + rangeMarker.Range = core.NewTextRange(start, end) + rangeMarker.LSRange = f.converters.ToLSPRange(script, rangeMarker.Range) + } } } f.rangesByText = nil @@ -2974,43 +2996,29 @@ func updatePosition(pos int, editStart int, editEnd int, newText string) int { return pos + len(newText) - (editEnd - editStart) } -func (f *FourslashTest) editScript(t *testing.T, fileName string, changes []core.TextChange) *scriptInfo { +func (f *FourslashTest) editScript(t *testing.T, fileName string, change core.TextChange) *scriptInfo { script := f.getOrLoadScriptInfo(fileName) if script == nil { panic(fmt.Sprintf("Script info for file %s not found", fileName)) } - var changeRange lsproto.Range - if len(changes) == 1 { - changeRange = f.converters.ToLSPRange(script, core.NewTextRange(changes[0].Pos(), changes[0].End())) + changeRange := f.converters.ToLSPRange(script, core.NewTextRange(change.Pos(), change.End())) + script.editContent(change) + if err := f.vfs.WriteFile(fileName, script.content); err != nil { + t.Fatalf("failed to write to VFS for %s: %v", fileName, err) } - script.editContent(changes) - if len(changes) == 1 { - sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ - TextDocument: lsproto.VersionedTextDocumentIdentifier{ - Uri: lsconv.FileNameToDocumentURI(fileName), - Version: script.version, - }, - ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ - Partial: &lsproto.TextDocumentContentChangePartial{ - Range: changeRange, - Text: changes[0].NewText, - }, - }}, - }) - } else { - sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ - TextDocument: lsproto.VersionedTextDocumentIdentifier{ - Uri: lsconv.FileNameToDocumentURI(fileName), - Version: script.version, + sendNotification(t, f, lsproto.TextDocumentDidChangeInfo, &lsproto.DidChangeTextDocumentParams{ + TextDocument: lsproto.VersionedTextDocumentIdentifier{ + Uri: lsconv.FileNameToDocumentURI(fileName), + Version: script.version, + }, + ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ + Partial: &lsproto.TextDocumentContentChangePartial{ + Range: changeRange, + Text: change.NewText, }, - ContentChanges: []lsproto.TextDocumentContentChangePartialOrWholeDocument{{ - WholeDocument: &lsproto.TextDocumentContentChangeWholeDocument{ - Text: script.content, - }, - }}, - }) - } + }}, + }) return script } @@ -3740,22 +3748,18 @@ func (f *FourslashTest) WillRenameFiles(t *testing.T, files ...*lsproto.FileRena }) } -func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, newPath string, expectedFileContents map[string]string, preferences *lsutil.UserPreferences) { +// Emulates a file rename by sending a workspace/willRenameFiles request and applying the resulting edits and file renames. +func (f *FourslashTest) willRenameFilesWorker(t *testing.T, files ...*lsproto.FileRename) { t.Helper() - if preferences != nil { - defer f.ConfigureWithReset(t, preferences)() - } + result := f.WillRenameFiles(t, files...) - result := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI(oldPath)), - NewUri: string(lsconv.FileNameToDocumentURI(newPath)), - }) if result.WorkspaceEdit == nil { - if len(expectedFileContents) == 0 { + for _, file := range files { + oldPath := lsproto.DocumentUri(file.OldUri).FileName() + newPath := lsproto.DocumentUri(file.NewUri).FileName() f.renameFileOrDirectory(t, oldPath, newPath) - return } - t.Fatalf("workspace/willRenameFiles returned nil workspace edit") + return } if result.WorkspaceEdit.Changes != nil { @@ -3768,10 +3772,61 @@ func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, NewText: edit.NewText, } }) - f.editScript(t, fileName, changes) + f.editScriptAndUpdateMarkersWorker(t, fileName, changes) } } + var renameFiles []*lsproto.RenameFile + if result.WorkspaceEdit.DocumentChanges != nil { + for _, docChange := range *result.WorkspaceEdit.DocumentChanges { + if docChange.TextDocumentEdit != nil { + fileName := docChange.TextDocumentEdit.TextDocument.Uri.FileName() + script := f.getOrLoadScriptInfo(fileName) + changes := core.Map(docChange.TextDocumentEdit.Edits, func(edit lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit) core.TextChange { + textEdit := edit.TextEdit + return core.TextChange{ + TextRange: f.converters.FromLSPRange(script, textEdit.Range), + NewText: textEdit.NewText, + } + }) + f.editScriptAndUpdateMarkersWorker(t, fileName, changes) + } else if docChange.RenameFile != nil { + renameFiles = append(renameFiles, docChange.RenameFile) + } + } + } + + var fileRenames []*lsproto.FileRename + for _, renameFile := range renameFiles { + oldFileName := lsproto.DocumentUri(renameFile.OldUri).FileName() + if !f.vfs.FileExists(oldFileName) && f.getScriptInfo(oldFileName) == nil { + continue + } + fileRenames = append(fileRenames, &lsproto.FileRename{ + OldUri: string(renameFile.OldUri), + NewUri: string(renameFile.NewUri), + }) + } + f.willRenameFilesWorker(t, fileRenames...) + + for _, file := range files { + oldPath := lsproto.DocumentUri(file.OldUri).FileName() + newPath := lsproto.DocumentUri(file.NewUri).FileName() + f.renameFileOrDirectory(t, oldPath, newPath) + } +} + +func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, newPath string, expectedFileContents map[string]string, preferences *lsutil.UserPreferences) { + t.Helper() + if preferences != nil { + defer f.ConfigureWithReset(t, preferences)() + } + + f.willRenameFilesWorker(t, &lsproto.FileRename{ + OldUri: string(lsconv.FileNameToDocumentURI(oldPath)), + NewUri: string(lsconv.FileNameToDocumentURI(newPath)), + }) + for fileName, expectedContent := range expectedFileContents { script := f.getOrLoadScriptInfo(fileName) if script == nil { @@ -3779,8 +3834,6 @@ func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, } assert.Equal(t, script.content, expectedContent, fmt.Sprintf("File content after workspace/willRenameFiles edits did not match expected content for %s.", fileName)) } - - f.renameFileOrDirectory(t, oldPath, newPath) } func (f *FourslashTest) getPathUpdater(oldPath, newPath string) func(path string) (string, bool) { @@ -3800,38 +3853,43 @@ func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newP t.Helper() pathUpdater := f.getPathUpdater(oldPath, newPath) - renamedContents := map[string]string{} - if content, ok := f.vfs.ReadFile(oldPath); ok { - renamedContents[oldPath] = content + // Collect all file paths that need to be renamed. + oldFileNames := map[string]struct{}{} + if _, ok := f.vfs.ReadFile(oldPath); ok { + oldFileNames[oldPath] = struct{}{} } else { walkErr := f.vfs.WalkDir(oldPath, func(path string, d vfs.DirEntry, err error) error { if err != nil { return err } - if d.IsDir() { - return nil - } - fileContent, exists := f.vfs.ReadFile(path) - if !exists { - return fmt.Errorf("file %s disappeared during rename walk", path) + if !d.IsDir() { + oldFileNames[path] = struct{}{} } - renamedContents[path] = fileContent return nil }) if walkErr != nil { t.Fatalf("failed to collect files for rename %s -> %s: %v", oldPath, newPath, walkErr) } } - - if len(renamedContents) == 0 { + if len(oldFileNames) == 0 { t.Fatalf("rename source %s did not exist in test environment", oldPath) } - wasOpen := map[string]bool{} - for oldFileName := range renamedContents { + // !!! TODO: handle overwrites if we need to. + // For each file: close if open, update script infos, write to VFS at new path, and collect file-watch events. + fileEvents := make([]*lsproto.FileEvent, 0, len(oldFileNames)*2) + reopenAtNewPath := map[string]string{} // newFileName -> content, for files that were open + for oldFileName := range oldFileNames { + newFileName, ok := pathUpdater(oldFileName) + if !ok { + t.Fatalf("failed to compute renamed path for %s", oldFileName) + } + + // Send didClose for open files; get content from the old script info. if _, ok := f.openFiles[oldFileName]; ok { - wasOpen[oldFileName] = true + script := f.scriptInfos[oldFileName] + reopenAtNewPath[newFileName] = script.content sendNotification(t, f, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: lsconv.FileNameToDocumentURI(oldFileName), @@ -3839,57 +3897,46 @@ func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newP }) delete(f.openFiles, oldFileName) } + + f.scriptInfos[newFileName] = newScriptInfo(newFileName, f.scriptInfos[oldFileName].content) delete(f.scriptInfos, oldFileName) - } - changes := make([]*lsproto.FileEvent, 0, len(renamedContents)*2) - for oldFileName, content := range renamedContents { - newFileName, ok := pathUpdater(oldFileName) + // Write renamed file to VFS. + content, ok := f.vfs.ReadFile(oldFileName) if !ok { - t.Fatalf("failed to compute renamed path for %s", oldFileName) + t.Fatalf("failed to read content for %s during rename to %s", oldFileName, newFileName) } if err := f.vfs.WriteFile(newFileName, content); err != nil { t.Fatalf("failed to write renamed file %s: %v", newFileName, err) } - f.scriptInfos[newFileName] = newScriptInfo(newFileName, content) - changes = append(changes, &lsproto.FileEvent{ - Uri: lsconv.FileNameToDocumentURI(oldFileName), - Type: lsproto.FileChangeTypeDeleted, - }) - changes = append(changes, &lsproto.FileEvent{ - Uri: lsconv.FileNameToDocumentURI(newFileName), - Type: lsproto.FileChangeTypeCreated, - }) + + fileEvents = append(fileEvents, + &lsproto.FileEvent{Uri: lsconv.FileNameToDocumentURI(oldFileName), Type: lsproto.FileChangeTypeDeleted}, + &lsproto.FileEvent{Uri: lsconv.FileNameToDocumentURI(newFileName), Type: lsproto.FileChangeTypeCreated}, + ) } + // Remove the old path from VFS and notify the server of all file-system changes. if err := f.vfs.Remove(oldPath); err != nil { t.Fatalf("failed to remove old path %s: %v", oldPath, err) } - sendNotification(t, f, lsproto.WorkspaceDidChangeWatchedFilesInfo, &lsproto.DidChangeWatchedFilesParams{ - Changes: changes, + Changes: fileEvents, }) - for oldFileName := range wasOpen { - newFileName, ok := pathUpdater(oldFileName) - if !ok { - t.Fatalf("failed to compute reopened path for %s", oldFileName) - } - script := f.getScriptInfo(newFileName) - if script == nil { - t.Fatalf("missing script info for reopened file %s", newFileName) - } - f.activeFilename = newFileName + // Reopen files that were previously open at their new paths. + for newFileName, content := range reopenAtNewPath { sendNotification(t, f, lsproto.TextDocumentDidOpenInfo, &lsproto.DidOpenTextDocumentParams{ TextDocument: &lsproto.TextDocumentItem{ Uri: lsconv.FileNameToDocumentURI(newFileName), LanguageId: getLanguageKind(newFileName), - Text: script.content, + Text: content, }, }) f.openFiles[newFileName] = struct{}{} } + // Update active filename if it was under the renamed path. if updatedActive, ok := pathUpdater(f.activeFilename); ok { f.activeFilename = updatedActive } diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go new file mode 100644 index 00000000000..398d69a5cbc --- /dev/null +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go @@ -0,0 +1,39 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_cssImport1(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @Filename: /tsconfig.json +{ "compilerOptions": { "allowArbitraryExtensions": true } } +// @Filename: /app.css +.cookie-banner { + display: none; +} +// @Filename: /app.d.css.ts +declare const css: { + cookieBanner: string; +}; +export default css; +// @Filename: /a.ts +import styles from "./app.css";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/app.css", "/app2.css", map[string]string{ + "/a.ts": `import styles from "./app2.css";`, + "/app2.css": `.cookie-banner { + display: none; +}`, + "/app2.d.css.ts": `declare const css: { + cookieBanner: string; +}; +export default css;`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go new file mode 100644 index 00000000000..1251b3efd9b --- /dev/null +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go @@ -0,0 +1,40 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_cssImport2(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /tsconfig.json +{ "compilerOptions": { "allowArbitraryExtensions": true } } +// @Filename: /app.css +.cookie-banner { + display: none; +} +// @Filename: /app.d.css.ts +declare const css: { + cookieBanner: string; +}; +export default css; +// @Filename: /a.ts +import styles from "./app.css";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyWillRenameFilesEdits(t, "/app.d.css.ts", "/app2.d.css.ts", map[string]string{ + "/a.ts": `import styles from "./app2.css";`, + // We cannot rename the .css file because that would lead to a circularity of `willRenameFiles`. + // So this case does not fully work. + "/app.css": `.cookie-banner { + display: none; +}`, + "/app2.d.css.ts": `declare const css: { + cookieBanner: string; +}; +export default css;`, + }, nil /*preferences*/) +} diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go new file mode 100644 index 00000000000..e2e9c986b18 --- /dev/null +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go @@ -0,0 +1,42 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_cssImport3(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @Filename: /tsconfig.json +{ "compilerOptions": { "allowArbitraryExtensions": true } } +// @Filename: /app.css +.cookie-banner { + display: none; +} +// @Filename: /app.d.css.ts +declare const css: { + cookieBanner: string; +}; +export default css; +// @Filename: /a.ts +import styles from ".//*rename*/app.css";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.GoToMarker(t, "rename") + f.RenameAtCaret(t, "/app2.css") + f.GoToFile(t, "/a.ts") + f.VerifyCurrentFileContent(t, `import styles from "./app2.css";`) + f.GoToFile(t, "/app2.d.css.ts") + f.VerifyCurrentFileContent(t, `declare const css: { + cookieBanner: string; +};`) + f.GoToFile(t, "/app2.css") + f.VerifyCurrentFileContent(t, `declare const css: { + cookieBanner: string; +}; +export default css;`) +} diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go index 5fc1443541b..c4e045b90fd 100644 --- a/internal/fourslash/tests/importRenameFileFlow_test.go +++ b/internal/fourslash/tests/importRenameFileFlow_test.go @@ -13,6 +13,21 @@ import ( "gotest.tools/v3/assert" ) +func getDocumentChangeEdits(t *testing.T, documentChanges *[]lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile, uri lsproto.DocumentUri) []*lsproto.TextEdit { + t.Helper() + var edits []*lsproto.TextEdit + for _, change := range *documentChanges { + if change.TextDocumentEdit != nil && change.TextDocumentEdit.TextDocument.Uri == uri { + for _, e := range change.TextDocumentEdit.Edits { + if e.TextEdit != nil { + edits = append(edits, e.TextEdit) + } + } + } + } + return edits +} + func fileRenameCapabilities() *lsproto.ClientCapabilities { capabilities := fourslash.GetDefaultCapabilities() capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ @@ -54,9 +69,9 @@ export = { name: "stuff" }; NewUri: string(lsconv.FileNameToDocumentURI("/src/renamed.cts")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - edits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/src/example.ts")] + edits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/src/example.ts")) assert.Equal(t, len(edits), 1) assert.Equal(t, edits[0].NewText, "./renamed.cjs") } @@ -90,9 +105,9 @@ export const x = 1; NewUri: string(lsconv.FileNameToDocumentURI("/src/renamed")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - edits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/src/example.ts")] + edits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/src/example.ts")) assert.Equal(t, len(edits), 1) assert.Equal(t, edits[0].NewText, "./renamed") } @@ -120,15 +135,15 @@ export const x = 1; NewUri: string(lsconv.FileNameToDocumentURI("/src/new.ts")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/src/app.ts")] + appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/src/app.ts")) assert.Equal(t, len(appEdits), 2) newTexts := []string{appEdits[0].NewText, appEdits[1].NewText} slices.Sort(newTexts) assert.DeepEqual(t, newTexts, []string{"./new", "./new.ts"}) - tsconfigEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/tsconfig.json")] + tsconfigEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/tsconfig.json")) assert.Equal(t, len(tsconfigEdits), 1) assert.Equal(t, tsconfigEdits[0].NewText, "src/new.ts") } @@ -163,9 +178,9 @@ import { x } from "../a/old"; NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/solution/b/app.ts")] + appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/solution/b/app.ts")) assert.Equal(t, len(appEdits), 1) assert.Equal(t, appEdits[0].NewText, "../a/new") } @@ -207,9 +222,9 @@ import { x } from "../a/old"; NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/solution/b/app.ts")] + appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/solution/b/app.ts")) assert.Equal(t, len(appEdits), 1) assert.Equal(t, appEdits[0].NewText, "../a/new") } @@ -258,9 +273,9 @@ import { x } from "../a/old"; NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/solution/b/app.ts")] + appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/solution/b/app.ts")) assert.Equal(t, len(appEdits), 1) assert.Equal(t, appEdits[0].NewText, "../a/new") } @@ -310,9 +325,9 @@ import { x } from "project-b/old"; NewUri: string(lsconv.FileNameToDocumentURI("/packages/project-b/new.ts")), }) assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.Changes != nil) + assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - appEdits := (*willRenameResult.WorkspaceEdit.Changes)[lsconv.FileNameToDocumentURI("/packages/project-a/app.ts")] + appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/packages/project-a/app.ts")) assert.Equal(t, len(appEdits), 1) assert.Equal(t, appEdits[0].NewText, "project-b/new") } diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_directory_down_test.go similarity index 89% rename from internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_directory_down_test.go index f94799e6b18..091aec0f022 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_directory_down_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_directory_down_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_directory_down" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_directory_down(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @Filename: /a.ts @@ -54,7 +50,7 @@ export default 0;`, import old from "../newDir/new"; import old2 from "../newDir/new/file"; export default 0;`, - "/src/old/index.ts": `import a from "../../../a"; + "/src/newDir/new/index.ts": `import a from "../../../a"; import a2 from "../../b"; import a3 from "../../foo/c"; import f from "./file"; diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_directory_up_test.go similarity index 90% rename from internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_directory_up_test.go index e7ed9b47fb1..b6fd8deea31 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_directory_up_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_directory_up_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_directory_up" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_directory_up(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @Filename: /a.ts @@ -54,7 +50,7 @@ export default 0;`, import old from "../../newDir/new"; import old2 from "../../newDir/new/file"; export default 0;`, - "/src/old/index.ts": `import a from "../../a"; + "/newDir/new/index.ts": `import a from "../../a"; import a2 from "../../src/b"; import a3 from "../../src/foo/c"; import f from "./file"; diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_jsExtension_test.go similarity index 74% rename from internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_jsExtension_test.go index 977f50fdb5b..710aecf31b5 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_jsExtension_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_jsExtension_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_jsExtension" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_jsExtension(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @allowJs: true @@ -22,6 +18,6 @@ import { a } from "./src/a.js";` f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) defer done() f.VerifyWillRenameFilesEdits(t, "/b.js", "/src/b.js", map[string]string{ - "/b.js": `import { a } from "./a.js";`, + "/src/b.js": `import { a } from "./a.js";`, }, nil /*preferences*/) } diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_preservePathEnding_test.go similarity index 84% rename from internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_preservePathEnding_test.go index fc335b8c9c5..e56ac8a5d3e 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_preservePathEnding_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_preservePathEnding_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_preservePathEnding" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_preservePathEnding(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @allowJs: true @@ -36,7 +32,7 @@ import { j } from "./j.jonah.json";` f.VerifyNoErrors(t) f.VerifyWillRenameFilesEdits(t, "/a.js", "/b.js", map[string]string{}, nil /*preferences*/) f.VerifyWillRenameFilesEdits(t, "/b.js", "/src/b.js", map[string]string{ - "/b.js": `import { x as x0 } from ".."; + "/src/b.js": `import { x as x0 } from ".."; import { x as x1 } from "../index"; import { x as x2 } from "../index.js"; import { y } from "../jsx.jsx"; diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_resolveJsonModule_test.go similarity index 73% rename from internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_resolveJsonModule_test.go index 0865a5adcb3..c515f9fb387 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_resolveJsonModule_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_resolveJsonModule_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_resolveJsonModule" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_resolveJsonModule(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @resolveJsonModule: true @@ -22,6 +18,6 @@ import text from "./message.json"; f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) defer done() f.VerifyWillRenameFilesEdits(t, "/a.ts", "/src/a.ts", map[string]string{ - "/a.ts": `import text from "../message.json";`, + "/src/a.ts": `import text from "../message.json";`, }, nil /*preferences*/) } diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_shortenRelativePaths_test.go similarity index 73% rename from internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_shortenRelativePaths_test.go index b405057609b..f12b639d0d5 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_shortenRelativePaths_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_shortenRelativePaths_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_shortenRelativePaths" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_shortenRelativePaths(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @Filename: /src/foo/x.ts @@ -21,6 +17,6 @@ import { x } from "./foo/x";` f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) defer done() f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/foo/new.ts", map[string]string{ - "/src/old.ts": `import { x } from "./x";`, + "/src/foo/new.ts": `import { x } from "./x";`, }, nil /*preferences*/) } diff --git a/internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go b/internal/fourslash/tests/manual/getEditsForFileRename_subDir_test.go similarity index 74% rename from internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go rename to internal/fourslash/tests/manual/getEditsForFileRename_subDir_test.go index b5ebaad931c..a4a799775cd 100644 --- a/internal/fourslash/tests/gen/getEditsForFileRename_subDir_test.go +++ b/internal/fourslash/tests/manual/getEditsForFileRename_subDir_test.go @@ -1,6 +1,3 @@ -// Code generated by convertFourslash; DO NOT EDIT. -// To modify this test, run "npm run makemanual getEditsForFileRename_subDir" - package fourslash_test import ( @@ -11,7 +8,6 @@ import ( ) func TestGetEditsForFileRename_subDir(t *testing.T) { - fourslash.SkipIfFailing(t) t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `// @Filename: /src/foo/a.ts @@ -21,6 +17,6 @@ import a from "./foo/a";` f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) defer done() f.VerifyWillRenameFilesEdits(t, "/src/old.ts", "/src/dir/new.ts", map[string]string{ - "/src/old.ts": `import a from "../foo/a";`, + "/src/dir/new.ts": `import a from "../foo/a";`, }, nil /*preferences*/) } diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go index 627c5b17eae..d2a23fa0c40 100644 --- a/internal/ls/filerename.go +++ b/internal/ls/filerename.go @@ -24,7 +24,7 @@ type toImport struct { updated bool } -func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lsproto.DocumentUri, newURI lsproto.DocumentUri) map[lsproto.DocumentUri][]*lsproto.TextEdit { +func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lsproto.DocumentUri, newURI lsproto.DocumentUri) []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile { program := l.GetProgram() oldPath := oldURI.FileName() newPath := newURI.FileName() @@ -35,11 +35,40 @@ func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lspr l.updateTsconfigFiles(program, changeTracker, oldToNew, oldPath, newPath) l.updateImportsForFileRename(program, changeTracker, oldToNew) - result := map[lsproto.DocumentUri][]*lsproto.TextEdit{} + var documentChanges []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile + + // When renaming e.g. `foo.css`, also rename `foo.d.css.ts` if it exists. + if !tspath.IsDeclarationFileName(oldPath) { + dtsExt := tspath.GetDeclarationEmitExtensionForPath(oldPath) + oldDeclarationPath := tspath.ChangeAnyExtension(oldPath, dtsExt, nil /*extensions*/, false /*ignoreCase*/) + if l.host.FileExists(oldDeclarationPath) { + newDeclarationPath := tspath.ChangeAnyExtension(newPath, dtsExt, nil /*extensions*/, false /*ignoreCase*/) + documentChanges = append(documentChanges, lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ + RenameFile: &lsproto.RenameFile{ + OldUri: lsconv.FileNameToDocumentURI(oldDeclarationPath), + NewUri: lsconv.FileNameToDocumentURI(newDeclarationPath), + }, + }) + } + } + for fileName, edits := range changeTracker.GetChanges() { - result[lsconv.FileNameToDocumentURI(fileName)] = edits + 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 result + + return documentChanges } func (l *LanguageService) createPathUpdater(oldPath string, newPath string) pathUpdater { diff --git a/internal/ls/host.go b/internal/ls/host.go index 805264c1724..6088b9bb355 100644 --- a/internal/ls/host.go +++ b/internal/ls/host.go @@ -21,4 +21,5 @@ type Host interface { ReadDirectory(currentDir string, path string, extensions []string, excludes []string, includes []string, depth int) []string GetDirectories(path string) []string DirectoryExists(path string) bool + FileExists(path string) bool } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 47113975345..e74ea49472d 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1252,7 +1252,7 @@ func (s *Server) handleRename(ctx context.Context, params *lsproto.RenameParams, } info := defaultLs.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) - if info.CanRename && info.FileToRename != "" && !s.supportsImportPathFileRename() { + if info.CanRename && info.FileToRename != "" && !s.supportsImportPathFileRename() { // !!! HERE: move this inside getRenameInfo return lsproto.RenameResponse{}, userFacingRequestFailedError("The client doesn't support file rename edits required for import path renames.") } return defaultLs.ProvideRename(ctx, params, orchestrator) @@ -1272,39 +1272,95 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena uris := make([]lsproto.DocumentUri, 0, len(params.Files)) for _, file := range params.Files { + // Handle rename of a file with arbitrary extension which may have a corresponding declaration file. + oldPath := lsproto.DocumentUri(file.OldUri).FileName() + if tspath.HasExtension(oldPath) && core.GetScriptKindFromFileName(oldPath) == core.ScriptKindUnknown { + dtsExt := tspath.GetDeclarationEmitExtensionForPath(oldPath) + oldDeclarationPath := tspath.ChangeAnyExtension(oldPath, dtsExt, nil /*extensions*/, false /*ignoreCase*/) + if s.fs.FileExists(oldDeclarationPath) { + uris = append(uris, lsconv.FileNameToDocumentURI((oldDeclarationPath))) + } + continue + } uris = append(uris, lsproto.DocumentUri(file.OldUri)) } + if len(uris) == 0 { + return lsproto.WillRenameFilesResponse{}, nil + } + services := s.session.GetLanguageServicesForDocuments(ctx, uris) - combined := make(map[lsproto.DocumentUri][]*lsproto.TextEdit) - seen := make(map[lsproto.DocumentUri]map[lsproto.Range]string) + + type editKey struct { + uri lsproto.DocumentUri + range_ lsproto.Range + } + seenEdits := make(map[editKey]string) + seenRenames := make(map[lsproto.DocumentUri]bool) + var documentChanges []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile for _, languageService := range services { - for _, file := range params.Files { // !!! TODO: can optimize by batching per language service instead of per file? - for uri, edits := range languageService.GetEditsForFileRename(ctx, lsproto.DocumentUri(file.OldUri), lsproto.DocumentUri(file.NewUri)) { - seenForURI, ok := seen[uri] - if !ok { - seenForURI = map[lsproto.Range]string{} - seen[uri] = seenForURI - } - for _, edit := range edits { - if newText, ok := seenForURI[edit.Range]; ok && newText == edit.NewText { - continue + for _, file := range params.Files { + changes := languageService.GetEditsForFileRename(ctx, lsproto.DocumentUri(file.OldUri), lsproto.DocumentUri(file.NewUri)) + for _, change := range changes { + if change.RenameFile != nil { + if !seenRenames[change.RenameFile.OldUri] { + seenRenames[change.RenameFile.OldUri] = true + documentChanges = append(documentChanges, change) + } + } else if change.TextDocumentEdit != nil { + uri := change.TextDocumentEdit.TextDocument.Uri + var deduped []lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit + for _, edit := range change.TextDocumentEdit.Edits { + if edit.TextEdit != nil { + key := editKey{uri: uri, range_: edit.TextEdit.Range} + if prev, ok := seenEdits[key]; ok && prev == edit.TextEdit.NewText { + continue + } + seenEdits[key] = edit.TextEdit.NewText + } + deduped = append(deduped, edit) + } + if len(deduped) > 0 { + documentChanges = append(documentChanges, lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ + TextDocumentEdit: &lsproto.TextDocumentEdit{ + TextDocument: change.TextDocumentEdit.TextDocument, + Edits: deduped, + }, + }) } - seenForURI[edit.Range] = edit.NewText - combined[uri] = append(combined[uri], edit) } } } } - if len(combined) == 0 { + if len(documentChanges) == 0 { return lsproto.WillRenameFilesResponse{}, nil } + if lsproto.GetClientCapabilities(ctx).Workspace.WorkspaceEdit.DocumentChanges { + return lsproto.WillRenameFilesResponse{ + WorkspaceEdit: &lsproto.WorkspaceEdit{ + DocumentChanges: &documentChanges, + }, + }, nil + } + + changes := make(map[lsproto.DocumentUri][]*lsproto.TextEdit) + for _, change := range documentChanges { + if change.TextDocumentEdit != nil { + uri := change.TextDocumentEdit.TextDocument.Uri + for _, edit := range change.TextDocumentEdit.Edits { + if edit.TextEdit != nil { + changes[uri] = append(changes[uri], edit.TextEdit) + } + } + } + } + return lsproto.WillRenameFilesResponse{ WorkspaceEdit: &lsproto.WorkspaceEdit{ - Changes: &combined, + Changes: new(changes), }, }, nil } diff --git a/internal/outputpaths/outputpaths.go b/internal/outputpaths/outputpaths.go index 30de419a0bd..09d977e6f59 100644 --- a/internal/outputpaths/outputpaths.go +++ b/internal/outputpaths/outputpaths.go @@ -102,7 +102,7 @@ func GetOutputDeclarationFileNameWorker(inputFileName string, options *core.Comp } return tspath.ChangeExtension( getOutputPathWithoutChangingExtension(inputFileName, dir, host), - getDeclarationEmitExtensionForPath(inputFileName), + tspath.GetDeclarationEmitExtensionForPath(inputFileName), ) } @@ -197,17 +197,6 @@ func GetSourceMapFilePath(jsFilePath string, options *core.CompilerOptions) stri return "" } -func getDeclarationEmitExtensionForPath(fileName string) string { - if tspath.FileExtensionIsOneOf(fileName, []string{tspath.ExtensionMjs, tspath.ExtensionMts}) { - return tspath.ExtensionDmts - } else if tspath.FileExtensionIsOneOf(fileName, []string{tspath.ExtensionCjs, tspath.ExtensionCts}) { - return tspath.ExtensionDcts - } else if tspath.FileExtensionIs(fileName, tspath.ExtensionJson) { - return ".d.json.ts" - } - return tspath.ExtensionDts -} - func GetBuildInfoFileName(options *core.CompilerOptions, opts tspath.ComparePathsOptions) string { if !options.IsIncremental() && !options.Build.IsTrue() { return "" diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go index 131eaab3c60..00707f55ffd 100644 --- a/internal/project/snapshot.go +++ b/internal/project/snapshot.go @@ -151,6 +151,10 @@ func (s *Snapshot) DirectoryExists(path string) bool { return s.fs.fs.DirectoryExists(path) } +func (s *Snapshot) FileExists(path string) bool { + return s.fs.fs.FileExists(path) +} + func (s *Snapshot) GetDirectories(path string) []string { return s.fs.fs.GetAccessibleEntries(path).Directories } diff --git a/internal/tspath/extension.go b/internal/tspath/extension.go index 7b6578363dc..01f3e9023e9 100644 --- a/internal/tspath/extension.go +++ b/internal/tspath/extension.go @@ -131,9 +131,13 @@ func GetDeclarationEmitExtensionForPath(path string) string { return ExtensionDmts case FileExtensionIsOneOf(path, []string{ExtensionCjs, ExtensionCts}): return ExtensionDcts - case FileExtensionIsOneOf(path, []string{ExtensionJson}): - return `.d.json.ts` // Drive-by redefinition of json declaration file output name so if it's ever enabled, it behaves well + case FileExtensionIsOneOf(path, []string{ExtensionTs, ExtensionTsx, ExtensionJs, ExtensionJsx}): + return ExtensionDts default: + ext := GetAnyExtensionFromPath(path, nil, false) + if ext != "" { + return ".d" + ext + ".ts" + } return ExtensionDts } } @@ -159,7 +163,7 @@ func ChangeAnyExtension(path string, ext string, extensions []string, ignoreCase } func ChangeExtension(path string, newExtension string) string { - return ChangeAnyExtension(path, newExtension, extensionsToRemove /*ignoreCase*/, false) + return ChangeAnyExtension(path, newExtension, extensionsToRemove, false /*ignoreCase*/) } // Like `changeAnyExtension`, but declaration file extensions are recognized @@ -186,8 +190,10 @@ func GetPossibleOriginalInputExtensionForExtension(path string) []string { if FileExtensionIsOneOf(path, []string{ExtensionDcts, ExtensionCjs, ExtensionCts}) { return []string{ExtensionCts, ExtensionCjs} } - if FileExtensionIs(path, ".d.json.ts") { - return []string{ExtensionJson} + // Handle any custom .d.x.ts extension (e.g., .d.json.ts -> .json, .d.css.ts -> .css) + if ext := GetDeclarationFileExtension(path); ext != "" && ext != ExtensionDts { + inner := ext[len(".d.") : len(ext)-len(".ts")] + return []string{"." + inner} } return []string{ExtensionTsx, ExtensionTs, ExtensionJsx, ExtensionJs} } From 252eb612eb5cc7e68ba9b98c428a7610ef103469 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 10 Apr 2026 17:02:40 +0000 Subject: [PATCH 15/19] make rename of resolving to .d.ts work --- internal/fourslash/fourslash.go | 66 ++++++++++++++++++- .../getEditsForFileRename_cssImport1_test.go | 6 +- .../getEditsForFileRename_cssImport2_test.go | 4 +- .../getEditsForFileRename_cssImport3_test.go | 8 +-- .../tests/importRenameFileFlow_test.go | 20 ------ internal/ls/filerename.go | 31 +++++---- internal/ls/rename.go | 26 ++++++-- internal/lsp/server.go | 14 ---- ...rceOfProjectReferenceRedirectEdit.baseline | 9 +++ ...OfProjectReferenceRedirectEditEnd.baseline | 8 +++ ...psRenameWithProjectReferencesEdit.baseline | 9 +++ ...enameWithProjectReferencesEditEnd.baseline | 8 +++ ...ationMapsRenameWithSourceMapsEdit.baseline | 9 +++ ...onMapsRenameWithSourceMapsEditEnd.baseline | 8 +++ ...nameWithSourceMapsNotSolutionEdit.baseline | 9 +++ ...eWithSourceMapsNotSolutionEditEnd.baseline | 8 +++ ...terItsUpdateDoesNotIncludeTheFile.baseline | 3 + 17 files changed, 183 insertions(+), 63 deletions(-) diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 5178df498aa..95276b5969c 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -381,6 +381,12 @@ var ( }, }, } + defaultWorkspaceEditCapabilities = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: ptrTrue, + ResourceOperations: &[]lsproto.ResourceOperationKind{ + lsproto.ResourceOperationKindRename, + }, + } ) func GetDefaultCapabilities() *lsproto.ClientCapabilities { @@ -494,6 +500,9 @@ func getCapabilitiesWithDefaults(capabilities *lsproto.ClientCapabilities) *lspr if capabilitiesWithDefaults.Workspace == nil { capabilitiesWithDefaults.Workspace = &lsproto.WorkspaceClientCapabilities{} } + if capabilitiesWithDefaults.Workspace.WorkspaceEdit == nil { + capabilitiesWithDefaults.Workspace.WorkspaceEdit = defaultWorkspaceEditCapabilities + } if capabilitiesWithDefaults.Workspace.Configuration == nil { capabilitiesWithDefaults.Workspace.Configuration = ptrTrue } @@ -3732,13 +3741,68 @@ func (f *FourslashTest) VerifyRenameSucceeded(t *testing.T, preferences *lsutil. func (f *FourslashTest) RenameAtCaret(t *testing.T, newName string) lsproto.RenameResponse { t.Helper() - return sendRequest(t, f, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{ + result := sendRequest(t, f, lsproto.TextDocumentRenameInfo, &lsproto.RenameParams{ TextDocument: lsproto.TextDocumentIdentifier{ Uri: lsconv.FileNameToDocumentURI(f.activeFilename), }, Position: f.currentCaretPosition, NewName: newName, }) + + if result.WorkspaceEdit == nil { + return result + } + + if result.WorkspaceEdit.Changes != nil { + for uri, edits := range *result.WorkspaceEdit.Changes { + fileName := uri.FileName() + script := f.getOrLoadScriptInfo(fileName) + changes := core.Map(edits, func(edit *lsproto.TextEdit) core.TextChange { + return core.TextChange{ + TextRange: f.converters.FromLSPRange(script, edit.Range), + NewText: edit.NewText, + } + }) + f.editScriptAndUpdateMarkersWorker(t, fileName, changes) + } + } + + var renameFiles []*lsproto.RenameFile + if result.WorkspaceEdit.DocumentChanges != nil { + for _, docChange := range *result.WorkspaceEdit.DocumentChanges { + if docChange.TextDocumentEdit != nil { + fileName := docChange.TextDocumentEdit.TextDocument.Uri.FileName() + script := f.getOrLoadScriptInfo(fileName) + changes := core.Map(docChange.TextDocumentEdit.Edits, func(edit lsproto.TextEditOrAnnotatedTextEditOrSnippetTextEdit) core.TextChange { + textEdit := edit.TextEdit + return core.TextChange{ + TextRange: f.converters.FromLSPRange(script, textEdit.Range), + NewText: textEdit.NewText, + } + }) + f.editScriptAndUpdateMarkersWorker(t, fileName, changes) + } else if docChange.RenameFile != nil { + renameFiles = append(renameFiles, docChange.RenameFile) + } + } + } + + if len(renameFiles) > 0 { + var fileRenames []*lsproto.FileRename + for _, renameFile := range renameFiles { + oldFileName := lsproto.DocumentUri(renameFile.OldUri).FileName() + if !f.vfs.FileExists(oldFileName) && f.getScriptInfo(oldFileName) == nil { + continue + } + fileRenames = append(fileRenames, &lsproto.FileRename{ + OldUri: string(renameFile.OldUri), + NewUri: string(renameFile.NewUri), + }) + } + f.willRenameFilesWorker(t, fileRenames...) + } + + return result } func (f *FourslashTest) WillRenameFiles(t *testing.T, files ...*lsproto.FileRename) lsproto.WillRenameFilesResponse { diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go index 398d69a5cbc..a0b8341ea33 100644 --- a/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go @@ -26,12 +26,14 @@ export default css; import styles from "./app.css";` f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) defer done() + // We cannot request rename of the .d.css file because that would lead to a circularity of `willRenameFiles`. + // So this case does not fully work. f.VerifyWillRenameFilesEdits(t, "/app.css", "/app2.css", map[string]string{ - "/a.ts": `import styles from "./app2.css";`, + "/a.ts": `import styles from "./app.css";`, "/app2.css": `.cookie-banner { display: none; }`, - "/app2.d.css.ts": `declare const css: { + "/app.d.css.ts": `declare const css: { cookieBanner: string; }; export default css;`, diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go index 1251b3efd9b..12f4551e20b 100644 --- a/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport2_test.go @@ -27,9 +27,7 @@ import styles from "./app.css";` defer done() f.VerifyWillRenameFilesEdits(t, "/app.d.css.ts", "/app2.d.css.ts", map[string]string{ "/a.ts": `import styles from "./app2.css";`, - // We cannot rename the .css file because that would lead to a circularity of `willRenameFiles`. - // So this case does not fully work. - "/app.css": `.cookie-banner { + "/app2.css": `.cookie-banner { display: none; }`, "/app2.d.css.ts": `declare const css: { diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go index e2e9c986b18..badbc5f7eb1 100644 --- a/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go @@ -33,10 +33,10 @@ import styles from ".//*rename*/app.css";` f.GoToFile(t, "/app2.d.css.ts") f.VerifyCurrentFileContent(t, `declare const css: { cookieBanner: string; -};`) - f.GoToFile(t, "/app2.css") - f.VerifyCurrentFileContent(t, `declare const css: { - cookieBanner: string; }; export default css;`) + f.GoToFile(t, "/app2.css") + f.VerifyCurrentFileContent(t, `.cookie-banner { + display: none; +}`) } diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go index c4e045b90fd..99db3ab8871 100644 --- a/internal/fourslash/tests/importRenameFileFlow_test.go +++ b/internal/fourslash/tests/importRenameFileFlow_test.go @@ -391,23 +391,3 @@ const global = require("/*global*/global"); f.GoToMarker(t, "global") f.VerifyRenameFailed(t, prefsTrue) } - -func TestImportPathRenameFailsWithoutFileRenameClientSupport(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /src/example.ts -import stuff from './[|stuff|].cts'; -// @Filename: /src/stuff.cts -export = { name: "stuff" }; -` - - capabilities := fileRenameCapabilities() - capabilities.Workspace.FileOperations = nil - - f, done := fourslash.NewFourslash(t, capabilities, content) - defer done() - f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) - f.GoToRangeStart(t, f.Ranges()[0]) - f.VerifyRenameFailed(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) -} diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go index d2a23fa0c40..ef6e1dbbda0 100644 --- a/internal/ls/filerename.go +++ b/internal/ls/filerename.go @@ -37,18 +37,25 @@ func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lspr var documentChanges []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile - // When renaming e.g. `foo.css`, also rename `foo.d.css.ts` if it exists. - if !tspath.IsDeclarationFileName(oldPath) { - dtsExt := tspath.GetDeclarationEmitExtensionForPath(oldPath) - oldDeclarationPath := tspath.ChangeAnyExtension(oldPath, dtsExt, nil /*extensions*/, false /*ignoreCase*/) - if l.host.FileExists(oldDeclarationPath) { - newDeclarationPath := tspath.ChangeAnyExtension(newPath, dtsExt, nil /*extensions*/, false /*ignoreCase*/) - documentChanges = append(documentChanges, lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ - RenameFile: &lsproto.RenameFile{ - OldUri: lsconv.FileNameToDocumentURI(oldDeclarationPath), - NewUri: lsconv.FileNameToDocumentURI(newDeclarationPath), - }, - }) + // 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), + }, + }) + } + } } } diff --git a/internal/ls/rename.go b/internal/ls/rename.go index f6e1f372c77..3b221dfc43c 100644 --- a/internal/ls/rename.go +++ b/internal/ls/rename.go @@ -33,6 +33,18 @@ func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.Ren info := l.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) if info.CanRename && info.FileToRename != "" { newPath := tspath.CombinePaths(tspath.GetDirectoryPath(info.FileToRename), params.NewName) + if tspath.IsDeclarationFileName(info.FileToRename) { + dtsExt := tspath.GetDeclarationFileExtension(info.FileToRename) + originalExtensions := tspath.GetPossibleOriginalInputExtensionForExtension(dtsExt) + ignoreCase := !l.host.UseCaseSensitiveFileNames() + if !tspath.HasExtension(newPath) { + newPath = newPath + "." + dtsExt + } else if tspath.FileExtensionIsOneOf(newPath, originalExtensions) { + newPath = tspath.ChangeAnyExtension(newPath, dtsExt, nil /*extensions*/, ignoreCase) + } + } + + // !!! HERE: Check if the client supports willRenameFiles. If not, compute the edits for the file rename here and include them in the response. documentChanges := []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ { RenameFile: &lsproto.RenameFile{ @@ -238,8 +250,8 @@ func wouldRenameInOtherNodeModules(originalFile *ast.SourceFile, symbol *ast.Sym } // getRenameInfoForModule handles rename validation for module specifiers. -func (l *LanguageService) getRenameInfoForModule(ctx context.Context, node *ast.Node, sourceFile *ast.SourceFile, moduleSymbol *ast.Symbol) (RenameInfo, bool) { - if !tspath.IsExternalModuleNameRelative(node.Text()) { +func (l *LanguageService) getRenameInfoForModule(ctx context.Context, 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 } @@ -250,7 +262,7 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, node *ast. fileName := moduleSourceFile.AsSourceFile().FileName() withoutIndex := "" - if !strings.HasSuffix(node.Text(), "/index") && !strings.HasSuffix(node.Text(), "/index.js") { + 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 @@ -263,13 +275,13 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, node *ast. } // Span should only be the last component of the path. + 1 to account for the quote character. - indexAfterLastSlash := strings.LastIndex(node.Text(), "/") + 1 - start := node.Pos() + 1 + indexAfterLastSlash - length := len(node.Text()) - indexAfterLastSlash + indexAfterLastSlash := strings.LastIndex(specifier.Text(), "/") + 1 + start := specifier.Pos() + 1 + indexAfterLastSlash + length := len(specifier.Text()) - indexAfterLastSlash return RenameInfo{ CanRename: true, - DisplayName: node.Text()[indexAfterLastSlash:], + DisplayName: specifier.Text()[indexAfterLastSlash:], TriggerSpan: l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)), FileToRename: displayName, }, true diff --git a/internal/lsp/server.go b/internal/lsp/server.go index e74ea49472d..a1df66f584c 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1234,9 +1234,6 @@ func (s *Server) handlePrepareRename(ctx context.Context, languageService *ls.La if !info.CanRename { return lsproto.PrepareRenameResponse{}, userFacingRequestFailedError(info.LocalizedErrorMessage) } - if info.FileToRename != "" && !s.supportsImportPathFileRename() { - return lsproto.PrepareRenameResponse{}, userFacingRequestFailedError("The client doesn't support file rename edits required for import path renames.") - } return lsproto.PrepareRenameResponse{ PrepareRenamePlaceholder: &lsproto.PrepareRenamePlaceholder{ Range: info.TriggerSpan, @@ -1251,20 +1248,9 @@ func (s *Server) handleRename(ctx context.Context, params *lsproto.RenameParams, return lsproto.RenameResponse{}, err } - info := defaultLs.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) - if info.CanRename && info.FileToRename != "" && !s.supportsImportPathFileRename() { // !!! HERE: move this inside getRenameInfo - return lsproto.RenameResponse{}, userFacingRequestFailedError("The client doesn't support file rename edits required for import path renames.") - } return defaultLs.ProvideRename(ctx, params, orchestrator) } -func (s *Server) supportsImportPathFileRename() bool { - workspaceCaps := s.clientCapabilities.Workspace - return workspaceCaps.WorkspaceEdit.DocumentChanges && - slices.Contains(workspaceCaps.WorkspaceEdit.ResourceOperations, lsproto.ResourceOperationKindRename) && - workspaceCaps.FileOperations.WillRename -} - func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { if len(params.Files) == 0 { return lsproto.WillRenameFilesResponse{}, nil diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEdit.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEdit.baseline index b72b2d4ff2d..dec58ec4d46 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEdit.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEdit.baseline @@ -1337,6 +1337,15 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +function fooBar() { } +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } + + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEditEnd.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEditEnd.baseline index a6f1d4ef6af..6ab5442c9a3 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEditEnd.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithDisableSourceOfProjectReferenceRedirectEditEnd.baseline @@ -891,6 +891,14 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } +const x = 10; + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEdit.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEdit.baseline index 2fb900dbb68..b50d4845505 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEdit.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEdit.baseline @@ -1196,6 +1196,15 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +function fooBar() { } +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } + + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEditEnd.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEditEnd.baseline index 0bc2e3c1282..79562d16db3 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEditEnd.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithProjectReferencesEditEnd.baseline @@ -750,6 +750,14 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } +const x = 10; + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEdit.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEdit.baseline index 214c93ebc9b..8b72e13c4ca 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEdit.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEdit.baseline @@ -1317,6 +1317,15 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +function fooBar() { } +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } + + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEditEnd.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEditEnd.baseline index 3197cea5dcb..8818ad0481e 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEditEnd.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsEditEnd.baseline @@ -871,6 +871,14 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } +const x = 10; + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEdit.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEdit.baseline index 795b73537c6..1c1497a7a47 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEdit.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEdit.baseline @@ -1342,6 +1342,15 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +function fooBar() { } +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } + + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEditEnd.baseline b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEditEnd.baseline index fb3bb6af59c..98d296b5586 100644 --- a/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEditEnd.baseline +++ b/testdata/baselines/reference/fourslash/state/declarationMapsRenameWithSourceMapsNotSolutionEditEnd.baseline @@ -896,6 +896,14 @@ Config:: } } +//// [/myproject/dependency/FnS.ts] *modified* +export function fn1() { } +export function fn2() { } +export function fn3() { } +export function fn4() { } +export function fn5() { } +const x = 10; + Projects:: [/myproject/dependency/tsconfig.json] *modified* /myproject/dependency/FnS.ts *modified* diff --git a/testdata/baselines/reference/fourslash/state/findAllRefsDoesNotTryToSearchProjectAfterItsUpdateDoesNotIncludeTheFile.baseline b/testdata/baselines/reference/fourslash/state/findAllRefsDoesNotTryToSearchProjectAfterItsUpdateDoesNotIncludeTheFile.baseline index 591ae38e6df..4fd48357523 100644 --- a/testdata/baselines/reference/fourslash/state/findAllRefsDoesNotTryToSearchProjectAfterItsUpdateDoesNotIncludeTheFile.baseline +++ b/testdata/baselines/reference/fourslash/state/findAllRefsDoesNotTryToSearchProjectAfterItsUpdateDoesNotIncludeTheFile.baseline @@ -591,6 +591,9 @@ Config File Names:: } } +//// [/packages/babel-loader/src/index.ts] *modified* +// commentimport type { Foo } from "../../core/src/index.js"; + Projects:: [/packages/babel-loader/tsconfig.json] *modified* /packages/babel-loader/src/index.ts *modified* From d4fdbcaf8359beb46b6943c8b53e11d2f3b36867 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 10 Apr 2026 22:00:55 +0000 Subject: [PATCH 16/19] remove support for arbitrary files; respect client capabilities --- internal/fourslash/_scripts/failingTests.txt | 2 - internal/fourslash/fourslash.go | 39 +- .../getEditsForFileRename_cssImport3_test.go | 17 +- ... getEditsForFileRename_cssImport4_test.go} | 24 +- .../getEditsForFileRename_jsRename_test.go | 26 ++ .../tests/importRenameFileFlow_test.go | 393 ------------------ internal/ls/rename.go | 63 ++- internal/lsp/server.go | 54 ++- 8 files changed, 138 insertions(+), 480 deletions(-) rename internal/fourslash/tests/{getEditsForFileRename_cssImport1_test.go => getEditsForFileRename_cssImport4_test.go} (56%) create mode 100644 internal/fourslash/tests/getEditsForFileRename_jsRename_test.go delete mode 100644 internal/fourslash/tests/importRenameFileFlow_test.go diff --git a/internal/fourslash/_scripts/failingTests.txt b/internal/fourslash/_scripts/failingTests.txt index a27dffbd955..ddd583d95e6 100644 --- a/internal/fourslash/_scripts/failingTests.txt +++ b/internal/fourslash/_scripts/failingTests.txt @@ -234,8 +234,6 @@ TestImportNameCodeFix_noDestructureNonObjectLiteral TestImportNameCodeFix_order2 TestImportNameCodeFix_preferBaseUrl TestImportNameCodeFix_reExportDefault -TestImportNameCodeFix_symlink_own_package -TestImportNameCodeFix_symlink_own_package_2 TestImportNameCodeFix_uriStyleNodeCoreModules2 TestImportNameCodeFix_uriStyleNodeCoreModules3 TestImportNameCodeFixDefaultExport4 diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index 95276b5969c..ab18839d0e3 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -467,6 +467,9 @@ func GetDefaultCapabilities() *lsproto.ClientCapabilities { }, Workspace: &lsproto.WorkspaceClientCapabilities{ Configuration: ptrTrue, + FileOperations: &lsproto.FileOperationClientCapabilities{ + WillRename: ptrTrue, + }, WorkspaceEdit: &lsproto.WorkspaceEditClientCapabilities{ DocumentChanges: ptrTrue, ResourceOperations: &[]lsproto.ResourceOperationKind{ @@ -500,6 +503,11 @@ func getCapabilitiesWithDefaults(capabilities *lsproto.ClientCapabilities) *lspr if capabilitiesWithDefaults.Workspace == nil { capabilitiesWithDefaults.Workspace = &lsproto.WorkspaceClientCapabilities{} } + if capabilitiesWithDefaults.Workspace.FileOperations == nil { + capabilitiesWithDefaults.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ + WillRename: ptrTrue, + } + } if capabilitiesWithDefaults.Workspace.WorkspaceEdit == nil { capabilitiesWithDefaults.Workspace.WorkspaceEdit = defaultWorkspaceEditCapabilities } @@ -3790,10 +3798,6 @@ func (f *FourslashTest) RenameAtCaret(t *testing.T, newName string) lsproto.Rena if len(renameFiles) > 0 { var fileRenames []*lsproto.FileRename for _, renameFile := range renameFiles { - oldFileName := lsproto.DocumentUri(renameFile.OldUri).FileName() - if !f.vfs.FileExists(oldFileName) && f.getScriptInfo(oldFileName) == nil { - continue - } fileRenames = append(fileRenames, &lsproto.FileRename{ OldUri: string(renameFile.OldUri), NewUri: string(renameFile.NewUri), @@ -3862,10 +3866,6 @@ func (f *FourslashTest) willRenameFilesWorker(t *testing.T, files ...*lsproto.Fi var fileRenames []*lsproto.FileRename for _, renameFile := range renameFiles { - oldFileName := lsproto.DocumentUri(renameFile.OldUri).FileName() - if !f.vfs.FileExists(oldFileName) && f.getScriptInfo(oldFileName) == nil { - continue - } fileRenames = append(fileRenames, &lsproto.FileRename{ OldUri: string(renameFile.OldUri), NewUri: string(renameFile.NewUri), @@ -3880,6 +3880,19 @@ func (f *FourslashTest) willRenameFilesWorker(t *testing.T, files ...*lsproto.Fi } } +func (f *FourslashTest) VerifyRename(t *testing.T, markerName string, newName string, expectedFileContents map[string]string) { + t.Helper() + f.GoToMarker(t, markerName) + f.RenameAtCaret(t, newName) + for fileName, expectedContent := range expectedFileContents { + script := f.getScriptInfo(fileName) + if script == nil { + t.Fatalf("Expected script info for %s, but got nil", fileName) + } + assert.Equal(t, script.content, expectedContent, fmt.Sprintf("File content after rename did not match expected content for %s.", fileName)) + } +} + func (f *FourslashTest) VerifyWillRenameFilesEdits(t *testing.T, oldPath string, newPath string, expectedFileContents map[string]string, preferences *lsutil.UserPreferences) { t.Helper() if preferences != nil { @@ -3945,13 +3958,13 @@ func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newP fileEvents := make([]*lsproto.FileEvent, 0, len(oldFileNames)*2) reopenAtNewPath := map[string]string{} // newFileName -> content, for files that were open for oldFileName := range oldFileNames { - newFileName, ok := pathUpdater(oldFileName) - if !ok { + newFileName, updated := pathUpdater(oldFileName) + if !updated { t.Fatalf("failed to compute renamed path for %s", oldFileName) } // Send didClose for open files; get content from the old script info. - if _, ok := f.openFiles[oldFileName]; ok { + if _, isOpen := f.openFiles[oldFileName]; isOpen { script := f.scriptInfos[oldFileName] reopenAtNewPath[newFileName] = script.content sendNotification(t, f, lsproto.TextDocumentDidCloseInfo, &lsproto.DidCloseTextDocumentParams{ @@ -3966,8 +3979,8 @@ func (f *FourslashTest) renameFileOrDirectory(t *testing.T, oldPath string, newP delete(f.scriptInfos, oldFileName) // Write renamed file to VFS. - content, ok := f.vfs.ReadFile(oldFileName) - if !ok { + content, updated := f.vfs.ReadFile(oldFileName) + if !updated { t.Fatalf("failed to read content for %s during rename to %s", oldFileName, newFileName) } if err := f.vfs.WriteFile(newFileName, content); err != nil { diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go index badbc5f7eb1..e75fcf6e1ad 100644 --- a/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport3_test.go @@ -26,17 +26,14 @@ export default css; import styles from ".//*rename*/app.css";` f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) defer done() - f.GoToMarker(t, "rename") - f.RenameAtCaret(t, "/app2.css") - f.GoToFile(t, "/a.ts") - f.VerifyCurrentFileContent(t, `import styles from "./app2.css";`) - f.GoToFile(t, "/app2.d.css.ts") - f.VerifyCurrentFileContent(t, `declare const css: { + f.VerifyRename(t, "rename", "app2.css", map[string]string{ + "/a.ts": `import styles from "./app2.css";`, + "/app2.d.css.ts": `declare const css: { cookieBanner: string; }; -export default css;`) - f.GoToFile(t, "/app2.css") - f.VerifyCurrentFileContent(t, `.cookie-banner { +export default css;`, + "/app2.css": `.cookie-banner { display: none; -}`) +}`, + }) } diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go similarity index 56% rename from internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go rename to internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go index a0b8341ea33..2c834d675ff 100644 --- a/internal/fourslash/tests/getEditsForFileRename_cssImport1_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go @@ -7,7 +7,7 @@ import ( "github.com/microsoft/typescript-go/internal/testutil" ) -func TestGetEditsForFileRename_cssImport1(t *testing.T) { +func TestGetEditsForFileRename_cssImport4(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = ` @@ -23,19 +23,19 @@ declare const css: { }; export default css; // @Filename: /a.ts -import styles from "./app.css";` - f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) +import styles from ".//*rename*/app.css";` + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.FileOperations = nil + f, done := fourslash.NewFourslash(t, capabilities, content) defer done() - // We cannot request rename of the .d.css file because that would lead to a circularity of `willRenameFiles`. - // So this case does not fully work. - f.VerifyWillRenameFilesEdits(t, "/app.css", "/app2.css", map[string]string{ - "/a.ts": `import styles from "./app.css";`, - "/app2.css": `.cookie-banner { - display: none; -}`, - "/app.d.css.ts": `declare const css: { + f.VerifyRename(t, "rename", "app2.css", map[string]string{ + "/a.ts": `import styles from "./app2.css";`, + "/app2.d.css.ts": `declare const css: { cookieBanner: string; }; export default css;`, - }, nil /*preferences*/) + "/app2.css": `.cookie-banner { + display: none; +}`, + }) } diff --git a/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go b/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go new file mode 100644 index 00000000000..66a0b897e52 --- /dev/null +++ b/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go @@ -0,0 +1,26 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestGetEditsForFileRename_cssImport5(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @Filename: /tsconfig.json +{ "compilerOptions": { "module": "nodenext" } } +// @Filename: /a.ts +export const a = 1; +// @Filename: /b.ts +import { a } from ".//*rename*/a.js";` + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyRename(t, "rename", "c.js", map[string]string{ + "/c.ts": `export const a = 1;`, + "/b.ts": `import { a } from "./c.js";`, + }) +} diff --git a/internal/fourslash/tests/importRenameFileFlow_test.go b/internal/fourslash/tests/importRenameFileFlow_test.go deleted file mode 100644 index 99db3ab8871..00000000000 --- a/internal/fourslash/tests/importRenameFileFlow_test.go +++ /dev/null @@ -1,393 +0,0 @@ -package fourslash_test - -import ( - "slices" - "testing" - - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/fourslash" - "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/testutil" - "gotest.tools/v3/assert" -) - -func getDocumentChangeEdits(t *testing.T, documentChanges *[]lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile, uri lsproto.DocumentUri) []*lsproto.TextEdit { - t.Helper() - var edits []*lsproto.TextEdit - for _, change := range *documentChanges { - if change.TextDocumentEdit != nil && change.TextDocumentEdit.TextDocument.Uri == uri { - for _, e := range change.TextDocumentEdit.Edits { - if e.TextEdit != nil { - edits = append(edits, e.TextEdit) - } - } - } - } - return edits -} - -func fileRenameCapabilities() *lsproto.ClientCapabilities { - capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ - DocumentChanges: new(true), - ResourceOperations: &[]lsproto.ResourceOperationKind{lsproto.ResourceOperationKindRename}, - } - capabilities.Workspace.FileOperations = &lsproto.FileOperationClientCapabilities{ - WillRename: new(true), - } - return capabilities -} - -func TestImportPathRenameReturnsRenameFileAndWillRenameEdits(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /src/example.ts -import stuff from './[|stuff|].cts'; -// @Filename: /src/stuff.cts -export = { name: "stuff" }; -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) - f.GoToRangeStart(t, f.Ranges()[0]) - - renameResult := f.RenameAtCaret(t, "renamed.cts") - assert.Assert(t, renameResult.WorkspaceEdit != nil) - assert.Assert(t, renameResult.WorkspaceEdit.DocumentChanges != nil) - assert.Equal(t, len(*renameResult.WorkspaceEdit.DocumentChanges), 1) - renameChange := (*renameResult.WorkspaceEdit.DocumentChanges)[0].RenameFile - assert.Assert(t, renameChange != nil) - assert.Equal(t, renameChange.OldUri, lsconv.FileNameToDocumentURI("/src/stuff.cts")) - assert.Equal(t, renameChange.NewUri, lsconv.FileNameToDocumentURI("/src/renamed.cts")) - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/src/stuff.cts")), - NewUri: string(lsconv.FileNameToDocumentURI("/src/renamed.cts")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - edits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/src/example.ts")) - assert.Equal(t, len(edits), 1) - assert.Equal(t, edits[0].NewText, "./renamed.cjs") -} - -func TestImportPathDirectoryRenameReturnsRenameFileAndWillRenameEdits(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /src/example.ts -import dir from './[|dir|]'; -// @Filename: /src/dir/index.ts -export const x = 1; -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - f.Configure(t, &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue}) - f.GoToRangeStart(t, f.Ranges()[0]) - - renameResult := f.RenameAtCaret(t, "renamed") - assert.Assert(t, renameResult.WorkspaceEdit != nil) - assert.Assert(t, renameResult.WorkspaceEdit.DocumentChanges != nil) - assert.Equal(t, len(*renameResult.WorkspaceEdit.DocumentChanges), 1) - renameChange := (*renameResult.WorkspaceEdit.DocumentChanges)[0].RenameFile - assert.Assert(t, renameChange != nil) - assert.Equal(t, renameChange.OldUri, lsconv.FileNameToDocumentURI("/src/dir")) - assert.Equal(t, renameChange.NewUri, lsconv.FileNameToDocumentURI("/src/renamed")) - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/src/dir")), - NewUri: string(lsconv.FileNameToDocumentURI("/src/renamed")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - edits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/src/example.ts")) - assert.Equal(t, len(edits), 1) - assert.Equal(t, edits[0].NewText, "./renamed") -} - -func TestWillRenameFilesUpdatesTsconfigAndTripleSlashReferences(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /src/app.ts -/// -import { x } from "./old"; -// @Filename: /src/old.ts -export const x = 1; -// @Filename: /tsconfig.json -{ - "files": ["src/app.ts", "src/old.ts"] -} -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/src/old.ts")), - NewUri: string(lsconv.FileNameToDocumentURI("/src/new.ts")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/src/app.ts")) - assert.Equal(t, len(appEdits), 2) - newTexts := []string{appEdits[0].NewText, appEdits[1].NewText} - slices.Sort(newTexts) - assert.DeepEqual(t, newTexts, []string{"./new", "./new.ts"}) - - tsconfigEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/tsconfig.json")) - assert.Equal(t, len(tsconfigEdits), 1) - assert.Equal(t, tsconfigEdits[0].NewText, "src/new.ts") -} - -func TestWillRenameFilesUpdatesProjectReferenceConsumer(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /solution/a/old.ts -export const x = 1; -// @Filename: /solution/b/app.ts -import { x } from "../a/old"; -// @Filename: /solution/a/tsconfig.json -{ - "compilerOptions": { - "composite": true - }, - "files": ["old.ts"] -} -// @Filename: /solution/b/tsconfig.json -{ - "references": [{ "path": "../a" }], - "files": ["app.ts"] -} -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/solution/a/old.ts")), - NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/solution/b/app.ts")) - assert.Equal(t, len(appEdits), 1) - assert.Equal(t, appEdits[0].NewText, "../a/new") -} - -func TestWillRenameFilesUpdatesSiblingProjectLoadedViaSolutionRoot(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /solution/a/old.ts -export const x = 1; -// @Filename: /solution/b/app.ts -import { x } from "../a/old"; -// @Filename: /solution/tsconfig.json -{ - "files": [], - "references": [ - { "path": "./a" }, - { "path": "./b" } - ] -} -// @Filename: /solution/a/tsconfig.json -{ - "compilerOptions": { - "composite": true - }, - "files": ["old.ts"] -} -// @Filename: /solution/b/tsconfig.json -{ - "files": ["app.ts"] -} -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/solution/a/old.ts")), - NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/solution/b/app.ts")) - assert.Equal(t, len(appEdits), 1) - assert.Equal(t, appEdits[0].NewText, "../a/new") -} - -func TestWillRenameFilesUpdatesSiblingProjectWhenUnrelatedFileIsInitiallyActive(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /solution/c/unrelated.ts -export const active = 1; -// @Filename: /solution/a/old.ts -export const x = 1; -// @Filename: /solution/b/app.ts -import { x } from "../a/old"; -// @Filename: /solution/tsconfig.json -{ - "files": [], - "references": [ - { "path": "./a" }, - { "path": "./b" }, - { "path": "./c" } - ] -} -// @Filename: /solution/a/tsconfig.json -{ - "compilerOptions": { - "composite": true - }, - "files": ["old.ts"] -} -// @Filename: /solution/b/tsconfig.json -{ - "files": ["app.ts"] -} -// @Filename: /solution/c/tsconfig.json -{ - "files": ["unrelated.ts"] -} -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/solution/a/old.ts")), - NewUri: string(lsconv.FileNameToDocumentURI("/solution/a/new.ts")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/solution/b/app.ts")) - assert.Equal(t, len(appEdits), 1) - assert.Equal(t, appEdits[0].NewText, "../a/new") -} - -func TestWillRenameFilesUpdatesSymlinkedPackageConsumer(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @Filename: /packages/project-b/old.ts -export const x = 1; -// @Filename: /packages/project-b/package.json -{ - "name": "project-b", - "version": "1.0.0" -} -// @Filename: /packages/project-b/tsconfig.json -{ - "compilerOptions": { - "composite": true, - "module": "commonjs" - }, - "files": ["old.ts"] -} -// @Filename: /packages/project-a/app.ts -import { x } from "project-b/old"; -// @Filename: /packages/project-a/package.json -{ - "name": "project-a", - "dependencies": { - "project-b": "*" - } -} -// @Filename: /packages/project-a/tsconfig.json -{ - "compilerOptions": { - "module": "commonjs" - }, - "files": ["app.ts"] -} -// @link: /packages/project-b -> /packages/project-a/node_modules/project-b` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - - willRenameResult := f.WillRenameFiles(t, &lsproto.FileRename{ - OldUri: string(lsconv.FileNameToDocumentURI("/packages/project-b/old.ts")), - NewUri: string(lsconv.FileNameToDocumentURI("/packages/project-b/new.ts")), - }) - assert.Assert(t, willRenameResult.WorkspaceEdit != nil) - assert.Assert(t, willRenameResult.WorkspaceEdit.DocumentChanges != nil) - - appEdits := getDocumentChangeEdits(t, willRenameResult.WorkspaceEdit.DocumentChanges, lsconv.FileNameToDocumentURI("/packages/project-a/app.ts")) - assert.Equal(t, len(appEdits), 1) - assert.Equal(t, appEdits[0].NewText, "project-b/new") -} - -func TestImportTypePathRenameReturnsRenameFile(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @module: commonjs -// @Filename: /a.ts -export = 0; -// @Filename: /b.ts -const x: import("[|./a|]") = 0; -` - - f, done := fourslash.NewFourslash(t, fileRenameCapabilities(), content) - defer done() - - prefsTrue := &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSTrue} - prefsFalse := &lsutil.UserPreferences{AllowRenameOfImportPath: core.TSFalse} - - f.Configure(t, prefsTrue) - f.GoToRangeStart(t, f.Ranges()[0]) - - renameResult := f.RenameAtCaret(t, "renamed.ts") - assert.Assert(t, renameResult.WorkspaceEdit != nil) - assert.Assert(t, renameResult.WorkspaceEdit.DocumentChanges != nil) - assert.Equal(t, len(*renameResult.WorkspaceEdit.DocumentChanges), 1) - renameChange := (*renameResult.WorkspaceEdit.DocumentChanges)[0].RenameFile - assert.Assert(t, renameChange != nil) - assert.Equal(t, renameChange.OldUri, lsconv.FileNameToDocumentURI("/a.ts")) - assert.Equal(t, renameChange.NewUri, lsconv.FileNameToDocumentURI("/renamed.ts")) - - f.Configure(t, prefsFalse) - f.GoToRangeStart(t, f.Ranges()[0]) - f.VerifyRenameFailed(t, prefsFalse) -} - -func TestGlobalImportRenameStillFails(t *testing.T) { - t.Parallel() - defer testutil.RecoverAndFail(t, "Panic on fourslash test") - - const content = `// @allowJs: true -// @module: commonjs -// @Filename: /node_modules/global/index.d.ts -export const x: number; -// @Filename: /c.js -const global = require("/*global*/global"); -` - - f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) - defer done() - - prefsTrue := &lsutil.UserPreferences{ - IncludeCompletionsForModuleExports: core.TSTrue, - IncludeCompletionsForImportStatements: core.TSTrue, - AllowRenameOfImportPath: core.TSTrue, - } - - f.Configure(t, prefsTrue) - f.GoToMarker(t, "global") - f.VerifyRenameFailed(t, prefsTrue) -} diff --git a/internal/ls/rename.go b/internal/ls/rename.go index 3b221dfc43c..f31353351a7 100644 --- a/internal/ls/rename.go +++ b/internal/ls/rename.go @@ -27,40 +27,10 @@ type RenameInfo struct { DisplayName string TriggerSpan lsproto.Range FileToRename string + NewFileName string } func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.RenameParams, orchestrator CrossProjectOrchestrator) (lsproto.WorkspaceEditOrNull, error) { - info := l.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) - if info.CanRename && info.FileToRename != "" { - newPath := tspath.CombinePaths(tspath.GetDirectoryPath(info.FileToRename), params.NewName) - if tspath.IsDeclarationFileName(info.FileToRename) { - dtsExt := tspath.GetDeclarationFileExtension(info.FileToRename) - originalExtensions := tspath.GetPossibleOriginalInputExtensionForExtension(dtsExt) - ignoreCase := !l.host.UseCaseSensitiveFileNames() - if !tspath.HasExtension(newPath) { - newPath = newPath + "." + dtsExt - } else if tspath.FileExtensionIsOneOf(newPath, originalExtensions) { - newPath = tspath.ChangeAnyExtension(newPath, dtsExt, nil /*extensions*/, ignoreCase) - } - } - - // !!! HERE: Check if the client supports willRenameFiles. If not, compute the edits for the file rename here and include them in the response. - documentChanges := []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ - { - RenameFile: &lsproto.RenameFile{ - Kind: lsproto.StringLiteralRename{}, - OldUri: lsconv.FileNameToDocumentURI(info.FileToRename), - NewUri: lsconv.FileNameToDocumentURI(newPath), - }, - }, - } - return lsproto.WorkspaceEditOrNull{ - WorkspaceEdit: &lsproto.WorkspaceEdit{ - DocumentChanges: &documentChanges, - }, - }, nil - } - return handleCrossProject( l, ctx, @@ -74,7 +44,7 @@ func (l *LanguageService) ProvideRename(ctx context.Context, params *lsproto.Ren ) } -func (l *LanguageService) GetRenameInfo(ctx context.Context, documentURI lsproto.DocumentUri, position lsproto.Position) RenameInfo { +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)) @@ -82,7 +52,7 @@ func (l *LanguageService) GetRenameInfo(ctx context.Context, documentURI lsproto node = getAdjustedLocation(node, true /*forRename*/, sourceFile) if nodeIsEligibleForRename(node) { - if renameInfo, ok := l.getRenameInfoForNode(ctx, node, sourceFile, program); ok { + if renameInfo, ok := l.getRenameInfoForNode(ctx, newName, node, sourceFile, program); ok { return renameInfo } } @@ -100,7 +70,7 @@ func (l *LanguageService) symbolAndEntriesToRename(ctx context.Context, params * // 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, data.OriginalNode, sourceFile, program); !ok || !info.CanRename { + if info, ok := l.getRenameInfoForNode(ctx, params.NewName, data.OriginalNode, sourceFile, program); !ok || !info.CanRename { return lsproto.WorkspaceEditOrNull{}, nil } @@ -130,7 +100,7 @@ func (l *LanguageService) symbolAndEntriesToRename(ctx context.Context, params * } // getRenameInfoForNode performs detailed validation for a rename operation on a specific node. -func (l *LanguageService) getRenameInfoForNode(ctx context.Context, node *ast.Node, sourceFile *ast.SourceFile, program *compiler.Program) (RenameInfo, bool) { +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() @@ -163,7 +133,7 @@ func (l *LanguageService) getRenameInfoForNode(ctx context.Context, node *ast.No if ast.IsStringLiteralLike(node) && ast.TryGetImportFromModuleSpecifier(node) != nil { if l.UserPreferences().AllowRenameOfImportPath.IsTrue() { - return l.getRenameInfoForModule(ctx, node, sourceFile, symbol) + return l.getRenameInfoForModule(ctx, newName, node, sourceFile, symbol) } return RenameInfo{}, false } @@ -250,7 +220,7 @@ func wouldRenameInOtherNodeModules(originalFile *ast.SourceFile, symbol *ast.Sym } // getRenameInfoForModule handles rename validation for module specifiers. -func (l *LanguageService) getRenameInfoForModule(ctx context.Context, specifier *ast.StringLiteralLike, sourceFile *ast.SourceFile, moduleSymbol *ast.Symbol) (RenameInfo, bool) { +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 } @@ -273,6 +243,7 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, specifier 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 @@ -284,9 +255,27 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, specifier DisplayName: specifier.Text()[indexAfterLastSlash:], TriggerSpan: l.converters.ToLSPRange(sourceFile, core.NewTextRange(start, start+length)), FileToRename: displayName, + NewFileName: newFileName, }, true } +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) string { if entry.kind != entryKindRange && (ast.IsIdentifier(originalNode) || ast.IsStringLiteralLike(originalNode)) { node := ast.GetReparsedNodeForNode(entry.node) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index a1df66f584c..b4ae2025fc3 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -79,7 +79,7 @@ var ( { Scheme: new("file"), Pattern: &lsproto.FileOperationPattern{ - Glob: "**/*", + Glob: "**/*.{ts,tsx,js,jsx,cts,cjs,mts,mjs,json}", }, }, } @@ -1230,7 +1230,7 @@ func (s *Server) handleHover(ctx context.Context, ls *ls.LanguageService, params } func (s *Server) handlePrepareRename(ctx context.Context, languageService *ls.LanguageService, params *lsproto.PrepareRenameParams) (lsproto.PrepareRenameResponse, error) { - info := languageService.GetRenameInfo(ctx, params.TextDocument.Uri, params.Position) + info := languageService.GetRenameInfo(ctx, "" /*newName*/, params.TextDocument.Uri, params.Position) if !info.CanRename { return lsproto.PrepareRenameResponse{}, userFacingRequestFailedError(info.LocalizedErrorMessage) } @@ -1247,10 +1247,40 @@ func (s *Server) handleRename(ctx context.Context, params *lsproto.RenameParams, if err != nil { return lsproto.RenameResponse{}, err } + info := defaultLs.GetRenameInfo(ctx, params.NewName, params.TextDocument.Uri, params.Position) + if info.CanRename && info.FileToRename != "" { + if clientSupportsWillRenameFiles(ctx) && clientSupportsDocumentChanges(ctx) && clientSupportsRenameResourceOperations(ctx) { + documentChanges := []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ + { + RenameFile: &lsproto.RenameFile{ + Kind: lsproto.StringLiteralRename{}, + OldUri: lsconv.FileNameToDocumentURI(info.FileToRename), + NewUri: lsconv.FileNameToDocumentURI(info.NewFileName), + }, + }, + } + return lsproto.WorkspaceEditOrNull{ + WorkspaceEdit: &lsproto.WorkspaceEdit{ + DocumentChanges: &documentChanges, + }, + }, nil + } + renameFilesParams := &lsproto.RenameFilesParams{ + Files: []*lsproto.FileRename{{ + OldUri: string(lsconv.FileNameToDocumentURI(info.FileToRename)), + NewUri: string(lsconv.FileNameToDocumentURI(info.NewFileName)), + }}, + } + return s.handleWillRenameFiles(ctx, renameFilesParams, req) + } return defaultLs.ProvideRename(ctx, params, orchestrator) } +func clientSupportsWillRenameFiles(ctx context.Context) bool { + return lsproto.GetClientCapabilities(ctx).Workspace.FileOperations.WillRename +} + func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { if len(params.Files) == 0 { return lsproto.WillRenameFilesResponse{}, nil @@ -1258,16 +1288,6 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena uris := make([]lsproto.DocumentUri, 0, len(params.Files)) for _, file := range params.Files { - // Handle rename of a file with arbitrary extension which may have a corresponding declaration file. - oldPath := lsproto.DocumentUri(file.OldUri).FileName() - if tspath.HasExtension(oldPath) && core.GetScriptKindFromFileName(oldPath) == core.ScriptKindUnknown { - dtsExt := tspath.GetDeclarationEmitExtensionForPath(oldPath) - oldDeclarationPath := tspath.ChangeAnyExtension(oldPath, dtsExt, nil /*extensions*/, false /*ignoreCase*/) - if s.fs.FileExists(oldDeclarationPath) { - uris = append(uris, lsconv.FileNameToDocumentURI((oldDeclarationPath))) - } - continue - } uris = append(uris, lsproto.DocumentUri(file.OldUri)) } @@ -1324,7 +1344,7 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena return lsproto.WillRenameFilesResponse{}, nil } - if lsproto.GetClientCapabilities(ctx).Workspace.WorkspaceEdit.DocumentChanges { + if clientSupportsDocumentChanges(ctx) { return lsproto.WillRenameFilesResponse{ WorkspaceEdit: &lsproto.WorkspaceEdit{ DocumentChanges: &documentChanges, @@ -1351,6 +1371,14 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena }, nil } +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) +} + func (s *Server) handleSignatureHelp(ctx context.Context, languageService *ls.LanguageService, params *lsproto.SignatureHelpParams) (lsproto.SignatureHelpResponse, error) { return languageService.ProvideSignatureHelp( ctx, From 4ea519cd387bd0aee5912f8716aa6663f06fe156 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 10 Apr 2026 22:56:19 +0000 Subject: [PATCH 17/19] fix handling of client capabilities, cleanup --- internal/diagnostics/diagnostics_generated.go | 4 ++ .../diagnostics/extraDiagnosticMessages.json | 4 ++ ...mportSpecifierNoResourceOperations_test.go | 28 ++++++++++++++ internal/ls/filerename.go | 16 +------- internal/ls/rename.go | 17 +++++++++ internal/lsp/server.go | 37 ++++++++++++------- internal/module/resolver.go | 2 - 7 files changed, 77 insertions(+), 31 deletions(-) create mode 100644 internal/fourslash/tests/renameImportSpecifierNoResourceOperations_test.go diff --git a/internal/diagnostics/diagnostics_generated.go b/internal/diagnostics/diagnostics_generated.go index 84056edf14f..97f77a13f67 100644 --- a/internal/diagnostics/diagnostics_generated.go +++ b/internal/diagnostics/diagnostics_generated.go @@ -3540,6 +3540,8 @@ var Decorators_may_not_appear_after_export_or_export_default_if_they_also_appear var A_JSDoc_template_tag_may_not_follow_a_typedef_callback_or_overload_tag = &Message{code: 8039, category: CategoryError, key: "A_JSDoc_template_tag_may_not_follow_a_typedef_callback_or_overload_tag_8039", text: "A JSDoc '@template' tag may not follow a '@typedef', '@callback', or '@overload' tag"} +var File_rename_is_not_supported_by_the_editor = &Message{code: 8040, category: CategoryError, key: "File_rename_is_not_supported_by_the_editor_8040", text: "File rename is not supported by the editor"} + var Declaration_emit_for_this_file_requires_using_private_name_0_An_explicit_type_annotation_may_unblock_declaration_emit = &Message{code: 9005, category: CategoryError, key: "Declaration_emit_for_this_file_requires_using_private_name_0_An_explicit_type_annotation_may_unblock_9005", text: "Declaration emit for this file requires using private name '{0}'. An explicit type annotation may unblock declaration emit."} var Declaration_emit_for_this_file_requires_using_private_name_0_from_module_1_An_explicit_type_annotation_may_unblock_declaration_emit = &Message{code: 9006, category: CategoryError, key: "Declaration_emit_for_this_file_requires_using_private_name_0_from_module_1_An_explicit_type_annotati_9006", text: "Declaration emit for this file requires using private name '{0}' from module '{1}'. An explicit type annotation may unblock declaration emit."} @@ -7838,6 +7840,8 @@ func keyToMessage(key Key) *Message { return Decorators_may_not_appear_after_export_or_export_default_if_they_also_appear_before_export case "A_JSDoc_template_tag_may_not_follow_a_typedef_callback_or_overload_tag_8039": return A_JSDoc_template_tag_may_not_follow_a_typedef_callback_or_overload_tag + case "File_rename_is_not_supported_by_the_editor_8040": + return File_rename_is_not_supported_by_the_editor case "Declaration_emit_for_this_file_requires_using_private_name_0_An_explicit_type_annotation_may_unblock_9005": return Declaration_emit_for_this_file_requires_using_private_name_0_An_explicit_type_annotation_may_unblock_declaration_emit case "Declaration_emit_for_this_file_requires_using_private_name_0_from_module_1_An_explicit_type_annotati_9006": diff --git a/internal/diagnostics/extraDiagnosticMessages.json b/internal/diagnostics/extraDiagnosticMessages.json index 96874900c9b..d06b16a27f9 100644 --- a/internal/diagnostics/extraDiagnosticMessages.json +++ b/internal/diagnostics/extraDiagnosticMessages.json @@ -106,5 +106,9 @@ "Installing types for '{0}'": { "category": "Message", "code": 100013 + }, + "File rename is not supported by the editor": { + "category": "Error", + "code": 8040 } } diff --git a/internal/fourslash/tests/renameImportSpecifierNoResourceOperations_test.go b/internal/fourslash/tests/renameImportSpecifierNoResourceOperations_test.go new file mode 100644 index 00000000000..efaeb086d93 --- /dev/null +++ b/internal/fourslash/tests/renameImportSpecifierNoResourceOperations_test.go @@ -0,0 +1,28 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestRenameImportSpecifierNoResourceOperations(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = ` +// @Filename: /a.ts +export const x = 0; +// @Filename: /b.ts +import * as a from ".//*rename*/a";` + capabilities := fourslash.GetDefaultCapabilities() + capabilities.Workspace.WorkspaceEdit = &lsproto.WorkspaceEditClientCapabilities{ + DocumentChanges: new(true), + ResourceOperations: &[]lsproto.ResourceOperationKind{}, + } + f, done := fourslash.NewFourslash(t, capabilities, content) + defer done() + f.GoToMarker(t, "rename") + f.VerifyRenameFailed(t, nil /*preferences*/) +} diff --git a/internal/ls/filerename.go b/internal/ls/filerename.go index ef6e1dbbda0..cd36bb074fb 100644 --- a/internal/ls/filerename.go +++ b/internal/ls/filerename.go @@ -80,7 +80,7 @@ func (l *LanguageService) GetEditsForFileRename(ctx context.Context, oldURI lspr func (l *LanguageService) createPathUpdater(oldPath string, newPath string) pathUpdater { compareOptions := tspath.ComparePathsOptions{UseCaseSensitiveFileNames: l.UseCaseSensitiveFileNames()} - getUpdatedPath := func(path string) (string, bool) { + return func(path string) (string, bool) { if tspath.ComparePaths(path, oldPath, compareOptions) == 0 { return newPath, true } @@ -89,20 +89,6 @@ func (l *LanguageService) createPathUpdater(oldPath string, newPath string) path } return "", false } - - return func(path string) (string, bool) { - if original := l.tryGetSourcePosition(path, 0); original != nil { - if updated, ok := getUpdatedPath(original.FileName); ok { - return makeCorrespondingRelativeChange(original.FileName, updated, path, compareOptions), true - } - } - return getUpdatedPath(path) - } -} - -func makeCorrespondingRelativeChange(a0 string, b0 string, a1 string, compareOptions tspath.ComparePathsOptions) string { - rel := tspath.GetRelativePathFromFile(a0, b0, compareOptions) - return tspath.CombinePaths(tspath.GetDirectoryPath(a1), rel) } func (l *LanguageService) updateTsconfigFiles(program *compiler.Program, changeTracker *change.Tracker, oldToNew pathUpdater, oldPath string, newPath string) { diff --git a/internal/ls/rename.go b/internal/ls/rename.go index f31353351a7..3d3f7180d40 100644 --- a/internal/ls/rename.go +++ b/internal/ls/rename.go @@ -219,11 +219,26 @@ func wouldRenameInOtherNodeModules(originalFile *ast.SourceFile, symbol *ast.Sym 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 { @@ -259,6 +274,8 @@ func (l *LanguageService) getRenameInfoForModule(ctx context.Context, newName st }, 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() diff --git a/internal/lsp/server.go b/internal/lsp/server.go index b4ae2025fc3..0c468fa8aeb 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1249,7 +1249,9 @@ func (s *Server) handleRename(ctx context.Context, params *lsproto.RenameParams, } info := defaultLs.GetRenameInfo(ctx, params.NewName, params.TextDocument.Uri, params.Position) if info.CanRename && info.FileToRename != "" { - if clientSupportsWillRenameFiles(ctx) && clientSupportsDocumentChanges(ctx) && clientSupportsRenameResourceOperations(ctx) { + // We send a `willRenameFiles` request if the client allows; + // otherwise we directly compute the edits for renaming the file. + if ls.ClientSupportsWillRenameFiles(ctx) { documentChanges := []lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ { RenameFile: &lsproto.RenameFile{ @@ -1271,17 +1273,20 @@ func (s *Server) handleRename(ctx context.Context, params *lsproto.RenameParams, NewUri: string(lsconv.FileNameToDocumentURI(info.NewFileName)), }}, } - return s.handleWillRenameFiles(ctx, renameFilesParams, req) + return s.handleWillRenameFilesWorker(ctx, renameFilesParams, req, true /*sendRenameFile*/) } return defaultLs.ProvideRename(ctx, params, orchestrator) } -func clientSupportsWillRenameFiles(ctx context.Context) bool { - return lsproto.GetClientCapabilities(ctx).Workspace.FileOperations.WillRename +func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, msg *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { + return s.handleWillRenameFilesWorker(ctx, params, msg, false /*sendRenameFile*/) } -func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage) (lsproto.WillRenameFilesResponse, error) { +// If `sendRenameFile` is true, the original `willRenameFiles` request is being handled as part of a rename operation +// where the client doesn't support `willRenameFiles`, +// so we should include the file rename in the edits we return +func (s *Server) handleWillRenameFilesWorker(ctx context.Context, params *lsproto.RenameFilesParams, _ *lsproto.RequestMessage, sendRenameFile bool) (lsproto.WillRenameFilesResponse, error) { if len(params.Files) == 0 { return lsproto.WillRenameFilesResponse{}, nil } @@ -1340,11 +1345,23 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena } } + if sendRenameFile { + for _, file := range params.Files { + documentChanges = append(documentChanges, lsproto.TextDocumentEditOrCreateFileOrRenameFileOrDeleteFile{ + RenameFile: &lsproto.RenameFile{ + Kind: lsproto.StringLiteralRename{}, + OldUri: lsproto.DocumentUri(file.OldUri), + NewUri: lsproto.DocumentUri(file.NewUri), + }, + }) + } + } + if len(documentChanges) == 0 { return lsproto.WillRenameFilesResponse{}, nil } - if clientSupportsDocumentChanges(ctx) { + if ls.ClientSupportsDocumentChanges(ctx) { return lsproto.WillRenameFilesResponse{ WorkspaceEdit: &lsproto.WorkspaceEdit{ DocumentChanges: &documentChanges, @@ -1371,14 +1388,6 @@ func (s *Server) handleWillRenameFiles(ctx context.Context, params *lsproto.Rena }, nil } -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) -} - func (s *Server) handleSignatureHelp(ctx context.Context, languageService *ls.LanguageService, params *lsproto.SignatureHelpParams) (lsproto.SignatureHelpResponse, error) { return languageService.ProvideSignatureHelp( ctx, diff --git a/internal/module/resolver.go b/internal/module/resolver.go index 9a69fba2400..2a269297265 100644 --- a/internal/module/resolver.go +++ b/internal/module/resolver.go @@ -84,7 +84,6 @@ type resolutionState struct { candidateIsFromPackageJsonField bool resolvedPackageDirectory bool diagnostics []*ast.Diagnostic - failedLookupLocations []string // Similar to whats on resolver but only done if compilerOptions are for project reference redirect // Cached representation for `core.CompilerOptions.paths`. @@ -1544,7 +1543,6 @@ func (r *resolutionState) tryFileLookup(fileName string) bool { } else if r.tracer != nil { r.tracer.write(diagnostics.File_0_does_not_exist, fileName) } - r.failedLookupLocations = append(r.failedLookupLocations, fileName) return false } From 971d70869cd7cc574bd6d03485e332fd947d67e9 Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Fri, 10 Apr 2026 23:43:09 +0000 Subject: [PATCH 18/19] add test from fuzzer crash --- .../tests/renameFilePackageJson_test.go | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 internal/fourslash/tests/renameFilePackageJson_test.go diff --git a/internal/fourslash/tests/renameFilePackageJson_test.go b/internal/fourslash/tests/renameFilePackageJson_test.go new file mode 100644 index 00000000000..fc54f385f40 --- /dev/null +++ b/internal/fourslash/tests/renameFilePackageJson_test.go @@ -0,0 +1,24 @@ +package fourslash_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/fourslash" + "github.com/microsoft/typescript-go/internal/testutil" +) + +func TestRenameFilePackageJson(t *testing.T) { + t.Parallel() + defer testutil.RecoverAndFail(t, "Panic on fourslash test") + const content = `// @Filename: /src/example.ts +import brushPackageJson from './visx-brush//*rename*/package.json'; +// @Filename: /src/visx-brush/package.json +{ "name": "brush" }` + + f, done := fourslash.NewFourslash(t, nil /*capabilities*/, content) + defer done() + f.VerifyRename(t, "rename", "package2.json", map[string]string{ + "/src/example.ts": `import brushPackageJson from './visx-brush/package2.json';`, + "/src/visx-brush/package2.json": `{ "name": "brush" }`, + }) +} From 16c0a4393bd02a84fded37395a35a4fd1cc191fb Mon Sep 17 00:00:00 2001 From: Gabriela Araujo Britto Date: Sat, 11 Apr 2026 00:16:53 +0000 Subject: [PATCH 19/19] feedback --- .../fourslash/tests/getEditsForFileRename_cssImport4_test.go | 2 +- internal/fourslash/tests/getEditsForFileRename_jsRename_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go b/internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go index 2c834d675ff..8f0de661137 100644 --- a/internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_cssImport4_test.go @@ -25,7 +25,7 @@ export default css; // @Filename: /a.ts import styles from ".//*rename*/app.css";` capabilities := fourslash.GetDefaultCapabilities() - capabilities.Workspace.FileOperations = nil + capabilities.Workspace.FileOperations.WillRename = new(false) f, done := fourslash.NewFourslash(t, capabilities, content) defer done() f.VerifyRename(t, "rename", "app2.css", map[string]string{ diff --git a/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go b/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go index 66a0b897e52..41b48345bf9 100644 --- a/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go +++ b/internal/fourslash/tests/getEditsForFileRename_jsRename_test.go @@ -7,7 +7,7 @@ import ( "github.com/microsoft/typescript-go/internal/testutil" ) -func TestGetEditsForFileRename_cssImport5(t *testing.T) { +func TestGetEditsForFileRename_jsRename(t *testing.T) { t.Parallel() defer testutil.RecoverAndFail(t, "Panic on fourslash test") const content = `