1 package org.djutils.cli; 2 3 import java.lang.reflect.Field; 4 import java.util.LinkedHashMap; 5 import java.util.List; 6 import java.util.Locale; 7 import java.util.Map; 8 9 import org.djutils.reflection.ClassUtil; 10 11 import picocli.CommandLine; 12 import picocli.CommandLine.Command; 13 import picocli.CommandLine.Help; 14 import picocli.CommandLine.IHelpSectionRenderer; 15 import picocli.CommandLine.IVersionProvider; 16 import picocli.CommandLine.Model.ArgSpec; 17 import picocli.CommandLine.Option; 18 import picocli.CommandLine.ParseResult; 19 import picocli.CommandLine.Unmatched; 20 21 /** 22 * CliUtil offers a helper method to display --help and --version without starting the program. The method is used as follows: 23 * 24 * <pre> 25 * public static void main(final String[] args) throws Exception 26 * { 27 * Program program = new Program(); // initialize the Checkable class with the @Option information 28 * CliUtil.execute(program, args); // register Unit converters, parse the command line, catch --help, --version and error 29 * // do rest of what the main method should do 30 * } 31 * </pre> 32 * 33 * When the program is Checkable, the <code>check()</code> method is called after the arguments have been parsed. Here, further 34 * checks on the arguments (i.e., range checks) can be carried out. Potentially, check() can also provide other initialization 35 * of the program to be executed, but this can better be provided by other methods in main() . Make sure that expensive 36 * initialization is <b>not</b> carried out in the constructor of the program class that is given to the execute method. 37 * Alternatively, move the command line options to a separate class, e.g. called Options and initialize that class rather than 38 * the real program class. The real program can then take the values of the program from the Options class. An example: 39 * 40 * <pre> 41 * public class Program 42 * { 43 * @Command(description = "Test program for CLI", name = "Program", mixinStandardHelpOptions = true, version = "1.0") 44 * public static class Options implements Checkable 45 * { 46 * @Option(names = {"-p", "--port"}, description = "Internet port to use", defaultValue = "80") 47 * private int port; 48 * 49 * public int getPort() 50 * { 51 * return this.port; 52 * } 53 * 54 * @Override 55 * public void check() throws Exception 56 * { 57 * if (this.port <= 0 || this.port > 65535) 58 * throw new Exception("Port should be between 1 and 65535"); 59 * } 60 * } 61 * 62 * public Program() 63 * { 64 * // initialization for the program; avoid really starting things 65 * } 66 * 67 * public static void main(final String[] args) 68 * { 69 * Options options = new Options(); 70 * CliUtil.execute(options, args); 71 * System.out.println("port = " + options.getPort()); 72 * // you can now call methods on the program, e.g. for real initialization using the CLI parameters in options 73 * } 74 * } 75 * </pre> 76 * 77 * <br> 78 * Copyright (c) 2019-2024 Delft University of Technology, Jaffalaan 5, 2628 BX Delft, the Netherlands. All rights reserved. See 79 * for project information <a href="https://www.simulation.tudelft.nl/" target="_blank">www.simulation.tudelft.nl</a>. The 80 * source code and binary code of this software is proprietary information of Delft University of Technology. 81 * @author <a href="https://www.tudelft.nl/averbraeck" target="_blank">Alexander Verbraeck</a> 82 */ 83 public final class CliUtil 84 { 85 /** Mixin class to provide a --locale option, and a default locale. */ 86 static class InitLocale 87 { 88 /** the locale. */ 89 @Option(names = {"--locale"}, defaultValue = "en-US", description = "locale for variables with units.") 90 private String locale; 91 } 92 93 /** Retrieval class to provide a --locale option, and a default locale. */ 94 static class RetrieveLocale 95 { 96 /** the locale. */ 97 @Option(names = {"--locale"}, defaultValue = "en-US", description = "locale for variables with units.") 98 private String locale; 99 100 /** 101 * @return the locale from --locale 102 */ 103 String getLocale() 104 { 105 return this.locale; 106 } 107 108 /** The other options. */ 109 @Unmatched 110 private List<String> remainder; 111 } 112 113 /** Utility class constructor. */ 114 private CliUtil() 115 { 116 // Utility class 117 } 118 119 /** 120 * The map with overrides for default values and other Option and Program annotation values. values in the map are: 121 * <ul> 122 * <li>className%fieldName%propertyName for the @Option annotation for field fieldName within the class named className, 123 * and the annotation property propertyName. An example of the propertyName is "defaultValue"</li> 124 * <li>className%propertyName for the @Command annotation for the annotation property with propertyName in the named 125 * class. Examples of the propertyName are "name", "version", and "description"</li> 126 * </ul> 127 */ 128 @SuppressWarnings("checkstyle:visibilitymodifier") 129 static Map<String, Object> overrideMap = new LinkedHashMap<>(); 130 131 /** 132 * Parse the command line for the program. Register Unit converters, parse the command line, catch --help, --version and 133 * errors. If the program implements the Checkable interface, it calls the "check" method of the class that can take care of 134 * further checks of the CLI arguments. Potentially, check() can also provide other initialization of the program to be 135 * executed, but this can better be provided by other methods in main(). The method will exit on requesting help or version 136 * information, or when the arguments are not complete or not correct. 137 * @param program Object; the potentially checkable program with the @Option information 138 * @param args String[]; the arguments from the command line 139 */ 140 public static void execute(final Object program, final String[] args) 141 { 142 execute(new CommandLine(program), args); 143 } 144 145 /** 146 * Parse the given CommandLine object, that has been generated for a program. Register Unit converters, parse the command 147 * line, catch --help, --version, --locale, and errors. If the program implements the Checkable interface, it calls the 148 * "check" method of the class that can take care of further checks of the CLI arguments. Potentially, check() can also 149 * provide other initialization of the program to be executed, but this can better be provided by other methods in main(). 150 * The method will exit on requesting help or version information, or when the arguments are not complete or not correct. 151 * @param commandLine CommandLine; the CommandLine object for the program with the @Option information 152 * @param args String[]; the arguments from the command line 153 */ 154 @SuppressWarnings("checkstyle:methodlength") 155 public static void execute(final CommandLine commandLine, final String[] args) 156 { 157 // Issue #13. add the --locale option 158 var initLocale = new InitLocale(); 159 commandLine.addMixin("locale", initLocale); 160 161 // set-up a new provider for default @Option values that can be overridden 162 CommandLine.IDefaultValueProvider vp = new CommandLine.IDefaultValueProvider() 163 { 164 @Override 165 public String defaultValue(final ArgSpec argSpec) throws Exception 166 { 167 String fieldName = ((Field) argSpec.userObject()).getName(); 168 Class<?> fieldClass = null; 169 try 170 { 171 Field field = ClassUtil.resolveField(commandLine.getCommand().getClass(), fieldName); 172 fieldClass = field.getDeclaringClass(); 173 } 174 catch (NoSuchFieldException nsfe) 175 { 176 fieldClass = commandLine.getCommand().getClass(); 177 } 178 String key = CliUtil.makeOverrideKeyProperty(fieldClass, fieldName, "defaultValue"); 179 if (CliUtil.overrideMap.containsKey(key)) 180 { 181 return CliUtil.overrideMap.get(key).toString(); 182 } 183 else 184 { 185 return argSpec.defaultValue(); 186 } 187 } 188 }; 189 commandLine.setDefaultValueProvider(vp); 190 191 // check @Program name override 192 String programKey = makeOverrideKeyCommand(commandLine.getCommand().getClass(), "name"); 193 if (overrideMap.containsKey(programKey)) 194 { 195 commandLine.setCommandName(overrideMap.get(programKey).toString()); 196 } 197 198 // set-up the version provider that provides a version number that can be overridden 199 String versionKey = makeOverrideKeyCommand(commandLine.getCommand().getClass(), "version"); 200 if (overrideMap.containsKey(versionKey)) 201 { 202 commandLine.getCommandSpec().versionProvider(new IVersionProvider() 203 { 204 @Override 205 public String[] getVersion() throws Exception 206 { 207 if (overrideMap.get(versionKey) instanceof String[]) 208 { 209 return (String[]) overrideMap.get(versionKey); 210 } 211 return new String[] {overrideMap.get(versionKey).toString()}; 212 } 213 }); 214 } 215 216 // set-up the description provider that provides a description that can be overridden 217 Map<String, IHelpSectionRenderer> helpMap = commandLine.getHelpSectionMap(); 218 final IHelpSectionRenderer defaultDescriptionRenderer = helpMap.get("description"); 219 helpMap.put("description", new IHelpSectionRenderer() 220 { 221 @Override 222 public String render(final Help help) 223 { 224 String descriptionKey = makeOverrideKeyCommand(commandLine.getCommand().getClass(), "description"); 225 if (overrideMap.containsKey(descriptionKey)) 226 { 227 if (overrideMap.get(descriptionKey) instanceof String[]) 228 { 229 StringBuilder sb = new StringBuilder(); 230 for (String line : (String[]) overrideMap.get(descriptionKey)) 231 { 232 sb.append(line); 233 sb.append("\n"); 234 } 235 return sb.toString(); 236 } 237 return overrideMap.get(descriptionKey).toString(); 238 } 239 return defaultDescriptionRenderer.render(help); 240 } 241 }); 242 commandLine.setHelpSectionMap(helpMap); 243 244 // register the DJUNITS converters 245 CliUnitConverters.registerAll(commandLine); 246 247 // Issue #13. set the locale, and store the old one 248 Locale saveLocale = Locale.getDefault(); 249 RetrieveLocale retrieveLocale = new RetrieveLocale(); 250 CommandLine cmdLocale = new CommandLine(retrieveLocale); 251 cmdLocale.parseArgs(args); 252 String[] localeParts = retrieveLocale.getLocale().contains("_") ? retrieveLocale.getLocale().split("_") 253 : retrieveLocale.getLocale().split("-"); 254 if (localeParts.length == 1) 255 { 256 Locale.setDefault(new Locale(localeParts[0])); 257 } 258 else if (localeParts.length == 2) 259 { 260 Locale.setDefault(new Locale(localeParts[0], localeParts[1])); 261 } 262 else 263 { 264 Locale.setDefault(new Locale(localeParts[0], localeParts[1], localeParts[2])); 265 } 266 267 // parse the command line arguments and handle errors, now based on the set locale 268 commandLine.getCommandSpec().parser().collectErrors(true); 269 ParseResult parseResult = commandLine.parseArgs(args); 270 List<Exception> parseErrors = parseResult.errors(); 271 if (parseErrors.size() > 0) 272 { 273 for (Exception e : parseErrors) 274 { 275 System.err.println(e.getMessage()); 276 } 277 System.exit(-1); 278 } 279 280 // process help and usage (using overridden values) 281 if (parseResult.isUsageHelpRequested()) 282 { 283 commandLine.usage(System.out); 284 System.exit(0); 285 } 286 else if (parseResult.isVersionHelpRequested()) 287 { 288 commandLine.printVersionHelp(System.out); 289 System.exit(0); 290 } 291 292 // check the values for the variables 293 Object program = commandLine.getCommand(); 294 if (program instanceof Checkable) 295 { 296 try 297 { 298 ((Checkable) program).check(); 299 } 300 catch (Exception exception) 301 { 302 System.err.println(exception.getMessage()); 303 System.exit(-1); 304 } 305 } 306 307 // Issue #13. reset the locale 308 Locale.setDefault(saveLocale); 309 } 310 311 /** 312 * Change the value of a property of an already present @Option annotation of a field in a class or superclass. 313 * @param programClass Class<?>; the class of the program for which the options should be changed 314 * @param fieldName String; the field for which the defaultValue in @Option should be changed 315 * @param propertyName String; the name of the property to change the value of 316 * @param newValue Object; the new value of the property 317 * @throws CliException when the field cannot be found, or when the @Option annotation is not present in the field 318 * @throws NoSuchFieldException when the field with the name does not exist in the program object 319 */ 320 public static void changeOptionProperty(final Class<?> programClass, final String fieldName, final String propertyName, 321 final Object newValue) throws CliException, NoSuchFieldException 322 { 323 Field field = ClassUtil.resolveField(programClass, fieldName); 324 Option optionAnnotation = field.getAnnotation(Option.class); 325 if (optionAnnotation == null) 326 { 327 throw new CliException( 328 String.format("@Option annotation not found for field %s in class %s", fieldName, programClass.getName())); 329 } 330 String key = makeOverrideKeyProperty(field.getDeclaringClass(), fieldName, propertyName); 331 overrideMap.put(key, newValue); 332 } 333 334 /** 335 * Change the value of a property of an already present @Option annotation of a field in a class or superclass. 336 * @param program Object; the program for which the options should be changed 337 * @param fieldName String; the field for which the defaultValue in @Option should be changed 338 * @param propertyName String; the name of the property to change the value of 339 * @param newValue Object; the new value of the property 340 * @throws CliException when the field cannot be found, or when the @Option annotation is not present in the field 341 * @throws NoSuchFieldException when the field with the name does not exist in the program object 342 */ 343 public static void changeOptionProperty(final Object program, final String fieldName, final String propertyName, 344 final Object newValue) throws CliException, NoSuchFieldException 345 { 346 changeOptionProperty(program.getClass(), fieldName, propertyName, newValue); 347 } 348 349 /** 350 * Change the default value of an already present @Option annotation of the "defaultValue" field in a class or 351 * superclass. 352 * @param program Object; the program for which the options should be changed 353 * @param fieldName String; the field for which the defaultValue in @Option should be changed 354 * @param newDefaultValue String; the new value of the defaultValue 355 * @throws CliException when the field cannot be found, or when the @Option annotation is not present in the field 356 * @throws NoSuchFieldException when the field with the name does not exist in the program object 357 */ 358 public static void changeOptionDefault(final Object program, final String fieldName, final String newDefaultValue) 359 throws CliException, NoSuchFieldException 360 { 361 changeOptionProperty(program, fieldName, "defaultValue", newDefaultValue); 362 } 363 364 /** 365 * Change the default value of an already present @Option annotation of the "defaultValue" field in a class or 366 * superclass. 367 * @param programClass Class<?>; the class of the program for which the options should be changed 368 * @param fieldName String; the field for which the defaultValue in @Option should be changed 369 * @param newDefaultValue String; the new value of the defaultValue 370 * @throws CliException when the field cannot be found, or when the @Option annotation is not present in the field 371 * @throws NoSuchFieldException when the field with the name does not exist in the program object 372 */ 373 public static void changeOptionDefault(final Class<?> programClass, final String fieldName, final String newDefaultValue) 374 throws CliException, NoSuchFieldException 375 { 376 changeOptionProperty(programClass, fieldName, "defaultValue", newDefaultValue); 377 } 378 379 /** 380 * Change the value of a property of an already present @Command annotation in a class or superclass of that class. 381 * @param program Object; the program for which the cli property should be changed 382 * @param propertyName String; the name of the property to change the value of 383 * @param newValue Object; the new value of the property 384 * @throws CliException when the class is not annotated with @Command 385 */ 386 private static void changeCommandProperty(final Object program, final String propertyName, final Object newValue) 387 throws CliException 388 { 389 changeCommandProperty(program.getClass(), propertyName, newValue); 390 } 391 392 /** 393 * Change the value of a property of an already present @Command annotation in a class or superclass of that class. 394 * @param programClass Class<?>; the class of the program for which the options should be changed 395 * @param propertyName String; the name of the property to change the value of 396 * @param newValue Object; the new value of the property 397 * @throws CliException when the class is not annotated with @Command 398 */ 399 private static void changeCommandProperty(final Class<?> programClass, final String propertyName, final Object newValue) 400 throws CliException 401 { 402 Class<?> declaringClass = getCommandAnnotationClass(programClass); 403 String key = makeOverrideKeyCommand(declaringClass, propertyName); 404 overrideMap.put(key, newValue); 405 } 406 407 /** 408 * Change the value of the 'name' property of an already present @Command annotation in a class or superclass of that 409 * class. 410 * @param program Object; the program for which the cli property should be changed 411 * @param newName String; the new value of the name 412 * @throws CliException when the class is not annotated with @Command 413 */ 414 public static void changeCommandName(final Object program, final String newName) throws CliException 415 { 416 changeCommandProperty(program, "name", newName); 417 } 418 419 /** 420 * Change the value of the 'name' property of an already present @Command annotation in a class or superclass of that 421 * class. 422 * @param programClass Class<?>; the class of the program for which the options should be changed 423 * @param newName String; the new value of the name 424 * @throws CliException when the class is not annotated with @Command 425 */ 426 public static void changeCommandName(final Class<?> programClass, final String newName) throws CliException 427 { 428 changeCommandProperty(programClass, "name", newName); 429 } 430 431 /** 432 * Change the value of the 'description' property of an already present @Command annotation in a class or superclass of 433 * that class. 434 * @param program Object; the program for which the cli property should be changed 435 * @param newDescription String; the new value of the description 436 * @throws CliException when the class is not annotated with @Command 437 */ 438 public static void changeCommandDescription(final Object program, final String newDescription) throws CliException 439 { 440 changeCommandProperty(program, "description", new String[] {newDescription}); 441 } 442 443 /** 444 * Change the value of the 'description' property of an already present @Command annotation in a class or superclass of 445 * that class. 446 * @param programClass Class<?>; the class of the program for which the options should be changed 447 * @param newDescription String; the new value of the description 448 * @throws CliException when the class is not annotated with @Command 449 */ 450 public static void changeCommandDescription(final Class<?> programClass, final String newDescription) throws CliException 451 { 452 changeCommandProperty(programClass, "description", new String[] {newDescription}); 453 } 454 455 /** 456 * Change the value of the 'version' property of an already present @Command annotation in a class or superclass of that 457 * class. 458 * @param program Object; the program for which the cli property should be changed 459 * @param newVersion String; the new value of the version 460 * @throws CliException when the class is not annotated with @Command 461 */ 462 public static void changeCommandVersion(final Object program, final String newVersion) throws CliException 463 { 464 changeCommandProperty(program, "version", new String[] {newVersion}); 465 } 466 467 /** 468 * Change the value of the 'version' property of an already present @Command annotation in a class or superclass of that 469 * class. 470 * @param programClass Class<?>; the class of the program for which the options should be changed 471 * @param newVersion String; the new value of the version 472 * @throws CliException when the class is not annotated with @Command 473 */ 474 public static void changeCommandVersion(final Class<?> programClass, final String newVersion) throws CliException 475 { 476 changeCommandProperty(programClass, "version", new String[] {newVersion}); 477 } 478 479 /** 480 * Return the @Command annotation of a class or one of its superclasses. 481 * @param programClass Class<?>; the class of the program for which the annotation should be retrieved 482 * @return Command; the @Command annotation of the class or one of its superclasses 483 * @throws CliException when the class or one of its superclasses is not annotated with @Command 484 */ 485 public static Command getCommandAnnotation(final Class<?> programClass) throws CliException 486 { 487 return getCommandAnnotationClass(programClass).getDeclaredAnnotation(Command.class); 488 } 489 490 /** 491 * Return the @Command annotation of a class or one of its superclasses. 492 * @param programClass Class<?>; the class of the program for which the annotation should be retrieved 493 * @return Class<?>; the class or superclass in which the @Command annotation was found 494 * @throws CliException when the class or one of its superclasses is not annotated with @Command 495 */ 496 public static Class<?> getCommandAnnotationClass(final Class<?> programClass) throws CliException 497 { 498 Class<?> clazz = programClass; 499 while (clazz != null) 500 { 501 Command commandAnnotation = clazz.getDeclaredAnnotation(Command.class); 502 if (commandAnnotation != null) 503 { 504 return clazz; 505 } 506 clazz = clazz.getSuperclass(); 507 } 508 throw new CliException( 509 String.format("@Command annotation not found for class %s or one of its superclasses", programClass.getName())); 510 } 511 512 /** 513 * @param programClass Class<?>; the class for which to retrieve the version. The class should be annotated with 514 * @Command 515 * @return String[] the version string 516 * @throws CliException when the class is not annotated with @Command 517 */ 518 public static String[] getCommandVersion(final Class<?> programClass) throws CliException 519 { 520 String versionKey = makeOverrideKeyCommand(programClass, "version"); 521 if (overrideMap.containsKey(versionKey)) 522 { 523 Object version = overrideMap.get(versionKey); 524 if (version instanceof String[]) 525 { 526 return (String[]) version; 527 } 528 return new String[] {version.toString()}; 529 } 530 return getCommandAnnotation(programClass).version(); 531 } 532 533 /** 534 * @param program Object; the program for which to retrieve the version. The program's class should be annotated with 535 * @Command 536 * @return String[] the version string 537 * @throws CliException when the class is not annotated with @Command 538 */ 539 public static String[] getCommandVersion(final Object program) throws CliException 540 { 541 return getCommandVersion(program.getClass()); 542 } 543 544 /** 545 * @param programClass Class<?>; the class for which to retrieve the program name. The class should be annotated with 546 * @Command 547 * @return String the name string 548 * @throws CliException when the class is not annotated with @Command 549 */ 550 public static String getCommandName(final Class<?> programClass) throws CliException 551 { 552 String nameKey = makeOverrideKeyCommand(programClass, "name"); 553 if (overrideMap.containsKey(nameKey)) 554 { 555 return overrideMap.get(nameKey).toString(); 556 } 557 return getCommandAnnotation(programClass).name(); 558 } 559 560 /** 561 * @param program Object; the program for which to retrieve the program name. The program's class should be annotated with 562 * @Command 563 * @return String the name string 564 * @throws CliException when the class is not annotated with @Command 565 */ 566 public static String getCommandName(final Object program) throws CliException 567 { 568 return getCommandName(program.getClass()); 569 } 570 571 /** 572 * @param programClass Class<?>; the class for which to retrieve the description. The class should be annotated with 573 * @Command 574 * @return String[] the description string 575 * @throws CliException when the class is not annotated with @Command 576 */ 577 public static String[] getCommandDescription(final Class<?> programClass) throws CliException 578 { 579 String descriptionKey = makeOverrideKeyCommand(programClass, "description"); 580 if (overrideMap.containsKey(descriptionKey)) 581 { 582 Object description = overrideMap.get(descriptionKey); 583 if (description instanceof String[]) 584 { 585 return (String[]) description; 586 } 587 return new String[] {description.toString()}; 588 } 589 return getCommandAnnotation(programClass).description(); 590 } 591 592 /** 593 * @param program Object; the program for which to retrieve the description. The program's class should be annotated with 594 * @Command 595 * @return String[] the description string 596 * @throws CliException when the class is not annotated with @Command 597 */ 598 public static String[] getCommandDescription(final Object program) throws CliException 599 { 600 return getCommandDescription(program.getClass()); 601 } 602 603 /** 604 * Make the override key for an option property. 605 * @param programClass Class<?>; the class of the program for which the options should be changed 606 * @param fieldName String; the field for which the defaultValue in @Option should be changed 607 * @param propertyName String; the name of the property to change the value of 608 * @return String; the override key for an option property 609 */ 610 static String makeOverrideKeyProperty(final Class<?> programClass, final String fieldName, final String propertyName) 611 { 612 return programClass.getName() + "%" + fieldName + "%" + propertyName; 613 } 614 615 /** 616 * Make the override key for the Command annotation. 617 * @param programClass Class<?>; the class of the program for which the options should be changed 618 * @param propertyName String; the name of the annotation property to change the value of 619 * @return String; the override key for an option property 620 */ 621 static String makeOverrideKeyCommand(final Class<?> programClass, final String propertyName) 622 { 623 return programClass.getName() + "%" + propertyName; 624 } 625 }