Refactor packages and artifactIds to prepare for official migration to spring-projects repo.

Remove usage of component scan in favor of auto-conf

Fixes #61
This commit is contained in:
Eric Bottard
2017-08-03 18:06:28 +02:00
parent 5fd1f716d9
commit 6497df181d
91 changed files with 488 additions and 264 deletions

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.shell;
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.shell;
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

@@ -0,0 +1,32 @@
/*
* 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.shell;
import org.jline.reader.ParsedLine;
/**
* An extension of {@link ParsedLine} that, being aware of the quoting and escaping rules
* of the {@link org.jline.reader.Parser} that produced it, knows if and how a completion candidate
* should be escaped/quoted.
*
* @author Eric Bottard
*/
@FunctionalInterface
public interface CompletingParsedLine {
public CharSequence emit(CharSequence candidate);
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2016 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.shell;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Represents the buffer context in which completion was triggered.
*
* @author Eric Bottard
*/
public class CompletionContext {
private final List<String> words;
private final int wordIndex;
private final int position;
/**
*
* @param words words in the buffer, excluding words for the command name
* @param wordIndex the index of the word the cursor is in
* @param position the position inside the current word where the cursor is
*/
public CompletionContext(List<String> words, int wordIndex, int position) {
this.words = words;
this.wordIndex = wordIndex;
this.position = position;
}
public List<String> getWords() {
return words;
}
public int getWordIndex() {
return wordIndex;
}
public int getPosition() {
return position;
}
public String upToCursor() {
String start = words.subList(0, wordIndex).stream().collect(Collectors.joining(" "));
if (wordIndex < words.size()) {
if (!start.isEmpty()) {
start += " ";
}
start += currentWord().substring(0, position);
}
return start;
}
/**
* Return the whole word the cursor is in, or {@code null} if the cursor is past the last word.
*/
public String currentWord() {
return wordIndex >= 0 && wordIndex < words.size() ? words.get(wordIndex) : null;
}
public String currentWordUpToCursor() {
String currentWord = currentWord();
return currentWord != null ? currentWord.substring(0, getPosition()) : null;
}
/**
* Return a copy of this context, as if the first {@literal nbWords} were not present
*/
public CompletionContext drop(int nbWords) {
return new CompletionContext(new ArrayList<String>(words.subList(nbWords, words.size())), wordIndex-nbWords, position);
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2016 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.shell;
/**
* Represents a proposal for TAB completion, made not only of the text to append, but also metadata about the proposal.
*
* @author Eric Bottard
*/
public class CompletionProposal {
/**
* The actual value of the proposal.
*/
private String value;
/**
* The text displayed while the proposal is being considered.
*/
private String displayText;
/**
* The description for the proposal.
*/
private String description;
/**
* The category of the proposal, which may be used to group proposals together.
*/
private String category;
/**
* Whether the proposal should bypass escaping and quoting rules. This is useful for command proposals, which can
* appear as true multi-word Strings.
*/
private boolean dontQuote = false;
public CompletionProposal(String value) {
this.value = this.displayText = value;
}
public String value() {
return value;
}
public CompletionProposal value(String value) {
this.value = value;
return this;
}
public String displayText() {
return displayText;
}
public CompletionProposal displayText(String displayText) {
this.displayText = displayText;
return this;
}
public String description() {
return description;
}
public CompletionProposal description(String description) {
this.description = description;
return this;
}
public String category() {
return category;
}
public CompletionProposal category(String category) {
this.category = category;
return this;
}
public CompletionProposal dontQuote(boolean dontQuote) {
this.dontQuote = dontQuote;
return this;
}
public boolean dontQuote() {
return dontQuote;
}
@Override
public String toString() {
return value;
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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.shell;
/**
* This exception, when thrown and caught, will ask the shell to gracefully shutdown.
*
* @author Eric Bottard
*/
public class ExitRequest extends RuntimeException {
private final int code;
public ExitRequest() {
this(0);
}
public ExitRequest(int code) {
this.code = code;
}
/**
* The exit code to be returned when the process exits.
*/
public int status() {
return code;
}
}

View File

@@ -0,0 +1,43 @@
/*
* 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.shell;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Represents the input buffer to the shell.
*
* @author Eric Bottard
*/
public interface Input {
Input EMPTY = () -> "";
/**
* 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")
*/
default List<String> words() {return "".equals(rawText()) ? Collections.emptyList() : Arrays.asList(rawText().split(" "));}
}

View File

@@ -0,0 +1,100 @@
/*
* 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.shell;
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.
*
* @author Eric Bottard
*/
public class MethodTarget {
private final Method method;
private final Object bean;
private final String help;
public MethodTarget(Method method, Object bean, String help) {
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;
}
public Object getBean() {
return bean;
}
public String getHelp() {
return help;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MethodTarget that = (MethodTarget) o;
if (!method.equals(that.method)) return false;
if (!bean.equals(that.bean)) return false;
return help.equals(that.help);
}
@Override
public int hashCode() {
int result = method.hashCode();
result = 31 * result + bean.hashCode();
result = 31 * result + help.hashCode();
return result;
}
@Override
public String toString() {
return method.toString();
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2015-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.shell;
import java.util.Map;
/**
* Strategy interface for discovering commands.
*
* @author Eric Bottard
* @author Camilo Gonzalez
*/
public interface MethodTargetResolver {
/**
* Return a mapping from {@literal <command keyword(s)>} to actual behavior.
*/
public Map<String, MethodTarget> resolve();
}

View File

@@ -0,0 +1,176 @@
/*
* 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.shell;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.MethodParameter;
/**
* Encapsulates information about a shell invokable method parameter, so that it can be documented.
*
* <p>Instances of this class are constructed by {@link ParameterResolver#describe(MethodParameter)}.</p>
*
* @author Eric Bottard
*/
public class ParameterDescription {
/**
* The original method parameter this is describing.
*/
private final MethodParameter parameter;
/**
* A string representation of the type of the parameter.
*/
private final String type;
/**
* A string representation of the parameter, as it should appear in a parameter list.
* If not provided, this is derived from the parameter type.
*/
private String formal;
/**
* A string representation of the default value (if the option is left out entirely) for the parameter, if any.
*/
private Optional<String> defaultValue = Optional.empty();
/**
* A string representation of the default value for this parameter, if it can be used as a mere flag (<em>e.g.</em>
* {@literal --force} without a value, being an equivalent to {@literal --force true}).
* <p>{@literal Optional.empty()} (the default) means that this parameter cannot be used as a flag.</p>
*/
private Optional<String> defaultValueWhenFlag = Optional.empty();
/**
* The list of 'keys' that can be used to specify this parameter, if any.
*/
private List<String> keys = Collections.emptyList();
/**
* Depending on the {@link ParameterResolver}, whether keys are mandatory to identify this parameter.
*/
private boolean mandatoryKey = true;
/**
* A short description of this parameter.
*/
private String help = "";
public ParameterDescription(MethodParameter parameter, String type) {
this.parameter = parameter;
this.type = type;
this.formal = type;
}
public static ParameterDescription outOf(MethodParameter parameter) {
Class<?> type = parameter.getParameterType();
return new ParameterDescription(parameter, Utils.unCamelify(type.getSimpleName()));
}
public ParameterDescription help(String help) {
this.help = help;
return this;
}
public boolean mandatoryKey() {
return mandatoryKey;
}
public List<String> keys() {
return keys;
}
public Optional<String> defaultValue() {
return defaultValue;
}
public ParameterDescription defaultValue(String defaultValue) {
this.defaultValue = Optional.ofNullable(defaultValue);
return this;
}
public ParameterDescription whenFlag(String defaultValue) {
this.defaultValueWhenFlag = Optional.of(defaultValue);
return this;
}
public ParameterDescription keys(List<String> keys) {
this.keys = keys;
return this;
}
public ParameterDescription mandatoryKey(boolean mandatoryKey) {
this.mandatoryKey = mandatoryKey;
return this;
}
public String type() {
return type;
}
public String formal() {
return formal;
}
public String help() {
return help;
}
public Optional<String> defaultValueWhenFlag() {
return defaultValueWhenFlag;
}
public ParameterDescription formal(String formal) {
this.formal = formal;
return this;
}
@Override
public String toString() {
return String.format("%s %s", keys.isEmpty() ? "" : keys().iterator().next(), formal());
}
public MethodParameter parameter() {
return parameter;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ParameterDescription that = (ParameterDescription) o;
return mandatoryKey == that.mandatoryKey &&
Objects.equals(parameter, that.parameter) &&
Objects.equals(type, that.type) &&
Objects.equals(formal, that.formal) &&
Objects.equals(defaultValue, that.defaultValue) &&
Objects.equals(defaultValueWhenFlag, that.defaultValueWhenFlag) &&
Objects.equals(keys, that.keys) &&
Objects.equals(help, that.help);
}
@Override
public int hashCode() {
return Objects.hash(parameter, type, formal, defaultValue, defaultValueWhenFlag, keys, mandatoryKey, help);
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.shell;
/**
* Thrown by a {@link ParameterResolver} when a parameter that should have been set has been left out altogether.
*
* @author Eric Bottard
*/
public class ParameterMissingResolutionException extends RuntimeException {
private final ParameterDescription parameterDescription;
public ParameterMissingResolutionException(ParameterDescription parameterDescription) {
this.parameterDescription = parameterDescription;
}
public ParameterDescription getParameterDescription() {
return parameterDescription;
}
@Override
public String getMessage() {
return String.format("Parameter '%s' should be specified", parameterDescription);
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.shell;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.core.MethodParameter;
/**
* Implementations of this interface are responsible, once the command has been identified, of transforming the textual
* input to an actual parameter object.
*/
public interface ParameterResolver {
/**
* Should return true if this resolver recognizes the given method parameter (<em>e.g.</em> it
* has the correct annotation or the correct type).
*/
boolean supports(MethodParameter parameter);
/**
* Turn the given textual input into an actual object, maybe using some conversion or lookup mechanism.
*/
ValueResult resolve(MethodParameter methodParameter, List<String> words);
/**
* Describe a supported parameter, so that integrated help can be generated.
* <p>Typical implementations will return a one element stream result, but some may return several (for
* example if binding several words to a POJO).</p>
*/
Stream<ParameterDescription> describe(MethodParameter parameter);
/**
* Invoked during TAB completion. If the {@link CompletionContext} can be interpreted as the start
* of a supported {@link MethodParameter} value, one or several proposals should be returned.
*/
List<CompletionProposal> complete(MethodParameter parameter, CompletionContext context);
}

View File

@@ -0,0 +1,32 @@
/*
* 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.shell;
/**
* Implementations know how to deal with results of method invocations, whether normal results or exceptions thrown.
*
* @author Eric Bottard
*/
public interface ResultHandler<T> {
/**
* Deal with some method execution result, whether it was the normal return value, or some kind
* of {@link Throwable}.
*/
void handleResult(T result);
}

View File

@@ -0,0 +1,260 @@
/*
* 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.shell;
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.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.MethodParameter;
import org.springframework.util.ReflectionUtils;
/**
* 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 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 (noInput(input)) {
continue;
}
String line = input.words().stream().collect(Collectors.joining(" ")).trim();
String command = findLongestCommand(line);
List<String> words = input.words();
Object result;
if (command != null) {
MethodTarget methodTarget = methodTargets.get(command);
List<String> wordsForArgs = wordsForArguments(command, words);
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);
}
}
/**
* Return true if the parsed input ends up being empty (<em>e.g.</em> hitting ENTER on an empty line or blank space)
*/
private boolean noInput(Input input) {
return input.words().isEmpty()
|| (input.words().size() == 1 && input.words().get(0).trim().isEmpty());
}
/**
* Returns the list of words to be considered for argument resolving. Drops the first N words used for the
* command, as well as an optional empty word at the end of the list (which may be present if user added spaces
* before submitting the buffer)
*/
private List<String> wordsForArguments(String command, List<String> words) {
int wordsUsedForCommandKey = command.split(" ").length;
List<String> args = words.subList(wordsUsedForCommandKey, words.size());
int last = args.size() - 1;
if (last >= 0 && "".equals(args.get(last))) {
args.remove(last);
}
return args;
}
/**
* 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<>();
candidates.addAll(commandsStartingWith(prefix));
String best = findLongestCommand(prefix);
if (best != null) {
CompletionContext argsContext = context.drop(best.split(" ").length);
// Try to complete arguments
MethodTarget methodTarget = methodTargets.get(best);
Method method = methodTarget.getMethod();
Arrays.stream(method.getParameters())
.map(Utils::createMethodParameter)
.flatMap(mp -> findResolver(mp).complete(mp, argsContext).stream())
.forEach(candidates::add);
}
return candidates;
}
private List<CompletionProposal> commandsStartingWith(String prefix) {
return methodTargets.entrySet().stream()
.filter(e -> e.getKey().startsWith(prefix))
.map(e -> toCommandProposal(e.getKey(), e.getValue()))
.collect(Collectors.toList());
}
private CompletionProposal toCommandProposal(String command, MethodTarget methodTarget) {
return new CompletionProposal(command)
.dontQuote(true)
.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).resolvedValue();
}
return args;
}
private 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
*/
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

@@ -0,0 +1,55 @@
/*
* 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.shell;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell.jline.JLineShell;
import org.springframework.shell.result.ResultHandlerConfig;
/**
* Creates supporting beans for running the Shell
*/
@Configuration
@ComponentScan(basePackageClasses = ResultHandlerConfig.class)
@Import(JLineShell.class)
public class SpringShellAutoConfiguration {
@Bean
@ConditionalOnMissingBean(ConversionService.class)
public ConversionService conversionService() {
return new DefaultConversionService();
}
@Bean
public ApplicationRunner applicationRunner(Shell shell) {
return new ApplicationRunner() {
@Override
public void run(ApplicationArguments args) throws Exception {
shell.run();
}
};
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2016 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.shell;
/**
* Thrown during parameter resolution, when a parameter has been identified, but could not be correctly resolved.
*
* @author Eric Bottard
*/
public class UnfinishedParameterResolutionException extends RuntimeException {
private final ParameterDescription parameterDescription;
private final CharSequence input;
public UnfinishedParameterResolutionException(ParameterDescription parameterDescription, CharSequence input) {
this.parameterDescription = parameterDescription;
this.input = input;
}
public ParameterDescription getParameterDescription() {
return parameterDescription;
}
public CharSequence getInput() {
return input;
}
@Override
public String getMessage() {
return String.format("Error trying to resolve '%s' using [%s]", parameterDescription, input);
}
}

View File

@@ -0,0 +1,89 @@
/*
* 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.shell;
import java.lang.reflect.Constructor;
import java.lang.reflect.Executable;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
/**
* Some text utilities.
*
* @author Eric Bottard
*/
public class Utils {
/**
* Turn CamelCaseText into gnu-style-lowercase.
*/
public static String unCamelify(CharSequence original) {
StringBuilder result = new StringBuilder(original.length());
boolean wasLowercase = false;
for (int i = 0; i < original.length(); i++) {
char ch = original.charAt(i);
if (Character.isUpperCase(ch) && wasLowercase) {
result.append('-');
}
wasLowercase = Character.isLowerCase(ch);
result.append(Character.toLowerCase(ch));
}
return result.toString();
}
/**
* Convert from JDK {@link Parameter} to Spring {@link MethodParameter}.
*/
public static MethodParameter createMethodParameter(Parameter parameter) {
Parameter[] parameters = parameter.getDeclaringExecutable().getParameters();
for (int i = 0; i < parameters.length; i++) {
if (parameters[i].equals(parameter)) {
return createMethodParameter(parameter.getDeclaringExecutable(), i);
}
}
throw new AssertionError("Can't happen");
}
/**
* Return a properly initialized MethodParameter for the given executable and index.
*/
public static MethodParameter createMethodParameter(Executable executable, int i) {
MethodParameter methodParameter;
if (executable instanceof Method) {
methodParameter = new MethodParameter((Method) executable, i);
} else if (executable instanceof Constructor){
methodParameter = new MethodParameter((Constructor) executable, i);
} else {
throw new IllegalArgumentException("Unsupported Executable: " + executable);
}
methodParameter.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
return methodParameter;
}
/**
* Return MethodParameters for each parameter of the given method/constructor.
*/
public static Stream<MethodParameter> createMethodParameters(Executable executable) {
return IntStream.range(0, executable.getParameterCount())
.mapToObj(i -> createMethodParameter(executable, i));
}
}

View File

@@ -0,0 +1,92 @@
/*
* 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.shell;
import java.util.BitSet;
import java.util.List;
import java.util.stream.Collectors;
import javax.naming.spi.ResolveResult;
import org.springframework.core.MethodParameter;
/**
* A {@link ResolveResult} for a successful {@link ParameterResolver#resolve} operation.
*
* @author Camilo Gonzalez
*/
public class ValueResult {
private final MethodParameter methodParameter;
private final Object resolvedValue;
private final BitSet wordsUsed;
private final BitSet wordsUsedForValue;
public ValueResult(MethodParameter methodParameter, Object resolvedValue) {
this(methodParameter, resolvedValue, new BitSet(), new BitSet());
}
public ValueResult(MethodParameter methodParameter, Object resolvedValue, BitSet wordsUsed,
BitSet wordsUsedForValue) {
this.methodParameter = methodParameter;
this.resolvedValue = resolvedValue;
this.wordsUsed = wordsUsed == null ? new BitSet() : wordsUsed;
this.wordsUsedForValue = wordsUsedForValue == null ? new BitSet() : wordsUsedForValue;
}
/**
* The {@link MethodParameter} that was the target of the {@link ParameterResolver#resolve}
* operation.
*/
public MethodParameter methodParameter() {
return methodParameter;
}
/**
* Represents the resolved value for the {@link MethodParameter} associated with this result.
*/
public Object resolvedValue() {
return resolvedValue;
}
/**
* Represents the full set of words used to resolve the {@link MethodParameter}. This includes
* any tags/keys consumed from the input.
*/
public BitSet wordsUsed() {
return wordsUsed;
}
/**
* Represents the full set of words used to resolve the value of this {@link MethodParameter}.
*/
public BitSet wordsUsedForValue() {
return wordsUsedForValue;
}
public List<String> wordsUsed(List<String> words) {
return wordsUsed.stream().mapToObj(index -> words.get(index)).collect(Collectors.toList());
}
public List<String> wordsUsedForValue(List<String> words) {
return wordsUsedForValue.stream().mapToObj(index -> words.get(index)).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,324 @@
/*
* 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.shell.jline;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.function.Predicate;
import org.jline.reader.EOFError;
import org.jline.reader.ParsedLine;
import org.jline.reader.Parser;
import org.springframework.shell.CompletingParsedLine;
/**
* Shameful copy-paste of JLine's {@link org.jline.reader.impl.DefaultParser} which
* creates {@link CompletingParsedLine}.
*
* @author Original JLine author
* @author Eric Bottard
*/
public class ExtendedDefaultParser implements Parser {
private char[] quoteChars = { '\'', '"' };
private char[] escapeChars = { '\\' };
private boolean eofOnUnclosedQuote;
private boolean eofOnEscapedNewLine;
public void setQuoteChars(final char[] chars) {
this.quoteChars = chars;
}
public char[] getQuoteChars() {
return this.quoteChars;
}
public void setEscapeChars(final char[] chars) {
this.escapeChars = chars;
}
public char[] getEscapeChars() {
return this.escapeChars;
}
public void setEofOnUnclosedQuote(boolean eofOnUnclosedQuote) {
this.eofOnUnclosedQuote = eofOnUnclosedQuote;
}
public boolean isEofOnUnclosedQuote() {
return eofOnUnclosedQuote;
}
public void setEofOnEscapedNewLine(boolean eofOnEscapedNewLine) {
this.eofOnEscapedNewLine = eofOnEscapedNewLine;
}
public boolean isEofOnEscapedNewLine() {
return eofOnEscapedNewLine;
}
public ParsedLine parse(final String line, final int cursor, ParseContext context) {
List<String> words = new LinkedList<>();
StringBuilder current = new StringBuilder();
int wordCursor = -1;
int wordIndex = -1;
int quoteStart = -1;
for (int i = 0; (line != null) && (i < line.length()); i++) {
// once we reach the cursor, set the
// position of the selected index
if (i == cursor) {
wordIndex = words.size();
// the position in the current argument is just the
// length of the current argument
wordCursor = current.length();
}
if (quoteStart < 0 && isQuoteChar(line, i)) {
// Start a quote block
quoteStart = i;
}
else if (quoteStart >= 0) {
// In a quote block
if (line.charAt(quoteStart) == line.charAt(i) && !isEscaped(line, i)) {
// End the block; arg could be empty, but that's fine
words.add(current.toString());
current.setLength(0);
quoteStart = -1;
}
else if (!isEscapeChar(line, i)) {
// Take the next character
current.append(line.charAt(i));
}
}
else {
// Not in a quote block
if (isDelimiter(line, i)) {
if (current.length() > 0) {
words.add(current.toString());
current.setLength(0); // reset the arg
}
}
else if (!isEscapeChar(line, i)) {
current.append(line.charAt(i));
}
}
}
if (current.length() > 0 || cursor == line.length()) {
words.add(current.toString());
}
if (cursor == line.length()) {
wordIndex = words.size() - 1;
wordCursor = words.get(words.size() - 1).length();
}
if (eofOnEscapedNewLine && isEscapeChar(line, line.length() - 1)) {
throw new EOFError(-1, -1, "Escaped new line", "newline");
}
if (eofOnUnclosedQuote && quoteStart >= 0 && context != ParseContext.COMPLETE) {
throw new EOFError(-1, -1, "Missing closing quote", line.charAt(quoteStart) == '\'' ? "quote" : "dquote");
}
String openingQuote = quoteStart >= 0 ? line.substring(quoteStart, quoteStart + 1) : null;
return new ExtendedArgumentList(line, words, wordIndex, wordCursor, cursor, openingQuote);
}
/**
* Returns true if the specified character is a whitespace parameter. Check to ensure
* that the character is not escaped by any of {@link #getQuoteChars}, and is not
* escaped by ant of the {@link #getEscapeChars}, and returns true from
* {@link #isDelimiterChar}.
*
* @param buffer The complete command buffer
* @param pos The index of the character in the buffer
* @return True if the character should be a delimiter
*/
public boolean isDelimiter(final CharSequence buffer, final int pos) {
return !isQuoted(buffer, pos) && !isEscaped(buffer, pos) && isDelimiterChar(buffer, pos);
}
public boolean isQuoted(final CharSequence buffer, final int pos) {
return false;
}
public boolean isQuoteChar(final CharSequence buffer, final int pos) {
if (pos < 0) {
return false;
}
for (int i = 0; (quoteChars != null) && (i < quoteChars.length); i++) {
if (buffer.charAt(pos) == quoteChars[i]) {
return !isEscaped(buffer, pos);
}
}
return false;
}
/**
* Check if this character is a valid escape char (i.e. one that has not been escaped)
*/
public boolean isEscapeChar(final CharSequence buffer, final int pos) {
if (pos < 0) {
return false;
}
for (int i = 0; (escapeChars != null) && (i < escapeChars.length); i++) {
if (buffer.charAt(pos) == escapeChars[i]) {
return !isEscaped(buffer, pos); // escape escape
}
}
return false;
}
/**
* Check if a character is escaped (i.e. if the previous character is an escape)
*
* @param buffer the buffer to check in
* @param pos the position of the character to check
* @return true if the character at the specified position in the given buffer is an
* escape character and the character immediately preceding it is not an escape
* character.
*/
public boolean isEscaped(final CharSequence buffer, final int pos) {
if (pos <= 0) {
return false;
}
return isEscapeChar(buffer, pos - 1);
}
/**
* Returns true if the character at the specified position if a delimiter. This method
* will only be called if the character is not enclosed in any of the
* {@link #getQuoteChars}, and is not escaped by ant of the {@link #getEscapeChars}.
* To perform escaping manually, override {@link #isDelimiter} instead.
*/
public boolean isDelimiterChar(CharSequence buffer, int pos) {
return Character.isWhitespace(buffer.charAt(pos));
}
/**
* The result of a delimited buffer.
*
* @author <a href="mailto:mwp1@cornell.edu">Marc Prud'hommeaux</a>
*/
public class ExtendedArgumentList implements ParsedLine, CompletingParsedLine {
private final String line;
private final List<String> words;
private final int wordIndex;
private final int wordCursor;
private final int cursor;
private final String openingQuote;
public ExtendedArgumentList(final String line, final List<String> words, final int wordIndex,
final int wordCursor, final int cursor, final String openingQuote) {
this.line = line;
this.words = Collections.unmodifiableList(Objects.requireNonNull(words));
this.wordIndex = wordIndex;
this.wordCursor = wordCursor;
this.cursor = cursor;
this.openingQuote = openingQuote;
}
public int wordIndex() {
return this.wordIndex;
}
public String word() {
// TODO: word() should always be contained in words()
if ((wordIndex < 0) || (wordIndex >= words.size())) {
return "";
}
return words.get(wordIndex);
}
public int wordCursor() {
return this.wordCursor;
}
public List<String> words() {
return this.words;
}
public int cursor() {
return this.cursor;
}
public String line() {
return line;
}
@Override
public CharSequence emit(CharSequence candidate) {
StringBuilder sb = new StringBuilder(candidate);
Predicate<Integer> needToBeEscaped;
// Completion is protected by an opening quote:
// Delimiters (spaces) don't need to be escaped, nor do other quotes, but everything else does.
// Also, close the quote at the end
if (openingQuote != null) {
needToBeEscaped = i -> isRawEscapeChar(sb.charAt(i)) || String.valueOf(sb.charAt(i)).equals(openingQuote);
} // No quote protection, need to escape everything: delimiter chars (spaces), quote chars
// and escapes themselves
else {
needToBeEscaped = i -> isDelimiterChar(sb, i) || isRawEscapeChar(sb.charAt(i)) || isRawQuoteChar(sb.charAt(i));
}
for (int i = 0; i < sb.length(); i++) {
if (needToBeEscaped.test(i)) {
sb.insert(i++, escapeChars[0]);
}
}
if (openingQuote != null) {
sb.append(openingQuote);
}
return sb;
}
}
private boolean isRawEscapeChar(char key) {
for (char e : escapeChars) {
if (e == key) {
return true;
}
}
return false;
}
private boolean isRawQuoteChar(char key) {
for (char e : quoteChars) {
if (e == key) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,218 @@
/*
* 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.shell.jline;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import org.jline.reader.Candidate;
import org.jline.reader.Completer;
import org.jline.reader.Highlighter;
import org.jline.reader.LineReader;
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.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.shell.CompletingParsedLine;
import org.springframework.shell.CompletionContext;
import org.springframework.shell.CompletionProposal;
import org.springframework.shell.ExitRequest;
import org.springframework.shell.Input;
import org.springframework.shell.ResultHandler;
import org.springframework.shell.Shell;
/**
* Shell implementation using JLine to capture input and trigger completions.
*
* @author Eric Bottard
* @author Florent Biville
*/
@Configuration
public class JLineShell {
@Autowired
@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 lateInit() {
completer().setShell(shell());
}
@Bean
public LineReader lineReader() {
ExtendedDefaultParser parser = new ExtendedDefaultParser();
parser.setEofOnUnclosedQuote(true);
parser.setEofOnEscapedNewLine(true);
LineReaderBuilder lineReaderBuilder = LineReaderBuilder.builder()
.terminal(terminal())
.appName("Foo")
.completer(completer())
.highlighter(new Highlighter() {
@Override
public AttributedString highlight(LineReader reader, String buffer) {
int l = 0;
String best = null;
for (String command : shell().listCommands().keySet()) {
if (buffer.startsWith(command) && command.length() > l) {
l = command.length();
best = command;
}
}
if (best != null) {
return new AttributedStringBuilder(buffer.length()).append(best, AttributedStyle.BOLD).append(buffer.substring(l)).toAttributedString();
}
else {
return new AttributedString(buffer, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED));
}
}
})
.parser(parser);
return lineReaderBuilder.build();
}
/**
* Sanitize the buffer input given the customizations applied to the JLine parser (<em>e.g.</em> support for
* line continuations, <em>etc.</em>)
*/
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
.collect(Collectors.toList());
return words;
}
/**
* A bridge between JLine's {@link Completer} contract and our own.
* @author Eric Bottard
*/
public static class CompleterAdapter implements Completer {
private Shell shell;
@Override
public void complete(LineReader reader, ParsedLine line, List<Candidate> candidates) {
CompletingParsedLine cpl = (line instanceof CompletingParsedLine) ? ((CompletingParsedLine) line) : t -> t;
CompletionContext context = new CompletionContext(sanitizeInput(line.words()), line.wordIndex(), line.wordCursor());
List<CompletionProposal> proposals = shell.complete(context);
proposals.stream()
.map(p -> new Candidate(
p.dontQuote() ? p.value() : cpl.emit(p.value()).toString(),
p.displayText(),
p.category(),
p.description(),
null,
null,
true)
)
.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 {
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());
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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.shell.result;
import org.jline.utils.AttributedCharSequence;
import org.springframework.shell.ResultHandler;
import org.springframework.stereotype.Component;
/**
* A {@link ResultHandler} that knows how to render JLine's {@link AttributedCharSequence}.
*
* @author Eric Bottard
*/
@Component
public class AttributedCharSequenceResultHandler extends TerminalAwareResultHandler implements ResultHandler<AttributedCharSequence> {
@Override
public void handleResult(AttributedCharSequence result) {
terminal.writer().println(result.toAnsi(terminal));
}
}

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.shell.result;
import org.jline.utils.AttributedString;
import org.jline.utils.AttributedStyle;
import org.springframework.shell.CommandNotFound;
import org.springframework.shell.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 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.shell.result;
import org.springframework.shell.ResultHandler;
import org.springframework.stereotype.Component;
/**
* A simple {@link ResultHandler} that deals with Objects (hence comes as a last resort)
* and prints the {@link Object#toString()} value of results to standard out.
*
* @author Eric Bottard
*/
@Component
public class DefaultResultHandler implements ResultHandler<Object> {
@Override
public void handleResult(Object result) {
System.out.println(String.valueOf(result));
}
}

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.shell.result;
import java.io.Closeable;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.shell.ExitRequest;
import org.springframework.shell.ResultHandler;
import org.springframework.stereotype.Component;
/**
* Intercepts {@link org.springframework.shell.ExitRequest} exceptions and gracefully exits the running process.
*
* @author Eric Bottard
*/
@Component
public class ExitRequestResultHandler implements ResultHandler<ExitRequest> {
@Autowired
private ApplicationContext applicationContext;
@Override
public void handleResult(ExitRequest result) {
if (applicationContext instanceof Closeable) {
try {
((Closeable) applicationContext).close();
}
catch (IOException e) {
// ignore
}
System.exit(result.status());
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.shell.result;
import org.springframework.shell.ResultHandler;
import org.springframework.stereotype.Component;
/**
* A {@link ResultHandler} that deals with {@link Iterable}s and delegates to
* {@link TypeHierarchyResultHandler} for each element in turn.
*
* @author Eric Bottard
*/
@Component
public class IterableResultHandler implements ResultHandler<Iterable> {
private ResultHandler delegate;
// Setter injection to avoid circular dependency at creation time
void setDelegate(ResultHandler delegate) {
this.delegate = delegate;
}
@Override
@SuppressWarnings("unchecked")
public void handleResult(Iterable result) {
for (Object o : result) {
delegate.handleResult(o);
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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.shell.result;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.shell.ResultHandler;
/**
* Used for explicit configuration of {@link org.springframework.shell.ResultHandler}s.
*
* @author Eric Bottard
*/
@Configuration
public class ResultHandlerConfig {
@Bean
@Qualifier("main")
public ResultHandler<?> mainResultHandler() {
return new TypeHierarchyResultHandler();
}
@Bean
public IterableResultHandler iterableResultHandler() {
return new IterableResultHandler();
}
@PostConstruct
public void wireIterableResultHandler() {
iterableResultHandler().setDelegate(mainResultHandler());
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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.shell.result;
import org.jline.terminal.Terminal;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
/**
* Base class for ResultHandlers that rely on JLine's {@link Terminal}.
*
* @author Eric Bottard
*/
public abstract class TerminalAwareResultHandler {
protected Terminal terminal;
@Autowired @Lazy
public void setTerminal(Terminal terminal) {
this.terminal = terminal;
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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.shell.result;
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.context.annotation.Lazy;
import org.springframework.shell.CommandRegistry;
import org.springframework.shell.ResultHandler;
import org.springframework.stereotype.Component;
/**
* A {@link ResultHandler} that prints thrown exceptions messages in red.
*
* @author Eric Bottard
*/
@Component
public class ThrowableResultHandler extends TerminalAwareResultHandler implements ResultHandler<Throwable> {
/**
* The name of the command that may be used to print details about the last error.
*/
public static final String DETAILS_COMMAND_NAME = "stacktrace";
private Throwable lastError;
@Autowired @Lazy
private CommandRegistry commandRegistry;
@Override
public void handleResult(Throwable result) {
lastError = result;
terminal.writer().println(new AttributedString(result.toString(),
AttributedStyle.DEFAULT.foreground(AttributedStyle.RED)).toAnsi());
if (commandRegistry.listCommands().containsKey(DETAILS_COMMAND_NAME)) {
terminal.writer().println(
new AttributedStringBuilder()
.append("Details of the error have been omitted. You can use the ", AttributedStyle.DEFAULT.foreground(AttributedStyle.RED))
.append(DETAILS_COMMAND_NAME, AttributedStyle.DEFAULT.foreground(AttributedStyle.RED).bold())
.append(" command to print the full stacktrace.", AttributedStyle.DEFAULT.foreground(AttributedStyle.RED))
.toAnsi()
);
}
}
/**
* Return the last error that was dealt with by this result handler.
*/
public Throwable getLastError() {
return lastError;
}
}

View File

@@ -0,0 +1,83 @@
/*
* 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.shell.result;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.shell.ResultHandler;
/**
* A delegating {@link ResultHandler} that dispatches handling based on the type of the result.
* <p>
* If no direct match is found, the type hierarchy of the result is considered, including implemented interfaces.
* Auto-populates the handler map based on Generics type declaration of each discovered {@link ResultHandler} in the
* ApplicationContext.
* </p>
*
* @author Eric Bottard
*/
public class TypeHierarchyResultHandler implements ResultHandler<Object> {
private Map<Class<?>, ResultHandler<?>> resultHandlers = new HashMap<>();
@SuppressWarnings("unchecked")
public void handleResult(Object result) {
if (result == null) { // void methods
return;
}
Class<?> clazz = result.getClass();
ResultHandler handler = getResultHandler(clazz);
handler.handleResult(result);
}
private ResultHandler getResultHandler(Class<?> clazz) {
ResultHandler handler = resultHandlers.get(clazz);
if (handler != null) {
return handler;
}
else {
for (Class type : clazz.getInterfaces()) {
handler = getResultHandler(type);
if (handler != null) {
return handler;
}
}
return clazz.getSuperclass() != null ? getResultHandler(clazz.getSuperclass()) : null;
}
}
@Autowired
public void setResultHandlers(Set<ResultHandler<?>> resultHandlers) {
for (ResultHandler<?> resultHandler : resultHandlers) {
Type type = ((ParameterizedType) resultHandler.getClass().getGenericInterfaces()[0]).getActualTypeArguments()[0];
registerHandler((Class<?>) type, resultHandler);
}
}
private void registerHandler(Class<?> type, ResultHandler<?> resultHandler) {
ResultHandler<?> previous = this.resultHandlers.put(type, resultHandler);
if (previous != null) {
throw new IllegalArgumentException(String.format("Multiple ResultHandlers configured for %s: both %s and %s", type, previous, resultHandler));
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* 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.
*/
/**
* Contains strategies for dealing with results of commands.
*
* @author Eric Bottard
*/
package org.springframework.shell.result;

View File

@@ -0,0 +1,3 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.shell.SpringShellAutoConfiguration,\
org.springframework.shell.jline.JLineShell

View File

@@ -0,0 +1,5 @@
<configuration>
<!-- Disable most stdout logging by default -->
<root level="ERROR">
</root>
</configuration>

View File

@@ -0,0 +1,164 @@
/*
* 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.shell;
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;
private ValueResult valueResult;
@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 ?");
valueResult = new ValueResult(null, "test");
when(parameterResolver.resolve(any(), any())).thenReturn(valueResult);
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 ?");
valueResult = new ValueResult(null, "test");
when(parameterResolver.resolve(any(), any())).thenReturn(valueResult);
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

@@ -0,0 +1,37 @@
/*
* 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.shell;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.Test;
/**
* Tests for {@link Utils}.
*
* @author Eric Bottard
*/
public class UtilsTest {
@Test
public void testUnCamelify() throws Exception {
assertThat(Utils.unCamelify("HelloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorld")).isEqualTo("hello-world");
assertThat(Utils.unCamelify("helloWorldHowAreYou")).isEqualTo("hello-world-how-are-you");
assertThat(Utils.unCamelify("URL")).isEqualTo("url");
}
}