From 50ba8de8b436fc3da82c760a037ce8a9d9a5ee92 Mon Sep 17 00:00:00 2001 From: Eric Bottard Date: Tue, 30 May 2017 17:25:05 +0200 Subject: [PATCH] Decouple main REPL from JLine Fixes #66 --- .../springframework/shell2/AbstractShell.java | 234 +++++++++++++++++ .../shell2/CompletionContext.java | 5 +- .../shell2/CompletionProposal.java | 12 +- .../org/springframework/shell2/Input.java | 52 ++++ .../springframework/shell2/JLineShell.java | 239 ++++-------------- 5 files changed, 349 insertions(+), 193 deletions(-) create mode 100644 spring-shell2-core/src/main/java/org/springframework/shell2/AbstractShell.java create mode 100644 spring-shell2-core/src/main/java/org/springframework/shell2/Input.java 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()); + } } + } +