commit e3e64a40e4b936a45ff2dafe25aa11ccaabd4014 Author: upstream Date: Fri Aug 28 10:12:58 2026 +0200 Upstream Tyrrrz/CliFx state of the touched files before 9ef5dcb635 diff --git a/CliFx.Generators/Binding/CommandSymbol.cs b/CliFx.Generators/Binding/CommandSymbol.cs new file mode 100644 index 0000000..5765495 --- /dev/null +++ b/CliFx.Generators/Binding/CommandSymbol.cs @@ -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 Inputs +) +{ + public bool IsDefault => string.IsNullOrWhiteSpace(Name); + + public IReadOnlyList Parameters { get; } = + Inputs.OfType().ToArray(); + + public IReadOnlyList Options { get; } = + Inputs.OfType().ToArray(); + + public override string ToString() => !string.IsNullOrWhiteSpace(Name) ? Name : ""; +} + +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]); + } +} diff --git a/CliFx.Generators/Emitters/CommandDescriptor.cs b/CliFx.Generators/Emitters/CommandDescriptor.cs new file mode 100644 index 0000000..8f14af3 --- /dev/null +++ b/CliFx.Generators/Emitters/CommandDescriptor.cs @@ -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 validatorTypes, + string propertyTypeName + ) + { + if (validatorTypes.Count == 0) + return $"global::System.Array.Empty>()"; + + // 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 + 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 + is SpecialType.System_Collections_Generic_IEnumerable_T + // ICollection + or SpecialType.System_Collections_Generic_ICollection_T + // IList + or SpecialType.System_Collections_Generic_IList_T + // IReadOnlyCollection + or SpecialType.System_Collections_Generic_IReadOnlyCollection_T + // IReadOnlyList + 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 + is SpecialType.System_Collections_Generic_IEnumerable_T + // ICollection + or SpecialType.System_Collections_Generic_ICollection_T + // IList + or SpecialType.System_Collections_Generic_IList_T + // IReadOnlyCollection + or SpecialType.System_Collections_Generic_IReadOnlyCollection_T + // IReadOnlyList + 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>() + ) + """; + } + + 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>() + ) + """; + } + + 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 + $$""" + // + + {{(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}} + { + /// + /// Generated command descriptor for ({{Xml.Escape(command.ToString())}}). + /// + /// + /// + {{string.Join( + Environment.NewLine, + command.Inputs.Select(i => + // lang=xml + $""" + /// + /// ({Xml.Escape(i.ToString())}) + /// + """ + ) + ).NullIfWhiteSpace() ?? "///"}} + /// + /// + 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 + { + /// + public bool IsHelpRequested { get; set; } + } + """ + : "" + )}} + + {{(command.IsDefault && !command.Type.Implements("CliFx.ICommandWithVersionOption") + ? // lang=csharp + $$""" + partial class {{command.Type.Name}} : global::CliFx.ICommandWithVersionOption + { + /// + public bool IsVersionRequested { get; set; } + } + """ + : "" + )}} + + {{string.Concat(command.Type.GetContainingTypes().Reverse().Select(_ => "}"))}} + """; +} diff --git a/CliFx.Tests/HelpSpecs.cs b/CliFx.Tests/HelpSpecs.cs new file mode 100644 index 0000000..72a1e4b --- /dev/null +++ b/CliFx.Tests/HelpSpecs.cs @@ -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()); + + // 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()); + + // 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()); + + // 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() + ); + + // 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() + ); + + // 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() + ); + + // 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()); + + // 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()); + + // 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()); + + // 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 Baz { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + """ + ) + ) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync(["--help"], new Dictionary()); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut + .Should() + .ContainAllInOrder("USAGE", "", "", "", ""); + } + + [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 Baz { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + """ + ) + ) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync(["--help"], new Dictionary()); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut + .Should() + .ContainAllInOrder("USAGE", "--foo ", "--baz ", "[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()); + + // 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()); + + // 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()); + + // 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() + ); + + // 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()); + + // 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()); + + // 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()); + + // 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? 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()); + + // 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()); + + // 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()); + + // 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()); + + // 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()); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut.Trim().Should().Be("v1.0"); + } +} diff --git a/CliFx/Binding/CommandAttribute.cs b/CliFx/Binding/CommandAttribute.cs new file mode 100644 index 0000000..b6fa828 --- /dev/null +++ b/CliFx/Binding/CommandAttribute.cs @@ -0,0 +1,32 @@ +using System; + +namespace CliFx.Binding; + +/// +/// Binds a type to a command. +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public class CommandAttribute(string? name) : Attribute +{ + /// + /// Initializes an instance of . + /// + public CommandAttribute() + : this(null) { } + + /// + /// Command name. + /// + /// + /// 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). + /// + public string? Name { get; } = name; + + /// + /// Command description. + /// Used for display purposes in the help text. + /// + public string? Description { get; set; } +} diff --git a/CliFx/Binding/CommandDescriptor.cs b/CliFx/Binding/CommandDescriptor.cs new file mode 100644 index 0000000..169515c --- /dev/null +++ b/CliFx/Binding/CommandDescriptor.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace CliFx.Binding; + +/// +/// Describes a binding between a CLR type and a command. +/// +public class CommandDescriptor( + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + Type type, + string? name, + string? description, + IReadOnlyList inputs +) +{ + /// + /// CLR type of the command. + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + public Type Type { get; } = type; + + /// + public string? Name { get; } = name; + + internal bool IsDefault => string.IsNullOrWhiteSpace(Name); + + /// + public string? Description { get; } = description; + + /// + /// Inputs (parameters and options) of the command; + /// + public IReadOnlyList Inputs { get; } = inputs; + + internal IReadOnlyList Parameters { get; } = + inputs.OfType().ToArray(); + + internal IReadOnlyList Options { get; } = + inputs.OfType().ToArray(); + + internal bool MatchesName(string? name) => + !string.IsNullOrWhiteSpace(Name) + ? string.Equals(name, Name, StringComparison.OrdinalIgnoreCase) + : string.IsNullOrWhiteSpace(name); + + /// + public override string ToString() => !string.IsNullOrWhiteSpace(Name) ? Name : ""; +} + +/// +/// +/// Generic version used by source-generated code for static type references and AOT compatibility. +/// +public class CommandDescriptor< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] + TCommand +>(string? name, string? description, IReadOnlyList inputs) + : CommandDescriptor(typeof(TCommand), name, description, inputs) + where TCommand : ICommand; diff --git a/CliFx/Help/HelpWriter.cs b/CliFx/Help/HelpWriter.cs new file mode 100644 index 0000000..2be4295 --- /dev/null +++ b/CliFx/Help/HelpWriter.cs @@ -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().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(); + } +}