Click here to Skip to main content
15,867,568 members
Articles / Programming Languages / C#

Command Line Parsing for IConfiguration

Rate me:
Please Sign up or sign in to vote.
5.00/5 (3 votes)
1 Oct 2021GPL38 min read 8.1K   5   4
An IConfigurationBuilder addon for parsing command lines
J4JCommandLine is an easy to use command line parser for Net5 and above applications. It works with Microsoft's IConfiguration API so that the resulting configuration object can draw from a variety of sources in addition to the command line.

Introduction

There are a number of command line parsers available for .NET, some of which work with Microsoft's IConfiguration API. I wrote J4JCommandLine because I was intrigued by how command line parsing works behind the scenes... and because the parsers I found struck me as difficult to configure. In fairness, that's probably because they are more flexible than J4JCommandLine, allowing you to bind command lines to all sorts of targets.

Restricting the parser's scope to the IConfiguration system makes it less flexible as to what it can target. But it gains more than it loses (in my opinion, at least :)) because it can now tap into configuration information from a variety of sources: the command line, JSON configuration files, user secrets, and more.

Background

Originally, J4JCommandLine was "just another command line parser" because I was curious about LL(1) parsers and wanted to write one.

"LL(1)" means the parser scans tokens from Left to right, and only looks 1 token ahead (the second "L" is about following/constructing "left hand" routes in a tree data structure; I'm not sure about that because I didn't use a tree-based approach in J4JCommandLine).

J4JCommandLine isn't, technically, an LL(1) parser because it does some pre-processing of the tokens it generates before parsing them. The main such step being to merge all tokens between a starting "quoter" token and an ending "quoter" token into a single test token.

You can read more about J4JCommandLine and conceptually how it parses command lines by consulting the Github documentation. But for now, the key milestone for me was when, after getting it working, I realized there was a way to make it work with Microsoft's IConfiguration system.

You create an instance of IConfiguration (technically, IConfigurationRoot) by creating an instance of ConfigurationBuilder, adding providers (of configuration information) to it and then calling its Build() method:

C#
var parser = testConfig.OperatingSystem.Equals( "windows", StringComparison.OrdinalIgnoreCase )
                ? Parser.GetWindowsDefault( logger: Logger )
                : Parser.GetLinuxDefault(logger: Logger);

_options = parser.Collection;

_configRoot = new ConfigurationBuilder()
                .AddJsonFile("some file path")
                .AddUserSecrets<ConfigurationTests>()
                .AddJ4JCommandLine(
                    parser,
                    out _cmdLineSrc, 
                    Logger )
                .Build();

Behind the scenes, the providers are queried for key/value pairs of property names and values. When you request an object from the IConfiguration system, those are used to initialize the object:

C#
var configObject = _configRoot.Get<SomeConfigurationObject>();

J4JCommandLine's parser works with its provider to translate command line text into configuration values.

Using the Code

J4JCommandLine is pretty configurable but basic usage is very simple provided the defaults work for you. All you need to do is add the provider to your ConfigurationBuilder instance, invoke Build() and go.

However, that won't result in your command line being parsed... because you have to tell the parser what command line options it may encounter, what types those are, whether they're required, etc. You do that by binding properties of your configuration object to command line tokens.

Here's an example using a simple configuration object:

C#
public class Configuration
{
    public int IntValue { get; set; }
    public string TextValue { get; set; }
}

In your startup code, you'd do something like this:

C#
var config = new ConfigurationBuilder()
    .AddJ4JCommandLineForWindows( out var options, out _ )
    .Build();

options!.Bind<Configuration, int>(x => Program.IntValue, "i")!
    .SetDefaultValue(75)
    .SetDescription("An integer value");

options.Bind<Configuration, string>(x => Program.TextValue, "t")!
    .SetDefaultValue("a cool default")
    .SetDescription("A string value");

options.FinishConfiguration();

Now when you call:

C#
var parsed = config.Get<Configuration>();

the resulting Configuration object will reflect the command line arguments.

There is also a TryBind<>() method which you can use instead of Bind so you don't have to check the return value for being non-null (which would indicate the binding could not be done). I didn't include the check for null code in the example just to keep things simple.

What Operating Systems Are Supported?

I've tried to make J4JCommandLine "operating system agnostic" since .NET can run on non-Windows platforms these days. Frankly, I haven't tested it under anything other than Windows... but the logic to support other systems is there. 

You control how J4JCommandLine works, operating-system-wise, by telling it what "lexical elements" to use and whether or not command line keys (e.g., the x in /x) are case sensitive or not. Behind the scenes, that's part of what the AddJ4JCommandLineForWindows() and AddJ4JCommandLineForLinux() extension methods do: they set those operating system specific parameters to reasonable defaults.

Here are the defaults:

Operating System Defaults
 
  Lexical Elements Keys Case Sensitive?
Windows quoters: " '
key prefix: /
separators: [space] [tab]
value prefix: =
No
Linux quoters: " '
key prefixes: - --
separators: [space] [tab]
value prefix: =
Yes

Quoters define text elements that should be treated as a single block. Key prefixes indicate the start of an option key (e.g., /x or -x). Separators separate tokens on the command line. Value prefixes (which I rarely use since regular separators seem to work just fine) link an option key to a text element (e.g., /x=abc).

Keep in mind that when there are multiple valid key prefixes (e.g., - and --) either can be used with any key. So -a, --ALongerKey, -ALongerKey and --a are all valid so far as J4JCommandLine is concerned. That's not the "Linux way" but I haven't figured out how to do things "right" yet.

What Kinds of Properties Can You Bind To?

J4JCommandLine cannot bind command line options to any random C# type. It has to be able to convert one or more text values to instances of the target type and that requires there be a conversion method available. In that, it's no different from the IConfiguration API itself, which depends on C#'s built-in Convert class to convert text to type instances.

However, J4JCommandLine is extensible as regards conversions; you can define your own converter methods for changing text into an instance of a custom type. Consult the Github documentation for details... and keep in mind that portion of my codebase isn't well-tested.

As a practical matter, I doubt you'll need to define your own converters. By default, J4JCommandLine can convert any type which has a corresponding method in C#'s built-in Convert class (in fact, J4JCommandLine simply wraps those methods to match its specific needs).

Binding to Collections

In addition to being able to bind to most commonly used configuration types, J4JCommandLine can also bind to certain types of collections of those types:

  • arrays of supported types (e.g., string[])
  • lists of supported types (e.g., List<string>)

The IConfiguration system also allows you to bind to Dictionaries. But I haven't figured out how to make that work within the context of a command line yet.

There is another, less obvious limitation of what can be bound. J4JCommandLine can bind to enums natively, even flagged enums (i.e., enums marked with the [Flag] attribute). But it cannot bind to collections of flagged enums. That's because it can't tell whether a sequence of text tokens corresponding to enum values should be concatenated into a single, OR'd result or should be treated as a collection of unconcatenated enums.

Nested Properties and Public Parameterless Constructors

J4JCommandLine shares IConfiguration's requirement that configuration objects, and the properties being bound, must generally have public parameterless constructors. That's because the IConfiguration API has to be able to create instances internally, without any knowledge of how to actually do that.

There is an exception to that requirement, however: if the constructor logic of your configuration object takes care of initializing the properties, then those properties do not need public parameterless constructors. Here's an example:

C#
public class EmbeddedTargetNoSetter
{
    public BasicTargetParameteredCtor Target1 { get; } = new( 0 );
    public BasicTargetParameteredCtor Target2 { get; } = new( 0 );
}

public class BasicTargetParameteredCtor
{
    private readonly int _value;

    public BasicTargetParameteredCtor( int value )
    {
        _value = value;
    }

    public bool ASwitch { get; set; }
    public string ASingleValue { get; set; } = string.Empty;
    public List<string> ACollection { get; set; } = new();
    public TestEnum AnEnumValue { get; set; }
    public TestFlagEnum AFlagEnumValue { get; set; }
}

Even though the type BasicTargetParameteredCtor doesn't have a public parameterless constructor, you can still bind to the Target1 and Target2 properties because they are initialized by EmbeddedTargetNoSetter's constructor logic (in this case, implicitly via those new() calls).

This example also highlights another thing about J4JCommandLine: you can bind to nested properties:

C#
Bind<EmbeddedTargetNoSetter, bool>( _options!, x => x.Target1.ASwitch, testConfig );
Bind<EmbeddedTargetNoSetter, string>( _options!, x => x.Target1.ASingleValue, testConfig );
Bind<EmbeddedTargetNoSetter, TestEnum>( _options!, x => x.Target1.AnEnumValue, testConfig );
Bind<EmbeddedTargetNoSetter, TestFlagEnum>
    ( _options!, x => x.Target1.AFlagEnumValue, testConfig );
Bind<EmbeddedTargetNoSetter, List<string>>
    ( _options!, x => x.Target1.ACollection, testConfig );

Providing Help

It's not uncommon for users to provide invalid arguments on the command line. Letting them know what they should've entered is important. J4JCommandLine addresses this by providing an interface for you to use to display help information that you associate with bound options. Here's how you might do this:

C#
options!.Bind<Program, int>(x => Program.IntValue, "i")!
    .SetDefaultValue(75)
    .SetDescription("An integer value")
    .IsOptional();

options.Bind<Program, string>(x => Program.TextValue, "t")!
    .SetDefaultValue("a cool default")
    .SetDescription("A string value")
    .IsRequired();

options.FinishConfiguration();

var help = new ColorHelpDisplay(new WindowsLexicalElements(), options);
help.Display();

You describe an option by calling extension-methods on it. These let you set a default value, describe what it's for, and indicate whether it's required or optional (the default is optional).

Normally, of course, you wouldn't always display the help information the way this code snippet does. You'd only display help if the user asked for it (e.g., by setting a command line option) or if there was a problem with whatever configuration object got created.

Note that J4JCommandLine does not automatically display help when it detects a problem. That's up to you.

The J4JCommandLine assembly includes a plain-vanilla help display engine called DefaultHelpDisplay. But I generally prefer to use the more colorful display provided by the ColorfulHelp library which is included in the github repository. Whichever you use (or if you roll your own) you have to configure the help system with information about the "lexical elements" being used (i.e., the special characters, like / ? = ") and the option collection you've defined. That's so the help system can extract and display information about how the options should be specified/formatted, their default values, whether they're required, etc.

Logging

J4JCommandLine uses my companion library J4JLogger to log problems. The logging capability is optional -- by default, nothing is logged. But if you want to know what's going on inside J4JCommandLine, it's a good thing to include.

Refer to the CodeProject article on J4JLogger or its github repository for more information.

Points of Interest

When I started digging into how the IConfiguration system works, I expected to find something along the lines of how I structured J4JCommandLine's conversion capabilities. Interestingly, I couldn't find it. Instead, it looks like the IConfiguration API simply relies on the built-in Convert class and fails if there isn't a conversion method available.

In a similar vein, the IConfiguration system does its checks (e.g., for a property type having a public parameterless constructor) in an unstructured sense. There doesn't appear to be a set of (potentially extensible) rules. Instead, the checks are simply hard-wired into the codebase.

History

  • 1st October, 2021: Initial release on CodeProject

License

This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)


Written By
Jump for Joy Software
United States United States
Some people like to do crossword puzzles to hone their problem-solving skills. Me, I like to write software for the same reason.

A few years back I passed my 50th anniversary of programming. I believe that means it's officially more than a hobby or pastime. In fact, it may qualify as an addiction Smile | :) .

I mostly work in C# and Windows. But I also play around with Linux (mostly Debian on Raspberry Pis) and Python.

Comments and Discussions

 
QuestionLL(1) parsers Pin
alan@1st-straw.com4-Oct-21 7:19
alan@1st-straw.com4-Oct-21 7:19 
AnswerRe: LL(1) parsers Pin
Mark Olbert4-Oct-21 7:32
Mark Olbert4-Oct-21 7:32 
GeneralRe: LL(1) parsers Pin
alan@1st-straw.com4-Oct-21 8:17
alan@1st-straw.com4-Oct-21 8:17 
GeneralRe: LL(1) parsers Pin
Mark Olbert4-Oct-21 15:18
Mark Olbert4-Oct-21 15:18 

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.