Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -329,50 +329,72 @@ internal static List<CompletionResult> MakeCommandsUnique(IEnumerable<PSObject>
}
}

List<CompletionResult> endResults = null;
foreach (var keyValuePair in commandTable)
{
var commandList = keyValuePair.Value as List<object>;
if (commandList != null)
if (keyValuePair.Value is List<object> commandList)
{
endResults ??= new List<CompletionResult>();

// The first command might be an un-prefixed commandInfo that we get by importing a module with the -Prefix parameter,
// in that case, we should add the module name qualification because if the module is not in the module path, calling
// 'Get-Foo' directly doesn't work
string completionName = keyValuePair.Key;
if (!includeModulePrefix)
var modulesWithCommand = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var importedModules = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var commandInfoList = new List<CommandInfo>(commandList.Count);
for (int i = 0; i < commandList.Count; i++)
{
var commandInfo = commandList[0] as CommandInfo;
if (commandInfo != null && !string.IsNullOrEmpty(commandInfo.Prefix))
if (commandList[i] is not CommandInfo commandInfo)
{
Diagnostics.Assert(!string.IsNullOrEmpty(commandInfo.ModuleName), "the module name should exist if commandInfo.Prefix is not an empty string");
if (!ModuleCmdletBase.IsPrefixedCommand(commandInfo))
{
completionName = commandInfo.ModuleName + "\\" + completionName;
}
continue;
}

commandInfoList.Add(commandInfo);
if (commandInfo.CommandType == CommandTypes.Application)
{
continue;
}

modulesWithCommand.Add(commandInfo.ModuleName);
if ((commandInfo.CommandType == CommandTypes.Cmdlet && commandInfo.CommandMetadata.CommandType is not null)
|| (commandInfo.CommandType is CommandTypes.Function or CommandTypes.Filter && commandInfo.Definition != string.Empty)
|| (commandInfo.CommandType == CommandTypes.Alias && commandInfo.Definition is not null))
{
// Checks if the command or source module has been imported.
_ = importedModules.Add(commandInfo.ModuleName);
}
}

results.Add(GetCommandNameCompletionResult(completionName, commandList[0], addAmpersandIfNecessary, quote));
if (commandInfoList.Count == 0)
{
continue;
}

// For the other commands that are hidden, we need to disambiguate,
// but put these at the end as it's less likely any of the hidden
// commands are desired. If we can't add anything to disambiguate,
// then we'll skip adding a completion result.
for (int index = 1; index < commandList.Count; index++)
int moduleCount = modulesWithCommand.Count;
modulesWithCommand.Clear();
int index;
if (commandInfoList[0].CommandType == CommandTypes.Application
|| importedModules.Count == 1
|| moduleCount < 2)
{
var commandInfo = commandList[index] as CommandInfo;
Diagnostics.Assert(commandInfo != null, "Elements should always be CommandInfo");
// We can use the short name for this command because there's no ambiguity about which command it resolves to.
// If the first element is an application then we know there's no conflicting commands/aliases (because of the command precedence).
// If there's just 1 module imported then the short name refers to that module (and it will be the first element in the list)
// If there's less than 2 unique modules exporting that command then we can use the short name because it can only refer to that module.
index = 1;
results.Add(GetCommandNameCompletionResult(keyValuePair.Key, commandInfoList[0], addAmpersandIfNecessary, quote));
modulesWithCommand.Add(commandInfoList[0].ModuleName);
}
else
{
index = 0;
}

for (; index < commandInfoList.Count; index++)
{
CommandInfo commandInfo = commandInfoList[index];
if (commandInfo.CommandType == CommandTypes.Application)
{
endResults.Add(GetCommandNameCompletionResult(commandInfo.Definition, commandInfo, addAmpersandIfNecessary, quote));
results.Add(GetCommandNameCompletionResult(commandInfo.Definition, commandInfo, addAmpersandIfNecessary, quote));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, you are not using endResults anymore, meaning that you are not putting all the module-name-prefixed results at the end. Can you confirm if that expected and why? Also, if it's expected, you should remove the endResults declaration above and the use of it below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that's intentional. It seemed weird to move commands with the same name far apart from each other so the user has to tab through the entire list to find the command alternatives. For example if I want the VMWare version of Get-VMHost and I type in Get-VMH<Tab> and end up with the Hyper-V version, why shouldn't the next result then be the VMWare version?

}
else if (!string.IsNullOrEmpty(commandInfo.ModuleName))
else if (!string.IsNullOrEmpty(commandInfo.ModuleName) && modulesWithCommand.Add(commandInfo.ModuleName))
{
var name = commandInfo.ModuleName + "\\" + commandInfo.Name;
endResults.Add(GetCommandNameCompletionResult(name, commandInfo, addAmpersandIfNecessary, quote));
results.Add(GetCommandNameCompletionResult(name, commandInfo, addAmpersandIfNecessary, quote));
}
}
}
Expand All @@ -399,11 +421,6 @@ internal static List<CompletionResult> MakeCommandsUnique(IEnumerable<PSObject>
}
}

if (endResults != null && endResults.Count > 0)
{
results.AddRange(endResults);
}

return results;
}

Expand Down
31 changes: 31 additions & 0 deletions test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,37 @@ Describe "TabCompletion" -Tags CI {
$res.CompletionMatches[0].CompletionText | Should -BeExactly 'notepad.exe'
}

It 'Should not include duplicate command results' {
$OldModulePath = $env:PSModulePath
$tempDir = Join-Path -Path $TestDrive -ChildPath "TempPsModuleDir"
try
{
$ModuleDirs = @(
Join-Path $tempDir "TestModule1\1.0"
Join-Path $tempDir "TestModule1\1.1"
Join-Path $tempDir "TestModule2\1.0"
)
foreach ($Dir in $ModuleDirs)
{
$NewDir = New-Item -Path $Dir -ItemType Directory -Force
$ModuleName = $NewDir.Parent.Name
Set-Content -Value 'MyTestFunction{}' -LiteralPath "$($NewDir.FullName)\$ModuleName.psm1"
New-ModuleManifest -Path "$($NewDir.FullName)\$ModuleName.psd1" -RootModule "$ModuleName.psm1" -FunctionsToExport "MyTestFunction" -ModuleVersion $NewDir.Name
}

$env:PSModulePath += [System.IO.Path]::PathSeparator + $tempDir
$Res = TabExpansion2 -inputScript MyTestFunction
$Res.CompletionMatches.Count | Should -Be 2
$SortedMatches = $Res.CompletionMatches.CompletionText | Sort-Object
$SortedMatches[0] | Should -Be "TestModule1\MyTestFunction"
$SortedMatches[1] | Should -Be "TestModule2\MyTestFunction"
}
finally
{
$env:PSModulePath = $OldModulePath
}
}

It 'Should complete dotnet method' {
$res = TabExpansion2 -inputScript '(1).ToSt' -cursorColumn '(1).ToSt'.Length
$res.CompletionMatches[0].CompletionText | Should -BeExactly 'ToString('
Expand Down
Loading