Awesome
Command Line Parser for NetStandard
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
- Supports core command line abstractions such as
Commands
,Subcommands
,Arguments
,Options
, etc. - Comes with many different
Argument
types to handle common use cases, such as 1-to-1 property mappings, binding values to a sequence, mutually-exclusive sets, etc. - Comes with a
Parser
that handles the most common system types out of the box. Easily configurable to handle any arbitrary types. - Provides a cleanly formatted
help
menu by default, with options to configure further. - Several
hooks
which provide custom extensibility points in various stages of the build process.
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
- Custom Callbacks
- Commands and the Default Command
- Arguments
- Options
- Parser
- Hooks
- Help Menu
- Version
- Gotchas and Limitations
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:
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:
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 Func
s 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>
TRunInfo
parameter is theRunInfo
class the command is associated to.
The Command
is the core entity, as everything else is nested within it.
Properties:
- Key -
string
- A unique keyword that represents theCommand
. This only needs to be unique within a givenCommand
. For example, both aCommand
and one of its nestedSubCommands
can have the same key. - Description -
string
- Text that's displayed in the help menu. - Arguments -
List<ArgumentBase<TRunInfo>>
- A list ofArguments
required by theCommand
. Details of the differentArgument
types are discussed later. - Options -
List<OptionBase<TRunInfo>>
- A list ofOptions
associated to theCommand
. - SubCommands -
List<SubCommand<TRunInfo>>
- A list ofSubCommands
associated to theCommand
. - GlobalOptions -
List<OptionBase<TRunInfo>>
- A list ofOptions
that are available to theCommand
and any of its nestedSubCommands
. - OnMatched -
Func<TRunInfo, ProcessStageResult>
- An optional callback that's invoked immediately after the command is matched and begins processing (happens before anything else like arguments, options, etc).
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>
TRunInfo
is theRunInfo
class the default command is associated to.
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>
TRunInfo
is theRunInfo
class the property is associated to.TProperty
represents the type of the mappedRunInfo
property.
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:
- HelpToken -
string
- The text that appears in the help menu representing thisPropertyArgument
. It should be short and succinct. For example, aHelpToken
could be"<string>"
, indicating to the user that thisPropertyArgument
binds to a string property. - Property -
Expression<Func<TRunInfo, TProperty>>
- An expression representing theRunInfo
property the parsed value will be bound to. - OnParsed -
Func<TProperty, ProcessStageResult>
- An optional custom callback that is invoked after a valid value has been parsed. The callback will be invoked with that value as its single argument, and return aProcessStageResult
. If the callback returnsProcessResult.End
, processing will stop before the parsed value is bound to the property. - OnParseErrorUseMessage -
Func<string, string>
- An optional function used to generate the error message on parsing error. The single argument is the program argument that failed to parse.
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>
TRunInfo
is theRunInfo
class the property is associated to.TProperty
represents the type of the mappedRunInfo
property.
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:
- HelpToken -
string
- The text that appears in the help menu representing thisSetArgument
. It should be short and succinct. For example, aHelpToken
could be"(a|b|c)"
, indicating that the acceptable values are "a", "b", and "c". - Property -
Expression<Func<TRunInfo, TProperty>>
- An expression representing theRunInfo
property the paired value will be bound to. - Values -
List<(string, TProperty)>
- List of tuples representing the key and value pairings.
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>
TRunInfo
is theRunInfo
class the property is associated to.
Custom arguments handle a configurable number of consecutive program arguments through a callback that you provide.
Properties:
- HelpToken -
string
- The token that appears in the help menu representing this custom argument. Example:"<first> <second> <third>"
- Count -
int
- The number of program arguments the callback will handle. - Handler -
Func<CustomHandlerContext<TRunInfo>, ProcessStageResult>
- The custom callback that will handle the program arguments.
The callback provides a CustomHandlerContext
with the following properties:
- RunInfo -
TRunInfo
- The RunInfo instance so you can modify it yourself. - ProgramArguments -
List<string>
- A list containing the program arguments to be handled (as set by theCount
property). - Parser -
ArgumentParser
- The same Parser that's configured on the builder.
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>
TRunInfo
is theRunInfo
class the property is associated to.TListProperty
represents the type of theList<T>
the parsed values will be added to.
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:
HelpToken
(string
) - The token that appears in the help menu representing thisSequenceArgument
. Example:"<...int>"
.ListProperty
(Expression<Func<TRunInfo, List<TListProperty>>>
) - An expression representing theRunInfo
list property the values will be added to.OnParsed
(Func<TListProperty, ProcessStageResult>
) - An optional custom callback that is invoked for every value after they are parsed. The callback will be invoked with that value as its single argument, and return aProcessStageResult
. If the callback returnsProcessResult.End
, processing will stop before the parsed value is added to the property.OnParseErrorUseMessage
(Func<string, string>
) - An optional function used to generate the error message on parsing error. The single argument is the program argument that failed to parse.
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:
- By parsing the right side of the
=
character in an option program argument: For example, if the program argument is"--option=value"
, then the string"value"
will be parsed into the expected type and bound to the property. - By parsing the next program argument: If an option was declared without the
=
, the builder will simply assume the next program argument is its intended value, and will parse and bind that to the property.
Type: Option<TRunInfo, TProperty>
TRunInfo
is theRunInfo
class the option is associated to.TProperty
represents the type of the property the parsed option value will be bound to.
Properties:
Key
(string
) - A string representing the option key. For example, if it's set as"hide"
, it would be called as"--hide"
in a program argument. You can optionally set a short key by delimiting the string with a|
character. If the key is set to"hide | h"
, then you could use this option with either"--hide"
or"-h"
.Property
(Expression<Func<TRunInfo, TProperty>>
) - An expression representing theRunInfo
property the parsed value will be bound to.HelpToken
(string
) - The token that appears in the help menu representing this option. Example:"[--hide|-h]"
.OnParseErrorUseMessage
(Func<string, string>
) - An optional function used to generate the error message on parsing error. The single argument is the program argument (representing option's value) that failed to parse.
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):
"--minutes=5"
"--minutes", "5"
"-m=5"
"-m", "5"
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:
- string
- bool
- byte
- char
- DateTime
- decimal
- double
- int
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 RunInfo
s 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.