Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow custom help providers #1259

Merged
merged 25 commits into from
Sep 8, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dea428a
Help writer improvements
FrankRay78 Jul 5, 2023
e8ce305
Reordering to better group together tests for default command and bra…
FrankRay78 Jul 7, 2023
a467c35
Added documentation to CommandInfo.Flatten() method
FrankRay78 Jul 11, 2023
0272a7d
Allow custom help providers
FrankRay78 Jul 19, 2023
1393ed5
Updated documentation for clarity and grammar
FrankRay78 Aug 3, 2023
a7757f9
DefaultHelpProvider constructor now accepts an ICommandAppSettings, r…
FrankRay78 Aug 4, 2023
1f22d15
Removed the setter on ICommandArgument.Position
FrankRay78 Aug 6, 2023
bdea5a5
Allow custom help providers to be configured, in addition to being re…
FrankRay78 Aug 10, 2023
f66e5cf
Exposed internal methods within DefaultHelpProvider for overriding by…
FrankRay78 Aug 10, 2023
a7fdf3c
Added example showing how to extend the built-in spectre.console help…
FrankRay78 Aug 11, 2023
f6986a5
Added example showing how to extend the built-in spectre.console help…
FrankRay78 Aug 11, 2023
db7c27d
Removed use of Composer internal class (which should not have been ma…
FrankRay78 Aug 11, 2023
6b449d5
User documentation for help providers
FrankRay78 Aug 17, 2023
919f9f8
Added Spectre.Console.Cli.Help to global usings; removed fully qualif…
FrankRay78 Sep 2, 2023
a4f06aa
Added the Example meta data to Help.csproj
FrankRay78 Sep 2, 2023
55c4271
Explicitly qualify the return type as IRenderable
FrankRay78 Sep 2, 2023
bb1c986
Removed namespace qualification from DefaultHelpProvider, IHelpProvid…
FrankRay78 Sep 2, 2023
d3b7f6b
Use an injected IAnsiConsole since that is the recommended best pract…
FrankRay78 Sep 2, 2023
6903c15
Added null argument check
FrankRay78 Sep 2, 2023
3e7dbc2
No multiple blank lines
FrankRay78 Sep 2, 2023
ebd73a1
Minor refactoring and code commentary to show that CommandModel alway…
FrankRay78 Sep 2, 2023
08eda96
Removed the redundant Write method on IHelpProvider
FrankRay78 Sep 2, 2023
416f656
Use IReadOnlyList<> instead
FrankRay78 Sep 2, 2023
57613f3
Renamed DefaultHelpProvider to HelpProvider
FrankRay78 Sep 2, 2023
475818b
Removed redundant "Help." namespace prefixes
FrankRay78 Sep 2, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/input/cli/commandApp.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ For more complex command hierarchical configurations, they can also be composed

## Customizing Command Configurations

The `Configure` method is also used to change how help for the commands is generated. This configuration will give our command an additional alias of `file-size` and a description to be used when displaying the help. Additional, an example is specified that will be parsed and displayed for users asking for help. Multiple examples can be provided. Commands can also be marked as hidden. With this option they are still executable, but will not be displayed in help screens.
The `Configure` method is also used to change how help for the commands is generated. This configuration will give our command an additional alias of `file-size` and a description to be used when displaying the help. Additionally, an example is specified that will be parsed and displayed for users asking for help. Multiple examples can be provided. Commands can also be marked as hidden. With this option they are still executable, but will not be displayed in help screens.

``` csharp
var app = new CommandApp();
Expand Down
2 changes: 1 addition & 1 deletion docs/input/cli/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This setting file tells `Spectre.Console.Cli` that our command has two parameter

## CommandArgument

Arguments have a position and a name. The name is not only used for generating help, but its formatting is used to determine whether or not the argument is optional. The name must either be surrounded by square brackets (e.g. `[name]`) or angle brackets (e.g. `<name>`). Angle brackets denote required whereas square brackets denote optional. If neither are specified an exception will be thrown.
Arguments have a position and a name. The name is not only used for generating help, but its formatting is used to determine whether or not the argument is optional. Angle brackets denote a required argument (e.g. `<name>`) whereas square brackets denote an optional argument (e.g. `[name]`). If neither are specified an exception will be thrown.

The position is used for scenarios where there could be more than one argument.

Expand Down
34 changes: 17 additions & 17 deletions examples/Cli/Demo/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,25 @@ public static int Main(string[] args)
{
config.SetApplicationName("fake-dotnet");
config.ValidateExamples();
config.AddExample(new[] { "run", "--no-build" });

// Run
config.AddCommand<RunCommand>("run");

// Add
config.AddBranch<AddSettings>("add", add =>
{
add.SetDescription("Add a package or reference to a .NET project");
add.AddCommand<AddPackageCommand>("package");
add.AddCommand<AddReferenceCommand>("reference");
config.AddExample("run", "--no-build");

// Run
config.AddCommand<RunCommand>("run");

// Add
config.AddBranch<AddSettings>("add", add =>
{
add.SetDescription("Add a package or reference to a .NET project");
add.AddCommand<AddPackageCommand>("package");
add.AddCommand<AddReferenceCommand>("reference");
});

// Serve
config.AddCommand<ServeCommand>("serve")
.WithExample("serve", "-o", "firefox")
.WithExample("serve", "--port", "80", "-o", "firefox");
});

// Serve
config.AddCommand<ServeCommand>("serve")
.WithExample(new[] { "serve", "-o", "firefox" })
.WithExample(new[] { "serve", "--port", "80", "-o", "firefox" });
});

return app.Run(args);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
namespace Spectre.Console.Cli;

internal static class HelpWriter
{
namespace Spectre.Console.Cli.Help;

/// <summary>
/// The default help provider for spectre.console.
/// </summary>
/// <remarks>
/// Other IHelpProvider implementations can be injected into the CommandApp, if desired.
/// </remarks>
public class DefaultHelpProvider : IHelpProvider
FrankRay78 marked this conversation as resolved.
Show resolved Hide resolved
{
// Help provider configuration settings.
private readonly bool writeOptionsDefaultValues;
private readonly int maxIndirectExamples;
private readonly bool trimTrailingPeriod;

private sealed class HelpArgument
{
public string Name { get; }
Expand All @@ -17,10 +28,10 @@ public HelpArgument(string name, int position, bool required, string? descriptio
Description = description;
}

public static IReadOnlyList<HelpArgument> Get(CommandInfo? command)
public static IReadOnlyList<HelpArgument> Get(ICommandInfo? command)
{
var arguments = new List<HelpArgument>();
arguments.AddRange(command?.Parameters?.OfType<CommandArgument>()?.Select(
arguments.AddRange(command?.Parameters?.OfType<ICommandArgument>()?.Select(
x => new HelpArgument(x.Value, x.Position, x.Required, x.Description))
?? Array.Empty<HelpArgument>());
return arguments;
Expand All @@ -46,49 +57,62 @@ public HelpOption(string? @short, string? @long, string? @value, bool? valueIsOp
DefaultValue = defaultValue;
}

public static IReadOnlyList<HelpOption> Get(CommandModel model, CommandInfo? command)
public static IReadOnlyList<HelpOption> Get(ICommandInfo? command)
{
var parameters = new List<HelpOption>();
parameters.Add(new HelpOption("h", "help", null, null, "Prints help information", null));

// At the root and no default command?
if (command == null && model?.DefaultCommand == null)
{
parameters.Add(new HelpOption("v", "version", null, null, "Prints version information", null));
parameters.Add(new HelpOption("h", "help", null, null, "Prints help information", null));
// At the root?
if ((command == null || command?.Parent == null) && !(command?.IsBranch ?? false))
FrankRay78 marked this conversation as resolved.
Show resolved Hide resolved
{
parameters.Add(new HelpOption("v", "version", null, null, "Prints version information", null));
}

parameters.AddRange(command?.Parameters.OfType<CommandOption>().Where(o => !o.IsHidden).Select(o =>
parameters.AddRange(command?.Parameters.OfType<ICommandOption>().Where(o => !o.IsHidden).Select(o =>
new HelpOption(
o.ShortNames.FirstOrDefault(), o.LongNames.FirstOrDefault(),
o.ValueName, o.ValueIsOptional, o.Description,
o.ParameterKind == ParameterKind.Flag && o.DefaultValue?.Value is false ? null : o.DefaultValue?.Value))
o.IsFlag && o.DefaultValue?.Value is false ? null : o.DefaultValue?.Value))
?? Array.Empty<HelpOption>());
return parameters;
}
}

public static IEnumerable<IRenderable> Write(CommandModel model, bool writeOptionsDefaultValues)
}

/// <summary>
/// Initializes a new instance of the <see cref="DefaultHelpProvider"/> class.
/// </summary>
/// <param name="writeOptionsDefaultValue">A boolean value indicating whether to write option default values.</param>
/// <param name="maxIndirectExamples">The maximum number of indirect examples to display.</param>
/// <param name="trimTrailingPeriod">A boolean value indicating whether to trim trailing periods from command descriptions.</param>
public DefaultHelpProvider(bool writeOptionsDefaultValue, int maxIndirectExamples, bool trimTrailingPeriod)
{
this.writeOptionsDefaultValues = writeOptionsDefaultValue;
this.maxIndirectExamples = maxIndirectExamples;
this.trimTrailingPeriod = trimTrailingPeriod;
}

/// <inheritdoc/>
public virtual IEnumerable<IRenderable> Write(ICommandModel model)
{
return WriteCommand(model, null, writeOptionsDefaultValues);
}

public static IEnumerable<IRenderable> WriteCommand(CommandModel model, CommandInfo? command, bool writeOptionsDefaultValues)
return WriteCommand(model, null);
}

/// <inheritdoc/>
public virtual IEnumerable<IRenderable> WriteCommand(ICommandModel model, ICommandInfo? command)
{
var container = command as ICommandContainer ?? model;
var isDefaultCommand = command?.IsDefaultCommand ?? false;
var result = new List<IRenderable>();

var result = new List<IRenderable>();
result.AddRange(GetDescription(command));
result.AddRange(GetUsage(model, command));
result.AddRange(GetExamples(model, command));
result.AddRange(GetExamples(model, command, maxIndirectExamples));
result.AddRange(GetArguments(command));
result.AddRange(GetOptions(model, command, writeOptionsDefaultValues));
result.AddRange(GetCommands(model, container, isDefaultCommand));
result.AddRange(GetOptions(command, writeOptionsDefaultValues));
result.AddRange(GetCommands(model, command, trimTrailingPeriod));

return result;
}

private static IEnumerable<IRenderable> GetDescription(CommandInfo? command)
private static IEnumerable<IRenderable> GetDescription(ICommandInfo? command)
{
if (command?.Description == null)
{
Expand All @@ -99,13 +123,13 @@ private static IEnumerable<IRenderable> GetDescription(CommandInfo? command)
composer.Style("yellow", "DESCRIPTION:").LineBreak();
composer.Text(command.Description).LineBreak();
yield return composer.LineBreak();
}

private static IEnumerable<IRenderable> GetUsage(CommandModel model, CommandInfo? command)
}
private static IEnumerable<IRenderable> GetUsage(ICommandModel model, ICommandInfo? command)
{
var composer = new Composer();
composer.Style("yellow", "USAGE:").LineBreak();
composer.Tab().Text(model.GetApplicationName());
composer.Tab().Text(model.ApplicationName);

var parameters = new List<string>();

Expand All @@ -132,18 +156,18 @@ private static IEnumerable<IRenderable> GetUsage(CommandModel model, CommandInfo
}
}

if (current.Parameters.OfType<CommandArgument>().Any())
if (current.Parameters.OfType<ICommandArgument>().Any())
{
if (isCurrent)
{
foreach (var argument in current.Parameters.OfType<CommandArgument>()
foreach (var argument in current.Parameters.OfType<ICommandArgument>()
.Where(a => a.Required).OrderBy(a => a.Position).ToArray())
{
parameters.Add($"[aqua]<{argument.Value.EscapeMarkup()}>[/]");
}
}

var optionalArguments = current.Parameters.OfType<CommandArgument>().Where(x => !x.Required).ToArray();
var optionalArguments = current.Parameters.OfType<ICommandArgument>().Where(x => !x.Required).ToArray();
if (optionalArguments.Length > 0 || !isCurrent)
{
foreach (var optionalArgument in optionalArguments)
Expand All @@ -159,9 +183,27 @@ private static IEnumerable<IRenderable> GetUsage(CommandModel model, CommandInfo
}
}

if (command.IsBranch)
{
if (command.IsBranch && command.DefaultCommand == null)
{
// The user must specify the command
FrankRay78 marked this conversation as resolved.
Show resolved Hide resolved
parameters.Add("[aqua]<COMMAND>[/]");
}
else if (command.IsBranch && command.DefaultCommand != null && command.Commands.Count > 0)
{
// We are on a branch with a default commnd
FrankRay78 marked this conversation as resolved.
Show resolved Hide resolved
// The user can optionally specify the command
parameters.Add("[aqua][[COMMAND]][/]");
}
else if (command.IsDefaultCommand)
{
var commands = model.Commands.Where(x => !x.IsHidden && !x.IsDefaultCommand).ToList();

if (commands.Count > 0)
{
// Commands other than the default are present
// So make these optional in the usage statement
parameters.Add("[aqua][[COMMAND]][/]");
}
}
}

Expand All @@ -172,9 +214,16 @@ private static IEnumerable<IRenderable> GetUsage(CommandModel model, CommandInfo
{
composer,
};
}

private static IEnumerable<IRenderable> GetExamples(CommandModel model, CommandInfo? command)
}

/// <summary>
/// Gets the examples for a command.
/// </summary>
/// <remarks>
/// Examples from the command's direct children are used
/// if no examples have been set on the specified command or model.
/// </remarks>
private static IEnumerable<IRenderable> GetExamples(ICommandModel model, ICommandInfo? command, int maxIndirectExamples)
{
var maxExamples = int.MaxValue;

Expand All @@ -183,26 +232,27 @@ private static IEnumerable<IRenderable> GetExamples(CommandModel model, CommandI
{
// Since we're not checking direct examples,
// make sure that we limit the number of examples.
maxExamples = 5;
maxExamples = maxIndirectExamples;

// Get the current root command.
var root = command ?? (ICommandContainer)model;
var queue = new Queue<ICommandContainer>(new[] { root });
// Start at the current command (if exists)
// or alternatively commence at the model.
var commandContainer = command ?? (ICommandContainer)model;
var queue = new Queue<ICommandContainer>(new[] { commandContainer });

// Traverse the command tree and look for examples.
// Traverse the command tree and look for examples.
// As soon as a node contains commands, bail.
while (queue.Count > 0)
{
var current = queue.Dequeue();

foreach (var cmd in current.Commands.Where(x => !x.IsHidden))
foreach (var child in current.Commands.Where(x => !x.IsHidden))
{
if (cmd.Examples.Count > 0)
if (child.Examples.Count > 0)
{
examples.AddRange(cmd.Examples);
examples.AddRange(child.Examples);
}

queue.Enqueue(cmd);
queue.Enqueue(child);
}

if (examples.Count >= maxExamples)
Expand All @@ -212,7 +262,7 @@ private static IEnumerable<IRenderable> GetExamples(CommandModel model, CommandI
}
}

if (examples.Count > 0)
if (Math.Min(maxExamples, examples.Count) > 0)
{
var composer = new Composer();
composer.LineBreak();
Expand All @@ -221,17 +271,17 @@ private static IEnumerable<IRenderable> GetExamples(CommandModel model, CommandI
for (var index = 0; index < Math.Min(maxExamples, examples.Count); index++)
{
var args = string.Join(" ", examples[index]);
composer.Tab().Text(model.GetApplicationName()).Space().Style("grey", args);
composer.Tab().Text(model.ApplicationName).Space().Style("grey", args);
composer.LineBreak();
}

return new[] { composer };
}

return Array.Empty<IRenderable>();
}

private static IEnumerable<IRenderable> GetArguments(CommandInfo? command)
}
private static IEnumerable<IRenderable> GetArguments(ICommandInfo? command)
{
var arguments = HelpArgument.Get(command);
if (arguments.Count == 0)
Expand Down Expand Up @@ -269,10 +319,10 @@ private static IEnumerable<IRenderable> GetArguments(CommandInfo? command)
return result;
}

private static IEnumerable<IRenderable> GetOptions(CommandModel model, CommandInfo? command, bool writeDefaultValues)
private static IEnumerable<IRenderable> GetOptions(ICommandInfo? command, bool writeOptionsDefaultValues)
{
// Collect all options into a single structure.
var parameters = HelpOption.Get(model, command);
var parameters = HelpOption.Get(command);
if (parameters.Count == 0)
{
return Array.Empty<IRenderable>();
Expand All @@ -286,7 +336,7 @@ private static IEnumerable<IRenderable> GetOptions(CommandModel model, CommandIn
};

var helpOptions = parameters.ToArray();
var defaultValueColumn = writeDefaultValues && helpOptions.Any(e => e.DefaultValue != null);
var defaultValueColumn = writeOptionsDefaultValues && helpOptions.Any(e => e.DefaultValue != null);

var grid = new Grid();
grid.AddColumn(new GridColumn { Padding = new Padding(4, 4), NoWrap = true });
Expand Down Expand Up @@ -371,12 +421,12 @@ static string GetOptionParts(HelpOption option)
return result;
}

private static IEnumerable<IRenderable> GetCommands(
CommandModel model,
ICommandContainer command,
bool isDefaultCommand)
{
var commands = isDefaultCommand ? model.Commands : command.Commands;
private static IEnumerable<IRenderable> GetCommands(ICommandModel model, ICommandInfo? command, bool trimTrailingPeriod)
{
var commandContainer = command ?? (ICommandContainer)model;
bool isDefaultCommand = command?.IsDefaultCommand ?? false;

var commands = isDefaultCommand ? model.Commands : commandContainer.Commands;
commands = commands.Where(x => !x.IsHidden).ToList();

if (commands.Count == 0)
Expand Down Expand Up @@ -407,7 +457,7 @@ private static IEnumerable<IRenderable> GetCommands(
arguments.Space();
}

if (model.TrimTrailingPeriod)
if (trimTrailingPeriod)
{
grid.AddRow(
arguments.ToString().TrimEnd(),
Expand All @@ -425,4 +475,4 @@ private static IEnumerable<IRenderable> GetCommands(

return result;
}
}
}
Loading