/*
* Copyright 2011-2012 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.core;
import static org.fusesource.jansi.Ansi.ansi;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.nio.charset.Charset;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Handler;
import java.util.logging.Level;
import java.util.logging.Logger;
import jline.WindowsTerminal;
import jline.console.ConsoleReader;
import jline.console.history.History;
import jline.console.history.MemoryHistory;
import org.apache.commons.io.input.ReversedLinesFileReader;
import org.fusesource.jansi.Ansi;
import org.fusesource.jansi.Ansi.Attribute;
import org.fusesource.jansi.Ansi.Color;
import org.fusesource.jansi.Ansi.Erase;
import org.fusesource.jansi.AnsiConsole;
import org.springframework.shell.event.ShellStatus;
import org.springframework.shell.event.ShellStatus.Status;
import org.springframework.shell.event.ShellStatusListener;
import org.springframework.shell.support.util.IOUtils;
import org.springframework.shell.support.util.OsUtils;
import org.springframework.shell.support.util.VersionUtils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* Uses the feature-rich <a href="http://sourceforge.net/projects/jline/">JLine</a> library to provide an interactive
* shell.
*
* <p>
* Due to Windows' lack of color ANSI services out-of-the-box, this implementation automatically detects the classpath
* presence of <a href="http://jansi.fusesource.org/">Jansi</a> and uses it if present. This library is not necessary
* for *nix machines, which support colour ANSI without any special effort. This implementation has been written to use
* reflection in order to avoid hard dependencies on Jansi.
*
* @author Ben Alex
* @author Jarred Li
* @author Glenn Renfro
* @since 1.0
*/
public abstract class JLineShell extends AbstractShell implements Shell, Runnable {
// Constants
private static final String ANSI_CONSOLE_CLASSNAME = "org.fusesource.jansi.AnsiConsole";
private static final boolean JANSI_AVAILABLE = ClassUtils.isPresent(ANSI_CONSOLE_CLASSNAME,
JLineShell.class.getClassLoader());
private static final char ESCAPE = 27;
private static final String BEL = "\007";
// Fields
protected ConsoleReader reader;
private boolean developmentMode = false;
private FileWriter fileLog;
private final DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
protected ShellStatusListener statusListener; // ROO-836
/** key: slot name, value: flashInfo instance */
private final Map<String, FlashInfo> flashInfoMap = new HashMap<String, FlashInfo>();
/** key: row number, value: eraseLineFromPosition */
private final Map<Integer, Integer> rowErasureMap = new HashMap<Integer, Integer>();
private boolean shutdownHookFired = false; // ROO-1599
private int historySize;
public void run() {
reader = createConsoleReader();
setPromptPath(null);
JLineLogHandler handler = new JLineLogHandler(reader, this);
JLineLogHandler.prohibitRedraw(); // Affects this thread only
Logger mainLogger = Logger.getLogger("");
removeHandlers(mainLogger);
mainLogger.addHandler(handler);
reader.addCompleter(new ParserCompleter(getParser()));
reader.setBellEnabled(true);
if (Boolean.getBoolean("jline.nobell")) {
reader.setBellEnabled(false);
}
// reader.setDebug(new PrintWriter(new FileWriter("writer.debug", true)));
openFileLogIfPossible();
History history = this.reader.getHistory();
if (history instanceof MemoryHistory) {
((MemoryHistory) history).setMaxSize(getHistorySize());
}
// Try to build previous command history from the project's log
String[] filteredLogEntries = filterLogEntry();
for (String logEntry : filteredLogEntries) {
reader.getHistory().add(logEntry);
}
flashMessageRenderer();
flash(Level.FINE, this.getProductName() + " " + this.getVersion(), Shell.WINDOW_TITLE_SLOT);
printBannerAndWelcome();
String startupNotifications = getStartupNotifications();
if (StringUtils.hasText(startupNotifications)) {
logger.info(startupNotifications);
}
setShellStatus(Status.STARTED);
try {
// Monitor CTRL+C initiated shutdowns (ROO-1599)
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
public void run() {
shutdownHookFired = true;
}
}, getProductName() + " JLine Shutdown Hook"));
}
catch (Throwable t) {
}
// Handle any "execute-then-quit" operation
String rooArgs = System.getProperty("roo.args");
if (rooArgs != null && !"".equals(rooArgs)) {
setShellStatus(Status.USER_INPUT);
boolean success = executeCommand(rooArgs).isSuccess();
if (exitShellRequest == null) {
// The command itself did not specify an exit shell code, so we'll fall back to something sensible here
executeCommand("quit"); // ROO-839
exitShellRequest = success ? ExitShellRequest.NORMAL_EXIT : ExitShellRequest.FATAL_EXIT;
}
setShellStatus(Status.SHUTTING_DOWN);
}
else {
// Normal RPEL processing
promptLoop();
}
}
/**
* read history commands from history log. the history size if determined by --histsize options.
*
* @return history commands
*/
private String[] filterLogEntry() {
ArrayList<String> entries = new ArrayList<String>();
ReversedLinesFileReader reversedReader = null;
try {
reversedReader = new ReversedLinesFileReader(new File(getHistoryFileName()), 4096, Charset.forName("UTF-8"));
int size = 0;
String line = null;
while ((line = reversedReader.readLine()) != null) {
if (!line.startsWith("//")) {
size++;
if (size > historySize) {
break;
}
else {
entries.add(line);
}
}
}
}
catch (IOException e) {
logger.warning("read history file failed. Reason:" + e.getMessage());
}
finally {
closeReversedReader(reversedReader);
}
Collections.reverse(entries);
return entries.toArray(new String[0]);
}
private void closeReversedReader(ReversedLinesFileReader reversedReader) {
if (reversedReader != null) {
try {
reversedReader.close();
}
catch (IOException ex) {
logger.warning("Cloud not close ReversedLinesFileReader: " + ex);
}
}
}
/**
* Creates new jline ConsoleReader. On Windows if jansi is available, uses createAnsiWindowsReader(). Otherwise,
* always creates a default ConsoleReader. Sub-classes of this class can plug in their version of ConsoleReader by
* overriding this method, if required.
*
* @return a jline ConsoleReader instance
*/
protected ConsoleReader createConsoleReader() {
ConsoleReader consoleReader = null;
try {
if (isJansiAvailable()) {
try {
consoleReader = createAnsiWindowsReader();
}
catch (Exception e) {
// Try again using default ConsoleReader constructor
logger.warning("Can't initialize jansi AnsiConsole, falling back to default: " + e);
}
}
if (consoleReader == null) {
consoleReader = new ConsoleReader();
}
}
catch (IOException ioe) {
throw new IllegalStateException("Cannot start console class", ioe);
}
consoleReader.setExpandEvents(false);
return consoleReader;
}
private boolean isJansiAvailable() {
return JANSI_AVAILABLE && OsUtils.isWindows() && System.getProperty("jline.terminal") == null;
}
public void printBannerAndWelcome() {
}
public String getStartupNotifications() {
return null;
}
private void removeHandlers(final Logger l) {
Handler[] handlers = l.getHandlers();
if (handlers != null && handlers.length > 0) {
for (Handler h : handlers) {
l.removeHandler(h);
}
}
}
@Override
public void setPromptPath(final String path) {
setPromptPath(path, false);
}
@Override
public void setPromptPath(final String path, final boolean overrideStyle) {
if (reader.getTerminal().isAnsiSupported()) {
// ANSIBuffer ansi = JLineLogHandler.getANSIBuffer();
Ansi ansi = ansi();
if (path == null || "".equals(path)) {
shellPrompt = ansi.fg(Color.YELLOW).a(getPromptText()).reset().toString();
}
else {
if (overrideStyle) {
ansi.a(path);
}
else {
ansi.fg(Color.CYAN).a(path).reset();
}
shellPrompt = ansi.fg(Color.YELLOW).a(" " + getPromptText()).toString();
}
}
else {
// The superclass will do for this non-ANSI terminal
super.setPromptPath(path);
}
// The shellPrompt is now correct; let's ensure it now gets used
reader.setPrompt(AbstractShell.shellPrompt);
}
protected ConsoleReader createAnsiWindowsReader() throws Exception {
// Get decorated OutputStream that parses ANSI-codes
final PrintStream ansiOut = (PrintStream) ClassUtils
.forName(ANSI_CONSOLE_CLASSNAME, JLineShell.class.getClassLoader()).getMethod("out").invoke(null);
WindowsTerminal ansiTerminal = new WindowsTerminal() {
@Override
public synchronized boolean isAnsiSupported() {
return true;
}
};
ansiTerminal.init();
// Make sure to reset the original shell's colors on shutdown by closing the stream
statusListener = new ShellStatusListener() {
public void onShellStatusChange(final ShellStatus oldStatus, final ShellStatus newStatus) {
if (newStatus.getStatus().equals(Status.SHUTTING_DOWN)) {
ansiOut.close();
}
}
};
addShellStatusListener(statusListener);
// return new ConsoleReader(new FileInputStream(FileDescriptor.in), new PrintWriter(new OutputStreamWriter(
// ansiOut,
// // Default to Cp850 encoding for Windows console output (ROO-439)
// System.getProperty("jline.WindowsTerminal.output.encoding", "Cp850"))), null, ansiTerminal);
OutputStream out = AnsiConsole.wrapOutputStream(ansiOut);
return new ConsoleReader(new FileInputStream(FileDescriptor.in), out, ansiTerminal);
}
private void flashMessageRenderer() {
if (!reader.getTerminal().isAnsiSupported()) {
return;
}
// Setup a thread to ensure flash messages are displayed and cleared correctly
Thread t = new Thread(new Runnable() {
public void run() {
while (!shellStatus.getStatus().equals(Status.SHUTTING_DOWN) && !shutdownHookFired) {
synchronized (flashInfoMap) {
long now = System.currentTimeMillis();
Set<String> toRemove = new HashSet<String>();
for (String slot : flashInfoMap.keySet()) {
FlashInfo flashInfo = flashInfoMap.get(slot);
if (flashInfo.flashMessageUntil < now) {
// Message has expired, so clear it
toRemove.add(slot);
doAnsiFlash(flashInfo.rowNumber, Level.ALL, "");
}
else {
// The expiration time for this message has not been reached, so preserve it
doAnsiFlash(flashInfo.rowNumber, flashInfo.flashLevel, flashInfo.flashMessage);
}
}
for (String slot : toRemove) {
flashInfoMap.remove(slot);
}
}
try {
Thread.sleep(200);
}
catch (InterruptedException ignore) {
}
}
}
}, getProductName() + " JLine Flash Message Manager");
t.start();
}
@Override
public void flash(final Level level, final String message, final String slot) {
Assert.notNull(level, "Level is required for a flash message");
Assert.notNull(message, "Message is required for a flash message");
Assert.hasText(slot, "Slot name must be specified for a flash message");
if (Shell.WINDOW_TITLE_SLOT.equals(slot)) {
if (reader != null && reader.getTerminal().isAnsiSupported()) {
// We can probably update the window title, as requested
if (!StringUtils.hasText(message)) {
System.out.println("No text");
}
Ansi ansi = ansi();
ansi.a(ESCAPE + "]0;").a(message).a(BEL);
try {
reader.print(ansi.toString());
reader.flush();
}
catch (IOException ignored) {
}
}
return;
}
if ((reader != null && !reader.getTerminal().isAnsiSupported())) {
super.flash(level, message, slot);
return;
}
synchronized (flashInfoMap) {
FlashInfo flashInfo = flashInfoMap.get(slot);
if ("".equals(message)) {
// Request to clear the message, but give the user some time to read it first
if (flashInfo == null) {
// We didn't have a record of displaying it in the first place, so just quit
return;
}
flashInfo.flashMessageUntil = System.currentTimeMillis() + 1500;
}
else {
// Display this message displayed until further notice
if (flashInfo == null) {
// Find a row for this new slot; we basically take the first line number we discover
flashInfo = new FlashInfo();
flashInfo.rowNumber = Integer.MAX_VALUE;
outer: for (int i = 1; i < Integer.MAX_VALUE; i++) {
for (FlashInfo existingFlashInfo : flashInfoMap.values()) {
if (existingFlashInfo.rowNumber == i) {
// Veto, let's try the new candidate row number
continue outer;
}
}
// If we got to here, nobody owns this row number, so use it
flashInfo.rowNumber = i;
break outer;
}
// Store it
flashInfoMap.put(slot, flashInfo);
}
// Populate the instance with the latest data
flashInfo.flashMessageUntil = Long.MAX_VALUE;
flashInfo.flashLevel = level;
flashInfo.flashMessage = message;
// Display right now
doAnsiFlash(flashInfo.rowNumber, flashInfo.flashLevel, flashInfo.flashMessage);
}
}
}
// Externally synchronized via the two calling methods having a mutex on flashInfoMap
private void doAnsiFlash(final int row, final Level level, final String message) {
Ansi ansi = ansi();
if (isAppleTerminal()) {
ansi.a(ESCAPE + "7");
}
else {
ansi.saveCursorPosition();
}
// Figure out the longest line we're presently displaying (or were) and erase the line from that position
int mostFurtherLeftColNumber = Integer.MAX_VALUE;
for (Integer candidate : rowErasureMap.values()) {
if (candidate < mostFurtherLeftColNumber) {
mostFurtherLeftColNumber = candidate;
}
}
if (mostFurtherLeftColNumber == Integer.MAX_VALUE) {
// There is nothing to erase
}
else {
ansi.cursor(row, mostFurtherLeftColNumber);
ansi.eraseLine(Erase.FORWARD); // Clear what was present on the line
}
if (("".equals(message))) {
// They want the line blank; we've already achieved this if needed via the erasing above
// Just need to record we no longer care about this line the next time doAnsiFlash is invoked
rowErasureMap.remove(row);
}
else {
if (shutdownHookFired) {
return; // ROO-1599
}
// They want some message displayed
int startFrom = reader.getTerminal().getWidth() - message.length() + 1;
if (startFrom < 1) {
startFrom = 1;
}
ansi.cursor(row, startFrom);
ansi.a(Attribute.NEGATIVE_ON).a(message).a(Attribute.NEGATIVE_OFF);
// Record we want to erase from this positioning next time (so we clean up after ourselves)
rowErasureMap.put(row, startFrom);
}
if (isAppleTerminal()) {
ansi.a(ESCAPE + "8");
}
else {
ansi.reset();
}
try {
reader.print(ansi.toString());
reader.flush();
}
catch (IOException ignored) {
}
}
/**
* Awaits user input, executes the command and displays the prompt to the user.
*/
public void promptLoop() {
setShellStatus(Status.USER_INPUT);
String line;
String prompt = getPromptText();
try {
while (exitShellRequest == null && (reader != null && ((line = reader.readLine()) != null))) {
JLineLogHandler.resetMessageTracking();
setShellStatus(Status.USER_INPUT);
if (!StringUtils.hasText(line)) {
//generate prompt if empty line, the prompt maybe showing the time or something else that updates
//independent of the lack of a command to execute.
prompt = generatePromptUpdate(prompt);
continue;
}
executeCommand(line);
//update the prompt after the command has been executed in case an application event listener in the
//command changes state in the prompt provider.
prompt = generatePromptUpdate(prompt);
}
}
catch (IOException ioe) {
throw new IllegalStateException("Shell line reading failure", ioe);
}
setShellStatus(Status.SHUTTING_DOWN);
}
/**
* Retrieves the latest prompt and if the latest prompt is different than the existing prompt,
* the shellPrompt is updated.
* @param existingPrompt The prompt that is recognized as the current prompt.
* @return The prompt that the shellPrompt displays.
*/
public String generatePromptUpdate(String existingPrompt) {
String newPrompt = getPromptText();
if (!ObjectUtils.nullSafeEquals(existingPrompt, newPrompt)) {
setPromptPath(null);
}
return newPrompt;
}
public void setDevelopmentMode(final boolean developmentMode) {
JLineLogHandler.setIncludeThreadName(developmentMode);
JLineLogHandler.setSuppressDuplicateMessages(!developmentMode); // We want to see duplicate messages during
// development time (ROO-1873)
this.developmentMode = developmentMode;
}
public boolean isDevelopmentMode() {
return this.developmentMode;
}
private void openFileLogIfPossible() {
try {
fileLog = new FileWriter(getHistoryFileName(), true);
// First write, so let's record the date and time of the first user command
fileLog.write("// " + getProductName() + " " + versionInfo() + " log opened at " + df.format(new Date())
+ "\n");
fileLog.flush();
}
catch (IOException ignoreIt) {
}
}
@Override
protected void logCommandToOutput(final String processedLine) {
if (fileLog == null) {
openFileLogIfPossible();
if (fileLog == null) {
// Still failing, so give up
return;
}
}
try {
fileLog.write(processedLine + "\n"); // Unix line endings only from Roo
fileLog.flush(); // So tail -f will show it's working
if (getExitShellRequest() != null) {
// Shutting down, so close our file (we can always reopen it later if needed)
fileLog.write("// " + getProductName() + " " + versionInfo() + " log closed at "
+ df.format(new Date()) + "\n");
IOUtils.closeQuietly(fileLog);
fileLog = null;
}
}
catch (IOException ignoreIt) {
}
}
/**
* Obtains the "roo.home" from the system property, falling back to the current working directory if missing.
*
* @return the 'roo.home' system property
*/
@Override
protected String getHomeAsString() {
String rooHome = System.getProperty("roo.home");
if (rooHome == null) {
try {
rooHome = new File(".").getCanonicalPath();
}
catch (Exception e) {
throw new IllegalStateException(e);
}
}
return rooHome;
}
/**
* Should be called by a subclass before deactivating the shell.
*/
protected void closeShell() {
// Notify we're closing down (normally our status is already shutting_down, but if it was a CTRL+C via the
// o.s.r.bootstrap.Main hook)
setShellStatus(Status.SHUTTING_DOWN);
if (statusListener != null) {
removeShellStatusListener(statusListener);
}
}
private static class FlashInfo {
String flashMessage;
long flashMessageUntil;
Level flashLevel;
int rowNumber;
}
/**
* get history file name from provider. The provider has highest order
* <link>org.springframework.core.Ordered.getOder</link> will win.
*
* @return history file name
*/
abstract protected String getHistoryFileName();
/**
* get prompt text from provider. The provider has highest order
* <link>org.springframework.core.Ordered.getOder</link> will win.
*
* @return prompt text
*/
abstract protected String getPromptText();
/**
* get product name
*
* @return Product Name
*/
abstract protected String getProductName();
/**
* get version information
*
* @return Version
*/
protected String getVersion() {
return VersionUtils.versionInfo();
}
/**
* @return the historySize
*/
public int getHistorySize() {
return historySize;
}
/**
* @param historySize the historySize to set
*/
public void setHistorySize(int historySize) {
this.historySize = historySize;
}
private static boolean isAppleTerminal() {
final String terminalName = System.getenv("TERM_PROGRAM");
return ("Apple_Terminal".equalsIgnoreCase(terminalName) || Boolean.getBoolean("is.apple.terminal"));
}
}