001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.tools; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.util.HashMap; 007import java.util.LinkedList; 008import java.util.List; 009import java.util.Map.Entry; 010import java.util.Objects; 011import java.util.function.Consumer; 012import java.util.stream.Collectors; 013 014/** 015 * A replacement of getopt. 016 * <p> 017 * Allows parsing command line options 018 * 019 * @author Michael Zangl 020 * @since 14415 021 */ 022public class OptionParser { 023 024 private final HashMap<String, AvailableOption> availableOptions = new HashMap<>(); 025 private final String program; 026 027 /** 028 * Create a new option parser. 029 * @param program The program name. 030 */ 031 public OptionParser(String program) { 032 Objects.requireNonNull(program, "program name must be provided"); 033 this.program = program; 034 } 035 036 /** 037 * Adds an alias for the long option --optionName to the short version -name 038 * @param optionName The long option 039 * @param shortName The short version 040 * @return this {@link OptionParser} 041 */ 042 public OptionParser addShortAlias(String optionName, String shortName) { 043 if (!shortName.matches("\\w")) { 044 throw new IllegalArgumentException("Short name '" + shortName + "' must be one character"); 045 } 046 if (availableOptions.containsKey("-" + shortName)) { 047 throw new IllegalArgumentException("Short name '" + shortName + "' is already used"); 048 } 049 AvailableOption longDefinition = availableOptions.get("--" + optionName); 050 if (longDefinition == null) { 051 throw new IllegalArgumentException("No long definition for " + optionName 052 + " was defined. Define the long definition first before creating " + "a short definition for it."); 053 } 054 availableOptions.put("-" + shortName, longDefinition); 055 return this; 056 } 057 058 /** 059 * Adds an option that may be used as a flag, e.g. --debug 060 * @param optionName The parameter name 061 * @param handler The handler that is called when the flag is encountered. 062 * @return this {@link OptionParser} 063 */ 064 public OptionParser addFlagParameter(String optionName, Runnable handler) { 065 checkOptionName(optionName); 066 availableOptions.put("--" + optionName, parameter -> handler.run()); 067 return this; 068 } 069 070 private void checkOptionName(String optionName) { 071 if (!optionName.matches("\\w([\\w-]*\\w)?")) { 072 throw new IllegalArgumentException("Illegal option name: '" + optionName + "'"); 073 } 074 if (availableOptions.containsKey("--" + optionName)) { 075 throw new IllegalArgumentException("The option '--" + optionName + "' is already registered"); 076 } 077 } 078 079 /** 080 * Add a parameter that expects a string attribute. E.g.: --config=/path/to/file 081 * @param optionName The name of the parameter. 082 * @param count The number of times the parameter may occur. 083 * @param handler A function that gets the current object and the parameter. 084 * It should throw an {@link OptionParseException} if the parameter cannot be handled / is invalid. 085 * @return this {@link OptionParser} 086 */ 087 public OptionParser addArgumentParameter(String optionName, OptionCount count, Consumer<String> handler) { 088 checkOptionName(optionName); 089 availableOptions.put("--" + optionName, new AvailableOption() { 090 @Override 091 public boolean requiresParameter() { 092 return true; 093 } 094 095 @Override 096 public OptionCount getRequiredCount() { 097 return count; 098 } 099 100 @Override 101 public void runFor(String parameter) { 102 Objects.requireNonNull(parameter, "parameter"); 103 handler.accept(parameter); 104 } 105 }); 106 return this; 107 } 108 109 /** 110 * Same as {@link #parseOptions(List)}, but exits if option parsing fails. 111 * @param arguments The options 112 * @return The remaining program arguments that are no options. 113 */ 114 public List<String> parseOptionsOrExit(List<String> arguments) { 115 try { 116 return parseOptions(arguments); 117 } catch (OptionParseException e) { 118 System.err.println(e.getLocalizedMessage()); 119 System.exit(1); 120 // unreachable, but makes compilers happy 121 throw e; 122 } 123 } 124 125 /** 126 * Parses the options. 127 * <p> 128 * It first checks if all required options are present, if all options are known and validates the option count. 129 * <p> 130 * Then, all option handlers are called in the order in which the options are encountered. 131 * @param arguments Program arguments 132 * @return The remaining program arguments that are no options. 133 * @throws OptionParseException The error to display if option parsing failed. 134 */ 135 public List<String> parseOptions(List<String> arguments) { 136 LinkedList<String> toHandle = new LinkedList<>(arguments); 137 List<String> remainingArguments = new LinkedList<>(); 138 boolean argumentOnlyMode = false; 139 List<FoundOption> options = new LinkedList<>(); 140 141 while (!toHandle.isEmpty()) { 142 String next = toHandle.removeFirst(); 143 if (argumentOnlyMode || !next.matches("-.+")) { 144 // argument found, add it to arguments list 145 remainingArguments.add(next); 146 } else if ("--".equals(next)) { 147 // we are done, the remaining should be used as arguments. 148 argumentOnlyMode = true; 149 } else { 150 if (next.matches("-\\w\\w+")) { 151 // special case: concatenated short options like -hv 152 // We handle them as if the user wrote -h -v by just scheduling the remainder for the next loop. 153 toHandle.addFirst("-" + next.substring(2)); 154 next = next.substring(0, 2); 155 } 156 157 String[] split = next.split("=", 2); 158 String optionName = split[0]; 159 AvailableOption definition = findParameter(optionName); 160 String parameter = null; 161 if (definition.requiresParameter()) { 162 if (split.length > 1) { 163 parameter = split[1]; 164 } else { 165 if (toHandle.isEmpty() || "--".equals(toHandle.getFirst())) { 166 throw new OptionParseException(tr("{0}: option ''{1}'' requires an argument", program, optionName)); 167 } 168 parameter = toHandle.removeFirst(); 169 } 170 } else if (split.length > 1) { 171 throw new OptionParseException( 172 tr("{0}: option ''{1}'' does not allow an argument", program, optionName)); 173 } 174 options.add(new FoundOption(optionName, definition, parameter)); 175 } 176 } 177 178 // Count how often they are used 179 availableOptions.values().stream().distinct().forEach(def -> { 180 long count = options.stream().filter(p -> def.equals(p.option)).count(); 181 String optionName = availableOptions.entrySet().stream() 182 .filter(entry -> def.equals(entry.getValue())) 183 .map(Entry::getKey) 184 .findFirst() 185 .orElse("?"); 186 if (count < def.getRequiredCount().min) { 187 // min may be 0 or 1 at the moment 188 throw new OptionParseException(tr("{0}: option ''{1}'' is required", program, optionName)); 189 } else if (count > def.getRequiredCount().max) { 190 // max may be 1 or MAX_INT at the moment 191 throw new OptionParseException(tr("{0}: option ''{1}'' may not appear multiple times", program, optionName)); 192 } 193 }); 194 195 // Actually apply the parameters. 196 for (FoundOption option : options) { 197 try { 198 option.option.runFor(option.parameter); 199 } catch (OptionParseException e) { 200 StringBuilder message = new StringBuilder(); 201 // Just add a nicer error message 202 if (option.parameter == null) { 203 message.append(tr("{0}: Error while handling option ''{1}''", program, option.optionName)); 204 } else { 205 message.append(tr("{0}: Invalid value {2} for option ''{1}''", program, option.optionName, 206 option.parameter)); 207 } 208 if (!e.getLocalizedMessage().isEmpty()) { 209 message.append(": ").append(e.getLocalizedMessage().isEmpty()); 210 } 211 throw new OptionParseException(message.toString(), e); 212 } 213 } 214 return remainingArguments; 215 } 216 217 private AvailableOption findParameter(String optionName) { 218 AvailableOption exactMatch = availableOptions.get(optionName); 219 if (exactMatch != null) { 220 return exactMatch; 221 } else if (optionName.startsWith("--")) { 222 List<AvailableOption> alternatives = availableOptions.entrySet().stream() 223 .filter(entry -> entry.getKey().startsWith(optionName)).map(Entry::getValue).distinct() 224 .collect(Collectors.toList()); 225 226 if (alternatives.size() == 1) { 227 return alternatives.get(0); 228 } else if (alternatives.size() > 1) { 229 throw new OptionParseException(tr("{0}: option ''{1}'' is ambiguous", program, optionName)); 230 } 231 } 232 throw new OptionParseException(tr("{0}: unrecognized option ''{1}''", program, optionName)); 233 } 234 235 /** 236 * How often an option may / must be specified on the command line. 237 * @author Michael Zangl 238 */ 239 public enum OptionCount { 240 /** 241 * The option may be specified once 242 */ 243 OPTIONAL(0, 1), 244 /** 245 * The option is required exactly once 246 */ 247 REQUIRED(1, 1), 248 /** 249 * The option may be specified multiple times 250 */ 251 MULTIPLE(0, Integer.MAX_VALUE); 252 253 private final int min; 254 private final int max; 255 256 OptionCount(int min, int max) { 257 this.min = min; 258 this.max = max; 259 260 } 261 } 262 263 protected interface AvailableOption { 264 265 /** 266 * Determines if this option requires a parameter. 267 * @return {@code true} if this option requires a parameter ({@code false} by default) 268 */ 269 default boolean requiresParameter() { 270 return false; 271 } 272 273 /** 274 * Determines how often this option may / must be specified on the command line. 275 * @return how often this option may / must be specified on the command line 276 */ 277 default OptionCount getRequiredCount() { 278 return OptionCount.OPTIONAL; 279 } 280 281 /** 282 * Called once if the parameter is encountered, afer basic validation. 283 * @param parameter The parameter if {@link #requiresParameter()} is true, <code>null</code> otherwise. 284 */ 285 void runFor(String parameter); 286 } 287 288 private static class FoundOption { 289 private final String optionName; 290 private final AvailableOption option; 291 private final String parameter; 292 293 FoundOption(String optionName, AvailableOption option, String parameter) { 294 this.optionName = optionName; 295 this.option = option; 296 this.parameter = parameter; 297 } 298 } 299 300 /** 301 * Exception thrown when an option cannot be parsed. 302 * @author Michael Zangl 303 */ 304 public static class OptionParseException extends RuntimeException { 305 // Don't rely on JAVA handling this correctly. 306 private final String localizedMessage; 307 308 /** 309 * Create an empty error with no description 310 */ 311 public OptionParseException() { 312 super(); 313 localizedMessage = ""; 314 } 315 316 /** 317 * Create an error with a localized description 318 * @param localizedMessage The message to display to the user. 319 */ 320 public OptionParseException(String localizedMessage) { 321 super(localizedMessage); 322 this.localizedMessage = localizedMessage; 323 } 324 325 /** 326 * Create an error with a localized description and a root cause 327 * @param localizedMessage The message to display to the user. 328 * @param t The error that caused this message to be displayed. 329 */ 330 public OptionParseException(String localizedMessage, Throwable t) { 331 super(localizedMessage, t); 332 this.localizedMessage = localizedMessage; 333 } 334 335 @Override 336 public String getLocalizedMessage() { 337 return localizedMessage; 338 } 339 } 340}