Add the experimental feature 'PSUseAbbreviationExpansion' to support tab completion on abbreviated command names (#8109)

This commit is contained in:
Steve Lee
2019-01-10 16:11:43 -08:00
committed by Dongbo Wang
parent b87cd1c0a3
commit 71e58f9439
8 changed files with 260 additions and 66 deletions
@@ -87,7 +87,6 @@ namespace System.Management.Automation
string commandName = context.WordToComplete;
string quote = HandleDoubleAndSingleQuote(ref commandName);
commandName += "*";
List<CompletionResult> commandResults = null;
if (commandName.IndexOfAny(Utils.Separators.DirectoryOrDrive) == -1)
@@ -100,29 +99,7 @@ namespace System.Management.Automation
lastAst = context.RelatedAsts.Last();
}
var powershell = context.Helper
.AddCommandWithPreferenceSetting("Get-Command", typeof(GetCommandCommand))
.AddParameter("All")
.AddParameter("Name", commandName);
if (moduleName != null)
powershell.AddParameter("Module", moduleName);
if (!types.Equals(CommandTypes.All))
powershell.AddParameter("CommandType", types);
Exception exceptionThrown;
var commandInfos = context.Helper.ExecuteCurrentPowerShell(out exceptionThrown);
if (commandInfos != null && commandInfos.Count > 1)
{
// OrderBy is using stable sorting
var sortedCommandInfos = commandInfos.OrderBy(a => a, new CommandNameComparer());
commandResults = MakeCommandsUnique(sortedCommandInfos, /* includeModulePrefix: */ false, addAmpersandIfNecessary, quote);
}
else
{
commandResults = MakeCommandsUnique(commandInfos, /* includeModulePrefix: */ false, addAmpersandIfNecessary, quote);
}
commandResults = ExecuteGetCommandCommand(useModulePrefix: false);
if (lastAst != null)
{
@@ -159,31 +136,74 @@ namespace System.Management.Automation
moduleName = commandName.Substring(0, indexOfFirstBackslash);
commandName = commandName.Substring(indexOfFirstBackslash + 1);
var powershell = context.Helper
.AddCommandWithPreferenceSetting("Get-Command", typeof(GetCommandCommand))
.AddParameter("All")
.AddParameter("Name", commandName)
.AddParameter("Module", moduleName);
if (!types.Equals(CommandTypes.All))
powershell.AddParameter("CommandType", types);
Exception exceptionThrown;
var commandInfos = context.Helper.ExecuteCurrentPowerShell(out exceptionThrown);
if (commandInfos != null && commandInfos.Count > 1)
{
var sortedCommandInfos = commandInfos.OrderBy(a => a, new CommandNameComparer());
commandResults = MakeCommandsUnique(sortedCommandInfos, /* includeModulePrefix: */ true, addAmpersandIfNecessary, quote);
}
else
{
commandResults = MakeCommandsUnique(commandInfos, /* includeModulePrefix: */ true, addAmpersandIfNecessary, quote);
}
commandResults = ExecuteGetCommandCommand(useModulePrefix: true);
}
}
return commandResults;
List<CompletionResult> ExecuteGetCommandCommand(bool useModulePrefix)
{
var powershell = context.Helper
.AddCommandWithPreferenceSetting("Get-Command", typeof(GetCommandCommand))
.AddParameter("All")
.AddParameter("Name", commandName + "*");
if (moduleName != null)
{
powershell.AddParameter("Module", moduleName);
}
if (!types.Equals(CommandTypes.All))
{
powershell.AddParameter("CommandType", types);
}
// Exception is ignored, the user simply does not get any completion results if the pipeline fails
Exception exceptionThrown;
var commandInfos = context.Helper.ExecuteCurrentPowerShell(out exceptionThrown);
if (commandInfos == null || commandInfos.Count == 0)
{
powershell.Commands.Clear();
powershell
.AddCommandWithPreferenceSetting("Get-Command", typeof(GetCommandCommand))
.AddParameter("All")
.AddParameter("Name", commandName);
if (ExperimentalFeature.IsEnabled("PSUseAbbreviationExpansion"))
{
powershell.AddParameter("UseAbbreviationExpansion");
}
if (moduleName != null)
{
powershell.AddParameter("Module", moduleName);
}
if (!types.Equals(CommandTypes.All))
{
powershell.AddParameter("CommandType", types);
}
commandInfos = context.Helper.ExecuteCurrentPowerShell(out exceptionThrown);
}
List<CompletionResult> completionResults = null;
if (commandInfos != null && commandInfos.Count > 1)
{
// OrderBy is using stable sorting
var sortedCommandInfos = commandInfos.OrderBy(a => a, new CommandNameComparer());
completionResults = MakeCommandsUnique(sortedCommandInfos, useModulePrefix, addAmpersandIfNecessary, quote);
}
else
{
completionResults = MakeCommandsUnique(commandInfos, useModulePrefix, addAmpersandIfNecessary, quote);
}
return completionResults;
}
}
private static readonly HashSet<string> s_keywordsToExcludeFromAddingAmpersand
@@ -1493,7 +1493,6 @@ namespace System.Management.Automation
// Check the current cmdlet cache then check the top level
// if we aren't already at the top level.
SessionStateScopeEnumerator scopeEnumerator =
new SessionStateScopeEnumerator(Context.EngineSessionState.CurrentScope);
@@ -5,6 +5,7 @@ using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Management.Automation.Internal;
using Dbg = System.Management.Automation.Diagnostics;
namespace System.Management.Automation
@@ -725,7 +726,7 @@ namespace System.Management.Automation
{
CommandInfo result = null;
if ((_commandResolutionOptions & SearchResolutionOptions.ResolveFunctionPatterns) != 0)
if (_commandResolutionOptions.HasFlag(SearchResolutionOptions.ResolveFunctionPatterns))
{
if (_matchingFunctionEnumerator == null)
{
@@ -745,13 +746,20 @@ namespace System.Management.Automation
{
matchingFunction.Add((CommandInfo)functionEntry.Value);
}
else if (_commandResolutionOptions.HasFlag(SearchResolutionOptions.UseAbbreviationExpansion))
{
if (_commandName.Equals(ModuleUtils.AbbreviateName((string)functionEntry.Key), StringComparison.OrdinalIgnoreCase))
{
matchingFunction.Add((CommandInfo)functionEntry.Value);
}
}
}
// Process functions from modules
CommandInfo c = GetFunctionFromModules(_commandName);
if (c != null)
CommandInfo cmdInfo = GetFunctionFromModules(_commandName);
if (cmdInfo != null)
{
matchingFunction.Add(c);
matchingFunction.Add(cmdInfo);
}
_matchingFunctionEnumerator = matchingFunction.GetEnumerator();
@@ -946,17 +954,18 @@ namespace System.Management.Automation
private CmdletInfo GetNextCmdlet()
{
CmdletInfo result = null;
bool useAbbreviationExpansion = _commandResolutionOptions.HasFlag(SearchResolutionOptions.UseAbbreviationExpansion);
if (_matchingCmdlet == null)
{
if ((_commandResolutionOptions & SearchResolutionOptions.CommandNameIsPattern) != 0)
if (_commandResolutionOptions.HasFlag(SearchResolutionOptions.CommandNameIsPattern) || useAbbreviationExpansion)
{
Collection<CmdletInfo> matchingCmdletInfo = new Collection<CmdletInfo>();
PSSnapinQualifiedName PSSnapinQualifiedCommandName =
PSSnapinQualifiedName.GetInstance(_commandName);
if (PSSnapinQualifiedCommandName == null)
if (!useAbbreviationExpansion && PSSnapinQualifiedCommandName == null)
{
return null;
}
@@ -984,6 +993,13 @@ namespace System.Management.Automation
matchingCmdletInfo.Add(cmdlet);
}
}
else if (useAbbreviationExpansion)
{
if (_commandName.Equals(ModuleUtils.AbbreviateName(cmdlet.Name), StringComparison.OrdinalIgnoreCase))
{
matchingCmdletInfo.Add(cmdlet);
}
}
}
}
@@ -992,7 +1008,7 @@ namespace System.Management.Automation
else
{
_matchingCmdlet = _context.CommandDiscovery.GetCmdletInfo(_commandName,
(_commandResolutionOptions & SearchResolutionOptions.SearchAllScopes) != 0);
_commandResolutionOptions.HasFlag(SearchResolutionOptions.SearchAllScopes));
}
}
@@ -1588,5 +1604,10 @@ namespace System.Management.Automation
/// <summary>Use fuzzy matching.</summary>
FuzzyMatch = 0x10,
/// <summary>
/// Enable searching for cmdlets/functions by abbreviation expansion.
/// </summary>
UseAbbreviationExpansion = 0x20,
}
}
@@ -91,7 +91,11 @@ namespace System.Management.Automation
new ExperimentalFeature(name: "PSImplicitRemotingBatching",
description: "Batch implicit remoting proxy commands to improve performance",
source: EngineSource,
isEnabled: false)
isEnabled: false),
new ExperimentalFeature(name: "PSUseAbbreviationExpansion",
description: "Allow tab completion of cmdlets and functions by abbreviation",
source: EngineSource,
isEnabled: false),
};
EngineExperimentalFeatures = new ReadOnlyCollection<ExperimentalFeature>(engineFeatures);
@@ -358,6 +358,15 @@ namespace Microsoft.PowerShell.Commands
private List<CommandScore> _commandScores = new List<CommandScore>();
/// <summary>
/// Gets or sets the parameter that determines if return cmdlets based on abbreviation expansion.
/// This means it matches cmdlets where the uppercase characters for the noun match
/// the given characters. i.e., g-sgc would match Get-SomeGreatCmdlet.
/// </summary>
[Experimental("PSUseAbbreviationExpansion", ExperimentAction.Show)]
[Parameter(ValueFromPipelineByPropertyName = true, ParameterSetName = "AllCommandSet")]
public SwitchParameter UseAbbreviationExpansion { get; set; }
#endregion Definitions of cmdlet parameters
#region Overrides
@@ -701,6 +710,16 @@ namespace Microsoft.PowerShell.Commands
options = SearchResolutionOptions.SearchAllScopes;
}
if (UseAbbreviationExpansion)
{
options |= SearchResolutionOptions.UseAbbreviationExpansion;
}
if (UseFuzzyMatching)
{
options |= SearchResolutionOptions.FuzzyMatch;
}
if ((this.CommandType & CommandTypes.Alias) != 0)
{
options |= SearchResolutionOptions.ResolveAliasPatterns;
@@ -728,13 +747,7 @@ namespace Microsoft.PowerShell.Commands
moduleName = this.Module[0];
}
bool isPattern = WildcardPattern.ContainsWildcardCharacters(plainCommandName);
if (UseFuzzyMatching)
{
options |= SearchResolutionOptions.FuzzyMatch;
isPattern = true;
}
bool isPattern = WildcardPattern.ContainsWildcardCharacters(plainCommandName) || UseAbbreviationExpansion || UseFuzzyMatching;
if (isPattern)
{
options |= SearchResolutionOptions.CommandNameIsPattern;
@@ -798,7 +811,8 @@ namespace Microsoft.PowerShell.Commands
this.Context,
this.MyInvocation.CommandOrigin,
rediscoverImportedModules: true,
moduleVersionRequired: _isFullyQualifiedModuleSpecified);
moduleVersionRequired: _isFullyQualifiedModuleSpecified,
useAbbreviationExpansion: UseAbbreviationExpansion);
}
foreach (CommandInfo command in commands)
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Management.Automation.Runspaces;
using System.Text;
using Dbg = System.Management.Automation.Diagnostics;
namespace System.Management.Automation.Internal
@@ -411,8 +412,9 @@ namespace System.Management.Automation.Internal
/// <param name="rediscoverImportedModules">If true, rediscovers imported modules.</param>
/// <param name="moduleVersionRequired">Specific module version to be required.</param>
/// <param name="useFuzzyMatching">Use fuzzy matching.</param>
/// <returns>Returns CommandInfo IEnumerable.</returns>
internal static IEnumerable<CommandInfo> GetMatchingCommands(string pattern, ExecutionContext context, CommandOrigin commandOrigin, bool rediscoverImportedModules = false, bool moduleVersionRequired = false, bool useFuzzyMatching = false)
/// <param name="useAbbreviationExpansion">Use abbreviation expansion for matching.</param>
/// <returns>Returns matching CommandInfo IEnumerable.</returns>
internal static IEnumerable<CommandInfo> GetMatchingCommands(string pattern, ExecutionContext context, CommandOrigin commandOrigin, bool rediscoverImportedModules = false, bool moduleVersionRequired = false, bool useFuzzyMatching = false, bool useAbbreviationExpansion = false)
{
// Otherwise, if it had wildcards, just return the "AvailableCommand"
// type of command info.
@@ -450,7 +452,8 @@ namespace System.Management.Automation.Internal
foreach (KeyValuePair<string, CommandInfo> entry in psModule.ExportedCommands)
{
if (commandPattern.IsMatch(entry.Value.Name) ||
(useFuzzyMatching && FuzzyMatcher.IsFuzzyMatch(entry.Value.Name, pattern)))
(useFuzzyMatching && FuzzyMatcher.IsFuzzyMatch(entry.Value.Name, pattern)) ||
(useAbbreviationExpansion && string.Equals(pattern, AbbreviateName(entry.Value.Name), StringComparison.OrdinalIgnoreCase)))
{
CommandInfo current = null;
switch (entry.Value.CommandType)
@@ -509,7 +512,8 @@ namespace System.Management.Automation.Internal
CommandTypes commandTypes = pair.Value;
if (commandPattern.IsMatch(commandName) ||
(useFuzzyMatching && FuzzyMatcher.IsFuzzyMatch(commandName, pattern)))
(useFuzzyMatching && FuzzyMatcher.IsFuzzyMatch(commandName, pattern)) ||
(useAbbreviationExpansion && string.Equals(pattern, AbbreviateName(commandName), StringComparison.OrdinalIgnoreCase)))
{
bool shouldExportCommand = true;
@@ -578,6 +582,26 @@ namespace System.Management.Automation.Internal
}
}
}
/// <summary>
/// Returns abbreviated version of a command name.
/// </summary>
/// <param name="commandName">Name of the command to transform.</param>
/// <returns>Abbreviated version of the command name.</returns>
internal static string AbbreviateName(string commandName)
{
// Use default size of 6 which represents expected average abbreviation length
StringBuilder abbreviation = new StringBuilder(6);
foreach (char c in commandName)
{
if (char.IsUpper(c) || c == '-')
{
abbreviation.Append(c);
}
}
return abbreviation.ToString();
}
}
internal struct CommandScore
@@ -10,6 +10,35 @@ Describe "TabCompletion" -Tags CI {
$res.CompletionMatches[0].CompletionText | Should -BeExactly 'Get-Command'
}
Context "ExperimentalFeatures" {
BeforeAll {
$configFilePath = Join-Path $testdrive "useabbreviationexpansion.json"
@"
{
"ExperimentalFeatures": [
"PSUseAbbreviationExpansion"
]
}
"@ > $configFilePath
}
It 'Should complete abbreviated cmdlet' {
$res = pwsh -settingsfile $configFilePath -c "(TabExpansion2 -inputScript 'i-psdf' -cursorColumn 'pschr'.Length).CompletionMatches.CompletionText"
$res | Should -HaveCount 1
$res | Should -BeExactly 'Import-PowerShellDataFile'
}
It 'Should complete abbreviated function' {
$res = pwsh -settingsfile $configFilePath -c "(TabExpansion2 -inputScript 'pschrl' -cursorColumn 'pschr'.Length).CompletionMatches.CompletionText"
$res.Count | Should -BeGreaterOrEqual 1
$res | Should -BeExactly 'PSConsoleHostReadLine'
}
}
It 'Should complete native exe' -Skip:(!$IsWindows) {
$res = TabExpansion2 -inputScript 'notep' -cursorColumn 'notep'.Length
$res.CompletionMatches[0].CompletionText | Should -BeExactly 'notepad.exe'
@@ -20,4 +20,87 @@ Describe "Get-Command CI tests" -Tag Feature {
$cmds.Name | Should -Contain $ping
}
}
Context "-UseAbbreviationExpansion tests" {
BeforeAll {
$testModulesPath = Join-Path $testdrive "Modules"
$testPSModulePath = [System.IO.Path]::PathSeparator + $testModulesPath
$null = New-Item -ItemType Directory -Path $testModulesPath
$null = New-Item -ItemType Directory -Path (Join-Path $testModulesPath "test1")
$null = New-Item -ItemType Directory -Path (Join-Path $testModulesPath "test2")
Set-Content -Path (Join-Path $testModulesPath "test1/test1.psm1") -Value "function Import-FooZedZed {}"
Set-Content -Path (Join-Path $testModulesPath "test2/test2.psm1") -Value "function Invoke-FooZedZed {}"
$configFilePath = Join-Path $testdrive "useabbreviationexpansion.json"
@"
{
"ExperimentalFeatures": [
"PSUseAbbreviationExpansion"
]
}
"@ > $configFilePath
}
It "Can return multiple results relying on auto module loading" {
$results = pwsh -outputformat xml -settingsfile $configFilePath -command "`$env:PSModulePath += '$testPSModulePath'; Get-Command i-fzz -UseAbbreviationExpansion"
$results | Should -HaveCount 2
$results.Name | Should -Contain "Invoke-FooZedZed"
$results.Name | Should -Contain "Import-FooZedZed"
}
It "Valid cmdlets works with name <name> and module <module>" -TestCases @(
@{ Name = "i-psdf"; expected = "Import-PowerShellDataFile"; module = $null },
@{ Name = "i-psdf"; expected = "Import-PowerShellDataFile"; module = "Microsoft.PowerShell.Utility" },
@{ Name = "r-psb" ; expected = "Remove-PSBreakpoint" ; module = $null },
@{ Name = "r-psb" ; expected = "Remove-PSBreakpoint" ; module = "Microsoft.PowerShell.Utility" }
) {
param($name, $expected, $module)
$command = "Get-Command $name -UseAbbreviationExpansion"
if ($module) {
$command += " -Module $module"
}
$results = pwsh -outputformat xml -settingsfile $configFilePath -command "$command"
$results | Should -HaveCount 1
$results.Name | Should -BeExactly $expected
}
It "Can return multiple results for cmdlets matching abbreviation" {
# use mixed casing to validate case insensitivity
$results = pwsh -outputformat xml -settingsfile $configFilePath -command "Get-Command i-C -UseAbbreviationExpansion"
$results | Should -HaveCount 3
$results.Name | Should -Contain "Invoke-Command"
$results.Name | Should -Contain "Import-Clixml"
$results.Name | Should -Contain "Import-Csv"
}
It "Will return multiple results for functions matching abbreviation" {
$manifestPath = Join-Path $testdrive "test.psd1"
$modulePath = Join-Path $testdrive "test.psm1"
New-ModuleManifest -Path $manifestPath -FunctionsToExport "Get-FooBar","Get-FB" -RootModule test.psm1
@"
function Get-FooBar { "foobar" }
function Get-FB { "fb" }
"@ > $modulePath
$results = pwsh -outputformat xml -settingsfile $configFilePath -command "Import-Module $manifestPath; Get-Command g-fb -UseAbbreviationExpansion"
$results | Should -HaveCount 2
$results[0].Name | Should -BeExactly "Get-FB"
$results[1].Name | Should -BeExactly "Get-FooBar"
}
It "Non-existing cmdlets returns non-terminating error" {
pwsh -settingsfile $configFilePath -command 'try { get-command g-adf -ea stop } catch { $_.fullyqualifiederrorid }' | Should -BeExactly "CommandNotFoundException,Microsoft.PowerShell.Commands.GetCommandCommand"
}
It "No results if wildcard is used" {
pwsh -settingsfile $configFilePath -command Get-Command i-psd* -UseAbbreviationExpansion | Should -BeNullOrEmpty
}
}
}