dotmake-build / command-line

Declarative syntax for System.CommandLine via attributes for easy, fast, strongly-typed (no reflection) usage. Includes a source generator which automagically converts your classes to CLI commands and properties to CLI options or CLI arguments.
https://dotmake.build
MIT License
81 stars 6 forks source link

How do I get the parent command instance? #14

Closed poizan42 closed 9 months ago

poizan42 commented 9 months ago

I have a global CliOption (i.e. Recursive = true) that I want to get the value of from a sub-command. However I can't seem to find a way to get access to the root command instance from the sub-command. Trying to add it as an argument to Run/RunAsync causes the method to not be recognized. Right now the best I can do is a hack like this

using System.CommandLine;
using DotMake.CommandLine;

namespace DotMakeCommandLineTest;

[CliCommand]
internal class RootCommand
{
    [CliOption(Aliases = ["-f"], Recursive = true)]
    public bool Frob { get; set; }
}

[CliCommand(Parent = typeof(RootCommand))]
internal class FooCommand
{
    [CliArgument()]
    public string? FooBar { get; set; }

    public async Task RunAsync(CliContext context)
    {
        // There must be a less hacky way of getting the global option
        var frobOption = (CliOption<bool>)context.ParseResult.RootCommandResult.Command.Options.First(o => o.Name == "--frob");
        bool frob = context.ParseResult.RootCommandResult.GetValue(frobOption);
        await Console.Out.WriteLineAsync($"Frob = {frob}, FooBar = {FooBar}");
    }
}

public class Program
{   
    public static async Task Main()
    {
        await Cli.RunAsync<RootCommand>("--frob foo BAZ");
        await Cli.RunAsync<RootCommand>("foo --frob BAZ2");
        await Cli.RunAsync<RootCommand>("foo BAZ3 --frob");
        await Cli.RunAsync<RootCommand>("foo BAZ4");
    }      
}
calacayir commented 9 months ago

How about this?

[CliCommand(Parent = typeof(RootCommand))]
internal class FooCommand
{
    [CliArgument()]
    public string? FooBar { get; set; }

    public async Task RunAsync(CliContext context)
    {
        var parent = context.ParseResult.Bind<RootCommand>();

        await Console.Out.WriteLineAsync($"Frob = {parent.Frob}, FooBar = {FooBar}");
    }
}
poizan42 commented 9 months ago

Seems to work and is less hacky I guess. Still it's creating a new instance of RootCommand rather than giving me the one it already constructed.

using System.CommandLine;
using DotMake.CommandLine;

namespace DotMakeCommandLineTest;

[CliCommand]
internal class RootCommand
{
    private static int LastId = -1;
    public RootCommand()
    {
        id = Interlocked.Increment(ref LastId);
        Console.WriteLine($"RootCommand {id} constructed");
    }
    private int id;
    public int GetId() => id;

    [CliOption(Aliases = ["-f"], Recursive = true)]
    public bool Frob { get; set; }
}

[CliCommand(Parent = typeof(RootCommand))]
internal class FooCommand
{
    [CliArgument()]
    public string? FooBar { get; set; }

    public async Task RunAsync(CliContext context)
    {
        var parent = context.ParseResult.Bind<RootCommand>();

        var frob = parent.Frob;
        await Console.Out.WriteLineAsync($"Frob = {frob}, FooBar = {FooBar}, RootCommand.id = {parent.GetId()}");
    }
}

public class Program
{   
    public static async Task Main()
    {
        await Cli.RunAsync<RootCommand>("--frob foo BAZ");
        await Cli.RunAsync<RootCommand>("foo --frob BAZ2");
        await Cli.RunAsync<RootCommand>("foo BAZ3 --frob");
        await Cli.RunAsync<RootCommand>("foo BAZ4");

        await Cli.RunAsync<RootCommand>("-f foo BAZ");
        await Cli.RunAsync<RootCommand>("foo -f BAZ2");
        await Cli.RunAsync<RootCommand>("foo BAZ3 -f");
        await Cli.RunAsync<RootCommand>("foo BAZ4");
    }      
}

Output:

RootCommand 0 constructed
RootCommand 1 constructed
Frob = True, FooBar = BAZ, RootCommand.id = 1
RootCommand 2 constructed
RootCommand 3 constructed
Frob = True, FooBar = BAZ2, RootCommand.id = 3
RootCommand 4 constructed
RootCommand 5 constructed
Frob = True, FooBar = BAZ3, RootCommand.id = 5
RootCommand 6 constructed
RootCommand 7 constructed
Frob = False, FooBar = BAZ4, RootCommand.id = 7
RootCommand 8 constructed
RootCommand 9 constructed
Frob = True, FooBar = BAZ, RootCommand.id = 9
RootCommand 10 constructed
RootCommand 11 constructed
Frob = True, FooBar = BAZ2, RootCommand.id = 11
RootCommand 12 constructed
RootCommand 13 constructed
Frob = True, FooBar = BAZ3, RootCommand.id = 13
RootCommand 14 constructed
RootCommand 15 constructed
Frob = False, FooBar = BAZ4, RootCommand.id = 15
calacayir commented 9 months ago

New instance of RootCommand is due to creating an instance for default values, it's not due to parsing. We may find a better way in future but object creation is cheap any way.

Actually a neater way exists, you put your common option in a base class (you don't use a recursive option) and you use inheritance:

using System.CommandLine;
using DotMake.CommandLine;

namespace DotMakeCommandLineTest;

internal abstract class CommandBase
{
    [CliOption(Aliases = ["-f"])]
    public bool Frob { get; set; }
}

[CliCommand]
internal class RootCommand : CommandBase
{

}

[CliCommand(Parent = typeof(RootCommand))]
internal class FooCommand : CommandBase
{
    [CliArgument()]
    public string? FooBar { get; set; }

    public async Task RunAsync(CliContext context)
    {
        await Console.Out.WriteLineAsync($"Frob = {Frob}, FooBar = {FooBar}");
    }
}
poizan42 commented 9 months ago

This does not quite work. Frob becomes a common option but not a global option. There are now two distinct versions of the frob option instead, one on RootCommand and one on FooCommand.

using System.CommandLine;
using DotMake.CommandLine;

namespace DotMakeCommandLineTest;

internal abstract class CommandBase
{
    [CliOption(Aliases = ["-f"])]
    public bool Frob { get; set; }
}

[CliCommand]
internal class RootCommand : CommandBase
{

}

[CliCommand(Parent = typeof(RootCommand))]
internal class FooCommand : RootCommand
{
    [CliArgument()]
    public string? FooBar { get; set; }

    public async Task RunAsync(CliContext context)
    {
        await Console.Out.WriteLineAsync($"Frob = {Frob}, FooBar = {FooBar}");
    }
}

public class Program
{   
    public static async Task Main()
    {
        await Cli.RunAsync<RootCommand>("--frob foo BAZ"); // Fail
        await Cli.RunAsync<RootCommand>("foo --frob BAZ2");
        await Cli.RunAsync<RootCommand>("foo BAZ3 --frob");
        await Cli.RunAsync<RootCommand>("foo BAZ4");

        await Cli.RunAsync<RootCommand>("-f foo BAZ"); // Fail
        await Cli.RunAsync<RootCommand>("foo -f BAZ2");
        await Cli.RunAsync<RootCommand>("foo BAZ3 -f");
        await Cli.RunAsync<RootCommand>("foo BAZ4");
    }      
}

Output:

Frob = False, FooBar = BAZ
Frob = True, FooBar = BAZ2
Frob = True, FooBar = BAZ3
Frob = False, FooBar = BAZ4
Frob = False, FooBar = BAZ
Frob = True, FooBar = BAZ2
Frob = True, FooBar = BAZ3
Frob = False, FooBar = BAZ4