Skip to content

Commit 0edf4eb

Browse files
committed
Fix parser handling of positional args
- Change to have better support for args like: "--arg1 a --arg2 b" "--arg1 a --arg2 b c" "--arg1 a c --arg2 b c" "c --arg1 a --arg2 b" where option can have default values and position of positional args doesn't matter that much. - Make parser to be aware of if it's handling last option so that we can differentiate if error can be given i.e. with too many args, etc. - Backport #795 - Fixes #797
1 parent d159245 commit 0edf4eb

File tree

4 files changed

+392
-22
lines changed

4 files changed

+392
-22
lines changed

spring-shell-core/src/main/java/org/springframework/shell/command/parser/Parser.java

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class DefaultNodeVisitor extends AbstractNodeVisitor {
149149
private List<OptionNode> invalidOptionNodes = new ArrayList<>();
150150
private List<ArgumentResult> argumentResults = new ArrayList<>();
151151
private int commandArgumentPos = 0;
152+
private int optionPos = -1;
153+
private long expectedOptionCount;
152154

153155
DefaultNodeVisitor(CommandModel commandModel, ConversionService conversionService, ParserConfig config) {
154156
this.commandModel = commandModel;
@@ -166,31 +168,27 @@ protected ParseResult buildResult() {
166168
messageResults.addAll(commonMessageResults);
167169
messageResults.addAll(validateOptionIsValid(registration));
168170

169-
170-
// add options with default values
171-
Set<CommandOption> resolvedOptions1 = optionResults.stream()
171+
// we should already have options defined with arguments.
172+
// go through positional arguments and fill using those and
173+
// then fill from option default values.
174+
Set<CommandOption> resolvedOptions = optionResults.stream()
172175
.map(or -> or.option())
173176
.collect(Collectors.toSet());
174-
registration.getOptions().stream()
175-
.filter(o -> o.getDefaultValue() != null)
176-
.filter(o -> !resolvedOptions1.contains(o))
177-
.forEach(o -> {
178-
resolvedOptions1.add(o);
179-
Object value = convertOptionType(o, o.getDefaultValue());
180-
optionResults.add(OptionResult.of(o, value));
181-
});
182177

178+
// get sorted list by position as we later match by order
183179
List<CommandOption> optionsForArguments = registration.getOptions().stream()
184-
.filter(o -> !resolvedOptions1.contains(o))
180+
.filter(o -> !resolvedOptions.contains(o))
185181
.filter(o -> o.getPosition() > -1)
186182
.sorted(Comparator.comparingInt(o -> o.getPosition()))
187183
.collect(Collectors.toList());
188184

185+
// leftover arguments to match into needed options
189186
List<String> argumentValues = argumentResults.stream()
190187
.sorted(Comparator.comparingInt(ar -> ar.position()))
191188
.map(ar -> ar.value())
192189
.collect(Collectors.toList());
193190

191+
// try to find matching arguments
194192
int i = 0;
195193
for (CommandOption o : optionsForArguments) {
196194
int aMax = o.getArityMax();
@@ -202,11 +200,17 @@ protected ParseResult buildResult() {
202200

203201
List<String> asdf = argumentValues.subList(i, j);
204202
if (asdf.isEmpty()) {
205-
optionResults.add(OptionResult.of(o, null));
203+
// don't arguments so only add if we know
204+
// it's going to get added later via default value
205+
if (o.getDefaultValue() == null) {
206+
resolvedOptions.add(o);
207+
optionResults.add(OptionResult.of(o, null));
208+
}
206209
}
207210
else {
208211
Object toConvertValue = asdf.size() == 1 ? asdf.get(0) : asdf;
209212
Object value = convertOptionType(o, toConvertValue);
213+
resolvedOptions.add(o);
210214
optionResults.add(OptionResult.of(o, value));
211215
}
212216

@@ -216,6 +220,16 @@ protected ParseResult buildResult() {
216220
i = j;
217221
}
218222

223+
// possibly fill in from default values
224+
registration.getOptions().stream()
225+
.filter(o -> o.getDefaultValue() != null)
226+
.filter(o -> !resolvedOptions.contains(o))
227+
.forEach(o -> {
228+
resolvedOptions.add(o);
229+
Object value = convertOptionType(o, o.getDefaultValue());
230+
optionResults.add(OptionResult.of(o, value));
231+
});
232+
219233
// can only validate after optionResults has been populated
220234
messageResults.addAll(validateOptionNotMissing(registration));
221235
}
@@ -234,6 +248,7 @@ protected void onExitDirectiveNode(DirectiveNode node) {
234248

235249
@Override
236250
protected void onEnterRootCommandNode(CommandNode node) {
251+
expectedOptionCount = optionCountInCommand(node);
237252
resolvedCommmand.add(node.getCommand());
238253
}
239254

@@ -243,6 +258,7 @@ protected void onExitRootCommandNode(CommandNode node) {
243258

244259
@Override
245260
protected void onEnterCommandNode(CommandNode node) {
261+
expectedOptionCount = optionCountInCommand(node);
246262
resolvedCommmand.add(node.getCommand());
247263
}
248264

@@ -254,6 +270,7 @@ protected void onExitCommandNode(CommandNode node) {
254270

255271
@Override
256272
protected void onEnterOptionNode(OptionNode node) {
273+
optionPos++;
257274
commandArgumentPos = 0;
258275
currentOptions.clear();
259276
currentOptionArgument.clear();
@@ -307,16 +324,40 @@ protected void onExitOptionNode(OptionNode node) {
307324
int max = currentOption.getArityMax() > 0 ? currentOption.getArityMax() : Integer.MAX_VALUE;
308325
max = Math.min(max, currentOptionArgument.size());
309326
List<String> toUse = currentOptionArgument.subList(0, max);
327+
List<String> toUnused = currentOptionArgument.subList(max, currentOptionArgument.size());
328+
toUnused.forEach(a -> {
329+
argumentResults.add(ArgumentResult.of(a, commandArgumentPos++));
330+
});
310331

311-
if (currentOption.getArityMin() > -1 && currentOptionArgument.size() < currentOption.getArityMin()) {
312-
String arg = currentOption.getLongNames()[0];
313-
commonMessageResults.add(MessageResult.of(ParserMessage.NOT_ENOUGH_OPTION_ARGUMENTS, 0, arg,
314-
currentOptionArgument.size()));
332+
// if we're not in a last option
333+
// "--arg1 a b --arg2 c" vs "--arg1 a --arg2 b c"
334+
// we know to impose restriction to argument count,
335+
// last option is different as we can't really fail
336+
// because number of argument to eat dependes on arity
337+
// and rest would go back to positional args.
338+
if (optionPos + 1 < expectedOptionCount) {
339+
if (currentOption.getArityMin() > -1 && currentOptionArgument.size() < currentOption.getArityMin()) {
340+
String arg = currentOption.getLongNames()[0];
341+
commonMessageResults.add(MessageResult.of(ParserMessage.NOT_ENOUGH_OPTION_ARGUMENTS, 0, arg,
342+
currentOptionArgument.size()));
343+
}
344+
else if (currentOption.getArityMax() > -1 && currentOptionArgument.size() > currentOption.getArityMax()) {
345+
String arg = currentOption.getLongNames()[0];
346+
commonMessageResults.add(MessageResult.of(ParserMessage.TOO_MANY_OPTION_ARGUMENTS, 0, arg,
347+
currentOption.getArityMax()));
348+
}
315349
}
316-
else if (currentOption.getArityMax() > -1 && currentOptionArgument.size() > currentOption.getArityMax()) {
317-
String arg = currentOption.getLongNames()[0];
318-
commonMessageResults.add(MessageResult.of(ParserMessage.TOO_MANY_OPTION_ARGUMENTS, 0, arg,
319-
currentOption.getArityMax()));
350+
else {
351+
if (currentOption.getArityMin() > -1 && toUse.size() < currentOption.getArityMin()) {
352+
String arg = currentOption.getLongNames()[0];
353+
commonMessageResults.add(MessageResult.of(ParserMessage.NOT_ENOUGH_OPTION_ARGUMENTS, 0, arg,
354+
toUse.size()));
355+
}
356+
else if (currentOption.getArityMax() > -1 && toUse.size() > currentOption.getArityMax()) {
357+
String arg = currentOption.getLongNames()[0];
358+
commonMessageResults.add(MessageResult.of(ParserMessage.TOO_MANY_OPTION_ARGUMENTS, 0, arg,
359+
currentOption.getArityMax()));
360+
}
320361
}
321362

322363
Object value = null;
@@ -415,5 +456,12 @@ private List<MessageResult> validateOptionIsValid(CommandRegistration registrati
415456
})
416457
.collect(Collectors.toList());
417458
}
459+
460+
private static long optionCountInCommand(CommandNode node) {
461+
if (node == null) {
462+
return 0;
463+
}
464+
return node.getChildren().stream().filter(n -> n instanceof OptionNode).count();
465+
}
418466
}
419467
}

spring-shell-core/src/test/java/org/springframework/shell/command/parser/AbstractParsingTests.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.junit.jupiter.api.BeforeEach;
2424

2525
import org.springframework.shell.command.CommandRegistration;
26+
import org.springframework.shell.command.CommandRegistration.OptionArity;
2627
import org.springframework.shell.command.parser.Ast.AstResult;
2728
import org.springframework.shell.command.parser.Lexer.LexerResult;
2829
import org.springframework.shell.command.parser.Parser.ParseResult;
@@ -251,6 +252,67 @@ abstract class AbstractParsingTests {
251252
.and()
252253
.build();
253254

255+
static final CommandRegistration ROOT7_POSITIONAL_ONE_ARG_STRING_DEFAULT = CommandRegistration.builder()
256+
.command("root7")
257+
.withOption()
258+
.longNames("arg1")
259+
.defaultValue("arg1default")
260+
.type(String.class)
261+
.position(0)
262+
.and()
263+
.withTarget()
264+
.consumer(ctx -> {})
265+
.and()
266+
.build();
267+
268+
static final CommandRegistration ROOT7_POSITIONAL_TWO_ARG_STRING_DEFAULT = CommandRegistration.builder()
269+
.command("root7")
270+
.withOption()
271+
.longNames("arg1")
272+
.defaultValue("arg1default")
273+
.type(String.class)
274+
.arity(OptionArity.EXACTLY_ONE)
275+
.position(0)
276+
.and()
277+
.withOption()
278+
.longNames("arg2")
279+
.defaultValue("arg2default")
280+
.type(String.class)
281+
.arity(OptionArity.EXACTLY_ONE)
282+
.position(1)
283+
.and()
284+
.withTarget()
285+
.consumer(ctx -> {})
286+
.and()
287+
.build();
288+
289+
static final CommandRegistration ROOT7_POSITIONAL_TWO_ARG_STRING_DEFAULT_ONE_NODEFAULT = CommandRegistration.builder()
290+
.command("root7")
291+
.withOption()
292+
.longNames("arg1")
293+
.defaultValue("arg1default")
294+
.type(String.class)
295+
.arity(OptionArity.EXACTLY_ONE)
296+
.position(0)
297+
.and()
298+
.withOption()
299+
.longNames("arg2")
300+
.defaultValue("arg2default")
301+
.type(String.class)
302+
.arity(OptionArity.EXACTLY_ONE)
303+
.position(1)
304+
.and()
305+
.withOption()
306+
.longNames("arg3")
307+
.type(String.class)
308+
.arity(OptionArity.EXACTLY_ONE)
309+
.position(2)
310+
.and()
311+
.withTarget()
312+
.consumer(ctx -> {})
313+
.and()
314+
.build();
315+
254316
Map<String, CommandRegistration> registrations = new HashMap<>();
255317

256318
@BeforeEach

0 commit comments

Comments
 (0)