Skip to content

Commit 57dec9a

Browse files
Modernize alias expansion to use the language parser
The `powerShell/expandAlias` handler built an embedded PowerShell here-string that called `[System.Management.Automation.PsParser]::Tokenize` to find command tokens. That legacy tokenizer is Windows PowerShell / full-framework only and carries the parsing limitations that come with it. This rewrites the logic in C# against the modern, cross-edition APIs: - Tokenize with `Parser.ParseInput` and select command-name tokens via `TokenFlags.CommandName` instead of the legacy `PSTokenType.Command`. - Resolve every distinct name to its alias definition in a single `Get-Command -CommandType Alias` round-trip rather than re-defining a helper function and querying one token at a time. - Escape wildcard metacharacters with `WildcardPattern.Escape` so aliases whose names are patterns -- `?` (Where-Object) and `%` (ForEach-Object) -- resolve to themselves. The old script did this by hand with a leading backtick. - Rebuild the text by substituting from the highest offset down so the earlier extents stay valid as the length changes, preserving the existing behavior for multiple aliases on a line and multi-token definitions. Scripts with no aliases are returned unchanged. The JSON-RPC contract is untouched. I extended the E2E tests with a multi-alias line that includes the wildcard-named `?` alias, which the naive modern approach would mishandle. Fixes #2108 Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ad4f46 commit 57dec9a

2 files changed

Lines changed: 73 additions & 26 deletions

File tree

src/PowerShellEditorServices/Services/PowerShell/Handlers/ExpandAliasHandler.cs

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
47
using System.Management.Automation;
8+
using System.Management.Automation.Language;
9+
using System.Text;
510
using System.Threading;
611
using System.Threading.Tasks;
712
using MediatR;
@@ -31,42 +36,61 @@ internal class ExpandAliasHandler : IExpandAliasHandler
3136

3237
public async Task<ExpandAliasResult> Handle(ExpandAliasParams request, CancellationToken cancellationToken)
3338
{
34-
const string script = @"
35-
function __Expand-Alias {
36-
[System.Diagnostics.DebuggerHidden()]
37-
param($targetScript)
39+
string targetScript = request.Text;
3840

39-
[ref]$errors=$null
41+
// Use the modern language parser to tokenize the script, then collect
42+
// the command-name tokens (the first token of each command invocation).
43+
Parser.ParseInput(targetScript, out Token[] tokens, out _);
44+
List<Token> commandNameTokens = tokens
45+
.Where(static token => (token.TokenFlags & TokenFlags.CommandName) == TokenFlags.CommandName)
46+
.ToList();
4047

41-
$tokens = [System.Management.Automation.PsParser]::Tokenize($targetScript, $errors).Where({$_.type -eq 'command'}) |
42-
Sort-Object Start -Descending
48+
if (commandNameTokens.Count == 0)
49+
{
50+
return new ExpandAliasResult { Text = targetScript };
51+
}
4352

44-
foreach ($token in $tokens) {
45-
$definition=(Get-Command ('`'+$token.Content) -CommandType Alias -ErrorAction SilentlyContinue).Definition
53+
// Resolve all the distinct command names to their alias definitions in a
54+
// single round-trip. Wildcard metacharacters are escaped so that aliases
55+
// like `?` (Where-Object) and `%` (ForEach-Object) resolve to themselves
56+
// rather than being treated as patterns.
57+
string[] names = commandNameTokens
58+
.Select(static token => WildcardPattern.Escape(token.Text))
59+
.Distinct()
60+
.ToArray();
4661

47-
if($definition) {
48-
$lhs=$targetScript.Substring(0, $token.Start)
49-
$rhs=$targetScript.Substring($token.Start + $token.Length)
62+
PSCommand psCommand = new PSCommand()
63+
.AddCommand("Get-Command")
64+
.AddParameter("Name", names)
65+
.AddParameter("CommandType", CommandTypes.Alias)
66+
.AddParameter("ErrorAction", ActionPreference.SilentlyContinue);
5067

51-
$targetScript=$lhs + $definition + $rhs
52-
}
53-
}
68+
IReadOnlyList<AliasInfo> aliases = await _executionService
69+
.ExecutePSCommandAsync<AliasInfo>(psCommand, cancellationToken)
70+
.ConfigureAwait(false);
5471

55-
$targetScript
56-
}";
72+
Dictionary<string, string> definitions = new(StringComparer.OrdinalIgnoreCase);
73+
foreach (AliasInfo alias in aliases)
74+
{
75+
definitions[alias.Name] = alias.Definition;
76+
}
5777

58-
// TODO: Refactor to not rerun the function definition every time.
59-
PSCommand psCommand = new();
60-
psCommand
61-
.AddScript(script)
62-
.AddStatement()
63-
.AddCommand("__Expand-Alias")
64-
.AddArgument(request.Text);
65-
System.Collections.Generic.IReadOnlyList<string> result = await _executionService.ExecutePSCommandAsync<string>(psCommand, cancellationToken).ConfigureAwait(false);
78+
// Substitute from the end of the script backwards so that earlier offsets
79+
// remain valid as the text length changes.
80+
StringBuilder expanded = new(targetScript);
81+
foreach (Token token in commandNameTokens.OrderByDescending(static token => token.Extent.StartOffset))
82+
{
83+
if (definitions.TryGetValue(token.Text, out string definition))
84+
{
85+
int start = token.Extent.StartOffset;
86+
int length = token.Extent.EndOffset - start;
87+
expanded.Remove(start, length).Insert(start, definition);
88+
}
89+
}
6690

6791
return new ExpandAliasResult
6892
{
69-
Text = result[0]
93+
Text = expanded.ToString()
7094
};
7195
}
7296
}

test/PowerShellEditorServices.Test.E2E/LanguageServerProtocolMessageTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,29 @@ await PsesLanguageClient
12741274
Assert.Equal("Get-ChildItem", expandAliasResult.Text);
12751275
}
12761276

1277+
[SkippableFact]
1278+
public async Task CanSendExpandAliasRequestWithMultipleAliasesAsync()
1279+
{
1280+
Skip.If(PsesStdioLanguageServerProcessHost.RunningInConstrainedLanguageMode,
1281+
"The expand alias request doesn't work in Constrained Language Mode.");
1282+
1283+
// Exercises multiple aliases on one line, including `?` whose name is a
1284+
// wildcard metacharacter that the legacy tokenizer-based implementation
1285+
// would have to escape by hand. Substitution happens from the end so the
1286+
// earlier offsets stay valid as the text grows.
1287+
ExpandAliasResult expandAliasResult =
1288+
await PsesLanguageClient
1289+
.SendRequest(
1290+
"powerShell/expandAlias",
1291+
new ExpandAliasParams
1292+
{
1293+
Text = "gci | ? Name | % Name"
1294+
})
1295+
.Returning<ExpandAliasResult>(CancellationToken.None);
1296+
1297+
Assert.Equal("Get-ChildItem | Where-Object Name | ForEach-Object Name", expandAliasResult.Text);
1298+
}
1299+
12771300
[Fact]
12781301
public async Task CanSendSemanticTokenRequestAsync()
12791302
{

0 commit comments

Comments
 (0)