CommandLineParser.java

package edu.udel.cis.vsl.gmc;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.io.PrintStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;

import edu.udel.cis.vsl.gmc.Option.OptionType;

/**
 * A tool for parsing a command line and generating a {@link GMCConfiguration}.
 * 
 * A parser is instantiated by specifying the set of options that it will be
 * able to parse. It can then be used repeatedly to parse a command line and
 * produce a configuration.
 * 
 * @author Stephen F. Siegel
 * 
 */
public class CommandLineParser {

	// Static fields...

	/**
	 * The Boolean value true.
	 */
	private static Boolean trueBoolean = Boolean.valueOf(true);

	/**
	 * The Boolean value false.
	 */
	private static Boolean falseBoolean = Boolean.valueOf(false);

	// Instance fields...

	/**
	 * Map of all options associated to this parser: key is name of option,
	 * value is the option.
	 */
	private SortedMap<String, Option> optionMap = new TreeMap<>();

	/**
	 * Map of all options of Map type associated to this parser. The entries of
	 * this map are a subset of those of {@link #optionMap}.
	 */
	private Map<String, Option> mapOptionMap = new LinkedHashMap<>();

	// Constructors...

	/**
	 * Constructs a new parser from the given collection of options.
	 * 
	 * @param options
	 *                    a collection of non-null options with distinct names
	 * 
	 */
	public CommandLineParser(Collection<Option> options) {
		for (Option option : options) {
			String name = option.name();

			if (optionMap.put(name, option) != null)
				throw new IllegalArgumentException(
						"Saw two options named " + name);
			if (option.type() == OptionType.MAP)
				mapOptionMap.put(name, option);
		}
	}

	// Helper methods...

	private static String unescape(String string) {
		String result = string;

		result = result.replace("\\" + "n", "\n");
		result = result.replace("\\" + "t", "\t");
		result = result.replace("\\" + " ", " ");
		result = result.replace("\\" + "\"", "\"");
		result = result.replace("\\" + "'", "'");
		result = result.replace("\\" + "\\", "\\");
		return result;
	}

	/**
	 * Interprets the string valueString to determine its type and value as a
	 * command line parameter. The rules are:
	 * 
	 * If value is null or the empty string, the parameter will be interpreted
	 * as having boolean type with value true. This conforms with examples such
	 * as <code>-verbose</code>, which usually means parameter "verbose" is a
	 * boolean flag which should be set to true.
	 * 
	 * If value is surrounded by quotes, it will be interpreted as a String,
	 * with the quotes removed. Example: <code>-rep="~/civl/examples/gcd</code>
	 * on the command line yields a key "rep" with value
	 * <code>~/civl/examples/gcd</code>, i.e., the String in between those
	 * quotes.
	 * 
	 * If value is an integer, it will be interpred as an Integer. Example:
	 * <code>-depth=100</code>
	 * 
	 * If value can be interpreted as a floating point number, it will be
	 * interpreted as a Double. Example: <code>-inputPi=3.14</code>
	 * 
	 * If value is "true" or "false", it will be interpreted as a Boolean with
	 * the corresponding value. Example: <code>-verbose=false</code>
	 * 
	 * Otherwise value will be interpreted as a String. Example:
	 * <code>-dir=/usr/local</code>
	 * 
	 * @param key
	 *                        the parameter name
	 * @param valueString
	 *                        a string which will be interpreted to yield the
	 *                        parameter value
	 * @return the value that results from interpreting the valueString
	 */
	private static Object interpretValue(String valueString) {
		if (valueString == null) {
			return trueBoolean;
		} else {
			int length = valueString.length();

			if (length == 0)
				return trueBoolean;
			if (length >= 2) {
				char firstChar = valueString.charAt(0),
						lastChar = valueString.charAt(length - 1);

				if (firstChar == '"' && lastChar == '"')
					return unescape(valueString.substring(1, length - 1));
				if (firstChar == '\'' && lastChar == '\'')
					return valueString.substring(1, length - 1);
			}
			try {
				return Integer.valueOf(valueString);
			} catch (Exception e) {
				// proceed...
			}
			try {
				return Double.valueOf(valueString);
			} catch (Exception e) {
				// proceed...
			}
			if ("true".equals(valueString))
				return trueBoolean;
			if ("false".equals(valueString))
				return falseBoolean;
			return valueString;
		}
	}

	private static Object parseValue(Option option, String valueString) {
		OptionType type = option.type();
		String name = option.name();

		switch (type) {
			case BOOLEAN :
				if (valueString == null)
					return trueBoolean;
				if ("true".equals(valueString) || "".equals(valueString))
					return trueBoolean;
				if ("false".equals(valueString))
					return falseBoolean;
				throw new IllegalArgumentException("Option " + name
						+ ": expected boolean, saw " + valueString);
			case DOUBLE :
				try {
					return Double.valueOf(valueString);
				} catch (Exception e) {
					throw new IllegalArgumentException("Option " + name
							+ ": expected double, saw " + valueString);
				}
			case INTEGER :
				try {
					return Integer.valueOf(valueString);
				} catch (Exception e) {
					throw new IllegalArgumentException("Option " + name
							+ ": expected integer, saw " + valueString);
				}
			case MAP :
				throw new IllegalArgumentException(
						"map should not be used here");
			case STRING : {
				int length = valueString.length();

				if (length >= 2) {
					char firstChar = valueString.charAt(0),
							lastChar = valueString.charAt(length - 1);

					if (firstChar == '"' && lastChar == '"')
						return unescape(valueString.substring(1, length - 1));
					if (firstChar == '\'' && lastChar == '\'')
						return valueString.substring(1, length - 1);
				}
				return valueString;
			}
			default :
				throw new RuntimeException("unreachable");
		}
	}

	/**
	 * Processes an argument for a GMC section. The argument is a regular
	 * expression: <code>'-' text ('=' text)? </code> (e.g., -inputB=9,
	 * -showModel=true) or <code>text</code> (e.g.,
	 * /Users/test/civl/examples/dinging.cvl), where <code>text</code> is a
	 * string that doesn't start with '-' and contains no space. The argument
	 * will be translated into either an option or a free argument of the given
	 * section. If an entry is specified in an argument and an entry for the
	 * same option already exists in the section, the old entry is overwritten.
	 * Similarly for map entries.
	 * 
	 * @param section
	 *                    The GMC section that the argument to be processed
	 *                    belongs to.
	 * @param arg
	 *                    The argument to be processed.
	 * @throws CommandLineException
	 *                                  when the option referenced in the given
	 *                                  argument is not defined in the given
	 *                                  section.
	 */
	private void processArg(GMCSection section, String arg)
			throws CommandLineException {
		int length = arg.length();

		if (arg.startsWith("-")) {
			int eqIndex = arg.indexOf('=');
			String optionName, valueString;
			Option option;
			Object value;

			if (eqIndex >= 0) {
				optionName = arg.substring(1, eqIndex);
				valueString = arg.substring(eqIndex + 1, length);
			} else {
				optionName = arg.substring(1);
				valueString = null;
			}
			// is it a map?
			for (String mapName : mapOptionMap.keySet()) {
				if (optionName.startsWith(mapName)) {
					String key = optionName.substring(mapName.length(),
							optionName.length());

					option = mapOptionMap.get(mapName);
					value = interpretValue(valueString);
					section.putMapEntry(option, key, value);
					return;
				}
			}
			option = optionMap.get(optionName);
			if (option == null)
				throw new CommandLineException(
						"Unknown command line option " + optionName);
			value = parseValue(option, valueString);
			section.setScalarValue(option, value);
		} else {
			section.addFreeArg(arg);
		}
	}

	// Public methods...

	/**
	 * Returns new, empty configuration with option set equal to the set of
	 * options associated to this parser.
	 * 
	 * @return new empty configuration compatible with this parser
	 */
	public GMCConfiguration newConfig() {
		return new GMCConfiguration(optionMap.values());
	}

	/**
	 * Given a collection of strings and a configuration compatible with this
	 * parser, parses the strings and uses the resulting information to modify
	 * the configuration. The strings are expected to be in the following
	 * format:
	 * 
	 * <pre>
	 * ('-' text '=' text)* text* (('--' text) ('-' text '=' text)* text*)*
	 * </pre>
	 * 
	 * where <code>text</code> is a string that doesn't start with '-' and
	 * contains no space.
	 * 
	 * @param config
	 *                   a configuration with the same option set as this parser
	 * @param args
	 *                   a collection of strings, the command line arguments
	 * @throws CommandLineException
	 *                                  if the args do not conform to what is
	 *                                  expected. What is expected is determined
	 *                                  by the set of options associated to this
	 *                                  parser.
	 */
	public void parse(GMCConfiguration config, Collection<String> args)
			throws CommandLineException {
		GMCSection section = null;
		boolean isDefault = false;

		for (String arg : args) {
			if (arg.startsWith("--")) {
				if (section != null) {
					if (isDefault) {
						config.setAnonymousSection(section);
						isDefault = false;
					} else
						config.addSection(section);
				}
				section = new GMCSection(config, arg.substring(2));
				continue;
			}
			if (section == null) {
				section = new GMCSection(config,
						GMCConfiguration.ANONYMOUS_SECTION);
				isDefault = true;
			}
			processArg(section, arg);
		}
		if (section != null)
			if (isDefault)
				config.setAnonymousSection(section);
			else
				config.addSection(section);
	}

	/**
	 * Given a collection of strings, parses them in the order of their iterator
	 * to produce a new configuration.
	 * 
	 * @param args
	 *                 a collection of strings, the command line arguments
	 * @return a new configuration obtained from the information in the args
	 * @throws CommandLineException
	 *                                  if the args do not conform to what is
	 *                                  expected. What is expected is determined
	 *                                  by the set of options associated to this
	 *                                  parser.
	 */
	public GMCConfiguration parse(Collection<String> args)
			throws CommandLineException {
		GMCConfiguration config = new GMCConfiguration(optionMap.values());

		parse(config, args);
		return config;
	}

	/**
	 * Parses the reader, interpreting lines as command line args, modifying
	 * given configuration accordingly.
	 * 
	 * Ignores all lines until it reaches a line of the form "== Begin
	 * Configuration ==". Then parses each line as if it were a command line
	 * argument. Continues until end of stream or a line of the form "== End
	 * Configuration ==" is reached.
	 * 
	 * @param config
	 *                   a configuration compatible with this parser
	 * @param reader
	 *                   a buffered reader which provides a sequence of lines as
	 *                   described above
	 * @throws IOException
	 *                                  if an I/O error occurs in reading the
	 *                                  reader
	 * @throws CommandLineException
	 *                                  if format does not conform to pattern
	 *                                  described above
	 */
	public void parse(GMCConfiguration config, BufferedReader reader)
			throws IOException, CommandLineException {
		ArrayList<String> args = new ArrayList<>();

		while (true) {
			String line = reader.readLine();

			if (line == null)
				throw new CommandLineException(
						"Did not find line with ==Begin Configuration== marker in trace file");
			line = line.trim();
			if ("== Begin Configuration ==".equals(line))
				break;
		}
		while (true) {
			String line = reader.readLine();

			if (line == null)
				break;
			line = line.trim();
			if ("== End Configuration ==".equals(line))
				break;
			args.add(line);
		}
		parse(config, args);
	}

	/**
	 * Parses a file containing a configuration section, using the command line
	 * arguments from that section to modify the given configuration.
	 * 
	 * @param config
	 *                   a configuration to be modified; it must have the same
	 *                   set of options as those associated to this parser
	 * @param file
	 *                   the file to open and parse; it must contain a
	 *                   configuration section
	 * @throws FileNotFoundException
	 *                                   if the file does not exist
	 * @throws IOException
	 *                                   if an I/O error occurs in opening,
	 *                                   reading, or closing the file
	 * @throws CommandLineException
	 *                                   if the command lines do not conform to
	 *                                   the options of this parser
	 */
	public void parse(GMCConfiguration config, File file)
			throws FileNotFoundException, IOException, CommandLineException {
		BufferedReader reader = new BufferedReader(new FileReader(file));

		parse(config, reader);
		reader.close();
	}

	/**
	 * Prints the list of options in a human-readable format. Appropriate for
	 * including in a "usage" message for the user.
	 * 
	 * @param out
	 *                print stream to which to print this information
	 */
	public void printUsage(PrintStream out) {
		for (Option option : optionMap.values()) {
			option.print(out);
		}
		out.flush();
	}

}