diff --git a/media/RenameDisclaimer.txt b/media/RenameDisclaimer.txt new file mode 100644 index 0000000000..13009fac46 --- /dev/null +++ b/media/RenameDisclaimer.txt @@ -0,0 +1,19 @@ +⚠️⚠️⚠️ PowerShell Extension Rename Disclaimer ⚠️⚠️⚠️ + +PowerShell is not a statically typed language. As such, the renaming of functions, parameters, and other symbols can only be done on a best effort basis. While this is sufficient for the majority of use cases, it cannot be relied upon to find all instances of a symbol and rename them across an entire code base such as in C# or TypeScript. + +There are several edge case scenarios which may exist where rename is difficult or impossible, or unable to be determined due to the dynamic scoping nature of PowerShell. + +🤚🤚 Unsupported Scenarios + +❌ Renaming can only be done within a single file. Renaming symbols across multiple files is not supported. +❌ Unsaved/Virtual files are currently not supported, you must save a file to disk before you can rename values. + +👍👍 Implemented and Tested Rename Scenaiors + +See the supported rename scenarios we are currently testing at: +https://github.com/PowerShell/PowerShellEditorServices/blob/main/test/PowerShellEditorServices.Test.Shared/Refactoring + +📄📄 Filing a Rename Issue + +If there is a rename scenario you feel can be reasonably supported in PowerShell, please file a bug report in the PowerShellEditorServices repository with the "Expected" and "Actual" being the before and after rename. We will evaluate it and accept or reject it and give reasons why. Items that fall under the Unsupported Scenarios above will be summarily rejected, however that does not mean that they may not be supported in the future if we come up with a reasonably safe way to implement a scenario. diff --git a/package.json b/package.json index 57cbd1b3b7..21667da9f0 100644 --- a/package.json +++ b/package.json @@ -1011,7 +1011,17 @@ "verbose" ], "default": "off", - "markdownDescription": "Traces the communication between VS Code and the PowerShell Editor Services language server. **This setting is only meant for extension developers!**" + "description": "Traces the communication between VS Code and the PowerShell Editor Services language server. **This setting is only meant for extension developers!**" + }, + "powershell.renameSymbol.createAlias": { + "type": "boolean", + "default": true, + "description": "If set, an [Alias] attribute will be added when renaming a function parameter so that the previous parameter name still works. This helps avoid breaking changes." + }, + "powershell.renameSymbol.acceptRenameDisclaimer": { + "type": "boolean", + "default": false, + "description": "Accepts the disclaimer about risks and limitations to the PowerShell rename functionality" } } }, diff --git a/src/extension.ts b/src/extension.ts index 01a4ee4a1c..f41629b971 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ import { SessionManager } from "./session"; import { LogLevel, getSettings } from "./settings"; import { PowerShellLanguageId } from "./utils"; import { LanguageClientConsumer } from "./languageClientConsumer"; +import { RenameSymbolFeature } from "./features/RenameSymbol"; // The most reliable way to get the name and version of the current extension. // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-var-requires @@ -151,6 +152,7 @@ export async function activate(context: vscode.ExtensionContext): Promise("textDocument/rename"); +const PrepareRenameSymbolRequestType = new RequestType("textDocument/prepareRename"); + +export class RenameSymbolFeature extends LanguageClientConsumer implements RenameProvider { + private languageRenameProvider:vscode.Disposable; + // Used to singleton the disclaimer prompt in case multiple renames are triggered + private disclaimerPromise?: Promise; + + constructor(documentSelector:DocumentSelector,private logger: ILogger){ + super(); + + this.languageRenameProvider = vscode.languages.registerRenameProvider(documentSelector,this); + } + + // eslint-disable-next-line @typescript-eslint/no-empty-function + public override onLanguageClientSet(_languageClient: LanguageClient): void {} + + public async provideRenameEdits(document: TextDocument, position: Position, newName: string, _token: CancellationToken): Promise { + + const disclaimerAccepted = await this.acknowledgeDisclaimer(); + if (!disclaimerAccepted) {return undefined;} + + const req:IRenameSymbolRequestArguments = { + TextDocument : {uri:document.uri.toString()}, + Position : position, + NewName : newName + }; + + try { + const client = await LanguageClientConsumer.getLanguageClient(); + const response = await client.sendRequest(RenameSymbolRequestType, req); + + if (!response.changes.length) { + return undefined; + } + + const edit = new WorkspaceEdit(); + for (const file of response.changes) { + const uri = Uri.file(file.fileName); + for (const change of file.changes) { + edit.replace( + uri, + new Range(change.startLine, change.startColumn, change.endLine, change.endColumn), + change.newText + ); + } + } + return edit; + } catch (error) { + return undefined; + } + } + + public async prepareRename( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken + ): Promise { + + const disclaimerAccepted = await this.acknowledgeDisclaimer(); + if (!disclaimerAccepted) {return undefined;} + + const req:IRenameSymbolRequestArguments = { + TextDocument : {uri:document.uri.toString()}, + Position : position, + NewName : "" + }; + + try { + const client = await LanguageClientConsumer.getLanguageClient(); + const response = await client.sendRequest(PrepareRenameSymbolRequestType, req); + + if (!response.message) { + return null; + } + const wordRange = document.getWordRangeAtPosition(position); + if (!wordRange) { + throw new Error("Not a valid location for renaming."); + + } + const wordText = document.getText(wordRange); + if (response.message) { + throw new Error(response.message); + } + + return { + range: wordRange, + placeholder: wordText + }; + }catch (error) { + const msg = `RenameSymbol: ${error}`; + this.logger.writeError(msg); + throw new Error(msg); + } + } + + + /** Prompts the user to acknowledge the risks inherent with the rename provider and does not proceed until it is accepted */ + async acknowledgeDisclaimer(): Promise { + if (!this.disclaimerPromise) { + this.disclaimerPromise = this.acknowledgeDisclaimerImpl(); + } + return this.disclaimerPromise; + } + + /** This is a separate function so that it only runs once as a singleton and the promise only resolves once */ + async acknowledgeDisclaimerImpl(): Promise + { + const config = vscode.workspace.getConfiguration(); + const acceptRenameDisclaimer = config.get("powershell.renameSymbol.acceptRenameDisclaimer", false); + + if (!acceptRenameDisclaimer) { + const extensionPath = vscode.extensions.getExtension("ms-vscode.PowerShell")?.extensionPath; + const disclaimerPath = vscode.Uri.file(`${extensionPath}/media/RenameDisclaimer.txt`); + + const result = await vscode.window.showWarningMessage( + //TODO: Provide a link to a markdown document that appears in the editor window, preferably one hosted with the extension itself. + `The PowerShell Rename functionality has limitations and risks, please [review the disclaimer](${disclaimerPath}).`, + "I Accept", + "I Accept [Workspace]", + "No" + ); + + switch (result) { + case "I Accept": + await config.update("powershell.renameSymbol.acceptRenameDisclaimer", true, vscode.ConfigurationTarget.Global); + break; + case "I Accept [Workspace]": + await config.update("powershell.renameSymbol.acceptRenameDisclaimer", true, vscode.ConfigurationTarget.Workspace); + break; + default: + void vscode.window.showInformationMessage("Rename operation cancelled and rename has been disabled until the extension is restarted."); + break; + } + } + + // Refresh the config to ensure it was set + return vscode.workspace.getConfiguration().get("powershell.renameSymbol.acceptRenameDisclaimer", false); + } + + public dispose(): void { + this.languageRenameProvider.dispose(); + } + +} diff --git a/src/settings.ts b/src/settings.ts index 878cac9036..f5a9054117 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -17,7 +17,7 @@ import path = require("path"); // Perhaps we just get rid of this entirely? // eslint-disable-next-line @typescript-eslint/no-extraneous-class -class PartialSettings { } +export class PartialSettings { } export class Settings extends PartialSettings { powerShellAdditionalExePaths: PowerShellAdditionalExePathSettings = {}; @@ -39,6 +39,7 @@ export class Settings extends PartialSettings { cwd = ""; // NOTE: use validateCwdSetting() instead of this directly! enableReferencesCodeLens = true; analyzeOpenDocumentsOnly = false; + renameSymbol = new RenameSymbolSettings(); // TODO: Add (deprecated) useX86Host (for testing) } @@ -155,6 +156,12 @@ class ButtonSettings extends PartialSettings { showPanelMovementButtons = false; } +class RenameSymbolSettings extends PartialSettings { + createAlias = true; + acceptRenameDisclaimer = false; +} + + // This is a recursive function which unpacks a WorkspaceConfiguration into our settings. function getSetting(key: string | undefined, value: TSetting, configuration: vscode.WorkspaceConfiguration): TSetting { // Base case where we're looking at a primitive type (or our special record).