CommandLineParser.java

/*
 * Copyright 2017-2020 original authors
 *
 * Licensed 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
 *
 * https://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 io.micronaut.core.cli;

import io.micronaut.core.annotation.Internal;
import io.micronaut.core.cli.exceptions.ParseException;
import io.micronaut.core.util.StringUtils;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.StringTokenizer;

import static io.micronaut.core.util.StringUtils.EMPTY_STRING_ARRAY;

/**
 * Command line parser that parses arguments to the command line. Written as a
 * replacement for Commons CLI because it doesn't support unknown arguments and
 * requires all arguments to be declared up front.
 * <p>
 * It also doesn't support command options with hyphens. This class gets around those problems.
 *
 * @author Graeme Rocher
 * @since 1.0
 */
@Internal
class CommandLineParser implements CommandLine.Builder<CommandLineParser> {

    private static final String DEFAULT_PADDING = "        ";

    private Map<String, Option> declaredOptions = new HashMap<>();
    private int longestOptionNameLength = 0;
    private String usageMessage;

    /**
     * Adds a declared option.
     *
     * @param name        The name of the option
     * @param description The description
     */
    @Override
    public CommandLineParser addOption(String name, String description) {
        int length = name.length();
        if (length > longestOptionNameLength) {
            longestOptionNameLength = length;
        }
        declaredOptions.put(name, new Option(name, description));
        return this;
    }

    @Override
    public CommandLine parseString(String string) {
        // Steal ants implementation for argument splitting. Handles quoted arguments with " or '.
        // Doesn't handle escape sequences with \
        return parse(translateCommandline(string));
    }

    @Override
    public CommandLine parse(String... args) {
        DefaultCommandLine cl = createCommandLine();
        return parse(cl, args);
    }

    /**
     * Parse the command line entry.
     * @param cl commandLine
     * @param args args passed in
     * @return commandLine
     */
    CommandLine parse(DefaultCommandLine cl, String[] args) {
        parseInternal(cl, args);
        return cl;
    }

    /**
     * Build the options message.
     * @return message
     */
    public String getOptionsHelpMessage() {
        String ls = System.getProperty("line.separator");
        usageMessage = "Available options:";
        StringBuilder sb = new StringBuilder(usageMessage);
        sb.append(ls);
        for (Option option : declaredOptions.values()) {
            String name = option.getName();
            int extraPadding = longestOptionNameLength - name.length();
            sb.append(" -").append(name);
            for (int i = 0; i < extraPadding; i++) {
                sb.append(' ');
            }
            sb.append(DEFAULT_PADDING).append(option.getDescription()).append(ls);
        }

        return sb.toString();
    }

    private void parseInternal(DefaultCommandLine cl, String[] args) {
        cl.setRawArguments(args);
        String lastOptionName = null;
        for (String arg : args) {
            if (arg == null) {
                continue;
            }
            String trimmed = arg.trim();
            if (StringUtils.isNotEmpty(trimmed)) {
                if (trimmed.charAt(0) == '"' && trimmed.charAt(trimmed.length() - 1) == '"') {
                    trimmed = trimmed.substring(1, trimmed.length() - 1);
                }
                if (trimmed.charAt(0) == '-') {
                    lastOptionName = processOption(cl, trimmed);
                } else {
                    if (lastOptionName != null) {
                        Option opt = declaredOptions.get(lastOptionName);
                        if (opt != null) {
                            cl.addDeclaredOption(opt, trimmed);
                        } else {
                            cl.addUndeclaredOption(lastOptionName, trimmed);
                        }
                        lastOptionName = null;
                    } else {
                        cl.addRemainingArg(trimmed);
                    }
                }
            }
        }
    }

    /**
     * Create a default command line.
     * @return commandLine
     */
    protected DefaultCommandLine createCommandLine() {
        return new DefaultCommandLine();
    }

    /**
     * Process the passed in options.
     * @param cl cl
     * @param arg arg
     * @return argument processed
     */
    protected String processOption(DefaultCommandLine cl, String arg) {
        if (arg.length() < 2) {
            return null;
        }

        if (arg.charAt(1) == 'D' && arg.contains("=")) {
            processSystemArg(cl, arg);
            return null;
        }

        arg = (arg.charAt(1) == '-' ? arg.substring(2, arg.length()) : arg.substring(1, arg.length())).trim();

        if (arg.contains("=")) {
            String[] split = arg.split("=", 2);
            String name = split[0].trim();
            validateOptionName(name);
            String value = split.length > 1 ? split[1].trim() : "";
            if (declaredOptions.containsKey(name)) {
                cl.addDeclaredOption(declaredOptions.get(name), value);
            } else {
                cl.addUndeclaredOption(name, value);
            }
            return null;
        }

        validateOptionName(arg);
        if (declaredOptions.containsKey(arg)) {
            cl.addDeclaredOption(declaredOptions.get(arg));
        } else {
            cl.addUndeclaredOption(arg);
        }
        return arg;
    }

    /**
     * Process System property arg.
     * @param cl cl
     * @param arg system arg
     */
    protected void processSystemArg(DefaultCommandLine cl, String arg) {
        int i = arg.indexOf('=');
        String name = arg.substring(2, i);
        String value = arg.substring(i + 1, arg.length());
        cl.addSystemProperty(name, value);
    }

    private void validateOptionName(String name) {
        if (name.contains(" ")) {
            throw new ParseException("Invalid argument: " + name);
        }
    }

    /**
     * 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.
     */
    static String[] translateCommandline(String toProcess) {
        if (toProcess == null || toProcess.isEmpty()) {
            //no command? no string
            return EMPTY_STRING_ARRAY;
        }
        // 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> result = new ArrayList<>();
        final StringBuilder current = new StringBuilder();
        boolean lastTokenHasBeenQuoted = false;

        while (tok.hasMoreTokens()) {
            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.isEmpty()) {
                            result.add(current.toString());
                            current.setLength(0);
                        }
                    } else {
                        current.append(nextTok);
                    }
                    lastTokenHasBeenQuoted = false;
                    break;
            }
        }
        if (lastTokenHasBeenQuoted || !current.isEmpty()) {
            result.add(current.toString());
        }
        if (state == inQuote || state == inDoubleQuote) {
            throw new ParseException("unbalanced quotes in " + toProcess);
        }
        return result.toArray(EMPTY_STRING_ARRAY);
    }
}