Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand All @@ -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);
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* @author Janne Valkealahti
* @author Piotr Olaszewski
* @author Mahmoud Ben Hassine
* @author David Pilar
*/
public class Help extends AbstractCommand {

Expand Down Expand Up @@ -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("]");
}
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 "";

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -167,7 +168,9 @@ private List<CommandOption> getCommandOptions() {

private List<CommandArgument> getCommandArguments() {
List<CommandArgument> 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();
Expand All @@ -180,11 +183,31 @@ private List<CommandArgument> 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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@
import java.io.PrintWriter;
import java.io.StringWriter;

/**
* @author David Pilar
*/
class HelpTests {

@Test
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CommandArgument> 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<CommandArgument> 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<CommandArgument> 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
Expand Down Expand Up @@ -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<Integer> 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<String> rest) {
// no-op
}

}

@CommandGroup(name = "Greeting Commands", prefix = "greeting")
static class GreetingCommands {

Expand Down
Loading