Completion command for bash

- Add basic support of defining a command `completion bash` which
  outputs a generic bash script which can be used in a user environment.
- Idea for completion is copied from go's cobra library what comes for
  a bash dance itself.
- Goes through command registry, builds a model for command structure
  and uses antlr st4 for templating bash.
- Should give foundation to create other completions just like in cobra.
- Currently as we don't know a root-command in a generic way, option
  `spring.shell.command.completion.root-command` is required user to set.
- Fixes #343
This commit is contained in:
Janne Valkealahti
2022-01-07 09:58:21 +00:00
parent 7298b2ce78
commit 668ddb458e
17 changed files with 1408 additions and 22 deletions

View File

@@ -24,6 +24,7 @@
<properties>
<jline.version>3.21.0</jline.version>
<jcommander.version>1.81</jcommander.version>
<antlr-st4.version>4.3.1</antlr-st4.version>
</properties>
<modules>
@@ -98,6 +99,11 @@
<version>${jcommander.version}</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>ST4</artifactId>
<version>${antlr-st4.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

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.
@@ -179,6 +179,28 @@ public class SpringShellProperties {
}
}
public static class CompletionCommand {
private boolean enabled = true;
private String rootCommand;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getRootCommand() {
return rootCommand;
}
public void setRootCommand(String rootCommand) {
this.rootCommand = rootCommand;
}
}
public static class Command {
private HelpCommand help = new HelpCommand();
@@ -187,6 +209,7 @@ public class SpringShellProperties {
private StacktraceCommand stacktrace = new StacktraceCommand();
private ScriptCommand script = new ScriptCommand();
private HistoryCommand history = new HistoryCommand();
private CompletionCommand completion = new CompletionCommand();
public void setHelp(HelpCommand help) {
this.help = help;
@@ -235,5 +258,13 @@ public class SpringShellProperties {
public void setHistory(HistoryCommand history) {
this.history = history;
}
public CompletionCommand getCompletion() {
return completion;
}
public void setCompletion(CompletionCommand completion) {
this.completion = completion;
}
}
}

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.
@@ -22,10 +22,14 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.shell.boot.condition.OnCompletionCommandCondition;
import org.springframework.shell.result.ThrowableResultHandler;
import org.springframework.shell.standard.commands.Clear;
import org.springframework.shell.standard.commands.Completion;
import org.springframework.shell.standard.commands.Help;
import org.springframework.shell.standard.commands.History;
import org.springframework.shell.standard.commands.Quit;
@@ -39,6 +43,7 @@ import org.springframework.shell.standard.commands.Stacktrace;
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({ Help.Command.class })
@EnableConfigurationProperties(SpringShellProperties.class)
public class StandardCommandsAutoConfiguration {
@Bean
@@ -82,4 +87,11 @@ public class StandardCommandsAutoConfiguration {
public History historyCommand(org.jline.reader.History jLineHistory) {
return new History(jLineHistory);
}
@Bean
@ConditionalOnMissingBean(Completion.Command.class)
@Conditional(OnCompletionCommandCondition.class)
public Completion completion(SpringShellProperties properties) {
return new Completion(properties.getCommand().getCompletion().getRootCommand());
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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.condition;
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
public class OnCompletionCommandCondition extends AllNestedConditions {
public OnCompletionCommandCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnProperty(prefix = "spring.shell.command.completion", value = "root-command")
static class RootNameCondition {
}
@ConditionalOnProperty(prefix = "spring.shell.command.completion", value = "enabled", havingValue = "true", matchIfMissing = true)
static class EnabledCondition {
}
}

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.
@@ -15,15 +15,10 @@
*/
package org.springframework.shell.boot;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.env.SystemEnvironmentPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
@@ -46,26 +41,25 @@ public class SpringShellPropertiesTests {
assertThat(properties.getCommand().getQuit().isEnabled()).isTrue();
assertThat(properties.getCommand().getScript().isEnabled()).isTrue();
assertThat(properties.getCommand().getStacktrace().isEnabled()).isTrue();
assertThat(properties.getCommand().getCompletion().isEnabled()).isTrue();
assertThat(properties.getCommand().getCompletion().getRootCommand()).isNull();
});
}
@Test
public void setProperties() {
this.contextRunner
.withInitializer(context -> {
Map<String, Object> map = new HashMap<>();
map.put("spring.shell.script.enabled", "false");
map.put("spring.shell.interactive.enabled", "false");
map.put("spring.shell.noninteractive.enabled", "false");
map.put("spring.shell.command.clear.enabled", "false");
map.put("spring.shell.command.help.enabled", "false");
map.put("spring.shell.command.history.enabled", "false");
map.put("spring.shell.command.quit.enabled", "false");
map.put("spring.shell.command.script.enabled", "false");
map.put("spring.shell.command.stacktrace.enabled", "false");
context.getEnvironment().getPropertySources().addLast(new SystemEnvironmentPropertySource(
StandardEnvironment.SYSTEM_ENVIRONMENT_PROPERTY_SOURCE_NAME, map));
})
.withPropertyValues("spring.shell.script.enabled=false")
.withPropertyValues("spring.shell.interactive.enabled=false")
.withPropertyValues("spring.shell.noninteractive.enabled=false")
.withPropertyValues("spring.shell.command.clear.enabled=false")
.withPropertyValues("spring.shell.command.help.enabled=false")
.withPropertyValues("spring.shell.command.history.enabled=false")
.withPropertyValues("spring.shell.command.quit.enabled=false")
.withPropertyValues("spring.shell.command.script.enabled=false")
.withPropertyValues("spring.shell.command.stacktrace.enabled=false")
.withPropertyValues("spring.shell.command.completion.enabled=false")
.withPropertyValues("spring.shell.command.completion.root-command=fake")
.withUserConfiguration(Config1.class)
.run((context) -> {
SpringShellProperties properties = context.getBean(SpringShellProperties.class);
@@ -78,6 +72,8 @@ public class SpringShellPropertiesTests {
assertThat(properties.getCommand().getQuit().isEnabled()).isFalse();
assertThat(properties.getCommand().getScript().isEnabled()).isFalse();
assertThat(properties.getCommand().getStacktrace().isEnabled()).isFalse();
assertThat(properties.getCommand().getCompletion().isEnabled()).isFalse();
assertThat(properties.getCommand().getCompletion().getRootCommand()).isEqualTo("fake");
});
}

View File

@@ -0,0 +1,59 @@
/*
* 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.function.Function;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.shell.standard.commands.Completion;
import static org.assertj.core.api.Assertions.assertThat;
public class StandardCommandsAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(StandardCommandsAutoConfiguration.class));
@Test
public void testCompletionCommand() {
this.contextRunner
.with(disableCommands("help", "clear", "quit", "stacktrace", "script", "history"))
.run((context) -> {assertThat(context).doesNotHaveBean(Completion.class);
});
this.contextRunner
.with(disableCommands("help", "clear", "quit", "stacktrace", "script", "history", "completion"))
.withPropertyValues("spring.shell.command.completion.root-command=fake")
.run((context) -> {assertThat(context).doesNotHaveBean(Completion.class);
});
this.contextRunner
.with(disableCommands("help", "clear", "quit", "stacktrace", "script", "history"))
.withPropertyValues("spring.shell.command.completion.root-command=fake")
.run((context) -> {assertThat(context).hasSingleBean(Completion.class);
});
}
private static Function<ApplicationContextRunner, ApplicationContextRunner> disableCommands(String... commands) {
return (cr) -> {
for (String command : commands) {
cr = cr.withPropertyValues(String.format("spring.shell.command.%s.enabled=false", command));
}
return cr;
};
}
}

View File

@@ -1,3 +1,10 @@
logging:
level:
root: error
spring:
main:
banner-mode: off
shell:
command:
completion:
root-command: spring-shell-samples

View File

@@ -0,0 +1,59 @@
/*
* 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.commands;
import java.util.stream.Collectors;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.core.io.ResourceLoader;
import org.springframework.shell.standard.AbstractShellComponent;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import org.springframework.shell.standard.completion.BashCompletions;
/**
* Command to create a shell completion files, i.e. for {@code bash}.
*
* @author Janne Valkealahti
*/
@ShellComponent
public class Completion extends AbstractShellComponent implements ResourceLoaderAware {
/**
* Marker interface used in auto-config.
*/
public interface Command {
}
private ResourceLoader resourceLoader;
private String rootCommand;
public Completion(String rootCommand) {
this.rootCommand = rootCommand;
}
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
@ShellMethod(key = "completion bash", value = "Generate bash completion script")
public String bash() {
BashCompletions bashCompletions = new BashCompletions(resourceLoader, getCommandRegistry(),
getParameterResolver().collect(Collectors.toList()));
return bashCompletions.generate(rootCommand);
}
}

View File

@@ -18,6 +18,10 @@
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-core</artifactId>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>ST4</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>

View File

@@ -0,0 +1,421 @@
/*
* 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.completion;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
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;
import org.stringtemplate.v4.ST;
import org.stringtemplate.v4.STGroup;
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.util.FileCopyUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
/**
* Base class for completion script commands providing functionality for
* resource handling and templating with {@code antrl stringtemplate}.
*
* @author Janne Valkealahti
*/
public abstract class AbstractCompletions {
private final ResourceLoader resourceLoader;
private final CommandRegistry commandRegistry;
private final List<ParameterResolver> parameterResolvers;
public AbstractCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
this.resourceLoader = resourceLoader;
this.commandRegistry = commandRegistry;
this.parameterResolvers = parameterResolvers;
}
protected Builder builder() {
return new DefaultBuilder();
}
/**
* Generates a model for a recursive command model starting from root
* level going down with all sub commands with options. Essentially providing
* all needed to build completions structure.
*/
protected CommandModel generateCommandModel() {
Map<String, MethodTarget> commandsByName = commandRegistry.listCommands();
HashMap<String, DefaultCommandModelCommand> commands = new HashMap<>();
HashSet<CommandModelCommand> topCommands = new HashSet<>();
commandsByName.entrySet().stream()
.forEach(entry -> {
String key = entry.getKey();
String[] splitKeys = key.split(" ");
String commandKey = "";
for (int i = 0; i < splitKeys.length; i++) {
DefaultCommandModelCommand parent = null;
String main = splitKeys[i];
if (i > 0) {
parent = commands.get(commandKey);
commandKey = commandKey + " " + splitKeys[i];
}
else {
commandKey = splitKeys[i];
}
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());
if (i == splitKeys.length - 1) {
command.addOptions(options);
}
if (parent != null) {
parent.addCommand(command);
}
if (i == 0) {
topCommands.add(command);
}
}
});
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
* of a templates.
*/
interface CommandModel {
/**
* Gets root level commands where sub-commands can be found.
*
* @return root level commands
*/
List<CommandModelCommand> getCommands();
/**
* Gets all commands as a flattened structure.
*
* @return all commands
*/
List<CommandModelCommand> getAllCommands();
/**
* Gets root commands.
*
* @return root commands
*/
List<String> getRootCommands();
}
/**
* Interface for a command in a model. Also contains methods which makes it
* easier to work with ST4 templates.
*/
interface CommandModelCommand {
/**
* Gets sub-commands known to this command.
* @return known sub-commands
*/
List<CommandModelCommand> getCommands();
/**
* Gets options known to this command
*
* @return known options
*/
List<CommandModelOption> getOptions();
/**
* Gets command flags.
*
* @return command flags
*/
List<String> getFlags();
/**
* Gets sub commands.
*
* @return sub commands
*/
List<String> getSubCommands();
/**
* Gets command parts. Essentially full command split into parts.
*
* @return command parts
*/
List<String> getCommandParts();
/**
* Gets a main command
*
* @return the main command
*/
String getMainCommand();
/**
* Gets a last command part.
*
* @return the last command part
*/
String getLastCommandPart();
}
interface CommandModelOption {
String option();
}
class DefaultCommandModel implements CommandModel {
private final List<CommandModelCommand> commands;
public DefaultCommandModel(List<CommandModelCommand> commands) {
this.commands = commands;
}
@Override
public List<CommandModelCommand> getCommands() {
return commands;
}
@Override
public List<CommandModelCommand> getAllCommands() {
return getCommands().stream()
.flatMap(c -> flatten(c))
.collect(Collectors.toList());
}
@Override
public List<String> getRootCommands() {
return getCommands().stream()
.map(c -> c.getLastCommandPart())
.collect(Collectors.toList());
}
private Stream<CommandModelCommand> flatten(CommandModelCommand command) {
return Stream.concat(Stream.of(command), command.getCommands().stream().flatMap(c -> flatten(c)));
}
}
class DefaultCommandModelCommand implements CommandModelCommand {
private String fullCommand;
private String mainCommand;
private List<CommandModelCommand> commands = new ArrayList<>();
private List<CommandModelOption> options = new ArrayList<>();
DefaultCommandModelCommand(String fullCommand, String mainCommand) {
this.fullCommand = fullCommand;
this.mainCommand = mainCommand;
}
@Override
public List<String> getCommandParts() {
return Arrays.asList(fullCommand.split(" "));
}
@Override
public String getLastCommandPart() {
String[] split = fullCommand.split(" ");
return split[split.length - 1];
}
@Override
public String getMainCommand() {
return mainCommand;
}
@Override
public List<String> getSubCommands() {
return this.commands.stream()
.map(c -> c.getMainCommand())
.collect(Collectors.toList());
}
@Override
public List<String> getFlags() {
return this.options.stream()
.map(o -> o.option())
.collect(Collectors.toList());
}
@Override
public List<CommandModelCommand> getCommands() {
return commands;
}
@Override
public List<CommandModelOption> getOptions() {
return options;
}
void addOptions(List<DefaultCommandModelOption> options) {
this.options.addAll(options);
}
void addCommand(DefaultCommandModelCommand command) {
commands.add(command);
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + getEnclosingInstance().hashCode();
result = prime * result + ((fullCommand == null) ? 0 : fullCommand.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
DefaultCommandModelCommand other = (DefaultCommandModelCommand) obj;
if (!getEnclosingInstance().equals(other.getEnclosingInstance())) {
return false;
}
if (fullCommand == null) {
if (other.fullCommand != null) {
return false;
}
} else if (!fullCommand.equals(other.fullCommand)) {
return false;
}
return true;
}
private AbstractCompletions getEnclosingInstance() {
return AbstractCompletions.this;
}
}
class DefaultCommandModelOption implements CommandModelOption {
private String option;
public DefaultCommandModelOption(String option) {
this.option = option;
}
@Override
public String option() {
return option;
}
}
private static String resourceAsString(Resource resource) {
try (Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) {
return FileCopyUtils.copyToString(reader);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
interface Builder {
Builder attribute(String name, Object value);
Builder group(String resource);
Builder appendGroup(String instance);
String build();
}
class DefaultBuilder implements Builder {
private final MultiValueMap<String, Object> defaultAttributes = new LinkedMultiValueMap<>();
private final List<Supplier<String>> operations = new ArrayList<>();
private String groupResource;
@Override
public Builder attribute(String name, Object value) {
this.defaultAttributes.add(name, value);
return this;
}
@Override
public Builder group(String resource) {
groupResource = resource;
return this;
}
@Override
public Builder appendGroup(String instance) {
// delay so that we render with build
Supplier<String> operation = () -> {
String template = resourceAsString(resourceLoader.getResource(groupResource));
STGroup group = new STGroupString(template);
ST st = group.getInstanceOf(instance);
defaultAttributes.entrySet().stream().forEach(entry -> {
String key = entry.getKey();
List<Object> values = entry.getValue();
values.stream().forEach(v -> {
st.add(key, v);
});
});
return st.render();
};
operations.add(operation);
return this;
}
@Override
public String build() {
StringBuilder buf = new StringBuilder();
operations.stream().forEach(operation -> {
buf.append(operation.get());
});
return buf.toString();
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.completion;
import java.util.List;
import org.springframework.core.io.ResourceLoader;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.ParameterResolver;
/**
* Completion script generator for a {@code bash}.
*
* @author Janne Valkealahti
*/
public class BashCompletions extends AbstractCompletions {
public BashCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
super(resourceLoader, commandRegistry, parameterResolvers);
}
public String generate(String rootCommand) {
CommandModel model = generateCommandModel();
return builder()
.attribute("name", rootCommand)
.attribute("model", model)
.group("classpath:completion/bash.stg")
.appendGroup("main")
.build();
}
}

View File

@@ -0,0 +1,10 @@
[
{
"name": "org.springframework.shell.standard.completion.AbstractCompletions$DefaultCommandModel",
"allDeclaredMethods": true
},
{
"name": "org.springframework.shell.standard.completion.AbstractCompletions$DefaultCommandModelCommand",
"allDeclaredMethods": true
}
]

View File

@@ -0,0 +1,9 @@
{
"resources": {
"includes": [
{
"pattern": "completion/.*"
}
]
}
}

View File

@@ -0,0 +1,492 @@
//
// pre content template before commands
// needs to escape some > characters
//
pre(name) ::= <<
# bash completion for <name> -*- shell-script -*-
__<name>_debug()
{
if [[ -n ${BASH_COMP_DEBUG_FILE:-} ]]; then
echo "$*" \>> "${BASH_COMP_DEBUG_FILE}"
fi
}
# Homebrew on Macs have version 1.3 of bash-completion which doesn't include
# _init_completion. This is a very minimal version of that function.
__<name>_init_completion()
{
COMPREPLY=()
_get_comp_words_by_ref "$@" cur prev words cword
}
__<name>_index_of_word()
{
local w word=$1
shift
index=0
for w in "$@"; do
[[ $w = "$word" ]] && return
index=$((index+1))
done
index=-1
}
__<name>_contains_word()
{
local w word=$1; shift
for w in "$@"; do
[[ $w = "$word" ]] && return
done
return 1
}
__<name>_handle_go_custom_completion()
{
__<name>_debug "${FUNCNAME[0]}: cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}"
local shellCompDirectiveError=%[3]d
local shellCompDirectiveNoSpace=%[4]d
local shellCompDirectiveNoFileComp=%[5]d
local shellCompDirectiveFilterFileExt=%[6]d
local shellCompDirectiveFilterDirs=%[7]d
local out requestComp lastParam lastChar comp directive args
# Prepare the command to request completions for the program.
# Calling ${words[0]} instead of directly <name> allows to handle aliases
args=("${words[@]:1}")
requestComp="${words[0]} %[2]s ${args[*]}"
lastParam=${words[$((${#words[@]}-1))]}
lastChar=${lastParam:$((${#lastParam}-1)):1}
__<name>_debug "${FUNCNAME[0]}: lastParam ${lastParam}, lastChar ${lastChar}"
if [ -z "${cur}" ] && [ "${lastChar}" != "=" ]; then
# If the last parameter is complete (there is a space following it)
# We add an extra empty parameter so we can indicate this to the go method.
__<name>_debug "${FUNCNAME[0]}: Adding extra empty parameter"
requestComp="${requestComp} \"\""
fi
__<name>_debug "${FUNCNAME[0]}: calling ${requestComp}"
# Use eval to handle any environment variables and such
out=$(eval "${requestComp}" 2>/dev/null)
# Extract the directive integer at the very end of the output following a colon (:)
directive=${out##*:}
# Remove the directive
out=${out%%:*}
if [ "${directive}" = "${out}" ]; then
# There is not directive specified
directive=0
fi
__<name>_debug "${FUNCNAME[0]}: the completion directive is: ${directive}"
__<name>_debug "${FUNCNAME[0]}: the completions are: ${out[*]}"
if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then
# Error code. No completion.
__<name>_debug "${FUNCNAME[0]}: received error from custom completion go code"
return
else
if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then
if [[ $(type -t compopt) = "builtin" ]]; then
__<name>_debug "${FUNCNAME[0]}: activating no space"
compopt -o nospace
fi
fi
if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then
if [[ $(type -t compopt) = "builtin" ]]; then
__<name>_debug "${FUNCNAME[0]}: activating no file completion"
compopt +o default
fi
fi
fi
if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then
# File extension filtering
local fullFilter filter filteringCmd
# Do not use quotes around the $out variable or else newline
# characters will be kept.
for filter in ${out[*]}; do
fullFilter+="$filter|"
done
filteringCmd="_filedir $fullFilter"
__<name>_debug "File filtering command: $filteringCmd"
$filteringCmd
elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then
# File completion for directories only
local subdir
# Use printf to strip any trailing newline
subdir=$(printf "%%s" "${out[0]}")
if [ -n "$subdir" ]; then
__<name>_debug "Listing directories in $subdir"
__<name>_handle_subdirs_in_dir_flag "$subdir"
else
__<name>_debug "Listing directories in ."
_filedir -d
fi
else
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done \< \<(compgen -W "${out[*]}" -- "$cur")
fi
}
__<name>_handle_reply()
{
__<name>_debug "${FUNCNAME[0]}"
local comp
case $cur in
-*)
if [[ $(type -t compopt) = "builtin" ]]; then
compopt -o nospace
fi
local allflags
if [ ${#must_have_one_flag[@]} -ne 0 ]; then
allflags=("${must_have_one_flag[@]}")
else
allflags=("${flags[*]} ${two_word_flags[*]}")
fi
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done \< \<(compgen -W "${allflags[*]}" -- "$cur")
if [[ $(type -t compopt) = "builtin" ]]; then
[[ "${COMPREPLY[0]}" == *= ]] || compopt +o nospace
fi
# complete after --flag=abc
if [[ $cur == *=* ]]; then
if [[ $(type -t compopt) = "builtin" ]]; then
compopt +o nospace
fi
local index flag
flag="${cur%%=*}"
__<name>_index_of_word "${flag}" "${flags_with_completion[@]}"
COMPREPLY=()
if [[ ${index} -ge 0 ]]; then
PREFIX=""
cur="${cur#*=}"
${flags_completion[${index}]}
if [ -n "${ZSH_VERSION:-}" ]; then
# zsh completion needs --flag= prefix
eval "COMPREPLY=( \"\${COMPREPLY[@]/#/${flag}=}\" )"
fi
fi
fi
if [[ -z "${flag_parsing_disabled}" ]]; then
# If flag parsing is enabled, we have completed the flags and can return.
# If flag parsing is disabled, we may not know all (or any) of the flags, so we fallthrough
# to possibly call handle_go_custom_completion.
return 0;
fi
;;
esac
# check if we are handling a flag with special work handling
local index
__<name>_index_of_word "${prev}" "${flags_with_completion[@]}"
if [[ ${index} -ge 0 ]]; then
${flags_completion[${index}]}
return
fi
# we are parsing a flag and don't have a special handler, no completion
if [[ ${cur} != "${words[cword]}" ]]; then
return
fi
local completions
completions=("${commands[@]}")
if [[ ${#must_have_one_noun[@]} -ne 0 ]]; then
completions+=("${must_have_one_noun[@]}")
elif [[ -n "${has_completion_function}" ]]; then
# if a go completion function is provided, defer to that function
__<name>_handle_go_custom_completion
fi
if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then
completions+=("${must_have_one_flag[@]}")
fi
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done \< \<(compgen -W "${completions[*]}" -- "$cur")
if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then
while IFS='' read -r comp; do
COMPREPLY+=("$comp")
done \< \<(compgen -W "${noun_aliases[*]}" -- "$cur")
fi
if [[ ${#COMPREPLY[@]} -eq 0 ]]; then
if declare -F __<name>_custom_func >/dev/null; then
# try command name qualified custom func
__<name>_custom_func
else
# otherwise fall back to unqualified for compatibility
declare -F __custom_func >/dev/null && __custom_func
fi
fi
# available in bash-completion >= 2, not always present on macOS
if declare -F __ltrim_colon_completions >/dev/null; then
__ltrim_colon_completions "$cur"
fi
# If there is only 1 completion and it is a flag with an = it will be completed
# but we don't want a space after the =
if [[ "${#COMPREPLY[@]}" -eq "1" ]] && [[ $(type -t compopt) = "builtin" ]] && [[ "${COMPREPLY[0]}" == --*= ]]; then
compopt -o nospace
fi
}
# The arguments should be in the form "ext1|ext2|extn"
__<name>_handle_filename_extension_flag()
{
local ext="$1"
_filedir "@(${ext})"
}
__<name>_handle_subdirs_in_dir_flag()
{
local dir="$1"
pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return
}
__<name>_handle_flag()
{
__<name>_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
# if a command required a flag, and we found it, unset must_have_one_flag()
local flagname=${words[c]}
local flagvalue=""
# if the word contained an =
if [[ ${words[c]} == *"="* ]]; then
flagvalue=${flagname#*=} # take in as flagvalue after the =
flagname=${flagname%%=*} # strip everything after the =
flagname="${flagname}=" # but put the = back
fi
__<name>_debug "${FUNCNAME[0]}: looking for ${flagname}"
if __<name>_contains_word "${flagname}" "${must_have_one_flag[@]}"; then
must_have_one_flag=()
fi
# if you set a flag which only applies to this command, don't show subcommands
if __<name>_contains_word "${flagname}" "${local_nonpersistent_flags[@]}"; then
commands=()
fi
# keep flag value with flagname as flaghash
# flaghash variable is an associative array which is only supported in bash > 3.
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
if [ -n "${flagvalue}" ] ; then
flaghash[${flagname}]=${flagvalue}
elif [ -n "${words[ $((c+1)) ]}" ] ; then
flaghash[${flagname}]=${words[ $((c+1)) ]}
else
flaghash[${flagname}]="true" # pad "true" for bool flag
fi
fi
# skip the argument to a two word flag
if [[ ${words[c]} != *"="* ]] && __<name>_contains_word "${words[c]}" "${two_word_flags[@]}"; then
__<name>_debug "${FUNCNAME[0]}: found a flag ${words[c]}, skip the next argument"
c=$((c+1))
# if we are looking for a flags value, don't show commands
if [[ $c -eq $cword ]]; then
commands=()
fi
fi
c=$((c+1))
}
__<name>_handle_noun()
{
__<name>_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
if __<name>_contains_word "${words[c]}" "${must_have_one_noun[@]}"; then
must_have_one_noun=()
elif __<name>_contains_word "${words[c]}" "${noun_aliases[@]}"; then
must_have_one_noun=()
fi
nouns+=("${words[c]}")
c=$((c+1))
}
__<name>_handle_command()
{
__<name>_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
local next_command
if [[ -n ${last_command} ]]; then
next_command="_${last_command}_${words[c]//:/__}"
else
if [[ $c -eq 0 ]]; then
next_command="_<name>_root_command"
else
next_command="_${words[c]//:/__}"
fi
fi
c=$((c+1))
__<name>_debug "${FUNCNAME[0]}: looking for ${next_command}"
declare -F "$next_command" >/dev/null && $next_command
}
__<name>_handle_word()
{
if [[ $c -ge $cword ]]; then
__<name>_handle_reply
return
fi
__<name>_debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}"
if [[ "${words[c]}" == -* ]]; then
__<name>_handle_flag
elif __<name>_contains_word "${words[c]}" "${commands[@]}"; then
__<name>_handle_command
elif [[ $c -eq 0 ]]; then
__<name>_handle_command
elif __<name>_contains_word "${words[c]}" "${command_aliases[@]}"; then
# aliashash variable is an associative array which is only supported in bash > 3.
if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
words[c]=${aliashash[${words[c]}]}
__<name>_handle_command
else
__<name>_handle_noun
fi
else
__<name>_handle_noun
fi
__<name>_handle_word
}
>>
//
// post content template after commands
//
post(name) ::= <<
__start_<name>()
{
local cur prev words cword split
declare -A flaghash 2>/dev/null || :
declare -A aliashash 2>/dev/null || :
if declare -F _init_completion >/dev/null 2>&1; then
_init_completion -s || return
else
__<name>_init_completion -n "=" || return
fi
local c=0
local flag_parsing_disabled=
local flags=()
local two_word_flags=()
local local_nonpersistent_flags=()
local flags_with_completion=()
local flags_completion=()
local commands=("<name>")
local command_aliases=()
local must_have_one_flag=()
local must_have_one_noun=()
local has_completion_function=""
local last_command=""
local nouns=()
local noun_aliases=()
__<name>_handle_word
}
if [[ $(type -t compopt) = "builtin" ]]; then
complete -o default -F __start_<name> <name>
else
complete -o default -o nospace -F __start_<name> <name>
fi
>>
//
// command_aliases=() template section in commands
//
command_aliases() ::= <<
command_aliases=()
>>
//
// commands=() template section in commands
//
commands(commands) ::= <<
commands=()
<commands:{c | commands+=("<c>")}; separator="\n">
>>
//
// flags=() template section in commands
//
flags() ::= <<
flags=()
>>
//
// two_word_flags=() template section in commands
//
two_word_flags(flags) ::= <<
two_word_flags=()
<flags:{f | two_word_flags+=("<f>")}; separator="\n">
>>
//
// local_nonpersistent_flags=() template section in commands
//
local_nonpersistent_flags() ::= <<
local_nonpersistent_flags=()
>>
//
// flags_with_completion=() template section in commands
//
flags_with_completion() ::= <<
flags_with_completion=()
>>
//
// flags_completion=() template section in commands
//
flags_completion() ::= <<
flags_completion=()
>>
//
// must_have_one_flag=() template section in commands
//
must_have_one_flag() ::= <<
must_have_one_flag=()
>>
//
// must_have_one_noun=() template section in commands
//
must_have_one_noun() ::= <<
must_have_one_noun=()
>>
//
// noun_aliases=() template section in commands
//
noun_aliases() ::= <<
noun_aliases=()
>>
//
// template for each command
//
sub_command(name,command) ::= <<
_<name>_<command.commandParts:{p | <p>}; separator="_">()
{
last_command="<name>_<c.commandParts:{p | <p>}; separator="_">"
<command_aliases()>
<commands(command.subCommands)>
<flags()>
<two_word_flags(command.flags)>
<local_nonpersistent_flags()>
<flags_with_completion()>
<flags_completion()>
<must_have_one_flag()>
<must_have_one_noun()>
<noun_aliases()>
}
>>
//
// top level root commands template
//
root_commands(name,commands) ::= <<
_<name>_root_command()
{
last_command="<name>"
<command_aliases()>
<commands(commands)>
<flags()>
<two_word_flags([])>
<local_nonpersistent_flags()>
<flags_with_completion()>
<flags_completion()>
<must_have_one_flag()>
<must_have_one_noun()>
<noun_aliases()>
}
>>
//
// main template to call from render
//
main(name, model) ::= <<
<pre(name)>
<model.allCommands:{c | <sub_command(name,c)>}; separator="\n\n">
<root_commands(name,model.rootCommands)>
<post(name)>
>>

View File

@@ -0,0 +1,140 @@
/*
* 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.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.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;
public class AbstractCompletionsTests {
@Test
public void testBasicModelGeneration() {
DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry();
List<ParameterResolver> parameterResolvers = new ArrayList<>();
StandardParameterResolver resolver = new StandardParameterResolver(new DefaultConversionService(),
Collections.emptySet());
parameterResolvers.add(resolver);
TestCommands commands = new TestCommands();
Method method1 = ReflectionUtils.findMethod(TestCommands.class, "test1", String.class);
Method method2 = ReflectionUtils.findMethod(TestCommands.class, "test2");
Method method3 = ReflectionUtils.findMethod(TestCommands.class, "test3");
Method method4 = ReflectionUtils.findMethod(TestCommands.class, "test4", String.class);
MethodTarget methodTarget1 = new MethodTarget(method1, commands, "help");
MethodTarget methodTarget2 = new MethodTarget(method2, commands, "help");
MethodTarget methodTarget3 = new MethodTarget(method3, commands, "help");
MethodTarget methodTarget4 = new MethodTarget(method4, commands, "help");
commandRegistry.register("test1", methodTarget1);
commandRegistry.register("test2", methodTarget2);
commandRegistry.register("test3", methodTarget3);
commandRegistry.register("test3 test4", methodTarget4);
TestCompletions completions = new TestCompletions(resourceLoader, commandRegistry, parameterResolvers);
CommandModel commandModel = completions.testCommandModel();
assertThat(commandModel.getCommands()).hasSize(3);
assertThat(commandModel.getCommands().stream().map(c -> c.getMainCommand())).containsExactlyInAnyOrder("test1", "test2",
"test3");
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test1")).findFirst().get()
.getOptions()).hasSize(1);
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test1")).findFirst().get()
.getOptions().get(0).option()).isEqualTo("--param1");
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test2")).findFirst().get()
.getOptions()).hasSize(0);
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get()
.getOptions()).hasSize(0);
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get()
.getCommands()).hasSize(1);
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get()
.getCommands().get(0).getMainCommand()).isEqualTo("test4");
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get()
.getCommands().get(0).getOptions()).hasSize(1);
assertThat(commandModel.getCommands().stream().filter(c -> c.getMainCommand().equals("test3")).findFirst().get()
.getCommands().get(0).getOptions().get(0).option()).isEqualTo("--param4");
}
@Test
public void testBuilder() {
DefaultResourceLoader resourceLoader = new DefaultResourceLoader();
ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry();
List<ParameterResolver> parameterResolvers = new ArrayList<>();
TestCompletions completions = new TestCompletions(resourceLoader, commandRegistry, parameterResolvers);
String result = completions.testBuilder()
.attribute("x", "command")
.group("classpath:completion/test.stg")
.appendGroup("a")
.build();
assertThat(result).contains("foocommand");
}
private static class TestCompletions extends AbstractCompletions {
public TestCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry,
List<ParameterResolver> parameterResolvers) {
super(resourceLoader, commandRegistry, parameterResolvers);
}
CommandModel testCommandModel() {
return generateCommandModel();
}
Builder testBuilder() {
return super.builder();
}
}
private static class TestCommands {
@ShellMethod
void test1(@ShellOption String param1) {
}
@ShellMethod
void test2() {
}
@ShellMethod
void test3() {
}
@ShellMethod
void test4(@ShellOption String param4) {
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.completion;
import java.util.ArrayList;
import java.util.List;
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 static org.assertj.core.api.Assertions.assertThat;
public class BashCompletionsTests {
AnnotationConfigApplicationContext context;
@BeforeEach
public void setup() {
context = new AnnotationConfigApplicationContext();
context.refresh();
}
@AfterEach
public void clean() {
if (context != null) {
context.close();
}
context = null;
}
@Test
public void testDoesNotError() {
ConfigurableCommandRegistry commandRegistry = new ConfigurableCommandRegistry();
List<ParameterResolver> parameterResolvers = new ArrayList<>();
BashCompletions completions = new BashCompletions(context, commandRegistry, parameterResolvers);
String bash = completions.generate("root-command");
assertThat(bash).contains("root-command");
}
}

View File

@@ -0,0 +1,4 @@
b(y) ::= "<y>"
a(x) ::= <<
foo<b(x)>
>>