diff --git a/docs/index.adoc b/docs/index.adoc index e28761c65..8d0ad0573 100644 --- a/docs/index.adoc +++ b/docs/index.adoc @@ -1039,6 +1039,9 @@ assert(mixed.positional == Arrays.asList("param0", "param1", "param2", "param3") assert(mixed.options == Arrays.asList("AAA", "BBB")) ---- +Note that the mixing of positional parameters and options is configurable, see <>. + + === Double dash (`--`) When one of the command line arguments is just two dashes without any characters attached (`--`), picocli interprets all following arguments as positional parameters, even arguments that match an option name. @@ -5261,6 +5264,12 @@ java App -x -y -y=123 ---- +=== End of Options Behaviour +Since picocli 2.0, positional parameters can be specified anywhere on the command line and no longer need to follow the options. +Starting with v4.8.0, `CommandLine::setParameterAllowedBeforeEndOfOptions` can be set to false in order to restrict +the mixing of options and positional parameters. This option forces positional parameters to follow the <>. + + === Toggle Boolean Flags When a flag option is specified on the command line picocli will set its value to the opposite of its _default_ value. diff --git a/src/main/java/picocli/CommandLine.java b/src/main/java/picocli/CommandLine.java index e4b730511..944fa5f1c 100644 --- a/src/main/java/picocli/CommandLine.java +++ b/src/main/java/picocli/CommandLine.java @@ -1104,6 +1104,33 @@ public CommandLine setUnmatchedOptionsArePositionalParams(boolean newValue) { return this; } + /** Returns whether positional parameters on the command line are allowed to occur before the special End of Options delimiter. + * The default is {@code true}. + * @return {@code true} positional parameters may occur anywhere on the command line, {@code false} if they must follow End of Options. + * @since 4.8.0 + */ + public boolean isParameterAllowedBeforeEndOfOptions() { + return getCommandSpec().parser().parameterAllowedBeforeEndOfOptions(); + } + + /** Sets whether positional parameters on the command line are allowed to occur before the special End of Options delimiter. + * The default is {@code true}. + *

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 newValue the new setting. When {@code false}, positional parameters must follow the special End of Options delimiter. + * @return this {@code CommandLine} object, to allow method chaining + * @since 4.8.0 + */ + public CommandLine setParameterAllowedBeforeEndOfOptions(boolean newValue) { + getCommandSpec().parser().parameterAllowedBeforeEndOfOptions(newValue); + for (CommandLine command : getCommandSpec().subcommands().values()) { + command.setParameterAllowedBeforeEndOfOptions(newValue); + } + return this; + } + /** Returns whether the end user may specify arguments on the command line that are not matched to any option or parameter fields. * The default is {@code false} and a {@link UnmatchedArgumentException} is thrown if this happens. * When {@code true}, the last unmatched arguments are available via the {@link #getUnmatchedArguments()} method. @@ -8546,6 +8573,7 @@ public static class ParserSpec { private boolean unmatchedArgumentsAllowed = false; private boolean unmatchedOptionsAllowedAsOptionParameters = true; private boolean unmatchedOptionsArePositionalParams = false; + private boolean parameterAllowedBeforeEndOfOptions = true; private boolean useSimplifiedAtFiles = false; /** Returns the String to use as the separator between options and option parameters. {@code "="} by default, @@ -8596,6 +8624,7 @@ public boolean useSimplifiedAtFiles() { public boolean splitQuotedStrings() { return splitQuotedStrings; } /** @see CommandLine#isUnmatchedOptionsArePositionalParams() */ public boolean unmatchedOptionsArePositionalParams() { return unmatchedOptionsArePositionalParams; } + public boolean parameterAllowedBeforeEndOfOptions() { return parameterAllowedBeforeEndOfOptions; } /** * @see CommandLine#isUnmatchedOptionsAllowedAsOptionParameters() * @since 4.4 */ @@ -8664,6 +8693,10 @@ public boolean useSimplifiedAtFiles() { public ParserSpec unmatchedOptionsAllowedAsOptionParameters(boolean unmatchedOptionsAllowedAsOptionParameters) { this.unmatchedOptionsAllowedAsOptionParameters = unmatchedOptionsAllowedAsOptionParameters; return this; } /** @see CommandLine#setUnmatchedOptionsArePositionalParams(boolean) */ public ParserSpec unmatchedOptionsArePositionalParams(boolean unmatchedOptionsArePositionalParams) { this.unmatchedOptionsArePositionalParams = unmatchedOptionsArePositionalParams; return this; } + /** + * @see CommandLine#setParameterAllowedBeforeEndOfOptions(boolean) + * @since 4.8.0*/ + public ParserSpec parameterAllowedBeforeEndOfOptions(boolean allowParametersBeforeEndOfOptions) { this.parameterAllowedBeforeEndOfOptions = allowParametersBeforeEndOfOptions; return this; } /** * @see CommandLine#setAllowSubcommandsAsOptionParameters(boolean) * @since 4.7.6-SNAPSHOT */ @@ -8699,14 +8732,16 @@ public String toString() { "limitSplit=%s, overwrittenOptionsAllowed=%s, posixClusteredShortOptionsAllowed=%s, " + "separator=%s, splitQuotedStrings=%s, stopAtPositional=%s, stopAtUnmatched=%s, " + "toggleBooleanFlags=%s, trimQuotes=%s, " + - "unmatchedArgumentsAllowed=%s, unmatchedOptionsAllowedAsOptionParameters=%s, unmatchedOptionsArePositionalParams=%s, useSimplifiedAtFiles=%s", + "unmatchedArgumentsAllowed=%s, unmatchedOptionsAllowedAsOptionParameters=%s, " + + "unmatchedOptionsArePositionalParams=%s, allowParametersBeforeEndOfOptions=%s, useSimplifiedAtFiles=%s", abbreviatedOptionsAllowed, abbreviatedSubcommandsAllowed, allowOptionsAsOptionParameters, allowSubcommandsAsOptionParameters, aritySatisfiedByAttachedOptionParam, atFileCommentChar, caseInsensitiveEnumValuesAllowed, collectErrors, endOfOptionsDelimiter, expandAtFiles, limitSplit, overwrittenOptionsAllowed, posixClusteredShortOptionsAllowed, separator, splitQuotedStrings, stopAtPositional, stopAtUnmatched, toggleBooleanFlags, trimQuotes, - unmatchedArgumentsAllowed, unmatchedOptionsAllowedAsOptionParameters, unmatchedOptionsArePositionalParams, useSimplifiedAtFiles); + unmatchedArgumentsAllowed, unmatchedOptionsAllowedAsOptionParameters, + unmatchedOptionsArePositionalParams, parameterAllowedBeforeEndOfOptions, useSimplifiedAtFiles); } void initFrom(ParserSpec settings) { @@ -8732,6 +8767,7 @@ void initFrom(ParserSpec settings) { unmatchedArgumentsAllowed = settings.unmatchedArgumentsAllowed; unmatchedOptionsAllowedAsOptionParameters = settings.unmatchedOptionsAllowedAsOptionParameters; unmatchedOptionsArePositionalParams = settings.unmatchedOptionsArePositionalParams; + parameterAllowedBeforeEndOfOptions = settings.parameterAllowedBeforeEndOfOptions; useSimplifiedAtFiles = settings.useSimplifiedAtFiles; } } @@ -13659,7 +13695,11 @@ private void validateConstraints(Stack argumentStack, List requ for (UnmatchedArgsBinding unmatchedArgsBinding : getCommandSpec().unmatchedArgsBindings()) { unmatchedArgsBinding.addAll(unmatched.clone()); } - if (!isUnmatchedArgumentsAllowed()) { maybeThrow(new UnmatchedArgumentException(CommandLine.this, Collections.unmodifiableList(parseResultBuilder.unmatched))); } + if (!isUnmatchedArgumentsAllowed()) { + String extraMsg = ""; + if (!isParameterAllowedBeforeEndOfOptions()) { extraMsg = ". Positional parameters must follow the EndOfOptions delimiter '" + getEndOfOptionsDelimiter() + "'."; } + maybeThrow(new UnmatchedArgumentException(CommandLine.this, Collections.unmodifiableList(parseResultBuilder.unmatched), extraMsg)); + } Tracer tracer = CommandLine.tracer(); if (tracer.isInfo()) { tracer.info("Unmatched arguments: %s", parseResultBuilder.unmatched); } } @@ -13932,6 +13972,10 @@ private void processPositionalParameter(Collection required, Set list = new ArrayList(); + public void run() { } + } + + static class SubCommand implements Runnable { + @CommandLine.Option(names = "--optB") String optB; + @CommandLine.Parameters() + final List list = new ArrayList(); + public void run() { } + } + + /** + * Original behavior allows positional parameters before and after EndOfOptions delimiter. + */ + @Test + public void testOriginalBehavior() { + App app = CommandLine.populateCommand(new App(), "--optA joe a b -- --optB c d".split(" ")); + assertEquals("joe", app.optA); + assertEquals(Arrays.asList("a", "b", "--optB", "c", "d"), app.list); + } + + /** + * The default value for allowing parameters prior to the End Of Options delimiter should be true + * in order to maintain backward compatibility with previous releases. + */ + @Test + public void testOriginalDefault() { + App app = new App(); + CommandLine c = new CommandLine(app); + assertTrue(c.isParameterAllowedBeforeEndOfOptions()); + //verify ParserSpec getter (should return same value as CommandLine setting + assertTrue(c.getCommandSpec().parser().parameterAllowedBeforeEndOfOptions()); + + // Toggle value for setting and verify (tests setter, and verifies getters) + c.getCommandSpec().parser().parameterAllowedBeforeEndOfOptions(false); + assertFalse(c.getCommandSpec().parser().parameterAllowedBeforeEndOfOptions()); + assertFalse(c.isParameterAllowedBeforeEndOfOptions()); + } + + /** + * When ParameterAllowedBeforeEndOfOptions is disabled, the exit code for USAGE should be returned + * when positional parameters are found before EndOfOptions delimiter. + */ + @Test + public void testTriggerUsage() { + App app = new App(); + int exitCode = new CommandLine(app) + .setParameterAllowedBeforeEndOfOptions(false) + .execute("--optA joe a b -- --optB c d".split(" ")); + assertEquals(2, exitCode); // Should exit with USAGE since a and b are unmatched arguments + assertEquals("joe", app.optA); + assertEquals(Arrays.asList("--optB", "c", "d"), app.list); + } + + /** + * Using a valid command line with ParameterAllowedBeforeEndOfOptions disabled, should correctly parse the options + * after the EndOfOptions delimiter as well as the valid options before the delimiter. + */ + @Test + public void testParameterAllowedBeforeEndOfOptions() { + App app = new App(); + int exitCode = new CommandLine(app) + .setParameterAllowedBeforeEndOfOptions(false) + .execute("--optA joe -- --optB c d".split(" ")); + assertEquals(0, exitCode); + assertEquals("joe", app.optA); + assertEquals(Arrays.asList("--optB", "c", "d"), app.list); + } + + /** + * Subcommand tests for ParameterAllowedBeforeEndOfOptions. + */ + @Test + public void testParameterAllowedBeforeEndOfOptionsSubCommand1() { + class SubCommandZ implements Runnable { + @CommandLine.Option(names = "--optZ") String optZ; + @CommandLine.Parameters() + final List list = new ArrayList(); + public void run() { } + } + + CommandLine cl = new CommandLine(new App()) + .addSubcommand("cmdA", new SubCommand()) + .setParameterAllowedBeforeEndOfOptions(false) + .addSubcommand("cmdZ", new SubCommandZ()); + + // The ParameterAllowedBeforeEndOfOptions should apply to both main command and subcommands + // The extra "a1" after "jack" should be rejected. + assertEquals(2, cl.execute("--optA jill cmdA --optB jack a1 -- --optC c d".split(" "))); + // The extra "a2" after "jill" should be rejected + assertEquals(2, cl.execute("--optA jill a2 cmdA --optB jack -- --optC c d".split(" "))); + /* The extra "a3" after "hill" should NOT be rejected since the setParameterAllowedBeforeEndOfOptions was + called before the subcommand was added. */ + assertEquals(0, cl.execute("--optA jill cmdZ --optZ hill a3 -- --optC c d".split(" "))); + } + + /** + * Subcommand tests for ParameterAllowedBeforeEndOfOptions. + */ + @Test + public void testParameterAllowedBeforeEndOfOptionsSubCommand2() { + App app = new App(); + SubCommand sub = new SubCommand(); + int exitCode = new CommandLine(app) + .addSubcommand("cmdA", sub) + .execute("--optA jill cmdA --optB jack a -- --optC c d".split(" ")); + // the extra "a" after "jack" should be accepted, because ParameterAllowedBeforeEndOfOptions was not set + assertEquals(0, exitCode); + assertEquals("jill", app.optA); + assertEquals("jack", sub.optB); + assertTrue(app.list.isEmpty()); + assertEquals(Arrays.asList("a", "--optC", "c", "d"), sub.list); + } + + /** + * Validate that the setParameterAllowedBeforeEndOfOptions triggers a new message for unmatched positional arguments. + */ + @Test + public void testUnmatchedArgumentMessageAsFalse() { + App app = new App(); + CommandLine cl = new CommandLine(app) + .setParameterAllowedBeforeEndOfOptions(false); + try { + CommandLine.populateCommand(cl, "--optA joe a1 -- --optB c d".split(" ")); + fail("Unmatched positional argument should have thrown exception"); + } catch (CommandLine.UnmatchedArgumentException ex) { + assertEquals("Unmatched argument at index 2: 'a1'. Positional parameters must follow the EndOfOptions delimiter '--'.", ex.getMessage()); + } + } + + /** + * Verify that the setParameterAllowedBeforeEndOfOptions triggers the original message when not set. + */ + @Test + public void testUnmatchedArgumentMessageAsDefault() { + class UnmatchedApp implements Runnable { + @CommandLine.Option(names = "--optA") String optA; + public void run() { } + } + + UnmatchedApp app = new UnmatchedApp(); + CommandLine cl = new CommandLine(app); + try { + CommandLine.populateCommand(cl, "--optA joe a1".split(" ")); + fail("Unmatched positional argument should have thrown exception"); + } catch (CommandLine.UnmatchedArgumentException ex) { + assertEquals("Unmatched argument at index 2: 'a1'", ex.getMessage()); + } + } +} diff --git a/src/test/java/picocli/TracerTest.java b/src/test/java/picocli/TracerTest.java index c73444ae4..aaf1a965a 100644 --- a/src/test/java/picocli/TracerTest.java +++ b/src/test/java/picocli/TracerTest.java @@ -63,7 +63,7 @@ public void testDebugOutputForDoubleDashSeparatesPositionalParameters() throws E "[picocli DEBUG] Creating CommandSpec for picocli.CommandLineTest$CompactFields@20f5239f with factory picocli.CommandLine$DefaultFactory%n" + "[picocli INFO] Picocli version: %3$s%n" + "[picocli INFO] Parsing 6 command line args [-oout, --, -r, -v, p1, p2]%n" + - "[picocli DEBUG] Parser configuration: optionsCaseInsensitive=false, subcommandsCaseInsensitive=false, abbreviatedOptionsAllowed=false, abbreviatedSubcommandsAllowed=false, allowOptionsAsOptionParameters=false, allowSubcommandsAsOptionParameters=false, aritySatisfiedByAttachedOptionParam=false, atFileCommentChar=#, caseInsensitiveEnumValuesAllowed=false, collectErrors=false, endOfOptionsDelimiter=--, expandAtFiles=true, limitSplit=false, overwrittenOptionsAllowed=false, posixClusteredShortOptionsAllowed=true, separator=null, splitQuotedStrings=false, stopAtPositional=false, stopAtUnmatched=false, toggleBooleanFlags=false, trimQuotes=false, unmatchedArgumentsAllowed=false, unmatchedOptionsAllowedAsOptionParameters=true, unmatchedOptionsArePositionalParams=false, useSimplifiedAtFiles=false%n" + + "[picocli DEBUG] Parser configuration: optionsCaseInsensitive=false, subcommandsCaseInsensitive=false, abbreviatedOptionsAllowed=false, abbreviatedSubcommandsAllowed=false, allowOptionsAsOptionParameters=false, allowSubcommandsAsOptionParameters=false, aritySatisfiedByAttachedOptionParam=false, atFileCommentChar=#, caseInsensitiveEnumValuesAllowed=false, collectErrors=false, endOfOptionsDelimiter=--, expandAtFiles=true, limitSplit=false, overwrittenOptionsAllowed=false, posixClusteredShortOptionsAllowed=true, separator=null, splitQuotedStrings=false, stopAtPositional=false, stopAtUnmatched=false, toggleBooleanFlags=false, trimQuotes=false, unmatchedArgumentsAllowed=false, unmatchedOptionsAllowedAsOptionParameters=true, unmatchedOptionsArePositionalParams=false, allowParametersBeforeEndOfOptions=true, useSimplifiedAtFiles=false%n" + "[picocli DEBUG] (ANSI is disabled by default: ...)%n" + "[picocli DEBUG] Initializing command 'null' (user object: picocli.CommandLineTest$CompactFields@20f5239f): 3 options, 1 positional parameters, 0 required, 0 groups, 0 subcommands.%n" + "[picocli DEBUG] Set initial value for field boolean picocli.CommandLineTest$CompactFields.verbose of type boolean to false.%n" + @@ -293,7 +293,7 @@ public void testTracingDebugWithSubCommands() throws Exception { "[picocli DEBUG] Adding subcommand 'tag' to 'git'%n" + "[picocli INFO] Picocli version: %3$s%n" + "[picocli INFO] Parsing 8 command line args [--git-dir=/home/rpopma/picocli, commit, -m, \"Fixed typos\", --, src1.java, src2.java, src3.java]%n" + - "[picocli DEBUG] Parser configuration: optionsCaseInsensitive=false, subcommandsCaseInsensitive=false, abbreviatedOptionsAllowed=false, abbreviatedSubcommandsAllowed=false, allowOptionsAsOptionParameters=false, allowSubcommandsAsOptionParameters=false, aritySatisfiedByAttachedOptionParam=false, atFileCommentChar=#, caseInsensitiveEnumValuesAllowed=false, collectErrors=false, endOfOptionsDelimiter=--, expandAtFiles=true, limitSplit=false, overwrittenOptionsAllowed=false, posixClusteredShortOptionsAllowed=true, separator=null, splitQuotedStrings=false, stopAtPositional=false, stopAtUnmatched=false, toggleBooleanFlags=false, trimQuotes=false, unmatchedArgumentsAllowed=false, unmatchedOptionsAllowedAsOptionParameters=true, unmatchedOptionsArePositionalParams=false, useSimplifiedAtFiles=false%n" + + "[picocli DEBUG] Parser configuration: optionsCaseInsensitive=false, subcommandsCaseInsensitive=false, abbreviatedOptionsAllowed=false, abbreviatedSubcommandsAllowed=false, allowOptionsAsOptionParameters=false, allowSubcommandsAsOptionParameters=false, aritySatisfiedByAttachedOptionParam=false, atFileCommentChar=#, caseInsensitiveEnumValuesAllowed=false, collectErrors=false, endOfOptionsDelimiter=--, expandAtFiles=true, limitSplit=false, overwrittenOptionsAllowed=false, posixClusteredShortOptionsAllowed=true, separator=null, splitQuotedStrings=false, stopAtPositional=false, stopAtUnmatched=false, toggleBooleanFlags=false, trimQuotes=false, unmatchedArgumentsAllowed=false, unmatchedOptionsAllowedAsOptionParameters=true, unmatchedOptionsArePositionalParams=false, allowParametersBeforeEndOfOptions=true, useSimplifiedAtFiles=false%n" + "[picocli DEBUG] (ANSI is disabled by default: ...)%n" + "[picocli DEBUG] Initializing command 'git' (user object: picocli.Demo$Git@75d4a5c2): 3 options, 0 positional parameters, 0 required, 0 groups, 12 subcommands.%n" + "[picocli DEBUG] Set initial value for field java.io.File picocli.Demo$Git.gitDir of type class java.io.File to null.%n" +