Allow custom help providers (#1259)

Allow custom help providers

* Version option will show in help even with a default command

* Reserve `-v` and `--version` as special Spectre.Console command line arguments (nb. breaking change for Spectre.Console users who have a default command with a settings class that uses either of these switches).

* Help writer correctly determines if trailing commands exist and whether to display them as optional or mandatory in the usage statement.

* Ability to control the number of indirect commands to display in the help text when the command itself doesn't have any examples of its own. Defaults to 5 (for backward compatibility) but can be set to any integer or zero to disable completely.

* Significant increase in unit test coverage for the help writer.

* Minor grammatical improvements to website documentation.
This commit is contained in:
Frank Ray
2023-09-08 08:51:33 +01:00
committed by GitHub
parent 813a53cdfa
commit 131b37fff8
70 changed files with 1646 additions and 330 deletions

View File

@ -1,3 +1,5 @@
using Spectre.Console.Cli.Tests.Data.Help;
namespace Spectre.Console.Tests.Unit.Cli;
public sealed partial class CommandAppTests
@ -75,8 +77,8 @@ public sealed partial class CommandAppTests
}
[Fact]
[Expectation("Command")]
public Task Should_Output_Command_Correctly()
[Expectation("Branch")]
public Task Should_Output_Branch_Correctly()
{
// Given
var fixture = new CommandAppTester();
@ -91,7 +93,53 @@ public sealed partial class CommandAppTests
});
// When
var result = fixture.Run("cat", "--help");
var result = fixture.Run("cat", "--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Branch_Called_Without_Help")]
public Task Should_Output_Branch_When_Called_Without_Help_Option()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddBranch<CatSettings>("cat", animal =>
{
animal.SetDescription("Contains settings for a cat.");
animal.AddCommand<LionCommand>("lion");
});
});
// When
var result = fixture.Run("cat");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Branch_Default_Greeter")]
public Task Should_Output_Branch_With_Default_Correctly()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddBranch<OptionalArgumentWithDefaultValueSettings>("branch", animal =>
{
animal.SetDefaultCommand<GreeterCommand>();
animal.AddCommand<GreeterCommand>("greeter");
});
});
// When
var result = fixture.Run("branch", "--help");
// Then
return Verifier.Verify(result.Output);
@ -138,7 +186,7 @@ public sealed partial class CommandAppTests
});
// When
var result = fixture.Run("cat", "lion", "--help");
var result = fixture.Run("cat", "lion", "--help");
// Then
return Verifier.Verify(result.Output);
@ -203,7 +251,7 @@ public sealed partial class CommandAppTests
}
[Fact]
[Expectation("Greeter_Default")]
[Expectation("Default_Greeter")]
public Task Should_Not_Output_Default_Command_When_Command_Has_No_Required_Parameters_And_Is_Called_Without_Args()
{
// Given
@ -219,19 +267,131 @@ public sealed partial class CommandAppTests
// Then
return Verifier.Verify(result.Output);
}
}
[Fact]
[Expectation("Custom_Help_Registered_By_Instance")]
public Task Should_Output_Custom_Help_When_Registered_By_Instance()
{
var registrar = new DefaultTypeRegistrar();
// Given
var fixture = new CommandAppTester(registrar);
fixture.Configure(configurator =>
{
// Create the custom help provider
var helpProvider = new CustomHelpProvider(configurator.Settings, "1.0");
// Register the custom help provider instance
registrar.RegisterInstance(typeof(IHelpProvider), helpProvider);
configurator.SetApplicationName("myapp");
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run();
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Custom_Help_Registered_By_Type")]
public Task Should_Output_Custom_Help_When_Registered_By_Type()
{
var registrar = new DefaultTypeRegistrar();
// Given
var fixture = new CommandAppTester(registrar);
fixture.Configure(configurator =>
{
// Register the custom help provider type
registrar.Register(typeof(IHelpProvider), typeof(RedirectHelpProvider));
configurator.SetApplicationName("myapp");
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run();
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Custom_Help_Configured_By_Instance")]
public Task Should_Output_Custom_Help_When_Configured_By_Instance()
{
var registrar = new DefaultTypeRegistrar();
// Given
var fixture = new CommandAppTester(registrar);
fixture.Configure(configurator =>
{
// Configure the custom help provider instance
configurator.SetHelpProvider(new CustomHelpProvider(configurator.Settings, "1.0"));
configurator.SetApplicationName("myapp");
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run();
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Custom_Help_Configured_By_Type")]
public Task Should_Output_Custom_Help_When_Configured_By_Type()
{
var registrar = new DefaultTypeRegistrar();
// Given
var fixture = new CommandAppTester(registrar);
fixture.Configure(configurator =>
{
// Configure the custom help provider type
configurator.SetHelpProvider<RedirectHelpProvider>();
configurator.SetApplicationName("myapp");
configurator.AddCommand<DogCommand>("dog");
});
// When
var result = fixture.Run();
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("RootExamples")]
public Task Should_Output_Root_Examples_Defined_On_Root()
[Expectation("Root_Examples")]
public Task Should_Output_Examples_Defined_On_Root()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddExample("dog", "--name", "Rufus", "--age", "12", "--good-boy");
configurator.AddExample("horse", "--name", "Brutus");
// All root examples should be shown
configurator.AddExample("dog", "--name", "Rufus", "--age", "12", "--good-boy");
configurator.AddExample("dog", "--name", "Luna");
configurator.AddExample("dog", "--name", "Charlie");
configurator.AddExample("dog", "--name", "Bella");
configurator.AddExample("dog", "--name", "Daisy");
configurator.AddExample("dog", "--name", "Milo");
configurator.AddExample("horse", "--name", "Brutus");
configurator.AddExample("horse", "--name", "Sugar", "--IsAlive", "false");
configurator.AddExample("horse", "--name", "Cash");
configurator.AddExample("horse", "--name", "Dakota");
configurator.AddExample("horse", "--name", "Cisco");
configurator.AddExample("horse", "--name", "Spirit");
configurator.AddCommand<DogCommand>("dog");
configurator.AddCommand<HorseCommand>("horse");
});
@ -241,21 +401,147 @@ public sealed partial class CommandAppTests
// Then
return Verifier.Verify(result.Output);
}
}
[Fact]
[Expectation("RootExamples_Children")]
public Task Should_Output_Root_Examples_Defined_On_Direct_Children_If_Root_Have_No_Examples()
[Expectation("Root_Examples_Children")]
[SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1512:SingleLineCommentsMustNotBeFollowedByBlankLine", Justification = "Single line comment is relevant to several code blocks that follow.")]
public Task Should_Output_Examples_Defined_On_Direct_Children_If_Root_Has_No_Examples()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.SetApplicationName("myapp");
// It should be capped to the first 5 examples by default
configurator.AddCommand<DogCommand>("dog")
.WithExample("dog", "--name", "Rufus", "--age", "12", "--good-boy");
.WithExample("dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("dog", "--name", "Luna")
.WithExample("dog", "--name", "Charlie")
.WithExample("dog", "--name", "Bella")
.WithExample("dog", "--name", "Daisy")
.WithExample("dog", "--name", "Milo");
configurator.AddCommand<HorseCommand>("horse")
.WithExample("horse", "--name", "Brutus");
.WithExample("horse", "--name", "Brutus")
.WithExample("horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("horse", "--name", "Cash")
.WithExample("horse", "--name", "Dakota")
.WithExample("horse", "--name", "Cisco")
.WithExample("horse", "--name", "Spirit");
});
// When
var result = fixture.Run("--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Root_Examples_Children_Eight")]
public Task Should_Output_Eight_Examples_Defined_On_Direct_Children_If_Root_Has_No_Examples()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
// Show the first 8 examples defined on the direct children
configurator.Settings.MaximumIndirectExamples = 8;
configurator.AddCommand<DogCommand>("dog")
.WithExample("dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("dog", "--name", "Luna")
.WithExample("dog", "--name", "Charlie")
.WithExample("dog", "--name", "Bella")
.WithExample("dog", "--name", "Daisy")
.WithExample("dog", "--name", "Milo");
configurator.AddCommand<HorseCommand>("horse")
.WithExample("horse", "--name", "Brutus")
.WithExample("horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("horse", "--name", "Cash")
.WithExample("horse", "--name", "Dakota")
.WithExample("horse", "--name", "Cisco")
.WithExample("horse", "--name", "Spirit");
});
// When
var result = fixture.Run("--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Root_Examples_Children_Twelve")]
public Task Should_Output_All_Examples_Defined_On_Direct_Children_If_Root_Has_No_Examples()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
// Show all examples defined on the direct children
configurator.Settings.MaximumIndirectExamples = int.MaxValue;
configurator.AddCommand<DogCommand>("dog")
.WithExample("dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("dog", "--name", "Luna")
.WithExample("dog", "--name", "Charlie")
.WithExample("dog", "--name", "Bella")
.WithExample("dog", "--name", "Daisy")
.WithExample("dog", "--name", "Milo");
configurator.AddCommand<HorseCommand>("horse")
.WithExample("horse", "--name", "Brutus")
.WithExample("horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("horse", "--name", "Cash")
.WithExample("horse", "--name", "Dakota")
.WithExample("horse", "--name", "Cisco")
.WithExample("horse", "--name", "Spirit");
});
// When
var result = fixture.Run("--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Root_Examples_Children_None")]
public Task Should_Not_Output_Examples_Defined_On_Direct_Children_If_Root_Has_No_Examples()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
// Do not show examples defined on the direct children
configurator.Settings.MaximumIndirectExamples = 0;
configurator.AddCommand<DogCommand>("dog")
.WithExample("dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("dog", "--name", "Luna")
.WithExample("dog", "--name", "Charlie")
.WithExample("dog", "--name", "Bella")
.WithExample("dog", "--name", "Daisy")
.WithExample("dog", "--name", "Milo");
configurator.AddCommand<HorseCommand>("horse")
.WithExample("horse", "--name", "Brutus")
.WithExample("horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("horse", "--name", "Cash")
.WithExample("horse", "--name", "Dakota")
.WithExample("horse", "--name", "Cisco")
.WithExample("horse", "--name", "Spirit");
});
// When
@ -266,8 +552,9 @@ public sealed partial class CommandAppTests
}
[Fact]
[Expectation("RootExamples_Leafs")]
public Task Should_Output_Root_Examples_Defined_On_Leaves_If_No_Other_Examples_Are_Found()
[Expectation("Root_Examples_Leafs")]
[SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1512:SingleLineCommentsMustNotBeFollowedByBlankLine", Justification = "Single line comment is relevant to several code blocks that follow.")]
public Task Should_Output_Examples_Defined_On_Leaves_If_No_Other_Examples_Are_Found()
{
// Given
var fixture = new CommandAppTester();
@ -276,11 +563,148 @@ public sealed partial class CommandAppTests
configurator.SetApplicationName("myapp");
configurator.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDescription("The animal command.");
animal.SetDescription("The animal command.");
// It should be capped to the first 5 examples by default
animal.AddCommand<DogCommand>("dog")
.WithExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy");
.WithExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("animal", "dog", "--name", "Luna")
.WithExample("animal", "dog", "--name", "Charlie")
.WithExample("animal", "dog", "--name", "Bella")
.WithExample("animal", "dog", "--name", "Daisy")
.WithExample("animal", "dog", "--name", "Milo");
animal.AddCommand<HorseCommand>("horse")
.WithExample("animal", "horse", "--name", "Brutus");
.WithExample("animal", "horse", "--name", "Brutus")
.WithExample("animal", "horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("animal", "horse", "--name", "Cash")
.WithExample("animal", "horse", "--name", "Dakota")
.WithExample("animal", "horse", "--name", "Cisco")
.WithExample("animal", "horse", "--name", "Spirit");
});
});
// When
var result = fixture.Run("--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Root_Examples_Leafs_Eight")]
public Task Should_Output_Eight_Examples_Defined_On_Leaves_If_No_Other_Examples_Are_Found()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDescription("The animal command.");
// Show the first 8 examples defined on the direct children
configurator.Settings.MaximumIndirectExamples = 8;
animal.AddCommand<DogCommand>("dog")
.WithExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("animal", "dog", "--name", "Luna")
.WithExample("animal", "dog", "--name", "Charlie")
.WithExample("animal", "dog", "--name", "Bella")
.WithExample("animal", "dog", "--name", "Daisy")
.WithExample("animal", "dog", "--name", "Milo");
animal.AddCommand<HorseCommand>("horse")
.WithExample("animal", "horse", "--name", "Brutus")
.WithExample("animal", "horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("animal", "horse", "--name", "Cash")
.WithExample("animal", "horse", "--name", "Dakota")
.WithExample("animal", "horse", "--name", "Cisco")
.WithExample("animal", "horse", "--name", "Spirit");
});
});
// When
var result = fixture.Run("--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Root_Examples_Leafs_Twelve")]
public Task Should_Output_All_Examples_Defined_On_Leaves_If_No_Other_Examples_Are_Found()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDescription("The animal command.");
// Show all examples defined on the direct children
configurator.Settings.MaximumIndirectExamples = int.MaxValue;
animal.AddCommand<DogCommand>("dog")
.WithExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("animal", "dog", "--name", "Luna")
.WithExample("animal", "dog", "--name", "Charlie")
.WithExample("animal", "dog", "--name", "Bella")
.WithExample("animal", "dog", "--name", "Daisy")
.WithExample("animal", "dog", "--name", "Milo");
animal.AddCommand<HorseCommand>("horse")
.WithExample("animal", "horse", "--name", "Brutus")
.WithExample("animal", "horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("animal", "horse", "--name", "Cash")
.WithExample("animal", "horse", "--name", "Dakota")
.WithExample("animal", "horse", "--name", "Cisco")
.WithExample("animal", "horse", "--name", "Spirit");
});
});
// When
var result = fixture.Run("--help");
// Then
return Verifier.Verify(result.Output);
}
[Fact]
[Expectation("Root_Examples_Leafs_None")]
public Task Should_Not_Output_Examples_Defined_On_Leaves_If_No_Other_Examples_Are_Found()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDescription("The animal command.");
// Do not show examples defined on the direct children
configurator.Settings.MaximumIndirectExamples = 0;
animal.AddCommand<DogCommand>("dog")
.WithExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy")
.WithExample("animal", "dog", "--name", "Luna")
.WithExample("animal", "dog", "--name", "Charlie")
.WithExample("animal", "dog", "--name", "Bella")
.WithExample("animal", "dog", "--name", "Daisy")
.WithExample("animal", "dog", "--name", "Milo");
animal.AddCommand<HorseCommand>("horse")
.WithExample("animal", "horse", "--name", "Brutus")
.WithExample("animal", "horse", "--name", "Sugar", "--IsAlive", "false")
.WithExample("animal", "horse", "--name", "Cash")
.WithExample("animal", "horse", "--name", "Dakota")
.WithExample("animal", "horse", "--name", "Cisco")
.WithExample("animal", "horse", "--name", "Spirit");
});
});
@ -292,18 +716,31 @@ public sealed partial class CommandAppTests
}
[Fact]
[Expectation("CommandExamples")]
public Task Should_Only_Output_Command_Examples_Defined_On_Command()
[Expectation("Branch_Examples")]
public Task Should_Output_Examples_Defined_On_Branch()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.SetApplicationName("myapp");
configurator.AddBranch<AnimalSettings>("animal", animal =>
{
animal.SetDescription("The animal command.");
animal.AddExample(new[] { "animal", "--help" });
animal.SetDescription("The animal command.");
// All branch examples should be shown
animal.AddExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy");
animal.AddExample("animal", "dog", "--name", "Luna");
animal.AddExample("animal", "dog", "--name", "Charlie");
animal.AddExample("animal", "dog", "--name", "Bella");
animal.AddExample("animal", "dog", "--name", "Daisy");
animal.AddExample("animal", "dog", "--name", "Milo");
animal.AddExample("animal", "horse", "--name", "Brutus");
animal.AddExample("animal", "horse", "--name", "Sugar", "--IsAlive", "false");
animal.AddExample("animal", "horse", "--name", "Cash");
animal.AddExample("animal", "horse", "--name", "Dakota");
animal.AddExample("animal", "horse", "--name", "Cisco");
animal.AddExample("animal", "horse", "--name", "Spirit");
animal.AddCommand<DogCommand>("dog")
.WithExample("animal", "dog", "--name", "Rufus", "--age", "12", "--good-boy");
@ -317,19 +754,26 @@ public sealed partial class CommandAppTests
// Then
return Verifier.Verify(result.Output);
}
}
[Fact]
[Expectation("DefaultExamples")]
public Task Should_Output_Root_Examples_If_Default_Command_Is_Specified()
[Expectation("Default_Examples")]
public Task Should_Output_Examples_Defined_On_Root_If_Default_Command_Is_Specified()
{
// Given
var fixture = new CommandAppTester();
fixture.SetDefaultCommand<LionCommand>();
fixture.SetDefaultCommand<DogCommand>();
fixture.Configure(configurator =>
{
configurator.SetApplicationName("myapp");
configurator.AddExample("12", "-c", "3");
// All root examples should be shown
configurator.AddExample("--name", "Rufus", "--age", "12", "--good-boy");
configurator.AddExample("--name", "Luna");
configurator.AddExample("--name", "Charlie");
configurator.AddExample("--name", "Bella");
configurator.AddExample("--name", "Daisy");
configurator.AddExample("--name", "Milo");
});
// When

View File

@ -5,27 +5,92 @@ public sealed partial class CommandAppTests
public sealed class Version
{
[Fact]
public void Should_Output_The_Version_To_The_Console()
public void Should_Output_CLI_Version_To_The_Console()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(config =>
{
config.AddBranch<AnimalSettings>("animal", animal =>
{
animal.AddBranch<MammalSettings>("mammal", mammal =>
{
mammal.AddCommand<DogCommand>("dog");
mammal.AddCommand<HorseCommand>("horse");
});
});
});
// When
var result = fixture.Run(Constants.VersionCommand);
// Then
result.Output.ShouldStartWith("Spectre.Cli version ");
}
[Fact]
public void Should_Output_Application_Version_To_The_Console_With_No_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationVersion("1.0");
});
// When
var result = fixture.Run("--version");
// Then
result.Output.ShouldBe("1.0");
}
[Fact]
public void Should_Output_Application_Version_To_The_Console_With_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationVersion("1.0");
configurator.AddCommand<EmptyCommand>("empty");
});
// When
var result = fixture.Run("empty", "--version");
// Then
result.Output.ShouldBe("1.0");
}
[Fact]
public void Should_Output_Application_Version_To_The_Console_With_Default_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.SetDefaultCommand<EmptyCommand>();
fixture.Configure(configurator =>
{
configurator.SetApplicationVersion("1.0");
});
// When
var result = fixture.Run("--version");
// Then
result.Output.ShouldBe("1.0");
}
[Fact]
public void Should_Output_Application_Version_To_The_Console_With_Branch_Default_Command()
{
// Given
var fixture = new CommandAppTester();
fixture.Configure(configurator =>
{
configurator.SetApplicationVersion("1.0");
configurator.AddBranch<EmptyCommandSettings>("branch", branch =>
{
branch.SetDefaultCommand<EmptyCommand>();
});
});
// When
var result = fixture.Run("--version");
// Then
result.Output.ShouldBe("1.0");
}
}
}

View File

@ -362,7 +362,7 @@ public sealed partial class CommandAppTests
});
// When
var result = app.Run("-c", "0", "-v", "50", "ABBA", "Herreys");
var result = app.Run("-c", "0", "--value", "50", "ABBA", "Herreys");
// Then
result.ExitCode.ShouldBe(0);