Skip to content

Commit e76657c

Browse files
Make S.CL builder DSLs more transferable (#50685)
Co-authored-by: Marc Paine <marcpop@microsoft.com>
1 parent ec0b905 commit e76657c

File tree

99 files changed

+1017
-595
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

99 files changed

+1017
-595
lines changed

sdk.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
</Folder>
6464
<Folder Name="/src/Cli/">
6565
<Project Path="src/Cli/dotnet/dotnet.csproj" />
66+
<Project Path="src/Cli/Microsoft.DotNet.Cli.CommandLine/Microsoft.DotNet.Cli.CommandLine.csproj" />
6667
<Project Path="src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj" />
6768
<Project Path="src/Cli/Microsoft.DotNet.Configurer/Microsoft.DotNet.Configurer.csproj" />
6869
<Project Path="src/Cli/Microsoft.DotNet.InternalAbstractions/Microsoft.DotNet.InternalAbstractions.csproj" />

src/BuiltInTools/dotnet-watch/CommandLine/CommandLineOptions.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using System.Diagnostics;
99
using Microsoft.Build.Logging;
1010
using Microsoft.DotNet.Cli;
11+
using Microsoft.DotNet.Cli.CommandLine;
1112
using Microsoft.DotNet.Cli.Commands.Run;
1213
using Microsoft.DotNet.Cli.Extensions;
1314
using Microsoft.Extensions.Logging;
@@ -119,7 +120,7 @@ internal sealed class CommandLineOptions
119120
// determine subcommand:
120121
var explicitCommand = TryGetSubcommand(parseResult);
121122
var command = explicitCommand ?? RunCommandParser.GetCommand();
122-
var buildOptions = command.Options.Where(o => o is IForwardedOption);
123+
var buildOptions = command.Options.Where(o => o.ForwardingFunction is not null);
123124

124125
foreach (var buildOption in buildOptions)
125126
{
@@ -161,7 +162,7 @@ internal sealed class CommandLineOptions
161162
var commandArguments = GetCommandArguments(parseResult, watchOptions, explicitCommand, out var binLogToken, out var binLogPath);
162163

163164
// We assume that forwarded options, if any, are intended for dotnet build.
164-
var buildArguments = buildOptions.Select(option => ((IForwardedOption)option).GetForwardingFunction()(parseResult)).SelectMany(args => args).ToList();
165+
var buildArguments = buildOptions.Select(option => option.ForwardingFunction!(parseResult)).SelectMany(args => args).ToList();
165166

166167
if (binLogToken != null)
167168
{
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Completions;
3+
4+
namespace Microsoft.DotNet.Cli.CommandLine;
5+
6+
/// <summary>
7+
/// Extension methods that make it easier to chain argument configuration methods when building arguments.
8+
/// </summary>
9+
public static class ArgumentBuilderExtensions
10+
{
11+
extension<T>(Argument<T> argument)
12+
{
13+
public Argument<T> AddCompletions(Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
14+
{
15+
argument.CompletionSources.Add(completionSource);
16+
return argument;
17+
}
18+
}
19+
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Parsing;
3+
4+
namespace Microsoft.DotNet.Cli.CommandLine;
5+
6+
/// <summary>
7+
/// Extensions for tracking and invoking forwarding functions on options and arguments.
8+
/// Forwarding functions are used to translate the parsed value of an option or argument
9+
/// into a set of zero or more string values that will be passed to an inner command.
10+
/// </summary>
11+
public static class ForwardedOptionExtensions
12+
{
13+
private static readonly Dictionary<Symbol, Func<ParseResult, IEnumerable<string>>> s_forwardingFunctions = [];
14+
private static readonly Lock s_lock = new();
15+
16+
extension(Option option)
17+
{
18+
/// <summary>
19+
/// If this option has a forwarding function, this property will return it; otherwise, it will be null.
20+
/// </summary>
21+
/// <remarks>
22+
/// This getter is on the untyped Option because much of the _processing_ of option forwarding
23+
/// is done at the ParseResult level, where we don't have the generic type parameter.
24+
/// </remarks>
25+
public Func<ParseResult, IEnumerable<string>>? ForwardingFunction => s_forwardingFunctions.GetValueOrDefault(option);
26+
}
27+
28+
extension<TValue>(Option<TValue> option)
29+
{
30+
/// <summary>
31+
/// Internal-only helper function that ensures the provided forwarding function is only called
32+
/// if the option actually has a value.
33+
/// </summary>
34+
private Func<ParseResult, IEnumerable<string>> GetForwardingFunction(Func<TValue, IEnumerable<string>> func)
35+
{
36+
return (ParseResult parseResult) =>
37+
{
38+
if (parseResult.GetResult(option) is OptionResult r)
39+
{
40+
if (r.GetValueOrDefault<TValue>() is TValue value)
41+
{
42+
return func(value);
43+
}
44+
else
45+
{
46+
return [];
47+
}
48+
}
49+
return [];
50+
};
51+
}
52+
53+
/// <summary>
54+
/// Internal-only helper function that ensures the provided forwarding function is only called
55+
/// if the option actually has a value.
56+
/// </summary>
57+
private Func<ParseResult, IEnumerable<string>> GetForwardingFunction(Func<TValue, ParseResult, IEnumerable<string>> func)
58+
{
59+
return (ParseResult parseResult) =>
60+
{
61+
if (parseResult.GetResult(option) is OptionResult r)
62+
{
63+
if (r.GetValueOrDefault<TValue>() is TValue value)
64+
{
65+
return func(value, parseResult);
66+
}
67+
else
68+
{
69+
return [];
70+
}
71+
}
72+
return [];
73+
};
74+
}
75+
76+
/// <summary>
77+
/// Forwards the option using the provided function to convert the option's value to zero or more string values.
78+
/// The function will only be called if the option has a value.
79+
/// </summary>
80+
public Option<TValue> SetForwardingFunction(Func<TValue?, IEnumerable<string>> func)
81+
{
82+
lock (s_lock)
83+
{
84+
s_forwardingFunctions[option] = option.GetForwardingFunction(func);
85+
}
86+
return option;
87+
}
88+
89+
/// <summary>
90+
/// Forward the option using the provided function to convert the option's value to a single string value.
91+
/// The function will only be called if the option has a value.
92+
/// </summary>
93+
public Option<TValue> SetForwardingFunction(Func<TValue, string> format)
94+
{
95+
lock (s_lock)
96+
{
97+
s_forwardingFunctions[option] = option.GetForwardingFunction(o => [format(o)]);
98+
}
99+
return option;
100+
}
101+
102+
/// <summary>
103+
/// Forward the option using the provided function to convert the option's value to a single string value.
104+
/// The function will only be called if the option has a value.
105+
/// </summary>
106+
public Option<TValue> SetForwardingFunction(Func<TValue?, ParseResult, IEnumerable<string>> func)
107+
{
108+
lock (s_lock)
109+
{
110+
s_forwardingFunctions[option] = option.GetForwardingFunction(func);
111+
}
112+
return option;
113+
}
114+
115+
/// <summary>
116+
/// Forward the option as multiple calculated string values from whatever the option's value is.
117+
/// </summary>
118+
/// <param name="format"></param>
119+
/// <returns></returns>
120+
public Option<TValue> ForwardAsMany(Func<TValue?, IEnumerable<string>> format) => option.SetForwardingFunction(format);
121+
122+
/// <summary>
123+
/// Forward the option as its own name.
124+
/// </summary>
125+
/// <returns></returns>
126+
public Option<TValue> Forward() => option.SetForwardingFunction((TValue? o) => [option.Name]);
127+
128+
/// <summary>
129+
/// Forward the option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
130+
/// any implicit value calculation will cause the string value to be forwarded.
131+
/// </summary>
132+
public Option<TValue> ForwardAs(string value) => option.SetForwardingFunction((TValue? o) => [value]);
133+
134+
/// <summary>
135+
/// Forward the option as a singular calculated string value.
136+
/// </summary>
137+
public Option<TValue> ForwardAsSingle(Func<TValue, string> format) => option.SetForwardingFunction(format);
138+
}
139+
140+
extension(Option<bool> option)
141+
{
142+
/// <summary>
143+
/// Forward the boolean option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
144+
/// any implicit value calculation will cause the string value to be forwarded. For boolean options specifically, if the option is zero arity
145+
/// and has no default value factory, S.CL will synthesize a true or false value based on whether the option was provided or not, so we need to
146+
/// add an additional implicit 'value is true' check to prevent accidentally forwarding the option for flags that are absent..
147+
/// </summary>
148+
public Option<bool> ForwardIfEnabled(string value) => option.SetForwardingFunction((bool o) => o ? [value] : []);
149+
/// <summary>
150+
/// Forward the boolean option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
151+
/// any implicit value calculation will cause the string value to be forwarded. For boolean options specifically, if the option is zero arity
152+
/// and has no default value factory, S.CL will synthesize a true or false value based on whether the option was provided or not, so we need to
153+
/// add an additional implicit 'value is true' check to prevent accidentally forwarding the option for flags that are absent..
154+
/// </summary>
155+
public Option<bool> ForwardIfEnabled(string[] value) => option.SetForwardingFunction((bool o) => o ? value : []);
156+
157+
/// <summary>
158+
/// Forward the boolean option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
159+
/// any implicit value calculation will cause the string value to be forwarded. For boolean options specifically, if the option is zero arity
160+
/// and has no default value factory, S.CL will synthesize a true or false value based on whether the option was provided or not, so we need to
161+
/// add an additional implicit 'value is true' check to prevent accidentally forwarding the option for flags that are absent..
162+
/// </summary>
163+
public Option<bool> ForwardAs(string value) => option.ForwardIfEnabled(value);
164+
}
165+
166+
extension(Option<IEnumerable<string>> option)
167+
{
168+
/// <summary>
169+
/// Foreach argument in the option's value, yield the <paramref name="alias"/> followed by the argument.
170+
/// </summary>
171+
public Option<IEnumerable<string>> ForwardAsManyArgumentsEachPrefixedByOption(string alias) =>
172+
option.ForwardAsMany(o => ForwardedArguments(alias, o));
173+
}
174+
175+
extension(ParseResult parseResult)
176+
{
177+
/// <summary>
178+
/// Calls the forwarding functions for all options that have declared a forwarding function (via <see cref="ForwardedOptionExtensions"/>'s extension members) in the provided <see cref="ParseResult"/>.
179+
/// </summary>
180+
/// <param name="parseResult"></param>
181+
/// <param name="command">If not provided, uses the <see cref="ParseResult.CommandResult" />'s <see cref="CommandResult.Command"/>.</param>
182+
/// <returns></returns>
183+
public IEnumerable<string> OptionValuesToBeForwarded(Command? command = null) =>
184+
(command ?? parseResult.CommandResult.Command)
185+
.Options
186+
.Select(o => o.ForwardingFunction)
187+
.SelectMany(f => f is not null ? f(parseResult) : []);
188+
189+
/// <summary>
190+
/// Tries to find the first option named <paramref name="alias"/> in <paramref name="command"/>, and if found,
191+
/// invokes its forwarding function (if any) and returns the result. If no option with that name is found, or if the option
192+
/// has no forwarding function, returns an empty enumeration.
193+
/// </summary>
194+
/// <param name="command"></param>
195+
/// <param name="alias"></param>
196+
/// <returns></returns>
197+
public IEnumerable<string> ForwardedOptionValues(Command command, string alias)
198+
{
199+
var func = command.Options?
200+
.Where(o =>
201+
(o.Name.Equals(alias) || o.Aliases.Contains(alias))
202+
&& o.ForwardingFunction is not null)
203+
.FirstOrDefault()?.ForwardingFunction;
204+
return func?.Invoke(parseResult) ?? [];
205+
}
206+
}
207+
208+
/// <summary>
209+
/// For each argument in <paramref name="arguments"/>, yield the <paramref name="alias"/> followed by the argument.
210+
/// </summary>
211+
private static IEnumerable<string> ForwardedArguments(string alias, IEnumerable<string>? arguments)
212+
{
213+
foreach (string arg in arguments ?? [])
214+
{
215+
yield return alias;
216+
yield return arg;
217+
}
218+
}
219+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(SdkTargetFramework)</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<StrongNameKeyId>MicrosoftAspNetCore</StrongNameKeyId>
8+
<SignAssembly>true</SignAssembly>
9+
<PublicSign Condition=" '$([MSBuild]::IsOSPlatform(`Windows`))' == 'false' ">true</PublicSign>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="System.CommandLine" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="../../System.CommandLine.StaticCompletions/System.CommandLine.StaticCompletions.csproj" />
18+
</ItemGroup>
19+
</Project>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.CommandLine;
2+
using System.CommandLine.Completions;
3+
4+
namespace Microsoft.DotNet.Cli.CommandLine;
5+
6+
/// <summary>
7+
/// Extension methods that make it easier to chain option configuration methods when building options.
8+
/// </summary>
9+
public static class OptionBuilderExtensions
10+
{
11+
extension<T>(T option) where T : Option
12+
{
13+
/// <summary>
14+
/// Forces an option that represents a collection-type to only allow a single
15+
/// argument per instance of the option. This means that you'd have to
16+
/// use the option multiple times to pass multiple values.
17+
/// This prevents ambiguity in parsing when argument tokens may appear after the option.
18+
/// </summary>
19+
/// <typeparam name="T"></typeparam>
20+
/// <param name="option"></param>
21+
/// <returns></returns>
22+
public T AllowSingleArgPerToken()
23+
{
24+
option.AllowMultipleArgumentsPerToken = false;
25+
return option;
26+
}
27+
28+
29+
public T AggregateRepeatedTokens()
30+
{
31+
option.AllowMultipleArgumentsPerToken = true;
32+
return option;
33+
}
34+
35+
public T Hide()
36+
{
37+
option.Hidden = true;
38+
return option;
39+
}
40+
public T AddCompletions(Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
41+
{
42+
option.CompletionSources.Add(completionSource);
43+
return option;
44+
}
45+
}
46+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Microsoft.Dotnet.Cli.CommandLine
2+
3+
This project contains extensions and utilities for building command line applications.
4+
5+
These extensions are layered on top of core System.CommandLine concepts and types, and
6+
do not directly reference concepts that are specific to the `dotnet` CLI. We hope that
7+
these would be published separately as a NuGet package for use by other command line
8+
applications in the future.
9+
10+
From a layering perspective, everything that is specific to the `dotnet` CLI should
11+
be in the `src/Cli/dotnet` or `src/Cli/Microsoft.DotNet.Cli.Utils` projects, which
12+
reference this project. Keep this one generally-speaking clean.

0 commit comments

Comments
 (0)