// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Public License (Ms-PL).
// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details.
// All other rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
namespace Microsoft.Test.CommandLineParsing
{
///
/// Provides utilities for parsing command-line values.
///
///
///
/// The following example shows parsing of a command-line such as "Test.exe /verbose /runId=10"
/// into a strongly-typed structure.
///
/// using System;
/// using System.Linq;
/// using Microsoft.Test.CommandLineParsing;
///
/// public class CommandLineArguments
/// {
/// public bool? Verbose { get; set; }
/// public int? RunId { get; set; }
/// }
///
/// public class Program
/// {
/// public static void Main(string[] args)
/// {
/// CommandLineArguments a = new CommandLineArguments();
/// a.ParseArguments(args); // or CommandLineParser.ParseArguments(a, args);
///
/// Console.WriteLine("Verbose: {0}, RunId: {1}", a.Verbose, a.RunId);
/// }
/// }
///
///
///
///
/// The following example shows parsing of a command-line such as "Test.exe RUN /verbose /runId=10"
/// into a strongly-typed Command, that can then be excuted.
///
/// using System;
/// using System.Linq;
/// using Microsoft.Test.CommandLineParsing;
///
/// public class RunCommand : Command
/// {
/// public bool? Verbose { get; set; }
/// public int? RunId { get; set; }
///
/// public override void Execute()
/// {
/// Console.WriteLine("RunCommand: Verbose={0} RunId={1}", Verbose, RunId);
/// }
/// }
///
/// public class Program
/// {
/// public static void Main(string[] args)
/// {
/// if (String.Compare(args[0], "run", StringComparison.InvariantCultureIgnoreCase) == 0)
/// {
/// Command c = new RunCommand();
/// c.ParseArguments(args.Skip(1)); // or CommandLineParser.ParseArguments(c, args.Skip(1))
/// c.Execute();
/// }
/// }
/// }
///
///
public static class CommandLineParser
{
#region Constructors
///
/// Static constructor.
///
static CommandLineParser()
{
// The parser will want to convert from value line string arguments into various
// data types on a value. Any type that doesn't have a default TypeConverter that
// can convert from string to it's type needs to have a custom TypeConverter written
// for it, and have it added here.
TypeDescriptor.AddAttributes(typeof(DirectoryInfo), new TypeConverterAttribute(typeof(DirectoryInfoConverter)));
TypeDescriptor.AddAttributes(typeof(FileInfo), new TypeConverterAttribute(typeof(FileInfoConverter)));
}
#endregion
#region Public Members
///
/// Sets properties on an object from a series of key/value string
/// arguments that are in the form "/PropertyName=Value", where the
/// value is converted from a string into the property type.
///
/// The object to set properties on.
/// The key/value arguments describing the property names and values to set.
///
/// Indicates whether the properties were successfully set. Reasons for failure reasons include
/// a property name that does not exist or a value that cannot be converted from a string.
///
/// Thrown when one of the key/value strings cannot be parsed into a property.
public static void ParseArguments(this object valueToPopulate, IEnumerable args)
{
CommandLineDictionary commandLineDictionary = CommandLineDictionary.FromArguments(args);
PropertyDescriptorCollection properties = TypeDescriptor.GetProperties(valueToPopulate);
// Ensure required properties are specified.
foreach (PropertyDescriptor property in properties)
{
// See whether any of the attributes on the property is a RequiredAttribute.
if (property.Attributes.Cast().Any(attribute => attribute is RequiredAttribute))
{
// If so, and the command line dictionary doesn't contain a key matching
// the property's name, it means that a required property isn't specified.
if (!commandLineDictionary.ContainsKey(property.Name))
{
throw new ArgumentException("A value for the " + property.Name + " property is required.");
}
}
}
foreach (KeyValuePair keyValuePair in commandLineDictionary)
{
// Find a property whose name matches the kvp's key, ignoring case.
// We can't just use the indexer because that is case-sensitive.
PropertyDescriptor property = MatchProperty(keyValuePair.Key, properties,valueToPopulate.GetType());
// If the value is null/empty and the property is a bool, we
// treat it as a flag, which means its presence means true.
if (String.IsNullOrEmpty(keyValuePair.Value) &&
(property.PropertyType == typeof(bool) || property.PropertyType == typeof(bool?)))
{
property.SetValue(valueToPopulate, true);
continue;
}
object valueToSet;
// We support a limited set of collection types. Setting a List
// is one of the most flexible types as it supports three different
// interfaces, but the catch is that we don't support the concrete
// Collection type. We can expand it to support Collection
// in the future, but the code will get a bit uglier.
switch (property.PropertyType.Name)
{
case "IEnumerable`1":
case "ICollection`1":
case "IList`1":
case "List`1":
MethodInfo methodInfo = typeof(CommandLineParser).GetMethod("FromCommaSeparatedList", BindingFlags.Static | BindingFlags.NonPublic);
Type[] genericArguments = property.PropertyType.GetGenericArguments();
MethodInfo genericMethodInfo = methodInfo.MakeGenericMethod(genericArguments);
valueToSet = genericMethodInfo.Invoke(null, new object[] { keyValuePair.Value });
break;
default:
TypeConverter typeConverter = TypeDescriptor.GetConverter(property.PropertyType);
if (typeConverter == null || !typeConverter.CanConvertFrom(typeof(string)))
{
throw new ArgumentException("Unable to convert from a string to a property of type " + property.PropertyType + ".");
}
valueToSet = typeConverter.ConvertFromInvariantString(keyValuePair.Value);
break;
}
property.SetValue(valueToPopulate, valueToSet);
}
return;
}
///
/// Match the property to the specified keyName
///
/// If match cannot be found, throw an argument exception
///
private static PropertyDescriptor MatchProperty(string keyName, PropertyDescriptorCollection properties, Type targetType)
{
foreach(PropertyDescriptor prop in properties)
{
if(prop.Name.Equals(keyName, StringComparison.OrdinalIgnoreCase))
{
return prop;
}
}
throw new ArgumentException("A matching public property of name " + keyName + " on type " + targetType + " could not be found.");
}
///
/// Prints names and descriptions for properties on the specified component.
///
/// The component to print usage for.
public static void PrintUsage(object component)
{
IEnumerable properties = TypeDescriptor.GetProperties(component).Cast();
IEnumerable propertyNames = properties.Select(property => property.Name);
IEnumerable propertyDescriptions = properties.Select(property => property.Description);
IEnumerable lines = FormatNamesAndDescriptions(propertyNames, propertyDescriptions, Console.WindowWidth);
Console.WriteLine("Possible arguments:");
foreach (string line in lines)
{
Console.WriteLine(line);
}
}
///
/// Prints a general summary of each command.
///
/// A collection of possible commands.
public static void PrintCommands(IEnumerable commands)
{
// Print out general descriptions for every command.
IEnumerable commandNames = commands.Select(command => command.Name);
IEnumerable commandDescriptions = commands.Select(command => command.GetAttribute().Description);
IEnumerable lines = FormatNamesAndDescriptions(commandNames, commandDescriptions, Console.WindowWidth);
Console.WriteLine("Possible commands:");
foreach (string line in lines)
{
Console.WriteLine(line);
}
}
///
/// Creates a string that represents key/value arguments for the properties of the
/// specified object. For example, an object with a name (string) of "example" and a
/// priority value (integer) of 1 translates to '/name=example /priority=1'. This
/// can be used to send data structures through the command line.
///
/// Value to create key/value arguments from.
/// Space-delimited key/value arguments.
public static string ToString(object valueToConvert)
{
IEnumerable properties = TypeDescriptor.GetProperties(valueToConvert).Cast();
IEnumerable propertiesOnParent = TypeDescriptor.GetProperties(valueToConvert.GetType().BaseType).Cast();
properties = properties.Except(propertiesOnParent);
CommandLineDictionary commandLineDictionary = new CommandLineDictionary();
foreach (PropertyDescriptor property in properties)
{
commandLineDictionary[property.Name] = property.GetValue(valueToConvert).ToString();
}
return commandLineDictionary.ToString();
}
#endregion
#region Private Members
///
/// Given collections of names and descriptions, returns a set of lines
/// where the description text is wrapped and left aligned. eg:
/// First Name this is a string that wraps around
/// and is left aligned.
/// Second Name this is another string.
///
/// Collection of name strings.
/// Collection of description strings.
/// Maximum length of formatted lines
/// Formatted lines of text.
private static IEnumerable FormatNamesAndDescriptions(IEnumerable names, IEnumerable descriptions, int maxLineLength)
{
if (names.Count() != descriptions.Count())
{
throw new ArgumentException("Collection sizes are not equal", "names");
}
int namesMaxLength = names.Max(commandName => commandName.Length);
List lines = new List();
for (int i = 0; i < names.Count(); i++)
{
string line = names.ElementAt(i);
line = line.PadRight(namesMaxLength + 2);
foreach (string wrappedLine in WordWrap(descriptions.ElementAt(i), maxLineLength - namesMaxLength - 3))
{
line += wrappedLine;
lines.Add(line);
line = new string(' ', namesMaxLength + 2);
}
}
return lines;
}
///
/// Convert a comma separated list to a List of T. There must be a
/// TypeConverter for the collection type that can convert from a string.
/// "1,2,3" => List(int) containing 1, 2, and 3.
/// Commas in the textual representation itself should be escaped with
/// a blackslash, as should backslash itself.
///
/// Type of objects in the collection.
/// Comma separated list representation.
/// Collection of objects.
private static List FromCommaSeparatedList(this string commaSeparatedList)
{
List collection = new List();
TypeConverter typeConverter = TypeDescriptor.GetConverter(typeof(T));
if (typeConverter.CanConvertFrom(typeof(string)))
{
StringBuilder builder = new StringBuilder();
bool isEscaped = false;
foreach (char character in commaSeparatedList)
{
// If we are in escaped mode, add the character and exit escape mode
if (isEscaped)
{
builder.Append(character);
isEscaped = false;
}
// If we see the backslash and are not in escaped mode, go into escaped mode
else if (character == '\\' && !isEscaped)
{
isEscaped = true;
}
// A comma outside of escaped mode is an item separator, convert
// built string to T and add to collection, then zero out the builder
else if (character == ',' && !isEscaped)
{
collection.Add((T)typeConverter.ConvertFromInvariantString(builder.ToString()));
builder.Length = 0;
}
// Otherwise simply add the character
else
{
builder.Append(character);
}
}
// If builder.Length is non-zero, of course we want to add it.
// If, however, it is zero, it can mean one of two things:
// - There are no items at all, i.e. the commaSeparatedList string
// is null/empty, and we should return an empty collection.
// - The builder just got flushed by a comma, and there is one last
// item in the collection to add that should be typeconverted
// from an empty string.
// collection.Count is always 0 for the former and greater than 0
// for the later, so we will also add if Count > 0.
if (builder.Length > 0 || collection.Count > 0)
{
collection.Add((T)typeConverter.ConvertFromInvariantString(builder.ToString()));
}
}
return collection;
}
///
/// Gets an attribute on the specified object instance.
///
/// Type of attribute to get.
/// Object instance to look for attribute on.
/// First instance of the specified attribute.
private static T GetAttribute(this object value) where T : Attribute
{
IEnumerable attributes = TypeDescriptor.GetAttributes(value).Cast();
return (T)attributes.First(attribute => attribute is T);
}
///
/// Word wrap text for a specified maximum line length.
///
/// Text to word wrap.
/// Maximum length of a line.
/// Collection of lines for the word wrapped text.
private static IEnumerable WordWrap(string text, int maxLineLength)
{
List lines = new List();
string currentLine = String.Empty;
foreach (string word in text.Split(' '))
{
// Whenever adding the word would push us over the maximum
// width, add the current line to the lines collection and
// begin a new line. The new line starts with space padding
// it to be left aligned with the previous line of text from
// this column.
if (currentLine.Length + word.Length > maxLineLength)
{
lines.Add(currentLine);
currentLine = String.Empty;
}
currentLine += word;
// Add spaces between words except for when we are at exactly the
// maximum width.
if (currentLine.Length != maxLineLength)
{
currentLine += " ";
}
}
// Add the remainder of the current line except for when it is
// empty, which is true in the case when we had just started a
// new line.
if (currentLine.Trim() != String.Empty)
{
lines.Add(currentLine);
}
return lines;
}
#endregion
}
}