diff --git a/src/System.Management.Automation/engine/CommandPathSearch.cs b/src/System.Management.Automation/engine/CommandPathSearch.cs index a0dee7a3e0..644b07be2f 100644 --- a/src/System.Management.Automation/engine/CommandPathSearch.cs +++ b/src/System.Management.Automation/engine/CommandPathSearch.cs @@ -44,8 +44,10 @@ namespace System.Management.Automation string commandName, IEnumerable lookupPaths, ExecutionContext context, - Collection acceptableCommandNames) + Collection acceptableCommandNames, + bool useFuzzyMatch = false) { + _useFuzzyMatch = useFuzzyMatch; string[] commandPatterns; if (acceptableCommandNames != null) { @@ -420,11 +422,29 @@ namespace System.Management.Automation // to forcefully use null if pattern is "." if (pattern.Length != 1 || pattern[0] != '.') { - var matchingFiles = Directory.EnumerateFiles(directory, pattern); + if (_useFuzzyMatch) + { + var files = new List(); + var matchingFiles = Directory.EnumerateFiles(directory); + foreach (string file in matchingFiles) + { + if (FuzzyMatcher.IsFuzzyMatch(Path.GetFileName(file), pattern)) + { + files.Add(file); + } + } - result = _postProcessEnumeratedFiles != null - ? _postProcessEnumeratedFiles(matchingFiles.ToArray()) - : matchingFiles; + result = _postProcessEnumeratedFiles != null + ? _postProcessEnumeratedFiles(files.ToArray()) + : files; + } + else + { + var matchingFiles = Directory.EnumerateFiles(directory, pattern); + result = _postProcessEnumeratedFiles != null + ? _postProcessEnumeratedFiles(matchingFiles.ToArray()) + : matchingFiles; + } } } } @@ -559,6 +579,8 @@ namespace System.Management.Automation private string[] _orderedPathExt; private Collection _acceptableCommandNames; + private bool _useFuzzyMatch = false; + #endregion private members } } diff --git a/src/System.Management.Automation/engine/CommandSearcher.cs b/src/System.Management.Automation/engine/CommandSearcher.cs index d215f87f1a..e1eb9e5490 100644 --- a/src/System.Management.Automation/engine/CommandSearcher.cs +++ b/src/System.Management.Automation/engine/CommandSearcher.cs @@ -659,7 +659,9 @@ namespace System.Management.Automation foreach (KeyValuePair aliasEntry in _context.EngineSessionState.GetAliasTable()) { - if (aliasMatcher.IsMatch(aliasEntry.Key)) + if (aliasMatcher.IsMatch(aliasEntry.Key) || + (_commandResolutionOptions.HasFlag(SearchResolutionOptions.FuzzyMatch) && + FuzzyMatcher.IsFuzzyMatch(aliasEntry.Key, _commandName))) { matchingAliases.Add(aliasEntry.Value); } @@ -737,7 +739,9 @@ namespace System.Management.Automation foreach (DictionaryEntry functionEntry in _context.EngineSessionState.GetFunctionTable()) { - if (functionMatcher.IsMatch((string)functionEntry.Key)) + if (functionMatcher.IsMatch((string)functionEntry.Key) || + (_commandResolutionOptions.HasFlag(SearchResolutionOptions.FuzzyMatch) && + FuzzyMatcher.IsFuzzyMatch(functionEntry.Key.ToString(), _commandName))) { matchingFunction.Add((CommandInfo)functionEntry.Value); } @@ -968,7 +972,9 @@ namespace System.Management.Automation { foreach (CmdletInfo cmdlet in cmdletList) { - if (cmdletMatcher.IsMatch(cmdlet.Name)) + if (cmdletMatcher.IsMatch(cmdlet.Name) || + (_commandResolutionOptions.HasFlag(SearchResolutionOptions.FuzzyMatch) && + FuzzyMatcher.IsFuzzyMatch(cmdlet.Name, _commandName))) { if (string.IsNullOrEmpty(PSSnapinQualifiedCommandName.PSSnapInName) || (PSSnapinQualifiedCommandName.PSSnapInName.Equals( @@ -1380,7 +1386,8 @@ namespace System.Management.Automation _commandName, _context.CommandDiscovery.GetLookupDirectoryPaths(), _context, - acceptableCommandNames: null); + acceptableCommandNames: null, + useFuzzyMatch: _commandResolutionOptions.HasFlag(SearchResolutionOptions.FuzzyMatch)); } else { @@ -1578,5 +1585,8 @@ namespace System.Management.Automation ResolveFunctionPatterns = 0x02, CommandNameIsPattern = 0x04, SearchAllScopes = 0x08, + + /// Use fuzzy matching. + FuzzyMatch = 0x10, } } diff --git a/src/System.Management.Automation/engine/GetCommandCommand.cs b/src/System.Management.Automation/engine/GetCommandCommand.cs index 10c0314ba5..236ff00b1b 100644 --- a/src/System.Management.Automation/engine/GetCommandCommand.cs +++ b/src/System.Management.Automation/engine/GetCommandCommand.cs @@ -10,6 +10,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.IO; using System.Management.Automation; +using System.Management.Automation.Internal; using System.Diagnostics.CodeAnalysis; using System.Management.Automation.Language; using System.Reflection; @@ -349,6 +350,14 @@ namespace Microsoft.PowerShell.Commands private PSTypeName[] _parameterTypes; + /// + /// Gets or sets the parameter that enables using fuzzy matching. + /// + [Parameter(ParameterSetName = "AllCommandSet")] + public SwitchParameter UseFuzzyMatching { get; set; } + + private List _commandScores = new List(); + #endregion Definitions of cmdlet parameters #region Overrides @@ -418,7 +427,7 @@ namespace Microsoft.PowerShell.Commands { // We do not show the pithy aliases (not of the format Verb-Noun) and applications by default. // We will show them only if the Name, All and totalCount are not specified. - if ((this.Name == null) && (!_all) && TotalCount == -1) + if ((this.Name == null) && (!_all) && TotalCount == -1 && !UseFuzzyMatching) { CommandTypes commandTypesToIgnore = 0; @@ -499,6 +508,11 @@ namespace Microsoft.PowerShell.Commands { CommandOrigin origin = this.MyInvocation.CommandOrigin; + if (UseFuzzyMatching) + { + results = _commandScores.OrderBy(x => x.Score).Select(x => x.Command).ToList(); + } + int count = 0; foreach (CommandInfo result in results) { @@ -715,6 +729,12 @@ namespace Microsoft.PowerShell.Commands } bool isPattern = WildcardPattern.ContainsWildcardCharacters(plainCommandName); + if (UseFuzzyMatching) + { + options |= SearchResolutionOptions.FuzzyMatch; + isPattern = true; + } + if (isPattern) { options |= SearchResolutionOptions.CommandNameIsPattern; @@ -756,7 +776,32 @@ namespace Microsoft.PowerShell.Commands { if (TotalCount < 0 || count < TotalCount) { - foreach (CommandInfo command in System.Management.Automation.Internal.ModuleUtils.GetMatchingCommands(plainCommandName, this.Context, this.MyInvocation.CommandOrigin, rediscoverImportedModules: true, moduleVersionRequired: _isFullyQualifiedModuleSpecified)) + IEnumerable commands; + if (UseFuzzyMatching) + { + foreach (var commandScore in System.Management.Automation.Internal.ModuleUtils.GetFuzzyMatchingCommands( + plainCommandName, + this.Context, + this.MyInvocation.CommandOrigin, + rediscoverImportedModules: true, + moduleVersionRequired: _isFullyQualifiedModuleSpecified)) + { + _commandScores.Add(commandScore); + } + + commands = _commandScores.Select(x => x.Command).ToList(); + } + else + { + commands = System.Management.Automation.Internal.ModuleUtils.GetMatchingCommands( + plainCommandName, + this.Context, + this.MyInvocation.CommandOrigin, + rediscoverImportedModules: true, + moduleVersionRequired: _isFullyQualifiedModuleSpecified); + } + + foreach (CommandInfo command in commands) { // Cannot pass in "command" by ref (foreach iteration variable) CommandInfo current = command; @@ -902,6 +947,12 @@ namespace Microsoft.PowerShell.Commands break; } + if (UseFuzzyMatching) + { + int score = FuzzyMatcher.GetDamerauLevenshteinDistance(current.Name, commandName); + _commandScores.Add(new CommandScore(current, score)); + } + _accumulatedResults.Add(current); if (ArgumentList != null) diff --git a/src/System.Management.Automation/engine/Modules/ModuleUtils.cs b/src/System.Management.Automation/engine/Modules/ModuleUtils.cs index 34c3348154..9214f56186 100644 --- a/src/System.Management.Automation/engine/Modules/ModuleUtils.cs +++ b/src/System.Management.Automation/engine/Modules/ModuleUtils.cs @@ -381,16 +381,38 @@ namespace System.Management.Automation.Internal #endif } + /// + /// Gets a list of fuzzy matching commands and their scores. + /// + /// Command pattern. + /// Execution context. + /// Command origin. + /// If true, rediscovers imported modules. + /// Specific module version to be required. + /// IEnumerable tuple containing the CommandInfo and the match score. + internal static IEnumerable GetFuzzyMatchingCommands(string pattern, ExecutionContext context, CommandOrigin commandOrigin, bool rediscoverImportedModules = false, bool moduleVersionRequired = false) + { + foreach (CommandInfo command in GetMatchingCommands(pattern, context, commandOrigin, rediscoverImportedModules, moduleVersionRequired, useFuzzyMatching: true)) + { + int score = FuzzyMatcher.GetDamerauLevenshteinDistance(command.Name, pattern); + if (score <= FuzzyMatcher.MinimumDistance) + { + yield return new CommandScore(command, score); + } + } + } + /// /// Gets a list of matching commands. /// /// Command pattern. - /// - /// - /// - /// - /// - internal static IEnumerable GetMatchingCommands(string pattern, ExecutionContext context, CommandOrigin commandOrigin, bool rediscoverImportedModules = false, bool moduleVersionRequired = false) + /// Execution context. + /// Command origin. + /// 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) { // Otherwise, if it had wildcards, just return the "AvailableCommand" // type of command info. @@ -400,9 +422,7 @@ namespace System.Management.Automation.Internal PSModuleAutoLoadingPreference moduleAutoLoadingPreference = CommandDiscovery.GetCommandDiscoveryPreference(context, SpecialVariables.PSModuleAutoLoadingPreferenceVarPath, "PSModuleAutoLoadingPreference"); if ((moduleAutoLoadingPreference != PSModuleAutoLoadingPreference.None) && - ((commandOrigin == CommandOrigin.Internal) || ((cmdletInfo != null) && (cmdletInfo.Visibility == SessionStateEntryVisibility.Public)) - ) - ) + ((commandOrigin == CommandOrigin.Internal) || ((cmdletInfo != null) && (cmdletInfo.Visibility == SessionStateEntryVisibility.Public)))) { foreach (string modulePath in GetDefaultAvailableModuleFiles(isForAutoDiscovery: false, context)) { @@ -429,7 +449,8 @@ namespace System.Management.Automation.Internal foreach (KeyValuePair entry in psModule.ExportedCommands) { - if (commandPattern.IsMatch(entry.Value.Name)) + if (commandPattern.IsMatch(entry.Value.Name) || + (useFuzzyMatching && FuzzyMatcher.IsFuzzyMatch(entry.Value.Name, pattern))) { CommandInfo current = null; switch (entry.Value.CommandType) @@ -487,7 +508,8 @@ namespace System.Management.Automation.Internal string commandName = pair.Key; CommandTypes commandTypes = pair.Value; - if (commandPattern.IsMatch(commandName)) + if (commandPattern.IsMatch(commandName) || + (useFuzzyMatching && FuzzyMatcher.IsFuzzyMatch(commandName, pattern))) { bool shouldExportCommand = true; @@ -557,5 +579,17 @@ namespace System.Management.Automation.Internal } } } + + internal struct CommandScore + { + public CommandScore(CommandInfo command, int score) + { + Command = command; + Score = score; + } + + public CommandInfo Command; + public int Score; + } } diff --git a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs index 4dfb970fff..40cf76739a 100644 --- a/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs +++ b/src/System.Management.Automation/engine/hostifaces/HostUtilities.cs @@ -21,9 +21,15 @@ namespace System.Management.Automation { internal enum SuggestionMatchType { + /// Match on a command. Command = 0, + /// Match based on exception message. Error = 1, - Dynamic = 2 + /// Match by running a script block. + Dynamic = 2, + + /// Match by fully qualified ErrorId. + ErrorId = 3 } #region Public HostUtilities Class @@ -58,6 +64,13 @@ namespace System.Management.Automation $formatString -f $lastError.TargetObject,"".\$($lastError.TargetObject)"" "; + private static string s_getFuzzyMatchedCommands = @" + [System.Diagnostics.DebuggerHidden()] + param([string] $formatString) + + $formatString -f [string]::Join(', ', (Get-Command $lastError.TargetObject -UseFuzzyMatch | Select-Object -First 10 -ExpandProperty Name)) + "; + private static ArrayList s_suggestions = new ArrayList( new Hashtable[] { NewSuggestion(1, "Transactions", SuggestionMatchType.Command, "^Start-Transaction", @@ -68,7 +81,15 @@ namespace System.Management.Automation ScriptBlock.CreateDelayParsedScriptBlock(s_checkForCommandInCurrentDirectoryScript, isProductCode: true), ScriptBlock.CreateDelayParsedScriptBlock(s_createCommandExistsInCurrentDirectoryScript, isProductCode: true), new object[] { CodeGeneration.EscapeSingleQuotedStringContent(SuggestionStrings.Suggestion_CommandExistsInCurrentDirectory) }, - true) + true), + NewSuggestion( + id: 4, + category: "General", + matchType: SuggestionMatchType.ErrorId, + rule: "CommandNotFoundException", + suggestion: ScriptBlock.CreateDelayParsedScriptBlock(s_getFuzzyMatchedCommands, isProductCode: true), + suggestionArgs: new object[] { CodeGeneration.EscapeSingleQuotedStringContent(SuggestionStrings.Suggestion_CommandNotFound) }, + enabled: true) } ); @@ -428,6 +449,13 @@ namespace System.Management.Automation } } } + else if (matchType == SuggestionMatchType.ErrorId) + { + if (lastError != null && lastError is ErrorRecord errorRecord) + { + matchText = errorRecord.FullyQualifiedErrorId; + } + } else { suggestion["Enabled"] = false; @@ -511,6 +539,13 @@ namespace System.Management.Automation /// /// Create suggestion with string rule and suggestion. /// + /// Identifier for the suggestion. + /// Category for the suggestion. + /// Suggestion match type. + /// Rule to match. + /// Suggestion to return. + /// True if the suggestion is enabled. + /// Hashtable representing the suggestion. private static Hashtable NewSuggestion(int id, string category, SuggestionMatchType matchType, string rule, string suggestion, bool enabled) { Hashtable result = new Hashtable(StringComparer.CurrentCultureIgnoreCase); @@ -525,6 +560,32 @@ namespace System.Management.Automation return result; } + /// + /// Create suggestion with string rule and scriptblock suggestion. + /// + /// Identifier for the suggestion. + /// Category for the suggestion. + /// Suggestion match type. + /// Rule to match. + /// Scriptblock to run that returns the suggestion. + /// Arguments to pass to suggestion scriptblock. + /// True if the suggestion is enabled. + /// Hashtable representing the suggestion. + private static Hashtable NewSuggestion(int id, string category, SuggestionMatchType matchType, string rule, ScriptBlock suggestion, object[] suggestionArgs, bool enabled) + { + Hashtable result = new Hashtable(StringComparer.CurrentCultureIgnoreCase); + + result["Id"] = id; + result["Category"] = category; + result["MatchType"] = matchType; + result["Rule"] = rule; + result["Suggestion"] = suggestion; + result["SuggestionArgs"] = suggestionArgs; + result["Enabled"] = enabled; + + return result; + } + /// /// Create suggestion with scriptblock rule and suggestion. /// diff --git a/src/System.Management.Automation/resources/SuggestionStrings.resx b/src/System.Management.Automation/resources/SuggestionStrings.resx index 66df4cfbde..cd44c33e75 100644 --- a/src/System.Management.Automation/resources/SuggestionStrings.resx +++ b/src/System.Management.Automation/resources/SuggestionStrings.resx @@ -126,6 +126,9 @@ The command {0} was not found, but does exist in the current location. PowerShell does not load commands from the current location by default. If you trust this command, instead type: "{1}". See "get-help about_Command_Precedence" for more details. + + The most similar commands are: {0}. + Rule must be a ScriptBlock for dynamic match types. diff --git a/src/System.Management.Automation/utils/FuzzyMatch.cs b/src/System.Management.Automation/utils/FuzzyMatch.cs new file mode 100644 index 0000000000..0c99370c10 --- /dev/null +++ b/src/System.Management.Automation/utils/FuzzyMatch.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; + +namespace System.Management.Automation +{ + internal static class FuzzyMatcher + { + public const int MinimumDistance = 5; + + /// + /// Determine if the two strings are considered similar. + /// + /// The first string to compare. + /// The second string to compare. + /// True if the two strings have a distance <= MinimumDistance. + public static bool IsFuzzyMatch(string string1, string string2) + { + return GetDamerauLevenshteinDistance(string1, string2) <= MinimumDistance; + } + + + /// + /// Compute the distance between two strings. + /// Based off https://www.csharpstar.com/csharp-string-distance-algorithm/. + /// + /// The first string to compare. + /// The second string to compare. + /// The distance value where the lower the value the shorter the distance between the two strings representing a closer match. + public static int GetDamerauLevenshteinDistance(string string1, string string2) + { + var bounds = new { Height = string1.Length + 1, Width = string2.Length + 1 }; + + int[,] matrix = new int[bounds.Height, bounds.Width]; + + for (int height = 0; height < bounds.Height; height++) { matrix[height, 0] = height; }; + for (int width = 0; width < bounds.Width; width++) { matrix[0, width] = width; }; + + for (int height = 1; height < bounds.Height; height++) + { + for (int width = 1; width < bounds.Width; width++) + { + int cost = (string1[height - 1] == string2[width - 1]) ? 0 : 1; + int insertion = matrix[height, width - 1] + 1; + int deletion = matrix[height - 1, width] + 1; + int substitution = matrix[height - 1, width - 1] + cost; + + int distance = Math.Min(insertion, Math.Min(deletion, substitution)); + + if (height > 1 && width > 1 && string1[height - 1] == string2[width - 2] && string1[height - 2] == string2[width - 1]) + { + distance = Math.Min(distance, matrix[height - 2, width - 2] + cost); + } + + matrix[height, width] = distance; + } + } + + return matrix[bounds.Height - 1, bounds.Width - 1]; + } + } +} diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 new file mode 100644 index 0000000000..83b255c2d4 --- /dev/null +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Get-Command.Tests.ps1 @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe "Get-Command CI tests" -Tag Feature { + Context "-UseFuzzyMatch tests" { + It "Should match cmdlets" { + $cmds = Get-Command get-hlp -UseFuzzyMatch + $cmds.Count | Should -BeGreaterThan 0 + $cmds[0].Name | Should -BeExactly 'Get-Help' -Because "This should be closest match so shows up first" + } + + It "Should match native commands" { + $ping = "ping" + if ($IsWindows) { + $ping = "PING.EXE" + } + + $cmds = Get-Command pin -UseFuzzyMatch + $cmds.Count | Should -BeGreaterThan 0 + $cmds.Name | Should -Contain $ping + } + } +}