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.
This commit is contained in:
dualfroz committed 2026-08-28 10:12:58 +02:00
1 parent 50964082ba
commit 5e1eff9f24
6 files changed
+133 -3

No files matched your search

+12 -1
View File
@@ -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<string> Examples,
IReadOnlyList<CommandInputSymbol> 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]);
}
}
@@ -31,6 +31,17 @@ internal static class CommandDescriptor
""";
}
private static string EmitExamples(IReadOnlyList<string> examples)
{
if (examples.Count == 0)
return "global::System.Array.Empty<string>()";
// 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
+75
View File
@@ -381,6 +381,81 @@ public class HelpSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
.ContainAllInOrder("USAGE", "--foo <value>", "--baz <values...>", "[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<string, string>()
);
// 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<string, string>()
);
// 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()
{
+11
View File
@@ -29,4 +29,15 @@ public class CommandAttribute(string? name) : Attribute
/// Used for display purposes in the help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Usage examples of the command.
/// Used for display purposes in the help text.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public string[] Examples { get; set; } = [];
}
+10 -2
View File
@@ -13,6 +13,7 @@ public class CommandDescriptor(
Type type,
string? name,
string? description,
IReadOnlyList<string> examples,
IReadOnlyList<CommandInputDescriptor> inputs
)
{
@@ -30,6 +31,9 @@ public class CommandDescriptor(
/// <inheritdoc cref="CommandAttribute.Description" />
public string? Description { get; } = description;
/// <inheritdoc cref="CommandAttribute.Examples" />
public IReadOnlyList<string> Examples { get; } = examples;
/// <summary>
/// Inputs (parameters and options) of the command;
/// </summary>
@@ -57,6 +61,10 @@ public class CommandDescriptor(
public class CommandDescriptor<
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)]
TCommand
>(string? name, string? description, IReadOnlyList<CommandInputDescriptor> inputs)
: CommandDescriptor(typeof(TCommand), name, description, inputs)
>(
string? name,
string? description,
IReadOnlyList<string> examples,
IReadOnlyList<CommandInputDescriptor> inputs
) : CommandDescriptor(typeof(TCommand), name, description, examples, inputs)
where TCommand : ICommand;
+13
View File
@@ -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())
{