View Javadoc
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 &#64;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   *     &#64;Command(description = "Test program for CLI", name = "Program", mixinStandardHelpOptions = true, version = "1.0")
44   *     public static class Options implements Checkable
45   *     {
46   *         &#64;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   *         &#64;Override
55   *         public void check() throws Exception
56   *         {
57   *             if (this.port &lt;= 0 || this.port &gt; 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 &#64;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 &#64;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 &#64;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 &#64;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 &#64;Option annotation of a field in a class or superclass.
313      * @param programClass Class&lt;?&gt;; the class of the program for which the options should be changed
314      * @param fieldName String; the field for which the defaultValue in &#64;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 &#64;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 &#64;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 &#64;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 &#64;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 &#64;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 &#64;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 &#64;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 &#64;Option annotation of the "defaultValue" field in a class or
366      * superclass.
367      * @param programClass Class&lt;?&gt;; the class of the program for which the options should be changed
368      * @param fieldName String; the field for which the defaultValue in &#64;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 &#64;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 &#64;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 &#64;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 &#64;Command annotation in a class or superclass of that class.
394      * @param programClass Class&lt;?&gt;; 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 &#64;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 &#64;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 &#64;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 &#64;Command annotation in a class or superclass of that
421      * class.
422      * @param programClass Class&lt;?&gt;; 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 &#64;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 &#64;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 &#64;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 &#64;Command annotation in a class or superclass of
445      * that class.
446      * @param programClass Class&lt;?&gt;; 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 &#64;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 &#64;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 &#64;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 &#64;Command annotation in a class or superclass of that
469      * class.
470      * @param programClass Class&lt;?&gt;; 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 &#64;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 &#64;Command annotation of a class or one of its superclasses.
481      * @param programClass Class&lt;?&gt;; the class of the program for which the annotation should be retrieved
482      * @return Command; the &#64;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 &#64;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 &#64;Command annotation of a class or one of its superclasses.
492      * @param programClass Class&lt;?&gt;; the class of the program for which the annotation should be retrieved
493      * @return Class&lt;?&gt;; the class or superclass in which the &#64;Command annotation was found
494      * @throws CliException when the class or one of its superclasses is not annotated with &#64;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&lt;?&gt;; the class for which to retrieve the version. The class should be annotated with
514      *            &#64;Command
515      * @return String[] the version string
516      * @throws CliException when the class is not annotated with &#64;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      *            &#64;Command
536      * @return String[] the version string
537      * @throws CliException when the class is not annotated with &#64;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&lt;?&gt;; the class for which to retrieve the program name. The class should be annotated with
546      *            &#64;Command
547      * @return String the name string
548      * @throws CliException when the class is not annotated with &#64;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      *            &#64;Command
563      * @return String the name string
564      * @throws CliException when the class is not annotated with &#64;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&lt;?&gt;; the class for which to retrieve the description. The class should be annotated with
573      *            &#64;Command
574      * @return String[] the description string
575      * @throws CliException when the class is not annotated with &#64;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      *            &#64;Command
595      * @return String[] the description string
596      * @throws CliException when the class is not annotated with &#64;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&lt;?&gt;; the class of the program for which the options should be changed
606      * @param fieldName String; the field for which the defaultValue in &#64;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&lt;?&gt;; 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 }