Jump start your command line interface applications with command line argument parsing, progress indicators, word wrapping, stale file checking, automatic using screen generation, and foundational assembly information.
Introduction
Update: Better default value handling
Update 2: Can now load or emit files as arguments if you use TextReader
or TextWriter
as argument types (or in collections/arrays). The example has been updated to show how it works. Now all disposable instances of all IDisposable
argument instances get Dispose()
called on them after Run()
completes or errors.
Update 3: Bugfix, and added a "wrap" sample app that word wraps text files to the specified width.
Update 4: Some bugfixes. Forked for .NET Framework, and removed nullability issues.
Update 5: Largely rewritten and cleaned up. Now supports ordinal arguments at the beginning other than just one. The article has been largely rewritten as a result.
Update 6: Added some more environment parsing, added FileSystemInfo
/FileInfo
/DirectoryInfo
support.
Update 7: I found some parsing bugs when using it for a recent project. Color me chagrined. These have been fixed, and this represents an important update.
I build a lot of command line tools in C# - often build tools. Sometimes providing the basic functionality common to most command line tools such as argument parsing and a usage screen can take more time and effort than the utility's functionality.
Never again. I'd rather just include some code and have everything already in place and at my fingertips. To that end, I finally decided to write my holy grail CLI boilerplate to handle all the stuff that I have to do over and over, and I'm providing it to the community.
Using the Code
This is no trivial code to implement, but it was designed to be easy to use. It uses .NET Reflection extensively to grab information from the assembly, including figuring out which arguments to parse and how to generate the using screen. The whole thing is orchestrated to be as automatic as possible.
First, drop Program.Base.cs into your project. Because of the way it works, it can't be a library. You'll need to implement the Program
class under the default/empty namespace or edit Program.Base.cs.
Basically, it hijacks your entry point, giving you a void Run()
method to implement in your Program partial class
instead. By the time it's called, everything is sliced, diced, and cooked to perfection. Your command line is parsed, assembly information loaded, and you have utility methods to help.
Your command line is defined by static
fields in your Program
class that are marked up with the CmdArgAttribute
. It's a good idea to provide as much information as possible for the using screen.
Speaking of which, you may also want to specify things like the assembly title, copyright, version and description, all of which are used by the using screen.
I'm going to hit you with some code now.
Defining Your Program Class and Alternative Entry Point
You'll want to make your Program
class partial
. I tend to make it static
as well.
static partial class Program {
void Run() {
}
}
That creates a program that takes no arguments other than potentially /?
to display the using screen. If you do pass /?
the result will look like this:
wrap v1.0
word wraps input
Usage: wrap
/? Displays this screen and exits
The above is assuming you filled in the assembly description and version.
Defining Your Arguments
Let's add some arguments to the application:
[CmdArg(Ordinal=0, Required =true,Description ="The input files")]
public static TextReader[] Inputs = new TextReader[] { Console.In };
[CmdArg("output",Description = "The output file. Defaults to <stdout>")]
public static TextWriter Output = Console.Out;
[CmdArg("id", Description = "The guid id", ItemName = "guid")]
public static Guid Id = Guid.Empty;
[CmdArg("ips", Description = "The ip addesses", ItemName = "address")]
public static List<IPAddress> Ips = new List<IPAddress>() { IPAddress.Any };
[CmdArg("ifstale", Description = "Only regenerate if input has changed")]
public static bool IfStale = false;
[CmdArg("width", Description = "The width to wrap to", ItemName = "chars")]
public static int Width = Console.WindowWidth/2;
[CmdArg("enum", Description = "The binding flags", ItemName = "flag")]
public static List<BindingFlags> Enum = null;
[CmdArg("indices", Description = "The indices", ItemName = "index")]
Here, we've defined many arguments. The first is the Inputs
/#0
argument (see the [CmdArg]
metadata).
This is an ordinal argument as indicated by setting Ordinal
to a non-negative value. Ordinal arguments must be first in the argument list, and do not have a /
switch associated with them.
Anyway, this particular argument takes a TextReader[]
array which indicates that it's a series of files. It could easily be some kind of TextReader
collection - that's up to you. Since it's a list of TextReader
objects, the ItemName
defaults to "infile" which is what we want since that refers to the name presented for each item in the list in the using screen. It's usually best to specify it for clarity, but we didn't need it here. We've also indicated that it's Required
, so the parser will insist on one or more of these entries. Note that we didn't specify the ItemName, but it was resolved to infile
. This could happen because the elemental type is TextReader
. Of course, that can be overridden by setting ItemName
explicitly.
The second one is a simple string /output <outfile>
which just takes a single argument. Similarly to above, we didn't specify "outfile" as the ItemName
. It was inferred by the use of TextWriter
as the elemental type.
Id
/id
is a bit more interesting. It takes a Guid
. This works because Guid
has a TypeConverter
associated with it. That means .NET has a built in well known facility for converting this from a string. That is used internally by the parser.
Ips
/ips
is more interesting still in that it contains a list of IPAddress
instances. Each of those is parsed using its Parse()
method, since it doesn't have a TypeConverter
.
Since IfStale
/ifstale
is a bool
, it just has the switch /ifstale
with no argument. If specified, this field will be set to true.
Count
/count
takes a single integer.
Enum
/enum
takes flags. Notice how we used a list instead of doing bitwise or operations. The fact is the parser does not support enum
s intrinsically, but does so through the magic of TypeConverter
, so it is at its mercy. The appropriate pattern in this case is to take a list of flags and merge them later.
Indices
/indices
- the final parameter - is just a list of integers
Any optional arguments that are not specified can have default values assigned to their associated field or property. If the value is specified, the default values will get replaced. This also works with lists and arrays.
If we don't specify any arguments and run it in Release*, we get the following using screen.
Example v1.0
An example of using Program.Base to handle core CLI functionality
Usage: Example {<infile1> [<infileN>]} [/enum {<flag1> <flagN>}] [/id <guid>] [/ifstale] [/indices {<index1> <indexN>}]
[/ips {<address1> <addressN>}] [/output <outfile>] [/width <chars>]
<infile> The output files
<flag> The binding flags
<guid> The guid id
<ifstale> Only regenerate if input has changed
<index> The indices
<address> The ip addesses
<outfile> The output file - defaults to <stdout>
<chars> The width to wrap to
- or -
/help Displays this screen and exits
Error: Missing required argument <infile>
You can see it did plenty of work for you based on that information you fed it. It handles basic validation and stuff as well, but if you need to do further logic to validate arguments, you should throw an exception after calling PrintUsage()
on a validation failure.
*In Debug builds, the application will throw on error. The reason for this is simply so you can debug any exceptions that crop up. In Release, errors pop the using screen and a description of the error.
Accessing the Info
property nets you the Filename
of the executable, the CodeBase
, which is typically the full path to the executable, a friendly Name
, a Description
, Copyright
and the Version
. It also has CommandLinePrefix
which gives you the literal command line prior to the passed in arguments. Some of these are used by the using screen. This is reflected off your assembly information.
Progress Reporting
There are two ways to report progress to the console. One way is for when you don't have a definite ending point, and you don't know how long it will take. The other gives you a progress bar with a percentage. Using them is easy.
Console.Write("Progress test: ");
for (int i = 0; i < 10; ++i)
{
WriteProgress(i, i > 0, Console.Out);
Thread.Sleep(100);
}
Console.WriteLine();
Console.Write("Progress bar test: ");
for (int i = 0; i <= 100; ++i)
{
WriteProgressBar(i, i > 0, Console.Out);
Thread.Sleep(10);
}
Console.WriteLine();
After some animation, you'll see this final screen:
Progress test: \
Progress bar test: [■■■■■■■■■■] 100%
On the first call to each method, you must pass false
as the second argument. After that, pass true
.
Note that these require backspace and some console emulation facilities like Visual Studio's output window don't respect backspace.
Word Wrapping
It's often desirable to be able to wrap text to the width of the console*.
The WordWrap()
function can take text
, a width
, an indent
, and a startOffset
for the first line.
The text
is the text to wrap. The width
is the width to wrap it to, in characters. The indent
is the count of spaces to use in order to indent each line after the first. Finally, startOffset
indicates the starting position on the first line where the printing begins. If width
is zero, it tries to approximate the console width.
Stale File Checking
Utilities often take input files and generate output files. Sometimes, if the utility takes a long time to process, it can be desirable to skip the processing unless the output needs to be regenerated. IsStale()
takes one or more input files and an output file, or alternatively one or more TextReader
s and a TextWriter
. It can also take FileInfo
objects. It returns true if the output file does not exist, or if it is older than the input file.
Automatic File Management
If you use TextReader
or TextWriter
as the type of an argument (or an array/collection of these), the readers will automatically be opened, and closed on exit or error. The writers will create or open the files on first write and close on exit or error, as necessary. IsStale()
can be used to compare inputs and outputs. There's no way for the argument parser to figure out if a stream should be read or write so if you want to do binary, you'll most likely just want to take FileInfo
parameters and deal with the file handling yourself.
History
- 24th January, 2024 - Initial submission
- 25th January, 2024 - Improved default handling to work with arrays and lists
- 25th January, 2024 - Works with
TextReader
and TextWriter
arguments and lists - 25th January, 2024 - Bugfix and wrap application sample
- 25th January, 2024 - Bugfix and DNF support, plus removing nullability warnings
- 26th January, 2024 - Rewrite. Added better using screen and argument options. Fixed word wrap
- 29th January, 2024 - Added more functionality
- 15th February, 2024 - Bug fixes in parsing code