Add the parameter Register-ArgumentCompleter -NativeFallback to support registering a cover-all completer for native commands (#25230)

This commit is contained in:
Dongbo Wang
2025-08-29 15:50:02 -07:00
committed by GitHub
parent 8d524adf7f
commit 53805fb426
5 changed files with 134 additions and 34 deletions
@@ -2638,9 +2638,8 @@ namespace System.Management.Automation
}
}
var registeredCompleters = optionKey.Equals("NativeArgumentCompleters", StringComparison.OrdinalIgnoreCase)
? context.NativeArgumentCompleters
: context.CustomArgumentCompleters;
bool isNative = optionKey.Equals("NativeArgumentCompleters", StringComparison.OrdinalIgnoreCase);
var registeredCompleters = isNative ? context.NativeArgumentCompleters : context.CustomArgumentCompleters;
if (registeredCompleters != null)
{
@@ -2651,6 +2650,13 @@ namespace System.Management.Automation
return scriptBlock;
}
}
// For a native command, if a fallback completer is registered, then return it.
// For example, the 'Microsoft.PowerShell.UnixTabCompletion' module.
if (isNative && registeredCompleters.TryGetValue(RegisterArgumentCompleterCommand.FallbackCompleterKey, out scriptBlock))
{
return scriptBlock;
}
}
return null;
@@ -172,66 +172,110 @@ namespace System.Management.Automation
[Cmdlet(VerbsLifecycle.Register, "ArgumentCompleter", HelpUri = "https://go.microsoft.com/fwlink/?LinkId=528576")]
public class RegisterArgumentCompleterCommand : PSCmdlet
{
private const string PowerShellSetName = "PowerShellSet";
private const string NativeCommandSetName = "NativeCommandSet";
private const string NativeFallbackSetName = "NativeFallbackSet";
// Use a key that is unlikely to be a file name or path to indicate the fallback completer for native commands.
internal const string FallbackCompleterKey = "___ps::<native_fallback_key>@@___";
/// <summary>
/// Gets or sets the command names for which the argument completer is registered.
/// </summary>
[Parameter(ParameterSetName = "NativeSet", Mandatory = true)]
[Parameter(ParameterSetName = "PowerShellSet")]
[Parameter(ParameterSetName = NativeCommandSetName, Mandatory = true)]
[Parameter(ParameterSetName = PowerShellSetName)]
[SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays")]
public string[] CommandName { get; set; }
/// <summary>
/// Gets or sets the name of the parameter for which the argument completer is registered.
/// </summary>
[Parameter(ParameterSetName = "PowerShellSet", Mandatory = true)]
[Parameter(ParameterSetName = PowerShellSetName, Mandatory = true)]
public string ParameterName { get; set; }
/// <summary>
/// Gets or sets the script block that will be executed to provide argument completions.
/// </summary>
[Parameter(Mandatory = true)]
[AllowNull()]
public ScriptBlock ScriptBlock { get; set; }
/// <summary>
/// Indicates the argument completer is for native commands.
/// </summary>
[Parameter(ParameterSetName = "NativeSet")]
[Parameter(ParameterSetName = NativeCommandSetName)]
public SwitchParameter Native { get; set; }
/// <summary>
/// Indicates the argument completer is a fallback for any native commands that don't have a completer registered.
/// </summary>
[Parameter(ParameterSetName = NativeFallbackSetName)]
public SwitchParameter NativeFallback { get; set; }
/// <summary>
/// </summary>
protected override void EndProcessing()
{
Dictionary<string, ScriptBlock> completerDictionary;
if (ParameterName != null)
{
completerDictionary = Context.CustomArgumentCompleters ??
(Context.CustomArgumentCompleters = new Dictionary<string, ScriptBlock>(StringComparer.OrdinalIgnoreCase));
}
else
{
completerDictionary = Context.NativeArgumentCompleters ??
(Context.NativeArgumentCompleters = new Dictionary<string, ScriptBlock>(StringComparer.OrdinalIgnoreCase));
}
if (CommandName == null || CommandName.Length == 0)
if (ParameterSetName is NativeFallbackSetName)
{
CommandName = new[] { string.Empty };
}
completerDictionary = Context.NativeArgumentCompleters ??= new(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < CommandName.Length; i++)
SetKeyValue(completerDictionary, FallbackCompleterKey, ScriptBlock);
}
else if (ParameterSetName is NativeCommandSetName)
{
var key = CommandName[i];
if (!string.IsNullOrWhiteSpace(ParameterName))
completerDictionary = Context.NativeArgumentCompleters ??= new(StringComparer.OrdinalIgnoreCase);
foreach (string command in CommandName)
{
if (!string.IsNullOrWhiteSpace(key))
var key = command?.Trim();
if (string.IsNullOrEmpty(key))
{
key = key + ":" + ParameterName;
}
else
{
key = ParameterName;
continue;
}
SetKeyValue(completerDictionary, key, ScriptBlock);
}
}
else if (ParameterSetName is PowerShellSetName)
{
completerDictionary = Context.CustomArgumentCompleters ??= new(StringComparer.OrdinalIgnoreCase);
string paramName = ParameterName.Trim();
if (paramName.Length is 0)
{
return;
}
completerDictionary[key] = ScriptBlock;
if (CommandName is null || CommandName.Length is 0)
{
SetKeyValue(completerDictionary, paramName, ScriptBlock);
return;
}
foreach (string command in CommandName)
{
var key = command?.Trim();
key = string.IsNullOrEmpty(key)
? paramName
: $"{key}:{paramName}";
SetKeyValue(completerDictionary, key, ScriptBlock);
}
}
static void SetKeyValue(Dictionary<string, ScriptBlock> table, string key, ScriptBlock value)
{
if (value is null)
{
table.Remove(key);
}
else
{
table[key] = value;
}
}
}
}
@@ -1067,9 +1067,9 @@ namespace System.Management.Automation.Runspaces
/// ExecutionContext, if it available in TLS
/// Null, if ExecutionContext is not available in TLS
/// </returns>
internal static System.Management.Automation.ExecutionContext GetExecutionContextFromTLS()
internal static ExecutionContext GetExecutionContextFromTLS()
{
System.Management.Automation.Runspaces.Runspace runspace = Runspace.DefaultRunspace;
Runspace runspace = Runspace.DefaultRunspace;
if (runspace == null)
{
return null;
@@ -104,7 +104,7 @@ namespace System.Management.Automation
/// </summary>
/// <param name="script">The string to compile.</param>
public static ScriptBlock Create(string script) => Create(
parser: new Language.Parser(),
parser: new Parser(),
fileName: null,
fileContents: script);
@@ -1929,8 +1929,12 @@ param([ValidatePattern(
Context NativeCommand {
BeforeAll {
$nativeCommand = (Get-Command -CommandType Application -TotalCount 1).Name
## Find a native command that is not 'pwsh'. We will use 'pwsh' for fallback completer tests later.
$nativeCommand = Get-Command -CommandType Application -TotalCount 2 |
Where-Object Name -NotLike pwsh* |
Select-Object -First 1
}
It 'Completes native commands with -' {
Register-ArgumentCompleter -Native -CommandName $nativeCommand -ScriptBlock {
param($wordToComplete, $ast, $cursorColumn)
@@ -1994,6 +1998,52 @@ param([ValidatePattern(
$res.CompletionMatches | Should -HaveCount 1
$res.CompletionMatches.CompletionText | Should -BeExactly "-option"
}
It 'Covers an arbitrary unbound native command with -t' {
## Register a completer for $nativeCommand.
Register-ArgumentCompleter -Native -CommandName $nativeCommand -ScriptBlock {
param($wordToComplete, $ast, $cursorColumn)
if ($wordToComplete -eq '-t') {
return "-terminal"
}
}
## Register a fallback native command completer.
Register-ArgumentCompleter -NativeFallback -ScriptBlock {
param($wordToComplete, $ast, $cursorColumn)
if ($wordToComplete -eq '-t') {
return "-testing"
}
}
## The specific completer will be used if it exists.
$line = "$nativeCommand -t"
$res = TabExpansion2 -inputScript $line -cursorColumn $line.Length
$res.CompletionMatches | Should -HaveCount 1
$res.CompletionMatches.CompletionText | Should -BeExactly "-terminal"
## Otherwise, the fallback completer will kick in.
$line = "pwsh -t"
$res = TabExpansion2 -inputScript $line -cursorColumn $line.Length
$res.CompletionMatches | Should -HaveCount 1
$res.CompletionMatches.CompletionText | Should -BeExactly "-testing"
## Remove the completer for $nativeCommand.
Register-ArgumentCompleter -Native -CommandName $nativeCommand -ScriptBlock $null
## The fallback completer will be used for $nativeCommand.
$line = "$nativeCommand -t"
$res = TabExpansion2 -inputScript $line -cursorColumn $line.Length
$res.CompletionMatches | Should -HaveCount 1
$res.CompletionMatches.CompletionText | Should -BeExactly "-testing"
## Remove the fallback completer for $nativeCommand.
Register-ArgumentCompleter -NativeFallback -ScriptBlock $null
## The fallback completer will be used for $nativeCommand.
$res = TabExpansion2 -inputScript $line -cursorColumn $line.Length
$res.CompletionMatches | Should -HaveCount 0
}
}
It 'Should complete "Export-Counter -FileFormat" with available output formats' -Pending {