From 82a4b7952194501fc4c1c489286d449036fd6a9e Mon Sep 17 00:00:00 2001 From: Remko Popma Date: Sun, 5 May 2019 08:47:44 +0900 Subject: [PATCH] [#678] Exit Status section in usage help message --- RELEASE-NOTES.md | 1 + docs/index.adoc | 34 ++- src/main/java/picocli/CommandLine.java | 146 ++++++++++- src/test/java/picocli/ExecuteTest.java | 233 +++++++++++++++++- src/test/java/picocli/I18nTest.java | 60 +++++ src/test/java/picocli/SubcommandTests.java | 8 +- .../picocli/SharedMessages.properties | 4 + .../picocli/SharedMessages_ja.properties | 4 + .../resources/picocli/exitcodes.properties | 4 + 9 files changed, 482 insertions(+), 12 deletions(-) create mode 100644 src/test/resources/picocli/exitcodes.properties diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 1103dc013..49da1753b 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -161,6 +161,7 @@ With the new execute API the ColorScheme class will start to play a more central - [#541] Improved exception handling for Runnable/Callable. - [#663] How to remove stacktraces on error. Thanks to [Nicolas Mingo](https://github.com/nicolasmingo) and [jrevault](https://github.com/jrevault) for raising this and subsequent discussion. - [#672] Need way to send errors back from subcommand. Thanks to [Garret Wilson](https://github.com/garretwilson) for raising this. +- [#678] Exit Status section in usage help message. - [#676] Bugfix: non-defined variables in `defaultValue` now correctly resolve to `null`, and options and positional parameters are now correctly considered `required` only if their default value is `null` after variable interpolation. Thanks to [ifedorenko](https://github.com/ifedorenko) for raising this. - [#679] Documentation: Update examples for new execute API. Add examples for exit code control and custom exception handlers. diff --git a/docs/index.adoc b/docs/index.adoc index 922f6ff8a..66b7e3988 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -1253,8 +1253,7 @@ The `CommandLine.execute` method introduced in picocli 4.0 returns an `int`, and ```java public static void main(String... args) { - CommandLine cmd = new CommandLine(new App()); - int exitCode = cmd.execute(args); + int exitCode = new CommandLine(new MyApp()).execute(args); System.exit(exitCode); } ``` @@ -1330,6 +1329,37 @@ When the end user specified invalid input, the `execute` method prints an error If the business logic of the command throws an exception, the `execute` method prints the stack trace of the exception and returns an exit code. This can be customized by configuring a `IExecutionExceptionHandler`. +=== Usage Help Exit Code Section +By default, the usage help message does not display the exit code section. +Applications that call `System.exit` need to configure this manually by calling `CommandLine.setExitCodeHelpSection(String, Map)` +or by calling `UsageMessageSpec.exitCodeListHeading` and `UsageMessageSpec.exitCodeList`. For example: + +```java +// import static picocli.CommandLine.Model.UsageMessageSpec.keyValuesMap; +@Command class App {} +CommandLine cmd = new CommandLine(new App()); +cmd.setExitCodeHelpSection("Exit Codes:%n", + keyValuesMap(" 0:Successful program execution", + "64:Usage error: user input for the command was incorrect, " + + "e.g., the wrong number of arguments, a bad flag, " + + "a bad syntax in a parameter, etc.", + "70:Internal software error: an exception occurred when invoking " + + "the business logic of this command.")); +cmd.usage(System.out); +``` + +This will print the following message to the console: + +``` +Usage:
+Exit Codes: + 0 Successful program execution + 64 Usage error: user input for the command was incorrect, e.g., the wrong + number of arguments, a bad flag, a bad syntax in a parameter, etc. + 70 Internal software error: an exception occurred when invoking the + business logic of this command. +``` + === Execution Configuration The following methods can be used to configure the behaviour of the `execute` method: diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index e382fe192..24e7addac 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -387,7 +387,7 @@ public CommandLine setHelpFactory(IHelpFactory helpFactory) { /** * Returns the section keys in the order that the usage help message should render the sections. * This ordering may be modified with {@link #setHelpSectionKeys(List) setSectionKeys}. The default keys are (in order): - *
    + *
      *
    1. {@link UsageMessageSpec#SECTION_KEY_HEADER_HEADING SECTION_KEY_HEADER_HEADING}
    2. *
    3. {@link UsageMessageSpec#SECTION_KEY_HEADER SECTION_KEY_HEADER}
    4. *
    5. {@link UsageMessageSpec#SECTION_KEY_SYNOPSIS_HEADING SECTION_KEY_SYNOPSIS_HEADING}
    6. @@ -400,6 +400,8 @@ public CommandLine setHelpFactory(IHelpFactory helpFactory) { *
    7. {@link UsageMessageSpec#SECTION_KEY_OPTION_LIST SECTION_KEY_OPTION_LIST}
    8. *
    9. {@link UsageMessageSpec#SECTION_KEY_COMMAND_LIST_HEADING SECTION_KEY_COMMAND_LIST_HEADING}
    10. *
    11. {@link UsageMessageSpec#SECTION_KEY_COMMAND_LIST SECTION_KEY_COMMAND_LIST}
    12. + *
    13. {@link UsageMessageSpec#SECTION_KEY_EXIT_CODE_LIST_HEADING SECTION_KEY_EXIT_CODE_LIST_HEADING}
    14. + *
    15. {@link UsageMessageSpec#SECTION_KEY_EXIT_CODE_LIST SECTION_KEY_EXIT_CODE_LIST}
    16. *
    17. {@link UsageMessageSpec#SECTION_KEY_FOOTER_HEADING SECTION_KEY_FOOTER_HEADING}
    18. *
    19. {@link UsageMessageSpec#SECTION_KEY_FOOTER SECTION_KEY_FOOTER}
    20. *
    @@ -824,6 +826,35 @@ public List getUnmatchedArguments() { return interpreter.parseResultBuilder == null ? Collections.emptyList() : UnmatchedArgumentException.stripErrorMessage(interpreter.parseResultBuilder.unmatched); } + /** + * Sets the header and exit code descriptions to show in the Exit Code section of the usage help. + * Callers may be interested in the {@link UsageMessageSpec#keyValuesMap(String...) keyValuesMap} method for creating a map from a list of {@code "key:value"} Strings. + *

    The specified setting will be registered with this {@code CommandLine} and the full hierarchy of its + * subcommands and nested sub-subcommands at the moment this method is called. Subcommands added + * later will have the default setting. To ensure a setting is applied to all + * subcommands, call the setter last, after adding subcommands.

    + * + * @param exitCodeListHeading the heading preceding the exit codes section, ending in {@code "%n"}. + * May contain additional {@code "%n"} line separators. + * Common values are {@code "Exit Status%n"} or {@code "Exit Codes%n"}. + * @param exitCodeDescriptions map with values to be displayed in the exit codes section; + * each entry has an exit code key and its description as value. + * Descriptions containing {@code "%n"} line separators are broken up into multiple lines. + * @see UsageMessageSpec#keyValuesMap(String...) + * @see UsageMessageSpec#exitCodeList() + * @see UsageMessageSpec#exitCodeListHeading() + * @see UsageMessageSpec#SECTION_KEY_EXIT_CODE_LIST + * @see UsageMessageSpec#SECTION_KEY_EXIT_CODE_LIST_HEADING + * @since 4.0 */ + public CommandLine setExitCodeHelpSection(String exitCodeListHeading, Map exitCodeDescriptions) { + getCommandSpec().usageMessage().exitCodeListHeading(exitCodeListHeading); + getCommandSpec().usageMessage().exitCodeList(exitCodeDescriptions); + for (CommandLine command : getCommandSpec().subcommands().values()) { + command.setExitCodeHelpSection(exitCodeListHeading, exitCodeDescriptions); + } + return this; + } + /** * Defines some exit codes used by picocli as default return values from the {@link #execute(String...) execute} * and {@link #executeHelpRequest(ParseResult) executeHelpRequest} methods. @@ -5347,6 +5378,8 @@ private static boolean isNonDefault(Object[] candidate, Object[] defaultValue) { * sectionMap.put(SECTION_KEY_OPTION_LIST, help -> help.optionList()); //e.g. -h, --help displays this help and exits * sectionMap.put(SECTION_KEY_COMMAND_LIST_HEADING, help -> help.commandListHeading()); //e.g. %nCommands:%n%n * sectionMap.put(SECTION_KEY_COMMAND_LIST, help -> help.commandList()); //e.g. add adds the frup to the frooble + * sectionMap.put(SECTION_KEY_EXIT_CODE_LIST_HEADING, help -> help.exitCodeListHeading()); + * sectionMap.put(SECTION_KEY_EXIT_CODE_LIST, help -> help.exitCodeList()); * sectionMap.put(SECTION_KEY_FOOTER_HEADING, help -> help.footerHeading()); * sectionMap.put(SECTION_KEY_FOOTER, help -> help.footer()); * } @@ -5414,6 +5447,16 @@ public static class UsageMessageSpec { * @since 3.9 */ public static final String SECTION_KEY_COMMAND_LIST = "commandList"; + /** {@linkplain #sectionKeys() Section key} to {@linkplain #sectionMap() control} the {@linkplain IHelpSectionRenderer section renderer} for the Exit Code List Heading section. + * The default renderer for this section calls {@link Help#exitCodeListHeading(Object...)}. + * @since 4.0 */ + public static final String SECTION_KEY_EXIT_CODE_LIST_HEADING = "exitCodeListHeading"; + + /** {@linkplain #sectionKeys() Section key} to {@linkplain #sectionMap() control} the {@linkplain IHelpSectionRenderer section renderer} for the Exit Code List section. + * The default renderer for this section calls {@link Help#exitCodeList()}. + * @since 4.0 */ + public static final String SECTION_KEY_EXIT_CODE_LIST = "exitCodeList"; + /** {@linkplain #sectionKeys() Section key} to {@linkplain #sectionMap() control} the {@linkplain IHelpSectionRenderer section renderer} for the Footer Heading section. * The default renderer for this section calls {@link Help#footerHeading(Object...)}. * @since 3.9 */ @@ -5470,6 +5513,8 @@ public static class UsageMessageSpec { SECTION_KEY_OPTION_LIST, SECTION_KEY_COMMAND_LIST_HEADING, SECTION_KEY_COMMAND_LIST, + SECTION_KEY_EXIT_CODE_LIST_HEADING, + SECTION_KEY_EXIT_CODE_LIST, SECTION_KEY_FOOTER_HEADING, SECTION_KEY_FOOTER)); @@ -5491,6 +5536,8 @@ public static class UsageMessageSpec { private String optionListHeading; private String commandListHeading; private String footerHeading; + private String exitCodeListHeading; + private Map exitCodeList; private int width = DEFAULT_USAGE_WIDTH; private final Interpolator interpolator; @@ -5560,6 +5607,8 @@ private Map createHelpSectionRendererMap() { result.put(SECTION_KEY_COMMAND_LIST_HEADING, new IHelpSectionRenderer() { public String render(Help help) { return help.commandListHeading(); } }); //e.g. add adds the frup to the frooble result.put(SECTION_KEY_COMMAND_LIST, new IHelpSectionRenderer() { public String render(Help help) { return help.commandList(); } }); + result.put(SECTION_KEY_EXIT_CODE_LIST_HEADING, new IHelpSectionRenderer() { public String render(Help help) { return help.exitCodeListHeading(); } }); + result.put(SECTION_KEY_EXIT_CODE_LIST, new IHelpSectionRenderer() { public String render(Help help) { return help.exitCodeList(); } }); result.put(SECTION_KEY_FOOTER_HEADING, new IHelpSectionRenderer() { public String render(Help help) { return help.footerHeading(); } }); result.put(SECTION_KEY_FOOTER, new IHelpSectionRenderer() { public String render(Help help) { return help.footer(); } }); return result; @@ -5568,7 +5617,7 @@ private Map createHelpSectionRendererMap() { /** * Returns the section keys in the order that the usage help message should render the sections. * This ordering may be modified with the {@link #sectionKeys(List) sectionKeys setter}. The default keys are (in order): - *
      + *
        *
      1. {@link UsageMessageSpec#SECTION_KEY_HEADER_HEADING SECTION_KEY_HEADER_HEADING}
      2. *
      3. {@link UsageMessageSpec#SECTION_KEY_HEADER SECTION_KEY_HEADER}
      4. *
      5. {@link UsageMessageSpec#SECTION_KEY_SYNOPSIS_HEADING SECTION_KEY_SYNOPSIS_HEADING}
      6. @@ -5581,6 +5630,8 @@ private Map createHelpSectionRendererMap() { *
      7. {@link UsageMessageSpec#SECTION_KEY_OPTION_LIST SECTION_KEY_OPTION_LIST}
      8. *
      9. {@link UsageMessageSpec#SECTION_KEY_COMMAND_LIST_HEADING SECTION_KEY_COMMAND_LIST_HEADING}
      10. *
      11. {@link UsageMessageSpec#SECTION_KEY_COMMAND_LIST SECTION_KEY_COMMAND_LIST}
      12. + *
      13. {@link UsageMessageSpec#SECTION_KEY_EXIT_CODE_LIST_HEADING SECTION_KEY_EXIT_CODE_LIST_HEADING}
      14. + *
      15. {@link UsageMessageSpec#SECTION_KEY_EXIT_CODE_LIST SECTION_KEY_EXIT_CODE_LIST}
      16. *
      17. {@link UsageMessageSpec#SECTION_KEY_FOOTER_HEADING SECTION_KEY_FOOTER_HEADING}
      18. *
      19. {@link UsageMessageSpec#SECTION_KEY_FOOTER SECTION_KEY_FOOTER}
      20. *
      @@ -5613,7 +5664,7 @@ private Map createHelpSectionRendererMap() { * @see #setHelpSectionMap(Map) * @since 3.9 */ - public UsageMessageSpec sectionMap(Map map) { this.helpSectionRendererMap = new HashMap(map); return this; } + public UsageMessageSpec sectionMap(Map map) { this.helpSectionRendererMap = new LinkedHashMap(map); return this; } /** Returns the {@code IHelpFactory} that is used to construct the usage help message. * @see #setHelpFactory(IHelpFactory) @@ -5646,7 +5697,7 @@ private String[] arr(String[] localized, String[] value, String[] defaultValue) private String resourceStr(String key) { return messages == null ? null : messages.getString(key, null); } private String[] resourceArr(String key) { return messages == null ? null : messages.getStringArray(key, null); } - /** Returns the optional heading preceding the header section. Initialized from {@link Command#headerHeading()}, or null. */ + /** Returns the optional heading preceding the header section. Initialized from {@link Command#headerHeading()}, or {@code ""} (empty string). */ public String headerHeading() { return str(resourceStr("usage.headerHeading"), headerHeading, DEFAULT_SINGLE_VALUE); } /** Returns the optional header lines displayed at the top of the help message. For subcommands, the first header line is @@ -5700,7 +5751,44 @@ private String[] arr(String[] localized, String[] value, String[] defaultValue) /** Returns the optional heading preceding the subcommand list. Initialized from {@link Command#commandListHeading()}. {@code "Commands:%n"} by default. */ public String commandListHeading() { return str(resourceStr("usage.commandListHeading"), commandListHeading, DEFAULT_COMMAND_LIST_HEADING); } - /** Returns the optional heading preceding the footer section. Initialized from {@link Command#footerHeading()}, or null. */ + /** Returns the optional heading preceding the exit codes section, may contain {@code "%n"} line separators. {@code ""} (empty string) by default. */ + public String exitCodeListHeading() { return str(resourceStr("usage.exitCodeListHeading"), exitCodeListHeading, DEFAULT_SINGLE_VALUE); } + + /** Returns an unmodifiable map with values to be displayed in the exit codes section: keys are exit codes, values are descriptions. + * Descriptions may contain {@code "%n"} line separators. + *

      This may be configured in a resource bundle by listing up multiple {@code "key:value"} pairs. For example:

      + *
      +             * usage.exitCodeList.0 = 0:Successful program execution.
      +             * usage.exitCodeList.1 = 64:Invalid input: an unknown option or invalid parameter was specified.
      +             * usage.exitCodeList.2 = 70:Execution exception: an exception occurred while executing the business logic.
      +             * 
      + * @return an unmodifiable map with values to be displayed in the exit codes section, or an empty map if no exit codes are {@linkplain #exitCodeList(Map) registered}. + * @see #keyValuesMap(String...) + * @since 4.0 */ + public Map exitCodeList() { + Map result = keyValuesMap(resourceArr("usage.exitCodeList")); + return !result.isEmpty() ? Collections.unmodifiableMap(result) : (exitCodeList == null ? Collections.emptyMap() : exitCodeList); + } + + /** Creates and returns a {@code Map} that contains an entry for each specified String that is in {@code "key:value"} format. + * @param entries the strings to process; values that are not in {@code "key:value"} format are ignored + * @return a {@code Map} with an entry for each line, preserving the input order + * @since 4.0 */ + public static Map keyValuesMap(String... entries) { + Map result = new LinkedHashMap(); + if (entries == null) { return result; } + for (int i = 0; i < entries.length; i++) { + int pos = entries[i].indexOf(':'); + if (pos >= 0) { + result.put(entries[i].substring(0, pos), entries[i].substring(pos + 1)); + } else { + new Tracer().info("Ignoring line at index %d: cannot split '%s' into 'key:value'%n", i, entries[i]); + } + } + return result; + } + + /** Returns the optional heading preceding the footer section. Initialized from {@link Command#footerHeading()}, or {@code ""} (empty string). */ public String footerHeading() { return str(resourceStr("usage.footerHeading"), footerHeading, DEFAULT_SINGLE_VALUE); } /** Returns the optional footer text lines displayed at the bottom of the help message. Initialized from @@ -5770,6 +5858,23 @@ private String[] arr(String[] localized, String[] value, String[] defaultValue) * @return this UsageMessageSpec for method chaining */ public UsageMessageSpec commandListHeading(String newValue) {commandListHeading = newValue; return this;} + /** Sets the optional heading preceding the exit codes section, may contain {@code "%n"} line separators. {@code ""} (empty string) by default. + * @since 4.0 */ + public UsageMessageSpec exitCodeListHeading(String newValue) { exitCodeListHeading = newValue; return this;} + + /** Sets the values to be displayed in the exit codes section: keys are exit codes, values are descriptions. + * Descriptions may contain {@code "%n"} line separators. + *

      This may be configured in a resource bundle by listing up multiple {@code "key:value"} pairs. For example:

      + *
      +             * usage.exitCodeList.0 = 0:Successful program execution.
      +             * usage.exitCodeList.1 = 64:Invalid input: an unknown option or invalid parameter was specified.
      +             * usage.exitCodeList.2 = 70:Execution exception: an exception occurred while executing the business logic.
      +             * 
      + * @newValue a map with values to be displayed in the exit codes section + * @see #keyValuesMap(String...) + * @since 4.0 */ + public UsageMessageSpec exitCodeList(Map newValue) { exitCodeList = newValue == null ? null : Collections.unmodifiableMap(new LinkedHashMap(newValue)); return this;} + /** Sets the optional heading preceding the footer section. * @return this UsageMessageSpec for method chaining */ public UsageMessageSpec footerHeading(String newValue) {footerHeading = newValue; return this;} @@ -11909,6 +12014,37 @@ public String commandListHeading(Object... params) { public String footerHeading(Object... params) { return heading(ansi(), width(), adjustCJK(), commandSpec.usageMessage().footerHeading(), params); } + + /** Returns the text displayed before the exit code list text; the result of {@code String.format(exitCodeHeading, params)}. + * @param params the parameters to use to format the exit code heading + * @return the formatted heading of the exit code section of the usage help message + * @since 4.0 */ + public String exitCodeListHeading(Object... params) { + return heading(ansi(), width(), adjustCJK(), commandSpec.usageMessage().exitCodeListHeading(), params); + } + /** Returns a 2-column list with exit codes and their description. Descriptions containing {@code "%n"} line separators are broken up into multiple lines. + * @return a usage help section describing the exit codes + * @since 4.0 */ + public String exitCodeList() { + Map map = commandSpec.usageMessage().exitCodeList(); + if (map.isEmpty()) { return ""; } + int keyLength = maxLength(map.keySet()); + Help.TextTable textTable = Help.TextTable.forColumns(ansi(), + new Help.Column(keyLength + 3, 2, Help.Column.Overflow.SPAN), + new Help.Column(width() - (keyLength + 3), 2, Help.Column.Overflow.WRAP)); + textTable.setAdjustLineBreaksForWideCJKCharacters(adjustCJK()); + + for (Map.Entry entry : map.entrySet()) { + Text[] keys = ansi().text(format(entry.getKey())).splitLines(); + Text[] values = ansi().text(format(entry.getValue())).splitLines(); + for (int i = 0; i < Math.max(keys.length, values.length); i++) { + Text key = i < keys.length ? keys[i] : Ansi.EMPTY_TEXT; + Text value = i < values.length ? values[i] : Ansi.EMPTY_TEXT; + textTable.addRowValues(key, value); + } + } + return textTable.toString(); + } /** Returns a 2-column list with command names and the first line of their header or (if absent) description. * @return a usage help section describing the added commands */ public String commandList() { diff --git a/src/test/java/picocli/ExecuteTest.java b/src/test/java/picocli/ExecuteTest.java index 797694b75..c5e7574c5 100644 --- a/src/test/java/picocli/ExecuteTest.java +++ b/src/test/java/picocli/ExecuteTest.java @@ -18,15 +18,17 @@ import org.junit.Rule; import org.junit.Test; import org.junit.contrib.java.lang.system.ProvideSystemProperty; +import org.junit.contrib.java.lang.system.RestoreSystemProperties; import org.junit.contrib.java.lang.system.SystemErrRule; import org.junit.contrib.java.lang.system.SystemOutRule; +import org.junit.rules.TestRule; import picocli.CommandLine.ExitCode; import picocli.CommandLine.IExecutionExceptionHandler; import picocli.CommandLine.IExitCodeExceptionMapper; import picocli.CommandLine.IExitCodeGenerator; import picocli.CommandLine.IParameterExceptionHandler; import picocli.CommandLine.Model.CommandSpec; -import picocli.CommandLine.PicocliException; +import picocli.CommandLine.Model.UsageMessageSpec; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; @@ -35,7 +37,11 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; @@ -47,6 +53,7 @@ import static picocli.CommandLine.ExecutionException; import static picocli.CommandLine.Help; import static picocli.CommandLine.IExecutionStrategy; +import static picocli.CommandLine.Model.UsageMessageSpec.keyValuesMap; import static picocli.CommandLine.Option; import static picocli.CommandLine.ParameterException; import static picocli.CommandLine.Parameters; @@ -60,6 +67,10 @@ public class ExecuteTest { @Rule public final ProvideSystemProperty ansiOFF = new ProvideSystemProperty("picocli.ansi", "false"); + @Rule + // allows tests to set any kind of properties they like, without having to individually roll them back + public final TestRule restoreSystemProperties = new RestoreSystemProperties(); + @Rule public final SystemErrRule systemErrRule = new SystemErrRule().enableLog().muteForSuccessfulTests(); @@ -999,4 +1010,224 @@ public TimeUnit call() { assertEquals(TimeUnit.SECONDS, cmd.getExecutionResult()); } + + @Test + public void testSetExitCodeHelpSection() { + @Command(mixinStandardHelpOptions = true) + class App {} + CommandLine cmd = new CommandLine(new App()); + String expected = String.format("" + + "Usage:
      [-hV]%n" + + " -h, --help Show this help message and exit.%n" + + " -V, --version Print version information and exit.%n"); + assertEquals(expected, cmd.getUsageMessage()); + + cmd.setExitCodeHelpSection("Exit Codes:%n", + keyValuesMap(" 0:Successful program execution", + "64:Usage error: user input for the command was incorrect, " + + "e.g., the wrong number of arguments, a bad flag, " + + "a bad syntax in a parameter, etc.", + "70:Internal software error: an exception occurred when invoking " + + "the business logic of this command.")); + expected = String.format("" + + "Usage:
      [-hV]%n" + + " -h, --help Show this help message and exit.%n" + + " -V, --version Print version information and exit.%n" + + "Exit Codes:%n" + + " 0 Successful program execution%n" + + " 64 Usage error: user input for the command was incorrect, e.g., the wrong%n" + + " number of arguments, a bad flag, a bad syntax in a parameter, etc.%n" + + " 70 Internal software error: an exception occurred when invoking the%n" + + " business logic of this command.%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testSetExitCodeHelpSectionSetsUsageMessageSpec() { + @Command(mixinStandardHelpOptions = true) + class App {} + CommandLine cmd = new CommandLine(new App()); + CommandSpec spec = cmd.getCommandSpec(); + UsageMessageSpec usage = spec.usageMessage(); + + assertEquals("", usage.exitCodeListHeading()); + assertEquals(true, usage.exitCodeList().isEmpty()); + + cmd.setExitCodeHelpSection("My Exit Codes%n", + keyValuesMap(" 0:Normal Execution", + "64:Invalid user input", + "70:Internal error")); + + assertEquals("My Exit Codes%n", usage.exitCodeListHeading()); + assertEquals(3, usage.exitCodeList().size()); + assertEquals("Invalid user input", usage.exitCodeList().get("64")); + + usage.exitCodeListHeading("EXIT STATUS OVERWRITTEN%n"); + + String expected = String.format("" + + "Usage:
      [-hV]%n" + + " -h, --help Show this help message and exit.%n" + + " -V, --version Print version information and exit.%n" + + "EXIT STATUS OVERWRITTEN%n" + + " 0 Normal Execution%n" + + " 64 Invalid user input%n" + + " 70 Internal error%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testSetExitCodeHelpSectionReordered() { + @Command(mixinStandardHelpOptions = true) + class App {} + CommandLine cmd = new CommandLine(new App()); + + List keys = new ArrayList(cmd.getHelpSectionKeys()); + keys.remove(UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST); + keys.remove(UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST_HEADING); + keys.add(8, UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST_HEADING); + keys.add(9, UsageMessageSpec.SECTION_KEY_EXIT_CODE_LIST); + cmd.setHelpSectionKeys(keys); + + cmd.getCommandSpec().usageMessage().optionListHeading("Options:%n"); + cmd.setExitCodeHelpSection("Exit Codes:%n", + keyValuesMap(" 0:Normal Execution", "64:Invalid user input", "70:Internal error")); + String expected = String.format("" + + "Usage:
      [-hV]%n" + + "Exit Codes:%n" + + " 0 Normal Execution%n" + + " 64 Invalid user input%n" + + " 70 Internal error%n" + + "Options:%n" + + " -h, --help Show this help message and exit.%n" + + " -V, --version Print version information and exit.%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testExitCodeHelpSectionFromResourceBundle() { + @Command(resourceBundle = "picocli.exitcodes") + class App {} + + CommandLine cmd = new CommandLine(new App()); + String expected = String.format("" + + "Usage:
      %n" + + "Exit Codes:%n" + + "These exit codes are blah blah etc.%n" + + " 0 Normal termination (notice leading space)%n" + + " 64 Multiline!%n" + + " Invalid input%n" + + " 70 Very long line: aaaaa bbbbbbbb ccccc dddddddd eeeeeee fffffffff ggggg%n" + + " hhhh iiii jjjjjjj kkkk lllll mmmmmmmm nn ooooo ppppp qqqqq%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testResourceBundleOverwritesSetExitCodeHelpSection() { + @Command(resourceBundle = "picocli.exitcodes") + class App {} + + CommandLine cmd = new CommandLine(new App()); + cmd.setExitCodeHelpSection("EXIT STATUS%n", + keyValuesMap("000:IGNORED 1", "11:IGNORED 2")); + + String expected = String.format("" + + "Usage:
      %n" + + "Exit Codes:%n" + + "These exit codes are blah blah etc.%n" + + " 0 Normal termination (notice leading space)%n" + + " 64 Multiline!%n" + + " Invalid input%n" + + " 70 Very long line: aaaaa bbbbbbbb ccccc dddddddd eeeeeee fffffffff ggggg%n" + + " hhhh iiii jjjjjjj kkkk lllll mmmmmmmm nn ooooo ppppp qqqqq%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testSetExitCodeHelpSectionAllowsNullHeader() { + @Command + class App {} + CommandLine cmd = new CommandLine(new App()); + String expected = String.format("" + + "Usage:
      %n"); + assertEquals(expected, cmd.getUsageMessage()); + + cmd.setExitCodeHelpSection(null, + keyValuesMap(" 0:Normal Execution", + "64:Invalid user input", + "70:Internal error")); + expected = String.format("" + + "Usage:
      %n" + + " 0 Normal Execution%n" + + " 64 Invalid user input%n" + + " 70 Internal error%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testSetExitCodeHelpSectionAllowsNullMap() { + @Command + class App {} + CommandLine cmd = new CommandLine(new App()); + String expected = String.format("" + + "Usage:
      %n"); + assertEquals(expected, cmd.getUsageMessage()); + + cmd.setExitCodeHelpSection("Exit Codes%n", null); + expected = String.format("" + + "Usage:
      %n" + + "Exit Codes%n"); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testSetExitCodeHelpSectionAllowsNullHeaderAndMap() { + @Command + class App {} + CommandLine cmd = new CommandLine(new App()); + String expected = String.format("" + + "Usage:
      %n"); + assertEquals(expected, cmd.getUsageMessage()); + + cmd.setExitCodeHelpSection(null, null); + assertEquals(expected, cmd.getUsageMessage()); + } + + @Test + public void testKeyValuesMapCreatesMapFromStrings() { + Map map = keyValuesMap(" 0:Normal Execution", + "64:Invalid user input", + "70:Internal error"); + assertTrue(map instanceof LinkedHashMap); + assertEquals(3, map.size()); + assertEquals("Normal Execution", map.get(" 0")); + assertEquals("Invalid user input", map.get("64")); + assertEquals("Internal error", map.get("70")); + } + + @Test + public void testKeyValuesMapIgnoresInvalidEntries() { + HelpTestUtil.setTraceLevel("INFO"); + Map map = keyValuesMap(" 0:Normal Execution", + "INVALID ENTRY", + "70:Internal error"); + assertTrue(map instanceof LinkedHashMap); + assertEquals(2, map.size()); + assertEquals("Normal Execution", map.get(" 0")); + assertEquals("Internal error", map.get("70")); + + String expected = String.format("[picocli INFO] Ignoring line at index 1: cannot split 'INVALID ENTRY' into 'key:value'%n"); + assertEquals(expected, systemErrRule.getLog()); + } + + @Test + public void testKeyValuesMapReturnsEmptyMapForNull() { + Map map = keyValuesMap((String[]) null); + assertTrue(map instanceof LinkedHashMap); + assertEquals(0, map.size()); + } + + @Test(expected = NullPointerException.class) + public void testKeyValuesMapDisallowsNullValues() { + keyValuesMap(null, null); + } } diff --git a/src/test/java/picocli/I18nTest.java b/src/test/java/picocli/I18nTest.java index f0bb79b99..e68159770 100644 --- a/src/test/java/picocli/I18nTest.java +++ b/src/test/java/picocli/I18nTest.java @@ -28,8 +28,10 @@ import picocli.CommandLine.Parameters; import java.io.File; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.ResourceBundle; import static org.junit.Assert.*; @@ -187,6 +189,13 @@ public void testParentCommandWithSharedResourceBundle() { "top command list heading%n" + " help i18n-top HELP command header%n" + " i18n-sub i18n-sub header (only one line)%n" + + "Shared Exit Codes Heading%n" + + "These exit codes are blah blah etc.%n" + + " 00 (From shared bundle) Normal termination%n" + + " 64 (From shared bundle)%n" + + " Multiline!%n" + + " Invalid input%n" + + " 70 (From shared bundle) Internal error%n" + "Shared footer heading%n" + "footer for i18n-top%n"); Locale original = Locale.getDefault(); @@ -223,6 +232,13 @@ public void testParentCommandWithSharedResourceBundle_ja() { "top command list heading%n" + " help i18n-top\u7528 HELP \u30b3\u30de\u30f3\u30c9\u30d8\u30c3\u30c0\u30fc%n" + " i18n-sub i18n-sub \u30d8\u30c3\u30c0\u30fc\uff08\u4e00\u884c\u306e\u307f\uff09%n" + + "\u7d42\u4e86\u30b9\u30c6\u30fc\u30bf\u30b9%n" + + "\u3053\u308c\u3089\u306e\u7d42\u4e86\u30b9\u30c6\u30fc\u30bf\u30b9\u306f\u7b49\u3005%n" + + " 00 (\u5171\u901a\u30d0\u30f3\u30c9\u30eb\u304b\u3089) \u6b63\u5e38\u7d42\u4e86%n" + + " 64 (\u5171\u901a\u30d0\u30f3\u30c9\u30eb\u304b\u3089)%n" + + " \u8907\u6570\u884c!%n" + + " \u7121\u52b9\u306e\u5165\u529b%n" + + " 70 (\u5171\u901a\u30d0\u30f3\u30c9\u30eb\u304b\u3089) \u5185\u90e8\u30a8\u30e9\u30fc%n" + "\u5171\u901a\u30d5\u30c3\u30bf\u30fc\u898b\u51fa\u3057%n" + "i18n-top\u7528\u30d5\u30c3\u30bf\u30fc%n"); Locale original = Locale.getDefault(); @@ -258,6 +274,13 @@ public void testSubcommandUsesParentBundle() { " -z, --zzz= subcmd zzz description%n" + "subcmd command list heading%n" + " help i18n-sub HELP command header%n" + + "Shared Exit Codes Heading%n" + + "These exit codes are blah blah etc.%n" + + " 00 (From shared bundle) Normal termination%n" + + " 64 (From shared bundle)%n" + + " Multiline!%n" + + " Invalid input%n" + + " 70 (From shared bundle) Internal error%n" + "Shared footer heading%n" + "footer for i18n-sub%n"); Locale original = Locale.getDefault(); @@ -321,6 +344,29 @@ public void testSetResourceBundle_descriptionsFromBundle() { } } + @Test + public void testExitCodeMapFromResourceBundle() { + @Command class Noop {} + CommandLine cmd = new CommandLine(new Noop()); + Locale original = Locale.getDefault(); + Locale.setDefault(Locale.ENGLISH); + try { + ResourceBundle rb = ResourceBundle.getBundle("picocli.SharedMessages"); + cmd.setResourceBundle(rb); + CommandLine.Model.UsageMessageSpec usageMessageSpec = cmd.getCommandSpec().usageMessage(); + + assertEquals("Shared Exit Codes Heading%nThese exit codes are blah blah etc.%n", usageMessageSpec.exitCodeListHeading()); + + Map exitCodes = new LinkedHashMap(); + exitCodes.put("00", "(From shared bundle) Normal termination"); + exitCodes.put("64", "(From shared bundle)%nMultiline!%nInvalid input"); + exitCodes.put("70", "(From shared bundle) Internal error"); + assertEquals(exitCodes, usageMessageSpec.exitCodeList()); + } finally { + Locale.setDefault(original); + } + } + @Test public void testSetResourceBundle_overwritesSubcommandBundle() { Locale original = Locale.getDefault(); @@ -536,6 +582,13 @@ public void testLocalizeBuiltInHelp_Shared() { " [COMMAND...] Shared description of COMMAND parameter of built-in help%n" + " subcommand%n" + " -h, --help Shared description of --help option of built-in help subcommand%n" + + "Shared Exit Codes Heading%n" + + "These exit codes are blah blah etc.%n" + + " 00 (From shared bundle) Normal termination%n" + + " 64 (From shared bundle)%n" + + " Multiline!%n" + + " Invalid input%n" + + " 70 (From shared bundle) Internal error%n" + "Shared footer heading%n" + "Shared footer%n"); @@ -564,6 +617,13 @@ public void testLocalizeBuiltInHelp_Specialized() { " subcommand%n" + " -h, --help Specialized description of --help option of i18-top help%n" + " subcommand%n" + + "Shared Exit Codes Heading%n" + + "These exit codes are blah blah etc.%n" + + " 00 (From shared bundle) Normal termination%n" + + " 64 (From shared bundle)%n" + + " Multiline!%n" + + " Invalid input%n" + + " 70 (From shared bundle) Internal error%n" + "Shared footer heading%n" + "Shared footer%n"); diff --git a/src/test/java/picocli/SubcommandTests.java b/src/test/java/picocli/SubcommandTests.java index 43bd3e271..3793218ee 100644 --- a/src/test/java/picocli/SubcommandTests.java +++ b/src/test/java/picocli/SubcommandTests.java @@ -1695,7 +1695,7 @@ class TopLevel {} final List DEFAULT_LIST = Arrays.asList("headerHeading", "header", "synopsisHeading", "synopsis", "descriptionHeading", "description", "parameterListHeading", "parameterList", "optionListHeading", - "optionList", "commandListHeading", "commandList", "footerHeading", "footer"); + "optionList", "commandListHeading", "commandList", "exitCodeListHeading", "exitCodeList", "footerHeading", "footer"); assertEquals(DEFAULT_LIST, commandLine.getHelpSectionKeys()); final List NEW_LIST = Arrays.asList("a", "b", "c"); @@ -1726,7 +1726,7 @@ class TopLevel {} final List DEFAULT_LIST = Arrays.asList("headerHeading", "header", "synopsisHeading", "synopsis", "descriptionHeading", "description", "parameterListHeading", "parameterList", "optionListHeading", - "optionList", "commandListHeading", "commandList", "footerHeading", "footer"); + "optionList", "commandListHeading", "commandList", "exitCodeListHeading", "exitCodeList", "footerHeading", "footer"); assertEquals(DEFAULT_LIST, commandLine.getHelpSectionKeys()); final List NEW_LIST = Arrays.asList("a", "b", "c"); @@ -1755,7 +1755,7 @@ class TopLevel {} final Set DEFAULT_KEYS = new HashSet(Arrays.asList("headerHeading", "header", "synopsisHeading", "synopsis", "descriptionHeading", "description", "parameterListHeading", "parameterList", "optionListHeading", - "optionList", "commandListHeading", "commandList", "footerHeading", "footer")); + "optionList", "commandListHeading", "commandList", "exitCodeListHeading", "exitCodeList", "footerHeading", "footer")); assertEquals(DEFAULT_KEYS, commandLine.getHelpSectionMap().keySet()); Map NEW_MAP = new HashMap(); @@ -1789,7 +1789,7 @@ class TopLevel {} final Set DEFAULT_KEYS = new HashSet(Arrays.asList("headerHeading", "header", "synopsisHeading", "synopsis", "descriptionHeading", "description", "parameterListHeading", "parameterList", "optionListHeading", - "optionList", "commandListHeading", "commandList", "footerHeading", "footer")); + "optionList", "commandListHeading", "commandList", "exitCodeListHeading", "exitCodeList", "footerHeading", "footer")); assertEquals(DEFAULT_KEYS, commandLine.getHelpSectionMap().keySet()); Map NEW_MAP = new HashMap(); diff --git a/src/test/resources/picocli/SharedMessages.properties b/src/test/resources/picocli/SharedMessages.properties index 447c0a78f..4963b8c53 100644 --- a/src/test/resources/picocli/SharedMessages.properties +++ b/src/test/resources/picocli/SharedMessages.properties @@ -11,6 +11,10 @@ usage.description.1 = Shared description 1 usage.description.2 = Shared description 2 usage.footerHeading = Shared footer heading%n usage.footer = Shared footer +usage.exitCodeListHeading = Shared Exit Codes Heading%nThese exit codes are blah blah etc.%n +usage.exitCodeList.0 = 00:(From shared bundle) Normal termination +usage.exitCodeList.1 = 64:(From shared bundle)%nMultiline!%nInvalid input +usage.exitCodeList.2 = 70:(From shared bundle) Internal error i18n-top.usage.footer = footer for i18n-top i18n-top.i18n-sub.usage.footer = footer for i18n-sub diff --git a/src/test/resources/picocli/SharedMessages_ja.properties b/src/test/resources/picocli/SharedMessages_ja.properties index 98f74e9b2..c20fc7046 100644 --- a/src/test/resources/picocli/SharedMessages_ja.properties +++ b/src/test/resources/picocli/SharedMessages_ja.properties @@ -9,6 +9,10 @@ i18n-top.i18n-sub.usage.descriptionHeading = i18n-sub \u8aac\u660e\u898b\u51fa\u usage.description.0 = \u5171\u901a\u8aac\u660e0 usage.description.1 = \u5171\u901a\u8aac\u660e1 usage.description.2 = \u5171\u901a\u8aac\u660e2 +usage.exitCodeListHeading = \u7d42\u4e86\u30b9\u30c6\u30fc\u30bf\u30b9%n\u3053\u308c\u3089\u306e\u7d42\u4e86\u30b9\u30c6\u30fc\u30bf\u30b9\u306f\u7b49\u3005%n +usage.exitCodeList.0 = 00:(\u5171\u901a\u30d0\u30f3\u30c9\u30eb\u304b\u3089) \u6b63\u5e38\u7d42\u4e86 +usage.exitCodeList.1 = 64:(\u5171\u901a\u30d0\u30f3\u30c9\u30eb\u304b\u3089)%n\u8907\u6570\u884c!%n\u7121\u52b9\u306e\u5165\u529b +usage.exitCodeList.2 = 70:(\u5171\u901a\u30d0\u30f3\u30c9\u30eb\u304b\u3089) \u5185\u90e8\u30a8\u30e9\u30fc usage.footerHeading = \u5171\u901a\u30d5\u30c3\u30bf\u30fc\u898b\u51fa\u3057%n usage.footer = \u5171\u901a\u30d5\u30c3\u30bf\u30fc diff --git a/src/test/resources/picocli/exitcodes.properties b/src/test/resources/picocli/exitcodes.properties new file mode 100644 index 000000000..83e7839d4 --- /dev/null +++ b/src/test/resources/picocli/exitcodes.properties @@ -0,0 +1,4 @@ +usage.exitCodeListHeading = Exit Codes:%nThese exit codes are blah blah etc.%n +usage.exitCodeList.0 = \u00200:Normal termination (notice leading space) +usage.exitCodeList.1 = 64:Multiline!%nInvalid input +usage.exitCodeList.2 = 70:Very long line: aaaaa bbbbbbbb ccccc dddddddd eeeeeee fffffffff ggggg hhhh iiii jjjjjjj kkkk lllll mmmmmmmm nn ooooo ppppp qqqqq