package org.springframework.roo.shell; import static org.apache.commons.io.IOUtils.LINE_SEPARATOR; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.net.URISyntaxException; import java.net.URL; import java.text.DateFormat; import java.util.Collection; import java.util.Date; import java.util.List; import java.util.Map.Entry; import java.util.Set; import java.util.TreeSet; import java.util.jar.JarFile; import java.util.jar.Manifest; import java.util.logging.Level; import java.util.logging.Logger; import java.util.zip.ZipEntry; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; import org.springframework.roo.shell.event.AbstractShellStatusPublisher; import org.springframework.roo.shell.event.ShellStatus; import org.springframework.roo.shell.event.ShellStatus.Status; import org.springframework.roo.support.logging.HandlerUtils; import org.springframework.roo.support.util.CollectionUtils; /** * Provides a base {@link Shell} implementation. * * @author Ben Alex */ public abstract class AbstractShell extends AbstractShellStatusPublisher implements Shell { private static final String MY_SLOT = AbstractShell.class.getName(); protected static final String ROO_PROMPT = "roo> "; // Public static fields; don't rename, make final, or make non-public, as // they are part of the public API, e.g. are changed by STS. public static String completionKeys = "TAB"; public static String shellPrompt = ROO_PROMPT; public static String versionInfo() { // Try to determine the bundle version String bundleVersion = null; String gitCommitHash = null; JarFile jarFile = null; try { final URL classContainer = AbstractShell.class .getProtectionDomain().getCodeSource().getLocation(); if (classContainer.toString().endsWith(".jar")) { // Attempt to obtain the "Bundle-Version" version from the // manifest jarFile = new JarFile(new File(classContainer.toURI()), false); final ZipEntry manifestEntry = jarFile .getEntry("META-INF/MANIFEST.MF"); final Manifest manifest = new Manifest( jarFile.getInputStream(manifestEntry)); bundleVersion = manifest.getMainAttributes().getValue( "Bundle-Version"); gitCommitHash = manifest.getMainAttributes().getValue( "Git-Commit-Hash"); } } catch (final IOException ignoreAndMoveOn) { } catch (final URISyntaxException ignoreAndMoveOn) { } finally { if (jarFile != null) { try { jarFile.close(); } catch (final IOException ignored) { } } } final StringBuilder sb = new StringBuilder(); if (bundleVersion != null) { sb.append(bundleVersion); } if (gitCommitHash != null && gitCommitHash.length() > 7) { if (sb.length() > 0) { sb.append(" "); } sb.append("[rev "); sb.append(gitCommitHash.substring(0, 7)); sb.append("]"); } if (sb.length() == 0) { sb.append("UNKNOWN VERSION"); } return sb.toString(); } protected final Logger logger = HandlerUtils.getLogger(getClass()); protected boolean inBlockComment; protected ExitShellRequest exitShellRequest; private Tailor tailor; @CliCommand(value = { "/*" }, help = "Start of block comment") public void blockCommentBegin() { Validate.isTrue(!inBlockComment, "Cannot open a new block comment when one already active"); inBlockComment = true; } @CliCommand(value = { "*/" }, help = "End of block comment") public void blockCommentFinish() { Validate.isTrue(inBlockComment, "Cannot close a block comment when it has not been opened"); inBlockComment = false; } @CliCommand(value = { "date" }, help = "Displays the local date and time") public String date() { return DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.FULL) .format(new Date()); } public boolean executeCommand(final String line) { if (tailor == null) { return executeCommandImpl(line); } /* * If getTailor() is not null, then try to transform input command and * execute all outputs sequentially */ List<String> commands = null; commands = tailor.sew(line); if (CollectionUtils.isEmpty(commands)) { return executeCommandImpl(line); } for (final String command : commands) { logger.info("roo-tailor> " + command); if (!executeCommandImpl(command)) { return false; } } return true; } /** * Runs the specified command. Control will return to the caller after the * command is run. */ private boolean executeCommandImpl(String line) { // Another command was attempted setShellStatus(ShellStatus.Status.PARSING); final ExecutionStrategy executionStrategy = getExecutionStrategy(); boolean flashedMessage = false; while (executionStrategy == null || !executionStrategy.isReadyForCommands()) { // Wait try { Thread.sleep(500); } catch (final InterruptedException ignore) { } if (!flashedMessage) { flash(Level.INFO, "Please wait - still loading", MY_SLOT); flashedMessage = true; } } if (flashedMessage) { flash(Level.INFO, "", MY_SLOT); } ParseResult parseResult = null; try { // We support simple block comments; ie a single pair per line if (!inBlockComment && line.contains("/*") && line.contains("*/")) { blockCommentBegin(); final String lhs = line.substring(0, line.lastIndexOf("/*")); if (line.contains("*/")) { line = lhs + line.substring(line.lastIndexOf("*/") + 2); blockCommentFinish(); } else { line = lhs; } } if (inBlockComment) { if (!line.contains("*/")) { return true; } blockCommentFinish(); line = line.substring(line.lastIndexOf("*/") + 2); } // We also support inline comments (but only at start of line, // otherwise valid // command options like http://www.helloworld.com will fail as per // ROO-517) if (!inBlockComment && (line.trim().startsWith("//") || line.trim().startsWith( "#"))) { // # support in ROO-1116 line = ""; } // Convert any TAB characters to whitespace (ROO-527) line = line.replace('\t', ' '); if ("".equals(line.trim())) { setShellStatus(Status.EXECUTION_SUCCESS); return true; } parseResult = getParser().parse(line); if (parseResult == null) { return false; } setShellStatus(Status.EXECUTING); final Object result = executionStrategy.execute(parseResult); setShellStatus(Status.EXECUTION_RESULT_PROCESSING); if (result != null) { if (result instanceof ExitShellRequest) { exitShellRequest = (ExitShellRequest) result; // Give ProcessManager a chance to close down its threads // before the overall OSGi framework is terminated // (ROO-1938) executionStrategy.terminate(); } else if (result instanceof Iterable<?>) { for (final Object o : (Iterable<?>) result) { logger.info(o.toString()); } } else { logger.info(result.toString()); } } logCommandIfRequired(line, true); setShellStatus(Status.EXECUTION_SUCCESS, line, parseResult); return true; } catch (final RuntimeException e) { setShellStatus(Status.EXECUTION_FAILED, line, parseResult); // We rely on execution strategy to log it try { logCommandIfRequired(line, false); } catch (final Exception ignored) { } return false; } finally { setShellStatus(Status.USER_INPUT); } } /** * Execute the single line from a script. * <p> * This method can be overridden by sub-classes to pre-process script lines. */ protected boolean executeScriptLine(final String line) { return executeCommand(line); } /** * Returns any classpath resources with the given path * * @param path the path for which to search (never null) * @return <code>null</code> if the search can't be performed * @since 1.2.0 */ protected abstract Collection<URL> findResources(String path); /** * Simple implementation of {@link #flash(Level, String, String)} that * simply displays the message via the logger. It is strongly recommended * shell implementations override this method with a more effective * approach. */ 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 (!"".equals(message)) { logger.log(level, message); } } @CliCommand(value = { "flash test" }, help = "Tests message flashing") public void flashCustom() throws Exception { flash(Level.FINE, "Hello world", "a"); Thread.sleep(150); flash(Level.FINE, "Short world", "a"); Thread.sleep(150); flash(Level.FINE, "Small", "a"); Thread.sleep(150); flash(Level.FINE, "Downloading xyz", "b"); Thread.sleep(150); flash(Level.FINE, "", "a"); Thread.sleep(150); flash(Level.FINE, "Downloaded xyz", "b"); Thread.sleep(150); flash(Level.FINE, "System online", "c"); Thread.sleep(150); flash(Level.FINE, "System ready", "c"); Thread.sleep(150); flash(Level.FINE, "System farewell", "c"); Thread.sleep(150); flash(Level.FINE, "", "c"); Thread.sleep(150); flash(Level.FINE, "", "b"); } protected abstract ExecutionStrategy getExecutionStrategy(); public ExitShellRequest getExitShellRequest() { return exitShellRequest; } /** * Obtains the home directory for the current shell instance. * <p> * Note: calls the {@link #getHomeAsString()} method to allow subclasses to * provide the home directory location as string using different * environment-specific strategies. * <p> * If the path indicated by {@link #getHomeAsString()} exists and refers to * a directory, that directory is returned. * <p> * If the path indicated by {@link #getHomeAsString()} exists and refers to * a file, an exception is thrown. * <p> * If the path indicated by {@link #getHomeAsString()} does not exist, it * will be created as a directory. If this fails, an exception will be * thrown. * * @return the home directory for the current shell instance (which is * guaranteed to exist and be a directory) */ public File getHome() { final String rooHome = getHomeAsString(); final File f = new File(rooHome); Validate.isTrue(!f.exists() || f.exists() && f.isDirectory(), "Path '" + f.getAbsolutePath() + "' must be a directory, or it must not exist"); if (!f.exists()) { f.mkdirs(); } Validate.isTrue( f.exists() && f.isDirectory(), "Path '" + f.getAbsolutePath() + "' is not a directory; please specify roo.home system property correctly"); return f; } protected abstract String getHomeAsString(); protected abstract Parser getParser(); public String getShellPrompt() { return shellPrompt; } @CliCommand(value = { "//", ";" }, help = "Inline comment markers (start of line only)") public void inlineComment() { } /** * Allows a subclass to log the execution of a well-formed command. This is * invoked after a command has completed, and indicates whether the command * returned normally or returned an exception. Note that attempted commands * that are not well-formed (eg they are missing a mandatory argument) will * never be presented to this method, as the command execution is never * actually attempted in those cases. This method is only invoked if an * attempt is made to execute a particular command. * <p> * Implementations should consider specially handling the "script" commands, * and also indicating whether a command was successful or not. * Implementations that wish to behave consistently with other * {@link AbstractShell} subclasses are encouraged to simply override * {@link #logCommandToOutput(String)} instead, and only override this * method if you actually need to fine-tune the output logic. * * @param line the parsed line (any comments have been removed; never null) * @param successful if the command was successful or not */ protected void logCommandIfRequired(final String line, final boolean successful) { if (line.startsWith("script")) { logCommandToOutput((successful ? "// " : "// [failed] ") + line); } else { logCommandToOutput((successful ? "" : "// [failed] ") + line); } } /** * Allows a subclass to actually write the resulting logged command to some * form of output. This frees subclasses from needing to implement the logic * within {@link #logCommandIfRequired(String, boolean)}. * <p> * Implementations should invoke {@link #getExitShellRequest()} to monitor * any attempts to exit the shell and release resources such as output log * files. * * @param processedLine the line that should be appended to some type of * output (excluding the \n character) */ protected void logCommandToOutput(final String processedLine) { } /** * Opens the given script for reading * * @param script the script to read (required) * @return a non-<code>null</code> input stream */ private InputStream openScript(final File script) { try { return new BufferedInputStream(new FileInputStream(script)); } catch (final FileNotFoundException fnfe) { // Try to find the script via the classloader final Collection<URL> urls = findResources(script.getName()); // Handle search failure Validate.notNull(urls, "Unexpected error looking for '" + script.getName() + "'"); // Handle the search being OK but the file simply not being present Validate.notEmpty(urls, "Script '" + script + "' not found on disk or in classpath"); Validate.isTrue(urls.size() == 1, "More than one '" + script + "' was found in the classpath; unable to continue"); try { return urls.iterator().next().openStream(); } catch (final IOException e) { throw new IllegalStateException(e); } } } @CliCommand(value = { "system properties" }, help = "Shows the shell's properties") public String props() { final Set<String> data = new TreeSet<String>(); for (final Entry<Object, Object> entry : System.getProperties() .entrySet()) { data.add(entry.getKey() + " = " + entry.getValue()); } return StringUtils.join(data, LINE_SEPARATOR) + LINE_SEPARATOR; } private double round(final double valueToRound, final int numberOfDecimalPlaces) { final double multiplicationFactor = Math.pow(10, numberOfDecimalPlaces); final double interestedInZeroDPs = valueToRound * multiplicationFactor; return Math.round(interestedInZeroDPs) / multiplicationFactor; } @CliCommand(value = { "script" }, help = "Parses the specified resource file and executes its commands") public void script( @CliOption(key = { "", "file" }, help = "The file to locate and execute", mandatory = true) final File script, @CliOption(key = "lineNumbers", mandatory = false, specifiedDefaultValue = "true", unspecifiedDefaultValue = "false", help = "Display line numbers when executing the script") final boolean lineNumbers) { Validate.notNull(script, "Script file to parse is required"); final double startedNanoseconds = System.nanoTime(); final InputStream inputStream = openScript(script); try { int i = 0; for (final String line : IOUtils.readLines(inputStream)) { i++; if (lineNumbers) { logger.fine("Line " + i + ": " + line); } else { logger.fine(line); } if (!"".equals(line.trim())) { final boolean success = executeScriptLine(line); if (success && (line.trim().startsWith("q") || line.trim() .startsWith("ex"))) { break; } else if (!success) { // Abort script processing, given something went wrong throw new IllegalStateException( "Script execution aborted"); } } } } catch (final IOException e) { throw new IllegalStateException(e); } finally { IOUtils.closeQuietly(inputStream); final double executionDurationInSeconds = (System.nanoTime() - startedNanoseconds) / 1000000000D; logger.fine("Script required " + round(executionDurationInSeconds, 3) + " seconds to execute"); } } /** * Base implementation of the {@link Shell#setPromptPath(String)} method, * designed for simple shell implementations. Advanced implementations (eg * those that support ANSI codes etc) will likely want to override this * method and set the {@link #shellPrompt} variable directly. * * @param path to set (can be null or empty; must NOT be formatted in any * special way eg ANSI codes) */ public void setPromptPath(final String path) { shellPrompt = (StringUtils.isNotBlank(path) ? path + " " : "") + ROO_PROMPT; } /** * Default implementation of {@link Shell#setPromptPath(String, boolean))} * method to satisfy STS compatibility. * * @param path to set (can be null or empty) * @param overrideStyle */ public void setPromptPath(final String path, final boolean overrideStyle) { setPromptPath(path); } public void setTailor(final Tailor tailor) { this.tailor = tailor; } @CliCommand(value = { "version" }, help = "Displays shell version") public String version( @CliOption(key = "", help = "Special version flags") final String extra) { final StringBuilder sb = new StringBuilder(); if ("roorocks".equals(extra)) { 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( " _/ \\ | Thanks for loading Roo! |") .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(versionInfo()).append(LINE_SEPARATOR); return sb.toString(); } sb.append(" ____ ____ ____ ").append(LINE_SEPARATOR); sb.append(" / __ \\/ __ \\/ __ \\ ").append(LINE_SEPARATOR); sb.append(" / /_/ / / / / / / / ").append(LINE_SEPARATOR); sb.append(" / _, _/ /_/ / /_/ / ").append(LINE_SEPARATOR); sb.append("/_/ |_|\\____/\\____/ ").append(" ").append(versionInfo()) .append(LINE_SEPARATOR); sb.append(LINE_SEPARATOR); return sb.toString(); } }