// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.tools; import static org.openstreetmap.josm.tools.I18n.tr; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map.Entry; import java.util.Objects; import java.util.function.Consumer; import java.util.stream.Collectors; /** * A replacement of getopt. *

* Allows parsing command line options * * @author Michael Zangl * @since 14415 */ public class OptionParser { private final HashMap availableOptions = new HashMap<>(); private final String program; /** * Create a new option parser. * @param program The program name. */ public OptionParser(String program) { Objects.requireNonNull(program, "program name must be provided"); this.program = program; } /** * Adds an alias for the long option --optionName to the short version -name * @param optionName The long option * @param shortName The short version * @return this {@link OptionParser} */ public OptionParser addShortAlias(String optionName, String shortName) { if (!shortName.matches("\\w")) { throw new IllegalArgumentException("Short name '" + shortName + "' must be one character"); } if (availableOptions.containsKey("-" + shortName)) { throw new IllegalArgumentException("Short name '" + shortName + "' is already used"); } AvailableOption longDefinition = availableOptions.get("--" + optionName); if (longDefinition == null) { throw new IllegalArgumentException("No long definition for " + optionName + " was defined. Define the long definition first before creating " + "a short definition for it."); } availableOptions.put("-" + shortName, longDefinition); return this; } /** * Adds an option that may be used as a flag, e.g. --debug * @param optionName The parameter name * @param handler The handler that is called when the flag is encountered. * @return this {@link OptionParser} */ public OptionParser addFlagParameter(String optionName, Runnable handler) { checkOptionName(optionName); availableOptions.put("--" + optionName, parameter -> handler.run()); return this; } private void checkOptionName(String optionName) { if (!optionName.matches("\\w([\\w-]*\\w)?")) { throw new IllegalArgumentException("Illegal option name: '" + optionName + "'"); } if (availableOptions.containsKey("--" + optionName)) { throw new IllegalArgumentException("The option '--" + optionName + "' is already registered"); } } /** * Add a parameter that expects a string attribute. E.g.: --config=/path/to/file * @param optionName The name of the parameter. * @param count The number of times the parameter may occur. * @param handler A function that gets the current object and the parameter. * It should throw an {@link OptionParseException} if the parameter cannot be handled / is invalid. * @return this {@link OptionParser} */ public OptionParser addArgumentParameter(String optionName, OptionCount count, Consumer handler) { checkOptionName(optionName); availableOptions.put("--" + optionName, new AvailableOption() { @Override public boolean requiresParameter() { return true; } @Override public OptionCount getRequiredCount() { return count; } @Override public void runFor(String parameter) { Objects.requireNonNull(parameter, "parameter"); handler.accept(parameter); } }); return this; } /** * Same as {@link #parseOptions(List)}, but exits if option parsing fails. * @param arguments The options * @return The remaining program arguments that are no options. */ public List parseOptionsOrExit(List arguments) { try { return parseOptions(arguments); } catch (OptionParseException e) { System.err.println(e.getLocalizedMessage()); System.exit(1); // unreachable, but makes compilers happy throw e; } } /** * Parses the options. *

* It first checks if all required options are present, if all options are known and validates the option count. *

* Then, all option handlers are called in the order in which the options are encountered. * @param arguments Program arguments * @return The remaining program arguments that are no options. * @throws OptionParseException The error to display if option parsing failed. */ public List parseOptions(List arguments) { LinkedList toHandle = new LinkedList<>(arguments); List remainingArguments = new LinkedList<>(); boolean argumentOnlyMode = false; List options = new LinkedList<>(); while (!toHandle.isEmpty()) { String next = toHandle.removeFirst(); if (argumentOnlyMode || !next.matches("-.+")) { // argument found, add it to arguments list remainingArguments.add(next); } else if ("--".equals(next)) { // we are done, the remaining should be used as arguments. argumentOnlyMode = true; } else { if (next.matches("-\\w\\w+")) { // special case: concatenated short options like -hv // We handle them as if the user wrote -h -v by just scheduling the remainder for the next loop. toHandle.addFirst("-" + next.substring(2)); next = next.substring(0, 2); } String[] split = next.split("=", 2); String optionName = split[0]; AvailableOption definition = findParameter(optionName); String parameter = null; if (definition.requiresParameter()) { if (split.length > 1) { parameter = split[1]; } else { if (toHandle.isEmpty() || "--".equals(toHandle.getFirst())) { throw new OptionParseException(tr("{0}: option ''{1}'' requires an argument", program, optionName)); } parameter = toHandle.removeFirst(); } } else if (split.length > 1) { throw new OptionParseException( tr("{0}: option ''{1}'' does not allow an argument", program, optionName)); } options.add(new FoundOption(optionName, definition, parameter)); } } // Count how often they are used availableOptions.values().stream().distinct().forEach(def -> { long count = options.stream().filter(p -> def.equals(p.option)).count(); String optionName = availableOptions.entrySet().stream() .filter(entry -> def.equals(entry.getValue())) .map(Entry::getKey) .findFirst() .orElse("?"); if (count < def.getRequiredCount().min) { // min may be 0 or 1 at the moment throw new OptionParseException(tr("{0}: option ''{1}'' is required", program, optionName)); } else if (count > def.getRequiredCount().max) { // max may be 1 or MAX_INT at the moment throw new OptionParseException(tr("{0}: option ''{1}'' may not appear multiple times", program, optionName)); } }); // Actually apply the parameters. for (FoundOption option : options) { try { option.option.runFor(option.parameter); } catch (OptionParseException e) { StringBuilder message = new StringBuilder(); // Just add a nicer error message if (option.parameter == null) { message.append(tr("{0}: Error while handling option ''{1}''", program, option.optionName)); } else { message.append(tr("{0}: Invalid value {2} for option ''{1}''", program, option.optionName, option.parameter)); } if (!e.getLocalizedMessage().isEmpty()) { message.append(": ").append(e.getLocalizedMessage().isEmpty()); } throw new OptionParseException(message.toString(), e); } } return remainingArguments; } private AvailableOption findParameter(String optionName) { AvailableOption exactMatch = availableOptions.get(optionName); if (exactMatch != null) { return exactMatch; } else if (optionName.startsWith("--")) { List alternatives = availableOptions.entrySet().stream() .filter(entry -> entry.getKey().startsWith(optionName)).map(Entry::getValue).distinct() .collect(Collectors.toList()); if (alternatives.size() == 1) { return alternatives.get(0); } else if (alternatives.size() > 1) { throw new OptionParseException(tr("{0}: option ''{1}'' is ambiguous", program, optionName)); } } throw new OptionParseException(tr("{0}: unrecognized option ''{1}''", program, optionName)); } /** * How often an option may / must be specified on the command line. * @author Michael Zangl */ public enum OptionCount { /** * The option may be specified once */ OPTIONAL(0, 1), /** * The option is required exactly once */ REQUIRED(1, 1), /** * The option may be specified multiple times */ MULTIPLE(0, Integer.MAX_VALUE); private final int min; private final int max; OptionCount(int min, int max) { this.min = min; this.max = max; } } protected interface AvailableOption { /** * Determines if this option requires a parameter. * @return {@code true} if this option requires a parameter ({@code false} by default) */ default boolean requiresParameter() { return false; } /** * Determines how often this option may / must be specified on the command line. * @return how often this option may / must be specified on the command line */ default OptionCount getRequiredCount() { return OptionCount.OPTIONAL; } /** * Called once if the parameter is encountered, afer basic validation. * @param parameter The parameter if {@link #requiresParameter()} is true, null otherwise. */ void runFor(String parameter); } private static class FoundOption { private final String optionName; private final AvailableOption option; private final String parameter; FoundOption(String optionName, AvailableOption option, String parameter) { this.optionName = optionName; this.option = option; this.parameter = parameter; } } /** * Exception thrown when an option cannot be parsed. * @author Michael Zangl */ public static class OptionParseException extends RuntimeException { // Don't rely on JAVA handling this correctly. private final String localizedMessage; /** * Create an empty error with no description */ public OptionParseException() { super(); localizedMessage = ""; } /** * Create an error with a localized description * @param localizedMessage The message to display to the user. */ public OptionParseException(String localizedMessage) { super(localizedMessage); this.localizedMessage = localizedMessage; } /** * Create an error with a localized description and a root cause * @param localizedMessage The message to display to the user. * @param t The error that caused this message to be displayed. */ public OptionParseException(String localizedMessage, Throwable t) { super(localizedMessage, t); this.localizedMessage = localizedMessage; } @Override public String getLocalizedMessage() { return localizedMessage; } } }