diff --git a/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java b/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java index deffa10d1..a3fe54904 100644 --- a/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java +++ b/spring-shell-jline/src/main/java/org/springframework/shell/jline/CommandCompleter.java @@ -15,6 +15,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; /** * A JLine {@link Completer} that completes command names from a {@link CommandRegistry}. @@ -59,7 +60,8 @@ public void complete(LineReader reader, ParsedLine line, List candida commandByName, commandOption); List proposals = completionProvider.apply(context); for (CompletionProposal proposal : proposals) { - candidates.add(new Candidate(proposal.value())); + candidates.add(new Candidate(proposal.value(), proposal.displayText(), proposal.category(), + proposal.description(), null, null, proposal.complete(), 0)); } } else { @@ -92,27 +94,49 @@ private boolean isOptionPresent(ParsedLine line, CommandOption option) { } commandName.append(word).append(" "); } - return this.commandRegistry.getCommandByName(commandName.toString().trim()); + + Command command = this.commandRegistry.getCommandByName(commandName.toString().trim()); + // the command is found but was not completed on the line + if (command != null && command.getName().equals(String.join(" ", words))) { + command = null; + } + return command; } @Nullable private CommandOption findOptionByWords(List words, List options) { List reversed = new ArrayList<>(words); Collections.reverse(reversed); - String optionName = reversed.stream() - .filter(word -> !word.trim().isEmpty()) - .findFirst() - .filter(word -> !word.contains("=") || !reversed.get(0).isEmpty()) - .orElse(""); - return options.stream().filter(option -> isOptionEqual(optionName, option)).findFirst().orElse(null); + CommandOption option; + if (reversed.get(0).isEmpty()) { + // the option name was completed, but no value provided ---> "--optionName " + option = findOption(options, o -> isOptionEqual(reversed.get(1), o)); + } + else { + // the option uses key-value pair ---> "--optionName=someValue" + option = findOption(options, o -> isOptionStartWith(reversed.get(0), o)); + + // the option uses completion on the value level ---> "--optionName someValue" + if (option == null) { + option = findOption(options, o -> isOptionEqual(reversed.get(1), o)); + } + } + + return option; + } + + @Nullable private CommandOption findOption(List options, Predicate optionFilter) { + return options.stream().filter(optionFilter).findFirst().orElse(null); } private static boolean isOptionEqual(String optionName, CommandOption option) { - return option.longName() != null - && (optionName.equals("--" + option.longName()) - || optionName.startsWith("--" + option.longName() + "=")) - || option.shortName() != ' ' && (optionName.equals("-" + option.shortName()) - || optionName.startsWith("-" + option.shortName() + "=")); + return option.longName() != null && optionName.equals("--" + option.longName()) + || option.shortName() != ' ' && optionName.equals("-" + option.shortName()); + } + + private static boolean isOptionStartWith(String optionName, CommandOption option) { + return option.longName() != null && optionName.startsWith("--" + option.longName() + "=") + || option.shortName() != ' ' && optionName.startsWith("-" + option.shortName() + "="); } } diff --git a/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTests.java b/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTests.java index c1983643b..ba2d3d92b 100644 --- a/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTests.java +++ b/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTests.java @@ -54,10 +54,7 @@ class CommandCompleterTests { } String word = completionContext.getWords().get(completionContext.getWords().size() - 1); - if (word.contains("=")) { - word = word.substring(0, word.indexOf('=')); - } - String prefix = word.isEmpty() ? word : word + "="; + String prefix = word.contains("=") ? word.substring(0, word.indexOf('=') + 1) : ""; Stream options = Stream.empty(); @@ -68,13 +65,14 @@ else if ("last".equals(option.longName()) || 'l' == option.shortName()) { options = Stream.of("Chan", "Noris"); } - return options.map(str -> prefix + str).map(CompletionProposal::new).toList(); + return options.map(str -> new CompletionProposal(prefix + str).displayText(str)).toList(); }; @BeforeEach public void before() { command = mock(Command.class); when(command.getName()).thenReturn("hello"); + when(command.getDescription()).thenReturn("Says Hello."); when(command.getCompletionProvider()).thenReturn(completionProvider); completer = new CommandCompleter(new CommandRegistry(Set.of(command))); @@ -84,11 +82,14 @@ private List toCandidateNames(List candidates) { return candidates.stream().map(Candidate::value).sorted().toList(); } + private List toCandidateDisplayText(List candidates) { + return candidates.stream().map(Candidate::displ).sorted().toList(); + } + @ParameterizedTest @MethodSource("completeData") public void testComplete(List words, List expectedValues) { // given - when(command.getName()).thenReturn("hello"); when(command.getOptions()) .thenReturn(List.of(new CommandOption.Builder().longName("first").shortName('f').build(), new CommandOption.Builder().longName("last").shortName('l').build())); @@ -108,22 +109,20 @@ public void testComplete(List words, List expectedValues) { static Stream completeData() { return Stream.of(Arguments.of(List.of(""), List.of("hello")), Arguments.of(List.of("he"), List.of("hello")), - Arguments.of(List.of("he", ""), List.of()), + Arguments.of(List.of("he", ""), List.of()), Arguments.of(List.of("hello"), List.of("hello")), - Arguments.of(List.of("hello"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", ""), List.of("--first", "--last", "-f", "-l")), - Arguments.of(List.of("hello", "--"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "-"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "--fi"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "--la"), List.of("--first", "--last", "-f", "-l")), - Arguments.of(List.of("hello", "-f"), List.of("-f=Mary", "-f=Paul", "-f=Peter")), + Arguments.of(List.of("hello", "-f"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "-f="), List.of("-f=Mary", "-f=Paul", "-f=Peter")), Arguments.of(List.of("hello", "-f=Pe"), List.of("-f=Mary", "-f=Paul", "-f=Peter")), Arguments.of(List.of("hello", "-f=Pe", ""), List.of("--last", "-l")), - Arguments.of(List.of("hello", "--first"), List.of("--first=Mary", "--first=Paul", "--first=Peter")), + Arguments.of(List.of("hello", "--first"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "--first="), List.of("--first=Mary", "--first=Paul", "--first=Peter")), Arguments.of(List.of("hello", "--first=Pe"), List.of("--first=Mary", "--first=Paul", "--first=Peter")), Arguments.of(List.of("hello", "--first=Pe", ""), List.of("--last", "-l")), @@ -131,15 +130,17 @@ static Stream completeData() { Arguments.of(List.of("hello", "-f", ""), List.of("Mary", "Paul", "Peter")), Arguments.of(List.of("hello", "--first", ""), List.of("Mary", "Paul", "Peter")), - Arguments.of(List.of("hello", "-f", "Pe"), List.of("--last", "-l")), - Arguments.of(List.of("hello", "--first", "Pe"), List.of("--last", "-l")), + Arguments.of(List.of("hello", "-f", "Pe"), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "-f", "Pe", ""), List.of("--last", "-l")), + Arguments.of(List.of("hello", "--first", "Pe"), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "--first", "Pe", ""), List.of("--last", "-l")), - Arguments.of(List.of("hello", "-l"), List.of("-l=Chan", "-l=Noris")), + Arguments.of(List.of("hello", "-l"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "-l="), List.of("-l=Chan", "-l=Noris")), Arguments.of(List.of("hello", "-l=No"), List.of("-l=Chan", "-l=Noris")), Arguments.of(List.of("hello", "-l=No", ""), List.of("--first", "-f")), - Arguments.of(List.of("hello", "--last"), List.of("--last=Chan", "--last=Noris")), + Arguments.of(List.of("hello", "--last"), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello", "--last="), List.of("--last=Chan", "--last=Noris")), Arguments.of(List.of("hello", "--last=No"), List.of("--last=Chan", "--last=Noris")), Arguments.of(List.of("hello", "--last=No", ""), List.of("--first", "-f")), @@ -147,23 +148,25 @@ static Stream completeData() { Arguments.of(List.of("hello", "-l", ""), List.of("Chan", "Noris")), Arguments.of(List.of("hello", "--last", ""), List.of("Chan", "Noris")), - Arguments.of(List.of("hello", "-l", "No"), List.of("--first", "-f")), - Arguments.of(List.of("hello", "--last", "No"), List.of("--first", "-f")), + Arguments.of(List.of("hello", "-l", "No"), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "-l", "No", ""), List.of("--first", "-f")), + Arguments.of(List.of("hello", "--last", "No"), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "--last", "No", ""), List.of("--first", "-f")), - Arguments.of(List.of("hello", "--first", "Paul", "--last", "Noris"), List.of()), - Arguments.of(List.of("hello", "--first", "Paul", "-l", "Noris"), List.of()), - Arguments.of(List.of("hello", "-f", "Paul", "--last", "Noris"), List.of()), - Arguments.of(List.of("hello", "-f", "Paul", "-l", "Noris"), List.of()), + Arguments.of(List.of("hello", "--first", "Paul", "--last", "Noris", ""), List.of()), + Arguments.of(List.of("hello", "--first", "Paul", "-l", "Noris", ""), List.of()), + Arguments.of(List.of("hello", "-f", "Paul", "--last", "Noris", ""), List.of()), + Arguments.of(List.of("hello", "-f", "Paul", "-l", "Noris", ""), List.of()), Arguments.of(List.of("hello", "--first=Paul", "--last=Noris", ""), List.of()), Arguments.of(List.of("hello", "--first=Paul", "-l=Noris", ""), List.of()), Arguments.of(List.of("hello", "-f=Paul", "--last=Noris", ""), List.of()), Arguments.of(List.of("hello", "-f=Paul", "-l=Noris", ""), List.of()), - Arguments.of(List.of("hello", "--first=Paul", "--last", "Noris"), List.of()), - Arguments.of(List.of("hello", "--first=Paul", "-l", "Noris"), List.of()), - Arguments.of(List.of("hello", "-f=Paul", "--last", "Noris"), List.of()), - Arguments.of(List.of("hello", "-f=Paul", "-l", "Noris"), List.of()), + Arguments.of(List.of("hello", "--first=Paul", "--last", "Noris", ""), List.of()), + Arguments.of(List.of("hello", "--first=Paul", "-l", "Noris", ""), List.of()), + Arguments.of(List.of("hello", "-f=Paul", "--last", "Noris", ""), List.of()), + Arguments.of(List.of("hello", "-f=Paul", "-l", "Noris", ""), List.of()), Arguments.of(List.of("hello", "--first", "Paul", "--last=Noris", ""), List.of()), Arguments.of(List.of("hello", "--first", "Paul", "-l=Noris", ""), List.of()), @@ -193,35 +196,35 @@ public void testCompleteCommandWithLongNames(List words, List ex static Stream completeCommandWithLongNamesData() { return Stream.of(Arguments.of(List.of(""), List.of("hello")), Arguments.of(List.of("he"), List.of("hello")), - Arguments.of(List.of("he", ""), List.of()), + Arguments.of(List.of("he", ""), List.of()), Arguments.of(List.of("hello"), List.of("hello")), - Arguments.of(List.of("hello"), List.of("--first", "--last")), Arguments.of(List.of("hello", ""), List.of("--first", "--last")), - Arguments.of(List.of("hello", "--"), List.of("--first", "--last")), Arguments.of(List.of("hello", "-"), List.of("--first", "--last")), Arguments.of(List.of("hello", "--fi"), List.of("--first", "--last")), Arguments.of(List.of("hello", "--la"), List.of("--first", "--last")), - Arguments.of(List.of("hello", "--first"), List.of("--first=Mary", "--first=Paul", "--first=Peter")), + Arguments.of(List.of("hello", "--first"), List.of("--first", "--last")), + Arguments.of(List.of("hello", "--first", ""), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "--first", "Pe"), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "--first", "Pe", ""), List.of("--last")), + Arguments.of(List.of("hello", "--first="), List.of("--first=Mary", "--first=Paul", "--first=Peter")), Arguments.of(List.of("hello", "--first=Pe"), List.of("--first=Mary", "--first=Paul", "--first=Peter")), Arguments.of(List.of("hello", "--first=Pe", ""), List.of("--last")), - Arguments.of(List.of("hello", "--first", ""), List.of("Mary", "Paul", "Peter")), - Arguments.of(List.of("hello", "--first", "Pe"), List.of("--last")), + Arguments.of(List.of("hello", "--last"), List.of("--first", "--last")), + Arguments.of(List.of("hello", "--last", ""), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "--last", "No"), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "--last", "No", ""), List.of("--first")), - Arguments.of(List.of("hello", "--last"), List.of("--last=Chan", "--last=Noris")), Arguments.of(List.of("hello", "--last="), List.of("--last=Chan", "--last=Noris")), Arguments.of(List.of("hello", "--last=No"), List.of("--last=Chan", "--last=Noris")), Arguments.of(List.of("hello", "--last=No", ""), List.of("--first")), - Arguments.of(List.of("hello", "--last", ""), List.of("Chan", "Noris")), - Arguments.of(List.of("hello", "--last", "No"), List.of("--first")), - - Arguments.of(List.of("hello", "--first", "Paul", "--last", "Noris"), List.of()), + Arguments.of(List.of("hello", "--first", "Paul", "--last", "Noris", ""), List.of()), Arguments.of(List.of("hello", "--first=Paul", "--last=Noris", ""), List.of()), - Arguments.of(List.of("hello", "--first=Paul", "--last", "Noris"), List.of()), + Arguments.of(List.of("hello", "--first=Paul", "--last", "Noris", ""), List.of()), Arguments.of(List.of("hello", "--first", "Paul", "--last=Noris", ""), List.of())); } @@ -247,33 +250,33 @@ public void testCompleteCommandWithShortNames(List words, List e static Stream completeCommandWithShortNamesData() { return Stream.of(Arguments.of(List.of(""), List.of("hello")), Arguments.of(List.of("he"), List.of("hello")), - Arguments.of(List.of("he", ""), List.of()), + Arguments.of(List.of("he", ""), List.of()), Arguments.of(List.of("hello"), List.of("hello")), - Arguments.of(List.of("hello"), List.of("-f", "-l")), Arguments.of(List.of("hello", ""), List.of("-f", "-l")), - Arguments.of(List.of("hello", "--"), List.of("-f", "-l")), Arguments.of(List.of("hello", "-"), List.of("-f", "-l")), - Arguments.of(List.of("hello", "-f"), List.of("-f=Mary", "-f=Paul", "-f=Peter")), + Arguments.of(List.of("hello", "-f"), List.of("-f", "-l")), + Arguments.of(List.of("hello", "-f", ""), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "-f", "Pe"), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "-f", "Pe", ""), List.of("-l")), + Arguments.of(List.of("hello", "-f="), List.of("-f=Mary", "-f=Paul", "-f=Peter")), Arguments.of(List.of("hello", "-f=Pe"), List.of("-f=Mary", "-f=Paul", "-f=Peter")), Arguments.of(List.of("hello", "-f=Pe", ""), List.of("-l")), - Arguments.of(List.of("hello", "-f", ""), List.of("Mary", "Paul", "Peter")), - Arguments.of(List.of("hello", "-f", "Pe"), List.of("-l")), + Arguments.of(List.of("hello", "-l"), List.of("-f", "-l")), + Arguments.of(List.of("hello", "-l", ""), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "-l", "No"), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "-l", "No", ""), List.of("-f")), - Arguments.of(List.of("hello", "-l"), List.of("-l=Chan", "-l=Noris")), Arguments.of(List.of("hello", "-l="), List.of("-l=Chan", "-l=Noris")), Arguments.of(List.of("hello", "-l=No"), List.of("-l=Chan", "-l=Noris")), Arguments.of(List.of("hello", "-l=No", ""), List.of("-f")), - Arguments.of(List.of("hello", "-l", ""), List.of("Chan", "Noris")), - Arguments.of(List.of("hello", "-l", "No"), List.of("-f")), - - Arguments.of(List.of("hello", "-f", "Paul", "-l", "Noris"), List.of()), + Arguments.of(List.of("hello", "-f", "Paul", "-l", "Noris", ""), List.of()), Arguments.of(List.of("hello", "-f=Paul", "-l=Noris", ""), List.of()), - Arguments.of(List.of("hello", "-f=Paul", "-l", "Noris"), List.of()), + Arguments.of(List.of("hello", "-f=Paul", "-l", "Noris", ""), List.of()), Arguments.of(List.of("hello", "-f", "Paul", "-l=Noris", ""), List.of())); } @@ -313,7 +316,7 @@ static Stream completeWithSubCommandsData() { Arguments.of(List.of("hello wo"), List.of("hello world")), Arguments.of(List.of("hello co"), List.of("hello country")), - Arguments.of(List.of("hello world"), List.of("--first", "--last", "-f", "-l")), + Arguments.of(List.of("hello world"), List.of("hello world")), Arguments.of(List.of("hello world", ""), List.of("--first", "--last", "-f", "-l")), Arguments.of(List.of("hello world", "--"), List.of("--first", "--last", "-f", "-l")), @@ -323,7 +326,7 @@ static Stream completeWithSubCommandsData() { Arguments.of(List.of("hello world", "--first", ""), List.of("Mary", "Paul", "Peter")), Arguments.of(List.of("hello world", "--last", ""), List.of("Chan", "Noris")), - Arguments.of(List.of("hello world", "--first", "Paul", "--last", "Noris"), List.of())); + Arguments.of(List.of("hello world", "--first", "Paul", "--last", "Noris", ""), List.of())); } @ParameterizedTest @@ -348,27 +351,27 @@ public void testCompleteWithTwoOptionsWhereOneIsSubsetOfOther(List words static Stream completeWithTwoOptionsWhereOneIsSubsetOfOtherData() { return Stream.of(Arguments.of(List.of(""), List.of("hello")), Arguments.of(List.of("he"), List.of("hello")), - Arguments.of(List.of("he", ""), List.of()), + Arguments.of(List.of("he", ""), List.of()), Arguments.of(List.of("hello"), List.of("hello")), - Arguments.of(List.of("hello"), List.of("--first", "--firstname")), Arguments.of(List.of("hello", ""), List.of("--first", "--firstname")), - Arguments.of(List.of("hello", "--"), List.of("--first", "--firstname")), Arguments.of(List.of("hello", "-"), List.of("--first", "--firstname")), Arguments.of(List.of("hello", "--fi"), List.of("--first", "--firstname")), Arguments.of(List.of("hello", "--first=Peter", ""), List.of("--firstname")), Arguments.of(List.of("hello", "--first", "Peter", ""), List.of("--firstname")), - Arguments.of(List.of("hello", "--first", "Peter"), List.of("--firstname")), + Arguments.of(List.of("hello", "--first", "Peter"), List.of("Mary", "Paul", "Peter")), Arguments.of(List.of("hello", "--firstname=Peter", ""), List.of("--first")), + Arguments.of(List.of("hello", "--firstname=Peter"), List.of()), Arguments.of(List.of("hello", "--firstname", "Peter", ""), List.of("--first")), - Arguments.of(List.of("hello", "--firstname", "Peter"), List.of("--first")), + Arguments.of(List.of("hello", "--firstname", "Peter"), List.of()), Arguments.of(List.of("hello", "--firstname=Peter", "--first=Paul", ""), List.of()), - Arguments.of(List.of("hello", "--firstname=Peter", "--first", "Paul"), List.of()), - Arguments.of(List.of("hello", "--firstname", "Peter", "--first=Paul", ""), List.of()), - Arguments.of(List.of("hello", "--firstname", "Peter", "--first", "Paul"), List.of())); + Arguments.of(List.of("hello", "--firstname=Peter", "--first", "Paul"), + List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "--firstname", "Peter", "--first=Paul", ""), List.of()), Arguments + .of(List.of("hello", "--firstname", "Peter", "--first", "Paul"), List.of("Mary", "Paul", "Peter"))); } @ParameterizedTest @@ -411,4 +414,42 @@ static Stream completeWithHiddenCommandsData() { Arguments.of(List.of("hello hi"), List.of())); } + @ParameterizedTest + @MethodSource("completeForProposalDisplayText") + public void testCompleteForProposalDisplayText(List words, List expectedValues) { + // given + when(command.getOptions()) + .thenReturn(List.of(new CommandOption.Builder().longName("first").shortName('f').build(), + new CommandOption.Builder().longName("last").shortName('l').build())); + + List candidates = new ArrayList<>(); + ParsedLine line = mock(ParsedLine.class); + when(line.words()).thenReturn(words); + when(line.word()).thenReturn(words.get(words.size() - 1)); + when(line.line()).thenReturn(String.join(" ", words)); + + // when + completer.complete(mock(LineReader.class), line, candidates); + + // then + assertEquals(expectedValues, toCandidateDisplayText(candidates)); + } + + static Stream completeForProposalDisplayText() { + return Stream.of(Arguments.of(List.of(""), List.of("hello: Says Hello.")), + + Arguments.of(List.of("hello"), List.of("hello: Says Hello.")), + Arguments.of(List.of("hello", ""), List.of("--first", "--last", "-f", "-l")), + + Arguments.of(List.of("hello", "-f="), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "--first="), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "-l="), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "--last="), List.of("Chan", "Noris")), + + Arguments.of(List.of("hello", "-f", ""), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "--first", ""), List.of("Mary", "Paul", "Peter")), + Arguments.of(List.of("hello", "-l", ""), List.of("Chan", "Noris")), + Arguments.of(List.of("hello", "--last", ""), List.of("Chan", "Noris"))); + } + } \ No newline at end of file