Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Not Ready] [typespec-vscode] Rename file should rename import #5674

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
28 changes: 28 additions & 0 deletions packages/compiler/src/core/path-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,34 @@ export function getPathComponents(path: string, currentDirectory = "") {
return pathComponents(path, getRootLength(path));
}

export function getRelativePathByComparePaths(from: string, to: string): string {
if (from.length === 0 || to.length === 0 || from === to) {
Copy link
Contributor

Choose a reason for hiding this comment

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

the path-util is copied from vscode, we won't change it.

Copy link
Member

Choose a reason for hiding this comment

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

There is also already a get relative path above

return "";
}

const fromStrArray = getPathComponents(from);
const toStrArray = getPathComponents(to);
let i = 0;
while (i < fromStrArray.length && i < toStrArray.length && fromStrArray[i] === toStrArray[i]) {
i++;
}

const fromPaths = fromStrArray.slice(i);
const toPaths = toStrArray.slice(i);

const result: string[] = [];
if (fromPaths.length === 1) {
result.push(".");
} else {
for (i = 1; i < fromPaths.length; i++) {
result.push("..");
}
}
result.push(...toPaths);

return result.join(directorySeparator);
}

//#endregion

//#region Path Formatting
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ function main() {
connection.onRequest(getInitProjectContextRequestName, profile(s.getInitProjectContext));
const initProjectRequestName: CustomRequestName = "typespec/initProject";
connection.onRequest(initProjectRequestName, profile(s.initProject));
const updateImportsOnFileMovedOrRenamedRequestName: CustomRequestName =
"typespec/updateImportsOnFileMovedOrRenamed";
connection.onRequest(
updateImportsOnFileMovedOrRenamedRequestName,
profile(s.updateImportsOnFileRename),
);

documents.onDidChangeContent(profile(s.checkChange));
documents.onDidClose(profile(s.documentClosed));
Expand Down
42 changes: 42 additions & 0 deletions packages/compiler/src/server/serverlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ import { getPositionBeforeTrivia } from "../core/parser-utils.js";
import { getNodeAtPosition, getNodeAtPositionDetail, visitChildren } from "../core/parser.js";
import {
ensureTrailingDirectorySeparator,
getBaseFileName,
getDirectoryPath,
getRelativePathByComparePaths,
joinPaths,
normalizePath,
} from "../core/path-utils.js";
Expand Down Expand Up @@ -185,6 +187,7 @@ export function createServer(host: ServerHost): Server {
getInitProjectContext,
validateInitProjectTemplate,
initProject,
updateImportsOnFileRename: updateImportsOnFileMovedOrRenamed,
};

async function initialize(params: InitializeParams): Promise<InitializeResult> {
Expand Down Expand Up @@ -339,6 +342,45 @@ export function createServer(host: ServerHost): Server {
}
}

async function updateImportsOnFileMovedOrRenamed(param: {
oldFilePath: string;
newFilePath: string;
openedFilePath: string;
}): Promise<string[]> {
const result: string[] = [];

const resultCompile = await compileService.compile({
uri: fileService.getURL(normalizePath(param.openedFilePath)),
});
if (resultCompile === undefined) {
return [];
}

const { program } = resultCompile;
const oldFileName = getBaseFileName(param.oldFilePath);
for (const diagnostic of program.diagnostics) {
if (diagnostic.code === "import-not-found") {
const target = diagnostic.target as Node;
if (
target.parent &&
target.kind === SyntaxKind.ImportStatement &&
target.path.value.includes(oldFileName)
) {
const filePath = target.parent.file.path;
const text = target.parent.file.text;
result.push(getBaseFileName(filePath));
// The value of 'target.path.value' is the value of imports, so the value of imports needs to be replaced here.
const replaceText = getRelativePathByComparePaths(filePath, param.newFilePath);
if (replaceText.length > 0) {
await compilerHost.writeFile(filePath, text.replace(target.path.value, replaceText));
}
}
}
}

return result;
}

async function workspaceFoldersChanged(e: WorkspaceFoldersChangeEvent) {
log({ level: "info", message: "Workspace Folders Changed", detail: e });
const map = new Map(workspaceFolders.map((f) => [f.uri, f]));
Expand Down
7 changes: 6 additions & 1 deletion packages/compiler/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ export interface Server {
getInitProjectContext(): Promise<InitProjectContext>;
validateInitProjectTemplate(param: { template: InitTemplate }): Promise<boolean>;
initProject(param: { config: InitProjectConfig }): Promise<boolean>;

// Following custom capacities are added for supporting update imports on file rename from IDE (vscode for now) so that IDE can trigger compiler
updateImportsOnFileRename(param: { oldFilePath: string; newFilePath: string }): Promise<string[]>;
}

export interface ServerSourceFile extends SourceFile {
Expand Down Expand Up @@ -151,7 +154,9 @@ export interface SemanticToken {
export type CustomRequestName =
| "typespec/getInitProjectContext"
| "typespec/initProject"
| "typespec/validateInitProjectTemplate";
| "typespec/validateInitProjectTemplate"
| "typespec/updateImportsOnFileMovedOrRenamed";

export interface ServerCustomCapacities {
getInitProjectContext?: boolean;
validateInitProjectTemplate?: boolean;
Expand Down
6 changes: 6 additions & 0 deletions packages/typespec-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@
],
"default": "off",
"description": "Define whether/how the TypeSpec language server should send traces to client. For the traces to show properly in vscode Output, make sure 'Log Level' is also set to 'Trace' so that they won't be filtered at client side, which can be set through 'Developer: Set Log Level...' command."
},
"typespec.updateImportsOnFileMovedOrRenamed.enabled": {
"type": "boolean",
"default": "true",
"description": "Controls whether imports are updated when files are moved or renamed.",
"scope": "window"
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions packages/typespec-vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
RestartServerCommandArgs,
SettingName,
} from "./types.js";
import { updateImportsOnFileMovedOrRenamed } from "./update-import-on-file-rename.js";
import { createTypeSpecProject } from "./vscode-cmd/create-tsp-project.js";
import { installCompilerGlobally } from "./vscode-cmd/install-tsp-compiler.js";

Expand Down Expand Up @@ -97,6 +98,12 @@ export async function activate(context: ExtensionContext) {
}),
);

context.subscriptions.push(
vscode.workspace.onDidRenameFiles(async (e) => {
await updateImportsOnFileMovedOrRenamed(e, client);
}),
);

return await vscode.window.withProgress(
{
title: "Launching TypeSpec language service...",
Expand Down
19 changes: 19 additions & 0 deletions packages/typespec-vscode/src/tsp-language-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ export class TspLanguageClient {
}
}

async updateImportsOnFileMovedOrRenamed(
Copy link
Member

Choose a reason for hiding this comment

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

can this not be done in the language server?

oldFilePath: string,
newFilePath: string,
openedFilePath: string,
): Promise<string[]> {
const updateImportsOnFileMovedOrRenamedRequestName: CustomRequestName =
"typespec/updateImportsOnFileMovedOrRenamed";
try {
return await this.client.sendRequest(updateImportsOnFileMovedOrRenamedRequestName, {
oldFilePath,
newFilePath,
openedFilePath,
});
} catch (e) {
logger.error("Unexpected error when updating imports on file move", [e]);
return [];
}
}

async runCliCommand(args: string[], cwd: string): Promise<ExecOutput | undefined> {
if (isWhitespaceStringOrUndefined(this.initializeResult?.compilerCliJsPath)) {
logger.warning(
Expand Down
6 changes: 6 additions & 0 deletions packages/typespec-vscode/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const enum SettingName {
TspServerPath = "typespec.tsp-server.path",
InitTemplatesUrls = "typespec.initTemplatesUrls",
UpdateImportsOnFileMovedOrRenamed = "typespec.updateImportsOnFileMovedOrRenamed.enabled",
}

export const enum CommandName {
Expand All @@ -11,6 +12,11 @@ export const enum CommandName {
OpenUrl = "typespec.openUrl",
}

export interface MoveOrRenameAction {
readonly newFilePath: string;
readonly oldFilePath: string;
}

export interface InstallGlobalCliCommandArgs {
/**
* whether to confirm with end user before action
Expand Down
123 changes: 123 additions & 0 deletions packages/typespec-vscode/src/update-import-on-file-rename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import vscode from "vscode";
import { State } from "vscode-languageclient";
import logger from "./log/logger.js";
import { getAnyExtensionFromPath } from "./path-utils.js";
import { TspLanguageClient } from "./tsp-language-client.js";
import { MoveOrRenameAction, SettingName } from "./types.js";
import { createPromiseWithCancelAndTimeout } from "./utils.js";

export async function updateImportsOnFileMovedOrRenamed(
e: vscode.FileRenameEvent,
client?: TspLanguageClient,
): Promise<void> {
if (client && client.state === State.Running) {
const tspExtensionName = ".tsp";
const renames: MoveOrRenameAction[] = [];

for (const { newUri, oldUri } of e.files) {
// Skip if the file is not a TypeScript file
if (getAnyExtensionFromPath(newUri.path) !== tspExtensionName) {
continue;
}

if (getAnyExtensionFromPath(oldUri.path) !== tspExtensionName) {
continue;
}

const setting = vscode.workspace
.getConfiguration()
.get(SettingName.UpdateImportsOnFileMovedOrRenamed, true);
if (!setting) {
continue;
}

renames.push({
newFilePath: newUri.fsPath,
oldFilePath: oldUri.fsPath,
});

vscode.window.withProgress(
{
location: vscode.ProgressLocation.Window,
title: "Checking for update of TSP imports",
},
async (_progress, token) => {
const TIMEOUT = 300000; // set timeout to 5 minutes which should be enough for changes imports
await createPromiseWithCancelAndTimeout(flushRenames(client), token, TIMEOUT);
},
);
}

async function flushRenames(client?: TspLanguageClient): Promise<void> {
const tspOpenedFiles = vscode.workspace.textDocuments.filter(
(doc) => getAnyExtensionFromPath(doc.fileName) === tspExtensionName,
);
if (tspOpenedFiles && tspOpenedFiles.length <= 0) {
return;
}

for (const { newFilePath, oldFilePath } of renames) {
const updatedFiles = await withEditsForFileMovedOrRenamed(
oldFilePath,
newFilePath,
tspOpenedFiles[0].uri.fsPath,
client,
);

await showMessageToUser(updatedFiles);
}
}
} else {
logger.warning(
"TypeSpec language server is not running, skipping update imports on file rename.",
);
}
}

async function withEditsForFileMovedOrRenamed(
oldFilePath: string,
newFilePath: string,
openedFilePath: string,
client?: TspLanguageClient,
): Promise<string[]> {
if (!client) {
return [];
}

return await client.updateImportsOnFileMovedOrRenamed(oldFilePath, newFilePath, openedFilePath);
}

async function showMessageToUser(newResources: string[]): Promise<void> {
if (!newResources.length) {
return;
}

await vscode.window.showInformationMessage(
newResources.length === 1
? `Update imports for '${newResources[0]}'`
: getMessageForUser(
`Update imports for the following ${newResources.length} files:`,
newResources,
),
{ modal: true },
);
}

function getMessageForUser(start: string, resourcesToConfirm: readonly string[]): string {
const MAX_CONFIRM_FILES = 10;

const paths = [start];
paths.push("");
paths.push(...resourcesToConfirm.slice(0, MAX_CONFIRM_FILES).map((r) => r));

if (resourcesToConfirm.length > MAX_CONFIRM_FILES) {
if (resourcesToConfirm.length - MAX_CONFIRM_FILES === 1) {
paths.push("...1 additional file not shown");
} else {
paths.push(`...${resourcesToConfirm.length - MAX_CONFIRM_FILES} additional files not shown`);
}
}

paths.push("");
return paths.join("\n");
}