From 78da986012ac3b77ba77f365644618f9d81d2577 Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Fri, 7 Jan 2022 09:58:21 +0000 Subject: [PATCH] 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 --- pom.xml | 6 + .../shell/boot/SpringShellProperties.java | 33 +- .../StandardCommandsAutoConfiguration.java | 14 +- .../OnCompletionCommandCondition.java | 34 ++ .../boot/SpringShellPropertiesTests.java | 36 +- ...tandardCommandsAutoConfigurationTests.java | 59 +++ .../src/main/resources/application.yml | 7 + .../shell/standard/commands/Completion.java | 59 +++ spring-shell-standard/pom.xml | 4 + .../completion/AbstractCompletions.java | 421 +++++++++++++++ .../standard/completion/BashCompletions.java | 45 ++ .../META-INF/native-image/reflect-config.json | 10 + .../native-image/resource-config.json | 9 + .../src/main/resources/completion/bash.stg | 492 ++++++++++++++++++ .../completion/AbstractCompletionsTests.java | 140 +++++ .../completion/BashCompletionsTests.java | 57 ++ .../src/test/resources/completion/test.stg | 4 + 17 files changed, 1408 insertions(+), 22 deletions(-) create mode 100644 spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/condition/OnCompletionCommandCondition.java create mode 100644 spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/StandardCommandsAutoConfigurationTests.java create mode 100644 spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java create mode 100644 spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java create mode 100644 spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java create mode 100644 spring-shell-standard/src/main/resources/META-INF/native-image/reflect-config.json create mode 100644 spring-shell-standard/src/main/resources/META-INF/native-image/resource-config.json create mode 100644 spring-shell-standard/src/main/resources/completion/bash.stg create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java create mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java create mode 100644 spring-shell-standard/src/test/resources/completion/test.stg diff --git a/pom.xml b/pom.xml index dcf9e8659..0a0857edd 100644 --- a/pom.xml +++ b/pom.xml @@ -24,6 +24,7 @@ 3.21.0 1.81 + 4.3.1 @@ -98,6 +99,11 @@ ${jcommander.version} true + + org.antlr + ST4 + ${antlr-st4.version} + diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java index 7aa18b13f..07e80f80e 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/SpringShellProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 the original author or authors. + * Copyright 2021-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -179,6 +179,28 @@ public void setEnabled(boolean enabled) { } } + 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 static class Command { 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 HistoryCommand getHistory() { public void setHistory(HistoryCommand history) { this.history = history; } + + public CompletionCommand getCompletion() { + return completion; + } + + public void setCompletion(CompletionCommand completion) { + this.completion = completion; + } } } diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardCommandsAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardCommandsAutoConfiguration.java index eb2b7f9c1..0885a0a15 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardCommandsAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/StandardCommandsAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2021 the original author or authors. + * Copyright 2017-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,10 +22,14 @@ 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 @@ */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ Help.Command.class }) +@EnableConfigurationProperties(SpringShellProperties.class) public class StandardCommandsAutoConfiguration { @Bean @@ -82,4 +87,11 @@ public Script script(Parser parser) { 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()); + } } diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/condition/OnCompletionCommandCondition.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/condition/OnCompletionCommandCondition.java new file mode 100644 index 000000000..a82f9b78c --- /dev/null +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/condition/OnCompletionCommandCondition.java @@ -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 { + } +} diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java index 6b1b38cf3..234f8b28e 100644 --- a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/SpringShellPropertiesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2021 the original author or authors. + * Copyright 2021-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -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 void defaultNoPropertiesSet() { 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 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 void setProperties() { 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"); }); } diff --git a/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/StandardCommandsAutoConfigurationTests.java b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/StandardCommandsAutoConfigurationTests.java new file mode 100644 index 000000000..e812fc86b --- /dev/null +++ b/spring-shell-autoconfigure/src/test/java/org/springframework/shell/boot/StandardCommandsAutoConfigurationTests.java @@ -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 disableCommands(String... commands) { + return (cr) -> { + for (String command : commands) { + cr = cr.withPropertyValues(String.format("spring.shell.command.%s.enabled=false", command)); + } + return cr; + }; + } +} diff --git a/spring-shell-samples/src/main/resources/application.yml b/spring-shell-samples/src/main/resources/application.yml index ca84aebe5..41888623a 100644 --- a/spring-shell-samples/src/main/resources/application.yml +++ b/spring-shell-samples/src/main/resources/application.yml @@ -1,3 +1,10 @@ logging: level: root: error +spring: + main: + banner-mode: off + shell: + command: + completion: + root-command: spring-shell-samples diff --git a/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java new file mode 100644 index 000000000..38a3beb62 --- /dev/null +++ b/spring-shell-standard-commands/src/main/java/org/springframework/shell/standard/commands/Completion.java @@ -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); + } +} diff --git a/spring-shell-standard/pom.xml b/spring-shell-standard/pom.xml index 48a3f8d32..b27ceaeb2 100644 --- a/spring-shell-standard/pom.xml +++ b/spring-shell-standard/pom.xml @@ -18,6 +18,10 @@ org.springframework.shell spring-shell-core + + org.antlr + ST4 + org.springframework.boot spring-boot-starter-test diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java new file mode 100644 index 000000000..5c66346ef --- /dev/null +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/AbstractCompletions.java @@ -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 parameterResolvers; + + public AbstractCompletions(ResourceLoader resourceLoader, CommandRegistry commandRegistry, + List 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 commandsByName = commandRegistry.listCommands(); + HashMap commands = new HashMap<>(); + HashSet 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 parameterDescriptions = getParameterDescriptions(methodTarget); + List 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 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 getCommands(); + + /** + * Gets all commands as a flattened structure. + * + * @return all commands + */ + List getAllCommands(); + + /** + * Gets root commands. + * + * @return root commands + */ + List 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 getCommands(); + + /** + * Gets options known to this command + * + * @return known options + */ + List getOptions(); + + /** + * Gets command flags. + * + * @return command flags + */ + List getFlags(); + + /** + * Gets sub commands. + * + * @return sub commands + */ + List getSubCommands(); + + /** + * Gets command parts. Essentially full command split into parts. + * + * @return command parts + */ + List 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 commands; + + public DefaultCommandModel(List commands) { + this.commands = commands; + } + + @Override + public List getCommands() { + return commands; + } + + @Override + public List getAllCommands() { + return getCommands().stream() + .flatMap(c -> flatten(c)) + .collect(Collectors.toList()); + } + + @Override + public List getRootCommands() { + return getCommands().stream() + .map(c -> c.getLastCommandPart()) + .collect(Collectors.toList()); + } + + private Stream 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 commands = new ArrayList<>(); + private List options = new ArrayList<>(); + + DefaultCommandModelCommand(String fullCommand, String mainCommand) { + this.fullCommand = fullCommand; + this.mainCommand = mainCommand; + } + + @Override + public List 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 getSubCommands() { + return this.commands.stream() + .map(c -> c.getMainCommand()) + .collect(Collectors.toList()); + } + + @Override + public List getFlags() { + return this.options.stream() + .map(o -> o.option()) + .collect(Collectors.toList()); + } + + @Override + public List getCommands() { + return commands; + } + + @Override + public List getOptions() { + return options; + } + + void addOptions(List 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 defaultAttributes = new LinkedMultiValueMap<>(); + private final List> 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 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 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(); + } + } +} diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java new file mode 100644 index 000000000..c08cb7f74 --- /dev/null +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/completion/BashCompletions.java @@ -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 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(); + } +} diff --git a/spring-shell-standard/src/main/resources/META-INF/native-image/reflect-config.json b/spring-shell-standard/src/main/resources/META-INF/native-image/reflect-config.json new file mode 100644 index 000000000..c0541d003 --- /dev/null +++ b/spring-shell-standard/src/main/resources/META-INF/native-image/reflect-config.json @@ -0,0 +1,10 @@ +[ + { + "name": "org.springframework.shell.standard.completion.AbstractCompletions$DefaultCommandModel", + "allDeclaredMethods": true + }, + { + "name": "org.springframework.shell.standard.completion.AbstractCompletions$DefaultCommandModelCommand", + "allDeclaredMethods": true + } +] \ No newline at end of file diff --git a/spring-shell-standard/src/main/resources/META-INF/native-image/resource-config.json b/spring-shell-standard/src/main/resources/META-INF/native-image/resource-config.json new file mode 100644 index 000000000..9f362800c --- /dev/null +++ b/spring-shell-standard/src/main/resources/META-INF/native-image/resource-config.json @@ -0,0 +1,9 @@ +{ + "resources": { + "includes": [ + { + "pattern": "completion/.*" + } + ] + } +} \ No newline at end of file diff --git a/spring-shell-standard/src/main/resources/completion/bash.stg b/spring-shell-standard/src/main/resources/completion/bash.stg new file mode 100644 index 000000000..0e368f88f --- /dev/null +++ b/spring-shell-standard/src/main/resources/completion/bash.stg @@ -0,0 +1,492 @@ +// +// pre content template before commands +// needs to escape some > characters +// +pre(name) ::= << +# bash completion for -*- shell-script -*- +___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. +___init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} +___index_of_word() +{ + local w word=$1 + shift + index=0 + for w in "$@"; do + [[ $w = "$word" ]] && return + index=$((index+1)) + done + index=-1 +} +___contains_word() +{ + local w word=$1; shift + for w in "$@"; do + [[ $w = "$word" ]] && return + done + return 1 +} +___handle_go_custom_completion() +{ + ___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 allows to handle aliases + args=("${words[@]:1}") + requestComp="${words[0]} %[2]s ${args[*]}" + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + ___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. + ___debug "${FUNCNAME[0]}: Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + ___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 + ___debug "${FUNCNAME[0]}: the completion directive is: ${directive}" + ___debug "${FUNCNAME[0]}: the completions are: ${out[*]}" + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + # Error code. No completion. + ___debug "${FUNCNAME[0]}: received error from custom completion go code" + return + else + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + ___debug "${FUNCNAME[0]}: activating no space" + compopt -o nospace + fi + fi + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + if [[ $(type -t compopt) = "builtin" ]]; then + ___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" + ___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 + ___debug "Listing directories in $subdir" + ___handle_subdirs_in_dir_flag "$subdir" + else + ___debug "Listing directories in ." + _filedir -d + fi + else + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done \< \<(compgen -W "${out[*]}" -- "$cur") + fi +} +___handle_reply() +{ + ___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%%=*}" + ___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 + ___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 + ___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 ___custom_func >/dev/null; then + # try command name qualified custom func + ___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" +___handle_filename_extension_flag() +{ + local ext="$1" + _filedir "@(${ext})" +} +___handle_subdirs_in_dir_flag() +{ + local dir="$1" + pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return +} +___handle_flag() +{ + ___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 + ___debug "${FUNCNAME[0]}: looking for ${flagname}" + if ___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 ___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]} != *"="* ]] && ___contains_word "${words[c]}" "${two_word_flags[@]}"; then + ___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)) +} +___handle_noun() +{ + ___debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + if ___contains_word "${words[c]}" "${must_have_one_noun[@]}"; then + must_have_one_noun=() + elif ___contains_word "${words[c]}" "${noun_aliases[@]}"; then + must_have_one_noun=() + fi + nouns+=("${words[c]}") + c=$((c+1)) +} +___handle_command() +{ + ___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="__root_command" + else + next_command="_${words[c]//:/__}" + fi + fi + c=$((c+1)) + ___debug "${FUNCNAME[0]}: looking for ${next_command}" + declare -F "$next_command" >/dev/null && $next_command +} +___handle_word() +{ + if [[ $c -ge $cword ]]; then + ___handle_reply + return + fi + ___debug "${FUNCNAME[0]}: c is $c words[c] is ${words[c]}" + if [[ "${words[c]}" == -* ]]; then + ___handle_flag + elif ___contains_word "${words[c]}" "${commands[@]}"; then + ___handle_command + elif [[ $c -eq 0 ]]; then + ___handle_command + elif ___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]}]} + ___handle_command + else + ___handle_noun + fi + else + ___handle_noun + fi + ___handle_word +} +>> + +// +// post content template after commands +// +post(name) ::= << +__start_() +{ + 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 + ___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=("") + 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=() + ___handle_word +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_ +else + complete -o default -o nospace -F __start_ +fi +>> + +// +// command_aliases=() template section in commands +// +command_aliases() ::= << +command_aliases=() +>> + +// +// commands=() template section in commands +// +commands(commands) ::= << +commands=() +")}; separator="\n"> +>> + +// +// flags=() template section in commands +// +flags() ::= << +flags=() +>> + +// +// two_word_flags=() template section in commands +// +two_word_flags(flags) ::= << +two_word_flags=() +")}; 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) ::= << +__}; separator="_">() +{ + last_command="_}; separator="_">" + + + + + + + + + + + +} +>> + +// +// top level root commands template +// +root_commands(name,commands) ::= << +__root_command() +{ + last_command="" + + + + + + + + + + + +} +>> + +// +// main template to call from render +// +main(name, model) ::= << + + +}; separator="\n\n"> + + + + +>> diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java new file mode 100644 index 000000000..5f7d4381c --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/AbstractCompletionsTests.java @@ -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 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 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 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) { + } + } +} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java new file mode 100644 index 000000000..9d8b1d252 --- /dev/null +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/completion/BashCompletionsTests.java @@ -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 parameterResolvers = new ArrayList<>(); + BashCompletions completions = new BashCompletions(context, commandRegistry, parameterResolvers); + String bash = completions.generate("root-command"); + assertThat(bash).contains("root-command"); + } +} diff --git a/spring-shell-standard/src/test/resources/completion/test.stg b/spring-shell-standard/src/test/resources/completion/test.stg new file mode 100644 index 000000000..6b3e6c834 --- /dev/null +++ b/spring-shell-standard/src/test/resources/completion/test.stg @@ -0,0 +1,4 @@ +b(y) ::= "" +a(x) ::= << +foo +>>