From 44d94aa9bf0233f2da61f26f6794d7ca844c7759 Mon Sep 17 00:00:00 2001 From: czpilar Date: Sun, 25 Jan 2026 13:40:22 +0100 Subject: [PATCH] Fix autocompletion of quit build in command and command aliases (#1290) Signed-off-by: czpilar --- .../CommandRegistryAutoConfiguration.java | 2 + .../shell/core/command/CommandRegistry.java | 3 +- .../shell/core/utils/Utils.java | 2 +- .../shell/jline/CommandCompleter.java | 25 +++++++--- .../shell/jline/CommandCompleterTests.java | 50 ++++++++++++++++--- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/CommandRegistryAutoConfiguration.java b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/CommandRegistryAutoConfiguration.java index 2a5005a18..4bdb5545c 100644 --- a/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/CommandRegistryAutoConfiguration.java +++ b/spring-shell-core-autoconfigure/src/main/java/org/springframework/shell/core/autoconfigure/CommandRegistryAutoConfiguration.java @@ -36,6 +36,7 @@ import org.springframework.shell.core.command.Command; import org.springframework.shell.core.command.CommandRegistry; import org.springframework.shell.core.command.annotation.support.CommandFactoryBean; +import org.springframework.shell.core.utils.Utils; import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; @@ -50,6 +51,7 @@ public CommandRegistry commandRegistry(ApplicationContext applicationContext) { CommandRegistry commandRegistry = new CommandRegistry(); registerProgrammaticCommands(applicationContext, commandRegistry); registerAnnotatedCommands(applicationContext, commandRegistry); + commandRegistry.registerCommand(Utils.QUIT_COMMAND); return commandRegistry; } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandRegistry.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandRegistry.java index fd42df19c..c9bc188da 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandRegistry.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandRegistry.java @@ -79,7 +79,8 @@ public Set getCommands() { public List getCommandsByPrefix(String prefix) { return commands.stream() .filter(command -> !command.isHidden()) - .filter(command -> command.getName().startsWith(prefix)) + .filter(command -> command.getName().startsWith(prefix) + || command.getAliases().stream().anyMatch(alias -> alias.startsWith(prefix))) .toList(); } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/utils/Utils.java b/spring-shell-core/src/main/java/org/springframework/shell/core/utils/Utils.java index 03a229270..b69f60acd 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/utils/Utils.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/utils/Utils.java @@ -78,7 +78,7 @@ private static Set getCommands(CommandRegistry commandRegistry) { } // Dummy exit command to show in available commands - private static final Command QUIT_COMMAND = new AbstractCommand("quit", "Exit the shell", "Built-In Commands") { + public static final Command QUIT_COMMAND = new AbstractCommand("quit", "Exit the shell", "Built-In Commands") { @Override public List getAliases() { return List.of("exit"); 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 a3fe54904..26370b003 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 @@ -11,11 +11,11 @@ import org.springframework.shell.core.command.completion.CompletionContext; import org.springframework.shell.core.command.completion.CompletionProposal; import org.springframework.shell.core.command.completion.CompletionProvider; +import org.springframework.shell.core.utils.Utils; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; +import java.util.*; import java.util.function.Predicate; +import java.util.stream.Stream; /** * A JLine {@link Completer} that completes command names from a {@link CommandRegistry}. @@ -67,15 +67,20 @@ public void complete(LineReader reader, ParsedLine line, List candida else { this.commandRegistry.getCommandsByPrefix(line.line()) .stream() - .map(command -> toCommandCandidate(command, line.words())) + .map(command -> toCommandCandidates(command, line.words())) + .flatMap(List::stream) + .sorted(Candidate::compareTo) .forEach(candidates::add); } } - private Candidate toCommandCandidate(Command command, List words) { + private List toCommandCandidates(Command command, List words) { String prefix = words.size() > 1 ? String.join(" ", words.subList(0, words.size() - 1)) : ""; - return new Candidate(command.getName().substring(prefix.length()).trim(), - command.getName() + ": " + command.getDescription(), command.getGroup(), null, null, null, true); + return getCommandNames(command).filter(name -> name.startsWith(words.get(0))) + .filter(name -> name.startsWith(prefix)) + .map(cmd -> new Candidate(cmd.substring(prefix.length()).trim(), cmd + ": " + command.getDescription(), + command.getGroup(), null, null, null, !Utils.QUIT_COMMAND.equals(command))) + .toList(); } private boolean isOptionPresent(ParsedLine line, CommandOption option) { @@ -97,7 +102,7 @@ private boolean isOptionPresent(ParsedLine line, CommandOption option) { 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))) { + if (command != null && getCommandNames(command).toList().contains(String.join(" ", words))) { command = null; } return command; @@ -139,4 +144,8 @@ private static boolean isOptionStartWith(String optionName, CommandOption option || option.shortName() != ' ' && optionName.startsWith("-" + option.shortName() + "="); } + private Stream getCommandNames(Command command) { + return Stream.concat(Stream.of(command.getName()), command.getAliases().stream()); + } + } 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 ba2d3d92b..0d1e6fe30 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 @@ -69,7 +69,7 @@ else if ("last".equals(option.longName()) || 'l' == option.shortName()) { }; @BeforeEach - public void before() { + void before() { command = mock(Command.class); when(command.getName()).thenReturn("hello"); when(command.getDescription()).thenReturn("Says Hello."); @@ -88,7 +88,7 @@ private List toCandidateDisplayText(List candidates) { @ParameterizedTest @MethodSource("completeData") - public void testComplete(List words, List expectedValues) { + void testComplete(List words, List expectedValues) { // given when(command.getOptions()) .thenReturn(List.of(new CommandOption.Builder().longName("first").shortName('f').build(), @@ -176,7 +176,7 @@ static Stream completeData() { @ParameterizedTest @MethodSource("completeCommandWithLongNamesData") - public void testCompleteCommandWithLongNames(List words, List expectedValues) { + 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())); @@ -230,7 +230,7 @@ static Stream completeCommandWithLongNamesData() { @ParameterizedTest @MethodSource("completeCommandWithShortNamesData") - public void testCompleteCommandWithShortNames(List words, List expectedValues) { + 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())); @@ -282,7 +282,7 @@ static Stream completeCommandWithShortNamesData() { @ParameterizedTest @MethodSource("completeWithSubCommandsData") - public void testCompleteWithSubCommands(List words, List expectedValues) { + void testCompleteWithSubCommands(List words, List expectedValues) { // given when(command.getName()).thenReturn("hello world"); when(command.getOptions()) @@ -331,7 +331,7 @@ static Stream completeWithSubCommandsData() { @ParameterizedTest @MethodSource("completeWithTwoOptionsWhereOneIsSubsetOfOtherData") - public void testCompleteWithTwoOptionsWhereOneIsSubsetOfOther(List words, List expectedValues) { + 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())); @@ -376,7 +376,7 @@ static Stream completeWithTwoOptionsWhereOneIsSubsetOfOtherData() { @ParameterizedTest @MethodSource("completeWithHiddenCommandsData") - public void testCompleteWithHiddenCommands(List words, List expectedValues) { + void testCompleteWithHiddenCommands(List words, List expectedValues) { // given when(command.getName()).thenReturn("hello visible"); when(command.getOptions()).thenReturn(List.of()); @@ -416,7 +416,7 @@ static Stream completeWithHiddenCommandsData() { @ParameterizedTest @MethodSource("completeForProposalDisplayText") - public void testCompleteForProposalDisplayText(List words, List expectedValues) { + void testCompleteForProposalDisplayText(List words, List expectedValues) { // given when(command.getOptions()) .thenReturn(List.of(new CommandOption.Builder().longName("first").shortName('f').build(), @@ -452,4 +452,38 @@ static Stream completeForProposalDisplayText() { Arguments.of(List.of("hello", "--last", ""), List.of("Chan", "Noris"))); } + @ParameterizedTest + @MethodSource("completeForCommandAlias") + void testCompleteForCommandAlias(List words, List expectedValues) { + // given + when(command.getAliases()).thenReturn(List.of("hi", "bye")); + + 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 completeForCommandAlias() { + return Stream.of( + Arguments.of(List.of(""), List.of("bye: Says Hello.", "hello: Says Hello.", "hi: Says Hello.")), + + Arguments.of(List.of("h"), List.of("hello: Says Hello.", "hi: Says Hello.")), + Arguments.of(List.of("he"), List.of("hello: Says Hello.")), + Arguments.of(List.of("hello"), List.of("hello: Says Hello.")), + Arguments.of(List.of("hi"), List.of("hi: Says Hello.")), + Arguments.of(List.of("b"), List.of("bye: Says Hello.")), + Arguments.of(List.of("bye"), List.of("bye: Says Hello.")), + + Arguments.of(List.of("hello", ""), List.of()), Arguments.of(List.of("hi", ""), List.of()), + Arguments.of(List.of("bye", ""), List.of())); + } + } \ No newline at end of file