All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.springframework.shell.Shell Maven / Gradle / Ivy

/*
 * Copyright 2017-2022 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
 *
 *      https://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.UndeclaredThrowableException;
import java.nio.channels.ClosedByInterruptException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.validation.Validator;
import javax.validation.ValidatorFactory;

import org.jline.terminal.Terminal;
import org.jline.utils.Signals;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.shell.command.CommandAlias;
import org.springframework.shell.command.CommandCatalog;
import org.springframework.shell.command.CommandExecution;
import org.springframework.shell.command.CommandOption;
import org.springframework.shell.command.CommandExecution.CommandExecutionException;
import org.springframework.shell.command.CommandExecution.CommandExecutionHandlerMethodArgumentResolvers;
import org.springframework.shell.command.CommandRegistration;
import org.springframework.shell.completion.CompletionResolver;
import org.springframework.shell.context.InteractionMode;
import org.springframework.shell.context.ShellContext;
import org.springframework.shell.exit.ExitCodeMappings;
import org.springframework.util.StringUtils;

/**
 * Main class implementing a shell loop.
 *
 * @author Eric Bottard
 * @author Janne Valkealahti
 */
public class Shell {

	private final static Logger log = LoggerFactory.getLogger(Shell.class);
	private final ResultHandlerService resultHandlerService;

	/**
	 * Marker object returned to signify that there was no input to turn into a command
	 * execution.
	 */
	public static final Object NO_INPUT = new Object();

	private final Terminal terminal;
	private final CommandCatalog commandRegistry;
	protected List completionResolvers = new ArrayList<>();
	private CommandExecutionHandlerMethodArgumentResolvers argumentResolvers;
	private ConversionService conversionService = new DefaultConversionService();
	private final ShellContext shellContext;
	private final ExitCodeMappings exitCodeMappings;

	/**
	 * Marker object to distinguish unresolved arguments from {@code null}, which is a valid
	 * value.
	 */
	protected static final Object UNRESOLVED = new Object();

	private Validator validator = Utils.defaultValidator();

	public Shell(ResultHandlerService resultHandlerService, CommandCatalog commandRegistry, Terminal terminal,
			ShellContext shellContext, ExitCodeMappings exitCodeMappings) {
		this.resultHandlerService = resultHandlerService;
		this.commandRegistry = commandRegistry;
		this.terminal = terminal;
		this.shellContext = shellContext;
		this.exitCodeMappings = exitCodeMappings;
	}

	@Autowired
	public void setCompletionResolvers(List resolvers) {
		this.completionResolvers = new ArrayList<>(resolvers);
		AnnotationAwareOrderComparator.sort(completionResolvers);
	}

	@Autowired
	public void setArgumentResolvers(CommandExecutionHandlerMethodArgumentResolvers argumentResolvers) {
		this.argumentResolvers = argumentResolvers;
	}

	public void setConversionService(ConversionService shellConversionService) {
		this.conversionService = shellConversionService;
	}

	@Autowired(required = false)
	public void setValidatorFactory(ValidatorFactory validatorFactory) {
		this.validator = validatorFactory.getValidator();
	}

	/**
	 * 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 or there is no input.
	 * 

* This method has public visibility so that it can be invoked by actual commands * (e.g. a {@literal script} command). *

*/ public void run(InputProvider inputProvider) throws Exception { Object result = null; while (!(result instanceof ExitRequest)) { // Handles ExitRequest thrown from Quit command Input input; try { input = inputProvider.readInput(); } catch (Exception e) { if (e instanceof ExitRequest) { // Handles ExitRequest thrown from hitting CTRL-C break; } resultHandlerService.handle(e); continue; } if (input == null) { break; } result = evaluate(input); if (result != NO_INPUT && !(result instanceof ExitRequest)) { resultHandlerService.handle(result); } // throw if not in interactive mode so that boot's exit code feature // can contribute exit code. we can't throw when in interactive mode as // that would exit a shell if (this.shellContext != null && this.shellContext.getInteractionMode() != InteractionMode.INTERACTIVE) { if (result instanceof CommandExecution.CommandParserExceptionsException) { throw (CommandExecution.CommandParserExceptionsException) result; } else if (result instanceof Exception) { throw (Exception) result; } } } } /** * Evaluate a single "line" of input from the user by trying to map words to a command and * arguments. * *

* This method does not throw exceptions, it catches them and returns them as a regular * result *

*/ public Object evaluate(Input input) { if (noInput(input)) { return NO_INPUT; } String line = input.words().stream().collect(Collectors.joining(" ")).trim(); String command = findLongestCommand(line); List words = input.words(); log.debug("Evaluate input with line=[{}], command=[{}]", line, command); if (command != null) { Optional commandRegistration = commandRegistry.getRegistrations().values().stream() .filter(r -> { if (r.getCommand().equals(command)) { return true; } for (CommandAlias a : r.getAliases()) { if (a.getCommand().equals(command)) { return true; } } return false; }) .findFirst(); if (commandRegistration.isPresent()) { if (this.exitCodeMappings != null) { List> mappingFunctions = commandRegistration.get().getExitCode() .getMappingFunctions(); this.exitCodeMappings.reset(mappingFunctions); } List wordsForArgs = wordsForArguments(command, words); Thread commandThread = Thread.currentThread(); Object sh = Signals.register("INT", () -> commandThread.interrupt()); try { CommandExecution execution = CommandExecution .of(argumentResolvers != null ? argumentResolvers.getResolvers() : null, validator, terminal, conversionService); return execution.evaluate(commandRegistration.get(), wordsForArgs.toArray(new String[0])); } catch (UndeclaredThrowableException e) { if (e.getCause() instanceof InterruptedException || e.getCause() instanceof ClosedByInterruptException) { Thread.interrupted(); // to reset interrupted flag } return e.getCause(); } catch (CommandExecutionException e) { return e.getCause(); } catch (Exception e) { return e; } finally { Signals.unregister("INT", sh); } } else { return new CommandNotFound(words); } } else { return new CommandNotFound(words); } } /** * Return true if the parsed input ends up being empty (e.g. hitting ENTER on an * empty line or blank space). * *

* Also returns true (i.e. ask to ignore) when input starts with {@literal //}, * which is used for comments. *

*/ private boolean noInput(Input input) { return input.words().isEmpty() || (input.words().size() == 1 && input.words().get(0).trim().isEmpty()) || (input.words().iterator().next().matches("\\s*//.*")); } /** * 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 wordsForArguments(String command, List words) { int wordsUsedForCommandKey = command.split(" ").length; List 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 complete(CompletionContext context) { String prefix = context.upToCursor(); List candidates = new ArrayList<>(); candidates.addAll(commandsStartingWith(prefix)); String best = findLongestCommand(prefix); if (best != null) { context = context.drop(best.split(" ").length); CommandRegistration registration = commandRegistry.getRegistrations().get(best); CompletionContext argsContext = context.commandRegistration(registration); for (CompletionResolver resolver : completionResolvers) { List resolved = resolver.apply(argsContext); candidates.addAll(resolved); } // Try to complete arguments List matchedArgOptions = new ArrayList<>(); if (argsContext.getWords().size() > 0 && argsContext.getWordIndex() > 0 && argsContext.getWords().size() > argsContext.getWordIndex()) { matchedArgOptions.addAll(matchOptions(registration.getOptions(), argsContext.getWords().get(argsContext.getWordIndex() - 1))); } List argProposals = matchedArgOptions.stream() .flatMap(o -> { Function> completion = o.getCompletion(); if (completion != null) { List apply = completion.apply(argsContext.commandOption(o)); return apply.stream(); } return Stream.empty(); }) .collect(Collectors.toList()); candidates.addAll(argProposals); } return candidates; } private List matchOptions(List options, String arg) { List matched = new ArrayList<>(); String trimmed = StringUtils.trimLeadingCharacter(arg, '-'); int count = arg.length() - trimmed.length(); if (count == 1) { if (trimmed.length() == 1) { Character trimmedChar = trimmed.charAt(0); options.stream() .filter(o -> { for (Character sn : o.getShortNames()) { if (trimmedChar.equals(sn)) { return true; } } return false; }) .findFirst() .ifPresent(o -> matched.add(o)); } else if (trimmed.length() > 1) { trimmed.chars().mapToObj(i -> (char)i) .forEach(c -> { options.stream().forEach(o -> { for (Character sn : o.getShortNames()) { if (c.equals(sn)) { matched.add(o); } } }); }); } } else if (count == 2) { options.stream() .filter(o -> { for (String ln : o.getLongNames()) { if (trimmed.equals(ln)) { return true; } } return false; }) .findFirst() .ifPresent(o -> matched.add(o)); } return matched; } private List commandsStartingWith(String prefix) { // Workaround for https://github.com/spring-projects/spring-shell/issues/150 // (sadly, this ties this class to JLine somehow) int lastWordStart = prefix.lastIndexOf(' ') + 1; return commandRegistry.getRegistrations().entrySet().stream() .filter(e -> e.getKey().startsWith(prefix)) .map(e -> { String c = e.getKey(); c = c.substring(lastWordStart); return toCommandProposal(c, e.getValue()); }) .collect(Collectors.toList()); } private CompletionProposal toCommandProposal(String command, CommandRegistration registration) { return new CompletionProposal(command) .dontQuote(true) .category("Available commands") .description(registration.getDescription()); } /** * 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 = commandRegistry.getRegistrations().keySet().stream() .filter(command -> prefix.equals(command) || prefix.startsWith(command + " ")) .reduce("", (c1, c2) -> c1.length() > c2.length() ? c1 : c2); return "".equals(result) ? null : result; } }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy