Click here to Skip to main content
15,886,919 members
Articles / Programming Languages / C#

Program.Base: Drop In Command Line Application Functionality for Your Projects

Rate me:
Please Sign up or sign in to vote.
5.00/5 (4 votes)
15 Feb 2024MIT8 min read 7.2K   59   13   1
Presenting a C# partial Program class to add core functionality to your CLI projects and get you up and running faster
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.

Image 1

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.

C#
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:

Terminal
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:

C#
[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 enums 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.

Terminal
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.

C#
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:

Terminal
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 TextReaders 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

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
United States United States
Just a shiny lil monster. Casts spells in C++. Mostly harmless.

Comments and Discussions

 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA24-Jan-24 6:41
professionalȘtefan-Mihai MOGA24-Jan-24 6:41 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.