diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/AbstractShell.java b/spring-shell2-core/src/main/java/org/springframework/shell2/AbstractShell.java deleted file mode 100644 index becf0e11..00000000 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/AbstractShell.java +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright 2017 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 - * - * http://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.shell2; - -import java.io.IOException; -import java.lang.reflect.Method; -import java.lang.reflect.Parameter; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -import javax.annotation.PostConstruct; -import javax.validation.ConstraintViolation; -import javax.validation.Validation; -import javax.validation.executable.ExecutableValidator; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.context.ApplicationContext; -import org.springframework.core.MethodParameter; -import org.springframework.util.ReflectionUtils; - -/** - * Base class implementing a shell loop. - * - *

Given some textual input, locate the {@link MethodTarget} to invoke and {@link ResultHandler#handleResult(Object) handle} - * the result.

- * - *

Also provides hooks for code completion

- * - * @author Eric Bottard - */ -public abstract class AbstractShell implements Shell { - - @Autowired - @Qualifier("main") - ResultHandler resultHandler; - - @Autowired - protected ApplicationContext applicationContext; - - protected Map methodTargets = new HashMap<>(); - - @Autowired - protected List parameterResolvers = new ArrayList<>(); - - /** - * Marker object to distinguish unresolved arguments from {@code null}, which is a valid value. - */ - protected static final Object UNRESOLVED = new Object(); - - @Override - public Map listCommands() { - return methodTargets; - } - - @PostConstruct - public void gatherMethodTargets() throws Exception { - for (MethodTargetResolver resolver : applicationContext.getBeansOfType(MethodTargetResolver.class).values()) { - methodTargets.putAll(resolver.resolve()); - } - } - - /** - * The main program loop: acquire input, try to match it to a command and evaluate. Repeat until a - * {@link ResultHandler} causes the process to exit. - */ - public void run() throws IOException { - while (true) { - Input input = readInput(); - if (input.words().isEmpty()) { - continue; - } - - - String line = input.rawText(); - List words = input.words(); - - String command = findLongestCommand(line); - - if (command != null) { - int wordsUsedForCommandKey = command.split(" ").length; - MethodTarget methodTarget = methodTargets.get(command); - List wordsForArgs = words.subList(wordsUsedForCommandKey, words.size()); - Method method = methodTarget.getMethod(); - - Object result = null; - try { - Object[] args = resolveArgs(method, wordsForArgs); - validateArgs(args, methodTarget); - result = ReflectionUtils.invokeMethod(method, methodTarget.getBean(), args); - } - catch (Exception e) { - result = e; - } - - resultHandler.handleResult(result); - - } - else { - System.out.println("No command found for " + words); - } - } - } - - /** - * Return text entered by user to invoke commands. - */ - protected abstract Input readInput(); - - /** - * Gather completion proposals given some (incomplete) input the user has already typed in. - * When and how this method is invoked is implementation specific and decided by subclasses. - */ - public List complete(CompletionContext context) { - - String prefix = context.upToCursor(); - - List candidates = new ArrayList<>(); - // Find the longest match for a command name with words in the buffer - String best = findLongestCommand(prefix); - if (best == null) { // no command found - candidates.addAll(commandsStartingWith(prefix)); - return candidates; - } // if we're here, we're either trying to complete args for command (will fall through) - // or trying to complete command whose name starts with (which also happens to be a command) - else if (prefix.equals(best)) { - candidates.addAll(commandsStartingWith(best)); - } // valid command () followed by a suffix (but not necessarily [ args*]) - else if (!prefix.startsWith(best + " ")) { - // must be an invalid command, can't do anything - return candidates; - } - - // Try to complete arguments - MethodTarget methodTarget = methodTargets.get(best); - Method method = methodTarget.getMethod(); - return Arrays.stream(method.getParameters()) - .map(Utils::createMethodParameter) - .flatMap(mp -> findResolver(mp).complete(mp, context).stream()) - .collect(Collectors.toList()); - } - - private List commandsStartingWith(String prefix) { - return methodTargets.entrySet().stream() - .filter(e -> e.getKey().startsWith(prefix)) - .map(e -> toCompletionProposal(e.getKey(), e.getValue())) - .collect(Collectors.toList()); - } - - private CompletionProposal toCompletionProposal(String command, MethodTarget methodTarget) { - return new CompletionProposal(command) - .category("Available commands") - .description(methodTarget.getHelp()); - } - - private void validateArgs(Object[] args, MethodTarget methodTarget) { - for (int i = 0; i < args.length; i++) { - if (args[i] == UNRESOLVED) { - MethodParameter methodParameter = Utils.createMethodParameter(methodTarget.getMethod(), i); - throw new IllegalStateException("Could not resolve " + methodParameter); - } - } - ExecutableValidator executableValidator = Validation - .buildDefaultValidatorFactory().getValidator().forExecutables(); - Set> constraintViolations = executableValidator.validateParameters(methodTarget.getBean(), - methodTarget.getMethod(), - args); - if (constraintViolations.size() > 0) { - System.out.println(constraintViolations); - } - } - - /** - * Use all known {@link ParameterResolver}s to try to compute a value for each parameter of the method to - * invoke. - * @param method the method for which parameters should be computed - * @param wordsForArgs the list of 'words' that should be converted to parameter values. - * May include markers for passing parameters 'by name' - * @return an array containing resolved parameter values, or {@link #UNRESOLVED} for parameters that could not be - * resolved - */ - private Object[] resolveArgs(Method method, List wordsForArgs) { - Parameter[] parameters = method.getParameters(); - Object[] args = new Object[parameters.length]; - Arrays.fill(args, UNRESOLVED); - for (int i = 0; i < parameters.length; i++) { - MethodParameter methodParameter = Utils.createMethodParameter(method, i); - args[i] = findResolver(methodParameter).resolve(methodParameter, wordsForArgs); - } - return args; - } - - protected ParameterResolver findResolver(MethodParameter parameter) { - return parameterResolvers.stream() - .filter(resolver -> resolver.supports(parameter)) - .findFirst() - .orElseThrow(() -> new RuntimeException("resolver not found")); - } - - - /** - * Returns the longest command that can be matched as first word(s) in the given buffer. - * - * @return a valid command name, or {@literal null} if none matched - */ - protected String findLongestCommand(String prefix) { - String result = methodTargets.keySet().stream() - .filter(prefix::startsWith) - .reduce("", (c1, c2) -> c1.length() > c2.length() ? c1 : c2); - return "".equals(result) ? null : result; - } - - - -} diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/Bootstrap.java b/spring-shell2-core/src/main/java/org/springframework/shell2/Bootstrap.java index d82c4a92..6fd01356 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/Bootstrap.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/Bootstrap.java @@ -16,11 +16,6 @@ package org.springframework.shell2; -import java.io.IOException; - -import org.jline.terminal.Terminal; -import org.jline.terminal.TerminalBuilder; - import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; @@ -41,7 +36,7 @@ public class Bootstrap { public static void main(String[] args) throws Exception { ConfigurableApplicationContext context = SpringApplication.run(Bootstrap.class); - context.getBean(JLineShell.class).run(); + context.getBean(Shell.class).run(); } @Bean @@ -49,9 +44,4 @@ public class Bootstrap { return new DefaultConversionService(); } - @Bean - public Terminal terminal() throws IOException { - return TerminalBuilder.builder().build(); - } - } diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/CommandNotFound.java b/spring-shell2-core/src/main/java/org/springframework/shell2/CommandNotFound.java new file mode 100644 index 00000000..30e51b10 --- /dev/null +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/CommandNotFound.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017 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 + * + * http://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.shell2; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * A result to be handled by the {@link ResultHandler} when no command could be mapped to user input + */ +public class CommandNotFound extends RuntimeException { + + private final List words; + + public CommandNotFound(List words) { + this.words = words; + } + + @Override + public String getMessage() { + return String.format("No command found for '%s'", words.stream().collect(Collectors.joining(" "))); + } +} diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/CommandRegistry.java b/spring-shell2-core/src/main/java/org/springframework/shell2/CommandRegistry.java new file mode 100644 index 00000000..ca625b8d --- /dev/null +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/CommandRegistry.java @@ -0,0 +1,35 @@ +/* + * Copyright 2015 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 + * + * http://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.shell2; + +import java.util.Map; + +/** + * Implementing this interface allows sub-systems (such as the {@literal help} command) to + * discover available commands. + * + * @author Eric Bottard + */ +public interface CommandRegistry { + + + /** + * Return the mapping from command trigger keywords to implementation. + */ + public Map listCommands(); + +} diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/Input.java b/spring-shell2-core/src/main/java/org/springframework/shell2/Input.java index 6dbc0ed7..62ed87fe 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/Input.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/Input.java @@ -16,6 +16,7 @@ package org.springframework.shell2; +import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -26,17 +27,7 @@ import java.util.List; */ public interface Input { - Input EMPTY = new Input() { - @Override - public String rawText() { - return ""; - } - - @Override - public List words() { - return Collections.emptyList(); - } - }; + Input EMPTY = () -> ""; /** * Return the input as entered by the user. @@ -48,5 +39,5 @@ public interface Input { * to parsing rules (for example, handling quoted portions of the readInput as a single * "word") */ - List words(); + default List words() {return "".equals(rawText()) ? Collections.emptyList() : Arrays.asList(rawText().split(" "));} } diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/MethodTarget.java b/spring-shell2-core/src/main/java/org/springframework/shell2/MethodTarget.java index faaa481e..dbf236d3 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/MethodTarget.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/MethodTarget.java @@ -17,8 +17,11 @@ package org.springframework.shell2; import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Set; import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; /** * Represents a shell command behavior, i.e. code to be executed when a command is requested. @@ -37,11 +40,26 @@ public class MethodTarget { Assert.notNull(method, "Method cannot be null"); Assert.notNull(bean, "Bean cannot be null"); Assert.hasText(help, "Help cannot be blank"); + ReflectionUtils.makeAccessible(method); this.method = method; this.bean = bean; this.help = help; } + /** + * Construct a MethodTarget for the unique method named {@literal name} on the given object. Fails with an exception + * in case of overloaded method. + */ + public static MethodTarget of(String name, Object bean, String help) { + Set found = new HashSet<>(); + ReflectionUtils.doWithMethods(bean.getClass(), found::add, m -> m.getName().equals(name)); + if (found.size() != 1) { + throw new IllegalArgumentException(String.format("Could not find unique method named '%s' on object of class %s. Found %s", + name, bean.getClass(), found)); + } + return new MethodTarget(found.iterator().next(), bean, help); + } + public Method getMethod() { return method; } diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/Shell.java b/spring-shell2-core/src/main/java/org/springframework/shell2/Shell.java index 0e7f8130..3230e1b5 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/Shell.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/Shell.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2017 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. @@ -16,20 +16,229 @@ package org.springframework.shell2; +import java.io.IOException; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.annotation.PostConstruct; +import javax.validation.ConstraintViolation; +import javax.validation.Validation; +import javax.validation.executable.ExecutableValidator; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.core.MethodParameter; +import org.springframework.util.ReflectionUtils; /** - * Implementing this interface allows sub-systems (such as the {@literal help} command) to - * discover available commands. + * Main class implementing a shell loop. + * + *

Given some textual input, locate the {@link MethodTarget} to invoke and {@link ResultHandler#handleResult(Object) handle} + * the result.

+ * + *

Also provides hooks for code completion

* * @author Eric Bottard */ -public interface Shell { +public class Shell implements CommandRegistry { + + private final InputProvider inputProvider; + + private final ResultHandler resultHandler; + + @Autowired + protected ApplicationContext applicationContext; + + protected Map methodTargets = new HashMap<>(); + + @Autowired + protected List parameterResolvers = new ArrayList<>(); + + /** + * Marker object to distinguish unresolved arguments from {@code null}, which is a valid value. + */ + protected static final Object UNRESOLVED = new Object(); + + public Shell(InputProvider inputProvider, ResultHandler resultHandler) { + this.inputProvider = inputProvider; + this.resultHandler = resultHandler; + } + + @Override + public Map listCommands() { + return methodTargets; + } + + @PostConstruct + public void gatherMethodTargets() throws Exception { + for (MethodTargetResolver resolver : applicationContext.getBeansOfType(MethodTargetResolver.class).values()) { + methodTargets.putAll(resolver.resolve()); + } + } + + /** + * The main program loop: acquire input, try to match it to a command and evaluate. Repeat until a + * {@link ResultHandler} causes the process to exit. + */ + public void run() throws IOException { + while (true) { + Input input; + try { + input = inputProvider.readInput(); + } + catch (Exception e) { + resultHandler.handleResult(e); + continue; + } + if (input.words().isEmpty()) { + continue; + } + + + String line = input.rawText(); + List words = input.words(); + + String command = findLongestCommand(line); + + Object result; + if (command != null) { + int wordsUsedForCommandKey = command.split(" ").length; + MethodTarget methodTarget = methodTargets.get(command); + List wordsForArgs = words.subList(wordsUsedForCommandKey, words.size()); + Method method = methodTarget.getMethod(); + + try { + Object[] args = resolveArgs(method, wordsForArgs); + validateArgs(args, methodTarget); + result = ReflectionUtils.invokeMethod(method, methodTarget.getBean(), args); + } + catch (Exception e) { + result = e; + } + } + else { + result = new CommandNotFound(words); + } + resultHandler.handleResult(result); + } + } + + /** + * Gather completion proposals given some (incomplete) input the user has already typed in. + * When and how this method is invoked is implementation specific and decided by the actual user interface. + */ + public List complete(CompletionContext context) { + + String prefix = context.upToCursor(); + + List candidates = new ArrayList<>(); + // Find the longest match for a command name with words in the buffer + String best = findLongestCommand(prefix); + if (best == null) { // no command found + candidates.addAll(commandsStartingWith(prefix)); + return candidates; + } // if we're here, we're either trying to complete args for command (will fall through) + // or trying to complete command whose name starts with (which also happens to be a command) + else if (prefix.equals(best)) { + candidates.addAll(commandsStartingWith(best)); + } // valid command () followed by a suffix (but not necessarily [ args*]) + else if (!prefix.startsWith(best + " ")) { + // must be an invalid command, can't do anything + return candidates; + } + + // Try to complete arguments + MethodTarget methodTarget = methodTargets.get(best); + Method method = methodTarget.getMethod(); + return Arrays.stream(method.getParameters()) + .map(Utils::createMethodParameter) + .flatMap(mp -> findResolver(mp).complete(mp, context).stream()) + .collect(Collectors.toList()); + } + + private List commandsStartingWith(String prefix) { + return methodTargets.entrySet().stream() + .filter(e -> e.getKey().startsWith(prefix)) + .map(e -> toCompletionProposal(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + } + + private CompletionProposal toCompletionProposal(String command, MethodTarget methodTarget) { + return new CompletionProposal(command) + .category("Available commands") + .description(methodTarget.getHelp()); + } + + private void validateArgs(Object[] args, MethodTarget methodTarget) { + for (int i = 0; i < args.length; i++) { + if (args[i] == UNRESOLVED) { + MethodParameter methodParameter = Utils.createMethodParameter(methodTarget.getMethod(), i); + throw new IllegalStateException("Could not resolve " + methodParameter); + } + } + ExecutableValidator executableValidator = Validation + .buildDefaultValidatorFactory().getValidator().forExecutables(); + Set> constraintViolations = executableValidator.validateParameters(methodTarget.getBean(), + methodTarget.getMethod(), + args); + if (constraintViolations.size() > 0) { + System.out.println(constraintViolations); + } + } + + /** + * Use all known {@link ParameterResolver}s to try to compute a value for each parameter of the method to + * invoke. + * @param method the method for which parameters should be computed + * @param wordsForArgs the list of 'words' that should be converted to parameter values. + * May include markers for passing parameters 'by name' + * @return an array containing resolved parameter values, or {@link #UNRESOLVED} for parameters that could not be + * resolved + */ + private Object[] resolveArgs(Method method, List wordsForArgs) { + Parameter[] parameters = method.getParameters(); + Object[] args = new Object[parameters.length]; + Arrays.fill(args, UNRESOLVED); + for (int i = 0; i < parameters.length; i++) { + MethodParameter methodParameter = Utils.createMethodParameter(method, i); + args[i] = findResolver(methodParameter).resolve(methodParameter, wordsForArgs); + } + return args; + } + + private ParameterResolver findResolver(MethodParameter parameter) { + return parameterResolvers.stream() + .filter(resolver -> resolver.supports(parameter)) + .findFirst() + .orElseThrow(() -> new RuntimeException("resolver not found")); + } /** - * Return the mapping from command trigger keywords to implementation. + * Returns the longest command that can be matched as first word(s) in the given buffer. + * + * @return a valid command name, or {@literal null} if none matched */ - public Map listCommands(); + private String findLongestCommand(String prefix) { + String result = methodTargets.keySet().stream() + .filter(prefix::startsWith) + .reduce("", (c1, c2) -> c1.length() > c2.length() ? c1 : c2); + return "".equals(result) ? null : result; + } + + public interface InputProvider { + /** + * Return text entered by user to invoke commands. + */ + Input readInput(); + } + } diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/ExtendedDefaultParser.java b/spring-shell2-core/src/main/java/org/springframework/shell2/jline/ExtendedDefaultParser.java similarity index 98% rename from spring-shell2-core/src/main/java/org/springframework/shell2/ExtendedDefaultParser.java rename to spring-shell2-core/src/main/java/org/springframework/shell2/jline/ExtendedDefaultParser.java index 907a6251..c49a7827 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/ExtendedDefaultParser.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/jline/ExtendedDefaultParser.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.shell2; +package org.springframework.shell2.jline; import java.util.Collections; import java.util.LinkedList; @@ -26,6 +26,8 @@ import org.jline.reader.EOFError; import org.jline.reader.ParsedLine; import org.jline.reader.Parser; +import org.springframework.shell2.CompletingParsedLine; + /** * Shameful copy-paste of JLine's {@link org.jline.reader.impl.DefaultParser} which * creates {@link CompletingParsedLine}. diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java b/spring-shell2-core/src/main/java/org/springframework/shell2/jline/JLineShell.java similarity index 62% rename from spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java rename to spring-shell2-core/src/main/java/org/springframework/shell2/jline/JLineShell.java index 9e2398b0..57a0b499 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/jline/JLineShell.java @@ -1,5 +1,5 @@ /* - * Copyright 2015 the original author or authors. + * Copyright 2017 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. @@ -14,8 +14,9 @@ * limitations under the License. */ -package org.springframework.shell2; +package org.springframework.shell2.jline; +import java.io.IOException; import java.util.List; import java.util.stream.Collectors; @@ -29,12 +30,23 @@ import org.jline.reader.LineReaderBuilder; import org.jline.reader.ParsedLine; import org.jline.reader.UserInterruptException; import org.jline.terminal.Terminal; +import org.jline.terminal.TerminalBuilder; import org.jline.utils.AttributedString; import org.jline.utils.AttributedStringBuilder; import org.jline.utils.AttributedStyle; +import org.springframework.beans.factory.BeanCreationException; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.shell2.CompletingParsedLine; +import org.springframework.shell2.CompletionContext; +import org.springframework.shell2.CompletionProposal; +import org.springframework.shell2.ExitRequest; +import org.springframework.shell2.Input; +import org.springframework.shell2.ResultHandler; +import org.springframework.shell2.Shell; /** * Shell implementation using JLine to capture input and trigger completions. @@ -42,31 +54,58 @@ import org.springframework.stereotype.Component; * @author Eric Bottard * @author Florent Biville */ -@Component -public class JLineShell extends AbstractShell { - - LineReader lineReader; +@Configuration +public class JLineShell { @Autowired - private Terminal terminal; + @Qualifier("main") + private ResultHandler resultHandler; + @Bean + public Terminal terminal() { + try { + return TerminalBuilder.builder().build(); + } + catch (IOException e) { + throw new BeanCreationException("Could not create Terminal: " + e.getMessage()); + } + } + + @Bean + public Shell shell() { + return new Shell(new JLineInputProvider(lineReader()), resultHandler); + } + + @Bean + public CompleterAdapter completer() { + return new CompleterAdapter(); + } + + /* + * Using setter injection to work around a circular dependency. + */ @PostConstruct - public void init() throws Exception { + public void lateInit() { + completer().setShell(shell()); + } + + @Bean + public LineReader lineReader() { ExtendedDefaultParser parser = new ExtendedDefaultParser(); parser.setEofOnUnclosedQuote(true); parser.setEofOnEscapedNewLine(true); LineReaderBuilder lineReaderBuilder = LineReaderBuilder.builder() - .terminal(terminal) + .terminal(terminal()) .appName("Foo") - .completer(new CompleterAdapter()) + .completer(completer()) .highlighter(new Highlighter() { @Override public AttributedString highlight(LineReader reader, String buffer) { int l = 0; String best = null; - for (String command : methodTargets.keySet()) { + for (String command : shell().listCommands().keySet()) { if (buffer.startsWith(command) && command.length() > l) { l = command.length(); best = command; @@ -82,24 +121,7 @@ public class JLineShell extends AbstractShell { }) .parser(parser); - lineReader = lineReaderBuilder.build(); - - } - - - @Override - protected Input readInput() { - try { - lineReader.readLine(new AttributedString("shell:>", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)).toAnsi(terminal)); - } - catch (UserInterruptException e) { - if (e.getPartialLine().isEmpty()) { - resultHandler.handleResult(new ExitRequest(1)); - } else { - return Input.EMPTY; - } - } - return new JLineInput(lineReader.getParsedLine()); + return lineReaderBuilder.build(); } /** @@ -115,19 +137,13 @@ public class JLineShell extends AbstractShell { return words; } - // Overridden so it can be called from CompleterAdapter - - - @Override - public List complete(CompletionContext context) { - return super.complete(context); - } - /** * A bridge between JLine's {@link Completer} contract and our own. * @author Eric Bottard */ - private class CompleterAdapter implements Completer { + public static class CompleterAdapter implements Completer { + + private Shell shell; @Override public void complete(LineReader reader, ParsedLine line, List candidates) { @@ -135,7 +151,7 @@ public class JLineShell extends AbstractShell { CompletionContext context = new CompletionContext(sanitizeInput(line.words()), line.wordIndex(), line.wordCursor()); - List proposals = JLineShell.this.complete(context); + List proposals = shell.complete(context); proposals.stream() .map(p -> new Candidate( cpl.emit(p.value()).toString(), @@ -148,6 +164,36 @@ public class JLineShell extends AbstractShell { ) .forEach(candidates::add); } + + public void setShell(Shell shell) { + this.shell = shell; + } + } + + public static class JLineInputProvider implements Shell.InputProvider { + + private final LineReader lineReader; + + public JLineInputProvider(LineReader lineReader) { + this.lineReader = lineReader; + } + + @Override + public Input readInput() { + try { + lineReader.readLine(new AttributedString("shell:>", AttributedStyle.DEFAULT.foreground(AttributedStyle.YELLOW)).toAnsi(lineReader.getTerminal())); + } + catch (UserInterruptException e) { + if (e.getPartialLine().isEmpty()) { + throw new ExitRequest(1); + } else { + return Input.EMPTY; + } + } + return new JLineInput(lineReader.getParsedLine()); + } + + } private static class JLineInput implements Input { diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/result/AttributedCharSequenceResultHandler.java b/spring-shell2-core/src/main/java/org/springframework/shell2/result/AttributedCharSequenceResultHandler.java index 6c47cdce..287c89ca 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/result/AttributedCharSequenceResultHandler.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/result/AttributedCharSequenceResultHandler.java @@ -29,14 +29,7 @@ import org.springframework.stereotype.Component; * @author Eric Bottard */ @Component -public class AttributedCharSequenceResultHandler implements ResultHandler { - - private final Terminal terminal; - - @Autowired - public AttributedCharSequenceResultHandler(Terminal terminal) { - this.terminal = terminal; - } +public class AttributedCharSequenceResultHandler extends TerminalAwareResultHandler implements ResultHandler { @Override public void handleResult(AttributedCharSequence result) { diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/result/CommandNotFoundResultHandler.java b/spring-shell2-core/src/main/java/org/springframework/shell2/result/CommandNotFoundResultHandler.java new file mode 100644 index 00000000..c175ffb2 --- /dev/null +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/result/CommandNotFoundResultHandler.java @@ -0,0 +1,42 @@ +/* + * Copyright 2017 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 + * + * http://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.shell2.result; + +import org.jline.utils.AttributedString; +import org.jline.utils.AttributedStyle; + +import org.springframework.shell2.CommandNotFound; +import org.springframework.shell2.ResultHandler; +import org.springframework.stereotype.Component; + +/** + * Used when no command can be matched for user input. + * + * Simply prints an error message, without printing the exception class. + * + * @author Eric Bottard + */ +@Component +public class CommandNotFoundResultHandler extends TerminalAwareResultHandler implements ResultHandler { + + @Override + public void handleResult(CommandNotFound result) { + terminal.writer().println(new AttributedString(result.getMessage(), + AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi()); + + } +} diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/result/TerminalAwareResultHandler.java b/spring-shell2-core/src/main/java/org/springframework/shell2/result/TerminalAwareResultHandler.java new file mode 100644 index 00000000..433bd96a --- /dev/null +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/result/TerminalAwareResultHandler.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017 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 + * + * http://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.shell2.result; + +import org.jline.terminal.Terminal; + +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Base class for ResultHandlers that rely on JLine's {@link Terminal}. + * + * @author Eric Bottard + */ +public abstract class TerminalAwareResultHandler { + protected Terminal terminal; + + @Autowired + public void setTerminal(Terminal terminal) { + this.terminal = terminal; + } +} diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/result/ThrowableResultHandler.java b/spring-shell2-core/src/main/java/org/springframework/shell2/result/ThrowableResultHandler.java index 33799c25..ec9e8d55 100644 --- a/spring-shell2-core/src/main/java/org/springframework/shell2/result/ThrowableResultHandler.java +++ b/spring-shell2-core/src/main/java/org/springframework/shell2/result/ThrowableResultHandler.java @@ -28,11 +28,11 @@ import org.springframework.stereotype.Component; * @author Eric Bottard */ @Component -public class ThrowableResultHandler implements ResultHandler { +public class ThrowableResultHandler extends TerminalAwareResultHandler implements ResultHandler { @Override public void handleResult(Throwable result) { - System.out.println(new AttributedString(result.toString(), + terminal.writer().println(new AttributedString(result.toString(), AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi()); } } diff --git a/spring-shell2-core/src/test/java/org/springframework/shell2/JLineShellTest.java b/spring-shell2-core/src/test/java/org/springframework/shell2/JLineShellTest.java deleted file mode 100644 index 74025c59..00000000 --- a/spring-shell2-core/src/test/java/org/springframework/shell2/JLineShellTest.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2017 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 - * - * http://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.shell2; - -import java.util.Collections; - -import org.jline.reader.LineReader; -import org.jline.reader.UserInterruptException; -import org.junit.Test; -import org.mockito.Mockito; - -import static org.hamcrest.CoreMatchers.is; -import static org.junit.Assert.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import org.springframework.shell2.result.TypeHierarchyResultHandler; - -/** - * Unit tests for {@link JLineShell}. - * - * @author Eric Bottard - */ -public class JLineShellTest { - - private JLineShell shell = new JLineShell(); - - @Test - public void testCtrlCInterception() throws Exception { - shell.lineReader = mock(LineReader.class); - AssertingExitRequestResultHandler resultHandler = new AssertingExitRequestResultHandler(); - shell.resultHandler = resultHandler; - when(shell.lineReader.readLine(Mockito.any())).thenThrow( - new UserInterruptException("some input"), - new UserInterruptException("") - ); - - try { - shell.run(); - } - catch (BreakControlFlow breakControlFlow) { - - } - assertThat(resultHandler.exited, is(true)); - } - - private static class AssertingExitRequestResultHandler implements ResultHandler { - - private boolean exited; - - @Override - public void handleResult(ExitRequest result) { - exited = true; - throw new BreakControlFlow(); - } - } - - private static class BreakControlFlow extends RuntimeException { - } - - - -} diff --git a/spring-shell2-core/src/test/java/org/springframework/shell2/ShellTest.java b/spring-shell2-core/src/test/java/org/springframework/shell2/ShellTest.java new file mode 100644 index 00000000..e1bdc3a0 --- /dev/null +++ b/spring-shell2-core/src/test/java/org/springframework/shell2/ShellTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2017 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 + * + * http://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.shell2; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import static org.assertj.core.api.Assertions.fail; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link Shell}. + * + * @author Eric Bottard + */ +public class ShellTest { + + @Rule + public MockitoRule mockitoRule = MockitoJUnit.rule(); + + @Mock + private Shell.InputProvider inputProvider; + + @Mock + private ResultHandler resultHandler; + + @Mock + private ParameterResolver parameterResolver; + + @InjectMocks + private Shell shell; + + private boolean invoked; + + @Before + public void setUp() { + shell.parameterResolvers = Arrays.asList(parameterResolver); + } + + @Test + public void commandMatch() throws IOException { + when(parameterResolver.supports(any())).thenReturn(true); + when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?"); + doThrow(new Exit()).when(resultHandler).handleResult(any()); + + shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, "Say hello")); + + try { + shell.run(); + fail("Exit expected"); + } + catch (Exit expected) { + + } + + Assert.assertTrue(invoked); + } + + @Test + public void commandNotFound() throws IOException { + when(inputProvider.readInput()).thenReturn(() -> "hello world how are you doing ?"); + doThrow(new Exit()).when(resultHandler).handleResult(any(CommandNotFound.class)); + + shell.methodTargets = Collections.singletonMap("bonjour", MethodTarget.of("helloWorld", this, "Say hello")); + + try { + shell.run(); + fail("Exit expected"); + } + catch (Exit expected) { + + } + } + + @Test + public void noCommand() throws IOException { + when(parameterResolver.supports(any())).thenReturn(true); + when(inputProvider.readInput()).thenReturn(() -> "", () -> "hello world how are you doing ?"); + doThrow(new Exit()).when(resultHandler).handleResult(any()); + + shell.methodTargets = Collections.singletonMap("hello world", MethodTarget.of("helloWorld", this, "Say hello")); + + try { + shell.run(); + fail("Exit expected"); + } + catch (Exit expected) { + + } + + Assert.assertTrue(invoked); + } + + @Test + public void commandThrowingAnException() throws IOException { + when(parameterResolver.supports(any())).thenReturn(true); + when(inputProvider.readInput()).thenReturn(() -> "fail"); + doThrow(new Exit()).when(resultHandler).handleResult(any(SomeException.class)); + + shell.methodTargets = Collections.singletonMap("fail", MethodTarget.of("failing", this, "Will throw an exception")); + + try { + shell.run(); + fail("Exit expected"); + } + catch (Exit expected) { + + } + + Assert.assertTrue(invoked); + + } + + private void helloWorld(String a) { + invoked = true; + } + + private String failing() { + invoked = true; + throw new SomeException(); + } + + + private static class Exit extends RuntimeException { + } + + private static class SomeException extends RuntimeException { + + } + + +} diff --git a/spring-shell2-standard-commands/src/main/java/org/springframework/shell2/standard/commands/Help.java b/spring-shell2-standard-commands/src/main/java/org/springframework/shell2/standard/commands/Help.java index 418ef057..3615ae19 100644 --- a/spring-shell2-standard-commands/src/main/java/org/springframework/shell2/standard/commands/Help.java +++ b/spring-shell2-standard-commands/src/main/java/org/springframework/shell2/standard/commands/Help.java @@ -37,7 +37,7 @@ import org.springframework.core.MethodParameter; import org.springframework.shell2.MethodTarget; import org.springframework.shell2.ParameterDescription; import org.springframework.shell2.ParameterResolver; -import org.springframework.shell2.Shell; +import org.springframework.shell2.CommandRegistry; import org.springframework.shell2.standard.CommandValueProvider; import org.springframework.shell2.standard.ShellComponent; import org.springframework.shell2.standard.ShellMethod; @@ -54,7 +54,7 @@ public class Help { private final List parameterResolvers; - private Shell shell; + private CommandRegistry commandRegistry; @Autowired public Help(List parameterResolvers) { @@ -62,8 +62,8 @@ public class Help { } @Autowired // ctor injection impossible b/c of circular dependency - public void setShell(Shell shell) { - this.shell = shell; + public void setCommandRegistry(CommandRegistry commandRegistry) { + this.commandRegistry = commandRegistry; } @ShellMethod(help = "Display help about available commands.", prefix = "-") @@ -85,7 +85,7 @@ public class Help { * Return a description of a specific command. Uses a layout inspired by *nix man pages. */ private CharSequence documentCommand(String command) { - MethodTarget methodTarget = shell.listCommands().get(command); + MethodTarget methodTarget = commandRegistry.listCommands().get(command); if (methodTarget == null) { throw new IllegalArgumentException("Unknown command '" + command + "'"); } @@ -181,7 +181,7 @@ public class Help { } // ALSO KNOWN AS - Set aliases = shell.listCommands().entrySet().stream() + Set aliases = commandRegistry.listCommands().entrySet().stream() .filter(e -> e.getValue().equals(methodTarget)) .map(Map.Entry::getKey) .filter(c -> !command.equals(c)) @@ -203,7 +203,7 @@ public class Help { } private CharSequence listCommands() { - Map> groupedByMethodTarget = shell.listCommands().entrySet().stream() + Map> groupedByMethodTarget = commandRegistry.listCommands().entrySet().stream() .collect(Collectors.groupingBy(e -> e.getValue().getHelp(), // Use help() as the grouping key mapping(Map.Entry::getKey, toCollection(TreeSet::new)))); // accumulate the command 'names' into a sorted set diff --git a/spring-shell2-standard-commands/src/test/java/org/springframework/shell2/standard/commands/HelpTest.java b/spring-shell2-standard-commands/src/test/java/org/springframework/shell2/standard/commands/HelpTest.java index 6b592431..84b43898 100644 --- a/spring-shell2-standard-commands/src/test/java/org/springframework/shell2/standard/commands/HelpTest.java +++ b/spring-shell2-standard-commands/src/test/java/org/springframework/shell2/standard/commands/HelpTest.java @@ -38,7 +38,7 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.shell2.standard.StandardParameterResolver; import org.springframework.shell2.MethodTarget; import org.springframework.shell2.ParameterResolver; -import org.springframework.shell2.Shell; +import org.springframework.shell2.CommandRegistry; import org.springframework.shell2.standard.ShellComponent; import org.springframework.shell2.standard.ShellMethod; import org.springframework.shell2.standard.ShellOption; @@ -93,7 +93,7 @@ public class HelpTest { } @Bean - public Shell shell() { + public CommandRegistry shell() { return () -> { Map result = new HashMap<>(); Method method = ReflectionUtils.findMethod(Commands.class, "firstCommand", boolean.class, boolean.class, int.class, float[].class); diff --git a/spring-shell2-standard/src/main/java/org/springframework/shell2/standard/CommandValueProvider.java b/spring-shell2-standard/src/main/java/org/springframework/shell2/standard/CommandValueProvider.java index 6bed201e..42f5110a 100644 --- a/spring-shell2-standard/src/main/java/org/springframework/shell2/standard/CommandValueProvider.java +++ b/spring-shell2-standard/src/main/java/org/springframework/shell2/standard/CommandValueProvider.java @@ -19,15 +19,12 @@ package org.springframework.shell2.standard; import java.util.List; import java.util.stream.Collectors; -import javax.annotation.PostConstruct; - import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Lazy; import org.springframework.core.MethodParameter; import org.springframework.shell2.CompletionContext; import org.springframework.shell2.CompletionProposal; -import org.springframework.shell2.Shell; +import org.springframework.shell2.CommandRegistry; import org.springframework.stereotype.Component; /** @@ -38,17 +35,17 @@ import org.springframework.stereotype.Component; @Component public class CommandValueProvider extends ValueProviderSupport { - private final Shell shell; + private final CommandRegistry commandRegistry; @Lazy @Autowired - public CommandValueProvider(Shell shell) { - this.shell = shell; + public CommandValueProvider(CommandRegistry commandRegistry) { + this.commandRegistry = commandRegistry; } @Override public List complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) { - return shell.listCommands().keySet().stream() + return commandRegistry.listCommands().keySet().stream() .map(CompletionProposal::new) .collect(Collectors.toList()); } diff --git a/spring-shell2-standard/src/test/java/org/springframework/shell2/standard/CommandValueProviderTest.java b/spring-shell2-standard/src/test/java/org/springframework/shell2/standard/CommandValueProviderTest.java index 2d1c9a5a..10ac43c7 100644 --- a/spring-shell2-standard/src/test/java/org/springframework/shell2/standard/CommandValueProviderTest.java +++ b/spring-shell2-standard/src/test/java/org/springframework/shell2/standard/CommandValueProviderTest.java @@ -23,24 +23,19 @@ import static org.mockito.Mockito.when; import java.lang.reflect.Method; import java.util.Arrays; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import org.assertj.core.api.Assertions; -import org.hamcrest.CoreMatchers; import org.junit.Before; import org.junit.Test; -import org.mockito.Answers; import org.mockito.Mock; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.springframework.core.MethodParameter; import org.springframework.shell2.CompletionContext; import org.springframework.shell2.CompletionProposal; import org.springframework.shell2.MethodTarget; -import org.springframework.shell2.Shell; +import org.springframework.shell2.CommandRegistry; import org.springframework.shell2.Utils; import org.springframework.util.ReflectionUtils; @@ -52,7 +47,7 @@ import org.springframework.util.ReflectionUtils; public class CommandValueProviderTest { @Mock - private Shell shell; + private CommandRegistry shell; @Before public void setUp() {