Rework command parser

- Previously CommandParser contained parser which was
  scannerless type of brute force parsing of command
  line args.
- Contract in that old parser wasn't super clear what
  is its role with caller as it was given options
  and registration was parsed in a Shell class.
- Add completely new parser package which has better
  model and which will be much easier to modify for
  future needs.
- Change some interfaces around parsing so that we do
  as much in this new parsing model instead of pre-parsing
  something in a Shell class.
- We also try to move away from using exceptions as
  a message delivery which had its own problems. Instead
  introducing parser messages which gives better info
  when errors are detected.
- Add ParserConfig class which allows to expose settings
  to change some parser features. Later this will be
  exposed to user so that some features can be turned on/off
  for an actual shell needs.
- Fixes #646
This commit is contained in:
Janne Valkealahti
2023-02-08 15:47:08 +00:00
parent e8d5d70792
commit 75b8a0c90e
40 changed files with 3826 additions and 1256 deletions

View File

@@ -229,8 +229,6 @@ public class Shell {
this.exitCodeMappings.reset(mappingFunctions);
}
List<String> wordsForArgs = wordsForArguments(command, words);
Thread commandThread = Thread.currentThread();
Object sh = Signals.register("INT", () -> commandThread.interrupt());
@@ -243,7 +241,7 @@ public class Shell {
Object evaluate = null;
Exception e = null;
try {
evaluate = execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0]));
evaluate = execution.evaluate(words.toArray(new String[0]));
}
catch (UndeclaredThrowableException ute) {
if (ute.getCause() instanceof InterruptedException || ute.getCause() instanceof ClosedByInterruptException) {
@@ -327,21 +325,6 @@ public class Shell {
|| (input.words().iterator().next().matches("\\s*//.*"));
}
/**
* Returns the list of words to be considered for argument resolving. Drops the first N
* words used for the command, as well as an optional empty word at the end of the list
* (which may be present if user added spaces before submitting the buffer)
*/
private List<String> wordsForArguments(String command, List<String> words) {
int wordsUsedForCommandKey = command.split(" ").length;
List<String> args = words.subList(wordsUsedForCommandKey, words.size());
int last = args.size() - 1;
if (last >= 0 && "".equals(args.get(last))) {
args.remove(last);
}
return args;
}
/**
* Gather completion proposals given some (incomplete) input the user has already typed
* in. When and how this method is invoked is implementation specific and decided by the

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 the original author or authors.
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -38,6 +38,7 @@ import org.springframework.shell.command.CommandRegistration.TargetInfo;
import org.springframework.shell.command.CommandRegistration.TargetInfo.TargetType;
import org.springframework.shell.command.invocation.InvocableShellMethod;
import org.springframework.shell.command.invocation.ShellMethodArgumentResolverComposite;
import org.springframework.shell.command.parser.ParserConfig;
import org.springframework.util.ObjectUtils;
/**
@@ -50,11 +51,10 @@ public interface CommandExecution {
/**
* Evaluate a command with a given arguments.
*
* @param registration the command registration
* @param args the command args
* @return evaluated execution
*/
Object evaluate(CommandRegistration registration, String[] args);
Object evaluate(String[] args);
/**
* Gets an instance of a default {@link CommandExecution}.
@@ -114,17 +114,17 @@ public interface CommandExecution {
this.commandCatalog = commandCatalog;
}
public Object evaluate(CommandRegistration registration, String[] args) {
public Object evaluate(String[] args) {
CommandParser parser = CommandParser.of(conversionService, commandCatalog.getRegistrations(), new ParserConfig());
CommandParserResults results = parser.parse(args);
CommandRegistration registration = results.registration();
// fast fail with availability before doing anything else
Availability availability = registration.getAvailability();
if (availability != null && !availability.isAvailable()) {
return new CommandNotCurrentlyAvailable(registration.getCommand(), availability);
}
List<CommandOption> options = registration.getOptions();
CommandParser parser = CommandParser.of(conversionService);
CommandParserResults results = parser.parse(options, args);
// check help options to short circuit
boolean handleHelpOption = false;
HelpOptionInfo helpOption = registration.getHelpOption();
@@ -157,11 +157,11 @@ public interface CommandExecution {
CommandRegistration usedRegistration;
if (handleHelpOption) {
String command = registration.getCommand();
CommandParser helpParser = CommandParser.of(conversionService);
CommandParser helpParser = CommandParser.of(conversionService, commandCatalog.getRegistrations(),
new ParserConfig());
CommandRegistration helpCommandRegistration = commandCatalog.getRegistrations()
.get(registration.getHelpOption().getCommand());
List<CommandOption> helpOptions = helpCommandRegistration.getOptions();
CommandParserResults helpResults = helpParser.parse(helpOptions, new String[] { "--command", command });
CommandParserResults helpResults = helpParser.parse(new String[] { "help", "--command", command });
results = helpResults;
usedRegistration = helpCommandRegistration;
}

View File

@@ -15,21 +15,19 @@
*/
package org.springframework.shell.command;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Deque;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.Map;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.ConversionService;
import org.springframework.shell.Utils;
import org.springframework.util.StringUtils;
import org.springframework.shell.command.parser.Ast;
import org.springframework.shell.command.parser.Ast.DefaultAst;
import org.springframework.shell.command.parser.CommandModel;
import org.springframework.shell.command.parser.Lexer.DefaultLexer;
import org.springframework.shell.command.parser.Parser.DefaultParser;
import org.springframework.shell.command.parser.Parser.ParseResult;
import org.springframework.shell.command.parser.ParserConfig;
/**
* Interface parsing arguments for a {@link CommandRegistration}. A command is
@@ -77,6 +75,13 @@ public interface CommandParser {
*/
interface CommandParserResults {
/**
* Gets the registration.
*
* @return the registration
*/
CommandRegistration registration();
/**
* Gets the results.
*
@@ -101,13 +106,15 @@ public interface CommandParser {
/**
* Gets an instance of a default {@link CommandParserResults}.
*
* @param registration the registration
* @param results the results
* @param positional the list of positional arguments
* @param errors the parsing errors
* @return a new instance of results
*/
static CommandParserResults of(List<CommandParserResult> results, List<String> positional, List<CommandParserException> errors) {
return new DefaultCommandParserResults(results, positional, errors);
static CommandParserResults of(CommandRegistration registration, List<CommandParserResult> results,
List<String> positional, List<CommandParserException> errors) {
return new DefaultCommandParserResults(registration, results, positional, errors);
}
}
@@ -117,29 +124,22 @@ public interface CommandParser {
* May throw various runtime exceptions depending how parser is configure.
* For example if required option is missing an exception is thrown.
*
* @param options the command options
* @param args the arguments
* @return parsed results
*/
CommandParserResults parse(List<CommandOption> options, String[] args);
/**
* Gets an instance of a default command parser.
*
* @return instance of a default command parser
*/
static CommandParser of() {
return of(null);
}
CommandParserResults parse(String[] args);
/**
* Gets an instance of a default command parser.
*
* @param conversionService the conversion service
* @param registrations the command registrations
* @param config the parser config
* @return instance of a default command parser
*/
static CommandParser of(ConversionService conversionService) {
return new DefaultCommandParser(conversionService);
static CommandParser of(ConversionService conversionService, Map<String, CommandRegistration> registrations,
ParserConfig config) {
return new AstCommandParser(registrations, config, conversionService);
}
/**
@@ -147,16 +147,24 @@ public interface CommandParser {
*/
static class DefaultCommandParserResults implements CommandParserResults {
private CommandRegistration registration;
private List<CommandParserResult> results;
private List<String> positional;
private List<CommandParserException> errors;
DefaultCommandParserResults(List<CommandParserResult> results, List<String> positional, List<CommandParserException> errors) {
DefaultCommandParserResults(CommandRegistration registration, List<CommandParserResult> results,
List<String> positional, List<CommandParserException> errors) {
this.registration = registration;
this.results = results;
this.positional = positional;
this.errors = errors;
}
@Override
public CommandRegistration registration() {
return registration;
}
@Override
public List<CommandParserResult> results() {
return results;
@@ -200,343 +208,45 @@ public interface CommandParser {
/**
* Default implementation of a {@link CommandParser}.
*/
static class DefaultCommandParser implements CommandParser {
static class AstCommandParser implements CommandParser {
private final Map<String, CommandRegistration> registrations;
private final ParserConfig configuration;
private final ConversionService conversionService;
DefaultCommandParser(ConversionService conversionService) {
public AstCommandParser(Map<String, CommandRegistration> registrations, ParserConfig configuration,
ConversionService conversionService) {
this.registrations = registrations;
this.configuration = configuration;
this.conversionService = conversionService;
}
@Override
public CommandParserResults parse(List<CommandOption> options, String[] args) {
List<CommandOption> requiredOptions = options.stream()
.filter(o -> o.isRequired())
.collect(Collectors.toList());
Set<String> splitValidValues = options.stream()
.flatMap(o -> {
Stream<String> longs = Stream.of(o.getLongNames()).map(l -> "--" + l);
Stream<String> shorts = Stream.of(o.getShortNames()).map(s -> "-"+ Character.toString(s));
return Stream.concat(longs, shorts);
})
.collect(Collectors.toSet());
Lexer lexer = new Lexer(args, splitValidValues);
List<List<String>> lexerResults = lexer.visit();
Parser parser = new Parser();
ParserResults parserResults = parser.visit(lexerResults, options);
public CommandParserResults parse(String[] args) {
CommandModel commandModel = new CommandModel(registrations, configuration);
org.springframework.shell.command.parser.Lexer lexer = new DefaultLexer(commandModel, configuration);
Ast ast = new DefaultAst();
org.springframework.shell.command.parser.Parser parser = new DefaultParser(commandModel, lexer, ast,
configuration, conversionService);
ParseResult result = parser.parse(Arrays.asList(args));
List<CommandParserResult> results = new ArrayList<>();
List<String> positional = new ArrayList<>();
List<CommandParserException> errors = new ArrayList<>();
parserResults.results.stream().forEach(pr -> {
if (pr.option != null) {
results.add(new DefaultCommandParserResult(pr.option, pr.value));
requiredOptions.remove(pr.option);
}
else {
for (String arg : pr.args) {
if (arg.startsWith("--")) {
errors.add(UnrecognisedOptionException.of(String.format("Unrecognised option '%s'", arg),
arg));
}
else {
positional.add(arg);
}
}
}
if (pr.error != null) {
errors.add(pr.error);
}
result.optionResults().forEach(or -> {
results.add(CommandParserResult.of(or.option(), or.value()));
});
Deque<ParserResult> queue = new ArrayDeque<>(parserResults.results);
options.stream()
.filter(o -> o.getPosition() > -1)
.sorted(Comparator.comparingInt(o -> o.getPosition()))
.forEach(o -> {
int arityMin = o.getArityMin();
int arityMax = o.getArityMax();
List<String> oargs = new ArrayList<>();
if (arityMin > -1) {
for (int i = 0; i < arityMax; i++) {
ParserResult pop = null;
if (!queue.isEmpty()) {
pop = queue.pop();
}
else {
break;
}
if (pop != null && pop.option == null) {
if (!pop.args.isEmpty()) {
oargs.addAll(pop.args);
}
}
}
}
// don't do anything if first arg looks like an option as if we are here
// then we'd might remove wrong required option
if (!oargs.isEmpty() && !oargs.get(0).startsWith("-")) {
// as we now have a candicate option, try to see if there is a
// conversion we can do and the use it.
Object value = convertOptionType(o, oargs);
results.add(new DefaultCommandParserResult(o, value));
requiredOptions.remove(o);
}
});
requiredOptions.stream().forEach(o -> {
String ln = o.getLongNames() != null ? Stream.of(o.getLongNames()).collect(Collectors.joining(",")) : "";
String sn = o.getShortNames() != null ? Stream.of(o.getShortNames()).map(n -> Character.toString(n))
.collect(Collectors.joining(",")) : "";
errors.add(MissingOptionException
.of(String.format("Missing option, longnames='%s', shortnames='%s'", ln, sn), o));
result.messageResults().forEach(mr -> {
errors.add(new CommandParserException(mr.getMessage()));
});
return new DefaultCommandParserResults(results, positional, errors);
}
result.argumentResults().forEach(ar -> {
positional.add(ar.value());
});
private Object convertOptionType(CommandOption option, Object value) {
if (conversionService != null && option.getType() != null && value != null) {
if (conversionService.canConvert(value.getClass(), option.getType().getRawClass())) {
value = conversionService.convert(value, option.getType().getRawClass());
}
}
return value;
}
private static class ParserResult {
private CommandOption option;
private List<String> args;
private Object value;
private CommandParserException error;
private ParserResult(CommandOption option, List<String> args, Object value, CommandParserException error) {
this.option = option;
this.args = args;
this.value = value;
this.error = error;
}
static ParserResult of(CommandOption option, List<String> args, Object value,
CommandParserException error) {
return new ParserResult(option, args, value, error);
}
}
private static class ParserResults {
private List<ParserResult> results;
private ParserResults(List<ParserResult> results) {
this.results = results;
}
static ParserResults of(List<ParserResult> results) {
return new ParserResults(results);
}
}
/**
* Parser works on a results from a lexer. It looks for given options
* and builds parsing results.
*/
private class Parser {
ParserResults visit(List<List<String>> lexerResults, List<CommandOption> options) {
List<ParserResult> results = lexerResults.stream()
.flatMap(lr -> {
List<CommandOption> option = matchOptions(options, lr.get(0));
if (option.isEmpty()) {
return lr.stream().map(a -> ParserResult.of(null, Arrays.asList(a), null, null));
}
else {
return option.stream().flatMap(o -> {
List<String> subArgs = lr.subList(1, lr.size());
ConvertArgumentsHolder holder = convertArguments(o, subArgs);
if (holder.error != null) {
return Stream.of(ParserResult.of(o, subArgs, null, holder.error));
}
Object value = convertOptionType(o, holder.value);
Stream<ParserResult> unmapped = holder.unmapped.stream()
.map(um -> ParserResult.of(null, Arrays.asList(um), null, null));
Stream<ParserResult> res = Stream.of(ParserResult.of(o, subArgs, value, null));
return Stream.concat(res, unmapped);
});
}
})
.collect(Collectors.toList());
// Check options which didn't get matched and add parser result
// for those having default value.
List<CommandOption> defaultValueOptionsToCheck = new ArrayList<>(options);
results.stream()
.forEach(pr -> {
if (pr.option != null) {
defaultValueOptionsToCheck.remove(pr.option);
}
});
defaultValueOptionsToCheck.stream()
.filter(co -> co.getDefaultValue() != null)
.forEach(co -> {
Object value = co.getDefaultValue();
value = convertOptionType(co, value);
results.add(ParserResult.of(co, Collections.emptyList(), value, null));
});
return ParserResults.of(results);
}
private List<CommandOption> matchOptions(List<CommandOption> options, String arg) {
List<CommandOption> matched = new ArrayList<>();
String trimmed = StringUtils.trimLeadingCharacter(arg, '-');
int count = arg.length() - trimmed.length();
if (count == 1) {
if (trimmed.length() == 1) {
Character trimmedChar = trimmed.charAt(0);
options.stream()
.filter(o -> {
for (Character sn : o.getShortNames()) {
if (trimmedChar.equals(sn)) {
return true;
}
}
return false;
})
.findFirst()
.ifPresent(o -> matched.add(o));
}
else if (trimmed.length() > 1) {
trimmed.chars().mapToObj(i -> (char)i)
.forEach(c -> {
options.stream().forEach(o -> {
for (Character sn : o.getShortNames()) {
if (c.equals(sn)) {
matched.add(o);
}
}
});
});
}
}
else if (count == 2) {
options.stream()
.filter(o -> {
for (String ln : o.getLongNames()) {
if (trimmed.equals(ln)) {
return true;
}
}
return false;
})
.findFirst()
.ifPresent(o -> matched.add(o));
}
return matched;
}
private ConvertArgumentsHolder convertArguments(CommandOption option, List<String> arguments) {
Object value = null;
List<String> unmapped = new ArrayList<>();
ResolvableType type = option.getType();
int arityMin = option.getArityMin();
int arityMax = option.getArityMax();
if (arityMin < 0 && type != null) {
if (type.isAssignableFrom(boolean.class)) {
arityMin = 1;
arityMax = 1;
}
}
if (arityMax > 1 && arityMin > -1 && arityMax >= arityMin && (arguments.size() < arityMin || arguments.size() > arityMax)) {
String ln = option.getLongNames() != null
? Stream.of(option.getLongNames()).collect(Collectors.joining(","))
: "";
String sn = option.getShortNames() != null ? Stream.of(option.getShortNames())
.map(n -> Character.toString(n)).collect(Collectors.joining(",")) : "";
if (arguments.size() < arityMin) {
String msg = String.format("Not enough arguments, longnames='%s', shortnames='%s'", ln, sn);
return new ConvertArgumentsHolder(value, unmapped, new NotEnoughArgumentsOptionException(msg, option));
}
if (arguments.size() > arityMax) {
String msg = String.format("Too many arguments, longnames='%s', shortnames='%s'", ln, sn);
return new ConvertArgumentsHolder(value, unmapped, new TooManyArgumentsOptionException(msg, option));
}
}
if (type != null && type.isAssignableFrom(boolean.class)) {
if (arguments.size() == 0) {
value = true;
}
else {
value = Boolean.parseBoolean(arguments.get(0));
}
}
else if (type != null && type.isArray()) {
value = arguments.stream().collect(Collectors.toList()).toArray();
}
// if it looks like type is a collection just get as list
// as conversion will happen later. we just need to know
// if user has Set, List, Collection, etc without worrying
// about generics.
else if (type != null && type.asCollection() != ResolvableType.NONE) {
value = arguments.stream().collect(Collectors.toList());
}
else {
if (!arguments.isEmpty()) {
if (arguments.size() == 1) {
value = arguments.get(0);
}
else {
if (arityMax > 0) {
int limit = Math.min(arguments.size(), arityMax);
value = arguments.stream().limit(limit).collect(Collectors.toList());
unmapped.addAll(arguments.subList(limit, arguments.size()));
}
else {
value = arguments.get(0);
unmapped.addAll(arguments.subList(1, arguments.size()));
}
}
}
}
return new ConvertArgumentsHolder(value, unmapped);
}
private class ConvertArgumentsHolder {
Object value;
final List<String> unmapped = new ArrayList<>();
CommandParserException error;
ConvertArgumentsHolder(Object value, List<String> unmapped) {
this(value, unmapped, null);
}
ConvertArgumentsHolder(Object value, List<String> unmapped, CommandParserException error) {
this.value = value;
if (unmapped != null) {
this.unmapped.addAll(unmapped);
}
this.error = error;
}
}
}
/**
* Lexers only responsibility is to splice arguments array into
* chunks which belongs together what comes for option structure.
*/
private static class Lexer {
private final String[] args;
private final Set<String> splitValidValues;
Lexer(String[] args, Set<String> splitValues) {
this.args = args;
this.splitValidValues = splitValues;
}
List<List<String>> visit() {
return Utils.split(args, t -> splitValidValues.contains(t));
}
return new DefaultCommandParserResults(result.commandRegistration(), results, positional, errors);
}
}
@@ -554,68 +264,4 @@ public interface CommandParser {
return new CommandParserException(message);
}
}
public static class OptionException extends CommandParserException {
private CommandOption option;
public OptionException(String message, CommandOption option) {
super(message);
this.option = option;
}
public CommandOption getOption() {
return option;
}
}
public static class TooManyArgumentsOptionException extends OptionException {
public TooManyArgumentsOptionException(String message, CommandOption option) {
super(message, option);
}
}
public static class NotEnoughArgumentsOptionException extends OptionException {
public NotEnoughArgumentsOptionException(String message, CommandOption option) {
super(message, option);
}
}
public static class MissingOptionException extends CommandParserException {
private CommandOption option;
public MissingOptionException(String message, CommandOption option) {
super(message);
this.option = option;
}
public static MissingOptionException of(String message, CommandOption option) {
return new MissingOptionException(message, option);
}
public CommandOption getOption() {
return option;
}
}
public static class UnrecognisedOptionException extends CommandParserException {
private String option;
public UnrecognisedOptionException(String message, String option) {
super(message);
this.option = option;
}
public static UnrecognisedOptionException of(String message, String option) {
return new UnrecognisedOptionException(message, option);
}
public String getOption() {
return option;
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 the original author or authors.
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -20,10 +20,6 @@ import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;
import org.springframework.shell.command.CommandParser.MissingOptionException;
import org.springframework.shell.command.CommandParser.NotEnoughArgumentsOptionException;
import org.springframework.shell.command.CommandParser.TooManyArgumentsOptionException;
import org.springframework.util.StringUtils;
/**
* Handles {@link CommandParserExceptionsException}.
@@ -37,30 +33,7 @@ public class CommandParserExceptionResolver implements CommandExceptionResolver
if (ex instanceof CommandParserExceptionsException cpee) {
AttributedStringBuilder builder = new AttributedStringBuilder();
cpee.getParserExceptions().stream().forEach(e -> {
if (e instanceof MissingOptionException moe) {
CommandOption option = moe.getOption();
if (option.getLongNames().length > 0) {
handleLong(builder, option);
}
else if (option.getShortNames().length > 0) {
handleShort(builder, option);
}
}
else if (e instanceof NotEnoughArgumentsOptionException neaoe) {
CommandOption option = neaoe.getOption();
if (option.getLongNames().length > 0) {
handleLongNotEnough(builder, option);
}
}
else if (e instanceof TooManyArgumentsOptionException tmaoe) {
CommandOption option = tmaoe.getOption();
if (option.getLongNames().length > 0) {
handleLongTooMany(builder, option);
}
}
else {
builder.append(new AttributedString(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
}
builder.append(new AttributedString(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
builder.append("\n");
});
String as = builder.toAttributedString().toAnsi();
@@ -68,54 +41,4 @@ public class CommandParserExceptionResolver implements CommandExceptionResolver
}
return null;
}
private static void handleLong(AttributedStringBuilder builder, CommandOption option) {
StringBuilder buf = new StringBuilder();
buf.append("Missing mandatory option --");
buf.append(option.getLongNames()[0]);
if (StringUtils.hasText(option.getDescription())) {
buf.append(", ");
buf.append(option.getDescription());
}
buf.append(".");
builder.append(new AttributedString(buf.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
}
private static void handleShort(AttributedStringBuilder builder, CommandOption option) {
StringBuilder buf = new StringBuilder();
buf.append("Missing mandatory option -");
buf.append(option.getShortNames()[0]);
if (StringUtils.hasText(option.getDescription())) {
buf.append(", ");
buf.append(option.getDescription());
}
buf.append(".");
builder.append(new AttributedString(buf.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
}
private static void handleLongNotEnough(AttributedStringBuilder builder, CommandOption option) {
StringBuilder buf = new StringBuilder();
buf.append("Not enough arguments --");
buf.append(option.getLongNames()[0]);
buf.append(" requires at least " + option.getArityMin());
if (StringUtils.hasText(option.getDescription())) {
buf.append(", ");
buf.append(option.getDescription());
}
buf.append(".");
builder.append(new AttributedString(buf.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
}
private static void handleLongTooMany(AttributedStringBuilder builder, CommandOption option) {
StringBuilder buf = new StringBuilder();
buf.append("Too many arguments --");
buf.append(option.getLongNames()[0]);
buf.append(" requires at most " + option.getArityMax());
if (StringUtils.hasText(option.getDescription())) {
buf.append(", ");
buf.append(option.getDescription());
}
buf.append(".");
builder.append(new AttributedString(buf.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)));
}
}

View File

@@ -232,9 +232,6 @@ class CommandRegistrationFactoryBean implements FactoryBean<CommandRegistration>
else if (ClassUtils.isAssignable(Boolean.class, parameterType)) {
optionSpec.arity(OptionArity.ZERO);
}
else {
optionSpec.arity(OptionArity.EXACTLY_ONE);
}
}
if (StringUtils.hasText(so.defaultValue())) {

View File

@@ -0,0 +1,244 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.shell.command.parser.Parser.ParseResult;
/**
* Base abstract {@link NodeVisitor} which visits all nodes allowing user to
* implement callback methods.
*
* @author Janne Valkealahti
*/
public abstract class AbstractNodeVisitor implements NodeVisitor {
private final static Logger log = LoggerFactory.getLogger(AbstractNodeVisitor.class);
@Override
public final ParseResult visit(List<NonterminalAstNode> nonterminalNodes,
List<TerminalAstNode> terminalNodes) {
for (NonterminalAstNode ntn : nonterminalNodes) {
log.debug("visit {}", ntn);
if (ntn instanceof CommandNode node) {
log.debug("onEnterRootCommandNode {}", node);
onEnterRootCommandNode(node);
visitChildren(node);
log.debug("onExitRootCommandNode {}", node);
onExitRootCommandNode(node);
}
}
for (TerminalAstNode tn : terminalNodes) {
if (tn instanceof DirectiveNode node) {
enterDirectiveNode(node);
exitDirectiveNode(node);
}
}
return buildResult();
}
/**
* Called after all nodes has been visited to build results.
*
* @return the results from this visit operation
*/
protected abstract ParseResult buildResult();
/**
* Called when {@link CommandNode} for root is entered. When node is fully
* visited, {@link #onExitRootCommandNode(CommandNode)} is called.
*
* @param node the command node
* @see #onExitRootCommandNode(CommandNode)
*/
protected abstract void onEnterRootCommandNode(CommandNode node);
/**
* Called when {@link CommandNode} for root is exited.
*
* @param node the command node
* @see #onEnterRootCommandNode(CommandNode)
*/
protected abstract void onExitRootCommandNode(CommandNode node);
/**
* Called when {@link CommandNode} is entered. When node is fully visited,
* {@link #onExitCommandNode(CommandNode)} is called.
*
* @param node the command node
* @see #onExitCommandNode(CommandNode)
*/
protected abstract void onEnterCommandNode(CommandNode node);
/**
* Called when {@link CommandNode} is exited.
*
* @param node the command node
* @see #onEnterCommandNode(CommandNode)
*/
protected abstract void onExitCommandNode(CommandNode node);
/**
* Called when {@link OptionNode} is entered. When node is fully visited,
* {@link #onExitOptionNode(OptionNode)} is called.
*
* @param node the option node
* @see #onExitOptionNode(OptionNode)
*/
protected abstract void onEnterOptionNode(OptionNode node);
/**
* Called when {@link OptionNode} is exited.
*
* @param node the option node
* @see #onEnterOptionNode(OptionNode)
*/
protected abstract void onExitOptionNode(OptionNode node);
/**
* Called when {@link CommandArgumentNode} is entered. When node is fully visited,
* {@link #onExitCommandArgumentNode(CommandArgumentNode)} is called.
*
* @param node the command argument node
* @see #onExitCommandArgumentNode(CommandArgumentNode)
*/
protected abstract void onEnterCommandArgumentNode(CommandArgumentNode node);
/**
* Called when {@link CommandArgumentNode} is exited.
*
* @param node the command argument node
* @see #onEnterCommandArgumentNode(CommandArgumentNode)
*/
protected abstract void onExitCommandArgumentNode(CommandArgumentNode node);
/**
* Called when {@link OptionArgumentNode} is entered. When node is fully visited,
* {@link #onExitOptionArgumentNode(OptionArgumentNode)} is called.
*
* @param node the option argument node
* @see #onExitOptionArgumentNode(OptionArgumentNode)
*/
protected abstract void onEnterOptionArgumentNode(OptionArgumentNode node);
/**
* Called when {@link OptionArgumentNode} is exited.
*
* @param node the command argument node
* @see #onEnterOptionArgumentNode(OptionArgumentNode)
*/
protected abstract void onExitOptionArgumentNode(OptionArgumentNode node);
/**
* Called when {@link DirectiveNode} is entered. When node is fully visited,
* {@link #onExitDirectiveNode(DirectiveNode)} is called.
*
* @param node the option node
* @see #onExitDirectiveNode(DirectiveNode)
*/
protected abstract void onEnterDirectiveNode(DirectiveNode node);
/**
* Called when {@link DirectiveNode} is exited.
*
* @param node the option node
* @see #onEnterDirectiveNode(DirectiveNode)
*/
protected abstract void onExitDirectiveNode(DirectiveNode node);
private void visitChildren(NonterminalAstNode node) {
log.debug("visitChildren {}", node);
for (AstNode syntaxNode : node.getChildren()) {
visitInternal(syntaxNode);
}
}
private void enterCommandNode(CommandNode node) {
log.debug("enterCommandNode {}", node);
onEnterCommandNode(node);
}
private void exitCommandNode(CommandNode node) {
log.debug("exitCommandNode {}", node);
onExitCommandNode(node);
}
private void enterOptionNode(OptionNode node) {
log.debug("enterOptionNode {}", node);
onEnterOptionNode(node);
}
private void exitOptionNode(OptionNode node) {
log.debug("exitOptionNode {}", node);
onExitOptionNode(node);
}
private void enterCommandArgumentNode(CommandArgumentNode node) {
log.debug("enterCommandArgumentNode {}", node);
onEnterCommandArgumentNode(node);
}
private void exitCommandArgumentNode(CommandArgumentNode node) {
log.debug("exitCommandArgumentNode {}", node);
onExitCommandArgumentNode(node);
}
private void enterOptionArgumentNode(OptionArgumentNode node) {
log.debug("enterOptionArgumentNode {}", node);
onEnterOptionArgumentNode(node);
}
private void exitOptionArgumentNode(OptionArgumentNode node) {
log.debug("exitOptionArgumentNode {}", node);
onExitOptionArgumentNode(node);
}
private void enterDirectiveNode(DirectiveNode node) {
log.debug("enterDirectiveNode {}", node);
onEnterDirectiveNode(node);
}
private void exitDirectiveNode(DirectiveNode node) {
log.debug("exitDirectiveNode {}", node);
onExitDirectiveNode(node);
}
private void visitInternal(AstNode node) {
log.debug("visitInternal {}", node);
if (node instanceof CommandNode n) {
enterCommandNode(n);
visitChildren(n);
exitCommandNode(n);
}
else if (node instanceof OptionNode n) {
enterOptionNode(n);
visitChildren(n);
exitOptionNode(n);
}
else if (node instanceof CommandArgumentNode n) {
enterCommandArgumentNode(n);
exitCommandArgumentNode(n);
}
else if (node instanceof OptionArgumentNode n) {
enterOptionArgumentNode(n);
exitOptionArgumentNode(n);
}
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.ArrayList;
import java.util.List;
/**
* Interface to generate abstract syntax tree from tokens. Generic language
* parser usually contains lexing and parsing where this {@code Ast} represents
* the latter parsing side.
*
* Parsing looks tokens and combines those together into nodes and we get
* closer to understand commands, its options and arguments whether those
* belong to command or option. Parser don't look if for example option
* arguments makes sense which happen later when ast tree is visited.
*
* @author Janne Valkealahti
*/
public interface Ast {
/**
* Generate ast result from a tokens. {@link AstResult} contains info about
* token to ast tree generation.
*
* @param tokens the tokens
* @return a result containing further syntax info
*/
AstResult generate(List<Token> tokens);
/**
* Representing result from tokens to ast tree generation.
*
* @param nonterminalNodes list of nonterminal nodes
* @param terminalNodes list of terminal nodes
*/
public record AstResult(List<NonterminalAstNode> nonterminalNodes, List<TerminalAstNode> terminalNodes) {
}
/**
* Default implementation of an {@link Ast}.
*/
public class DefaultAst implements Ast {
@Override
public AstResult generate(List<Token> tokens) {
List<CommandNode> commandNodes = new ArrayList<>();
CommandNode commandNode = null;
OptionNode optionNode = null;
List<DirectiveNode> directiveNodes = new ArrayList<>();
for (Token token : tokens) {
switch (token.getType()) {
case DIRECTIVE:
String raw = token.getValue();
String[] split = raw.split(":", 2);
directiveNodes.add(new DirectiveNode(token, split[0], split.length > 1 ? split[1] : null));
break;
case COMMAND:
CommandNode n = new CommandNode(token, token.getValue());
if (commandNode == null) {
commandNode = n;
}
else {
commandNode.addChildNode(n);
commandNode = n;
}
commandNodes.add(commandNode);
break;
case OPTION:
optionNode = new OptionNode(token, token.getValue());
commandNode.addChildNode(optionNode);
break;
case ARGUMENT:
if (optionNode != null) {
OptionArgumentNode optionArgumentNode = new OptionArgumentNode(token, optionNode, token.getValue());
optionNode.addChildNode(optionArgumentNode);
}
else {
CommandArgumentNode commandArgumentNode = new CommandArgumentNode(token, commandNode);
commandNode.addChildNode(commandArgumentNode);
}
break;
case DOUBLEDASH:
optionNode = null;
break;
default:
break;
}
}
List<NonterminalAstNode> nonterminalNodes = new ArrayList<>();
List<TerminalAstNode> terminalNodes = new ArrayList<>();
if (commandNodes.size() > 0) {
nonterminalNodes.add(commandNodes.get(0));
}
for (DirectiveNode dn : directiveNodes) {
terminalNodes.add(dn);
}
return new AstResult(nonterminalNodes, terminalNodes);
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Represents a node in an {@code abstract syntax tree} and knows about
* {@link Token}.
*
* @author Janne Valkealahti
*/
public abstract sealed class AstNode permits NonterminalAstNode, TerminalAstNode {
private final Token token;
public AstNode(Token token) {
this.token = token;
}
public Token getToken() {
return token;
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Node representing a command argument.
*
* @author Janne Valkealahti
*/
public final class CommandArgumentNode extends TerminalAstNode {
private final CommandNode parent;
public CommandArgumentNode(Token token, CommandNode parent) {
super(token);
this.parent = parent;
}
public CommandNode getParentCommandNode() {
return parent;
}
}

View File

@@ -0,0 +1,221 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.lang.Nullable;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.parser.ParserConfig.Feature;
/**
* Helper class to make it easier to work with command registrations and how
* those are used with parser model.
*
* @author Janne Valkealahti
*/
public class CommandModel {
private final Map<String, CommandInfo> rootCommands = new HashMap<>();
private final ParserConfig configuration;
public CommandModel(Map<String, CommandRegistration> registrations, ParserConfig configuration) {
this.configuration = configuration;
buildModel(registrations);
}
@Nullable
CommandInfo getRootCommand(String command) {
return rootCommands.get(command);
}
CommandInfo resolve(List<String> commands) {
CommandInfo info = null;
boolean onRoot = true;
for (String commandx : commands) {
String command = configuration.isEnabled(Feature.CASE_SENSITIVE_COMMANDS) ? commandx : commandx.toLowerCase();
if (onRoot) {
info = rootCommands.get(command);
onRoot = false;
}
else {
Optional<CommandInfo> nextInfo = info.getChildren().stream()
.filter(i -> i.command.equals(command))
.findFirst();
if (nextInfo.isEmpty()) {
break;
}
info = nextInfo.get();
}
}
return info;
}
Map<String, CommandInfo> getRootCommands() {
return rootCommands;
}
Map<String, Token> getValidRootTokens() {
Map<String, Token> tokens = new HashMap<>();
rootCommands.entrySet().forEach(e -> {
tokens.put(e.getKey(), new Token(e.getKey(), TokenType.COMMAND));
// Map<String, Token> validTokens = e.getValue().getValidTokens();
// tokens.putAll(validTokens);
});
// rootCommands.entrySet().forEach(e -> {
// tokens.put(e.getKey(), new Token(e.getKey(), TokenType.COMMAND));
// if (e.getValue().registration != null) {
// e.getValue().registration.getAliases().forEach(alias -> {
// String[] commands = alias.getCommand().split(" ");
// tokens.put(commands[0], new Token(commands[0], TokenType.COMMAND));
// });
// }
// });
return tokens;
}
void xxx(String command, CommandRegistration registration) {
String[] commands = command.split(" ");
for (int i = 0; i < commands.length; i++) {
}
}
// root1 sub1
// root1 sub2
private CommandInfo getOrCreate(String[] commands, CommandRegistration registration) {
CommandInfo ret = null;
CommandInfo parent = null;
int i = -1;
for (String command : commands) {
i++;
String key = configuration.isEnabled(Feature.CASE_SENSITIVE_COMMANDS) ? command : command.toLowerCase();
if (i == 0) {
parent = rootCommands.get(command);
if (parent == null) {
parent = new CommandInfo(key, i < commands.length - 1 ? null : registration, parent);
rootCommands.put(key, parent);
}
ret = parent;
continue;
}
CommandInfo children = parent.getChildren(command);
if (children == null) {
children = new CommandInfo(key, i < commands.length - 1 ? null : registration, parent);
parent.addChildred(command, children);
}
parent = children;
ret = parent;
// CommandInfo asdf = new CommandInfo(key, i < commands.length - 1 ? null : registration, parent);
// parent.addChildred(command, asdf);
// parent = asdf;
}
if (ret.registration == null) {
ret.registration = registration;
}
return ret;
}
private void buildModel(Map<String, CommandRegistration> registrations) {
registrations.entrySet().forEach(e -> {
String[] commands = e.getKey().split(" ");
getOrCreate(commands, e.getValue());
// String[] commands = e.getKey().split(" ");
// CommandInfo parent = null;
// for (int i = 0; i < commands.length; i++) {
// CommandRegistration registration = i + 1 == commands.length ? e.getValue() : null;
// String key = configuration.isCommandsCaseSensitive() ? commands[i] : commands[i].toLowerCase();
// if (parent == null) {
// CommandInfo info = new CommandInfo(commands[i], registration, parent);
// rootCommands.computeIfAbsent(key, command -> info);
// parent = info;
// }
// else {
// CommandInfo info = new CommandInfo(key, registration, parent);
// parent.children.add(info);
// parent = info;
// }
// }
});
}
/**
* Contains info about a command, its registration, its parent and childs.
*/
static class CommandInfo {
String command;
CommandRegistration registration;
CommandInfo parent;
// private List<CommandInfo> children = new ArrayList<>();
private Map<String, CommandInfo> children = new HashMap<>();
CommandInfo(String command, CommandRegistration registration, CommandInfo parent) {
this.registration = registration;
this.parent = parent;
this.command = command;
}
/**
* Get valid tokens for this {@code CommandInfo}.
*
* @return mapping from raw value to a token
*/
Map<String, Token> getValidTokens() {
Map<String, Token> tokens = new HashMap<>();
children.values().forEach(commandInfo -> {
tokens.put(commandInfo.command, new Token(command, TokenType.COMMAND));
});
if (registration != null) {
registration.getOptions().forEach(commandOption -> {
for (String longName : commandOption.getLongNames()) {
tokens.put("--" + longName, new Token(longName, TokenType.OPTION));
}
});
}
return tokens;
}
public Collection<CommandInfo> getChildren() {
return children.values();
}
public void addChildred(String command, CommandInfo children) {
this.children.put(command, children);
}
CommandInfo getChildren(String command) {
return children.get(command);
// return children.stream()
// .filter(c -> c.command.equals(command))
// .findFirst()
// .orElse(null)
// ;
}
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Node representing a command.
*
* @author Janne Valkealahti
*/
public final class CommandNode extends NonterminalAstNode {
private final String command;
public CommandNode(Token token, String command) {
super(token);
this.command = command;
}
public String getCommand() {
return command;
}
@Override
public String toString() {
return "CommandNode [command=" + command + ", children=" + getChildren() + ", token=" + getToken() + "]";
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Node representing
*
* @author Janne Valkealahti
*/
public final class DirectiveNode extends TerminalAstNode {
private final String name;
private final String value;
public DirectiveNode(Token token, String name, String value) {
super(token);
this.name = name;
this.value = value;
}
public String getName() {
return name;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import org.springframework.lang.Nullable;
/**
* Encapsulating {@code Directive} with its fields, {@code name} and
* {@code value}.
*
* @author Janne Valkealahti
*/
public record DirectiveResult(String name, @Nullable String value) {
public static DirectiveResult of(String name, @Nullable String value) {
return new DirectiveResult(name, value);
}
}

View File

@@ -0,0 +1,247 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.shell.command.parser.CommandModel.CommandInfo;
import org.springframework.shell.command.parser.ParserConfig.Feature;
/**
* Interface to tokenize arguments into tokens. Generic language parser usually
* contains lexing and parsing where this {@code Lexer} represents the former
* lexing side.
*
* Lexing takes a first step to analyse basic construct of elements out from
* given arguments. We get rough idea what each argument represents but don't
* look deeper if any of it is correct which happens later when tokens go
* through parsing operation.
*
* @author Janne Valkealahti
*/
public interface Lexer {
/**
* Tokenize given command line arguments into a list of tokens.
*
* @param arguments the command line arguments
* @return lexer result having tokens and operation messages
*/
LexerResult tokenize(List<String> arguments);
/**
* Representing result from {@link Lexer} tokenisation.
*
* @param tokens list of tokens in this result
* @param messageResults list of error results in this result
*/
public record LexerResult(List<Token> tokens, List<MessageResult> messageResults) {
}
/**
* Default implementation of a {@link Lexer}.
*/
public class DefaultLexer implements Lexer {
private final static Logger log = LoggerFactory.getLogger(DefaultLexer.class);
private final CommandModel commandModel;
private final ParserConfig config;
public DefaultLexer(CommandModel commandModel, ParserConfig config) {
this.commandModel = commandModel;
this.config = config;
}
private record ArgumentsSplit(List<String> before, List<String> after) {
}
/**
* Splits arguments from a point first valid command is found, where
* {@code before} is everything before commands and {@code after} what's
* remaining.
*/
private ArgumentsSplit splitArguments(List<String> arguments, Map<String, Token> validTokens) {
int i = -1;
boolean foundSplit = false;
for (String argument : arguments) {
if (!config.isEnabled(Feature.CASE_SENSITIVE_COMMANDS)) {
argument = argument.toLowerCase();
}
i++;
if (validTokens.containsKey(argument)) {
foundSplit = true;
break;
}
}
if (i < 0) {
return new ArgumentsSplit(Collections.emptyList(), Collections.emptyList());
}
else if (i == 0) {
if (foundSplit) {
return new ArgumentsSplit(Collections.emptyList(), arguments);
}
return new ArgumentsSplit(arguments, Collections.emptyList());
}
return new ArgumentsSplit(arguments.subList(0, i), arguments.subList(i, arguments.size()));
}
private List<String> extractDirectives(List<String> arguments) {
List<String> ret = new ArrayList<>();
Pattern pattern = Pattern.compile("\\[(.*?)\\]");
String raw = arguments.stream().collect(Collectors.joining());
Matcher matcher = pattern.matcher(raw);
while (matcher.find()) {
String group = matcher.group(1);
ret.add(group);
}
return ret;
}
@Override
public LexerResult tokenize(List<String> arguments) {
log.debug("Tokenizing arguments {}", arguments);
List<MessageResult> errorResults = new ArrayList<>();
List<Token> tokenList = new ArrayList<Token>();
preValidate(errorResults, arguments);
// starting from root level
Map<String, Token> validTokens = commandModel.getValidRootTokens();
// we process arguments in two steps, ones before commands and commands
ArgumentsSplit split = splitArguments(arguments, validTokens);
// consume everything before command section starts
// currently there can only be directives and we need
// to differentiate if we silenty ignore those vs.
// whether directive support is enabled or not
List<String> beforeArguments = split.before();
int i1 = split.before().size() - 1;
if (config.isEnabled(Feature.ALLOW_DIRECTIVES)) {
List<String> rawDirectives = extractDirectives(beforeArguments);
for (String raw : rawDirectives) {
tokenList.add(Token.of(raw, TokenType.DIRECTIVE, 0));
}
}
else {
if (!config.isEnabled(Feature.IGNORE_DIRECTIVES) && beforeArguments.size() > 0) {
errorResults.add(MessageResult.of(ParserMessage.ILLEGAL_CONTENT_BEFORE_COMMANDS, 0, beforeArguments));
}
}
// consume remaining arguments which should contain
// only ones starting from a first command
boolean foundDoubleDash = false;
List<String> afterArguments = split.after();
CommandInfo currentCommand = null;
int i2 = i1;
for (String argument : afterArguments) {
// if (!configuration.isEnabled(Feature.CASE_SENSITIVE_COMMANDS)) {
// argument = argument.toLowerCase();
// }
i2++;
// We've found bash style "--" meaning further option processing is
// stopped and remaining arguments are simply command arguments
if (foundDoubleDash) {
tokenList.add(Token.of(argument, TokenType.ARGUMENT, i2));
continue;
}
if (!foundDoubleDash && "--".equals(argument)) {
tokenList.add(Token.of(argument, TokenType.DOUBLEDASH, i2));
foundDoubleDash = true;
continue;
}
String argumentToCheck = argument;
if (!config.isEnabled(Feature.CASE_SENSITIVE_COMMANDS)
|| !config.isEnabled(Feature.CASE_SENSITIVE_OPTIONS)) {
argumentToCheck = argument.toLowerCase();
}
if (validTokens.containsKey(argumentToCheck)) {
Token token = validTokens.get(argumentToCheck);
switch (token.getType()) {
case COMMAND:
currentCommand = currentCommand == null ? commandModel.getRootCommands().get(argumentToCheck)
: currentCommand.getChildren(argument);
tokenList.add(Token.of(argument, TokenType.COMMAND, i2));
validTokens = currentCommand.getValidTokens();
break;
case OPTION:
tokenList.add(Token.of(argument, TokenType.OPTION, i2));
break;
default:
break;
}
}
else if (isLastTokenOfType(tokenList, TokenType.OPTION)) {
if (argument.startsWith("-")) {
tokenList.add(Token.of(argument, TokenType.OPTION, i2));
}
else {
tokenList.add(Token.of(argument, TokenType.ARGUMENT, i2));
}
}
else if (isLastTokenOfType(tokenList, TokenType.COMMAND)) {
if (argument.startsWith("-")) {
tokenList.add(Token.of(argument, TokenType.OPTION, i2));
}
else {
tokenList.add(Token.of(argument, TokenType.ARGUMENT, i2));
}
}
else if (isLastTokenOfType(tokenList, TokenType.ARGUMENT)) {
tokenList.add(Token.of(argument, TokenType.ARGUMENT, i2));
}
}
log.debug("Generated token list {}", tokenList);
return new LexerResult(tokenList, errorResults);
}
private void preValidate(List<MessageResult> errorResults, List<String> arguments) {
if (arguments.size() > 0) {
String arg = arguments.get(0);
if ("--".equals(arg)) {
errorResults.add(MessageResult.of(ParserMessage.ILLEGAL_CONTENT_BEFORE_COMMANDS, 0, arg));
}
}
}
private static boolean isLastTokenOfType(List<Token> tokenList, TokenType type) {
if (tokenList.size() > 0) {
if (tokenList.get(tokenList.size() - 1).getType() == type) {
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Encapsulating {@link ParserMessage} with its postion and {@code inserts}.
*
* @author Janne Valkealahti
*/
public record MessageResult(ParserMessage parserMessage, int position, Object[] inserts) {
public static MessageResult of(ParserMessage parserMessage, int position, Object... inserts) {
return new MessageResult(parserMessage, position, inserts);
}
public String getMessage() {
return parserMessage.formatMessage(position, inserts);
}
}

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.List;
import org.springframework.shell.command.parser.Parser.ParseResult;
/**
* Interface to vising nodes.
*
* @author Janne Valkealahti
*/
public interface NodeVisitor {
/**
* Visit lists of non terminal and terminal nodes.
*
* @param nonterminalNodes non terminal nodes
* @param terminalNodes terminal nodes
* @return parser result
*/
ParseResult visit(List<NonterminalAstNode> nonterminalNodes, List<TerminalAstNode> terminalNodes);
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.ArrayList;
import java.util.List;
/**
* {@code Nonterminal} node means that it can have children and doesn't
* necessarily terminal {@code ast tree branch}.
*
* @author Janne Valkealahti
*/
public abstract sealed class NonterminalAstNode extends AstNode permits CommandNode, OptionNode {
private final List<AstNode> children = new ArrayList<>();
public NonterminalAstNode(Token token) {
super(token);
}
public List<AstNode> getChildren() {
return children;
}
void addChildNode(AstNode node) {
this.children.add(node);
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Node representing an option argument.
*
* @author Janne Valkealahti
*/
public final class OptionArgumentNode extends TerminalAstNode {
private final OptionNode parentOptionNode;
private final String value;
public OptionArgumentNode(Token token, OptionNode parentOptionNode, String value) {
super(token);
this.parentOptionNode = parentOptionNode;
this.value = value;
}
public OptionNode getParentOptionNode() {
return parentOptionNode;
}
public String getValue() {
return value;
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* Node representing an option.
*
* @author Janne Valkealahti
*/
public final class OptionNode extends NonterminalAstNode {
private final String name;
public OptionNode(Token token, String name) {
super(token);
this.name = name;
}
public String getName() {
return name;
}
@Override
public String toString() {
return "OptionNode [name=" + name + ", children=" + getChildren() + ", token=" + getToken() + "]";
}
}

View File

@@ -0,0 +1,396 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell.command.CommandOption;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.parser.Ast.AstResult;
import org.springframework.shell.command.parser.CommandModel.CommandInfo;
import org.springframework.shell.command.parser.Lexer.LexerResult;
import org.springframework.shell.command.parser.Parser.ParseResult.ArgumentResult;
import org.springframework.shell.command.parser.Parser.ParseResult.OptionResult;
import org.springframework.shell.command.parser.ParserConfig.Feature;
/**
* Interface to parse command line arguments.
*
* @author Janne Valkealahti
*/
public interface Parser {
/**
* Parse given arguments into a {@link ParseResult}.
*
* @param arguments the command line arguments
* @return a parsed results
*/
ParseResult parse(List<String> arguments);
/**
* Results from a {@link Parser} containing needed information like resolved
* {@link CommandRegistration}, list of {@link CommandOption} instances, errors
* and directive.
*
* @param commandRegistration command registration
* @param optionResults option results
* @param argumentResults argument results
* @param messageResults message results
* @param directiveResults directive result
*/
public record ParseResult(CommandRegistration commandRegistration, List<OptionResult> optionResults,
List<ArgumentResult> argumentResults, List<MessageResult> messageResults,
List<DirectiveResult> directiveResults) {
public record OptionResult(CommandOption option, Object value) {
public static OptionResult of(CommandOption option, Object value) {
return new OptionResult(option, value);
}
}
public record ArgumentResult(String value, int position) {
public static ArgumentResult of(String value, int position) {
return new ArgumentResult(value, position);
}
}
}
/**
* Default implementation of a {@link Parser}. Uses {@link Lexer} and
* {@link Ast}.
*/
public class DefaultParser implements Parser {
private final ParserConfig config;
private final CommandModel commandModel;
private final Lexer lexer;
private final Ast ast;
private ConversionService conversionService;
public DefaultParser(CommandModel commandModel, Lexer lexer, Ast ast) {
this(commandModel, lexer, ast, new ParserConfig());
}
public DefaultParser(CommandModel commandModel, Lexer lexer, Ast ast, ParserConfig config) {
this(commandModel, lexer, ast, config, null);
}
public DefaultParser(CommandModel commandModel, Lexer lexer, Ast ast, ParserConfig config,
ConversionService conversionService) {
this.commandModel = commandModel;
this.lexer = lexer;
this.ast = ast;
this.config = config;
this.conversionService = conversionService != null ? conversionService : new DefaultConversionService();
}
@Override
public ParseResult parse(List<String> arguments) {
// 1. tokenize arguments
LexerResult lexerResult = lexer.tokenize(arguments);
List<Token> tokens = lexerResult.tokens();
// 2. generate syntax tree results from tokens
// result from it is then feed into node visitor
AstResult astResult = ast.generate(tokens);
// 3. visit nodes
// whoever uses this parser can then do further
// things with final parsing results
NodeVisitor visitor = new DefaultNodeVisitor(commandModel, conversionService, config);
ParseResult parseResult = visitor.visit(astResult.nonterminalNodes(), astResult.terminalNodes());
parseResult.messageResults().addAll(lexerResult.messageResults());
return parseResult;
}
}
/**
* Default implementation of a {@link NodeVisitor}.
*/
class DefaultNodeVisitor extends AbstractNodeVisitor {
private final CommandModel commandModel;
private final ConversionService conversionService;
private final ParserConfig config;
private final List<MessageResult> commonMessageResults = new ArrayList<>();
private List<String> resolvedCommmand = new ArrayList<>();
private List<OptionResult> optionResults = new ArrayList<>();
private List<String> currentOptionArgument = new ArrayList<>();
private List<DirectiveResult> directiveResults = new ArrayList<>();
private List<OptionNode> invalidOptionNodes = new ArrayList<>();
private List<ArgumentResult> argumentResults = new ArrayList<>();
private int commandArgumentPos = 0;
DefaultNodeVisitor(CommandModel commandModel, ConversionService conversionService, ParserConfig config) {
this.commandModel = commandModel;
this.conversionService = conversionService;
this.config = config;
}
@Override
protected ParseResult buildResult() {
CommandInfo info = commandModel.resolve(resolvedCommmand);
CommandRegistration registration = info != null ? info.registration : null;
List<MessageResult> messageResults = new ArrayList<>();
if (registration != null) {
messageResults.addAll(commonMessageResults);
messageResults.addAll(validateOptionNotMissing(registration));
messageResults.addAll(validateOptionIsValid(registration));
// add options with default values
Set<CommandOption> resolvedOptions1 = optionResults.stream()
.map(or -> or.option())
.collect(Collectors.toSet());
registration.getOptions().stream()
.filter(o -> o.getDefaultValue() != null)
.filter(o -> !resolvedOptions1.contains(o))
.forEach(o -> {
resolvedOptions1.add(o);
Object value = convertOptionType(o, o.getDefaultValue());
optionResults.add(OptionResult.of(o, value));
});
List<CommandOption> optionsForArguments = registration.getOptions().stream()
.filter(o -> !resolvedOptions1.contains(o))
.filter(o -> o.getPosition() > -1)
.filter(o -> o.getArityMin() > -1)
.sorted(Comparator.comparingInt(o -> o.getPosition()))
.collect(Collectors.toList());
List<String> argumentValues = argumentResults.stream()
.sorted(Comparator.comparingInt(ar -> ar.position()))
.map(ar -> ar.value())
.collect(Collectors.toList());
int i = 0;
for (CommandOption o : optionsForArguments) {
int j = i + o.getArityMax();
j = Math.min(argumentValues.size(), j);
List<String> asdf = argumentValues.subList(i, j);
if (asdf.isEmpty()) {
optionResults.add(OptionResult.of(o, null));
}
else {
Object value = convertOptionType(o, asdf);
optionResults.add(OptionResult.of(o, value));
}
if (j == argumentValues.size()) {
break;
}
i = j;
}
}
return new ParseResult(registration, optionResults, argumentResults, messageResults, directiveResults);
}
@Override
protected void onEnterDirectiveNode(DirectiveNode node) {
directiveResults.add(DirectiveResult.of(node.getName(), node.getValue()));
}
@Override
protected void onExitDirectiveNode(DirectiveNode node) {
}
@Override
protected void onEnterRootCommandNode(CommandNode node) {
resolvedCommmand.add(node.getCommand());
}
@Override
protected void onExitRootCommandNode(CommandNode node) {
}
@Override
protected void onEnterCommandNode(CommandNode node) {
resolvedCommmand.add(node.getCommand());
}
@Override
protected void onExitCommandNode(CommandNode node) {
}
private List<CommandOption> currentOptions = new ArrayList<>();
@Override
protected void onEnterOptionNode(OptionNode node) {
commandArgumentPos = 0;
currentOptions.clear();
currentOptionArgument.clear();
CommandInfo info = commandModel.resolve(resolvedCommmand);
String name = node.getName();
if (name.startsWith("--")) {
info.registration.getOptions().forEach(option -> {
Set<String> longNames = Arrays.asList(option.getLongNames()).stream()
.map(n -> "--" + n)
.collect(Collectors.toSet());
String nameToMatch = config.isEnabled(Feature.CASE_SENSITIVE_OPTIONS) ? name : name.toLowerCase();
boolean match = longNames.contains(nameToMatch);
if (match) {
currentOptions.add(option);
}
});
}
else if (name.startsWith("-")) {
if (name.length() == 2) {
info.registration.getOptions().forEach(option -> {
Set<String> shortNames = Arrays.asList(option.getShortNames()).stream()
.map(n -> "-" + Character.toString(n))
.collect(Collectors.toSet());
boolean match = shortNames.contains(name);
if (match) {
currentOptions.add(option);
}
});
}
else if (name.length() > 2) {
info.registration.getOptions().forEach(option -> {
Set<String> shortNames = Arrays.asList(option.getShortNames()).stream()
.map(n -> "-" + Character.toString(n))
.collect(Collectors.toSet());
for (int i = 1; i < name.length(); i++) {
boolean match = shortNames.contains("-" + name.charAt(i));
if (match) {
currentOptions.add(option);
}
}
});
}
}
}
@Override
protected void onExitOptionNode(OptionNode node) {
if (!currentOptions.isEmpty()) {
for (CommandOption currentOption : currentOptions) {
int max = currentOption.getArityMax() > 0 ? currentOption.getArityMax() : Integer.MAX_VALUE;
max = Math.min(max, currentOptionArgument.size());
List<String> toUse = currentOptionArgument.subList(0, max);
if (currentOption.getArityMin() > -1 && currentOptionArgument.size() < currentOption.getArityMin()) {
String arg = currentOption.getLongNames()[0];
commonMessageResults.add(MessageResult.of(ParserMessage.NOT_ENOUGH_OPTION_ARGUMENTS, 0, arg,
currentOptionArgument.size()));
}
else if (currentOption.getArityMax() > -1 && currentOptionArgument.size() > currentOption.getArityMax()) {
String arg = currentOption.getLongNames()[0];
commonMessageResults.add(MessageResult.of(ParserMessage.TOO_MANY_OPTION_ARGUMENTS, 0, arg,
currentOption.getArityMax()));
}
Object value = null;
if (toUse.size() == 1) {
value = toUse.get(0);
}
else if (toUse.size() > 1) {
value = new ArrayList<>(toUse);
}
try {
value = convertOptionType(currentOption, value);
} catch (Exception e) {
commonMessageResults.add(MessageResult.of(ParserMessage.ILLEGAL_OPTION_VALUE, 0, value, e.getMessage()));
}
optionResults.add(new OptionResult(currentOption, value));
}
}
else {
invalidOptionNodes.add(node);
}
}
@Override
protected void onEnterCommandArgumentNode(CommandArgumentNode node) {
argumentResults.add(ArgumentResult.of(node.getToken().getValue(), commandArgumentPos++));
}
@Override
protected void onExitCommandArgumentNode(CommandArgumentNode node) {
}
@Override
protected void onEnterOptionArgumentNode(OptionArgumentNode node) {
currentOptionArgument.add(node.getValue());
}
@Override
protected void onExitOptionArgumentNode(OptionArgumentNode node) {
}
private Object convertOptionType(CommandOption option, Object value) {
ResolvableType type = option.getType();
if (value == null && type != null && type.isAssignableFrom(boolean.class)) {
return true;
}
if (conversionService != null && option.getType() != null && value != null) {
if (conversionService.canConvert(value.getClass(), option.getType().getRawClass())) {
value = conversionService.convert(value, option.getType().getRawClass());
}
}
return value;
}
private List<MessageResult> validateOptionNotMissing(CommandRegistration registration) {
HashSet<CommandOption> requiredOptions = registration.getOptions().stream()
.filter(o -> o.isRequired())
.collect(Collectors.toCollection(() -> new HashSet<>()));
optionResults.stream().map(or -> or.option()).forEach(o -> {
requiredOptions.remove(o);
});
return requiredOptions.stream()
.map(o -> {
String ln = o.getLongNames() != null
? Stream.of(o.getLongNames()).collect(Collectors.joining(","))
: "";
String sn = o.getShortNames() != null ? Stream.of(o.getShortNames()).map(n -> Character.toString(n))
.collect(Collectors.joining(",")) : "";
return MessageResult.of(ParserMessage.MANDATORY_OPTION_MISSING, 0, ln, sn);
})
.collect(Collectors.toList());
}
private List<MessageResult> validateOptionIsValid(CommandRegistration registration) {
return invalidOptionNodes.stream()
.map(on -> {
return MessageResult.of(ParserMessage.UNRECOGNISED_OPTION, 0, on.getName());
})
.collect(Collectors.toList());
}
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
public class ParserConfig {
private long features;
public ParserConfig() {
this.features = Feature.collectDefaults();
}
boolean isEnabled(Feature feature) {
return (features & feature.getMask()) != 0;
}
ParserConfig configure(Feature feature, boolean state) {
return state ? enable(feature) : disable(feature);
}
ParserConfig enable(Feature feature) {
features |= feature.getMask();
return this;
}
ParserConfig disable(Feature feature) {
features &= ~feature.getMask();
return this;
}
public static enum Feature {
/**
* Defines if directives support is enabled, disabled on default.
*/
ALLOW_DIRECTIVES(false),
/**
* Used in a case where directive support is disabled and parser should ignore
* ones found instead of reporting error, disabled on default.
*/
IGNORE_DIRECTIVES(false),
/**
* Defines if commands are parsed using case-sensitivity, enabled on default.
*/
CASE_SENSITIVE_COMMANDS(true),
/**
* Defines if options are parsed using case-sensitivity, enabled on default.
*/
CASE_SENSITIVE_OPTIONS(true)
;
private final boolean defaultState;
private final long mask;
private Feature(boolean defaultState) {
this.mask = (1 << ordinal());
this.defaultState = defaultState;
}
public static long collectDefaults() {
long flags = 0;
for (Feature f : values()) {
if (f.enabledByDefault()) {
flags |= f.getMask();
}
}
return flags;
}
public boolean enabledByDefault() {
return defaultState;
}
public boolean enabledIn(int flags) {
return (flags & mask) != 0;
}
public long getMask() {
return mask;
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.text.MessageFormat;
/**
* Contains all the messages that can be produced during parsing. Each message
* has a kind (WARNING, ERROR) and a code number. Code is used to identify
* particular message and makes it easier to test what messages are produced.
* Code numbers are split so that ones within {@code 1xxx} are from lexer
* and {@code 2xxx} from parser.
*
* Messages with {@code ERROR} should be treated as terminating messages because
* those are most likely hard errors based on manual validation or exception
* thrown within lexing or parsing.
*
* Messages with {@code WARNING} can be ignored but can be used to provide
* info to user. For example parsing may detect some ambiguities with a command
* and option model related to what user tries to use as an input. This
* because there are limits how clever a parser can be as command model
* is beyond its control.
*
* @author Janne Valkealahti
*/
public enum ParserMessage {
ILLEGAL_CONTENT_BEFORE_COMMANDS(Type.ERROR, 1000, "Illegal content before commands ''{0}''"),
MANDATORY_OPTION_MISSING(Type.ERROR, 2000, "Missing mandatory option, longnames=''{0}'', shortnames=''{1}''"),
UNRECOGNISED_OPTION(Type.ERROR, 2001, "Unrecognised option ''{0}''"),
ILLEGAL_OPTION_VALUE(Type.ERROR, 2002, "Illegal option value ''{0}'', reason ''{1}''"),
NOT_ENOUGH_OPTION_ARGUMENTS(Type.ERROR, 2003, "Not enough arguments for option ''{0}'', requires at least ''{1}''"),
TOO_MANY_OPTION_ARGUMENTS(Type.ERROR, 2004, "Too many arguments for option ''{0}'', requires at most ''{1}''")
;
private Type type;
private int code;
private String message;
ParserMessage(Type type, int code, String message) {
this.type = type;
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public Type getType() {
return type;
}
public String formatMessage(int position, Object... inserts) {
StringBuilder msg = new StringBuilder();
msg.append(code);
switch (type) {
case WARNING:
msg.append("W");
break;
case ERROR:
msg.append("E");
break;
}
msg.append(":");
if (position != -1) {
msg.append("(pos ").append(position).append("): ");
}
msg.append(MessageFormat.format(message, inserts));
return msg.toString();
}
public enum Type {
WARNING,
ERROR
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
/**
* {@code Terminal} node means that it is end of {@code ast tree branch}.
*
* @author Janne Valkealahti
*/
public abstract sealed class TerminalAstNode
extends AstNode permits DirectiveNode, OptionArgumentNode, CommandArgumentNode {
public TerminalAstNode(Token token) {
super(token);
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
public class Token {
private final static int IMPLICIT_POSITION = -1;
private final String value;
private final TokenType type;
private final int position;
public Token(String value, TokenType type) {
this(value, type, IMPLICIT_POSITION);
}
public Token(String value, TokenType type, int position) {
this.value = value;
this.type = type;
this.position = position;
}
public static Token of(String value, TokenType type) {
return new Token(value, type);
}
public static Token of(String value, TokenType type, int position) {
return new Token(value, type, position);
}
public String getValue() {
return value;
}
public TokenType getType() {
return type;
}
public int getPosition() {
return position;
}
@Override
public String toString() {
return "Token [type=" + type + ", position=" + position + ", value=" + value + "]";
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
public enum TokenType {
ARGUMENT,
COMMAND,
OPTION,
DOUBLEDASH,
DIRECTIVE
}

View File

@@ -19,6 +19,7 @@ import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@@ -37,14 +38,16 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class CommandExecutionTests extends AbstractCommandTests {
private CommandExecution execution;
private CommandCatalog commandCatalog;
@BeforeEach
public void setupCommandExecutionTests() {
commandCatalog = CommandCatalog.of();
ConversionService conversionService = new DefaultConversionService();
List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>();
resolvers.add(new ArgumentHeaderMethodArgumentResolver(conversionService, null));
resolvers.add(new CommandContextMethodArgumentResolver());
execution = CommandExecution.of(resolvers, null, null, conversionService);
execution = CommandExecution.of(resolvers, null, null, conversionService, commandCatalog);
}
@Test
@@ -60,7 +63,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.function(function1)
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
commandCatalog.register(r1);
Object result = execution.evaluate(new String[] { "command1", "--arg1", "myarg1value" });
assertThat(result).isEqualTo("himyarg1value");
}
@@ -77,7 +81,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method3", String.class)
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
commandCatalog.register(r1);
Object result = execution.evaluate(new String[] { "command1", "--arg1", "myarg1value" });
assertThat(result).isEqualTo("himyarg1value");
assertThat(pojo1.method3Count).isEqualTo(1);
}
@@ -95,7 +100,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method1")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "--arg1", "myarg1value" });
assertThat(pojo1.method1Count).isEqualTo(1);
assertThat(pojo1.method1Ctx).isNotNull();
}
@@ -109,13 +115,13 @@ public class CommandExecutionTests extends AbstractCommandTests {
.longNames("arg1")
.description("some arg1")
.position(0)
.arity(OptionArity.EXACTLY_ONE)
.and()
.withTarget()
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "--arg1" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isNull();
}
@@ -135,7 +141,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "myarg1value" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value");
}
@@ -152,7 +159,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method4")
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
commandCatalog.register(r1);
Object result = execution.evaluate(new String[] { "command1", "--arg1", "myarg1value" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value");
assertThat(result).isEqualTo("himyarg1value");
@@ -173,7 +181,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "myarg1value1", "myarg1value2" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value1");
}
@@ -193,7 +202,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "myarg1value1", "myarg1value2" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("myarg1value1,myarg1value2");
}
@@ -213,7 +223,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method9")
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "myarg1value1", "myarg1value2" });
assertThat(pojo1.method9Count).isEqualTo(1);
assertThat(pojo1.method9Arg1).isEqualTo(new String[] { "myarg1value1", "myarg1value2" });
}
@@ -233,7 +244,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method8")
.and()
.build();
execution.evaluate(r1, new String[]{"1", "2"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "1", "2" });
assertThat(pojo1.method8Count).isEqualTo(1);
assertThat(pojo1.method8Arg1).isEqualTo(new float[] { 1, 2 });
}
@@ -260,7 +272,9 @@ public class CommandExecutionTests extends AbstractCommandTests {
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value"});
commandCatalog.register(r1);
execution.evaluate(
new String[] { "command1", "--arg1", "myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value" });
assertThat(pojo1.method6Count).isEqualTo(1);
assertThat(pojo1.method6Arg1).isEqualTo("myarg1value");
assertThat(pojo1.method6Arg2).isEqualTo("myarg2value");
@@ -290,7 +304,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "1", "--arg2", "2", "--arg3", "3"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "--arg1", "1", "--arg2", "2", "--arg3", "3" });
assertThat(pojo1.method7Count).isEqualTo(1);
assertThat(pojo1.method7Arg1).isEqualTo(1);
assertThat(pojo1.method7Arg2).isEqualTo(2);
@@ -325,7 +340,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.and()
.build();
execution.evaluate(r1, new String[]{"myarg1value", "myarg2value", "myarg3value"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "myarg1value", "myarg2value", "myarg3value" });
assertThat(pojo1.method6Count).isEqualTo(1);
assertThat(pojo1.method6Arg1).isEqualTo("myarg1value");
assertThat(pojo1.method6Arg2).isEqualTo("myarg2value");
@@ -333,10 +349,11 @@ public class CommandExecutionTests extends AbstractCommandTests {
}
@ParameterizedTest
@Disabled("concepts change")
@ValueSource(strings = {
"myarg1value --arg2 myarg2value --arg3 myarg3value",
"--arg1 myarg1value myarg2value --arg3 myarg3value",
"--arg1 myarg1value --arg2 myarg2value myarg3value"
"command1 myarg1value --arg2 myarg2value --arg3 myarg3value",
"command1 --arg1 myarg1value myarg2value --arg3 myarg3value",
"command1 --arg1 myarg1value --arg2 myarg2value myarg3value"
})
public void testMethodMultiplePositionalStringArgsMixed(String arg) {
CommandRegistration r1 = CommandRegistration.builder()
@@ -365,8 +382,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.and()
.build();
String[] args = arg.split(" ");
// execution.evaluate(r1, new String[]{"myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value"});
execution.evaluate(r1, args);
commandCatalog.register(r1);
execution.evaluate(args);
assertThat(pojo1.method6Count).isEqualTo(1);
assertThat(pojo1.method6Arg1).isEqualTo("myarg1value");
assertThat(pojo1.method6Arg2).isEqualTo("myarg2value");
@@ -397,7 +414,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method5")
.and()
.build();
execution.evaluate(r1, new String[]{"-abc"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "-abc" });
assertThat(pojo1.method5ArgA).isTrue();
assertThat(pojo1.method5ArgB).isTrue();
assertThat(pojo1.method5ArgC).isTrue();
@@ -427,7 +445,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method5")
.and()
.build();
execution.evaluate(r1, new String[]{"-ac", "-b", "false"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "-ac", "-b", "false" });
assertThat(pojo1.method5ArgA).isTrue();
assertThat(pojo1.method5ArgB).isFalse();
assertThat(pojo1.method5ArgC).isTrue();
@@ -446,7 +465,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method8")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "0.1"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "--arg1", "0.1" });
assertThat(pojo1.method8Count).isEqualTo(1);
assertThat(pojo1.method8Arg1).isEqualTo(new float[]{0.1f});
}
@@ -464,7 +484,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method8")
.and()
.build();
execution.evaluate(r1, new String[]{"--arg1", "0.1", "0.2"});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1", "--arg1", "0.1", "0.2" });
assertThat(pojo1.method8Count).isEqualTo(1);
assertThat(pojo1.method8Arg1).isEqualTo(new float[]{0.1f, 0.2f});
}
@@ -480,7 +501,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isNull();
}
@@ -499,7 +521,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.method(pojo1, "method4")
.and()
.build();
execution.evaluate(r1, new String[]{});
commandCatalog.register(r1);
execution.evaluate(new String[] { "command1" });
assertThat(pojo1.method4Count).isEqualTo(1);
assertThat(pojo1.method4Arg1).isEqualTo("defaultValue1");
}
@@ -517,8 +540,9 @@ public class CommandExecutionTests extends AbstractCommandTests {
.and()
.build();
commandCatalog.register(r1);
assertThatThrownBy(() -> {
execution.evaluate(r1, new String[]{});
execution.evaluate(new String[] { "command1" });
}).isInstanceOf(CommandParserExceptionsException.class);
}
@@ -536,7 +560,8 @@ public class CommandExecutionTests extends AbstractCommandTests {
.function(function1)
.and()
.build();
Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"});
commandCatalog.register(r1);
Object result = execution.evaluate(new String[] { "command1", "--arg1", "myarg1value" });
assertThat(result).isInstanceOf(CommandNotCurrentlyAvailable.class);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2022 the original author or authors.
* Copyright 2022-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -22,9 +22,6 @@ import org.junit.jupiter.api.Test;
import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;
import org.springframework.shell.command.CommandParser.CommandParserException;
import org.springframework.shell.command.CommandParser.MissingOptionException;
import org.springframework.shell.command.CommandParser.NotEnoughArgumentsOptionException;
import org.springframework.shell.command.CommandParser.TooManyArgumentsOptionException;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,118 +30,14 @@ class CommandParserExceptionResolverTests {
private final CommandParserExceptionResolver resolver = new CommandParserExceptionResolver();
@Test
void resolvesMissingLongOption() {
CommandRegistration registration = CommandRegistration.builder()
.command("required-value")
.withOption()
.longNames("arg1")
.description("Desc arg1")
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
CommandHandlingResult resolve = resolver.resolve(missingOption(registration.getOptions().get(0)));
void resolvesCommandParserException() {
CommandHandlingResult resolve = resolver.resolve(genericParserException());
assertThat(resolve).isNotNull();
assertThat(resolve.message()).contains("--arg1", "Desc arg1");
assertThat(resolve.message()).contains("hi");
}
@Test
void resolvesMissingLongOptionWhenAlsoShort() {
CommandRegistration registration = CommandRegistration.builder()
.command("required-value")
.withOption()
.longNames("arg1")
.shortNames('x')
.description("Desc arg1")
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
CommandHandlingResult resolve = resolver.resolve(missingOption(registration.getOptions().get(0)));
assertThat(resolve).isNotNull();
assertThat(resolve.message()).contains("--arg1", "Desc arg1");
assertThat(resolve.message()).doesNotContain("-x", "Desc x");
}
@Test
void resolvesMissingShortOption() {
CommandRegistration registration = CommandRegistration.builder()
.command("required-value")
.withOption()
.shortNames('x')
.description("Desc x")
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
CommandHandlingResult resolve = resolver.resolve(missingOption(registration.getOptions().get(0)));
assertThat(resolve).isNotNull();
assertThat(resolve.message()).contains("-x", "Desc x");
}
@Test
void resolvesTooManyLongOption() {
CommandRegistration registration = CommandRegistration.builder()
.command("required-value")
.withOption()
.longNames("arg1")
.description("Desc arg1")
.arity(2, 3)
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
CommandHandlingResult resolve = resolver.resolve(tooManyArguments(registration.getOptions().get(0)));
assertThat(resolve).isNotNull();
assertThat(resolve.message()).contains("--arg1 requires at most", "Desc arg1");
}
@Test
void resolvesNotEnoughLongOption() {
CommandRegistration registration = CommandRegistration.builder()
.command("required-value")
.withOption()
.longNames("arg1")
.description("Desc arg1")
.arity(2, 3)
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
CommandHandlingResult resolve = resolver.resolve(notEnoughArguments(registration.getOptions().get(0)));
assertThat(resolve).isNotNull();
assertThat(resolve.message()).contains("--arg1 requires at least", "Desc arg1");
}
static CommandParserExceptionsException missingOption(CommandOption option) {
MissingOptionException e = new MissingOptionException("msg", option);
List<CommandParserException> parserExceptions = Arrays.asList(e);
return new CommandParserExceptionsException("msg", parserExceptions);
}
static CommandParserExceptionsException tooManyArguments(CommandOption option) {
TooManyArgumentsOptionException e = new TooManyArgumentsOptionException("msg", option);
List<CommandParserException> parserExceptions = Arrays.asList(e);
return new CommandParserExceptionsException("msg", parserExceptions);
}
static CommandParserExceptionsException notEnoughArguments(CommandOption option) {
NotEnoughArgumentsOptionException e = new NotEnoughArgumentsOptionException("msg", option);
static CommandParserExceptionsException genericParserException() {
CommandParserException e = new CommandParserException("hi");
List<CommandParserException> parserExceptions = Arrays.asList(e);
return new CommandParserExceptionsException("msg", parserExceptions);
}

View File

@@ -15,600 +15,40 @@
*/
package org.springframework.shell.command;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell.command.CommandParser.CommandParserResults;
import org.springframework.shell.command.CommandParser.MissingOptionException;
import org.springframework.shell.command.CommandParser.NotEnoughArgumentsOptionException;
import org.springframework.shell.command.CommandParser.TooManyArgumentsOptionException;
import org.springframework.shell.command.CommandParser.UnrecognisedOptionException;
import org.springframework.shell.command.parser.ParserConfig;
import static org.assertj.core.api.Assertions.assertThat;
public class CommandParserTests extends AbstractCommandTests {
private CommandParser parser;
@Test
public void testMethodExecution1() {
CommandRegistration r1 = CommandRegistration.builder()
.command("command1")
.description("help")
.withOption()
.longNames("arg1")
.description("some arg1")
.and()
.withTarget()
.method(pojo1, "method3", String.class)
.and()
.build();
@BeforeEach
public void setupCommandParserTests() {
ConversionService conversionService = new DefaultConversionService();
parser = CommandParser.of(conversionService);
}
@Test
public void testEmptyOptionsAndArgs() {
CommandParserResults results = parser.parse(Collections.emptyList(), new String[0]);
assertThat(results.results()).hasSize(0);
}
@Test
public void testLongName() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo"};
CommandParserResults results = parser.parse(options, args);
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("command1", r1);
CommandParser parser = CommandParser.of(conversionService, registrations, new ParserConfig());
CommandParserResults results = parser.parse(new String[] { "command1", "--arg1", "myarg1value" });
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
}
@Test
public void testShortName() {
CommandOption option1 = shortOption('a');
CommandOption option2 = shortOption('b');
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"-a", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
}
@Test
public void testMultipleArgs() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo", "--arg2", "bar"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("bar");
}
@Test
public void testMultipleArgsWithMultiValues() {
CommandOption option1 = longOption("arg1", null, false, null, 1, 2);
CommandOption option2 = longOption("arg2", null, false, null, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo1", "foo2", "--arg2", "bar1", "bar2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(Arrays.asList("foo1", "foo2"));
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo(Arrays.asList("bar1", "bar2"));
assertThat(results.positional()).isEmpty();
}
@Test
public void testBooleanWithoutArg() {
ResolvableType type = ResolvableType.forType(boolean.class);
CommandOption option1 = shortOption('v', type);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"-v"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(true);
}
@Test
public void testBooleanWithArg() {
ResolvableType type = ResolvableType.forType(boolean.class);
CommandOption option1 = shortOption('v', type);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"-v", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(false);
}
@Test
public void testMissingRequiredOption() {
CommandOption option1 = longOption("arg1", true);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{};
CommandParserResults results = parser.parse(options, args);
assertThat(results.errors()).hasSize(1);
}
@Test
public void testSpaceInArgWithOneArg() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "foo bar"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo bar");
}
@Test
public void testSpaceInArgWithMultipleArgs() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo bar", "--arg2", "hi"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo bar");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("hi");
}
@Test
public void testNonMappedArgs() {
String[] args = new String[]{"arg1", "arg2"};
CommandParserResults results = parser.parse(Collections.emptyList(), args);
assertThat(results.results()).hasSize(0);
assertThat(results.positional()).containsExactly("arg1", "arg2");
}
@Test
public void testNonMappedArgBeforeOption() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"foo", "--arg1", "value"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value");
assertThat(results.positional()).containsExactly("foo");
}
@Test
public void testNonMappedArgAfterOption() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value");
assertThat(results.positional()).containsExactly("foo");
}
@Test
public void testNonMappedArgWithoutOption() {
CommandOption option1 = longOption("arg1", 0, 1, 2);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
// no type so we get raw list
assertThat(results.results().get(0).value()).isEqualTo(Arrays.asList("value", "foo"));
assertThat(results.positional()).containsExactly("value", "foo");
}
@Test
public void testNonMappedArgWithoutOptionHavingType() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(String.class), false, 0, 1, 2);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value,foo");
assertThat(results.positional()).containsExactly("value", "foo");
}
@Test
public void testMappedFromArgToString() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(String.class), false, 0, 1, 2);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value,foo");
assertThat(results.positional()).isEmpty();
}
@Test
public void testShortOptionsCombined() {
CommandOption optionA = shortOption('a');
CommandOption optionB = shortOption('b');
CommandOption optionC = shortOption('c');
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isNull();
assertThat(results.results().get(1).value()).isNull();
assertThat(results.results().get(2).value()).isNull();
}
@Test
public void testShortOptionsCombinedBooleanType() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isEqualTo(true);
assertThat(results.results().get(1).value()).isEqualTo(true);
assertThat(results.results().get(2).value()).isEqualTo(true);
}
@Test
public void testShortOptionsCombinedBooleanTypeArgFalse() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isEqualTo(false);
assertThat(results.results().get(1).value()).isEqualTo(false);
assertThat(results.results().get(2).value()).isEqualTo(false);
}
@Test
public void testShortOptionsCombinedBooleanTypeSomeArgFalse() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-ac", "-b", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionC);
assertThat(results.results().get(2).option()).isSameAs(optionB);
assertThat(results.results().get(0).value()).isEqualTo(true);
assertThat(results.results().get(1).value()).isEqualTo(true);
assertThat(results.results().get(2).value()).isEqualTo(false);
}
@Test
public void testLongOptionsWithArray() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(int[].class));
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(new int[] { 1, 2 });
}
@Test
public void testLongOptionsWithStringArray() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(String[].class));
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(new String[] { "1", "2" });
}
@Test
public void testLongOptionsWithPlainList() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(List.class));
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(Arrays.asList("1", "2"));
}
@Test
public void testLongOptionsWithTypedList() {
CommandOption option1 = longOption("arg1", ResolvableType.forClassWithGenerics(List.class, String.class));
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(Arrays.asList("1", "2"));
}
@Test
public void testArityErrors() {
CommandOption option1 = CommandOption.of(
new String[] { "arg1" },
null,
null,
ResolvableType.forType(int[].class),
true,
null,
null,
2,
3,
null,
null);
List<CommandOption> options = Arrays.asList(option1);
String[] args1 = new String[]{"--arg1", "1", "2", "3", "4"};
CommandParserResults results1 = parser.parse(options, args1);
assertThat(results1.errors()).hasSize(1);
assertThat(results1.errors().get(0)).isInstanceOf(TooManyArgumentsOptionException.class);
assertThat(results1.results()).hasSize(1);
assertThat(results1.results().get(0).option()).isSameAs(option1);
assertThat(results1.results().get(0).value()).isNull();
String[] args2 = new String[]{"--arg1", "1"};
CommandParserResults results2 = parser.parse(options, args2);
assertThat(results2.errors()).hasSize(1);
assertThat(results2.errors().get(0)).isInstanceOf(NotEnoughArgumentsOptionException.class);
assertThat(results2.results()).hasSize(1);
assertThat(results2.results().get(0).option()).isSameAs(option1);
assertThat(results2.results().get(0).value()).isNull();
}
@Test
public void testMapToIntArray() {
CommandOption option1 = CommandOption.of(
new String[] { "arg1" },
null,
null,
ResolvableType.forType(int[].class),
false,
null,
0,
1,
2,
null,
null);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.errors()).hasSize(0);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(new int[] { 1, 2 });
}
@Test
public void testMapPositionalArgs1() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(String.class), false, 0, 1, 1);
CommandOption option2 = longOption("arg2", ResolvableType.forType(String.class), false, 1, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo("1");
assertThat(results.results().get(1).value()).isEqualTo("2");
}
@Test
public void testMapPositionalArgs11() {
CommandOption option1 = longOption("arg1", 0, 1, 1);
CommandOption option2 = longOption("arg2", 1, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo(Arrays.asList("1"));
// no type so we get raw list
assertThat(results.results().get(1).value()).isEqualTo(Arrays.asList("2"));
}
@Test
public void testMapPositionalArgs2() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(String.class), false, 0, 1, 1);
CommandOption option2 = longOption("arg2", ResolvableType.forType(String.class), false, 1, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo("1");
assertThat(results.results().get(1).value()).isEqualTo("2");
}
@Test
public void testBooleanWithDefault() {
ResolvableType type = ResolvableType.forType(boolean.class);
CommandOption option1 = CommandOption.of(new String[] { "arg1" }, new Character[0], "description", type, false,
"true", null, null, null, null, null);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(true);
}
@Test
public void testIntegerWithDefault() {
ResolvableType type = ResolvableType.forType(Integer.class);
CommandOption option1 = CommandOption.of(new String[] { "arg1" }, new Character[0], "description", type, false,
"1", null, null, null, null, null);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(1);
}
@Test
public void testIntegerWithGivenValue() {
ResolvableType type = ResolvableType.forType(Integer.class);
CommandOption option1 = CommandOption.of(new String[] { "arg1" }, new Character[0], "description", type, false,
null, null, null, null, null, null);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[] { "--arg1", "1" };
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(1);
}
@Test
public void testNotDefinedLongOptionWithoutOptions() {
// gh-602
List<CommandOption> options = Arrays.asList();
String[] args = new String[]{"--arg1", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(0);
assertThat(results.errors()).hasSize(1);
assertThat(results.errors()).satisfiesExactly(
e -> {
assertThat(e).isInstanceOf(UnrecognisedOptionException.class);
}
);
assertThat(results.positional()).hasSize(1);
}
@Test
public void testNotDefinedLongOptionWithOptionalOption() {
// gh-602
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "bar", "--arg2", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.errors()).hasSize(1);
assertThat(results.errors()).satisfiesExactly(
e -> {
assertThat(e).isInstanceOf(UnrecognisedOptionException.class);
}
);
assertThat(results.positional()).hasSize(1);
}
@Test
public void testNotDefinedLongOptionWithRequiredOption() {
// gh-602
CommandOption option1 = longOption("arg1", true);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg2", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(0);
assertThat(results.errors()).hasSize(2);
assertThat(results.errors()).satisfiesExactly(
e -> {
assertThat(e).isInstanceOf(UnrecognisedOptionException.class);
},
e -> {
assertThat(e).isInstanceOf(MissingOptionException.class);
}
);
assertThat(results.positional()).hasSize(1);
}
@Test
public void testDashOptionValueDoNotError() {
// gh-651
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "-1"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.errors()).hasSize(0);
assertThat(results.positional()).hasSize(0);
}
@Test
public void testPositionDoesNotAffectRequiredErrorWithOtherErrors() {
// gh-601
CommandOption o1 = CommandOption.of(
new String[] { "arg1" },
null,
null,
null,
true,
null,
0,
1,
1,
null,
null);
List<CommandOption> options = Arrays.asList(o1);
String[] args = new String[]{"--arg2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(0);
assertThat(results.errors()).hasSize(2);
assertThat(results.positional()).hasSize(0);
}
private static CommandOption longOption(String name) {
return longOption(name, null);
}
private static CommandOption longOption(String name, boolean required) {
return longOption(name, null, required, null);
}
private static CommandOption longOption(String name, ResolvableType type) {
return longOption(name, type, false, null);
}
private static CommandOption longOption(String name, int position, int arityMin, int arityMax) {
return longOption(name, null, false, position, arityMin, arityMax);
}
private static CommandOption longOption(String name, ResolvableType type, boolean required, Integer position) {
return longOption(name, type, required, position, null, null);
}
private static CommandOption longOption(String name, ResolvableType type, boolean required, Integer position, Integer arityMin, Integer arityMax) {
return CommandOption.of(new String[] { name }, new Character[0], "desc", type, required, null, position,
arityMin, arityMax, null, null);
}
private static CommandOption shortOption(char name) {
return shortOption(name, null);
}
private static CommandOption shortOption(char name, ResolvableType type) {
return CommandOption.of(new String[0], new Character[] { name }, "desc", type);
assertThat(results.results().get(0).value()).isEqualTo("myarg1value");
}
}

View File

@@ -0,0 +1,274 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.parser.Ast.AstResult;
import org.springframework.shell.command.parser.Lexer.LexerResult;
import org.springframework.shell.command.parser.Parser.ParseResult;
abstract class AbstractParsingTests {
static final CommandRegistration ROOT1 = CommandRegistration.builder()
.command("root1")
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT1_UP = CommandRegistration.builder()
.command("ROOT1")
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT2 = CommandRegistration.builder()
.command("root2")
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT2_SUB1 = CommandRegistration.builder()
.command("root2", "sub1")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT2_SUB2 = CommandRegistration.builder()
.command("root2", "sub2")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT2_SUB1_SUB2 = CommandRegistration.builder()
.command("root2", "sub1", "sub2")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT2_SUB1_SUB3 = CommandRegistration.builder()
.command("root2", "sub1", "sub3")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT2_SUB1_SUB4 = CommandRegistration.builder()
.command("root2", "sub1", "sub4")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT3 = CommandRegistration.builder()
.command("root3")
.withOption()
.longNames("arg1")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT3_SHORT_OPTION_A = CommandRegistration.builder()
.command("root3")
.withOption()
.shortNames('a')
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT4 = CommandRegistration.builder()
.command("root4")
.withOption()
.longNames("arg1")
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT5 = CommandRegistration.builder()
.command("root5")
.withOption()
.longNames("arg1")
.required()
.and()
.withOption()
.longNames("arg2")
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT6_OPTION_INT = CommandRegistration.builder()
.command("root6")
.withOption()
.longNames("arg1")
.type(int.class)
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT6_OPTION_INTARRAY = CommandRegistration.builder()
.command("root6")
.withOption()
.longNames("arg1")
.type(int[].class)
.required()
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
static final CommandRegistration ROOT6_OPTION_DEFAULT_VALUE = CommandRegistration.builder()
.command("root6")
.withOption()
.longNames("arg1")
.defaultValue("defaultvalue")
.and()
.withTarget()
.consumer(ctx -> {})
.and()
.build();
Map<String, CommandRegistration> registrations = new HashMap<>();
@BeforeEach
void setup() {
registrations.clear();
}
void register(CommandRegistration registration) {
registrations.put(registration.getCommand(), registration);
}
CommandModel commandModel() {
return new CommandModel(registrations, new ParserConfig());
}
CommandModel commandModel(ParserConfig configuration) {
return new CommandModel(registrations, configuration);
}
Token token(String value, TokenType type, int position) {
return new Token(value, type, position);
}
Lexer lexer() {
return new Lexer.DefaultLexer(commandModel(), new ParserConfig());
}
Lexer lexer(ParserConfig config) {
return new Lexer.DefaultLexer(commandModel(config), config);
}
Lexer lexer(CommandModel commandModel) {
return new Lexer.DefaultLexer(commandModel, new ParserConfig());
}
Lexer lexer(CommandModel commandModel, ParserConfig configuration) {
return new Lexer.DefaultLexer(commandModel, configuration);
}
List<Token> tokenize(String... arguments) {
return tokenize(lexer(), arguments);
}
List<Token> tokenize(Lexer lexer, String... arguments) {
return lexer.tokenize(Arrays.asList(arguments)).tokens();
}
LexerResult tokenizeAsResult(String... arguments) {
return tokenizeAsResult(lexer(), arguments);
}
LexerResult tokenizeAsResult(Lexer lexer, String... arguments) {
return lexer.tokenize(Arrays.asList(arguments));
}
Ast ast() {
return new Ast.DefaultAst();
}
AstResult ast(List<Token> tokens) {
Ast ast = ast();
return ast.generate(tokens);
}
AstResult ast(Token... tokens) {
Ast ast = ast();
return ast.generate(Arrays.asList(tokens));
}
ParseResult parse(String... arguments) {
CommandModel commandModel = commandModel();
Lexer lexer = lexer(commandModel);
Ast ast = ast();
Parser parser = new Parser.DefaultParser(commandModel, lexer, ast);
return parser.parse(Arrays.asList(arguments));
}
ParseResult parse(Lexer lexer, String... arguments) {
CommandModel commandModel = commandModel();
Ast ast = ast();
Parser parser = new Parser.DefaultParser(commandModel, lexer, ast);
return parser.parse(Arrays.asList(arguments));
}
ParseResult parse(ParserConfig config, String... arguments) {
CommandModel commandModel = commandModel(config);
Ast ast = ast();
Parser parser = new Parser.DefaultParser(commandModel, lexer(config), ast, config);
return parser.parse(Arrays.asList(arguments));
}
}

View File

@@ -0,0 +1,210 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import org.junit.jupiter.api.Test;
import org.springframework.shell.command.parser.Ast.AstResult;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for default implementation in an {@link Ast}.
*
* @author Janne Valkealahti
*/
class AstTests extends AbstractParsingTests {
@Test
void createsCommandNode() {
register(ROOT1);
Token root1 = token("root1", TokenType.COMMAND, 0);
AstResult result = ast(root1);
assertThat(result).isNotNull();
assertThat(result.nonterminalNodes()).satisfiesExactly(
node -> {
assertThat(node).isInstanceOf(CommandNode.class);
CommandNode cn = (CommandNode)node;
assertThat(cn.getCommand()).isEqualTo("root1");
});
}
@Test
void createsMultipleCommandNodes() {
register(ROOT2_SUB1_SUB2);
Token root1 = token("root1", TokenType.COMMAND, 0);
Token sub1 = token("sub1", TokenType.COMMAND, 1);
Token sub2 = token("sub2", TokenType.COMMAND, 2);
Token arg1 = token("--arg1", TokenType.OPTION, 3);
Token value1 = token("xxx", TokenType.ARGUMENT, 4);
AstResult result = ast(root1, sub1, sub2, arg1, value1);
assertThat(result).isNotNull();
assertThat(result.nonterminalNodes()).satisfiesExactly(
n1 -> {
assertThat(n1).isInstanceOf(CommandNode.class);
CommandNode cn1 = (CommandNode)n1;
assertThat(cn1.getCommand()).isEqualTo("root1");
assertThat(cn1.getChildren()).satisfiesExactly(
n2 -> {
assertThat(n2).isInstanceOf(CommandNode.class);
CommandNode cn2 = (CommandNode)n2;
assertThat(cn2.getCommand()).isEqualTo("sub1");
assertThat(cn2.getChildren()).satisfiesExactly(
n3 -> {
assertThat(n3).isInstanceOf(CommandNode.class);
CommandNode cn3 = (CommandNode)n3;
assertThat(cn3.getCommand()).isEqualTo("sub2");
assertThat(cn3.getChildren()).satisfiesExactly(
n4 -> {
assertThat(n4).isInstanceOf(OptionNode.class);
OptionNode on4 = (OptionNode)n4;
assertThat(on4.getChildren()).satisfiesExactly(
n5 -> {
assertThat(n5).isInstanceOf(OptionArgumentNode.class);
}
);
}
);
}
);
});
});
}
@Test
void createsOptionNodeNoOptionArg() {
register(ROOT3);
Token root3 = token("root3", TokenType.COMMAND, 0);
Token arg1 = token("--arg1", TokenType.OPTION, 1);
AstResult result = ast(root3, arg1);
assertThat(result).isNotNull();
assertThat(result.nonterminalNodes()).hasSize(1);
assertThat(result.nonterminalNodes().get(0)).isInstanceOf(CommandNode.class);
assertThat(result.nonterminalNodes().get(0)).satisfies(n -> {
CommandNode cn = (CommandNode)n;
assertThat(cn.getCommand()).isEqualTo("root3");
assertThat(cn.getChildren()).hasSize(1);
assertThat(cn.getChildren())
.filteredOn(on -> on instanceof OptionNode)
.extracting(on -> {
return ((OptionNode)on).getName();
})
.containsExactly("--arg1");
});
}
@Test
void createsOptionNodeWithOptionArg() {
register(ROOT3);
Token root3 = token("root3", TokenType.COMMAND, 0);
Token arg1 = token("--arg1", TokenType.OPTION, 1);
Token value1 = token("value1", TokenType.ARGUMENT, 2);
AstResult result = ast(root3, arg1, value1);
assertThat(result).isNotNull();
assertThat(result.nonterminalNodes()).hasSize(1);
assertThat(result.nonterminalNodes().get(0)).isInstanceOf(CommandNode.class);
assertThat(result.nonterminalNodes().get(0)).satisfies(n -> {
CommandNode cn = (CommandNode)n;
assertThat(cn.getCommand()).isEqualTo("root3");
assertThat(cn.getChildren()).hasSize(1);
assertThat(cn.getChildren())
.filteredOn(on -> on instanceof OptionNode)
.extracting(on -> {
return ((OptionNode)on).getName();
})
.containsExactly("--arg1");
OptionNode on = (OptionNode) cn.getChildren().get(0);
assertThat(on.getChildren()).hasSize(1);
OptionArgumentNode oan = (OptionArgumentNode) on.getChildren().get(0);
assertThat(oan.getValue()).isEqualTo("value1");
});
}
@Test
void createOptionNodesWhenNoOptionArguments() {
register(ROOT3);
Token root3 = token("root3", TokenType.COMMAND, 0);
Token arg1 = token("--arg1", TokenType.OPTION, 1);
Token arg2 = token("--arg2", TokenType.OPTION, 2);
AstResult result = ast(root3, arg1, arg2);
assertThat(result).isNotNull();
assertThat(result.nonterminalNodes()).hasSize(1);
assertThat(result.nonterminalNodes().get(0)).isInstanceOf(CommandNode.class);
assertThat(result.nonterminalNodes().get(0)).satisfies(n -> {
CommandNode cn = (CommandNode)n;
assertThat(cn.getCommand()).isEqualTo("root3");
assertThat(cn.getChildren()).hasSize(2);
assertThat(cn.getChildren())
.filteredOn(on -> on instanceof OptionNode)
.extracting(on -> {
return ((OptionNode)on).getName();
})
.containsExactly("--arg1", "--arg2");
});
}
@Test
void directiveNoValueWithCommand() {
register(ROOT1);
Token directive = token("fake", TokenType.DIRECTIVE, 0);
Token root1 = token("root1", TokenType.COMMAND, 1);
AstResult result = ast(directive, root1);
assertThat(result.terminalNodes()).hasSize(1);
assertThat(result.terminalNodes().get(0)).isInstanceOf(DirectiveNode.class);
DirectiveNode dn = (DirectiveNode) result.terminalNodes().get(0);
assertThat(dn.getName()).isEqualTo("fake");
assertThat(dn.getValue()).isNull();
}
@Test
void directiveWithValueWithCommand() {
register(ROOT1);
Token directive = token("fake:value", TokenType.DIRECTIVE, 0);
Token root1 = token("root1", TokenType.COMMAND, 1);
AstResult result = ast(directive, root1);
assertThat(result.terminalNodes()).hasSize(1);
assertThat(result.terminalNodes().get(0)).isInstanceOf(DirectiveNode.class);
DirectiveNode dn = (DirectiveNode) result.terminalNodes().get(0);
assertThat(dn.getName()).isEqualTo("fake");
assertThat(dn.getValue()).isEqualTo("value");
}
@Test
void multipleDirectivesWithValueWithCommand() {
register(ROOT1);
Token directive1 = token("fake1:value1", TokenType.DIRECTIVE, 0);
Token directive2 = token("fake2:value2", TokenType.DIRECTIVE, 1);
Token root1 = token("root1", TokenType.COMMAND, 2);
AstResult result = ast(directive1, directive2, root1);
assertThat(result.terminalNodes()).hasSize(2);
assertThat(result.terminalNodes().get(0)).isInstanceOf(DirectiveNode.class);
DirectiveNode dn1 = (DirectiveNode) result.terminalNodes().get(0);
assertThat(dn1.getName()).isEqualTo("fake1");
assertThat(dn1.getValue()).isEqualTo("value1");
DirectiveNode dn2 = (DirectiveNode) result.terminalNodes().get(1);
assertThat(dn2.getName()).isEqualTo("fake2");
assertThat(dn2.getValue()).isEqualTo("value2");
}
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.Arrays;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.shell.command.parser.ParserConfig.Feature;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link CommandModel}.
*
* @author Janne Valkealahti
*/
class CommandModelTests extends AbstractParsingTests {
@Test
void oneRootCommand() {
register(ROOT1);
CommandModel model = commandModel();
Map<String, Token> tokens = model.getValidRootTokens();
assertThat(tokens).hasSize(1);
assertThat(tokens.get("root1")).satisfies(token -> {
assertThat(token).isNotNull();
assertThat(token.getValue()).isEqualTo("root1");
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
}
@Test
void oneRootCommandCaseInsensitive() {
register(ROOT1_UP);
ParserConfig configuration = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS);
CommandModel model = commandModel(configuration);
Map<String, Token> tokens = model.getValidRootTokens();
assertThat(tokens).hasSize(1);
assertThat(tokens.get("root1")).satisfies(token -> {
assertThat(token).isNotNull();
assertThat(token.getValue()).isEqualTo("root1");
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
}
@Test
void onePlainSubCommand() {
register(ROOT2_SUB1);
CommandModel model = commandModel();
Map<String, Token> tokens = model.getValidRootTokens();
assertThat(tokens).hasSize(1);
assertThat(tokens.get("root2")).satisfies(token -> {
assertThat(token).isNotNull();
assertThat(token.getValue()).isEqualTo("root2");
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
assertThat(model.getRootCommand("root2")).satisfies(root1 -> {
assertThat(root1).isNotNull();
assertThat(root1.getChildren()).hasSize(1);
assertThat(root1.registration).isNull();
assertThat(root1.getChildren()).satisfiesExactly(
sub1 -> {
assertThat(sub1).isNotNull();
assertThat(sub1.getChildren()).isEmpty();
assertThat(sub1.registration).isNotNull();
});
});
}
@Test
void onePlainSubCommand2() {
register(ROOT2_SUB1);
register(ROOT2_SUB2);
CommandModel model = commandModel();
Map<String, Token> tokens = model.getValidRootTokens();
assertThat(tokens).hasSize(1);
assertThat(tokens.get("root2")).satisfies(token -> {
assertThat(token).isNotNull();
assertThat(token.getValue()).isEqualTo("root2");
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
assertThat(model.getRootCommand("root2")).satisfies(root1 -> {
assertThat(root1.command).isEqualTo("root2");
assertThat(root1.getChildren()).hasSize(2);
assertThat(root1.registration).isNull();
assertThat(root1.getValidTokens()).satisfies(map -> {
assertThat(map).hasSize(2);
assertThat(map).hasEntrySatisfying("sub1", token -> {
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
assertThat(map).hasEntrySatisfying("sub2", token -> {
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
});
assertThat(root1.getChildren()).satisfiesExactlyInAnyOrder(
sub1 -> {
assertThat(sub1.command).isEqualTo("sub1");
assertThat(sub1).isNotNull();
assertThat(sub1.getChildren()).isEmpty();
assertThat(sub1.registration).isNotNull();
},
sub2 -> {
assertThat(sub2.command).isEqualTo("sub2");
assertThat(sub2).isNotNull();
assertThat(sub2.getChildren()).isEmpty();
assertThat(sub2.registration).isNotNull();
});
});
}
@Test
void onePlainSubCommand3() {
register(ROOT2_SUB1_SUB2);
register(ROOT2_SUB1_SUB3);
register(ROOT2_SUB1_SUB4);
CommandModel model = commandModel();
Map<String, Token> tokens = model.getValidRootTokens();
assertThat(tokens).hasSize(1);
assertThat(tokens.get("root2")).satisfies(token -> {
assertThat(token).isNotNull();
assertThat(token.getValue()).isEqualTo("root2");
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
assertThat(model.getRootCommand("root2")).satisfies(root1 -> {
assertThat(root1.command).isEqualTo("root2");
assertThat(root1.getChildren()).hasSize(1);
assertThat(root1.registration).isNull();
assertThat(root1.getValidTokens()).satisfies(map -> {
assertThat(map).hasSize(1);
assertThat(map).hasEntrySatisfying("sub1", token -> {
assertThat(token.getType()).isEqualTo(TokenType.COMMAND);
});
});
assertThat(root1.getChildren()).satisfiesExactlyInAnyOrder(
sub1 -> {
assertThat(sub1.command).isEqualTo("sub1");
assertThat(sub1.getChildren()).satisfiesExactlyInAnyOrder(
sub2 -> {
assertThat(sub2.getChildren()).isEmpty();
},
sub3 -> {
assertThat(sub3.getChildren()).isEmpty();
},
sub4 -> {
assertThat(sub4.getChildren()).isEmpty();
}
);
// assertThat(sub1).isNotNull();
// assertThat(sub1.getChildren()).isEmpty();
// assertThat(sub1.registration).isNotNull();
}
);
});
}
@Test
void shouldResolveCommand() {
register(ROOT2_SUB1);
CommandModel model = commandModel();
assertThat(model.resolve(Arrays.asList("root2", "sub1"))).isNotNull();
}
@Test
void shouldResolveParentCommand() {
register(ROOT2);
register(ROOT2_SUB1_SUB2);
CommandModel model = commandModel();
assertThat(model.resolve(Arrays.asList("root2"))).satisfies(
info -> {
assertThat(info.registration).isNotNull();
}
);
}
}

View File

@@ -0,0 +1,473 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.shell.command.parser.Lexer.LexerResult;
import org.springframework.shell.command.parser.ParserConfig.Feature;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for default implementation in a {@link Lexer}.
*
* @author Janne Valkealahti
*/
class LexerTests extends AbstractParsingTests {
@Nested
class NoOptions {
@Test
void rootCommandNoOptions() {
register(ROOT1);
List<Token> tokens = tokenize("root1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root1");
});
}
}
@Nested
class CaseSensitivity {
@Test
void commandRegUpperCommandLower() {
register(ROOT1_UP);
ParserConfig config = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS);
List<Token> tokens = tokenize(lexer(config), "root1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root1");
});
}
@Test
void commandRegLowerCommandUpper() {
register(ROOT1);
ParserConfig config = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS);
List<Token> tokens = tokenize(lexer(config), "Root1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("Root1");
});
}
}
// @Test
// void commandWithCaseInsensitiveInArguments() {
// register(ROOT1);
// ParserConfig configuration = new ParserConfig()
// .setOptionsCaseSensitive(false);
// List<Token> tokens = tokenize(lexer(configuration), "ROOT1");
// assertThat(tokens).satisfiesExactly(
// token -> {
// ParserAssertions.assertThat(token)
// .isType(TokenType.COMMAND)
// .hasPosition(0)
// .hasValue("ROOT1");
// });
// }
@Test
void rootCommandWithChildGetsRootForRoot() {
register(ROOT1);
register(ROOT2_SUB1);
List<Token> tokens = tokenize("root1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root1");
});
}
@Test
void subCommandLevel1WithoutRoot() {
register(ROOT2_SUB1);
List<Token> tokens = tokenize("root2", "sub1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root2");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(1)
.hasValue("sub1");
});
}
@Test
void subCommandLevel2WithoutRoot() {
register(ROOT2_SUB1_SUB2);
List<Token> tokens = tokenize("root2", "sub1", "sub2");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root2");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(1)
.hasValue("sub1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(2)
.hasValue("sub2");
});
}
@Test
void subCommandLevel2WithoutRootWithOption() {
register(ROOT2_SUB1_SUB2);
List<Token> tokens = tokenize("root2", "sub1", "sub2", "--arg1", "xxx");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root2");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(1)
.hasValue("sub1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(2)
.hasValue("sub2");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(3)
.hasValue("--arg1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.ARGUMENT)
.hasPosition(4)
.hasValue("xxx");
});
}
@Test
void definedOptionShouldBeOptionAfterCommand() {
register(ROOT3);
List<Token> tokens = tokenize("root3", "--arg1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root3");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(1)
.hasValue("--arg1");
});
}
@Test
void notDefinedOptionShouldBeOptionAfterCommand() {
register(ROOT3);
List<Token> tokens = tokenize("root3", "--arg2");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root3");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(1)
.hasValue("--arg2");
});
}
@Test
void notDefinedOptionShouldBeOptionAfterDefinedOption() {
register(ROOT3);
List<Token> tokens = tokenize("root3", "--arg1", "--arg2");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root3");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(1)
.hasValue("--arg1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(2)
.hasValue("--arg2");
});
}
@Test
void optionsWithoutValuesFromRoot() {
register(ROOT5);
List<Token> tokens = tokenize("root5", "--arg1", "--arg2");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root5");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(1)
.hasValue("--arg1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(2)
.hasValue("--arg2");
});
}
@Test
void optionsWithValuesFromRoot() {
register(ROOT5);
List<Token> tokens = tokenize("root5", "--arg1", "value1", "--arg2", "value2");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(0)
.hasValue("root5");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(1)
.hasValue("--arg1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.ARGUMENT)
.hasPosition(2)
.hasValue("value1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.OPTION)
.hasPosition(3)
.hasValue("--arg2");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.ARGUMENT)
.hasPosition(4)
.hasValue("value2");
});
}
@Test
void optionValueFromRoot() {
register(ROOT3);
List<Token> tokens = tokenize("root3", "--arg1", "value1");
assertThat(tokens).extracting(Token::getType).containsExactly(TokenType.COMMAND, TokenType.OPTION,
TokenType.ARGUMENT);
}
@Nested
class DoubleDash {
@Test
void doubleDashAddsCommandArguments() {
register(ROOT3);
List<Token> tokens = tokenize("root3", "--", "arg1");
assertThat(tokens).extracting(Token::getType).containsExactly(TokenType.COMMAND, TokenType.DOUBLEDASH,
TokenType.ARGUMENT);
}
}
@Test
void commandArgsWithoutOption() {
register(ROOT3);
List<Token> tokens = tokenize("root3", "value1", "value2");
assertThat(tokens).extracting(Token::getType).containsExactly(TokenType.COMMAND, TokenType.ARGUMENT,
TokenType.ARGUMENT);
}
@Nested
class Directives {
@Test
void directiveWithCommand() {
register(ROOT1);
ParserConfig config = new ParserConfig().enable(Feature.ALLOW_DIRECTIVES);
List<Token> tokens = tokenize(lexer(config), "[fake]", "root1");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.DIRECTIVE)
.hasPosition(0)
.hasValue("fake");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.COMMAND)
.hasPosition(1)
.hasValue("root1");
});
}
@Test
void hasOneDirective() {
register(ROOT1);
ParserConfig config = new ParserConfig().enable(Feature.ALLOW_DIRECTIVES);
List<Token> tokens = tokenize(lexer(config), "[fake]");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.DIRECTIVE)
.hasPosition(0)
.hasValue("fake");
});
}
@Test
void hasMultipleDirectives() {
register(ROOT1);
ParserConfig config = new ParserConfig().enable(Feature.ALLOW_DIRECTIVES);
List<Token> tokens = tokenize(lexer(config), "[fake1][fake2]");
assertThat(tokens).satisfiesExactly(
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.DIRECTIVE)
.hasPosition(0)
.hasValue("fake1");
},
token -> {
ParserAssertions.assertThat(token)
.isType(TokenType.DIRECTIVE)
.hasPosition(0)
.hasValue("fake2");
});
}
@Test
void errorIfDirectivesDisabledAndIgnoreDisabled() {
ParserConfig config = new ParserConfig()
.disable(Feature.ALLOW_DIRECTIVES)
.disable(Feature.IGNORE_DIRECTIVES);
LexerResult result = tokenizeAsResult(lexer(config),"[fake]");
assertThat(result.messageResults()).isNotEmpty();
}
@Test
void noErrorIfDirectivesDisabledAndIgnoreEnabled() {
ParserConfig config = new ParserConfig()
.enable(Feature.IGNORE_DIRECTIVES);
LexerResult result = tokenizeAsResult(lexer(config), "[fake]");
assertThat(result.messageResults()).isEmpty();
}
@Test
void ignoreDirectiveIfDisabled() {
register(ROOT1);
List<Token> tokens = tokenize("[fake]", "root1");
assertThat(tokens).extracting(Token::getType).containsExactly(TokenType.COMMAND);
}
}
@Nested
class ParserMessages {
@Test
void hasErrorMessageWithoutArguments() {
LexerResult result = tokenizeAsResult("--");
assertThat(result.messageResults()).satisfiesExactly(
message -> {
assertThat(message.parserMessage().getCode()).isEqualTo(1000);
assertThat(message.position()).isEqualTo(0);
},
message -> {
assertThat(message.parserMessage().getCode()).isEqualTo(1000);
assertThat(message.position()).isEqualTo(0);
});
}
}
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
public class ParserAssertions {
public static TokenAssert assertThat(Token actual) {
return new TokenAssert(actual);
}
public static ParserMessageAssert assertThat(ParserMessage message) {
return new ParserMessageAssert(message);
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import org.junit.jupiter.api.Test;
import org.springframework.shell.command.parser.ParserConfig.Feature;
import static org.assertj.core.api.Assertions.assertThat;
class ParserConfigTests {
@Test
void hasDefaultEnabled() {
ParserConfig config = new ParserConfig();
assertThat(config.isEnabled(Feature.ALLOW_DIRECTIVES)).isFalse();
assertThat(config.isEnabled(Feature.IGNORE_DIRECTIVES)).isFalse();
assertThat(config.isEnabled(Feature.CASE_SENSITIVE_COMMANDS)).isTrue();
assertThat(config.isEnabled(Feature.CASE_SENSITIVE_OPTIONS)).isTrue();
}
@Test
void configureStates() {
ParserConfig config = new ParserConfig();
config.configure(Feature.ALLOW_DIRECTIVES, true);
assertThat(config.isEnabled(Feature.ALLOW_DIRECTIVES)).isTrue();
config.disable(Feature.ALLOW_DIRECTIVES);
assertThat(config.isEnabled(Feature.ALLOW_DIRECTIVES)).isFalse();
config.enable(Feature.ALLOW_DIRECTIVES);
assertThat(config.isEnabled(Feature.ALLOW_DIRECTIVES)).isTrue();
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import org.assertj.core.api.AbstractAssert;
import org.springframework.shell.command.parser.ParserMessage.Type;
import static org.assertj.core.api.Assertions.assertThat;
public class ParserMessageAssert extends AbstractAssert<ParserMessageAssert, ParserMessage> {
public ParserMessageAssert(ParserMessage actual) {
super(actual, ParserMessageAssert.class);
}
public ParserMessageAssert hasCode(int code) {
isNotNull();
assertThat(actual.getCode()).isEqualTo(code);
return this;
}
public ParserMessageAssert hasType(Type type) {
isNotNull();
assertThat(actual.getType()).isEqualTo(type);
return this;
}
}

View File

@@ -0,0 +1,342 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.shell.command.parser.Parser.ParseResult;
import org.springframework.shell.command.parser.ParserConfig.Feature;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for default implementations in/with a {@link Parser}.
*
* @author Janne Valkealahti
*/
class ParserTests extends AbstractParsingTests {
@Test
void shouldFindRegistration() {
register(ROOT1);
ParseResult result = parse("root1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.messageResults()).isEmpty();
}
@Nested
class CommandArguments {
@Test
void commandArgumentsGetsAddedWhenNoOptions() {
register(ROOT1);
ParseResult result = parse("root1", "arg1", "arg2");
assertThat(result.argumentResults()).satisfiesExactly(
ar -> {
assertThat(ar.value()).isEqualTo("arg1");
assertThat(ar.position()).isEqualTo(0);
},
ar -> {
assertThat(ar.value()).isEqualTo("arg2");
assertThat(ar.position()).isEqualTo(1);
}
);
}
@Test
void commandArgumentsGetsAddedAfterDoubleDash() {
register(ROOT3);
ParseResult result = parse("root3", "--arg1", "value1", "--", "arg1", "arg2");
assertThat(result.argumentResults()).satisfiesExactly(
ar -> {
assertThat(ar.value()).isEqualTo("arg1");
assertThat(ar.position()).isEqualTo(0);
},
ar -> {
assertThat(ar.value()).isEqualTo("arg2");
assertThat(ar.position()).isEqualTo(1);
}
);
}
}
@Nested
class TypeConversions {
@Test
void optionValueShouldBeInteger() {
register(ROOT6_OPTION_INT);
ParseResult result = parse("root6", "--arg1", "1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.optionResults().get(0).value()).isEqualTo(1);
assertThat(result.messageResults()).isEmpty();
}
@Test
void optionValueShouldBeIntegerArray() {
register(ROOT6_OPTION_INTARRAY);
ParseResult result = parse("root6", "--arg1", "1", "2");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.optionResults().get(0).value()).isEqualTo(new int[] { 1, 2 });
assertThat(result.messageResults()).isEmpty();
}
@Test
void optionValueFailsFromStringToInteger() {
register(ROOT6_OPTION_INT);
ParseResult result = parse("root6", "--arg1", "x");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.optionResults().get(0).value()).isEqualTo("x");
assertThat(result.messageResults()).isNotEmpty();
assertThat(result.messageResults()).satisfiesExactly(ms -> {
// "2002E:(pos 0): Illegal option value 'x', reason 'Failed to convert from type [java.lang.String] to type [int] for value 'x''"
assertThat(ms.getMessage()).contains("Failed to convert");
});
}
}
@Nested
class ParserMessages {
@Test
void shouldHaveErrorResult() {
register(ROOT4);
ParseResult result = parse("root4");
assertThat(result).isNotNull();
assertThat(result.messageResults()).satisfiesExactly(message -> {
ParserAssertions.assertThat(message.parserMessage()).hasCode(2000).hasType(ParserMessage.Type.ERROR);
});
// "100E:(pos 0): Missing option, longnames='arg1', shortnames=''"
assertThat(result.messageResults().get(0).getMessage()).contains("Missing mandatory option", "arg1");
}
@Test
void shouldHaveErrorResult2() {
register(ROOT4);
// ParseResult result = parse("root4", "--arg1", "value1", "--arg2", "value2");
ParseResult result = parse("root4", "--arg1", "--arg2");
assertThat(result).isNotNull();
assertThat(result.messageResults()).satisfiesExactly(message -> {
ParserAssertions.assertThat(message.parserMessage()).hasCode(2001).hasType(ParserMessage.Type.ERROR);
});
// "101E:(pos 0): Unrecognised option '--arg2'"
// assertThat(result.messageResults().get(0).getMessage()).contains("xxx");
}
@Test
void lexerMessageShouldGetPropagated() {
ParseResult parse = parse("--");
assertThat(parse.messageResults()).extracting(ms -> ms.parserMessage().getCode()).containsExactly(1000, 1000);
}
}
@Nested
class Directives {
@Test
void directiveNoValueWithCommand() {
register(ROOT3);
ParserConfig config = new ParserConfig().enable(Feature.ALLOW_DIRECTIVES);
ParseResult result = parse(lexer(config), "[fake]", "root3");
assertThat(result.directiveResults()).satisfiesExactly(d -> {
assertThat(d.name()).isEqualTo("fake");
assertThat(d.value()).isNull();
});
assertThat(result.messageResults()).isEmpty();
}
@Test
void directiveWithValueWithCommand() {
register(ROOT3);
ParserConfig config = new ParserConfig().enable(Feature.ALLOW_DIRECTIVES);
ParseResult result = parse(lexer(config), "[fake:value]", "root3");
assertThat(result.directiveResults()).satisfiesExactly(d -> {
assertThat(d.name()).isEqualTo("fake");
assertThat(d.value()).isEqualTo("value");
});
assertThat(result.messageResults()).isEmpty();
}
@Test
void multipleDirectivesWithCommand() {
register(ROOT3);
ParserConfig config = new ParserConfig().enable(Feature.ALLOW_DIRECTIVES);
ParseResult result = parse(lexer(config), "[foo][bar:value]", "root3");
assertThat(result.directiveResults()).satisfiesExactly(
d -> {
assertThat(d.name()).isEqualTo("foo");
assertThat(d.value()).isNull();
},
d -> {
assertThat(d.name()).isEqualTo("bar");
assertThat(d.value()).isEqualTo("value");
});
assertThat(result.messageResults()).isEmpty();
}
}
@Nested
class LongOptions {
@Test
void shouldFindLongOption() {
register(ROOT3);
ParseResult result = parse("root3", "--arg1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.messageResults()).isEmpty();
}
@Test
void shouldFindLongOptionTwoCommands() {
register(ROOT2_SUB1);
ParseResult result = parse("root2", "sub1", "--arg1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.messageResults()).isEmpty();
}
@Test
void shouldFindLongOptionThreeCommands() {
register(ROOT2_SUB1_SUB2);
ParseResult result = parse("root2", "sub1", "sub2", "--arg1", "xxx");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.messageResults()).isEmpty();
}
@Test
void shouldFindLongOptionArgument() {
register(ROOT3);
ParseResult result = parse("root3", "--arg1", "value1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.optionResults()).satisfiesExactly(
r -> {
assertThat(r.value()).isEqualTo("value1");
}
);
assertThat(result.messageResults()).isEmpty();
}
}
@Nested
class ShortOptions {
@Test
void shouldFindShortOption() {
register(ROOT3_SHORT_OPTION_A);
ParseResult result = parse("root3", "-a");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.messageResults()).isEmpty();
}
}
@Nested
class CaseSensitivity {
@Test
void shouldFindRegistrationRegUpperCommandLower() {
register(ROOT1_UP);
ParserConfig config = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS);
ParseResult result = parse(config, "root1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.messageResults()).isEmpty();
}
@Test
void shouldFindRegistrationRegUpperCommandUpper() {
register(ROOT1_UP);
ParserConfig config = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS);
ParseResult result = parse(config, "Root1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.messageResults()).isEmpty();
}
@Test
void shouldFindRegistrationRegLowerCommandUpper() {
register(ROOT1);
ParserConfig config = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS);
ParseResult result = parse(config, "Root1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.messageResults()).isEmpty();
}
@Test
void shouldFindLongOptionRegLowerCommandUpper() {
register(ROOT3);
ParserConfig config = new ParserConfig()
.disable(Feature.CASE_SENSITIVE_COMMANDS)
.disable(Feature.CASE_SENSITIVE_OPTIONS)
;
ParseResult result = parse(config, "root3", "--Arg1", "value1");
assertThat(result).isNotNull();
assertThat(result.commandRegistration()).isNotNull();
assertThat(result.optionResults()).isNotEmpty();
assertThat(result.optionResults()).satisfiesExactly(
r -> {
assertThat(r.value()).isEqualTo("value1");
}
);
assertThat(result.messageResults()).isEmpty();
}
}
@Nested
class DefaultValues {
@Test
void shouldAddOptionIfItHasDefaultValue() {
register(ROOT6_OPTION_DEFAULT_VALUE);
ParseResult result = parse("root6");
assertThat(result.optionResults()).satisfiesExactly(
r -> {
assertThat(r.value()).isEqualTo("defaultvalue");
}
);
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.shell.command.parser;
import org.assertj.core.api.AbstractAssert;
import static org.assertj.core.api.Assertions.assertThat;
public class TokenAssert extends AbstractAssert<TokenAssert, Token> {
public TokenAssert(Token actual) {
super(actual, TokenAssert.class);
}
public TokenAssert isType(TokenType type) {
isNotNull();
assertThat(actual.getType()).isEqualTo(type);
return this;
}
public TokenAssert hasPosition(int position) {
isNotNull();
assertThat(actual.getPosition()).isEqualTo(position);
return this;
}
public TokenAssert hasValue(String value) {
isNotNull();
assertThat(actual.getValue()).isEqualTo(value);
return this;
}
}

View File

@@ -21,6 +21,8 @@ import java.util.Set;
import org.springframework.context.annotation.Bean;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.command.annotation.Command;
import org.springframework.shell.command.annotation.Option;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.ShellOption;
@@ -107,6 +109,102 @@ public class OptionTypeCommands {
}
}
@Command(command = BaseE2ECommands.ANNO, group = BaseE2ECommands.GROUP)
public static class Annotation extends BaseE2ECommands {
@Command(command = "option-type-string")
public String optionTypeStringAnnotation(
@Option(longNames = "arg1")
String arg1
) {
return "Hello " + arg1;
}
@Command(command = "option-type-boolean")
public String optionTypeBooleanAnnotation(
@ShellOption()
@Option(longNames = "arg1")
boolean arg1,
@ShellOption(defaultValue = "true")
@Option(longNames = "arg2", defaultValue = "true")
boolean arg2,
@ShellOption(defaultValue = "false")
@Option(longNames = "arg3", defaultValue = "false")
boolean arg3,
@ShellOption()
@Option(longNames = "arg4")
Boolean arg4,
@ShellOption(defaultValue = "true")
@Option(longNames = "arg5", defaultValue = "true")
Boolean arg5,
@ShellOption(defaultValue = "false")
@Option(longNames = "arg6", defaultValue = "false")
Boolean arg6
) {
return String.format("Hello arg1=%s arg2=%s arg3=%s arg4=%s arg5=%s arg6=%s", arg1, arg2, arg3, arg4, arg5,
arg6);
}
@Command(command = "option-type-integer")
public String optionTypeIntegerAnnotation(
@Option(longNames = "arg1")
int arg1,
@Option(longNames = "arg2")
Integer arg2
) {
return String.format("Hello '%s' '%s'", arg1, arg2);
}
@Command(command = "option-type-enum")
public String optionTypeEnumAnnotation(
@Option(longNames = "arg1")
OptionTypeEnum arg1
) {
return "Hello " + arg1;
}
@Command(command = "option-type-string-array")
public String optionTypeStringArrayAnnotation(
@Option(longNames = "arg1")
String[] arg1
) {
return "Hello " + stringOfStrings(arg1);
}
@Command(command = "option-type-int-array")
public String optionTypeIntArrayAnnotation(
@Option(longNames = "arg1")
int[] arg1
) {
return "Hello " + stringOfInts(arg1);
}
@Command(command = "option-type-string-list")
public String optionTypeStringListAnnotation(
@Option(longNames = "arg1")
List<String> arg1
) {
return "Hello " + arg1;
}
@Command(command = "option-type-string-set")
public String optionTypeStringSetAnnotation(
@Option(longNames = "arg1")
Set<String> arg1
) {
return "Hello " + arg1;
}
@Command(command = "option-type-string-collection")
public String optionTypeStringCollectionAnnotation(
@Option(longNames = "arg1")
Collection<String> arg1
) {
return "Hello " + arg1;
}
}
@Component
public static class Registration extends BaseE2ECommands {

View File

@@ -17,24 +17,27 @@ package org.springframework.shell.samples.e2e;
import org.junit.jupiter.params.ParameterizedTest;
import org.springframework.shell.command.annotation.EnableCommand;
import org.springframework.shell.samples.AbstractSampleTests;
import org.springframework.shell.samples.e2e.OptionTypeCommands.Annotation;
import org.springframework.shell.samples.e2e.OptionTypeCommands.LegacyAnnotation;
import org.springframework.shell.samples.e2e.OptionTypeCommands.Registration;
import org.springframework.shell.test.ShellTestClient.BaseShellSession;
import org.springframework.test.context.ContextConfiguration;
@ContextConfiguration(classes = { LegacyAnnotation.class, Registration.class })
@EnableCommand(Annotation.class)
class OptionTypeCommandsTests extends AbstractSampleTests {
@ParameterizedTest
@E2ESource(command = "option-type-string --arg1 hi", annox = false)
@E2ESource(command = "option-type-string --arg1 hi")
void optionTypeString(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello hi");
}
@ParameterizedTest
@E2ESource(command = "option-type-boolean", annox = false, reg = false)
@E2ESource(command = "option-type-boolean", reg = false)
void optionTypeBooleanWithAnno(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello arg1=false arg2=true arg3=false arg4=false arg5=true arg6=false");
@@ -48,49 +51,49 @@ class OptionTypeCommandsTests extends AbstractSampleTests {
}
@ParameterizedTest
@E2ESource(command = "option-type-integer --arg1 1 --arg2 2", annox = false)
@E2ESource(command = "option-type-integer --arg1 1 --arg2 2")
void optionTypeInteger(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello '1' '2'");
}
@ParameterizedTest
@E2ESource(command = "option-type-enum --arg1 ONE", annox = false)
@E2ESource(command = "option-type-enum --arg1 ONE")
void optionTypeEnum(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello ONE");
}
@ParameterizedTest
@E2ESource(command = "option-type-string-array --arg1 one two", annox = false)
@E2ESource(command = "option-type-string-array --arg1 one two")
void optionTypeStringArray(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello [one,two]");
}
@ParameterizedTest
@E2ESource(command = "option-type-int-array --arg1 1 2", annox = false)
@E2ESource(command = "option-type-int-array --arg1 1 2")
void optionTypeIntArray(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello [1,2]");
}
@ParameterizedTest
@E2ESource(command = "option-type-string-list --arg1 one two", annox = false)
@E2ESource(command = "option-type-string-list --arg1 one two")
void optionTypeStringList(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello [one, two]");
}
@ParameterizedTest
@E2ESource(command = "option-type-string-set --arg1 one two", annox = false)
@E2ESource(command = "option-type-string-set --arg1 one two")
void optionTypeStringSet(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello [one, two]");
}
@ParameterizedTest
@E2ESource(command = "option-type-string-collection --arg1 one two", annox = false)
@E2ESource(command = "option-type-string-collection --arg1 one two")
void optionTypeStringCollection(String command, boolean interactive) {
BaseShellSession<?> session = createSession(command, interactive);
assertScreenContainsText(session, "Hello [one, two]");

View File

@@ -162,9 +162,6 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar {
else if (ClassUtils.isAssignable(Boolean.class, parameterType)) {
optionSpec.arity(OptionArity.ZERO);
}
else {
optionSpec.arity(OptionArity.EXACTLY_ONE);
}
}
if (!ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NONE)
&& !ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NULL)) {