Upstream Tyrrrz/CliFx state of the touched files before 9ef5dcb635

This commit is contained in:
upstream committed 2026-08-28 10:12:58 +02:00
commit e3e64a40e4
6 files changed
+2165

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using CliFx.Generators.Utils;
using CliFx.Generators.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.Generators.Binding;
internal partial record CommandSymbol(
INamedTypeSymbol Type,
string? Name,
string? Description,
IReadOnlyList<CommandInputSymbol> Inputs
)
{
public bool IsDefault => string.IsNullOrWhiteSpace(Name);
public IReadOnlyList<CommandParameterSymbol> Parameters { get; } =
Inputs.OfType<CommandParameterSymbol>().ToArray();
public IReadOnlyList<CommandOptionSymbol> Options { get; } =
Inputs.OfType<CommandOptionSymbol>().ToArray();
public override string ToString() => !string.IsNullOrWhiteSpace(Name) ? Name : "<default>";
}
internal partial record CommandSymbol
{
internal static CommandSymbol? TryResolve(INamedTypeSymbol type, DiagnosticReporter diagnostics)
{
// Must have the [Command] attribute
var attribute = type.TryGetAttribute("CliFx.Binding.CommandAttribute");
if (attribute is null)
{
// Shouldn't happen when called by the generator since it filters types by attribute,
// so not worth the effort to produce a diagnostic.
return null;
}
// Must be partial by itself, along with all containing types
foreach (var declaration in type.GetDeclarations().ToArray())
{
if (!declaration.IsFullyPartial())
{
diagnostics.Report(
DiagnosticDescriptors.CommandTypeMustBePartial,
declaration.Identifier.GetLocation(),
type.Name
);
}
}
// Must implement ICommand
if (!type.Implements("CliFx.ICommand"))
{
diagnostics.Report(
DiagnosticDescriptors.CommandTypeMustImplementICommand,
type.Locations.FirstOrDefault(),
type.Name
);
}
var name =
attribute?.NamedArguments.FirstOrDefault(a => a.Key == "Name").Value.Value as string
?? attribute
?.ConstructorArguments.Where(a => a.Type?.SpecialType == SpecialType.System_String)
.Select(a => a.Value as string)
.FirstOrDefault();
var description =
attribute?.NamedArguments.FirstOrDefault(a => a.Key == "Description").Value.Value
as string;
var properties = type.GetProperties().ToArray();
var parameters = CommandParameterSymbol.Resolve(properties, diagnostics);
var options = CommandOptionSymbol.Resolve(properties, diagnostics);
return new CommandSymbol(type, name, description, [.. parameters, .. options]);
}
}
@@ -0,0 +1,575 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.Generators.Binding;
using CliFx.Generators.Utils;
using CliFx.Generators.Utils.Extensions;
using Microsoft.CodeAnalysis;
using PowerKit.Extensions;
namespace CliFx.Generators.Emitters;
internal static class CommandDescriptor
{
private static string EmitValidators(
IReadOnlyList<INamedTypeSymbol> validatorTypes,
string propertyTypeName
)
{
if (validatorTypes.Count == 0)
return $"global::System.Array.Empty<global::CliFx.Activation.IInputValidator<{propertyTypeName}>>()";
// lang=csharp
return $$"""
new global::CliFx.Activation.IInputValidator<{{propertyTypeName}}>[]
{
{{string.Join(
", ",
validatorTypes.Select(v => $"new {v.GetGloballyQualifiedName()}()")
)}}
}
""";
}
private static string? TryEmitDefaultScalarConverter(ITypeSymbol type)
{
// Object
if (type.SpecialType == SpecialType.System_Object)
return "new global::CliFx.Activation.StringScalarInputConverter()";
// String
if (type.SpecialType == SpecialType.System_String)
return "new global::CliFx.Activation.StringScalarInputConverter()";
// Bool
if (type.SpecialType == SpecialType.System_Boolean)
return "new global::CliFx.Activation.BoolScalarInputConverter()";
// Enum
if (type.TypeKind == TypeKind.Enum)
return $"new global::CliFx.Activation.EnumScalarInputConverter<{type.GetGloballyQualifiedName()}>()";
// Nullable<T>
if (type.TryGetNullableUnderlyingType() is { } underlyingType)
{
var innerConverterCode = TryEmitDefaultScalarConverter(underlyingType);
if (innerConverterCode is null)
return null;
return $"global::CliFx.Activation.NullableScalarInputConverter.Create({innerConverterCode})";
}
// Has static Parse(string, IFormatProvider)
var parseMethodWithFormatProvider = type.GetMethods()
.FirstOrDefault(m =>
string.Equals(m.Name, "Parse", StringComparison.Ordinal)
&& m.IsStatic
&& m.DeclaredAccessibility == Accessibility.Public
&& m.Parameters.Length == 2
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
&& m.Parameters[1].Type.IsMatchedBy("System.IFormatProvider")
&& SymbolEqualityComparer.Default.Equals(m.ReturnType, type)
);
if (parseMethodWithFormatProvider is not null)
{
return $"""
global::CliFx.Activation.DelegateScalarInputConverter.Create(
v => {parseMethodWithFormatProvider.GetGloballyQualifiedName()}(
v!,
global::System.Globalization.CultureInfo.InvariantCulture
)
)
""";
}
// Has static Parse(string)
var parseMethod = type.GetMethods()
.FirstOrDefault(m =>
string.Equals(m.Name, "Parse", StringComparison.Ordinal)
&& m.IsStatic
&& m.DeclaredAccessibility == Accessibility.Public
&& m.Parameters.Length == 1
&& m.Parameters[0].Type.SpecialType == SpecialType.System_String
&& SymbolEqualityComparer.Default.Equals(m.ReturnType, type)
);
if (parseMethod is not null)
{
// lang=csharp
return $"""
global::CliFx.Activation.DelegateScalarInputConverter.Create(
v => {parseMethod.GetGloballyQualifiedName()}(v!)
)
""";
}
// Has ctor(string)
if (
type is INamedTypeSymbol namedConstructable
&& namedConstructable.Constructors.Any(c =>
c.DeclaredAccessibility == Accessibility.Public
&& c.Parameters.Length == 1
&& c.Parameters[0].Type.SpecialType == SpecialType.System_String
)
)
{
// lang=csharp
return $"""
global::CliFx.Activation.DelegateScalarInputConverter.Create(
v => new {type.GetGloballyQualifiedName()}(v!)
)
""";
}
return null;
}
private static string? TryEmitDefaultSequenceConverter(
ITypeSymbol collectionType,
ITypeSymbol elementType
)
{
var elementConverterFragment = TryEmitDefaultScalarConverter(elementType);
if (elementConverterFragment is null)
return null;
// Assignable from T[]
if (
// T[]
collectionType is IArrayTypeSymbol
|| (
collectionType
is INamedTypeSymbol
{
TypeKind: TypeKind.Interface,
TypeArguments: [var interfaceElementType],
} collectionInterfaceType
&& collectionInterfaceType.ConstructedFrom.SpecialType
// IEnumerable<T>
is SpecialType.System_Collections_Generic_IEnumerable_T
// ICollection<T>
or SpecialType.System_Collections_Generic_ICollection_T
// IList<T>
or SpecialType.System_Collections_Generic_IList_T
// IReadOnlyCollection<T>
or SpecialType.System_Collections_Generic_IReadOnlyCollection_T
// IReadOnlyList<T>
or SpecialType.System_Collections_Generic_IReadOnlyList_T
&& SymbolEqualityComparer.Default.Equals(interfaceElementType, elementType)
)
)
{
return $"global::CliFx.Activation.ArraySequenceInputConverter.Create({elementConverterFragment})";
}
// Has ctor(T[])
if (
collectionType is INamedTypeSymbol collectionNamedType
&& collectionNamedType.Constructors.Any(c =>
c.DeclaredAccessibility == Accessibility.Public
&& c.Parameters.Length == 1
&& (
// T[]
(
c.Parameters[0].Type is IArrayTypeSymbol arrayParameterType
&& SymbolEqualityComparer.Default.Equals(
arrayParameterType.ElementType,
elementType
)
)
// Interfaces implemented by T[]
|| (
c.Parameters[0].Type is INamedTypeSymbol namedParameterType
&& namedParameterType.ConstructedFrom.SpecialType
// IEnumerable<T>
is SpecialType.System_Collections_Generic_IEnumerable_T
// ICollection<T>
or SpecialType.System_Collections_Generic_ICollection_T
// IList<T>
or SpecialType.System_Collections_Generic_IList_T
// IReadOnlyCollection<T>
or SpecialType.System_Collections_Generic_IReadOnlyCollection_T
// IReadOnlyList<T>
or SpecialType.System_Collections_Generic_IReadOnlyList_T
&& SymbolEqualityComparer.Default.Equals(
namedParameterType.TypeArguments[0],
elementType
)
)
)
)
)
{
// lang=csharp
return $"""
global::CliFx.Activation.DelegateSequenceInputConverter.Create(
global::CliFx.Activation.ArraySequenceInputConverter.Create({elementConverterFragment}),
vs => new {collectionType.GetGloballyQualifiedName()}(vs)
)
""";
}
return null;
}
private static string? TryEmitConverter(
INamedTypeSymbol? userConverterType,
IPropertySymbol property
)
{
if (userConverterType is not null)
return $"new {userConverterType.GetGloballyQualifiedName()}()";
// Sequence-based property
if (
property.Type.SpecialType != SpecialType.System_String
&& property.Type.TryGetEnumerableUnderlyingType() is { } elementType
)
{
return TryEmitDefaultSequenceConverter(property.Type, elementType);
}
// Scalar property
return TryEmitDefaultScalarConverter(property.Type);
}
private static string? TryEmitCommandParameterDescriptor(
CommandSymbol command,
CommandParameterSymbol parameter,
DiagnosticReporter diagnostics
)
{
var converterFragment = TryEmitConverter(parameter.ConverterType, parameter.Property);
if (converterFragment is null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandInputConverterNotInferrable,
parameter.Property.Locations.FirstOrDefault(),
parameter.Property.Name,
parameter.Property.Type.GetGloballyQualifiedName()
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandParameterDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{parameter.Property.Type.GetGloballyQualifiedName()}}>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{parameter.Property.Type.GetGloballyQualifiedName()}}>(
{{CSharp.Encode(parameter.Property.Name)}},
c => c.{{parameter.Property.Name}},
(c, v) => c.{{parameter.Property.Name}} = v
),
{{parameter.Order}},
{{CSharp.Encode(parameter.Name)}},
{{parameter.IsRequired.ToString().ToLowerInvariant()}},
{{CSharp.Encode(parameter.Description)}},
{{converterFragment}},
{{EmitValidators(
parameter.ValidatorTypes,
parameter.Property.Type.GetGloballyQualifiedName()
)}}
)
""";
}
private static string? TryEmitCommandOptionDescriptor(
CommandSymbol command,
CommandOptionSymbol option,
DiagnosticReporter diagnostics
)
{
var converterFragment = TryEmitConverter(option.ConverterType, option.Property);
if (converterFragment is null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandInputConverterNotInferrable,
option.Property.Locations.FirstOrDefault(),
option.Property.Name,
option.Property.Type.GetGloballyQualifiedName()
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandOptionDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{option.Property.Type.GetGloballyQualifiedName()}}>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, {{option.Property.Type.GetGloballyQualifiedName()}}>(
{{CSharp.Encode(option.Property.Name)}},
c => c.{{option.Property.Name}},
(c, v) => c.{{option.Property.Name}} = v
),
{{CSharp.Encode(option.Name)}},
{{CSharp.Encode(option.ShortName)}},
{{CSharp.Encode(option.EnvironmentVariable)}},
{{option.IsRequired.ToString().ToLowerInvariant()}},
{{CSharp.Encode(option.Description)}},
{{converterFragment}},
{{EmitValidators(
option.ValidatorTypes,
option.Property.Type.GetGloballyQualifiedName()
)}}
)
""";
}
private static string TryEmitCommandHelpOptionDescriptor(
CommandSymbol command,
DiagnosticReporter diagnostics
)
{
// Check if the user already implemented a custom help option
if (command.Type.Implements("CliFx.ICommandWithHelpOption"))
{
// Ensure that the user bound their IsHelpRequested property to an option
var isHelpRequestedProperty = command
.Type.GetProperties()
.FirstOrDefault(p =>
string.Equals(p.Name, "IsHelpRequested", StringComparison.Ordinal)
);
var hasOptionBinding =
isHelpRequestedProperty?.TryGetAttribute("CliFx.Binding.CommandOptionAttribute")
is not null;
if (!hasOptionBinding)
{
diagnostics.Report(
DiagnosticDescriptors.CommandHelpOptionPropertyMustBeBound,
isHelpRequestedProperty?.Locations.FirstOrDefault()
?? command.Type.Locations.FirstOrDefault(),
"IsHelpRequested"
);
}
return null;
}
// Check if the help option is being shadowed by an existing option
var shadowingOptionByName = command.Options.FirstOrDefault(o =>
string.Equals(o.Name, "help", StringComparison.OrdinalIgnoreCase)
);
var shadowingOptionByShortName = command.Options.FirstOrDefault(o => o.ShortName == 'h');
var shadowingOption = shadowingOptionByShortName ?? shadowingOptionByName;
if (shadowingOption is not null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandOptionShadowsConventionalHelpOption,
shadowingOption.Property.Locations.FirstOrDefault(),
shadowingOption.Property.Name,
shadowingOptionByName is not null ? "name" : "short name"
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandOptionDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
{{CSharp.Encode("IsHelpRequested")}},
c => c.IsHelpRequested,
(c, v) => c.IsHelpRequested = v
),
{{CSharp.Encode("help")}},
{{CSharp.Encode('h')}},
null,
false,
{{CSharp.Encode("Shows help text.")}},
new global::CliFx.Activation.BoolScalarInputConverter(),
global::System.Array.Empty<global::CliFx.Activation.IInputValidator<bool>>()
)
""";
}
private static string TryEmitCommandVersionOptionDescriptor(
CommandSymbol command,
DiagnosticReporter diagnostics
)
{
// Check if the user already implemented a custom version option
if (command.Type.Implements("CliFx.ICommandWithVersionOption"))
{
var isVersionRequestedProperty = command
.Type.GetProperties()
.FirstOrDefault(p =>
string.Equals(p.Name, "IsVersionRequested", StringComparison.Ordinal)
);
var hasOptionBinding =
isVersionRequestedProperty?.TryGetAttribute("CliFx.Binding.CommandOptionAttribute")
is not null;
if (!hasOptionBinding)
{
diagnostics.Report(
DiagnosticDescriptors.CommandVersionOptionPropertyMustBeBound,
isVersionRequestedProperty?.Locations.FirstOrDefault()
?? command.Type.Locations.FirstOrDefault(),
"IsVersionRequested"
);
}
return null;
}
// Only default commands get the conventional version option
if (!command.IsDefault)
return null;
// Check if the version option is being shadowed by an existing option
var shadowingOptionByName = command.Options.FirstOrDefault(o =>
string.Equals(o.Name, "version", StringComparison.OrdinalIgnoreCase)
);
if (shadowingOptionByName is not null)
{
diagnostics.Report(
DiagnosticDescriptors.CommandOptionShadowsConventionalVersionOption,
shadowingOptionByName.Property.Locations.FirstOrDefault(),
shadowingOptionByName.Property.Name,
"name"
);
return null;
}
// lang=csharp
return $$"""
new global::CliFx.Binding.CommandOptionDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
new global::CliFx.Binding.PropertyDescriptor<{{command.Type.GetGloballyQualifiedName()}}, bool>(
{{CSharp.Encode("IsVersionRequested")}},
c => c.IsVersionRequested,
(c, v) => c.IsVersionRequested = v
),
{{CSharp.Encode("version")}},
null,
null,
false,
{{CSharp.Encode("Shows version information.")}},
new global::CliFx.Activation.BoolScalarInputConverter(),
global::System.Array.Empty<global::CliFx.Activation.IInputValidator<bool>>()
)
""";
}
private static string? TryEmitCommandInputDescriptor(
CommandSymbol command,
CommandInputSymbol input,
DiagnosticReporter diagnostics
) =>
input switch
{
CommandParameterSymbol parameter => TryEmitCommandParameterDescriptor(
command,
parameter,
diagnostics
),
CommandOptionSymbol option => TryEmitCommandOptionDescriptor(
command,
option,
diagnostics
),
_ => throw new InvalidOperationException(
$"Unknown input type '{input.GetType().Name}'."
),
};
internal static string Emit(CommandSymbol command, DiagnosticReporter diagnostics) =>
// lang=csharp
$$"""
// <auto-generated />
{{(command.Type.TryGetNamespaceName() is { } namespaceName
? $"namespace {namespaceName};"
: ""
)}}
{{string.Concat(command.Type.GetContainingTypes().Reverse().Select(t =>
// lang=csharp
$$"""
partial class {{t.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)}}
{
"""
))}}
partial class {{command.Type.Name}}
{
/// <summary>
/// Generated command descriptor for <see cref="{{Xml.Escape(command.Type.GetGloballyQualifiedName())}}" /> ({{Xml.Escape(command.ToString())}}).
/// </summary>
/// <remarks>
/// <list type="bullet">
{{string.Join(
Environment.NewLine,
command.Inputs.Select(i =>
// lang=xml
$"""
/// <item>
/// <see cref="{Xml.Escape(i.Property.GetGloballyQualifiedName())}" /> ({Xml.Escape(i.ToString())})
/// </item>
"""
)
).NullIfWhiteSpace() ?? "///"}}
/// </list>
/// </remarks>
public static global::CliFx.Binding.CommandDescriptor Descriptor { get; } =
new global::CliFx.Binding.CommandDescriptor<{{command.Type.GetGloballyQualifiedName()}}>(
{{CSharp.Encode(command.Name)}},
{{CSharp.Encode(command.Description)}},
new global::CliFx.Binding.CommandInputDescriptor[]
{
{{string.Join(",", command.Inputs
.Select(i => TryEmitCommandInputDescriptor(
command,
i,
diagnostics
))
.Append(
TryEmitCommandHelpOptionDescriptor(command, diagnostics) is {} helpOptionDescriptorFragment
? helpOptionDescriptorFragment
: null
)
.Append(
TryEmitCommandVersionOptionDescriptor(command, diagnostics) is {} versionOptionDescriptorFragment
? versionOptionDescriptorFragment
: null
)
.WhereNotNull()
)}}
}
);
}
{{(!command.Type.Implements("CliFx.ICommandWithHelpOption")
? // lang=csharp
$$"""
partial class {{command.Type.Name}} : global::CliFx.ICommandWithHelpOption
{
/// <inheritdoc />
public bool IsHelpRequested { get; set; }
}
"""
: ""
)}}
{{(command.IsDefault && !command.Type.Implements("CliFx.ICommandWithVersionOption")
? // lang=csharp
$$"""
partial class {{command.Type.Name}} : global::CliFx.ICommandWithVersionOption
{
/// <inheritdoc />
public bool IsVersionRequested { get; set; }
}
"""
: ""
)}}
{{string.Concat(command.Type.GetContainingTypes().Reverse().Select(_ => "}"))}}
""";
}
+984
View File
@@ -0,0 +1,984 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Tests.Utils;
using CliFx.Tests.Utils.Extensions;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;
namespace CliFx.Tests;
public class HelpSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
{
[Fact]
public async Task I_can_request_help_by_passing_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.SetDescription("This will be in the help text")
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("This will be in the help text");
}
[Fact]
public async Task I_can_request_help_by_passing_the_manually_bound_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand, ICommandWithHelpOption
{
[CommandOption("help", 'h', Description = "Custom help text.")]
public bool IsHelpRequested { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("OPTIONS", "-h", "--help", "Custom help text.");
}
[Fact]
public async Task I_can_request_help_by_passing_no_arguments_if_the_default_command_is_not_registered()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.UseConsole(FakeConsole)
.SetDescription("This will be in the help text")
.Build();
// Act
var exitCode = await application.RunAsync([], new Dictionary<string, string>());
// Assert
exitCode.Should().NotBe(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("This will be in the help text");
}
[Fact]
public async Task I_can_request_help_for_a_specific_named_command_by_passing_its_name_and_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd", Description = "Description of a named command.")]
public partial class NamedCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd child")]
public partial class NamedChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["cmd", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("Description of a named command.");
}
[Fact]
public async Task I_can_request_help_for_a_specific_nested_named_command_by_passing_its_name_and_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd")]
public partial class NamedCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd child", Description = "Description of a named child command.")]
public partial class NamedChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["cmd", "sub", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("Description of a named child command.");
}
[Fact]
public async Task I_can_request_help_by_passing_invalid_arguments()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommand(NoOpCommand.Descriptor)
.UseConsole(FakeConsole)
.SetDescription("This will be in the help text")
.Build();
// Act
var exitCode = await application.RunAsync(
["invalid-command", "--invalid-option"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().NotBe(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().Contain("This will be in the help text");
var stdErr = FakeConsole.ReadErrorString();
stdErr.Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task I_can_request_help_to_see_the_application_title_description_and_version()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.UseConsole(FakeConsole)
.SetTitle("App title")
.SetDescription("App description")
.SetVersion("App version")
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAll("App title", "App description", "App version");
}
[Fact]
public async Task I_can_request_help_to_see_the_command_description()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command(Description = "Description of the default command.")]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("DESCRIPTION", "Description of the default command.");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_format_of_a_named_command()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class DefaultCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd")]
public partial class NamedCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("USAGE", "[command]", "...");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_format_of_all_parameters()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
[CommandParameter(0)]
public required string Foo { get; set; }
[CommandParameter(1)]
public required string Bar { get; set; }
[CommandParameter(2)]
public required string MultiWordName123 { get; set; }
[CommandParameter(3)]
public required IReadOnlyList<string> Baz { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder("USAGE", "<foo>", "<bar>", "<multi-word-name123>", "<baz...>");
}
[Fact]
public async Task I_can_request_help_to_see_the_usage_format_of_all_required_options()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
[CommandOption("foo")]
public required string Foo { get; set; }
[CommandOption("bar")]
public string? Bar { get; set; }
[CommandOption("baz")]
public required IReadOnlyList<string> Baz { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder("USAGE", "--foo <value>", "--baz <values...>", "[options]");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_inputs()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
[CommandParameter(0, Name = "foo", Description = "Description of foo.")]
public string? Foo { get; set; }
[CommandOption("bar", Description = "Description of bar.")]
public string? Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Description of foo.",
"OPTIONS",
"--bar",
"Description of bar."
);
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_inputs_including_inherited_ones()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public abstract class GrandParentCommand : ICommand
{
[CommandParameter(0, Name = "foo", Description = "Description of foo.")]
public string? Foo { get; set; }
public abstract ValueTask ExecuteAsync(IConsole console);
}
public abstract class ParentCommand : GrandParentCommand
{
[CommandOption("bar", Description = "Description of bar.")]
public string? Bar { get; set; }
}
[Command]
public partial class Command : ParentCommand
{
[CommandOption("baz", Description = "Description of baz.")]
public string? Baz { get; set; }
public override ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Description of foo.",
"OPTIONS",
"--baz",
"Description of baz.",
"--bar",
"Description of bar."
);
}
[Fact]
public async Task I_can_request_help_to_see_the_conventional_help_and_version_options()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"OPTIONS",
"-h",
"--help",
"Shows help text",
"--version",
"Shows version information"
);
}
[Fact]
public async Task I_can_request_help_on_a_named_command_to_see_the_conventional_help_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("cmd")]
public partial class Command : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["cmd", "--help"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().ContainAllInOrder("OPTIONS", "-h", "--help", "Shows help text");
stdOut.Should().NotContainAny("--version", "Shows version information");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_valid_values_for_inputs_bound_to_enum_properties()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandParameter(0)]
public CustomEnum Foo { get; set; }
[CommandOption("bar")]
public CustomEnum Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Choices:",
"One",
"Two",
"Three",
"OPTIONS",
"--bar",
"Choices:",
"One",
"Two",
"Three"
);
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_valid_values_for_inputs_bound_to_nullable_enum_properties()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandParameter(0)]
public CustomEnum? Foo { get; set; }
[CommandOption("bar")]
public CustomEnum? Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"PARAMETERS",
"foo",
"Choices:",
"One",
"Two",
"Three",
"OPTIONS",
"--bar",
"Choices:",
"One",
"Two",
"Three"
);
}
[Fact]
public async Task I_can_request_help_to_see_the_environment_variables_of_options_that_use_them_as_fallback()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandOption("foo", EnvironmentVariable = "ENV_FOO")]
public CustomEnum Foo { get; set; }
[CommandOption("bar", EnvironmentVariable = "ENV_BAR")]
public CustomEnum Bar { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"OPTIONS",
"--foo",
"Environment variable:",
"ENV_FOO",
"--bar",
"Environment variable:",
"ENV_BAR"
);
}
[Fact]
public async Task I_can_request_help_to_see_the_default_values_of_non_required_options()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
public enum CustomEnum { One, Two, Three }
[Command]
public partial class Command : ICommand
{
[CommandOption("foo")]
public object? Foo { get; set; } = 42;
[CommandOption("bar")]
public string? Bar { get; set; } = "hello";
[CommandOption("baz")]
public IReadOnlyList<string>? Baz { get; set; } = new[] {"one", "two", "three"};
[CommandOption("qwe")]
public bool Qwe { get; set; } = true;
[CommandOption("qop")]
public int? Qop { get; set; } = 1337;
[CommandOption("zor")]
public TimeSpan Zor { get; set; } = TimeSpan.FromMinutes(123);
[CommandOption("lol")]
public CustomEnum Lol { get; set; } = CustomEnum.Two;
[CommandOption("hmm")]
public required string Hmm { get; set; } = "not printed";
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"OPTIONS",
"--foo",
"Default:",
"42",
"--bar",
"Default:",
"hello",
"--baz",
"Default:",
"one",
"two",
"three",
"--qwe",
"Default:",
"true",
"--qop",
"Default:",
"1337",
"--zor",
"Default:",
"02:03:00",
"--lol",
"Default:",
"Two"
);
stdOut.Should().NotContain("not printed");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_immediate_child_commands()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("cmd1", Description = "Description of one command.")]
public partial class FirstCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2", Description = "Description of another command.")]
public partial class SecondCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2 child", Description = "Description of another command's child command.")]
public partial class SecondCommandChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd3 child", Description = "Description of an orphaned command.")]
public partial class ThirdCommandChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"COMMANDS",
"cmd1",
"Description of one command.",
"cmd2",
"Description of another command.",
// "cmd2 child" will appear as an immediate command because it does not
// have a more specific parent.
"cmd3 child",
"Description of an orphaned command."
);
// "cmd2 child" will still appear in the list of "cmd2" subcommands,
// but its description will not be visible.
stdOut.Should().NotContain("Description of another command's child command.");
}
[Fact]
public async Task I_can_request_help_to_see_the_list_of_all_immediate_grand_child_commands()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command("cmd1")]
public partial class FirstCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd1 child1")]
public partial class FirstCommandFirstChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2")]
public partial class SecondCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2 child11")]
public partial class SecondCommandFirstChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
[Command("cmd2 child2")]
public partial class SecondCommandSecondChildCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut
.Should()
.ContainAllInOrder(
"COMMANDS",
"cmd1",
"Subcommands:",
"cmd1 child1",
"cmd2",
"Subcommands:",
"cmd2 child1",
"cmd2 child2"
);
}
[Fact]
public async Task I_can_request_version_information_by_passing_the_conventional_version_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommand(NoOpCommand.Descriptor)
.SetVersion("v6.9")
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--version"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("v6.9");
}
[Fact]
public async Task I_can_request_version_information_by_passing_the_manually_bound_version_option()
{
// Arrange
var application = new CommandLineApplicationBuilder()
.AddCommands(
CommandCompiler.Compile(
// lang=csharp
"""
[Command]
public partial class Command : ICommand, ICommandWithVersionOption
{
[CommandOption("version", Description = "Custom version text.")]
public bool IsVersionRequested { get; set; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
)
)
.SetVersion("v1.0")
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--version"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("v1.0");
}
}
+32
View File
@@ -0,0 +1,32 @@
using System;
namespace CliFx.Binding;
/// <summary>
/// Binds a type to a command.
/// </summary>
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class CommandAttribute(string? name) : Attribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute" />.
/// </summary>
public CommandAttribute()
: this(null) { }
/// <summary>
/// Command name.
/// </summary>
/// <remarks>
/// Command can have no name, in which case it's treated as the application's default (i.e., root) command.
/// Only one default command is allowed to be registered in an application.
/// All commands registered in an application must have unique names (comparison IS NOT case-sensitive).
/// </remarks>
public string? Name { get; } = name;
/// <summary>
/// Command description.
/// Used for display purposes in the help text.
/// </summary>
public string? Description { get; set; }
}
+62
View File
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace CliFx.Binding;
/// <summary>
/// Describes a binding between a CLR type and a command.
/// </summary>
public class CommandDescriptor(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
Type type,
string? name,
string? description,
IReadOnlyList<CommandInputDescriptor> inputs
)
{
/// <summary>
/// CLR type of the command.
/// </summary>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
public Type Type { get; } = type;
/// <inheritdoc cref="CommandAttribute.Name" />
public string? Name { get; } = name;
internal bool IsDefault => string.IsNullOrWhiteSpace(Name);
/// <inheritdoc cref="CommandAttribute.Description" />
public string? Description { get; } = description;
/// <summary>
/// Inputs (parameters and options) of the command;
/// </summary>
public IReadOnlyList<CommandInputDescriptor> Inputs { get; } = inputs;
internal IReadOnlyList<CommandParameterDescriptor> Parameters { get; } =
inputs.OfType<CommandParameterDescriptor>().ToArray();
internal IReadOnlyList<CommandOptionDescriptor> Options { get; } =
inputs.OfType<CommandOptionDescriptor>().ToArray();
internal bool MatchesName(string? name) =>
!string.IsNullOrWhiteSpace(Name)
? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase)
: string.IsNullOrWhiteSpace(name);
/// <inheritdoc />
public override string ToString() => !string.IsNullOrWhiteSpace(Name) ? Name : "<default>";
}
/// <inheritdoc cref="CommandDescriptor" />
/// <remarks>
/// Generic version used by source-generated code for static type references and AOT compatibility.
/// </remarks>
public class CommandDescriptor<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
TCommand
>(string? name, string? description, IReadOnlyList<CommandInputDescriptor> inputs)
: CommandDescriptor(typeof(TCommand), name, description, inputs)
where TCommand : ICommand;
+432
View File
@@ -0,0 +1,432 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using CliFx.Binding;
using CliFx.Infrastructure;
namespace CliFx.Help;
internal class HelpWriter(HelpContext context, ConsoleWriter consoleWriter)
: FormattedConsoleWriter(consoleWriter)
{
private void WriteHeader(string text)
{
Write(ConsoleColor.White, text.ToUpperInvariant());
WriteLine();
}
private void WriteCommandInvocation()
{
Write(context.Metadata.ExecutableName);
// Command name
if (!string.IsNullOrWhiteSpace(context.Command.Name))
{
Write(' ');
Write(ConsoleColor.Cyan, context.Command.Name);
}
}
private void WriteCommandUsage()
{
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Usage");
// Current command
{
WriteHorizontalMargin();
WriteCommandInvocation();
Write(' ');
// Parameters
foreach (var parameter in context.Command.Parameters.OrderBy(p => p.Order))
{
Write(ConsoleColor.DarkCyan, parameter.ToString(includeKind: false));
Write(' ');
}
// Required options
foreach (var option in context.Command.Options.Where(o => o.IsRequired))
{
Write(
ConsoleColor.Yellow,
option.ToString(
includeKind: false,
includeBothIdentifiers: false,
includeValuePlaceholder: true
)
);
Write(' ');
}
// Placeholder for non-required options
if (context.Command.Options.Any(o => !o.IsRequired))
{
Write(ConsoleColor.Yellow, "[options]");
}
WriteLine();
}
// Child commands
if (context.Root.GetChildCommands(context.Command.Name).Any())
{
WriteHorizontalMargin();
WriteCommandInvocation();
Write(' ');
// Placeholder for child command
Write(ConsoleColor.Cyan, "[command]");
Write(' ');
// Placeholder for other arguments
Write("...");
WriteLine();
}
}
private void WriteCommandDescription()
{
if (string.IsNullOrWhiteSpace(context.Command.Description))
return;
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Description");
WriteHorizontalMargin();
Write(context.Command.Description);
WriteLine();
}
private void WriteCommandInputDefaultValue(CommandInputDescriptor input)
{
var defaultValue = context.CommandDefaultValues.GetValueOrDefault(input);
if (defaultValue is null)
return;
// Normalize to an array to process both scalar and sequence default values uniformly
var defaultValues =
defaultValue is not string && defaultValue is IEnumerable defaultValueAsEnumerable
? defaultValueAsEnumerable.Cast<object>().ToArray()
: [defaultValue];
// Only strings, chars, bools, and types that implement IFormattable have
// meaningful ToString() representations.
if (!defaultValues.All(v => v is string or char or bool or IFormattable))
return;
var isFirst = true;
foreach (var value in defaultValues)
{
if (isFirst)
{
Write(ConsoleColor.White, "Default: ");
isFirst = false;
}
else
{
Write(", ");
}
Write(ConsoleColor.DarkGray, '<');
Write(
value switch
{
// Lowercase bools look better
bool boolValue => boolValue.ToString().ToLowerInvariant(),
IFormattable formattable => formattable.ToString(
null,
CultureInfo.InvariantCulture
),
_ => value.ToString(),
}
);
Write(ConsoleColor.DarkGray, '>');
}
if (!isFirst)
Write('.');
}
private void WriteCommandParameters()
{
if (!context.Command.Parameters.Any())
return;
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Parameters");
foreach (var parameter in context.Command.Parameters.OrderBy(p => p.Order))
{
if (parameter.IsRequired)
{
Write(ConsoleColor.Red, "* ");
}
else
{
WriteHorizontalMargin();
}
Write(ConsoleColor.DarkCyan, $"{parameter.Name}");
WriteColumnMargin();
// Description
if (!string.IsNullOrWhiteSpace(parameter.Description))
{
Write(parameter.Description);
Write(' ');
}
// Valid values
var validValues = parameter.Property.TryGetValidValues() ?? [];
if (validValues.Any())
{
Write(ConsoleColor.White, "Choices: ");
var isFirst = true;
foreach (var validValue in validValues)
{
if (validValue is null)
continue;
if (isFirst)
{
isFirst = false;
}
else
{
Write(", ");
}
Write('"');
Write(validValue.ToString());
Write('"');
}
Write('.');
Write(' ');
}
// Default value
if (!parameter.IsRequired)
{
WriteCommandInputDefaultValue(parameter);
}
WriteLine();
}
}
private void WriteCommandOptions()
{
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Options");
foreach (var option in context.Command.Options.OrderByDescending(o => o.IsRequired))
{
if (option.IsRequired)
{
Write(ConsoleColor.Red, "* ");
}
else
{
WriteHorizontalMargin();
}
// Short name
if (option.ShortName is not null)
{
Write(ConsoleColor.Yellow, $"-{option.ShortName}");
}
// Separator
if (!string.IsNullOrWhiteSpace(option.Name) && option.ShortName is not null)
{
Write('|');
}
// Name
if (!string.IsNullOrWhiteSpace(option.Name))
{
Write(ConsoleColor.Yellow, $"--{option.Name}");
}
WriteColumnMargin();
// Description
if (!string.IsNullOrWhiteSpace(option.Description))
{
Write(option.Description);
Write(' ');
}
// Valid values
var validValues = option.Property.TryGetValidValues() ?? [];
if (validValues.Any())
{
Write(ConsoleColor.White, "Choices: ");
var isFirst = true;
foreach (var validValue in validValues)
{
if (validValue is null)
continue;
if (isFirst)
{
isFirst = false;
}
else
{
Write(", ");
}
Write('"');
Write(validValue.ToString());
Write('"');
}
Write('.');
Write(' ');
}
// Environment variable
if (!string.IsNullOrWhiteSpace(option.EnvironmentVariable))
{
Write(ConsoleColor.White, "Environment variable: ");
Write(option.EnvironmentVariable);
Write('.');
Write(' ');
}
// Default value
if (!option.IsRequired)
{
WriteCommandInputDefaultValue(option);
}
WriteLine();
}
}
private void WriteCommandChildren()
{
var childCommands = context
.Root.GetChildCommands(context.Command.Name)
.OrderBy(a => a.Name, StringComparer.Ordinal)
.ToArray();
if (!childCommands.Any())
return;
if (!IsEmpty)
WriteVerticalMargin();
WriteHeader("Commands");
foreach (var childCommand in childCommands)
{
// Name
WriteHorizontalMargin();
Write(
ConsoleColor.Cyan,
// Trim the name so it's relative to the current command
childCommand.Name?.Substring(context.Command.Name?.Length ?? 0).Trim()
);
WriteColumnMargin();
// Description
if (!string.IsNullOrWhiteSpace(childCommand.Description))
{
Write(childCommand.Description);
Write(' ');
}
// Grand-child commands
var grandChildCommands = context
.Root.GetChildCommands(childCommand.Name)
.OrderBy(c => c.Name, StringComparer.Ordinal)
.ToArray();
if (grandChildCommands.Any())
{
Write(ConsoleColor.White, "Subcommands: ");
var isFirst = true;
foreach (var grandChildCommand in grandChildCommands)
{
if (isFirst)
{
isFirst = false;
}
else
{
Write(", ");
}
Write(
ConsoleColor.Cyan,
// Trim the name so it's relative to the current command
grandChildCommand.Name?.Substring(context.Command.Name?.Length ?? 0).Trim()
);
}
Write('.');
}
WriteLine();
}
// Child command help tip
WriteVerticalMargin();
Write("You can run \"");
WriteCommandInvocation();
Write(' ');
Write(ConsoleColor.Cyan, "[command]");
Write(' ');
Write(ConsoleColor.White, "--help");
Write("\" to show help for a specific command.");
WriteLine();
}
public void WriteHelpText()
{
// Application info
Write(ConsoleColor.White, context.Metadata.Title);
Write(' ');
Write(ConsoleColor.Yellow, context.Metadata.Version);
WriteLine();
if (!string.IsNullOrWhiteSpace(context.Metadata.Description))
{
WriteHorizontalMargin();
Write(context.Metadata.Description);
WriteLine();
}
// Command info
WriteCommandUsage();
WriteCommandDescription();
WriteCommandParameters();
WriteCommandOptions();
WriteCommandChildren();
}
}