Home

Awesome

Command Line Parser for NetStandard

Build status Nuget

This library provides a clean and simple API for parsing program arguments into a RunInfo object.

Targets NET Standard 2.0

Core Features at a Glance

Code Configuration over Attributes

There are no attributes used to mark the resulting RunInfo class properties. Rather, you configure commands by providing a Command object as a representation of an object tree. C# provides a clean way to express nested objects through its object initializers so RunInfoBuilder makes use of that.

Using attributes to tell a command line parser how to interpret things works for very simple binding schemes. But if you need to go beyond that, for example, by using custom callbacks for validations or as extensibility points, using pure code to define the commands works better.


Getting Started

Install via NuGet or DotNet:

Install-Package R5.RunInfoBuilder
dotnet add package R5.RunInfoBuilder

A Simple Example

A program is desired that can read some message from the program arguments, and then do one of many things as determined by a command.

For example, it may take the message and send it off to some HTTP endpoint. Also, the user can optionally specify that the request should be retried on fail.

The required information for this has been collected into this RunInfo class:

public class SendRequestRunInfo
{
    public string RequestUrl { get; set; }
    public string Message { get; set; }
    public int DelayMinutes { get; set; }
    public bool RetryOnFail { set; set; }
}

The program should take three program arguments and simply bind them to the properties. To do this, a Command called sendhttp is added to the CommandStore:

// initialize a builder instance
var builder = new RunInfoBuilder();

// add the 'sendhttp' command to the store
builder.Commands.Add(new Command<SendRequestRunInfo>
{
    Key = "sendhttp",
    Arguments =
    {
        new PropertyArgument<SendRequestRunInfo, string>
        {
            Property = ri => ri.RequestUrl
        },
        new PropertyArgument<SendRequestRunInfo, string>
        {
            Property = ri => ri.Message
        },
        new PropertyArgument<SendRequestRunInfo, int>
        {
            Property = ri => ri.DelayMinutes
        }
    },
    Options =
    {
        new Option<SendRequestRunInfo, bool>
        {
            Key = "retry | r",
            Property = ri => ri.RetryOnFail
        }
    }
});

// build the run info object by passing program arguments (led by the command's key)
var args = new string[] { "sendhttp", "http://www.somewhere.com", "hello from program!", "3", "--retry" };
var runInfo = builder.Build(args);

The resulting runInfo variable will be of type SendRequestRunInfo with the expected values:

{
    RequestUrl: 'http://www.somewhere.com',
    Message: 'hello from program!',
    DelayMinutes: 3,
    RetryOnFail: true
}

The values were parsed from the program arguments and bound to the RunInfo properties as configured. Also, the RetryOnFail property was set to true because the option was specified (--retry). The option could also have been specified by -r instead because a short key was configured for the option.

This is a very simple example, illustrating the most basic of binding requirements: simple 1-to-1 mappings of program arguments to properties.

There's a lot more that can be done and configured, but hopefully you can at least see how simple and expressive defining commands through an object is. You can take a quick look at any command configuration and immediately know how it parses the program arguments.

If this has captured your interest, keep reading below for a deeper dive into all the features and areas of RunInfoBuilder.


In-Depth Documentation

Topics covered below:


Command Processing Overview

Before diving into Command configuration, we need to understand the order in which commands, and their child items like subcommands and options are processed.

It doesn't matter if you have just a single Command or one with many levels of SubCommands nested within it. When the builder begins processing a Command or SubCommand, it will always go through the same steps in order as depicted below:

alt text

1. Arguments

Arguments are processed first, and in the same order they're defined in the Command object added to the store.

They are also all required, so an exception will be thrown if no more program arguments are found.

2. Options

Any Options are processed immediately after the command's Arguments are, and can appear in any order. They are optional and any number of them can be specified.

3. SubCommands

A Command can contain nested SubCommands in a list, which are processed after Options. If any are configured, it is required that one is matched by the next program argument.

For example, if a command called search has two subCommands configured, outside and inside, then the two valid methods of calling the search command would be

search outside or search inside (we're ignoring any arguments or options for brevity here)

Here's a few examples of the search command being called incorrectly:

search - an exception will be thrown because subCommands have been configured but one wasn't specified.

search everywhere - an exception will be thrown because everywhere doesn't match a valid subCommand.

search outside inside - this simply makes no sense. It's not possible to call more than one subCommand from the list. One, and only one, must be matched.

A SubCommand is essentially the same type as a Command (just without the GlobalOptions property, more on that later). This results in a Command definition being a recursive tree structure, which can be nested arbitrarily deep:

alt text

Observing the command tree diagram above, when you create a Command, a valid processing will always start at the root node and traverse downwards (think DFS-like) until it hits and finishes processing the last SubCommand (a leaf node).

Although it's technically possible to create a Command with an arbitrary number of layers, it's probably best to limit it. Else, you risk the program having a confusing API.

To recap: All Arguments and Options for a given Command are processed first, in that order. After which, if any SubCommands exist, the matching one will be processed in the same manner. And so on and so forth.


Custom Callbacks

When configuring Commands, there are several places where you can provide custom callbacks. Most of these are Funcs that must return a ProcessStageResult. The type that is returned will determine whether the builder continues processing or stops early.

A static helper class exists with some members that makes returning the correct type easier.

To continue: return ProcessResult.Continue.

To end early: return ProcessResult.End.


Commands and the Default Command

Command Store

All Commands are configured on the builder's CommandStore object. The store provides two methods with the following interface:

CommandStore Add(Command<TRunInfo> command, Action<TRunInfo> postBuildCallback = null);
CommandStore AddDefault(DefaultCommand<TRunInfo> defaultCommand), Action<TRunInfo> postBuildCallback = null;

If the optional postBuildCallback action is set, it will be called after the program arguments are done processing, receiving the resolved RunInfo object as its single argument:

builder.Commands.Add(command, runInfo =>
{
    // do something with the resolved runInfo
});

Command

Type: Command<TRunInfo>

The Command is the core entity, as everything else is nested within it.

Properties:

Descriptions and example configurations for Arguments and Options can be found later in their respective sections.

The SubCommand<TRunInfo> type has the same properties as Command<TRunInfo>, with the exception of GlobalOptions which is only available on the root Command object. Once a global option is configured, it's made available to the Command and any of its SubCommands, whereas normal options are scoped to the Command or SubCommand it's configured in.

The global option keys must also be unique, relative to any of the options configured in the Command or SubCommands.

An arbitrary number of Commands can be added to the store:

builder.Commands.Add(new Command<TRunInfo>
{
    Key = "command_key",
    Description = "command description",
    Arguments =
    {
        // arguments for this command
    },
    Options =
    {
        // options scoped specifically to this command
    },
    SubCommands =
    {
        new SubCommand<TRunInfo>
        {
            Key = "subcommand",
            Arguments =
            {
                // arguments for this subcommand
            },
            Options = 
            {
                // options scoped specifically to this subCommand
            },
            SubCommands = 
            {
                // need another level of subCommands? Sure, add em here!
            },
            OnMatched = runInfo =>
            {
                // immediately fires if this subCommand is matched
            }
        },
        // add as many SubCommands as needed
    },
    GlobalOptions =
    {
        // options scoped to be accessible from this command or any subcommand in the tree
    },
    OnMatched = runInfo => 
    {
        // do something with runInfo
        return ProcessResult.Continue;
    }
});

Default Command

Type: DefaultCommand<TRunInfo>

You can optionally include a single DefaultCommand. This behaves exactly like a normal Command, except that it doesn't include the Key, SubCommands, or GlobalOptions properties. It's a simple single-level command that can only process Arguments, Options, and the OnMatched callback.

This can be useful if your program doesn't require more than a single command. Or even if it does have a list of commands, it could be useful to provide some default behavior if it doesn't fit within your program's definition of a command.

Only a single DefaultCommand can be configured:

builder.Commands.AddDefault(new DefaultCommand<TRunInfo>
{
    Description = "default command description",
    Arguments =
    {
        // ... arguments ...
    },
    Options =
    {
        // ... options ...
    },
    OnMatched = runInfo => 
    {
        // do something with runInfo
        return ProcessResult.Continue;
    }
});

Arguments

All Arguments are required (matching program arguments must be found). The order in which they're configured is significant: program arguments must also appear in the same order to correctly match an Argument.

Property Argument

Type: PropertyArgument<TRunInfo, TProperty>

Property argument's take the next single program argument, then attempts to parse and bind it to the configured RunInfo property

An exception is thrown if the program argument cannot be parsed into a TProperty type.

Properties:

Example Configuration:

Arguments =
{
    new PropertyArgument<SendRequestRunInfo, string>
    {
        HelpToken = "<msg>",
        Property = ri => ri.Message,
        OnParsed = value =>
        {
            if (value == "dont send")
            {
                throw new Exception("Shouldn't send!");
            }
            return ProcessResult.Continue;
        },
        OnParseErrorUseMessage = arg => $"Failed to parse program argument '{arg}' because ..."
    }
}

Set Argument

Type: SetArgument<TRunInfo, TProperty>

Set arguments provide a list of tuples in the form (key, boundValue). If the program argument matches one of the keys, its paired value will be bound to the RunInfo property.

An exception is thrown if the program argument doesn't match a key.

Properties:

Example Configuration:

Arguments =
{
    new SetArgument<SendRequestRunInfo, int>
    {
        HelpToken = "(now|one|five)",
        Property = ri => ri.DelayMinutes,
        Values =
        {
            ("now", 0), ("one", 1), ("five", 5)
        }
    }
}

In the example above, a value of 0, 1, or 5 will be bound to the DelayMinutes property, depending on which key the program argument matched.

Custom Argument

Type: CustomArgument<TRunInfo>

Custom arguments handle a configurable number of consecutive program arguments through a callback that you provide.

Properties:

The callback provides a CustomHandlerContext with the following properties:

Example Configuration:

Arguments =
{
    new CustomArgument<SendRequestRunInfo>
    {
        HelpToken = "<greeting> <name>",
        Count = 2,
        Handler = context =>
        {
            string greeting = context.ProgramArguments[0];
            string name = context.ProgramArguments[1];
            context.RunInfo.Message = $"{greeting} {name}!";
            
            return ProcessResult.Continue;
        }
    }
}

In this example, the custom argument will handle two program arguments: the first representing a greeting (eg "hello"), and the second representing the name of the recipient (eg "bob"). The callback simply concatenates the two values to use as the message ("hello bob!").

Sequence Argument

Type: SequenceArgument<TRunInfo, TListProperty>

Sequence arguments take consecutive program arguments, parsing and adding them to the configured list.

Note: the builder will continue to consider program arguments as long as they aren't an Option or SubCommand. Don't configure other Arguments after a SequenceArgument - sequences should either be the last or only Argument in a command.

An exception is thrown if any of the considered program arguments fail to parse into a TListProperty.

Properties:

Example Configuration:

Arguments =
{
    new SequenceArgument<RunInfo, int>
    {
        HelpToken = "<...int>",
        ListProperty = ri => ri.ListOfNumbers,
        OnParsed = value =>
        {
            if (value < 10) 
            {
                return ProcessResult.End;
            }
            return ProcessResult.Continue;
        },
        OnParseErrorUseMessage = arg => $"Failed to parse program argument '{arg}' because ..."
    }
}

In the example above, the builder will continue to parse and add program arguments as ints. However, if the parsed int value is less than 10, it will stop further processing.


Options

Options allow you to setup optional 1-to-1 bindings to a property on the RunInfo. The user specifies an option using the standard --option (full) or -o (short) syntax.

Options can appear in any order in the Command configuration, unlike Arguments where order matters (because they're all required).

Options are bound to a property on the RunInfo, and its value is determined in one of two ways:

Type: Option<TRunInfo, TProperty>

Properties:

Stacking bool options

Multiple bool options can be stacked using the short syntax by combining their single character short keys. Re-emphasis on the bool constraint: stacking short options are not allowed on any other types.

Global options can also be stacked with other options from the Command or any of its SubCommands.

Options =
{
    new Option<RunInfo, int>
    {
        Key = "minutes | m"
        HelpToken = "[--minutes|-m]",
        Property = ri => ri.DelayMinutes,
        OnParsed = value =>
        {
            if (value < 10) 
            {
                return ProcessResult.End;
            }
            return ProcessResult.Continue;
        },
        OnParseErrorUseMessage = arg => $"Failed to parse program argument '{arg}' because ..."
    }
}

In the example above, the user can set an int value for the DelayMinutes property on the RunInfo object through these program argument(s):

Both full and short keys must be unique within a given Command. This means that a command and its subcommand can have options that share the same option keys.


Parser

The parsing of program arguments into the configured Types is handled by the ArgumentParser, available as a property on the RunInfoBuilder object and in some callback contexts.

The following types are automatically handled out-of-the-box, using the standard TYPE.TryParse methods:

Configuring the parser is easy, the following methods are available:

ArgumentParser EnumParsingIgnoresCase()

The parser automatically handles enum types. By default, it does a case-sensitive comparison of the program argument to the enum values. Calling this method will set all future comparisons to be case-insensitive.

ArgumentParser SetPredicateForType<T>(Func<string, (bool isValid, T parsed)> predicateFunc)

This method allows you to extend the parser to handle additional types (or re-configure how an already-handled Type should be parsed).

You provide a Func that takes in the program argument as its single argument, and it returns a ValueTuple where the first item represents whether the parsing was successful, and the second item being the parsed object. The custom predicates set using this method are used internally, and the second item (value) in the tuple is always ignored if the parsing failed.

bool TryParseAs(Type type, string value, out object parsed)

Attempts to parse the value as the specified type. The method returns a bool indicating a successful parse, with the parsed object being returned as an out parameter.

bool TryParseAs<T>(string value, out T parsed)

The same as above, but the Type is specified generically.

bool HandlesType(Type type)

Returns a bool, indicating whether the parser handles the given Type.

HandlesType<T>()

The same as above, but the Type is specified generically.


Hooks

The builder provides some hooks to invoke custom functionality at different phases of the build process.

Setting these hooks is done on the BuildHooks object, found as the property Hooks on the RunInfoBuilder class.

The following methods are available:

BuildHooks OnStartBuild(Action<string[]> callback)

Set a callback that receives the program arguments as it's single argument. This is invoked as the very first thing, immediately after the builder's Build(args) method is called.

BuildHooks ArgsNullOrEmptyReturns<TReturn>(Func<TReturn> callback)

If you need to define what kind of resulting object is returned when the program arguments is null or empty, use this hook by defining a Func<TReturn> callback. Running the build will return whatever the custom callback returns.


Help Menu

Providing a help menu is essential to any program with a CLI, so this library provides some nice default behavior in that area.

The help menu is configured on the HelpManager object, found as the property Help on the RunInfoBuilder class.

Here's an example of a help menu that's displayed by default (taken from the GitCloneSample project):

add - Add file contents to the index.
  Usage: git add [--dry-run|-n] [--verbose|-v] [--ignore-errors] [--refresh]

branch - List, create, or delete branches.
  Usage: git branch <branchname> [--delete|-d] [--force|-f] [--ignore-case|-i]

checkout - Switch branches or restore working tree files.
  Usage: git checkout <branch> [--quiet|-q] [--force|-f] [--track|-t]

commit - Stores the current contents of the index in a new commit along with a log message from the user describing the changes.
  Usage: git commit [--all|-a] [--patch|-p] [--branch]

diff - Switch branches or restore working tree files.
  Usage: git diff [first-branch]...[second-branch] [--no-patch|-s] [--raw] [--minimal]

init - Create an empty Git repository or reinitialize an existing one.
  Usage: git init [--quiet|-q] [--bare]

The following methods are available:

HelpManager SetProgramName(string name)

Sets the name of your program. This will most likely be the name of your program's executable file. For example, if the file is git.exe, you'd want to set your program name as git. If set, the name is displayed in the help menu.

HelpManager SetTriggers(params string[] triggers)

Sets the triggers that will display the help menu.

The help menu displays if the very first argument matches one of the triggers. By default, the following triggers are set:

"--help", "-h", "/help"

You can replace the default triggers by passing in a comma-separated list of strings:

builder.Help.SetTriggers("--?", "/h");

HelpManager InvokeOnBuildFail(bool suppressException)

By default, the help menu is only displayed if explicitly called by the user's program argument. This method will configure the builder to automatically display the help on any exceptions thrown.

The suppressException parameter allows you to configure whether exceptions thrown during the build process are suppressed. If true, will only display help text while suppressing the exception from bubbling to the client.

An example of when you'd select one over the other would be when you're creating a program/tool for internal use versus one for public release.

If the tool is being used internally, then it probably makes sense not to suppress any exceptions such that your app code can handle and deal with it.

However, if it's something being released publically, it's generally not good practice to have exceptions and details such as stack traces shown to the clients. You can switch things up during development anyways, then suppress them for releases.

HelpManager OnTrigger(Action customCallback)

If you need something other than the default help menu, you can set your own callback that is invoked instead. The callback is simple an Action, so you'll need to handle everything including the displaying of help text.

builder.Help.OnTrigger(() => {
    Console.WriteLine("my custom help menu.");
});

The help manager's ToString method has been overridden to return the help text (especially useful during development if you're building out your own custom help text).


Version

Users should be able to quickly note the version of the program. The setup is similar to that of the help menu, and default behavior is provided as well.

The version is configured on the VersionManager object, found as the property Version on the RunInfoBuilder class.

The following methods are available:

VersionManager Set(string version)

Set the version string that is displayed. The default is "1.0.0".

VersionManager SetTriggers(params string[] triggers)

Sets the triggers that will display the version.

The version displays if the very first argument matches one of the triggers. By default, the following triggers are set:

"--version", "-v", "/version"

You can replace the default triggers by passing in a comma-separated list of strings:

builder.Version.SetTriggers("--v", "/v");

Gotchas and Limitations

This section will go over any known limitations or gotchas that you should be aware of when using this library.

A Limitation of the Processing Flow (when dealing with strings)

Lets imagine a Command that expects a single Argument mapped to the string property Message. You also define some Options:

Arguments =
{
    new PropertyArgument<SendRequestRunInfo, string>
    {
        Property = ri => ri.Message
    }
},
Options =
{
    // .. some options defined here ..
}

What happens when a user forgets to include a value for the Argument and passes these program arguments:

[ "sendhttp", "--some-option" ]

Well, the builder has no way to know whether a given program argument is valid for the expected string Argument. So it would take the "--some-option" value and bind it to the RunInfos Message property.

What if the Argument was instead mapped to an int property? In this case, the build would throw an exception because the string "--some-option" is not parseable into an int type.

The workaround or solution to this issue is to simply be aware of this scenario and design your Command to avoid it.