diff --git a/.gitignore b/.gitignore index 68bf78b4f..3e6eb9f08 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ target/ *.iws .DS_Store spring-shell.log +shell.log +shell.log.*.gz # Visual Studio Code .vscode/* diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CommandCatalogAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CommandCatalogAutoConfiguration.java new file mode 100644 index 000000000..b97803720 --- /dev/null +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CommandCatalogAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2021-2022 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.boot; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell.MethodTargetRegistrar; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandCatalogCustomizer; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.CommandResolver; + +@Configuration(proxyBeanMethods = false) +public class CommandCatalogAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(CommandCatalog.class) + public CommandCatalog commandCatalog(ObjectProvider methodTargetRegistrars, + ObjectProvider commandResolvers, + ObjectProvider commandCatalogCustomizers) { + List resolvers = commandResolvers.orderedStream().collect(Collectors.toList()); + CommandCatalog catalog = CommandCatalog.of(resolvers, null); + methodTargetRegistrars.orderedStream().forEach(resolver -> { + resolver.register(catalog); + }); + commandCatalogCustomizers.orderedStream().forEach(customizer -> { + customizer.customize(catalog); + }); + return catalog; + } + + @Bean + public CommandCatalogCustomizer defaultCommandCatalogCustomizer(ObjectProvider commandRegistrations) { + return catalog -> { + commandRegistrations.orderedStream().forEach(registration -> { + catalog.register(registration); + }); + }; + } +} diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CommandRegistryAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CommandRegistryAutoConfiguration.java deleted file mode 100644 index 105be462f..000000000 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CommandRegistryAutoConfiguration.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2021 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.boot; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.shell.CommandRegistry; -import org.springframework.shell.ConfigurableCommandRegistry; -import org.springframework.shell.MethodTargetRegistrar; -import org.springframework.shell.context.ShellContext; - -@Configuration(proxyBeanMethods = false) -public class CommandRegistryAutoConfiguration { - - @Bean - public CommandRegistry commandRegistry( - ObjectProvider methodTargetRegistrars, - ShellContext shellContext) { - ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(shellContext); - methodTargetRegistrars.orderedStream().forEach(resolver -> { - resolver.register(registry); - }); - return registry; - } -} diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java index 386c526bf..d256a242c 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/LineReaderAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 the original author or authors. + * Copyright 2021-2022 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. @@ -34,7 +34,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.event.ContextClosedEvent; import org.springframework.context.event.EventListener; -import org.springframework.shell.CommandRegistry; +import org.springframework.shell.command.CommandCatalog; @Configuration(proxyBeanMethods = false) public class LineReaderAutoConfiguration { @@ -45,7 +45,7 @@ public class LineReaderAutoConfiguration { private Parser parser; - private CommandRegistry commandRegistry; + private CommandCatalog commandRegistry; private org.jline.reader.History jLineHistory; @@ -53,7 +53,7 @@ public class LineReaderAutoConfiguration { private String historyPath; public LineReaderAutoConfiguration(Terminal terminal, Completer completer, Parser parser, - CommandRegistry commandRegistry, org.jline.reader.History jLineHistory) { + CommandCatalog commandRegistry, org.jline.reader.History jLineHistory) { this.terminal = terminal; this.completer = completer; this.parser = parser; @@ -79,7 +79,7 @@ public LineReader lineReader() { public AttributedString highlight(LineReader reader, String buffer) { int l = 0; String best = null; - for (String command : commandRegistry.listCommands().keySet()) { + for (String command : commandRegistry.getRegistrations().keySet()) { if (buffer.startsWith(command) && command.length() > l) { l = command.length(); best = command; diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ParameterResolverAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ParameterResolverAutoConfiguration.java index d394e9183..34ed9ce6e 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ParameterResolverAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/ParameterResolverAutoConfiguration.java @@ -1,24 +1,35 @@ package org.springframework.shell.boot; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.List; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.convert.ConversionService; -import org.springframework.shell.ParameterResolver; -import org.springframework.shell.standard.StandardParameterResolver; -import org.springframework.shell.standard.ValueProvider; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.handler.annotation.support.HeadersMethodArgumentResolver; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.shell.command.ArgumentHeaderMethodArgumentResolver; +import org.springframework.shell.command.CommandContextMethodArgumentResolver; +import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers; +import org.springframework.shell.completion.CompletionResolver; +import org.springframework.shell.completion.DefaultCompletionResolver; +import org.springframework.shell.standard.ShellOptionMethodArgumentResolver; @Configuration(proxyBeanMethods = false) public class ParameterResolverAutoConfiguration { @Bean - public ParameterResolver standardParameterResolver(ConversionService conversionService, - ObjectProvider valueProviders) { - Set collect = valueProviders.orderedStream().collect(Collectors.toSet()); - return new StandardParameterResolver(conversionService, collect); + public CompletionResolver defaultCompletionResolver() { + return new DefaultCompletionResolver(); } + @Bean + public CommandExecutionHandlerMethodArgumentResolvers commandExecutionHandlerMethodArgumentResolvers() { + List resolvers = new ArrayList<>(); + resolvers.add(new ArgumentHeaderMethodArgumentResolver(new DefaultConversionService(), null)); + resolvers.add(new HeadersMethodArgumentResolver()); + resolvers.add(new CommandContextMethodArgumentResolver()); + resolvers.add(new ShellOptionMethodArgumentResolver(new DefaultConversionService(), null)); + return new CommandExecutionHandlerMethodArgumentResolvers(resolvers); + } } diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java index ea53129ea..11751bdd8 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2021 the original author or authors. + * Copyright 2017-2022 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. @@ -18,6 +18,8 @@ import java.util.Set; +import org.jline.terminal.Terminal; + import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.convert.ApplicationConversionService; import org.springframework.context.ApplicationContext; @@ -27,10 +29,10 @@ import org.springframework.core.convert.ConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.format.support.FormattingConversionService; -import org.springframework.shell.CommandRegistry; import org.springframework.shell.ResultHandler; import org.springframework.shell.ResultHandlerService; import org.springframework.shell.Shell; +import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.result.GenericResultHandlerService; import org.springframework.shell.result.ResultHandlerConfig; @@ -61,7 +63,7 @@ public ResultHandlerService resultHandlerService(Set> resultHan } @Bean - public Shell shell(ResultHandlerService resultHandlerService, CommandRegistry commandRegistry) { - return new Shell(resultHandlerService, commandRegistry); + public Shell shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal) { + return new Shell(resultHandlerService, commandRegistry, terminal); } } diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardAPIAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardAPIAutoConfiguration.java index bd6398ee1..5c4f91cf7 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardAPIAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardAPIAutoConfiguration.java @@ -18,8 +18,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.shell.CommandRegistry; import org.springframework.shell.MethodTargetRegistrar; +import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.standard.CommandValueProvider; import org.springframework.shell.standard.EnumValueProvider; import org.springframework.shell.standard.FileValueProvider; @@ -35,7 +35,7 @@ public class StandardAPIAutoConfiguration { @Bean - public ValueProvider commandValueProvider(CommandRegistry commandRegistry) { + public ValueProvider commandValueProvider(CommandCatalog commandRegistry) { return new CommandValueProvider(commandRegistry); } diff --git a/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories index c2a321113..1b7d570c9 100644 --- a/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-shell-autoconfigure/src/main/resources/META-INF/spring.factories @@ -3,12 +3,11 @@ org.springframework.shell.boot.ShellContextAutoConfiguration,\ org.springframework.shell.boot.SpringShellAutoConfiguration,\ org.springframework.shell.boot.ShellRunnerAutoConfiguration,\ org.springframework.shell.boot.ApplicationRunnerAutoConfiguration,\ -org.springframework.shell.boot.CommandRegistryAutoConfiguration,\ +org.springframework.shell.boot.CommandCatalogAutoConfiguration,\ org.springframework.shell.boot.LineReaderAutoConfiguration,\ org.springframework.shell.boot.CompleterAutoConfiguration,\ org.springframework.shell.boot.JLineAutoConfiguration,\ org.springframework.shell.boot.JLineShellAutoConfiguration,\ -org.springframework.shell.boot.JCommanderParameterResolverAutoConfiguration,\ org.springframework.shell.boot.ParameterResolverAutoConfiguration,\ org.springframework.shell.boot.StandardAPIAutoConfiguration,\ org.springframework.shell.boot.ThemingAutoConfiguration,\ diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/CommandCatalogAutoConfigurationTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/CommandCatalogAutoConfigurationTests.java new file mode 100644 index 000000000..d1b39ad4c --- /dev/null +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/CommandCatalogAutoConfigurationTests.java @@ -0,0 +1,106 @@ +/* + * Copyright 2022 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.boot; + +import java.util.Collections; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.CommandResolver; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CommandCatalogAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CommandCatalogAutoConfiguration.class)); + + @Test + void defaultCommandCatalog() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(CommandCatalog.class)); + } + + @Test + void testCommandResolvers() { + this.contextRunner.withUserConfiguration(CustomCommandResolverConfiguration.class) + .run((context) -> { + CommandCatalog commandCatalog = context.getBean(CommandCatalog.class); + assertThat(commandCatalog).extracting("resolvers").asInstanceOf(InstanceOfAssertFactories.LIST) + .hasSize(1); + }); + } + + @Test + void customCommandCatalog() { + this.contextRunner.withUserConfiguration(CustomCommandCatalogConfiguration.class) + .run((context) -> { + CommandCatalog commandCatalog = context.getBean(CommandCatalog.class); + assertThat(commandCatalog).isSameAs(CustomCommandCatalogConfiguration.testCommandCatalog); + }); + } + + @Test + void registerCommandRegistration() { + this.contextRunner.withUserConfiguration(CustomCommandRegistrationConfiguration.class) + .run((context) -> { + CommandCatalog commandCatalog = context.getBean(CommandCatalog.class); + assertThat(commandCatalog.getRegistrations().get("customcommand")).isNotNull(); + }); + } + + @Configuration + static class CustomCommandResolverConfiguration { + + @Bean + CommandResolver customCommandResolver() { + return () -> Collections.emptyList(); + } + } + + @Configuration + static class CustomCommandCatalogConfiguration { + + static final CommandCatalog testCommandCatalog = CommandCatalog.of(); + + @Bean + CommandCatalog customCommandCatalog() { + return testCommandCatalog; + } + } + + @Configuration + static class CustomCommandRegistrationConfiguration { + + @Bean + CommandRegistration commandRegistration() { + return CommandRegistration.builder() + .command("customcommand") + .withTarget() + .function(ctx -> { + return null; + }) + .and() + .build(); + } + } +} diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/ShellRunnerAutoConfigurationTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/ShellRunnerAutoConfigurationTests.java index 009b4c555..c433088d9 100644 --- a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/ShellRunnerAutoConfigurationTests.java +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/ShellRunnerAutoConfigurationTests.java @@ -22,8 +22,9 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.shell.ParameterResolver; import org.springframework.shell.Shell; +import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers; +import org.springframework.shell.completion.CompletionResolver; import org.springframework.shell.context.ShellContext; import org.springframework.shell.jline.InteractiveShellRunner; import org.springframework.shell.jline.NonInteractiveShellRunner; @@ -46,7 +47,8 @@ class ShellRunnerAutoConfigurationTests { .withBean(LineReader.class, () -> mock(LineReader.class)) .withBean(Parser.class, () -> mock(Parser.class)) .withBean(ShellContext.class, () -> mock(ShellContext.class)) - .withBean(ParameterResolver.class, () -> mock(ParameterResolver.class)); + .withBean(CompletionResolver.class, () -> mock(CompletionResolver.class)) + .withBean(CommandExecutionHandlerMethodArgumentResolvers.class, () -> mock(CommandExecutionHandlerMethodArgumentResolvers.class)); @Nested class Interactive { diff --git a/spring-shell-core/pom.xml b/spring-shell-core/pom.xml index ddd52cc1c..f477079a1 100644 --- a/spring-shell-core/pom.xml +++ b/spring-shell-core/pom.xml @@ -23,6 +23,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework + spring-messaging + org.jline jline diff --git a/spring-shell-core/src/main/java/org/springframework/shell/CommandRegistry.java b/spring-shell-core/src/main/java/org/springframework/shell/CommandRegistry.java deleted file mode 100644 index 0051ea4de..000000000 --- a/spring-shell-core/src/main/java/org/springframework/shell/CommandRegistry.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2015-2022 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; - -import java.util.Map; - -/** - * Implementing this interface allows sub-systems (such as the {@literal help} command) to - * discover available commands. - * - * @author Eric Bottard - * @author Janne Valkealahti - */ -public interface CommandRegistry { - - /** - * Return the mapping from command trigger keywords to implementation. - */ - Map listCommands(); - - /** - * Register a new command. - * - * @param name the command name - * @param target the method target - */ - void addCommand(String name, MethodTarget target); - - /** - * Deregister a command. - * - * @param name the command name - */ - void removeCommand(String name); -} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/ConfigurableCommandRegistry.java b/spring-shell-core/src/main/java/org/springframework/shell/ConfigurableCommandRegistry.java deleted file mode 100644 index 2159c803e..000000000 --- a/spring-shell-core/src/main/java/org/springframework/shell/ConfigurableCommandRegistry.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2017-2022 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; - -import java.util.HashMap; -import java.util.Map; -import java.util.stream.Collectors; - -import org.springframework.shell.context.InteractionMode; -import org.springframework.shell.context.ShellContext; - -/** - * A {@link CommandRegistry} that supports registration of new commands. - * - *

Makes sure that no two commands are registered with the same name.

- * - * @author Eric Bottard - */ -public class ConfigurableCommandRegistry implements CommandRegistry { - - private final ShellContext shellContext; - private Map commands = new HashMap<>(); - - public ConfigurableCommandRegistry(ShellContext shellContext) { - this.shellContext = shellContext; - } - - @Override - public Map listCommands() { - return commands.entrySet().stream() - .filter(e -> { - InteractionMode mim = e.getValue().getInteractionMode(); - InteractionMode cim = shellContext.getInteractionMode(); - if (mim == null || cim == null || mim == InteractionMode.ALL) { - return true; - } - else if (mim == InteractionMode.INTERACTIVE) { - return cim == InteractionMode.INTERACTIVE || cim == InteractionMode.ALL; - } - else if (mim == InteractionMode.NONINTERACTIVE) { - return cim == InteractionMode.NONINTERACTIVE || cim == InteractionMode.ALL; - } - return true; - }) - .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); - } - - @Override - public void addCommand(String name, MethodTarget target) { - commands.put(name, target); - } - - @Override - public void removeCommand(String name) { - commands.remove(name); - } - - public void register(String name, MethodTarget target) { - MethodTarget previous = commands.get(name); - if (previous != null) { - throw new IllegalArgumentException( - String.format("Illegal registration for command '%s': Attempt to register both '%s' and '%s'", name, target, previous)); - } - commands.put(name, target); - } -} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java b/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java deleted file mode 100644 index 23d015274..000000000 --- a/spring-shell-core/src/main/java/org/springframework/shell/MethodTarget.java +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Copyright 2015-2022 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; - -import java.lang.reflect.Method; -import java.util.HashSet; -import java.util.Set; -import java.util.function.Supplier; - -import org.springframework.shell.context.InteractionMode; -import org.springframework.util.Assert; -import org.springframework.util.ReflectionUtils; - -/** - * Represents a shell command behavior, i.e. code to be executed when a command is requested. - * - * @author Eric Bottard - */ -public class MethodTarget implements Command { - - private final Method method; - - private final Object bean; - - private final Help help; - - private final InteractionMode interactionMode; - - /** - * If not null, returns whether or not the command is currently available. Implementations must be idempotent. - */ - private final Supplier availabilityIndicator; - - public MethodTarget(Method method, Object bean, String help) { - this(method, bean, new Help(help, null), null); - } - - public MethodTarget(Method method, Object bean, String help, Supplier availabilityIndicator) { - this(method, bean, new Help(help, null), availabilityIndicator); - } - - public MethodTarget(Method method, Object bean, Help help, Supplier availabilityIndicator) { - this(method, bean, help, availabilityIndicator, null); - } - - public MethodTarget(Method method, Object bean, Help help, Supplier availabilityIndicator, InteractionMode interactionMode) { - Assert.notNull(method, "Method cannot be null"); - Assert.notNull(bean, "Bean cannot be null"); - Assert.hasText(help.getDescription(), String.format("Help cannot be blank when trying to define command based on '%s'", method)); - ReflectionUtils.makeAccessible(method); - this.method = method; - this.bean = bean; - this.help = help; - this.availabilityIndicator = availabilityIndicator != null ? availabilityIndicator : () -> Availability.available(); - this.interactionMode = interactionMode; - } - - /** - * Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception - * in case of overloaded method. - */ - public static MethodTarget of(String name, Object bean, String description, String group) { - return of(name, bean, new Help(description, group)); - } - - /** - * Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception - * in case of overloaded method. - */ - public static MethodTarget of(String name, Object bean, Help help) { - return of(name, bean, help, null); - } - - /** - * Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception - * in case of overloaded method. - */ - public static MethodTarget of(String name, Object bean, Help help, Supplier availabilityIndicator) { - Set found = new HashSet<>(); - ReflectionUtils.doWithMethods(bean.getClass(), found::add, m -> m.getName().equals(name)); - if (found.size() != 1) { - throw new IllegalArgumentException(String.format("Could not find unique method named '%s' on object of class %s. Found %s", - name, bean.getClass(), found)); - } - return new MethodTarget(found.iterator().next(), bean, help, availabilityIndicator); - } - - public Method getMethod() { - return method; - } - - public Object getBean() { - return bean; - } - - public String getHelp() { - return help.getDescription(); - } - - public String getGroup() { - return help.getGroup(); - } - - public Availability getAvailability() { - return availabilityIndicator.get(); - } - - public InteractionMode getInteractionMode() { - return interactionMode; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - MethodTarget that = (MethodTarget) o; - - if (!method.equals(that.method)) return false; - if (!bean.equals(that.bean)) return false; - if (!help.equals(that.help)) return false; - return help.equals(that.help); - - } - - @Override - public int hashCode() { - int result = method.hashCode(); - result = 31 * result + bean.hashCode(); - result = 31 * result + help.hashCode(); - result = 31 * result + help.hashCode(); - return result; - } - - @Override - public String toString() { - return method.toString(); - } -} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/MethodTargetRegistrar.java b/spring-shell-core/src/main/java/org/springframework/shell/MethodTargetRegistrar.java index 169e23f25..2a663b702 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/MethodTargetRegistrar.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/MethodTargetRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2017 the original author or authors. + * Copyright 2015-2022 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. @@ -16,6 +16,8 @@ package org.springframework.shell; +import org.springframework.shell.command.CommandCatalog; + /** * Strategy interface for registering commands. * @@ -27,6 +29,6 @@ public interface MethodTargetRegistrar { /** * Register mappings from {@literal } to actual behavior. */ - void register(ConfigurableCommandRegistry registry); + void register(CommandCatalog registry); } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java b/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java index 1c338ae25..893e5237b 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/ParameterDescription.java @@ -34,15 +34,10 @@ */ public class ParameterDescription { - /** - * The original method parameter this is describing. - */ - private final MethodParameter parameter; - /** * A string representation of the type of the parameter. */ - private final String type; + private String type; /** * A string representation of the parameter, as it should appear in a parameter list. @@ -85,15 +80,8 @@ public class ParameterDescription { */ private ElementDescriptor elementDescriptor; - public ParameterDescription(MethodParameter parameter, String type) { - this.parameter = parameter; + public void type(String type) { this.type = type; - this.formal = type; - } - - public static ParameterDescription outOf(MethodParameter parameter) { - Class type = parameter.getParameterType(); - return new ParameterDescription(parameter, Utils.unCamelify(type.getSimpleName())); } public ParameterDescription help(String help) { @@ -171,17 +159,13 @@ public String toString() { return String.format("%s %s", keys.isEmpty() ? "" : keys().iterator().next(), formal()); } - public MethodParameter parameter() { - return parameter; - } - @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ParameterDescription that = (ParameterDescription) o; return mandatoryKey == that.mandatoryKey && - Objects.equals(parameter, that.parameter) && + // Objects.equals(parameter, that.parameter) && Objects.equals(type, that.type) && Objects.equals(formal, that.formal) && Objects.equals(defaultValue, that.defaultValue) && @@ -192,6 +176,6 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(parameter, type, formal, defaultValue, defaultValueWhenFlag, keys, mandatoryKey, help); + return Objects.hash(type, formal, defaultValue, defaultValueWhenFlag, keys, mandatoryKey, help); } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/ParameterResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/ParameterResolver.java deleted file mode 100644 index 5b1b1ef9e..000000000 --- a/spring-shell-core/src/main/java/org/springframework/shell/ParameterResolver.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2015-2017 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; - -import java.util.List; -import java.util.stream.Stream; - -import org.springframework.core.MethodParameter; -import org.springframework.core.annotation.AnnotationAwareOrderComparator; - -/** - * Implementations of this interface are responsible, once the command has been identified, of transforming the textual - * input to an actual parameter object. - * - *

- * An order can also be specified in case more than one {@link ParameterResolver} supports a {@link MethodParameter}. - * See {@link AnnotationAwareOrderComparator} for details.. - *

- * - * @author Eric Bottard - * @author Camilo Gonzalez - */ -public interface ParameterResolver { - - /** - * Should return true if this resolver recognizes the given method parameter (e.g. it - * has the correct annotation or the correct type). - */ - boolean supports(MethodParameter parameter); - - /** - * Turn the given textual input into an actual object, maybe using some conversion or lookup mechanism. - */ - ValueResult resolve(MethodParameter methodParameter, List words); - - /** - * Describe a supported parameter, so that integrated help can be generated. - *

Typical implementations will return a one element stream result, but some may return several (for - * example if binding several words to a POJO).

- */ - Stream describe(MethodParameter parameter); - - /** - * Invoked during TAB completion. If the {@link CompletionContext} can be interpreted as the start - * of a supported {@link MethodParameter} value, one or several proposals should be returned. - */ - List complete(MethodParameter parameter, CompletionContext context); - -} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/ParameterValidationException.java b/spring-shell-core/src/main/java/org/springframework/shell/ParameterValidationException.java index 8af2738f6..46c6a07f0 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/ParameterValidationException.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/ParameterValidationException.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell; import javax.validation.ConstraintViolation; @@ -23,21 +22,17 @@ * Thrown when one or more parameters fail bean validation constraints. * * @author Eric Bottard + * @author Janne Valkealahti */ public class ParameterValidationException extends RuntimeException { + private final Set> constraintViolations; - private final MethodTarget methodTarget; - public ParameterValidationException(Set> constraintViolations, MethodTarget methodTarget) { + public ParameterValidationException(Set> constraintViolations) { this.constraintViolations = constraintViolations; - this.methodTarget = methodTarget; } public Set> getConstraintViolations() { return constraintViolations; } - - public MethodTarget getMethodTarget() { - return methodTarget; - } } 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 3faec2198..15e18cb03 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 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. @@ -13,45 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell; import java.io.IOException; -import java.lang.reflect.Method; import java.lang.reflect.UndeclaredThrowableException; import java.nio.channels.ClosedByInterruptException; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.Optional; import java.util.stream.Collectors; -import javax.validation.ConstraintViolation; import javax.validation.Validator; import javax.validation.ValidatorFactory; +import org.jline.terminal.Terminal; import org.jline.utils.Signals; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.MethodParameter; import org.springframework.core.annotation.AnnotationAwareOrderComparator; -import org.springframework.util.ReflectionUtils; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandExecution; +import org.springframework.shell.command.CommandExecution.CommandExecutionException; +import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.completion.CompletionResolver; /** * Main class implementing a shell loop. * - *

- * Given some textual input, locate the {@link MethodTarget} to invoke and - * {@link ResultHandler#handleResult(Object) handle} the result. - *

- * - *

- * Also provides hooks for code completion - *

- * * @author Eric Bottard * @author Janne Valkealahti */ @@ -66,11 +57,10 @@ public class Shell { */ public static final Object NO_INPUT = new Object(); - private final CommandRegistry commandRegistry; - - private Validator validator = Utils.defaultValidator(); - - protected List parameterResolvers; + private final Terminal terminal; + private final CommandCatalog commandRegistry; + protected List completionResolvers = new ArrayList<>(); + private CommandExecutionHandlerMethodArgumentResolvers argumentResolvers; /** * Marker object to distinguish unresolved arguments from {@code null}, which is a valid @@ -78,20 +68,28 @@ public class Shell { */ protected static final Object UNRESOLVED = new Object(); - public Shell(ResultHandlerService resultHandlerService, CommandRegistry commandRegistry) { + private Validator validator = Utils.defaultValidator(); + + public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal) { this.resultHandlerService = resultHandlerService; this.commandRegistry = commandRegistry; + this.terminal = terminal; } - @Autowired(required = false) - public void setValidatorFactory(ValidatorFactory validatorFactory) { - this.validator = validatorFactory.getValidator(); + @Autowired + public void setCompletionResolvers(List resolvers) { + this.completionResolvers = new ArrayList<>(resolvers); + AnnotationAwareOrderComparator.sort(completionResolvers); } @Autowired - public void setParameterResolvers(List resolvers) { - this.parameterResolvers = new ArrayList<>(resolvers); - AnnotationAwareOrderComparator.sort(parameterResolvers); + public void setArgumentResolvers(CommandExecutionHandlerMethodArgumentResolvers argumentResolvers) { + this.argumentResolvers = argumentResolvers; + } + + @Autowired(required = false) + public void setValidatorFactory(ValidatorFactory validatorFactory) { + this.validator = validatorFactory.getValidator(); } /** @@ -145,21 +143,24 @@ public Object evaluate(Input input) { String command = findLongestCommand(line); List words = input.words(); + log.debug("Evaluate input with line=[{}], command=[{}]", line, command); if (command != null) { - Map methodTargets = commandRegistry.listCommands(); - MethodTarget methodTarget = methodTargets.get(command); - Availability availability = methodTarget.getAvailability(); - if (availability.isAvailable()) { + + Optional commandRegistration = commandRegistry.getRegistrations().values().stream() + .filter(r -> { + return r.getCommand().equals(command); + }) + .findFirst(); + + if (commandRegistration.isPresent()) { List wordsForArgs = wordsForArguments(command, words); - Method method = methodTarget.getMethod(); Thread commandThread = Thread.currentThread(); Object sh = Signals.register("INT", () -> commandThread.interrupt()); try { - Object[] args = resolveArgs(method, wordsForArgs); - validateArgs(args, methodTarget); - - return ReflectionUtils.invokeMethod(method, methodTarget.getBean(), args); + CommandExecution execution = CommandExecution + .of(argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal); + return execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0])); } catch (UndeclaredThrowableException e) { if (e.getCause() instanceof InterruptedException || e.getCause() instanceof ClosedByInterruptException) { @@ -167,6 +168,9 @@ public Object evaluate(Input input) { } return e.getCause(); } + catch (CommandExecutionException e) { + return e.getCause(); + } catch (Exception e) { return e; } @@ -175,7 +179,7 @@ public Object evaluate(Input input) { } } else { - return new CommandNotCurrentlyAvailable(command, availability); + return new CommandNotFound(words); } } else { @@ -183,6 +187,7 @@ public Object evaluate(Input input) { } } + /** * Return true if the parsed input ends up being empty (e.g. hitting ENTER on an * empty line or blank space). @@ -229,18 +234,11 @@ public List complete(CompletionContext context) { if (best != null) { CompletionContext argsContext = context.drop(best.split(" ").length); // Try to complete arguments - Map methodTargets = commandRegistry.listCommands(); - MethodTarget methodTarget = methodTargets.get(best); - Method method = methodTarget.getMethod(); - - List parameters = Utils.createMethodParameters(method).collect(Collectors.toList()); - for (ParameterResolver resolver : parameterResolvers) { - for (int index = 0; index < parameters.size(); index++) { - MethodParameter parameter = parameters.get(index); - if (resolver.supports(parameter)) { - resolver.complete(parameter, argsContext).stream().forEach(candidates::add); - } - } + CommandRegistration registration = commandRegistry.getRegistrations().get(best); + + for (CompletionResolver resolver : completionResolvers) { + List resolved = resolver.resolve(registration, argsContext); + candidates.addAll(resolved); } } return candidates; @@ -250,61 +248,23 @@ private List commandsStartingWith(String prefix) { // Workaround for https://github.com/spring-projects/spring-shell/issues/150 // (sadly, this ties this class to JLine somehow) int lastWordStart = prefix.lastIndexOf(' ') + 1; - Map methodTargets = commandRegistry.listCommands(); - return methodTargets.entrySet().stream() - .filter(e -> e.getKey().startsWith(prefix)) - .map(e -> toCommandProposal(e.getKey().substring(lastWordStart), e.getValue())) - .collect(Collectors.toList()); + return commandRegistry.getRegistrations().values().stream() + .filter(r -> { + return r.getCommand().startsWith(prefix); + }) + .map(r -> { + String c = r.getCommand(); + c = c.substring(lastWordStart); + return toCommandProposal(c, r); + }) + .collect(Collectors.toList()); } - private CompletionProposal toCommandProposal(String command, MethodTarget methodTarget) { + private CompletionProposal toCommandProposal(String command, CommandRegistration registration) { return new CompletionProposal(command) .dontQuote(true) .category("Available commands") - .description(methodTarget.getHelp()); - } - - private void validateArgs(Object[] args, MethodTarget methodTarget) { - for (int i = 0; i < args.length; i++) { - if (args[i] == UNRESOLVED) { - MethodParameter methodParameter = Utils.createMethodParameter(methodTarget.getMethod(), i); - throw new IllegalStateException("Could not resolve " + methodParameter); - } - } - Set> constraintViolations = validator.forExecutables().validateParameters( - methodTarget.getBean(), - methodTarget.getMethod(), - args); - if (constraintViolations.size() > 0) { - throw new ParameterValidationException(constraintViolations, methodTarget); - } - } - - /** - * Use all known {@link ParameterResolver}s to try to compute a value for each parameter - * of the method to invoke. - * @param method the method for which parameters should be computed - * @param wordsForArgs the list of 'words' that should be converted to parameter values. - * May include markers for passing parameters 'by name' - * @return an array containing resolved parameter values, or {@link #UNRESOLVED} for - * parameters that could not be resolved - */ - private Object[] resolveArgs(Method method, List wordsForArgs) { - log.debug("Resolving args {} {}", method, wordsForArgs); - List parameters = Utils.createMethodParameters(method).collect(Collectors.toList()); - Object[] args = new Object[parameters.size()]; - Arrays.fill(args, UNRESOLVED); - for (ParameterResolver resolver : parameterResolvers) { - log.debug("Resolving args with {}", resolver); - for (int argIndex = 0; argIndex < args.length; argIndex++) { - MethodParameter parameter = parameters.get(argIndex); - if (args[argIndex] == UNRESOLVED && resolver.supports(parameter)) { - args[argIndex] = resolver.resolve(parameter, wordsForArgs).resolvedValue(); - log.debug("Resolved {} {} {} {}", method, args[argIndex], resolver, parameter); - } - } - } - return args; + .description(registration.getHelp()); } /** @@ -313,8 +273,7 @@ private Object[] resolveArgs(Method method, List wordsForArgs) { * @return a valid command name, or {@literal null} if none matched */ private String findLongestCommand(String prefix) { - Map methodTargets = commandRegistry.listCommands(); - String result = methodTargets.keySet().stream() + String result = commandRegistry.getRegistrations().keySet().stream() .filter(command -> prefix.equals(command) || prefix.startsWith(command + " ")) .reduce("", (c1, c2) -> c1.length() > c2.length() ? c1 : c2); return "".equals(result) ? null : result; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Utils.java b/spring-shell-core/src/main/java/org/springframework/shell/Utils.java index 28d972d0e..dacc8bdb8 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/Utils.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/Utils.java @@ -20,7 +20,10 @@ import java.lang.reflect.Executable; import java.lang.reflect.Method; import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.function.Predicate; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -130,4 +133,37 @@ public static ValidatorFactory defaultValidatorFactory() { public static Validator defaultValidator() { return DEFAULT_VALIDATOR; } + + /** + * Split array into list of lists by predicate + * + * @param array the array + * @param predicate the predicate + * @return the list of lists + */ + public static List> split(T[] array, Predicate predicate) { + List list = Arrays.asList(array); + boolean[] boundaries = new boolean[array.length]; + List> split = new ArrayList<>(); + + for (int i = 0; i < array.length; i++) { + boundaries[i] = predicate.test(array[i]); + } + + int tail = 0; + for (int i = 0; i < boundaries.length; i++) { + if (boundaries[i]) { + if (tail < i) { + split.add(list.subList(tail, i)); + } + tail = i; + } + } + + if (tail < array.length) { + split.add(list.subList(tail, array.length)); + } + + return split; + } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/ArgumentHeaderMethodArgumentResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/ArgumentHeaderMethodArgumentResolver.java new file mode 100644 index 000000000..384f0ee91 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/ArgumentHeaderMethodArgumentResolver.java @@ -0,0 +1,79 @@ +/* + * Copyright 2022 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; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.messaging.handler.annotation.Header; +import org.springframework.shell.support.AbstractArgumentMethodArgumentResolver; +import org.springframework.util.Assert; + +/** + * Resolver for {@link Header @Header} arguments. + * + * @author Janne Valkealahti + */ +public class ArgumentHeaderMethodArgumentResolver extends AbstractArgumentMethodArgumentResolver { + + public ArgumentHeaderMethodArgumentResolver(ConversionService conversionService, + @Nullable ConfigurableBeanFactory beanFactory) { + super(conversionService, beanFactory); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(Header.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + Header annot = parameter.getParameterAnnotation(Header.class); + Assert.state(annot != null, "No Header annotation"); + return new HeaderNamedValueInfo(annot); + } + + @Override + @Nullable + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, List names) + throws Exception { + if (names.size() == 1) { + return message.getHeaders().get(ARGUMENT_PREFIX + names.get(0)); + } + else { + return null; + } + } + + @Override + protected void handleMissingValue(List headerName, MethodParameter parameter, Message message) { + throw new MessageHandlingException(message, "Missing header '" + headerName + + "' for method parameter type [" + parameter.getParameterType() + "]"); + } + + private static final class HeaderNamedValueInfo extends NamedValueInfo { + + private HeaderNamedValueInfo(Header annotation) { + super(Arrays.asList(annotation.name()), annotation.required(), annotation.defaultValue()); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandCatalog.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandCatalog.java new file mode 100644 index 000000000..5897bad9f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandCatalog.java @@ -0,0 +1,168 @@ +/* + * Copyright 2022 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; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.springframework.shell.context.InteractionMode; +import org.springframework.shell.context.ShellContext; + +/** + * Interface defining contract to handle existing {@link CommandRegistration}s. + * + * @author Janne Valkealahti + */ +public interface CommandCatalog { + + /** + * Register a {@link CommandRegistration}. + * + * @param registration the command registration + */ + void register(CommandRegistration... registration); + + /** + * Unregister a {@link CommandRegistration}. + * + * @param registration the command registration + */ + void unregister(CommandRegistration... registration); + + /** + * Unregister a {@link CommandRegistration} by its command name. + * + * @param commandName the command name + */ + void unregister(String... commandName); + + /** + * Gets all {@link CommandRegistration}s mapped with their names. + * Returned map is a copy and cannot be used to register new commands. + * + * @return all command registrations + */ + Map getRegistrations(); + + /** + * Gets an instance of a default {@link CommandCatalog}. + * + * @return default command catalog + */ + static CommandCatalog of() { + return new DefaultCommandCatalog(null, null); + } + + /** + * Gets an instance of a default {@link CommandCatalog}. + * + * @param resolvers the command resolvers + * @param shellContext the shell context + * @return default command catalog + */ + static CommandCatalog of(Collection resolvers, ShellContext shellContext) { + return new DefaultCommandCatalog(resolvers, shellContext); + } + + /** + * Default implementation of a {@link CommandCatalog}. + */ + static class DefaultCommandCatalog implements CommandCatalog { + + private final Map commandRegistrations = new HashMap<>(); + private final Collection resolvers = new ArrayList<>(); + private final ShellContext shellContext; + + DefaultCommandCatalog(Collection resolvers, ShellContext shellContext) { + this.shellContext = shellContext; + if (resolvers != null) { + this.resolvers.addAll(resolvers); + } + } + + @Override + public void register(CommandRegistration... registration) { + for (CommandRegistration r : registration) { + String commandName = r.getCommand(); + commandRegistrations.put(commandName, r); + } + } + + @Override + public void unregister(CommandRegistration... registration) { + for (CommandRegistration r : registration) { + String commandName = r.getCommand(); + commandRegistrations.remove(commandName); + } + } + + @Override + public void unregister(String... commandName) { + for (String n : commandName) { + commandRegistrations.remove(n); + } + } + + @Override + public Map getRegistrations() { + Map regs = new HashMap<>(); + regs.putAll(commandRegistrations); + for (CommandResolver resolver : resolvers) { + resolver.resolve().stream().forEach(r -> { + regs.put(r.getCommand(), r); + }); + } + return regs.entrySet().stream() + .filter(filterByInteractionMode(shellContext)) + .collect(Collectors.toMap(Entry::getKey, Entry::getValue)); + } + + /** + * Filter registration entries by currently set mode. Having it set to ALL or null + * effectively disables filtering as as we only care if mode is set to interactive + * or non-interactive. + */ + private static Predicate> filterByInteractionMode(ShellContext shellContext) { + return e -> { + InteractionMode mim = e.getValue().getInteractionMode(); + InteractionMode cim = shellContext != null ? shellContext.getInteractionMode() : InteractionMode.ALL; + if (mim == null || cim == null || mim == InteractionMode.ALL) { + return true; + } + else if (mim == InteractionMode.INTERACTIVE) { + return cim == InteractionMode.INTERACTIVE || cim == InteractionMode.ALL; + } + else if (mim == InteractionMode.NONINTERACTIVE) { + return cim == InteractionMode.NONINTERACTIVE || cim == InteractionMode.ALL; + } + return true; + }; + } + + // private static String commandName(String[] commands) { + // return Arrays.asList(commands).stream() + // .flatMap(c -> Stream.of(c.split(" "))) + // .filter(c -> StringUtils.hasText(c)) + // .map(c -> c.trim()) + // .collect(Collectors.joining(" ")); + // } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandCatalogCustomizer.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandCatalogCustomizer.java new file mode 100644 index 000000000..4bf2a678c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandCatalogCustomizer.java @@ -0,0 +1,32 @@ +/* + * Copyright 2022 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; + +/** + * Interface to customize a {@link CommandCatalog}. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface CommandCatalogCustomizer { + + /** + * Customize a command catalog. + * + * @param commandCatalog a command catalog + */ + void customize(CommandCatalog commandCatalog); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandContext.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandContext.java new file mode 100644 index 000000000..b17594929 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandContext.java @@ -0,0 +1,143 @@ +/* + * Copyright 2022 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; + +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Stream; + +import org.jline.terminal.Terminal; + +import org.springframework.shell.command.CommandParser.CommandParserResult; +import org.springframework.shell.command.CommandParser.CommandParserResults; +import org.springframework.util.ObjectUtils; + +/** + * Interface containing information about current command execution. + * + * @author Janne Valkealahti + */ +public interface CommandContext { + + /** + * Gets a raw args passed into a currently executing command. + * + * @return raw command arguments + */ + String[] getRawArgs(); + + /** + * Gets if option has been mapped. + * + * @param name the option name + * @return true if option has been mapped, false otherwise + */ + boolean hasMappedOption(String name); + + /** + * Gets a command option parser results. + * + * @return the command option parser results + */ + CommandParserResults getParserResults(); + + /** + * Gets an mapped option value. + * + * @param the type to map to + * @param name the option name + * @return mapped value + */ + T getOptionValue(String name); + + /** + * Gets a terminal. + * + * @return a terminal + */ + Terminal getTerminal(); + + /** + * Gets an instance of a default {@link CommandContext}. + * + * @param args the arguments + * @param results the results + * @param terminal the terminal + * @return a command context + */ + static CommandContext of(String[] args, CommandParserResults results, Terminal terminal) { + return new DefaultCommandContext(args, results, terminal); + } + + /** + * Default implementation of a {@link CommandContext}. + */ + static class DefaultCommandContext implements CommandContext { + + private String[] args; + private CommandParserResults results; + private Terminal terminal; + + DefaultCommandContext(String[] args, CommandParserResults results, Terminal terminal) { + this.args = args; + this.results = results; + this.terminal = terminal; + } + + @Override + public String[] getRawArgs() { + return args; + } + + @Override + public boolean hasMappedOption(String name) { + return find(name).isPresent(); + } + + @Override + public CommandParserResults getParserResults() { + return results; + } + + @Override + @SuppressWarnings("unchecked") + public T getOptionValue(String name) { + Optional find = find(name); + if (find.isPresent()) { + return (T) find.get().value(); + } + return null; + } + + @Override + public Terminal getTerminal() { + return terminal; + } + + private Optional find(String name) { + return results.results().stream() + .filter(r -> { + Stream l = Arrays.asList(r.option().getLongNames()).stream(); + Stream s = Arrays.asList(r.option().getShortNames()).stream().map(n -> Character.toString(n)); + return Stream.concat(l, s) + .filter(o -> ObjectUtils.nullSafeEquals(o, name)) + .findFirst() + .isPresent(); + }) + .findFirst(); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandContextMethodArgumentResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandContextMethodArgumentResolver.java new file mode 100644 index 000000000..b4a4957b7 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandContextMethodArgumentResolver.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 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; + +import java.util.Optional; + +import org.springframework.core.MethodParameter; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; + +/** + * Implementation of a {@link HandlerMethodArgumentResolver} resolving + * {@link CommandContext}. + * + * @author Janne Valkealahti + */ +public class CommandContextMethodArgumentResolver implements HandlerMethodArgumentResolver { + + public static final String HEADER_COMMAND_CONTEXT = "springShellCommandContext"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + MethodParameter nestedParameter = parameter.nestedIfOptional(); + Class paramType = nestedParameter.getNestedParameterType(); + return CommandContext.class.isAssignableFrom(paramType); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message){ + CommandContext commandContext = message.getHeaders().get(HEADER_COMMAND_CONTEXT, CommandContext.class); + return parameter.isOptional() ? Optional.ofNullable(commandContext) : commandContext; + } +} 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 new file mode 100644 index 000000000..5ddd1c44f --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandExecution.java @@ -0,0 +1,219 @@ +/* + * Copyright 2022 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; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import javax.validation.Validator; + +import org.jline.terminal.Terminal; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.Order; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.shell.command.CommandParser.CommandParserException; +import org.springframework.shell.command.CommandParser.CommandParserResults; +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; + +/** + * Interface to evaluate a result from a command with an arguments. + * + * @author Janne Valkealahti + */ +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); + + /** + * Gets an instance of a default {@link CommandExecution}. + * + * @param resolvers the handler method argument resolvers + * @return default command execution + */ + public static CommandExecution of(List resolvers) { + return new DefaultCommandExecution(resolvers, null, null); + } + + /** + * Gets an instance of a default {@link CommandExecution}. + * + * @param resolvers the handler method argument resolvers + * @param validator the validator + * @param terminal the terminal + * @return default command execution + */ + public static CommandExecution of(List resolvers, Validator validator, + Terminal terminal) { + return new DefaultCommandExecution(resolvers, validator, terminal); + } + + /** + * Default implementation of a {@link CommandExecution}. + */ + static class DefaultCommandExecution implements CommandExecution { + + private List resolvers; + private Validator validator; + private Terminal terminal; + + public DefaultCommandExecution(List resolvers, Validator validator, + Terminal terminal) { + this.resolvers = resolvers; + this.validator = validator; + this.terminal = terminal; + } + + public Object evaluate(CommandRegistration registration, String[] args) { + + List options = registration.getOptions(); + CommandParser parser = CommandParser.of(); + CommandParserResults results = parser.parse(options, args); + + if (!results.errors().isEmpty()) { + throw new CommandParserExceptionsException("Command parser resulted errors", results.errors()); + } + + CommandContext ctx = CommandContext.of(args, results, terminal); + + Object res = null; + + TargetInfo targetInfo = registration.getTarget(); + + // pick the target to execute + if (targetInfo.getTargetType() == TargetType.FUNCTION) { + res = targetInfo.getFunction().apply(ctx); + } + else if (targetInfo.getTargetType() == TargetType.CONSUMER) { + targetInfo.getConsumer().accept(ctx); + } + else if (targetInfo.getTargetType() == TargetType.METHOD) { + try { + MessageBuilder messageBuilder = MessageBuilder.withPayload(args); + Map paramValues = new HashMap<>(); + results.results().stream().forEach(r -> { + if (r.option().getLongNames() != null) { + for (String n : r.option().getLongNames()) { + messageBuilder.setHeader(ArgumentHeaderMethodArgumentResolver.ARGUMENT_PREFIX + n, r.value()); + paramValues.put(n, r.value()); + } + } + if (r.option().getShortNames() != null) { + for (Character n : r.option().getShortNames()) { + messageBuilder.setHeader(ArgumentHeaderMethodArgumentResolver.ARGUMENT_PREFIX + n.toString(), r.value()); + } + } + }); + messageBuilder.setHeader(CommandContextMethodArgumentResolver.HEADER_COMMAND_CONTEXT, ctx); + + InvocableShellMethod invocableShellMethod = new InvocableShellMethod(targetInfo.getBean(), targetInfo.getMethod()); + invocableShellMethod.setValidator(validator); + ShellMethodArgumentResolverComposite argumentResolvers = new ShellMethodArgumentResolverComposite(); + if (resolvers != null) { + argumentResolvers.addResolvers(resolvers); + } + if (!paramValues.isEmpty()) { + argumentResolvers.addResolver(new ParamNameHandlerMethodArgumentResolver(paramValues)); + } + invocableShellMethod.setMessageMethodArgumentResolvers(argumentResolvers); + + res = invocableShellMethod.invoke(messageBuilder.build(), (Object[])null); + + } catch (Exception e) { + throw new CommandExecutionException(e); + } + } + + return res; + } + } + + @Order(100) + static class ParamNameHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver { + + private final Map paramValues = new HashMap<>(); + ConversionService conversionService = new DefaultConversionService(); + + ParamNameHandlerMethodArgumentResolver(Map paramValues) { + this.paramValues.putAll(paramValues); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + String parameterName = parameter.getParameterName(); + if (parameterName == null) { + return false; + } + return paramValues.containsKey(parameterName) && conversionService + .canConvert(paramValues.get(parameterName).getClass(), parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) throws Exception { + return conversionService.convert(paramValues.get(parameter.getParameterName()), parameter.getParameterType()); + } + + } + + static class CommandExecutionException extends RuntimeException { + + public CommandExecutionException(Throwable cause) { + super(cause); + } + } + + static class CommandParserExceptionsException extends RuntimeException { + + private final List parserExceptions; + + public CommandParserExceptionsException(String message, List parserExceptions) { + super(message); + this.parserExceptions = parserExceptions; + } + + public List getParserExceptions() { + return parserExceptions; + } + } + + static class CommandExecutionHandlerMethodArgumentResolvers { + + private final List resolvers; + + public CommandExecutionHandlerMethodArgumentResolvers(List resolvers) { + this.resolvers = resolvers; + } + + public List getResolvers() { + return resolvers; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java new file mode 100644 index 000000000..77d50ebcc --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java @@ -0,0 +1,210 @@ +/* + * Copyright 2022 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; + +import org.springframework.core.ResolvableType; + +/** + * Interface representing an option in a command. + * + * @author Janne Valkealahti + */ +public interface CommandOption { + + /** + * Gets a long names of an option. + * + * @return long names of an option + */ + String[] getLongNames(); + + /** + * Gets a short names of an option. + * + * @return short names of an option + */ + Character[] getShortNames(); + + /** + * Gets a description of an option. + * + * @return description of an option + */ + String getDescription(); + + /** + * Gets a {@link ResolvableType} of an option. + * + * @return type of an option + */ + ResolvableType getType(); + + /** + * Gets a flag if option is required. + * + * @return the required flag + */ + boolean isRequired(); + + /** + * Gets a default value of an option. + * + * @return the default value + */ + String getDefaultValue(); + + /** + * Gets a positional value. + * + * @return the positional value + */ + int getPosition(); + + /** + * Gets a minimum arity. + * + * @return the minimum arity + */ + int getArityMin(); + + /** + * Gets a maximum arity. + * + * @return the maximum arity + */ + int getArityMax(); + + /** + * Gets an instance of a default {@link CommandOption}. + * + * @param longNames the long names + * @param shortNames the short names + * @param description the description + * @return default command option + */ + public static CommandOption of(String[] longNames, Character[] shortNames, String description) { + return of(longNames, shortNames, description, null, false, null, null, null, null); + } + + /** + * Gets an instance of a default {@link CommandOption}. + * + * @param longNames the long names + * @param shortNames the short names + * @param description the description + * @param type the type + * @return default command option + */ + public static CommandOption of(String[] longNames, Character[] shortNames, String description, + ResolvableType type) { + return of(longNames, shortNames, description, type, false, null, null, null, null); + } + + /** + * Gets an instance of a default {@link CommandOption}. + * + * @param longNames the long names + * @param shortNames the short names + * @param description the description + * @param type the type + * @param required the required flag + * @param defaultValue the default value + * @param position the position value + * @param arityMin the min arity + * @param arityMax the max arity + * @return default command option + */ + public static CommandOption of(String[] longNames, Character[] shortNames, String description, + ResolvableType type, boolean required, String defaultValue, Integer position, Integer arityMin, Integer arityMax) { + return new DefaultCommandOption(longNames, shortNames, description, type, required, defaultValue, position, + arityMin, arityMax); + } + + /** + * Default implementation of {@link CommandOption}. + */ + public static class DefaultCommandOption implements CommandOption { + + private String[] longNames; + private Character[] shortNames; + private String description; + private ResolvableType type; + private boolean required; + private String defaultValue; + private int position; + private int arityMin; + private int arityMax; + + public DefaultCommandOption(String[] longNames, Character[] shortNames, String description, + ResolvableType type, boolean required, String defaultValue, Integer position, + Integer arityMin, Integer arityMax) { + this.longNames = longNames != null ? longNames : new String[0]; + this.shortNames = shortNames != null ? shortNames : new Character[0]; + this.description = description; + this.type = type; + this.required = required; + this.defaultValue = defaultValue; + this.position = position != null && position > -1 ? position : -1 ; + this.arityMin = arityMin != null ? arityMin : -1; + this.arityMax = arityMax != null ? arityMax : -1; + } + + @Override + public String[] getLongNames() { + return longNames; + } + + @Override + public Character[] getShortNames() { + return shortNames; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public ResolvableType getType() { + return type; + } + + @Override + public boolean isRequired() { + return required; + } + + @Override + public String getDefaultValue() { + return defaultValue; + } + + @Override + public int getPosition() { + return position; + } + + @Override + public int getArityMin() { + return arityMin; + } + + @Override + public int getArityMax() { + return arityMax; + } + } +} 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 new file mode 100644 index 000000000..ca78e1958 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandParser.java @@ -0,0 +1,479 @@ +/* + * Copyright 2022 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; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Deque; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.core.ResolvableType; +import org.springframework.shell.Utils; +import org.springframework.util.StringUtils; + +/** + * Interface parsing arguments for a {@link CommandRegistration}. A command is + * always identified by a set of words like + * {@code command subcommand1 subcommand2} and remaining part of it are options + * which this interface intercepts and translates into format we can understand. + * + * @author Janne Valkealahti + */ +public interface CommandParser { + + /** + * Result of a parsing {@link CommandOption} with an argument. + */ + interface CommandParserResult { + + /** + * Gets the {@link CommandOption}. + * + * @return the command option + */ + CommandOption option(); + + /** + * Gets the value. + * + * @return the value + */ + Object value(); + + /** + * Gets an instance of a default {@link CommandParserResult}. + * + * @param option the command option + * @param value the value + * @return a result + */ + static CommandParserResult of(CommandOption option, Object value) { + return new DefaultCommandParserResult(option, value); + } + } + + /** + * Results of a {@link CommandParser}. Basically contains a list of {@link CommandParserResult}s. + */ + interface CommandParserResults { + + /** + * Gets the results. + * + * @return the results + */ + List results(); + + /** + * Gets the unmapped positional arguments. + * + * @return the unmapped positional arguments + */ + List positional(); + + /** + * Gets parsing errors. + * + * @return the parsing errors + */ + List errors(); + + /** + * Gets an instance of a default {@link CommandParserResults}. + * + * @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); + } + } + + /** + * Parse options with a given arguments. + * + * 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 new DefaultCommandParser(); + } + + /** + * Default implementation of a {@link CommandParserResults}. + */ + static class DefaultCommandParserResults implements CommandParserResults { + + private List results; + private List positional; + private List errors; + + DefaultCommandParserResults(List results, List positional, List errors) { + this.results = results; + this.positional = positional; + this.errors = errors; + } + + @Override + public List results() { + return results; + } + + @Override + public List positional() { + return positional; + } + + @Override + public List errors() { + return errors; + } + } + + /** + * Default implementation of a {@link CommandParserResult}. + */ + static class DefaultCommandParserResult implements CommandParserResult { + + private CommandOption option; + private Object value; + + DefaultCommandParserResult(CommandOption option, Object value) { + this.option = option; + this.value = value; + } + + @Override + public CommandOption option() { + return option; + } + + @Override + public Object value() { + return value; + } + } + + /** + * Default implementation of a {@link CommandParser}. + */ + static class DefaultCommandParser implements CommandParser { + + @Override + public CommandParserResults parse(List options, String[] args) { + List requiredOptions = options.stream() + .filter(o -> o.isRequired()) + .collect(Collectors.toList()); + + Lexer lexer = new Lexer(args); + List> lexerResults = lexer.visit(); + Parser parser = new Parser(); + ParserResults parserResults = parser.visit(lexerResults, options); + + 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 { + positional.addAll(pr.args); + } + if (pr.error != null) { + errors.add(pr.error); + } + }); + + 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.add(pop.args.stream().collect(Collectors.joining(" "))); + } + } + } + } + if (!oargs.isEmpty()) { + results.add(new DefaultCommandParserResult(o, oargs.stream().collect(Collectors.joining(" ")))); + 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)); + }); + + return new DefaultCommandParserResults(results, positional, errors); + } + + 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 static 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); + Object value = 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()); + 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 (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(); + } + 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.joining(" ")); + unmapped.addAll(arguments.subList(limit, arguments.size())); + } + else { + value = arguments.get(0); + unmapped.addAll(arguments.subList(1, arguments.size())); + } + } + } + } + + return ConvertArgumentsHolder.of(value, unmapped); + } + + private static class ConvertArgumentsHolder { + Object value; + final List unmapped = new ArrayList<>(); + + ConvertArgumentsHolder(Object value, List unmapped) { + this.value = value; + if (unmapped != null) { + this.unmapped.addAll(unmapped); + } + } + + static ConvertArgumentsHolder of(Object value, List unmapped) { + return new ConvertArgumentsHolder(value, unmapped); + } + } + } + + /** + * 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; + Lexer(String[] args) { + this.args = args; + } + List> visit() { + return Utils.split(args, t -> t.startsWith("-")); + } + } + } + + static class CommandParserException extends RuntimeException { + + public CommandParserException(String message) { + super(message); + } + + public CommandParserException(String message, Throwable cause) { + super(message, cause); + } + + public static CommandParserException of(String message) { + return new CommandParserException(message); + } + } + + 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; + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java new file mode 100644 index 000000000..07b5e076c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java @@ -0,0 +1,781 @@ +/* + * Copyright 2022 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; + +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.core.ResolvableType; +import org.springframework.lang.Nullable; +import org.springframework.shell.Availability; +import org.springframework.shell.context.InteractionMode; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * Interface defining a command registration endpoint. + * + * @author Janne Valkealahti + */ +public interface CommandRegistration { + + /** + * Gets a command for this registration. + * + * @return command + */ + String getCommand(); + + /** + * Gets an {@link InteractionMode}. + * + * @return the interaction mode + */ + InteractionMode getInteractionMode(); + + /** + * Get help for a command. + * + * @return the help + */ + String getHelp(); + + /** + * Get group for a command. + * + * @return the group + */ + String getGroup(); + + /** + * Get description for a command. + * + * @return the description + */ + String getDescription(); + + /** + * Get {@link Availability} for a command + * + * @return the availability + */ + Availability getAvailability(); + + /** + * Gets target info. + * + * @return the target info + */ + TargetInfo getTarget(); + + /** + * Gets an options. + * + * @return the options + */ + List getOptions(); + + /** + * Gets a new instance of a {@link Buidler}. + * + * @return a new builder instance + */ + public static Builder builder() { + return new DefaultBuilder(); + } + + /** + * Spec defining an option. + */ + public interface OptionSpec { + + /** + * Define long option names. + * + * @param names the long option names + * @return option spec for chaining + */ + OptionSpec longNames(String... names); + + /** + * Define short option names. + * + * @param names the long option names + * @return option spec for chaining + */ + OptionSpec shortNames(Character... names); + + /** + * Define a type for an option. + * + * @param type the type + * @return option spec for chaining + */ + OptionSpec type(Type type); + + /** + * Define a {@code description} for an option. + * + * @param description the option description + * @return option spec for chaining + */ + OptionSpec description(String description); + + /** + * Define if option is required. + * + * @param required the required flag + * @return option spec for chaining + */ + OptionSpec required(boolean required); + + /** + * Define option to be required. Syntatic sugar calling + * {@link #required(boolean)} with {@code true}. + * + * @return option spec for chaining + */ + OptionSpec required(); + + /** + * Define a {@code defaultValue} for an option. + * + * @param defaultValue the option default value + * @return option spec for chaining + */ + OptionSpec defaultValue(String defaultValue); + + /** + * Define an optional hint for possible positional mapping. + * + * @param position the position + * @return option spec for chaining + */ + OptionSpec position(Integer position); + + /** + * Define an {@code arity} for an option. + * + * @param min the min arity + * @param max the max arity + * @return option spec for chaining + */ + OptionSpec arity(int min, int max); + + /** + * Define an {@code arity} for an option. + * + * @param arity the arity + * @return option spec for chaining + */ + OptionSpec arity(OptionArity arity); + + /** + * Return a builder for chaining. + * + * @return a builder for chaining + */ + Builder and(); + } + + public enum OptionArity { + ZERO, + ZERO_OR_ONE, + EXACTLY_ONE, + ZERO_OR_MORE, + ONE_OR_MORE + } + + /** + * Encapsulates info for {@link TargetSpec}. + */ + public interface TargetInfo { + + /** + * Get target type + * + * @return the target type + */ + TargetType getTargetType(); + + /** + * Get the bean. + * + * @return the bean + */ + Object getBean(); + + /** + * Get the bean method + * + * @return the bean method + */ + Method getMethod(); + + /** + * Get the function + * + * @return the function + */ + Function getFunction(); + + /** + * Get the consumer + * + * @return the consumer + */ + Consumer getConsumer(); + + static TargetInfo of(Object bean, Method method) { + return new DefaultTargetInfo(TargetType.METHOD, bean, method, null, null); + } + + static TargetInfo of(Function function) { + return new DefaultTargetInfo(TargetType.FUNCTION, null, null, function, null); + } + + static TargetInfo of(Consumer consumer) { + return new DefaultTargetInfo(TargetType.CONSUMER, null, null, null, consumer); + } + + enum TargetType { + METHOD, FUNCTION, CONSUMER; + } + + static class DefaultTargetInfo implements TargetInfo { + + private final TargetType targetType; + private final Object bean; + private final Method method; + private final Function function; + private final Consumer consumer; + + public DefaultTargetInfo(TargetType targetType, Object bean, Method method, + Function function, Consumer consumer) { + this.targetType = targetType; + this.bean = bean; + this.method = method; + this.function = function; + this.consumer = consumer; + } + + @Override + public TargetType getTargetType() { + return targetType; + } + + @Override + public Object getBean() { + return bean; + } + + @Override + public Method getMethod() { + return method; + } + + @Override + public Function getFunction() { + return function; + } + + @Override + public Consumer getConsumer() { + return consumer; + } + } + } + + /** + * Spec defining a target. + */ + public interface TargetSpec { + + /** + * Register a method target. + * + * @param bean the bean + * @param method the method + * @param paramTypes the parameter types + * @return a target spec for chaining + */ + TargetSpec method(Object bean, String method, @Nullable Class... paramTypes); + + /** + * Register a method target. + * + * @param bean the bean + * @param method the method + * @return a target spec for chaining + */ + TargetSpec method(Object bean, Method method); + + /** + * Register a function target. + * + * @param function the function to register + * @return a target spec for chaining + */ + TargetSpec function(Function function); + + /** + * Register a consumer target. + * + * @param consumer the consumer to register + * @return a target spec for chaining + */ + TargetSpec consumer(Consumer consumer); + + /** + * Return a builder for chaining. + * + * @return a builder for chaining + */ + Builder and(); + } + + /** + * Builder interface for {@link CommandRegistration}. + */ + public interface Builder { + + /** + * Define commands this registration uses. Essentially defines a full set of + * main and sub commands. It doesn't matter if full command is defined in one + * string or multiple strings as "words" are splitted and trimmed with + * whitespaces. You will get result of {@code command subcommand1 subcommand2, ...}. + * + * @param commands the commands + * @return builder for chaining + */ + Builder command(String... commands); + + /** + * Define {@link InteractionMode} for a command. + * + * @param mode the interaction mode + * @return builder for chaining + */ + Builder interactionMode(InteractionMode mode); + + /** + * Define a simple help text for a command. + * + * @param help the help text + * @return builder for chaining + */ + Builder help(String help); + + /** + * Define an {@link Availability} suppliear for a command. + * + * @param availability the availability + * @return builder for chaining + */ + Builder availability(Supplier availability); + + /** + * Define a group for a command. + * + * @param group the group + * @return builder for chaining + */ + Builder group(String group); + + /** + * Define an option what this command should user for. Can be used multiple + * times. + * + * @return option spec for chaining + */ + OptionSpec withOption(); + + /** + * Define a target what this command should execute + * + * @return target spec for chaining + */ + TargetSpec withTarget(); + + /** + * Builds a {@link CommandRegistration}. + * + * @return a command registration + */ + CommandRegistration build(); + } + + static class DefaultOptionSpec implements OptionSpec { + + private BaseBuilder builder; + private String[] longNames; + private Character[] shortNames; + private ResolvableType type; + private String description; + private boolean required; + private String defaultValue; + private Integer position; + private Integer arityMin; + private Integer arityMax; + + DefaultOptionSpec(BaseBuilder builder) { + this.builder = builder; + } + + @Override + public OptionSpec longNames(String... names) { + this.longNames = names; + return this; + } + + @Override + public OptionSpec shortNames(Character... names) { + this.shortNames = names; + return this; + } + + @Override + public OptionSpec type(Type type) { + this.type = ResolvableType.forType(type); + return this; + } + + @Override + public OptionSpec description(String description) { + this.description = description; + return this; + } + + @Override + public OptionSpec required(boolean required) { + this.required = required; + return this; + } + + @Override + public OptionSpec required() { + return required(true); + } + + @Override + public OptionSpec defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + @Override + public OptionSpec position(Integer position) { + this.position = position; + return this; + } + + @Override + public OptionSpec arity(int min, int max) { + Assert.isTrue(min > -1, "arity min must be 0 or more"); + Assert.isTrue(max >= min, "arity max must be equal more than min"); + this.arityMin = min; + this.arityMax = max; + return this; + } + + @Override + public OptionSpec arity(OptionArity arity) { + switch (arity) { + case ZERO: + this.arityMin = 0; + this.arityMax = 0; + break; + case ZERO_OR_ONE: + this.arityMin = 0; + this.arityMax = Integer.MAX_VALUE; + break; + case EXACTLY_ONE: + this.arityMin = 1; + this.arityMax = 1; + break; + case ZERO_OR_MORE: + this.arityMin = 0; + this.arityMax = Integer.MAX_VALUE; + break; + case ONE_OR_MORE: + this.arityMin = 1; + this.arityMax = Integer.MAX_VALUE; + break; + default: + this.arityMin = 0; + this.arityMax = 0; + break; + } + return this; + } + + @Override + public Builder and() { + return builder; + } + + public String[] getLongNames() { + return longNames; + } + + public Character[] getShortNames() { + return shortNames; + } + + public ResolvableType getType() { + return type; + } + + public String getDescription() { + return description; + } + + public boolean isRequired() { + return required; + } + + public String getDefaultValue() { + return defaultValue; + } + + public Integer getPosition() { + return position; + } + + public Integer getArityMin() { + return arityMin; + } + + public Integer getArityMax() { + return arityMax; + } + } + + static class DefaultTargetSpec implements TargetSpec { + + private BaseBuilder builder; + private Object bean; + private Method method; + private Function function; + private Consumer consumer; + + DefaultTargetSpec(BaseBuilder builder) { + this.builder = builder; + } + + @Override + public TargetSpec method(Object bean, Method method) { + this.bean = bean; + this.method = method; + return this; + } + + @Override + public TargetSpec method(Object bean, String method, Class... paramTypes) { + this.bean = bean; + this.method = ReflectionUtils.findMethod(bean.getClass(), method, + ObjectUtils.isEmpty(paramTypes) ? null : paramTypes); + return this; + } + + @Override + public TargetSpec function(Function function) { + this.function = function; + return this; + } + + @Override + public TargetSpec consumer(Consumer consumer) { + this.consumer = consumer; + return this; + } + + @Override + public Builder and() { + return builder; + } + } + + static class DefaultCommandRegistration implements CommandRegistration { + + private String command; + private InteractionMode interactionMode; + private String help; + private String group; + private String description; + private Supplier availability; + private List optionSpecs; + private DefaultTargetSpec targetSpec; + + public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String help, + String group, String description, Supplier availability, + List optionSpecs, DefaultTargetSpec targetSpec) { + this.command = commandArrayToName(commands); + this.interactionMode = interactionMode; + this.help = help; + this.group = group; + this.description = description; + this.availability = availability; + this.optionSpecs = optionSpecs; + this.targetSpec = targetSpec; + } + + @Override + public String getCommand() { + return command; + } + + @Override + public InteractionMode getInteractionMode() { + return interactionMode; + } + + @Override + public String getHelp() { + return help; + } + + @Override + public String getGroup() { + return group; + } + + @Override + public String getDescription() { + return description; + } + + @Override + public Availability getAvailability() { + return availability != null ? availability.get() : Availability.available(); + } + + @Override + public List getOptions() { + return optionSpecs.stream() + .map(o -> CommandOption.of(o.getLongNames(), o.getShortNames(), o.getDescription(), o.getType(), + o.isRequired(), o.getDefaultValue(), o.getPosition(), o.getArityMin(), o.getArityMax())) + .collect(Collectors.toList()); + } + + @Override + public TargetInfo getTarget() { + if (targetSpec.bean != null) { + return TargetInfo.of(targetSpec.bean, targetSpec.method); + } + if (targetSpec.function != null) { + return TargetInfo.of(targetSpec.function); + } + if (targetSpec.consumer != null) { + return TargetInfo.of(targetSpec.consumer); + } + throw new IllegalArgumentException("No bean, function or consumer defined"); + } + + private static String commandArrayToName(String[] commands) { + return Arrays.asList(commands).stream() + .flatMap(c -> Stream.of(c.split(" "))) + .filter(c -> StringUtils.hasText(c)) + .map(c -> c.trim()) + .collect(Collectors.joining(" ")); + } + } + + static class DefaultBuilder extends BaseBuilder { + + } + + static class BaseBuilder implements Builder { + + private String[] commands; + private InteractionMode interactionMode = InteractionMode.ALL; + private String help; + private String group; + private String description; + private Supplier availability; + private List optionSpecs = new ArrayList<>(); + private DefaultTargetSpec targetSpec; + + @Override + public Builder command(String... commands) { + Assert.notNull(commands, "commands must be set"); + this.commands = Arrays.asList(commands).stream() + .flatMap(c -> Stream.of(c.split(" "))) + .filter(c -> StringUtils.hasText(c)) + .map(c -> c.trim()) + .collect(Collectors.toList()) + .toArray(new String[0]); + return this; + } + + @Override + public Builder interactionMode(InteractionMode mode) { + this.interactionMode = mode != null ? mode : InteractionMode.ALL; + return this; + } + + @Override + public Builder help(String help) { + this.help = help; + return this; + } + + @Override + public Builder group(String group) { + this.group = group; + return this; + } + + @Override + public Builder availability(Supplier availability) { + this.availability = availability; + return this; + } + + @Override + public OptionSpec withOption() { + DefaultOptionSpec spec = new DefaultOptionSpec(this); + optionSpecs.add(spec); + return spec; + } + + @Override + public TargetSpec withTarget() { + DefaultTargetSpec spec = new DefaultTargetSpec(this); + targetSpec = spec; + return spec; + } + + @Override + public CommandRegistration build() { + Assert.notNull(commands, "command cannot be empty"); + Assert.notNull(targetSpec, "target cannot be empty"); + Assert.state(!(targetSpec.bean != null && targetSpec.function != null), "only one target can exist"); + return new DefaultCommandRegistration(commands, interactionMode, help, group, description, availability, + optionSpecs, targetSpec); + } + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandResolver.java new file mode 100644 index 000000000..acf629e5c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandResolver.java @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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; + +import java.util.List; + +/** + * Interface to resolve currently existing commands. It is useful to have fully + * dynamic set of commands which may exists only if some conditions in a running + * shell are met. For example if shell is targeting arbitrary server environment + * some commands may or may not exist depending on a runtime state. + * + * @author Janne Valkealahti + */ +@FunctionalInterface +public interface CommandResolver { + + /** + * Resolve command registrations. + * + * @return command registrations + */ + List resolve(); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/invocation/InvocableShellMethod.java b/spring-shell-core/src/main/java/org/springframework/shell/command/invocation/InvocableShellMethod.java new file mode 100644 index 000000000..7c0aaca5c --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/invocation/InvocableShellMethod.java @@ -0,0 +1,630 @@ +/* + * Copyright 2022 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.invocation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import javax.validation.ConstraintViolation; +import javax.validation.Validator; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.core.BridgeMethodResolver; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.core.ResolvableType; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.shell.ParameterValidationException; +import org.springframework.shell.Utils; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +/** + * Encapsulates information about a handler method consisting of a + * {@linkplain #getMethod() method} and a {@linkplain #getBean() bean}. + * Provides convenient access to method parameters, the method return value, + * method annotations, etc. + * + *

The class may be created with a bean instance or with a bean name + * (e.g. lazy-init bean, prototype bean). Use {@link #createWithResolvedBean()} + * to obtain a {@code HandlerMethod} instance with a bean instance resolved + * through the associated {@link BeanFactory}. + * + * @author Janne Valkealahti + */ +public class InvocableShellMethod { + + /** Public for wrapping with fallback logger. */ + public static final Logger log = LoggerFactory.getLogger(InvocableShellMethod.class); + + private static final Object[] EMPTY_ARGS = new Object[0]; + + private final Object bean; + + @Nullable + private final BeanFactory beanFactory; + + private final Class beanType; + + private final Method method; + + private final Method bridgedMethod; + + private final MethodParameter[] parameters; + + @Nullable + private InvocableShellMethod resolvedFromHandlerMethod; + + private ShellMethodArgumentResolverComposite resolvers = new ShellMethodArgumentResolverComposite(); + + private ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + private Validator validator; + + /** + * Create an instance from a bean instance and a method. + */ + public InvocableShellMethod(Object bean, Method method) { + Assert.notNull(bean, "Bean is required"); + Assert.notNull(method, "Method is required"); + this.bean = bean; + this.beanFactory = null; + this.beanType = ClassUtils.getUserClass(bean); + this.method = method; + this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method); + ReflectionUtils.makeAccessible(this.bridgedMethod); + this.parameters = initMethodParameters(); + } + + /** + * Create an instance from a bean instance, method name, and parameter types. + * @throws NoSuchMethodException when the method cannot be found + */ + public InvocableShellMethod(Object bean, String methodName, Class... parameterTypes) throws NoSuchMethodException { + Assert.notNull(bean, "Bean is required"); + Assert.notNull(methodName, "Method name is required"); + this.bean = bean; + this.beanFactory = null; + this.beanType = ClassUtils.getUserClass(bean); + this.method = bean.getClass().getMethod(methodName, parameterTypes); + this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(this.method); + ReflectionUtils.makeAccessible(this.bridgedMethod); + this.parameters = initMethodParameters(); + } + + /** + * Create an instance from a bean name, a method, and a {@code BeanFactory}. + * The method {@link #createWithResolvedBean()} may be used later to + * re-create the {@code HandlerMethod} with an initialized bean. + */ + public InvocableShellMethod(String beanName, BeanFactory beanFactory, Method method) { + Assert.hasText(beanName, "Bean name is required"); + Assert.notNull(beanFactory, "BeanFactory is required"); + Assert.notNull(method, "Method is required"); + this.bean = beanName; + this.beanFactory = beanFactory; + Class beanType = beanFactory.getType(beanName); + if (beanType == null) { + throw new IllegalStateException("Cannot resolve bean type for bean with name '" + beanName + "'"); + } + this.beanType = ClassUtils.getUserClass(beanType); + this.method = method; + this.bridgedMethod = BridgeMethodResolver.findBridgedMethod(method); + ReflectionUtils.makeAccessible(this.bridgedMethod); + this.parameters = initMethodParameters(); + } + + /** + * Copy constructor for use in subclasses. + */ + protected InvocableShellMethod(InvocableShellMethod handlerMethod) { + Assert.notNull(handlerMethod, "HandlerMethod is required"); + this.bean = handlerMethod.bean; + this.beanFactory = handlerMethod.beanFactory; + this.beanType = handlerMethod.beanType; + this.method = handlerMethod.method; + this.bridgedMethod = handlerMethod.bridgedMethod; + this.parameters = handlerMethod.parameters; + this.resolvedFromHandlerMethod = handlerMethod.resolvedFromHandlerMethod; + } + + /** + * Re-create HandlerMethod with the resolved handler. + */ + private InvocableShellMethod(InvocableShellMethod handlerMethod, Object handler) { + Assert.notNull(handlerMethod, "HandlerMethod is required"); + Assert.notNull(handler, "Handler object is required"); + this.bean = handler; + this.beanFactory = handlerMethod.beanFactory; + this.beanType = handlerMethod.beanType; + this.method = handlerMethod.method; + this.bridgedMethod = handlerMethod.bridgedMethod; + this.parameters = handlerMethod.parameters; + this.resolvedFromHandlerMethod = handlerMethod; + } + + public void setValidator(Validator validator) { + this.validator = validator; + } + + /** + * Set {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} to use to use for resolving method argument values. + */ + public void setMessageMethodArgumentResolvers(ShellMethodArgumentResolverComposite argumentResolvers) { + this.resolvers = argumentResolvers; + } + + /** + * Set the ParameterNameDiscoverer for resolving parameter names when needed + * (e.g. default request attribute name). + *

Default is a {@link org.springframework.core.DefaultParameterNameDiscoverer}. + */ + public void setParameterNameDiscoverer(ParameterNameDiscoverer parameterNameDiscoverer) { + this.parameterNameDiscoverer = parameterNameDiscoverer; + } + + /** + * Invoke the method after resolving its argument values in the context of the given message. + *

Argument values are commonly resolved through + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * The {@code providedArgs} parameter however may supply argument values to be used directly, + * i.e. without argument resolution. + *

Delegates to {@link #getMethodArgumentValues} and calls {@link #doInvoke} with the + * resolved arguments. + * @param message the current message being processed + * @param providedArgs "given" arguments matched by type, not resolved + * @return the raw value returned by the invoked method + * @throws Exception raised if no suitable argument resolver can be found, + * or if the method raised an exception + * @see #getMethodArgumentValues + * @see #doInvoke + */ + @Nullable + public Object invoke(Message message, Object... providedArgs) throws Exception { + Object[] args = getMethodArgumentValues(message, providedArgs); + if (log.isTraceEnabled()) { + log.trace("Arguments: " + Arrays.toString(args)); + } + return doInvoke(args); + } + + /** + * Get the method argument values for the current message, checking the provided + * argument values and falling back to the configured argument resolvers. + *

The resulting array will be passed into {@link #doInvoke}. + */ + protected Object[] getMethodArgumentValues(Message message, Object... providedArgs) throws Exception { + ConversionService conversionService = new DefaultConversionService(); + MethodParameter[] parameters = getMethodParameters(); + if (ObjectUtils.isEmpty(parameters)) { + return EMPTY_ARGS; + } + + ResolvedHolder[] holders = new ResolvedHolder[parameters.length]; + + int unresolvedCount = 0; + for (int i = 0; i < parameters.length; i++) { + MethodParameter parameter = parameters[i]; + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + boolean supports = this.resolvers.supportsParameter(parameter); + Object arg = null; + if (supports) { + arg = this.resolvers.resolveArgument(parameter, message); + } + else { + unresolvedCount++; + } + holders[i] = new ResolvedHolder(supports, parameter, arg); + } + + Object[] args = new Object[parameters.length]; + int providedArgsIndex = 0; + for (int i = 0; i < parameters.length; i++) { + if (!holders[i].resolved) { + if (providedArgs != null && unresolvedCount <= providedArgs.length) { + if (conversionService.canConvert(providedArgs[providedArgsIndex].getClass(), holders[i].parameter.getParameterType())) { + holders[i].arg = conversionService.convert(providedArgs[providedArgsIndex], holders[i].parameter.getParameterType()); + providedArgsIndex++; + } + } + } + args[i] = holders[i].arg; + } + + return args; + } + + private static class ResolvedHolder { + boolean resolved; + MethodParameter parameter; + Object arg; + + public ResolvedHolder(boolean resolved, MethodParameter parameter, Object arg) { + this.resolved = resolved; + this.parameter = parameter; + this.arg = arg; + } + } + + /** + * Invoke the handler method with the given argument values. + */ + @Nullable + protected Object doInvoke(Object... args) throws Exception { + try { + if (validator != null) { + Method bridgedMethod = getBridgedMethod(); + Validator validator = Utils.defaultValidator(); + Set> constraintViolations = validator.forExecutables() + .validateParameters(getBean(), bridgedMethod, args); + if (constraintViolations.size() > 0) { + throw new ParameterValidationException(constraintViolations); + } + } + return getBridgedMethod().invoke(getBean(), args); + } + catch (IllegalArgumentException ex) { + assertTargetBean(getBridgedMethod(), getBean(), args); + String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument"); + throw new IllegalStateException(formatInvokeError(text, args), ex); + } + catch (InvocationTargetException ex) { + // Unwrap for HandlerExceptionResolvers ... + Throwable targetException = ex.getTargetException(); + if (targetException instanceof RuntimeException) { + throw (RuntimeException) targetException; + } + else if (targetException instanceof Error) { + throw (Error) targetException; + } + else if (targetException instanceof Exception) { + throw (Exception) targetException; + } + else { + throw new IllegalStateException(formatInvokeError("Invocation failure", args), targetException); + } + } + } + + + MethodParameter getAsyncReturnValueType(@Nullable Object returnValue) { + return new AsyncResultMethodParameter(returnValue); + } + + private MethodParameter[] initMethodParameters() { + int count = this.bridgedMethod.getParameterCount(); + MethodParameter[] result = new MethodParameter[count]; + for (int i = 0; i < count; i++) { + result[i] = new HandlerMethodParameter(i); + } + return result; + } + + /** + * Return the bean for this handler method. + */ + public Object getBean() { + return this.bean; + } + + /** + * Return the method for this handler method. + */ + public Method getMethod() { + return this.method; + } + + /** + * This method returns the type of the handler for this handler method. + *

Note that if the bean type is a CGLIB-generated class, the original + * user-defined class is returned. + */ + public Class getBeanType() { + return this.beanType; + } + + /** + * If the bean method is a bridge method, this method returns the bridged + * (user-defined) method. Otherwise it returns the same method as {@link #getMethod()}. + */ + protected Method getBridgedMethod() { + return this.bridgedMethod; + } + + /** + * Return the method parameters for this handler method. + */ + public MethodParameter[] getMethodParameters() { + return this.parameters; + } + + /** + * Return the HandlerMethod return type. + */ + public MethodParameter getReturnType() { + return new HandlerMethodParameter(-1); + } + + /** + * Return the actual return value type. + */ + public MethodParameter getReturnValueType(@Nullable Object returnValue) { + return new ReturnValueMethodParameter(returnValue); + } + + /** + * Return {@code true} if the method return type is void, {@code false} otherwise. + */ + public boolean isVoid() { + return Void.TYPE.equals(getReturnType().getParameterType()); + } + + /** + * Return a single annotation on the underlying method traversing its super methods + * if no annotation can be found on the given method itself. + *

Also supports merged composed annotations with attribute + * overrides. + * @param annotationType the type of annotation to introspect the method for + * @return the annotation, or {@code null} if none found + * @see AnnotatedElementUtils#findMergedAnnotation + */ + @Nullable + public A getMethodAnnotation(Class annotationType) { + return AnnotatedElementUtils.findMergedAnnotation(this.method, annotationType); + } + + /** + * Return whether the parameter is declared with the given annotation type. + * @param annotationType the annotation type to look for + * @see AnnotatedElementUtils#hasAnnotation + */ + public boolean hasMethodAnnotation(Class annotationType) { + return AnnotatedElementUtils.hasAnnotation(this.method, annotationType); + } + + /** + * Return the HandlerMethod from which this HandlerMethod instance was + * resolved via {@link #createWithResolvedBean()}. + */ + @Nullable + public InvocableShellMethod getResolvedFromHandlerMethod() { + return this.resolvedFromHandlerMethod; + } + + /** + * If the provided instance contains a bean name rather than an object instance, + * the bean name is resolved before a {@link HandlerMethod} is created and returned. + */ + public InvocableShellMethod createWithResolvedBean() { + Object handler = this.bean; + if (this.bean instanceof String) { + Assert.state(this.beanFactory != null, "Cannot resolve bean name without BeanFactory"); + String beanName = (String) this.bean; + handler = this.beanFactory.getBean(beanName); + } + return new InvocableShellMethod(this, handler); + } + + /** + * Return a short representation of this handler method for log message purposes. + */ + public String getShortLogMessage() { + int args = this.method.getParameterCount(); + return getBeanType().getSimpleName() + "#" + this.method.getName() + "[" + args + " args]"; + } + + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof InvocableShellMethod)) { + return false; + } + InvocableShellMethod otherMethod = (InvocableShellMethod) other; + return (this.bean.equals(otherMethod.bean) && this.method.equals(otherMethod.method)); + } + + @Override + public int hashCode() { + return (this.bean.hashCode() * 31 + this.method.hashCode()); + } + + @Override + public String toString() { + return this.method.toGenericString(); + } + + + // Support methods for use in "InvocableHandlerMethod" sub-class variants.. + + @Nullable + protected static Object findProvidedArgument(MethodParameter parameter, @Nullable Object... providedArgs) { + if (!ObjectUtils.isEmpty(providedArgs)) { + for (Object providedArg : providedArgs) { + if (parameter.getParameterType().isInstance(providedArg)) { + return providedArg; + } + } + } + return null; + } + + protected static String formatArgumentError(MethodParameter param, String message) { + return "Could not resolve parameter [" + param.getParameterIndex() + "] in " + + param.getExecutable().toGenericString() + (StringUtils.hasText(message) ? ": " + message : ""); + } + + /** + * Assert that the target bean class is an instance of the class where the given + * method is declared. In some cases the actual endpoint instance at request- + * processing time may be a JDK dynamic proxy (lazy initialization, prototype + * beans, and others). Endpoint classes that require proxying should prefer + * class-based proxy mechanisms. + */ + protected void assertTargetBean(Method method, Object targetBean, Object[] args) { + Class methodDeclaringClass = method.getDeclaringClass(); + Class targetBeanClass = targetBean.getClass(); + if (!methodDeclaringClass.isAssignableFrom(targetBeanClass)) { + String text = "The mapped handler method class '" + methodDeclaringClass.getName() + + "' is not an instance of the actual endpoint bean class '" + + targetBeanClass.getName() + "'. If the endpoint requires proxying " + + "(e.g. due to @Transactional), please use class-based proxying."; + throw new IllegalStateException(formatInvokeError(text, args)); + } + } + + protected String formatInvokeError(String text, Object[] args) { + + String formattedArgs = IntStream.range(0, args.length) + .mapToObj(i -> (args[i] != null ? + "[" + i + "] [type=" + args[i].getClass().getName() + "] [value=" + args[i] + "]" : + "[" + i + "] [null]")) + .collect(Collectors.joining(",\n", " ", " ")); + + return text + "\n" + + "Endpoint [" + getBeanType().getName() + "]\n" + + "Method [" + getBridgedMethod().toGenericString() + "] " + + "with argument values:\n" + formattedArgs; + } + + + /** + * A MethodParameter with HandlerMethod-specific behavior. + */ + protected class HandlerMethodParameter extends SynthesizingMethodParameter { + + public HandlerMethodParameter(int index) { + super(InvocableShellMethod.this.bridgedMethod, index); + } + + protected HandlerMethodParameter(HandlerMethodParameter original) { + super(original); + } + + @Override + public Class getContainingClass() { + return InvocableShellMethod.this.getBeanType(); + } + + @Override + public T getMethodAnnotation(Class annotationType) { + return InvocableShellMethod.this.getMethodAnnotation(annotationType); + } + + @Override + public boolean hasMethodAnnotation(Class annotationType) { + return InvocableShellMethod.this.hasMethodAnnotation(annotationType); + } + + @Override + public HandlerMethodParameter clone() { + return new HandlerMethodParameter(this); + } + } + + + /** + * A MethodParameter for a HandlerMethod return type based on an actual return value. + */ + private class ReturnValueMethodParameter extends HandlerMethodParameter { + + @Nullable + private final Object returnValue; + + public ReturnValueMethodParameter(@Nullable Object returnValue) { + super(-1); + this.returnValue = returnValue; + } + + protected ReturnValueMethodParameter(ReturnValueMethodParameter original) { + super(original); + this.returnValue = original.returnValue; + } + + @Override + public Class getParameterType() { + return (this.returnValue != null ? this.returnValue.getClass() : super.getParameterType()); + } + + @Override + public ReturnValueMethodParameter clone() { + return new ReturnValueMethodParameter(this); + } + } + + private class AsyncResultMethodParameter extends HandlerMethodParameter { + + @Nullable + private final Object returnValue; + + private final ResolvableType returnType; + + public AsyncResultMethodParameter(@Nullable Object returnValue) { + super(-1); + this.returnValue = returnValue; + this.returnType = ResolvableType.forType(super.getGenericParameterType()).getGeneric(); + } + + protected AsyncResultMethodParameter(AsyncResultMethodParameter original) { + super(original); + this.returnValue = original.returnValue; + this.returnType = original.returnType; + } + + @Override + public Class getParameterType() { + if (this.returnValue != null) { + return this.returnValue.getClass(); + } + if (!ResolvableType.NONE.equals(this.returnType)) { + return this.returnType.toClass(); + } + return super.getParameterType(); + } + + @Override + public Type getGenericParameterType() { + return this.returnType.getType(); + } + + @Override + public AsyncResultMethodParameter clone() { + return new AsyncResultMethodParameter(this); + } + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/invocation/ShellMethodArgumentResolverComposite.java b/spring-shell-core/src/main/java/org/springframework/shell/command/invocation/ShellMethodArgumentResolverComposite.java new file mode 100644 index 000000000..ef3afcd35 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/invocation/ShellMethodArgumentResolverComposite.java @@ -0,0 +1,143 @@ +/* + * Copyright 2022 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.invocation; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotationAwareOrderComparator; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; + +/** + * Resolves method parameters by delegating to a list of registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * Previously resolved method parameters are cached for faster lookups. + * + * @author Rossen Stoyanchev + * @author Juergen Hoeller + */ +public class ShellMethodArgumentResolverComposite implements HandlerMethodArgumentResolver { + + private final List argumentResolvers = new ArrayList<>(); + + private final Map argumentResolverCache = + new ConcurrentHashMap<>(256); + + + /** + * Add the given {@link HandlerMethodArgumentResolver}. + */ + public ShellMethodArgumentResolverComposite addResolver(HandlerMethodArgumentResolver resolver) { + this.argumentResolvers.add(resolver); + AnnotationAwareOrderComparator.sort(this.argumentResolvers); + return this; + } + + /** + * Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + * @since 4.3 + */ + public ShellMethodArgumentResolverComposite addResolvers( + @Nullable HandlerMethodArgumentResolver... resolvers) { + + if (resolvers != null) { + Collections.addAll(this.argumentResolvers, resolvers); + } + AnnotationAwareOrderComparator.sort(this.argumentResolvers); + return this; + } + + /** + * Add the given {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers}. + */ + public ShellMethodArgumentResolverComposite addResolvers( + @Nullable List resolvers) { + + if (resolvers != null) { + this.argumentResolvers.addAll(resolvers); + } + AnnotationAwareOrderComparator.sort(this.argumentResolvers); + return this; + } + + /** + * Return a read-only list with the contained resolvers, or an empty list. + */ + public List getResolvers() { + return Collections.unmodifiableList(this.argumentResolvers); + } + + /** + * Clear the list of configured resolvers and the resolver cache. + */ + public void clear() { + this.argumentResolvers.clear(); + this.argumentResolverCache.clear(); + } + + + /** + * Whether the given {@linkplain MethodParameter method parameter} is + * supported by any registered {@link HandlerMethodArgumentResolver}. + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return getArgumentResolver(parameter) != null; + } + + /** + * Iterate over registered + * {@link HandlerMethodArgumentResolver HandlerMethodArgumentResolvers} + * and invoke the one that supports it. + * @throws IllegalArgumentException if no suitable argument resolver is found + */ + @Override + @Nullable + public Object resolveArgument(MethodParameter parameter, Message message) throws Exception { + HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter); + if (resolver == null) { + throw new IllegalArgumentException("Unsupported parameter type [" + + parameter.getParameterType().getName() + "]. supportsParameter should be called first."); + } + return resolver.resolveArgument(parameter, message); + } + + /** + * Find a registered {@link HandlerMethodArgumentResolver} that supports + * the given method parameter. + */ + @Nullable + private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) { + HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter); + if (result == null) { + for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) { + if (resolver.supportsParameter(parameter)) { + result = resolver; + this.argumentResolverCache.put(parameter, result); + break; + } + } + } + return result; + } + +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/completion/CompletionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/completion/CompletionResolver.java new file mode 100644 index 000000000..1ac4053a4 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/completion/CompletionResolver.java @@ -0,0 +1,39 @@ +/* + * Copyright 2022 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.completion; + +import java.util.List; + +import org.springframework.shell.CompletionContext; +import org.springframework.shell.CompletionProposal; +import org.springframework.shell.command.CommandRegistration; + +/** + * Interface resolving completion proposals. + * + * @author Janne Valkealahti + */ +public interface CompletionResolver { + + /** + * Resolve completions. + * + * @param registration the command registration + * @param context the completion context + * @return list of resolved completions + */ + List resolve(CommandRegistration registration, CompletionContext context); +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/completion/DefaultCompletionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/completion/DefaultCompletionResolver.java new file mode 100644 index 000000000..9e691a8c3 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/completion/DefaultCompletionResolver.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 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.completion; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.shell.CompletionContext; +import org.springframework.shell.CompletionProposal; +import org.springframework.shell.command.CommandRegistration; + +/** + * Default implementation of a {@link CompletionResolver}. + * + * @author Janne Valkealahti + */ +public class DefaultCompletionResolver implements CompletionResolver { + + @Override + public List resolve(CommandRegistration registration, CompletionContext context) { + List candidates = new ArrayList<>(); + registration.getOptions().stream() + .flatMap(o -> Stream.of(o.getLongNames())) + .map(ln -> new CompletionProposal("--" + ln)) + .forEach(candidates::add); + registration.getOptions().stream() + .flatMap(o -> Stream.of(o.getShortNames())) + .map(ln -> new CompletionProposal("-" + ln)) + .forEach(candidates::add); + return candidates; + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java new file mode 100644 index 000000000..61d0cd906 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/CommandParserExceptionsExceptionResultHandler.java @@ -0,0 +1,30 @@ +package org.springframework.shell.result; + +import org.jline.terminal.Terminal; +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; + +import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException;; + +/** + * Displays command parsing errors on the terminal. + * + * @author Janne Valkealahti + */ +public class CommandParserExceptionsExceptionResultHandler extends TerminalAwareResultHandler { + + public CommandParserExceptionsExceptionResultHandler(Terminal terminal) { + super(terminal); + } + + @Override + protected void doHandleResult(CommandParserExceptionsException result) { + AttributedStringBuilder builder = new AttributedStringBuilder(); + result.getParserExceptions().stream().forEach(e -> { + builder.append(new AttributedString(e.getMessage(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED))); + builder.append("\n"); + }); + terminal.writer().append(builder.toAnsi()); + } +} diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/ParameterValidationExceptionResultHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/result/ParameterValidationExceptionResultHandler.java index 2532f0cb9..f2db45976 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/result/ParameterValidationExceptionResultHandler.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/ParameterValidationExceptionResultHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 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. @@ -13,33 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.result; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; - -import javax.validation.ElementKind; -import javax.validation.Path; - import org.jline.terminal.Terminal; import org.jline.utils.AttributedString; -import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.MethodParameter; -import org.springframework.shell.ParameterDescription; -import org.springframework.shell.ParameterResolver; import org.springframework.shell.ParameterValidationException; -import org.springframework.shell.Utils; /** * Displays validation errors on the terminal. * * @author Eric Bottard + * @author Janne Valkealahti */ public class ParameterValidationExceptionResultHandler extends TerminalAwareResultHandler { @@ -48,48 +34,15 @@ public ParameterValidationExceptionResultHandler(Terminal terminal) { super(terminal); } - @Autowired - private List parameterResolvers; - @Override protected void doHandleResult(ParameterValidationException result) { terminal.writer().println(new AttributedString("The following constraints were not met:", AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi()); result.getConstraintViolations().stream() .forEach(v -> { - Optional parameterIndex = StreamSupport.stream(v.getPropertyPath().spliterator(), false) - .filter(n -> n.getKind() == ElementKind.PARAMETER) - .map(n -> ((Path.ParameterNode) n).getParameterIndex()) - .findFirst(); - - MethodParameter methodParameter = Utils.createMethodParameter(result.getMethodTarget().getMethod(), - parameterIndex.get()); - List descriptions = findParameterResolver(methodParameter) - .describe(methodParameter).collect(Collectors.toList()); - if (descriptions.size() == 1) { - ParameterDescription description = descriptions.get(0); - AttributedStringBuilder ansi = new AttributedStringBuilder(100); - ansi.append("\t").append(description.keys().get(0), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED).bold()); - ansi.append(" ").append(description.formal(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED).underline()); - String msg = String.format(" : %s (You passed '%s')", - v.getMessage(), - String.valueOf(v.getInvalidValue()) - ); - ansi.append(msg, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)); - - terminal.writer().println(ansi.toAnsi(terminal)); - } - // Several formals for one method param, must be framework like JCommander, etc - else { - // Output toString() for now... - terminal.writer().println(new AttributedString(v.toString(), - AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi(terminal)); - } - + terminal.writer().println(new AttributedString(v.toString(), + AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi(terminal)); }); } - private ParameterResolver findParameterResolver(MethodParameter methodParameter) { - return parameterResolvers.stream().filter(pr -> pr.supports(methodParameter)).findFirst().get(); - } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java index a18f1f63a..b7127c0d5 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/ResultHandlerConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2021 the original author or authors. + * Copyright 2017-2022 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.result; import org.jline.terminal.Terminal; @@ -22,8 +21,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.shell.CommandRegistry; import org.springframework.shell.TerminalSizeAware; +import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.jline.InteractiveShellRunner; /** @@ -57,9 +56,13 @@ public ParameterValidationExceptionResultHandler parameterValidationExceptionRes } @Bean - public ThrowableResultHandler throwableResultHandler(Terminal terminal, CommandRegistry commandRegistry, - ObjectProvider interactiveApplicationRunner) { - return new ThrowableResultHandler(terminal, commandRegistry, interactiveApplicationRunner); + public CommandParserExceptionsExceptionResultHandler commandParserExceptionsExceptionResultHandler(Terminal terminal) { + return new CommandParserExceptionsExceptionResultHandler(terminal); } + @Bean + public ThrowableResultHandler throwableResultHandler(Terminal terminal, CommandCatalog commandCatalog, + ObjectProvider interactiveApplicationRunner) { + return new ThrowableResultHandler(terminal, commandCatalog, interactiveApplicationRunner); + } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/result/ThrowableResultHandler.java b/spring-shell-core/src/main/java/org/springframework/shell/result/ThrowableResultHandler.java index cbe21e718..26aba98c2 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/result/ThrowableResultHandler.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/result/ThrowableResultHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2015-2022 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,8 +22,8 @@ import org.jline.utils.AttributedStyle; import org.springframework.beans.factory.ObjectProvider; -import org.springframework.shell.CommandRegistry; import org.springframework.shell.ResultHandler; +import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.jline.InteractiveShellRunner; import org.springframework.util.StringUtils; @@ -43,14 +43,14 @@ public class ThrowableResultHandler extends TerminalAwareResultHandler interactiveRunner; - public ThrowableResultHandler(Terminal terminal, CommandRegistry commandRegistry, + public ThrowableResultHandler(Terminal terminal, CommandCatalog commandCatalog, ObjectProvider interactiveRunner) { super(terminal); - this.commandRegistry = commandRegistry; + this.commandCatalog = commandCatalog; this.interactiveRunner = interactiveRunner; } @@ -60,7 +60,7 @@ protected void doHandleResult(Throwable result) { String toPrint = StringUtils.hasLength(result.getMessage()) ? result.getMessage() : result.toString(); terminal.writer().println(new AttributedString(toPrint, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi()); - if (interactiveRunner.getIfAvailable() != null && commandRegistry.listCommands().containsKey(DETAILS_COMMAND_NAME)) { + if (interactiveRunner.getIfAvailable() != null && commandCatalog.getRegistrations().keySet().contains(DETAILS_COMMAND_NAME)) { terminal.writer().println( new AttributedStringBuilder() .append("Details of the error have been omitted. You can use the ", AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/support/AbstractArgumentMethodArgumentResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/support/AbstractArgumentMethodArgumentResolver.java new file mode 100644 index 000000000..cdf9dfd65 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/support/AbstractArgumentMethodArgumentResolver.java @@ -0,0 +1,253 @@ +/* + * Copyright 2022 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.support; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.config.BeanExpressionContext; +import org.springframework.beans.factory.config.BeanExpressionResolver; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.TypeDescriptor; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.handler.annotation.ValueConstants; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.util.ClassUtils; + +/** + * Abstract base class to resolve method arguments from a named value, e.g. + * message headers or destination variables. Named values could have one or more + * of a name, a required flag, and a default value. + * + *

Subclasses only need to define specific steps such as how to obtain named + * value details from a method parameter, how to resolve to argument values, or + * how to handle missing values. + * + *

A default value string can contain ${...} placeholders and Spring + * Expression Language {@code #{...}} expressions which will be resolved if a + * {@link ConfigurableBeanFactory} is supplied to the class constructor. + * + *

A {@link ConversionService} is used to convert a resolved String argument + * value to the expected target method parameter type. + * + * @author Janne Valkealahti + */ +public abstract class AbstractArgumentMethodArgumentResolver implements HandlerMethodArgumentResolver { + + public static final String ARGUMENT_PREFIX = "springShellArgument."; + + private final ConversionService conversionService; + + @Nullable + private final ConfigurableBeanFactory configurableBeanFactory; + + @Nullable + private final BeanExpressionContext expressionContext; + + private final Map namedValueInfoCache = new ConcurrentHashMap<>(256); + + /** + * Constructor with a {@link ConversionService} and a {@link BeanFactory}. + * @param conversionService conversion service for converting String values + * to the target method parameter type + * @param beanFactory a bean factory for resolving {@code ${...}} + * placeholders and {@code #{...}} SpEL expressions in default values + */ + protected AbstractArgumentMethodArgumentResolver(ConversionService conversionService, + @Nullable ConfigurableBeanFactory beanFactory) { + + // Fallback on shared ConversionService for now for historic reasons. + // Possibly remove after discussion in gh-23882. + + //noinspection ConstantConditions + this.conversionService = (conversionService != null ? + conversionService : DefaultConversionService.getSharedInstance()); + + this.configurableBeanFactory = beanFactory; + this.expressionContext = (beanFactory != null ? new BeanExpressionContext(beanFactory, null) : null); + } + + @Override + public Object resolveArgument(MethodParameter parameter, Message message) throws Exception { + NamedValueInfo namedValueInfo = getNamedValueInfo(parameter); + MethodParameter nestedParameter = parameter.nestedIfOptional(); + + Object arg = resolveArgumentInternal(nestedParameter, message, namedValueInfo.names); + if (arg == null) { + if (namedValueInfo.defaultValue != null) { + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); + } + else if (namedValueInfo.required && !nestedParameter.isOptional()) { + handleMissingValue(namedValueInfo.names, nestedParameter, message); + } + arg = handleNullValue(namedValueInfo.names, arg, nestedParameter.getNestedParameterType()); + } + else if ("".equals(arg) && namedValueInfo.defaultValue != null) { + arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue); + } + + if (parameter != nestedParameter || !ClassUtils.isAssignableValue(parameter.getParameterType(), arg)) { + arg = this.conversionService.convert(arg, TypeDescriptor.forObject(arg), new TypeDescriptor(parameter)); + // Check for null value after conversion of incoming argument value + if (arg == null && namedValueInfo.defaultValue == null && + namedValueInfo.required && !nestedParameter.isOptional()) { + handleMissingValue(namedValueInfo.names, nestedParameter, message); + } + } + + handleResolvedValue(arg, namedValueInfo.names, parameter, message); + + return arg; + } + + /** + * Obtain the named value for the given method parameter. + */ + private NamedValueInfo getNamedValueInfo(MethodParameter parameter) { + NamedValueInfo namedValueInfo = this.namedValueInfoCache.get(parameter); + if (namedValueInfo == null) { + namedValueInfo = createNamedValueInfo(parameter); + namedValueInfo = updateNamedValueInfo(parameter, namedValueInfo); + this.namedValueInfoCache.put(parameter, namedValueInfo); + } + return namedValueInfo; + } + + /** + * Create the {@link NamedValueInfo} object for the given method parameter. + * Implementations typically retrieve the method annotation by means of + * {@link MethodParameter#getParameterAnnotation(Class)}. + * @param parameter the method parameter + * @return the named value information + */ + protected abstract NamedValueInfo createNamedValueInfo(MethodParameter parameter); + + /** + * Fall back on the parameter name from the class file if necessary and + * replace {@link ValueConstants#DEFAULT_NONE} with null. + */ + private NamedValueInfo updateNamedValueInfo(MethodParameter parameter, NamedValueInfo info) { + List names = info.names; + if (info.names.isEmpty()) { + String name = parameter.getParameterName(); + if (name == null) { + throw new IllegalArgumentException( + "Name for argument of type [" + parameter.getNestedParameterType().getName() + + "] not specified, and parameter name information not found in class file either."); + } + names.add(name); + } + return new NamedValueInfo(names, info.required, + ValueConstants.DEFAULT_NONE.equals(info.defaultValue) ? null : info.defaultValue); + } + + /** + * Resolve the given annotation-specified value, + * potentially containing placeholders and expressions. + */ + @Nullable + private Object resolveEmbeddedValuesAndExpressions(String value) { + if (this.configurableBeanFactory == null || this.expressionContext == null) { + return value; + } + String placeholdersResolved = this.configurableBeanFactory.resolveEmbeddedValue(value); + BeanExpressionResolver exprResolver = this.configurableBeanFactory.getBeanExpressionResolver(); + if (exprResolver == null) { + return value; + } + return exprResolver.evaluate(placeholdersResolved, this.expressionContext); + } + + /** + * Resolves the given parameter type and value name into an argument value. + * @param parameter the method parameter to resolve to an argument value + * @param message the current request + * @param name the name of the value being resolved + * @return the resolved argument. May be {@code null} + * @throws Exception in case of errors + */ + @Nullable + protected abstract Object resolveArgumentInternal(MethodParameter parameter, Message message, List names) + throws Exception; + + /** + * Invoked when a value is required, but {@link #resolveArgumentInternal} + * returned {@code null} and there is no default value. Sub-classes can + * throw an appropriate exception for this case. + * @param names the name for the value + * @param parameter the target method parameter + * @param message the message being processed + */ + protected abstract void handleMissingValue(List names, MethodParameter parameter, Message message); + + /** + * One last chance to handle a possible null value. + * Specifically for booleans method parameters, use {@link Boolean#FALSE}. + * Also raise an ISE for primitive types. + */ + @Nullable + private Object handleNullValue(List name, @Nullable Object value, Class paramType) { + if (value == null) { + if (Boolean.TYPE.equals(paramType)) { + return Boolean.FALSE; + } + else if (paramType.isPrimitive()) { + throw new IllegalStateException("Optional " + paramType + " parameter '" + name + + "' is present but cannot be translated into a null value due to being " + + "declared as a primitive type. Consider declaring it as object wrapper " + + "for the corresponding primitive type."); + } + } + return value; + } + + /** + * Invoked after a value is resolved. + * @param arg the resolved argument value + * @param name the argument name + * @param parameter the argument parameter type + * @param message the message + */ + protected void handleResolvedValue( + @Nullable Object arg, List name, MethodParameter parameter, Message message) { + } + + /** + * Represents a named value declaration. + */ + protected static class NamedValueInfo { + + private final List names; + + private final boolean required; + + @Nullable + private final String defaultValue; + + protected NamedValueInfo(List names, boolean required, @Nullable String defaultValue) { + this.names = names; + this.required = required; + this.defaultValue = defaultValue; + } + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java b/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java deleted file mode 100644 index 88dbcd60f..000000000 --- a/spring-shell-core/src/test/java/org/springframework/shell/ConfigurableCommandRegistryTest.java +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2017 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; - -import org.junit.jupiter.api.Test; - -import org.springframework.shell.context.DefaultShellContext; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Unit tests for {@link ConfigurableCommandRegistry}. - * - * @author Eric Bottard - */ -public class ConfigurableCommandRegistryTest { - - @Test - public void testRegistration() { - ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(new DefaultShellContext()); - registry.register("foo", MethodTarget.of("toString", this, new Command.Help("some command"))); - assertThat(registry.listCommands()).containsKeys("foo"); - } - - @Test - public void testDoubleRegistration() { - ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(new DefaultShellContext()); - registry.register("foo", MethodTarget.of("toString", this, new Command.Help("some command"))); - - assertThatThrownBy(() -> { - registry.register("foo", MethodTarget.of("hashCode", this, new Command.Help("some command"))); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("foo") - .hasMessageContaining("toString") - .hasMessageContaining("hashCode"); - } -} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java b/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java similarity index 56% rename from spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java rename to spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java index 66d5e993f..856937351 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/ShellTest.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 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. @@ -13,12 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell; import java.io.IOException; import java.util.Arrays; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -31,6 +29,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.completion.CompletionResolver; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; @@ -44,7 +46,7 @@ * @author Eric Bottard */ @ExtendWith(MockitoExtension.class) -public class ShellTest { +public class ShellTests { @Mock private InputProvider inputProvider; @@ -53,12 +55,10 @@ public class ShellTest { ResultHandlerService resultHandlerService; @Mock - CommandRegistry commandRegistry; + CommandCatalog commandRegistry; @Mock - private ParameterResolver parameterResolver; - - private ValueResult valueResult; + private CompletionResolver completionResolver; @InjectMocks private Shell shell; @@ -67,26 +67,30 @@ public class ShellTest { @BeforeEach public void setUp() { - shell.parameterResolvers = Arrays.asList(parameterResolver); + shell.setCompletionResolvers(Arrays.asList(completionResolver)); } @Test public void commandMatch() throws IOException { - when(parameterResolver.supports(any())).thenReturn(true); when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?"); - valueResult = new ValueResult(null, "test"); - when(parameterResolver.resolve(any(), any())).thenReturn(valueResult); doThrow(new Exit()).when(resultHandlerService).handle(any()); - when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("hello world", - MethodTarget.of("helloWorld", this, new Command.Help("Say hello")))); + CommandRegistration registration = CommandRegistration.builder() + .command("hello world") + .withTarget() + .method(this, "helloWorld") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration); + when(commandRegistry.getRegistrations()).thenReturn(registrations); try { shell.run(inputProvider); fail("Exit expected"); } catch (Exit expected) { - + System.out.println(expected); } assertThat(invoked).isTrue(); @@ -97,8 +101,15 @@ public void commandNotFound() throws IOException { when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?"); doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class)); - when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("bonjour", - MethodTarget.of("helloWorld", this, new Command.Help("Say hello")))); + CommandRegistration registration = CommandRegistration.builder() + .command("bonjour") + .withTarget() + .method(this, "helloWorld") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration); + when(commandRegistry.getRegistrations()).thenReturn(registrations); try { shell.run(inputProvider); @@ -115,8 +126,15 @@ public void commandNotFoundPrefix() throws IOException { when(inputProvider.readInput()).thenReturn(() -> "helloworld how are you doing ?"); doThrow(new Exit()).when(resultHandlerService).handle(isA(CommandNotFound.class)); - when(commandRegistry.listCommands()).thenReturn( - Collections.singletonMap("hello", MethodTarget.of("helloWorld", this, new Command.Help("Say hello")))); + CommandRegistration registration = CommandRegistration.builder() + .command("hello world") + .withTarget() + .method(this, "helloWorld") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration); + when(commandRegistry.getRegistrations()).thenReturn(registrations); try { shell.run(inputProvider); @@ -129,14 +147,18 @@ public void commandNotFoundPrefix() throws IOException { @Test public void noCommand() throws IOException { - when(parameterResolver.supports(any())).thenReturn(true); when(inputProvider.readInput()).thenReturn(() -> "", () -> "hello world how are you doing ?", null); - valueResult = new ValueResult(null, "test"); - when(parameterResolver.resolve(any(), any())).thenReturn(valueResult); doThrow(new Exit()).when(resultHandlerService).handle(any()); - when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("hello world", - MethodTarget.of("helloWorld", this, new Command.Help("Say hello")))); + CommandRegistration registration = CommandRegistration.builder() + .command("hello world") + .withTarget() + .method(this, "helloWorld") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration); + when(commandRegistry.getRegistrations()).thenReturn(registrations); try { shell.run(inputProvider); @@ -154,8 +176,16 @@ public void commandThrowingAnException() throws IOException { when(inputProvider.readInput()).thenReturn(() -> "fail"); doThrow(new Exit()).when(resultHandlerService).handle(isA(SomeException.class)); - when(commandRegistry.listCommands()).thenReturn(Collections.singletonMap("fail", - MethodTarget.of("failing", this, new Command.Help("Will throw an exception")))); + CommandRegistration registration = CommandRegistration.builder() + .command("fail") + .withTarget() + .method(this, "failing") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("fail", registration); + when(commandRegistry.getRegistrations()).thenReturn(registrations); + try { shell.run(inputProvider); @@ -177,17 +207,27 @@ public void comments() throws IOException { @Test public void commandNameCompletion() throws Exception { - Map methodTargets = new HashMap<>(); - methodTargets.put("hello world", MethodTarget.of("helloWorld", this, new Command.Help("hellow world"))); - methodTargets.put("another command", MethodTarget.of("helloWorld", this, new Command.Help("another command"))); - when(parameterResolver.supports(any())).thenReturn(true); - when(commandRegistry.listCommands()).thenReturn(methodTargets); + CommandRegistration registration1 = CommandRegistration.builder() + .command("hello world") + .withTarget() + .method(this, "helloWorld") + .and() + .build(); + CommandRegistration registration2 = CommandRegistration.builder() + .command("another command") + .withTarget() + .method(this, "helloWorld") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration1); + registrations.put("another command", registration2); + when(commandRegistry.getRegistrations()).thenReturn(registrations); // Invoke at very start List proposals = shell.complete(new CompletionContext(Arrays.asList(""), 0, "".length())) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactlyInAnyOrder("another command", "hello world"); - // assertThat(proposals).containsExactly("another command", "hello world"); // Invoke in middle of first word proposals = shell.complete(new CompletionContext(Arrays.asList("hel"), 0, "hel".length())) @@ -231,6 +271,51 @@ private String failing() { throw new SomeException(); } + @Test + public void completionArgWithMethod() throws Exception { + when(completionResolver.resolve(any(), any())).thenReturn(Arrays.asList(new CompletionProposal("--arg1"))); + CommandRegistration registration1 = CommandRegistration.builder() + .command("hello world") + .withTarget() + .method(this, "helloWorld") + .and() + .withOption() + .longNames("arg1") + .description("arg1 desc") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration1); + when(commandRegistry.getRegistrations()).thenReturn(registrations); + + List proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length())) + .stream().map(CompletionProposal::value).collect(Collectors.toList()); + assertThat(proposals).containsExactlyInAnyOrder("--arg1"); + } + + @Test + public void completionArgWithFunction() throws Exception { + when(completionResolver.resolve(any(), any())).thenReturn(Arrays.asList(new CompletionProposal("--arg1"))); + CommandRegistration registration1 = CommandRegistration.builder() + .command("hello world") + .withTarget() + .function(ctx -> { + return null; + }) + .and() + .withOption() + .longNames("arg1") + .description("arg1 desc") + .and() + .build(); + Map registrations = new HashMap<>(); + registrations.put("hello world", registration1); + when(commandRegistry.getRegistrations()).thenReturn(registrations); + + List proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length())) + .stream().map(CompletionProposal::value).collect(Collectors.toList()); + assertThat(proposals).containsExactlyInAnyOrder("--arg1"); + } private static class Exit extends RuntimeException { } @@ -238,6 +323,4 @@ private static class Exit extends RuntimeException { private static class SomeException extends RuntimeException { } - - } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/UtilsTest.java b/spring-shell-core/src/test/java/org/springframework/shell/UtilsTest.java deleted file mode 100644 index 9d8c542d2..000000000 --- a/spring-shell-core/src/test/java/org/springframework/shell/UtilsTest.java +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2015 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; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link Utils}. - * - * @author Eric Bottard - */ -public class UtilsTest { - - @Test - public void testUnCamelify() throws Exception { - assertThat(Utils.unCamelify("HelloWorld")).isEqualTo("hello-world"); - assertThat(Utils.unCamelify("helloWorld")).isEqualTo("hello-world"); - assertThat(Utils.unCamelify("helloWorldHowAreYou")).isEqualTo("hello-world-how-are-you"); - assertThat(Utils.unCamelify("URL")).isEqualTo("url"); - } -} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/UtilsTests.java b/spring-shell-core/src/test/java/org/springframework/shell/UtilsTests.java new file mode 100644 index 000000000..e63d1b841 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/UtilsTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2015-2022 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; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link Utils}. + * + * @author Eric Bottard + */ +public class UtilsTests { + + @Test + public void testUnCamelify() throws Exception { + assertThat(Utils.unCamelify("HelloWorld")).isEqualTo("hello-world"); + assertThat(Utils.unCamelify("helloWorld")).isEqualTo("hello-world"); + assertThat(Utils.unCamelify("helloWorldHowAreYou")).isEqualTo("hello-world-how-are-you"); + assertThat(Utils.unCamelify("URL")).isEqualTo("url"); + } + + @Test + public void testSplit() { + Predicate predicate = t -> t.startsWith("-"); + List> split = null; + + split = Utils.split(new String[] { "-a1", "a1" }, predicate); + assertThat(split).containsExactly(Arrays.asList("-a1", "a1")); + + split = Utils.split(new String[] { "-a1", "a1", "-a2", "a2" }, predicate); + assertThat(split).containsExactly(Arrays.asList("-a1", "a1"), Arrays.asList("-a2", "a2")); + + split = Utils.split(new String[] { "a0", "-a1", "a1" }, predicate); + assertThat(split).containsExactly(Arrays.asList("a0"), Arrays.asList("-a1", "a1")); + + split = Utils.split(new String[] { "-a1", "-a2" }, predicate); + assertThat(split).containsExactly(Arrays.asList("-a1"), Arrays.asList("-a2")); + + split = Utils.split(new String[] { "a1", "a2" }, predicate); + assertThat(split).containsExactly(Arrays.asList("a1", "a2")); + + split = Utils.split(new String[] { "-a1", "a1", "a2" }, predicate); + assertThat(split).containsExactly(Arrays.asList("-a1", "a1", "a2")); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/AbstractCommandTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/AbstractCommandTests.java new file mode 100644 index 000000000..2f98939f7 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/AbstractCommandTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2022 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; + +import java.util.function.Function; + +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.messaging.handler.annotation.Header; + +public abstract class AbstractCommandTests { + + protected Pojo1 pojo1; + + protected Function function1 = ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return "hi" + arg1; + }; + + protected Function function2 = ctx -> { + return null; + }; + + @BeforeEach + public void setupAbstractCommandTests() { + pojo1 = new Pojo1(); + } + + protected static class Pojo1 { + + public int method1Count; + public CommandContext method1Ctx; + public int method2Count; + public int method3Count; + public int method4Count; + public String method4Arg1; + public Boolean method5ArgA; + public Boolean method5ArgB; + public Boolean method5ArgC; + public int method6Count; + public String method6Arg1; + public String method6Arg2; + public String method6Arg3; + public int method7Count; + public int method7Arg1; + public int method7Arg2; + public int method7Arg3; + public int method8Count; + public float[] method8Arg1; + + public void method1(CommandContext ctx) { + method1Ctx = ctx; + method1Count++; + } + + public String method2() { + method2Count++; + return "hi"; + } + + public String method3(@Header("arg1") String arg1) { + method3Count++; + return "hi" + arg1; + } + + public String method4(String arg1) { + method4Arg1 = arg1; + method4Count++; + return "hi" + arg1; + } + + public void method5(@Header("a") boolean a, @Header("b") boolean b, @Header("c") boolean c) { + method5ArgA = a; + method5ArgB = b; + method5ArgC = c; + } + + public String method6(String arg1, String arg2, String arg3) { + method6Arg1 = arg1; + method6Arg2 = arg2; + method6Arg3 = arg3; + method6Count++; + return "hi" + arg1 + arg2 + arg3; + } + + public void method7(int arg1, int arg2, int arg3) { + method7Arg1 = arg1; + method7Arg2 = arg2; + method7Arg3 = arg3; + method7Count++; + } + + public void method8(float[] arg1) { + method8Arg1 = arg1; + method8Count++; + } + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandCatalogTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandCatalogTests.java new file mode 100644 index 000000000..813627c04 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandCatalogTests.java @@ -0,0 +1,72 @@ +/* + * Copyright 2022 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; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CommandCatalogTests extends AbstractCommandTests { + + @Test + public void testCommandCatalog () { + CommandRegistration r1 = CommandRegistration.builder() + .command("group1 sub1") + .withTarget() + .function(function1) + .and() + .build(); + CommandCatalog catalog = CommandCatalog.of(); + catalog.register(r1); + assertThat(catalog.getRegistrations()).hasSize(1); + catalog.unregister(r1); + assertThat(catalog.getRegistrations()).hasSize(0); + } + + @Test + public void testResolver() { + // catalog itself would not have any registered command but + // this custom resolver adds one which may dymanically go away. + DynamicCommandResolver resolver = new DynamicCommandResolver(); + CommandCatalog catalog = CommandCatalog.of(Arrays.asList(resolver), null); + assertThat(catalog.getRegistrations()).hasSize(1); + resolver.enabled = false; + assertThat(catalog.getRegistrations()).hasSize(0); + } + + class DynamicCommandResolver implements CommandResolver { + CommandRegistration r1 = CommandRegistration.builder() + .command("group1 sub1") + .withTarget() + .function(function1) + .and() + .build(); + boolean enabled = true; + + @Override + public List resolve() { + List regs = new ArrayList<>(); + if (enabled) { + regs.add(r1); + } + return regs; + } + } +} 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 new file mode 100644 index 000000000..811b1a45f --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandExecutionTests.java @@ -0,0 +1,441 @@ +/* + * Copyright 2022 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; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver; +import org.springframework.shell.command.CommandExecution.CommandParserExceptionsException; +import org.springframework.shell.command.CommandRegistration.OptionArity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class CommandExecutionTests extends AbstractCommandTests { + + private CommandExecution execution; + + @BeforeEach + public void setupCommandExecutionTests() { + List resolvers = new ArrayList<>(); + resolvers.add(new ArgumentHeaderMethodArgumentResolver(new DefaultConversionService(), null)); + resolvers.add(new CommandContextMethodArgumentResolver()); + execution = CommandExecution.of(resolvers); + } + + @Test + public void testFunctionExecution() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withTarget() + .function(function1) + .and() + .build(); + Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"}); + assertThat(result).isEqualTo("himyarg1value"); + } + + @Test + public void testMethodExecution1() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withTarget() + .method(pojo1, "method3", String.class) + .and() + .build(); + Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"}); + assertThat(result).isEqualTo("himyarg1value"); + assertThat(pojo1.method3Count).isEqualTo(1); + } + + @Test + public void testMethodExecution2() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withTarget() + .method(pojo1, "method1") + .and() + .build(); + execution.evaluate(r1, new String[]{"--arg1", "myarg1value"}); + assertThat(pojo1.method1Count).isEqualTo(1); + assertThat(pojo1.method1Ctx).isNotNull(); + } + + @Test + public void testMethodSinglePositionalArgs() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .position(0) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withTarget() + .method(pojo1, "method4") + .and() + .build(); + execution.evaluate(r1, new String[]{"myarg1value"}); + assertThat(pojo1.method4Count).isEqualTo(1); + assertThat(pojo1.method4Arg1).isEqualTo("myarg1value"); + } + + @Test + public void testMethodSingleWithNamedArgs() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .and() + .withTarget() + .method(pojo1, "method4") + .and() + .build(); + Object result = execution.evaluate(r1, new String[]{"--arg1", "myarg1value"}); + assertThat(pojo1.method4Count).isEqualTo(1); + assertThat(pojo1.method4Arg1).isEqualTo("myarg1value"); + assertThat(result).isEqualTo("himyarg1value"); + } + + @Test + public void testMethodMultiPositionalArgs() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .position(0) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withTarget() + .method(pojo1, "method4") + .and() + .build(); + execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"}); + assertThat(pojo1.method4Count).isEqualTo(1); + assertThat(pojo1.method4Arg1).isEqualTo("myarg1value1"); + } + + @Test + public void testMethodMultiPositionalArgsAll() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .position(0) + .arity(OptionArity.ONE_OR_MORE) + .and() + .withTarget() + .method(pojo1, "method4") + .and() + .build(); + execution.evaluate(r1, new String[]{"myarg1value1", "myarg1value2"}); + assertThat(pojo1.method4Count).isEqualTo(1); + assertThat(pojo1.method4Arg1).isEqualTo("myarg1value1 myarg1value2"); + } + + @Test + public void testMethodMultipleArgs() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withOption() + .longNames("arg2") + .description("some arg2") + .and() + .withOption() + .longNames("arg3") + .description("some arg3") + .and() + .withTarget() + .method(pojo1, "method6") + .and() + .build(); + + execution.evaluate(r1, new String[]{"--arg1", "myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value"}); + assertThat(pojo1.method6Count).isEqualTo(1); + assertThat(pojo1.method6Arg1).isEqualTo("myarg1value"); + assertThat(pojo1.method6Arg2).isEqualTo("myarg2value"); + assertThat(pojo1.method6Arg3).isEqualTo("myarg3value"); + } + + + @Test + public void testMethodMultipleIntArgs() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withOption() + .longNames("arg2") + .description("some arg2") + .and() + .withOption() + .longNames("arg3") + .description("some arg3") + .and() + .withTarget() + .method(pojo1, "method7") + .and() + .build(); + + execution.evaluate(r1, new String[]{"--arg1", "1", "--arg2", "2", "--arg3", "3"}); + assertThat(pojo1.method7Count).isEqualTo(1); + assertThat(pojo1.method7Arg1).isEqualTo(1); + assertThat(pojo1.method7Arg2).isEqualTo(2); + assertThat(pojo1.method7Arg3).isEqualTo(3); + } + + @Test + public void testMethodMultiplePositionalStringArgs() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .position(0) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withOption() + .longNames("arg2") + .description("some arg2") + .position(1) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withOption() + .longNames("arg3") + .description("some arg3") + .position(2) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withTarget() + .method(pojo1, "method6") + .and() + .build(); + + execution.evaluate(r1, new String[]{"myarg1value", "myarg2value", "myarg3value"}); + assertThat(pojo1.method6Count).isEqualTo(1); + assertThat(pojo1.method6Arg1).isEqualTo("myarg1value"); + assertThat(pojo1.method6Arg2).isEqualTo("myarg2value"); + assertThat(pojo1.method6Arg3).isEqualTo("myarg3value"); + } + + @ParameterizedTest + @ValueSource(strings = { + "myarg1value --arg2 myarg2value --arg3 myarg3value", + "--arg1 myarg1value myarg2value --arg3 myarg3value", + "--arg1 myarg1value --arg2 myarg2value myarg3value" + }) + public void testMethodMultiplePositionalStringArgsMixed(String arg) { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .position(0) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withOption() + .longNames("arg2") + .description("some arg2") + .position(1) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withOption() + .longNames("arg3") + .description("some arg3") + .position(2) + .arity(OptionArity.EXACTLY_ONE) + .and() + .withTarget() + .method(pojo1, "method6") + .and() + .build(); + String[] args = arg.split(" "); + // execution.evaluate(r1, new String[]{"myarg1value", "--arg2", "myarg2value", "--arg3", "myarg3value"}); + execution.evaluate(r1, args); + assertThat(pojo1.method6Count).isEqualTo(1); + assertThat(pojo1.method6Arg1).isEqualTo("myarg1value"); + assertThat(pojo1.method6Arg2).isEqualTo("myarg2value"); + assertThat(pojo1.method6Arg3).isEqualTo("myarg3value"); + } + + @Test + public void testShortCombinedWithoutValue() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('a') + .description("short arg a") + .type(boolean.class) + .and() + .withOption() + .shortNames('b') + .description("short arg b") + .type(boolean.class) + .and() + .withOption() + .shortNames('c') + .description("short arg c") + .type(boolean.class) + .and() + .withTarget() + .method(pojo1, "method5") + .and() + .build(); + execution.evaluate(r1, new String[]{"-abc"}); + assertThat(pojo1.method5ArgA).isTrue(); + assertThat(pojo1.method5ArgB).isTrue(); + assertThat(pojo1.method5ArgC).isTrue(); + } + + @Test + public void testShortCombinedSomeHavingValue() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('a') + .description("short arg a") + .type(boolean.class) + .and() + .withOption() + .shortNames('b') + .description("short arg b") + .type(boolean.class) + .and() + .withOption() + .shortNames('c') + .description("short arg c") + .type(boolean.class) + .and() + .withTarget() + .method(pojo1, "method5") + .and() + .build(); + execution.evaluate(r1, new String[]{"-ac", "-b", "false"}); + assertThat(pojo1.method5ArgA).isTrue(); + assertThat(pojo1.method5ArgB).isFalse(); + assertThat(pojo1.method5ArgC).isTrue(); + } + + @Test + public void testFloatArrayOne() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .type(float[].class) + .and() + .withTarget() + .method(pojo1, "method8") + .and() + .build(); + execution.evaluate(r1, new String[]{"--arg1", "0.1"}); + assertThat(pojo1.method8Count).isEqualTo(1); + assertThat(pojo1.method8Arg1).isEqualTo(new float[]{0.1f}); + } + + @Test + public void testFloatArrayTwo() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .type(float[].class) + .and() + .withTarget() + .method(pojo1, "method8") + .and() + .build(); + execution.evaluate(r1, new String[]{"--arg1", "0.1", "0.2"}); + assertThat(pojo1.method8Count).isEqualTo(1); + assertThat(pojo1.method8Arg1).isEqualTo(new float[]{0.1f, 0.2f}); + } + + @Test + public void testDefaultValueAsNull() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .withOption() + .longNames("arg1") + .and() + .withTarget() + .method(pojo1, "method4") + .and() + .build(); + execution.evaluate(r1, new String[]{}); + assertThat(pojo1.method4Count).isEqualTo(1); + assertThat(pojo1.method4Arg1).isNull(); + } + + @Test + public void testRequiredArg() { + CommandRegistration r1 = CommandRegistration.builder() + .command("command1") + .withOption() + .longNames("arg1") + .required() + .and() + .withTarget() + .method(pojo1, "method4") + .and() + .build(); + + assertThatThrownBy(() -> { + execution.evaluate(r1, new String[]{}); + }).isInstanceOf(CommandParserExceptionsException.class); + } +} 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 new file mode 100644 index 000000000..a15685ec2 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java @@ -0,0 +1,355 @@ +/* + * Copyright 2022 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; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.shell.command.CommandParser.CommandParserResults; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CommandParserTests extends AbstractCommandTests { + + private CommandParser parser; + + @BeforeEach + public void setupCommandParserTests() { + parser = CommandParser.of(); + } + + @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); + 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("foo1 foo2"); + assertThat(results.results().get(1).option()).isSameAs(option2); + assertThat(results.results().get(1).value()).isEqualTo("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); + assertThat(results.results().get(0).value()).isEqualTo("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 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 String[] { "1", "2" }); + } + + @Test + public void testMapPositionalArgs1() { + 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("1"); + assertThat(results.results().get(1).value()).isEqualTo("2"); + } + + @Test + public void testMapPositionalArgs2() { + CommandOption option1 = longOption("arg1", 0, 1, 1); + CommandOption option2 = longOption("arg2", 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"); + } + + 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); + } + + 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); + } +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java new file mode 100644 index 000000000..898963408 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java @@ -0,0 +1,357 @@ +/* + * Copyright 2022 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; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ResolvableType; +import org.springframework.shell.command.CommandRegistration.OptionArity; +import org.springframework.shell.command.CommandRegistration.TargetInfo.TargetType; +import org.springframework.shell.context.InteractionMode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class CommandRegistrationTests extends AbstractCommandTests { + + @Test + public void testCommandMustBeSet() { + assertThatThrownBy(() -> { + CommandRegistration.builder().build(); + }).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("command cannot be empty"); + } + + @Test + public void testBasics() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getGroup()).isNull(); + assertThat(registration.getInteractionMode()).isEqualTo(InteractionMode.ALL); + + registration = CommandRegistration.builder() + .command("command1") + .interactionMode(InteractionMode.NONINTERACTIVE) + .group("fakegroup") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getInteractionMode()).isEqualTo(InteractionMode.NONINTERACTIVE); + assertThat(registration.getGroup()).isEqualTo("fakegroup"); + } + + @Test + public void testCommandStructures() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + + registration = CommandRegistration.builder() + .command("command1", "command2") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1 command2"); + + registration = CommandRegistration.builder() + .command("command1 command2") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1 command2"); + + registration = CommandRegistration.builder() + .command(" command1 command2 ") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1 command2"); + } + + @Test + public void testFunctionRegistration() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getTarget().getTargetType()).isEqualTo(TargetType.FUNCTION); + assertThat(registration.getTarget().getFunction()).isNotNull(); + assertThat(registration.getTarget().getBean()).isNull(); + assertThat(registration.getTarget().getMethod()).isNull(); + } + + @Test + public void testConsumerRegistration() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withTarget() + .consumer(ctx -> {}) + .and() + .build(); + assertThat(registration.getTarget().getTargetType()).isEqualTo(TargetType.CONSUMER); + assertThat(registration.getTarget().getFunction()).isNull(); + assertThat(registration.getTarget().getConsumer()).isNotNull(); + assertThat(registration.getTarget().getBean()).isNull(); + assertThat(registration.getTarget().getMethod()).isNull(); + } + + @Test + public void testMethodRegistration() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withTarget() + .method(pojo1, "method3", String.class) + .and() + .build(); + assertThat(registration.getTarget().getTargetType()).isEqualTo(TargetType.METHOD); + assertThat(registration.getTarget().getFunction()).isNull(); + assertThat(registration.getTarget().getBean()).isNotNull(); + assertThat(registration.getTarget().getMethod()).isNotNull(); + } + + @Test + public void testCanUseOnlyOneTarget() { + assertThatThrownBy(() -> { + CommandRegistration.builder() + .command("command1") + .withTarget() + .method(pojo1, "method3", String.class) + .function(function1) + .and() + .build(); + }).isInstanceOf(IllegalStateException.class).hasMessageContaining("only one target can exist"); + } + + @Test + public void testSimpleFullRegistrationWithFunction() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getHelp()).isEqualTo("help"); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getLongNames()).containsExactly("arg1"); + } + + @Test + public void testSimpleFullRegistrationWithMethod() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .longNames("arg1") + .description("some arg1") + .and() + .withTarget() + .method(pojo1, "method3", String.class) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getHelp()).isEqualTo("help"); + assertThat(registration.getOptions()).hasSize(1); + } + + @Test + public void testOptionWithType() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('v') + .type(boolean.class) + .description("some arg1") + .and() + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getHelp()).isEqualTo("help"); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getShortNames()).containsExactly('v'); + assertThat(registration.getOptions().get(0).getType()).isEqualTo(ResolvableType.forType(boolean.class)); + } + + @Test + public void testOptionWithRequired() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('v') + .type(boolean.class) + .description("some arg1") + .required(true) + .and() + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getHelp()).isEqualTo("help"); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getShortNames()).containsExactly('v'); + assertThat(registration.getOptions().get(0).isRequired()).isTrue(); + + registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('v') + .type(boolean.class) + .description("some arg1") + .required(false) + .and() + .withTarget() + .function(function1) + .and() + .build(); + + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).isRequired()).isFalse(); + + registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('v') + .type(boolean.class) + .description("some arg1") + .and() + .withTarget() + .function(function1) + .and() + .build(); + + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).isRequired()).isFalse(); + + registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('v') + .type(boolean.class) + .description("some arg1") + .required() + .and() + .withTarget() + .function(function1) + .and() + .build(); + + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).isRequired()).isTrue(); + } + + @Test + public void testOptionWithDefaultValue() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .help("help") + .withOption() + .shortNames('v') + .type(boolean.class) + .description("some arg1") + .defaultValue("defaultValue") + .and() + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getHelp()).isEqualTo("help"); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getDefaultValue()).isEqualTo("defaultValue"); + } + + @Test + public void testOptionWithPositionValue() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withOption() + .longNames("arg1") + .position(1) + .and() + .withOption() + .longNames("arg2") + .and() + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getCommand()).isEqualTo("command1"); + assertThat(registration.getOptions()).hasSize(2); + assertThat(registration.getOptions().get(0).getPosition()).isEqualTo(1); + assertThat(registration.getOptions().get(1).getPosition()).isEqualTo(-1); + } + + @Test + public void testArityViaInts() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withOption() + .longNames("arg1") + .arity(0, 0) + .and() + .withTarget() + .consumer(ctx -> {}) + .and() + .build(); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0); + assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0); + } + + @Test + public void testArityViaEnum() { + CommandRegistration registration = CommandRegistration.builder() + .command("command1") + .withOption() + .longNames("arg1") + .arity(OptionArity.ZERO) + .and() + .withTarget() + .consumer(ctx -> {}) + .and() + .build(); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getArityMin()).isEqualTo(0); + assertThat(registration.getOptions().get(0).getArityMax()).isEqualTo(0); + } +} diff --git a/spring-shell-docs/pom.xml b/spring-shell-docs/pom.xml index a63f6301a..067d88d56 100644 --- a/spring-shell-docs/pom.xml +++ b/spring-shell-docs/pom.xml @@ -128,6 +128,22 @@ + + copy-asciidoc-snippets + generate-resources + + copy-resources + + + ${project.build.directory}/refdocs/snippets/ + + + src/test/java/org/springframework/shell/docs + false + + + + @@ -223,6 +239,7 @@ ${project.version} ${project.name} ${project.version} + ${project.build.directory}/refdocs/snippets/ true 4 true diff --git a/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-commandcatalog.adoc b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-commandcatalog.adoc new file mode 100644 index 000000000..37551b841 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-commandcatalog.adoc @@ -0,0 +1,46 @@ +=== Command Catalog +`CommandCatalog` is an interface defining how command registrations exists in +a shell application. It is possible to dynamically register and de-register +commands which gives flexibility for a user cases where possible commands will +come and go depending on a states shell is at. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandCatalogSnippets.java[tag=snippet1] +---- +==== + +==== Command Resolver +`CommandResolver` is an interface you can implement and define as a bean to dynamically +resolve mappings from a command names to its `CommandRegistration` instances. Its use +case looks something like: + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandCatalogSnippets.java[tag=snippet2] +---- +==== + +[IMPORTANT] +==== +Current limitation of a `CommandResolver` is that it is used every time commands are resolved. +Thus it's adviced not to use it if command resolve call takes a long time as it would +make shell feel sluggish. +==== + +==== Command Catalog Customizer +`CommandCatalogCustomizer` is an interface which can be used to customize a `CommandCatalog`. +Its main use case is to modify catalog and within `spring-shell` _auto-configuration_ this +interface is used to register existing `CommandRegistration` beans into a catalog. +Its use case looks something like: + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandCatalogSnippets.java[tag=snippet3] +---- +==== + +Create `CommandCatalogCustomizer` as a bean and `spring-shell` will handle rest. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-commandcontext.adoc b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-commandcontext.adoc new file mode 100644 index 000000000..d153d6fab --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-commandcontext.adoc @@ -0,0 +1,20 @@ +=== Command Context +`CommandContext` is an interface which gives access to a currently executing +context. It can be used to get access to options: + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandContextSnippets.java[tag=snippet1] +---- +==== + +If you need to print something into a shell you can get `Terminal` +and use its writer to print something: + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandContextSnippets.java[tag=snippet2] +---- +==== diff --git a/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-execution.adoc b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-execution.adoc new file mode 100644 index 000000000..eb9792940 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-execution.adoc @@ -0,0 +1,3 @@ +=== Command Execution +When _command parsing_ has done its job togethere with resolving _command registration_, execution +will do the hard work and execute a real user level code. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-parser.adoc b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-parser.adoc new file mode 100644 index 000000000..cab26092d --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-parser.adoc @@ -0,0 +1,3 @@ +=== Command Parser +Before a command can be executed we need to parse commands and options provided by a user. Parsing +sits between _command registration_ and _command execution_. diff --git a/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-registration.adoc b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-registration.adoc new file mode 100644 index 000000000..adf39761f --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro-registration.adoc @@ -0,0 +1,106 @@ +[#appendix-tech-intro-registration] +=== Command Registration +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] + +Defining a command registation is a first step to introduce a structure of a commands and its options +and parameters. This is loosely decoupled what happens later like parsing command-line and executing +actual target code. Essentially it is a definition of an command API shown to a user. + +==== Commands +Command in a `spring-shell` structure is defined as an array of commands. This will give +you something like: + +==== +[source, bash] +---- +command1 sub1 +command2 sub1 subsub1 +command2 sub2 subsub1 +command2 sub2 subsub2 +---- +==== + +[NOTE] +==== +We don't currently support mapping commands to explicit parent if sub-commands are defined. +For example there can't be `command1 sub1` and `command1 sub1 subsub1` registered. +==== + +==== Interaction Mode +Spring Shell has been designed to work on two modes one being interactive which essentially +is a `REPL` where you have an active shell instance throughout commands and secondly +non-interactive mode where commands are executed one by one from a command line. + +Differentation between these modes are mostly around limitations what can be done +in each mode as for example it would not be feasible to show what was a previous stacktrace +of a command if shell is not alive anymore and generally things around information +if shell is alive or not. + +Also being on an active `REPL` session may provide more info about what user has been +doing within an active session. + +==== Options +Options can be defined as long and short where prefixing is `--` and `-` respectively. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandRegistrationSnippets.java[tag=snippet1] +---- +==== + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandRegistrationSnippets.java[tag=snippet2] +---- +==== + +==== Target +Target defines what is an execution target of a command. It can be a _method_ in a `POJO`, +`Consumer` or `Function`. + +===== Method +Using a `Method` is a way to define target as a method in an existing pojo. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandTargetSnippets.java[tag=snippet11] +---- +==== + +Having existing class shown above you can then register its method. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandTargetSnippets.java[tag=snippet12] +---- +==== + +===== Function +Using a `Function` as a target gives a lot of flexibility to handle what +happens in a command execution as you can handle many things manually using +a `CommandContext` given to a `Function`. Return type from a `Function` is +then what gets printed into a shell as a result. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandTargetSnippets.java[tag=snippet2] +---- +==== + +===== Consumer +Using a `Consumer` is basically same as `Function` with difference being +that there is not return type. If you need to print something into a shell +you can get a reference to a `Terminal` from a context and print something +through it. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandTargetSnippets.java[tag=snippet3] +---- +==== diff --git a/spring-shell-docs/src/main/asciidoc/appendices-techical-intro.adoc b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro.adoc new file mode 100644 index 000000000..89900e2c0 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices-techical-intro.adoc @@ -0,0 +1,15 @@ +[appendix] +[#appendix-tech-intro] +== Techical Introduction +This section contains information for a developers and others who would like to know more about how _spring-shell_ +internally works and what are its design decisions. + +include::appendices-techical-intro-registration.adoc[] + +include::appendices-techical-intro-parser.adoc[] + +include::appendices-techical-intro-execution.adoc[] + +include::appendices-techical-intro-commandcontext.adoc[] + +include::appendices-techical-intro-commandcatalog.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/appendices.adoc b/spring-shell-docs/src/main/asciidoc/appendices.adoc new file mode 100644 index 000000000..aa95d1532 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/appendices.adoc @@ -0,0 +1 @@ +include::appendices-techical-intro.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/index.adoc b/spring-shell-docs/src/main/asciidoc/index.adoc index d5a9dc83e..2fbcebed8 100644 --- a/spring-shell-docs/src/main/asciidoc/index.adoc +++ b/spring-shell-docs/src/main/asciidoc/index.adoc @@ -20,3 +20,5 @@ include::introduction.adoc[] include::getting-started.adoc[] include::using-shell.adoc[] + +include::appendices.adoc[] diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-basics.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-basics.adoc new file mode 100644 index 000000000..4a40e6569 --- /dev/null +++ b/spring-shell-docs/src/main/asciidoc/using-shell-basics.adoc @@ -0,0 +1,14 @@ +[[using-shell-basics]] +=== Basics +You are here to learn basics of a _spring shell_. Before going forward to define actual _commands_ and _options_ +lets take this moment to go trough some fundamental concepts of a _spring shell_. + +Essentially few things needs to happen before you have a working _spring shell_ app: + +- Create a _spring boot_ application +- Define commands and its option +- Package an application +- Execute either interactively or non-interactively + +You will get a full working _spring shell_ application without defining any user level commands +as some basic build-in commands are provided out of a box like `help` and `history`. diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-write-command.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-define-command.adoc similarity index 50% rename from spring-shell-docs/src/main/asciidoc/using-shell-write-command.adoc rename to spring-shell-docs/src/main/asciidoc/using-shell-define-command.adoc index 6bdbfefa2..cd22975ab 100644 --- a/spring-shell-docs/src/main/asciidoc/using-shell-write-command.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-shell-define-command.adoc @@ -1,13 +1,21 @@ -=== Writing Your Own Commands +=== Define a Command +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] -The way Spring Shell decides to turn a method into an actual shell command is entirely pluggable -(see <>). However, as of Spring Shell 2.x, the recommended way to write commands -is to use the new API described in this section (the standard API). +In this section we go through an actual command registration and leave command options +and execution later in a documentation. More detailed info can be found from +<>. +There are two different ways to define a command. Firstly through an annotation model and +secondly through programmatic model. Annotation model if where you define your methods +in a class and annotate class and methods with a spesific annotations. Programmatic model +is where things are done on a more low level ways by defining command registrations either +as beans or registering those with a command catalog dynamically. + +==== Annotation Model When you use the standard API, methods on beans are turned into executable commands, provided that: -* The bean class bears the `@ShellComponent` annotation. This is used to restrict the set of beans that -are considered. +* The bean class bears the `@ShellComponent` annotation. This is used to restrict the set of beans +that are considered. * The method bears the `@ShellMethod` annotation. [TIP] @@ -18,17 +26,22 @@ you can used it in addition to the filtering mechanism to declare beans (for exa You can customize the name of the created bean by using the `value` attribute of the annotation. ==== -[[documenting-the-command]] -==== Documenting the Command +==== +[source, java, indent=0] +---- +include::{snippets}/AnnotationRegistrationSnippets.java[tag=snippet1] +---- +==== The only required attribute of the `@ShellMethod` annotation is its `value` attribute, which should have a short, one-sentence, description of what the command does. This lets your users get consistent help about your commands without having to leave the shell (see <>). -NOTE: The description of your command should be short -- no more than one or two sentences. For better +[NOTE] +==== +The description of your command should be short -- no more than one or two sentences. For better consistency, it should starts with a capital letter and end with a period. - -==== Customizing the Command Name(s) +==== By default, there is no need to specify the key for your command (that is, the word(s) that should be used to invoke it in the shell). The name of the method is used as the command key, turning camelCase names into @@ -37,19 +50,31 @@ dashed, gnu-style, names (that is, `sayHello()` becomes `say-hello`). You can, however, explicitly set the command key, by using the `key` attribute of the annotation: ==== -[source, java] +[source, java, indent=0] ---- - @ShellMethod(value = "Add numbers.", key = "sum") - public int add(int a, int b) { - return a + b; - } - +include::{snippets}/AnnotationRegistrationSnippets.java[tag=snippet2] ---- ==== -NOTE: The `key` attribute accepts multiple values. +[NOTE] +==== +The `key` attribute accepts multiple values. If you set multiple keys for a single method, the command is registered with those different aliases. +==== -TIP: The command key can contain pretty much any character, including spaces. When coming up with names though, +[TIP] +==== +The command key can contain pretty much any character, including spaces. When coming up with names though, keep in mind that consistency is often appreciated by users (that is, you should avoid mixing dashed-names with spaced names and other inconsistencies). +==== + +==== Programmatic Model +`CommandRegistration` can be defined as a `@Bean` and it's automatically registered. + +==== +[source, java, indent=0] +---- +include::{snippets}/CommandRegistrationBeanSnippets.java[tag=snippet1] +---- +==== diff --git a/spring-shell-docs/src/main/asciidoc/using-shell-ui-components.adoc b/spring-shell-docs/src/main/asciidoc/using-shell-ui-components.adoc index db731cf0c..7454f77fa 100644 --- a/spring-shell-docs/src/main/asciidoc/using-shell-ui-components.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-shell-ui-components.adoc @@ -1,5 +1,6 @@ [[uicomponents]] === UI Components +ifndef::snippets[:snippets: ../../test/java/org/springframework/shell/docs] Starting from _2.1.x_ there is a new component model which provides easier way to create higher level user interaction for usual use cases @@ -32,49 +33,18 @@ via code then gives you flexibility to do whatever you need. Programmatic way to render is simple as to create a `Function`: ==== -[source, java] +[source, java, indent=0] ---- -class StringInputCustomRenderer implements Function> { - @Override - public List apply(StringInputContext context) { - AttributedStringBuilder builder = new AttributedStringBuilder(); - builder.append(context.getName()); - builder.append(" "); - if (context.getResultValue() != null) { - builder.append(context.getResultValue()); - } - else { - String input = context.getInput(); - if (StringUtils.hasText(input)) { - builder.append(input); - } - else { - builder.append("[Default " + context.getDefaultValue() + "]"); - } - } - return Arrays.asList(builder.toAttributedString()); - } -} +include::{snippets}/UiComponentSnippets.java[tag=snippet1] ---- ==== And then hook it with a component: ==== -[source, java] +[source, java, indent=0] ---- -@ShellMethod(key = "component stringcustom", value = "String input", group = "Components") -public String stringInputCustom(boolean mask) { - StringInput component = new StringInput(getTerminal(), "Enter value", "myvalue", - new StringInputCustomRenderer()); - component.setResourceLoader(getResourceLoader()); - component.setTemplateExecutor(getTemplateExecutor()); - if (mask) { - component.setMaskCharater('*'); - } - StringInputContext context = component.run(StringInputContext.empty()); - return "Got value " + context.getResultValue(); -} +include::{snippets}/UiComponentSnippets.java[tag=snippet2] ---- ==== @@ -147,26 +117,13 @@ Used to ask a simple text input from a user, optionally masking values if content contains something sensitive. ==== -[source, java] +[source, java, indent=0] ---- -@ShellComponent -public class ComponentCommands extends AbstractShellComponent { - - @ShellMethod(key = "component string", value = "String input", group = "Components") - public String stringInput(boolean mask) { - StringInput component = new StringInput(getTerminal(), "Enter value", "myvalue"); - component.setResourceLoader(getResourceLoader()); - component.setTemplateExecutor(getTemplateExecutor()); - if (mask) { - component.setMaskCharater('*'); - } - StringInputContext context = component.run(StringInputContext.empty()); - return "Got value " + context.getResultValue(); - } -} +include::{snippets}/UiComponentSnippets.java[tag=snippet3] ---- ==== + image::images/component-text-input-1.svg[text input] Context object is `StringInputContext`. @@ -200,20 +157,9 @@ Context object is `StringInputContext`. Used to ask a `Path` from a user and gives additional info about a path itself. ==== -[source, java] +[source, java, indent=0] ---- -@ShellComponent -public class ComponentCommands extends AbstractShellComponent { - - @ShellMethod(key = "component path", value = "Path input", group = "Components") - public String pathInput() { - PathInput component = new PathInput(getTerminal(), "Enter value"); - component.setResourceLoader(getResourceLoader()); - component.setTemplateExecutor(getTemplateExecutor()); - PathInputContext context = component.run(PathInputContext.empty()); - return "Got value " + context.getResultValue(); - } -} +include::{snippets}/UiComponentSnippets.java[tag=snippet4] ---- ==== @@ -236,20 +182,9 @@ Used to ask a simple confirmation from a user and essentially is yes/no question. ==== -[source, java] +[source, java, indent=0] ---- -@ShellComponent -public class ComponentCommands extends AbstractShellComponent { - - @ShellMethod(key = "component confirmation", value = "Confirmation input", group = "Components") - public String confirmationInput(boolean no) { - ConfirmationInput component = new ConfirmationInput(getTerminal(), "Enter value", !no); - component.setResourceLoader(getResourceLoader()); - component.setTemplateExecutor(getTemplateExecutor()); - ConfirmationInputContext context = component.run(ConfirmationInputContext.empty()); - return "Got value " + context.getResultValue(); - } -} +include::{snippets}/UiComponentSnippets.java[tag=snippet5] ---- ==== @@ -275,26 +210,9 @@ Used to ask an item from a list and is essentially similar to simple dropbox implementation. ==== -[source, java] +[source, java, indent=0] ---- -@ShellComponent -public class ComponentCommands extends AbstractShellComponent { - - @ShellMethod(key = "component single", value = "Single selector", group = "Components") - public String singleSelector() { - List> items = new ArrayList<>(); - items.add(SelectorItem.of("key1", "value1")); - items.add(SelectorItem.of("key2", "value2")); - SingleItemSelector> component = new SingleItemSelector<>(getTerminal(), - items, "testSimple", null); - component.setResourceLoader(getResourceLoader()); - component.setTemplateExecutor(getTemplateExecutor()); - SingleItemSelectorContext> context = component - .run(SingleItemSelectorContext.empty()); - String result = context.getResultItem().flatMap(si -> Optional.ofNullable(si.getItem())).get(); - return "Got value " + result; - } -} +include::{snippets}/UiComponentSnippets.java[tag=snippet6] ---- ==== @@ -322,29 +240,9 @@ Context object is `SingleItemSelectorContext`. Used to ask an items from a list. ==== -[source, java] +[source, java, indent=0] ---- -@ShellComponent -public class ComponentCommands extends AbstractShellComponent { - - @ShellMethod(key = "component multi", value = "Multi selector", group = "Components") - public String multiSelector() { - List> items = new ArrayList<>(); - items.add(SelectorItem.of("key1", "value1")); - items.add(SelectorItem.of("key2", "value2", false)); - items.add(SelectorItem.of("key3", "value3")); - MultiItemSelector> component = new MultiItemSelector<>(getTerminal(), - items, "testSimple", null); - component.setResourceLoader(getResourceLoader()); - component.setTemplateExecutor(getTemplateExecutor()); - MultiItemSelectorContext> context = component - .run(MultiItemSelectorContext.empty()); - String result = context.getResultItems().stream() - .map(si -> si.getItem()) - .collect(Collectors.joining(",")); - return "Got value " + result; - } -} +include::{snippets}/UiComponentSnippets.java[tag=snippet7] ---- ==== diff --git a/spring-shell-docs/src/main/asciidoc/using-shell.adoc b/spring-shell-docs/src/main/asciidoc/using-shell.adoc index 22391e889..c9e3fbe98 100644 --- a/spring-shell-docs/src/main/asciidoc/using-shell.adoc +++ b/spring-shell-docs/src/main/asciidoc/using-shell.adoc @@ -10,7 +10,9 @@ more relevant on a java space. Moving to new major version also allows us to clean up codebase and make some needed breaking changes. ==== -include::using-shell-write-command.adoc[] +include::using-shell-basics.adoc[] + +include::using-shell-define-command.adoc[] include::using-shell-invoke-command.adoc[] diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/AnnotationRegistrationSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/AnnotationRegistrationSnippets.java new file mode 100644 index 000000000..89da98536 --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/AnnotationRegistrationSnippets.java @@ -0,0 +1,27 @@ +package org.springframework.shell.docs; + +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; + +public class AnnotationRegistrationSnippets { + + // tag::snippet1[] + @ShellComponent + static class MyCommands { + + @ShellMethod + public void mycommand() { + } + } + // end::snippet1[] + + static class Dump1 { + + // tag::snippet2[] + @ShellMethod(value = "Add numbers.", key = "sum") + public int add(int a, int b) { + return a + b; + } + // end::snippet2[] + } +} diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandCatalogSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandCatalogSnippets.java new file mode 100644 index 000000000..5a21fab5c --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandCatalogSnippets.java @@ -0,0 +1,52 @@ +package org.springframework.shell.docs; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandCatalogCustomizer; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.CommandResolver; + +public class CommandCatalogSnippets { + + CommandCatalog catalog = CommandCatalog.of(); + + void dump1() { + // tag::snippet1[] + CommandRegistration registration = CommandRegistration.builder().build(); + catalog.register(registration); + // end::snippet1[] + } + + // tag::snippet2[] + static class CustomCommandResolver implements CommandResolver { + List registrations = new ArrayList<>(); + + CustomCommandResolver() { + CommandRegistration resolved = CommandRegistration.builder() + .command("resolve command") + .build(); + registrations.add(resolved); + } + + @Override + public List resolve() { + return registrations; + } + } + // end::snippet2[] + + // tag::snippet3[] + static class CustomCommandCatalogCustomizer implements CommandCatalogCustomizer { + + @Override + public void customize(CommandCatalog commandCatalog) { + CommandRegistration registration = CommandRegistration.builder() + .command("resolve command") + .build(); + commandCatalog.register(registration); + } + } + // end::snippet3[] +} diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandContextSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandContextSnippets.java new file mode 100644 index 000000000..ff55db311 --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandContextSnippets.java @@ -0,0 +1,24 @@ +package org.springframework.shell.docs; + +import javax.validation.constraints.Null; + +import org.springframework.shell.command.CommandContext; + +@SuppressWarnings("unused") +public class CommandContextSnippets { + + CommandContext ctx = CommandContext.of(null, null, null); + + void dump1() { + // tag::snippet1[] + String arg = ctx.getOptionValue("arg"); + // end::snippet1[] + } + + void dump2() { + // tag::snippet2[] + ctx.getTerminal().writer().println("hi"); + // end::snippet2[] + } + +} diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandRegistrationBeanSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandRegistrationBeanSnippets.java new file mode 100644 index 000000000..3afcf9dd4 --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandRegistrationBeanSnippets.java @@ -0,0 +1,16 @@ +package org.springframework.shell.docs; + +import org.springframework.context.annotation.Bean; +import org.springframework.shell.command.CommandRegistration; + +public class CommandRegistrationBeanSnippets { + + // tag::snippet1[] + @Bean + CommandRegistration commandRegistration() { + return CommandRegistration.builder() + .command("mycommand") + .build(); + } + // end::snippet1[] +} diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandRegistrationSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandRegistrationSnippets.java new file mode 100644 index 000000000..777ff0b67 --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandRegistrationSnippets.java @@ -0,0 +1,26 @@ +package org.springframework.shell.docs; + +import org.springframework.shell.command.CommandRegistration; + +public class CommandRegistrationSnippets { + + void dump1() { + // tag::snippet1[] + CommandRegistration.builder() + .withOption() + .longNames("myopt") + .and() + .build(); + // end::snippet1[] + } + + void dump2() { + // tag::snippet2[] + CommandRegistration.builder() + .withOption() + .shortNames('s') + .and() + .build(); + // end::snippet2[] + } +} diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandTargetSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandTargetSnippets.java new file mode 100644 index 000000000..8cf1beb85 --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/CommandTargetSnippets.java @@ -0,0 +1,65 @@ +package org.springframework.shell.docs; + +import org.springframework.shell.command.CommandRegistration; + +public class CommandTargetSnippets { + + // tag::snippet11[] + public static class CommandPojo { + + String command(String arg) { + return arg; + } + } + // end::snippet11[] + + void dump1() { + // tag::snippet12[] + CommandPojo pojo = new CommandPojo(); + CommandRegistration.builder() + .command("command") + .withTarget() + .method(pojo, "command") + .and() + .withOption() + .longNames("arg") + .and() + .build(); + // end::snippet12[] + } + + void dump2() { + // tag::snippet2[] + CommandRegistration.builder() + .command("command") + .withTarget() + .function(ctx -> { + String arg = ctx.getOptionValue("arg"); + return String.format("hi, arg value is '%s'", arg); + }) + .and() + .withOption() + .longNames("arg") + .and() + .build(); + // end::snippet2[] + } + + void dump3() { + // tag::snippet3[] + CommandRegistration.builder() + .command("command") + .withTarget() + .consumer(ctx -> { + String arg = ctx.getOptionValue("arg"); + ctx.getTerminal().writer() + .println(String.format("hi, arg value is '%s'", arg)); + }) + .and() + .withOption() + .longNames("arg") + .and() + .build(); + // end::snippet3[] + } +} diff --git a/spring-shell-docs/src/test/java/org/springframework/shell/docs/UiComponentSnippets.java b/spring-shell-docs/src/test/java/org/springframework/shell/docs/UiComponentSnippets.java new file mode 100644 index 000000000..96ff40848 --- /dev/null +++ b/spring-shell-docs/src/test/java/org/springframework/shell/docs/UiComponentSnippets.java @@ -0,0 +1,174 @@ +package org.springframework.shell.docs; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStringBuilder; + +import org.springframework.shell.component.ConfirmationInput; +import org.springframework.shell.component.MultiItemSelector; +import org.springframework.shell.component.PathInput; +import org.springframework.shell.component.SingleItemSelector; +import org.springframework.shell.component.StringInput; +import org.springframework.shell.component.ConfirmationInput.ConfirmationInputContext; +import org.springframework.shell.component.MultiItemSelector.MultiItemSelectorContext; +import org.springframework.shell.component.PathInput.PathInputContext; +import org.springframework.shell.component.SingleItemSelector.SingleItemSelectorContext; +import org.springframework.shell.component.StringInput.StringInputContext; +import org.springframework.shell.component.support.SelectorItem; +import org.springframework.shell.standard.AbstractShellComponent; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.util.StringUtils; + +public class UiComponentSnippets { + + // tag::snippet1[] + class StringInputCustomRenderer implements Function> { + @Override + public List apply(StringInputContext context) { + AttributedStringBuilder builder = new AttributedStringBuilder(); + builder.append(context.getName()); + builder.append(" "); + if (context.getResultValue() != null) { + builder.append(context.getResultValue()); + } + else { + String input = context.getInput(); + if (StringUtils.hasText(input)) { + builder.append(input); + } + else { + builder.append("[Default " + context.getDefaultValue() + "]"); + } + } + return Arrays.asList(builder.toAttributedString()); + } + } + // end::snippet1[] + + class Dump1 extends AbstractShellComponent { + // tag::snippet2[] + @ShellMethod(key = "component stringcustom", value = "String input", group = "Components") + public String stringInputCustom(boolean mask) { + StringInput component = new StringInput(getTerminal(), "Enter value", "myvalue", + new StringInputCustomRenderer()); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + if (mask) { + component.setMaskCharater('*'); + } + StringInputContext context = component.run(StringInputContext.empty()); + return "Got value " + context.getResultValue(); + } + // end::snippet2[] + } + + class Dump2 { + // tag::snippet3[] + @ShellComponent + public class ComponentCommands extends AbstractShellComponent { + + @ShellMethod(key = "component string", value = "String input", group = "Components") + public String stringInput(boolean mask) { + StringInput component = new StringInput(getTerminal(), "Enter value", "myvalue"); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + if (mask) { + component.setMaskCharater('*'); + } + StringInputContext context = component.run(StringInputContext.empty()); + return "Got value " + context.getResultValue(); + } + } + // end::snippet3[] + } + + class Dump3 { + // tag::snippet4[] + @ShellComponent + public class ComponentCommands extends AbstractShellComponent { + + @ShellMethod(key = "component path", value = "Path input", group = "Components") + public String pathInput() { + PathInput component = new PathInput(getTerminal(), "Enter value"); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + PathInputContext context = component.run(PathInputContext.empty()); + return "Got value " + context.getResultValue(); + } + } + // end::snippet4[] + } + + class Dump4 { + // tag::snippet5[] + @ShellComponent + public class ComponentCommands extends AbstractShellComponent { + + @ShellMethod(key = "component confirmation", value = "Confirmation input", group = "Components") + public String confirmationInput(boolean no) { + ConfirmationInput component = new ConfirmationInput(getTerminal(), "Enter value", !no); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + ConfirmationInputContext context = component.run(ConfirmationInputContext.empty()); + return "Got value " + context.getResultValue(); + } + } + // end::snippet5[] + } + + class Dump5 { + // tag::snippet6[] + @ShellComponent + public class ComponentCommands extends AbstractShellComponent { + + @ShellMethod(key = "component single", value = "Single selector", group = "Components") + public String singleSelector() { + List> items = new ArrayList<>(); + items.add(SelectorItem.of("key1", "value1")); + items.add(SelectorItem.of("key2", "value2")); + SingleItemSelector> component = new SingleItemSelector<>(getTerminal(), + items, "testSimple", null); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + SingleItemSelectorContext> context = component + .run(SingleItemSelectorContext.empty()); + String result = context.getResultItem().flatMap(si -> Optional.ofNullable(si.getItem())).get(); + return "Got value " + result; + } + } + // end::snippet6[] + } + + class Dump6 { + // tag::snippet7[] + @ShellComponent + public class ComponentCommands extends AbstractShellComponent { + + @ShellMethod(key = "component multi", value = "Multi selector", group = "Components") + public String multiSelector() { + List> items = new ArrayList<>(); + items.add(SelectorItem.of("key1", "value1")); + items.add(SelectorItem.of("key2", "value2", false)); + items.add(SelectorItem.of("key3", "value3")); + MultiItemSelector> component = new MultiItemSelector<>(getTerminal(), + items, "testSimple", null); + component.setResourceLoader(getResourceLoader()); + component.setTemplateExecutor(getTemplateExecutor()); + MultiItemSelectorContext> context = component + .run(MultiItemSelectorContext.empty()); + String result = context.getResultItems().stream() + .map(si -> si.getItem()) + .collect(Collectors.joining(",")); + return "Got value " + result; + } + } + // end::snippet7[] + } +} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/SpringShellSample.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/SpringShellSample.java index c2931481b..905eaad61 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/SpringShellSample.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/SpringShellSample.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2021 the original author or authors. + * Copyright 2017-2022 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. @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.samples; import org.jline.utils.AttributedString; @@ -30,6 +29,7 @@ *

Creates the application context and start the REPL.

* * @author Eric Bottard + * @author Janne Valkealahti */ @SpringBootApplication public class SpringShellSample { diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java index 3d1fa070f..c57f3cbf8 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java @@ -71,6 +71,11 @@ public int add(int a, int b, int c) { return a + b + c; } + @ShellMethod("Concat strings.") + public String concat(String a, String b, String c) { + return a + b + c; + } + @ShellMethod("Fails with an exception. Shows enum conversion.") public void fail(ElementType elementType) { throw new IllegalArgumentException("You said " + elementType); diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/FunctionCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/FunctionCommands.java new file mode 100644 index 000000000..f6e52d10d --- /dev/null +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/FunctionCommands.java @@ -0,0 +1,90 @@ +/* + * Copyright 2022 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.samples.standard; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell.command.CommandRegistration; + +@Configuration +public class FunctionCommands { + + @Bean + public CommandRegistration commandRegistration1() { + return CommandRegistration.builder() + .command("function", "command1") + .help("function sample") + .group("Function Commands") + .withTarget() + .function(ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return String.format("hi, arg1 value is '%s'", arg1); + }) + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + } + + @Bean + public CommandRegistration commandRegistration2() { + return CommandRegistration.builder() + .command("function", "command2") + .help("function sample") + .group("Function Commands") + .withTarget() + .function(ctx -> { + Boolean a = ctx.getOptionValue("a"); + Boolean b = ctx.getOptionValue("b"); + Boolean c = ctx.getOptionValue("c"); + return String.format("hi, boolean values for a, b, c are '%s' '%s' '%s'", a, b, c); + }) + .and() + .withOption() + .shortNames('a') + .type(boolean.class) + .and() + .withOption() + .shortNames('b') + .type(boolean.class) + .and() + .withOption() + .shortNames('c') + .type(boolean.class) + .and() + .build(); + } + + @Bean + public CommandRegistration commandRegistration3() { + return CommandRegistration.builder() + .command("function", "command3") + .help("function sample") + .group("Function Commands") + .withTarget() + .consumer(ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + ctx.getTerminal().writer() + .println(String.format("hi, arg1 value is '%s'", arg1)); + }) + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + } +} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/RegisterCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/RegisterCommands.java index 8bdb09e1a..d06e657d0 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/RegisterCommands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/RegisterCommands.java @@ -15,7 +15,10 @@ */ package org.springframework.shell.samples.standard; -import org.springframework.shell.MethodTarget; +import java.util.function.Function; + +import org.springframework.shell.command.CommandContext; +import org.springframework.shell.command.CommandRegistration; import org.springframework.shell.standard.AbstractShellComponent; import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; @@ -24,42 +27,90 @@ @ShellComponent public class RegisterCommands extends AbstractShellComponent { + private final static String GROUP = "Register Commands"; private final PojoMethods pojoMethods = new PojoMethods(); + private final CommandRegistration registered1; + private final CommandRegistration registered2; + private final CommandRegistration registered3; + + public RegisterCommands() { + registered1 = CommandRegistration.builder() + .command("register registered1") + .group(GROUP) + .help("registered1 command") + .withTarget() + .method(pojoMethods, "registered1") + .and() + .build(); + registered2 = CommandRegistration.builder() + .command("register registered2") + .help("registered2 command") + .group(GROUP) + .withTarget() + .method(pojoMethods, "registered2") + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + registered3 = CommandRegistration.builder() + .command("register registered3") + .help("registered3 command") + .group(GROUP) + .withTarget() + .method(pojoMethods, "registered3") + .and() + .build(); + } - @ShellMethod(key = "register add", value = "Register commands", group = "Register Commands") + @ShellMethod(key = "register add", value = "Register commands", group = GROUP) public String register() { - MethodTarget target1 = MethodTarget.of("dynamic1", pojoMethods, "Dynamic1 command", "Register Commands"); - MethodTarget target2 = MethodTarget.of("dynamic2", pojoMethods, "Dynamic2 command", "Register Commands"); - MethodTarget target3 = MethodTarget.of("dynamic3", pojoMethods, "Dynamic3 command", "Register Commands"); - getCommandRegistry().addCommand("register dynamic1", target1); - getCommandRegistry().addCommand("register dynamic2", target2); - getCommandRegistry().addCommand("register dynamic3", target3); - return "Registered commands dynamic1, dynamic2, dynamic3"; + getCommandCatalog().register(registered1, registered2, registered3); + registerFunctionCommand("register registered4"); + return "Registered commands registered1, registered2, registered3, registered4"; } - @ShellMethod(key = "register remove", value = "Deregister commands", group = "Register Commands") + @ShellMethod(key = "register remove", value = "Deregister commands", group = GROUP) public String deregister() { - getCommandRegistry().removeCommand("register dynamic1"); - getCommandRegistry().removeCommand("register dynamic2"); - getCommandRegistry().removeCommand("register dynamic3"); - return "Deregistered commands dynamic1, dynamic2, dynamic3"; + getCommandCatalog().unregister("register registered1", "register registered2", "register registered3", + "register registered4"); + return "Deregistered commands registered1, registered2, registered3, registered4"; } + private void registerFunctionCommand(String command) { + Function function = ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return String.format("hi, arg1 value is '%s'", arg1); + }; + CommandRegistration registration = CommandRegistration.builder() + .command(command) + .help("registered4 command") + .group(GROUP) + .withTarget() + .function(function) + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + getCommandCatalog().register(registration); + } + public static class PojoMethods { @ShellMethod - public String dynamic1() { - return "dynamic1"; + public String registered1() { + return "registered1"; } @ShellMethod - public String dynamic2(String arg1) { - return "dynamic2" + arg1; + public String registered2(String arg1) { + return "registered2" + arg1; } @ShellMethod - public String dynamic3(@ShellOption(defaultValue = ShellOption.NULL) String arg1) { - return "dynamic3" + arg1; + public String registered3(@ShellOption(defaultValue = ShellOption.NULL) String arg1) { + return "registered3" + arg1; } } } diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ResolvedCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ResolvedCommands.java new file mode 100644 index 000000000..65544f9fc --- /dev/null +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/ResolvedCommands.java @@ -0,0 +1,144 @@ +/* + * Copyright 2022 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.samples.standard; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.CommandResolver; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; + +public class ResolvedCommands { + + private static final String GROUP = "Resolve Commands"; + + @Configuration + public static class ResolvedCommandsConfiguration { + + @Bean + Server1CommandResolver server1CommandResolver() { + return new Server1CommandResolver(); + } + + @Bean + Server2CommandResolver server2CommandResolver() { + return new Server2CommandResolver(); + } + } + + @ShellComponent + public static class ResolvedCommandsCommands { + + private final Server1CommandResolver server1CommandResolver; + private final Server2CommandResolver server2CommandResolver; + + ResolvedCommandsCommands(Server1CommandResolver server1CommandResolver, + Server2CommandResolver server2CommandResolver) { + this.server1CommandResolver = server1CommandResolver; + this.server2CommandResolver = server2CommandResolver; + } + + @ShellMethod(key = "resolve enableserver1", group = GROUP) + public String server1Enable() { + server1CommandResolver.enabled = true; + return "Enabled server1"; + } + + @ShellMethod(key = "resolve disableserver1", group = GROUP) + public String server1Disable() { + server1CommandResolver.enabled = false; + return "Disabled server1"; + } + + @ShellMethod(key = "resolve enableserver2", group = GROUP) + public String server2Enable() { + server2CommandResolver.enabled = true; + return "Enabled server2"; + } + + @ShellMethod(key = "resolve disableserver2", group = GROUP) + public String server2Disable() { + server2CommandResolver.enabled = false; + return "Disabled server2"; + } + } + + static class Server1CommandResolver implements CommandResolver { + + private final List registrations = new ArrayList<>(); + boolean enabled = false; + + Server1CommandResolver() { + CommandRegistration resolved1 = CommandRegistration.builder() + .command("resolve server1 command1") + .group(GROUP) + .help("server1 command1") + .withTarget() + .function(ctx -> { + return "hi from server1 command1"; + }) + .and() + .build(); + registrations.add(resolved1); + } + + @Override + public List resolve() { + return enabled ? registrations : Collections.emptyList(); + } + } + + static class Server2CommandResolver implements CommandResolver { + + private final List registrations = new ArrayList<>(); + boolean enabled = false; + + Server2CommandResolver() { + CommandRegistration resolved1 = CommandRegistration.builder() + .command("resolve server2 command1") + .group(GROUP) + .help("server2 command1") + .withTarget() + .function(ctx -> { + return "hi from server2 command1"; + }) + .and() + .build(); + CommandRegistration resolved2 = CommandRegistration.builder() + .command("resolve server2 command2") + .group(GROUP) + .help("server2 command2") + .withTarget() + .function(ctx -> { + return "hi from server2 command2"; + }) + .and() + .build(); + registrations.add(resolved1); + registrations.add(resolved2); + } + + @Override + public List resolve() { + return enabled ? registrations : Collections.emptyList(); + } + } +} diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java index 5d19f39e7..737e8d9b1 100644 --- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java @@ -15,8 +15,6 @@ */ package org.springframework.shell.standard.commands; -import java.util.stream.Collectors; - import org.springframework.core.io.ResourceLoader; import org.springframework.shell.standard.AbstractShellComponent; import org.springframework.shell.standard.ShellComponent; @@ -51,8 +49,7 @@ public void setResourceLoader(ResourceLoader resourceLoader) { @ShellMethod(key = "completion bash", value = "Generate bash completion script") public String bash() { - BashCompletions bashCompletions = new BashCompletions(resourceLoader, getCommandRegistry(), - getParameterResolver().collect(Collectors.toList())); + BashCompletions bashCompletions = new BashCompletions(resourceLoader, getCommandCatalog()); return bashCompletions.generate(rootCommand); } } diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java index 818b77b9a..2d11d03bc 100644 --- a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Help.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 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. @@ -13,15 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.standard.commands; import java.io.IOException; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.Set; import java.util.SortedMap; import java.util.SortedSet; import java.util.TreeMap; @@ -37,24 +36,26 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.shell.Availability; -import org.springframework.shell.MethodTarget; import org.springframework.shell.ParameterDescription; import org.springframework.shell.Utils; +import org.springframework.shell.command.CommandOption; +import org.springframework.shell.command.CommandRegistration; import org.springframework.shell.standard.AbstractShellComponent; import org.springframework.shell.standard.CommandValueProvider; import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; +import org.springframework.util.StringUtils; import static java.util.stream.Collectors.groupingBy; import static java.util.stream.Collectors.mapping; import static java.util.stream.Collectors.toCollection; -import static java.util.stream.Collectors.toMap; /** * A command to display help about all available commands. * * @author Eric Bottard + * @author Janne Valkealahti */ @ShellComponent public class Help extends AbstractShellComponent { @@ -91,7 +92,7 @@ public void setValidatorFactory(ValidatorFactory validatorFactory) { @ShellMethod(value = "Display help about available commands.", prefix = "-") public CharSequence help( @ShellOption(defaultValue = ShellOption.NULL, valueProvider = CommandValueProvider.class, value = { "-C", - "--command" }, help = "The command to obtain help for.") String command) + "--command" }, help = "The command to obtain help for.", arity = Integer.MAX_VALUE) String command) throws IOException { if (command == null) { return listCommands(); @@ -116,16 +117,17 @@ public void setShowGroups(boolean showGroups) { * Return a description of a specific command. Uses a layout inspired by *nix man pages. */ private CharSequence documentCommand(String command) { - MethodTarget methodTarget = getCommandRegistry().listCommands().get(command); - if (methodTarget == null) { + Map registrations = getCommandCatalog().getRegistrations(); + CommandRegistration registration = registrations.get(command); + if (registration == null) { throw new IllegalArgumentException("Unknown command '" + command + "'"); } AttributedStringBuilder result = new AttributedStringBuilder().append("\n\n"); - List parameterDescriptions = getParameterDescriptions(methodTarget); + List parameterDescriptions = getParameterDescriptions(registration); // NAME - documentCommandName(result, command, methodTarget.getHelp()); + documentCommandName(result, command, registration.getHelp()); // SYNOPSYS documentSynopsys(result, command, parameterDescriptions); @@ -134,10 +136,10 @@ private CharSequence documentCommand(String command) { documentOptions(result, parameterDescriptions); // ALSO KNOWN AS - documentAliases(result, command, methodTarget); + documentAliases(result, command, registrations, registration); // AVAILABILITY - documentAvailability(result, methodTarget); + documentAvailability(result, registration); result.append("\n"); return result; @@ -242,12 +244,13 @@ else if (description.defaultValueWhenFlag().isPresent()) { } } - private void documentAliases(AttributedStringBuilder result, String command, MethodTarget methodTarget) { - Set aliases = getCommandRegistry().listCommands().entrySet().stream() - .filter(e -> e.getValue().equals(methodTarget)) - .map(Map.Entry::getKey) - .filter(c -> !command.equals(c)) - .collect(toCollection(TreeSet::new)); + private void documentAliases(AttributedStringBuilder result, String command, + Map registrations, CommandRegistration registration) { + List aliases = registrations.entrySet().stream() + .filter(e -> e.getValue().equals(registration)) + .map(Map.Entry::getKey) + .filter(c -> !command.equals(c)) + .collect(Collectors.toList()); if (!aliases.isEmpty()) { result.append("ALSO KNOWN AS", AttributedStyle.BOLD).append("\n"); @@ -257,8 +260,8 @@ private void documentAliases(AttributedStringBuilder result, String command, Met } } - private void documentAvailability(AttributedStringBuilder result, MethodTarget methodTarget) { - Availability availability = methodTarget.getAvailability(); + private void documentAvailability(AttributedStringBuilder result, CommandRegistration registration) { + Availability availability = registration.getAvailability(); if (!availability.isAvailable()) { result.append("CURRENTLY UNAVAILABLE", AttributedStyle.BOLD).append("\n"); result.append('\t').append("This command is currently not available because ") @@ -272,20 +275,21 @@ private String first(List keys) { } private CharSequence listCommands() { - Map commandsByName = getCommandRegistry().listCommands(); - AttributedStringBuilder result = new AttributedStringBuilder(); result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD); - SortedMap> commandsByGroupAndName = commandsByName.entrySet().stream() - .collect(groupingBy(e -> e.getValue().getGroup(), TreeMap::new, // group by and sort by command group - toMap(Entry::getKey, Entry::getValue))); - // display groups, sorted alphabetically, "Default" first + SortedMap> commandsByGroupAndName = getCommandCatalog().getRegistrations().entrySet().stream() + .collect(Collectors.groupingBy( + e -> StringUtils.hasText(e.getValue().getGroup()) ? e.getValue().getGroup() : "", + TreeMap::new, + Collectors.toMap(Entry::getKey, Entry::getValue) + )); + commandsByGroupAndName.forEach((group, commandsInGroup) -> { if (showGroups) { result.append("".equals(group) ? "Default" : group, AttributedStyle.BOLD).append('\n'); } - Map> commandNamesByMethod = commandsInGroup.entrySet().stream() + Map> commandNamesByMethod = commandsInGroup.entrySet().stream() .collect(groupingBy(Entry::getValue, // group by command method mapping(Entry::getKey, toCollection(TreeSet::new)))); // sort command names // display commands, sorted alphabetically by their first alias @@ -304,19 +308,15 @@ private CharSequence listCommands() { } }); - if (commandsByName.values().stream().distinct().anyMatch(m -> !isAvailable(m))) { - result.append("Commands marked with (*) are currently unavailable.\nType `help ` to learn more.\n\n"); - } - return result; } - private Comparator>> sortByFirstCommandName() { + private Comparator>> sortByFirstCommandName() { return Comparator.comparing(e -> e.getValue().first()); } - private boolean isAvailable(MethodTarget methodTarget) { - return methodTarget.getAvailability().isAvailable(); + private boolean isAvailable(CommandRegistration methodTarget) { + return true; } private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDescription description) { @@ -330,12 +330,50 @@ private void appendUnderlinedFormal(AttributedStringBuilder result, ParameterDes } } - private List getParameterDescriptions(MethodTarget methodTarget) { - return Utils.createMethodParameters(methodTarget.getMethod()) - .flatMap(mp -> getParameterResolver().filter(pr -> pr.supports(mp)).limit(1L) - .flatMap(pr -> pr.describe(mp))) - .collect(Collectors.toList()); + private List getParameterDescriptions(CommandRegistration registration) { + List options = registration.getOptions(); + List descriptions = new ArrayList<>(); + + for (CommandOption option : options) { + ParameterDescription description = new ParameterDescription(); + if (option.getType() != null) { + description.type(option.getType().toString()); + description.formal(option.getType().toClass().getSimpleName()); + } + else { + description.formal(""); + } + description.help(option.getDescription()); + description.mandatoryKey(option.isRequired()); + if (option.getType() != null && option.getType().isAssignableFrom(boolean.class)) { + description.defaultValue("false"); + } + else { + description.defaultValue(option.getDefaultValue()); + } + + List keys = new ArrayList<>(); + if (option.getLongNames() != null) { + for (String ln : option.getLongNames()) { + keys.add("--" + ln); + } + } + if (option.getShortNames() != null) { + for (Character sn : option.getShortNames()) { + keys.add("-" + String.valueOf(sn)); + } + } + description.keys(keys); + + descriptions.add(description); + } + + // return Utils.createMethodParameters(registration.getTarget().getMethod()) + // .flatMap(mp -> getParameterResolver().filter(pr -> pr.supports(mp)).limit(1L) + // .flatMap(pr -> pr.describe(mp))) + // .collect(Collectors.toList()); + return descriptions; } private static class DummyContext implements MessageInterpolator.Context { @@ -361,5 +399,4 @@ public T unwrap(Class type) { return null; } } - } diff --git a/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java b/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTests.java similarity index 52% rename from spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java rename to spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTests.java index 3baade572..68176917a 100644 --- a/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTest.java +++ b/spring-shell-standard-commands/src/test/java/org/springframework/shell/standard/commands/HelpTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2022 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. @@ -13,14 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.standard.commands; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Method; -import java.util.Collections; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -28,44 +26,45 @@ import javax.validation.constraints.Max; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.ClassPathResource; -import org.springframework.shell.Command; -import org.springframework.shell.CommandRegistry; -import org.springframework.shell.MethodTarget; -import org.springframework.shell.ParameterResolver; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; -import org.springframework.shell.standard.StandardParameterResolver; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.util.FileCopyUtils; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -/** - * Tests for the {@link Help} command. - * - * @author Eric Bottard - */ @ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = HelpTest.Config.class) -public class HelpTest { +@ContextConfiguration(classes = HelpTests.Config.class) +public class HelpTests { private static Locale previousLocale; private String testName; + private Map registrations = new HashMap<>(); + private CommandsPojo commandsPojo = new CommandsPojo(); + + @MockBean + private CommandCatalog commandCatalog; + + @Autowired + private Help help; @BeforeAll public static void setAssumedLocale() { @@ -80,25 +79,106 @@ public static void restorePreviousLocale() { @BeforeEach public void setup(TestInfo testInfo) { + registrations.clear(); Optional testMethod = testInfo.getTestMethod(); if (testMethod.isPresent()) { this.testName = testMethod.get().getName(); } + Mockito.when(commandCatalog.getRegistrations()).thenReturn(registrations); } - @Autowired - private Help help; - @Test public void testCommandHelp() throws Exception { + CommandRegistration registration = CommandRegistration.builder() + .command("first-command") + .help("A rather extensive description of some command.") + .withTarget() + .method(commandsPojo, "firstCommand") + .and() + .withOption() + .shortNames('r') + .description("Whether to delete recursively") + .type(boolean.class) + .and() + .withOption() + .shortNames('f') + .description("Do not ask for confirmation. YOLO") + .type(boolean.class) + .and() + .withOption() + .shortNames('n') + .description("The answer to everything") + .defaultValue("42") + .type(int.class) + .and() + .withOption() + .shortNames('o') + .description("Some other parameters") + .type(float[].class) + .and() + .build(); + registrations.put("first-command", registration); + registrations.put("1st-command", registration); CharSequence help = this.help.help("first-command").toString(); - Assertions.assertThat(help).isEqualTo(sample()); + assertThat(help).isEqualTo(sample()); } @Test public void testCommandList() throws Exception { + CommandRegistration registration1 = CommandRegistration.builder() + .command("first-command") + .help("A rather extensive description of some command.") + .withTarget() + .method(commandsPojo, "firstCommand") + .and() + .withOption() + .shortNames('r') + .and() + .build(); + registrations.put("first-command", registration1); + registrations.put("1st-command", registration1); + + CommandRegistration registration2 = CommandRegistration.builder() + .command("second-command") + .help("The second command. This one is known under several aliases as well.") + .withTarget() + .method(commandsPojo, "secondCommand") + .and() + .build(); + registrations.put("second-command", registration2); + registrations.put("yet-another-command", registration2); + + CommandRegistration registration3 = CommandRegistration.builder() + .command("second-command") + .help("The last command.") + .withTarget() + .method(commandsPojo, "thirdCommand") + .and() + .build(); + registrations.put("third-command", registration3); + + CommandRegistration registration4 = CommandRegistration.builder() + .command("first-group-command") + .help("The first command in a separate group.") + .group("Example Group") + .withTarget() + .method(commandsPojo, "firstCommandInGroup") + .and() + .build(); + registrations.put("first-group-command", registration4); + + CommandRegistration registration5 = CommandRegistration.builder() + .command("second-group-command") + .help("The second command in a separate group.") + .group("Example Group") + .withTarget() + .method(commandsPojo, "secondCommandInGroup") + .and() + .build(); + registrations.put("second-group-command", registration5); + String list = this.help.help(null).toString(); - Assertions.assertThat(list).isEqualTo(sample()); + assertThat(list).isEqualTo(sample()); } @Test @@ -109,7 +189,7 @@ public void testUnknownCommand() throws Exception { } private String sample() throws IOException { - InputStream is = new ClassPathResource(HelpTest.class.getSimpleName() + "-" + testName + ".txt", HelpTest.class).getInputStream(); + InputStream is = new ClassPathResource(HelpTests.class.getSimpleName() + "-" + testName + ".txt", HelpTests.class).getInputStream(); return FileCopyUtils.copyToString(new InputStreamReader(is, "UTF-8")).replace("&", ""); } @@ -117,62 +197,18 @@ private String sample() throws IOException { static class Config { @Bean - public Help help(CommandRegistry commandRegistry) { + public Help help() { return new Help(); } - @Bean - public CommandRegistry shell() { - - return new CommandRegistry() { - - @Override - public Map listCommands() { - Map result = new HashMap<>(); - MethodTarget methodTarget = MethodTarget.of("firstCommand", commands(), new Command.Help("A rather extensive description of some command.")); - result.put("first-command", methodTarget); - result.put("1st-command", methodTarget); - - methodTarget = MethodTarget.of("secondCommand", commands(), new Command.Help("The second command. This one is known under several aliases as well.")); - result.put("second-command", methodTarget); - result.put("yet-another-command", methodTarget); - - methodTarget = MethodTarget.of("thirdCommand", commands(), new Command.Help("The last command.")); - result.put("third-command", methodTarget); - - methodTarget = MethodTarget.of("firstCommandInGroup", commands(), new Command.Help("The first command in a separate group.", "Example Group")); - result.put("first-group-command", methodTarget); - - methodTarget = MethodTarget.of("secondCommandInGroup", commands(), new Command.Help("The second command in a separate group.", "Example Group")); - result.put("second-group-command", methodTarget); - - return result; - } - - @Override - public void addCommand(String name, MethodTarget target) { - } - - @Override - public void removeCommand(String name) { - } - }; - } - - @Bean - public ParameterResolver parameterResolver() { - return new StandardParameterResolver(new DefaultConversionService(), Collections.emptySet()); - } - - @Bean - public Object commands() { - return new Commands(); - } - + // @Bean + // public ParameterResolver parameterResolver() { + // return new StandardParameterResolver(new DefaultConversionService(), Collections.emptySet()); + // } } @ShellComponent - static class Commands { + static class CommandsPojo { @ShellMethod(prefix = "--") public void firstCommand( @@ -186,28 +222,22 @@ public void firstCommand( // Single key, arity > 1. @ShellOption(help = "Some other parameters", arity = 3, value = "-o") float[] o ) { - } @ShellMethod public void secondCommand() { - } @ShellMethod public void thirdCommand() { - } @ShellMethod public void firstCommandInGroup() { - } @ShellMethod public void secondCommandInGroup() { - } - } } diff --git a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTests-testCommandHelp.txt similarity index 65% rename from spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt rename to spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTests-testCommandHelp.txt index 1df901675..dc7ee45c6 100644 --- a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandHelp.txt +++ b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTests-testCommandHelp.txt @@ -4,22 +4,22 @@ NAME first-command - A rather extensive description of some command. SYNOPSYS - first-command [-r] [-f] [[-n] int] [-o] float float float + first-command [[-r] boolean] [[-f] boolean] [[-n] int] [-o] float[] OPTIONS - -r Whether to delete recursively + -r boolean + Whether to delete recursively [Optional, default = false] - -f or --force + -f boolean Do not ask for confirmation. YOLO [Optional, default = false] -n int The answer to everything [Optional, default = 42] - [must be less than or equal to 5] - -o float float float + -o float[] Some other parameters [Mandatory] diff --git a/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt b/spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTests-testCommandList.txt similarity index 100% rename from spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTest-testCommandList.txt rename to spring-shell-standard-commands/src/test/resources/org/springframework/shell/standard/commands/HelpTests-testCommandList.txt diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java index e7b01e2ff..cd584ae4a 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/AbstractShellComponent.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 the original author or authors. + * Copyright 2021-2022 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. @@ -26,9 +26,9 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.context.ResourceLoaderAware; import org.springframework.core.io.ResourceLoader; -import org.springframework.shell.CommandRegistry; -import org.springframework.shell.ParameterResolver; import org.springframework.shell.Shell; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.completion.CompletionResolver; import org.springframework.shell.style.TemplateExecutor; import org.springframework.shell.style.ThemeResolver; @@ -47,9 +47,9 @@ public abstract class AbstractShellComponent implements ApplicationContextAware, private ObjectProvider terminalProvider; - private ObjectProvider commandRegistryProvider; + private ObjectProvider commandCatalogProvider; - private ObjectProvider parameterResolverProvider; + private ObjectProvider completionResolverProvider; private ObjectProvider templateExecutorProvider; @@ -69,8 +69,8 @@ public void setResourceLoader(ResourceLoader resourceLoader) { public void afterPropertiesSet() throws Exception { shellProvider = applicationContext.getBeanProvider(Shell.class); terminalProvider = applicationContext.getBeanProvider(Terminal.class); - commandRegistryProvider = applicationContext.getBeanProvider(CommandRegistry.class); - parameterResolverProvider = applicationContext.getBeanProvider(ParameterResolver.class); + commandCatalogProvider = applicationContext.getBeanProvider(CommandCatalog.class); + completionResolverProvider = applicationContext.getBeanProvider(CompletionResolver.class); templateExecutorProvider = applicationContext.getBeanProvider(TemplateExecutor.class); themeResolverProvider = applicationContext.getBeanProvider(ThemeResolver.class); } @@ -91,12 +91,12 @@ protected Terminal getTerminal() { return terminalProvider.getObject(); } - protected CommandRegistry getCommandRegistry() { - return commandRegistryProvider.getObject(); + protected CommandCatalog getCommandCatalog() { + return commandCatalogProvider.getObject(); } - protected Stream getParameterResolver() { - return parameterResolverProvider.orderedStream(); + protected Stream getCompletionResolver() { + return completionResolverProvider.orderedStream(); } protected TemplateExecutor getTemplateExecutor() { diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java index d8145e15c..9a9e1cf16 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 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,9 +20,9 @@ import java.util.stream.Collectors; import org.springframework.core.MethodParameter; -import org.springframework.shell.CommandRegistry; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; +import org.springframework.shell.command.CommandCatalog; /** * A {@link ValueProvider} that can be used to auto-complete names of shell commands. @@ -31,15 +31,15 @@ */ public class CommandValueProvider extends ValueProviderSupport { - private final CommandRegistry commandRegistry; + private final CommandCatalog commandRegistry; - public CommandValueProvider(CommandRegistry commandRegistry) { + public CommandValueProvider(CommandCatalog commandRegistry) { this.commandRegistry = commandRegistry; } @Override public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { - return commandRegistry.listCommands().keySet().stream() + return commandRegistry.getRegistrations().keySet().stream() .map(CompletionProposal::new) .collect(Collectors.toList()); } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellOptionMethodArgumentResolver.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellOptionMethodArgumentResolver.java new file mode 100644 index 000000000..ea0de2da1 --- /dev/null +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ShellOptionMethodArgumentResolver.java @@ -0,0 +1,82 @@ +/* + * Copyright 2022 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.standard; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.core.MethodParameter; +import org.springframework.core.convert.ConversionService; +import org.springframework.lang.Nullable; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHandlingException; +import org.springframework.shell.support.AbstractArgumentMethodArgumentResolver; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Resolver for {@link ShellOption @ShellOption} arguments. + * + * @author Janne Valkealahti + */ +public class ShellOptionMethodArgumentResolver extends AbstractArgumentMethodArgumentResolver { + + public ShellOptionMethodArgumentResolver(ConversionService conversionService, + @Nullable ConfigurableBeanFactory beanFactory) { + super(conversionService, beanFactory); + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(ShellOption.class); + } + + @Override + protected NamedValueInfo createNamedValueInfo(MethodParameter parameter) { + ShellOption annot = parameter.getParameterAnnotation(ShellOption.class); + Assert.state(annot != null, "No ShellOption annotation"); + List names = Arrays.stream(annot.value()).map(v -> StringUtils.trimLeadingCharacter(v, '-')).collect(Collectors.toList()); + return new HeaderNamedValueInfo(annot, names); + } + + @Override + @Nullable + protected Object resolveArgumentInternal(MethodParameter parameter, Message message, List names) + throws Exception { + for (String name : names) { + if (message.getHeaders().containsKey(ARGUMENT_PREFIX + name)) { + return message.getHeaders().get(ARGUMENT_PREFIX + name); + } + } + return null; + } + + @Override + protected void handleMissingValue(List headerName, MethodParameter parameter, Message message) { + throw new MessageHandlingException(message, + "Missing headers '" + StringUtils.collectionToCommaDelimitedString(headerName) + + "' for method parameter type [" + parameter.getParameterType() + "]"); + } + + private static final class HeaderNamedValueInfo extends NamedValueInfo { + + private HeaderNamedValueInfo(ShellOption annotation, List names) { + super(names, false, null); + } + } +} 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 adb3a2bfb..949fae78f 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 @@ -26,21 +26,27 @@ import java.util.function.Supplier; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; import org.springframework.shell.Availability; -import org.springframework.shell.Command; -import org.springframework.shell.ConfigurableCommandRegistry; -import org.springframework.shell.MethodTarget; import org.springframework.shell.MethodTargetRegistrar; import org.springframework.shell.Utils; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.command.CommandRegistration.Builder; +import org.springframework.shell.command.CommandRegistration.OptionSpec; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; -import static org.springframework.util.StringUtils.collectionToDelimitedString; - /** * The standard implementation of {@link MethodTargetRegistrar} for new shell * applications, resolves methods annotated with {@link ShellMethod} on @@ -49,20 +55,20 @@ * @author Eric Bottard * @author Florent Biville * @author Camilo Gonzalez + * @author Janne Valkealahti */ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, ApplicationContextAware { + private final Logger log = LoggerFactory.getLogger(StandardMethodTargetRegistrar.class); private ApplicationContext applicationContext; - private Map commands = new HashMap<>(); - @Override public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } @Override - public void register(ConfigurableCommandRegistry registry) { + public void register(CommandCatalog registry) { Map commandBeans = applicationContext.getBeansWithAnnotation(ShellComponent.class); for (Object bean : commandBeans.values()) { Class clazz = bean.getClass(); @@ -74,11 +80,78 @@ public void register(ConfigurableCommandRegistry registry) { } String group = getOrInferGroup(method); for (String key : keys) { + log.debug("Registering with keys='{}' key='{}'", keys, key); Supplier availabilityIndicator = findAvailabilityIndicator(keys, bean, method); - MethodTarget target = new MethodTarget(method, bean, new Command.Help(shellMapping.value(), group), - availabilityIndicator, shellMapping.interactionMode()); - registry.register(key, target); - commands.put(key, target); + + Builder builder = CommandRegistration.builder() + .command(key) + .group(group) + .help(shellMapping.value()) + .interactionMode(shellMapping.interactionMode()) + .availability(availabilityIndicator); + + InvocableHandlerMethod ihm = new InvocableHandlerMethod(bean, method); + for (MethodParameter mp : ihm.getMethodParameters()) { + + ShellOption so = mp.getParameterAnnotation(ShellOption.class); + log.debug("Registering with mp='{}' so='{}'", mp, so); + if (so != null) { + List longNames = new ArrayList<>(); + List shortNames = new ArrayList<>(); + if (!ObjectUtils.isEmpty(so.value())) { + Arrays.asList(so.value()).stream().forEach(o -> { + String stripped = StringUtils.trimLeadingCharacter(o, '-'); + log.debug("Registering o='{}' stripped='{}'", o, stripped); + if (o.length() == stripped.length() + 2) { + longNames.add(stripped); + } + else if (o.length() == stripped.length() + 1 && stripped.length() == 1) { + shortNames.add(stripped.charAt(0)); + } + }); + } + else { + // ShellOption value not defined + mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + String longName = mp.getParameterName(); + Class parameterType = mp.getParameterType(); + if (longName != null) { + log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType); + longNames.add(longName); + } + } + if (!longNames.isEmpty() || !shortNames.isEmpty()) { + log.debug("Registering longNames='{}' shortNames='{}'", longNames, shortNames); + OptionSpec optionSpec = builder.withOption() + .type(mp.getParameterType()) + .longNames(longNames.toArray(new String[0])) + .shortNames(shortNames.toArray(new Character[0])) + .position(mp.getParameterIndex()) + .description(so.help()); + if (so.arity() > -1) { + optionSpec.arity(0, so.arity()); + } + } + } + else { + mp.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + String longName = mp.getParameterName(); + Class parameterType = mp.getParameterType(); + if (longName != null) { + log.debug("Using mp='{}' longName='{}' parameterType='{}'", mp, longName, parameterType); + builder.withOption() + .longNames(longName) + .type(parameterType) + .required() + .position(mp.getParameterIndex()); + } + } + } + + builder.withTarget().method(bean, method); + + CommandRegistration registration = builder.build(); + registry.register(registration); } }, method -> method.getAnnotation(ShellMethod.class) != null); } @@ -190,10 +263,4 @@ else if (candidates.size() == 1) { return null; } } - - @Override - public String toString() { - return getClass().getSimpleName() + " contributing " - + collectionToDelimitedString(commands.keySet(), ", ", "[", "]"); - } } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java deleted file mode 100644 index 82bb7bf10..000000000 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardParameterResolver.java +++ /dev/null @@ -1,573 +0,0 @@ -/* - * Copyright 2015-2021 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.standard; - -import java.lang.reflect.Array; -import java.lang.reflect.Executable; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.BitSet; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.validation.Validator; -import javax.validation.ValidatorFactory; -import javax.validation.metadata.MethodDescriptor; -import javax.validation.metadata.ParameterDescriptor; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.MethodParameter; -import org.springframework.core.convert.ConversionService; -import org.springframework.core.convert.TypeDescriptor; -import org.springframework.shell.CompletionContext; -import org.springframework.shell.CompletionProposal; -import org.springframework.shell.ParameterDescription; -import org.springframework.shell.ParameterMissingResolutionException; -import org.springframework.shell.ParameterResolver; -import org.springframework.shell.UnfinishedParameterResolutionException; -import org.springframework.shell.Utils; -import org.springframework.shell.ValueResult; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import org.springframework.util.ConcurrentReferenceHashMap; -import org.springframework.util.ObjectUtils; - -import static org.springframework.shell.Utils.unCamelify; - -/** - * Default ParameterResolver implementation that supports the following features: - *
    - *
  • named parameters (recognized because they start with some - * {@link ShellMethod#prefix()})
  • - *
  • implicit named parameters (from the actual method parameter name)
  • - *
  • positional parameters (in order, for all parameter values that were not resolved - * via named parameters)
  • - *
  • default values (for all remaining parameters)
  • - *
- * - *

- * Method arguments can consume several words of input at once (driven by - * {@link ShellOption#arity()}, default 1). If several words are consumed, they will be - * joined together as a comma separated value and passed to the {@link ConversionService} - * (which will typically return a List or array). - *

- * - *

- * Boolean parameters are by default expected to have an arity of 0, allowing invocations - * in the form {@code rm - * --force --dir /foo}: the presence of {@code --force} passes {@code true} as a parameter - * value, while its absence passes {@code false}. Both the default arity of 0 and the - * default value of {@code false} can be overridden via {@link ShellOption} if - * needed. - *

- * @author Eric Bottard - * @author Florent Biville - * @author Camilo Gonzalez - */ -@Component -public class StandardParameterResolver implements ParameterResolver { - - private final ConversionService conversionService; - - private Collection valueProviders = new HashSet<>(); - - private Validator validator = Utils.defaultValidator(); - - /** - * A cache from method+input to String representation of actual parameter values. Note - * that the converted result is not cached, to allow dynamic computation to happen at - * every invocation if needed (e.g. if a remote service is involved). - */ - private final Map> parameterCache = new ConcurrentReferenceHashMap<>(); - - public StandardParameterResolver(ConversionService conversionService, Set valueProviders) { - this.conversionService = conversionService; - this.valueProviders = valueProviders; - } - - @Autowired(required = false) - public void setValidatorFactory(ValidatorFactory validatorFactory) { - this.validator = validatorFactory.getValidator(); - } - - @Override - public boolean supports(MethodParameter parameter) { - boolean optOut = parameter.hasParameterAnnotation(ShellOption.class) - && parameter.getParameterAnnotation(ShellOption.class).optOut(); - return !optOut && parameter.getMethodAnnotation(ShellMethod.class) != null; - } - - @Override - public ValueResult resolve(MethodParameter methodParameter, List wordsBuffer) { - List words = wordsBuffer.stream().filter(w -> !w.isEmpty()).collect(Collectors.toList()); - - CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), wordsBuffer); - parameterCache.clear(); - Map resolved = parameterCache.computeIfAbsent(cacheKey, (k) -> { - - Map result = new HashMap<>(); - Map namedParameters = new HashMap<>(); - - // index of words that haven't yet been used to resolve parameter values - List unusedWords = new ArrayList<>(); - - Set possibleKeys = gatherAllPossibleKeys(methodParameter.getMethod()); - - // First, resolve all parameters passed by-name - for (int i = 0; i < words.size(); i++) { - int from = i; - String word = words.get(i); - if (possibleKeys.contains(word)) { - String key = word; - Parameter parameter = lookupParameterForKey(methodParameter.getMethod(), key); - int arity = getArity(parameter); - - if (i + 1 + arity > words.size()) { - String input = words.subList(i, words.size()).stream().collect(Collectors.joining(" ")); - throw new UnfinishedParameterResolutionException( - describe(Utils.createMethodParameter(parameter)).findFirst().get(), input); - } - Assert.isTrue(i + 1 + arity <= words.size(), - String.format("Not enough input for parameter '%s'", word)); - String raw = words.subList(i + 1, i + 1 + arity).stream().collect(Collectors.joining(",")); - Assert.isTrue(!namedParameters.containsKey(key), - String.format("Parameter for '%s' has already been specified", word)); - namedParameters.put(key, raw); - if (arity == 0) { - boolean defaultValue = booleanDefaultValue(parameter); - // Boolean parameter has been specified. Use the opposite of the default value - result.put(parameter, - ParameterRawValue.explicit(String.valueOf(!defaultValue), key, from, from)); - } - else { - i += arity; - result.put(parameter, ParameterRawValue.explicit(raw, key, from, i)); - } - } // store for later processing of positional params - else { - unusedWords.add(i); - } - } - - // Now have a second pass over params and treat them as positional - int offset = 0; - Parameter[] parameters = methodParameter.getMethod().getParameters(); - for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) { - Parameter parameter = parameters[i]; - // Compute the intersection between possible keys for the param and what we've already - // seen for named params - Collection keys = getKeysForParameter(methodParameter.getMethod(), i) - .collect(Collectors.toSet()); - Collection copy = new HashSet<>(keys); - copy.retainAll(namedParameters.keySet()); - if (copy.isEmpty()) { // Was not set via a key (including aliases), must be positional - int arity = getArity(parameter); - if (arity > 0 && (offset + arity) <= unusedWords.size()) { - String raw = unusedWords.subList(offset, offset + arity).stream() - .map(index -> words.get(index)) - .collect(Collectors.joining(",")); - int from = unusedWords.get(offset); - int to = from + arity - 1; - result.put(parameter, ParameterRawValue.explicit(raw, null, from, to)); - offset += arity; - } // No more input. Try defaultValues - else { - Optional defaultValue = defaultValueFor(parameter); - defaultValue.ifPresent( - value -> result.put(parameter, ParameterRawValue.implicit(value, null, null, null))); - } - } - else if (copy.size() > 1) { - throw new IllegalArgumentException( - "Named parameter has been specified multiple times via " + quote(copy)); - } - } - - Assert.isTrue(offset == unusedWords.size(), - "Too many arguments: the following could not be mapped to parameters: " - + unusedWords.subList(offset, unusedWords.size()).stream() - .map(index -> words.get(index)).collect(Collectors.joining(" ", "'", "'"))); - return result; - }); - - Parameter param = methodParameter.getMethod().getParameters()[methodParameter.getParameterIndex()]; - if (!resolved.containsKey(param)) { - throw new ParameterMissingResolutionException(describe(methodParameter).findFirst().get()); - } - ParameterRawValue parameterRawValue = resolved.get(param); - Object value = convertRawValue(parameterRawValue, methodParameter); - BitSet wordsUsed = getWordsUsed(parameterRawValue); - BitSet wordsUsedForValue = getWordsUsedForValue(parameterRawValue); - return new ValueResult(methodParameter, value, wordsUsed, wordsUsedForValue); - } - - private BitSet getWordsUsed(ParameterRawValue parameterRawValue) { - if (parameterRawValue.from != null) { - BitSet wordsUsed = new BitSet(); - wordsUsed.set(parameterRawValue.from, parameterRawValue.to + 1); - return wordsUsed; - } - return null; - } - - private BitSet getWordsUsedForValue(ParameterRawValue parameterRawValue) { - if (parameterRawValue.from != null) { - BitSet wordsUsedForValue = new BitSet(); - wordsUsedForValue.set(parameterRawValue.from, parameterRawValue.to + 1); - if (parameterRawValue.key != null) { - wordsUsedForValue.clear(parameterRawValue.from); - } - return wordsUsedForValue; - } - return null; - } - - private Object convertRawValue(ParameterRawValue parameterRawValue, MethodParameter methodParameter) { - String s = parameterRawValue.value; - if (ShellOption.NULL.equals(s)) { - return null; - } - else { - return conversionService.convert(s, TypeDescriptor.valueOf(String.class), - new TypeDescriptor(methodParameter)); - } - } - - private Set gatherAllPossibleKeys(Method method) { - return Arrays.stream(method.getParameters()) - .flatMap(this::getKeysForParameter) - .collect(Collectors.toSet()); - } - - private String prefixForMethod(Executable method) { - return method.getAnnotation(ShellMethod.class).prefix(); - } - - private Optional defaultValueFor(Parameter parameter) { - ShellOption option = parameter.getAnnotation(ShellOption.class); - if (option != null && !ShellOption.NONE.equals(option.defaultValue())) { - return Optional.of(option.defaultValue()); - } - else if (getArity(parameter) == 0) { - return Optional.of("false"); - } - return Optional.empty(); - } - - private boolean booleanDefaultValue(Parameter parameter) { - ShellOption option = parameter.getAnnotation(ShellOption.class); - if (option != null && !ShellOption.NONE.equals(option.defaultValue())) { - return Boolean.parseBoolean(option.defaultValue()); - } - return false; - } - - @Override - public Stream describe(MethodParameter parameter) { - Parameter jlrParameter = parameter.getMethod().getParameters()[parameter.getParameterIndex()]; - int arity = getArity(jlrParameter); - Class type = parameter.getParameterType(); - ShellOption option = jlrParameter.getAnnotation(ShellOption.class); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < arity; i++) { - if (i > 0) { - sb.append(" "); - } - sb.append(arity > 1 ? unCamelify(removeMultiplicityFromType(parameter).getSimpleName()) - : unCamelify(type.getSimpleName())); - } - ParameterDescription result = ParameterDescription.outOf(parameter); - result.formal(sb.toString()); - if (option != null) { - result.help(option.help()); - Optional defaultValue = defaultValueFor(jlrParameter); - if (defaultValue.isPresent()) { - result.defaultValue(defaultValue.map(dv -> dv.equals(ShellOption.NULL) ? "" : dv).get()); - } - } - result - .keys(getKeysForParameter(parameter.getMethod(), parameter.getParameterIndex()) - .collect(Collectors.toList())) - .mandatoryKey(false); - - MethodDescriptor constraintsForMethod = validator.getConstraintsForClass(parameter.getDeclaringClass()) - .getConstraintsForMethod(parameter.getMethod().getName(), parameter.getMethod().getParameterTypes()); - if (constraintsForMethod != null) { - ParameterDescriptor constraintsDescriptor = constraintsForMethod - .getParameterDescriptors().get(parameter.getParameterIndex()); - result.elementDescriptor(constraintsDescriptor); - } - - return Stream.of(result); - } - - @Override - public List complete(MethodParameter methodParameter, CompletionContext context) { - boolean set; - Exception unfinished = null; - // First try to see if this parameter has been set, even to some unfinished value - ParameterRawValue parameterRawValue = null; - int arity = 1; - try { - resolve(methodParameter, context.getWords()); - CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), context.getWords()); - Parameter parameter = methodParameter.getMethod().getParameters()[methodParameter.getParameterIndex()]; - arity = getArity(parameter); - parameterRawValue = parameterCache.get(cacheKey).get(parameter); - set = parameterRawValue.explicit; - } - catch (ParameterMissingResolutionException e) { - set = false; - } - catch (UnfinishedParameterResolutionException e) { - if (e.getParameterDescription().parameter().equals(methodParameter)) { - unfinished = e; - set = false; - } - else { - return Collections.emptyList(); - } - } - catch (Exception e) { - // Most likely what is already typed would fail resolution (eg type conversion failure) - return argumentKeysThatStartWithContextPrefix(methodParameter, context); - } - - // There are 4 possible cases: - // 1) parameter not set at all - // 2) parameter set via its key, not enough input to consume a value - // 3) parameter set with multiple values, enough to cover arity. We're done - // 4) parameter set, and some value bound. But maybe that value is just a prefix to what - // the user actually wants - // 4.1) or maybe that value was resolved by position, but is a prefix of an actual valid - // key - - if (!set) { - if (unfinished == null) { - // case 1 above - return argumentKeysThatStartWithContextPrefix(methodParameter, context); - } // case 2 - else { - return valueCompletions(methodParameter, context); - } - } - else { - List result = new ArrayList<>(); - - Object value = convertRawValue(parameterRawValue, methodParameter); - if (value instanceof Collection && ((Collection) value).size() == arity - || (ObjectUtils.isArray(value) && Array.getLength(value) == arity)) { - // We're done already - return result; - } - if (!context.currentWord().equals("")) { - // Case 4 - result.addAll(valueCompletions(methodParameter, context)); - } - - if (parameterRawValue.positional()) { - // Case 4.1: There exists "--command foo" and user has typed "--comm" which (wrongly) got - // resolved as a positional param - result.addAll(argumentKeysThatStartWithContextPrefix(methodParameter, context)); - } - return result; - } - } - - private List valueCompletions(MethodParameter methodParameter, - CompletionContext completionContext) { - return valueProviders.stream() - .filter(vp -> vp.supports(methodParameter, completionContext)) - .map(vp -> vp.complete(methodParameter, completionContext, null)) - .findFirst().orElseGet(() -> Collections.emptyList()); - } - - private List argumentKeysThatStartWithContextPrefix(MethodParameter methodParameter, - CompletionContext context) { - String prefix = context.currentWordUpToCursor() != null ? context.currentWordUpToCursor() : ""; - return describe(methodParameter).flatMap(pd -> pd.keys().stream()) - .filter(k -> k.startsWith(prefix)) - .map(CompletionProposal::new) - .collect(Collectors.toList()); - } - - /** - * In case of {@code foo[] or Collection} and arity > 1, return the element type. - */ - private Class removeMultiplicityFromType(MethodParameter parameter) { - Class parameterType = parameter.getParameterType(); - if (parameterType.isArray()) { - return parameterType.getComponentType(); - } - else if (Collection.class.isAssignableFrom(parameterType)) { - return parameter.getNestedParameterType(); - } - else { - throw new RuntimeException("For " + parameter + " (with arity > 1) expected an array/collection type"); - } - } - - /** - * Surrounds the parameter keys with quotes. - */ - private String quote(Collection keys) { - return keys.stream().collect(Collectors.joining(", ", "'", "'")); - } - - /** - * Return the arity of a given parameter. The default arity is 1, except for booleans - * where arity is 0 (can be overridden back to 1 via an annotation) - */ - private int getArity(Parameter parameter) { - ShellOption option = parameter.getAnnotation(ShellOption.class); - int inferred = (parameter.getType() == boolean.class || parameter.getType() == Boolean.class) ? 0 : 1; - return option != null && option.arity() != ShellOption.ARITY_USE_HEURISTICS ? option.arity() : inferred; - } - - /** - * Return the key(s) for the i-th parameter of the command method, resolved either from - * the {@link ShellOption} annotation, or from the actual parameter name. - */ - private Stream getKeysForParameter(Method method, int index) { - Parameter p = method.getParameters()[index]; - return getKeysForParameter(p); - } - - private Stream getKeysForParameter(Parameter p) { - Executable method = p.getDeclaringExecutable(); - String prefix = prefixForMethod(method); - ShellOption option = p.getAnnotation(ShellOption.class); - if (option != null && option.value().length > 0) { - return Arrays.stream(option.value()); - } - else { - return Stream.of(prefix + Utils.unCamelify(Utils.createMethodParameter(p).getParameterName())); - } - } - - /** - * Return the method parameter that should be bound to the given key. - */ - private Parameter lookupParameterForKey(Method method, String key) { - Parameter[] parameters = method.getParameters(); - for (int i = 0, parametersLength = parameters.length; i < parametersLength; i++) { - Parameter p = parameters[i]; - if (getKeysForParameter(method, i).anyMatch(k -> k.equals(key))) { - return p; - } - } - throw new IllegalArgumentException(String.format("Could not look up parameter for '%s' in %s", key, method)); - } - - private static class CacheKey { - - private final Method method; - - private final List words; - - private CacheKey(Method method, List words) { - this.method = method; - this.words = words; - } - - @Override - public boolean equals(Object o) { - if (this == o) - return true; - if (o == null || getClass() != o.getClass()) - return false; - CacheKey cacheKey = (CacheKey) o; - return Objects.equals(method, cacheKey.method) && - Objects.equals(words, cacheKey.words); - } - - @Override - public int hashCode() { - return Objects.hash(method, words); - } - - @Override - public String toString() { - return method.getName() + " " + words; - } - } - - private static class ParameterRawValue { - - private Integer from; - - private Integer to; - - /** - * The raw String value that got bound to a parameter. - */ - private final String value; - - /** - * If false, the value resolved is the result of applying defaults. - */ - private final boolean explicit; - - /** - * The key that was used to set the parameter, or null if resolution happened by position. - */ - private final String key; - - private ParameterRawValue(String value, boolean explicit, String key, Integer from, Integer to) { - this.value = value; - this.explicit = explicit; - this.key = key; - this.from = from; - this.to = to; - } - - public static ParameterRawValue explicit(String value, String key, Integer from, Integer to) { - return new ParameterRawValue(value, true, key, from, to); - } - - public static ParameterRawValue implicit(String value, String key, Integer from, Integer to) { - return new ParameterRawValue(value, false, key, from, to); - } - - public boolean positional() { - return key == null; - } - - @Override - public String toString() { - return "ParameterRawValue{" + - "value='" + value + '\'' + - ", explicit=" + explicit + - ", key='" + key + '\'' + - ", from=" + from + - ", to=" + to + - '}'; - } - } - -} diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java index 5c66346ef..0c61bcc70 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java @@ -22,10 +22,10 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -36,11 +36,8 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -import org.springframework.shell.CommandRegistry; -import org.springframework.shell.MethodTarget; -import org.springframework.shell.ParameterDescription; -import org.springframework.shell.ParameterResolver; -import org.springframework.shell.Utils; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; import org.springframework.util.FileCopyUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -54,14 +51,11 @@ public abstract class AbstractCompletions { private final ResourceLoader resourceLoader; - private final CommandRegistry commandRegistry; - private final List parameterResolvers; + private final CommandCatalog commandCatalog; - public AbstractCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry, - List parameterResolvers) { + public AbstractCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) { this.resourceLoader = resourceLoader; - this.commandRegistry = commandRegistry; - this.parameterResolvers = parameterResolvers; + this.commandCatalog = commandCatalog; } protected Builder builder() { @@ -74,12 +68,12 @@ protected Builder builder() { * all needed to build completions structure. */ protected CommandModel generateCommandModel() { - Map commandsByName = commandRegistry.listCommands(); + Collection commandsByName = commandCatalog.getRegistrations().values(); HashMap commands = new HashMap<>(); HashSet topCommands = new HashSet<>(); - commandsByName.entrySet().stream() - .forEach(entry -> { - String key = entry.getKey(); + commandsByName.stream() + .forEach(registration -> { + String key = registration.getCommand(); String[] splitKeys = key.split(" "); String commandKey = ""; for (int i = 0; i < splitKeys.length; i++) { @@ -94,12 +88,13 @@ protected CommandModel generateCommandModel() { } DefaultCommandModelCommand command = commands.computeIfAbsent(commandKey, (fullCommand) -> new DefaultCommandModelCommand(fullCommand, main)); - MethodTarget methodTarget = entry.getValue(); - List parameterDescriptions = getParameterDescriptions(methodTarget); - List options = parameterDescriptions.stream() - .flatMap(pd -> pd.keys().stream()) - .map(k -> new DefaultCommandModelOption(k)) - .collect(Collectors.toList()); + + // TODO long vs short + List options = registration.getOptions().stream() + .flatMap(co -> Arrays.stream(co.getLongNames())) + .map(lo -> CommandModelOption.of("--", lo)) + .collect(Collectors.toList()); + if (i == splitKeys.length - 1) { command.addOptions(options); } @@ -114,13 +109,6 @@ protected CommandModel generateCommandModel() { return new DefaultCommandModel(new ArrayList<>(topCommands)); } - private List getParameterDescriptions(MethodTarget methodTarget) { - return Utils.createMethodParameters(methodTarget.getMethod()) - .flatMap(mp -> parameterResolvers.stream().filter(pr -> pr.supports(mp)).limit(1L) - .flatMap(pr -> pr.describe(mp))) - .collect(Collectors.toList()); - } - /** * Interface for a command model structure. Is also used as entry model * for ST4 templates which is a reason it has utility methods for easier usage @@ -208,6 +196,10 @@ interface CommandModelCommand { interface CommandModelOption { String option(); + + static CommandModelOption of(String prefix, String name) { + return new DefaultCommandModelOption(String.format("%s%s", prefix, name)); + } } class DefaultCommandModel implements CommandModel { @@ -294,7 +286,7 @@ public List getOptions() { return options; } - void addOptions(List options) { + void addOptions(List options) { this.options.addAll(options); } @@ -341,7 +333,7 @@ private AbstractCompletions getEnclosingInstance() { } } - class DefaultCommandModelOption implements CommandModelOption { + static class DefaultCommandModelOption implements CommandModelOption { private String option; diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java index c08cb7f74..291bbf481 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java @@ -15,11 +15,8 @@ */ package org.springframework.shell.standard.completion; -import java.util.List; - import org.springframework.core.io.ResourceLoader; -import org.springframework.shell.CommandRegistry; -import org.springframework.shell.ParameterResolver; +import org.springframework.shell.command.CommandCatalog; /** * Completion script generator for a {@code bash}. @@ -28,9 +25,8 @@ */ public class BashCompletions extends AbstractCompletions { - public BashCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry, - List parameterResolvers) { - super(resourceLoader, commandRegistry, parameterResolvers); + public BashCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) { + super(resourceLoader, commandCatalog); } public String generate(String rootCommand) { diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java index 1ab74e7ad..28edef468 100644 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java @@ -29,11 +29,11 @@ import org.mockito.MockitoAnnotations; import org.springframework.core.MethodParameter; -import org.springframework.shell.CommandRegistry; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; -import org.springframework.shell.MethodTarget; import org.springframework.shell.Utils; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -47,7 +47,7 @@ public class CommandValueProviderTest { @Mock - private CommandRegistry shell; + private CommandCatalog catalog; @BeforeEach public void setUp() { @@ -56,7 +56,7 @@ public void setUp() { @Test public void testValues() { - CommandValueProvider valueProvider = new CommandValueProvider(shell); + CommandValueProvider valueProvider = new CommandValueProvider(catalog); Method help = ReflectionUtils.findMethod(Command.class, "help", String.class); MethodParameter methodParameter = Utils.createMethodParameter(help, 0); @@ -64,12 +64,12 @@ public void testValues() { boolean supports = valueProvider.supports(methodParameter, completionContext); assertThat(supports).isEqualTo(true); + Map registrations = new HashMap<>(); + registrations.put("me", null); + registrations.put("meow", null); + registrations.put("yourself", null); - Map commands = new HashMap<>(); - commands.put("me", null); - commands.put("meow", null); - commands.put("yourself", null); - when(shell.listCommands()).thenReturn(commands); + when(catalog.getRegistrations()).thenReturn(registrations); List proposals = valueProvider.complete(methodParameter, completionContext, new String[0]); assertThat(proposals).extracting("value", String.class) diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java deleted file mode 100644 index 0e6f18bf0..000000000 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTest.java +++ /dev/null @@ -1,304 +0,0 @@ -/* - * Copyright 2017-2022 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.standard; - -import java.util.Map; - -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; - -import org.springframework.context.ApplicationContext; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.shell.Availability; -import org.springframework.shell.ConfigurableCommandRegistry; -import org.springframework.shell.MethodTarget; -import org.springframework.shell.context.DefaultShellContext; -import org.springframework.shell.context.InteractionMode; -import org.springframework.shell.standard.test1.GroupOneCommands; -import org.springframework.shell.standard.test2.GroupThreeCommands; -import org.springframework.shell.standard.test2.GroupTwoCommands; -import org.springframework.util.ReflectionUtils; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Unit tests for {@link StandardMethodTargetRegistrar}. - * - * @author Eric Bottard - */ -public class StandardMethodTargetRegistrarTest { - - private StandardMethodTargetRegistrar registrar = new StandardMethodTargetRegistrar(); - private ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(new DefaultShellContext()); - - @Test - public void testRegistrations() { - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(Sample.class); - registrar.setApplicationContext(applicationContext); - registrar.register(registry); - - MethodTarget methodTarget = registry.listCommands().get("say-hello"); - assertThat(methodTarget).isNotNull(); - assertThat(methodTarget.getHelp()).isEqualTo("some command"); - assertThat(methodTarget.getMethod()).isEqualTo(ReflectionUtils.findMethod(Sample.class, "sayHello", String.class)); - assertThat(methodTarget.getAvailability().isAvailable()).isTrue(); - - methodTarget = registry.listCommands().get("hi"); - assertThat(methodTarget).isNotNull(); - assertThat(methodTarget.getHelp()).isEqualTo("method with alias"); - assertThat(methodTarget.getMethod()).isEqualTo(ReflectionUtils.findMethod(Sample.class, "greet", String.class)); - assertThat(methodTarget.getAvailability().isAvailable()).isTrue(); - methodTarget = registry.listCommands().get("alias"); - assertThat(methodTarget).isNotNull(); - assertThat(methodTarget.getHelp()).isEqualTo("method with alias"); - assertThat(methodTarget.getMethod()).isEqualTo(ReflectionUtils.findMethod(Sample.class, "greet", String.class)); - assertThat(methodTarget.getAvailability().isAvailable()).isTrue(); - } - - @ShellComponent - public static class Sample { - - @ShellMethod("some command") - public String sayHello(String what) { - return "hello " + what; - } - - @ShellMethod(value = "method with alias", key = {"hi", "alias"}) - public String greet(String what) { - return "hi " + what; - } - } - - @Test - public void testAvailabilityIndicators() { - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SampleWithAvailability.class); - registrar.setApplicationContext(applicationContext); - registrar.register(registry); - SampleWithAvailability sample = applicationContext.getBean(SampleWithAvailability.class); - - MethodTarget methodTarget = registry.listCommands().get("say-hello"); - assertThat(methodTarget.getMethod()).isEqualTo(ReflectionUtils.findMethod(SampleWithAvailability.class, "sayHello")); - assertThat(methodTarget.getAvailability().isAvailable()).isTrue(); - sample.available = false; - assertThat(methodTarget.getAvailability().isAvailable()).isFalse(); - assertThat(methodTarget.getAvailability().getReason()).isEqualTo("sayHelloAvailability"); - sample.available = true; - - methodTarget = registry.listCommands().get("hi"); - assertThat(methodTarget.getMethod()).isEqualTo(ReflectionUtils.findMethod(SampleWithAvailability.class, "hi")); - assertThat(methodTarget.getAvailability().isAvailable()).isTrue(); - sample.available = false; - assertThat(methodTarget.getAvailability().isAvailable()).isFalse(); - assertThat(methodTarget.getAvailability().getReason()).isEqualTo("customAvailabilityMethod"); - sample.available = true; - - methodTarget = registry.listCommands().get("bonjour"); - assertThat(methodTarget.getMethod()).isEqualTo(ReflectionUtils.findMethod(SampleWithAvailability.class, "bonjour")); - assertThat(methodTarget.getAvailability().isAvailable()).isTrue(); - sample.available = false; - assertThat(methodTarget.getAvailability().isAvailable()).isFalse(); - assertThat(methodTarget.getAvailability().getReason()).isEqualTo("availabilityForSeveralCommands"); - sample.available = true; - } - - @ShellComponent - public static class SampleWithAvailability { - - private boolean available = true; - - @ShellMethod("some command with an implicit availability indicator") - public void sayHello() { - - } - public Availability sayHelloAvailability() { - return available ? Availability.available() : Availability.unavailable("sayHelloAvailability"); - } - - - @ShellMethodAvailability("customAvailabilityMethod") - @ShellMethod("some method with an explicit availability indicator") - public void hi() { - - } - public Availability customAvailabilityMethod() { - return available ? Availability.available() : Availability.unavailable("customAvailabilityMethod"); - } - - @ShellMethod(value = "some method with an explicit availability indicator", key = {"bonjour", "salut"}) - public void bonjour() { - - } - @ShellMethodAvailability({"salut", "other"}) - public Availability availabilityForSeveralCommands() { - return available ? Availability.available() : Availability.unavailable("availabilityForSeveralCommands"); - } - - - @ShellMethod("a command whose availability indicator will come from wildcard") - public void wild() { - - } - - @ShellMethodAvailability("*") - private Availability availabilityFromWildcard() { - return available ? Availability.available() : Availability.unavailable("availabilityFromWildcard"); - } - } - - @Test - public void testAvailabilityIndicatorErrorMultipleExplicit() { - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorOnShellMethod.class); - registrar.setApplicationContext(applicationContext); - - assertThatThrownBy(() -> { - registrar.register(registry); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("When set on a @ShellMethod method, the value of the @ShellMethodAvailability should be a single element") - .hasMessageContaining("Found [one, two]") - .hasMessageContaining("wrong()"); - } - - @ShellComponent - public static class WrongAvailabilityIndicatorOnShellMethod { - - @ShellMethodAvailability({"one", "two"}) - @ShellMethod("foo") - public void wrong() { - - } - } - - @Test - public void testAvailabilityIndicatorWildcardNotAlone() { - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorWildcardNotAlone.class); - registrar.setApplicationContext(applicationContext); - - assertThatThrownBy(() -> { - registrar.register(registry); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("When using '*' as a wildcard for ShellMethodAvailability, this can be the only value. Found [one, *]") - .hasMessageContaining("availability()"); - } - - @ShellComponent - public static class WrongAvailabilityIndicatorWildcardNotAlone { - - @ShellMethodAvailability({"one", "*"}) - public Availability availability() { - return Availability.available(); - } - - @ShellMethod("foo") - public void wrong() { - - } - } - - @Test - public void testAvailabilityIndicatorAmbiguous() { - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorAmbiguous.class); - registrar.setApplicationContext(applicationContext); - - assertThatThrownBy(() -> { - registrar.register(registry); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Found several @ShellMethodAvailability") - .hasMessageContaining("wrong()") - .hasMessageContaining("availability()") - .hasMessageContaining("otherAvailability()"); - } - - @ShellComponent - public static class WrongAvailabilityIndicatorAmbiguous { - - @ShellMethodAvailability({"one", "wrong"}) - public Availability availability() { - return Availability.available(); - } - - @ShellMethodAvailability({"bar", "wrong"}) - public Availability otherAvailability() { - return Availability.available(); - } - - @ShellMethod("foo") - public void wrong() { - - } - } - - @Test - public void testGrouping() { - ApplicationContext context = new AnnotationConfigApplicationContext(GroupOneCommands.class, - GroupTwoCommands.class, GroupThreeCommands.class); - registrar.setApplicationContext(context); - registrar.register(registry); - - Map commands = registry.listCommands(); - Assertions.assertThat(commands.get("explicit1").getGroup()).isEqualTo("Explicit Group Method Level 1"); - Assertions.assertThat(commands.get("explicit2").getGroup()).isEqualTo("Explicit Group Method Level 2"); - Assertions.assertThat(commands.get("explicit3").getGroup()).isEqualTo("Explicit Group Method Level 3"); - Assertions.assertThat(commands.get("implicit1").getGroup()).isEqualTo("Implicit Group Package Level 1"); - Assertions.assertThat(commands.get("implicit2").getGroup()).isEqualTo("Group Two Commands"); - Assertions.assertThat(commands.get("implicit3").getGroup()).isEqualTo("Explicit Group 3 Class Level"); - } - - @Test - public void testInteractionModeInteractive() { - DefaultShellContext shellContext = new DefaultShellContext(); - shellContext.setInteractionMode(InteractionMode.INTERACTIVE); - registry = new ConfigurableCommandRegistry(shellContext); - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(InteractionModeCommands.class); - registrar.setApplicationContext(applicationContext); - registrar.register(registry); - - assertThat(registry.listCommands().get("foo1")).isNotNull(); - assertThat(registry.listCommands().get("foo2")).isNull(); - assertThat(registry.listCommands().get("foo3")).isNotNull(); - } - - @Test - public void testInteractionModeNonInteractive() { - DefaultShellContext shellContext = new DefaultShellContext(); - shellContext.setInteractionMode(InteractionMode.NONINTERACTIVE); - registry = new ConfigurableCommandRegistry(shellContext); - ApplicationContext applicationContext = new AnnotationConfigApplicationContext(InteractionModeCommands.class); - registrar.setApplicationContext(applicationContext); - registrar.register(registry); - - assertThat(registry.listCommands().get("foo1")).isNull(); - assertThat(registry.listCommands().get("foo2")).isNotNull(); - assertThat(registry.listCommands().get("foo3")).isNotNull(); - } - - @ShellComponent - public static class InteractionModeCommands { - - @ShellMethod(value = "foo1", interactionMode = InteractionMode.INTERACTIVE) - public void foo1() { - } - - @ShellMethod(value = "foo2", interactionMode = InteractionMode.NONINTERACTIVE) - public void foo2() { - } - - @ShellMethod(value = "foo3") - public void foo3() { - } - } -} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTests.java new file mode 100644 index 000000000..f3de4d8a7 --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardMethodTargetRegistrarTests.java @@ -0,0 +1,326 @@ +/* + * Copyright 2017-2022 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.standard; + +import java.util.Map; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.shell.Availability; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.context.DefaultShellContext; +import org.springframework.shell.context.InteractionMode; +import org.springframework.shell.standard.test1.GroupOneCommands; +import org.springframework.shell.standard.test2.GroupThreeCommands; +import org.springframework.shell.standard.test2.GroupTwoCommands; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link StandardMethodTargetRegistrar}. + * + * @author Eric Bottard + */ +public class StandardMethodTargetRegistrarTests { + + private StandardMethodTargetRegistrar registrar = new StandardMethodTargetRegistrar(); + private AnnotationConfigApplicationContext applicationContext; + private CommandCatalog catalog; + private DefaultShellContext shellContext; + + @BeforeEach + public void setup() { + shellContext = new DefaultShellContext(); + catalog = CommandCatalog.of(null, shellContext); + } + + @AfterEach + public void cleanup() { + if (applicationContext != null) { + applicationContext.close(); + } + applicationContext = null; + catalog = null; + } + + @Test + public void testRegistrations() { + applicationContext = new AnnotationConfigApplicationContext(Sample.class); + registrar.setApplicationContext(applicationContext); + registrar.register(catalog); + Map registrations = catalog.getRegistrations(); + assertThat(registrations).hasSize(3); + + assertThat(registrations.get("say-hello")).isNotNull(); + assertThat(registrations.get("say-hello").getAvailability()).isNotNull(); + assertThat(registrations.get("say-hello").getOptions()).hasSize(1); + assertThat(registrations.get("say-hello").getOptions().get(0).getLongNames()).containsExactly("what"); + + assertThat(registrations.get("hi")).isNotNull(); + assertThat(registrations.get("hi").getAvailability()).isNotNull(); + + assertThat(registrations.get("alias")).isNotNull(); + assertThat(registrations.get("alias").getAvailability()).isNotNull(); + } + + @ShellComponent + public static class Sample { + + @ShellMethod("some command") + public String sayHello(String what) { + return "hello " + what; + } + + @ShellMethod(value = "method with alias", key = {"hi", "alias"}) + public String greet(String what) { + return "hi " + what; + } + } + + @Test + public void testAvailabilityIndicators() { + applicationContext = new AnnotationConfigApplicationContext(SampleWithAvailability.class); + SampleWithAvailability sample = applicationContext.getBean(SampleWithAvailability.class); + registrar.setApplicationContext(applicationContext); + registrar.register(catalog); + Map registrations = catalog.getRegistrations(); + + assertThat(registrations.get("say-hello")).isNotNull(); + assertThat(registrations.get("say-hello").getAvailability().isAvailable()).isTrue(); + sample.available = false; + assertThat(registrations.get("say-hello").getAvailability().isAvailable()).isFalse(); + assertThat(registrations.get("say-hello").getAvailability().getReason()).isEqualTo("sayHelloAvailability"); + sample.available = true; + + assertThat(registrations.get("hi")).isNotNull(); + assertThat(registrations.get("hi").getAvailability().isAvailable()).isTrue(); + sample.available = false; + assertThat(registrations.get("hi").getAvailability().isAvailable()).isFalse(); + assertThat(registrations.get("hi").getAvailability().getReason()).isEqualTo("customAvailabilityMethod"); + sample.available = true; + + assertThat(registrations.get("bonjour")).isNotNull(); + assertThat(registrations.get("bonjour").getAvailability().isAvailable()).isTrue(); + sample.available = false; + assertThat(registrations.get("bonjour").getAvailability().isAvailable()).isFalse(); + assertThat(registrations.get("bonjour").getAvailability().getReason()).isEqualTo("availabilityForSeveralCommands"); + sample.available = true; + } + + @ShellComponent + public static class SampleWithAvailability { + + private boolean available = true; + + @ShellMethod("some command with an implicit availability indicator") + public void sayHello() { + + } + public Availability sayHelloAvailability() { + return available ? Availability.available() : Availability.unavailable("sayHelloAvailability"); + } + + + @ShellMethodAvailability("customAvailabilityMethod") + @ShellMethod("some method with an explicit availability indicator") + public void hi() { + + } + public Availability customAvailabilityMethod() { + return available ? Availability.available() : Availability.unavailable("customAvailabilityMethod"); + } + + @ShellMethod(value = "some method with an explicit availability indicator", key = {"bonjour", "salut"}) + public void bonjour() { + + } + + @ShellMethodAvailability({"salut", "other"}) + public Availability availabilityForSeveralCommands() { + return available ? Availability.available() : Availability.unavailable("availabilityForSeveralCommands"); + } + + @ShellMethod("a command whose availability indicator will come from wildcard") + public void wild() { + + } + + @ShellMethodAvailability("*") + private Availability availabilityFromWildcard() { + return available ? Availability.available() : Availability.unavailable("availabilityFromWildcard"); + } + } + + @Test + public void testAvailabilityIndicatorErrorMultipleExplicit() { + applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorOnShellMethod.class); + registrar.setApplicationContext(applicationContext); + + assertThatThrownBy(() -> { + registrar.register(catalog); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("When set on a @ShellMethod method, the value of the @ShellMethodAvailability should be a single element") + .hasMessageContaining("Found [one, two]") + .hasMessageContaining("wrong()"); + } + + @ShellComponent + public static class WrongAvailabilityIndicatorOnShellMethod { + + @ShellMethodAvailability({"one", "two"}) + @ShellMethod("foo") + public void wrong() { + } + } + + @Test + public void testAvailabilityIndicatorWildcardNotAlone() { + applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorWildcardNotAlone.class); + registrar.setApplicationContext(applicationContext); + + assertThatThrownBy(() -> { + registrar.register(catalog); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("When using '*' as a wildcard for ShellMethodAvailability, this can be the only value. Found [one, *]") + .hasMessageContaining("availability()"); + } + + @ShellComponent + public static class WrongAvailabilityIndicatorWildcardNotAlone { + + @ShellMethodAvailability({"one", "*"}) + public Availability availability() { + return Availability.available(); + } + + @ShellMethod("foo") + public void wrong() { + + } + } + + @Test + public void testAvailabilityIndicatorAmbiguous() { + applicationContext = new AnnotationConfigApplicationContext(WrongAvailabilityIndicatorAmbiguous.class); + registrar.setApplicationContext(applicationContext); + + assertThatThrownBy(() -> { + registrar.register(catalog); + }).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Found several @ShellMethodAvailability") + .hasMessageContaining("wrong()") + .hasMessageContaining("availability()") + .hasMessageContaining("otherAvailability()"); + } + + @ShellComponent + public static class WrongAvailabilityIndicatorAmbiguous { + + @ShellMethodAvailability({"one", "wrong"}) + public Availability availability() { + return Availability.available(); + } + + @ShellMethodAvailability({"bar", "wrong"}) + public Availability otherAvailability() { + return Availability.available(); + } + + @ShellMethod("foo") + public void wrong() { + + } + } + + @Test + public void testGrouping() { + applicationContext = new AnnotationConfigApplicationContext(GroupOneCommands.class, + GroupTwoCommands.class, GroupThreeCommands.class); + registrar.setApplicationContext(applicationContext); + registrar.register(catalog); + + assertThat(catalog.getRegistrations().get("explicit1")).satisfies(registration -> { + assertThat(registration).isNotNull(); + assertThat(registration.getGroup()).isEqualTo("Explicit Group Method Level 1"); + }); + assertThat(catalog.getRegistrations().get("explicit2")).satisfies(registration -> { + assertThat(registration).isNotNull(); + assertThat(registration.getGroup()).isEqualTo("Explicit Group Method Level 2"); + }); + assertThat(catalog.getRegistrations().get("explicit3")).satisfies(registration -> { + assertThat(registration).isNotNull(); + assertThat(registration.getGroup()).isEqualTo("Explicit Group Method Level 3"); + }); + assertThat(catalog.getRegistrations().get("implicit1")).satisfies(registration -> { + assertThat(registration).isNotNull(); + assertThat(registration.getGroup()).isEqualTo("Implicit Group Package Level 1"); + }); + assertThat(catalog.getRegistrations().get("implicit2")).satisfies(registration -> { + assertThat(registration).isNotNull(); + assertThat(registration.getGroup()).isEqualTo("Group Two Commands"); + }); + assertThat(catalog.getRegistrations().get("implicit3")).satisfies(registration -> { + assertThat(registration).isNotNull(); + assertThat(registration.getGroup()).isEqualTo("Explicit Group 3 Class Level"); + }); + } + + @Test + public void testInteractionModeInteractive() { + shellContext.setInteractionMode(InteractionMode.INTERACTIVE); + applicationContext = new AnnotationConfigApplicationContext(InteractionModeCommands.class); + registrar.setApplicationContext(applicationContext); + registrar.register(catalog); + + assertThat(catalog.getRegistrations().get("foo1")).isNotNull(); + assertThat(catalog.getRegistrations().get("foo2")).isNull(); + assertThat(catalog.getRegistrations().get("foo3")).isNotNull(); + } + + @Test + public void testInteractionModeNonInteractive() { + shellContext.setInteractionMode(InteractionMode.NONINTERACTIVE); + applicationContext = new AnnotationConfigApplicationContext(InteractionModeCommands.class); + registrar.setApplicationContext(applicationContext); + registrar.register(catalog); + + assertThat(catalog.getRegistrations().get("foo1")).isNull(); + assertThat(catalog.getRegistrations().get("foo2")).isNotNull(); + assertThat(catalog.getRegistrations().get("foo3")).isNotNull(); + } + + @ShellComponent + public static class InteractionModeCommands { + + @ShellMethod(value = "foo1", interactionMode = InteractionMode.INTERACTIVE) + public void foo1() { + } + + @ShellMethod(value = "foo2", interactionMode = InteractionMode.NONINTERACTIVE) + public void foo2() { + } + + @ShellMethod(value = "foo3") + public void foo3() { + } + } +} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardParameterResolverTest.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardParameterResolverTest.java deleted file mode 100644 index 834cba2dc..000000000 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/StandardParameterResolverTest.java +++ /dev/null @@ -1,272 +0,0 @@ -/* - * Copyright 2015 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.standard; - -import java.lang.reflect.Method; -import java.util.Collections; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import org.jline.reader.ParsedLine; -import org.jline.reader.impl.DefaultParser; -import org.junit.jupiter.api.Test; - -import org.springframework.core.convert.support.DefaultConversionService; -import org.springframework.shell.CompletionContext; -import org.springframework.shell.CompletionProposal; -import org.springframework.shell.ParameterMissingResolutionException; -import org.springframework.shell.UnfinishedParameterResolutionException; -import org.springframework.shell.Utils; -import org.springframework.shell.ValueResult; - -import static java.util.Arrays.asList; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.springframework.shell.ValueResultAsserts.assertThat; -import static org.springframework.util.ReflectionUtils.findMethod; - -/** - * Unit tests for DefaultParameterResolver. - * @author Eric Bottard - * @author Florent Biville - */ -public class StandardParameterResolverTest { - - // private StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), Collections.emptySet()); - - // Tests for resolution - - @Test - public void testParses() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - - List words = asList("--force --name --foo y".split(" ")); - ValueResult result0 = resolver.resolve(Utils.createMethodParameter(method, 0), words); - assertThat(result0).hasValue(true).usesWords(0).notUsesWordsForValue(); - assertThat(result0.wordsUsed(words)).containsExactly("--force"); - - ValueResult result1 = resolver.resolve(Utils.createMethodParameter(method, 1), words); - assertThat(result1).hasValue("--foo").usesWords(1, 2).usesWordsForValue(2); - assertThat(result1.wordsUsed(words)).containsExactly("--name", "--foo"); - assertThat(result1.wordsUsedForValue(words)).containsExactly("--foo"); - - ValueResult result2 = resolver.resolve(Utils.createMethodParameter(method, 2), words); - assertThat(result2).hasValue("y").usesWords(3).usesWordsForValue(3); - assertThat(result2.wordsUsed(words)).containsExactly("y"); - - ValueResult result3 = resolver.resolve(Utils.createMethodParameter(method, 3), words); - assertThat(result3).hasValue("last").notUsesWords().notUsesWordsForValue(); - } - - @Test - public void testParsesWithMethodPrefix() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "prefixTest", String.class); - - ValueResult result = resolver.resolve(Utils.createMethodParameter(method, 0), - asList("-message abc".split(" "))); - assertThat(result).hasValue("abc").usesWords(0, 1).usesWordsForValue(1); - } - - @Test - public void testParameterSpecifiedTwiceViaDifferentAliases() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - - assertThatThrownBy(() -> { - resolver.resolve( - Utils.createMethodParameter(method, 0), - asList("--force --name --foo y --bar x --baz z".split(" "))); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Named parameter has been specified multiple times via '--bar, --baz'"); - } - - @Test - public void testParameterSpecifiedTwiceViaSameKey() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - - assertThatThrownBy(() -> { - resolver.resolve( - Utils.createMethodParameter(method, 0), - asList("--force --name --foo y --baz x --baz z".split(" "))); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Parameter for '--baz' has already been specified"); - } - - @Test - public void testTooMuchInput() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - - assertThatThrownBy(() -> { - resolver.resolve( - Utils.createMethodParameter(method, 0), - asList("--foo hello --name bar --force --bar well leftover".split(" "))); - }).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("the following could not be mapped to parameters: 'leftover'"); - } - - @Test - public void testIncompleteCommandResolution() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "shutdown", Remote.Delay.class); - - assertThatThrownBy(() -> { - resolver.resolve( - Utils.createMethodParameter(method, 0), - asList("--delay".split(" "))); - }).isInstanceOf(UnfinishedParameterResolutionException.class) - .hasMessageContaining("Error trying to resolve '--delay delay' using [--delay]"); - } - - @Test - public void testIncompleteCommandResolutionBigArity() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "add", List.class); - - assertThatThrownBy(() -> { - resolver.resolve( - Utils.createMethodParameter(method, 0), - asList("--numbers 1 2".split(" "))); - }).isInstanceOf(UnfinishedParameterResolutionException.class) - .hasMessageContaining("Error trying to resolve '--numbers list list list' using [--numbers 1 2]"); - } - - @Test - public void testUnresolvableArg() throws Exception { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - - assertThatThrownBy(() -> { - resolver.resolve( - Utils.createMethodParameter(method, 1), - asList("--foo hello --force --bar well".split(" "))); - }).isInstanceOf(ParameterMissingResolutionException.class) - .hasMessageContaining("Parameter '--name string' should be specified"); - } - - // Tests for completion - - @Test - public void testParameterKeyNotYetSetAppearsInProposals() { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - List completions = resolver.complete( - Utils.createMethodParameter(method, 1), - contextFor("") - ).stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("--name"); - completions = resolver.complete( - Utils.createMethodParameter(method, 1), - contextFor("--force ") - ).stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("--name"); - } - - @Test - public void testParameterKeyNotFullySpecified() { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - List completions = resolver.complete( - Utils.createMethodParameter(method, 1), - contextFor("--na") - ).stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("--name"); - completions = resolver.complete( - Utils.createMethodParameter(method, 1), - contextFor("--force --na") - ).stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("--name"); - } - - @Test - public void testNoMoreAvailableParameters() { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "zap", boolean.class, String.class, String.class, String.class); - List completions = resolver.complete( - Utils.createMethodParameter(method, 2), // trying to complete --foo - contextFor("--name ") // but input is currently focused on --name - ).stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).isEmpty(); - } - - @Test - public void testNotTheRightTimeToCompleteThatParameter() { - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - Method method = findMethod(Remote.class, "shutdown", Remote.Delay.class); - List completions = resolver.complete( - Utils.createMethodParameter(method, 0), - contextFor("--delay 323") - ).stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).isEmpty(); - } - - @Test - public void testValueCompletionWithNonDefaultArity() { - Set valueProviders = new HashSet<>(); - valueProviders.add(new Remote.NumberValueProvider("12", "42", "7")); - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - valueProviders); - - Method[] methods = { - findMethod(org.springframework.shell.standard.Remote.class, "add", List.class), - findMethod(org.springframework.shell.standard.Remote.class, "addAsArray", int[].class), - }; - for (Method method : methods) { - List completions = resolver - .complete(Utils.createMethodParameter(method, 0), contextFor("--numbers ")).stream() - .map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("12", "42", "7"); - - completions = resolver.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers 42 ")) - .stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("12", "7"); - - completions = resolver.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers 42 34 ")) - .stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).contains("12", "7"); - - completions = resolver.complete(Utils.createMethodParameter(method, 0), contextFor("--numbers 42 34 66 ")) - .stream().map(CompletionProposal::value).collect(Collectors.toList()); - assertThat(completions).isEmpty(); // All 3 have already been set - } - } - - private CompletionContext contextFor(String input) { - DefaultParser defaultParser = new DefaultParser(); - ParsedLine parsed = defaultParser.parse(input, input.length()); - List words = parsed.words().stream().filter(w -> w.length() > 0).collect(Collectors.toList()); - - return new CompletionContext(words, parsed.wordIndex(), parsed.wordCursor()); - } -} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java index 989bd4e35..cfcd083ed 100644 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java @@ -15,26 +15,15 @@ */ package org.springframework.shell.standard.completion; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - import org.junit.jupiter.api.Test; -import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.ResourceLoader; -import org.springframework.shell.CommandRegistry; -import org.springframework.shell.ConfigurableCommandRegistry; -import org.springframework.shell.MethodTarget; -import org.springframework.shell.ParameterResolver; -import org.springframework.shell.context.DefaultShellContext; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandRegistration; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; -import org.springframework.shell.standard.StandardParameterResolver; import org.springframework.shell.standard.completion.AbstractCompletions.CommandModel; -import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; @@ -43,30 +32,50 @@ public class AbstractCompletionsTests { @Test public void testBasicModelGeneration() { DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); - ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry(new DefaultShellContext()); - List parameterResolvers = new ArrayList<>(); - StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(), - Collections.emptySet()); - parameterResolvers.add(resolver); + CommandCatalog commandCatalog = CommandCatalog.of(); TestCommands commands = new TestCommands(); - Method method1 = ReflectionUtils.findMethod(TestCommands.class, "test1", String.class); - Method method2 = ReflectionUtils.findMethod(TestCommands.class, "test2"); - Method method3 = ReflectionUtils.findMethod(TestCommands.class, "test3"); - Method method4 = ReflectionUtils.findMethod(TestCommands.class, "test4", String.class); - - MethodTarget methodTarget1 = new MethodTarget(method1, commands, "help"); - MethodTarget methodTarget2 = new MethodTarget(method2, commands, "help"); - MethodTarget methodTarget3 = new MethodTarget(method3, commands, "help"); - MethodTarget methodTarget4 = new MethodTarget(method4, commands, "help"); - - commandRegistry.register("test1", methodTarget1); - commandRegistry.register("test2", methodTarget2); - commandRegistry.register("test3", methodTarget3); - commandRegistry.register("test3 test4", methodTarget4); - - TestCompletions completions = new TestCompletions(resourceLoader, commandRegistry, parameterResolvers); + CommandRegistration registration1 = CommandRegistration.builder() + .command("test1") + .withTarget() + .method(commands, "test1") + .and() + .withOption() + .longNames("param1") + .and() + .build(); + + CommandRegistration registration2 = CommandRegistration.builder() + .command("test2") + .withTarget() + .method(commands, "test2") + .and() + .build(); + + CommandRegistration registration3 = CommandRegistration.builder() + .command("test3") + .withTarget() + .method(commands, "test3") + .and() + .build(); + + CommandRegistration registration4 = CommandRegistration.builder() + .command("test3", "test4") + .withTarget() + .method(commands, "test4") + .and() + .withOption() + .longNames("param4") + .and() + .build(); + + commandCatalog.register(registration1); + commandCatalog.register(registration2); + commandCatalog.register(registration3); + commandCatalog.register(registration4); + + TestCompletions completions = new TestCompletions(resourceLoader, commandCatalog); CommandModel commandModel = completions.testCommandModel(); assertThat(commandModel.getCommands()).hasSize(3); assertThat(commandModel.getCommands().stream().map(c -> c.getMainCommand())).containsExactlyInAnyOrder("test1", "test2", @@ -92,9 +101,8 @@ public void testBasicModelGeneration() { @Test public void testBuilder() { DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); - ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry(new DefaultShellContext()); - List parameterResolvers = new ArrayList<>(); - TestCompletions completions = new TestCompletions(resourceLoader, commandRegistry, parameterResolvers); + CommandCatalog commandCatalog = CommandCatalog.of(); + TestCompletions completions = new TestCompletions(resourceLoader, commandCatalog); String result = completions.testBuilder() .attribute("x", "command") @@ -106,9 +114,8 @@ public void testBuilder() { private static class TestCompletions extends AbstractCompletions { - public TestCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry, - List parameterResolvers) { - super(resourceLoader, commandRegistry, parameterResolvers); + public TestCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) { + super(resourceLoader, commandCatalog); } CommandModel testCommandModel() { diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java index 8a906bbe9..af374b092 100644 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java @@ -15,17 +15,16 @@ */ package org.springframework.shell.standard.completion; -import java.util.ArrayList; -import java.util.List; +import java.util.function.Function; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.shell.ConfigurableCommandRegistry; -import org.springframework.shell.ParameterResolver; -import org.springframework.shell.context.DefaultShellContext; +import org.springframework.shell.command.CommandCatalog; +import org.springframework.shell.command.CommandContext; +import org.springframework.shell.command.CommandRegistration; import static org.assertj.core.api.Assertions.assertThat; @@ -48,11 +47,71 @@ public void clean() { } @Test - public void testDoesNotError() { - ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry(new DefaultShellContext()); - List parameterResolvers = new ArrayList<>(); - BashCompletions completions = new BashCompletions(context, commandRegistry, parameterResolvers); + public void testNoCommands() { + CommandCatalog commandCatalog = CommandCatalog.of(); + BashCompletions completions = new BashCompletions(context, commandCatalog); String bash = completions.generate("root-command"); assertThat(bash).contains("root-command"); } + + @Test + public void testCommandFromMethod() { + CommandCatalog commandCatalog = CommandCatalog.of(); + registerFromMethod(commandCatalog); + BashCompletions completions = new BashCompletions(context, commandCatalog); + String bash = completions.generate("root-command"); + System.out.println(bash); + assertThat(bash).contains("root-command"); + assertThat(bash).contains("commands+=(\"testmethod1\")"); + assertThat(bash).contains("_root-command_testmethod1()"); + assertThat(bash).contains("two_word_flags+=(\"--arg1\")"); + } + + @Test + public void testCommandFromFunction() { + CommandCatalog commandCatalog = CommandCatalog.of(); + registerFromFunction(commandCatalog, "testmethod1"); + BashCompletions completions = new BashCompletions(context, commandCatalog); + String bash = completions.generate("root-command"); + assertThat(bash).contains("root-command"); + assertThat(bash).contains("commands+=(\"testmethod1\")"); + assertThat(bash).contains("_root-command_testmethod1()"); + assertThat(bash).contains("two_word_flags+=(\"--arg1\")"); + } + + private void registerFromMethod(CommandCatalog commandCatalog) { + Pojo1 pojo1 = new Pojo1(); + CommandRegistration registration = CommandRegistration.builder() + .command("testmethod1") + .withTarget() + .method(pojo1, "method1") + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + commandCatalog.register(registration); + } + + private void registerFromFunction(CommandCatalog commandCatalog, String command) { + Function function = ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return String.format("hi, arg1 value is '%s'", arg1); + }; + CommandRegistration registration = CommandRegistration.builder() + .command(command) + .withTarget() + .function(function) + .and() + .withOption() + .longNames("arg1") + .and() + .build(); + commandCatalog.register(registration); + } + + protected static class Pojo1 { + + void method1() {} + } }