package vnet.sms.common.shell.springshell; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.PrintStream; import java.io.PrintWriter; 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.ANSIBuffer; import jline.ANSIBuffer.ANSICodes; import jline.ConsoleReader; import jline.WindowsTerminal; import org.apache.commons.io.input.ReversedLinesFileReader; import vnet.sms.common.shell.springshell.command.CommandMarker; import vnet.sms.common.shell.springshell.event.ShellStatus; import vnet.sms.common.shell.springshell.event.ShellStatus.Status; import vnet.sms.common.shell.springshell.event.ShellStatusListener; import vnet.sms.common.shell.springshell.internal.JLineCompletorAdapter; import vnet.sms.common.shell.springshell.internal.logging.JLineLogHandler; import vnet.sms.common.shell.springshell.internal.util.Assert; import vnet.sms.common.shell.springshell.internal.util.ClassUtils; import vnet.sms.common.shell.springshell.internal.util.IOUtils; import vnet.sms.common.shell.springshell.internal.util.OsUtils; import vnet.sms.common.shell.springshell.internal.util.StringUtils; import vnet.sms.common.shell.springshell.plugin.BannerProvider; import vnet.sms.common.shell.springshell.plugin.HistoryFileNameProvider; import vnet.sms.common.shell.springshell.plugin.PluginProvidersRegistry; import vnet.sms.common.shell.springshell.plugin.PromptProvider; /** * 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 * @since 1.0 */ public abstract class JLineShell extends AbstractShell implements CommandMarker, 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 boolean APPLE_TERMINAL = Boolean .getBoolean("is.apple.terminal"); private static final char ESCAPE = 27; private static final String BEL = "\007"; private static final int DEFAULT_HISTORY_SIZE = 500; // Fields private final PluginProvidersRegistry pluginProvidersRegistry; private final InputStream input; private final OutputStream output; protected ConsoleReader reader; 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 boolean printBanner = true; private String historyFileName; private String promptText; private String version; private String welcomeMessage; private int historySize = DEFAULT_HISTORY_SIZE; /** * @param input * @param output */ protected JLineShell(final PluginProvidersRegistry pluginProvidersRegistry, final InputStream input, final OutputStream output) { this.pluginProvidersRegistry = pluginProvidersRegistry; this.input = input != null ? input : System.in; this.output = output != null ? output : System.out; } @Override public void run() { this.reader = createConsoleReader(); setPromptPath(null); final JLineLogHandler handler = new JLineLogHandler(this.reader, this); JLineLogHandler.prohibitRedraw(); // Affects this thread only final Logger mainLogger = Logger.getLogger(""); removeHandlers(mainLogger); mainLogger.addHandler(handler); this.reader.addCompletor(new JLineCompletorAdapter(getParser())); this.reader.setBellEnabled(true); if (Boolean.getBoolean("jline.nobell")) { this.reader.setBellEnabled(false); } // reader.setDebug(new PrintWriter(new FileWriter("writer.debug", // true))); openFileLogIfPossible(); this.reader.getHistory().setMaxSize(this.historySize); // Try to build previous command history from the project's log final String[] filteredLogEntries = filterLogEntry(); for (final String logEntry : filteredLogEntries) { this.reader.getHistory().addToHistory(logEntry); } flashMessageRenderer(); flash(Level.FINE, "Spring Shell " + versionInfo(), Shell.WINDOW_TITLE_SLOT); printBannerAndWelcome(); final String startupNotifications = getStartupNotifications(); if (StringUtils.hasText(startupNotifications)) { this.logger.info(startupNotifications); } setShellStatus(Status.STARTED); // Monitor CTRL+C initiated shutdowns (ROO-1599) Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { @Override public void run() { JLineShell.this.shutdownHookFired = true; // We don't need to closeShell(), as the shutdown hook in // o.s.r.bootstrap.Main calls stop() which calls // JLineShellComponent.deactivate() and that calls closeShell() } }, "Spring Shell JLine Shutdown Hook")); // Normal RPEL processing promptLoop(); } /** * read history commands from history log. the history size if determined by * --histsize options. * * @return history commands */ private String[] filterLogEntry() { final ArrayList<String> entries = new ArrayList<String>(); try { final ReversedLinesFileReader reader = new ReversedLinesFileReader( new File(this.historyFileName), 4096, Charset.forName("UTF-8")); int size = 0; String line = null; while ((line = reader.readLine()) != null) { if (!line.startsWith("//")) { size++; if (size > this.historySize) { break; } else { entries.add(line); } } } } catch (final IOException e) { this.logger.warning("read history file failed. Reason:" + e.getMessage()); } Collections.reverse(entries); return entries.toArray(new String[0]); } /** * 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() { try { ConsoleReader consoleReader = null; if (JANSI_AVAILABLE && OsUtils.isWindows()) { try { consoleReader = createAnsiWindowsReader(); } catch (final Exception e) { // Try again using default ConsoleReader constructor this.logger .warning("Can't initialize jansi AnsiConsole, falling back to default: " + e); } } if (consoleReader == null) { consoleReader = new ConsoleReader(this.input, new PrintWriter( this.output)); } return consoleReader; } catch (final IOException ioe) { throw new IllegalStateException("Cannot start console class", ioe); } } public void printBannerAndWelcome() { if (this.printBanner) { this.logger.info(this.version); this.logger.info(getWelcomeMessage()); } } public String getStartupNotifications() { return null; } private void removeHandlers(final Logger l) { final Handler[] handlers = l.getHandlers(); if ((handlers != null) && (handlers.length > 0)) { for (final 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 (this.reader.getTerminal().isANSISupported()) { final ANSIBuffer ansi = JLineLogHandler.getANSIBuffer(); if ((path == null) || "".equals(path)) { this.shellPrompt = ansi.yellow(this.promptText).toString(); } else { if (overrideStyle) { ansi.append(path); } else { ansi.cyan(path); } this.shellPrompt = ansi.yellow(" " + this.promptText) .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 this.reader.setDefaultPrompt(this.shellPrompt); } protected ConsoleReader createAnsiWindowsReader() throws Exception { // Get decorated OutputStream that parses ANSI-codes final OutputStream ansiOut = (PrintStream) ClassUtils .forName(ANSI_CONSOLE_CLASSNAME, JLineShell.class.getClassLoader()) .getMethod("wrapOutputStream", OutputStream.class) .invoke(this.output); final WindowsTerminal ansiTerminal = new WindowsTerminal() { @Override public boolean isANSISupported() { return true; } }; ansiTerminal.initializeTerminal(); // Make sure to reset the original shell's colors on shutdown by closing // the stream this.statusListener = new ShellStatusListener() { @Override public void onShellStatusChange(final ShellStatus oldStatus, final ShellStatus newStatus) { if (newStatus.getStatus().equals(Status.SHUTTING_DOWN)) { try { ansiOut.close(); } catch (final IOException e) { // Ignore } } } }; addShellStatusListener(this.statusListener); return new ConsoleReader(this.input, new PrintWriter( new OutputStreamWriter(ansiOut, // Default to Cp850 encoding for Windows console output // (ROO-439) System.getProperty( "jline.WindowsTerminal.output.encoding", "Cp850"))), null, ansiTerminal); } private void flashMessageRenderer() { if (!this.reader.getTerminal().isANSISupported()) { return; } // Setup a thread to ensure flash messages are displayed and cleared // correctly final Thread t = new Thread(new Runnable() { @Override public void run() { while (!JLineShell.this.shellStatus.getStatus().equals( Status.SHUTTING_DOWN) && !JLineShell.this.shutdownHookFired) { synchronized (JLineShell.this.flashInfoMap) { final long now = System.currentTimeMillis(); final Set<String> toRemove = new HashSet<String>(); for (final String slot : JLineShell.this.flashInfoMap.keySet()) { final FlashInfo flashInfo = JLineShell.this.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 (final String slot : toRemove) { JLineShell.this.flashInfoMap.remove(slot); } } try { Thread.sleep(200); } catch (final InterruptedException ignore) { } } } }, "Spring Roo 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 ((this.reader != null) && this.reader.getTerminal().isANSISupported()) { // We can probably update the window title, as requested if (StringUtils.isBlank(message)) { System.out.println("No text"); } final ANSIBuffer buff = JLineLogHandler.getANSIBuffer(); buff.append(ESCAPE + "]0;").append(message).append(BEL); final String stg = buff.toString(); try { this.reader.printString(stg); this.reader.flushConsole(); } catch (final IOException ignored) { } } return; } if (((this.reader != null) && !this.reader.getTerminal() .isANSISupported())) { super.flash(level, message, slot); return; } synchronized (this.flashInfoMap) { FlashInfo flashInfo = this.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 (final FlashInfo existingFlashInfo : this.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 this.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) { final ANSIBuffer buff = JLineLogHandler.getANSIBuffer(); if (APPLE_TERMINAL) { buff.append(ESCAPE + "7"); } else { buff.append(ANSICodes.save()); } // Figure out the longest line we're presently displaying (or were) and // erase the line from that position int mostFurtherLeftColNumber = Integer.MAX_VALUE; for (final Integer candidate : this.rowErasureMap.values()) { if (candidate < mostFurtherLeftColNumber) { mostFurtherLeftColNumber = candidate; } } if (mostFurtherLeftColNumber == Integer.MAX_VALUE) { // There is nothing to erase } else { buff.append(ANSICodes.gotoxy(row, mostFurtherLeftColNumber)); buff.append(ANSICodes.clreol()); // 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 this.rowErasureMap.remove(row); } else { if (this.shutdownHookFired) { return; // ROO-1599 } // They want some message displayed int startFrom = (this.reader.getTermwidth() - message.length()) + 1; if (startFrom < 1) { startFrom = 1; } buff.append(ANSICodes.gotoxy(row, startFrom)); buff.reverse(message); // Record we want to erase from this positioning next time (so we // clean up after ourselves) this.rowErasureMap.put(row, startFrom); } if (APPLE_TERMINAL) { buff.append(ESCAPE + "8"); } else { buff.append(ANSICodes.restore()); } final String stg = buff.toString(); try { this.reader.printString(stg); this.reader.flushConsole(); } catch (final IOException ignored) { } } @Override public void promptLoop() { setShellStatus(Status.USER_INPUT); try { String line; while ((this.exitShellRequest == null) && ((line = this.reader.readLine()) != null)) { JLineLogHandler.resetMessageTracking(); setShellStatus(Status.USER_INPUT); if ("".equals(line)) { continue; } executeCommand(line); } } catch (final IOException ioe) { throw new IllegalStateException("Shell line reading failure", ioe); } setShellStatus(Status.SHUTTING_DOWN); } private void openFileLogIfPossible() { try { this.fileLog = new FileWriter(this.historyFileName, true); // First write, so let's record the date and time of the first user // command this.fileLog.write("// Spring Roo " + versionInfo() + " log opened at " + this.df.format(new Date()) + "\n"); this.fileLog.flush(); } catch (final IOException ignoreIt) { } } @Override protected void logCommandToOutput(final String processedLine) { if (this.fileLog == null) { openFileLogIfPossible(); if (this.fileLog == null) { // Still failing, so give up return; } } try { this.fileLog.write(processedLine + "\n"); // Unix line endings only // from // Roo this.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) this.fileLog .write("// Spring Roo " + versionInfo() + " log closed at " + this.df.format(new Date()) + "\n"); IOUtils.closeQuietly(this.fileLog); this.fileLog = null; } } catch (final IOException ignoreIt) { } } /** * Obtains the "shell.home" from the system property, falling back to the * current working directory if missing. * * @return the 'shell.home' system property */ @Override protected String getHomeAsString() { String shellHome = System.getProperty("shell.home"); if (shellHome == null) { try { shellHome = new File(".").getCanonicalPath(); } catch (final Exception e) { throw new IllegalStateException(e); } } return shellHome; } /** * 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 (this.statusListener != null) { removeShellStatusListener(this.statusListener); } } private static class FlashInfo { String flashMessage; long flashMessageUntil; Level flashLevel; int rowNumber; } public void costomizePlugin() { this.historyFileName = getHistoryFileName(); this.promptText = getPromptText(); this.version = getBannerText()[0]; this.welcomeMessage = getBannerText()[1]; } /** * get history file name from provider. The provider has highest order * <link>vnet.sms.common.core.Ordered.getOder</link> will win. * * @return history file name */ private String getHistoryFileName() { return this.pluginProvidersRegistry.highestPriorityProviderOfType( HistoryFileNameProvider.class).getHistoryFileName(); } /** * get prompt text from provider. The provider has highest order * <link>vnet.sms.common.core.Ordered.getOder</link> will win. * * @return prompt text */ private String getPromptText() { return this.pluginProvidersRegistry.highestPriorityProviderOfType( PromptProvider.class).getPromptText(); } /** * Get Banner and Welcome Message from provider. The provider has highest * order <link>vnet.sms.common.core.Ordered.getOder</link> will win. * * @return BannerText[0]: Banner BannerText[1]: Welcome Message. */ private String[] getBannerText() { final String[] bannerText = new String[2]; final BannerProvider provider = this.pluginProvidersRegistry .highestPriorityProviderOfType(BannerProvider.class); bannerText[0] = provider.getBanner(); bannerText[1] = provider.getWelcomMessage(); return bannerText; } /** * get the version information * */ @Override public String version(final String text) { return this.version; } /** * get the welcome message at start. * * @return welcome message */ public String getWelcomeMessage() { return this.welcomeMessage; } /** * @param printBanner * the printBanner to set */ public void setPrintBanner(final boolean printBanner) { this.printBanner = printBanner; } /** * @return the historySize */ public int getHistorySize() { return this.historySize; } /** * @param historySize * the historySize to set */ public void setHistorySize(final int historySize) { this.historySize = historySize; } }