diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java index 7ec8a19fc..13dc82eaa 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandOption.java @@ -26,7 +26,7 @@ * @author Mahmoud Ben Hassine */ public record CommandOption(char shortName, @Nullable String longName, @Nullable String description, - @Nullable Boolean required, @Nullable String defaultValue, @Nullable String value) { + @Nullable Boolean required, @Nullable String defaultValue, @Nullable String value, boolean completion) { public static Builder with() { return new Builder(); @@ -46,6 +46,8 @@ public static class Builder { private @Nullable String value; + private boolean completion = true; + public Builder shortName(char shortName) { this.shortName = shortName; return this; @@ -76,8 +78,13 @@ public Builder value(String value) { return this; } + public Builder completion(boolean completion) { + this.completion = completion; + return this; + } + public CommandOption build() { - return new CommandOption(shortName, longName, description, required, defaultValue, value); + return new CommandOption(shortName, longName, description, required, defaultValue, value, completion); } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Option.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Option.java index 6d17eaf2d..b2131d90e 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Option.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Option.java @@ -62,4 +62,10 @@ */ String defaultValue() default ""; + /** + * Indicates whether this option should be completed in the command completer. + * @return true if the option should be completed, defaults to true. + */ + boolean completion() default true; + } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java index 023afbfbc..81908f634 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBean.java @@ -122,6 +122,7 @@ private List getCommandOptions() { String description = optionAnnotation.description(); boolean required = optionAnnotation.required(); String defaultValue = optionAnnotation.defaultValue(); + boolean completion = optionAnnotation.completion(); if (shortName == ' ' && longName.isEmpty()) { throw new IllegalArgumentException( "Either shortName or longName (or both) must be provided for option on parameter '" @@ -133,6 +134,7 @@ private List getCommandOptions() { .description(description) .required(required) .defaultValue(defaultValue) + .completion(completion) .build(); commandOptions.add(commandOption); } 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..dc4e1f1e0 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 @@ -45,11 +45,14 @@ public void complete(LineReader reader, ParsedLine line, List candida // add option completions for the command for (CommandOption option : options) { boolean present = isOptionPresent(line, option); + String separator = option.completion() ? "" : "="; if (option.longName() != null && !present) { - candidates.add(new Candidate("--" + option.longName())); + candidates.add(new Candidate("--" + option.longName() + separator, "--" + option.longName(), + null, null, null, null, option.completion(), 0)); } if (option.shortName() != ' ' && !present) { - candidates.add(new Candidate("-" + option.shortName())); + candidates.add(new Candidate("-" + option.shortName() + separator, "-" + option.shortName(), + null, null, null, null, option.completion(), 0)); } } } @@ -59,7 +62,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 { 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..396045efe 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 @@ -68,7 +68,7 @@ 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 @@ -85,8 +85,8 @@ private List toCandidateNames(List candidates) { } @ParameterizedTest - @MethodSource("completeData") - public void testComplete(List words, List expectedValues) { + @MethodSource("completeWithCompletionData") + public void testCompleteWithCompletion(List words, List expectedValues) { // given when(command.getName()).thenReturn("hello"); when(command.getOptions()) @@ -106,49 +106,81 @@ public void testComplete(List words, List expectedValues) { assertEquals(expectedValues, toCandidateNames(candidates)); } - static Stream completeData() { + static Stream completeWithCompletionData() { + return completeData(""); + } + + @ParameterizedTest + @MethodSource("completeWithoutCompletionData") + public void testCompleteWithoutCompletion(List words, List expectedValues) { + // given + when(command.getName()).thenReturn("hello"); + when(command.getOptions()) + .thenReturn(List.of(new CommandOption.Builder().longName("first").shortName('f').completion(false).build(), + new CommandOption.Builder().longName("last").shortName('l').completion(false).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 completeWithoutCompletionData() { + return completeData("="); + } + + static Stream completeData(String sep) { 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("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" + sep, "--last" + sep, "-f" + sep, "-l" + sep)), + Arguments.of(List.of("hello", ""), List.of("--first" + sep, "--last" + sep, "-f" + sep, "-l" + sep)), - 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", "--"), List.of("--first" + sep, "--last" + sep, "-f" + sep, "-l" + sep)), + Arguments.of(List.of("hello", "-"), List.of("--first" + sep, "--last" + sep, "-f" + sep, "-l" + sep)), + Arguments.of(List.of("hello", "--fi"), + List.of("--first" + sep, "--last" + sep, "-f" + sep, "-l" + sep)), + Arguments.of(List.of("hello", "--la"), + List.of("--first" + sep, "--last" + sep, "-f" + sep, "-l" + sep)), Arguments.of(List.of("hello", "-f"), List.of("-f=Mary", "-f=Paul", "-f=Peter")), 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", "-f=Pe", ""), List.of("--last" + sep, "-l" + sep)), Arguments.of(List.of("hello", "--first"), List.of("--first=Mary", "--first=Paul", "--first=Peter")), 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")), + Arguments.of(List.of("hello", "--first=Pe", ""), List.of("--last" + sep, "-l" + sep)), 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("--last" + sep, "-l" + sep)), + Arguments.of(List.of("hello", "--first", "Pe"), List.of("--last" + sep, "-l" + sep)), 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", "-l=No", ""), List.of("--first" + sep, "-f" + sep)), 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", "--last=No", ""), List.of("--first" + sep, "-f" + sep)), 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("--first" + sep, "-f" + sep)), + Arguments.of(List.of("hello", "--last", "No"), List.of("--first" + sep, "-f" + sep)), Arguments.of(List.of("hello", "--first", "Paul", "--last", "Noris"), List.of()), Arguments.of(List.of("hello", "--first", "Paul", "-l", "Noris"), List.of()),