diff --git a/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs b/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs index 8390bbdf5..8e8caacca 100644 --- a/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs +++ b/src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; +using System.Collections.Generic; +using System.Linq; using System.Management.Automation; +using System.Management.Automation.Language; +using System.Text; using System.Threading; using System.Threading.Tasks; using MediatR; @@ -31,42 +36,61 @@ internal class ExpandAliasHandler : IExpandAliasHandler public async Task Handle(ExpandAliasParams request, CancellationToken cancellationToken) { - const string script = @" -function __Expand-Alias { - [System.Diagnostics.DebuggerHidden()] - param($targetScript) + string targetScript = request.Text; - [ref]$errors=$null + // Use the modern language parser to tokenize the script, then collect + // the command-name tokens (the first token of each command invocation). + Parser.ParseInput(targetScript, out Token[] tokens, out _); + List commandNameTokens = tokens + .Where(static token => (token.TokenFlags & TokenFlags.CommandName) == TokenFlags.CommandName) + .ToList(); - $tokens = [System.Management.Automation.PsParser]::Tokenize($targetScript, $errors).Where({$_.type -eq 'command'}) | - Sort-Object Start -Descending + if (commandNameTokens.Count == 0) + { + return new ExpandAliasResult { Text = targetScript }; + } - foreach ($token in $tokens) { - $definition=(Get-Command ('`'+$token.Content) -CommandType Alias -ErrorAction SilentlyContinue).Definition + // Resolve all the distinct command names to their alias definitions in a + // single round-trip. Wildcard metacharacters are escaped so that aliases + // like `?` (Where-Object) and `%` (ForEach-Object) resolve to themselves + // rather than being treated as patterns. + string[] names = commandNameTokens + .Select(static token => WildcardPattern.Escape(token.Text)) + .Distinct() + .ToArray(); - if($definition) { - $lhs=$targetScript.Substring(0, $token.Start) - $rhs=$targetScript.Substring($token.Start + $token.Length) + PSCommand psCommand = new PSCommand() + .AddCommand("Get-Command") + .AddParameter("Name", names) + .AddParameter("CommandType", CommandTypes.Alias) + .AddParameter("ErrorAction", ActionPreference.SilentlyContinue); - $targetScript=$lhs + $definition + $rhs - } - } + IReadOnlyList aliases = await _executionService + .ExecutePSCommandAsync(psCommand, cancellationToken) + .ConfigureAwait(false); - $targetScript -}"; + Dictionary definitions = new(StringComparer.OrdinalIgnoreCase); + foreach (AliasInfo alias in aliases) + { + definitions[alias.Name] = alias.Definition; + } - // TODO: Refactor to not rerun the function definition every time. - PSCommand psCommand = new(); - psCommand - .AddScript(script) - .AddStatement() - .AddCommand("__Expand-Alias") - .AddArgument(request.Text); - System.Collections.Generic.IReadOnlyList result = await _executionService.ExecutePSCommandAsync(psCommand, cancellationToken).ConfigureAwait(false); + // Substitute from the end of the script backwards so that earlier offsets + // remain valid as the text length changes. + StringBuilder expanded = new(targetScript); + foreach (Token token in commandNameTokens.OrderByDescending(static token => token.Extent.StartOffset)) + { + if (definitions.TryGetValue(token.Text, out string definition)) + { + int start = token.Extent.StartOffset; + int length = token.Extent.EndOffset - start; + expanded.Remove(start, length).Insert(start, definition); + } + } return new ExpandAliasResult { - Text = result[0] + Text = expanded.ToString() }; } } diff --git a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs index 155c23a3a..d764807e4 100644 --- a/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs +++ b/test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs @@ -1274,6 +1274,29 @@ await PsesLanguageClient Assert.Equal("Get-ChildItem", expandAliasResult.Text); } + [SkippableFact] + public async Task CanSendExpandAliasRequestWithMultipleAliasesAsync() + { + Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode, + "The expand alias request doesn't work in Constrained Language Mode."); + + // Exercises multiple aliases on one line, including `?` whose name is a + // wildcard metacharacter that the legacy tokenizer-based implementation + // would have to escape by hand. Substitution happens from the end so the + // earlier offsets stay valid as the text grows. + ExpandAliasResult expandAliasResult = + await PsesLanguageClient + .SendRequest( + "powerShell/expandAlias", + new ExpandAliasParams + { + Text = "gci | ? Name | % Name" + }) + .Returning(CancellationToken.None); + + Assert.Equal("Get-ChildItem | Where-Object Name | ForEach-Object Name", expandAliasResult.Text); + } + [Fact] public async Task CanSendSemanticTokenRequestAsync() {