So, I decided to write my own little command line argument parser for various other projects I work on. I am aware that there are many good command line parser libraries, but I did wrote my own anyway (practice & implementation specific reasons).
The parser works fine, but I have a feeling that it can be improved a lot, mainly the following things come to mind
- Mainly the actual parser, CommandLineParser.cs. It seems very badly structured and I find it hard to read myself.
- Abstraction. I wonder if I can abstract it a bit more without making it a pain to use? Maybe by introducing some interfaces?
- Naming. I went with Option for the command line switch and with Value for the possible parameters. Are my methods/classes self-descriptive?
- Optimizations. I am sure there are segments that can be done more efficiently, mainly in
CommandLineParser.ParseArguments(string[] args)
A couple of things to note:
- I'd like to keep the structure for the CommandLineValue.cs and CommandLineOption.cs mostly the same as they are part of a plugin architecture to communicate command line arguments between the plugins and the main application.
- No usage of Attributes to store the command line options.
- I did write a couple of unit tests to verify the parsers functionality. Despite them being not the main class to review, I am appreciate feedback there too :)
Parser:
public class CommandLineParser { /// <summary> /// Defines all possible command line options the plugin can can process /// </summary> public List<CommandLineOption> SupportedOptions { get; } /// <summary> /// Initialize the commandline parser with a list of commandline options the plugin exposes /// </summary> /// <param name="supportedOptions"></param> public CommandLineParser(List<CommandLineOption> supportedOptions) { SupportedOptions = supportedOptions; } /// <summary> /// Parse the command line arguments and returns a list of commandline values that can be passed to the /// plugin for further processing. The function also handles invalid amount and/or format of options, values /// as well as missing required arguments etc /// </summary> /// <param name="args">The arguments to parse</param> /// <returns>A list of parsed commandline values + options</returns> /// <exception cref="InvalidCommandLineOptionException"></exception> /// <exception cref="InsufficientCommandLineValuesException"></exception> /// <exception cref="InvalidCommandLineValueException"></exception> /// <exception cref="MissingRequiredCommandLineOptionException"></exception> public IEnumerable<CommandLineValue> ParseArguments(string[] args) { var result = new List<CommandLineValue>(); if (args.Length == 0) return Enumerable.Empty<CommandLineValue>(); // Process all command line arguments for (int i = 0; i < args.Length; i++) { CommandLineOption option = null; if (!IsSupportedOption(args[i], out option)) throw new InvalidCommandLineOptionException($"{args[i]} is not a valid command line option"); // Verify if the option expects additional values if (HasAdditionalValues(option)) { // Check if enough additional values are given int additionalValues = option.ParameterTypes.Count; if (i + additionalValues + 1 > args.Length) throw new InsufficientCommandLineValuesException( $"{args[i]} expects {additionalValues} values."); // Check if the additional values are in the right format // ToDo: Find more elegant solution var values = args.ToList().GetRange(i + 1, i + additionalValues).ToList(); var types = option.ParameterTypes.ToList(); var castedValues = values.Zip(types, (value, type) => { try { return Convert.ChangeType(value, type); } catch { throw new InvalidCommandLineValueException( $"Cannot cast between value {value} to type {type}"); } }); result.Add(new CommandLineValue(option, castedValues.ToList())); // Increase i to skip to the next option i += additionalValues; } else { result.Add(new CommandLineValue(option, null)); } } // Collect required arguments List<string> requiredOptions = new List<string>(); foreach (var option in SupportedOptions) { if (option.Required) foreach (var tag in option.Tags) { requiredOptions.Add(tag); } } // Check that no required arguments are missing (or occur twice) var missing = GetMissingRequiredArgs<string>(requiredOptions, args.ToList()); if (missing == null) return result; throw new MissingRequiredCommandLineOptionException( $"The required arument(s) {string.Join(",", missing)} occured multiple times"); } /// <summary> /// Check that all required options are used and that they (the required options) dont occur multiple times are no duplicates /// </summary> /// <param name="required">A list of required options</param> /// <param name="arguments"><The args to check</param> /// <typeparam name="T">Any primitive type</typeparam> /// <exception cref="MissingRequiredCommandLineOptionException">Thrown if any distinct required arguments exist more then once</exception> /// <returns>A list of missing required args, if any. Null if none are missing.</returns> static List<T> GetMissingRequiredArgs<T>(List<T> required, List<T> arguments) { // convert to Dictionary where we store the required item as a key against count for an item var requiredDict = required.ToDictionary(k => k, v => 0); foreach (var item in arguments) { if (!requiredDict.ContainsKey(item)) continue; requiredDict[item]++; // if we have required, adding to count if (requiredDict[item] <= 1) continue; throw new DuplicateRequiredCommandLineOptionException( $"Required option {item} appeared more than once!"); } var result = new List<T>(); // now we are checking for missing items foreach (var key in requiredDict.Keys) { if (requiredDict[key] == 0) { result.Add(key); } } return result.Any() ? result : null; } /// <summary> /// Verify if given option is part of the supported options /// </summary> /// <returns>true if the option is supported otherwise false</returns> private bool IsSupportedOption(string optionIdentifier, out CommandLineOption option) { for (var index = 0; index < SupportedOptions.Count; index++) { var supportedOption = SupportedOptions[index]; if (supportedOption.Tags.Any(tag => tag == optionIdentifier)) { option = SupportedOptions[index]; return true; } } option = null; return false; } /// <summary> /// Indicates if a command line option has multiple values or if its just a flag /// </summary> /// <param name="option">Commandlineoption to check</param> /// <returns>true if the option has multiple values, otherwise false</returns> private bool HasAdditionalValues(CommandLineOption option) { var noParameters = option.ParameterTypes == null || option.ParameterTypes.Count == 0; return !noParameters; } }
Classes to store commandline information:
public class CommandLineOption { /// <summary> /// The identifier of the commandline option, e.g. -h or --help /// </summary> public ICollection<string> Tags { get; } /// <summary> /// Description of the commandline option /// </summary> public string Description { get; } /// <summary> /// Indicates if the argument is optional or required /// </summary> public bool Required { get; } /// <summary> /// Types of the additional provided values such as directory paths, values etc .. /// </summary> public IList<Type> ParameterTypes { get; } /// <summary> /// Create a new true/false commandline option /// </summary> /// <param name="tags">Identifier of the command line option</param> /// <param name="description">Description of the command line option</param> /// <param name="required">Indicates if the command line option is optional or not</param> public CommandLineOption(IEnumerable<string> tags, string description, bool required = false) { Tags = tags.ToList(); Description = description; Required = required; } /// <summary> /// Create a new true/false commandline option /// </summary> /// <param name="tags">Identifier of the command line option</param> /// <param name="description">Description of the command line option</param> /// <param name="required">Indicates if the command line option is optional or not</param> public CommandLineOption(IEnumerable<string> tags, string description, bool required = false, params Type[] parameterTypes): this(tags, description, required) { ParameterTypes = new List<Type>(parameterTypes); } }
public class CommandLineValue : IEqualityComparer<CommandLineValue> { /// <summary> /// Holds all the values specified after a command line option /// </summary> public IList<object> Values { get; } /// <summary> /// The command line option the value(s) belong to /// </summary> public CommandLineOption Option { get; set; } /// <summary> /// Stores the values that correspond to a commandline option /// </summary> /// <param name="option">The commandline option the values refer to</param> /// <param name="values">The values that are stored</param> public CommandLineValue(CommandLineOption option, IList<object> values) { Option = option; Values = values; } public bool Equals(CommandLineValue x, CommandLineValue y) { if (x.Option.Description == y.Option.Description && x.Option.Required == y.Option.Required && x.Option.Tags.SequenceEqual(y.Option.Tags) && x.Option.ParameterTypes.SequenceEqual(y.Option.ParameterTypes) && x.Values.SequenceEqual(x.Values)) return true; return false; } public int GetHashCode(CommandLineValue obj) { return base.GetHashCode(); } }
Custom Exception Classes:
public class DuplicateRequiredCommandLineOptionException : Exception { public DuplicateRequiredCommandLineOptionException(string message) : base(message) { } } public class InsufficientCommandLineValuesException : Exception { public InsufficientCommandLineValuesException(string message) : base(message) { } } public class InvalidCommandLineOptionException : Exception { public InvalidCommandLineOptionException(string message) : base(message) { } } public class InvalidCommandLineValueException : Exception { public InvalidCommandLineValueException(string message) : base(message) { } } public class MissingRequiredCommandLineOptionException : Exception { public MissingRequiredCommandLineOptionException(string message) : base(message) { } }
Unit Tests:
public class CommandLineParserTests { [Fact] public void ParseDuplicateRequiredArguments() { var args = new[] {"--randomize", "-o", "/home/user/Documents", "--randomize", "-d"}; var supportedOptions = new List<CommandLineOption> { new CommandLineOption( new[] {"-r", "--randomize"}, "Random flag", true), new CommandLineOption( new[] {"-o", "--output-directory"}, "Specifies the output directory", true, typeof(string)), new CommandLineOption( new[] {"-d", "--dummy"}, "Just another unused flag"), }; var parser = new CommandLineParser(supportedOptions); Assert.Throws<DuplicateRequiredCommandLineOptionException>(() => parser.ParseArguments(args) ); } [Fact] public void ParseMissingRequiredArguments() { var args = new[] {"--randomize", "--output-directory", "/home/user/Documents"}; var supportedOptions = new List<CommandLineOption> { new CommandLineOption( new[] {"-r", "--randomize"}, "Random flag"), new CommandLineOption( new[] {"-o", "--output-directory"}, "Specifies the output directory", true, typeof(string)), new CommandLineOption( new[] {"-d", "--dummy"}, "Just another unused flag"), }; var parser = new CommandLineParser(supportedOptions); Assert.Throws<MissingRequiredCommandLineOptionException>(() => parser.ParseArguments(args) ); } [Fact] public void ParseMatchingTypeCommandLineValues() { var args = new[] {"--log", "info", "1337", "3.1415"}; var supportedOptions = new List<CommandLineOption> { new CommandLineOption( new[] {"-l", "--log"}, "Logs info from exactly three data sources", false, typeof(string), typeof(int), typeof(float)) }; var parser = new CommandLineParser(supportedOptions); var expectedValue = new CommandLineValue(new CommandLineOption( new[] {"-l", "--log"}, "Logs info from exactly three data sources", false, typeof(string), typeof(int), typeof(float)), new object[] {"info", 1337, (float) 3.1415}); var actualValue = parser.ParseArguments(args).ToList()[0]; Assert.True(expectedValue.Equals(actualValue, expectedValue)); } [Fact] public void ParseMismatchingTypeCommandLineValues() { var args = new[] {"--log", "info", "1337", "3.1415"}; var supportedOptions = new List<CommandLineOption> { new CommandLineOption( new[] {"-l", "--log"}, "Logs info from exactly three data sources", false, typeof(string), typeof(int), typeof(long)), }; var parser = new CommandLineParser(supportedOptions); Assert.Throws<InvalidCommandLineValueException>(() => parser.ParseArguments(args) ); } [Fact] public void ParseInsufficientCommandLineValues() { var args = new[] {"-l", "info", "info2"}; var supportedOptions = new List<CommandLineOption> { new CommandLineOption( new[] {"-l", "--log"}, "Logs info from exactly three data sources", false, typeof(string), typeof(string), typeof(string)), }; var parser = new CommandLineParser(supportedOptions); Assert.Throws<InsufficientCommandLineValuesException>(() => parser.ParseArguments(args) ); } [Fact] public void ParseInvalidCommandLineOption() { var args = new[] {"--force"}; var supportedOptions = new List<CommandLineOption> { new CommandLineOption(new[] {"-h", "--help"}, "Show the help menu"), }; var parser = new CommandLineParser(supportedOptions); Assert.Throws<InvalidCommandLineOptionException>(() => parser.ParseArguments(args) ); } [Fact] public void ParseNoCommandLineOptions() { var args = new string[] { }; var parser = new CommandLineParser(null); var result = parser.ParseArguments(args); Assert.Equal(Enumerable.Empty<CommandLineValue>(), result); } }
I appreciate all suggestions. Feel free to be very nitpicky. :)