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

Feature request: ability to parse args into a "Command" object without requiring invocation of the Run command #1

Closed NeilMacMullen closed 11 months ago

NeilMacMullen commented 11 months ago

This library looks great!

In some applications it's useful to be able to treat a string supplied by the user as a "command line" and to populate the properties of an "options" object with the parsed values. The options can then be used later on in a different context . In the ideal world I'd like to be able to write something like this...

 [CliCommand]
 public class OptionSet
 {

     [CliArgument(Description = "width")]
     public int Width { get; set; } = 100;
 }

 void GetOptions()
 {
     var args = UiControl.Text.Tokenise();
     var result  = Cli.ParseWithoutInvocation<OptionSet>(args);
     if (result.HasErrors)
        DisplayErrorToUser(result.ErrorString);
    else 
        StoreOptionSetForLaterUse(result.PopulatedObject);
 }

I may be missing something but I can't see an easy way of achieving this?

On a related note, it would be really nice to expose an implementation of "Tokenise" that correctly handles quotes and escapes but I assume this is regarded as a responsibility of the shell and considered out of scope.

calacayir commented 11 months ago

Hi, I am glad you found it useful.

I have just released v1.4.0 which now has better Parse methods:

If you need to simply parse the command-line arguments without invocation, use this:

var rootCliCommand = Cli.Parse<RootCliCommand>(args);

If you need to examine the parse result, such as errors:

var rootCliCommand = Cli.Parse<RootCliCommand>(args, out var parseResult);
if (parseResult.Errors.Count > 0)
{

}

By the way you don't need to tokenize UiControl.Text, there are already methods signatures for Run<> and Parse<> which accepts a command line string too (in addition to array).

NeilMacMullen commented 11 months ago

Thanks - that sounds like what I was looking for but I can't seem to get it to work (at least, not the way I was expecting it to work...)

 internal class Program
 {
     private static void Main(string[] args)
     {
         var testArgs = "--First 100 --Second 200";

         var testCommand = Cli.Parse<TestCommand>(testArgs, out var parseResult);
         foreach (var error in parseResult.Errors)
             Console.WriteLine($"ERROR: {error.Message}");
         Console.WriteLine($"Parsed: '{testCommand.First}','{testCommand.Second}'");
     }
 }

 [CliCommand]
 public class TestCommand
 {
     [CliArgument] public string First { get; set; } = "1";
     [CliArgument] public string Second { get; set; } = "2";
 }

produces this output with 1.4.0...

ERROR: Unrecognized command or argument '--Second'.
ERROR: Unrecognized command or argument '200'.
Parsed: '--First','100'
calacayir commented 11 months ago

You need to use [CliOption] instead of [CliArgument] so your class should look like this:

 [CliCommand]
 public class TestCommand
 {
     [CliOption] public string First { get; set; } = "1";
     [CliOption] public string Second { get; set; } = "2";
 }

Argument means a value which is passed to a command or an option. For example in 100 --Second 200, 100 would be an argument for the root command, and 200 would be the argument for the option --Second. Option means a name-value pair, argument means only the value.

So from the parser perspective, when you pass --First 100 --Second 200, it sees 4 arguments because in your class you only define 2 arguments and parser does not know any option names. That's why you get Parsed: '--First','100'.

NeilMacMullen commented 11 months ago

Ah - yes that makes sense. Was a bit confused coming from the CommandLineParser library where everything is an 'Option' but you can supply a "Position" property on the attribute to allow it to be used as argument if you prefer. (In other words it's similar to the powershell model where you can choose to write

my.exe run -width 100 or my.exe run 100

Anyway, it works great with CliOption (when I also remembered to lower-case the option names) - thanks again, definitely going to be using this library going forward. :-)

calacayir commented 11 months ago

Yes, System.CommandLine and so this library distinguishes options and arguments. Options have a name (usually with a prefix) and an argument with specific type (string, bool, int vs.). Arguments are values passed to an option or a root command or a sub-command. Options can be specified in any order. Arguments, when they are multiple, the order does matter so they are positional. I recommend you take a look at https://learn.microsoft.com/en-us/dotnet/standard/commandline/syntax for more details.

Note that you can have a specific type for a Property with a CliOption or CliArgument, for example you can use int for your options and this will be parsed automatically:

 [CliCommand]
 public class TestCommand
 {
     [CliOption] public int First { get; set; } = 1;
     [CliOption] public int Second { get; set; } = 2;
 }

Supported types:

when I also remembered to lower-case the option names

This is because, by default the property names are converted to kebab-case (e.g. PropertyName -> property-Name), however you can change this:

 [CliCommand(NameCasingConvention = CliNameCasingConvention.None)]
 public class TestCommand
 {
     [CliOption] public int First { get; set; } = 1;
     [CliOption] public int Second { get; set; } = 2;
 }

Then the parser would be able to parse --First 100 --Second 200 because it knows the option names are First and Second (and not first and second). Command and option names and aliases are case-sensitive by default according to POSIX convention. If you want your CLI to be case insensitive, define aliases for the various casing alternatives. For example, First property could have aliases --first and --FIRST.

calacayir commented 11 months ago

84dcd0b