Skip to content

Commit

Permalink
[#1125] allow options to consume subcommand or option names
Browse files Browse the repository at this point in the history
  • Loading branch information
remkop committed Feb 19, 2022
1 parent 1a458ff commit a74cb2b
Show file tree
Hide file tree
Showing 9 changed files with 441 additions and 30 deletions.
18 changes: 18 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ class MyIntConverter implements ITypeConverter<Integer> {
}
```

### Enable Consuming Option Names or Subcommands

By default, options that take a parameter do not consume values that match a subcommand name or an option name.

This release introduces two parser configuration options to change this behaviour:

* `CommandLine::setAllowOptionsAsOptionParameters` allows options to consume option names
* `CommandLine::setAllowSubcommandsAsOptionParameters` allows options to consume subcommand names

When set to `true`, all options in the command (options that take a parameter) can consume values that match option names or subcommand names.

This means that any option will consume the maximum number of arguments possible for its <<Arity,arity>>.

USE WITH CAUTION!

If an option is defined as `arity = "*"`, this option will consume _all_ remaining command line arguments following this option (until the <<Double dash (`--`),End-of-options delimiter>>) as parameters of this option.

### Unsorted Synopsis
By default, the synopsis displays options in alphabetical order.
Picocli 4.7.0 introduced a `sortSynopsis = false` attribute to let the synopsis display options in the order they are declared in your class, or sorted by their `order` attribute.
Expand All @@ -58,6 +75,7 @@ Picocli 4.7.0 introduced a `sortSynopsis = false` attribute to let the synopsis

## <a name="4.7.0-fixes"></a> Fixed issues
* [#1471] API: Provide a programmatic way to configure Picocli's `TraceLevel`. Thanks to [ekinano](https://github.com/ekinano) for raising this.
* [#1125] API: Add parser configuration to allow options to consume values that match subcommand names or option names.
* [#1396][#1401] API: Support generic types in containers (e.g. List, Map). Thanks to [Michał Górniewski](https://github.com/mgorniew) for the pull request.
* [#1380][#1505] API, bugfix: `requiredOptionMarker` should not be displayed on `ArgGroup` options. Thanks to [Ahmed El Khalifa](https://github.com/ahmede41) for the pull request.
* [#1563] API: Add constructor to `PicocliSpringFactory` to allow custom fallback `IFactory`. Thanks to [Andrew Holland](https://github.com/a1dutch) for raising this.
Expand Down
28 changes: 26 additions & 2 deletions docs/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -4748,6 +4748,7 @@ This will throw an `UnmatchedArgumentException` with message:


=== Option Names or Subcommands as Option Values
==== By Default Options Do Not Consume Option Names or Subcommands
Since picocli 4.4, the parser will no longer assign values that match a subcommand name or an option name to options that take a parameter, unless the value is in quotes.
For example:

Expand Down Expand Up @@ -4789,7 +4790,7 @@ For example:

[source,bash]
----
java App -x="-y"
java App -x=\"-y\"
----

This will print the following output:
Expand All @@ -4799,7 +4800,30 @@ This will print the following output:
x='-y', y='null'
----

Another idea is to replace or augment picocli's parser by doing <<Custom Parameter Processing,custom parameter processing>> for such options. For example:
==== Enable Consuming Option Names or Subcommands

Picocli 4.7.0 introduces two parser configuration options to change this behaviour:

* `CommandLine::setAllowOptionsAsOptionParameters` allows options to consume option names
* `CommandLine::setAllowSubcommandsAsOptionParameters` allows options to consume subcommand names

When set to `true`, all options in the command (options that take a parameter) can consume values that match option names or subcommand names.

This means that any option will consume the maximum number of arguments possible for its <<Arity,arity>>.

[CAUTION]
====
USE WITH CAUTION!
If an option is defined as `arity = "*"`, this option will consume _all_ remaining command line arguments following this option (until the <<Double dash (`--`),End-of-options delimiter>>) as parameters of this option.
====

==== Custom Parsing for Option-Specific Behaviour
The parser configuration options in the previous section apply to _all_ options in the command.

Some applications may want to enable options consuming option names or subcommands for _some_ options, but not for all options in the command.
Such applications can replace or augment picocli's parser by doing <<Custom Parameter Processing,custom parameter processing>> for such options.
For example:

.Java
[source,java,role="primary"]
Expand Down
142 changes: 120 additions & 22 deletions src/main/java/picocli/CommandLine.java

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions src/test/java/picocli/ArgGroupTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2149,6 +2149,24 @@ public void testIssue1055Case2() {
}
}

@Test // https://github.com/remkop/picocli/issues/1125
public void testIssue1125Case1a() {
//-f -f -w text --> accepted --> wrong: findPattern = "-f", means, the second -f is treated as an option-parameter for the first -f
Issue1054 bean = new Issue1054();
new CommandLine(bean).setAllowOptionsAsOptionParameters(true).parseArgs("-f -f -w text".split(" "));
assertEquals("-f", bean.modifications.get(0).findPattern.pattern());
assertEquals("text", bean.modifications.get(0).change.replacement);
}

@Test // https://github.com/remkop/picocli/issues/1125
public void testIssue1125Case1b() {
//-f pattern -w -d --> wrong: replacement = "-d", means -d is treated as an option-parameter for -w
Issue1054 bean = new Issue1054();
new CommandLine(bean).setAllowOptionsAsOptionParameters(true).parseArgs("-f pattern -w -d".split(" "));
assertEquals("pattern", bean.modifications.get(0).findPattern.pattern());
assertEquals("-d", bean.modifications.get(0).change.replacement);
}

static class CompositeGroupSynopsisDemo {

@ArgGroup(exclusive = false, multiplicity = "2..*")
Expand Down
101 changes: 101 additions & 0 deletions src/test/java/picocli/ArityTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,24 @@ class ArrayOptionsArity2_nAndParameters {
assertArrayEquals(new String[] {"5.5"}, params.stringParams);
}

@Test
public void test1125_ArrayOptionArity2_nConsumesAllArgumentsWhenAllowOptionsAsOptionParameters() {
class ArrayOptionsArity2_nAndParameters {
@Parameters String[] stringParams;
@Option(names = "-s", arity = "2..*") String[] stringOptions;
@Option(names = "-v") boolean verbose;
@Option(names = "-f") File file;
}
ArrayOptionsArity2_nAndParameters params = new ArrayOptionsArity2_nAndParameters();
new CommandLine(params).setAllowOptionsAsOptionParameters(true)
.parseArgs("-s 1.1 2.2 3.3 4.4 -vfFILE 5.5".split(" "));
assertArrayEquals(Arrays.toString(params.stringOptions),
new String[] {"1.1", "2.2", "3.3", "4.4", "-vfFILE", "5.5"}, params.stringOptions);
assertFalse(params.verbose);
assertEquals(null, params.file);
assertNull(params.stringParams);
}

@Test
public void testArrayOptionArity2_nConsumesAllArgumentIncludingQuotedSimpleOption() {
class ArrayOptionArity2_nAndParameters {
Expand Down Expand Up @@ -1373,7 +1391,64 @@ private void assertMissing(String expected, Object command, String... args) {
} catch (MissingParameterException ex) {
assertEquals(expected, ex.getMessage());
}
}
@Test
public void test1125_ArityValidation() {
class Cmd {
@Option(names = "-a", arity = "2") String[] a;
@Option(names = "-b", arity = "1..2") String[] b;
@Option(names = "-c", arity = "2..3") String[] c;
@Option(names = "-v") boolean verbose;
}
assertWithAllowOptions("Unmatched argument at index 3: '2'",
new Cmd(), "-a", "1", "-a", "2");

Cmd bean = new Cmd();
new CommandLine(bean).setAllowOptionsAsOptionParameters(true).parseArgs("-a", "1", "-v");
assertArrayEquals(new String[]{"1", "-v"}, bean.a);

bean = new Cmd();
new CommandLine(bean).setAllowOptionsAsOptionParameters(true).parseArgs("-b", "-v");
assertArrayEquals(new String[]{"-v"}, bean.b);

assertWithAllowOptions("option '-c' at index 0 (<c>) requires at least 2 values, but only 1 were specified: [-a]",
new Cmd(), "-c", "-a");

bean = new Cmd();
new CommandLine(bean).setAllowOptionsAsOptionParameters(true).parseArgs("-c", "-a", "1", "2");
assertArrayEquals(new String[]{"-a", "1", "2"}, bean.c);

bean = new Cmd();
new CommandLine(bean).setAllowOptionsAsOptionParameters(true).parseArgs("-c", "1", "-a");
assertArrayEquals(new String[]{"1", "-a"}, bean.c);
}
@Test
public void test1125_ArityValidationWithMaps() {
class Cmd {
@Option(names = "-a", arity = "2") Map<String,String> a;
@Option(names = "-b", arity = "1..2") Map<String,String> b;
@Option(names = "-c", arity = "2..3") Map<String,String> c;
@Option(names = "-v") boolean verbose;
}
assertWithAllowOptions("Value for option option '-a' at index 0 (<String=String>) should be in KEY=VALUE format but was -a",
new Cmd(), "-a", "A=B", "-a", "C=D");

assertWithAllowOptions("Value for option option '-a' at index 0 (<String=String>) should be in KEY=VALUE format but was -v",
new Cmd(), "-a", "A=B", "-v");

assertWithAllowOptions("Value for option option '-b' at index 0 (<String=String>) should be in KEY=VALUE format but was -v",
new Cmd(), "-b", "-v");

assertWithAllowOptions("Value for option option '-c' at index 0 (<String=String>) should be in KEY=VALUE format but was -a",
new Cmd(), "-c", "A=B", "-a");
}
private void assertWithAllowOptions(String expected, Object command, String... args) {
try {
new CommandLine(command).setAllowOptionsAsOptionParameters(true).parseArgs(args);
fail("Expected unmatched arg exception");
} catch (ParameterException ex) {
assertEquals(expected, ex.getMessage());
}
}

@Test
Expand Down Expand Up @@ -1812,6 +1887,32 @@ class App {
assertEquals(Arrays.asList("a", "b", ";", "x", "y"), app.option);
}

@Test
public void test1125_CustomEndOfOptionsDelimiter() {
class App {
@Option(names = "-x", arity = "*")
List<String> option;

@Unmatched
List<String> unmatched;
}

App app = new App();
new CommandLine(app).setAllowOptionsAsOptionParameters(true).setEndOfOptionsDelimiter(";")
.parseArgs("-x", "a", "b", ";", "x", "y");
assertEquals(Arrays.asList("a", "b"), app.option);
assertEquals(Arrays.asList("x", "y"), app.unmatched);

app = new App();
new CommandLine(app).setAllowOptionsAsOptionParameters(true).parseArgs("-x", "a", "b", "--", "x", "y");
assertEquals(Arrays.asList("a", "b"), app.option);
assertEquals(Arrays.asList("x", "y"), app.unmatched);

app = new App();
new CommandLine(app).setAllowOptionsAsOptionParameters(true).parseArgs("-x", "a", "b", ";", "x", "y");
assertEquals(Arrays.asList("a", "b", ";", "x", "y"), app.option);
}

@Test
public void testUnmatchedListCleared() {
class App {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public boolean preprocess(Stack<String> args, CommandSpec commandSpec, ArgSpec a
}
}

@Option(names = "-option")
File option = new File(".");

@Option(names = "-x") String x;
@Option(names = "-y") String y;

Expand All @@ -54,7 +57,7 @@ public int mySubcommand() {
}

@Test
public void testSubcommandAsOptionName() {
public void testSubcommandAsOptionNameWithCustomPreprocessor() {
MyCommand obj = new MyCommand();
CommandLine cmdLine = new CommandLine(obj);
int exitCode = cmdLine.execute("-output", "abc", "mySubcommand");
Expand All @@ -71,19 +74,66 @@ public void testSubcommandAsOptionName() {
}

@Test
public void testAmbiguousOptions() {
public void testSubcommandAsOptionNameMaybeEnabled() {
MyCommand obj = new MyCommand();
CommandLine cmdLine = new CommandLine(obj).setAllowSubcommandsAsOptionParameters(true);
int exitCode = cmdLine.execute("-option", "abc", "mySubcommand");
assertEquals(13, exitCode);
assertEquals("abc", obj.option.getName());

exitCode = cmdLine.execute("-option=mySubcommand", "mySubcommand");
assertEquals(13, exitCode);
assertEquals("mySubcommand", obj.option.getName());

exitCode = cmdLine.execute("-option", "mySubcommand", "mySubcommand");
assertEquals(13, exitCode);
assertEquals("mySubcommand", obj.option.getName());
}

@Test
public void testSubcommandAsOptionNameDefault() {
MyCommand obj = new MyCommand();
CommandLine cmdLine = new CommandLine(obj);
int exitCode = cmdLine.execute("-option", "abc", "mySubcommand");
assertEquals(13, exitCode);
assertEquals("abc", obj.option.getName());

try {
cmdLine.parseArgs("-option=mySubcommand", "mySubcommand");
fail("expected exception");
} catch (CommandLine.MissingParameterException ex) {
assertEquals("Expected parameter for option '-option' but found 'mySubcommand'", ex.getMessage());
}
}

@Test
public void testAmbiguousOptionsDefault() {
//-x -y=123
MyCommand obj = new MyCommand();
CommandLine cmdLine = new CommandLine(obj);
int exitCode = cmdLine.execute("-x", "-y=123");
assertEquals(2, exitCode);
String expected = String.format("Expected parameter for option '-x' but found '-y=123'%n" +
"Usage: mycommand [-output=<output>] [-x=<x>] [-y=<y>] [COMMAND]%n" +
String expected = String.format("" +
"Expected parameter for option '-x' but found '-y=123'%n" +
"Usage: mycommand [-option=<option>] [-output=<output>] [-x=<x>] [-y=<y>]%n" +
" [COMMAND]%n" +
" -option=<option>%n" +
" -output=<output>%n" +
" -x=<x>%n" +
" -y=<y>%n" +
"Commands:%n" +
" mySubcommand%n");
assertEquals(expected, systemErrRule.getLog());
}

@Test
public void testAmbiguousOptionsWithOptionsAsOptionParametersEnabled() {
//-x -y=123
MyCommand obj = new MyCommand();
CommandLine cmdLine = new CommandLine(obj).setAllowOptionsAsOptionParameters(true);
int exitCode = cmdLine.execute("-x", "-y=123");
assertEquals(11, exitCode);
assertEquals("-y=123", obj.x);
assertNull(obj.y);
}
}
8 changes: 8 additions & 0 deletions src/test/java/picocli/RepeatableSubcommandsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,14 @@ public void testMultivalueOptions() {
assertEquals(4, exitCode);
}

@Test
public void test1125_MultivalueOptions_ConsumesSubcommands() {
int exitCode = new CommandLine(new MultivalueTop())
.setAllowSubcommandsAsOptionParameters(true)
.execute("sub1 -x1 -x2 -x3 sub2 -y1 -y2 -y3 -y4".split(" "));
assertEquals(8, exitCode); // 8 args of the first sub1
}

@Test
public void testCommandSpec_SubcommandsRepeatable() {
class Positional {
Expand Down
Loading

0 comments on commit a74cb2b

Please sign in to comment.