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
new file mode 100644
index 00000000..becf0e11
--- /dev/null
+++ b/spring-shell2-core/src/main/java/org/springframework/shell2/AbstractShell.java
@@ -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.
+ *
+ *
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/CompletionContext.java b/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionContext.java
index 1854c899..6a94683b 100644
--- a/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionContext.java
+++ b/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionContext.java
@@ -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;
}
diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionProposal.java b/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionProposal.java
index 14e0a9e7..d1ef5c04 100644
--- a/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionProposal.java
+++ b/spring-shell2-core/src/main/java/org/springframework/shell2/CompletionProposal.java
@@ -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
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
new file mode 100644
index 00000000..6dbc0ed7
--- /dev/null
+++ b/spring-shell2-core/src/main/java/org/springframework/shell2/Input.java
@@ -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 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 words();
+}
diff --git a/spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java b/spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java
index 5649c4d1..9e2398b0 100644
--- a/spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java
+++ b/spring-shell2-core/src/main/java/org/springframework/shell2/JLineShell.java
@@ -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.
- *
- * Discovers {@link MethodTarget}s at startup and hands off execution of commands according
- * to the parsed JLine buffer.
+ * 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 methodTargets = new HashMap<>();
+public class JLineShell extends AbstractShell {
LineReader lineReader;
@Autowired
private Terminal terminal;
- @Autowired
- private List 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 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 words = lineReader.getParsedLine().words();
- if (command != null) {
- int wordsUsedForCommandKey = command.split(" ").length;
- MethodTarget methodTarget = methodTargets.get(command);
- List 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 (e.g. support for
* line continuations, etc.)
*/
- private List sanitizeInput(List words) {
+ static private List sanitizeInput(List 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> 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 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 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 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 (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;
- }
-
CompletingParsedLine cpl = (line instanceof CompletingParsedLine) ? ((CompletingParsedLine) line) : t -> t;
- // Try to complete arguments
- MethodTarget methodTarget = methodTargets.get(best);
- List words = line.words();
- int noOfWordsInCommand = best.split(" ").length;
- List 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 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 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 words() {
+ return sanitizeInput(parsedLine.words());
+ }
}
+
}
+