Handle empty-string and null-value results returned from custom argument completer more properly (#27398)

This commit is contained in:
Dongbo Wang
2026-05-11 11:59:37 -07:00
committed by GitHub
parent 7164700777
commit c9ac205fda
4 changed files with 188 additions and 12 deletions
@@ -42,7 +42,7 @@
<PackageReference Include="Microsoft.Management.Infrastructure" Version="3.0.0" />
<PackageReference Include="Microsoft.PowerShell.Native" Version="700.0.0" />
<!-- Signing APIs -->
<PackageReference Include="Microsoft.Security.Extensions" Version="1.4.0" />
<PackageReference Include="Microsoft.Security.Extensions" Version="1.4.0" />
</ItemGroup>
<PropertyGroup>
@@ -601,7 +601,10 @@ namespace System.Management.Automation
completionContext.ExecutionContext.LanguageMode = PSLanguageMode.ConstrainedLanguage;
}
return GetResultHelper(completionContext, out replacementIndex, out replacementLength);
List<CompletionResult> results = GetResultHelper(completionContext, out replacementIndex, out replacementLength);
CompletionCompleters.RemoveLastNullCompletionResult(results);
return results;
}
finally
{
@@ -2675,14 +2675,17 @@ namespace System.Management.Automation
scriptBlock,
new object[] { commandName, parameterName, wordToComplete, commandAst, GetBoundArgumentsAsHashtable(context) },
resultList);
if (result)
{
resultList.Add(CompletionResult.Null);
}
return result;
}
/// <summary>
/// Invoke the custom argument completer and process its return values.
/// If we consider the completion successful, we add a null instance of the type 'CompletionResult'
/// to the end of the 'result' list to indicate that the argument completion has been processed, so we
/// will not go through the default argument completion even if the 'result' list is still empty.
/// </summary>
/// <returns>'true' if the argument completion was successful. 'false' otherwise.</returns>
private static bool InvokeScriptArgumentCompleter(
ScriptBlock scriptBlock,
object[] argumentsToCompleter,
@@ -2702,20 +2705,44 @@ namespace System.Management.Automation
return false;
}
if (customResults.Count is 1 && customResults[0] is { BaseObject: "" } or null)
{
// If the script block returns a single empty string or a null value, we will treat it as if it has
// completed successfully but has no results to return.
// This allows a custom completer to suppress the default completions that we may fall back otherwise.
result.Add(CompletionResult.Null);
return true;
}
int initialCount = result.Count;
foreach (var customResult in customResults)
{
var resultAsCompletion = customResult.BaseObject as CompletionResult;
if (resultAsCompletion != null)
if (customResult is null)
{
continue;
}
if (customResult.BaseObject is CompletionResult resultAsCompletion)
{
result.Add(resultAsCompletion);
continue;
}
var resultAsString = customResult.ToString();
result.Add(new CompletionResult(resultAsString));
if (!string.IsNullOrEmpty(resultAsString))
{
result.Add(new CompletionResult(resultAsString));
}
}
return true;
bool success = result.Count > initialCount;
if (success)
{
result.Add(CompletionResult.Null);
}
return success;
}
// All the methods for native command argument completion will add a null instance of the type CompletionResult to the end of the
@@ -2723,9 +2750,9 @@ namespace System.Management.Automation
// and has been processed already. So if the "result" list is still empty afterward, we will not go through the default argument completion anymore.
#region Native Command Argument Completion
private static void RemoveLastNullCompletionResult(List<CompletionResult> result)
internal static void RemoveLastNullCompletionResult(List<CompletionResult> result)
{
if (result.Count > 0 && result[result.Count - 1].Equals(CompletionResult.Null))
if (result?.Count > 0 && result[^1].Equals(CompletionResult.Null))
{
result.RemoveAt(result.Count - 1);
}
@@ -149,4 +149,150 @@ Describe "Tab completion bug fix" -Tags "CI" {
Pop-Location
}
}
Context 'Native CLI argument completion' {
BeforeAll {
$testDir = Join-Path $TestDrive "TempTestDir"
$file1 = Join-Path $testDir "abc.ps1"
$file2 = Join-Path $testDir "def.py"
New-Item -ItemType Directory -Path $testDir > $null
New-Item -ItemType File -Path $file1 > $null
New-Item -ItemType File -Path $file2 > $null
$dirSep = [System.IO.Path]::DirectorySeparatorChar
$relative_name_abc = ".${dirSep}abc.ps1"
$relative_name_def = ".${dirSep}def.py"
}
AfterAll {
## Unregister the completer for 'ping' to avoid affecting other tests.
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock $null
}
It 'Completer script block returning nothing should fall back to file name completion' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
}
try {
Push-Location -Path $testDir
$cmd = "ping "
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches | Should -Not -BeNullOrEmpty
$result.CompletionMatches.Count | Should -Be 2
$result.CompletionMatches[0].CompletionText | Should -BeExactly $relative_name_abc
$result.CompletionMatches[1].CompletionText | Should -BeExactly $relative_name_def
} finally {
Pop-Location
}
}
It 'Completer script block returning $null should suppress default completion fallback' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
return $null
}
try {
Push-Location -Path $testDir
$cmd = "ping "
## This call should not throw, and should suppress the default file name completion fallback, returning no results.
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches.Count | Should -Be 0
} finally {
Pop-Location
}
}
It 'Completer script block returning empty string should suppress default completion fallback' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
return ''
}
try {
Push-Location -Path $testDir
$cmd = "ping "
## This call should not throw, and should suppress the default file name completion fallback, returning no results.
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches.Count | Should -Be 0
} finally {
Pop-Location
}
}
It 'Completer script block returning empty-string-only array should fall back to default completion' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
return '', ''
}
try {
Push-Location -Path $testDir
$cmd = "ping "
## This call should not throw, and should fall back to the default completion.
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches.Count | Should -Be 2
$result.CompletionMatches[0].CompletionText | Should -BeExactly $relative_name_abc
$result.CompletionMatches[1].CompletionText | Should -BeExactly $relative_name_def
} finally {
Pop-Location
}
}
It 'Completer script block returning null-value-only array should fall back to default completion' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
return $null, $null
}
try {
Push-Location -Path $testDir
$cmd = "ping "
## This call should not throw, and should fall back to the default completion.
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches.Count | Should -Be 2
$result.CompletionMatches[0].CompletionText | Should -BeExactly $relative_name_abc
$result.CompletionMatches[1].CompletionText | Should -BeExactly $relative_name_def
} finally {
Pop-Location
}
}
It 'Completer script block returning a single string works as expected' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
return 'hello'
}
try {
Push-Location -Path $testDir
$cmd = "ping "
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches.Count | Should -Be 1
$result.CompletionMatches[0].CompletionText | Should -BeExactly "hello"
} finally {
Pop-Location
}
}
It 'Completer script block returning an array that contains non-empty-or-null strings works as expected' {
register-ArgumentCompleter -Native -CommandName ping -ScriptBlock {
param($WordToComplete, $CommandAst, $CursorPosition)
return '', 'hello', $null, 'world'
}
try {
Push-Location -Path $testDir
$cmd = "ping "
$result = TabExpansion2 -inputScript $cmd -cursorColumn $cmd.Length
$result.CompletionMatches.Count | Should -Be 2
$result.CompletionMatches[0].CompletionText | Should -BeExactly "hello"
$result.CompletionMatches[1].CompletionText | Should -BeExactly "world"
} finally {
Pop-Location
}
}
}
}