diff --git a/CliFx.Generators/Binding/CommandSymbol.cs b/CliFx.Generators/Binding/CommandSymbol.cs index 5765495..54e56b6 100644 --- a/CliFx.Generators/Binding/CommandSymbol.cs +++ b/CliFx.Generators/Binding/CommandSymbol.cs @@ -3,6 +3,7 @@ using System.Linq; using CliFx.Generators.Utils; using CliFx.Generators.Utils.Extensions; using Microsoft.CodeAnalysis; +using PowerKit.Extensions; namespace CliFx.Generators.Binding; @@ -10,6 +11,7 @@ internal partial record CommandSymbol( INamedTypeSymbol Type, string? Name, string? Description, + IReadOnlyList Examples, IReadOnlyList Inputs ) { @@ -71,10 +73,19 @@ internal partial record CommandSymbol attribute?.NamedArguments.FirstOrDefault(a => a.Key == "Description").Value.Value as string; + var examples = + attribute + ?.NamedArguments.Where(a => a.Key == "Examples") + .SelectMany(a => a.Value.Values) + .Select(v => v.Value as string) + .WhereNotNull() + .ToArray() + ?? []; + 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]); + return new CommandSymbol(type, name, description, examples, [.. parameters, .. options]); } } diff --git a/CliFx.Generators/Emitters/CommandDescriptor.cs b/CliFx.Generators/Emitters/CommandDescriptor.cs index 8f14af3..773fe10 100644 --- a/CliFx.Generators/Emitters/CommandDescriptor.cs +++ b/CliFx.Generators/Emitters/CommandDescriptor.cs @@ -31,6 +31,17 @@ internal static class CommandDescriptor """; } + private static string EmitExamples(IReadOnlyList examples) + { + if (examples.Count == 0) + return "global::System.Array.Empty()"; + + // lang=csharp + return $$""" + new[] { {{string.Join(", ", examples.Select(CSharp.Encode))}} } + """; + } + private static string? TryEmitDefaultScalarConverter(ITypeSymbol type) { // Object @@ -522,6 +533,7 @@ internal static class CommandDescriptor new global::CliFx.Binding.CommandDescriptor<{{command.Type.GetGloballyQualifiedName()}}>( {{CSharp.Encode(command.Name)}}, {{CSharp.Encode(command.Description)}}, + {{EmitExamples(command.Examples)}}, new global::CliFx.Binding.CommandInputDescriptor[] { {{string.Join(",", command.Inputs diff --git a/CliFx.Tests/HelpSpecs.cs b/CliFx.Tests/HelpSpecs.cs index 72a1e4b..084af76 100644 --- a/CliFx.Tests/HelpSpecs.cs +++ b/CliFx.Tests/HelpSpecs.cs @@ -381,6 +381,81 @@ public class HelpSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput) .ContainAllInOrder("USAGE", "--foo ", "--baz ", "[options]"); } + [Fact] + public async Task I_can_request_help_to_see_the_usage_examples_of_a_command() + { + // Arrange + var application = new CommandLineApplicationBuilder() + .AddCommands( + CommandCompiler.Compile( + // lang=csharp + """ + [Command("foo", Examples = new[] { "--bar 42", "-b 42" })] + public partial class Command : ICommand + { + [CommandOption("bar", 'b')] + public int Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + """ + ) + ) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + ["foo", "--help"], + new Dictionary() + ); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut + .Should() + .ContainAllInOrder("USAGE", "foo [options]", "foo --bar 42", "foo -b 42", "OPTIONS"); + } + + [Fact] + public async Task I_do_not_see_any_usage_examples_if_the_command_does_not_define_any() + { + // Arrange + var application = new CommandLineApplicationBuilder() + .AddCommands( + CommandCompiler.Compile( + // lang=csharp + """ + [Command("foo")] + public partial class Command : ICommand + { + [CommandOption("bar", 'b')] + public int Bar { get; set; } + + public ValueTask ExecuteAsync(IConsole console) => default; + } + """ + ) + ) + .UseConsole(FakeConsole) + .Build(); + + // Act + var exitCode = await application.RunAsync( + ["foo", "--help"], + new Dictionary() + ); + + // Assert + exitCode.Should().Be(0); + + var stdOut = FakeConsole.ReadOutputString(); + stdOut.Should().Contain("foo [options]"); + stdOut.Should().NotContain("foo --bar 42"); + } + [Fact] public async Task I_can_request_help_to_see_the_list_of_all_inputs() { diff --git a/CliFx/Binding/CommandAttribute.cs b/CliFx/Binding/CommandAttribute.cs index b6fa828..637801b 100644 --- a/CliFx/Binding/CommandAttribute.cs +++ b/CliFx/Binding/CommandAttribute.cs @@ -29,4 +29,15 @@ public class CommandAttribute(string? name) : Attribute /// Used for display purposes in the help text. /// public string? Description { get; set; } + + /// + /// Usage examples of the command. + /// Used for display purposes in the help text. + /// + /// + /// Each example should only contain the parameters and options that come after the + /// command name; the command name itself is prepended automatically when the example + /// is rendered in the help text. + /// + public string[] Examples { get; set; } = []; } diff --git a/CliFx/Binding/CommandDescriptor.cs b/CliFx/Binding/CommandDescriptor.cs index 169515c..64c81b6 100644 --- a/CliFx/Binding/CommandDescriptor.cs +++ b/CliFx/Binding/CommandDescriptor.cs @@ -13,6 +13,7 @@ public class CommandDescriptor( Type type, string? name, string? description, + IReadOnlyList examples, IReadOnlyList inputs ) { @@ -30,6 +31,9 @@ public class CommandDescriptor( /// public string? Description { get; } = description; + /// + public IReadOnlyList Examples { get; } = examples; + /// /// Inputs (parameters and options) of the command; /// @@ -57,6 +61,10 @@ public class CommandDescriptor( public class CommandDescriptor< [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TCommand ->(string? name, string? description, IReadOnlyList inputs) - : CommandDescriptor(typeof(TCommand), name, description, inputs) +>( + string? name, + string? description, + IReadOnlyList examples, + IReadOnlyList inputs +) : CommandDescriptor(typeof(TCommand), name, description, examples, inputs) where TCommand : ICommand; diff --git a/CliFx/Help/HelpWriter.cs b/CliFx/Help/HelpWriter.cs index 2be4295..67dcd2f 100644 --- a/CliFx/Help/HelpWriter.cs +++ b/CliFx/Help/HelpWriter.cs @@ -73,6 +73,19 @@ internal class HelpWriter(HelpContext context, ConsoleWriter consoleWriter) WriteLine(); } + // Examples + foreach (var example in context.Command.Examples) + { + WriteHorizontalMargin(); + + WriteCommandInvocation(); + Write(' '); + + Write(example); + + WriteLine(); + } + // Child commands if (context.Root.GetChildCommands(context.Command.Name).Any()) {