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 93905fa92..237bc9638 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 @@ -71,8 +71,11 @@ public void complete(LineReader reader, ParsedLine line, List candida } private boolean isOptionPresent(ParsedLine line, CommandOption option) { - return option.longName() != null && line.line().contains(" --" + option.longName() + " ") - || option.shortName() != ' ' && line.line().contains(" -" + option.shortName() + " "); + return option.longName() != null + && (line.line().contains(" --" + option.longName() + " ") + || line.line().contains(" --" + option.longName() + "=")) + || option.shortName() != ' ' && (line.line().contains(" -" + option.shortName() + " ") + || line.line().contains(" -" + option.shortName() + "=")); } @Nullable private Command findCommandByWords(List words) { @@ -89,15 +92,21 @@ private boolean isOptionPresent(ParsedLine line, CommandOption option) { @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().orElse(""); + String optionName = reversed.stream() + .filter(word -> !word.trim().isEmpty()) + .findFirst() + .filter(word -> !word.contains("=") || !reversed.get(0).isEmpty()) + .orElse(""); - for (CommandOption option : options) { - if (option.longName() != null && optionName.equals("--" + option.longName()) - || option.shortName() != ' ' && optionName.equals("-" + option.shortName())) { - return option; - } - } - return null; + return options.stream().filter(option -> isOptionEqual(optionName, option)).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() + "=")); } } diff --git a/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTest.java b/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTest.java new file mode 100644 index 000000000..fc7119d00 --- /dev/null +++ b/spring-shell-jline/src/test/java/org/springframework/shell/jline/CommandCompleterTest.java @@ -0,0 +1,367 @@ +/* + * Copyright 2025-present 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.jline; + +import org.jline.reader.Candidate; +import org.jline.reader.LineReader; +import org.jline.reader.ParsedLine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.shell.core.command.Command; +import org.springframework.shell.core.command.CommandOption; +import org.springframework.shell.core.command.CommandRegistry; +import org.springframework.shell.core.command.completion.CompletionProposal; +import org.springframework.shell.core.command.completion.CompletionProvider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * @author David Pilar + */ +class CommandCompleterTest { + + private CommandCompleter completer; + + private Command command; + + private final CompletionProvider completionProvider = completionContext -> { + CommandOption option = completionContext.getCommandOption(); + if (option == null) { + return Collections.emptyList(); + } + + String word = completionContext.getWords().get(completionContext.getWords().size() - 1); + if (word.contains("=")) { + word = word.substring(0, word.indexOf('=')); + } + String prefix = word.isEmpty() ? word : word + "="; + + Stream options = Stream.empty(); + + if ("first".equals(option.longName()) || 'f' == option.shortName()) { + options = Stream.of("Peter", "Paul", "Mary"); + } + else if ("last".equals(option.longName()) || 'l' == option.shortName()) { + options = Stream.of("Chan", "Noris"); + } + + return options.map(str -> prefix + str).map(CompletionProposal::new).toList(); + }; + + @BeforeEach + public void before() { + command = mock(Command.class); + when(command.getName()).thenReturn("hello"); + when(command.getCompletionProvider()).thenReturn(completionProvider); + + completer = new CommandCompleter(new CommandRegistry(Set.of(command))); + } + + private List toCandidateNames(List candidates) { + return candidates.stream().map(Candidate::value).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())); + + 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, toCandidateNames(candidates)); + } + + 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("hello")), + + Arguments.of(List.of("hello"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello", ""), List.of("--first", "-f", "--last", "-l")), + + Arguments.of(List.of("hello", "--"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello", "-"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello", "--fi"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello", "--la"), List.of("--first", "-f", "--last", "-l")), + + Arguments.of(List.of("hello", "-f"), List.of("-f=Peter", "-f=Paul", "-f=Mary")), + Arguments.of(List.of("hello", "-f="), List.of("-f=Peter", "-f=Paul", "-f=Mary")), + Arguments.of(List.of("hello", "-f=Pe"), List.of("-f=Peter", "-f=Paul", "-f=Mary")), + Arguments.of(List.of("hello", "-f=Pe", ""), List.of("--last", "-l")), + + Arguments.of(List.of("hello", "--first"), List.of("--first=Peter", "--first=Paul", "--first=Mary")), + Arguments.of(List.of("hello", "--first="), List.of("--first=Peter", "--first=Paul", "--first=Mary")), + Arguments.of(List.of("hello", "--first=Pe"), List.of("--first=Peter", "--first=Paul", "--first=Mary")), + Arguments.of(List.of("hello", "--first=Pe", ""), List.of("--last", "-l")), + + Arguments.of(List.of("hello", "-f", ""), List.of("Peter", "Paul", "Mary")), + Arguments.of(List.of("hello", "--first", ""), List.of("Peter", "Paul", "Mary")), + + 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", "-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("--first", "-f")), + + 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", "-f")), + + 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", "--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())); + } + + @ParameterizedTest + @MethodSource("completeCommandWithLongNamesData") + public void testCompleteCommandWithLongNames(List words, List expectedValues) { + // given + when(command.getOptions()).thenReturn(List.of(new CommandOption.Builder().longName("first").build(), + new CommandOption.Builder().longName("last").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, toCandidateNames(candidates)); + } + + 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("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=Peter", "--first=Paul", "--first=Mary")), + Arguments.of(List.of("hello", "--first="), List.of("--first=Peter", "--first=Paul", "--first=Mary")), + Arguments.of(List.of("hello", "--first=Pe"), List.of("--first=Peter", "--first=Paul", "--first=Mary")), + Arguments.of(List.of("hello", "--first=Pe", ""), List.of("--last")), + + Arguments.of(List.of("hello", "--first", ""), List.of("Peter", "Paul", "Mary")), + Arguments.of(List.of("hello", "--first", "Pe"), List.of("--last")), + + 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())); + } + + @ParameterizedTest + @MethodSource("completeCommandWithShortNamesData") + public void testCompleteCommandWithShortNames(List words, List expectedValues) { + // given + when(command.getOptions()).thenReturn(List.of(new CommandOption.Builder().shortName('f').build(), + new CommandOption.Builder().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, toCandidateNames(candidates)); + } + + 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("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=Peter", "-f=Paul", "-f=Mary")), + Arguments.of(List.of("hello", "-f="), List.of("-f=Peter", "-f=Paul", "-f=Mary")), + Arguments.of(List.of("hello", "-f=Pe"), List.of("-f=Peter", "-f=Paul", "-f=Mary")), + Arguments.of(List.of("hello", "-f=Pe", ""), List.of("-l")), + + Arguments.of(List.of("hello", "-f", ""), List.of("Peter", "Paul", "Mary")), + Arguments.of(List.of("hello", "-f", "Pe"), List.of("-l")), + + 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())); + } + + @ParameterizedTest + @MethodSource("completeWithSubCommandsData") + public void testCompleteWithSubCommands(List words, List expectedValues) { + // given + when(command.getName()).thenReturn("hello world"); + 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, toCandidateNames(candidates)); + } + + static Stream completeWithSubCommandsData() { + return Stream.of(Arguments.of(List.of(""), List.of("hello world")), + Arguments.of(List.of("he"), List.of("hello world")), + Arguments.of(List.of("he", ""), List.of("hello world")), + Arguments.of(List.of("hello"), List.of("hello world")), + Arguments.of(List.of("hello wo"), List.of("hello world")), + + Arguments.of(List.of("hello world"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello world", ""), List.of("--first", "-f", "--last", "-l")), + + Arguments.of(List.of("hello world", "--"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello world", "-"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello world", "--fi"), List.of("--first", "-f", "--last", "-l")), + Arguments.of(List.of("hello world", "--la"), List.of("--first", "-f", "--last", "-l")), + + Arguments.of(List.of("hello world", "--first", ""), List.of("Peter", "Paul", "Mary")), + Arguments.of(List.of("hello world", "--last", ""), List.of("Chan", "Noris")), + Arguments.of(List.of("hello world", "--first", "Paul", "--last", "Noris"), List.of())); + } + + @ParameterizedTest + @MethodSource("completeWithTwoOptionsWhereOneIsSubsetOfOtherData") + public void testCompleteWithTwoOptionsWhereOneIsSubsetOfOther(List words, List expectedValues) { + // given + when(command.getOptions()).thenReturn(List.of(new CommandOption.Builder().longName("first").build(), + new CommandOption.Builder().longName("firstname").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, toCandidateNames(candidates)); + } + + 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("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", "--firstname=Peter", ""), List.of("--first")), + 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", "--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())); + } + +} \ No newline at end of file