Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
</Folder>
<Folder Name="/src/Cli/">
<Project Path="src/Cli/dotnet/dotnet.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Cli.CommandLine/Microsoft.DotNet.Cli.CommandLine.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Cli.Utils/Microsoft.DotNet.Cli.Utils.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.Configurer/Microsoft.DotNet.Configurer.csproj" />
<Project Path="src/Cli/Microsoft.DotNet.InternalAbstractions/Microsoft.DotNet.InternalAbstractions.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using System.Diagnostics;
using Microsoft.Build.Logging;
using Microsoft.DotNet.Cli;
using Microsoft.DotNet.Cli.CommandLine;
using Microsoft.DotNet.Cli.Commands.Run;
using Microsoft.DotNet.Cli.Extensions;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -119,7 +120,7 @@ internal sealed class CommandLineOptions
// determine subcommand:
var explicitCommand = TryGetSubcommand(parseResult);
var command = explicitCommand ?? RunCommandParser.GetCommand();
var buildOptions = command.Options.Where(o => o is IForwardedOption);
var buildOptions = command.Options.Where(o => o.ForwardingFunction is not null);

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

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

if (binLogToken != null)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.CommandLine;
using System.CommandLine.Completions;

namespace Microsoft.DotNet.Cli.CommandLine;

/// <summary>
/// Extension methods that make it easier to chain argument configuration methods when building arguments.
/// </summary>
public static class ArgumentBuilderExtensions
{
extension<T>(Argument<T> argument)
{
public Argument<T> AddCompletions(Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
{
argument.CompletionSources.Add(completionSource);
return argument;
}
}
}
219 changes: 219 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.CommandLine/ForwardedOptionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
using System.CommandLine;
using System.CommandLine.Parsing;

namespace Microsoft.DotNet.Cli.CommandLine;

/// <summary>
/// Extensions for tracking and invoking forwarding functions on options and arguments.
/// Forwarding functions are used to translate the parsed value of an option or argument
/// into a set of zero or more string values that will be passed to an inner command.
/// </summary>
public static class ForwardedOptionExtensions
{
private static readonly Dictionary<Symbol, Func<ParseResult, IEnumerable<string>>> s_forwardingFunctions = [];
private static readonly Lock s_lock = new();

extension(Option option)
{
/// <summary>
/// If this option has a forwarding function, this property will return it; otherwise, it will be null.
/// </summary>
/// <remarks>
/// This getter is on the untyped Option because much of the _processing_ of option forwarding
/// is done at the ParseResult level, where we don't have the generic type parameter.
/// </remarks>
public Func<ParseResult, IEnumerable<string>>? ForwardingFunction => s_forwardingFunctions.GetValueOrDefault(option);
}

extension<TValue>(Option<TValue> option)
{
/// <summary>
/// Internal-only helper function that ensures the provided forwarding function is only called
/// if the option actually has a value.
/// </summary>
private Func<ParseResult, IEnumerable<string>> GetForwardingFunction(Func<TValue, IEnumerable<string>> func)
{
return (ParseResult parseResult) =>
{
if (parseResult.GetResult(option) is OptionResult r)
{
if (r.GetValueOrDefault<TValue>() is TValue value)
{
return func(value);
}
else
{
return [];
}
}
return [];
};
}

/// <summary>
/// Internal-only helper function that ensures the provided forwarding function is only called
/// if the option actually has a value.
/// </summary>
private Func<ParseResult, IEnumerable<string>> GetForwardingFunction(Func<TValue, ParseResult, IEnumerable<string>> func)
{
return (ParseResult parseResult) =>
{
if (parseResult.GetResult(option) is OptionResult r)
{
if (r.GetValueOrDefault<TValue>() is TValue value)
{
return func(value, parseResult);
}
else
{
return [];
}
}
return [];
};
}

/// <summary>
/// Forwards the option using the provided function to convert the option's value to zero or more string values.
/// The function will only be called if the option has a value.
/// </summary>
public Option<TValue> SetForwardingFunction(Func<TValue?, IEnumerable<string>> func)
{
lock (s_lock)
{
s_forwardingFunctions[option] = option.GetForwardingFunction(func);
}
return option;
}

/// <summary>
/// Forward the option using the provided function to convert the option's value to a single string value.
/// The function will only be called if the option has a value.
/// </summary>
public Option<TValue> SetForwardingFunction(Func<TValue, string> format)
{
lock (s_lock)
{
s_forwardingFunctions[option] = option.GetForwardingFunction(o => [format(o)]);
}
return option;
}

/// <summary>
/// Forward the option using the provided function to convert the option's value to a single string value.
/// The function will only be called if the option has a value.
/// </summary>
public Option<TValue> SetForwardingFunction(Func<TValue?, ParseResult, IEnumerable<string>> func)
{
lock (s_lock)
{
s_forwardingFunctions[option] = option.GetForwardingFunction(func);
}
return option;
}

/// <summary>
/// Forward the option as multiple calculated string values from whatever the option's value is.
/// </summary>
/// <param name="format"></param>
/// <returns></returns>
public Option<TValue> ForwardAsMany(Func<TValue?, IEnumerable<string>> format) => option.SetForwardingFunction(format);

/// <summary>
/// Forward the option as its own name.
/// </summary>
/// <returns></returns>
public Option<TValue> Forward() => option.SetForwardingFunction((TValue? o) => [option.Name]);

/// <summary>
/// Forward the option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
/// any implicit value calculation will cause the string value to be forwarded.
/// </summary>
public Option<TValue> ForwardAs(string value) => option.SetForwardingFunction((TValue? o) => [value]);

/// <summary>
/// Forward the option as a singular calculated string value.
/// </summary>
public Option<TValue> ForwardAsSingle(Func<TValue, string> format) => option.SetForwardingFunction(format);
}

extension(Option<bool> option)
{
/// <summary>
/// Forward the boolean option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
/// any implicit value calculation will cause the string value to be forwarded. For boolean options specifically, if the option is zero arity
/// 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
/// add an additional implicit 'value is true' check to prevent accidentally forwarding the option for flags that are absent..
/// </summary>
public Option<bool> ForwardIfEnabled(string value) => option.SetForwardingFunction((bool o) => o ? [value] : []);
/// <summary>
/// Forward the boolean option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
/// any implicit value calculation will cause the string value to be forwarded. For boolean options specifically, if the option is zero arity
/// 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
/// add an additional implicit 'value is true' check to prevent accidentally forwarding the option for flags that are absent..
/// </summary>
public Option<bool> ForwardIfEnabled(string[] value) => option.SetForwardingFunction((bool o) => o ? value : []);

/// <summary>
/// Forward the boolean option as a string value. This value will be forwarded as long as the option has a OptionResult - which means that
/// any implicit value calculation will cause the string value to be forwarded. For boolean options specifically, if the option is zero arity
/// 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
/// add an additional implicit 'value is true' check to prevent accidentally forwarding the option for flags that are absent..
/// </summary>
public Option<bool> ForwardAs(string value) => option.ForwardIfEnabled(value);
}

extension(Option<IEnumerable<string>> option)
{
/// <summary>
/// Foreach argument in the option's value, yield the <paramref name="alias"/> followed by the argument.
/// </summary>
public Option<IEnumerable<string>> ForwardAsManyArgumentsEachPrefixedByOption(string alias) =>
option.ForwardAsMany(o => ForwardedArguments(alias, o));
}

extension(ParseResult parseResult)
{
/// <summary>
/// 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"/>.
/// </summary>
/// <param name="parseResult"></param>
/// <param name="command">If not provided, uses the <see cref="ParseResult.CommandResult" />'s <see cref="CommandResult.Command"/>.</param>
/// <returns></returns>
public IEnumerable<string> OptionValuesToBeForwarded(Command? command = null) =>
(command ?? parseResult.CommandResult.Command)
.Options
.Select(o => o.ForwardingFunction)
.SelectMany(f => f is not null ? f(parseResult) : []);

/// <summary>
/// Tries to find the first option named <paramref name="alias"/> in <paramref name="command"/>, and if found,
/// invokes its forwarding function (if any) and returns the result. If no option with that name is found, or if the option
/// has no forwarding function, returns an empty enumeration.
/// </summary>
/// <param name="command"></param>
/// <param name="alias"></param>
/// <returns></returns>
public IEnumerable<string> ForwardedOptionValues(Command command, string alias)
{
var func = command.Options?
.Where(o =>
(o.Name.Equals(alias) || o.Aliases.Contains(alias))
&& o.ForwardingFunction is not null)
.FirstOrDefault()?.ForwardingFunction;
return func?.Invoke(parseResult) ?? [];
}
}

/// <summary>
/// For each argument in <paramref name="arguments"/>, yield the <paramref name="alias"/> followed by the argument.
/// </summary>
private static IEnumerable<string> ForwardedArguments(string alias, IEnumerable<string>? arguments)
{
foreach (string arg in arguments ?? [])
{
yield return alias;
yield return arg;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>$(SdkTargetFramework)</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<StrongNameKeyId>MicrosoftAspNetCore</StrongNameKeyId>
<SignAssembly>true</SignAssembly>
<PublicSign Condition=" '$([MSBuild]::IsOSPlatform(`Windows`))' == 'false' ">true</PublicSign>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.CommandLine" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../../System.CommandLine.StaticCompletions/System.CommandLine.StaticCompletions.csproj" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.CommandLine;
using System.CommandLine.Completions;

namespace Microsoft.DotNet.Cli.CommandLine;

/// <summary>
/// Extension methods that make it easier to chain option configuration methods when building options.
/// </summary>
public static class OptionBuilderExtensions
{
extension<T>(T option) where T : Option
{
/// <summary>
/// Forces an option that represents a collection-type to only allow a single
/// argument per instance of the option. This means that you'd have to
/// use the option multiple times to pass multiple values.
/// This prevents ambiguity in parsing when argument tokens may appear after the option.
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="option"></param>
/// <returns></returns>
public T AllowSingleArgPerToken()
{
option.AllowMultipleArgumentsPerToken = false;
return option;
}


public T AggregateRepeatedTokens()
{
option.AllowMultipleArgumentsPerToken = true;
return option;
}

public T Hide()
{
option.Hidden = true;
return option;
}
public T AddCompletions(Func<CompletionContext, IEnumerable<CompletionItem>> completionSource)
{
option.CompletionSources.Add(completionSource);
return option;
}
}
}
12 changes: 12 additions & 0 deletions src/Cli/Microsoft.DotNet.Cli.CommandLine/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Microsoft.Dotnet.Cli.CommandLine

This project contains extensions and utilities for building command line applications.

These extensions are layered on top of core System.CommandLine concepts and types, and
do not directly reference concepts that are specific to the `dotnet` CLI. We hope that
these would be published separately as a NuGet package for use by other command line
applications in the future.

From a layering perspective, everything that is specific to the `dotnet` CLI should
be in the `src/Cli/dotnet` or `src/Cli/Microsoft.DotNet.Cli.Utils` projects, which
reference this project. Keep this one generally-speaking clean.
Loading
Loading