diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java index a123dc9d..34b6ddbd 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java @@ -229,8 +229,6 @@ public class Shell { this.exitCodeMappings.reset(mappingFunctions); } - List 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 wordsForArguments(String command, List words) { - int wordsUsedForCommandKey = command.split(" ").length; - List 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 diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java index f588dae4..51ff29cf 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java @@ -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 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 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; } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java index 099559ce..58af7fa3 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java @@ -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 results, List positional, List errors) { - return new DefaultCommandParserResults(results, positional, errors); + static CommandParserResults of(CommandRegistration registration, List results, + List positional, List 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 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 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 results; private List positional; private List errors; - DefaultCommandParserResults(List results, List positional, List errors) { + DefaultCommandParserResults(CommandRegistration registration, List results, + List positional, List errors) { + this.registration = registration; this.results = results; this.positional = positional; this.errors = errors; } + @Override + public CommandRegistration registration() { + return registration; + } + @Override public List 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 registrations; + private final ParserConfig configuration; private final ConversionService conversionService; - DefaultCommandParser(ConversionService conversionService) { + public AstCommandParser(Map registrations, ParserConfig configuration, + ConversionService conversionService) { + this.registrations = registrations; + this.configuration = configuration; this.conversionService = conversionService; } @Override - public CommandParserResults parse(List options, String[] args) { - List requiredOptions = options.stream() - .filter(o -> o.isRequired()) - .collect(Collectors.toList()); - - Set splitValidValues = options.stream() - .flatMap(o -> { - Stream longs = Stream.of(o.getLongNames()).map(l -> "--" + l); - Stream 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> 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 results = new ArrayList<>(); List positional = new ArrayList<>(); List 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 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 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 args; - private Object value; - private CommandParserException error; - - private ParserResult(CommandOption option, List args, Object value, CommandParserException error) { - this.option = option; - this.args = args; - this.value = value; - this.error = error; - } - - static ParserResult of(CommandOption option, List args, Object value, - CommandParserException error) { - return new ParserResult(option, args, value, error); - } - } - - private static class ParserResults { - private List results; - - private ParserResults(List results) { - this.results = results; - } - - static ParserResults of(List 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> lexerResults, List options) { - List results = lexerResults.stream() - .flatMap(lr -> { - List 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 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 unmapped = holder.unmapped.stream() - .map(um -> ParserResult.of(null, Arrays.asList(um), null, null)); - Stream 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 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 matchOptions(List options, String arg) { - List 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 arguments) { - Object value = null; - List 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 unmapped = new ArrayList<>(); - CommandParserException error; - - ConvertArgumentsHolder(Object value, List unmapped) { - this(value, unmapped, null); - } - - ConvertArgumentsHolder(Object value, List 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 splitValidValues; - Lexer(String[] args, Set splitValues) { - this.args = args; - this.splitValidValues = splitValues; - } - List> 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; - } - } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java index 20686f40..ecc7fbdc 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParserExceptionResolver.java @@ -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))); - } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java index fe53b434..0cab040d 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/annotation/support/CommandRegistrationFactoryBean.java @@ -232,9 +232,6 @@ class CommandRegistrationFactoryBean implements FactoryBean else if (ClassUtils.isAssignable(Boolean.class, parameterType)) { optionSpec.arity(OptionArity.ZERO); } - else { - optionSpec.arity(OptionArity.EXACTLY_ONE); - } } if (StringUtils.hasText(so.defaultValue())) { diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/AbstractNodeVisitor.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/AbstractNodeVisitor.java new file mode 100644 index 00000000..fa79ec5c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/AbstractNodeVisitor.java @@ -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 nonterminalNodes, + List 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); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Ast.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Ast.java new file mode 100644 index 00000000..a9c108c4 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Ast.java @@ -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 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 nonterminalNodes, List terminalNodes) { + } + + /** + * Default implementation of an {@link Ast}. + */ + public class DefaultAst implements Ast { + + @Override + public AstResult generate(List tokens) { + List commandNodes = new ArrayList<>(); + CommandNode commandNode = null; + OptionNode optionNode = null; + List 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 nonterminalNodes = new ArrayList<>(); + List terminalNodes = new ArrayList<>(); + + if (commandNodes.size() > 0) { + nonterminalNodes.add(commandNodes.get(0)); + } + for (DirectiveNode dn : directiveNodes) { + terminalNodes.add(dn); + } + + return new AstResult(nonterminalNodes, terminalNodes); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/AstNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/AstNode.java new file mode 100644 index 00000000..a3fc8ed7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/AstNode.java @@ -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; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandArgumentNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandArgumentNode.java new file mode 100644 index 00000000..0f009ee2 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandArgumentNode.java @@ -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; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandModel.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandModel.java new file mode 100644 index 00000000..cff61447 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandModel.java @@ -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 rootCommands = new HashMap<>(); + private final ParserConfig configuration; + + public CommandModel(Map registrations, ParserConfig configuration) { + this.configuration = configuration; + buildModel(registrations); + } + + @Nullable + CommandInfo getRootCommand(String command) { + return rootCommands.get(command); + } + + CommandInfo resolve(List 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 nextInfo = info.getChildren().stream() + .filter(i -> i.command.equals(command)) + .findFirst(); + if (nextInfo.isEmpty()) { + break; + } + info = nextInfo.get(); + } + } + return info; + } + + Map getRootCommands() { + return rootCommands; + } + + Map getValidRootTokens() { + Map tokens = new HashMap<>(); + + rootCommands.entrySet().forEach(e -> { + tokens.put(e.getKey(), new Token(e.getKey(), TokenType.COMMAND)); + // Map 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 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 children = new ArrayList<>(); + private Map 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 getValidTokens() { + Map 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 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) + // ; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandNode.java new file mode 100644 index 00000000..de3d1d7a --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/CommandNode.java @@ -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() + "]"; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/DirectiveNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/DirectiveNode.java new file mode 100644 index 00000000..433e93f5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/DirectiveNode.java @@ -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; + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/DirectiveResult.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/DirectiveResult.java new file mode 100644 index 00000000..da970994 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/DirectiveResult.java @@ -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); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java new file mode 100644 index 00000000..8ffd35ca --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Lexer.java @@ -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 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 tokens, List 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 before, List 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 arguments, Map 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 extractDirectives(List arguments) { + List 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 arguments) { + log.debug("Tokenizing arguments {}", arguments); + List errorResults = new ArrayList<>(); + List tokenList = new ArrayList(); + + preValidate(errorResults, arguments); + + // starting from root level + Map 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 beforeArguments = split.before(); + + int i1 = split.before().size() - 1; + + if (config.isEnabled(Feature.ALLOW_DIRECTIVES)) { + List 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 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 errorResults, List 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 tokenList, TokenType type) { + if (tokenList.size() > 0) { + if (tokenList.get(tokenList.size() - 1).getType() == type) { + return true; + } + } + return false; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/MessageResult.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/MessageResult.java new file mode 100644 index 00000000..a509f1e7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/MessageResult.java @@ -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); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/NodeVisitor.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/NodeVisitor.java new file mode 100644 index 00000000..f1a268d0 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/NodeVisitor.java @@ -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 nonterminalNodes, List terminalNodes); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/NonterminalAstNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/NonterminalAstNode.java new file mode 100644 index 00000000..518ae370 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/NonterminalAstNode.java @@ -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 children = new ArrayList<>(); + + public NonterminalAstNode(Token token) { + super(token); + } + + public List getChildren() { + return children; + } + + void addChildNode(AstNode node) { + this.children.add(node); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/OptionArgumentNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/OptionArgumentNode.java new file mode 100644 index 00000000..7915c091 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/OptionArgumentNode.java @@ -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; + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/OptionNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/OptionNode.java new file mode 100644 index 00000000..64093a99 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/OptionNode.java @@ -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() + "]"; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Parser.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Parser.java new file mode 100644 index 00000000..cf65211f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Parser.java @@ -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 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 optionResults, + List argumentResults, List messageResults, + List 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 arguments) { + // 1. tokenize arguments + LexerResult lexerResult = lexer.tokenize(arguments); + List 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 commonMessageResults = new ArrayList<>(); + private List resolvedCommmand = new ArrayList<>(); + private List optionResults = new ArrayList<>(); + private List currentOptionArgument = new ArrayList<>(); + private List directiveResults = new ArrayList<>(); + private List invalidOptionNodes = new ArrayList<>(); + private List 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 messageResults = new ArrayList<>(); + if (registration != null) { + messageResults.addAll(commonMessageResults); + messageResults.addAll(validateOptionNotMissing(registration)); + messageResults.addAll(validateOptionIsValid(registration)); + + + // add options with default values + Set 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 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 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 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 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 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 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 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 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 validateOptionNotMissing(CommandRegistration registration) { + HashSet 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 validateOptionIsValid(CommandRegistration registration) { + return invalidOptionNodes.stream() + .map(on -> { + return MessageResult.of(ParserMessage.UNRECOGNISED_OPTION, 0, on.getName()); + }) + .collect(Collectors.toList()); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java new file mode 100644 index 00000000..3ecec509 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserConfig.java @@ -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; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserMessage.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserMessage.java new file mode 100644 index 00000000..5d62b282 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/ParserMessage.java @@ -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 + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/TerminalAstNode.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/TerminalAstNode.java new file mode 100644 index 00000000..4a7f1e03 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/TerminalAstNode.java @@ -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); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Token.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Token.java new file mode 100644 index 00000000..187bcb91 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/Token.java @@ -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 + "]"; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/parser/TokenType.java b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/TokenType.java new file mode 100644 index 00000000..3312716a --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/parser/TokenType.java @@ -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 +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandExecutionTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandExecutionTests.java index 3951ac13..e7774435 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandExecutionTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandExecutionTests.java @@ -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 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); } } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java index f0ea3d7f..92419ac3 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserExceptionResolverTests.java @@ -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 parserExceptions = Arrays.asList(e); - return new CommandParserExceptionsException("msg", parserExceptions); - } - - static CommandParserExceptionsException tooManyArguments(CommandOption option) { - TooManyArgumentsOptionException e = new TooManyArgumentsOptionException("msg", option); - List 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 parserExceptions = Arrays.asList(e); return new CommandParserExceptionsException("msg", parserExceptions); } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java index 732a439d..db9a80fe 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java @@ -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 options = Arrays.asList(option1, option2); - String[] args = new String[]{"--arg1", "foo"}; - CommandParserResults results = parser.parse(options, args); + Map 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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"); } } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/AbstractParsingTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/AbstractParsingTests.java new file mode 100644 index 00000000..85522808 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/AbstractParsingTests.java @@ -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 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 tokenize(String... arguments) { + return tokenize(lexer(), arguments); + } + + List 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 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)); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/AstTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/AstTests.java new file mode 100644 index 00000000..4eee158c --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/AstTests.java @@ -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"); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/CommandModelTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/CommandModelTests.java new file mode 100644 index 00000000..eb018070 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/CommandModelTests.java @@ -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 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 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 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 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 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(); + } + ); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/LexerTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/LexerTests.java new file mode 100644 index 00000000..2b7ec0a2 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/LexerTests.java @@ -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 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 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 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 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 tokens = tokenize("root1"); + + assertThat(tokens).satisfiesExactly( + token -> { + ParserAssertions.assertThat(token) + .isType(TokenType.COMMAND) + .hasPosition(0) + .hasValue("root1"); + }); + } + + @Test + void subCommandLevel1WithoutRoot() { + register(ROOT2_SUB1); + List 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 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 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 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 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 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 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 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 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 tokens = tokenize("root3", "--", "arg1"); + + assertThat(tokens).extracting(Token::getType).containsExactly(TokenType.COMMAND, TokenType.DOUBLEDASH, + TokenType.ARGUMENT); + } + } + + @Test + void commandArgsWithoutOption() { + register(ROOT3); + List 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 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 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 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 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); + }); + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserAssertions.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserAssertions.java new file mode 100644 index 00000000..b8c21db3 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserAssertions.java @@ -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); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserConfigTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserConfigTests.java new file mode 100644 index 00000000..1ea9c896 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserConfigTests.java @@ -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(); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserMessageAssert.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserMessageAssert.java new file mode 100644 index 00000000..4a0cc5c7 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserMessageAssert.java @@ -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 { + + 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; + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java new file mode 100644 index 00000000..3a112df5 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/ParserTests.java @@ -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"); + } + ); + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/parser/TokenAssert.java b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/TokenAssert.java new file mode 100644 index 00000000..5c5e3ea5 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/parser/TokenAssert.java @@ -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 { + + 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; + } +} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/OptionTypeCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/OptionTypeCommands.java index cef52abb..d53be36d 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/OptionTypeCommands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/e2e/OptionTypeCommands.java @@ -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 arg1 + ) { + return "Hello " + arg1; + } + + @Command(command = "option-type-string-set") + public String optionTypeStringSetAnnotation( + @Option(longNames = "arg1") + Set arg1 + ) { + return "Hello " + arg1; + } + + @Command(command = "option-type-string-collection") + public String optionTypeStringCollectionAnnotation( + @Option(longNames = "arg1") + Collection arg1 + ) { + return "Hello " + arg1; + } + + } + @Component public static class Registration extends BaseE2ECommands { diff --git a/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/OptionTypeCommandsTests.java b/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/OptionTypeCommandsTests.java index a1edf79c..8e418253 100644 --- a/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/OptionTypeCommandsTests.java +++ b/spring-shell-samples/src/test/java/org/springframework/shell/samples/e2e/OptionTypeCommandsTests.java @@ -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]"); diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java index c19d4018..a55144ee 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java @@ -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)) {