Rework command subsystem

- Focus of these changes are to introduce a new command system based on
  real registrations (new way) instead of continuously (old way) resolve
  methods and its parameters via reflection.
- There's a lot of changes as this resolution via reflection had its
  hooks almost everywhere and thus most changes are just refactorings.
- Order to understand real changes I'd start to look classes under
  `org.springframework.shell.command` package as it defines new registration,
  catalog and parser classes. Also samples contain new classes to demonstrate
  new functionality.
- Fixes #380
This commit is contained in:
Janne Valkealahti
2022-05-06 08:32:53 +01:00
parent 81e5bf8c81
commit 8a23518b84
91 changed files with 7026 additions and 2291 deletions

2
.gitignore vendored
View File

@@ -10,6 +10,8 @@ target/
*.iws
.DS_Store
spring-shell.log
shell.log
shell.log.*.gz
# Visual Studio Code
.vscode/*

View File

@@ -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<MethodTargetRegistrar> methodTargetRegistrars,
ObjectProvider<CommandResolver> commandResolvers,
ObjectProvider<CommandCatalogCustomizer> commandCatalogCustomizers) {
List<CommandResolver> 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<CommandRegistration> commandRegistrations) {
return catalog -> {
commandRegistrations.orderedStream().forEach(registration -> {
catalog.register(registration);
});
};
}
}

View File

@@ -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<MethodTargetRegistrar> methodTargetRegistrars,
ShellContext shellContext) {
ConfigurableCommandRegistry registry = new ConfigurableCommandRegistry(shellContext);
methodTargetRegistrars.orderedStream().forEach(resolver -> {
resolver.register(registry);
});
return registry;
}
}

View File

@@ -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.Bean;
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 class LineReaderAutoConfiguration {
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;

View File

@@ -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<ValueProvider> valueProviders) {
Set<ValueProvider> collect = valueProviders.orderedStream().collect(Collectors.toSet());
return new StandardParameterResolver(conversionService, collect);
public CompletionResolver defaultCompletionResolver() {
return new DefaultCompletionResolver();
}
@Bean
public CommandExecutionHandlerMethodArgumentResolvers commandExecutionHandlerMethodArgumentResolvers() {
List<HandlerMethodArgumentResolver> 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);
}
}

View File

@@ -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 @@ package org.springframework.shell.boot;
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.context.annotation.Import;
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 class SpringShellAutoConfiguration {
}
@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);
}
}

View File

@@ -18,8 +18,8 @@ package org.springframework.shell.boot;
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 @@ import org.springframework.shell.standard.ValueProvider;
public class StandardAPIAutoConfiguration {
@Bean
public ValueProvider commandValueProvider(CommandRegistry commandRegistry) {
public ValueProvider commandValueProvider(CommandCatalog commandRegistry) {
return new CommandValueProvider(commandRegistry);
}

View File

@@ -3,7 +3,7 @@ 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,\

View File

@@ -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();
}
}
}

View File

@@ -22,8 +22,9 @@ import org.junit.jupiter.api.Test;
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 {

View File

@@ -23,6 +23,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
<groupId>org.jline</groupId>
<artifactId>jline</artifactId>

View File

@@ -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<String, MethodTarget> 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);
}

View File

@@ -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.
*
* <p>Makes sure that no two commands are registered with the same name.</p>
*
* @author Eric Bottard
*/
public class ConfigurableCommandRegistry implements CommandRegistry {
private final ShellContext shellContext;
private Map<String, MethodTarget> commands = new HashMap<>();
public ConfigurableCommandRegistry(ShellContext shellContext) {
this.shellContext = shellContext;
}
@Override
public Map<String, MethodTarget> 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);
}
}

View File

@@ -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, <em>i.e.</em> 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<Availability> 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<Availability> availabilityIndicator) {
this(method, bean, new Help(help, null), availabilityIndicator);
}
public MethodTarget(Method method, Object bean, Help help, Supplier<Availability> availabilityIndicator) {
this(method, bean, help, availabilityIndicator, null);
}
public MethodTarget(Method method, Object bean, Help help, Supplier<Availability> 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<Availability> availabilityIndicator) {
Set<Method> 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();
}
}

View File

@@ -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 <command keyword(s)>} to actual behavior.
*/
void register(ConfigurableCommandRegistry registry);
void register(CommandCatalog registry);
}

View File

@@ -34,15 +34,10 @@ import javax.validation.metadata.ElementDescriptor;
*/
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 class ParameterDescription {
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 class ParameterDescription {
@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);
}
}

View File

@@ -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.
*
* <p>
* An order can also be specified in case more than one {@link ParameterResolver} supports a {@link MethodParameter}.
* See {@link AnnotationAwareOrderComparator} for details..
* </p>
*
* @author Eric Bottard
* @author Camilo Gonzalez
*/
public interface ParameterResolver {
/**
* Should return true if this resolver recognizes the given method parameter (<em>e.g.</em> 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<String> words);
/**
* Describe a supported parameter, so that integrated help can be generated.
* <p>Typical implementations will return a one element stream result, but some may return several (for
* example if binding several words to a POJO).</p>
*/
Stream<ParameterDescription> 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<CompletionProposal> complete(MethodParameter parameter, CompletionContext context);
}

View File

@@ -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 @@ import java.util.Set;
* Thrown when one or more parameters fail bean validation constraints.
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
public class ParameterValidationException extends RuntimeException {
private final Set<ConstraintViolation<Object>> constraintViolations;
private final MethodTarget methodTarget;
public ParameterValidationException(Set<ConstraintViolation<Object>> constraintViolations, MethodTarget methodTarget) {
private final Set<ConstraintViolation<Object>> constraintViolations;
public ParameterValidationException(Set<ConstraintViolation<Object>> constraintViolations) {
this.constraintViolations = constraintViolations;
this.methodTarget = methodTarget;
}
public Set<ConstraintViolation<Object>> getConstraintViolations() {
return constraintViolations;
}
public MethodTarget getMethodTarget() {
return methodTarget;
}
}

View File

@@ -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.
*
* <p>
* Given some textual input, locate the {@link MethodTarget} to invoke and
* {@link ResultHandler#handleResult(Object) handle} the result.
* </p>
*
* <p>
* Also provides hooks for code completion
* </p>
*
* @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<ParameterResolver> parameterResolvers;
private final Terminal terminal;
private final CommandCatalog commandRegistry;
protected List<CompletionResolver> completionResolvers = new ArrayList<>();
private CommandExecutionHandlerMethodArgumentResolvers argumentResolvers;
/**
* Marker object to distinguish unresolved arguments from {@code null}, which is a valid
@@ -78,9 +68,23 @@ 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
public void setCompletionResolvers(List<CompletionResolver> resolvers) {
this.completionResolvers = new ArrayList<>(resolvers);
AnnotationAwareOrderComparator.sort(completionResolvers);
}
@Autowired
public void setArgumentResolvers(CommandExecutionHandlerMethodArgumentResolvers argumentResolvers) {
this.argumentResolvers = argumentResolvers;
}
@Autowired(required = false)
@@ -88,12 +92,6 @@ public class Shell {
this.validator = validatorFactory.getValidator();
}
@Autowired
public void setParameterResolvers(List<ParameterResolver> resolvers) {
this.parameterResolvers = new ArrayList<>(resolvers);
AnnotationAwareOrderComparator.sort(parameterResolvers);
}
/**
* The main program loop: acquire input, try to match it to a command and evaluate. Repeat
* until a {@link ResultHandler} causes the process to exit or there is no input.
@@ -145,21 +143,24 @@ public class Shell {
String command = findLongestCommand(line);
List<String> words = input.words();
log.debug("Evaluate input with line=[{}], command=[{}]", line, command);
if (command != null) {
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
MethodTarget methodTarget = methodTargets.get(command);
Availability availability = methodTarget.getAvailability();
if (availability.isAvailable()) {
Optional<CommandRegistration> commandRegistration = commandRegistry.getRegistrations().values().stream()
.filter(r -> {
return r.getCommand().equals(command);
})
.findFirst();
if (commandRegistration.isPresent()) {
List<String> 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 class Shell {
}
return e.getCause();
}
catch (CommandExecutionException e) {
return e.getCause();
}
catch (Exception e) {
return e;
}
@@ -175,7 +179,7 @@ public class Shell {
}
}
else {
return new CommandNotCurrentlyAvailable(command, availability);
return new CommandNotFound(words);
}
}
else {
@@ -183,6 +187,7 @@ public class Shell {
}
}
/**
* Return true if the parsed input ends up being empty (<em>e.g.</em> hitting ENTER on an
* empty line or blank space).
@@ -229,18 +234,11 @@ public class Shell {
if (best != null) {
CompletionContext argsContext = context.drop(best.split(" ").length);
// Try to complete arguments
Map<String, MethodTarget> methodTargets = commandRegistry.listCommands();
MethodTarget methodTarget = methodTargets.get(best);
Method method = methodTarget.getMethod();
CommandRegistration registration = commandRegistry.getRegistrations().get(best);
List<MethodParameter> 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);
}
}
for (CompletionResolver resolver : completionResolvers) {
List<CompletionProposal> resolved = resolver.resolve(registration, argsContext);
candidates.addAll(resolved);
}
}
return candidates;
@@ -250,61 +248,23 @@ public class Shell {
// 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<String, MethodTarget> 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<ConstraintViolation<Object>> 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<String> wordsForArgs) {
log.debug("Resolving args {} {}", method, wordsForArgs);
List<MethodParameter> 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 @@ public class Shell {
* @return a valid command name, or {@literal null} if none matched
*/
private String findLongestCommand(String prefix) {
Map<String, MethodTarget> 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;

View File

@@ -20,7 +20,10 @@ import java.lang.reflect.Constructor;
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 class Utils {
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 <T> List<List<T>> split(T[] array, Predicate<T> predicate) {
List<T> list = Arrays.asList(array);
boolean[] boundaries = new boolean[array.length];
List<List<T>> 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;
}
}

View File

@@ -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<String> names)
throws Exception {
if (names.size() == 1) {
return message.getHeaders().get(ARGUMENT_PREFIX + names.get(0));
}
else {
return null;
}
}
@Override
protected void handleMissingValue(List<String> 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());
}
}
}

View File

@@ -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<String, CommandRegistration> 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<CommandResolver> resolvers, ShellContext shellContext) {
return new DefaultCommandCatalog(resolvers, shellContext);
}
/**
* Default implementation of a {@link CommandCatalog}.
*/
static class DefaultCommandCatalog implements CommandCatalog {
private final Map<String, CommandRegistration> commandRegistrations = new HashMap<>();
private final Collection<CommandResolver> resolvers = new ArrayList<>();
private final ShellContext shellContext;
DefaultCommandCatalog(Collection<CommandResolver> 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<String, CommandRegistration> getRegistrations() {
Map<String, CommandRegistration> 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<Entry<String, CommandRegistration>> 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(" "));
// }
}
}

View File

@@ -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);
}

View File

@@ -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 <T> the type to map to
* @param name the option name
* @return mapped value
*/
<T> 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> T getOptionValue(String name) {
Optional<CommandParserResult> find = find(name);
if (find.isPresent()) {
return (T) find.get().value();
}
return null;
}
@Override
public Terminal getTerminal() {
return terminal;
}
private Optional<CommandParserResult> find(String name) {
return results.results().stream()
.filter(r -> {
Stream<String> l = Arrays.asList(r.option().getLongNames()).stream();
Stream<String> 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();
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<? extends HandlerMethodArgumentResolver> 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<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal) {
return new DefaultCommandExecution(resolvers, validator, terminal);
}
/**
* Default implementation of a {@link CommandExecution}.
*/
static class DefaultCommandExecution implements CommandExecution {
private List<? extends HandlerMethodArgumentResolver> resolvers;
private Validator validator;
private Terminal terminal;
public DefaultCommandExecution(List<? extends HandlerMethodArgumentResolver> resolvers, Validator validator,
Terminal terminal) {
this.resolvers = resolvers;
this.validator = validator;
this.terminal = terminal;
}
public Object evaluate(CommandRegistration registration, String[] args) {
List<CommandOption> 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<String[]> messageBuilder = MessageBuilder.withPayload(args);
Map<String, Object> 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<String, Object> paramValues = new HashMap<>();
ConversionService conversionService = new DefaultConversionService();
ParamNameHandlerMethodArgumentResolver(Map<String, Object> 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<CommandParserException> parserExceptions;
public CommandParserExceptionsException(String message, List<CommandParserException> parserExceptions) {
super(message);
this.parserExceptions = parserExceptions;
}
public List<CommandParserException> getParserExceptions() {
return parserExceptions;
}
}
static class CommandExecutionHandlerMethodArgumentResolvers {
private final List<? extends HandlerMethodArgumentResolver> resolvers;
public CommandExecutionHandlerMethodArgumentResolvers(List<? extends HandlerMethodArgumentResolver> resolvers) {
this.resolvers = resolvers;
}
public List<? extends HandlerMethodArgumentResolver> getResolvers() {
return resolvers;
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<CommandParserResult> results();
/**
* Gets the unmapped positional arguments.
*
* @return the unmapped positional arguments
*/
List<String> positional();
/**
* Gets parsing errors.
*
* @return the parsing errors
*/
List<CommandParserException> 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<CommandParserResult> results, List<String> positional, List<CommandParserException> 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<CommandOption> 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<CommandParserResult> results;
private List<String> positional;
private List<CommandParserException> errors;
DefaultCommandParserResults(List<CommandParserResult> results, List<String> positional, List<CommandParserException> errors) {
this.results = results;
this.positional = positional;
this.errors = errors;
}
@Override
public List<CommandParserResult> results() {
return results;
}
@Override
public List<String> positional() {
return positional;
}
@Override
public List<CommandParserException> 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<CommandOption> options, String[] args) {
List<CommandOption> requiredOptions = options.stream()
.filter(o -> o.isRequired())
.collect(Collectors.toList());
Lexer lexer = new Lexer(args);
List<List<String>> lexerResults = lexer.visit();
Parser parser = new Parser();
ParserResults parserResults = parser.visit(lexerResults, options);
List<CommandParserResult> results = new ArrayList<>();
List<String> positional = new ArrayList<>();
List<CommandParserException> errors = new ArrayList<>();
parserResults.results.stream().forEach(pr -> {
if (pr.option != null) {
results.add(new DefaultCommandParserResult(pr.option, pr.value));
requiredOptions.remove(pr.option);
}
else {
positional.addAll(pr.args);
}
if (pr.error != null) {
errors.add(pr.error);
}
});
Deque<ParserResult> queue = new ArrayDeque<>(parserResults.results);
options.stream()
.filter(o -> o.getPosition() > -1)
.sorted(Comparator.comparingInt(o -> o.getPosition()))
.forEach(o -> {
int arityMin = o.getArityMin();
int arityMax = o.getArityMax();
List<String> oargs = new ArrayList<>();
if (arityMin > -1) {
for (int i = 0; i < arityMax; i++) {
ParserResult pop = null;
if (!queue.isEmpty()) {
pop = queue.pop();
}
else {
break;
}
if (pop != null && pop.option == null) {
if (!pop.args.isEmpty()) {
oargs.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<String> args;
private Object value;
private CommandParserException error;
private ParserResult(CommandOption option, List<String> args, Object value, CommandParserException error) {
this.option = option;
this.args = args;
this.value = value;
this.error = error;
}
static ParserResult of(CommandOption option, List<String> args, Object value,
CommandParserException error) {
return new ParserResult(option, args, value, error);
}
}
private static class ParserResults {
private List<ParserResult> results;
private ParserResults(List<ParserResult> results) {
this.results = results;
}
static ParserResults of(List<ParserResult> results) {
return new ParserResults(results);
}
}
/**
* Parser works on a results from a lexer. It looks for given options
* and builds parsing results.
*/
private static class Parser {
ParserResults visit(List<List<String>> lexerResults, List<CommandOption> options) {
List<ParserResult> results = lexerResults.stream()
.flatMap(lr -> {
List<CommandOption> option = matchOptions(options, lr.get(0));
if (option.isEmpty()) {
return lr.stream().map(a -> ParserResult.of(null, Arrays.asList(a), null, null));
}
else {
return option.stream().flatMap(o -> {
List<String> subArgs = lr.subList(1, lr.size());
ConvertArgumentsHolder holder = convertArguments(o, subArgs);
Object value = holder.value;
Stream<ParserResult> unmapped = holder.unmapped.stream()
.map(um -> ParserResult.of(null, Arrays.asList(um), null, null));
Stream<ParserResult> res = Stream.of(ParserResult.of(o, subArgs, value, null));
return Stream.concat(res, unmapped);
});
}
})
.collect(Collectors.toList());
return ParserResults.of(results);
}
private List<CommandOption> matchOptions(List<CommandOption> options, String arg) {
List<CommandOption> matched = new ArrayList<>();
String trimmed = StringUtils.trimLeadingCharacter(arg, '-');
int count = arg.length() - trimmed.length();
if (count == 1) {
if (trimmed.length() == 1) {
Character trimmedChar = trimmed.charAt(0);
options.stream()
.filter(o -> {
for (Character sn : o.getShortNames()) {
if (trimmedChar.equals(sn)) {
return true;
}
}
return false;
})
.findFirst()
.ifPresent(o -> matched.add(o));
}
else if (trimmed.length() > 1) {
trimmed.chars().mapToObj(i -> (char)i)
.forEach(c -> {
options.stream().forEach(o -> {
for (Character sn : o.getShortNames()) {
if (c.equals(sn)) {
matched.add(o);
}
}
});
});
}
}
else if (count == 2) {
options.stream()
.filter(o -> {
for (String ln : o.getLongNames()) {
if (trimmed.equals(ln)) {
return true;
}
}
return false;
})
.findFirst()
.ifPresent(o -> matched.add(o));
}
return matched;
}
private ConvertArgumentsHolder convertArguments(CommandOption option, List<String> arguments) {
Object value = null;
List<String> unmapped = new ArrayList<>();
ResolvableType type = option.getType();
int arityMin = option.getArityMin();
int arityMax = option.getArityMax();
if (arityMin < 0 && type != null) {
if (type.isAssignableFrom(boolean.class)) {
arityMin = 1;
arityMax = 1;
}
}
if (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<String> unmapped = new ArrayList<>();
ConvertArgumentsHolder(Object value, List<String> unmapped) {
this.value = value;
if (unmapped != null) {
this.unmapped.addAll(unmapped);
}
}
static ConvertArgumentsHolder of(Object value, List<String> 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<List<String>> 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;
}
}
}

View File

@@ -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<CommandOption> 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<CommandContext, ?> getFunction();
/**
* Get the consumer
*
* @return the consumer
*/
Consumer<CommandContext> getConsumer();
static TargetInfo of(Object bean, Method method) {
return new DefaultTargetInfo(TargetType.METHOD, bean, method, null, null);
}
static TargetInfo of(Function<CommandContext, ?> function) {
return new DefaultTargetInfo(TargetType.FUNCTION, null, null, function, null);
}
static TargetInfo of(Consumer<CommandContext> 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<CommandContext, ?> function;
private final Consumer<CommandContext> consumer;
public DefaultTargetInfo(TargetType targetType, Object bean, Method method,
Function<CommandContext, ?> function, Consumer<CommandContext> 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<CommandContext, ?> getFunction() {
return function;
}
@Override
public Consumer<CommandContext> 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<CommandContext, ?> function);
/**
* Register a consumer target.
*
* @param consumer the consumer to register
* @return a target spec for chaining
*/
TargetSpec consumer(Consumer<CommandContext> 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> 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<CommandContext, ?> function;
private Consumer<CommandContext> 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<CommandContext, ?> function) {
this.function = function;
return this;
}
@Override
public TargetSpec consumer(Consumer<CommandContext> 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> availability;
private List<DefaultOptionSpec> optionSpecs;
private DefaultTargetSpec targetSpec;
public DefaultCommandRegistration(String[] commands, InteractionMode interactionMode, String help,
String group, String description, Supplier<Availability> availability,
List<DefaultOptionSpec> 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<CommandOption> 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> availability;
private List<DefaultOptionSpec> 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> 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);
}
}
}

View File

@@ -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<CommandRegistration> resolve();
}

View File

@@ -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.
*
* <p>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).
* <p>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.
* <p>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.
* <p>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.
* <p>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<ConstraintViolation<Object>> 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.
* <p>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.
* <p>Also supports <em>merged</em> 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 extends Annotation> A getMethodAnnotation(Class<A> 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 <A extends Annotation> boolean hasMethodAnnotation(Class<A> 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 extends Annotation> T getMethodAnnotation(Class<T> annotationType) {
return InvocableShellMethod.this.getMethodAnnotation(annotationType);
}
@Override
public <T extends Annotation> boolean hasMethodAnnotation(Class<T> 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);
}
}
}

View File

@@ -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<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<>();
private final Map<MethodParameter, HandlerMethodArgumentResolver> 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<? extends HandlerMethodArgumentResolver> 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<HandlerMethodArgumentResolver> 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;
}
}

View File

@@ -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<CompletionProposal> resolve(CommandRegistration registration, CompletionContext context);
}

View File

@@ -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<CompletionProposal> resolve(CommandRegistration registration, CompletionContext context) {
List<CompletionProposal> 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;
}
}

View File

@@ -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<CommandParserExceptionsException> {
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());
}
}

View File

@@ -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<ParameterValidationException> {
@@ -48,48 +34,15 @@ public class ParameterValidationExceptionResultHandler
super(terminal);
}
@Autowired
private List<ParameterResolver> 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<Integer> 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<ParameterDescription> 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();
}
}

View File

@@ -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.beans.factory.ObjectProvider;
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 class ResultHandlerConfig {
}
@Bean
public ThrowableResultHandler throwableResultHandler(Terminal terminal, CommandRegistry commandRegistry,
ObjectProvider<InteractiveShellRunner> 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<InteractiveShellRunner> interactiveApplicationRunner) {
return new ThrowableResultHandler(terminal, commandCatalog, interactiveApplicationRunner);
}
}

View File

@@ -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.AttributedStringBuilder;
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<Throwable
private Throwable lastError;
private CommandRegistry commandRegistry;
private CommandCatalog commandCatalog;
private ObjectProvider<InteractiveShellRunner> interactiveRunner;
public ThrowableResultHandler(Terminal terminal, CommandRegistry commandRegistry,
public ThrowableResultHandler(Terminal terminal, CommandCatalog commandCatalog,
ObjectProvider<InteractiveShellRunner> interactiveRunner) {
super(terminal);
this.commandRegistry = commandRegistry;
this.commandCatalog = commandCatalog;
this.interactiveRunner = interactiveRunner;
}
@@ -60,7 +60,7 @@ public class ThrowableResultHandler extends TerminalAwareResultHandler<Throwable
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))

View File

@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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<MethodParameter, NamedValueInfo> 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<String> 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<String> 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<String> 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<String> 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<String> name, MethodParameter parameter, Message<?> message) {
}
/**
* Represents a named value declaration.
*/
protected static class NamedValueInfo {
private final List<String> names;
private final boolean required;
@Nullable
private final String defaultValue;
protected NamedValueInfo(List<String> names, boolean required, @Nullable String defaultValue) {
this.names = names;
this.required = required;
this.defaultValue = defaultValue;
}
}
}

View File

@@ -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");
}
}

View File

@@ -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.InjectMocks;
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 @@ import static org.mockito.Mockito.when;
* @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<String, CommandRegistration> 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 class ShellTest {
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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -115,8 +126,15 @@ public class ShellTest {
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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -129,14 +147,18 @@ public class ShellTest {
@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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -154,8 +176,16 @@ public class ShellTest {
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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("fail", registration);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
try {
shell.run(inputProvider);
@@ -177,17 +207,27 @@ public class ShellTest {
@Test
public void commandNameCompletion() throws Exception {
Map<String, MethodTarget> 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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration1);
registrations.put("another command", registration2);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
// Invoke at very start
List<String> 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 @@ public class ShellTest {
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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration1);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
List<String> 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<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("hello world", registration1);
when(commandRegistry.getRegistrations()).thenReturn(registrations);
List<String> 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 @@ public class ShellTest {
private static class SomeException extends RuntimeException {
}
}

View File

@@ -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");
}
}

View File

@@ -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<String> predicate = t -> t.startsWith("-");
List<List<String>> 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"));
}
}

View File

@@ -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<CommandContext, String> function1 = ctx -> {
String arg1 = ctx.getOptionValue("arg1");
return "hi" + arg1;
};
protected Function<CommandContext, Void> 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++;
}
}
}

View File

@@ -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<CommandRegistration> resolve() {
List<CommandRegistration> regs = new ArrayList<>();
if (enabled) {
regs.add(r1);
}
return regs;
}
}
}

View File

@@ -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<HandlerMethodArgumentResolver> 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);
}
}

View File

@@ -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<CommandOption> 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<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"-a", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
}
@Test
public void testMultipleArgs() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo", "--arg2", "bar"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("bar");
}
@Test
public void testMultipleArgsWithMultiValues() {
CommandOption option1 = longOption("arg1", null, false, null, 1, 2);
CommandOption option2 = longOption("arg2", null, false, null, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo1", "foo2", "--arg2", "bar1", "bar2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("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<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"-v"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(true);
}
@Test
public void testBooleanWithArg() {
ResolvableType type = ResolvableType.forType(boolean.class);
CommandOption option1 = shortOption('v', type);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"-v", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(false);
}
@Test
public void testMissingRequiredOption() {
CommandOption option1 = longOption("arg1", true);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{};
CommandParserResults results = parser.parse(options, args);
assertThat(results.errors()).hasSize(1);
}
@Test
public void testSpaceInArgWithOneArg() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "foo bar"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo bar");
}
@Test
public void testSpaceInArgWithMultipleArgs() {
CommandOption option1 = longOption("arg1");
CommandOption option2 = longOption("arg2");
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "foo bar", "--arg2", "hi"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("foo bar");
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(1).value()).isEqualTo("hi");
}
@Test
public void testNonMappedArgs() {
String[] args = new String[]{"arg1", "arg2"};
CommandParserResults results = parser.parse(Collections.emptyList(), args);
assertThat(results.results()).hasSize(0);
assertThat(results.positional()).containsExactly("arg1", "arg2");
}
@Test
public void testNonMappedArgBeforeOption() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"foo", "--arg1", "value"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value");
assertThat(results.positional()).containsExactly("foo");
}
@Test
public void testNonMappedArgAfterOption() {
CommandOption option1 = longOption("arg1");
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value");
assertThat(results.positional()).containsExactly("foo");
}
@Test
public void testNonMappedArgWithoutOption() {
CommandOption option1 = longOption("arg1", 0, 1, 2);
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
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<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"value", "foo"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo("value foo");
assertThat(results.positional()).containsExactly("value", "foo");
}
@Test
public void testShortOptionsCombined() {
CommandOption optionA = shortOption('a');
CommandOption optionB = shortOption('b');
CommandOption optionC = shortOption('c');
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isNull();
assertThat(results.results().get(1).value()).isNull();
assertThat(results.results().get(2).value()).isNull();
}
@Test
public void testShortOptionsCombinedBooleanType() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isEqualTo(true);
assertThat(results.results().get(1).value()).isEqualTo(true);
assertThat(results.results().get(2).value()).isEqualTo(true);
}
@Test
public void testShortOptionsCombinedBooleanTypeArgFalse() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-abc", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionB);
assertThat(results.results().get(2).option()).isSameAs(optionC);
assertThat(results.results().get(0).value()).isEqualTo(false);
assertThat(results.results().get(1).value()).isEqualTo(false);
assertThat(results.results().get(2).value()).isEqualTo(false);
}
@Test
public void testShortOptionsCombinedBooleanTypeSomeArgFalse() {
CommandOption optionA = shortOption('a', ResolvableType.forType(boolean.class));
CommandOption optionB = shortOption('b', ResolvableType.forType(boolean.class));
CommandOption optionC = shortOption('c', ResolvableType.forType(boolean.class));
List<CommandOption> options = Arrays.asList(optionA, optionB, optionC);
String[] args = new String[]{"-ac", "-b", "false"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(3);
assertThat(results.results().get(0).option()).isSameAs(optionA);
assertThat(results.results().get(1).option()).isSameAs(optionC);
assertThat(results.results().get(2).option()).isSameAs(optionB);
assertThat(results.results().get(0).value()).isEqualTo(true);
assertThat(results.results().get(1).value()).isEqualTo(true);
assertThat(results.results().get(2).value()).isEqualTo(false);
}
@Test
public void testLongOptionsWithArray() {
CommandOption option1 = longOption("arg1", ResolvableType.forType(int[].class));
List<CommandOption> options = Arrays.asList(option1);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(1);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(0).value()).isEqualTo(new String[] { "1", "2" });
}
@Test
public void testMapPositionalArgs1() {
CommandOption option1 = longOption("arg1", 0, 1, 1);
CommandOption option2 = longOption("arg2", 1, 1, 2);
List<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"--arg1", "1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo("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<CommandOption> options = Arrays.asList(option1, option2);
String[] args = new String[]{"1", "2"};
CommandParserResults results = parser.parse(options, args);
assertThat(results.results()).hasSize(2);
assertThat(results.results().get(0).option()).isSameAs(option1);
assertThat(results.results().get(1).option()).isSameAs(option2);
assertThat(results.results().get(0).value()).isEqualTo("1");
assertThat(results.results().get(1).value()).isEqualTo("2");
}
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);
}
}

View File

@@ -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);
}
}

View File

@@ -128,6 +128,22 @@
</resources>
</configuration>
</execution>
<execution>
<id>copy-asciidoc-snippets</id>
<phase>generate-resources</phase>
<goals>
<goal>copy-resources</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/refdocs/snippets/</outputDirectory>
<resources>
<resource>
<directory>src/test/java/org/springframework/shell/docs</directory>
<filtering>false</filtering>
</resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
@@ -223,6 +239,7 @@
<version>${project.version}</version>
<projectName>${project.name}</projectName>
<projectVersion>${project.version}</projectVersion>
<snippets>${project.build.directory}/refdocs/snippets/</snippets>
<allow-uri-read>true</allow-uri-read>
<toclevels>4</toclevels>
<numbered>true</numbered>

View File

@@ -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.

View File

@@ -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]
----
====

View File

@@ -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.

View File

@@ -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_.

View File

@@ -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]
----
====

View File

@@ -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[]

View File

@@ -0,0 +1 @@
include::appendices-techical-intro.adoc[]

View File

@@ -20,3 +20,5 @@ include::introduction.adoc[]
include::getting-started.adoc[]
include::using-shell.adoc[]
include::appendices.adoc[]

View File

@@ -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`.

View File

@@ -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 <<extending-spring-shell>>). 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
<<appendix-tech-intro-registration>>.
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 <<help-command>>).
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]
----
====

View File

@@ -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<StringInputContext, List<AttributedString>> {
@Override
public List<AttributedString> 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<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2"));
SingleItemSelector<String, SelectorItem<String>> component = new SingleItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(getResourceLoader());
component.setTemplateExecutor(getTemplateExecutor());
SingleItemSelectorContext<String, SelectorItem<String>> 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<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2", false));
items.add(SelectorItem.of("key3", "value3"));
MultiItemSelector<String, SelectorItem<String>> component = new MultiItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(getResourceLoader());
component.setTemplateExecutor(getTemplateExecutor());
MultiItemSelectorContext<String, SelectorItem<String>> 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]
----
====

View File

@@ -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[]

View File

@@ -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[]
}
}

View File

@@ -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<CommandRegistration> registrations = new ArrayList<>();
CustomCommandResolver() {
CommandRegistration resolved = CommandRegistration.builder()
.command("resolve command")
.build();
registrations.add(resolved);
}
@Override
public List<CommandRegistration> 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[]
}

View File

@@ -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[]
}
}

View File

@@ -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[]
}

View File

@@ -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[]
}
}

View File

@@ -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[]
}
}

View File

@@ -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<StringInputContext, List<AttributedString>> {
@Override
public List<AttributedString> 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<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2"));
SingleItemSelector<String, SelectorItem<String>> component = new SingleItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(getResourceLoader());
component.setTemplateExecutor(getTemplateExecutor());
SingleItemSelectorContext<String, SelectorItem<String>> 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<SelectorItem<String>> items = new ArrayList<>();
items.add(SelectorItem.of("key1", "value1"));
items.add(SelectorItem.of("key2", "value2", false));
items.add(SelectorItem.of("key3", "value3"));
MultiItemSelector<String, SelectorItem<String>> component = new MultiItemSelector<>(getTerminal(),
items, "testSimple", null);
component.setResourceLoader(getResourceLoader());
component.setTemplateExecutor(getTemplateExecutor());
MultiItemSelectorContext<String, SelectorItem<String>> context = component
.run(MultiItemSelectorContext.empty());
String result = context.getResultItems().stream()
.map(si -> si.getItem())
.collect(Collectors.joining(","));
return "Got value " + result;
}
}
// end::snippet7[]
}
}

View File

@@ -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 @@ import org.springframework.shell.jline.PromptProvider;
* <p>Creates the application context and start the REPL.</p>
*
* @author Eric Bottard
* @author Janne Valkealahti
*/
@SpringBootApplication
public class SpringShellSample {

View File

@@ -71,6 +71,11 @@ public class Commands {
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);

View File

@@ -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();
}
}

View File

@@ -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 @@ import org.springframework.shell.standard.ShellOption;
@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;
@ShellMethod(key = "register add", value = "Register commands", group = "Register Commands")
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 = 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<CommandContext, String> 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;
}
}
}

View File

@@ -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<CommandRegistration> 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<CommandRegistration> resolve() {
return enabled ? registrations : Collections.emptyList();
}
}
static class Server2CommandResolver implements CommandResolver {
private final List<CommandRegistration> 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<CommandRegistration> resolve() {
return enabled ? registrations : Collections.emptyList();
}
}
}

View File

@@ -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 class Completion extends AbstractShellComponent {
@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);
}
}

View File

@@ -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.jline.utils.AttributedStyle;
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 class Help extends AbstractShellComponent {
@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 class Help extends AbstractShellComponent {
* 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<String, CommandRegistration> 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<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(registration);
// NAME
documentCommandName(result, command, methodTarget.getHelp());
documentCommandName(result, command, registration.getHelp());
// SYNOPSYS
documentSynopsys(result, command, parameterDescriptions);
@@ -134,10 +136,10 @@ public class Help extends AbstractShellComponent {
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 @@ public class Help extends AbstractShellComponent {
}
}
private void documentAliases(AttributedStringBuilder result, String command, MethodTarget methodTarget) {
Set<String> 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<String, CommandRegistration> registrations, CommandRegistration registration) {
List<String> 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 @@ public class Help extends AbstractShellComponent {
}
}
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 @@ public class Help extends AbstractShellComponent {
}
private CharSequence listCommands() {
Map<String, MethodTarget> commandsByName = getCommandRegistry().listCommands();
AttributedStringBuilder result = new AttributedStringBuilder();
result.append("AVAILABLE COMMANDS\n\n", AttributedStyle.BOLD);
SortedMap<String, Map<String, MethodTarget>> 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<String, Map<String, CommandRegistration>> 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<MethodTarget, SortedSet<String>> commandNamesByMethod = commandsInGroup.entrySet().stream()
Map<CommandRegistration, SortedSet<String>> 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 @@ public class Help extends AbstractShellComponent {
}
});
if (commandsByName.values().stream().distinct().anyMatch(m -> !isAvailable(m))) {
result.append("Commands marked with (*) are currently unavailable.\nType `help <command>` to learn more.\n\n");
}
return result;
}
private Comparator<Entry<MethodTarget, SortedSet<String>>> sortByFirstCommandName() {
private Comparator<Entry<CommandRegistration, SortedSet<String>>> 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 @@ public class Help extends AbstractShellComponent {
}
}
private List<ParameterDescription> 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<ParameterDescription> getParameterDescriptions(CommandRegistration registration) {
List<CommandOption> options = registration.getOptions();
List<ParameterDescription> 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<String> 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 class Help extends AbstractShellComponent {
return null;
}
}
}

View File

@@ -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 java.util.Optional;
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<String, CommandRegistration> 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 class HelpTest {
@BeforeEach
public void setup(TestInfo testInfo) {
registrations.clear();
Optional<Method> 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 class HelpTest {
}
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 @@ public class HelpTest {
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<String, MethodTarget> listCommands() {
Map<String, MethodTarget> 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 class HelpTest {
// 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() {
}
}
}

View File

@@ -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]

View File

@@ -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.ApplicationContext;
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<Terminal> terminalProvider;
private ObjectProvider<CommandRegistry> commandRegistryProvider;
private ObjectProvider<CommandCatalog> commandCatalogProvider;
private ObjectProvider<ParameterResolver> parameterResolverProvider;
private ObjectProvider<CompletionResolver> completionResolverProvider;
private ObjectProvider<TemplateExecutor> templateExecutorProvider;
@@ -69,8 +69,8 @@ public abstract class AbstractShellComponent implements ApplicationContextAware,
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 @@ public abstract class AbstractShellComponent implements ApplicationContextAware,
return terminalProvider.getObject();
}
protected CommandRegistry getCommandRegistry() {
return commandRegistryProvider.getObject();
protected CommandCatalog getCommandCatalog() {
return commandCatalogProvider.getObject();
}
protected Stream<ParameterResolver> getParameterResolver() {
return parameterResolverProvider.orderedStream();
protected Stream<CompletionResolver> getCompletionResolver() {
return completionResolverProvider.orderedStream();
}
protected TemplateExecutor getTemplateExecutor() {

View File

@@ -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.List;
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 @@ import org.springframework.shell.CompletionProposal;
*/
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<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) {
return commandRegistry.listCommands().keySet().stream()
return commandRegistry.getRegistrations().keySet().stream()
.map(CompletionProposal::new)
.collect(Collectors.toList());
}

View File

@@ -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<String> 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<String> 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<String> 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<String> names) {
super(names, false, null);
}
}
}

View File

@@ -26,21 +26,27 @@ import java.util.Set;
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 @@ import static org.springframework.util.StringUtils.collectionToDelimitedString;
* @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<String, MethodTarget> commands = new HashMap<>();
@Override
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
@Override
public void register(ConfigurableCommandRegistry registry) {
public void register(CommandCatalog registry) {
Map<String, Object> commandBeans = applicationContext.getBeansWithAnnotation(ShellComponent.class);
for (Object bean : commandBeans.values()) {
Class<?> clazz = bean.getClass();
@@ -74,11 +80,78 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, App
}
String group = getOrInferGroup(method);
for (String key : keys) {
log.debug("Registering with keys='{}' key='{}'", keys, key);
Supplier<Availability> 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<String> longNames = new ArrayList<>();
List<Character> 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 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, App
return null;
}
}
@Override
public String toString() {
return getClass().getSimpleName() + " contributing "
+ collectionToDelimitedString(commands.keySet(), ", ", "[", "]");
}
}

View File

@@ -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:
* <ul>
* <li>named parameters (recognized because they start with some
* {@link ShellMethod#prefix()})</li>
* <li>implicit named parameters (from the actual method parameter name)</li>
* <li>positional parameters (in order, for all parameter values that were not resolved
* <i>via</i> named parameters)</li>
* <li>default values (for all remaining parameters)</li>
* </ul>
*
* <p>
* 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).
* </p>
*
* <p>
* 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 <i>via</i> {@link ShellOption} if
* needed.
* </p>
* @author Eric Bottard
* @author Florent Biville
* @author Camilo Gonzalez
*/
@Component
public class StandardParameterResolver implements ParameterResolver {
private final ConversionService conversionService;
private Collection<ValueProvider> 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<CacheKey, Map<Parameter, ParameterRawValue>> parameterCache = new ConcurrentReferenceHashMap<>();
public StandardParameterResolver(ConversionService conversionService, Set<ValueProvider> 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<String> wordsBuffer) {
List<String> words = wordsBuffer.stream().filter(w -> !w.isEmpty()).collect(Collectors.toList());
CacheKey cacheKey = new CacheKey(methodParameter.getMethod(), wordsBuffer);
parameterCache.clear();
Map<Parameter, ParameterRawValue> resolved = parameterCache.computeIfAbsent(cacheKey, (k) -> {
Map<Parameter, ParameterRawValue> result = new HashMap<>();
Map<String, String> namedParameters = new HashMap<>();
// index of words that haven't yet been used to resolve parameter values
List<Integer> unusedWords = new ArrayList<>();
Set<String> 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<String> keys = getKeysForParameter(methodParameter.getMethod(), i)
.collect(Collectors.toSet());
Collection<String> 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<String> 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<String> 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<String> 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<ParameterDescription> 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<String> defaultValue = defaultValueFor(jlrParameter);
if (defaultValue.isPresent()) {
result.defaultValue(defaultValue.map(dv -> dv.equals(ShellOption.NULL) ? "<none>" : 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<CompletionProposal> 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<CompletionProposal> 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<CompletionProposal> 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<CompletionProposal> 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<Foo>} 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<String> 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<String> getKeysForParameter(Method method, int index) {
Parameter p = method.getParameters()[index];
return getKeysForParameter(p);
}
private Stream<String> 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<String> words;
private CacheKey(Method method, List<String> 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 +
'}';
}
}
}

View File

@@ -22,10 +22,10 @@ import java.io.UncheckedIOException;
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.stringtemplate.v4.STGroupString;
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 @@ import org.springframework.util.MultiValueMap;
public abstract class AbstractCompletions {
private final ResourceLoader resourceLoader;
private final CommandRegistry commandRegistry;
private final List<ParameterResolver> parameterResolvers;
private final CommandCatalog commandCatalog;
public AbstractCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> 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 @@ public abstract class AbstractCompletions {
* all needed to build completions structure.
*/
protected CommandModel generateCommandModel() {
Map<String, MethodTarget> commandsByName = commandRegistry.listCommands();
Collection<CommandRegistration> commandsByName = commandCatalog.getRegistrations().values();
HashMap<String, DefaultCommandModelCommand> commands = new HashMap<>();
HashSet<CommandModelCommand> 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 @@ public abstract class AbstractCompletions {
}
DefaultCommandModelCommand command = commands.computeIfAbsent(commandKey,
(fullCommand) -> new DefaultCommandModelCommand(fullCommand, main));
MethodTarget methodTarget = entry.getValue();
List<ParameterDescription> parameterDescriptions = getParameterDescriptions(methodTarget);
List<DefaultCommandModelOption> options = parameterDescriptions.stream()
.flatMap(pd -> pd.keys().stream())
.map(k -> new DefaultCommandModelOption(k))
.collect(Collectors.toList());
// TODO long vs short
List<CommandModelOption> 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 @@ public abstract class AbstractCompletions {
return new DefaultCommandModel(new ArrayList<>(topCommands));
}
private List<ParameterDescription> 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 @@ public abstract class AbstractCompletions {
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 abstract class AbstractCompletions {
return options;
}
void addOptions(List<DefaultCommandModelOption> options) {
void addOptions(List<CommandModelOption> options) {
this.options.addAll(options);
}
@@ -341,7 +333,7 @@ public abstract class AbstractCompletions {
}
}
class DefaultCommandModelOption implements CommandModelOption {
static class DefaultCommandModelOption implements CommandModelOption {
private String option;

View File

@@ -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 @@ import org.springframework.shell.ParameterResolver;
*/
public class BashCompletions extends AbstractCompletions {
public BashCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
super(resourceLoader, commandRegistry, parameterResolvers);
public BashCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) {
super(resourceLoader, commandCatalog);
}
public String generate(String rootCommand) {

View File

@@ -29,11 +29,11 @@ import org.mockito.Mock;
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 @@ import static org.mockito.Mockito.when;
public class CommandValueProviderTest {
@Mock
private CommandRegistry shell;
private CommandCatalog catalog;
@BeforeEach
public void setUp() {
@@ -56,7 +56,7 @@ public class CommandValueProviderTest {
@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 class CommandValueProviderTest {
boolean supports = valueProvider.supports(methodParameter, completionContext);
assertThat(supports).isEqualTo(true);
Map<String, CommandRegistration> registrations = new HashMap<>();
registrations.put("me", null);
registrations.put("meow", null);
registrations.put("yourself", null);
Map<String, MethodTarget> 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<CompletionProposal> proposals = valueProvider.complete(methodParameter, completionContext, new String[0]);
assertThat(proposals).extracting("value", String.class)

View File

@@ -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<String, MethodTarget> 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() {
}
}
}

View File

@@ -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<String, CommandRegistration> 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<String, CommandRegistration> 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() {
}
}
}

View File

@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<ValueProvider> 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<String> 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<String> words = parsed.words().stream().filter(w -> w.length() > 0).collect(Collectors.toList());
return new CompletionContext(words, parsed.wordIndex(), parsed.wordCursor());
}
}

View File

@@ -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<ParameterResolver> 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);
CommandRegistration registration1 = CommandRegistration.builder()
.command("test1")
.withTarget()
.method(commands, "test1")
.and()
.withOption()
.longNames("param1")
.and()
.build();
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");
CommandRegistration registration2 = CommandRegistration.builder()
.command("test2")
.withTarget()
.method(commands, "test2")
.and()
.build();
commandRegistry.register("test1", methodTarget1);
commandRegistry.register("test2", methodTarget2);
commandRegistry.register("test3", methodTarget3);
commandRegistry.register("test3 test4", methodTarget4);
CommandRegistration registration3 = CommandRegistration.builder()
.command("test3")
.withTarget()
.method(commands, "test3")
.and()
.build();
TestCompletions completions = new TestCompletions(resourceLoader, commandRegistry, parameterResolvers);
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 class AbstractCompletionsTests {
@Test
public void testBuilder() {
DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry(new DefaultShellContext());
List<ParameterResolver> 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 class AbstractCompletionsTests {
private static class TestCompletions extends AbstractCompletions {
public TestCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
super(resourceLoader, commandRegistry, parameterResolvers);
public TestCompletions(ResourceLoader resourceLoader, CommandCatalog commandCatalog) {
super(resourceLoader, commandCatalog);
}
CommandModel testCommandModel() {

View File

@@ -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 class BashCompletionsTests {
}
@Test
public void testDoesNotError() {
ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry(new DefaultShellContext());
List<ParameterResolver> 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<CommandContext, String> 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() {}
}
}