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

Apply help to all symbols in the CLI declaration #2442

Open
wants to merge 11 commits into
base: main-powderhouse
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ protected override void Execute(PipelineResult pipelineResult)
}
}

internal class AlternateHelp : HelpSubsystem
{
protected override void Execute(PipelineResult pipelineResult)
{
pipelineResult.ConsoleHack.WriteLine("***Help me!***");
pipelineResult.SetSuccess();
}
}

internal class VersionThatUsesHelpData : VersionSubsystem
{
// for testing, this class accepts a symbol and accesses its description
Expand Down
75 changes: 75 additions & 0 deletions src/System.CommandLine.Subsystems.Tests/HelpSubsystemTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using FluentAssertions;
using Xunit;
using System.CommandLine.Parsing;

namespace System.CommandLine.Subsystems.Tests;

public class HelpSubsystemTests
{
[Fact]
public void When_help_subsystem_is_used_the_help_option_is_added_to_each_command_in_the_tree()
{
var rootCommand = new CliRootCommand
{
new CliOption<bool>("-x"), // add option that is expected for the test data used here
new CliCommand("a")
};
var configuration = new CliConfiguration(rootCommand);

var pipeline = Pipeline.CreateEmpty();
pipeline.Help = new HelpSubsystem();

// Parse is used because directly calling Initialize would be unusual
var result = pipeline.Parse(configuration, "");

rootCommand.Options
.Count(x => x.Name == "--help")
.Should()
.Be(1);
var subcommand = rootCommand.Subcommands.First();
subcommand.Options
.Count(x => x.Name == "--help")
.Should()
.Be(1);
}

[Theory]
[ClassData(typeof(TestData.Help))]
public void Help_is_activated_only_when_requested(string input, bool result)
{
CliRootCommand rootCommand = [new CliOption<bool>("-x")]; // add random option as empty CLIs are rare
var configuration = new CliConfiguration(rootCommand);
var helpSubsystem = new HelpSubsystem();
var args = CliParser.SplitCommandLine(input).ToList().AsReadOnly();

Subsystem.Initialize(helpSubsystem, configuration, args);

var parseResult = CliParser.Parse(rootCommand, input, configuration);
var isActive = Subsystem.GetIsActivated(helpSubsystem, parseResult);

isActive.Should().Be(result);
}

[Fact]
public void Outputs_help_message()
{
var consoleHack = new ConsoleHack().RedirectToBuffer(true);
var helpSubsystem = new HelpSubsystem();
Subsystem.Execute(helpSubsystem, new PipelineResult(null, "", null, consoleHack));
consoleHack.GetBuffer().Trim().Should().Be("Help me!");
}

[Fact]
public void Custom_help_subsystem_can_be_used()
{
var consoleHack = new ConsoleHack().RedirectToBuffer(true);
var pipeline = Pipeline.CreateEmpty();
pipeline.Help = new AlternateSubsystems.AlternateHelp();

pipeline.Execute(new CliConfiguration(new CliRootCommand()), "-h", consoleHack);
consoleHack.GetBuffer().Trim().Should().Be("***Help me!***");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
-->
<Compile Include="AlternateSubsystems.cs" />
<Compile Include="Constants.cs" />
<Compile Include="HelpSubsystemTests.cs" />
<Compile Include="ValueSubsystemTests.cs" />
<Compile Include="ResponseSubsystemTests.cs" />
<Compile Include="DirectiveSubsystemTests.cs" />
Expand Down
18 changes: 18 additions & 0 deletions src/System.CommandLine.Subsystems.Tests/TestData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,22 @@ internal class Value : IEnumerable<object[]>

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

internal class Help : IEnumerable<object[]>
{
private readonly List<object[]> _data =
[
["--help", true],
["-h", true],
["-hx", true],
["-xh", true],
["-x", false],
[null, false],
["", false],
];

public IEnumerator<object[]> GetEnumerator() => _data.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}
16 changes: 14 additions & 2 deletions src/System.CommandLine.Subsystems/HelpSubsystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,20 @@ public class HelpSubsystem(IAnnotationProvider? annotationProvider = null)
Arity = ArgumentArity.Zero
};

protected internal override void Initialize(InitializationContext context)
=> context.Configuration.RootCommand.Add(HelpOption);
protected internal override void Initialize(InitializationContext context)
{
AddOptionRecursively(context.Configuration.RootCommand, HelpOption);

static void AddOptionRecursively(CliCommand command, CliOption option)
{
command.Add(option);

foreach (var subcommand in command.Subcommands)
{
AddOptionRecursively(subcommand, option);
}
}
Comment on lines +31 to +39
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine this method should belong to CliCommand or some extensions method, but I didn't want to change too much in this PR without consulting you first.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am torn between having this be an extension method on CliOption or CliCommand. Regardless, I think it belongs in the core (System.CommandLine) and appreciate you being conservative on this. Let's get some more thoughts on the location.

I think we should do a check so that if it is added twice we don't have problems. I think we should do that by name. We are in process of adding a GetSymbolName method, although I need to check that it is available on a CliCommand.

We will need to decide if first or last should win. My first intuition is to have the first win, so calling this method is "add the option to any commands that do not already have an option by this name". If you wanted custom help on one option (or another option), I think it would be most natural to add that first. Similarly, if you wanted one branch of commands in a complex tree to do help differently (dotnet new for example), you'd just have to call in order most specialized to least.

The other thing nagging on this is that our previous approach is that the CLI tree needs to be created before this is called. Another approach (although I like it less) is to mark the command and add the option to each command immediately before parsing. I am concerned about bad behavior if the tree is reused and a different help system is set (unless we copy the tree).

Sorry for not having a clear answer today on this. It's summer and we have some folks out, so it may be a bit before we have a clear answer on where and how to do the check.

Copy link
Author

@hasnik hasnik Jun 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy to let it wait for the right time and update the code whenever the questions you pointed out have answers. Thank you sharing all the context, I do appreciate it very much!

I am torn between having this be an extension method on CliOption or CliCommand. Regardless, I think it belongs in the core (System.CommandLine) and appreciate you being conservative on this. Let's get some more thoughts on the location.

My personal taste would be CliCommand, but I'm unsure as I know my pov is most likely restricted to my use-cases and understand that every decision impacts a huge audience.

I think we should do a check so that if it is added twice we don't have problems. I think we should do that by name. We are in process of adding a GetSymbolName method, although I need to check that it is available on a CliCommand.

Looking around on main-powderhouse I can see there's a GetSymbolByName in ParseResult class, but unfrotunately not available on CliCommand. Are you planning on bringing it or should I try to access it with some workaround?

We will need to decide if first or last should win. My first intuition is to have the first win, so calling this method is "add the option to any commands that do not already have an option by this name". If you wanted custom help on one option (or another option), I think it would be most natural to add that first. Similarly, if you wanted one branch of commands in a complex tree to do help differently (dotnet new for example), you'd just have to call in order most specialized to least.

Haven't really thought about it before, but I'd say your first intuition makes a lot of sense to me.

The other thing nagging on this is that our previous approach is that the CLI tree needs to be created before this is called. Another approach (although I like it less) is to mark the command and add the option to each command immediately before parsing. I am concerned about bad behavior if the tree is reused and a different help system is set (unless we copy the tree).

I believe there's much smarter individuals to discuss this with, but my intuition would be to stick with 'frozen' CLI tree - it just sounds right, unless there's any good reason not to use it. Is there any appropriate plugging point to add help 'higher' in the chain? Whatever the decision will be, do let me know - I'd love to have my small addition in the .NET ecosystem :)

}

protected internal override bool GetIsActivated(ParseResult? parseResult)
=> parseResult is not null && parseResult.GetValue(HelpOption);
Expand Down