diff --git a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs index 6f0715ef449..27166bc790b 100644 --- a/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs +++ b/src/System.Management.Automation/engine/CommandCompletion/CompletionCompleters.cs @@ -87,7 +87,6 @@ private static List CompleteCommand(CompletionContext context, string commandName = context.WordToComplete; string quote = HandleDoubleAndSingleQuote(ref commandName); - commandName += "*"; List commandResults = null; if (commandName.IndexOfAny(Utils.Separators.DirectoryOrDrive) == -1) @@ -100,29 +99,7 @@ private static List CompleteCommand(CompletionContext context, 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 @@ private static List CompleteCommand(CompletionContext context, moduleName = commandName.Substring(0, indexOfFirstBackslash); commandName = commandName.Substring(indexOfFirstBackslash + 1); - var powershell = context.Helper + commandResults = ExecuteGetCommandCommand(useModulePrefix: true); + } + } + + return commandResults; + + List 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) - .AddParameter("Module", moduleName); - - if (!types.Equals(CommandTypes.All)) - powershell.AddParameter("CommandType", types); + .AddParameter("Name", commandName); - Exception exceptionThrown; - var commandInfos = context.Helper.ExecuteCurrentPowerShell(out exceptionThrown); + if (ExperimentalFeature.IsEnabled("PSUseAbbreviationExpansion")) + { + powershell.AddParameter("UseAbbreviationExpansion"); + } - if (commandInfos != null && commandInfos.Count > 1) + if (moduleName != null) { - var sortedCommandInfos = commandInfos.OrderBy(a => a, new CommandNameComparer()); - commandResults = MakeCommandsUnique(sortedCommandInfos, /* includeModulePrefix: */ true, addAmpersandIfNecessary, quote); + powershell.AddParameter("Module", moduleName); } - else + + if (!types.Equals(CommandTypes.All)) { - commandResults = MakeCommandsUnique(commandInfos, /* includeModulePrefix: */ true, addAmpersandIfNecessary, quote); + powershell.AddParameter("CommandType", types); } + + commandInfos = context.Helper.ExecuteCurrentPowerShell(out exceptionThrown); } - } - return commandResults; + List 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 s_keywordsToExcludeFromAddingAmpersand diff --git a/src/System.Management.Automation/engine/CommandDiscovery.cs b/src/System.Management.Automation/engine/CommandDiscovery.cs index 2d2af3fa43b..54b0ac968d0 100644 --- a/src/System.Management.Automation/engine/CommandDiscovery.cs +++ b/src/System.Management.Automation/engine/CommandDiscovery.cs @@ -1493,7 +1493,6 @@ internal IEnumerator GetCmdletInfo(string cmdletName, bool searchAll // 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); diff --git a/src/System.Management.Automation/engine/CommandSearcher.cs b/src/System.Management.Automation/engine/CommandSearcher.cs index e1eb9e54902..f3f29a514c0 100644 --- a/src/System.Management.Automation/engine/CommandSearcher.cs +++ b/src/System.Management.Automation/engine/CommandSearcher.cs @@ -5,6 +5,7 @@ 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 @@ private CommandInfo GetNextFunction() { CommandInfo result = null; - if ((_commandResolutionOptions & SearchResolutionOptions.ResolveFunctionPatterns) != 0) + if (_commandResolutionOptions.HasFlag(SearchResolutionOptions.ResolveFunctionPatterns)) { if (_matchingFunctionEnumerator == null) { @@ -745,13 +746,20 @@ private CommandInfo GetNextFunction() { 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 @@ private CommandInfo GetFunction(string function) 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 matchingCmdletInfo = new Collection(); PSSnapinQualifiedName PSSnapinQualifiedCommandName = PSSnapinQualifiedName.GetInstance(_commandName); - if (PSSnapinQualifiedCommandName == null) + if (!useAbbreviationExpansion && PSSnapinQualifiedCommandName == null) { return null; } @@ -984,6 +993,13 @@ private CmdletInfo GetNextCmdlet() matchingCmdletInfo.Add(cmdlet); } } + else if (useAbbreviationExpansion) + { + if (_commandName.Equals(ModuleUtils.AbbreviateName(cmdlet.Name), StringComparison.OrdinalIgnoreCase)) + { + matchingCmdletInfo.Add(cmdlet); + } + } } } @@ -992,7 +1008,7 @@ private CmdletInfo GetNextCmdlet() else { _matchingCmdlet = _context.CommandDiscovery.GetCmdletInfo(_commandName, - (_commandResolutionOptions & SearchResolutionOptions.SearchAllScopes) != 0); + _commandResolutionOptions.HasFlag(SearchResolutionOptions.SearchAllScopes)); } } @@ -1588,5 +1604,10 @@ internal enum SearchResolutionOptions /// Use fuzzy matching. FuzzyMatch = 0x10, + + /// + /// Enable searching for cmdlets/functions by abbreviation expansion. + /// + UseAbbreviationExpansion = 0x20, } } diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 79554d4d7b0..bf3aafff877 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -91,7 +91,11 @@ static ExperimentalFeature() 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(engineFeatures); diff --git a/src/System.Management.Automation/engine/GetCommandCommand.cs b/src/System.Management.Automation/engine/GetCommandCommand.cs index 236ff00b1be..026bdcd12d2 100644 --- a/src/System.Management.Automation/engine/GetCommandCommand.cs +++ b/src/System.Management.Automation/engine/GetCommandCommand.cs @@ -358,6 +358,15 @@ public PSTypeName[] ParameterType private List _commandScores = new List(); + /// + /// 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. + /// + [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 @@ private void AccumulateMatchingCommands(IEnumerable commandNames) 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 @@ private void AccumulateMatchingCommands(IEnumerable commandNames) 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 @@ private void AccumulateMatchingCommands(IEnumerable commandNames) this.Context, this.MyInvocation.CommandOrigin, rediscoverImportedModules: true, - moduleVersionRequired: _isFullyQualifiedModuleSpecified); + moduleVersionRequired: _isFullyQualifiedModuleSpecified, + useAbbreviationExpansion: UseAbbreviationExpansion); } foreach (CommandInfo command in commands) diff --git a/src/System.Management.Automation/engine/Modules/ModuleUtils.cs b/src/System.Management.Automation/engine/Modules/ModuleUtils.cs index 9214f56186e..3bbe2ffa7f8 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleUtils.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleUtils.cs @@ -5,6 +5,7 @@ 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 @@ internal static IEnumerable GetFuzzyMatchingCommands(string patter /// If true, rediscovers imported modules. /// Specific module version to be required. /// Use fuzzy matching. - /// Returns CommandInfo IEnumerable. - internal static IEnumerable GetMatchingCommands(string pattern, ExecutionContext context, CommandOrigin commandOrigin, bool rediscoverImportedModules = false, bool moduleVersionRequired = false, bool useFuzzyMatching = false) + /// Use abbreviation expansion for matching. + /// Returns matching CommandInfo IEnumerable. + internal static IEnumerable 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 @@ internal static IEnumerable GetMatchingCommands(string pattern, Exe foreach (KeyValuePair 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 @@ internal static IEnumerable GetMatchingCommands(string pattern, Exe 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 @@ internal static IEnumerable GetMatchingCommands(string pattern, Exe } } } + + /// + /// Returns abbreviated version of a command name. + /// + /// Name of the command to transform. + /// Abbreviated version of the command name. + 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 diff --git a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 index 858173cf8e4..88a4b85dcae 100644 --- a/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 +++ b/test/powershell/Host/TabCompletion/TabCompletion.Tests.ps1 @@ -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' diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 index 83b255c2d4c..c9d7e030709 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 @@ -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 and 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 + } + } }