diff --git a/src/System.Management.Automation/System.Management.Automation.csproj b/src/System.Management.Automation/System.Management.Automation.csproj
index b16516c1b0..f3e1d0dd9e 100644
--- a/src/System.Management.Automation/System.Management.Automation.csproj
+++ b/src/System.Management.Automation/System.Management.Automation.csproj
@@ -42,7 +42,7 @@
-
+
diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs
index a6b6f4170b..9c4a61a683 100644
--- a/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs
+++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionAnalysis.cs
@@ -601,7 +601,10 @@ namespace System.Management.Automation
completionContext.ExecutionContext.LanguageMode = PSLanguageMode.ConstrainedLanguage;
}
- return GetResultHelper(completionContext, out replacementIndex, out replacementLength);
+ List results = GetResultHelper(completionContext, out replacementIndex, out replacementLength);
+ CompletionCompleters.RemoveLastNullCompletionResult(results);
+
+ return results;
}
finally
{
diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs
index 73406e449b..80cead788d 100644
--- a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs
+++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs
@@ -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;
}
+ ///
+ /// 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.
+ ///
+ /// 'true' if the argument completion was successful. 'false' otherwise.
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 result)
+ internal static void RemoveLastNullCompletionResult(List 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);
}
diff --git a/test/powershell/Host/TabCompletion/BugFix.Tests.ps1 b/test/powershell/Host/TabCompletion/BugFix.Tests.ps1
index a7191d06e9..2280d141ac 100644
--- a/test/powershell/Host/TabCompletion/BugFix.Tests.ps1
+++ b/test/powershell/Host/TabCompletion/BugFix.Tests.ps1
@@ -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
+ }
+ }
+ }
}