Use delegation rather than inheritance to decouple JLine

Fixes #78
This commit is contained in:
Eric Bottard
2017-05-31 18:12:00 +02:00
parent 50ba8de8b4
commit bc81d3ed9c
19 changed files with 652 additions and 415 deletions

View File

@@ -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.
*
* <p>Given some textual input, locate the {@link MethodTarget} to invoke and {@link ResultHandler#handleResult(Object) handle}
* the result.</p>
*
* <p>Also provides hooks for code completion</p>
*
* @author Eric Bottard
*/
public abstract class AbstractShell implements Shell {
@Autowired
@Qualifier("main")
ResultHandler resultHandler;
@Autowired
protected ApplicationContext applicationContext;
protected Map<String, MethodTarget> methodTargets = new HashMap<>();
@Autowired
protected List<ParameterResolver> 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<String, MethodTarget> 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<String> words = input.words();
String command = findLongestCommand(line);
if (command != null) {
int wordsUsedForCommandKey = command.split(" ").length;
MethodTarget methodTarget = methodTargets.get(command);
List<String> 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<CompletionProposal> complete(CompletionContext context) {
String prefix = context.upToCursor();
List<CompletionProposal> 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 <best> (will fall through)
// or trying to complete command whose name starts with <best> (which also happens to be a command)
else if (prefix.equals(best)) {
candidates.addAll(commandsStartingWith(best));
} // valid command (<best>) followed by a suffix (but not necessarily [<space> 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<CompletionProposal> 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<ConstraintViolation<Object>> 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<String> 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;
}
}

View File

@@ -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();
}
}

View File

@@ -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<String> words;
public CommandNotFound(List<String> words) {
this.words = words;
}
@Override
public String getMessage() {
return String.format("No command found for '%s'", words.stream().collect(Collectors.joining(" ")));
}
}

View File

@@ -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<String, MethodTarget> listCommands();
}

View File

@@ -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<String> 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<String> words();
default List<String> words() {return "".equals(rawText()) ? Collections.emptyList() : Arrays.asList(rawText().split(" "));}
}

View File

@@ -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, <em>i.e.</em> 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<Method> 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;
}

View File

@@ -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.
*
* <p>Given some textual input, locate the {@link MethodTarget} to invoke and {@link ResultHandler#handleResult(Object) handle}
* the result.</p>
*
* <p>Also provides hooks for code completion</p>
*
* @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<String, MethodTarget> methodTargets = new HashMap<>();
@Autowired
protected List<ParameterResolver> 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<String, MethodTarget> 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<String> words = input.words();
String command = findLongestCommand(line);
Object result;
if (command != null) {
int wordsUsedForCommandKey = command.split(" ").length;
MethodTarget methodTarget = methodTargets.get(command);
List<String> 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<CompletionProposal> complete(CompletionContext context) {
String prefix = context.upToCursor();
List<CompletionProposal> 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 <best> (will fall through)
// or trying to complete command whose name starts with <best> (which also happens to be a command)
else if (prefix.equals(best)) {
candidates.addAll(commandsStartingWith(best));
} // valid command (<best>) followed by a suffix (but not necessarily [<space> 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<CompletionProposal> 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<ConstraintViolation<Object>> 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<String> 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<String, MethodTarget> 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();
}
}

View File

@@ -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}.

View File

@@ -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<CompletionProposal> 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<Candidate> candidates) {
@@ -135,7 +151,7 @@ public class JLineShell extends AbstractShell {
CompletionContext context = new CompletionContext(sanitizeInput(line.words()), line.wordIndex(), line.wordCursor());
List<CompletionProposal> proposals = JLineShell.this.complete(context);
List<CompletionProposal> 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 {

View File

@@ -29,14 +29,7 @@ import org.springframework.stereotype.Component;
* @author Eric Bottard
*/
@Component
public class AttributedCharSequenceResultHandler implements ResultHandler<AttributedCharSequence> {
private final Terminal terminal;
@Autowired
public AttributedCharSequenceResultHandler(Terminal terminal) {
this.terminal = terminal;
}
public class AttributedCharSequenceResultHandler extends TerminalAwareResultHandler implements ResultHandler<AttributedCharSequence> {
@Override
public void handleResult(AttributedCharSequence result) {

View File

@@ -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<CommandNotFound> {
@Override
public void handleResult(CommandNotFound result) {
terminal.writer().println(new AttributedString(result.getMessage(),
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi());
}
}

View File

@@ -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;
}
}

View File

@@ -28,11 +28,11 @@ import org.springframework.stereotype.Component;
* @author Eric Bottard
*/
@Component
public class ThrowableResultHandler implements ResultHandler<Throwable> {
public class ThrowableResultHandler extends TerminalAwareResultHandler implements ResultHandler<Throwable> {
@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());
}
}

View File

@@ -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.<String>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<ExitRequest> {
private boolean exited;
@Override
public void handleResult(ExitRequest result) {
exited = true;
throw new BreakControlFlow();
}
}
private static class BreakControlFlow extends RuntimeException {
}
}

View File

@@ -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 {
}
}

View File

@@ -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<ParameterResolver> parameterResolvers;
private Shell shell;
private CommandRegistry commandRegistry;
@Autowired
public Help(List<ParameterResolver> 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<String> aliases = shell.listCommands().entrySet().stream()
Set<String> 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<String, Set<String>> groupedByMethodTarget = shell.listCommands().entrySet().stream()
Map<String, Set<String>> 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

View File

@@ -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<String, MethodTarget> result = new HashMap<>();
Method method = ReflectionUtils.findMethod(Commands.class, "firstCommand", boolean.class, boolean.class, int.class, float[].class);

View File

@@ -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<CompletionProposal> complete(MethodParameter parameter, CompletionContext completionContext, String[] hints) {
return shell.listCommands().keySet().stream()
return commandRegistry.listCommands().keySet().stream()
.map(CompletionProposal::new)
.collect(Collectors.toList());
}

View File

@@ -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() {