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
37 changes: 37 additions & 0 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ ConsoleAppFramework offers a rich set of features as a framework. The Source Gen
* Registration of nested commands
* Setting option aliases and descriptions from code document comment
* `System.ComponentModel.DataAnnotations` attribute-based Validation
* Grouped parameter binding via `[AsParameters]`
* Dependency Injection for command registration by type and public methods
* `Microsoft.Extensions`(Logging, Configuration, etc...) integration
* High performance value parsing via `ISpanParsable<T>`
Expand Down Expand Up @@ -626,6 +627,42 @@ ConsoleApp.Run(args, ([Argument]string input, [Argument]string output, bool dryR
ConsoleApp.Run(args, (string message, [Argument]params string[] outputs) => { });
```

### AsParameters

You can group command parameters into a single record class by annotating a command parameter with `[AsParameters]`. Constructor parameters of that record are flattened and treated as normal command parameters.

```csharp
ConsoleApp.Run(args, ([AsParameters] CreateUserOptions options, int repeat) =>
{
for (var i = 0; i < repeat; i++)
{
Console.WriteLine($"{options.Name}:{options.Age}:{options.Force}");
}
});

public record class CreateUserOptions(
string Name,
[Argument] int Age = 20,
bool Force = false);
```

In this case, `Name`, `Age`, and `Force` are parsed from CLI arguments, then `CreateUserOptions` is constructed and passed to the command method. `[AsParameters]` can be mixed with regular parameters, `[FromServices]`, `CancellationToken`, `ConsoleAppContext`, and global options.

Aliases and descriptions for expanded parameters are also supported via XML documentation comments on the target record constructor parameters.

```csharp
/// <param name="Name">-n, User name.</param>
/// <param name="Age">-a, User age.</param>
public record class CreateUserOptions(string Name, int Age);
```

Current constraints:

* The `[AsParameters]` target must be a `record class`.
* The target type must have exactly one public instance constructor.
* Nested `[AsParameters]` on constructor parameters is not supported.
* `params` constructor parameters are not supported.

To convert from string arguments to various types, basic primitive types (`string`, `char`, `sbyte`, `byte`, `short`, `int`, `long`, `uint`, `ushort`, `ulong`, `decimal`, `float`, `double`) use `TryParse`. For types that implement `ISpanParsable<T>` (`DateTime`, `DateTimeOffset`, `Guid`, `BigInteger`, `Complex`, `Half`, `Int128`, etc.), [IParsable<TSelf>.TryParse](https://learn.microsoft.com/en-us/dotnet/api/system.iparsable-1.tryparse?view=net-8.0#system-ispanparsable-1-tryparse(system-readonlyspan((system-char))-system-iformatprovider-0@)) or [ISpanParsable<TSelf>.TryParse](https://learn.microsoft.com/en-us/dotnet/api/system.ispanparsable-1.tryparse?view=net-8.0#system-ispanparsable-1-tryparse(system-readonlyspan((system-char))-system-iformatprovider-0@)) is used.

For `enum`, it is parsed using `Enum.TryParse(ignoreCase: true)`.
Expand Down
11 changes: 10 additions & 1 deletion src/ConsoleAppFramework/Command.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public record class Command
public required string Name { get; init; }

public required EquatableArray<CommandParameter> Parameters { get; init; }
public required EquatableArray<CommandParameter> EffectiveParseParameters { get; init; }
public required EquatableArray<AsParametersBinding> AsParametersExpansionBindings { get; init; }
public required string Description { get; init; }
public required MethodKind MethodKind { get; init; }
public required DelegateBuildType DelegateBuildType { get; init; }
Expand Down Expand Up @@ -166,6 +168,13 @@ public string BuildDynamicDependencyAttribute()
}
}

public record class AsParametersBinding
{
public required int RuntimeParameterIndex { get; init; }
public required EquatableTypeSymbol TargetType { get; init; }
public required EquatableArray<int> ParseParameterIndexes { get; init; }
}

public record class CommandParameter
{
public required EquatableTypeSymbol Type { get; init; }
Expand Down Expand Up @@ -339,7 +348,7 @@ public string DefaultValueToString(bool castValue = true, bool enumIncludeTypeNa
}
}

// for floating-point number, need to use InvaliantCulture(some culture uses ',' as separator)
// for floating-point number, need to use InvariantCulture(some culture uses ',' as separator)
var formattedValue = string.Format(CultureInfo.InvariantCulture, "{0}", DefaultValue);
if (!castValue)
{
Expand Down
26 changes: 17 additions & 9 deletions src/ConsoleAppFramework/CommandHelpBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ static string BuildOptionsMessage(CommandHelpDefinition definition)

sb.AppendLine("Options:");
var first = true;
foreach (var opt in optionsFormatted)
foreach (var (Options, Description, IsRequired, IsFlag, DefaultValue, IsDefaultValueHidden) in optionsFormatted)
{
if (first)
{
Expand All @@ -190,7 +190,7 @@ static string BuildOptionsMessage(CommandHelpDefinition definition)
sb.AppendLine();
}

var options = opt.Options;
var options = Options;
var padding = maxWidth - options.Length;

sb.Append(" ");
Expand All @@ -201,21 +201,29 @@ static string BuildOptionsMessage(CommandHelpDefinition definition)
}

sb.Append(" ");
sb.Append(opt.Description);
sb.Append(Description);

// Flags are optional by default; leave them untagged.
if (!opt.IsFlag)
if (!IsFlag)
{
if (opt.DefaultValue != null)
if (DefaultValue != null)
{
if (!opt.IsDefaultValueHidden)
if (!IsDefaultValueHidden)
{
sb.Append($" [Default: {opt.DefaultValue}]");
if (!string.IsNullOrEmpty(Description))
{
sb.Append(' ');
}
sb.Append($"[Default: {DefaultValue}]");
}
}
else if (opt.IsRequired)
else if (IsRequired)
{
sb.Append($" [Required]");
if (!string.IsNullOrEmpty(Description))
{
sb.Append(' ');
}
sb.Append("[Required]");
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions src/ConsoleAppFramework/ConsoleAppBaseCode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,11 @@ internal sealed class ArgumentAttribute : Attribute
{
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
internal sealed class AsParametersAttribute : Attribute
{
}

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
internal sealed class CommandAttribute : Attribute
{
Expand Down
17 changes: 10 additions & 7 deletions src/ConsoleAppFramework/ConsoleAppGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.ComponentModel.Design;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Xml.Linq;

namespace ConsoleAppFramework;

Expand Down Expand Up @@ -219,7 +215,7 @@ static void EmitConsoleAppRun(SourceProductionContext sourceProductionContext, C
using (sb.BeginBlock("internal static partial class ConsoleApp"))
{
var emitter = new Emitter(null);
var requiredParsableParameterCount = command.Parameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed);
var requiredParsableParameterCount = command.EffectiveParseParameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed);
var withId = new Emitter.CommandWithId(null, command, -1, requiredParsableParameterCount);
emitter.EmitRun(sb, withId, commandContext.IsAsync, null);
}
Expand All @@ -230,7 +226,7 @@ static void EmitConsoleAppRun(SourceProductionContext sourceProductionContext, C
using (help.BeginBlock("internal static partial class ConsoleApp"))
{
var emitter = new Emitter(null);
emitter.EmitHelp(help, command);
emitter.EmitHelp(help, command with { Parameters = command.EffectiveParseParameters });
}
sourceProductionContext.AddSource("ConsoleApp.Run.Help.g.cs", help.ToString().ReplaceLineEndings());
}
Expand Down Expand Up @@ -276,7 +272,7 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex
Command: x!,
Id: i,

RequiredParsableParameterCount: x!.Parameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed)
RequiredParsableParameterCount: x!.EffectiveParseParameters.Count(p => p.IsParsable && p.RequireCheckArgumentParsed)
);
if (delegateDef != null)
{
Expand Down Expand Up @@ -305,6 +301,13 @@ static void EmitConsoleAppBuilder(SourceProductionContext sourceProductionContex
using (help.BeginBlock("internal static partial class ConsoleApp"))
using (help.BeginBlock("internal partial class ConsoleAppBuilder"))
{
commandIds = commandIds
.Select(x => x with
{
Command = x.Command with { Parameters = x.Command.EffectiveParseParameters }
})
.ToArray();

if (collectBuilderContext.GlobalOptions.Length != 0)
{
// quick-hack to override commandIds
Expand Down
25 changes: 25 additions & 0 deletions src/ConsoleAppFramework/DiagnosticDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,29 @@ public static DiagnosticDescriptor Create(int id, string title, string messageFo
public static DiagnosticDescriptor InvalidGlobalOptionsType { get; } = Create(
18,
"GlobalOption parameter type only allows compile-time constant(primitives, string, enum) and there nullable.");

public static DiagnosticDescriptor AsParametersTargetMustBeRecordClass { get; } = Create(
19,
"[AsParameters] target must be a record class.",
"Parameter '{0}' marked with [AsParameters] must be a record class type, but found '{1}'.");

public static DiagnosticDescriptor AsParametersTargetMustHaveSinglePublicConstructor { get; } = Create(
20,
"[AsParameters] target must have exactly one public instance constructor.",
"Type '{0}' used with [AsParameters] must declare exactly one public instance constructor.");

public static DiagnosticDescriptor AsParametersNestedNotSupported { get; } = Create(
21,
"Nested [AsParameters] is not supported.",
"Parameter '{0}' in [AsParameters] target '{1}' cannot also be marked with [AsParameters].");

public static DiagnosticDescriptor AsParametersParamsNotSupported { get; } = Create(
22,
"[AsParameters] does not support params constructor parameters.",
"Parameter '{0}' in [AsParameters] target '{1}' cannot use the 'params' modifier.");

public static DiagnosticDescriptor DuplicateOptionNameOrAlias { get; } = Create(
23,
"Option name or alias is duplicated.",
"Option name or alias '{0}' is duplicated.");
}
Loading