Decouple main REPL from JLine

Fixes #66
This commit is contained in:
Eric Bottard
2017-05-30 17:25:05 +02:00
parent e4e66369ad
commit 50ba8de8b4
5 changed files with 349 additions and 193 deletions

View File

@@ -0,0 +1,234 @@
/*
* 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

@@ -59,7 +59,10 @@ public class CompletionContext {
public String upToCursor() {
String start = words.subList(0, wordIndex).stream().collect(Collectors.joining(" "));
if (wordIndex < words.size()) {
start += " " + currentWord().substring(0, position);
if (!start.isEmpty()) {
start += " ";
}
start += currentWord().substring(0, position);
}
return start;
}

View File

@@ -51,32 +51,36 @@ public class CompletionProposal {
return value;
}
public void value(String value) {
public CompletionProposal value(String value) {
this.value = value;
return this;
}
public String displayText() {
return displayText;
}
public void displayText(String displayText) {
public CompletionProposal displayText(String displayText) {
this.displayText = displayText;
return this;
}
public String description() {
return description;
}
public void description(String description) {
public CompletionProposal description(String description) {
this.description = description;
return this;
}
public String category() {
return category;
}
public void category(String category) {
public CompletionProposal category(String category) {
this.category = category;
return this;
}
@Override

View File

@@ -0,0 +1,52 @@
/*
* 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 java.util.List;
/**
* Represents the input buffer to the shell.
*
* @author Eric Bottard
*/
public interface Input {
Input EMPTY = new Input() {
@Override
public String rawText() {
return "";
}
@Override
public List<String> words() {
return Collections.emptyList();
}
};
/**
* Return the input as entered by the user.
*/
String rawText();
/**
* Return the input as a list of parsed "words", having split the raw input according
* to parsing rules (for example, handling quoted portions of the readInput as a single
* "word")
*/
List<String> words();
}

View File

@@ -16,22 +16,10 @@
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.function.Function;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.executable.ExecutableValidator;
import org.jline.reader.Candidate;
import org.jline.reader.Completer;
@@ -40,63 +28,30 @@ import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.ParsedLine;
import org.jline.reader.UserInterruptException;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStringBuilder;
import org.jline.utils.AttributedStyle;
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.stereotype.Component;
import org.springframework.util.ReflectionUtils;
/**
* Main component implementing a REPL using JLine.
*
* <p>Discovers {@link MethodTarget}s at startup and hands off execution of commands according
* to the parsed JLine buffer.</p>
* Shell implementation using JLine to capture input and trigger completions.
*
* @author Eric Bottard
* @author Florent Biville
*/
@Component
public class JLineShell implements Shell {
@Autowired
@Qualifier("main")
ResultHandler resultHandler;
@Autowired
private ApplicationContext applicationContext;
private Map<String, MethodTarget> methodTargets = new HashMap<>();
public class JLineShell extends AbstractShell {
LineReader lineReader;
@Autowired
private Terminal terminal;
@Autowired
private List<ParameterResolver> parameterResolvers = new ArrayList<>();
/**
* Marker object to distinguish unresolved arguments from {@code null}, which is a valid value.
*/
private static final Object UNRESOLVED = new Object();
@Override
public Map<String, MethodTarget> listCommands() {
return methodTargets;
}
@PostConstruct
public void init() throws Exception {
for (MethodTargetResolver resolver : applicationContext.getBeansOfType(MethodTargetResolver.class).values()) {
methodTargets.putAll(resolver.resolve());
}
ExtendedDefaultParser parser = new ExtendedDefaultParser();
parser.setEofOnUnclosedQuote(true);
parser.setEofOnEscapedNewLine(true);
@@ -132,53 +87,26 @@ public class JLineShell implements Shell {
}
public void run() throws IOException {
while (true) {
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 {
continue;
}
}
String line = lineReader.getParsedLine().line();
String command = findLongestCommand(line);
List<String> words = lineReader.getParsedLine().words();
if (command != null) {
int wordsUsedForCommandKey = command.split(" ").length;
MethodTarget methodTarget = methodTargets.get(command);
List<String> wordsForArgs = sanitizeInput(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 " + sanitizeInput(words));
@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());
}
/**
* Sanitize the buffer input given the customizations applied to the JLine parser (<em>e.g.</em> support for
* line continuations, <em>etc.</em>)
*/
private List<String> sanitizeInput(List<String> words) {
static private List<String> sanitizeInput(List<String> words) {
words = words.stream()
.map(s -> s.replaceAll("^\\n+|\\n+$", "")) // CR at beginning/end of line introduced by backslash continuation
.map(s -> s.replaceAll("\\n+", " ")) // CR in middle of word introduced by return inside a quoted string
@@ -187,48 +115,12 @@ public class JLineShell implements Shell {
return words;
}
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);
}
}
// Overridden so it can be called from CompleterAdapter
/**
* 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"));
@Override
public List<CompletionProposal> complete(CompletionContext context) {
return super.complete(context);
}
/**
@@ -239,72 +131,43 @@ public class JLineShell implements Shell {
@Override
public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
String prefix = reader.getBuffer().upToCursor();
// 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;
} // 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;
}
CompletingParsedLine cpl = (line instanceof CompletingParsedLine) ? ((CompletingParsedLine) line) : t -> t;
// Try to complete arguments
MethodTarget methodTarget = methodTargets.get(best);
List<String> words = line.words();
int noOfWordsInCommand = best.split(" ").length;
List<String> rest = words.subList(noOfWordsInCommand, words.size())
.stream()
.filter(w -> !w.isEmpty())
.collect(Collectors.toList());
CompletionContext context = new CompletionContext(rest, line.wordIndex() - noOfWordsInCommand, line.wordCursor());
Method method = methodTarget.getMethod();
for (int i = 0; i < method.getParameterCount(); i++) {
MethodParameter methodParameter = Utils.createMethodParameter(method, i);
ParameterResolver resolver = findResolver(methodParameter);
resolver.complete(methodParameter, context)
.stream()
.map(completion -> new Candidate(
cpl.emit(completion.value()).toString(),
completion.displayText(),
"Value for parameter " + resolver.describe(methodParameter).toString(),
resolver.describe(methodParameter).help(),
null, null, true)
)
.forEach(candidates::add);
}
}
CompletionContext context = new CompletionContext(sanitizeInput(line.words()), line.wordIndex(), line.wordCursor());
private List<Candidate> commandsStartingWith(String prefix) {
return methodTargets.entrySet().stream()
.filter(e -> e.getKey().startsWith(prefix)) // find commands that start with our buffer prefix
.map(e -> toCandidate(e.getKey(), e.getValue()))
.collect(Collectors.toList());
}
private Candidate toCandidate(String command, MethodTarget methodTarget) {
return new Candidate(command, command, "Available commands", methodTarget.getHelp(), null, null, true);
List<CompletionProposal> proposals = JLineShell.this.complete(context);
proposals.stream()
.map(p -> new Candidate(
cpl.emit(p.value()).toString(),
p.displayText(),
p.category(),
p.description(),
null,
null,
true)
)
.forEach(candidates::add);
}
}
/**
* 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
*/
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;
private static class JLineInput implements Input {
private final ParsedLine parsedLine;
JLineInput(ParsedLine parsedLine) {
this.parsedLine = parsedLine;
}
@Override
public String rawText() {
return parsedLine.line();
}
@Override
public List<String> words() {
return sanitizeInput(parsedLine.words());
}
}
}