Skip to content

Commit 679c60d

Browse files
committed
use single best completion item for commands, options, and global options
1 parent 6c71bd7 commit 679c60d

File tree

4 files changed

+117
-94
lines changed

4 files changed

+117
-94
lines changed

src/System.CommandLine.Suggest.Tests/DotnetSuggestEndToEndTests.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ public void Dotnet_suggest_provides_suggestions_for_app_with_only_commandname()
154154

155155
stdOut.ToString()
156156
.Should()
157-
.Be($"--apple{NewLine}--banana{NewLine}--cherry{NewLine}--durian{NewLine}--help{NewLine}--version{NewLine}-?{NewLine}-h{NewLine}/?{NewLine}/h{NewLine}");
157+
.Be($"--apple{NewLine}--banana{NewLine}--cherry{NewLine}--durian{NewLine}--help{NewLine}--version{NewLine}");
158158
}
159159
}
160160
}

src/System.CommandLine/Command.cs

+26-12
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,13 @@ public override IEnumerable<CompletionItem> GetCompletions(CompletionContext con
182182
var commands = Subcommands;
183183
for (int i = 0; i < commands.Count; i++)
184184
{
185-
AddCompletionsFor(commands[i]);
185+
AddCompletionFor(commands[i], textToMatch);
186186
}
187187

188188
var options = Options;
189189
for (int i = 0; i < options.Count; i++)
190190
{
191-
AddCompletionsFor(options[i]);
191+
AddCompletionFor(options[i], textToMatch);
192192
}
193193

194194
var arguments = Arguments;
@@ -214,7 +214,7 @@ public override IEnumerable<CompletionItem> GetCompletions(CompletionContext con
214214

215215
if (option.IsGlobal)
216216
{
217-
AddCompletionsFor(option);
217+
AddCompletionFor(option, textToMatch);
218218
}
219219
}
220220
}
@@ -225,17 +225,31 @@ public override IEnumerable<CompletionItem> GetCompletions(CompletionContext con
225225
.OrderBy(item => item.SortText.IndexOfCaseInsensitive(context.WordToComplete))
226226
.ThenBy(symbol => symbol.Label, StringComparer.OrdinalIgnoreCase);
227227

228-
void AddCompletionsFor(IdentifierSymbol identifier)
228+
// 'best' is a bit of a misnomer here. We want to return one and only one completion itme for each option,
229+
// but depending on what has already been entered by the user the algorithm changes.
230+
// 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
231+
// any prefixes (--, etc)).
232+
// For nonempty input, we find all tokens that contain the input and then return the first one sorted by:
233+
// * shortest Levenstein distance, then by
234+
// * longest common startswith substring
235+
string? FindBestCompletionFor(string textToMatch, IReadOnlyCollection<string> aliases) => textToMatch switch
236+
{
237+
#if NET6_0_OR_GREATER
238+
"" => aliases.MaxBy(a => a.Length), // find the longest alias
239+
(string stem) => aliases.Where(a => a.Contains(stem, StringComparison.OrdinalIgnoreCase)).OrderByDescending(a => TokenDistances.GetLevensteinDistance(stem, a)).ThenByDescending(a => TokenDistances.GetStartsWithDistance(stem, a)).FirstOrDefault()
240+
#else
241+
"" => aliases.OrderByDescending(a => a.Length).FirstOrDefault(), // find the longest alias
242+
(string stem) => aliases.Where(a => a.Contains(stem)).OrderByDescending(a => TokenDistances.GetLevensteinDistance(stem, a)).ThenByDescending(a => TokenDistances.GetStartsWithDistance(stem, a)).FirstOrDefault()
243+
#endif
244+
};
245+
246+
void AddCompletionFor(IdentifierSymbol identifier, string textToMatch)
229247
{
230248
if (!identifier.IsHidden)
231-
{
232-
foreach (var alias in identifier.Aliases)
233-
{
234-
if (alias is { } &&
235-
alias.ContainsCaseInsensitive(textToMatch))
236-
{
237-
completions.Add(new CompletionItem(alias, CompletionItemKind.Keyword, detail: identifier.Description));
238-
}
249+
{
250+
var bestAlias = FindBestCompletionFor(textToMatch, identifier.Aliases);
251+
if (bestAlias is not null) {
252+
completions.Add(new CompletionItem(bestAlias, CompletionItemKind.Keyword, detail: identifier.Description));
239253
}
240254
}
241255
}

src/System.CommandLine/Invocation/TypoCorrection.cs

+4-81
Original file line numberDiff line numberDiff line change
@@ -49,17 +49,17 @@ private IEnumerable<string> GetPossibleTokens(Command targetSymbol, string token
4949
.Select(symbol =>
5050
symbol.Aliases
5151
.Union(symbol.Aliases)
52-
.OrderBy(x => GetDistance(token, x))
53-
.ThenByDescending(x => GetStartsWithDistance(token, x))
52+
.OrderBy(x => TokenDistances.GetLevensteinDistance(token, x))
53+
.ThenByDescending(x => TokenDistances.GetStartsWithDistance(token, x))
5454
.First()
5555
);
5656

5757
int? bestDistance = null;
5858
return possibleMatches
59-
.Select(possibleMatch => (possibleMatch, distance:GetDistance(token, possibleMatch)))
59+
.Select(possibleMatch => (possibleMatch, distance: TokenDistances.GetLevensteinDistance(token, possibleMatch)))
6060
.Where(tuple => tuple.distance <= _maxLevenshteinDistance)
6161
.OrderBy(tuple => tuple.distance)
62-
.ThenByDescending(tuple => GetStartsWithDistance(token, tuple.possibleMatch))
62+
.ThenByDescending(tuple => TokenDistances.GetStartsWithDistance(token, tuple.possibleMatch))
6363
.TakeWhile(tuple =>
6464
{
6565
var (_, distance) = tuple;
@@ -71,82 +71,5 @@ private IEnumerable<string> GetPossibleTokens(Command targetSymbol, string token
7171
})
7272
.Select(tuple => tuple.possibleMatch);
7373
}
74-
75-
private static int GetStartsWithDistance(string first, string second)
76-
{
77-
int i;
78-
for (i = 0; i < first.Length && i < second.Length && first[i] == second[i]; i++)
79-
{ }
80-
return i;
81-
}
82-
83-
//Based on https://blogs.msdn.microsoft.com/toub/2006/05/05/generic-levenshtein-edit-distance-with-c/
84-
private static int GetDistance(string first, string second)
85-
{
86-
// Validate parameters
87-
if (first is null)
88-
{
89-
throw new ArgumentNullException(nameof(first));
90-
}
91-
92-
if (second is null)
93-
{
94-
throw new ArgumentNullException(nameof(second));
95-
}
96-
97-
98-
// Get the length of both. If either is 0, return
99-
// the length of the other, since that number of insertions
100-
// would be required.
101-
102-
int n = first.Length, m = second.Length;
103-
if (n == 0) return m;
104-
if (m == 0) return n;
105-
106-
107-
// Rather than maintain an entire matrix (which would require O(n*m) space),
108-
// just store the current row and the next row, each of which has a length m+1,
109-
// so just O(m) space. Initialize the current row.
110-
111-
int curRow = 0, nextRow = 1;
112-
int[][] rows = { new int[m + 1], new int[m + 1] };
113-
114-
for (int j = 0; j <= m; ++j)
115-
{
116-
rows[curRow][j] = j;
117-
}
118-
119-
// For each virtual row (since we only have physical storage for two)
120-
for (int i = 1; i <= n; ++i)
121-
{
122-
// Fill in the values in the row
123-
rows[nextRow][0] = i;
124-
for (int j = 1; j <= m; ++j)
125-
{
126-
int dist1 = rows[curRow][j] + 1;
127-
int dist2 = rows[nextRow][j - 1] + 1;
128-
int dist3 = rows[curRow][j - 1] + (first[i - 1].Equals(second[j - 1]) ? 0 : 1);
129-
130-
rows[nextRow][j] = Math.Min(dist1, Math.Min(dist2, dist3));
131-
}
132-
133-
134-
// Swap the current and next rows
135-
if (curRow == 0)
136-
{
137-
curRow = 1;
138-
nextRow = 0;
139-
}
140-
else
141-
{
142-
curRow = 0;
143-
nextRow = 1;
144-
}
145-
}
146-
147-
// Return the computed edit distance
148-
return rows[curRow][m];
149-
150-
}
15174
}
15275
}

src/System.CommandLine/Utilities.cs

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.CommandLine.Parsing;
2+
using System.IO;
3+
4+
namespace System.CommandLine;
5+
6+
internal static class TokenDistances
7+
{
8+
9+
//Based on https://blogs.msdn.microsoft.com/toub/2006/05/05/generic-levenshtein-edit-distance-with-c/
10+
public static int GetLevensteinDistance(string first, string second)
11+
{
12+
// Validate parameters
13+
if (first is null)
14+
{
15+
throw new ArgumentNullException(nameof(first));
16+
}
17+
18+
if (second is null)
19+
{
20+
throw new ArgumentNullException(nameof(second));
21+
}
22+
23+
24+
// Get the length of both. If either is 0, return
25+
// the length of the other, since that number of insertions
26+
// would be required.
27+
28+
int n = first.Length, m = second.Length;
29+
if (n == 0) return m;
30+
if (m == 0) return n;
31+
32+
33+
// Rather than maintain an entire matrix (which would require O(n*m) space),
34+
// just store the current row and the next row, each of which has a length m+1,
35+
// so just O(m) space. Initialize the current row.
36+
37+
int curRow = 0, nextRow = 1;
38+
int[][] rows = { new int[m + 1], new int[m + 1] };
39+
40+
for (int j = 0; j <= m; ++j)
41+
{
42+
rows[curRow][j] = j;
43+
}
44+
45+
// For each virtual row (since we only have physical storage for two)
46+
for (int i = 1; i <= n; ++i)
47+
{
48+
// Fill in the values in the row
49+
rows[nextRow][0] = i;
50+
for (int j = 1; j <= m; ++j)
51+
{
52+
int dist1 = rows[curRow][j] + 1;
53+
int dist2 = rows[nextRow][j - 1] + 1;
54+
int dist3 = rows[curRow][j - 1] + (first[i - 1].Equals(second[j - 1]) ? 0 : 1);
55+
56+
rows[nextRow][j] = Math.Min(dist1, Math.Min(dist2, dist3));
57+
}
58+
59+
60+
// Swap the current and next rows
61+
if (curRow == 0)
62+
{
63+
curRow = 1;
64+
nextRow = 0;
65+
}
66+
else
67+
{
68+
curRow = 0;
69+
nextRow = 1;
70+
}
71+
}
72+
73+
// Return the computed edit distance
74+
return rows[curRow][m];
75+
76+
}
77+
78+
///<summary>Measures the length of the common starting substring of two strings</summary>
79+
public static int GetStartsWithDistance(string first, string second)
80+
{
81+
int i;
82+
for (i = 0; i < first.Length && i < second.Length && first[i] == second[i]; i++)
83+
{ }
84+
return i;
85+
}
86+
}

0 commit comments

Comments
 (0)