diff --git a/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs b/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs index ed7a07261c..671ae5a330 100644 --- a/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs +++ b/src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs @@ -154,7 +154,7 @@ public void Dotnet_suggest_provides_suggestions_for_app_with_only_commandname() stdOut.ToString() .Should() - .Be($"--apple{NewLine}--banana{NewLine}--cherry{NewLine}--durian{NewLine}--help{NewLine}--version{NewLine}-?{NewLine}-h{NewLine}/?{NewLine}/h{NewLine}"); + .Be($"--apple{NewLine}--banana{NewLine}--cherry{NewLine}--durian{NewLine}--help{NewLine}--version{NewLine}"); } } } diff --git a/src/System.CommandLine/Command.cs b/src/System.CommandLine/Command.cs index 4f9cfc7cd8..c6d82166c2 100644 --- a/src/System.CommandLine/Command.cs +++ b/src/System.CommandLine/Command.cs @@ -182,13 +182,13 @@ public override IEnumerable GetCompletions(CompletionContext con var commands = Subcommands; for (int i = 0; i < commands.Count; i++) { - AddCompletionsFor(commands[i]); + AddCompletionFor(commands[i], textToMatch); } var options = Options; for (int i = 0; i < options.Count; i++) { - AddCompletionsFor(options[i]); + AddCompletionFor(options[i], textToMatch); } var arguments = Arguments; @@ -214,7 +214,7 @@ public override IEnumerable GetCompletions(CompletionContext con if (option.IsGlobal) { - AddCompletionsFor(option); + AddCompletionFor(option, textToMatch); } } } @@ -225,17 +225,31 @@ public override IEnumerable GetCompletions(CompletionContext con .OrderBy(item => item.SortText.IndexOfCaseInsensitive(context.WordToComplete)) .ThenBy(symbol => symbol.Label, StringComparer.OrdinalIgnoreCase); - void AddCompletionsFor(IdentifierSymbol identifier) + // 'best' is a bit of a misnomer here. We want to return one and only one completion itme for each option, + // but depending on what has already been entered by the user the algorithm changes. + // For empty input, we return the longest alias of the set of aliases (this matches the DefaultName logic in Option.cs, but does not remove + // any prefixes (--, etc)). + // For nonempty input, we find all tokens that contain the input and then return the first one sorted by: + // * shortest Levenstein distance, then by + // * longest common startswith substring + string? FindBestCompletionFor(string textToMatch, IReadOnlyCollection aliases) => textToMatch switch + { +#if NET6_0_OR_GREATER + "" => aliases.MaxBy(a => a.Length), // find the longest alias + (string stem) => aliases.Where(a => a.Contains(stem, StringComparison.OrdinalIgnoreCase)).OrderByDescending(a => TokenDistances.GetLevensteinDistance(stem, a)).ThenByDescending(a => TokenDistances.GetStartsWithDistance(stem, a)).FirstOrDefault() +#else + "" => aliases.OrderByDescending(a => a.Length).FirstOrDefault(), // find the longest alias + (string stem) => aliases.Where(a => a.Contains(stem)).OrderByDescending(a => TokenDistances.GetLevensteinDistance(stem, a)).ThenByDescending(a => TokenDistances.GetStartsWithDistance(stem, a)).FirstOrDefault() +#endif + }; + + void AddCompletionFor(IdentifierSymbol identifier, string textToMatch) { if (!identifier.IsHidden) - { - foreach (var alias in identifier.Aliases) - { - if (alias is { } && - alias.ContainsCaseInsensitive(textToMatch)) - { - completions.Add(new CompletionItem(alias, CompletionItemKind.Keyword, detail: identifier.Description)); - } + { + var bestAlias = FindBestCompletionFor(textToMatch, identifier.Aliases); + if (bestAlias is not null) { + completions.Add(new CompletionItem(bestAlias, CompletionItemKind.Keyword, detail: identifier.Description)); } } } diff --git a/src/System.CommandLine/Invocation/TypoCorrection.cs b/src/System.CommandLine/Invocation/TypoCorrection.cs index 7cf8e72f6b..0601666ca7 100644 --- a/src/System.CommandLine/Invocation/TypoCorrection.cs +++ b/src/System.CommandLine/Invocation/TypoCorrection.cs @@ -49,17 +49,17 @@ private IEnumerable GetPossibleTokens(Command targetSymbol, string token .Select(symbol => symbol.Aliases .Union(symbol.Aliases) - .OrderBy(x => GetDistance(token, x)) - .ThenByDescending(x => GetStartsWithDistance(token, x)) + .OrderBy(x => TokenDistances.GetLevensteinDistance(token, x)) + .ThenByDescending(x => TokenDistances.GetStartsWithDistance(token, x)) .First() ); int? bestDistance = null; return possibleMatches - .Select(possibleMatch => (possibleMatch, distance:GetDistance(token, possibleMatch))) + .Select(possibleMatch => (possibleMatch, distance: TokenDistances.GetLevensteinDistance(token, possibleMatch))) .Where(tuple => tuple.distance <= _maxLevenshteinDistance) .OrderBy(tuple => tuple.distance) - .ThenByDescending(tuple => GetStartsWithDistance(token, tuple.possibleMatch)) + .ThenByDescending(tuple => TokenDistances.GetStartsWithDistance(token, tuple.possibleMatch)) .TakeWhile(tuple => { var (_, distance) = tuple; @@ -71,82 +71,5 @@ private IEnumerable GetPossibleTokens(Command targetSymbol, string token }) .Select(tuple => tuple.possibleMatch); } - - private static int GetStartsWithDistance(string first, string second) - { - int i; - for (i = 0; i < first.Length && i < second.Length && first[i] == second[i]; i++) - { } - return i; - } - - //Based on https://blogs.msdn.microsoft.com/toub/2006/05/05/generic-levenshtein-edit-distance-with-c/ - private static int GetDistance(string first, string second) - { - // Validate parameters - if (first is null) - { - throw new ArgumentNullException(nameof(first)); - } - - if (second is null) - { - throw new ArgumentNullException(nameof(second)); - } - - - // Get the length of both. If either is 0, return - // the length of the other, since that number of insertions - // would be required. - - int n = first.Length, m = second.Length; - if (n == 0) return m; - if (m == 0) return n; - - - // Rather than maintain an entire matrix (which would require O(n*m) space), - // just store the current row and the next row, each of which has a length m+1, - // so just O(m) space. Initialize the current row. - - int curRow = 0, nextRow = 1; - int[][] rows = { new int[m + 1], new int[m + 1] }; - - for (int j = 0; j <= m; ++j) - { - rows[curRow][j] = j; - } - - // For each virtual row (since we only have physical storage for two) - for (int i = 1; i <= n; ++i) - { - // Fill in the values in the row - rows[nextRow][0] = i; - for (int j = 1; j <= m; ++j) - { - int dist1 = rows[curRow][j] + 1; - int dist2 = rows[nextRow][j - 1] + 1; - int dist3 = rows[curRow][j - 1] + (first[i - 1].Equals(second[j - 1]) ? 0 : 1); - - rows[nextRow][j] = Math.Min(dist1, Math.Min(dist2, dist3)); - } - - - // Swap the current and next rows - if (curRow == 0) - { - curRow = 1; - nextRow = 0; - } - else - { - curRow = 0; - nextRow = 1; - } - } - - // Return the computed edit distance - return rows[curRow][m]; - - } } } diff --git a/src/System.CommandLine/Utilities.cs b/src/System.CommandLine/Utilities.cs new file mode 100644 index 0000000000..9a54d0148b --- /dev/null +++ b/src/System.CommandLine/Utilities.cs @@ -0,0 +1,86 @@ +using System.CommandLine.Parsing; +using System.IO; + +namespace System.CommandLine; + +internal static class TokenDistances +{ + + //Based on https://blogs.msdn.microsoft.com/toub/2006/05/05/generic-levenshtein-edit-distance-with-c/ + public static int GetLevensteinDistance(string first, string second) + { + // Validate parameters + if (first is null) + { + throw new ArgumentNullException(nameof(first)); + } + + if (second is null) + { + throw new ArgumentNullException(nameof(second)); + } + + + // Get the length of both. If either is 0, return + // the length of the other, since that number of insertions + // would be required. + + int n = first.Length, m = second.Length; + if (n == 0) return m; + if (m == 0) return n; + + + // Rather than maintain an entire matrix (which would require O(n*m) space), + // just store the current row and the next row, each of which has a length m+1, + // so just O(m) space. Initialize the current row. + + int curRow = 0, nextRow = 1; + int[][] rows = { new int[m + 1], new int[m + 1] }; + + for (int j = 0; j <= m; ++j) + { + rows[curRow][j] = j; + } + + // For each virtual row (since we only have physical storage for two) + for (int i = 1; i <= n; ++i) + { + // Fill in the values in the row + rows[nextRow][0] = i; + for (int j = 1; j <= m; ++j) + { + int dist1 = rows[curRow][j] + 1; + int dist2 = rows[nextRow][j - 1] + 1; + int dist3 = rows[curRow][j - 1] + (first[i - 1].Equals(second[j - 1]) ? 0 : 1); + + rows[nextRow][j] = Math.Min(dist1, Math.Min(dist2, dist3)); + } + + + // Swap the current and next rows + if (curRow == 0) + { + curRow = 1; + nextRow = 0; + } + else + { + curRow = 0; + nextRow = 1; + } + } + + // Return the computed edit distance + return rows[curRow][m]; + + } + + ///Measures the length of the common starting substring of two strings + public static int GetStartsWithDistance(string first, string second) + { + int i; + for (i = 0; i < first.Length && i < second.Length && first[i] == second[i]; i++) + { } + return i; + } +}