diff --git a/CHANGELOG.md b/CHANGELOG.md index 0640253e9..52b0afa2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,12 @@ Please update to PowerShell 7.4 LTS going forward. This release contains a logging overhaul which purposely removes our dependency on Serilog and should lead to improved stability with PowerShell 5.1 (by avoiding a major GAC assembly conflict). +## v3.3.0 +### Friday, November 15, 2024 + +See more details at the GitHub Release for [v3.3.0](https://github.com/PowerShell/PowerShellEditorServices/releases/tag/v3.3.0). + +Logging updates and dropped EOL PowerShell ## v3.21.0 ### Wednesday, October 30, 2024 diff --git a/README.md b/README.md index 48341ab68..4f6c10c75 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ functionality needed to enable a consistent and robust PowerShell development experience in almost any editor or integrated development environment (IDE). -## [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) clients using PowerShell Editor Services: +## [Language Server Protocol](https://microsoft.github.io/language-server-protocol/) clients using PowerShell Editor Services - [PowerShell for Visual Studio Code](https://github.com/PowerShell/vscode-powershell) > [!NOTE] @@ -143,6 +143,33 @@ The debugging functionality in PowerShell Editor Services is available in the fo - [powershell.nvim for Neovim](https://github.com/TheLeoP/powershell.nvim) - [intellij-powershell](https://github.com/ant-druha/intellij-powershell) +### 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. + +The focus of the rename support is on quick updates to variables or functions within a self-contained script file. It is not intended for module developers to find and rename a symbol across multiple files, which is very difficult to do as the relationships are primarily only computed at runtime and not possible to be statically analyzed. +#### 👍 [Implemented and Tested Rename Scenarios](https://github.com/PowerShell/PowerShellEditorServices/blob/main/test/PowerShellEditorServices.Test.Shared/Refactoring) + +#### 🤚 Unsupported Scenarios + +- ❌ Renaming can only be done within a single file. Renaming symbols across multiple files is not supported, even if those are dotsourced from the source file. +- ❌ Functions or variables must have a corresponding definition within their scope or above to be renamed. If we cannot find the original definition of a variable or function, the rename will not be supported. +- ❌ Dynamic Parameters are not supported +- ❌ Dynamically constructed splat parameters will not be renamed/updated (e.g. `$splat = @{};$splat.a = 5;Do-Thing @a`) +- ❌ Scoped variables (e.g. $SCRIPT:test) are not currently supported +- ❌ Renaming a variable inside of a scriptblock that is used in unscoped operations like `Foreach-Parallel` or `Start-Job` and the variable is not defined within the scriptblock is not supported and can have unexpected results. +- ❌ Scriptblocks part of an assignment are considered isolated scopes. For example `$a = 5; $x = {$a}; & $x` does not consider the two $a to be related, even though in execution this reference matches. +- ❌ Scriptblocks that are part of a parameter are assumed to not be executing in a different runspace. For example, the renaming behavior will treat `ForEach-Object -Parallel {$x}` the same as `Foreach-Object {$x}` for purposes of finding scope definitions. To avoid unexpected renaming, define/redefine all your variables in the scriptblock using a param block. +- ❌ A lot of the logic relies on the position of items, so for example, defining a variable in a `begin` block and placing it after a `process` block, while technically correct in PowerShell, will not rename as expected. +- ❌ Similarly, defining a function, and having the function rely on a variable that is assigned outside the function and after the function definition, will not find the outer variable reference. +- ❌ `Get-Variable` and `Set-Variable` are not considered and not currently searched for renames + +#### 📄 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. + ## API Usage Please note that we only consider the following as stable APIs that can be relied on: diff --git a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs index c106d34c6..042e4e8fa 100644 --- a/src/PowerShellEditorServices/Server/PsesLanguageServer.cs +++ b/src/PowerShellEditorServices/Server/PsesLanguageServer.cs @@ -91,6 +91,8 @@ public async Task StartAsync() .ClearProviders() .AddPsesLanguageServerLogging() .SetMinimumLevel(_minimumLogLevel)) + // TODO: Consider replacing all WithHandler with AddSingleton + .WithConfigurationSection("powershell.rename") .WithHandler() .WithHandler() .WithHandler() @@ -123,6 +125,8 @@ public async Task StartAsync() .WithHandler() .WithHandler() .WithHandler() + .WithHandler() + .WithHandler() // NOTE: The OnInitialize delegate gets run when we first receive the // _Initialize_ request: // https://microsoft.github.io/language-server-protocol/specifications/specification-current/#initialize diff --git a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs index 82081c341..5a75ce448 100644 --- a/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs +++ b/src/PowerShellEditorServices/Server/PsesServiceCollectionExtensions.cs @@ -50,7 +50,8 @@ public static IServiceCollection AddPsesLanguageServices( extensionService.InitializeAsync(); return extensionService; }) - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } public static IServiceCollection AddPsesDebugServices( diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/HandlerErrorException.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/HandlerErrorException.cs new file mode 100644 index 000000000..14c3b949e --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/HandlerErrorException.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; + +namespace Microsoft.PowerShell.EditorServices.Handlers; + +/// +/// A convenience exception for handlers to throw when a request fails for a normal reason, +/// and to communicate that reason to the user without a full internal stacktrace. +/// +/// The message describing the reason for the request failure. +/// Additional details to be logged regarding the failure. It should be serializable to JSON. +/// The severity level of the message. This is only shown in internal logging. +public class HandlerErrorException +( + string message, + object logDetails = null, + MessageType severity = MessageType.Error +) : RpcErrorException((int)severity, logDetails!, message) +{ } diff --git a/src/PowerShellEditorServices/Services/PowerShell/Refactoring/Exceptions.cs b/src/PowerShellEditorServices/Services/PowerShell/Refactoring/Exceptions.cs new file mode 100644 index 000000000..e447556cf --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Refactoring/Exceptions.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +namespace Microsoft.PowerShell.EditorServices.Refactoring +{ + + public class TargetSymbolNotFoundException : Exception + { + public TargetSymbolNotFoundException() + { + } + + public TargetSymbolNotFoundException(string message) + : base(message) + { + } + + public TargetSymbolNotFoundException(string message, Exception inner) + : base(message, inner) + { + } + } + + public class FunctionDefinitionNotFoundException : Exception + { + public FunctionDefinitionNotFoundException() + { + } + + public FunctionDefinitionNotFoundException(string message) + : base(message) + { + } + + public FunctionDefinitionNotFoundException(string message, Exception inner) + : base(message, inner) + { + } + } +} diff --git a/src/PowerShellEditorServices/Services/PowerShell/Utility/IScriptExtentExtensions.cs b/src/PowerShellEditorServices/Services/PowerShell/Utility/IScriptExtentExtensions.cs new file mode 100644 index 000000000..5235ea3e5 --- /dev/null +++ b/src/PowerShellEditorServices/Services/PowerShell/Utility/IScriptExtentExtensions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// using System.Management.Automation.Language; +// using Microsoft.PowerShell.EditorServices.Services; + +// namespace PowerShellEditorServices.Services.PowerShell.Utility +// { +// public static class IScriptExtentExtensions +// { +// public static bool Contains(this IScriptExtent extent, IScriptExtent position) +// => ScriptExtentAdapter.ContainsPosition(extent, position); +// } +// } diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs index 64ccb3156..bf5f99d0f 100644 --- a/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/FormattingHandlers.cs @@ -90,7 +90,7 @@ public override async Task Handle(DocumentFormattingParams re return s_emptyTextEditContainer; } - return new TextEditContainer(new TextEdit + return new TextEditContainer(new OmniSharp.Extensions.LanguageServer.Protocol.Models.TextEdit { NewText = formattedScript, Range = editRange @@ -184,7 +184,7 @@ public override async Task Handle(DocumentRangeFormattingPara return s_emptyTextEditContainer; } - return new TextEditContainer(new TextEdit + return new TextEditContainer(new OmniSharp.Extensions.LanguageServer.Protocol.Models.TextEdit { NewText = formattedScript, Range = editRange diff --git a/src/PowerShellEditorServices/Services/TextDocument/Handlers/RenameHandler.cs b/src/PowerShellEditorServices/Services/TextDocument/Handlers/RenameHandler.cs new file mode 100644 index 000000000..77ad58d7b --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/Handlers/RenameHandler.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.PowerShell.EditorServices.Services; + +using OmniSharp.Extensions.LanguageServer.Protocol.Document; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Client.Capabilities; + +namespace Microsoft.PowerShell.EditorServices.Handlers; + +/// +/// A handler for textDocument/prepareRename +/// +internal class PrepareRenameHandler +( + RenameService renameService +) : IPrepareRenameHandler +{ + public RenameRegistrationOptions GetRegistrationOptions(RenameCapability capability, ClientCapabilities clientCapabilities) => capability.PrepareSupport ? new() { PrepareProvider = true } : new(); + + public async Task Handle(PrepareRenameParams request, CancellationToken cancellationToken) + => await renameService.PrepareRenameSymbol(request, cancellationToken).ConfigureAwait(false); +} + +/// +/// A handler for textDocument/rename +/// +internal class RenameHandler( + RenameService renameService +) : IRenameHandler +{ + // RenameOptions may only be specified if the client states that it supports prepareSupport in its initial initialize request. + public RenameRegistrationOptions GetRegistrationOptions(RenameCapability capability, ClientCapabilities clientCapabilities) => capability.PrepareSupport ? new() { PrepareProvider = true } : new(); + + public async Task Handle(RenameParams request, CancellationToken cancellationToken) + => await renameService.RenameSymbol(request, cancellationToken).ConfigureAwait(false); +} diff --git a/src/PowerShellEditorServices/Services/TextDocument/RenameService.cs b/src/PowerShellEditorServices/Services/TextDocument/RenameService.cs new file mode 100644 index 000000000..41051daaf --- /dev/null +++ b/src/PowerShellEditorServices/Services/TextDocument/RenameService.cs @@ -0,0 +1,627 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Language; +using Microsoft.PowerShell.EditorServices.Refactoring; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; + +namespace Microsoft.PowerShell.EditorServices.Services; + +/// +/// Used with Configuration Bind to sync the settings to what is set on the client. +/// +public class RenameServiceOptions +{ + public bool createFunctionAlias { get; set; } + public bool createParameterAlias { get; set; } + public bool acceptDisclaimer { get; set; } +} + +internal interface IRenameService +{ + /// + /// Implementation of textDocument/prepareRename + /// + internal Task PrepareRenameSymbol(PrepareRenameParams prepareRenameParams, CancellationToken cancellationToken); + + /// + /// Implementation of textDocument/rename + /// + internal Task RenameSymbol(RenameParams renameParams, CancellationToken cancellationToken); +} + +/// +/// Providers service for renaming supported symbols such as functions and variables. +/// +internal class RenameService( + WorkspaceService workspaceService, + ILanguageServerFacade lsp, + ILanguageServerConfiguration config +) : IRenameService +{ + internal bool DisclaimerAcceptedForSession; //This is exposed to allow testing non-interactively + private bool DisclaimerDeclinedForSession; + private const string ConfigSection = "powershell.rename"; + private RenameServiceOptions? options; + public async Task PrepareRenameSymbol(PrepareRenameParams request, CancellationToken cancellationToken) + { + RenameParams renameRequest = new() + { + NewName = "PREPARERENAMETEST", //A placeholder just to gather edits + Position = request.Position, + TextDocument = request.TextDocument + }; + + // TODO: As a performance optimization, should we cache these results and just fetch them on the actual rename, and move the bulk to an implementation method? Seems pretty fast right now but may slow down on large documents. Need to add a large document test example. + WorkspaceEdit? renameResponse = await RenameSymbol(renameRequest, cancellationToken).ConfigureAwait(false); + + // Since LSP 3.16 we can simply basically return a DefaultBehavior true or null to signal to the client that the position is valid for rename and it should use its default selection criteria (which is probably the language semantic highlighting or grammar). For the current scope of the rename provider, this should be fine, but we have the option to supply the specific range in the future for special cases. + RangeOrPlaceholderRange renameSupported = new(new RenameDefaultBehavior() { DefaultBehavior = true }); + + return (renameResponse?.Changes?[request.TextDocument.Uri].ToArray().Length > 0) + ? renameSupported + : null; + } + + public async Task RenameSymbol(RenameParams request, CancellationToken cancellationToken) + { + // We want scoped settings because a workspace setting might be relevant here. + options = await GetScopedSettings(request.TextDocument.Uri, cancellationToken).ConfigureAwait(false); + + if (!await AcceptRenameDisclaimer(options.acceptDisclaimer, cancellationToken).ConfigureAwait(false)) { return null; } + + ScriptFile scriptFile = workspaceService.GetFile(request.TextDocument.Uri); + ScriptPositionAdapter position = request.Position; + + Ast? tokenToRename = FindRenamableSymbol(scriptFile, position); + if (tokenToRename is null) { return null; } + + // TODO: Potentially future cross-file support + TextEdit[] changes = tokenToRename switch + { + FunctionDefinitionAst + or CommandAst + => RenameFunction(tokenToRename, scriptFile.ScriptAst, request), + + VariableExpressionAst + or CommandParameterAst + or StringConstantExpressionAst + => RenameVariable(tokenToRename, scriptFile.ScriptAst, request), + + _ => throw new InvalidOperationException("This should not happen as PrepareRename should have already checked for viability. File an issue if you see this.") + }; + + return new WorkspaceEdit + { + Changes = new Dictionary> + { + [request.TextDocument.Uri] = changes + } + }; + } + + // TODO: We can probably merge these two methods with Generic Type constraints since they are factored into overloading + + private static TextEdit[] RenameFunction(Ast target, Ast scriptAst, RenameParams renameParams) + { + RenameFunctionVisitor visitor = new(target, renameParams.NewName); + return visitor.VisitAndGetEdits(scriptAst); + } + + private TextEdit[] RenameVariable(Ast symbol, Ast scriptAst, RenameParams requestParams) + { + RenameVariableVisitor visitor = new( + symbol, requestParams.NewName, createParameterAlias: options?.createParameterAlias ?? false + ); + return visitor.VisitAndGetEdits(scriptAst); + } + + /// + /// Finds the most specific renamable symbol at the given position + /// + /// Ast of the token or null if no renamable symbol was found + internal static Ast? FindRenamableSymbol(ScriptFile scriptFile, IScriptPosition position) + { + List renameableAstTypes = [ + // Functions + typeof(FunctionDefinitionAst), + typeof(CommandAst), + + // Variables + typeof(VariableExpressionAst), + typeof(CommandParameterAst), + typeof(StringConstantExpressionAst) + ]; + Ast? ast = scriptFile.ScriptAst.FindClosest(position, renameableAstTypes.ToArray()); + + if (ast is StringConstantExpressionAst stringAst) + { + // Only splat string parameters should be considered for evaluation. + if (stringAst.FindSplatParameterReference() is not null) { return stringAst; } + // Otherwise redo the search without stringConstant, so the most specific is a command, etc. + renameableAstTypes.Remove(typeof(StringConstantExpressionAst)); + ast = scriptFile.ScriptAst.FindClosest(position, renameableAstTypes.ToArray()); + } + + // Performance optimizations + + // Only the function name is valid for rename, not other components + if (ast is FunctionDefinitionAst funcDefAst) + { + if (!funcDefAst.GetFunctionNameExtent().Contains(position)) + { + return null; + } + } + + // Only the command name (function call) portion is renamable + if (ast is CommandAst command) + { + if (command.CommandElements[0] is not StringConstantExpressionAst name) + { + return null; + } + + if (!new ScriptExtentAdapter(name.Extent).Contains(position)) + { + return null; + } + } + + + + return ast; + } + + /// + /// Prompts the user to accept the rename disclaimer. + /// + /// true if accepted, false if rejected + private async Task AcceptRenameDisclaimer(bool acceptDisclaimerOption, CancellationToken cancellationToken) + { + const string disclaimerDeclinedMessage = "PowerShell rename has been disabled for this session as the disclaimer message was declined. Please restart the extension if you wish to use rename and accept the disclaimer."; + + if (DisclaimerDeclinedForSession) { throw new HandlerErrorException(disclaimerDeclinedMessage); } + if (acceptDisclaimerOption || DisclaimerAcceptedForSession) { return true; } + + // TODO: Localization + const string renameDisclaimer = "PowerShell rename functionality is only supported in a limited set of circumstances. [Please review the notice](https://github.com/PowerShell/PowerShellEditorServices?tab=readme-ov-file#rename-disclaimer) and accept the limitations and risks."; + const string acceptAnswer = "I Accept"; + // const string acceptWorkspaceAnswer = "I Accept [Workspace]"; + // const string acceptSessionAnswer = "I Accept [Session]"; + const string declineAnswer = "Decline"; + + // TODO: Unfortunately the LSP spec has no spec for the server to change a client setting, so + // We have a suboptimal experience until we implement a custom feature for this. + ShowMessageRequestParams reqParams = new() + { + Type = MessageType.Warning, + Message = renameDisclaimer, + Actions = new MessageActionItem[] { + new MessageActionItem() { Title = acceptAnswer }, + new MessageActionItem() { Title = declineAnswer } + // new MessageActionItem() { Title = acceptWorkspaceAnswer }, + // new MessageActionItem() { Title = acceptSessionAnswer }, + } + }; + + MessageActionItem? result = await lsp.SendRequest(reqParams, cancellationToken).ConfigureAwait(false); + // null happens if the user closes the dialog rather than making a selection. + if (result is null || result.Title == declineAnswer) + { + const string renameDisabledNotice = "PowerShell Rename functionality will be disabled for this session and you will not be prompted again until restart."; + + ShowMessageParams msgParams = new() + { + Message = renameDisabledNotice, + Type = MessageType.Info + }; + lsp.SendNotification(msgParams); + DisclaimerDeclinedForSession = true; + throw new HandlerErrorException(disclaimerDeclinedMessage); + } + if (result.Title == acceptAnswer) + { + const string acceptDisclaimerNotice = "PowerShell rename functionality has been enabled for this session. To avoid this prompt in the future, set the powershell.rename.acceptDisclaimer to true in your settings."; + ShowMessageParams msgParams = new() + { + Message = acceptDisclaimerNotice, + Type = MessageType.Info + }; + lsp.SendNotification(msgParams); + + DisclaimerAcceptedForSession = true; + return DisclaimerAcceptedForSession; + } + // if (result.Title == acceptWorkspaceAnswer) + // { + // // FIXME: Set the appropriate setting + // return true; + // } + // if (result.Title == acceptSessionAnswer) + // { + // // FIXME: Set the appropriate setting + // return true; + // } + + throw new InvalidOperationException("Unknown Disclaimer Response received. This is a bug and you should report it."); + } + + private async Task GetScopedSettings(DocumentUri uri, CancellationToken cancellationToken = default) + { + IScopedConfiguration scopedConfig = await config.GetScopedConfiguration(uri, cancellationToken).ConfigureAwait(false); + return scopedConfig.GetSection(ConfigSection).Get() ?? new RenameServiceOptions(); + } +} + +internal abstract class RenameVisitorBase : AstVisitor +{ + internal List Edits { get; } = new(); + internal Ast? CurrentDocument { get; set; } + + /// + /// A convenience method to get text edits from a specified AST. + /// + internal virtual TextEdit[] VisitAndGetEdits(Ast ast) + { + ast.Visit(this); + return Edits.ToArray(); + } +} + +/// +/// A visitor that generates a list of TextEdits to a TextDocument to rename a PowerShell function +/// You should use a new instance for each rename operation. +/// Skipverify can be used as a performance optimization when you are sure you are in scope. +/// +internal class RenameFunctionVisitor(Ast target, string newName, bool skipVerify = false) : RenameVisitorBase +{ + private FunctionDefinitionAst? FunctionToRename; + + // Wire up our visitor to the relevant AST types we are potentially renaming + public override AstVisitAction VisitFunctionDefinition(FunctionDefinitionAst ast) => Visit(ast); + public override AstVisitAction VisitCommand(CommandAst ast) => Visit(ast); + + internal AstVisitAction Visit(Ast ast) + { + // If this is our first run, we need to verify we are in scope and gather our rename operation info + if (!skipVerify && CurrentDocument is null) + { + CurrentDocument = ast.GetHighestParent(); + if (CurrentDocument.Find(ast => ast == target, true) is null) + { + throw new TargetSymbolNotFoundException("The target this visitor would rename is not present in the AST. This is a bug and you should file an issue"); + } + + FunctionToRename = target switch + { + FunctionDefinitionAst f => f, + CommandAst command => CurrentDocument.FindFunctionDefinition(command) + ?? throw new HandlerErrorException("The command to rename does not have a function definition. Renaming a function is only supported when the function is defined within an accessible scope"), + _ => throw new Exception($"Unsupported AST type {target.GetType()} encountered") + }; + }; + + if (CurrentDocument != ast.GetHighestParent()) + { + throw new TargetSymbolNotFoundException("The visitor should not be reused to rename a different document. It should be created new for each rename operation. This is a bug and you should file an issue"); + } + + if (ShouldRename(ast)) + { + Edits.Add(GetRenameFunctionEdit(ast)); + } + return AstVisitAction.Continue; + + // TODO: Is there a way we can know we are fully outside where the function might be referenced, and if so, call a AstVisitAction Abort as a perf optimization? + } + + internal bool ShouldRename(Ast candidate) + { + // Rename our original function definition. There may be duplicate definitions of the same name + if (candidate is FunctionDefinitionAst funcDef) + { + return funcDef == FunctionToRename; + } + + // Should only be CommandAst (function calls) from this point forward in the visit. + if (candidate is not CommandAst command) + { + throw new InvalidOperationException($"ShouldRename for a function had an Unexpected Ast Type {candidate.GetType()}. This is a bug and you should file an issue."); + } + + if (CurrentDocument is null) + { + throw new InvalidOperationException("CurrentDoc should always be set by now from first Visit. This is a bug and you should file an issue."); + } + + // Match up the command to its function definition + return CurrentDocument.FindFunctionDefinition(command) == FunctionToRename; + } + + private TextEdit GetRenameFunctionEdit(Ast candidate) + { + if (candidate is FunctionDefinitionAst funcDef) + { + if (funcDef != FunctionToRename) + { + throw new InvalidOperationException("GetRenameFunctionEdit was called on an Ast that was not the target. This is a bug and you should file an issue."); + } + + if (!IsValidFunctionName(newName)) + { + throw new HandlerErrorException($"{newName} is not a valid function name."); + } + + ScriptExtentAdapter functionNameExtent = funcDef.GetFunctionNameExtent(); + + return new TextEdit() + { + NewText = newName, + Range = functionNameExtent + }; + } + + // Should be CommandAst past this point. + if (candidate is not CommandAst command) + { + throw new InvalidOperationException($"Expected a command but got {candidate.GetType()}"); + } + + if (command.CommandElements[0] is not StringConstantExpressionAst funcName) + { + throw new InvalidOperationException("Command element should always have a string expresssion as its first item. This is a bug and you should report it."); + } + + return new TextEdit() + { + NewText = newName, + Range = new ScriptExtentAdapter(funcName.Extent) + }; + } + + internal static bool IsValidFunctionName(string name) + { + // Allows us to supply function:varname or varname and get a proper result + string candidate = "function " + name.TrimStart('$').TrimStart('-') + " {}"; + Ast ast = Parser.ParseInput(candidate, out _, out ParseError[] errors); + if (errors.Length > 0) + { + return false; + } + + return (ast.Find(a => a is FunctionDefinitionAst, false) as FunctionDefinitionAst)? + .Name is not null; + } +} + +internal class RenameVariableVisitor(Ast target, string newName, bool skipVerify = false, bool createParameterAlias = false) : RenameVisitorBase +{ + // Used to store the original definition of the variable to use as a reference. + internal Ast? VariableDefinition; + + // Validate and cleanup the newName definition. User may have left off the $ + // TODO: Full AST parsing to validate the name + private readonly string NewName = newName.TrimStart('$').TrimStart('-'); + + // Wire up our visitor to the relevant AST types we are potentially renaming + public override AstVisitAction VisitVariableExpression(VariableExpressionAst ast) => Visit(ast); + public override AstVisitAction VisitCommandParameter(CommandParameterAst ast) => Visit(ast); + public override AstVisitAction VisitStringConstantExpression(StringConstantExpressionAst ast) => Visit(ast); + + internal AstVisitAction Visit(Ast ast) + { + // If this is our first visit, we need to initialize and verify the scope, otherwise verify we are still on the same document. + if (!skipVerify && CurrentDocument is null || VariableDefinition is null) + { + CurrentDocument = ast.GetHighestParent(); + if (CurrentDocument.Find(ast => ast == target, true) is null) + { + throw new TargetSymbolNotFoundException("The target this visitor would rename is not present in the AST. This is a bug and you should file an issue"); + } + + // Get the original assignment of our variable, this makes finding rename targets easier in subsequent visits as well as allows us to short-circuit quickly. + VariableDefinition = target.GetTopVariableAssignment(); + if (VariableDefinition is null) + { + throw new HandlerErrorException("The variable element to rename does not have a definition. Renaming an element is only supported when the variable element is defined within an accessible scope"); + } + } + else if (CurrentDocument != ast.GetHighestParent()) + { + throw new TargetSymbolNotFoundException("The visitor should not be reused to rename a different document. It should be created new for each rename operation. This is a bug and you should file an issue"); + } + + if (ShouldRename(ast)) + { + if ( + createParameterAlias + && ast == VariableDefinition + && VariableDefinition is not null and VariableExpressionAst varDefAst + && varDefAst.Parent is ParameterAst paramAst + ) + { + Edits.Add(new TextEdit + { + NewText = $"[Alias('{varDefAst.VariablePath.UserPath}')]", + Range = new Range() + { + Start = new ScriptPositionAdapter(paramAst.Extent.StartScriptPosition), + End = new ScriptPositionAdapter(paramAst.Extent.StartScriptPosition) + } + }); + } + + Edits.Add(GetRenameVariableEdit(ast)); + } + + return AstVisitAction.Continue; + } + + private bool ShouldRename(Ast candidate) + { + if (VariableDefinition is null) + { + throw new InvalidOperationException("VariableDefinition should always be set by now from first Visit. This is a bug and you should file an issue."); + } + + if (candidate == VariableDefinition) { return true; } + // Performance optimization + if (VariableDefinition.IsAfter(candidate)) { return false; } + + if (candidate.GetTopVariableAssignment() == VariableDefinition) { return true; } + + return false; + } + + private TextEdit GetRenameVariableEdit(Ast ast) + { + return ast switch + { + VariableExpressionAst var => !IsValidVariableName(NewName) + ? throw new HandlerErrorException($"${NewName} is not a valid variable name.") + : new TextEdit + { + NewText = '$' + NewName, + Range = new ScriptExtentAdapter(var.Extent) + }, + StringConstantExpressionAst stringAst => !IsValidVariableName(NewName) + ? throw new Exception($"{NewName} is not a valid variable name.") + : new TextEdit + { + NewText = NewName, + Range = new ScriptExtentAdapter(stringAst.Extent) + }, + CommandParameterAst param => !IsValidCommandParameterName(NewName) + ? throw new Exception($"-{NewName} is not a valid command parameter name.") + : new TextEdit + { + NewText = '-' + NewName, + Range = new ScriptExtentAdapter(param.Extent) + }, + _ => throw new InvalidOperationException($"GetRenameVariableEdit was called on an Ast that was not the target. This is a bug and you should file an issue.") + }; + } + + internal static bool IsValidVariableName(string name) + { + // Allows us to supply $varname or varname and get a proper result + string candidate = '$' + name.TrimStart('$').TrimStart('-'); + Parser.ParseInput(candidate, out Token[] tokens, out _); + return tokens.Length is 2 + && tokens[0].Kind == TokenKind.Variable + && tokens[1].Kind == TokenKind.EndOfInput; + } + + internal static bool IsValidCommandParameterName(string name) + { + // Allows us to supply -varname or varname and get a proper result + string candidate = "Command -" + name.TrimStart('$').TrimStart('-'); + Parser.ParseInput(candidate, out Token[] tokens, out _); + return tokens.Length == 3 + && tokens[0].Kind == TokenKind.Command + && tokens[1].Kind == TokenKind.Parameter + && tokens[2].Kind == TokenKind.EndOfInput; + } +} + +/// +/// Represents a position in a script file that adapts and implicitly converts based on context. PowerShell script lines/columns start at 1, but LSP textdocument lines/columns start at 0. The default line/column constructor is 1-based. +/// +public record ScriptPositionAdapter(IScriptPosition position) : IScriptPosition, IComparable, IComparable, IComparable +{ + public int Line => position.LineNumber; + public int LineNumber => position.LineNumber; + public int Column => position.ColumnNumber; + public int ColumnNumber => position.ColumnNumber; + public int Character => position.ColumnNumber; + + public string File => position.File; + string IScriptPosition.Line => position.Line; + public int Offset => position.Offset; + + public ScriptPositionAdapter(int Line, int Column) : this(new ScriptPosition(null, Line, Column, null)) { } + public ScriptPositionAdapter(ScriptPosition position) : this((IScriptPosition)position) { } + + public ScriptPositionAdapter(Position position) : this(position.Line + 1, position.Character + 1) { } + + public static implicit operator ScriptPositionAdapter(Position position) => new(position); + public static implicit operator Position(ScriptPositionAdapter scriptPosition) => new +( + scriptPosition.position.LineNumber - 1, scriptPosition.position.ColumnNumber - 1 + ); + + public static implicit operator ScriptPositionAdapter(ScriptPosition position) => new(position); + public static implicit operator ScriptPosition(ScriptPositionAdapter position) => position; + + internal ScriptPositionAdapter Delta(int LineAdjust, int ColumnAdjust) => new( + position.LineNumber + LineAdjust, + position.ColumnNumber + ColumnAdjust + ); + + public int CompareTo(ScriptPositionAdapter other) + { + if (position.LineNumber == other.position.LineNumber) + { + return position.ColumnNumber.CompareTo(other.position.ColumnNumber); + } + return position.LineNumber.CompareTo(other.position.LineNumber); + } + public int CompareTo(Position other) => CompareTo((ScriptPositionAdapter)other); + public int CompareTo(ScriptPosition other) => CompareTo((ScriptPositionAdapter)other); + public string GetFullScript() => throw new NotImplementedException(); +} + +/// +/// Represents a range in a script file that adapts and implicitly converts based on context. PowerShell script lines/columns start at 1, but LSP textdocument lines/columns start at 0. The default ScriptExtent constructor is 1-based +/// +/// +internal record ScriptExtentAdapter(IScriptExtent extent) : IScriptExtent +{ + internal ScriptPositionAdapter Start = new(extent.StartScriptPosition); + internal ScriptPositionAdapter End = new(extent.EndScriptPosition); + + public static implicit operator ScriptExtentAdapter(ScriptExtent extent) => new(extent); + + public static implicit operator ScriptExtentAdapter(Range range) => new(new ScriptExtent( + // Will get shifted to 1-based + new ScriptPositionAdapter(range.Start), + new ScriptPositionAdapter(range.End) + )); + public static implicit operator Range(ScriptExtentAdapter adapter) => new() + { + // Will get shifted to 0-based + Start = adapter.Start, + End = adapter.End + }; + + public static implicit operator ScriptExtent(ScriptExtentAdapter adapter) => adapter; + + public static implicit operator RangeOrPlaceholderRange(ScriptExtentAdapter adapter) => new((Range)adapter) + { + DefaultBehavior = new() { DefaultBehavior = false } + }; + + public IScriptPosition StartScriptPosition => Start; + public IScriptPosition EndScriptPosition => End; + public int EndColumnNumber => End.ColumnNumber; + public int EndLineNumber => End.LineNumber; + public int StartOffset => extent.StartOffset; + public int EndOffset => extent.EndOffset; + public string File => extent.File; + public int StartColumnNumber => extent.StartColumnNumber; + public int StartLineNumber => extent.StartLineNumber; + public string Text => extent.Text; +} diff --git a/src/PowerShellEditorServices/Utility/AstExtensions.cs b/src/PowerShellEditorServices/Utility/AstExtensions.cs new file mode 100644 index 000000000..787ed426f --- /dev/null +++ b/src/PowerShellEditorServices/Utility/AstExtensions.cs @@ -0,0 +1,578 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Management.Automation.Language; +using Microsoft.PowerShell.EditorServices.Services; + +namespace Microsoft.PowerShell.EditorServices.Language; + +// NOTE: A lot of this is reimplementation of https://github.com/PowerShell/PowerShell/blob/2d5d702273060b416aea9601e939ff63bb5679c9/src/System.Management.Automation/engine/parser/Position.cs which is internal and sealed. + +public static class AstExtensions +{ + private const int IS_BEFORE = -1; + private const int IS_AFTER = 1; + private const int IS_EQUAL = 0; + internal static int CompareTo(this IScriptPosition position, IScriptPosition other) + { + if (position.LineNumber < other.LineNumber) + { + return IS_BEFORE; + } + else if (position.LineNumber > other.LineNumber) + { + return IS_AFTER; + } + else //Lines are equal + { + if (position.ColumnNumber < other.ColumnNumber) + { + return IS_BEFORE; + } + else if (position.ColumnNumber > other.ColumnNumber) + { + return IS_AFTER; + } + else //Columns are equal + { + return IS_EQUAL; + } + } + } + + internal static bool IsEqual(this IScriptPosition position, IScriptPosition other) + => position.CompareTo(other) == IS_EQUAL; + + internal static bool IsBefore(this IScriptPosition position, IScriptPosition other) + => position.CompareTo(other) == IS_BEFORE; + + internal static bool IsAfter(this IScriptPosition position, IScriptPosition other) + => position.CompareTo(other) == IS_AFTER; + + internal static bool Contains(this IScriptExtent extent, IScriptPosition position) + => extent.StartScriptPosition.IsEqual(position) + || extent.EndScriptPosition.IsEqual(position) + || (extent.StartScriptPosition.IsBefore(position) && extent.EndScriptPosition.IsAfter(position)); + + internal static bool Contains(this IScriptExtent extent, IScriptExtent other) + => extent.Contains(other.StartScriptPosition) && extent.Contains(other.EndScriptPosition); + + internal static bool StartsBefore(this IScriptExtent extent, IScriptExtent other) + => extent.StartScriptPosition.IsBefore(other.StartScriptPosition); + + internal static bool StartsBefore(this IScriptExtent extent, IScriptPosition other) + => extent.StartScriptPosition.IsBefore(other); + + internal static bool StartsAfter(this IScriptExtent extent, IScriptExtent other) + => extent.StartScriptPosition.IsAfter(other.StartScriptPosition); + + internal static bool StartsAfter(this IScriptExtent extent, IScriptPosition other) + => extent.StartScriptPosition.IsAfter(other); + + internal static bool IsBefore(this IScriptExtent extent, IScriptExtent other) + => !other.Contains(extent) + && !extent.Contains(other) + && extent.StartScriptPosition.IsBefore(other.StartScriptPosition); + + internal static bool IsAfter(this IScriptExtent extent, IScriptExtent other) + => !other.Contains(extent) + && !extent.Contains(other) + && extent.StartScriptPosition.IsAfter(other.StartScriptPosition); + + internal static bool Contains(this Ast ast, Ast other) + => ast.Extent.Contains(other.Extent); + + internal static bool Contains(this Ast ast, IScriptPosition position) + => ast.Extent.Contains(position); + + internal static bool Contains(this Ast ast, IScriptExtent position) + => ast.Extent.Contains(position); + + internal static bool IsBefore(this Ast ast, Ast other) + => ast.Extent.IsBefore(other.Extent); + + internal static bool IsAfter(this Ast ast, Ast other) + => ast.Extent.IsAfter(other.Extent); + + internal static bool StartsBefore(this Ast ast, Ast other) + => ast.Extent.StartsBefore(other.Extent); + + internal static bool StartsBefore(this Ast ast, IScriptExtent other) + => ast.Extent.StartsBefore(other); + + internal static bool StartsBefore(this Ast ast, IScriptPosition other) + => ast.Extent.StartsBefore(other); + + internal static bool StartsAfter(this Ast ast, Ast other) + => ast.Extent.StartsAfter(other.Extent); + + internal static bool StartsAfter(this Ast ast, IScriptExtent other) + => ast.Extent.StartsAfter(other); + + internal static bool StartsAfter(this Ast ast, IScriptPosition other) + => ast.Extent.StartsAfter(other); + + /// + /// Finds the outermost Ast that starts before the target and matches the predicate within the scope. Returns null if none found. Useful for finding definitions of variable/function references + /// + /// The target Ast to search from + /// The predicate to match the Ast against + /// If true, the search will continue until the topmost scope boundary is + /// Searches scriptblocks within the parent at each level. This can be helpful to find "side" scopes but affects performance + internal static Ast? FindStartsBefore(this Ast target, Func predicate, bool crossScopeBoundaries = false, bool searchNestedScriptBlocks = false) + { + Ast? scope = target.GetScopeBoundary(); + do + { + Ast? result = scope?.Find(ast => + ast.StartsBefore(target) + && predicate(ast) + , searchNestedScriptBlocks); + + if (result is not null) + { + return result; + } + + scope = scope?.GetScopeBoundary(); + } while (crossScopeBoundaries && scope is not null); + + return null; + } + + internal static T? FindStartsBefore(this Ast target, Func predicate, bool crossScopeBoundaries = false, bool searchNestedScriptBlocks = false) where T : Ast + => target.FindStartsBefore + ( + ast => ast is T type && predicate(type), crossScopeBoundaries, searchNestedScriptBlocks + ) as T; + + /// + /// Finds all AST items that start before the target and match the predicate within the scope. Items are returned in order from closest to furthest. Returns an empty list if none found. Useful for finding definitions of variable/function references + /// + internal static IEnumerable FindAllStartsBefore(this Ast target, Func predicate, bool crossScopeBoundaries = false) + { + Ast? scope = target.GetScopeBoundary(); + do + { + IEnumerable results = scope?.FindAll(ast => ast.StartsBefore(target) && predicate(ast) + , searchNestedScriptBlocks: false) ?? []; + + foreach (Ast result in results.Reverse()) + { + yield return result; + } + scope = scope?.GetScopeBoundary(); + } while (crossScopeBoundaries && scope is not null); + } + + internal static Ast? FindStartsAfter(this Ast target, Func predicate, bool searchNestedScriptBlocks = false) + => target.Parent.Find(ast => ast.StartsAfter(target) && predicate(ast), searchNestedScriptBlocks); + + internal static IEnumerable FindAllStartsAfter(this Ast target, Func predicate, bool searchNestedScriptBlocks = false) + => target.Parent.FindAllStartsAfter(ast => ast.StartsAfter(target) && predicate(ast), searchNestedScriptBlocks); + + /// + /// Finds the most specific Ast at the given script position, or returns null if none found.
+ /// For example, if the position is on a variable expression within a function definition, + /// the variable will be returned even if the function definition is found first, unless variable definitions are not in the list of allowed types + ///
+ internal static Ast? FindClosest(this Ast ast, IScriptPosition position, Type[]? allowedTypes) + { + // Short circuit quickly if the position is not in the provided ast, no need to traverse if not + if (!ast.Contains(position)) { return null; } + + Ast? mostSpecificAst = null; + Ast? currentAst = ast; + do + { + currentAst = currentAst.Find(thisAst => + { + // Always starts with the current item, we can skip it + if (thisAst == mostSpecificAst) { return false; } + + if (allowedTypes is not null && !allowedTypes.Contains(thisAst.GetType())) { return false; } + + if (thisAst.Contains(position)) + { + mostSpecificAst = thisAst; + return true; //Restart the search within the more specific AST + } + + return false; + }, true); + } while (currentAst is not null); + + return mostSpecificAst; + } + + public static bool TryFindFunctionDefinition(this Ast ast, CommandAst command, out FunctionDefinitionAst? functionDefinition) + { + functionDefinition = ast.FindFunctionDefinition(command); + return functionDefinition is not null; + } + + public static FunctionDefinitionAst? FindFunctionDefinition(this Ast ast, CommandAst command) + { + if (!ast.Contains(command)) { return null; } // Short circuit if the command is not in the ast + + string? name = command.GetCommandName()?.ToLower(); + if (name is null) { return null; } + + // NOTE: There should only be one match most of the time, the only other cases is when a function is defined multiple times (bad practice). If there are multiple definitions, the candidate "closest" to the command, which would be the last one found, is the appropriate one + return command.FindAllStartsBefore(ast => + { + if (ast is not FunctionDefinitionAst funcDef) { return false; } + + if (funcDef.Name.ToLower() != name) { return false; } + + // If the function is recursive (calls itself), its parent is a match unless a more specific in-scope function definition comes next (this is a "bad practice" edge case) + // TODO: Consider a simple "contains" match + if (command.HasParent(funcDef)) { return true; } + + return command.HasParent(funcDef.Parent); // The command is in the same scope as the function definition + }, true).FirstOrDefault() as FunctionDefinitionAst; + } + + public static string GetUnqualifiedName(this VariableExpressionAst ast) + => ast.VariablePath.IsUnqualified + ? ast.VariablePath.ToString() + : ast.VariablePath.ToString().Split(':').Last(); + + public static Ast GetHighestParent(this Ast ast) + => ast.Parent is null ? ast : ast.Parent.GetHighestParent(); + + public static Ast GetHighestParent(this Ast ast, params Type[] type) + => FindParents(ast, type).LastOrDefault() ?? ast; + + /// + /// Gets the closest parent that matches the specified type or null if none found. + /// + public static T? FindParent(this Ast ast) where T : Ast + => ast.FindParent(typeof(T)) as T; + + /// + /// Gets the closest parent that matches the specified type or null if none found. + /// + public static Ast? FindParent(this Ast ast, params Type[] types) + => FindParents(ast, types).FirstOrDefault(); + + /// + /// Returns an enumerable of parents, in order of closest to furthest, that match the specified types. + /// + public static IEnumerable FindParents(this Ast ast, params Type[] types) + { + Ast parent = ast.Parent; + while (parent is not null) + { + if (types.Contains(parent.GetType())) + { + yield return parent; + } + parent = parent.Parent; + } + } + + /// + /// Gets the closest scope boundary of the ast. + /// + public static Ast? GetScopeBoundary(this Ast ast) + => ast.FindParent + ( + typeof(ScriptBlockAst), + typeof(FunctionDefinitionAst), + typeof(ForEachStatementAst), + typeof(ForStatementAst) + ); + + public static VariableExpressionAst? FindClosestParameterInFunction(this Ast target, string functionName, string parameterName) + { + Ast? scope = target.GetScopeBoundary(); + while (scope is not null) + { + FunctionDefinitionAst? funcDef = scope.FindAll + ( + ast => ast is FunctionDefinitionAst funcDef + && funcDef.StartsBefore(target) + && funcDef.Name.ToLower() == functionName.ToLower() + && (funcDef.Parameters ?? funcDef.Body.ParamBlock.Parameters) + .SingleOrDefault( + param => param.Name.GetUnqualifiedName().ToLower() == parameterName.ToLower() + ) is not null + , false + ).LastOrDefault() as FunctionDefinitionAst; + + if (funcDef is not null) + { + return (funcDef.Parameters ?? funcDef.Body.ParamBlock.Parameters) + .SingleOrDefault + ( + param => param.Name.GetUnqualifiedName().ToLower() == parameterName.ToLower() + )?.Name; //Should not be null at this point + } + + scope = scope.GetScopeBoundary(); + } + return null; + } + + /// + /// Returns true if the Expression is part of a variable assignment + /// + /// TODO: Potentially check the name matches + public static bool IsVariableAssignment(this VariableExpressionAst var) + => var.Parent is AssignmentStatementAst or ParameterAst; + + public static bool IsOperatorAssignment(this VariableExpressionAst var) + { + if (var.Parent is AssignmentStatementAst assignast) + { + return assignast.Operator != TokenKind.Equals; + } + else + { + return true; + } + } + + /// + /// Returns true if the Ast is a potential variable reference + /// + public static bool IsPotentialVariableReference(this Ast ast) + => ast is VariableExpressionAst or CommandParameterAst or StringConstantExpressionAst; + + /// + /// Determines if a variable assignment is a scoped variable assignment, meaning that it can be considered the top assignment within the current scope. This does not include Variable assignments within the body of a scope which may or may not be the top only if one of these do not exist above it in the same scope. + /// + // TODO: Naming is hard, I feel like this could have a better name + public static bool IsScopedVariableAssignment(this VariableExpressionAst var) + { + // foreach ($x in $y) { } + if (var.Parent is ForEachStatementAst forEachAst && forEachAst.Variable == var) + { + return true; + } + + // for ($x = 1; $x -lt 10; $x++) { } + if (var.Parent is ForStatementAst forAst && forAst.Initializer is AssignmentStatementAst assignAst && assignAst.Left == var) + { + return true; + } + + // param($x = 1) + if (var.Parent is ParameterAst paramAst && paramAst.Name == var) + { + return true; + } + + return false; + } + + /// + /// For a given string constant, determine if it is a splat, and there is at least one splat reference. If so, return the location of the splat assignment. + /// + public static VariableExpressionAst? FindSplatParameterReference(this StringConstantExpressionAst stringConstantAst) + { + if (stringConstantAst.Parent is not HashtableAst hashtableAst) { return null; } + if (hashtableAst.Parent is not CommandExpressionAst commandAst) { return null; } + if (commandAst.Parent is not AssignmentStatementAst assignmentAst) { return null; } + if (assignmentAst.Left is not VariableExpressionAst leftAssignVarAst) { return null; } + return assignmentAst.FindStartsAfter(ast => + ast is VariableExpressionAst var + && var.Splatted + && var.GetUnqualifiedName().ToLower() == leftAssignVarAst.GetUnqualifiedName().ToLower() + , true) as VariableExpressionAst; + } + + /// + /// For a given splat reference, find its source splat assignment. If the reference is not a splat, an exception will be thrown. If no assignment is found, null will be returned. + /// TODO: Support incremental splat references e.g. $x = @{}, $x.Method = 'GET' + /// + public static StringConstantExpressionAst? FindSplatAssignmentReference(this VariableExpressionAst varAst) + { + if (!varAst.Splatted) { throw new InvalidOperationException("The provided variable reference is not a splat and cannot be used with FindSplatVariableAssignment"); } + + return varAst.FindStartsBefore(ast => + ast is StringConstantExpressionAst stringAst + && stringAst.Value == varAst.GetUnqualifiedName() + && stringAst.FindSplatParameterReference() == varAst, + crossScopeBoundaries: true) as StringConstantExpressionAst; + } + + /// + /// Returns the function a parameter is defined in. Returns null if it is an anonymous function such as a scriptblock + /// + public static bool TryGetFunction(this ParameterAst ast, out FunctionDefinitionAst? function) + { + if (ast.Parent is FunctionDefinitionAst funcDef) { function = funcDef; return true; } + if (ast.Parent.Parent is FunctionDefinitionAst paramBlockFuncDef) { function = paramBlockFuncDef; return true; } + function = null; + return false; + } + + /// + /// Finds the highest variable expression within a variable assignment within the current scope of the provided variable reference. Returns the original object if it is the highest assignment or null if no assignment was found. It is assumed the reference is part of a larger Ast. + /// + /// A variable reference that is either a VariableExpression or a StringConstantExpression (splatting reference) + public static Ast? GetTopVariableAssignment(this Ast reference) + { + if (!reference.IsPotentialVariableReference()) + { + throw new NotSupportedException("The provided reference is not a variable reference type."); + } + + // Splats are special, we will treat them as a top variable assignment and search both above for a parameter assignment and below for a splat reference, but we don't require a command definition within the same scope for the splat. + if (reference is StringConstantExpressionAst stringConstant) + { + VariableExpressionAst? splat = stringConstant.FindSplatParameterReference(); + if (splat is null) { return null; } + // Find the function associated with the splat parameter reference + string? commandName = (splat.Parent as CommandAst)?.GetCommandName().ToLower(); + if (commandName is null) { return null; } + VariableExpressionAst? splatParamReference = splat.FindClosestParameterInFunction(commandName, stringConstant.Value); + + if (splatParamReference is not null) + { + return splatParamReference; + } + + } + + // If nothing found, search parent scopes for a variable assignment until we hit the top of the document + string name = reference switch + { + VariableExpressionAst varExpression => varExpression.GetUnqualifiedName(), + CommandParameterAst param => param.ParameterName, + StringConstantExpressionAst stringConstantExpressionAst => stringConstantExpressionAst.Value, + _ => throw new NotSupportedException("The provided reference is not a variable reference type.") + }; + VariableExpressionAst? varAssignment = null; + Ast? scope = reference; + + while (scope is not null) + { + // Check if the reference is a parameter in the current scope. This saves us from having to do a nested search later on. + // TODO: Can probably be combined with below + IEnumerable? parameters = scope switch + { + // Covers both function test() { param($x) } and function param($x) + FunctionDefinitionAst f => f.Body?.ParamBlock?.Parameters ?? f.Parameters, + ScriptBlockAst s => s.ParamBlock?.Parameters, + _ => null + }; + ParameterAst? matchParam = parameters?.SingleOrDefault( + param => param.Name.GetUnqualifiedName().ToLower() == name.ToLower() + ); + if (matchParam is not null) + { + return matchParam.Name; + } + + // Find any top level function definitions in the currentscope that might match the parameter + // TODO: This could be less complicated + if (reference is CommandParameterAst parameterAst) + { + string? commandName = (parameterAst.Parent as CommandAst)?.GetCommandName()?.ToLower(); + + if (commandName is not null) + { + VariableExpressionAst? paramDefinition = parameterAst.FindClosestParameterInFunction(commandName, parameterAst.ParameterName); + if (paramDefinition is not null) + { + return paramDefinition; + } + } + } + + // Will find the outermost assignment within the scope that matches the reference. + varAssignment = reference switch + { + VariableExpressionAst => scope.FindStartsBefore(var => + var.GetUnqualifiedName().ToLower() == name.ToLower() + && ( + (var.IsVariableAssignment() && !var.IsOperatorAssignment()) + || var.IsScopedVariableAssignment() + ) + , crossScopeBoundaries: false, searchNestedScriptBlocks: false + ), + + CommandParameterAst param => scope.FindStartsBefore(var => + var.GetUnqualifiedName().ToLower() == name.ToLower() + && var.Parent is ParameterAst paramAst + && paramAst.TryGetFunction(out FunctionDefinitionAst? foundFunction) + && foundFunction?.Name.ToLower() + == (param.Parent as CommandAst)?.GetCommandName()?.ToLower() + && foundFunction?.Parent?.Parent == scope + ), + + + _ => null + }; + + if (varAssignment is not null) + { + return varAssignment; + } + + if (reference is VariableExpressionAst varAst + && + ( + varAst.IsScopedVariableAssignment() + || (varAst.IsVariableAssignment() && !varAst.IsOperatorAssignment()) + ) + ) + { + // The current variable reference is the top level assignment because we didn't find any other assignments above it + return reference; + } + + // Get the next highest scope + scope = scope.GetScopeBoundary(); + } + + // If we make it this far we didn't find any references. + + // An operator assignment can be a definition only as long as there are no assignments above it in all scopes. + if (reference is VariableExpressionAst variableAst + && variableAst.IsVariableAssignment() + && variableAst.IsOperatorAssignment()) + { + return reference; + } + + return null; + } + + public static bool HasParent(this Ast ast, Ast parent) + { + Ast? current = ast; + while (current is not null) + { + if (current == parent) + { + return true; + } + current = current.Parent; + } + return false; + } + + + /// + /// Return an extent that only contains the position of the name of the function, for Client highlighting purposes. + /// + internal static ScriptExtentAdapter GetFunctionNameExtent(this FunctionDefinitionAst ast) + { + string name = ast.Name; + // FIXME: Gather dynamically from the AST and include backticks and whatnot that might be present + int funcLength = "function ".Length; + ScriptExtentAdapter funcExtent = new(ast.Extent); + funcExtent.Start = funcExtent.Start.Delta(0, funcLength); + funcExtent.End = funcExtent.Start.Delta(0, name.Length); + + return funcExtent; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCallWIthinStringExpression.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCallWIthinStringExpression.ps1 new file mode 100644 index 000000000..944e6d5df --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCallWIthinStringExpression.ps1 @@ -0,0 +1,3 @@ +function foo { + write-host "This will do recursion ... $(foo)" +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCallWIthinStringExpressionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCallWIthinStringExpressionRenamed.ps1 new file mode 100644 index 000000000..44d843c5a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCallWIthinStringExpressionRenamed.ps1 @@ -0,0 +1,3 @@ +function Renamed { + write-host "This will do recursion ... $(Renamed)" +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCmdlet.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCmdlet.ps1 new file mode 100644 index 000000000..1614b63a9 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCmdlet.ps1 @@ -0,0 +1,21 @@ +function Testing-Foo { + [CmdletBinding(SupportsShouldProcess)] + param ( + $Text, + $Param + ) + + begin { + if ($PSCmdlet.ShouldProcess("Target", "Operation")) { + Testing-Foo -Text "Param" -Param [1,2,3] + } + } + + process { + Testing-Foo -Text "Param" -Param [1,2,3] + } + + end { + Testing-Foo -Text "Param" -Param [1,2,3] + } +} \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCmdletRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCmdletRenamed.ps1 new file mode 100644 index 000000000..ee14a9fb7 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionCmdletRenamed.ps1 @@ -0,0 +1,21 @@ +function Renamed { + [CmdletBinding(SupportsShouldProcess)] + param ( + $Text, + $Param + ) + + begin { + if ($PSCmdlet.ShouldProcess("Target", "Operation")) { + Renamed -Text "Param" -Param [1,2,3] + } + } + + process { + Renamed -Text "Param" -Param [1,2,3] + } + + end { + Renamed -Text "Param" -Param [1,2,3] + } +} \ No newline at end of file diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeach.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeach.ps1 new file mode 100644 index 000000000..2454effe6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeach.ps1 @@ -0,0 +1,17 @@ +$x = 1..10 + +function testing_files { + param ( + $x + ) + write-host "Printing $x" +} + +foreach ($number in $x) { + testing_files $number + + function testing_files { + write-host "------------------" + } +} +testing_files "99" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachObject.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachObject.ps1 new file mode 100644 index 000000000..107c50223 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachObject.ps1 @@ -0,0 +1,17 @@ +$x = 1..10 + +function testing_files { + param ( + $x + ) + write-host "Printing $x" +} + +$x | ForEach-Object { + testing_files $_ + + function testing_files { + write-host "------------------" + } +} +testing_files "99" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachObjectRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachObjectRenamed.ps1 new file mode 100644 index 000000000..80073c640 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachObjectRenamed.ps1 @@ -0,0 +1,17 @@ +$x = 1..10 + +function Renamed { + param ( + $x + ) + write-host "Printing $x" +} + +$x | ForEach-Object { + Renamed $_ + + function testing_files { + write-host "------------------" + } +} +Renamed "99" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachRenamed.ps1 new file mode 100644 index 000000000..cd0dcb424 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionForeachRenamed.ps1 @@ -0,0 +1,17 @@ +$x = 1..10 + +function Renamed { + param ( + $x + ) + write-host "Printing $x" +} + +foreach ($number in $x) { + Renamed $number + + function testing_files { + write-host "------------------" + } +} +Renamed "99" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionInnerIsNested.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionInnerIsNested.ps1 new file mode 100644 index 000000000..fe67c234d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionInnerIsNested.ps1 @@ -0,0 +1,13 @@ +function outer { + function foo { + Write-Host 'Inside nested foo' + } + foo +} + +function foo { + Write-Host 'Inside top-level foo' +} + +outer +foo diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionInnerIsNestedRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionInnerIsNestedRenamed.ps1 new file mode 100644 index 000000000..8e698a3f1 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionInnerIsNestedRenamed.ps1 @@ -0,0 +1,13 @@ +function outer { + function Renamed { + Write-Host 'Inside nested foo' + } + Renamed +} + +function foo { + Write-Host 'Inside top-level foo' +} + +outer +foo diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionLoop.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionLoop.ps1 new file mode 100644 index 000000000..6973855a7 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionLoop.ps1 @@ -0,0 +1,6 @@ +for ($i = 0; $i -lt 2; $i++) { + function FunctionInLoop { + Write-Host "Function inside a loop" + } + FunctionInLoop +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionLoopRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionLoopRenamed.ps1 new file mode 100644 index 000000000..6e7632c46 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionLoopRenamed.ps1 @@ -0,0 +1,6 @@ +for ($i = 0; $i -lt 2; $i++) { + function Renamed { + Write-Host "Function inside a loop" + } + Renamed +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionMultipleOccurrences.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionMultipleOccurrences.ps1 new file mode 100644 index 000000000..76aeced88 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionMultipleOccurrences.ps1 @@ -0,0 +1,6 @@ +function foo { + Write-Host "Inside foo" +} + +foo +foo diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionMultipleOccurrencesRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionMultipleOccurrencesRenamed.ps1 new file mode 100644 index 000000000..cb78322be --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionMultipleOccurrencesRenamed.ps1 @@ -0,0 +1,6 @@ +function Renamed { + Write-Host "Inside foo" +} + +Renamed +Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionNestedRedefinition.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionNestedRedefinition.ps1 new file mode 100644 index 000000000..2454effe6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionNestedRedefinition.ps1 @@ -0,0 +1,17 @@ +$x = 1..10 + +function testing_files { + param ( + $x + ) + write-host "Printing $x" +} + +foreach ($number in $x) { + testing_files $number + + function testing_files { + write-host "------------------" + } +} +testing_files "99" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionNestedRedefinitionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionNestedRedefinitionRenamed.ps1 new file mode 100644 index 000000000..304a97c87 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionNestedRedefinitionRenamed.ps1 @@ -0,0 +1,17 @@ +$x = 1..10 + +function testing_files { + param ( + $x + ) + write-host "Printing $x" +} + +foreach ($number in $x) { + testing_files $number + + function Renamed { + write-host "------------------" + } +} +testing_files "99" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionOuterHasNestedFunction.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionOuterHasNestedFunction.ps1 new file mode 100644 index 000000000..966fdccb7 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionOuterHasNestedFunction.ps1 @@ -0,0 +1,7 @@ +function OuterFunction { + function NewInnerFunction { + Write-Host "This is the inner function" + } + NewInnerFunction +} +OuterFunction diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionOuterHasNestedFunctionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionOuterHasNestedFunctionRenamed.ps1 new file mode 100644 index 000000000..98f89d16f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionOuterHasNestedFunctionRenamed.ps1 @@ -0,0 +1,7 @@ +function Renamed { + function NewInnerFunction { + Write-Host "This is the inner function" + } + NewInnerFunction +} +Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSameName.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSameName.ps1 new file mode 100644 index 000000000..9849ee15a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSameName.ps1 @@ -0,0 +1,8 @@ +function SameNameFunction { + Write-Host 'This is the outer function' + function SameNameFunction { + Write-Host 'This is the inner function' + } + SameNameFunction +} +SameNameFunction diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSameNameRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSameNameRenamed.ps1 new file mode 100644 index 000000000..e32595a64 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSameNameRenamed.ps1 @@ -0,0 +1,8 @@ +function SameNameFunction { + Write-Host 'This is the outer function' + function Renamed { + Write-Host 'This is the inner function' + } + Renamed +} +SameNameFunction diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionScriptblock.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionScriptblock.ps1 new file mode 100644 index 000000000..de0fd1737 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionScriptblock.ps1 @@ -0,0 +1,7 @@ +$scriptBlock = { + function FunctionInScriptBlock { + Write-Host "Inside a script block" + } + FunctionInScriptBlock +} +& $scriptBlock diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionScriptblockRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionScriptblockRenamed.ps1 new file mode 100644 index 000000000..727ca6f58 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionScriptblockRenamed.ps1 @@ -0,0 +1,7 @@ +$scriptBlock = { + function Renamed { + Write-Host "Inside a script block" + } + Renamed +} +& $scriptBlock diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSimple.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSimple.ps1 new file mode 100644 index 000000000..e13582550 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSimple.ps1 @@ -0,0 +1,5 @@ +function foo { + Write-Host "Inside foo" +} + +foo diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSimpleRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSimpleRenamed.ps1 new file mode 100644 index 000000000..26ffe37f9 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionSimpleRenamed.ps1 @@ -0,0 +1,5 @@ +function Renamed { + Write-Host "Inside foo" +} + +Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInnerFunction.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInnerFunction.ps1 new file mode 100644 index 000000000..1e77268e4 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInnerFunction.ps1 @@ -0,0 +1,7 @@ +function OuterFunction { + function NewInnerFunction { + Write-Host 'This is the inner function' + } + NewInnerFunction +} +OuterFunction diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInnerFunctionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInnerFunctionRenamed.ps1 new file mode 100644 index 000000000..177d5940b --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInnerFunctionRenamed.ps1 @@ -0,0 +1,7 @@ +function OuterFunction { + function Renamed { + Write-Host 'This is the inner function' + } + Renamed +} +OuterFunction diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInternalCalls.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInternalCalls.ps1 new file mode 100644 index 000000000..eae1f3a19 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInternalCalls.ps1 @@ -0,0 +1,5 @@ +function FunctionWithInternalCalls { + Write-Host "This function calls itself" + FunctionWithInternalCalls +} +FunctionWithInternalCalls diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInternalCallsRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInternalCallsRenamed.ps1 new file mode 100644 index 000000000..4926dffb9 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/FunctionWithInternalCallsRenamed.ps1 @@ -0,0 +1,5 @@ +function Renamed { + Write-Host "This function calls itself" + Renamed +} +Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/_RefactorFunctionTestCases.cs b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/_RefactorFunctionTestCases.cs new file mode 100644 index 000000000..d57a5aede --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Functions/_RefactorFunctionTestCases.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PowerShellEditorServices.Test.Shared.Refactoring; + +public class RefactorFunctionTestCases +{ + /// + /// Defines where functions should be renamed. These numbers are 1-based. + /// + public static RenameTestTarget[] TestCases = + [ + new("FunctionSimple.ps1", Line: 1, Column: 11 ), + new("FunctionSimple.ps1", Line: 1, Column: 1, NoResult: true ), + new("FunctionSimple.ps1", Line: 2, Column: 4, NoResult: true ), + new("FunctionSimple.ps1", Line: 1, Column: 11, NewName: "Bad Name", ShouldThrow: true ), + new("FunctionCallWIthinStringExpression.ps1", Line: 1, Column: 10 ), + new("FunctionCmdlet.ps1", Line: 1, Column: 10 ), + new("FunctionForeach.ps1", Line: 11, Column: 5 ), + new("FunctionForeachObject.ps1", Line: 11, Column: 5 ), + new("FunctionInnerIsNested.ps1", Line: 5, Column: 5 ), + new("FunctionLoop.ps1", Line: 5, Column: 5 ), + new("FunctionMultipleOccurrences.ps1", Line: 5, Column: 3 ), + new("FunctionNestedRedefinition.ps1", Line: 13, Column: 15 ), + new("FunctionOuterHasNestedFunction.ps1", Line: 1, Column: 10 ), + new("FunctionSameName.ps1", Line: 3, Column: 14 ), + new("FunctionScriptblock.ps1", Line: 5, Column: 5 ), + new("FunctionWithInnerFunction.ps1", Line: 5, Column: 5 ), + new("FunctionWithInternalCalls.ps1", Line: 3, Column: 6 ), + ]; +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/RenameTestTarget.cs b/test/PowerShellEditorServices.Test.Shared/Refactoring/RenameTestTarget.cs new file mode 100644 index 000000000..25c0e3d7d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/RenameTestTarget.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +namespace PowerShellEditorServices.Test.Shared.Refactoring; + +/// +/// Describes a test case for renaming a file +/// +public class RenameTestTarget +{ + /// + /// The test case file name e.g. testScript.ps1 + /// + public string FileName { get; set; } = "UNKNOWN"; + /// + /// The line where the cursor should be positioned for the rename + /// + public int Line { get; set; } = -1; + /// + /// The column/character indent where ther cursor should be positioned for the rename + /// + public int Column { get; set; } = -1; + /// + /// What the target symbol represented by the line and column should be renamed to. Defaults to "Renamed" if not specified + /// + public string NewName = "Renamed"; + + public bool ShouldFail; + public bool ShouldThrow; + + /// The test case file name e.g. testScript.ps1 + /// The line where the cursor should be positioned for the rename + /// The column/character indent where ther cursor should be positioned for the rename + /// What the target symbol represented by the line and column should be renamed to. Defaults to "Renamed" if not specified + /// This test case should return null (cannot be renamed) + /// This test case should throw a HandlerErrorException meaning user needs to be alerted in a custom way + public RenameTestTarget(string FileName, int Line, int Column, string NewName = "Renamed", bool NoResult = false, bool ShouldThrow = false) + { + this.FileName = FileName; + this.Line = Line; + this.Column = Column; + this.NewName = NewName; + this.ShouldFail = NoResult; + this.ShouldThrow = ShouldThrow; + } + public RenameTestTarget() { } + + public override string ToString() => $"{FileName.Substring(0, FileName.Length - 4)} {Line}:{Column} N:{NewName} F:{ShouldFail} T:{ShouldThrow}"; +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/RefactorUtilitiesData.cs b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/RefactorUtilitiesData.cs new file mode 100644 index 000000000..a2452620e --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/RefactorUtilitiesData.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace PowerShellEditorServices.Test.Shared.Refactoring +{ + internal static class RenameUtilitiesData + { + public static readonly RenameTestTarget GetVariableExpressionAst = new() + { + Column = 11, + Line = 15, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + public static readonly RenameTestTarget GetVariableExpressionStartAst = new() + { + Column = 1, + Line = 15, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + public static readonly RenameTestTarget GetVariableWithinParameterAst = new() + { + Column = 21, + Line = 3, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + public static readonly RenameTestTarget GetHashTableKey = new() + { + Column = 9, + Line = 16, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + public static readonly RenameTestTarget GetVariableWithinCommandAst = new() + { + Column = 29, + Line = 6, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + public static readonly RenameTestTarget GetCommandParameterAst = new() + { + Column = 12, + Line = 21, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + public static readonly RenameTestTarget GetFunctionDefinitionAst = new() + { + Column = 12, + Line = 1, + NewName = "Renamed", + FileName = "TestDetection.ps1" + }; + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDetection.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDetection.ps1 new file mode 100644 index 000000000..d12a8652f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDetection.ps1 @@ -0,0 +1,21 @@ +function New-User { + param ( + [string]$Username, + [string]$password + ) + write-host $username + $password + + $splat= @{ + Username = "JohnDeer" + Password = "SomePassword" + } + New-User @splat +} + +$UserDetailsSplat= @{ + Username = "JohnDoe" + Password = "SomePassword" +} +New-User @UserDetailsSplat + +New-User -Username "JohnDoe" -Password "SomePassword" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDetectionUnderFunctionDef.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDetectionUnderFunctionDef.ps1 new file mode 100644 index 000000000..6ef6e2652 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDetectionUnderFunctionDef.ps1 @@ -0,0 +1,15 @@ +function Write-Item($itemCount) { + $i = 1 + + while ($i -le $itemCount) { + $str = "Output $i" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $i = $i + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $DelayMilliseconds + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDotSourcingFalse.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDotSourcingFalse.ps1 new file mode 100644 index 000000000..d12a8652f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDotSourcingFalse.ps1 @@ -0,0 +1,21 @@ +function New-User { + param ( + [string]$Username, + [string]$password + ) + write-host $username + $password + + $splat= @{ + Username = "JohnDeer" + Password = "SomePassword" + } + New-User @splat +} + +$UserDetailsSplat= @{ + Username = "JohnDoe" + Password = "SomePassword" +} +New-User @UserDetailsSplat + +New-User -Username "JohnDoe" -Password "SomePassword" diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDotSourcingTrue.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDotSourcingTrue.ps1 new file mode 100644 index 000000000..b1cd25e65 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Utilities/TestDotSourcingTrue.ps1 @@ -0,0 +1,7 @@ +$sb = { $var = 30 } +$shouldDotSource = Get-Random -Minimum 0 -Maximum 2 +if ($shouldDotSource) { + . $sb +} else { + & $sb +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/ParameterUndefinedFunction.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/ParameterUndefinedFunction.ps1 new file mode 100644 index 000000000..a07d73e79 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/ParameterUndefinedFunction.ps1 @@ -0,0 +1 @@ +FunctionThatIsNotDefinedInThisScope -TestParameter 'test' diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameter.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameter.ps1 new file mode 100644 index 000000000..49ca3a191 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameter.ps1 @@ -0,0 +1,10 @@ +function Get-foo { + param ( + [string]$string, + [int]$pos + ) + + return $string[$pos] + +} +Get-foo -string 'Hello' -pos -1 diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterRenamed.ps1 new file mode 100644 index 000000000..a3cd4fed5 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterRenamed.ps1 @@ -0,0 +1,10 @@ +function Get-foo { + param ( + [string]$Renamed, + [int]$pos + ) + + return $Renamed[$pos] + +} +Get-foo -Renamed 'Hello' -pos -1 diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterSplatted.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterSplatted.ps1 new file mode 100644 index 000000000..79dc6e7ee --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterSplatted.ps1 @@ -0,0 +1,21 @@ +function New-User { + param ( + [string]$Username, + [string]$password + ) + Write-Host $username + $password + + $splat = @{ + Username = 'JohnDeer' + Password = 'SomePassword' + } + New-User @splat +} + +$UserDetailsSplat = @{ + Username = 'JohnDoe' + Password = 'SomePassword' +} +New-User @UserDetailsSplat + +New-User -Username 'JohnDoe' -Password 'SomePassword' diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterSplattedRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterSplattedRenamed.ps1 new file mode 100644 index 000000000..176f51023 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableCommandParameterSplattedRenamed.ps1 @@ -0,0 +1,21 @@ +function New-User { + param ( + [string]$Renamed, + [string]$password + ) + Write-Host $Renamed + $password + + $splat = @{ + Renamed = 'JohnDeer' + Password = 'SomePassword' + } + New-User @splat +} + +$UserDetailsSplat = @{ + Renamed = 'JohnDoe' + Password = 'SomePassword' +} +New-User @UserDetailsSplat + +New-User -Renamed 'JohnDoe' -Password 'SomePassword' diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDefinedInParamBlock.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDefinedInParamBlock.ps1 new file mode 100644 index 000000000..737974e68 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDefinedInParamBlock.ps1 @@ -0,0 +1,12 @@ +$x = 1 +function test { + begin { + $x = 5 + } + process { + $x + } + end { + $x + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDefinedInParamBlockRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDefinedInParamBlockRenamed.ps1 new file mode 100644 index 000000000..abc8c54c8 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDefinedInParamBlockRenamed.ps1 @@ -0,0 +1,12 @@ +$x = 1 +function test { + begin { + $Renamed = 5 + } + process { + $Renamed + } + end { + $Renamed + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDotNotationFromInnerFunction.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDotNotationFromInnerFunction.ps1 new file mode 100644 index 000000000..126a2745d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDotNotationFromInnerFunction.ps1 @@ -0,0 +1,21 @@ +$NeededTools = @{ + OpenSsl = 'openssl for macOS' + PowerShellGet = 'PowerShellGet latest' + InvokeBuild = 'InvokeBuild latest' +} + +function getMissingTools () { + $missingTools = @() + + if (needsOpenSsl) { + $missingTools += $NeededTools.OpenSsl + } + if (needsPowerShellGet) { + $missingTools += $NeededTools.PowerShellGet + } + if (needsInvokeBuild) { + $missingTools += $NeededTools.InvokeBuild + } + + return $missingTools +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDotNotationFromInnerFunctionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDotNotationFromInnerFunctionRenamed.ps1 new file mode 100644 index 000000000..d8c478ec6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableDotNotationFromInnerFunctionRenamed.ps1 @@ -0,0 +1,21 @@ +$Renamed = @{ + OpenSsl = 'openssl for macOS' + PowerShellGet = 'PowerShellGet latest' + InvokeBuild = 'InvokeBuild latest' +} + +function getMissingTools () { + $missingTools = @() + + if (needsOpenSsl) { + $missingTools += $Renamed.OpenSsl + } + if (needsPowerShellGet) { + $missingTools += $Renamed.PowerShellGet + } + if (needsInvokeBuild) { + $missingTools += $Renamed.InvokeBuild + } + + return $missingTools +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForeachDuplicateAssignment.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForeachDuplicateAssignment.ps1 new file mode 100644 index 000000000..ba03d8eb3 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForeachDuplicateAssignment.ps1 @@ -0,0 +1,14 @@ + +$a = 1..5 +$b = 6..10 +function test { + process { + foreach ($testvar in $a) { + $testvar + } + + foreach ($testvar in $b) { + $testvar + } + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForeachDuplicateAssignmentRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForeachDuplicateAssignmentRenamed.ps1 new file mode 100644 index 000000000..4467e88cb --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForeachDuplicateAssignmentRenamed.ps1 @@ -0,0 +1,14 @@ + +$a = 1..5 +$b = 6..10 +function test { + process { + foreach ($Renamed in $a) { + $Renamed + } + + foreach ($testvar in $b) { + $testvar + } + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForloopDuplicateAssignment.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForloopDuplicateAssignment.ps1 new file mode 100644 index 000000000..66844c960 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForloopDuplicateAssignment.ps1 @@ -0,0 +1,17 @@ + +$a = 1..5 +$b = 6..10 +function test { + process { + + $i=10 + + for ($i = 0; $i -lt $a.Count; $i++) { + $i + } + + for ($i = 0; $i -lt $a.Count; $i++) { + $i + } + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForloopDuplicateAssignmentRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForloopDuplicateAssignmentRenamed.ps1 new file mode 100644 index 000000000..ff61eb4f6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInForloopDuplicateAssignmentRenamed.ps1 @@ -0,0 +1,17 @@ + +$a = 1..5 +$b = 6..10 +function test { + process { + + $i=10 + + for ($Renamed = 0; $Renamed -lt $a.Count; $Renamed++) { + $Renamed + } + + for ($i = 0; $i -lt $a.Count; $i++) { + $i + } + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInLoop.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInLoop.ps1 new file mode 100644 index 000000000..bf5af6be8 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInLoop.ps1 @@ -0,0 +1,4 @@ +$var = 10 +for ($i = 0; $i -lt $var; $i++) { + Write-Output "Count: $i" +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInLoopRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInLoopRenamed.ps1 new file mode 100644 index 000000000..cfc98f0d5 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInLoopRenamed.ps1 @@ -0,0 +1,4 @@ +$Renamed = 10 +for ($i = 0; $i -lt $Renamed; $i++) { + Write-Output "Count: $i" +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInParam.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInParam.ps1 new file mode 100644 index 000000000..436c6fbc8 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInParam.ps1 @@ -0,0 +1,28 @@ +param([int]$Count = 50, [int]$DelayMilliseconds = 200) + +function Write-Item($itemCount) { + $i = 1 + + while ($i -le $itemCount) { + $str = "Output $i" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $i = $i + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $DelayMilliseconds + } +} + +# Do-Work will be underlined in green if you haven't disable script analysis. +# Hover over the function name below to see the PSScriptAnalyzer warning that "Do-Work" +# doesn't use an approved verb. +function Do-Work($workCount) { + Write-Output 'Doing work...' + Write-Item $workcount + Write-Host 'Done!' +} + +Do-Work $Count diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInParamRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInParamRenamed.ps1 new file mode 100644 index 000000000..8127b6ced --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInParamRenamed.ps1 @@ -0,0 +1,28 @@ +param([int]$Count = 50, [int]$DelayMilliseconds = 200) + +function Write-Item($itemCount) { + $i = 1 + + while ($i -le $itemCount) { + $str = "Output $i" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $i = $i + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $DelayMilliseconds + } +} + +# Do-Work will be underlined in green if you haven't disable script analysis. +# Hover over the function name below to see the PSScriptAnalyzer warning that "Do-Work" +# doesn't use an approved verb. +function Do-Work($Renamed) { + Write-Output 'Doing work...' + Write-Item $Renamed + Write-Host 'Done!' +} + +Do-Work $Count diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInPipeline.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInPipeline.ps1 new file mode 100644 index 000000000..220a984b7 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInPipeline.ps1 @@ -0,0 +1,4 @@ +$oldVarName = 5 +1..10 | +Where-Object { $_ -le $oldVarName } | +Write-Output diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInPipelineRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInPipelineRenamed.ps1 new file mode 100644 index 000000000..dea826fbf --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInPipelineRenamed.ps1 @@ -0,0 +1,4 @@ +$Renamed = 5 +1..10 | +Where-Object { $_ -le $Renamed } | +Write-Output diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblock.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblock.ps1 new file mode 100644 index 000000000..3ddce4ece --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblock.ps1 @@ -0,0 +1,3 @@ +$var = 'Hello' +$action = { Write-Output $var } +&$action diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockRenamed.ps1 new file mode 100644 index 000000000..35ac2282a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockRenamed.ps1 @@ -0,0 +1,3 @@ +$Renamed = 'Hello' +$action = { Write-Output $Renamed } +&$action diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockScoped.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockScoped.ps1 new file mode 100644 index 000000000..c37f20f5d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockScoped.ps1 @@ -0,0 +1,3 @@ +$var = 'Hello' +$action = { $var = 'No'; Write-Output $var } +&$action diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockScopedRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockScopedRenamed.ps1 new file mode 100644 index 000000000..06e0db7a6 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableInScriptblockScopedRenamed.ps1 @@ -0,0 +1,3 @@ +$var = 'Hello' +$action = { $Renamed = 'No'; Write-Output $Renamed } +&$action diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedFunctionScriptblock.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedFunctionScriptblock.ps1 new file mode 100644 index 000000000..32efd9617 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedFunctionScriptblock.ps1 @@ -0,0 +1,9 @@ +function Sample{ + $var = 'Hello' + $sb = { + Write-Host $var + } + & $sb + $var +} +Sample diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedFunctionScriptblockRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedFunctionScriptblockRenamed.ps1 new file mode 100644 index 000000000..3d8fb1184 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedFunctionScriptblockRenamed.ps1 @@ -0,0 +1,9 @@ +function Sample{ + $Renamed = 'Hello' + $sb = { + Write-Host $Renamed + } + & $sb + $Renamed +} +Sample diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunction.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunction.ps1 new file mode 100644 index 000000000..3c6c22651 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunction.ps1 @@ -0,0 +1,7 @@ +$var = 10 +function TestFunction { + $var = 20 + Write-Output $var +} +TestFunction +Write-Output $var diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRefactorInner.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRefactorInner.ps1 new file mode 100644 index 000000000..3c6c22651 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRefactorInner.ps1 @@ -0,0 +1,7 @@ +$var = 10 +function TestFunction { + $var = 20 + Write-Output $var +} +TestFunction +Write-Output $var diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRefactorInnerRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRefactorInnerRenamed.ps1 new file mode 100644 index 000000000..d943f509a --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRefactorInnerRenamed.ps1 @@ -0,0 +1,7 @@ +$var = 10 +function TestFunction { + $Renamed = 20 + Write-Output $Renamed +} +TestFunction +Write-Output $var diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRenamed.ps1 new file mode 100644 index 000000000..3886cf867 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNestedScopeFunctionRenamed.ps1 @@ -0,0 +1,7 @@ +$Renamed = 10 +function TestFunction { + $var = 20 + Write-Output $var +} +TestFunction +Write-Output $Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNonParam.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNonParam.ps1 new file mode 100644 index 000000000..eaf921681 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNonParam.ps1 @@ -0,0 +1,8 @@ +$params = @{ + HtmlBodyContent = 'Testing JavaScript and CSS paths...' + JavaScriptPaths = '.\Assets\script.js' + StyleSheetPaths = '.\Assets\style.css' +} + +$view = New-VSCodeHtmlContentView -Title 'Test View' -ShowInColumn Two +Set-VSCodeHtmlContentView -View $view @params diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNonParamRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNonParamRenamed.ps1 new file mode 100644 index 000000000..31740427f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableNonParamRenamed.ps1 @@ -0,0 +1,8 @@ +$params = @{ + HtmlBodyContent = 'Testing JavaScript and CSS paths...' + JavaScriptPaths = '.\Assets\script.js' + StyleSheetPaths = '.\Assets\style.css' +} + +$Renamed = New-VSCodeHtmlContentView -Title 'Test View' -ShowInColumn Two +Set-VSCodeHtmlContentView -View $Renamed @params diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableParameterCommandWithSameName.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableParameterCommandWithSameName.ps1 new file mode 100644 index 000000000..88d091f84 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableParameterCommandWithSameName.ps1 @@ -0,0 +1,56 @@ +function Test-AADConnected { + + param ( + [Parameter(Mandatory = $false)][String]$UserPrincipalName + ) + Begin {} + Process { + [HashTable]$ConnectAADSplat = @{} + if ($UserPrincipalName) { + $ConnectAADSplat = @{ + AccountId = $UserPrincipalName + ErrorAction = 'Stop' + } + } + } +} + +function Set-MSolUMFA{ + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][ValidateSet('Enabled', 'Disabled', 'Enforced')][String]$StrongAuthenticationRequiremets + ) + begin{ + # Check if connected to Msol Session already + if (!(Test-MSolConnected)) { + Write-Verbose('No existing Msol session detected') + try { + Write-Verbose('Initiating connection to Msol') + Connect-MsolService -ErrorAction Stop + Write-Verbose('Connected to Msol successfully') + } catch{ + return Write-Error($_.Exception.Message) + } + } + if (!(Get-MsolUser -MaxResults 1 -ErrorAction Stop)){ + return Write-Error('Insufficient permissions to set MFA') + } + } + Process{ + # Get the time and calc 2 min to the future + $TimeStart = Get-Date + $TimeEnd = $timeStart.addminutes(1) + $Finished = $false + #Loop to check if the user exists already + if ($PSCmdlet.ShouldProcess($UserPrincipalName, 'StrongAuthenticationRequiremets = ' + $StrongAuthenticationRequiremets)) { + } + } + End{} +} + +Set-MsolUser -UserPrincipalName $UPN -StrongAuthenticationRequirements $sta -ErrorAction Stop +$UserPrincipalName = 'Bob' +if ($UserPrincipalName) { + $SplatTestAADConnected.Add('UserPrincipalName', $UserPrincipalName) +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableParameterCommandWithSameNameRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableParameterCommandWithSameNameRenamed.ps1 new file mode 100644 index 000000000..fb21baa6e --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableParameterCommandWithSameNameRenamed.ps1 @@ -0,0 +1,56 @@ +function Test-AADConnected { + + param ( + [Parameter(Mandatory = $false)][String]$Renamed + ) + Begin {} + Process { + [HashTable]$ConnectAADSplat = @{} + if ($Renamed) { + $ConnectAADSplat = @{ + AccountId = $Renamed + ErrorAction = 'Stop' + } + } + } +} + +function Set-MSolUMFA{ + [CmdletBinding(SupportsShouldProcess = $true)] + param ( + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName = $true)][ValidateSet('Enabled', 'Disabled', 'Enforced')][String]$StrongAuthenticationRequiremets + ) + begin{ + # Check if connected to Msol Session already + if (!(Test-MSolConnected)) { + Write-Verbose('No existing Msol session detected') + try { + Write-Verbose('Initiating connection to Msol') + Connect-MsolService -ErrorAction Stop + Write-Verbose('Connected to Msol successfully') + } catch{ + return Write-Error($_.Exception.Message) + } + } + if (!(Get-MsolUser -MaxResults 1 -ErrorAction Stop)){ + return Write-Error('Insufficient permissions to set MFA') + } + } + Process{ + # Get the time and calc 2 min to the future + $TimeStart = Get-Date + $TimeEnd = $timeStart.addminutes(1) + $Finished = $false + #Loop to check if the user exists already + if ($PSCmdlet.ShouldProcess($UserPrincipalName, 'StrongAuthenticationRequiremets = ' + $StrongAuthenticationRequiremets)) { + } + } + End{} +} + +Set-MsolUser -UserPrincipalName $UPN -StrongAuthenticationRequirements $sta -ErrorAction Stop +$UserPrincipalName = 'Bob' +if ($UserPrincipalName) { + $SplatTestAADConnected.Add('UserPrincipalName', $UserPrincipalName) +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableRedefinition.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableRedefinition.ps1 new file mode 100644 index 000000000..1063dc887 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableRedefinition.ps1 @@ -0,0 +1,3 @@ +$var = 10 +$var = 20 +Write-Output $var diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableRedefinitionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableRedefinitionRenamed.ps1 new file mode 100644 index 000000000..29f3f87c7 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableRedefinitionRenamed.ps1 @@ -0,0 +1,3 @@ +$Renamed = 10 +$Renamed = 20 +Write-Output $Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableScriptWithParamBlock.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableScriptWithParamBlock.ps1 new file mode 100644 index 000000000..1a14d2d8b --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableScriptWithParamBlock.ps1 @@ -0,0 +1,28 @@ +param([int]$Count = 50, [int]$DelayMilliSeconds = 200) + +function Write-Item($itemCount) { + $i = 1 + + while ($i -le $itemCount) { + $str = "Output $i" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $i = $i + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $DelayMilliSeconds + } +} + +# Do-Work will be underlined in green if you haven't disable script analysis. +# Hover over the function name below to see the PSScriptAnalyzer warning that "Do-Work" +# doesn't use an approved verb. +function Do-Work($workCount) { + Write-Output 'Doing work...' + Write-Item $workcount + Write-Host 'Done!' +} + +Do-Work $Count diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableScriptWithParamBlockRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableScriptWithParamBlockRenamed.ps1 new file mode 100644 index 000000000..aa9e325d0 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableScriptWithParamBlockRenamed.ps1 @@ -0,0 +1,28 @@ +param([int]$Count = 50, [int]$Renamed = 200) + +function Write-Item($itemCount) { + $i = 1 + + while ($i -le $itemCount) { + $str = "Output $i" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $i = $i + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $Renamed + } +} + +# Do-Work will be underlined in green if you haven't disable script analysis. +# Hover over the function name below to see the PSScriptAnalyzer warning that "Do-Work" +# doesn't use an approved verb. +function Do-Work($workCount) { + Write-Output 'Doing work...' + Write-Item $workcount + Write-Host 'Done!' +} + +Do-Work $Count diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleAssignment.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleAssignment.ps1 new file mode 100644 index 000000000..6097d4154 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleAssignment.ps1 @@ -0,0 +1,2 @@ +$var = 10 +Write-Output $var diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleAssignmentRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleAssignmentRenamed.ps1 new file mode 100644 index 000000000..3962ce503 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleAssignmentRenamed.ps1 @@ -0,0 +1,2 @@ +$Renamed = 10 +Write-Output $Renamed diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleFunctionParameter.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleFunctionParameter.ps1 new file mode 100644 index 000000000..ca370b580 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleFunctionParameter.ps1 @@ -0,0 +1,18 @@ +$x = 1..10 + +function testing_files { + + param ( + $x + ) + Write-Host "Printing $x" +} + +foreach ($number in $x) { + testing_files $number + + function testing_files { + Write-Host '------------------' + } +} +testing_files '99' diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleFunctionParameterRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleFunctionParameterRenamed.ps1 new file mode 100644 index 000000000..0e022321f --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableSimpleFunctionParameterRenamed.ps1 @@ -0,0 +1,18 @@ +$x = 1..10 + +function testing_files { + + param ( + $Renamed + ) + Write-Host "Printing $Renamed" +} + +foreach ($number in $x) { + testing_files $number + + function testing_files { + Write-Host '------------------' + } +} +testing_files '99' diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinCommandAstScriptBlock.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinCommandAstScriptBlock.ps1 new file mode 100644 index 000000000..4d2f47f74 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinCommandAstScriptBlock.ps1 @@ -0,0 +1,3 @@ +# Not same +$var = 10 +Get-ChildItem | Rename-Item -NewName { $var = $_.FullName + (Get-Random); $var } diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinCommandAstScriptBlockRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinCommandAstScriptBlockRenamed.ps1 new file mode 100644 index 000000000..56c4b4965 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinCommandAstScriptBlockRenamed.ps1 @@ -0,0 +1,3 @@ +# Not same +$var = 10 +Get-ChildItem | Rename-Item -NewName { $Renamed = $_.FullName + (Get-Random); $Renamed } diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinForeachObject.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinForeachObject.ps1 new file mode 100644 index 000000000..89ab6ca1d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinForeachObject.ps1 @@ -0,0 +1,5 @@ +# Same +$var = 10 +0..10 | ForEach-Object { + $var += 5 +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinForeachObjectRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinForeachObjectRenamed.ps1 new file mode 100644 index 000000000..12f936b61 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinForeachObjectRenamed.ps1 @@ -0,0 +1,5 @@ +# Same +$Renamed = 10 +0..10 | ForEach-Object { + $Renamed += 5 +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinHastableExpression.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinHastableExpression.ps1 new file mode 100644 index 000000000..cb3f58b1c --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinHastableExpression.ps1 @@ -0,0 +1,3 @@ +# Not same +$var = 10 +0..10 | Select-Object @{n='SomeProperty';e={ $var = 30 * $_; $var }} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinHastableExpressionRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinHastableExpressionRenamed.ps1 new file mode 100644 index 000000000..0ee85fa2d --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableWithinHastableExpressionRenamed.ps1 @@ -0,0 +1,3 @@ +# Not same +$var = 10 +0..10 | Select-Object @{n='SomeProperty';e={ $Renamed = 30 * $_; $Renamed }} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableusedInWhileLoop.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableusedInWhileLoop.ps1 new file mode 100644 index 000000000..6ef6e2652 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableusedInWhileLoop.ps1 @@ -0,0 +1,15 @@ +function Write-Item($itemCount) { + $i = 1 + + while ($i -le $itemCount) { + $str = "Output $i" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $i = $i + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $DelayMilliseconds + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableusedInWhileLoopRenamed.ps1 b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableusedInWhileLoopRenamed.ps1 new file mode 100644 index 000000000..7a5a46479 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/VariableusedInWhileLoopRenamed.ps1 @@ -0,0 +1,15 @@ +function Write-Item($itemCount) { + $Renamed = 1 + + while ($Renamed -le $itemCount) { + $str = "Output $Renamed" + Write-Output $str + + # In the gutter on the left, right click and select "Add Conditional Breakpoint" + # on the next line. Use the condition: $i -eq 25 + $Renamed = $Renamed + 1 + + # Slow down execution a bit so user can test the "Pause debugger" feature. + Start-Sleep -Milliseconds $DelayMilliseconds + } +} diff --git a/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/_RefactorVariableTestCases.cs b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/_RefactorVariableTestCases.cs new file mode 100644 index 000000000..3497c41eb --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Refactoring/Variables/_RefactorVariableTestCases.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +namespace PowerShellEditorServices.Test.Shared.Refactoring; +public class RefactorVariableTestCases +{ + public static RenameTestTarget[] TestCases = + [ + new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1), + new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1, NewName: "$Renamed"), + new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1, NewName: "$Bad Name", ShouldThrow: true), + new ("VariableSimpleAssignment.ps1", Line: 1, Column: 1, NewName: "Bad Name", ShouldThrow: true), + new ("VariableSimpleAssignment.ps1", Line: 1, Column: 6, NoResult: true), + new ("VariableCommandParameter.ps1", Line: 3, Column: 17), + new ("VariableCommandParameter.ps1", Line: 3, Column: 17, NewName: "-Renamed"), + new ("VariableCommandParameter.ps1", Line: 10, Column: 10), + new ("VariableCommandParameterSplatted.ps1", Line: 3, Column: 19 ), + new ("VariableCommandParameterSplatted.ps1", Line: 21, Column: 12), + new ("VariableDefinedInParamBlock.ps1", Line: 10, Column: 9), + new ("VariableDotNotationFromInnerFunction.ps1", Line: 1, Column: 1), + new ("VariableDotNotationFromInnerFunction.ps1", Line: 11, Column: 26), + new ("VariableInForeachDuplicateAssignment.ps1", Line: 6, Column: 18), + new ("VariableInForloopDuplicateAssignment.ps1", Line: 9, Column: 14), + new ("VariableInLoop.ps1", Line: 1, Column: 1), + new ("VariableInParam.ps1", Line: 24, Column: 16), + new ("VariableInPipeline.ps1", Line: 3, Column: 23), + new ("VariableInScriptblockScoped.ps1", Line: 2, Column: 16), + new ("VariableNestedFunctionScriptblock.ps1", Line: 4, Column: 20), + new ("VariableNestedScopeFunction.ps1", Line: 1, Column: 1), + new ("VariableNestedScopeFunctionRefactorInner.ps1", Line: 3, Column: 5), + new ("VariableNonParam.ps1", Line: 7, Column: 1), + new ("VariableParameterCommandWithSameName.ps1", Line: 9, Column: 13), + new ("VariableRedefinition.ps1", Line: 1, Column: 1), + new ("VariableScriptWithParamBlock.ps1", Line: 1, Column: 30), + new ("VariableSimpleFunctionParameter.ps1", Line: 6, Column: 9), + new ("VariableusedInWhileLoop.ps1", Line: 2, Column: 5), + new ("VariableWithinCommandAstScriptBlock.ps1", Line: 3, Column: 75), + new ("VariableWithinForeachObject.ps1", Line: 2, Column: 1), + new ("VariableWithinHastableExpression.ps1", Line: 3, Column: 46), + ]; +} diff --git a/test/PowerShellEditorServices.Test/Refactoring/PrepareRenameHandlerTests.cs b/test/PowerShellEditorServices.Test/Refactoring/PrepareRenameHandlerTests.cs new file mode 100644 index 000000000..4986212b9 --- /dev/null +++ b/test/PowerShellEditorServices.Test/Refactoring/PrepareRenameHandlerTests.cs @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediatR; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using Newtonsoft.Json.Linq; +using OmniSharp.Extensions.JsonRpc; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using OmniSharp.Extensions.LanguageServer.Protocol.Progress; +using OmniSharp.Extensions.LanguageServer.Protocol.Server; +using PowerShellEditorServices.Test.Shared.Refactoring; +using Xunit; +using Xunit.Abstractions; + +namespace PowerShellEditorServices.Test.Handlers; + +[Trait("Category", "PrepareRename")] +public class PrepareRenameHandlerTests +{ + private readonly PrepareRenameHandler testHandler; + + public PrepareRenameHandlerTests() + { + WorkspaceService workspace = new(NullLoggerFactory.Instance); + workspace.WorkspaceFolders.Add(new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(TestUtilities.GetSharedPath("Refactoring")) + }); + + testHandler = new + ( + new RenameService + ( + workspace, + new fakeLspSendMessageRequestFacade("I Accept"), + new EmptyConfiguration() + ) + { + DisclaimerAcceptedForSession = true //Disables UI prompts + } + ); + } + + /// + /// Convert test cases into theory data. This keeps us from needing xunit in the test data project + /// This type has a special ToString to add a data-driven test name which is why we dont convert directly to the param type first + /// + public static TheoryData VariableTestCases() + => new(RefactorVariableTestCases.TestCases.Select(RenameTestTargetSerializable.FromRenameTestTarget)); + + public static TheoryData FunctionTestCases() + => new(RefactorFunctionTestCases.TestCases.Select(RenameTestTargetSerializable.FromRenameTestTarget)); + + [Theory] + [MemberData(nameof(FunctionTestCases))] + public async Task FindsFunction(RenameTestTarget s) + { + PrepareRenameParams testParams = s.ToPrepareRenameParams("Functions"); + + RangeOrPlaceholderRange? result; + try + { + result = await testHandler.Handle(testParams, CancellationToken.None); + } + catch (HandlerErrorException err) + { + Assert.True(s.ShouldThrow, $"Unexpected HandlerErrorException: {err.Message}"); + return; + } + if (s.ShouldFail) + { + Assert.Null(result); + return; + } + + Assert.NotNull(result); + Assert.True(result?.DefaultBehavior?.DefaultBehavior); + } + + [Theory] + [MemberData(nameof(VariableTestCases))] + public async Task FindsVariable(RenameTestTarget s) + { + PrepareRenameParams testParams = s.ToPrepareRenameParams("Variables"); + + RangeOrPlaceholderRange? result; + try + { + result = await testHandler.Handle(testParams, CancellationToken.None); + } + catch (HandlerErrorException err) + { + Assert.True(s.ShouldThrow, $"Unexpected HandlerErrorException: {err.Message}"); + return; + } + if (s.ShouldFail) + { + Assert.Null(result); + return; + } + + Assert.NotNull(result); + Assert.True(result?.DefaultBehavior?.DefaultBehavior); + } + + // TODO: Bad Path Tests (strings, parameters, etc.) +} + +public static partial class RenameTestTargetExtensions +{ + public static PrepareRenameParams ToPrepareRenameParams(this RenameTestTarget testCase, string baseFolder) + => new() + { + Position = new ScriptPositionAdapter(Line: testCase.Line, Column: testCase.Column), + TextDocument = new TextDocumentIdentifier + { + Uri = DocumentUri.FromFileSystemPath( + TestUtilities.GetSharedPath($"Refactoring/{baseFolder}/{testCase.FileName}") + ) + } + }; +} + +public class fakeLspSendMessageRequestFacade(string title) : ILanguageServerFacade +{ + public async Task SendRequest(IRequest request, CancellationToken cancellationToken) + { + if (request is ShowMessageRequestParams) + { + return (TResponse)(object)new MessageActionItem { Title = title }; + } + else + { + throw new NotSupportedException(); + } + } + + public ITextDocumentLanguageServer TextDocument => throw new NotImplementedException(); + public INotebookDocumentLanguageServer NotebookDocument => throw new NotImplementedException(); + public IClientLanguageServer Client => throw new NotImplementedException(); + public IGeneralLanguageServer General => throw new NotImplementedException(); + public IWindowLanguageServer Window => throw new NotImplementedException(); + public IWorkspaceLanguageServer Workspace => throw new NotImplementedException(); + public IProgressManager ProgressManager => throw new NotImplementedException(); + public InitializeParams ClientSettings => throw new NotImplementedException(); + public InitializeResult ServerSettings => throw new NotImplementedException(); + public object GetService(Type serviceType) => throw new NotImplementedException(); + public IDisposable Register(Action registryAction) => throw new NotImplementedException(); + public void SendNotification(string method) => throw new NotImplementedException(); + public void SendNotification(string method, T @params) => throw new NotImplementedException(); + public void SendNotification(IRequest request) => throw new NotImplementedException(); + public IResponseRouterReturns SendRequest(string method) => throw new NotImplementedException(); + public IResponseRouterReturns SendRequest(string method, T @params) => throw new NotImplementedException(); + public bool TryGetRequest(long id, out string method, out TaskCompletionSource pendingTask) => throw new NotImplementedException(); +} + + + +public class EmptyConfiguration : ConfigurationRoot, ILanguageServerConfiguration, IScopedConfiguration +{ + public EmptyConfiguration() : base([]) { } + + public bool IsSupported => throw new NotImplementedException(); + + public ILanguageServerConfiguration AddConfigurationItems(IEnumerable configurationItems) => throw new NotImplementedException(); + public Task GetConfiguration(params ConfigurationItem[] items) => throw new NotImplementedException(); + public Task GetScopedConfiguration(DocumentUri scopeUri, CancellationToken cancellationToken) => Task.FromResult((IScopedConfiguration)this); + public ILanguageServerConfiguration RemoveConfigurationItems(IEnumerable configurationItems) => throw new NotImplementedException(); + public bool TryGetScopedConfiguration(DocumentUri scopeUri, out IScopedConfiguration configuration) => throw new NotImplementedException(); +} + +public static partial class RenameTestTargetExtensions +{ + /// + /// Extension Method to convert a RenameTestTarget to a RenameParams. Needed because RenameTestTarget is in a separate project. + /// + public static RenameParams ToRenameParams(this RenameTestTarget testCase, string subPath) + => new() + { + Position = new ScriptPositionAdapter(Line: testCase.Line, Column: testCase.Column), + TextDocument = new TextDocumentIdentifier + { + Uri = DocumentUri.FromFileSystemPath( + TestUtilities.GetSharedPath($"Refactoring/{subPath}/{testCase.FileName}") + ) + }, + NewName = testCase.NewName + }; +} + +/// +/// This is necessary for the MS test explorer to display the test cases +/// Ref: +/// +public class RenameTestTargetSerializable : RenameTestTarget, IXunitSerializable +{ + public RenameTestTargetSerializable() : base() { } + + public void Serialize(IXunitSerializationInfo info) + { + info.AddValue(nameof(FileName), FileName); + info.AddValue(nameof(Line), Line); + info.AddValue(nameof(Column), Column); + info.AddValue(nameof(NewName), NewName); + info.AddValue(nameof(ShouldFail), ShouldFail); + info.AddValue(nameof(ShouldThrow), ShouldThrow); + } + + public void Deserialize(IXunitSerializationInfo info) + { + FileName = info.GetValue(nameof(FileName)); + Line = info.GetValue(nameof(Line)); + Column = info.GetValue(nameof(Column)); + NewName = info.GetValue(nameof(NewName)); + ShouldFail = info.GetValue(nameof(ShouldFail)); + ShouldThrow = info.GetValue(nameof(ShouldThrow)); + } + + public static RenameTestTargetSerializable FromRenameTestTarget(RenameTestTarget t) + => new RenameTestTargetSerializable() + { + FileName = t.FileName, + Column = t.Column, + Line = t.Line, + NewName = t.NewName, + ShouldFail = t.ShouldFail, + ShouldThrow = t.ShouldThrow + }; +} diff --git a/test/PowerShellEditorServices.Test/Refactoring/RefactorUtilities.cs b/test/PowerShellEditorServices.Test/Refactoring/RefactorUtilities.cs new file mode 100644 index 000000000..288b7b83b --- /dev/null +++ b/test/PowerShellEditorServices.Test/Refactoring/RefactorUtilities.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using System.Linq; +using System.Collections.Generic; +using TextEditRange = OmniSharp.Extensions.LanguageServer.Protocol.Models.Range; + +namespace PowerShellEditorServices.Test.Refactoring +{ + public class RefactorUtilities + { + /// + /// A simplistic "Mock" implementation of vscode client performing rename activities. It is not comprehensive and an E2E test is recommended. + /// + /// + /// + /// + internal static string GetModifiedScript(string OriginalScript, TextEdit[] Modifications) + { + string[] Lines = OriginalScript.Split( + new string[] { Environment.NewLine }, + StringSplitOptions.None); + + // FIXME: Verify that we should be returning modifications in ascending order anyways as the LSP spec dictates it + IEnumerable sortedModifications = Modifications.OrderBy + ( + x => x, new TextEditComparer() + ); + + foreach (TextEdit change in sortedModifications) + { + TextEditRange editRange = change.Range; + string TargetLine = Lines[editRange.Start.Line]; + string begin = TargetLine.Substring(0, editRange.Start.Character); + string end = TargetLine.Substring(editRange.End.Character); + Lines[editRange.Start.Line] = begin + change.NewText + end; + } + + return string.Join(Environment.NewLine, Lines); + } + } + + internal class TextEditComparer : IComparer + { + public int Compare(TextEdit a, TextEdit b) + { + return a.Range.Start.Line == b.Range.Start.Line + ? b.Range.End.Character - a.Range.End.Character + : b.Range.Start.Line - a.Range.Start.Line; + } + } +} diff --git a/test/PowerShellEditorServices.Test/Refactoring/RenameHandlerTests.cs b/test/PowerShellEditorServices.Test/Refactoring/RenameHandlerTests.cs new file mode 100644 index 000000000..e115e5fcb --- /dev/null +++ b/test/PowerShellEditorServices.Test/Refactoring/RenameHandlerTests.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.PowerShell.EditorServices.Handlers; +using Microsoft.PowerShell.EditorServices.Services; +using Microsoft.PowerShell.EditorServices.Services.TextDocument; +using Microsoft.PowerShell.EditorServices.Test.Shared; +using OmniSharp.Extensions.LanguageServer.Protocol; +using OmniSharp.Extensions.LanguageServer.Protocol.Models; +using static PowerShellEditorServices.Test.Refactoring.RefactorUtilities; +using System.Linq; +using System.Threading; +using Xunit; +using PowerShellEditorServices.Test.Shared.Refactoring; + +namespace PowerShellEditorServices.Test.Handlers; +#pragma warning disable VSTHRD100 // XUnit handles async void with a custom SyncContext + +[Trait("Category", "RenameHandlerFunction")] +public class RenameHandlerTests +{ + internal WorkspaceService workspace = new(NullLoggerFactory.Instance); + + private readonly RenameHandler testHandler; + public RenameHandlerTests() + { + workspace.WorkspaceFolders.Add(new WorkspaceFolder + { + Uri = DocumentUri.FromFileSystemPath(TestUtilities.GetSharedPath("Refactoring")) + }); + + testHandler = new + ( + new RenameService + ( + workspace, + new fakeLspSendMessageRequestFacade("I Accept"), + new EmptyConfiguration() + ) + { + DisclaimerAcceptedForSession = true //Disables UI prompts + } + ); + } + + // Decided to keep this DAMP instead of DRY due to memberdata boundaries, duplicates with PrepareRenameHandler + public static TheoryData VariableTestCases() + => new(RefactorVariableTestCases.TestCases.Select(RenameTestTargetSerializable.FromRenameTestTarget)); + + public static TheoryData FunctionTestCases() + => new(RefactorFunctionTestCases.TestCases.Select(RenameTestTargetSerializable.FromRenameTestTarget)); + + [Theory] + [MemberData(nameof(FunctionTestCases))] + public async void RenamedFunction(RenameTestTarget s) + { + RenameParams request = s.ToRenameParams("Functions"); + WorkspaceEdit response; + try + { + response = await testHandler.Handle(request, CancellationToken.None); + } + catch (HandlerErrorException err) + { + Assert.True(s.ShouldThrow, $"Unexpected HandlerErrorException: {err.Message}"); + return; + } + if (s.ShouldFail) + { + Assert.Null(response); + return; + } + + DocumentUri testScriptUri = request.TextDocument.Uri; + + string expected = workspace.GetFile + ( + testScriptUri.ToString().Substring(0, testScriptUri.ToString().Length - 4) + "Renamed.ps1" + ).Contents; + + ScriptFile scriptFile = workspace.GetFile(testScriptUri); + + Assert.NotEmpty(response.Changes[testScriptUri]); + + string actual = GetModifiedScript(scriptFile.Contents, response.Changes[testScriptUri].ToArray()); + + Assert.Equal(expected, actual); + } + + [Theory] + [MemberData(nameof(VariableTestCases))] + public async void RenamedVariable(RenameTestTarget s) + { + RenameParams request = s.ToRenameParams("Variables"); + WorkspaceEdit response; + try + { + response = await testHandler.Handle(request, CancellationToken.None); + } + catch (HandlerErrorException err) + { + Assert.True(s.ShouldThrow, $"Unexpected HandlerErrorException: {err.Message}"); + return; + } + if (s.ShouldFail) + { + Assert.Null(response); + return; + } + DocumentUri testScriptUri = request.TextDocument.Uri; + + string expected = workspace.GetFile + ( + testScriptUri.ToString().Substring(0, testScriptUri.ToString().Length - 4) + "Renamed.ps1" + ).Contents; + + ScriptFile scriptFile = workspace.GetFile(testScriptUri); + + Assert.NotNull(response); + Assert.NotEmpty(response.Changes[testScriptUri]); + + string actual = GetModifiedScript(scriptFile.Contents, response.Changes[testScriptUri].ToArray()); + + Assert.Equal(expected, actual); + } +}