Skip to content

Option.Argument.GetDefaultValue() and Argument.AllowedValues should be public members #2526

Open
@KeithHenry

Description

@KeithHenry

In both cases these are set by the configuration of the command line, but are then only available internally. If you access the ParseResult they are hidden.

For instance, I want to implement --interactive on a large command line app, I don't want to edit every existing handler to do it's own interactions, I want to access the ParseResult of the command to find out what the prompts should be.

Here is an example use-case (using Spectre.Console for the prompt formatting, but you can use any console input):

static class InteractiveParsePrompt
{
    /// <summary>Limit of error depth - users can CTRL+C cancel out but this is a saftey check.</summary>
    const int MaxDepth = 20;

    /// <summary>Interative option, set globally and available on all commands.</summary>
    public static readonly Option<bool> OptInteractive = new(["--interactive", "--i", "-i"], "Run in interactive mode, any missing arguments or options will be requested") { Arity = ArgumentArity.Zero };

    /// <summary>Add interactive prompts when there are parse errors and the --interactive option is set</summary>
    public static CommandLineBuilder UseInteractive(this CommandLineBuilder builder, IServiceProvider services)
    {
        // Add the --interactive option to the root command
        builder.Command.AddGlobalOption(OptInteractive);
        builder.AddMiddleware(async (context, next) => await MiddlewareInteractive(services, context, next), MiddlewareOrder.Configuration);
        return builder;
    }

    /// <summary>Get the allowed values for an argument.</summary>
    static string[] GetAllowedValues(Argument argument) {

        // System.CommandLine makes all this info about the allowed values private, so we need to use reflection to get it
        var prop = typeof(Argument).GetProperty("AllowedValues", BindingFlags.NonPublic | BindingFlags.Instance);
        if(prop is null) return [];

        var getter = prop.GetGetMethod(nonPublic: true);
        if (getter is null) return [];

        var allowedValues = (HashSet<string>?) getter.Invoke(argument, null);
        if (allowedValues is null) return [];

        return [..allowedValues];
    }

    /// <summary>Get the underlying Argument implementation for an option.</summary>
    static Argument? GetArgument(Option option)
    {
        // System.CommandLine makes all this info about the allowed values private, so we need to use reflection to get it
        var prop = typeof(Option).GetProperty("Argument", BindingFlags.NonPublic | BindingFlags.Instance);
        if (prop is null) return null;

        var getter = prop.GetGetMethod(nonPublic: true);
        if (getter is null) return null;

        return (Argument?)getter.Invoke(option, null);
    }

    /// <summary>Get the markup text for the option or argument description.</summary>
    static string PromptText(Symbol symbol) {
        if (symbol.Description is not null)
            return $"[bold yellow]{symbol.Name}[/] [italic]{symbol.Description.EscapeMarkup().TrimEnd(' ', '.')}[/]" ;

        return $"[bold yellow]{symbol.Name}[/]";
    }

    /// <summary>Prompt the user to provide the value for an argument</summary>
    static string PromptArgument(Argument argument, IAnsiConsole console)
    {
        string[] allowedValues = GetAllowedValues(argument);
        IPrompt<string> prompt;
        if (allowedValues.Length > 0)
            prompt = new SelectionPrompt<string>().
                Title(PromptText(argument)).
                PageSize(20).
                AddChoices(allowedValues.Order());
        else
            prompt = new TextPrompt<string>(PromptText(argument));

        string argResponse = console.Prompt(prompt);
        console.MarkupLine($"Argument [bold yellow]{argument.Name}[/] = [green]{argResponse}[/]");
        return argResponse;
    }

    /// <summary>Prompt the user to provide the value for an option</summary>
    static IEnumerable<string> PromptOption(Option option, IAnsiConsole console)
    {
        if (option.ValueType == typeof(bool)) {
            // Boolean, give them a y/n confirmation prompt
            bool optConfirm = AnsiConsole.Prompt(
                new TextPrompt<bool>(PromptText(option)).
                    AddChoice(true).
                    AddChoice(false).
                    DefaultValue(false).
                    WithConverter(choice => choice ? "y" : "n"));

            if (optConfirm)
            {
                console.MarkupLine($"Option set [bold green]{option.Name}[/]");
                yield return $"--{option.Name}";
            }

            yield break;
        }

        TextPrompt<string> prompt = new(PromptText(option));

        // Get the underlying argument to get the default value
        var argument = GetArgument(option);
        if(argument is not null && argument.HasDefaultValue)
        {
            string? defaultValue = argument.GetDefaultValue()?.ToString();
            if (defaultValue is not null)
                prompt.DefaultValue(defaultValue);
        }

        string optResponse = console.Prompt(prompt);
        console.MarkupLine($"Option [bold yellow]{option.Name}[/] = [green]{optResponse}[/]");
        yield return $"--{option.Name}";
        yield return optResponse;
    }

    /// <summary>Prompt the user to choose a subcommand, if that has arguments or options prompt for them too, return a new set of arguments to parse from the prompts</summary>
    static IEnumerable<string> PromptCommand(Command command, IAnsiConsole console) {
        int maxL = command.Subcommands.Select(c => c.Name.Length).Max() + 1;

        string subCommand = console.Prompt(
            new SelectionPrompt<string>().
                Title("Choose command?").
                PageSize(20).
                AddChoices(command.Subcommands.Select(c => $"{c.Name.PadRight(maxL)}: {c.Description}").Order()));

        string commandName = subCommand.Split(":")[0].Trim();
        console.MarkupLine($"Command [green]{commandName}[/] selected");
        yield return commandName;

        var subCommandFound = command.Subcommands.FirstOrDefault(c => c.Name == commandName);
        if(subCommandFound is null) yield break;

        if(subCommandFound.Arguments.Count > 0)
            foreach (var argument in subCommandFound.Arguments)
                yield return PromptArgument(argument, console);

        if (subCommandFound.Options.Count > 0)
            foreach (var option in subCommandFound.Options)
                foreach(string optionValue in PromptOption(option, console))
                    yield return optionValue;

        if (subCommandFound.Subcommands.Count > 0)
            foreach (string sub in PromptCommand(subCommandFound, console))
                yield return sub;
    }

    /// <summary>Intercept the command line parse on failure if --interactive option is set to prompt the user for the missing commands, arguments and options.</summary>
    static async Task MiddlewareInteractive(IServiceProvider services, InvocationContext context, Func<InvocationContext, Task> next)
    {
        // If no errors or not in interactive mode, continue
        if (!context.ParseResult.GetValueForOption(OptInteractive) ||
            context.ParseResult.Errors.Count == 0)
        {
            await next(context);
            return;
        }

        var cancellationToken = context.GetCancellationToken();

        // Use Spectre.Console for interactive prompts, set up in the DI
        var console = services.GetRequiredService<IAnsiConsole>();
        console.WriteLine("Interactive mode");

        int retry = 0;
        while(retry++ < MaxDepth && 
            context.ParseResult.Errors.Count != 0 && 
            !cancellationToken.IsCancellationRequested)
        {
            var command = context.ParseResult.CommandResult.Command;

            List<string> interactiveArgs = [..context.ParseResult.Tokens.Select(t => t.Value)];
            foreach (var error in context.ParseResult.Errors)
            {
                if (cancellationToken.IsCancellationRequested) break;

                if (error.Message == "Required command was not provided.")
                {
                    foreach (string arg in PromptCommand(command, console))
                    {
                        if (cancellationToken.IsCancellationRequested) break;

                        interactiveArgs.Add(arg);
                    }
                }
                else if (error.Message.StartsWith("Required argument missing for command:"))
                {
                    string argumentName = error.Message.Split(":")[1].Trim(' ', '\'', '.');
                    var argument = command.Arguments.FirstOrDefault(a => a.Name == argumentName);
                    if (argument is null)
                    {
                        console.MarkupLine($"[red]{error.Message.EscapeMarkup()}[/]");
                        break;
                    }

                    interactiveArgs.Add(PromptArgument(argument, console));
                }
                else
                {
                    console.MarkupLine($"[red]{error.Message.EscapeMarkup()}[/]");
                    break;
                }
            }

            context.ParseResult = context.Parser.Parse(interactiveArgs);
        }

        if (cancellationToken.IsCancellationRequested)
            console.MarkupLine("[red]Cancelled[/]");
        else if (context.ParseResult.Errors.Count == 0)
        {
            string newArgs = string.Join(' ', context.ParseResult.Tokens.Select(t => t.Value));
            console.MarkupLine($"New arguments set: [green]{newArgs.EscapeMarkup()}[/]");
        }
        else
            console.MarkupLine("[red]Failed[/]");

        await next(context);
    }
}

I'm happy to be told if there's a better way to do this.

Note that I can't get the Argument.AllowedValues or Option.Argument.GetDefaultValue(), both of which I'd need for this to be interactive, without using reflection.

In both cases these are properties of the arguments and options configured in my code, why are they internal in ParseResult? Could we just have Argument.AllowedValues as public and a new public GetDefaultValue() = Argument.GetDefaultValue(); on Option?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions