Add support to show suggestions on CommandNotFound exception (#8458)

* support fuzzy matching with get-command and on CommandNotFound exception, show suggestion
* fix CodeFactor issues
* change algorithm to use Damerau-Levenshtein Distance which is more useful in finding close matches
This commit is contained in:
Steve Lee
2019-01-10 13:35:30 -08:00
committed by Travis Plunk
parent a3d55783cd
commit b87cd1c0a3
8 changed files with 292 additions and 24 deletions
@@ -44,8 +44,10 @@ namespace System.Management.Automation
string commandName,
IEnumerable<string> lookupPaths,
ExecutionContext context,
Collection<string> acceptableCommandNames)
Collection<string> 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<string>();
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<string> _acceptableCommandNames;
private bool _useFuzzyMatch = false;
#endregion private members
}
}
@@ -659,7 +659,9 @@ namespace System.Management.Automation
foreach (KeyValuePair<string, AliasInfo> 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,
/// <summary>Use fuzzy matching.</summary>
FuzzyMatch = 0x10,
}
}
@@ -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;
/// <summary>
/// Gets or sets the parameter that enables using fuzzy matching.
/// </summary>
[Parameter(ParameterSetName = "AllCommandSet")]
public SwitchParameter UseFuzzyMatching { get; set; }
private List<CommandScore> _commandScores = new List<CommandScore>();
#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<CommandInfo> 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)
@@ -381,16 +381,38 @@ namespace System.Management.Automation.Internal
#endif
}
/// <summary>
/// Gets a list of fuzzy matching commands and their scores.
/// </summary>
/// <param name="pattern">Command pattern.</param>
/// <param name="context">Execution context.</param>
/// <param name="commandOrigin">Command origin.</param>
/// <param name="rediscoverImportedModules">If true, rediscovers imported modules.</param>
/// <param name="moduleVersionRequired">Specific module version to be required.</param>
/// <returns>IEnumerable tuple containing the CommandInfo and the match score.</returns>
internal static IEnumerable<CommandScore> 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);
}
}
}
/// <summary>
/// Gets a list of matching commands.
/// </summary>
/// <param name="pattern">Command pattern.</param>
/// <param name="commandOrigin"></param>
/// <param name="context"></param>
/// <param name="rediscoverImportedModules"></param>
/// <param name="moduleVersionRequired"></param>
/// <returns></returns>
internal static IEnumerable<CommandInfo> GetMatchingCommands(string pattern, ExecutionContext context, CommandOrigin commandOrigin, bool rediscoverImportedModules = false, bool moduleVersionRequired = false)
/// <param name="context">Execution context.</param>
/// <param name="commandOrigin">Command origin.</param>
/// <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)
{
// 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<string, CommandInfo> 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;
}
}
@@ -21,9 +21,15 @@ namespace System.Management.Automation
{
internal enum SuggestionMatchType
{
/// <summary>Match on a command.</summary>
Command = 0,
/// <summary>Match based on exception message.</summary>
Error = 1,
Dynamic = 2
/// <summary>Match by running a script block.</summary>
Dynamic = 2,
/// <summary>Match by fully qualified ErrorId.</summary>
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
/// <summary>
/// Create suggestion with string rule and suggestion.
/// </summary>
/// <param name="id">Identifier for the suggestion.</param>
/// <param name="category">Category for the suggestion.</param>
/// <param name="matchType">Suggestion match type.</param>
/// <param name="rule">Rule to match.</param>
/// <param name="suggestion">Suggestion to return.</param>
/// <param name="enabled">True if the suggestion is enabled.</param>
/// <returns>Hashtable representing the suggestion.</returns>
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;
}
/// <summary>
/// Create suggestion with string rule and scriptblock suggestion.
/// </summary>
/// <param name="id">Identifier for the suggestion.</param>
/// <param name="category">Category for the suggestion.</param>
/// <param name="matchType">Suggestion match type.</param>
/// <param name="rule">Rule to match.</param>
/// <param name="suggestion">Scriptblock to run that returns the suggestion.</param>
/// <param name="suggestionArgs">Arguments to pass to suggestion scriptblock.</param>
/// <param name="enabled">True if the suggestion is enabled.</param>
/// <returns>Hashtable representing the suggestion.</returns>
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;
}
/// <summary>
/// Create suggestion with scriptblock rule and suggestion.
/// </summary>
@@ -126,6 +126,9 @@
<data name="Suggestion_CommandExistsInCurrentDirectory" xml:space="preserve">
<value>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.</value>
</data>
<data name="Suggestion_CommandNotFound" xml:space="preserve">
<value>The most similar commands are: {0}.</value>
</data>
<data name="RuleMustBeScriptBlock" xml:space="preserve">
<value>Rule must be a ScriptBlock for dynamic match types.</value>
</data>
@@ -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;
/// <summary>
/// Determine if the two strings are considered similar.
/// </summary>
/// <param name="string1">The first string to compare.</param>
/// <param name="string2">The second string to compare.</param>
/// <returns>True if the two strings have a distance <= MinimumDistance.</returns>
public static bool IsFuzzyMatch(string string1, string string2)
{
return GetDamerauLevenshteinDistance(string1, string2) <= MinimumDistance;
}
/// <summary>
/// Compute the distance between two strings.
/// Based off https://www.csharpstar.com/csharp-string-distance-algorithm/.
/// </summary>
/// <param name="string1">The first string to compare.</param>
/// <param name="string2">The second string to compare.</param>
/// <returns>The distance value where the lower the value the shorter the distance between the two strings representing a closer match.</returns>
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];
}
}
}
@@ -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
}
}
}