Explorative command line usage
I started using the project pretty early to build what I call interactive UI. You can start the console application with a global option named --interactive. I step in when the option is set and load available to analyze them and their options to build a menu system.
As an example think of nuget.
- Start it with 1 --interactive` and the given commands are analyzed to build up a menu.
- It prompts for
sources,install, and so on. Descriptions of the commands are loaded next to their names. - The user selects
installusing the cursor keys and hist [Enter]. - The command has a required option
-idand requires the user to input the name of the package, confirmed with [Enter]. Here also suggestions can build up a menu to select from. - The command has an optional option
-version, which can be skipped using [Esc].
The result is that the full command line is displayed, so that the user can copy it for next time or learn how to call ist without the interaction, and that the build up command with its options is executed. I really enjoyed the explorable style of System.CommandLine and would love to continue using it without dirty hacks like reflection.
There are 2 things that broke my use case:
- The
IsGlobal-property on theOptionclass was changed to internal. - The
Argument-property on theOptionclass was changed to internal.
I do not see any performance improvement - which was the label under which the changes have been made - and currently use reflection to access the properties. I would love to use the API without dirty hacks and hope to provide a valuable use case for this great library.
Thanks for the write-up! Could you say a bit more about how you're using these properties? Some of what you're describing here sounds related to the completions APIs . Are you using Symbol.GetCompletions and if so, where does it fall short of your use case? And if you're willing to share a relevant code sample, I'd be happy to take a look.
As an aside, what you're doing looks a little like an unparser, which is a feature we've talked about adding in a future version.
There are 2 things that broke my use case:
- The IsGlobal-property on the Option class was changed to internal.
- The Argument-property on the Option class was changed to internal.
Let me also give a little background on these two design changes so there's a common understanding of where they came from.
The reason IsGlobal relates to a performance improvement is that it was an alternative to adding the option to each of the child commands recursively and regardless of call order, e.g. when a new child command was added after AddGlobalOption had been called on the parent command. This state tracking was expensive and IsGlobal simplified it a good deal. But as an implementation detail, it was only made public accidentally at the time of introduction. (The implementation may need to change again, depending on what we do about #1728.)
Also, one quibble about naming, the famous hardest problem in programming. I'd say that Command.AddGlobalOption is a decent use of the word "global" because it's global within the scope of that command. But looking at it from the other end, is the option intrinsically global, as the IsGlobal property would suggest? (This thought exercise, for what it's worth, is what led me to realize we had a bug.) It's parented under multiple commands, potentially, but it's not really global in the same sense. And as #1728 shows, the same option might be global in some cases and not in others. A more accurate way of naming the ~property~ method could be something like IsGlobalUnderCommand(Command command), because it's really intended to be relative to the command, not absolute.
As for removing Option.Argument, its presence made the API more verbose and many people found it confusing that an Option had an Argument when the only way it was used most of the time was like this:
new Option("-x")
{
Argument = new Argument<string>()
};
And there was no guard rail to prevent generic type mismatches like this:
new Option<int>("-x")
{
Argument = new Argument<string>()
};
😳
As we were already planning on making the non-generic Option and Argument types abstract, this ceremony became too much, and making Option.Argument internal was a good simplifying change.
Dear @jonsequitur,
I cannot share my code completely but will try to explain the usage using two methods.
[NotNull]
public static string[] GatherArgsFromUserInput(
this RootCommand command,
string title = null) {
if (command == null) {
throw new ArgumentNullException(nameof(command));
}
var defaultCommand =
command.Children
.OfType<CommandBase>()
.Where(c => c is IDefaultCommand dc && dc.CanExecute())
.Take(1)
.FirstOrDefault();
var argumentSelectionTitle =
command.Description ?? command.Name;
if (defaultCommand != null) {
var defaultArguments = new List<string> {
$"{defaultCommand.Aliases.FirstOrDefault()}"
};
SelectArguments(argumentSelectionTitle, defaultCommand, defaultArguments);
return defaultArguments.ToArray();
}
var commands =
command.Children
.OfType<CommandBase>()
.Where(c => !c.IsHidden)
.ToArray();
if (!commands.Any()) {
return Array.Empty<string>();
}
var commandsMenu =
new ConsoleMenu(
commands
.Where(c => c != null)
.Select(c => c.ToConsoleMenuItem())
);
if (!commandsMenu.Show(
argumentSelectionTitle,
title ?? "Please select what you want to do:"
)) {
return Array.Empty<string>();
}
if (commandsMenu.SelectedItem is not CommandBase selectedCommand) {
return Array.Empty<string>();
}
var newArguments = new List<string> {
$"{selectedCommand.Aliases.FirstOrDefault()}"
};
var subCommands =
selectedCommand.Children
.OfType<CommandBase>()
.Where(c => !c.IsHidden)
.ToArray();
var subCommandsMenu =
new ConsoleMenu(
subCommands
.Where(c => c != null)
.Select(c => c.ToConsoleMenuItem())
);
argumentSelectionTitle += " > "
+ (selectedCommand.CommandSelectionText
?? selectedCommand.Description
?? selectedCommand.Name);
if (!subCommands.Any()
|| !subCommandsMenu.Show(
argumentSelectionTitle,
title ?? "Select what you want to do:"
)
|| subCommandsMenu.SelectedItem is not Command selectedSubCommand) {
SelectOptions(
argumentSelectionTitle,
selectedCommand,
newArguments
);
SelectArguments(
argumentSelectionTitle,
selectedCommand,
newArguments
);
return newArguments.ToArray();
}
newArguments.Add($"{selectedSubCommand.Aliases.FirstOrDefault()}");
argumentSelectionTitle += " > "
+ (selectedSubCommand.Description
?? selectedSubCommand.Name);
SelectOptions(
argumentSelectionTitle,
selectedSubCommand,
newArguments
);
SelectArguments(
argumentSelectionTitle,
selectedSubCommand,
newArguments
);
return newArguments.ToArray();
}
private static void SelectOptions(
string title,
[NotNull] Command selectedCommand,
[NotNull] ICollection<string> newArguments) {
if (title == null) {
throw new ArgumentNullException(nameof(title));
}
if (selectedCommand == null) {
throw new ArgumentNullException(nameof(selectedCommand));
}
if (newArguments == null) {
throw new ArgumentNullException(nameof(newArguments));
}
var rootCommand = selectedCommand.Parents.OfType<Command>().FirstOrDefault()
?? selectedCommand;
while (rootCommand.Parents.OfType<Command>().Any()) {
var parentCommand = rootCommand.Parents.OfType<Command>().FirstOrDefault();
if (parentCommand != null) {
rootCommand = parentCommand;
}
}
foreach (var option in selectedCommand.Options) {
if (option.IsHidden) {
continue;
}
if (selectedCommand.GetGlobalOptions().Any(o => o == option)) {
continue;
}
if (rootCommand.GetGlobalOptions().Any(o => o == option)) {
continue;
}
if (option.Parents.FirstOrDefault() is RootCommand) {
continue;
}
Console.WriteLine(title);
Console.WriteLine();
var suggestions =
option
.GetSuggestions(
selectedCommand.Parse(newArguments.Skip(1).ToArray())
)
.Where(s => s != null)
.ToArray();
if (!suggestions.Any()) {
var caption1 = option.Description ?? $"The option value for {option.Name}.";
if (caption1.StartsWith("The ", StringComparison.Ordinal)) {
caption1 = "Please enter t" + caption1.Substring(1);
} else if (caption1.StartsWith("A ", StringComparison.Ordinal)) {
caption1 = "Please select a" + caption1.Substring(1);
} else if (caption1.StartsWith("An ", StringComparison.Ordinal)) {
caption1 = "Please select a" + caption1.Substring(1);
}
Console.WriteLine(caption1);
Console.WriteLine();
Console.Write($" {FormatName(option.Name)}: ");
string value;
if (option.Name.IndexOf("secret", StringComparison.OrdinalIgnoreCase) > -1
|| option.Name.IndexOf("pass", StringComparison.OrdinalIgnoreCase) > -1) {
value = ConsoleEx.ReadEditableLine(string.Empty, '*');
} else {
if (option.GetArgument().HasDefaultValue && option.GetArgument().GetDefaultValue() is string stringDefault) {
value = ConsoleEx.ReadEditableLine(stringDefault);
} else {
value = ConsoleEx.ReadEditableLine(string.Empty);
}
}
if ((option.ValueType == typeof(FileInfo)
|| option.ValueType == typeof(DirectoryInfo))
&& value != null
&& value.StartsWith("\"", StringComparison.Ordinal)
&& value.EndsWith("\"", StringComparison.Ordinal)
) {
value = value.Substring(1, value.Length - 2);
}
if (option.IsRequired || !string.IsNullOrWhiteSpace(value)) {
newArguments.Add($"{ConsoleEx.OptionPrefix}{option.Name}");
newArguments.Add($"{FormatValue(value)}");
}
Console.Clear();
continue;
}
var argumentMenu = ConsoleMenu.Create(suggestions);
var caption = option.Description ?? $"The argument value for {option.Name}.";
if (caption.StartsWith("The ", StringComparison.Ordinal)) {
caption = "Please select t" + caption.Substring(1);
} else if (caption.StartsWith("A ", StringComparison.Ordinal)) {
caption = "Please select a" + caption.Substring(1);
} else if (caption.StartsWith("An ", StringComparison.Ordinal)) {
caption = "Please select a" + caption.Substring(1);
}
argumentMenu.Show(title, caption);
if (argumentMenu.SelectedItem is string selectedValue
&& (option.IsRequired
|| !string.IsNullOrWhiteSpace(selectedValue))) {
newArguments.Add($"{ConsoleEx.OptionPrefix}{option.Name}");
newArguments.Add($"{FormatValue(selectedValue)}");
}
Console.Clear();
}
}
public static IEnumerable<string> GetSuggestions(
this Option option,
ParseResult parseResult) {
if (option == null) {
throw new ArgumentNullException(nameof(option));
}
return option.GetArgument().GetSuggestions(parseResult);
}
public static Argument GetArgument(
this Option option) {
if (option == null) {
throw new ArgumentNullException(nameof(option));
}
return _s_argumentProperty.GetValue(option) as Argument;
}
public static IEnumerable<string> GetSuggestions(
this Option option,
ParseResult parseResult) {
if (option == null) {
throw new ArgumentNullException(nameof(option));
}
return option.GetArgument().GetSuggestions(parseResult);
}
public static IEnumerable<string> GetSuggestions(
this Argument argument,
ParseResult parseResult) {
if (argument == null) {
throw new ArgumentNullException(nameof(argument));
}
var context = _s_ctor!.Invoke(new object[]{ parseResult }) as CompletionContext;
return argument.Completions
.SelectMany(source => source!.GetCompletions(context!))
.Distinct()
.Where(c => c != null)
.OrderBy(c => c.SortText, StringComparer.OrdinalIgnoreCase)
.Select(c => c.InsertText);
}
Do I understand you correctly - global options are not copied to commands, so they are not in the list of options on the command? If so the continue in the method is obsolete and i can remove the if statements...
foreach (var option in selectedCommand.Options) {
...
if (selectedCommand.GetGlobalOptions().Any(o => o == option)) {
continue;
}
You were right on that I utilize GetCompletions and as you can see, the Argument is used read only.
Hope that helps a bit to clarify.
Do I understand you correctly - global options are not copied to commands, so they are not in the list of options on the command?
Correct. The old implementation added the global option to each command. The new implementation does not. This was the performance optimization that led to the creation of the IsGlobal property.
This is a little bit indirect but I don't think you need to use reflection to get the Option.Argument in order to get completions for the Argument. The Option completions will contain completions for its Argument if they're valid given the input. Here's an example:
For the completion I use the given API with the following code now:
/// <summary>
/// Gets suggestions.
/// </summary>
/// <param name="option">The option.</param>
/// <param name="parseResult">The parse result.</param>
public static IEnumerable<string> GetSuggestions(
this Option option,
ParseResult parseResult) {
if (option == null) {
throw new ArgumentNullException(nameof(option));
}
if (parseResult == null) {
throw new ArgumentNullException(nameof(parseResult));
}
var context = parseResult.GetCompletionContext();
return option.GetCompletions(context).Select(c => c.InsertText);
}
I still have to use reflection to get the default values of an argument when coming from an option.
if (option.GetArgument().HasDefaultValue && option.GetArgument().GetDefaultValue() is string stringDefault) {
This writes to default value the console and lets the user edit it...
sugguestedValue = ConsoleEx.ReadEditableLine(stringDefault);
}
Any updates on public usage of IsGlobal?