diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..2bfa4b538 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.{java,xml,gradle}] +indent_style = tab +indent_size = 4 diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/EnumValueProvider.java b/spring-shell-core/src/main/java/org/springframework/shell/core/EnumValueProvider.java index 3f1396931..818966ca6 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/EnumValueProvider.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/EnumValueProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2022 the original author or authors. + * Copyright 2016-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. @@ -29,6 +29,7 @@ * parameters. * * @author Eric Bottard + * @author Piotr Olaszewski */ public class EnumValueProvider implements ValueProvider { @@ -37,7 +38,7 @@ public List complete(CompletionContext completionContext) { List result = new ArrayList<>(); CommandOption commandOption = completionContext.getCommandOption(); if (commandOption != null) { - ResolvableType type = commandOption.getType(); + ResolvableType type = commandOption.type(); if (type != null) { Class clazz = type.getRawClass(); if (clazz != null) { diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/Input.java b/spring-shell-core/src/main/java/org/springframework/shell/core/Input.java index 8e2cc1810..7e1ae6f72 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/Input.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/Input.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 the original author or authors. + * Copyright 2017-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. @@ -24,6 +24,7 @@ * Represents the input buffer to the shell. * * @author Eric Bottard + * @author Piotr Olaszewski */ public interface Input { @@ -42,7 +43,7 @@ public interface Input { * single "word") */ default List words() { - return "".equals(rawText()) ? Collections.emptyList() : Arrays.asList(rawText().split(" ")); + return rawText().isEmpty() ? Collections.emptyList() : Arrays.asList(rawText().split(" ")); } } 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 ad71e8796..05f5e2bb8 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 the original author or authors. + * Copyright 2022-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. @@ -20,202 +20,13 @@ import org.springframework.shell.core.completion.CompletionResolver; /** - * Interface representing an option in a command. + * Represents an option of a command. * * @author Janne Valkealahti * @author Piotr Olaszewski */ -// TODO this is better defined as a record. -public interface CommandOption { - - /** - * Gets a long name of an option. - * @return long name of an option - */ - String getLongName(); - - /** - * Gets a modified long names of an option. Set within a command registration if - * option name modifier were used to have an info about original names. - * @return modified long names of an option - */ - String getLongNameModified(); - - /** - * Gets a short names of an option. - * @return short names of an option - */ - Character getShortName(); - - /** - * Gets a description of an option. - * @return description of an option - */ - @Nullable String getDescription(); - - /** - * Gets a {@link ResolvableType} of an option. - * @return type of an option - */ - @Nullable ResolvableType getType(); - - /** - * Gets a flag if option is required. - * @return the required flag - */ - boolean isRequired(); - - /** - * Gets a default value of an option. - * @return the default value - */ - @Nullable String getDefaultValue(); - - /** - * Gets a positional value. - * @return the positional value - */ - int getPosition(); - - /** - * Gets a minimum arity. - * @return the minimum arity - */ - int getArityMin(); - - /** - * Gets a maximum arity. - * @return the maximum arity - */ - int getArityMax(); - - /** - * Gets a completion function. - * @return the completion function - */ - @Nullable CompletionResolver getCompletion(); - - /** - * Gets an instance of a default {@link CommandOption}. - * @param longName the long name - * @param longNameModified the modified long name - * @param shortName the short name - * @param description the description - * @param type the type - * @param required the required flag - * @param defaultValue the default value - * @param position the position value - * @param arityMin the min arity - * @param arityMax the max arity - * @param label the label - * @param completion the completion - * @return default command option - */ - static CommandOption of(String longName, String longNameModified, Character shortName, String description, - ResolvableType type, boolean required, String defaultValue, Integer position, Integer arityMin, - Integer arityMax, String label, CompletionResolver completion) { - return new DefaultCommandOption(longName, longNameModified, shortName, description, type, required, - defaultValue, position, arityMin, arityMax, label, completion); - } - - /** - * Default implementation of {@link CommandOption}. - */ - class DefaultCommandOption implements CommandOption { - - private String longName; - - private String longNameModified; - - private Character shortName; - - private String description; - - private ResolvableType type; - - private boolean required; - - private String defaultValue; - - private int position; - - private int arityMin; - - private int arityMax; - - private CompletionResolver completion; - - public DefaultCommandOption(String longName, String longNameModified, Character shortName, String description, - ResolvableType type, boolean required, String defaultValue, Integer position, Integer arityMin, - Integer arityMax, String label, CompletionResolver completion) { - this.longName = longName; - this.longNameModified = longNameModified; - this.shortName = shortName; - this.description = description; - this.type = type; - this.required = required; - this.defaultValue = defaultValue; - this.position = position != null && position > -1 ? position : -1; - this.arityMin = arityMin != null ? arityMin : -1; - this.arityMax = arityMax != null ? arityMax : -1; - this.completion = completion; - } - - @Override - public String getLongName() { - return longName; - } - - @Override - public String getLongNameModified() { - return longNameModified; - } - - @Override - public Character getShortName() { - return shortName; - } - - @Override - public String getDescription() { - return description; - } - - @Override - public ResolvableType getType() { - return type; - } - - @Override - public boolean isRequired() { - return required; - } - - @Override - public String getDefaultValue() { - return defaultValue; - } - - @Override - public int getPosition() { - return position; - } - - @Override - public int getArityMin() { - return arityMin; - } - - @Override - public int getArityMax() { - return arityMax; - } - - @Override - public CompletionResolver getCompletion() { - return completion; - } - - } +public record CommandOption(String longName, String longNameModified, Character shortName, @Nullable String description, + @Nullable ResolvableType type, boolean required, @Nullable String defaultValue, int position, int arityMin, + int arityMax, @Nullable String label, @Nullable CompletionResolver completion) { } 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 46478aed4..52546c667 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 @@ -16,7 +16,10 @@ package org.springframework.shell.core.command; import java.util.Collection; +import java.util.HashMap; import java.util.HashSet; +import java.util.List; +import java.util.Map; import java.util.Set; import org.jspecify.annotations.Nullable; @@ -38,6 +41,8 @@ public class CommandRegistry implements SmartInitializingSingleton, ApplicationC private final Set commands; + private final CommandTree.Node root = new CommandTree.Node(); + @SuppressWarnings("NullAway.Init") private ApplicationContext applicationContext; @@ -53,26 +58,43 @@ public Set getCommands() { return Set.copyOf(commands); } - @Nullable public Command getCommandByName(String name) { + public @Nullable Command getCommandByName(String name) { return commands.stream().filter(command -> command.getName().equals(name)).findFirst().orElse(null); } - public void registerCommand(Command command) { - commands.add(command); - } + public @Nullable Command lookupCommand(List args) { + Command command = null; - public void unregisterCommand(Command command) { - commands.remove(command); - } + CommandTree.Node current = root; + for (String arg : args) { + CommandTree.Node next = current.getChild().get(arg); + if (next == null) { + return command; + } + current = next; + + Command cmd = current.getCommand(); + if (cmd != null) { + command = cmd; + } + } - public void clearCommands() { - commands.clear(); + return command; } @Override public void afterSingletonsInstantiated() { - Collection commandCollection = this.applicationContext.getBeansOfType(Command.class).values(); + Collection commandCollection = applicationContext.getBeansOfType(Command.class).values(); commands.addAll(commandCollection); + + for (Command command : commandCollection) { + CommandTree.Node current = root; + String[] names = command.getName().split(" "); + for (String name : names) { + current = current.child(name); + } + current.setCommand(command); + } } @Override @@ -80,4 +102,32 @@ public void setApplicationContext(ApplicationContext applicationContext) throws this.applicationContext = applicationContext; } + private static class CommandTree { + + private static class Node { + + private final Map child = new HashMap<>(); + + private @Nullable Command command; + + private Map getChild() { + return child; + } + + private Node child(String name) { + return child.computeIfAbsent(name, n -> new Node()); + } + + private @Nullable Command getCommand() { + return command; + } + + private void setCommand(Command command) { + this.command = command; + } + + } + + } + } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Command.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Command.java index 625b02dc0..5cb654421 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Command.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/Command.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * Copyright 2023-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. @@ -29,9 +29,10 @@ * * @author Janne Valkealahti * @author Mahmoud Ben Hassine + * @author Piotr Olaszewski */ @Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.METHOD }) +@Target({ ElementType.TYPE, ElementType.METHOD }) @Documented @Reflective public @interface Command { @@ -48,7 +49,7 @@ * Values are split and trimmed meaning spaces doesn't matter. * @return the command as an array */ - String[] name() default {}; + String name() default ""; /** * Define alias as an array. Given that alias should be {@code alias1 sub1} it can be 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 2c4c656c8..ba31c54f1 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 @@ -16,29 +16,42 @@ package org.springframework.shell.core.command.annotation.support; import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jline.terminal.Terminal; +import org.jspecify.annotations.Nullable; import org.springframework.beans.BeansException; import org.springframework.beans.factory.FactoryBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.MergedAnnotations; -import org.springframework.shell.core.command.Command; +import org.springframework.shell.core.InputProvider; +import org.springframework.shell.core.ShellRunner; +import org.springframework.shell.core.command.CommandRegistry; +import org.springframework.shell.core.command.annotation.Command; import org.springframework.shell.core.commands.adapter.MethodInvokerCommandAdapter; import org.springframework.util.Assert; import org.springframework.util.MethodInvoker; +import org.springframework.util.StringUtils; /** - * Factory bean to build instance of {@link Command}. This is internal class and not meant - * for generic use. + * {@link ShellRunner} that bootstraps the shell in interactive mode. + *

+ * It requires an {@link InputProvider} to read user input, a {@link Terminal} to write + * output, and a {@link CommandRegistry} to look up and execute commands. * * @author Janne Valkealahti * @author Piotr Olaszewski * @author Mahmoud Ben Hassine */ -public class CommandFactoryBean implements ApplicationContextAware, FactoryBean { +public class CommandFactoryBean + implements ApplicationContextAware, FactoryBean { private final Log log = LogFactory.getLog(CommandFactoryBean.class); @@ -53,37 +66,59 @@ public CommandFactoryBean(Method method) { } @Override - public Command getObject() throws Exception { - org.springframework.shell.core.command.annotation.Command command = MergedAnnotations.from(this.method) - .get(org.springframework.shell.core.command.annotation.Command.class) - .synthesize(); - String name = command.name()[0]; + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public org.springframework.shell.core.command.Command getObject() { + List parentCommandNames = getParentCommandNames(); + + Command command = MergedAnnotations.from(method).get(Command.class).synthesize(); + + String methodName = method.getName(); + String currentName = StringUtils.hasText(command.name()) ? command.name() : methodName; + parentCommandNames.add(currentName); + + String name = String.join(" ", parentCommandNames); String description = command.description(); String help = command.help(); String group = command.group(); // TODO handle options, aliases, etc MethodInvoker methodInvoker = getMethodInvoker(); - log.debug("Creating command for method : " + this.method.getName()); + log.debug("Creating command for method : %s".formatted(methodName)); + return new MethodInvokerCommandAdapter(name, description, help, group, methodInvoker); } - private MethodInvoker getMethodInvoker() throws ClassNotFoundException, NoSuchMethodException { + @Override + public @Nullable Class getObjectType() { + return org.springframework.shell.core.command.Command.class; + } + + private MethodInvoker getMethodInvoker() { + Class declaringClass = method.getDeclaringClass(); + Object bean = applicationContext.getBean(declaringClass); + MethodInvoker methodInvoker = new MethodInvoker(); - Class declaringClass = this.method.getDeclaringClass(); methodInvoker.setTargetClass(declaringClass); - methodInvoker.setTargetObject(this.applicationContext.getBean(declaringClass)); + methodInvoker.setTargetObject(bean); methodInvoker.setTargetMethod(method.getName()); return methodInvoker; } - @Override - public Class getObjectType() { - return Command.class; - } - - @Override - public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { - this.applicationContext = applicationContext; + private List getParentCommandNames() { + List parentNames = new ArrayList<>(); + Class current = method.getDeclaringClass(); + while (current != null) { + Command classCommand = AnnotatedElementUtils.findMergedAnnotation(current, Command.class); + if (classCommand != null) { + parentNames.add(classCommand.name()); + } + current = current.getEnclosingClass(); + } + Collections.reverse(parentNames); + return parentNames; } } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/EnableCommandRegistrar.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/EnableCommandRegistrar.java index 08060efce..f83190bd6 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/EnableCommandRegistrar.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/annotation/support/EnableCommandRegistrar.java @@ -36,6 +36,9 @@ import org.springframework.shell.core.command.CommandRegistry; import org.springframework.shell.core.command.annotation.Command; import org.springframework.shell.core.command.annotation.EnableCommand; +import org.springframework.shell.core.commands.Clear; +import org.springframework.shell.core.commands.Help; +import org.springframework.shell.core.commands.Version; import org.springframework.shell.core.jline.InteractiveShellRunner; import org.springframework.shell.core.jline.JLineInputProvider; import org.springframework.util.ReflectionUtils; @@ -45,34 +48,45 @@ * * @author Janne Valkealahti * @author Mahmoud Ben Hassine + * @author Piotr Olaszewski */ public final class EnableCommandRegistrar implements ImportBeanDefinitionRegistrar { + private static final String COMMAND_REGISTRY_BEAN_NAME = "commandRegistry"; + @Override public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) { - EnableCommand shellAnnotation = metadata.getAnnotations().get(EnableCommand.class).synthesize(); - // register built-in commands - registry.registerBeanDefinition("help", - new RootBeanDefinition(org.springframework.shell.core.commands.Help.class)); - registry.registerBeanDefinition("clear", - new RootBeanDefinition(org.springframework.shell.core.commands.Clear.class)); - registry.registerBeanDefinition("version", - new RootBeanDefinition(org.springframework.shell.core.commands.Version.class)); - - // register user defined commands - Class[] candidateClasses = shellAnnotation.value(); + EnableCommand enableCommand = metadata.getAnnotations().get(EnableCommand.class).synthesize(); + + registerBuiltInCommands(registry); + registerUserCommands(registry, enableCommand); + registerCommandRegistryIfNeeded(registry); + registerShellRunner(registry); + } + + private static void registerBuiltInCommands(BeanDefinitionRegistry registry) { + registry.registerBeanDefinition("help", new RootBeanDefinition(Help.class)); + registry.registerBeanDefinition("clear", new RootBeanDefinition(Clear.class)); + registry.registerBeanDefinition("version", new RootBeanDefinition(Version.class)); + } + + private void registerUserCommands(BeanDefinitionRegistry registry, EnableCommand enableCommand) { + Class[] candidateClasses = enableCommand.value(); for (Class candidateClass : candidateClasses) { registerCommands(candidateClass, registry); registerAnnotatedMethods(candidateClass, registry); } + } - // register command registry - if (!registry.containsBeanDefinition("commandRegistry")) { + private static void registerCommandRegistryIfNeeded(BeanDefinitionRegistry registry) { + if (!registry.containsBeanDefinition(COMMAND_REGISTRY_BEAN_NAME)) { RootBeanDefinition beanDefinition = new RootBeanDefinition(CommandRegistry.class); - registry.registerBeanDefinition("commandRegistry", beanDefinition); + registry.registerBeanDefinition(COMMAND_REGISTRY_BEAN_NAME, beanDefinition); } + } - // register shell runner (default to interactive if none is defined) + private static void registerShellRunner(BeanDefinitionRegistry registry) { + // register a shell runner (default to interactive if none is defined) if (!registry.containsBeanDefinition("shellRunner")) { BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder .genericBeanDefinition(InteractiveShellRunner.class); @@ -97,7 +111,7 @@ public void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionR } // autowire command registry - beanDefinitionBuilder.addConstructorArgReference("commandRegistry"); + beanDefinitionBuilder.addConstructorArgReference(COMMAND_REGISTRY_BEAN_NAME); registry.registerBeanDefinition("shellRunner", beanDefinitionBuilder.getBeanDefinition()); } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandModel.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandModel.java index dc59eaedd..cf39a75ed 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandModel.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandModel.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright 2023-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. @@ -210,9 +210,9 @@ Map getValidTokens() { }); if (registration != null) { registration.getOptions().forEach(commandOption -> { - String longName = commandOption.getLongName(); + String longName = commandOption.longName(); tokens.put("--" + longName, new Token(longName, TokenType.OPTION)); - String shortName = commandOption.getShortName().toString(); + String shortName = commandOption.shortName().toString(); tokens.put("-" + shortName, new Token(shortName.toString(), TokenType.OPTION)); }); } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandParser.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandParser.java similarity index 96% rename from spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandParser.java rename to spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandParser.java index 4272f96e3..b2acaba30 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/CommandParser.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/CommandParser.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 the original author or authors. + * Copyright 2022-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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.shell.core.command; +package org.springframework.shell.core.command.parser; import java.util.ArrayList; import java.util.Arrays; @@ -22,7 +22,8 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.convert.ConversionService; -import org.springframework.shell.core.command.parser.*; +import org.springframework.shell.core.command.Command; +import org.springframework.shell.core.command.CommandOption; import org.springframework.shell.core.command.parser.Ast.DefaultAst; import org.springframework.shell.core.command.parser.Lexer.DefaultLexer; import org.springframework.shell.core.command.parser.Parser.DefaultParser; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/Parser.java b/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/Parser.java index c7484cb28..38c8390ca 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/Parser.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/command/parser/Parser.java @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright 2023-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. @@ -194,8 +194,8 @@ protected ParseResult buildResult() { List optionsForArguments = registration.getOptions() .stream() .filter(o -> !resolvedOptions.contains(o)) - .filter(o -> o.getPosition() > -1) - .sorted(Comparator.comparingInt(o -> o.getPosition())) + .filter(o -> o.position() > -1) + .sorted(Comparator.comparingInt(o -> o.position())) .collect(Collectors.toList()); // leftover arguments to match into needed options @@ -207,7 +207,7 @@ protected ParseResult buildResult() { // try to find matching arguments int i = 0; for (CommandOption o : optionsForArguments) { - int aMax = o.getArityMax(); + int aMax = o.arityMax(); if (aMax < 0) { aMax = optionsForArguments.size() == 1 ? Integer.MAX_VALUE : 1; } @@ -218,7 +218,7 @@ protected ParseResult buildResult() { if (asdf.isEmpty()) { // don't arguments so only add if we know // it's going to get added later via default value - if (o.getDefaultValue() == null) { + if (o.defaultValue() == null) { resolvedOptions.add(o); optionResults.add(OptionResult.of(o, null)); } @@ -239,11 +239,11 @@ protected ParseResult buildResult() { // possibly fill in from default values registration.getOptions() .stream() - .filter(o -> o.getDefaultValue() != null) + .filter(o -> o.defaultValue() != null) .filter(o -> !resolvedOptions.contains(o)) .forEach(o -> { resolvedOptions.add(o); - Object value = convertOptionType(o, o.getDefaultValue()); + Object value = convertOptionType(o, o.defaultValue()); optionResults.add(OptionResult.of(o, value)); }); @@ -304,7 +304,7 @@ protected void onEnterOptionNode(OptionNode node) { String name = node.getName(); if (name.startsWith("--")) { registration.getOptions().forEach(option -> { - Set longNames = Arrays.asList(option.getLongName()) + Set longNames = Arrays.asList(option.longName()) .stream() .map(n -> "--" + n) .collect(Collectors.toSet()); @@ -318,7 +318,7 @@ protected void onEnterOptionNode(OptionNode node) { else if (name.startsWith("-")) { if (name.length() == 2) { registration.getOptions().forEach(option -> { - Set shortNames = Arrays.asList(option.getShortName()) + Set shortNames = Arrays.asList(option.shortName()) .stream() .map(n -> "-" + Character.toString(n)) .collect(Collectors.toSet()); @@ -330,7 +330,7 @@ else if (name.startsWith("-")) { } else if (name.length() > 2) { registration.getOptions().forEach(option -> { - Set shortNames = Arrays.asList(option.getShortName()) + Set shortNames = Arrays.asList(option.shortName()) .stream() .map(n -> "-" + Character.toString(n)) .collect(Collectors.toSet()); @@ -349,7 +349,7 @@ else if (name.length() > 2) { protected void onExitOptionNode(OptionNode node) { if (!currentOptions.isEmpty()) { for (CommandOption currentOption : currentOptions) { - int max = currentOption.getArityMax() > 0 ? currentOption.getArityMax() : Integer.MAX_VALUE; + int max = currentOption.arityMax() > 0 ? currentOption.arityMax() : Integer.MAX_VALUE; max = Math.min(max, currentOptionArgument.size()); List toUse = currentOptionArgument.subList(0, max); List toUnused = currentOptionArgument.subList(max, currentOptionArgument.size()); @@ -364,29 +364,28 @@ protected void onExitOptionNode(OptionNode node) { // because number of argument to eat dependes on arity // and rest would go back to positional args. if (optionPos + 1 < expectedOptionCount) { - if (currentOption.getArityMin() > -1 - && currentOptionArgument.size() < currentOption.getArityMin()) { - String arg = currentOption.getLongName(); + if (currentOption.arityMin() > -1 && currentOptionArgument.size() < currentOption.arityMin()) { + String arg = currentOption.longName(); commonMessageResults.add(MessageResult.of(ParserMessage.NOT_ENOUGH_OPTION_ARGUMENTS, 0, arg, currentOptionArgument.size())); } - else if (currentOption.getArityMax() > -1 - && currentOptionArgument.size() > currentOption.getArityMax()) { - String arg = currentOption.getLongName(); + else if (currentOption.arityMax() > -1 + && currentOptionArgument.size() > currentOption.arityMax()) { + String arg = currentOption.longName(); commonMessageResults.add(MessageResult.of(ParserMessage.TOO_MANY_OPTION_ARGUMENTS, 0, arg, - currentOption.getArityMax())); + currentOption.arityMax())); } } else { - if (currentOption.getArityMin() > -1 && toUse.size() < currentOption.getArityMin()) { - String arg = currentOption.getLongName(); + if (currentOption.arityMin() > -1 && toUse.size() < currentOption.arityMin()) { + String arg = currentOption.longName(); commonMessageResults.add(MessageResult.of(ParserMessage.NOT_ENOUGH_OPTION_ARGUMENTS, 0, arg, - currentOption.getArityMin())); + currentOption.arityMin())); } - else if (currentOption.getArityMax() > -1 && toUse.size() > currentOption.getArityMax()) { - String arg = currentOption.getLongName(); + else if (currentOption.arityMax() > -1 && toUse.size() > currentOption.arityMax()) { + String arg = currentOption.longName(); commonMessageResults.add(MessageResult.of(ParserMessage.TOO_MANY_OPTION_ARGUMENTS, 0, arg, - currentOption.getArityMax())); + currentOption.arityMax())); } } @@ -432,14 +431,14 @@ protected void onExitOptionArgumentNode(OptionArgumentNode node) { } private @Nullable Object convertOptionType(CommandOption option, @Nullable Object value) { - ResolvableType type = option.getType(); + ResolvableType type = option.type(); if (value == null && type != null && type.isAssignableFrom(boolean.class)) { return true; } - if (conversionService != null && option.getType() != null && value != null) { + if (conversionService != null && option.type() != null && value != null) { Object source = value; TypeDescriptor sourceType = new TypeDescriptor(ResolvableType.forClass(source.getClass()), null, null); - TypeDescriptor targetType = new TypeDescriptor(option.getType(), null, null); + TypeDescriptor targetType = new TypeDescriptor(option.type(), null, null); if (conversionService.canConvert(sourceType, targetType)) { value = conversionService.convert(source, sourceType, targetType); } @@ -450,7 +449,7 @@ protected void onExitOptionArgumentNode(OptionArgumentNode node) { private List validateOptionNotMissing(Command registration) { HashSet requiredOptions = registration.getOptions() .stream() - .filter(o -> o.isRequired()) + .filter(o -> o.required()) .collect(Collectors.toCollection(() -> new HashSet<>())); List argumentResultValues = argumentResults.stream() @@ -463,22 +462,22 @@ private List validateOptionNotMissing(Command registration) { if (argumentResultValues.isEmpty()) { return true; } - List longNames = Arrays.asList(o.getLongName()); + List longNames = Arrays.asList(o.longName()); return !Collections.disjoint(argumentResultValues, longNames); }).collect(Collectors.toSet()); return requiredOptions2.stream().map(o -> { String ins0 = ""; - if (o.getLongName() != null) { - ins0 = "--" + o.getLongName(); + if (o.longName() != null) { + ins0 = "--" + o.longName(); } - else if (o.getShortName() != null) { - ins0 = "-" + o.getShortName(); + else if (o.shortName() != null) { + ins0 = "-" + o.shortName(); } String ins1 = ""; - if (StringUtils.hasText(o.getDescription())) { - ins1 = ", " + o.getDescription(); + if (StringUtils.hasText(o.description())) { + ins1 = ", " + o.description(); } return MessageResult.of(ParserMessage.MANDATORY_OPTION_MISSING, 0, ins0, ins1); }).collect(Collectors.toList()); diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/AbstractCommand.java b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/AbstractCommand.java index 231abbde2..e21a1338f 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/AbstractCommand.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/AbstractCommand.java @@ -20,7 +20,6 @@ import org.springframework.shell.core.command.Command; import org.springframework.shell.core.command.CommandAlias; -import org.springframework.shell.core.command.CommandContext; import org.springframework.shell.core.command.CommandOption; /** @@ -97,7 +96,4 @@ public void setAliases(List aliases) { this.aliases = aliases; } - @Override - public abstract void execute(CommandContext commandContext) throws Exception; - } diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/CommandInfoModel.java b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/CommandInfoModel.java index 0d0168109..1d3843518 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/CommandInfoModel.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/CommandInfoModel.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-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. @@ -21,12 +21,11 @@ import java.util.stream.Stream; import org.jspecify.annotations.Nullable; -import org.springframework.shell.core.command.availability.Availability; + import org.springframework.shell.core.command.CommandOption; import org.springframework.shell.core.command.Command; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; /** * Model encapsulating info about {@code command}. @@ -66,11 +65,11 @@ static CommandInfoModel of(String name, Command command) { List parameters = options.stream().map(o -> { String type = commandOptionType(o); List arguments = Stream - .concat(Stream.of(o.getLongName()).map(a -> "--" + a), Stream.of(o.getShortName()).map(s -> "-" + s)) + .concat(Stream.of(o.longName()).map(a -> "--" + a), Stream.of(o.shortName()).map(s -> "-" + s)) .collect(Collectors.toList()); - boolean required = o.isRequired(); - String description = o.getDescription(); - String defaultValue = o.getDefaultValue(); + boolean required = o.required(); + String description = o.description(); + String defaultValue = o.defaultValue(); return CommandParameterInfoModel.of(type, arguments, required, description, defaultValue); }).collect(Collectors.toList()); @@ -89,8 +88,8 @@ static CommandInfoModel of(String name, Command command) { } private static String commandOptionType(CommandOption o) { - if (o.getType() != null) { - Class rawClass = o.getType().getRawClass(); + if (o.type() != null) { + Class rawClass = o.type().getRawClass(); Assert.notNull(rawClass, "'rawClass' must not be null"); if (ClassUtils.isAssignable(rawClass, Void.class)) { return ""; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/MethodInvokerCommandAdapter.java b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/MethodInvokerCommandAdapter.java index 1667a3b6c..e5b5082d8 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/MethodInvokerCommandAdapter.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/MethodInvokerCommandAdapter.java @@ -1,11 +1,28 @@ +/* + * Copyright 2025-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.commands.adapter; -import org.jspecify.annotations.Nullable; - import org.springframework.shell.core.command.CommandContext; import org.springframework.shell.core.commands.AbstractCommand; import org.springframework.util.MethodInvoker; +/** + * @author Mahmoud Ben Hassine + * @author Piotr Olaszewski + */ public class MethodInvokerCommandAdapter extends AbstractCommand { MethodInvoker methodInvoker; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/package-info.java b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/package-info.java new file mode 100644 index 000000000..007fb92f5 --- /dev/null +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/commands/adapter/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.springframework.shell.core.commands.adapter; + +import org.jspecify.annotations.NullMarked; diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/completion/AbstractCompletions.java b/spring-shell-core/src/main/java/org/springframework/shell/core/completion/AbstractCompletions.java index a467e6b48..b34c77e6c 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/completion/AbstractCompletions.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/completion/AbstractCompletions.java @@ -40,7 +40,6 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; -import org.springframework.shell.core.Utils; import org.springframework.shell.core.command.Command; import org.springframework.util.FileCopyUtils; import org.springframework.util.LinkedMultiValueMap; @@ -99,7 +98,7 @@ protected CommandModel generateCommandModel() { // TODO long vs short List options = registration.getOptions() .stream() - .map(co -> co.getLongName()) + .map(co -> co.longName()) .map(lo -> CommandModelOption.of("--", lo)) .collect(Collectors.toList()); diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/completion/RegistrationOptionsCompletionResolver.java b/spring-shell-core/src/main/java/org/springframework/shell/core/completion/RegistrationOptionsCompletionResolver.java index 0bedac92d..6307b17cd 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/completion/RegistrationOptionsCompletionResolver.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/completion/RegistrationOptionsCompletionResolver.java @@ -1,5 +1,5 @@ /* - * Copyright 2022 the original author or authors. + * Copyright 2022-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. @@ -39,14 +39,14 @@ public List apply(CompletionContext context) { List candidates = new ArrayList<>(); commandRegistration.getOptions() .stream() - .flatMap(o -> Stream.of(o.getLongName())) + .flatMap(o -> Stream.of(o.longName())) .map(ln -> "--" + ln) .filter(ln -> !context.getWords().contains(ln)) .map(CompletionProposal::new) .forEach(candidates::add); commandRegistration.getOptions() .stream() - .flatMap(o -> Stream.of(o.getShortName())) + .flatMap(o -> Stream.of(o.shortName())) .map(ln -> "-" + ln) .filter(ln -> !context.getWords().contains(ln)) .map(CompletionProposal::new) diff --git a/spring-shell-core/src/main/java/org/springframework/shell/core/jline/InteractiveShellRunner.java b/spring-shell-core/src/main/java/org/springframework/shell/core/jline/InteractiveShellRunner.java index 662387034..4dc37cf99 100644 --- a/spring-shell-core/src/main/java/org/springframework/shell/core/jline/InteractiveShellRunner.java +++ b/spring-shell-core/src/main/java/org/springframework/shell/core/jline/InteractiveShellRunner.java @@ -38,6 +38,7 @@ * @author Janne Valkealahti * @author Chris Bono * @author Mahmoud Ben Hassine + * @author Piotr Olaszewski */ public class InteractiveShellRunner implements ShellRunner { @@ -58,45 +59,47 @@ public InteractiveShellRunner(InputProvider inputProvider, Terminal terminal, Co @Override public void run(String[] args) throws Exception { while (true) { - Input input = this.inputProvider.readInput(); + Input input = inputProvider.readInput(); + if (input == null || input.rawText().isEmpty() || input.words().isEmpty()) { + // Ignore empty lines + continue; + } + if (input == Input.INTERRUPTED || input == Input.EMPTY) { break; } + if (input.rawText().equalsIgnoreCase("quit") || input.rawText().equalsIgnoreCase("exit")) { break; } - if (input == null || input.rawText().isEmpty() || input.words().isEmpty()) { - // Ignore empty lines - continue; - } - String commandName = input.words().get(0); - Command command = this.commandRegistry.getCommandByName(commandName); + + Command command = commandRegistry.lookupCommand(input.words()); + if (command == null) { String availableCommands = getAvailableCommands(); - this.terminal.writer() - .println( - "No command found for name: " + commandName + ". Available commands: " + availableCommands); - this.terminal.writer().flush(); + terminal.writer().printf("No command found. Available commands: %s%n", availableCommands); + terminal.writer().flush(); continue; } - log.debug(String.format("Evaluate input with line=[%s], command=[%s]", input.rawText(), command)); - CommandContext commandContext = new CommandContext(input.words(), this.commandRegistry, this.terminal); - try { - command.execute(commandContext); - } - catch (Exception exception) { - this.terminal.writer().append(exception.getMessage()); - this.terminal.writer().flush(); - } + + executeCommand(command, input); + } + } + + private void executeCommand(Command command, Input input) { + try { + log.debug("Evaluate input with line=[%s], command=[%s]".formatted(input.rawText(), command)); + CommandContext commandContext = new CommandContext(input.words(), commandRegistry, terminal); + command.execute(commandContext); + } + catch (Exception exception) { + terminal.writer().append(exception.getMessage()); + terminal.writer().flush(); } } private String getAvailableCommands() { - return this.commandRegistry.getCommands() - .stream() - .map(Command::getName) - .sorted() - .collect(Collectors.joining(", ")); + return commandRegistry.getCommands().stream().map(Command::getName).sorted().collect(Collectors.joining(", ")); } } diff --git a/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/SpringShellApplication.java b/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/SpringShellApplication.java index bbe06c42e..4a85bbc88 100644 --- a/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/SpringShellApplication.java +++ b/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/SpringShellApplication.java @@ -11,11 +11,13 @@ import org.springframework.shell.core.command.annotation.EnableCommand; import org.springframework.shell.core.commands.AbstractCommand; -@EnableCommand(SpringShellApplication.class) +@EnableCommand({ SpringShellApplication.class, SpringShellApplication.Nested.class, + SpringShellApplication.Nested.Child.class }) public class SpringShellApplication { public static void main(String[] args) throws Exception { - ApplicationContext context = new AnnotationConfigApplicationContext(SpringShellApplication.class); + ApplicationContext context = new AnnotationConfigApplicationContext(SpringShellApplication.class, + SpringShellApplication.Nested.class, SpringShellApplication.Nested.Child.class); ShellRunner runner = context.getBean(ShellRunner.class); runner.run(args); } @@ -26,6 +28,12 @@ public void sayHi(CommandContext commandContext) { terminal.writer().println("Hi there!"); } + @Command(description = "Say hi using method name", group = "greetings") + public void nameFromMethod(CommandContext commandContext) { + Terminal terminal = commandContext.terminal(); + terminal.writer().println("Hi from method name!"); + } + @Bean public AbstractCommand sayHello() { return new AbstractCommand("hello", "Say hello", "greetings") { @@ -38,4 +46,42 @@ public void execute(CommandContext commandContext) { }; } + // root first + // root child + // root child first + // root child second + @Command(name = "root") + public static class Nested { + + @Command(name = "first", description = "Say hi from nested method", group = "greetings") + public void nestedMethod(CommandContext commandContext) { + Terminal terminal = commandContext.terminal(); + terminal.writer().println("Hi form nested!"); + } + + @Command(name = "child", description = "Say hi from nested method", group = "greetings") + public void child(CommandContext commandContext) { + Terminal terminal = commandContext.terminal(); + terminal.writer().println("Hi form child!"); + } + + @Command(name = "child") + public static class Child { + + @Command(name = "first", group = "greetings") + public void firstChild(CommandContext commandContext) { + Terminal terminal = commandContext.terminal(); + terminal.writer().println("first!"); + } + + @Command(name = "second", group = "greetings") + public void secondChild(CommandContext commandContext) { + Terminal terminal = commandContext.terminal(); + terminal.writer().println("second!"); + } + + } + + } + } \ No newline at end of file diff --git a/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/package-info.java b/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/package-info.java new file mode 100644 index 000000000..407f6f394 --- /dev/null +++ b/spring-shell-samples/spring-shell-sample-hello-world/src/main/java/org/springframework/shell/samples/helloworld/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package org.springframework.shell.samples.helloworld; + +import org.jspecify.annotations.NullMarked;