From 5eaa5dd093baeb9dfa89803158d0f338a2dfa4a6 Mon Sep 17 00:00:00 2001 From: Janne Valkealahti Date: Tue, 28 Jun 2022 10:03:50 +0100 Subject: [PATCH] Implement interactive completion - This is a re-implementation of a interactive completion with breaking changes as it moves away from a direct use of a MethodParameter in favour of a CommandRegistration and its option definitions. - Fixes #449 --- .../boot/CompleterAutoConfiguration.java | 2 +- .../shell/CompletionContext.java | 26 +++- .../java/org/springframework/shell/Shell.java | 71 +++++++++- .../shell/command/CommandOption.java | 31 ++++- .../shell/command/CommandRegistration.java | 23 +++- .../org/springframework/shell/ShellTests.java | 18 +-- .../shell/command/CommandParserTests.java | 4 +- .../command/CommandRegistrationTests.java | 21 +++ .../shell/samples/standard/Commands.java | 38 +----- .../samples/standard/CompleteCommands.java | 123 ++++++++++++++++++ .../shell/standard/CommandValueProvider.java | 6 +- .../shell/standard/EnumValueProvider.java | 40 +++--- .../shell/standard/FileValueProvider.java | 21 ++- .../StandardMethodTargetRegistrar.java | 13 ++ .../shell/standard/ValueProvider.java | 18 ++- .../shell/standard/ValueProviderSupport.java | 38 ------ ...st.java => CommandValueProviderTests.java} | 27 +--- .../shell/standard/Remote.java | 91 ------------- 18 files changed, 364 insertions(+), 247 deletions(-) create mode 100644 spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/CompleteCommands.java delete mode 100644 spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProviderSupport.java rename spring-shell-standard/src/test/java/org/springframework/shell/standard/{CommandValueProviderTest.java => CommandValueProviderTests.java} (69%) delete mode 100644 spring-shell-standard/src/test/java/org/springframework/shell/standard/Remote.java diff --git a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CompleterAutoConfiguration.java b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CompleterAutoConfiguration.java index 2f16fd9a..2cd84499 100644 --- a/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CompleterAutoConfiguration.java +++ b/spring-shell-autoconfigure/src/main/java/org/springframework/shell/boot/CompleterAutoConfiguration.java @@ -52,7 +52,7 @@ public class CompleterAutoConfiguration { public void complete(LineReader reader, ParsedLine line, List candidates) { CompletingParsedLine cpl = (line instanceof CompletingParsedLine) ? ((CompletingParsedLine) line) : t -> t; - CompletionContext context = new CompletionContext(sanitizeInput(line.words()), line.wordIndex(), line.wordCursor()); + CompletionContext context = new CompletionContext(sanitizeInput(line.words()), line.wordIndex(), line.wordCursor(), null, null); List proposals = shell.complete(context); proposals.stream() diff --git a/spring-shell-core/src/main/java/org/springframework/shell/CompletionContext.java b/spring-shell-core/src/main/java/org/springframework/shell/CompletionContext.java index 0bb33197..1906db32 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/CompletionContext.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/CompletionContext.java @@ -20,6 +20,9 @@ import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +import org.springframework.shell.command.CommandOption; +import org.springframework.shell.command.CommandRegistration; + /** * Represents the buffer context in which completion was triggered. * @@ -33,16 +36,22 @@ public class CompletionContext { private final int position; + private final CommandOption commandOption; + + private final CommandRegistration commandRegistration; + /** * * @param words words in the buffer, excluding words for the command name * @param wordIndex the index of the word the cursor is in * @param position the position inside the current word where the cursor is */ - public CompletionContext(List words, int wordIndex, int position) { + public CompletionContext(List words, int wordIndex, int position, CommandRegistration commandRegistration, CommandOption commandOption) { this.words = words; this.wordIndex = wordIndex; this.position = position; + this.commandRegistration = commandRegistration; + this.commandOption = commandOption; } public List getWords() { @@ -57,6 +66,14 @@ public class CompletionContext { return position; } + public CommandOption getCommandOption() { + return commandOption; + } + + public CommandRegistration getCommandRegistration() { + return commandRegistration; + } + public String upToCursor() { String start = words.subList(0, wordIndex).stream().collect(Collectors.joining(" ")); if (wordIndex < words.size()) { @@ -84,6 +101,11 @@ public class CompletionContext { * Return a copy of this context, as if the first {@literal nbWords} were not present */ public CompletionContext drop(int nbWords) { - return new CompletionContext(new ArrayList(words.subList(nbWords, words.size())), wordIndex-nbWords, position); + return new CompletionContext(new ArrayList(words.subList(nbWords, words.size())), wordIndex - nbWords, + position, commandRegistration, commandOption); + } + + public CompletionContext commandOption(CommandOption commandOption) { + return new CompletionContext(words, wordIndex, position, commandRegistration, commandOption); } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java index 81ad1cc0..eda63b82 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/Shell.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/Shell.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.validation.Validator; import javax.validation.ValidatorFactory; @@ -38,6 +39,7 @@ import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.shell.command.CommandAlias; import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.command.CommandExecution; +import org.springframework.shell.command.CommandOption; import org.springframework.shell.command.CommandExecution.CommandExecutionException; import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers; import org.springframework.shell.command.CommandRegistration; @@ -45,6 +47,7 @@ import org.springframework.shell.completion.CompletionResolver; import org.springframework.shell.context.InteractionMode; import org.springframework.shell.context.ShellContext; import org.springframework.shell.exit.ExitCodeMappings; +import org.springframework.util.StringUtils; /** * Main class implementing a shell loop. @@ -275,17 +278,83 @@ public class Shell { String best = findLongestCommand(prefix); if (best != null) { CompletionContext argsContext = context.drop(best.split(" ").length); - // Try to complete arguments CommandRegistration registration = commandRegistry.getRegistrations().get(best); for (CompletionResolver resolver : completionResolvers) { List resolved = resolver.resolve(registration, argsContext); candidates.addAll(resolved); } + + // Try to complete arguments + List matchedArgOptions = new ArrayList<>(); + if (argsContext.getWords().size() > 0) { + matchedArgOptions.addAll(matchOptions(registration.getOptions(), argsContext.getWords().get(0))); + } + + List argProposals = matchedArgOptions.stream() + .flatMap(o -> { + Function> completion = o.getCompletion(); + if (completion != null) { + List apply = completion.apply(argsContext.commandOption(o)); + return apply.stream(); + } + return Stream.empty(); + }) + .collect(Collectors.toList()); + + candidates.addAll(argProposals); } return candidates; } + private List matchOptions(List options, String arg) { + List matched = new ArrayList<>(); + String trimmed = StringUtils.trimLeadingCharacter(arg, '-'); + int count = arg.length() - trimmed.length(); + if (count == 1) { + if (trimmed.length() == 1) { + Character trimmedChar = trimmed.charAt(0); + options.stream() + .filter(o -> { + for (Character sn : o.getShortNames()) { + if (trimmedChar.equals(sn)) { + return true; + } + } + return false; + }) + .findFirst() + .ifPresent(o -> matched.add(o)); + } + else if (trimmed.length() > 1) { + trimmed.chars().mapToObj(i -> (char)i) + .forEach(c -> { + options.stream().forEach(o -> { + for (Character sn : o.getShortNames()) { + if (c.equals(sn)) { + matched.add(o); + } + } + }); + }); + } + } + else if (count == 2) { + options.stream() + .filter(o -> { + for (String ln : o.getLongNames()) { + if (trimmed.equals(ln)) { + return true; + } + } + return false; + }) + .findFirst() + .ifPresent(o -> matched.add(o)); + } + return matched; + } + private List commandsStartingWith(String prefix) { // Workaround for https://github.com/spring-projects/spring-shell/issues/150 // (sadly, this ties this class to JLine somehow) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java index 9079d1cd..df240f4b 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandOption.java @@ -15,7 +15,12 @@ */ package org.springframework.shell.command; +import java.util.List; +import java.util.function.Function; + import org.springframework.core.ResolvableType; +import org.springframework.shell.CompletionContext; +import org.springframework.shell.CompletionProposal; /** * Interface representing an option in a command. @@ -94,6 +99,13 @@ public interface CommandOption { */ String getLabel(); + /** + * Gets a completion function. + * + * @return the completion function + */ + Function> getCompletion(); + /** * Gets an instance of a default {@link CommandOption}. * @@ -103,7 +115,7 @@ public interface CommandOption { * @return default command option */ public static CommandOption of(String[] longNames, Character[] shortNames, String description) { - return of(longNames, shortNames, description, null, false, null, null, null, null, null); + return of(longNames, shortNames, description, null, false, null, null, null, null, null, null); } /** @@ -117,7 +129,7 @@ public interface CommandOption { */ public static CommandOption of(String[] longNames, Character[] shortNames, String description, ResolvableType type) { - return of(longNames, shortNames, description, type, false, null, null, null, null, null); + return of(longNames, shortNames, description, type, false, null, null, null, null, null, null); } /** @@ -133,13 +145,14 @@ public interface CommandOption { * @param arityMin the min arity * @param arityMax the max arity * @param label the label + * @param completion the completion * @return default command option */ public static CommandOption of(String[] longNames, Character[] shortNames, String description, ResolvableType type, boolean required, String defaultValue, Integer position, Integer arityMin, - Integer arityMax, String label) { + Integer arityMax, String label, Function> completion) { return new DefaultCommandOption(longNames, shortNames, description, type, required, defaultValue, position, - arityMin, arityMax, label); + arityMin, arityMax, label, completion); } /** @@ -157,10 +170,12 @@ public interface CommandOption { private int arityMin; private int arityMax; private String label; + private Function> completion; public DefaultCommandOption(String[] longNames, Character[] shortNames, String description, ResolvableType type, boolean required, String defaultValue, Integer position, - Integer arityMin, Integer arityMax, String label) { + Integer arityMin, Integer arityMax, String label, + Function> completion) { this.longNames = longNames != null ? longNames : new String[0]; this.shortNames = shortNames != null ? shortNames : new Character[0]; this.description = description; @@ -171,6 +186,7 @@ public interface CommandOption { this.arityMin = arityMin != null ? arityMin : -1; this.arityMax = arityMax != null ? arityMax : -1; this.label = label; + this.completion = completion; } @Override @@ -222,5 +238,10 @@ public interface CommandOption { public String getLabel() { return label; } + + @Override + public Function> getCompletion() { + return completion; + } } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java index 11444c70..099ffbd1 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/command/CommandRegistration.java @@ -29,6 +29,8 @@ import java.util.stream.Stream; import org.springframework.core.ResolvableType; import org.springframework.lang.Nullable; import org.springframework.shell.Availability; +import org.springframework.shell.CompletionContext; +import org.springframework.shell.CompletionProposal; import org.springframework.shell.context.InteractionMode; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -208,6 +210,14 @@ public interface CommandRegistration { */ OptionSpec label(String label); + /** + * Define a {@code completion function} for an option. + * + * @param completion the completion function + * @return option spec for chaining + */ + OptionSpec completion(Function> completion); + /** * Return a builder for chaining. * @@ -528,6 +538,7 @@ public interface CommandRegistration { private Integer arityMin; private Integer arityMax; private String label; + private Function> completion; DefaultOptionSpec(BaseBuilder builder) { this.builder = builder; @@ -626,6 +637,12 @@ public interface CommandRegistration { return this; } + @Override + public OptionSpec completion(Function> completion) { + this.completion = completion; + return this; + } + @Override public Builder and() { return builder; @@ -670,6 +687,10 @@ public interface CommandRegistration { public String getLabel() { return label; } + + public Function> getCompletion() { + return completion; + } } static class DefaultTargetSpec implements TargetSpec { @@ -840,7 +861,7 @@ public interface CommandRegistration { return optionSpecs.stream() .map(o -> CommandOption.of(o.getLongNames(), o.getShortNames(), o.getDescription(), o.getType(), o.isRequired(), o.getDefaultValue(), o.getPosition(), o.getArityMin(), o.getArityMax(), - o.getLabel())) + o.getLabel(), o.getCompletion())) .collect(Collectors.toList()); } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java b/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java index 391bfcc5..1b40cf27 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/ShellTests.java @@ -224,37 +224,37 @@ public class ShellTests { when(commandRegistry.getRegistrations()).thenReturn(registrations); // Invoke at very start - List proposals = shell.complete(new CompletionContext(Arrays.asList(""), 0, "".length())) + List proposals = shell.complete(new CompletionContext(Arrays.asList(""), 0, "".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactlyInAnyOrder("another command", "hello world"); // Invoke in middle of first word - proposals = shell.complete(new CompletionContext(Arrays.asList("hel"), 0, "hel".length())) + proposals = shell.complete(new CompletionContext(Arrays.asList("hel"), 0, "hel".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactly("hello world"); // Invoke at end of first word (no space after yet) - proposals = shell.complete(new CompletionContext(Arrays.asList("hello"), 0, "hello".length())) + proposals = shell.complete(new CompletionContext(Arrays.asList("hello"), 0, "hello".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactly("hello world"); // Invoke after first word / start of second word - proposals = shell.complete(new CompletionContext(Arrays.asList("hello", ""), 1, "".length())) + proposals = shell.complete(new CompletionContext(Arrays.asList("hello", ""), 1, "".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactly("world"); // Invoke in middle of second word - proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "wo"), 1, "wo".length())) + proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "wo"), 1, "wo".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactly("world"); // Invoke at end of whole command (no space after yet) - proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world"), 1, "world".length())) + proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world"), 1, "world".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactly("world"); // Invoke in middle of second word - proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length())) + proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).isEmpty(); } @@ -287,7 +287,7 @@ public class ShellTests { registrations.put("hello world", registration1); when(commandRegistry.getRegistrations()).thenReturn(registrations); - List proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length())) + List proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactlyInAnyOrder("--arg1"); } @@ -311,7 +311,7 @@ public class ShellTests { registrations.put("hello world", registration1); when(commandRegistry.getRegistrations()).thenReturn(registrations); - List proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length())) + List proposals = shell.complete(new CompletionContext(Arrays.asList("hello", "world", ""), 2, "".length(), null, null)) .stream().map(CompletionProposal::value).collect(Collectors.toList()); assertThat(proposals).containsExactlyInAnyOrder("--arg1"); } diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java index f3c36183..52ffd9af 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandParserTests.java @@ -327,7 +327,7 @@ public class CommandParserTests extends AbstractCommandTests { public void testBooleanWithDefault() { ResolvableType type = ResolvableType.forType(boolean.class); CommandOption option1 = CommandOption.of(new String[] { "arg1" }, new Character[0], "description", type, false, - "true", null, null, null, null); + "true", null, null, null, null, null); List options = Arrays.asList(option1); String[] args = new String[]{}; @@ -359,7 +359,7 @@ public class CommandParserTests extends AbstractCommandTests { private static CommandOption longOption(String name, ResolvableType type, boolean required, Integer position, Integer arityMin, Integer arityMax) { return CommandOption.of(new String[] { name }, new Character[0], "desc", type, required, null, position, - arityMin, arityMax, null); + arityMin, arityMax, null, null); } private static CommandOption shortOption(char name) { diff --git a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java index 6979b21e..e025b67f 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/command/CommandRegistrationTests.java @@ -15,6 +15,8 @@ */ package org.springframework.shell.command; +import java.util.ArrayList; + import org.junit.jupiter.api.Test; import org.springframework.core.ResolvableType; @@ -434,4 +436,23 @@ public class CommandRegistrationTests extends AbstractCommandTests { assertThat(registration.getAvailability()).isNotNull(); assertThat(registration.getAvailability().isAvailable()).isFalse(); } + + @Test + public void testOptionWithCompletion() { + CommandRegistration registration; + registration = CommandRegistration.builder() + .command("command1") + .withOption() + .longNames("arg1") + .completion(ctx -> { + return new ArrayList<>(); + }) + .and() + .withTarget() + .function(function1) + .and() + .build(); + assertThat(registration.getOptions()).hasSize(1); + assertThat(registration.getOptions().get(0).getCompletion()).isNotNull(); + } } diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java index c57f3cbf..6985e545 100644 --- a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/Commands.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2015-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,18 +20,12 @@ import java.lang.annotation.ElementType; import java.util.Arrays; import java.util.Iterator; import java.util.List; -import java.util.stream.Collectors; -import org.springframework.core.MethodParameter; -import org.springframework.shell.CompletionContext; -import org.springframework.shell.CompletionProposal; +import javax.validation.constraints.Size; + import org.springframework.shell.standard.ShellComponent; import org.springframework.shell.standard.ShellMethod; import org.springframework.shell.standard.ShellOption; -import org.springframework.shell.standard.ValueProviderSupport; -import org.springframework.stereotype.Component; - -import javax.validation.constraints.Size; /** * Example commands for the Shell 2 Standard resolver. @@ -61,11 +55,6 @@ public class Commands { System.out.println("You passed " + force); } - @ShellMethod("Test completion of special values.") - public void quote(@ShellOption(valueProvider = FunnyValuesProvider.class) String text) { - System.out.println("You said " + text); - } - @ShellMethod("Add numbers.") public int add(int a, int b, int c) { return a + b + c; @@ -98,24 +87,3 @@ public class Commands { return iterable; } } - -/** - * A {@link org.springframework.shell.standard.ValueProvider} that emits values with special characters - * (quotes, escapes, etc.) - * - * @author Eric Bottard - */ -@Component -class FunnyValuesProvider extends ValueProviderSupport { - - private final static String[] VALUES = new String[] { - "hello world", - "I'm quoting \"The Daily Mail\"", - "10 \\ 3 = 3" - }; - - @Override - public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { - return Arrays.stream(VALUES).map(CompletionProposal::new).collect(Collectors.toList()); - } -} diff --git a/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/CompleteCommands.java b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/CompleteCommands.java new file mode 100644 index 00000000..dc58fdba --- /dev/null +++ b/spring-shell-samples/src/main/java/org/springframework/shell/samples/standard/CompleteCommands.java @@ -0,0 +1,123 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.shell.samples.standard; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.context.annotation.Bean; +import org.springframework.shell.CompletionContext; +import org.springframework.shell.CompletionProposal; +import org.springframework.shell.command.CommandRegistration; +import org.springframework.shell.standard.EnumValueProvider; +import org.springframework.shell.standard.ShellComponent; +import org.springframework.shell.standard.ShellMethod; +import org.springframework.shell.standard.ShellOption; +import org.springframework.shell.standard.ValueProvider; + +@ShellComponent +public class CompleteCommands { + + @Bean + CommandRegistration completeCommandRegistration1() { + return CommandRegistration.builder() + .command("complete", "sample1") + .description("complete sample1") + .group("Complete Commands") + .withOption() + .longNames("arg1") + .completion(ctx -> { + CompletionProposal p1 = new CompletionProposal("arg1hi1"); + CompletionProposal p2 = new CompletionProposal("arg1hi2"); + return Arrays.asList(p1, p2); + }) + .and() + .withOption() + .longNames("arg2") + .completion(ctx -> { + CompletionProposal p1 = new CompletionProposal("arg2hi1"); + CompletionProposal p2 = new CompletionProposal("arg2hi2"); + return Arrays.asList(p1, p2); + }) + .and() + .withTarget() + .function(ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return String.format("hi, arg1 value is '%s'", arg1); + }) + .and() + .build(); + } + + @ShellMethod(value = "complete sample2", key = "complete sample2") + public String completeCommandSample2(@ShellOption(valueProvider = FunnyValuesProvider.class) String arg1) { + return "You said " + arg1; + } + + @Bean + FunnyValuesProvider funnyValuesProvider() { + return new FunnyValuesProvider(); + } + + static class FunnyValuesProvider implements ValueProvider { + + private final static String[] VALUES = new String[] { + "hello world", + "I am quoting \"The Daily Mail\"", + "10 \\ 3 = 3" + }; + + @Override + public List complete(CompletionContext completionContext) { + return Arrays.stream(VALUES).map(CompletionProposal::new).collect(Collectors.toList()); + } + } + + @Bean + CommandRegistration completeCommandRegistration3() { + return CommandRegistration.builder() + .command("complete", "sample3") + .description("complete sample3") + .group("Complete Commands") + .withOption() + .longNames("arg1") + .type(MyEnums.class) + .completion(ctx -> { + CompletionProposal p1 = new CompletionProposal(MyEnums.E1.toString()); + CompletionProposal p2 = new CompletionProposal(MyEnums.E2.toString()); + CompletionProposal p3 = new CompletionProposal(MyEnums.E3.toString()); + return Arrays.asList(p1, p2, p3); + }) + .and() + .withTarget() + .function(ctx -> { + String arg1 = ctx.getOptionValue("arg1"); + return String.format("You said '%s'", arg1); + }) + .and() + .build(); + } + + @ShellMethod(value = "complete sample4", key = "complete sample4") + public String completeCommandSample4(@ShellOption(valueProvider = EnumValueProvider.class) MyEnums arg1) { + return "You said " + arg1; + } + + static enum MyEnums { + E1, E2, E3 + } +} diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java index 9a9e1cf1..2c482715 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/CommandValueProvider.java @@ -19,7 +19,6 @@ package org.springframework.shell.standard; import java.util.List; import java.util.stream.Collectors; -import org.springframework.core.MethodParameter; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; import org.springframework.shell.command.CommandCatalog; @@ -28,8 +27,9 @@ import org.springframework.shell.command.CommandCatalog; * A {@link ValueProvider} that can be used to auto-complete names of shell commands. * * @author Eric Bottard + * @author Janne Valkealahti */ -public class CommandValueProvider extends ValueProviderSupport { +public class CommandValueProvider implements ValueProvider { private final CommandCatalog commandRegistry; @@ -38,7 +38,7 @@ public class CommandValueProvider extends ValueProviderSupport { } @Override - public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { + public List complete(CompletionContext completionContext) { return commandRegistry.getRegistrations().keySet().stream() .map(CompletionProposal::new) .collect(Collectors.toList()); diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/EnumValueProvider.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/EnumValueProvider.java index 811c491a..4510c527 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/EnumValueProvider.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/EnumValueProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 the original author or authors. + * Copyright 2016-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. @@ -19,9 +19,10 @@ package org.springframework.shell.standard; import java.util.ArrayList; import java.util.List; -import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; +import org.springframework.shell.command.CommandOption; /** * A {@link ValueProvider} that knows how to complete values for {@link Enum} typed parameters. @@ -30,21 +31,28 @@ import org.springframework.shell.CompletionProposal; public class EnumValueProvider implements ValueProvider { @Override - public boolean supports(MethodParameter parameter, CompletionContext completionContext) { - return Enum.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { + public List complete(CompletionContext completionContext) { List result = new ArrayList<>(); - for (Object v : parameter.getParameterType().getEnumConstants()) { - Enum e = (Enum) v; - String prefix = completionContext.currentWordUpToCursor(); - if (prefix == null) { - prefix = ""; - } - if (e.name().startsWith(prefix)) { - result.add(new CompletionProposal(e.name())); + CommandOption commandOption = completionContext.getCommandOption(); + if (commandOption != null) { + ResolvableType type = commandOption.getType(); + if (type != null) { + Class clazz = type.getRawClass(); + if (clazz != null) { + Object[] enumConstants = clazz.getEnumConstants(); + if (enumConstants != null) { + for (Object v : enumConstants) { + Enum e = (Enum) v; + String prefix = completionContext.currentWordUpToCursor(); + if (prefix == null) { + prefix = ""; + } + if (e.name().startsWith(prefix)) { + result.add(new CompletionProposal(e.name())); + } + } + } + } } } return result; diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/FileValueProvider.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/FileValueProvider.java index 7098bd26..0f709341 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/FileValueProvider.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/FileValueProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,6 @@ import java.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; -import org.springframework.core.MethodParameter; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; @@ -36,29 +35,25 @@ import static java.nio.file.FileVisitOption.FOLLOW_LINKS; * current working directory. * * @author Eric Bottard + * @author Janne Valkealahti */ public class FileValueProvider implements ValueProvider { - @Override - public boolean supports(MethodParameter parameter, CompletionContext completionContext) { - return parameter.getParameterType().equals(File.class); - } - - @Override - public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { + @Override + public List complete(CompletionContext completionContext) { String input = completionContext.currentWordUpToCursor(); int lastSlash = input.lastIndexOf(File.separatorChar); Path dir = lastSlash > -1 ? Paths.get(input.substring(0, lastSlash+1)) : Paths.get(""); String prefix = input.substring(lastSlash + 1, input.length()); try { - return Files.find(dir, 1, (p, a) -> p.getFileName() != null && p.getFileName().toString().startsWith(prefix), FOLLOW_LINKS) + return Files + .find(dir, 1, (p, a) -> p.getFileName() != null && p.getFileName().toString().startsWith(prefix), + FOLLOW_LINKS) .map(p -> new CompletionProposal(p.toString())) .collect(Collectors.toList()); } catch (IOException e) { throw new UncheckedIOException(e); } - - } - + } } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java index 4167027d..ff8f04f8 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/StandardMethodTargetRegistrar.java @@ -23,6 +23,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -36,13 +37,17 @@ import org.springframework.core.MethodParameter; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.messaging.handler.invocation.InvocableHandlerMethod; import org.springframework.shell.Availability; +import org.springframework.shell.CompletionContext; +import org.springframework.shell.CompletionProposal; import org.springframework.shell.MethodTargetRegistrar; import org.springframework.shell.Utils; import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.command.CommandRegistration; import org.springframework.shell.command.CommandRegistration.Builder; import org.springframework.shell.command.CommandRegistration.OptionSpec; +import org.springframework.shell.standard.ShellOption.NoValueProvider; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; @@ -143,6 +148,14 @@ public class StandardMethodTargetRegistrar implements MethodTargetRegistrar, App if (ObjectUtils.nullSafeEquals(so.defaultValue(), ShellOption.NONE)) { optionSpec.required(); } + if (!ClassUtils.isAssignable(NoValueProvider.class, so.valueProvider())) { + Function> completionFunction = ctx -> { + ValueProvider valueProviderBean = this.applicationContext.getBean(so.valueProvider()); + List complete = valueProviderBean.complete(ctx); + return complete; + }; + optionSpec.completion(completionFunction); + } } } else { diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProvider.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProvider.java index c648dc8a..64b4ef0b 100644 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProvider.java +++ b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2016 the original author or authors. + * Copyright 2016-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,23 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.standard; import java.util.List; -import org.springframework.core.MethodParameter; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; /** - * Beans implementing this interface are queried during TAB completion to gather possible values of a parameter. + * Beans implementing this interface are queried during TAB completion to gather + * possible values of a parameter. * * @author Eric Bottard + * @author Janne Valkealahti */ public interface ValueProvider { - boolean supports(MethodParameter parameter, CompletionContext completionContext); - - List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints); + /** + * Complete completion proposals. + * + * @param completionContext the context + * @return the completion proposals + */ + List complete(CompletionContext completionContext); } diff --git a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProviderSupport.java b/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProviderSupport.java deleted file mode 100644 index a4209e87..00000000 --- a/spring-shell-standard/src/main/java/org/springframework/shell/standard/ValueProviderSupport.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2016 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.shell.standard; - -import org.springframework.core.MethodParameter; -import org.springframework.shell.CompletionContext; - -/** - * Base class for {@link ValueProvider} that match by type. Subclasses {@literal C} will be selected for parameters - * whose {@literal @}{@link ShellOption#valueProvider()} return the concrete class {@literal C}. - * - * @author Eric Bottard - */ -public abstract class ValueProviderSupport implements ValueProvider { - - @Override - public boolean supports(MethodParameter parameter, CompletionContext completionContext) { - ShellOption annotation = parameter.getParameterAnnotation(ShellOption.class); - if (annotation == null) { - return false; - } - return annotation.valueProvider().isAssignableFrom(this.getClass()); - } -} diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTests.java similarity index 69% rename from spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java rename to spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTests.java index 28edef46..4d2de9e1 100644 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTest.java +++ b/spring-shell-standard/src/test/java/org/springframework/shell/standard/CommandValueProviderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-2022 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,11 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package org.springframework.shell.standard; - -import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -28,13 +25,10 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.springframework.core.MethodParameter; import org.springframework.shell.CompletionContext; import org.springframework.shell.CompletionProposal; -import org.springframework.shell.Utils; import org.springframework.shell.command.CommandCatalog; import org.springframework.shell.command.CommandRegistration; -import org.springframework.util.ReflectionUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; @@ -44,7 +38,7 @@ import static org.mockito.Mockito.when; * * @author Eric Bottard */ -public class CommandValueProviderTest { +public class CommandValueProviderTests { @Mock private CommandCatalog catalog; @@ -58,30 +52,17 @@ public class CommandValueProviderTest { public void testValues() { CommandValueProvider valueProvider = new CommandValueProvider(catalog); - Method help = ReflectionUtils.findMethod(Command.class, "help", String.class); - MethodParameter methodParameter = Utils.createMethodParameter(help, 0); - CompletionContext completionContext = new CompletionContext(Arrays.asList("help", "m"), 0, 0); - boolean supports = valueProvider.supports(methodParameter, completionContext); + CompletionContext completionContext = new CompletionContext(Arrays.asList("help", "m"), 0, 0, null, null); - assertThat(supports).isEqualTo(true); Map registrations = new HashMap<>(); registrations.put("me", null); registrations.put("meow", null); registrations.put("yourself", null); when(catalog.getRegistrations()).thenReturn(registrations); - List proposals = valueProvider.complete(methodParameter, completionContext, new String[0]); + List proposals = valueProvider.complete(completionContext); assertThat(proposals).extracting("value", String.class) .contains("me", "meow", "yourself"); } - - - public static class Command { - - public void help(@ShellOption(valueProvider = CommandValueProvider.class) String command) { - - } - } - } diff --git a/spring-shell-standard/src/test/java/org/springframework/shell/standard/Remote.java b/spring-shell-standard/src/test/java/org/springframework/shell/standard/Remote.java deleted file mode 100644 index ec07bda2..00000000 --- a/spring-shell-standard/src/test/java/org/springframework/shell/standard/Remote.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2015 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.shell.standard; - -import java.util.List; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import org.springframework.core.MethodParameter; -import org.springframework.shell.CompletionContext; -import org.springframework.shell.CompletionProposal; - -/** - * An example commands class. - * - * @author Eric Bottard - * @author Florent Biville - */ -public class Remote { - - /** - * A command method that showcases
    - *
  • default handling for booleans (force)
  • - *
  • default parameter name discovery (name)
  • - *
  • default value supplying (foo and bar)
  • - *
- */ - @ShellMethod(value = "switch channels") - public void zap(boolean force, - String name, - @ShellOption(defaultValue="defoolt") String foo, - @ShellOption(value = {"--bar", "--baz"}, defaultValue = "last") String bar) { - } - - @ShellMethod(value = "bye bye") - public void shutdown(@ShellOption Delay delay) { - - } - - @ShellMethod(value = "a different prefix", prefix = "-") - public void prefixTest(@ShellOption String message) { - - } - - @ShellMethod(value = "add 3 numbers together") - public void add(@ShellOption(arity = 3, valueProvider = NumberValueProvider.class) List numbers) { - - } - - @ShellMethod(value = "add 3 numbers together (array)") - public void addAsArray(@ShellOption(arity = 3, valueProvider = NumberValueProvider.class) int[] numbers) { - - } - - public enum Delay { - small, medium, big; - } - - - public static class NumberValueProvider extends ValueProviderSupport { - - private final String[] values; - - public NumberValueProvider(String... values) { - this.values = values; - } - - @Override - public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { - String prefix = completionContext.currentWord() != null ? completionContext.currentWord() : ""; - return Stream.of(values) - .filter(n -> n.startsWith(prefix)) - .map(CompletionProposal::new) - .collect(Collectors.toList()); - } - } -}