Skip to content

Commit 6f266e8

Browse files
committed
Complete rewrite to support dynamic completions
1 parent bf80e3f commit 6f266e8

File tree

8 files changed

+267
-23
lines changed

8 files changed

+267
-23
lines changed

src/Cli/dotnet/CommonOptions.cs

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@
99

1010
namespace Microsoft.DotNet.Cli
1111
{
12+
/// <summary>
13+
/// Represents an Option whose completions are dynamically generated and so should not be emitted in static completion scripts.
14+
/// </summary>
15+
/// <typeparam name="T"></typeparam>
16+
internal class DynamicOption<T> : CliOption<T>
17+
{
18+
public DynamicOption(string name, params string[] aliases) : base(name, aliases)
19+
{
20+
}
21+
}
22+
23+
/// <summary>
24+
/// Represents an Argument whose completions are dynamically generated and so should not be emitted in static completion scripts.
25+
/// </summary>
26+
/// <typeparam name="T"></typeparam>
27+
internal class DynamicArgument<T> : CliArgument<T>
28+
{
29+
public DynamicArgument(string name) : base(name)
30+
{
31+
}
32+
}
33+
1234
internal static class CommonOptions
1335
{
1436
public static CliOption<string[]> PropertiesOption =
@@ -35,13 +57,13 @@ internal static class CommonOptions
3557
}.ForwardAsSingle(o => $"-verbosity:{o}");
3658

3759
public static CliOption<string> FrameworkOption(string description) =>
38-
new ForwardedOption<string>("--framework", "-f")
60+
new DynamicForwardedOption<string>("--framework", "-f")
3961
{
4062
Description = description,
4163
HelpName = CommonLocalizableStrings.FrameworkArgumentName
42-
43-
}.ForwardAsSingle(o => $"-property:TargetFramework={o}")
44-
.AddCompletions(Complete.TargetFrameworksFromProjectFile);
64+
}
65+
.AddCompletions(Complete.TargetFrameworksFromProjectFile)
66+
.ForwardAsSingle(o => $"-property:TargetFramework={o}");
4567

4668
public static CliOption<string> ArtifactsPathOption =
4769
new ForwardedOption<string>(
@@ -63,14 +85,14 @@ public static IEnumerable<string> RuntimeArgFunc(string rid)
6385
}
6486

6587
public static CliOption<string> RuntimeOption =
66-
new ForwardedOption<string>("--runtime", "-r")
88+
new DynamicForwardedOption<string>("--runtime", "-r")
6789
{
6890
HelpName = RuntimeArgName
6991
}.ForwardAsMany(RuntimeArgFunc)
7092
.AddCompletions(Complete.RunTimesFromProjectFile);
7193

7294
public static CliOption<string> LongFormRuntimeOption =
73-
new ForwardedOption<string>("--runtime")
95+
new DynamicForwardedOption<string>("--runtime")
7496
{
7597
HelpName = RuntimeArgName
7698
}.ForwardAsMany(RuntimeArgFunc)
@@ -83,7 +105,7 @@ public static CliOption<bool> CurrentRuntimeOption(string description) =>
83105
}.ForwardAs("-property:UseCurrentRuntimeIdentifier=True");
84106

85107
public static CliOption<string> ConfigurationOption(string description) =>
86-
new ForwardedOption<string>("--configuration", "-c")
108+
new DynamicForwardedOption<string>("--configuration", "-c")
87109
{
88110
Description = description,
89111
HelpName = CommonLocalizableStrings.ConfigurationArgumentName
@@ -277,6 +299,18 @@ internal static CliArgument<T> AddCompletions<T>(this CliArgument<T> argument, F
277299
argument.CompletionSources.Add(completionSource);
278300
return argument;
279301
}
302+
303+
internal static DynamicOption<T> AddCompletions<T>(this DynamicOption<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
304+
{
305+
option.CompletionSources.Add(completionSource);
306+
return option;
307+
}
308+
309+
internal static DynamicForwardedOption<T> AddCompletions<T>(this DynamicForwardedOption<T> option, Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
310+
{
311+
option.CompletionSources.Add(completionSource);
312+
return option;
313+
}
280314
}
281315

282316
public enum VerbosityOptions

src/Cli/dotnet/OptionForwardingExtensions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,14 @@ public Func<ParseResult, IEnumerable<string>> GetForwardingFunction()
147147
return ForwardingFunction;
148148
}
149149
}
150+
151+
public class DynamicForwardedOption<T> : ForwardedOption<T>
152+
{
153+
public DynamicForwardedOption(string name, Func<ArgumentResult, T> parseArgument, string description = null)
154+
: base(name, parseArgument, description)
155+
{
156+
}
157+
158+
public DynamicForwardedOption(string name, params string[] aliases) : base(name, aliases) { }
159+
}
150160
}

src/Cli/dotnet/commands/dotnet-add/dotnet-add-package/AddPackageParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ namespace Microsoft.DotNet.Cli
1212
{
1313
internal static class AddPackageParser
1414
{
15-
public static readonly CliArgument<string> CmdPackageArgument = new CliArgument<string>(LocalizableStrings.CmdPackage)
15+
public static readonly CliArgument<string> CmdPackageArgument = new DynamicArgument<string>(LocalizableStrings.CmdPackage)
1616
{
1717
Description = LocalizableStrings.CmdPackageDescription
1818
}.AddCompletions((context) => QueryNuGet(context.WordToComplete).Select(match => new CompletionItem(match)));

src/Cli/dotnet/commands/dotnet-add/dotnet-add-reference/AddProjectToProjectReferenceParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ internal static class AddProjectToProjectReferenceParser
1515
Arity = ArgumentArity.OneOrMore
1616
};
1717

18-
public static readonly CliOption<string> FrameworkOption = new CliOption<string>("--framework", "-f")
18+
public static readonly CliOption<string> FrameworkOption = new DynamicOption<string>("--framework", "-f")
1919
{
2020
Description = LocalizableStrings.CmdFrameworkDescription,
2121
HelpName = Tools.Add.PackageReference.LocalizableStrings.CmdFramework

src/Cli/dotnet/commands/dotnet-complete/CompleteCommandParser.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ internal static class CompleteCommandParser
1414
HelpName = "command"
1515
};
1616

17+
public static readonly CliOption<bool> Detailed = new("--detailed")
18+
{
19+
Hidden = true,
20+
Description = "Show detailed completion information"
21+
};
22+
1723
private static readonly CliCommand Command = ConstructCommand();
1824

1925
public static CliCommand GetCommand()

src/Cli/dotnet/commands/dotnet-completions/shells/Bash.cs

Lines changed: 206 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,221 @@
33

44
namespace Microsoft.DotNet.Cli.Completions.Shells;
55

6+
using System.CodeDom.Compiler;
7+
using System.CommandLine;
8+
using System.CommandLine.Completions;
9+
using System.IO;
10+
611
public class BashShellProvider : IShellProvider
712
{
813
public string ArgumentName => "bash";
914

10-
private static readonly string _dynamicCompletionScript =
11-
"""
12-
# bash parameter completion for the dotnet CLI
13-
# add this to your .bashrc, .bash_profile, .bash_login, or .profile to enable completion
15+
public string GenerateCompletions(System.CommandLine.CliCommand command)
16+
{
17+
var initialFunctionName = new[] { command }.FunctionName().MakeSafeFunctionName();
18+
return
19+
$"""
20+
#! /bin/bash
21+
{GenerateCommandsCompletions([command])}
22+
23+
complete -F {initialFunctionName} {command.Name}
24+
""";
25+
}
26+
27+
private string GenerateCommandsCompletions(CliCommand[] commands)
28+
{
29+
var command = commands.Last();
30+
var functionName = commands.FunctionName().MakeSafeFunctionName();
31+
32+
var isRootCommand = commands.Length == 1;
33+
var dollarOne = isRootCommand ? "1" : "$1";
34+
var subcommandArgument = isRootCommand ? "2" : "$(($1+1))";
35+
36+
// generate the words for options and subcommands
37+
var visibleSubcommands = command.Subcommands.Where(c => !c.Hidden).ToArray();
38+
var completionOptions = command.Options.Where(o => !o.Hidden).SelectMany(o => o.Names()).ToArray();
39+
var completionSubcommands = visibleSubcommands.Select(x => x.Name).ToArray();
40+
string[] completionWords = [.. completionSubcommands, .. completionOptions];
41+
42+
// for positional arguments this can be pretty dynamic
43+
var positionalArgumentCompletions = PositionalArgumentTerms(command.Arguments.Where(a => !a.Hidden).ToArray());
44+
45+
using var textWriter = new StringWriter { NewLine = "\n" };
46+
using var writer = new IndentedTextWriter(textWriter);
47+
48+
// write the overall completion function shell
49+
writer.WriteLine($"{functionName}() {{");
50+
writer.WriteLine();
51+
writer.Indent++;
52+
53+
// set up state
54+
writer.WriteLine("""cur="${COMP_WORDS[COMP_CWORD]}" """);
55+
writer.WriteLine("""prev="${COMP_WORDS[COMP_CWORD-1]}" """);
56+
writer.WriteLine("COMPREPLY=()");
57+
writer.WriteLine();
58+
59+
// fill in a set of completions for all of the subcommands and flag options for the top-level command
60+
writer.WriteLine($"""opts="{String.Join(' ', completionWords)}" """);
61+
foreach (var positionalArgumentCompletion in positionalArgumentCompletions)
62+
{
63+
writer.WriteLine($"""opts="$opts {positionalArgumentCompletion}" """);
64+
}
65+
writer.WriteLine();
66+
67+
// emit a short-circuit for when the first argument index (COMP_CWORD) is 1 (aka "dotnet")
68+
// in this short-circuit we'll just use the choices we set up above in $opts
69+
writer.WriteLine($"""if [[ $COMP_CWORD == {dollarOne} ]]; then""");
70+
writer.Indent++;
71+
writer.WriteLine("""COMPREPLY=( $(compgen -W "$opts" -- "$cur") )""");
72+
writer.WriteLine("return");
73+
writer.Indent--;
74+
writer.WriteLine("fi");
75+
writer.WriteLine();
76+
77+
// generate how to handle completions for options or flags
78+
var optionHandlers = GenerateOptionHandlers(command);
79+
if (optionHandlers is not null)
80+
{
81+
writer.WriteLine("case $prev in");
82+
writer.Indent++;
83+
foreach (var line in optionHandlers.Split('\n'))
84+
{
85+
writer.WriteLine(line);
86+
}
87+
writer.Indent--;
88+
writer.WriteLine("esac");
89+
writer.WriteLine();
90+
}
1491

15-
function _dotnet_bash_complete()
92+
// finally subcommand completions - these are going to emit calls to subcommand completion functions that we'll emit at the end of this method
93+
if (visibleSubcommands.Length > 0)
1694
{
17-
local cur="${COMP_WORDS[COMP_CWORD]}" IFS=$'\n' # On Windows you may need to use use IFS=$'\r\n'
18-
local candidates
95+
writer.WriteLine($"case ${{COMP_WORDS[{dollarOne}]}} in");
96+
writer.Indent++;
97+
foreach (var subcommand in visibleSubcommands)
98+
{
99+
writer.WriteLine($"({subcommand.Name})");
100+
writer.Indent++;
101+
writer.WriteLine($"{functionName}_{subcommand.Name} {subcommandArgument}");
102+
writer.WriteLine("return");
103+
writer.WriteLine(";;");
104+
writer.WriteLine();
105+
writer.Indent--;
106+
}
107+
writer.Indent--;
108+
writer.WriteLine("esac");
109+
writer.WriteLine();
110+
}
111+
112+
// write the final trailer for the overall completion script
113+
writer.WriteLine("""COMPREPLY=( $(compgen -W "$opts" -- "$cur") )""");
114+
writer.Indent--;
115+
writer.WriteLine("}");
116+
writer.WriteLine();
19117

20-
read -d '' -ra candidates < <(dotnet complete --position "${COMP_POINT}" "${COMP_LINE}" 2>/dev/null)
118+
// annnnd flush!
119+
writer.Flush();
120+
return textWriter.ToString() + String.Join('\n', visibleSubcommands.Select(c => GenerateCommandsCompletions([.. commands, c])));
121+
}
21122

22-
read -d '' -ra COMPREPLY < <(compgen -W "${candidates[*]:-}" -- "$cur")
123+
private static string[] PositionalArgumentTerms(CliArgument[] arguments)
124+
{
125+
var completions = new List<string>();
126+
foreach (var argument in arguments)
127+
{
128+
if (argument.GetType().GetGenericTypeDefinition() == typeof(DynamicArgument<int>).GetGenericTypeDefinition())
129+
{
130+
// if the argument is a not-static-friendly argument, we need to call into the app for completions
131+
completions.Add($"$({GenerateDynamicCall()})");
132+
continue;
133+
}
134+
var argCompletions = argument.GetCompletions(CompletionContext.Empty).Select(c => c.Label).ToArray();
135+
if (argCompletions.Length != 0)
136+
{
137+
// otherwise emit a direct list of choices
138+
completions.Add($"""({String.Join(' ', argCompletions)})""");
139+
}
23140
}
24141

25-
complete -f -F _dotnet_bash_complete dotnet
26-
""";
142+
return completions.ToArray();
143+
}
144+
145+
/// <summary>
146+
/// Generates a call to `dotnet complete <string> --position <int>` for dynamic completions where necessary, but in a more generic way
147+
/// </summary>
148+
/// <returns></returns>
149+
private static string GenerateDynamicCall()
150+
{
151+
return $$"""${COMP_WORDS[0]} complete --position "${COMP_POINT}" "${COMP_LINE}" 2>/dev/null | tr '\n' ' '""";
152+
}
153+
154+
private static string GenerateOptionHandlers(CliCommand command)
155+
{
156+
var optionHandlers = command.Options.Where(o => !o.Hidden).Select(GenerateOptionHandler);
157+
return String.Join("\n", optionHandlers);
158+
}
159+
160+
private static string GenerateOptionHandler(CliOption option)
161+
{
162+
var optionNames = String.Join('|', option.Names());
163+
string completionCommand;
164+
if (option.GetType().IsGenericType &&
165+
(option.GetType().GetGenericTypeDefinition() == typeof(DynamicOption<int>).GetGenericTypeDefinition()
166+
|| option.GetType().GetGenericTypeDefinition() == typeof(DynamicForwardedOption<string>).GetGenericTypeDefinition()))
167+
{
168+
// dynamic options require a call into the app for completions
169+
completionCommand = $$"""COMPREPLY=( $(compgen -W "$({{GenerateDynamicCall()}})" -- "$cur") )""";
170+
}
171+
else
172+
{
173+
var completions = option.GetCompletions(CompletionContext.Empty).Select(c => c.Label);
174+
if (completions.Count() == 0)
175+
{
176+
// if no completions, assume that we need to call into the app for completions
177+
completionCommand = "";
178+
}
179+
else
180+
{
181+
// otherwise emit a direct list of choices
182+
completionCommand = $"""COMPREPLY=( $(compgen -W "{String.Join(' ', completions)}" -- "$cur") )""";
183+
}
184+
}
185+
186+
return $"""
187+
{optionNames})
188+
{completionCommand}
189+
return
190+
;;
191+
""";
192+
}
193+
}
194+
195+
196+
public static class HelpExtensions
197+
{
198+
public static string FunctionName(this CliCommand[] commands) => "_" + String.Join('_', commands.Select(c => c.Name));
199+
public static string MakeSafeFunctionName(this string functionName) => functionName.Replace('-', '_');
200+
public static string[] Names(this CliOption option)
201+
{
202+
if (option.Aliases.Count == 0)
203+
{
204+
return [option.Name];
205+
}
206+
else
207+
{
208+
return [option.Name, .. option.Aliases];
209+
}
210+
}
211+
public static string[] Names(this CliCommand command)
212+
{
213+
if (command.Aliases.Count == 0)
214+
{
215+
return [command.Name];
216+
}
217+
else
218+
{
219+
return [command.Name, .. command.Aliases];
220+
}
221+
}
27222

28-
public string GenerateCompletions(System.CommandLine.CliCommand command) => _dynamicCompletionScript;
29223
}

src/Cli/dotnet/commands/dotnet-remove/dotnet-remove-reference/RemoveProjectToProjectReferenceParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ namespace Microsoft.DotNet.Cli
1010
{
1111
internal static class RemoveProjectToProjectReferenceParser
1212
{
13-
public static readonly CliArgument<IEnumerable<string>> ProjectPathArgument = new CliArgument<IEnumerable<string>>(LocalizableStrings.ProjectPathArgumentName)
13+
public static readonly CliArgument<IEnumerable<string>> ProjectPathArgument = new DynamicArgument<IEnumerable<string>>(LocalizableStrings.ProjectPathArgumentName)
1414
{
1515
Description = LocalizableStrings.ProjectPathArgumentDescription,
1616
Arity = ArgumentArity.OneOrMore,

src/Cli/dotnet/commands/dotnet-restore/RestoreCommandParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ private static IEnumerable<CliOption> ImplicitRestoreOptions(bool showHelp, bool
180180

181181
if (includeRuntimeOption)
182182
{
183-
CliOption<IEnumerable<string>> runtimeOption = new ForwardedOption<IEnumerable<string>>("--runtime")
183+
CliOption<IEnumerable<string>> runtimeOption = new DynamicForwardedOption<IEnumerable<string>>("--runtime")
184184
{
185185
Description = LocalizableStrings.CmdRuntimeOptionDescription,
186186
HelpName = LocalizableStrings.CmdRuntimeOption,

0 commit comments

Comments
 (0)