From f5edefe07eeeed27d22c4750322f45d5118b1d95 Mon Sep 17 00:00:00 2001 From: David Pilar Date: Sun, 23 Aug 2026 00:37:31 +0200 Subject: [PATCH] Report variadic arguments in the help message Signed-off-by: David Pilar --- .../shell/core/command/CommandArgument.java | 12 ++- .../shell/core/command/Help.java | 19 ++++- .../core/command/annotation/Arguments.java | 8 ++ .../support/CommandFactoryBean.java | 25 +++++- .../core/command/CommandArgumentTests.java | 54 +++++++++++++ .../shell/core/command/HelpTests.java | 69 ++++++++++++++++ .../support/CommandFactoryBeanTests.java | 79 +++++++++++++++++++ 7 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 spring-shell-core/src/test/java/org/springframework/shell/core/command/CommandArgumentTests.java diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandArgument.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandArgument.java index 9c53fefa2..4612b24d5 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandArgument.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandArgument.java @@ -22,10 +22,11 @@ * argument. * * @author Mahmoud Ben Hassine + * @author David Pilar * @since 4.0.0 */ public record CommandArgument(int index, @Nullable String description, @Nullable String defaultValue, - @Nullable String value, Class type) { + @Nullable String value, Class type, boolean variadic) { public static CommandArgument.Builder with() { return new CommandArgument.Builder(); @@ -43,6 +44,8 @@ public static class Builder { private Class type = Object.class; + private boolean variadic; + public CommandArgument.Builder index(int index) { this.index = index; return this; @@ -68,8 +71,13 @@ public CommandArgument.Builder type(Class type) { return this; } + public CommandArgument.Builder variadic(boolean variadic) { + this.variadic = variadic; + return this; + } + public CommandArgument build() { - return new CommandArgument(index, description, defaultValue, value, type); + return new CommandArgument(index, description, defaultValue, value, type, variadic); } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/Help.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/Help.java index de508eca6..45f7fb9d1 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/Help.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/Help.java @@ -28,6 +28,7 @@ * @author Janne Valkealahti * @author Piotr Olaszewski * @author Mahmoud Ben Hassine + * @author David Pilar */ public class Help extends AbstractCommand { @@ -111,7 +112,11 @@ private void appendSynopsis(Command command, StringBuilder helpMessageBuilder) { if (hasDefaultValue) { helpMessageBuilder.append("["); } - helpMessageBuilder.append("(").append(argument.type().getSimpleName()).append(")"); + helpMessageBuilder.append("(").append(argument.type().getSimpleName()); + if (argument.variadic()) { + helpMessageBuilder.append("..."); + } + helpMessageBuilder.append(")"); if (hasDefaultValue) { helpMessageBuilder.append("]"); } @@ -160,9 +165,19 @@ private void appendArguments(Command command, StringBuilder helpMessageBuilder) int index = 0; for (CommandArgument argument : arguments) { helpMessageBuilder.append("\t"); - helpMessageBuilder.append("[Index ").append(index++).append("]"); + helpMessageBuilder.append("[Index ").append(index++); + if (argument.variadic()) { + helpMessageBuilder.append("..."); + } + helpMessageBuilder.append("]"); helpMessageBuilder.append(" ").append(argument.type().getSimpleName()).append("\n"); helpMessageBuilder.append("\t").append(argument.description()).append("\n"); + if (argument.variadic()) { + // a variadic argument collects the remaining values, a default value + // does not apply to it + helpMessageBuilder.append("\n"); + continue; + } String defaultValue = argument.defaultValue(); helpMessageBuilder.append("\t").append("[default = "); Class optionType = argument.type(); diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Arguments.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Arguments.java index 09dc9cd52..c0bb1bcaf 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Arguments.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Arguments.java @@ -26,6 +26,7 @@ * {@link Command}. The parameter type must be an array or a collection. * * @author Mahmoud Ben Hassine + * @author David Pilar * @since 4.0.0 */ @Retention(RetentionPolicy.RUNTIME) @@ -41,4 +42,11 @@ */ int arity() default Integer.MAX_VALUE; + /** + * Return a description of the arguments. + * @return description of the arguments + * @since 4.0.4 + */ + String description() default ""; + } 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 30ac91785..edee8078b 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 @@ -38,6 +38,7 @@ import org.springframework.shell.core.command.CommandOption; import org.springframework.shell.core.command.adapter.MethodInvokerCommandAdapter; import org.springframework.shell.core.command.annotation.Argument; +import org.springframework.shell.core.command.annotation.Arguments; import org.springframework.shell.core.command.annotation.CommandGroup; import org.springframework.shell.core.command.annotation.Option; import org.springframework.shell.core.command.availability.AvailabilityProvider; @@ -167,7 +168,9 @@ private List getCommandOptions() { private List getCommandArguments() { List commandArguments = new ArrayList<>(); - for (Parameter parameter : this.method.getParameters()) { + Parameter[] parameters = this.method.getParameters(); + for (int i = 0; i < parameters.length; i++) { + Parameter parameter = parameters[i]; Argument argumentAnnotation = parameter.getAnnotation(Argument.class); if (argumentAnnotation != null) { int index = argumentAnnotation.index(); @@ -180,11 +183,31 @@ private List getCommandArguments() { .type(parameter.getType()) .build(); commandArguments.add(commandArgument); + continue; + } + Arguments argumentsAnnotation = parameter.getAnnotation(Arguments.class); + if (argumentsAnnotation != null) { + CommandArgument commandArgument = CommandArgument.with() + .index(commandArguments.size()) + .description(argumentsAnnotation.description()) + .type(getElementType(i)) + .variadic(true) + .build(); + commandArguments.add(commandArgument); } } return commandArguments; } + // the declared parameter is a collection or an array, the element type is what is + // meaningful to report for a variadic argument + private Class getElementType(int parameterIndex) { + ResolvableType parameterType = ResolvableType.forMethodParameter(this.method, parameterIndex); + Class elementType = parameterType.isArray() ? parameterType.getComponentType().resolve() + : parameterType.asCollection().resolveGeneric(0); + return elementType != null ? elementType : Object.class; + } + private CompletionProvider getCompletionProvider(String completionProviderBeanName) { CompletionProvider completionProvider = new DefaultCompletionProvider(); if (!completionProviderBeanName.isEmpty()) { diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/CommandArgumentTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/CommandArgumentTests.java new file mode 100644 index 000000000..6a970d869 --- /dev/null +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/CommandArgumentTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026-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.core.command; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author David Pilar + */ +class CommandArgumentTests { + + @Test + void testBuilderDefaultsToNonVariadic() { + CommandArgument argument = CommandArgument.with() + .index(1) + .description("a description") + .defaultValue("a default") + .value("a value") + .type(String.class) + .build(); + + assertEquals(1, argument.index()); + assertEquals("a description", argument.description()); + assertEquals("a default", argument.defaultValue()); + assertEquals("a value", argument.value()); + assertEquals(String.class, argument.type()); + assertFalse(argument.variadic()); + } + + @Test + void testBuilderWithVariadic() { + CommandArgument argument = CommandArgument.with().index(0).type(Integer.class).variadic(true).build(); + + assertTrue(argument.variadic()); + } + +} diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/HelpTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/HelpTests.java index 3542bf571..6c8102cac 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/HelpTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/HelpTests.java @@ -22,6 +22,9 @@ import java.io.PrintWriter; import java.io.StringWriter; +/** + * @author David Pilar + */ class HelpTests { @Test @@ -199,6 +202,72 @@ void testHelpMessageForCommandWithArgs() throws Exception { Assertions.assertEquals(expectedOutput.replaceAll("\\R", "\n"), actualOutput.replaceAll("\\R", "\n")); } + @Test + void testHelpMessageForCommandWithVariadicArgs() throws Exception { + // given + CommandArgument valuesArgument = CommandArgument.with() + .index(0) + .type(Integer.class) + .description("the values to sum up") + .variadic(true) + .build(); + CommandOption nameOption = CommandOption.with() + .longName("name") + .type(String.class) + .required(false) + .description("Name of the person to greet") + .build(); + Command command = Command.builder() + .name("hi") + .description("Say hi") + .group("Greetings") + .help("This command says hi to the user.") + .options(nameOption) + .arguments(valuesArgument) + .execute(commandContext -> { + }); + ParsedInput parsedInput = ParsedInput.builder() + .addArgument(CommandArgument.with().index(0).value("hi").build()) + .build(); + CommandRegistry commandRegistry = new CommandRegistry(); + commandRegistry.registerCommand(command); + StringWriter stringWriter = new StringWriter(); + PrintWriter outputWriter = new PrintWriter(stringWriter); + InputReader inputReader = new InputReader() { + }; + CommandContext commandContext = new CommandContext(parsedInput, commandRegistry, outputWriter, inputReader); + + // when + Help help = new Help(); + help.execute(commandContext); + + // then + String actualOutput = stringWriter.toString(); + String expectedOutput = """ + NAME + hi - Say hi + + SYNOPSIS + hi --name String (Integer...) --help + + OPTIONS + --name String + Name of the person to greet + [Optional, default = null] + + --help or -h + help for hi + [Optional] + + ARGUMENTS [Positional] + [Index 0...] Integer + the values to sum up + + + """; + Assertions.assertEquals(expectedOutput.replaceAll("\\R", "\n"), actualOutput.replaceAll("\\R", "\n")); + } + @Test void testSynopsisSpaceBetweenNonRequiredOptions() throws Exception { // given diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java index dfae79d8d..499fbf428 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/annotation/support/CommandFactoryBeanTests.java @@ -30,8 +30,11 @@ import org.springframework.core.convert.converter.GenericConverter; import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.shell.core.command.CommandContext; +import org.springframework.shell.core.command.CommandArgument; import org.springframework.shell.core.command.CommandOption; import org.springframework.shell.core.command.adapter.MethodInvokerCommandAdapter; +import org.springframework.shell.core.command.annotation.Argument; +import org.springframework.shell.core.command.annotation.Arguments; import org.springframework.shell.core.command.annotation.Command; import org.springframework.shell.core.command.annotation.CommandGroup; import org.springframework.shell.core.command.annotation.Option; @@ -84,6 +87,61 @@ void testOptionNames() { assertEquals(' ', options.get(3).shortName()); } + @Test + void testVariadicArgumentsMetadata() { + // given + org.springframework.shell.core.command.Command result = buildCommand("collect"); + + // then + List arguments = result.getArguments(); + assertEquals(1, arguments.size()); + CommandArgument argument = arguments.get(0); + assertEquals(0, argument.index()); + assertEquals("the values to collect", argument.description()); + assertEquals(Integer.class, argument.type()); + assertThat(argument.variadic()).isTrue(); + } + + @Test + void testVariadicArgumentsMetadataForArray() { + // given + org.springframework.shell.core.command.Command result = buildCommand("collectArray"); + + // then + List arguments = result.getArguments(); + assertEquals(1, arguments.size()); + assertEquals(String.class, arguments.get(0).type()); + assertThat(arguments.get(0).variadic()).isTrue(); + } + + @Test + void testVariadicArgumentsFollowingAnIndexedArgument() { + // given + org.springframework.shell.core.command.Command result = buildCommand("mixed"); + + // then + List arguments = result.getArguments(); + assertEquals(2, arguments.size()); + assertEquals(0, arguments.get(0).index()); + assertThat(arguments.get(0).variadic()).isFalse(); + assertEquals(1, arguments.get(1).index()); + assertEquals("the rest", arguments.get(1).description()); + assertEquals(String.class, arguments.get(1).type()); + assertThat(arguments.get(1).variadic()).isTrue(); + } + + private static org.springframework.shell.core.command.Command buildCommand(String methodName) { + ApplicationContext context = mockApplicationContext(); + when(context.getBean(ArgumentCommands.class)).thenReturn(new ArgumentCommands()); + Method method = Arrays.stream(ArgumentCommands.class.getDeclaredMethods()) + .filter(m -> m.getName().equals(methodName)) + .findFirst() + .orElseThrow(); + CommandFactoryBean commandFactoryBean = new CommandFactoryBean(method); + commandFactoryBean.setApplicationContext(context); + return commandFactoryBean.getObject(); + } + @Test public void testCommandGroup() { // given @@ -254,6 +312,27 @@ public void helloMethod(@Option String myOption1, @Option(shortName = 'm') Strin } + static class ArgumentCommands { + + @Command(name = "collect") + public void collect(@Arguments(description = "the values to collect") List values, + @Option String name) { + // no-op + } + + @Command(name = "collectArray") + public void collectArray(@Arguments String[] values) { + // no-op + } + + @Command(name = "mixed") + public void mixed(@Argument(index = 0, description = "the target") String target, + @Arguments(description = "the rest") List rest) { + // no-op + } + + } + @CommandGroup(name = "Greeting Commands", prefix = "greeting") static class GreetingCommands {