package org.springframework.roo.shell.jline; import static org.apache.commons.io.IOUtils.LINE_SEPARATOR; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.OutputStreamWriter; import java.io.PrintStream; import java.io.PrintWriter; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.dom.DOMSource; import javax.xml.transform.stream.StreamResult; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.ClassUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.SystemUtils; import org.apache.commons.lang3.Validate; import org.springframework.roo.shell.AbstractShell; import org.springframework.roo.shell.CommandMarker; import org.springframework.roo.shell.ExitShellRequest; import org.springframework.roo.shell.Shell; import org.springframework.roo.shell.event.ShellStatus; import org.springframework.roo.shell.event.ShellStatus.Status; import org.springframework.roo.shell.event.ShellStatusListener; import org.springframework.roo.support.logging.HandlerUtils; import org.springframework.roo.support.util.AnsiEscapeCode; import org.springframework.roo.support.util.XmlUtils; import org.w3c.dom.Document; import org.w3c.dom.Element; import jline.ANSIBuffer; import jline.ANSIBuffer.ANSICodes; import jline.ConsoleReader; import jline.WindowsTerminal; /** * 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 * @since 1.0 */ public abstract class JLineShell extends AbstractShell implements CommandMarker, Shell, Runnable { private static class FlashInfo { Level flashLevel; String flashMessage; long flashMessageUntil; int rowNumber; } protected final static Logger LOGGER = HandlerUtils.getLogger(JLineShell.class); private static final String ANSI_CONSOLE_CLASSNAME = "org.fusesource.jansi.AnsiConsole"; private static final boolean APPLE_TERMINAL = Boolean.getBoolean("is.apple.terminal"); private static final String BEL = "\007"; private static final char ESCAPE = 27; private static final boolean JANSI_AVAILABLE = isPresent(ANSI_CONSOLE_CLASSNAME, JLineShell.class.getClassLoader()); private static boolean isPresent(final String className, final ClassLoader classLoader) { try { return classLoader.loadClass(className) != null; } catch (final Throwable t) { // Class or one of its dependencies is not present... return false; } } private boolean developmentMode = false; private final DateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private FileWriter fileLog; /** key: slot name, value: flashInfo instance */ private final Map<String, FlashInfo> flashInfoMap = new HashMap<String, FlashInfo>(); private ConsoleReader reader; /** key: row number, value: eraseLineFromPosition */ private final Map<Integer, Integer> rowErasureMap = new HashMap<Integer, Integer>(); private boolean shutdownHookFired = false; // ROO-1599 protected ShellStatusListener statusListener; // ROO-836 /** * 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 ConsoleReader createAnsiWindowsReader() throws Exception { // Get decorated OutputStream that parses ANSI-codes final PrintStream ansiOut = (PrintStream) ClassUtils .getClass(JLineShell.class.getClassLoader(), ANSI_CONSOLE_CLASSNAME).getMethod("out") .invoke(null); 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 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); } // 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 : rowErasureMap.values()) { if (candidate < mostFurtherLeftColNumber) { mostFurtherLeftColNumber = candidate; } } if (mostFurtherLeftColNumber == Integer.MAX_VALUE) { // There is nothing to erase } else { buff.append(ANSICodes.gotoxy(row, mostFurtherLeftColNumber)); // Clear what was present on the line buff.append(ANSICodes.clreol()); } 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.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) rowErasureMap.put(row, startFrom); } if (APPLE_TERMINAL) { buff.append(ESCAPE + "8"); } else { buff.append(ANSICodes.restore()); } final String stg = buff.toString(); try { reader.printString(stg); reader.flushConsole(); } catch (final IOException ignored) { } } @Override public void flash(final Level level, final String message, final String slot) { Validate.notNull(level, "Level is required for a flash message"); Validate.notNull(message, "Message is required for a flash message"); Validate.notBlank(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.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 { reader.printString(stg); reader.flushConsole(); } catch (final 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 (final 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); } } } private void flashMessageRenderer() { if (!reader.getTerminal().isANSISupported()) { return; } // Setup a thread to ensure flash messages are displayed and cleared // correctly final Thread t = new Thread(new Runnable() { public void run() { while (!shellStatus.getStatus().equals(Status.SHUTTING_DOWN) && !shutdownHookFired) { synchronized (flashInfoMap) { final long now = System.currentTimeMillis(); final Set<String> toRemove = new HashSet<String>(); for (final String slot : flashInfoMap.keySet()) { final 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 (final String slot : toRemove) { flashInfoMap.remove(slot); } } try { Thread.sleep(200); } catch (final InterruptedException ignore) { } } } }, "Spring Roo JLine Flash Message Manager"); t.start(); } /** * 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 (final Exception e) { throw new IllegalStateException(e); } } return rooHome; } public String getStartupNotifications() { return null; } public boolean isDevelopmentMode() { return developmentMode; } @Override protected void logCommandToOutput(final String processedLine) { if (fileLog == null) { openFileLogIfPossible(); if (fileLog == null) { // Still failing, so give up return; } } // Don't write exit or quit commands if (processedLine.trim().startsWith("quit") || processedLine.trim().startsWith("exit")) { return; } try { // Unix line endings only from Roo fileLog.write(processedLine + "\n"); // So tail -f will show it's working fileLog.flush(); if (getExitShellRequest() != null) { // Shutting down, so close our file (we can always reopen it // later if needed) fileLog.write("// Spring Roo " + versionInfo() + " log closed at " + df.format(new Date()) + "\n"); IOUtils.closeQuietly(fileLog); fileLog = null; } } catch (final IOException ignored) { } } private void openFileLogIfPossible() { try { fileLog = new FileWriter("log.roo", true); // First write, so let's record the date and time of the first user // command fileLog.write("// Spring Roo " + versionInfo() + " log opened at " + df.format(new Date()) + "\n"); fileLog.flush(); } catch (final IOException ignoreIt) { } } public void promptLoop() { setShellStatus(Status.USER_INPUT); String line; // Changing to default prompt setRooPrompt(null); try { while (exitShellRequest == null && (line = 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 removeHandlers(final Logger l) { final Handler[] handlers = l.getHandlers(); if (handlers != null && handlers.length > 0) { for (final Handler h : handlers) { l.removeHandler(h); } } } public void run() { try { if (JANSI_AVAILABLE && SystemUtils.IS_OS_WINDOWS) { try { reader = createAnsiWindowsReader(); } catch (final Exception e) { // Try again using default ConsoleReader constructor logger.warning("Can't initialize jansi AnsiConsole, falling back to default: " + e); } } if (reader == null) { reader = new ConsoleReader(); } } catch (final IOException ioe) { throw new IllegalStateException("Cannot start console class", ioe); } final JLineLogHandler handler = new JLineLogHandler(reader, this); JLineLogHandler.prohibitRedraw(); // Affects this thread only final Logger mainLogger = Logger.getLogger(""); removeHandlers(mainLogger); mainLogger.addHandler(handler); reader.addCompletor(new JLineCompletorAdapter(getParser())); reader.setBellEnabled(true); if (Boolean.getBoolean("jline.nobell")) { reader.setBellEnabled(false); } // reader.setDebug(new PrintWriter(new FileWriter("writer.debug", // true))); openFileLogIfPossible(); // Try to build previous command history from the project's log try { final String logFileContents = FileUtils.readFileToString(new File("log.roo")); final String[] logEntries = logFileContents.split(IOUtils.LINE_SEPARATOR); // LIFO for (final String logEntry : logEntries) { if (!logEntry.startsWith("//")) { reader.getHistory().addToHistory(logEntry); } } } catch (final IOException ignored) { } flashMessageRenderer(); logger.info(version(null)); flash(Level.FINE, "Spring Roo " + versionInfo(), Shell.WINDOW_TITLE_SLOT); logger.info("Welcome to Spring Roo. For assistance press " + completionKeys + " or type \"hint\" then hit ENTER."); final String startupNotifications = getStartupNotifications(); if (StringUtils.isNotBlank(startupNotifications)) { logger.info(startupNotifications); } setShellStatus(Status.STARTED); // Monitor CTRL+C initiated shutdowns (ROO-1599) Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { public void run() { 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 Roo JLine Shutdown Hook")); // Handle any "execute-then-quit" operation final String rooArgs = System.getProperty("roo.args"); if (rooArgs != null && !"".equals(rooArgs)) { setShellStatus(Status.USER_INPUT); final boolean success = executeCommand(rooArgs); 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 { // ROO-3622: Validate if version change if (isDifferentVersion()) { logger.warning("WARNING: You are using Spring Roo " + versionInfoWithoutGit() + ", but " + "project was generated using Spring Roo " + getRooProjectVersion() + "."); logger.warning("If you continue with the execution " + "your project might suffer some changes."); // Ask a question about if Spring Roo should apply its prepared changes List<String> options = new ArrayList<String>(); options.add("Yes"); options.add("No"); String answer = askAQuestion("Do you want to continue opening Spring Roo Shell?", options, "Yes"); if ("yes".equals(answer.toLowerCase())) { showGoodLuckMessage(); updateRooVersion(versionInfoWithoutGit()); } else if ("no".equals(answer.toLowerCase())) { System.exit(0); } promptLoop(); } else { // Normal RPEL processing promptLoop(); } } } private void updateRooVersion(String shellVersion) { String homePath = getHome().getPath(); String pomPath = homePath + "/pom.xml"; File pom = new File(pomPath); try { if (pom.exists()) { InputStream is = new FileInputStream(pom); Document docXml = XmlUtils.readXml(is); Element document = docXml.getDocumentElement(); Element rooVersionElement = XmlUtils.findFirstElement("properties/roo.version", document); rooVersionElement.setTextContent(shellVersion); TransformerFactory transformerFactory = TransformerFactory.newInstance(); Transformer transformer = transformerFactory.newTransformer(); DOMSource source = new DOMSource(docXml); StreamResult result = new StreamResult(new File(pomPath)); transformer.transform(source, result); String changes = "[" + AnsiEscapeCode.decorate("updated property", AnsiEscapeCode.FG_CYAN) + " 'roo.version' to '" + shellVersion + "']"; LOGGER.log(Level.FINE, "Updated ROOT/pom.xml " + changes); } } catch (FileNotFoundException e) { e.printStackTrace(); } catch (TransformerException e) { e.printStackTrace(); } } private String getRooProjectVersion() { String homePath = getHome().getPath(); String pomPath = homePath + "/pom.xml"; File pom = new File(pomPath); try { if (pom.exists()) { InputStream is = new FileInputStream(pom); Document docXml = XmlUtils.readXml(is); Element document = docXml.getDocumentElement(); Element rooVersionElement = XmlUtils.findFirstElement("properties/roo.version", document); String rooVersion = rooVersionElement.getTextContent(); return rooVersion; } return "UNKNOWN"; } catch (FileNotFoundException e) { e.printStackTrace(); } return ""; } private boolean isDifferentVersion() { String rooVersion = getRooProjectVersion(); if ("UNKNOWN".equals(rooVersion)) { return false; } return !rooVersion.equals(versionInfoWithoutGit()); } public void setDevelopmentMode(final boolean developmentMode) { JLineLogHandler.setIncludeThreadName(developmentMode); // We want to see duplicate messages during development time (ROO-1873) JLineLogHandler.setSuppressDuplicateMessages(!developmentMode); this.developmentMode = developmentMode; } @Override public void setPromptPath(final String path) { setPromptPath(path, false); } @Override public void setPromptPath(final String path, final boolean overrideStyle) { if (reader.getTerminal().isANSISupported()) { if (StringUtils.isBlank(path)) { shellPrompt = AnsiEscapeCode.decorate(ROO_PROMPT, AnsiEscapeCode.FG_YELLOW); } else { final String decoratedPath = overrideStyle ? AnsiEscapeCode.decorate(path) : AnsiEscapeCode.decorate(path, AnsiEscapeCode.FG_CYAN); shellPrompt = decoratedPath + AnsiEscapeCode.decorate(" " + ROO_PROMPT, AnsiEscapeCode.FG_YELLOW); } } 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.setDefaultPrompt(AbstractShell.shellPrompt); } public String askAQuestion(String question, List<String> options, String defaultOption) { Validate.notBlank(question, "ERROR: 'question' param when uses askAQuestion operation is required."); // Preparing options if empty if (options == null || options.isEmpty()) { options = new ArrayList<String>(); options.add("y"); options.add("n"); } // Preparing question question = question.concat("("); for (String option : options) { if (option.toLowerCase().equals(defaultOption.toLowerCase())) { option = option.toUpperCase(); } question = question.concat(option).concat("/"); } question = question.substring(0, question.length() - 1).concat(")"); setShellStatus(Status.USER_WAITING_CONFIRMATION); String line; // Changing default prompt with question setRooPrompt(question); String answer = ""; try { while (exitShellRequest == null && (line = reader.readLine()) != null) { JLineLogHandler.resetMessageTracking(); setShellStatus(Status.USER_WAITING_CONFIRMATION); // If blank, use default option if (StringUtils.isBlank(line) && StringUtils.isNotBlank(defaultOption)) { answer = defaultOption; break; } else { for (String option : options) { if (option.toLowerCase().equals(line.toLowerCase())) { answer = line; break; } } if (StringUtils.isNotBlank(answer)) { break; } else { LOGGER.log(Level.SEVERE, String.format("'%s' is not a valid answer.", line)); } } } // Reset prompt with default value setRooPrompt(""); // Return answer return answer; } catch (final IOException ioe) { throw new IllegalStateException("Shell line reading failure", ioe); } } @Override public void setRooPrompt(final String prompt) { if (reader.getTerminal().isANSISupported()) { if (StringUtils.isBlank(prompt)) { shellPrompt = AnsiEscapeCode.decorate(ROO_PROMPT, AnsiEscapeCode.FG_YELLOW); } else { final String decoratedPath = AnsiEscapeCode.decorate(prompt, AnsiEscapeCode.FG_CYAN); shellPrompt = decoratedPath; } } else { // The superclass will do for this non-ANSI terminal super.setPromptPath(prompt); } // The shellPrompt is now correct; let's ensure it now gets used reader.setDefaultPrompt(AbstractShell.shellPrompt); } private void showGoodLuckMessage() { LOGGER.log(Level.INFO, ""); final StringBuilder sb = new StringBuilder(); sb.append(" /\\ /l").append(LINE_SEPARATOR); sb.append(" ((.Y(!").append(LINE_SEPARATOR); sb.append(" \\ |/").append(LINE_SEPARATOR); sb.append(" / 6~6,").append(LINE_SEPARATOR); sb.append(" \\ _ +-.").append(LINE_SEPARATOR); sb.append(" \\`-=--^-' \\").append(LINE_SEPARATOR); sb.append( " \\ \\ |\\---------------------------------------------------------------+") .append(LINE_SEPARATOR); sb.append( " _/ \\ | Updating project to Spring Roo " + versionInfoWithoutGit() + " version |").append(LINE_SEPARATOR); sb.append( " ( . Y +----------------------------------------------------------------+") .append(LINE_SEPARATOR); sb.append(" /\"\\ `---^--v---.").append(LINE_SEPARATOR); sb.append(" / _ `---\"T~~\\/~\\/").append(LINE_SEPARATOR); sb.append(" / \" ~\\. !").append(LINE_SEPARATOR); sb.append(" _ Y Y.~~~ /'").append(LINE_SEPARATOR); sb.append(" Y^| | | Roo 7").append(LINE_SEPARATOR); sb.append(" | l | / . /'").append(LINE_SEPARATOR); sb.append(" | `L | Y .^/ ~T").append(LINE_SEPARATOR); sb.append(" | l ! | |/ | | ").append(LINE_SEPARATOR); sb.append(" | .`\\/' | Y | ! ").append(LINE_SEPARATOR); sb.append(" l \"~ j l j L______ ").append(LINE_SEPARATOR); sb.append(" \\,____{ __\"\" ~ __ ,\\_,\\_").append(LINE_SEPARATOR); sb.append(" ~~~~~~~~~~~~~~~~~~~~~~~~~~~").append(" ").append(LINE_SEPARATOR); LOGGER.log(Level.INFO, sb.toString()); LOGGER.log(Level.INFO, ""); } }