using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Reflection; using HeuristicLab.CommandLineInterface.Data; using HeuristicLab.CommandLineInterface.Exceptions; using HeuristicLab.Common; namespace HeuristicLab.CommandLineInterface { /// /// The main CLIApplication class. /// Contains all methods for parsing the main method arguments and map them to types. /// public static class CLIApplication { /// /// The current application name. Gets set with the ApplicationAttribute. /// public static string AppName { get; private set; } /// /// The current application version. Get set with the ApplicationAttribute. /// public static Version AppVersion { get; private set; } #region Properties private static IList Errors { get; set; } = new List(); private static IList ExecutionOrder { get; set; } = new List(); #endregion /// /// Method to parse the given argument. The type parameter needs to implement the /// ICommand interface and has to be marked with the ApplicationAttribute. /// /// /// Needs to implement the ICommand interface and has to be /// marked with the ApplicationAttribute. /// /// The arguments from the main method. public static void Parse(string[] args) { // extract information from attributes and set the root command CommandData cmdData = Init(); ExecutionOrder.Add(cmdData); int valueIndex = 0; for (int i = 0; i < args.Length; ++i) { // check if help is request, -> when args[i] is '--help' if (IsHelpRequest(args[i])) CLIConsole.PrintHelp(cmdData); // else check if arg is valid option, then set option else if (IsValidOption(args[i], cmdData, out OptionData option)) SetOption(cmdData, option, (i + 1 < args.Length) ? args[++i] : ""); // else check if arg is valid command, then jump to next command else if (IsValidCommand(args[i], cmdData, out CommandData next)) { CheckRequirements(cmdData); valueIndex = 0; // reset value index for new command ExecutionOrder.Add(next); cmdData = next; } // else check if arg is valid value, then set value in property else if (IsValidValue(valueIndex, args[i], cmdData, out ValueData value)) { valueIndex++; SetValue(cmdData, value, args[i]); } // else add error to errorList else Errors.Add(new ArgumentException($"Argument '{args[i]}' is not valid!")); } CheckRequirements(cmdData); if (Errors.Count > 0) CLIConsole.PrintHelp(cmdData, new AggregateException(Errors)); CallExecutionOrder(); } #region Parser steps and attribute extraction /// /// Extracts application specific data (from ApplicationAttribute) and calls /// RegisterCommand for the application command (root command). /// /// The Command which is marked with the ApplicatonAttribute. private static CommandData Init() { Type rootCommand = typeof(T); if (!IsInstancableCommand(rootCommand)) throw new ArgumentException("Root type has to derive from ICommand", "rootCommand"); ApplicationAttribute app = rootCommand.GetCustomAttribute(true); if (app == null) throw new ArgumentException("Root type needs ApplicationAttribute", "rootCommand"); AppName = app.Identifier.ToLower(); AppVersion = new Version(app.Version); return RegisterCommand(rootCommand); } /// /// Registers a command and manages data extraction from attributes. /// /// RuntimeType of the command to register. /// A CommandData instance, which contains all information for that command. private static CommandData RegisterCommand(Type cmdType) { CommandData cmdData = ExtractCommandData(cmdType); foreach (var prop in cmdType.GetProperties(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance)) { ExtractOptionData(cmdData, prop); ExtractValueData(cmdData, prop); } ValidateValueIndexOrder(cmdData); return cmdData; } private static void SetOption(CommandData cmdData, OptionData option, string nextArg) { if (option.Property.PropertyType == typeof(bool)) SetPropertyValue(option.Property, cmdData, bool.TrueString); else SetPropertyValue(option.Property, cmdData, nextArg); option.Required = false; // set to false, because it got already set } private static void SetValue(CommandData cmdData, ValueData valueData, string nextArg) { valueData.Required = false; // set to false to check later, that every values was set SetPropertyValue(valueData.Property, cmdData, nextArg); } /// /// Checks if all required options and values are set. /// /// Command to check. private static void CheckRequirements(CommandData cmdData) { foreach (var x in cmdData.Options) { // because it get set to false, it is possible to see if all required options was specified if (x.Required) { Errors.Add(new TargetParameterCountException("Not all required options has been specified!")); break; } } foreach (var x in cmdData.Values) { // because it get set to false, it is possible to see if all required values was specified if (x.Required) { Errors.Add(new TargetParameterCountException("Not all required values has been specified!")); break; } } if (Errors.Count > 0) CLIConsole.PrintHelp(cmdData, new AggregateException(Errors)); } /// /// Calls all commands in top down order (this means, root command first then a first level child, second level child and so on..). /// private static void CallExecutionOrder() { foreach (CommandData cmd in ExecutionOrder) { try { GetInstance(cmd).Execute(); } catch (Exception e) { CLIConsole.PrintHelp(cmd, e); } } } #endregion #region Argument Validation /// /// Returns true when the given argument can be identified as help option (--help). /// private static bool IsHelpRequest(string arg) => arg.Equals("--help", StringComparison.InvariantCultureIgnoreCase); /// /// Returns true when the given argument is a valid option for the given command. /// private static bool IsValidOption(string arg, CommandData cmdData, out OptionData option) { foreach (var opt in cmdData.Options) { if ((OptionData.LongformPrefix + opt.Identifier).Equals(arg, StringComparison.InvariantCultureIgnoreCase) || (OptionData.ShortcutPrefix + opt.Shortcut).Equals(arg, StringComparison.InvariantCultureIgnoreCase)) { option = opt; return true; } } option = null; return false; } /// /// Returns true when the given argument is a valid sub-command for the given command. /// private static bool IsValidCommand(string arg, CommandData cmdData, out CommandData next) { foreach (var cmd in cmdData.Commands) { if (cmd.Identifier.Equals(arg, StringComparison.InvariantCultureIgnoreCase)) { next = cmd; return true; } } next = null; return false; } /// /// Returns true when the given arguments is a valid value for the given command. /// private static bool IsValidValue(int index, string arg, CommandData cmdData, out ValueData value) { bool b = index < cmdData.Values.Count; value = null; if (b) { foreach (var x in cmdData.Values) if (x.Index == index) value = x; b = value == null ? false : IsParsableString(arg, value.Property.PropertyType); } return b; } #endregion #region Helper /// /// Instanciates a command or returns a already existing instance. /// private static ICommand GetInstance(CommandData cmdData) { if (cmdData.Instance == null) cmdData.Instance = (ICommand)Activator.CreateInstance(cmdData.CommandType); return cmdData.Instance; } private static void SetPropertyValue(PropertyInfo property, CommandData cmdData, string nextArg) { try { if (IsValidIEnumerableType(property.PropertyType)) { // get the generic type of the IEnumerable, or type of string if its a IEnumerable Type targetType = property.PropertyType.IsGenericType ? property.PropertyType.GenericTypeArguments[0] : typeof(string); // build a generic List with the target type IList list = (IList)CreateGenericType(typeof(List<>), targetType); string[] elements = nextArg.Split(','); foreach (var elem in elements) list.Add(ParseString(elem, targetType)); property.SetValue(GetInstance(cmdData), list); } else property.SetValue(GetInstance(cmdData), ParseString(nextArg, property.PropertyType)); } catch (FormatException) { Errors.Add(new FormatException($"Arguments '{nextArg}' does not match type {property.PropertyType.GetPrettyName()}")); } } /// /// Returns true if the specified type is a valid enumerable type (IEnumerable, IEnumerable<>, ICollection<>, IList<>). /// private static bool IsValidIEnumerableType(Type t) => t == typeof(IEnumerable) || ( t.IsGenericType && ( t == typeof(IEnumerable<>).MakeGenericType(t.GenericTypeArguments) || t == typeof(ICollection<>).MakeGenericType(t.GenericTypeArguments) || t == typeof(IList<>).MakeGenericType(t.GenericTypeArguments) ) ) && t != typeof(string); private static object CreateGenericType(Type baseType, params Type[] genericTypeArguments) => Activator.CreateInstance(baseType.MakeGenericType(genericTypeArguments)); private static object ParseString(string str, Type targetType) { if (targetType == typeof(bool)) return bool.Parse(str); else if (targetType == typeof(string)) return str; else if (targetType == typeof(short)) return short.Parse(str); else if (targetType == typeof(int)) return int.Parse(str); else if (targetType == typeof(long)) return long.Parse(str); else if (targetType == typeof(float)) return float.Parse(str, NumberStyles.Any, CultureInfo.InvariantCulture); else if (targetType == typeof(double)) return double.Parse(str, NumberStyles.Any, CultureInfo.InvariantCulture); else if (targetType.IsEnum) return Enum.Parse(targetType, str, true); else throw new NotSupportedException($"Type {targetType.Name} is not supported!"); } private static bool IsParsableString(string str, Type targetType) { try { ParseString(str, targetType); return true; } catch (Exception) { return IsValidIEnumerableType(targetType); } } private static CommandData ExtractCommandData(Type cmdType) { if (!IsInstancableCommand(cmdType)) throw new ArgumentException($"Type {cmdType.Name} is not instantiable! Make sure that a parameterless constructor exists."); CommandData cmdData = new CommandData(); CommandAttribute cmdAttribute = cmdType.GetCustomAttribute(true); if (cmdAttribute != null) { cmdData.Identifier = (string.IsNullOrEmpty(cmdAttribute.Identifier) ? cmdType.Name.Replace("Command", "") : cmdAttribute.Identifier).ToLower(); cmdData.Description = cmdAttribute.Description; cmdData.CommandType = cmdType; foreach (Type t in cmdAttribute.SubCommands) { CommandData child = RegisterCommand(t); child.Parent = cmdData; cmdData.Commands.Add(child); } } return cmdData; } private static void ExtractOptionData(CommandData cmdData, PropertyInfo property) { OptionAttribute optAttr = (OptionAttribute)property.GetCustomAttributes(typeof(OptionAttribute), true).FirstOrDefault(); if (optAttr != null) { cmdData.Options.Add(new OptionData() { Identifier = property.Name.ToLower(), Description = optAttr.Description, Shortcut = optAttr.Shortcut, Required = optAttr.Required, Hidden = !property.GetMethod.IsPublic && !property.SetMethod.IsPublic, Property = property }); } } private static void ExtractValueData(CommandData cmdData, PropertyInfo property) { ValueAttribute valAttr = (ValueAttribute)property.GetCustomAttributes(typeof(ValueAttribute), true).FirstOrDefault(); if (valAttr != null) { cmdData.Values.Add(new ValueData() { Description = valAttr.Description, Property = property, Index = valAttr.Index }); } } /// /// Returns true when a type is a valid and instancable command. /// Valid = it implements ICommand and has a parameterless constructor. /// private static bool IsInstancableCommand(Type type) => type.GetInterface(nameof(ICommand)) != null && !type.IsAbstract && !type.IsInterface && type.GetConstructors().Any(info => info.GetParameters().Length == 0); private static void ValidateValueIndexOrder(CommandData cmdData) { for (int i = 0; i < cmdData.Values.Count; ++i) { bool b = false; foreach (var val in cmdData.Values) b = b || i == val.Index; if (!b) throw new InvalidValueIndexException("Invalid order of value indexes."); } } #endregion } }