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.
446 lines
12 KiB
C#
446 lines
12 KiB
C#
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();
|
|
}
|
|
|
|
// Examples
|
|
foreach (var example in context.Command.Examples)
|
|
{
|
|
WriteHorizontalMargin();
|
|
|
|
WriteCommandInvocation();
|
|
Write(' ');
|
|
|
|
Write(example);
|
|
|
|
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();
|
|
}
|
|
}
|