001/*
002 * Copyright 2012-2018 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.boot.cli.command.shell;
018
019import java.io.IOException;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.List;
025import java.util.Map;
026import java.util.ServiceLoader;
027import java.util.Set;
028
029import jline.console.ConsoleReader;
030import jline.console.completer.CandidateListCompletionHandler;
031import org.fusesource.jansi.AnsiRenderer.Code;
032
033import org.springframework.boot.cli.command.Command;
034import org.springframework.boot.cli.command.CommandFactory;
035import org.springframework.boot.cli.command.CommandRunner;
036import org.springframework.boot.cli.command.core.HelpCommand;
037import org.springframework.boot.cli.command.core.VersionCommand;
038import org.springframework.boot.loader.tools.SignalUtils;
039import org.springframework.util.StringUtils;
040
041/**
042 * A shell for Spring Boot. Drops the user into an event loop (REPL) where command line
043 * completion and history are available without relying on OS shell features.
044 *
045 * @author Jon Brisbin
046 * @author Dave Syer
047 * @author Phillip Webb
048 */
049public class Shell {
050
051        private static final Set<Class<?>> NON_FORKED_COMMANDS;
052
053        static {
054                Set<Class<?>> nonForked = new HashSet<>();
055                nonForked.add(VersionCommand.class);
056                NON_FORKED_COMMANDS = Collections.unmodifiableSet(nonForked);
057        }
058
059        private final ShellCommandRunner commandRunner;
060
061        private final ConsoleReader consoleReader;
062
063        private final EscapeAwareWhiteSpaceArgumentDelimiter argumentDelimiter = new EscapeAwareWhiteSpaceArgumentDelimiter();
064
065        private final ShellPrompts prompts = new ShellPrompts();
066
067        /**
068         * Create a new {@link Shell} instance.
069         * @throws IOException in case of I/O errors
070         */
071        Shell() throws IOException {
072                attachSignalHandler();
073                this.consoleReader = new ConsoleReader();
074                this.commandRunner = createCommandRunner();
075                initializeConsoleReader();
076        }
077
078        private ShellCommandRunner createCommandRunner() {
079                ShellCommandRunner runner = new ShellCommandRunner();
080                runner.addCommand(new HelpCommand(runner));
081                runner.addCommands(getCommands());
082                runner.addAliases("exit", "quit");
083                runner.addAliases("help", "?");
084                runner.addAliases("clear", "cls");
085                return runner;
086        }
087
088        private Iterable<Command> getCommands() {
089                List<Command> commands = new ArrayList<>();
090                ServiceLoader<CommandFactory> factories = ServiceLoader.load(CommandFactory.class,
091                                getClass().getClassLoader());
092                for (CommandFactory factory : factories) {
093                        for (Command command : factory.getCommands()) {
094                                commands.add(convertToForkCommand(command));
095                        }
096                }
097                commands.add(new PromptCommand(this.prompts));
098                commands.add(new ClearCommand(this.consoleReader));
099                commands.add(new ExitCommand());
100                return commands;
101        }
102
103        private Command convertToForkCommand(Command command) {
104                for (Class<?> nonForked : NON_FORKED_COMMANDS) {
105                        if (nonForked.isInstance(command)) {
106                                return command;
107                        }
108                }
109                return new ForkProcessCommand(command);
110        }
111
112        private void initializeConsoleReader() {
113                this.consoleReader.setHistoryEnabled(true);
114                this.consoleReader.setBellEnabled(false);
115                this.consoleReader.setExpandEvents(false);
116                this.consoleReader.addCompleter(new CommandCompleter(this.consoleReader,
117                                this.argumentDelimiter, this.commandRunner));
118                this.consoleReader.setCompletionHandler(new CandidateListCompletionHandler());
119        }
120
121        private void attachSignalHandler() {
122                SignalUtils.attachSignalHandler(this::handleSigInt);
123        }
124
125        /**
126         * Run the shell until the user exists.
127         * @throws Exception on error
128         */
129        public void run() throws Exception {
130                printBanner();
131                try {
132                        runInputLoop();
133                }
134                catch (Exception ex) {
135                        if (!(ex instanceof ShellExitException)) {
136                                throw ex;
137                        }
138                }
139        }
140
141        private void printBanner() {
142                String version = getClass().getPackage().getImplementationVersion();
143                version = (version != null) ? " (v" + version + ")" : "";
144                System.out.println(ansi("Spring Boot", Code.BOLD).append(version, Code.FAINT));
145                System.out.println(ansi("Hit TAB to complete. Type 'help' and hit "
146                                + "RETURN for help, and 'exit' to quit."));
147        }
148
149        private void runInputLoop() throws Exception {
150                String line;
151                while ((line = this.consoleReader.readLine(getPrompt())) != null) {
152                        while (line.endsWith("\\")) {
153                                line = line.substring(0, line.length() - 1);
154                                line += this.consoleReader.readLine("> ");
155                        }
156                        if (StringUtils.hasLength(line)) {
157                                String[] args = this.argumentDelimiter.parseArguments(line);
158                                this.commandRunner.runAndHandleErrors(args);
159                        }
160                }
161        }
162
163        private String getPrompt() {
164                String prompt = this.prompts.getPrompt();
165                return ansi(prompt, Code.FG_BLUE).toString();
166        }
167
168        private AnsiString ansi(String text, Code... codes) {
169                return new AnsiString(this.consoleReader.getTerminal()).append(text, codes);
170        }
171
172        /**
173         * Final handle an interrupt signal (CTRL-C).
174         */
175        protected void handleSigInt() {
176                if (this.commandRunner.handleSigInt()) {
177                        return;
178                }
179                System.out.println(String.format("%nThanks for using Spring Boot"));
180                System.exit(1);
181        }
182
183        /**
184         * Extension of {@link CommandRunner} to deal with {@link RunProcessCommand}s and
185         * aliases.
186         */
187        private class ShellCommandRunner extends CommandRunner {
188
189                private volatile Command lastCommand;
190
191                private final Map<String, String> aliases = new HashMap<>();
192
193                ShellCommandRunner() {
194                        super(null);
195                }
196
197                public void addAliases(String command, String... aliases) {
198                        for (String alias : aliases) {
199                                this.aliases.put(alias, command);
200                        }
201                }
202
203                @Override
204                public Command findCommand(String name) {
205                        if (name.startsWith("!")) {
206                                return new RunProcessCommand(name.substring(1));
207                        }
208                        if (this.aliases.containsKey(name)) {
209                                name = this.aliases.get(name);
210                        }
211                        return super.findCommand(name);
212                }
213
214                @Override
215                protected void beforeRun(Command command) {
216                        this.lastCommand = command;
217                }
218
219                @Override
220                protected void afterRun(Command command) {
221                }
222
223                public boolean handleSigInt() {
224                        Command command = this.lastCommand;
225                        if (command != null && command instanceof RunProcessCommand) {
226                                return ((RunProcessCommand) command).handleSigInt();
227                        }
228                        return false;
229                }
230
231        }
232
233}