From 8f11b8ff385fea589176801b9212b50b5c7cdc4b Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 10:12:58 +0200 Subject: [PATCH] feat: add command usage examples Add an Examples string array property to CommandAttribute so command authors can document example invocations. Each example contains only the arguments that follow the command name; the executable and command name are prepended automatically when the example is rendered. Examples are rendered as extra lines in the USAGE section of the help text. The source generator resolves the values from the attribute and emits them into the command descriptor consumed at runtime. --- CliFx.Generators/Binding/CommandSymbol.cs | 13 +++- .../Emitters/CommandDescriptor.cs | 12 +++ CliFx.Tests/HelpSpecs.cs | 75 +++++++++++++++++++ CliFx/Binding/CommandAttribute.cs | 10 +++ CliFx/Binding/CommandDescriptor.cs | 12 ++- CliFx/Help/HelpWriter.cs | 13 ++++ 6 files changed, 132 insertions(+), 3 deletions(-) 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..daad80e 100644 --- a/CliFx/Binding/CommandAttribute.cs +++ b/CliFx/Binding/CommandAttribute.cs @@ -29,4 +29,14 @@ 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 contains only what comes after the command name; the name itself + /// is prepended when the help text is rendered. + /// + 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()) {