CommandLine.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 *  contributor license agreements.  See the NOTICE file distributed with
 *  this work for additional information regarding copyright ownership.
 *  The ASF licenses this file to You under the Apache License, Version 2.0
 *  (the "License"); you may not use this file except in compliance with
 *  the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package org.apache.commons.exec;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.StringTokenizer;
import java.util.Vector;

import org.apache.commons.exec.util.StringUtils;

/**
 * CommandLine objects help handling command lines specifying processes to execute. The class can be used to a command line by an application.
 */
public class CommandLine {

    /**
     * Encapsulates a command line argument.
     */
    static final class Argument {

        private final String value;
        private final boolean handleQuoting;

        private Argument(final String value, final boolean handleQuoting) {
            this.value = value.trim();
            this.handleQuoting = handleQuoting;
        }

        private String getValue() {
            return value;
        }

        private boolean isHandleQuoting() {
            return handleQuoting;
        }
    }

    /**
     * Create a command line from a string.
     *
     * @param line the first element becomes the executable, the rest the arguments.
     * @return the parsed command line.
     * @throws IllegalArgumentException If line is null or all whitespace.
     */
    public static CommandLine parse(final String line) {
        return parse(line, null);
    }

    /**
     * Create a command line from a string.
     *
     * @param line            the first element becomes the executable, the rest the arguments.
     * @param substitutionMap the name/value pairs used for substitution.
     * @return the parsed command line.
     * @throws IllegalArgumentException If line is null or all whitespace.
     */
    public static CommandLine parse(final String line, final Map<String, ?> substitutionMap) {

        if (line == null) {
            throw new IllegalArgumentException("Command line can not be null");
        }
        if (line.trim().isEmpty()) {
            throw new IllegalArgumentException("Command line can not be empty");
        }
        final String[] tmp = translateCommandline(line);

        final CommandLine cl = new CommandLine(tmp[0]);
        cl.setSubstitutionMap(substitutionMap);
        for (int i = 1; i < tmp.length; i++) {
            cl.addArgument(tmp[i]);
        }

        return cl;
    }

    /**
     * Crack a command line.
     *
     * @param toProcess the command line to process.
     * @return the command line broken into strings. An empty or null toProcess parameter results in a zero sized array.
     */
    private static String[] translateCommandline(final String toProcess) {
        if (toProcess == null || toProcess.trim().isEmpty()) {
            // no command? no string
            return new String[0];
        }

        // parse with a simple finite state machine.

        final int normal = 0;
        final int inQuote = 1;
        final int inDoubleQuote = 2;
        int state = normal;
        final StringTokenizer tok = new StringTokenizer(toProcess, "\"\' ", true);
        final ArrayList<String> list = new ArrayList<>();
        StringBuilder current = new StringBuilder();
        boolean lastTokenHasBeenQuoted = false;

        while (tok.hasMoreTokens()) {
            final String nextTok = tok.nextToken();
            switch (state) {
            case inQuote:
                if ("\'".equals(nextTok)) {
                    lastTokenHasBeenQuoted = true;
                    state = normal;
                } else {
                    current.append(nextTok);
                }
                break;
            case inDoubleQuote:
                if ("\"".equals(nextTok)) {
                    lastTokenHasBeenQuoted = true;
                    state = normal;
                } else {
                    current.append(nextTok);
                }
                break;
            default:
                if ("\'".equals(nextTok)) {
                    state = inQuote;
                } else if ("\"".equals(nextTok)) {
                    state = inDoubleQuote;
                } else if (" ".equals(nextTok)) {
                    if (lastTokenHasBeenQuoted || current.length() != 0) {
                        list.add(current.toString());
                        current = new StringBuilder();
                    }
                } else {
                    current.append(nextTok);
                }
                lastTokenHasBeenQuoted = false;
                break;
            }
        }

        if (lastTokenHasBeenQuoted || current.length() != 0) {
            list.add(current.toString());
        }

        if (state == inQuote || state == inDoubleQuote) {
            throw new IllegalArgumentException("Unbalanced quotes in " + toProcess);
        }

        final String[] args = new String[list.size()];
        return list.toArray(args);
    }

    /**
     * The arguments of the command.
     */
    private final Vector<Argument> arguments = new Vector<>();

    /**
     * The program to execute.
     */
    private final String executable;

    /**
     * A map of name value pairs used to expand command line arguments.
     */
    private Map<String, ?> substitutionMap; // N.B. This can contain values other than Strings.

    /**
     * Tests whether a file was used to set the executable.
     */
    private final boolean isFile;

    /**
     * Copy constructor.
     *
     * @param other the instance to copy.
     */
    public CommandLine(final CommandLine other) {
        this.executable = other.getExecutable();
        this.isFile = other.isFile();
        this.arguments.addAll(other.arguments);

        if (other.getSubstitutionMap() != null) {
            this.substitutionMap = new HashMap<>(other.getSubstitutionMap());
        }
    }

    /**
     * Create a command line without any arguments.
     *
     * @param executable the executable file.
     */
    public CommandLine(final File executable) {
        this.isFile = true;
        this.executable = toCleanExecutable(executable.getAbsolutePath());
    }

    /**
     * Create a command line without any arguments.
     *
     * @param executable the executable.
     * @throws NullPointerException     on null input.
     * @throws IllegalArgumentException on empty input.
     */
    public CommandLine(final String executable) {
        this.isFile = false;
        this.executable = toCleanExecutable(executable);
    }

    /**
     * Add a single argument. Handles quoting.
     *
     * @param argument The argument to add.
     * @return The command line itself.
     * @throws IllegalArgumentException If argument contains both single and double quotes.
     */
    public CommandLine addArgument(final String argument) {
        return addArgument(argument, true);
    }

    /**
     * Add a single argument.
     *
     * @param argument      The argument to add.
     * @param handleQuoting Add the argument with/without handling quoting.
     * @return The command line itself.
     */
    public CommandLine addArgument(final String argument, final boolean handleQuoting) {

        if (argument == null) {
            return this;
        }

        // check if we can really quote the argument - if not throw an
        // IllegalArgumentException
        if (handleQuoting) {
            StringUtils.quoteArgument(argument);
        }

        arguments.add(new Argument(argument, handleQuoting));
        return this;
    }

    /**
     * Add multiple arguments. Handles parsing of quotes and whitespace. Please note that the parsing can have undesired side-effects therefore it is
     * recommended to build the command line incrementally.
     *
     * @param addArguments An string containing multiple arguments.
     * @return The command line itself.
     */
    public CommandLine addArguments(final String addArguments) {
        return addArguments(addArguments, true);
    }

    /**
     * Add multiple arguments. Handles parsing of quotes and whitespace. Please note that the parsing can have undesired side-effects therefore it is
     * recommended to build the command line incrementally.
     *
     * @param addArguments  An string containing multiple arguments.
     * @param handleQuoting Add the argument with/without handling quoting.
     * @return The command line itself.
     */
    public CommandLine addArguments(final String addArguments, final boolean handleQuoting) {
        if (addArguments != null) {
            final String[] argumentsArray = translateCommandline(addArguments);
            addArguments(argumentsArray, handleQuoting);
        }

        return this;
    }

    /**
     * Add multiple arguments. Handles parsing of quotes and whitespace.
     *
     * @param addArguments An array of arguments.
     * @return The command line itself.
     */
    public CommandLine addArguments(final String[] addArguments) {
        return addArguments(addArguments, true);
    }

    /**
     * Add multiple arguments.
     *
     * @param addArguments  An array of arguments.
     * @param handleQuoting Add the argument with/without handling quoting.
     * @return The command line itself.
     */
    public CommandLine addArguments(final String[] addArguments, final boolean handleQuoting) {
        if (addArguments != null) {
            for (final String addArgument : addArguments) {
                addArgument(addArgument, handleQuoting);
            }
        }
        return this;
    }

    /**
     * Expand variables in a command line argument.
     *
     * @param argument the argument.
     * @return the expanded string.
     */
    private String expandArgument(final String argument) {
        final StringBuffer stringBuffer = StringUtils.stringSubstitution(argument, getSubstitutionMap(), true);
        return stringBuffer.toString();
    }

    /**
     * Gets the expanded and quoted command line arguments.
     *
     * @return The quoted arguments.
     */
    public String[] getArguments() {

        Argument currArgument;
        String expandedArgument;
        final String[] result = new String[arguments.size()];

        for (int i = 0; i < result.length; i++) {
            currArgument = arguments.get(i);
            expandedArgument = expandArgument(currArgument.getValue());
            result[i] = currArgument.isHandleQuoting() ? StringUtils.quoteArgument(expandedArgument) : expandedArgument;
        }

        return result;
    }

    /**
     * Gets the executable.
     *
     * @return The executable.
     */
    public String getExecutable() {
        // Expand the executable and replace '/' and '\\' with the platform
        // specific file separator char. This is safe here since we know
        // that this is a platform specific command.
        return StringUtils.fixFileSeparatorChar(expandArgument(executable));
    }

    /**
     * Gets the substitution map.
     *
     * @return the substitution map.
     */
    public Map<String, ?> getSubstitutionMap() {
        return substitutionMap;
    }

    /**
     * Tests whether a file was used to set the executable.
     *
     * @return true whether a file was used for setting the executable.
     */
    public boolean isFile() {
        return isFile;
    }

    /**
     * Sets the substitutionMap to expand variables in the command line.
     *
     * @param substitutionMap the map
     */
    public void setSubstitutionMap(final Map<String, ?> substitutionMap) {
        this.substitutionMap = substitutionMap;
    }

    /**
     * Cleans the executable string. The argument is trimmed and '/' and '\\' are replaced with the platform specific file separator char
     *
     * @param dirtyExecutable the executable.
     * @return the platform-specific executable string.
     * @throws NullPointerException     on null input.
     * @throws IllegalArgumentException on empty input.
     */
    private String toCleanExecutable(final String dirtyExecutable) {
        Objects.requireNonNull(dirtyExecutable, "dirtyExecutable");
        if (dirtyExecutable.trim().isEmpty()) {
            throw new IllegalArgumentException("Executable can not be empty");
        }
        return StringUtils.fixFileSeparatorChar(dirtyExecutable);
    }

    /**
     * Stringify operator returns the command line as a string. Parameters are correctly quoted when containing a space or left untouched if the are already
     * quoted.
     *
     * @return the command line as single string.
     */
    @Override
    public String toString() {
        return "[" + String.join(", ", toStrings()) + "]";
    }

    /**
     * Converts the command line as an array of strings.
     *
     * @return The command line as an string array.
     */
    public String[] toStrings() {
        final String[] result = new String[arguments.size() + 1];
        result[0] = getExecutable();
        System.arraycopy(getArguments(), 0, result, 1, result.length - 1);
        return result;
    }
}