/*
 * Copyright (c) 2016, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package jdk.nashorn.internal.runtime;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.StreamTokenizer;
import java.io.StringReader;
import java.lang.ProcessBuilder.Redirect;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static jdk.nashorn.internal.runtime.CommandExecutor.RedirectType.*;
import static jdk.nashorn.internal.runtime.ECMAErrors.rangeError;

/**
 * The CommandExecutor class provides support for Nashorn's $EXEC
 * builtin function. CommandExecutor provides support for command parsing,
 * I/O redirection, piping, completion timeouts, # comments, and simple
 * environment variable management (cd, setenv, and unsetenv).
 */
class CommandExecutor {
    // Size of byte buffers used for piping.
    private static final int BUFFER_SIZE = 1024;

    // Test to see if running on Windows.
    private static final boolean IS_WINDOWS =
        AccessController.doPrivileged((PrivilegedAction<Boolean>)() -> {
        return System.getProperty("os.name").contains("Windows");
    });

    // Cygwin drive alias prefix.
    private static final String CYGDRIVE = "/cygdrive/";

    // User's home directory
    private static final String HOME_DIRECTORY =
        AccessController.doPrivileged((PrivilegedAction<String>)() -> {
        return System.getProperty("user.home");
    });

    // Various types of standard redirects.
    enum RedirectType {
        NO_REDIRECT,
        REDIRECT_INPUT,
        REDIRECT_OUTPUT,
        REDIRECT_OUTPUT_APPEND,
        REDIRECT_ERROR,
        REDIRECT_ERROR_APPEND,
        REDIRECT_OUTPUT_ERROR_APPEND,
        REDIRECT_ERROR_TO_OUTPUT
    };

    // Prefix strings to standard redirects.
    private static final String[] redirectPrefixes = new String[] {
        "<",
        "0<",
        ">",
        "1>",
        ">>",
        "1>>",
        "2>",
        "2>>",
        "&>",
        "2>&1"
    };

    // Map from redirectPrefixes to RedirectType.
    private static final RedirectType[] redirects = new RedirectType[] {
        REDIRECT_INPUT,
        REDIRECT_INPUT,
        REDIRECT_OUTPUT,
        REDIRECT_OUTPUT,
        REDIRECT_OUTPUT_APPEND,
        REDIRECT_OUTPUT_APPEND,
        REDIRECT_ERROR,
        REDIRECT_ERROR_APPEND,
        REDIRECT_OUTPUT_ERROR_APPEND,
        REDIRECT_ERROR_TO_OUTPUT
    };

    /**
     * The RedirectInfo class handles checking the next token in a command
     * to see if it contains a redirect.  If the redirect file does not butt
     * against the prefix, then the next token is consumed.
     */
    private static class RedirectInfo {
        // true if a redirect was encountered on the current command.
        private boolean hasRedirects;
        // Redirect.PIPE or an input redirect from the command line.
        private Redirect inputRedirect;
        // Redirect.PIPE or an output redirect from the command line.
        private Redirect outputRedirect;
        // Redirect.PIPE or an error redirect from the command line.
        private Redirect errorRedirect;
        // true if the error stream should be merged with output.
        private boolean mergeError;

        RedirectInfo() {
            this.hasRedirects = false;
            this.inputRedirect = Redirect.PIPE;
            this.outputRedirect = Redirect.PIPE;
            this.errorRedirect = Redirect.PIPE;
            this.mergeError = false;
        }

        /**
         * check - tests to see if the current token contains a redirect
         * @param token    current command line token
         * @param iterator current command line iterator
         * @param cwd      current working directory
         * @return true if token is consumed
         */
        boolean check(String token, final Iterator<String> iterator, final String cwd) {
            // Iterate through redirect prefixes to file a match.
            for (int i = 0; i < redirectPrefixes.length; i++) {
               final String prefix = redirectPrefixes[i];

               // If a match is found.
                if (token.startsWith(prefix)) {
                    // Indicate we have at least one redirect (efficiency.)
                    hasRedirects = true;
                    // Map prefix to RedirectType.
                    final RedirectType redirect = redirects[i];
                    // Strip prefix from token
                    token = token.substring(prefix.length());

                    // Get file from either current or next token.
                    File file = null;
                    if (redirect != REDIRECT_ERROR_TO_OUTPUT) {
                        // Nothing left of current token.
                        if (token.length() == 0) {
                            if (iterator.hasNext()) {
                                // Use next token.
                                token = iterator.next();
                            } else {
                                // Send to null device if not provided.
                                token = IS_WINDOWS ? "NUL:" : "/dev/null";
                            }
                        }

                        // Redirect file.
                        file = resolvePath(cwd, token).toFile();
                    }

                    // Define redirect based on prefix.
                    switch (redirect) {
                        case REDIRECT_INPUT:
                            inputRedirect = Redirect.from(file);
                            break;
                        case REDIRECT_OUTPUT:
                            outputRedirect = Redirect.to(file);
                            break;
                        case REDIRECT_OUTPUT_APPEND:
                            outputRedirect = Redirect.appendTo(file);
                            break;
                        case REDIRECT_ERROR:
                            errorRedirect = Redirect.to(file);
                            break;
                        case REDIRECT_ERROR_APPEND:
                            errorRedirect = Redirect.appendTo(file);
                            break;
                        case REDIRECT_OUTPUT_ERROR_APPEND:
                            outputRedirect = Redirect.to(file);
                            errorRedirect = Redirect.to(file);
                            mergeError = true;
                            break;
                        case REDIRECT_ERROR_TO_OUTPUT:
                            mergeError = true;
                            break;
                        default:
                            return false;
                    }

                    // Indicate token is consumed.
                    return true;
                }
            }

            // No redirect found.
            return false;
        }

        /**
         * apply - apply the redirects to the current ProcessBuilder.
         * @param pb current ProcessBuilder
         */
        void apply(final ProcessBuilder pb) {
            // Only if there was redirects (saves new structure in ProcessBuilder.)
            if (hasRedirects) {
                // If output and error are the same file then merge.
                final File outputFile = outputRedirect.file();
                final File errorFile = errorRedirect.file();

                if (outputFile != null && outputFile.equals(errorFile)) {
                    mergeError = true;
                }

                // Apply redirects.
                pb.redirectInput(inputRedirect);
                pb.redirectOutput(outputRedirect);
                pb.redirectError(errorRedirect);
                pb.redirectErrorStream(mergeError);
            }
        }
    }

    /**
     * The Piper class is responsible for copying from an InputStream to an
     * OutputStream without blocking the current thread.
     */
    private static class Piper implements java.lang.Runnable {
        // Stream to copy from.
        private final InputStream input;
        // Stream to copy to.
        private final OutputStream output;

        private final Thread thread;

        Piper(final InputStream input, final OutputStream output) {
            this.input = input;
            this.output = output;
            this.thread = new Thread(this, "$EXEC Piper");
        }

        /**
         * start - start the Piper in a new daemon thread
         * @return this Piper
         */
        Piper start() {
            thread.setDaemon(true);
            thread.start();
            return this;
        }

        /**
         * run - thread action
         */
        @Override
        public void run() {
            try {
                // Buffer for copying.
                final byte[] b = new byte[BUFFER_SIZE];
                // Read from the InputStream until EOF.
                int read;
                while (-1 < (read = input.read(b, 0, b.length))) {
                    // Write available date to OutputStream.
                    output.write(b, 0, read);
                }
            } catch (final Exception e) {
                // Assume the worst.
                throw new RuntimeException("Broken pipe", e);
            } finally {
                // Make sure the streams are closed.
                try {
                    input.close();
                } catch (final IOException e) {
                    // Don't care.
                }
                try {
                    output.close();
                } catch (final IOException e) {
                    // Don't care.
                }
            }
        }

        public void join() throws InterruptedException {
            thread.join();
        }

        // Exit thread.
    }

    // Process exit statuses.
    static final int EXIT_SUCCESS  =  0;
    static final int EXIT_FAILURE  =  1;

    // Copy of environment variables used by all processes.
    private  Map<String, String> environment;
    // Input string if provided on CommandExecutor call.
    private String inputString;
    // Output string if required from CommandExecutor call.
    private String outputString;
    // Error string if required from CommandExecutor call.
    private String errorString;
    // Last process exit code.
    private int exitCode;

    // Input stream if provided on CommandExecutor call.
    private InputStream inputStream;
    // Output stream if provided on CommandExecutor call.
    private OutputStream outputStream;
    // Error stream if provided on CommandExecutor call.
    private OutputStream errorStream;

    // Ordered collection of current or piped ProcessBuilders.
    private List<ProcessBuilder> processBuilders = new ArrayList<>();

    CommandExecutor() {
        this.environment = new HashMap<>();
        this.inputString = "";
        this.outputString = "";
        this.errorString = "";
        this.exitCode = EXIT_SUCCESS;
        this.inputStream = null;
        this.outputStream = null;
        this.errorStream = null;
        this.processBuilders = new ArrayList<>();
    }

    /**
     * envVarValue - return the value of the environment variable key, or
     * deflt if not found.
     * @param key   name of environment variable
     * @param deflt value to return if not found
     * @return value of the environment variable
     */
    private String envVarValue(final String key, final String deflt) {
        return environment.getOrDefault(key, deflt);
    }

    /**
     * envVarLongValue - return the value of the environment variable key as a
     * long value.
     * @param key name of environment variable
     * @return long value of the environment variable
     */
    private long envVarLongValue(final String key) {
        try {
            return Long.parseLong(envVarValue(key, "0"));
        } catch (final NumberFormatException ex) {
            return 0L;
        }
    }

    /**
     * envVarBooleanValue - return the value of the environment variable key as a
     * boolean value.  true if the value was non-zero, false otherwise.
     * @param key name of environment variable
     * @return boolean value of the environment variable
     */
    private boolean envVarBooleanValue(final String key) {
        return envVarLongValue(key) != 0;
    }

    /**
     * stripQuotes - strip quotes from token if present. Quoted tokens kept
     * quotes to prevent search for redirects.
     * @param token token to strip
     * @return stripped token
     */
    private static String stripQuotes(String token) {
        if ((token.startsWith("\"") && token.endsWith("\"")) ||
             token.startsWith("\'") && token.endsWith("\'")) {
            token = token.substring(1, token.length() - 1);
        }
        return token;
    }

    /**
     * resolvePath - resolves a path against a current working directory.
     * @param cwd      current working directory
     * @param fileName name of file or directory
     * @return resolved Path to file
     */
    private static Path resolvePath(final String cwd, final String fileName) {
        return Paths.get(sanitizePath(cwd)).resolve(fileName).normalize();
    }

    /**
     * builtIn - checks to see if the command is a builtin and performs
     * appropriate action.
     * @param cmd current command
     * @param cwd current working directory
     * @return true if was a builtin command
     */
    private boolean builtIn(final List<String> cmd, final String cwd) {
        switch (cmd.get(0)) {
            // Set current working directory.
            case "cd":
                final boolean cygpath = IS_WINDOWS && cwd.startsWith(CYGDRIVE);
                // If zero args then use home directory as cwd else use first arg.
                final String newCWD = cmd.size() < 2 ? HOME_DIRECTORY : cmd.get(1);
                // Normalize the cwd
                final Path cwdPath = resolvePath(cwd, newCWD);

                // Check if is a directory.
                final File file = cwdPath.toFile();
                if (!file.exists()) {
                    reportError("file.not.exist", file.toString());
                    return true;
                } else if (!file.isDirectory()) {
                    reportError("not.directory", file.toString());
                    return true;
                }

                // Set PWD environment variable to be picked up as cwd.
                // Make sure Cygwin paths look like Unix paths.
                String scwd = cwdPath.toString();
                if (cygpath && scwd.length() >= 2 &&
                        Character.isLetter(scwd.charAt(0)) && scwd.charAt(1) == ':') {
                    scwd = CYGDRIVE + Character.toLowerCase(scwd.charAt(0)) + "/" + scwd.substring(2);
                }
                environment.put("PWD", scwd);
                return true;

            // Set an environment variable.
            case "setenv":
                if (3 <= cmd.size()) {
                    final String key = cmd.get(1);
                    final String value = cmd.get(2);
                    environment.put(key, value);
                }

                return true;

            // Unset an environment variable.
            case "unsetenv":
                if (2 <= cmd.size()) {
                    final String key = cmd.get(1);
                    environment.remove(key);
                }

                return true;
        }

        return false;
    }

    /**
     * preprocessCommand - scan the command for redirects, and sanitize the
     * executable path
     * @param tokens       command tokens
     * @param cwd          current working directory
     * @param redirectInfo redirection information
     * @return tokens remaining for actual command
     */
    private List<String>  preprocessCommand(final List<String> tokens,
            final String cwd, final RedirectInfo redirectInfo) {
        // Tokens remaining for actual command.
        final List<String> command = new ArrayList<>();

        // iterate through all tokens.
        final Iterator<String> iterator = tokens.iterator();
        while (iterator.hasNext()) {
            final String token = iterator.next();

            // Check if is a redirect.
            if (redirectInfo.check(token, iterator, cwd)) {
                // Don't add to the command.
                continue;
            }

            // Strip quotes and add to command.
            command.add(stripQuotes(token));
        }

        if (command.size() > 0) {
            command.set(0, sanitizePath(command.get(0)));
        }

        return command;
    }

    /**
     * Sanitize a path in case the underlying platform is Cygwin. In that case,
     * convert from the {@code /cygdrive/x} drive specification to the usual
     * Windows {@code X:} format.
     *
     * @param d a String representing a path
     * @return a String representing the same path in a form that can be
     *         processed by the underlying platform
     */
    private static String sanitizePath(final String d) {
        if (!IS_WINDOWS || (IS_WINDOWS && !d.startsWith(CYGDRIVE))) {
            return d;
        }
        final String pd = d.substring(CYGDRIVE.length());
        if (pd.length() >= 2 && pd.charAt(1) == '/') {
            // drive letter plus / -> convert /cygdrive/x/... to X:/...
            return pd.charAt(0) + ":" + pd.substring(1);
        } else if (pd.length() == 1) {
            // just drive letter -> convert /cygdrive/x to X:
            return pd.charAt(0) + ":";
        }
        // remaining case: /cygdrive/ -> can't convert
        return d;
    }

    /**
     * createProcessBuilder - create a ProcessBuilder for the command.
     * @param command      command tokens
     * @param cwd          current working directory
     * @param redirectInfo redirect information
     */
    private void createProcessBuilder(final List<String> command,
            final String cwd, final RedirectInfo redirectInfo) {
        // Create new ProcessBuilder.
        final ProcessBuilder pb = new ProcessBuilder(command);
        // Set current working directory.
        pb.directory(new File(sanitizePath(cwd)));

        // Map environment variables.
        final Map<String, String> processEnvironment = pb.environment();
        processEnvironment.clear();
        processEnvironment.putAll(environment);

        // Apply redirects.
        redirectInfo.apply(pb);
        // Add to current list of commands.
        processBuilders.add(pb);
    }

    /**
     * command - process the command
     * @param tokens  tokens of the command
     * @param isPiped true if the output of this command should be piped to the next
     */
    private void command(final List<String> tokens, final boolean isPiped) {
        // Test to see if we should echo the command to output.
        if (envVarBooleanValue("JJS_ECHO")) {
            System.out.println(String.join(" ", tokens));
        }

        // Get the current working directory.
        final String cwd = envVarValue("PWD", HOME_DIRECTORY);
        // Preprocess the command for redirects.
        final RedirectInfo redirectInfo = new RedirectInfo();
        final List<String> command = preprocessCommand(tokens, cwd, redirectInfo);

        // Skip if empty or a built in.
        if (command.isEmpty() || builtIn(command, cwd)) {
            return;
        }

        // Create ProcessBuilder with cwd and redirects set.
        createProcessBuilder(command, cwd, redirectInfo);

        // If piped, wait for the next command.
        if (isPiped) {
            return;
        }

        // Fetch first and last ProcessBuilder.
        final ProcessBuilder firstProcessBuilder = processBuilders.get(0);
        final ProcessBuilder lastProcessBuilder = processBuilders.get(processBuilders.size() - 1);

        // Determine which streams have not be redirected from pipes.
        boolean inputIsPipe = firstProcessBuilder.redirectInput() == Redirect.PIPE;
        boolean outputIsPipe = lastProcessBuilder.redirectOutput() == Redirect.PIPE;
        boolean errorIsPipe = lastProcessBuilder.redirectError() == Redirect.PIPE;
        final boolean inheritIO = envVarBooleanValue("JJS_INHERIT_IO");

        // If not redirected and inputStream is current processes' input.
        if (inputIsPipe && (inheritIO || inputStream == System.in)) {
            // Inherit current processes' input.
            firstProcessBuilder.redirectInput(Redirect.INHERIT);
            inputIsPipe = false;
        }

        // If not redirected and outputStream is current processes' output.
        if (outputIsPipe && (inheritIO || outputStream == System.out)) {
            // Inherit current processes' output.
            lastProcessBuilder.redirectOutput(Redirect.INHERIT);
            outputIsPipe = false;
        }

        // If not redirected and errorStream is current processes' error.
        if (errorIsPipe && (inheritIO || errorStream == System.err)) {
            // Inherit current processes' error.
            lastProcessBuilder.redirectError(Redirect.INHERIT);
            errorIsPipe = false;
        }

        // Start the processes.
        final List<Process> processes = new ArrayList<>();
        for (final ProcessBuilder pb : processBuilders) {
            try {
                processes.add(pb.start());
            } catch (final IOException ex) {
                reportError("unknown.command", String.join(" ", pb.command()));
                return;
            }
        }

        // Clear processBuilders for next command.
        processBuilders.clear();

        // Get first and last process.
        final Process firstProcess = processes.get(0);
        final Process lastProcess = processes.get(processes.size() - 1);

        // Prepare for string based i/o if no redirection or provided streams.
        ByteArrayOutputStream byteOutputStream = null;
        ByteArrayOutputStream byteErrorStream = null;

        final List<Piper> piperThreads = new ArrayList<>();

        // If input is not redirected.
        if (inputIsPipe) {
            // If inputStream other than System.in is provided.
            if (inputStream != null) {
                // Pipe inputStream to first process output stream.
                piperThreads.add(new Piper(inputStream, firstProcess.getOutputStream()).start());
            } else {
                // Otherwise assume an input string has been provided.
                piperThreads.add(new Piper(new ByteArrayInputStream(inputString.getBytes()), firstProcess.getOutputStream()).start());
            }
        }

        // If output is not redirected.
        if (outputIsPipe) {
            // If outputStream other than System.out is provided.
            if (outputStream != null ) {
                // Pipe outputStream from last process input stream.
                piperThreads.add(new Piper(lastProcess.getInputStream(), outputStream).start());
            } else {
                // Otherwise assume an output string needs to be prepared.
                byteOutputStream = new ByteArrayOutputStream(BUFFER_SIZE);
                piperThreads.add(new Piper(lastProcess.getInputStream(), byteOutputStream).start());
            }
        }

        // If error is not redirected.
        if (errorIsPipe) {
            // If errorStream other than System.err is provided.
            if (errorStream != null) {
                piperThreads.add(new Piper(lastProcess.getErrorStream(), errorStream).start());
            } else {
                // Otherwise assume an error string needs to be prepared.
                byteErrorStream = new ByteArrayOutputStream(BUFFER_SIZE);
                piperThreads.add(new Piper(lastProcess.getErrorStream(), byteErrorStream).start());
            }
        }

        // Pipe commands in between.
        for (int i = 0, n = processes.size() - 1; i < n; i++) {
            final Process prev = processes.get(i);
            final Process next = processes.get(i + 1);
            piperThreads.add(new Piper(prev.getInputStream(), next.getOutputStream()).start());
        }

        // Wind up processes.
        try {
            // Get the user specified timeout.
            final long timeout = envVarLongValue("JJS_TIMEOUT");

            // If user specified timeout (milliseconds.)
            if (timeout != 0) {
                // Wait for last process, with timeout.
                if (lastProcess.waitFor(timeout, TimeUnit.MILLISECONDS)) {
                    // Get exit code of last process.
                    exitCode = lastProcess.exitValue();
                } else {
                    reportError("timeout", Long.toString(timeout));
                 }
            } else {
                // Wait for last process and get exit code.
                exitCode = lastProcess.waitFor();
            }
            // Wait for all piper threads to terminate
            for (final Piper piper : piperThreads) {
                piper.join();
            }

            // Accumulate the output and error streams.
            outputString += byteOutputStream != null ? byteOutputStream.toString() : "";
            errorString += byteErrorStream != null ? byteErrorStream.toString() : "";
        } catch (final InterruptedException ex) {
            // Kill any living processes.
            processes.stream().forEach(p -> {
                if (p.isAlive()) {
                    p.destroy();
                }

                // Get the first error code.
                exitCode = exitCode == 0 ? p.exitValue() : exitCode;
            });
        }

        // If we got a non-zero exit code then possibly throw an exception.
        if (exitCode != 0 && envVarBooleanValue("JJS_THROW_ON_EXIT")) {
            throw rangeError("exec.returned.non.zero", ScriptRuntime.safeToString(exitCode));
        }
    }

    /**
     * createTokenizer - build up StreamTokenizer for the command script
     * @param script command script to parsed
     * @return StreamTokenizer for command script
     */
    private static StreamTokenizer createTokenizer(final String script) {
        final StreamTokenizer tokenizer = new StreamTokenizer(new StringReader(script));
        tokenizer.resetSyntax();
        // Default all characters to word.
        tokenizer.wordChars(0, 255);
        // Spaces and special characters are white spaces.
        tokenizer.whitespaceChars(0, ' ');
        // Ignore # comments.
        tokenizer.commentChar('#');
        // Handle double and single quote strings.
        tokenizer.quoteChar('"');
        tokenizer.quoteChar('\'');
        // Need to recognize the end of a command.
        tokenizer.eolIsSignificant(true);
        // Command separator.
        tokenizer.ordinaryChar(';');
        // Pipe separator.
        tokenizer.ordinaryChar('|');

        return tokenizer;
    }

    /**
     * process - process a command string
     * @param script command script to parsed
     */
    void process(final String script) {
        // Build up StreamTokenizer for the command script.
        final StreamTokenizer tokenizer = createTokenizer(script);

        // Prepare to accumulate command tokens.
        final List<String> command = new ArrayList<>();
        // Prepare to acumulate partial tokens joined with "\ ".
        final StringBuilder sb = new StringBuilder();

        try {
            // Fetch next token until end of script.
            while (tokenizer.nextToken() != StreamTokenizer.TT_EOF) {
                // Next word token.
                String token = tokenizer.sval;

                // If special token.
                if (token == null) {
                    // Flush any partial token.
                    if (sb.length() != 0) {
                        command.add(sb.append(token).toString());
                        sb.setLength(0);
                    }

                    // Process a completed command.
                    // Will be either ';' (command end) or '|' (pipe), true if '|'.
                    command(command, tokenizer.ttype == '|');

                    if (exitCode != EXIT_SUCCESS) {
                        return;
                    }

                    // Start with a new set of tokens.
                    command.clear();
                } else if (token.endsWith("\\")) {
                    // Backslash followed by space.
                    sb.append(token.substring(0, token.length() - 1)).append(' ');
                } else if (sb.length() == 0) {
                    // If not a word then must be a quoted string.
                    if (tokenizer.ttype != StreamTokenizer.TT_WORD) {
                        // Quote string, sb is free to use (empty.)
                        sb.append((char)tokenizer.ttype);
                        sb.append(token);
                        sb.append((char)tokenizer.ttype);
                        token = sb.toString();
                        sb.setLength(0);
                    }

                    command.add(token);
                } else {
                    // Partial token pending.
                    command.add(sb.append(token).toString());
                    sb.setLength(0);
                }
            }
        } catch (final IOException ex) {
            // Do nothing.
        }

        // Partial token pending.
        if (sb.length() != 0) {
            command.add(sb.toString());
        }

        // Process last command.
        command(command, false);
    }

    /**
     * process - process a command array of strings
     * @param tokens command script to be processed
     */
    void process(final List<String> tokens) {
        // Prepare to accumulate command tokens.
        final List<String> command = new ArrayList<>();

        // Iterate through tokens.
        final Iterator<String> iterator = tokens.iterator();
        while (iterator.hasNext() && exitCode == EXIT_SUCCESS) {
            // Next word token.
            final String token = iterator.next();

            if (token == null) {
                continue;
            }

            switch (token) {
                case "|":
                    // Process as a piped command.
                    command(command, true);
                    // Start with a new set of tokens.
                    command.clear();

                    continue;
                case ";":
                    // Process as a normal command.
                    command(command, false);
                    // Start with a new set of tokens.
                    command.clear();

                    continue;
            }

            command.add(token);
        }

        // Process last command.
        command(command, false);
    }

    void reportError(final String msg, final String object) {
        errorString += ECMAErrors.getMessage("range.error.exec." + msg, object);
        exitCode = EXIT_FAILURE;
    }

    String getOutputString() {
        return outputString;
    }

    String getErrorString() {
        return errorString;
    }

    int getExitCode() {
        return exitCode;
    }

    void setEnvironment(final Map<String, String> environment) {
        this.environment = environment;
    }

    void setInputStream(final InputStream inputStream) {
        this.inputStream = inputStream;
    }

    void setInputString(final String inputString) {
        this.inputString = inputString;
    }

    void setOutputStream(final OutputStream outputStream) {
        this.outputStream = outputStream;
    }

    void setErrorStream(final OutputStream errorStream) {
        this.errorStream = errorStream;
    }
}
