From e020acb393db43c0678fc65c625ec5088876aa7d Mon Sep 17 00:00:00 2001 From: DF-Nico Date: Fri, 5 Jun 2026 15:28:10 +0200 Subject: [PATCH] feat: add argument registration on command Adding registration of argument will now allow to display them in help command Signed-off-by: DF-Nico --- .../shell/core/command/AbstractCommand.java | 25 +++-- .../shell/core/command/Command.java | 33 +++++-- .../shell/core/command/CommandArgument.java | 54 ++++++++++- .../core/command/DefaultCommandParser.java | 8 +- .../shell/core/command/Help.java | 49 ++++++++-- .../support/CommandFactoryBean.java | 40 ++++++-- .../shell/core/command/HelpTests.java | 94 +++++++++++++++++-- .../MethodInvokerCommandAdapterTests.java | 23 +++-- 8 files changed, 271 insertions(+), 55 deletions(-) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/AbstractCommand.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/AbstractCommand.java index 59c1cc347..9b4650a8d 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/AbstractCommand.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/AbstractCommand.java @@ -15,21 +15,19 @@ */ package org.springframework.shell.core.command; -import java.io.PrintWriter; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - import jakarta.validation.Path; - import org.jspecify.annotations.Nullable; - import org.springframework.shell.core.ParameterValidationException; import org.springframework.shell.core.command.availability.Availability; import org.springframework.shell.core.command.availability.AvailabilityProvider; +import org.springframework.shell.core.command.completion.CompletionProvider; import org.springframework.shell.core.command.completion.DefaultCompletionProvider; import org.springframework.shell.core.command.exit.ExitStatusExceptionMapper; -import org.springframework.shell.core.command.completion.CompletionProvider; + +import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; /** * Base class helping to build shell commands. @@ -58,6 +56,8 @@ public abstract class AbstractCommand implements Command { private List options = new ArrayList<>(); + private List arguments = new ArrayList<>(); + private List aliases = new ArrayList<>(); public AbstractCommand(String name, String description) { @@ -123,6 +123,15 @@ public void setOptions(List options) { this.options = options; } + @Override + public List getArguments() { + return arguments; + } + + public void setArguments(List arguments) { + this.arguments = arguments; + } + @Override public AvailabilityProvider getAvailabilityProvider() { return availabilityProvider; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/Command.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/Command.java index fc536808e..8ed92d3f3 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/Command.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/Command.java @@ -15,23 +15,22 @@ */ package org.springframework.shell.core.command; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - import org.jspecify.annotations.Nullable; - import org.springframework.shell.core.command.adapter.ConsumerCommandAdapter; import org.springframework.shell.core.command.adapter.FunctionCommandAdapter; import org.springframework.shell.core.command.availability.AvailabilityProvider; +import org.springframework.shell.core.command.completion.CompletionProvider; import org.springframework.shell.core.command.completion.DefaultCompletionProvider; import org.springframework.shell.core.command.exit.ExitStatusExceptionMapper; -import org.springframework.shell.core.command.completion.CompletionProvider; import org.springframework.util.Assert; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + /** * @author Eric Bottard * @author Piotr Olaszewski @@ -96,6 +95,14 @@ default List getOptions() { return Collections.emptyList(); } + /** + * Get the arguments of the command. + * @return the arguments of the command + */ + default List getArguments() { + return Collections.emptyList(); + } + /** * Get the availability provider of the command. Defaults to always available. * @return the availability provider of the command @@ -156,6 +163,8 @@ final class Builder { private List options = new ArrayList<>(); + private List arguments = new ArrayList<>(); + public Builder name(String name) { this.name = name; return this; @@ -206,6 +215,11 @@ public Builder options(CommandOption... options) { return this; } + public Builder arguments(CommandArgument... arguments) { + this.arguments = Arrays.asList(arguments); + return this; + } + public AbstractCommand execute(Consumer commandExecutor) { Assert.hasText(name, "'name' must be specified"); @@ -229,6 +243,7 @@ public AbstractCommand execute(Function commandExecutor) private void init(AbstractCommand command) { command.setAliases(aliases); command.setOptions(options); + command.setArguments(arguments); command.setAvailabilityProvider(availabilityProvider); command.setCompletionProvider(completionProvider); if (exitStatusExceptionMapper != null) { 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 d97519344..9c53fefa2 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 @@ -15,12 +15,62 @@ */ package org.springframework.shell.core.command; +import org.jspecify.annotations.Nullable; + /** - * Record representing a runtime argument to a command. + * Record representing the definition as well as the runtime information about a command + * argument. * * @author Mahmoud Ben Hassine * @since 4.0.0 */ -public record CommandArgument(int index, String value) { +public record CommandArgument(int index, @Nullable String description, @Nullable String defaultValue, + @Nullable String value, Class type) { + + public static CommandArgument.Builder with() { + return new CommandArgument.Builder(); + } + + public static class Builder { + + private int index = 0; + + private @Nullable String description; + + private @Nullable String defaultValue; + + private @Nullable String value; + + private Class type = Object.class; + + public CommandArgument.Builder index(int index) { + this.index = index; + return this; + } + + public CommandArgument.Builder description(String description) { + this.description = description; + return this; + } + + public CommandArgument.Builder defaultValue(String defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public CommandArgument.Builder value(String value) { + this.value = value; + return this; + } + + public CommandArgument.Builder type(Class type) { + this.type = type; + return this; + } + + public CommandArgument build() { + return new CommandArgument(index, description, defaultValue, value, type); + } + } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java index 5ed8ab2bd..dd1a63ce7 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/DefaultCommandParser.java @@ -15,13 +15,13 @@ */ package org.springframework.shell.core.command; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + import java.util.Collections; import java.util.List; import java.util.Optional; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; - /** * Default implementation of {@link CommandParser}. Supports options in the long form of * --key=value or --key value as well in the short form of -k=value or -k value. Options @@ -180,7 +180,7 @@ else if (word.startsWith("-")) { } private CommandArgument parseArgument(int index, String word) { - return new CommandArgument(index, unquoteAndUnescapeQuoted(word)); + return CommandArgument.with().index(index).value(unquoteAndUnescapeQuoted(word)).build(); } private String unquoteAndUnescapeQuoted(String s) { 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 613a3af2d..de508eca6 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 @@ -15,11 +15,12 @@ */ package org.springframework.shell.core.command; +import org.springframework.shell.core.utils.Utils; +import org.springframework.util.StringUtils; + import java.io.PrintWriter; import java.util.List; -import org.springframework.shell.core.utils.Utils; - /** * A command to display help about all available commands. * @@ -67,6 +68,7 @@ private String getHelpMessageForCommand(Command command) { appendName(command, helpMessageBuilder); appendSynopsis(command, helpMessageBuilder); appendOptions(command, helpMessageBuilder); + appendArguments(command, helpMessageBuilder); appendAliases(command, helpMessageBuilder); return helpMessageBuilder.toString(); } @@ -82,9 +84,11 @@ private void appendName(Command command, StringBuilder helpMessageBuilder) { private void appendSynopsis(Command command, StringBuilder helpMessageBuilder) { List options = command.getOptions(); - helpMessageBuilder.append("SYNOPSIS\n").append("\t").append(command.getName()).append(" "); + List arguments = command.getArguments(); + helpMessageBuilder.append("SYNOPSIS\n").append("\t").append(command.getName()); if (!options.isEmpty()) { for (CommandOption option : options) { + helpMessageBuilder.append(" "); if (option.required()) { helpMessageBuilder.append("["); } @@ -96,14 +100,24 @@ private void appendSynopsis(Command command, StringBuilder helpMessageBuilder) { } helpMessageBuilder.append(" ").append(option.type().getSimpleName()); if (option.required()) { - helpMessageBuilder.append("] "); + helpMessageBuilder.append("]"); } - else { - helpMessageBuilder.append(" "); + } + } + if (!arguments.isEmpty()) { + for (CommandArgument argument : arguments) { + helpMessageBuilder.append(" "); + boolean hasDefaultValue = StringUtils.hasText(argument.defaultValue()); + if (hasDefaultValue) { + helpMessageBuilder.append("["); + } + helpMessageBuilder.append("(").append(argument.type().getSimpleName()).append(")"); + if (hasDefaultValue) { + helpMessageBuilder.append("]"); } } } - helpMessageBuilder.append("--help\n\n"); + helpMessageBuilder.append(" ").append("--help\n\n"); } private void appendOptions(Command command, StringBuilder helpMessageBuilder) { @@ -139,6 +153,27 @@ private void appendOptions(Command command, StringBuilder helpMessageBuilder) { helpMessageBuilder.append("\t").append("[Optional]").append("\n").append("\n"); } + private void appendArguments(Command command, StringBuilder helpMessageBuilder) { + List arguments = command.getArguments(); + if (!arguments.isEmpty()) { + helpMessageBuilder.append("ARGUMENTS [Positional]\n"); + int index = 0; + for (CommandArgument argument : arguments) { + helpMessageBuilder.append("\t"); + helpMessageBuilder.append("[Index ").append(index++).append("]"); + helpMessageBuilder.append(" ").append(argument.type().getSimpleName()).append("\n"); + helpMessageBuilder.append("\t").append(argument.description()).append("\n"); + String defaultValue = argument.defaultValue(); + helpMessageBuilder.append("\t").append("[default = "); + Class optionType = argument.type(); + if (defaultValue == null && optionType.isPrimitive()) { + defaultValue = Utils.getDefaultValueForPrimitiveType(optionType).toString(); + } + helpMessageBuilder.append(defaultValue).append("]\n\n"); + } + } + } + private static void appendAliases(Command command, StringBuilder helpMessageBuilder) { List aliases = command.getAliases(); if (!aliases.isEmpty()) { 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 070cf728b..30ac91785 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 @@ -15,18 +15,10 @@ */ package org.springframework.shell.core.command.annotation.support; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - import jakarta.validation.Validator; - import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jspecify.annotations.Nullable; - import org.springframework.beans.BeansException; import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.NoSuchBeanDefinitionException; @@ -41,18 +33,26 @@ import org.springframework.core.convert.support.ConfigurableConversionService; import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.shell.core.command.Command; +import org.springframework.shell.core.command.CommandArgument; import org.springframework.shell.core.command.CommandCreationException; 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.CommandGroup; import org.springframework.shell.core.command.annotation.Option; import org.springframework.shell.core.command.availability.AvailabilityProvider; +import org.springframework.shell.core.command.completion.CompletionProvider; import org.springframework.shell.core.command.completion.DefaultCompletionProvider; import org.springframework.shell.core.command.exit.ExitStatusExceptionMapper; -import org.springframework.shell.core.command.completion.CompletionProvider; import org.springframework.shell.core.utils.Utils; import org.springframework.util.Assert; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + /** * Factory bean to build instances of {@link Command}. * @@ -122,12 +122,14 @@ public Command getObject() { Validator validator = getValidator(); CompletionProvider completionProvider = getCompletionProvider(completionProviderBeanName); List commandOptions = getCommandOptions(); + List commandArguments = getCommandArguments(); // create command adapter MethodInvokerCommandAdapter methodInvokerCommandAdapter = new MethodInvokerCommandAdapter(name, description, group, help, hidden, this.method, targetObject, configurableConversionService, validator); methodInvokerCommandAdapter.setAliases(Arrays.stream(aliases).toList()); methodInvokerCommandAdapter.setOptions(commandOptions); + methodInvokerCommandAdapter.setArguments(commandArguments); methodInvokerCommandAdapter.setAvailabilityProvider(availabilityProviderBean); methodInvokerCommandAdapter.setCompletionProvider(completionProvider); if (exitStatusExceptionMapperBean != null) { @@ -163,6 +165,26 @@ private List getCommandOptions() { return commandOptions; } + private List getCommandArguments() { + List commandArguments = new ArrayList<>(); + for (Parameter parameter : this.method.getParameters()) { + Argument argumentAnnotation = parameter.getAnnotation(Argument.class); + if (argumentAnnotation != null) { + int index = argumentAnnotation.index(); + String description = argumentAnnotation.description(); + String defaultValue = argumentAnnotation.defaultValue(); + CommandArgument commandArgument = CommandArgument.with() + .index(index) + .description(description) + .defaultValue(defaultValue) + .type(parameter.getType()) + .build(); + commandArguments.add(commandArgument); + } + } + return commandArguments; + } + 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/HelpTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/HelpTests.java index 870bffb88..6a8a317e4 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 @@ -15,14 +15,13 @@ */ package org.springframework.shell.core.command; -import java.io.PrintWriter; -import java.io.StringWriter; - import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; - import org.springframework.shell.core.InputReader; +import java.io.PrintWriter; +import java.io.StringWriter; + class HelpTests { @Test @@ -80,7 +79,7 @@ void testHelpMessageForCommand() throws Exception { }); ParsedInput parsedInput = ParsedInput.builder() .commandName("hi") - .addArgument(new CommandArgument(0, "hi")) + .addArgument(CommandArgument.with().index(0).value("hi").build()) .build(); CommandRegistry commandRegistry = new CommandRegistry(); commandRegistry.registerCommand(command); @@ -121,6 +120,87 @@ void testHelpMessageForCommand() throws Exception { Assertions.assertEquals(expectedOutput.replaceAll("\\R", "\n"), actualOutput.replaceAll("\\R", "\n")); } + @Test + void testHelpMessageForCommandWithArgs() throws Exception { + // given + CommandOption nameOption = CommandOption.with() + .shortName('n') + .longName("name") + .type(String.class) + .required(true) + .description("Name of the person to greet") + .build(); + CommandOption timesOption = CommandOption.with() + .shortName('t') + .longName("times") + .type(int.class) + .required(false) + .defaultValue("1") + .description("Number of times to greet") + .build(); + CommandArgument greetArgument = CommandArgument.with() + .index(0) + .type(String.class) + .defaultValue("hi") + .description("Custom text to greet") + .build(); + Command command = Command.builder() + .name("hi") + .description("Say hi") + .group("Greetings") + .help("This command says hi to the user.") + .options(nameOption, timesOption) + .arguments(greetArgument) + .execute(commandContext -> { + }); + ParsedInput parsedInput = ParsedInput.builder() + .commandName("hi") + .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] --times int [(String)] --help + + OPTIONS + --name or -n String + Name of the person to greet + [Mandatory] + + --times or -t int + Number of times to greet + [Optional, default = 1] + + --help or -h + help for hi + [Optional] + + ARGUMENTS [Positional] + [Index 0] String + Custom text to greet + [default = hi] + + + """; + Assertions.assertEquals(expectedOutput.replaceAll("\\R", "\n"), actualOutput.replaceAll("\\R", "\n")); + } + @Test void testSynopsisSpaceBetweenNonRequiredOptions() throws Exception { // given @@ -157,7 +237,7 @@ void testSynopsisSpaceBetweenNonRequiredOptions() throws Exception { }); ParsedInput parsedInput = ParsedInput.builder() .commandName("hi") - .addArgument(new CommandArgument(0, "hi")) + .addArgument(CommandArgument.with().index(0).value("hi").build()) .build(); CommandRegistry commandRegistry = new CommandRegistry(); commandRegistry.registerCommand(command); @@ -231,7 +311,7 @@ void testHelpMessageForCommandAlias() throws Exception { }); ParsedInput parsedInput = ParsedInput.builder() .commandName("hello") - .addArgument(new CommandArgument(0, "hello")) + .addArgument(CommandArgument.with().index(0).value("hi").build()) .build(); CommandRegistry commandRegistry = new CommandRegistry(); commandRegistry.registerCommand(command); diff --git a/spring-shell-core/src/test/java/org/springframework/shell/core/command/adapter/MethodInvokerCommandAdapterTests.java b/spring-shell-core/src/test/java/org/springframework/shell/core/command/adapter/MethodInvokerCommandAdapterTests.java index f0740d8f0..ffb4171ee 100644 --- a/spring-shell-core/src/test/java/org/springframework/shell/core/command/adapter/MethodInvokerCommandAdapterTests.java +++ b/spring-shell-core/src/test/java/org/springframework/shell/core/command/adapter/MethodInvokerCommandAdapterTests.java @@ -15,15 +15,10 @@ */ package org.springframework.shell.core.command.adapter; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.lang.reflect.Method; - import jakarta.validation.Validation; import jakarta.validation.Validator; import org.junit.jupiter.api.Test; import org.mockito.Mockito; - import org.springframework.core.convert.support.DefaultConversionService; import org.springframework.shell.core.command.CommandArgument; import org.springframework.shell.core.command.CommandContext; @@ -33,6 +28,10 @@ import org.springframework.shell.core.command.annotation.Command; import org.springframework.shell.core.command.annotation.Option; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Method; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -106,10 +105,16 @@ void argumentsAreParsed() throws Exception { .thenReturn(CommandOption.with().longName("base").value("10").build()); StringWriter out = new StringWriter(); Mockito.when(ctx.parsedInput().arguments()) - .thenReturn(java.util.List.of(new CommandArgument(0, "1"), new CommandArgument(1, "2"), - new CommandArgument(2, "3"), new CommandArgument(3, "4"), new CommandArgument(4, "5"), - new CommandArgument(5, "6"), new CommandArgument(6, "7"), new CommandArgument(7, "8"), - new CommandArgument(8, "9"), new CommandArgument(9, "10"))); + .thenReturn(java.util.List.of(CommandArgument.with().index(0).value("1").build(), + CommandArgument.with().index(1).value("2").build(), + CommandArgument.with().index(2).value("3").build(), + CommandArgument.with().index(3).value("4").build(), + CommandArgument.with().index(4).value("5").build(), + CommandArgument.with().index(5).value("6").build(), + CommandArgument.with().index(6).value("7").build(), + CommandArgument.with().index(7).value("8").build(), + CommandArgument.with().index(8).value("9").build(), + CommandArgument.with().index(9).value("10").build())); Mockito.when(ctx.outputWriter()).thenReturn(new PrintWriter(out)); MethodInvokerCommandAdapter adapter = new MethodInvokerCommandAdapter("name", "desc", "group", "help", false, method, target, conversionService, validator);