com.github.rinde.rinsim.cli.Menu.java Source code

Java tutorial

Introduction

Here is the source code for com.github.rinde.rinsim.cli.Menu.java

Source

/*
 * Copyright (C) 2011-2016 Rinde van Lon, iMinds-DistriNet, KU Leuven
 *
 * 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
 *
 *         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 com.github.rinde.rinsim.cli;

import static com.github.rinde.rinsim.cli.CliException.checkAlreadySelected;
import static com.github.rinde.rinsim.cli.CliException.checkCommand;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Verify.verifyNotNull;
import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.collect.Maps.newLinkedHashMap;
import static com.google.common.collect.Sets.newLinkedHashSet;

import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.Nullable;

import com.github.rinde.rinsim.cli.CliException.CauseType;
import com.github.rinde.rinsim.cli.Option.OptionArg;
import com.github.rinde.rinsim.cli.Option.OptionNoArg;
import com.google.common.base.Joiner;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterators;
import com.google.common.collect.PeekingIterator;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;

/**
 * A menu is the main class for a command-line interface. It contains all
 * options and via the {@link #execute(String...)} method the command-line
 * arguments are parsed and handled. Instances can be constructed via the
 * {@link #builder()} method.
 * @author Rinde van Lon
 */
public final class Menu {
    final String header;
    final String footer;
    final String cmdLineSyntax;
    final ImmutableMap<String, OptionParser> optionMap;
    final ImmutableList<ImmutableSet<Option>> groups;
    final ImmutableMultimap<Option, Option> groupMap;
    final HelpFormatter helpFormatter;

    Menu(Builder b) {
        header = b.header;
        footer = b.footer;
        cmdLineSyntax = b.cmdLineSyntax;
        optionMap = ImmutableMap.copyOf(b.optionMap);
        helpFormatter = b.helpFormatter;

        final ImmutableList.Builder<ImmutableSet<Option>> groupsBuilder = ImmutableList.builder();
        final ImmutableMultimap.Builder<Option, Option> groups2Builder = ImmutableMultimap.builder();
        for (final Set<Option> group : b.groups) {
            groupsBuilder.add(ImmutableSet.copyOf(group));
            for (final Option opt : group) {
                final Set<Option> groupWithoutMe = newLinkedHashSet(group);
                groupWithoutMe.remove(opt);
                groups2Builder.putAll(opt, groupWithoutMe);
            }
        }
        groups = groupsBuilder.build();
        groupMap = groups2Builder.build();
    }

    /**
     * Same as {@link #execute(String...)} but catches all thrown
     * {@link CliException}s. If an exception is thrown it's message will be added
     * to the returned error message.
     * @param args The arguments to parse.
     * @return A string containing an error message or {@link Optional#absent()}
     *         if no error occurred.
     */
    public Optional<String> safeExecute(String... args) {
        try {
            return execute(args);
        } catch (final CliException e) {
            return Optional.of(Joiner.on("\n").join(e.getMessage(), printHelp()));
        }
    }

    /**
     * Parses and executes the provided command-line arguments.
     * @param args The arguments to parse.
     * @return A string containing the help message, or {@link Optional#absent()}
     *         if no help was requested.
     * @throws CliException If anything in the parsing or execution went wrong.
     */
    public Optional<String> execute(String... args) {
        final PeekingIterator<String> it = Iterators.peekingIterator(Iterators.forArray(args));
        final Set<Option> selectedOptions = newLinkedHashSet();
        while (it.hasNext()) {
            final String arg = it.next();
            final Optional<OptionParser> optParser = parseOption(arg);

            checkCommand(optParser.isPresent(), "Found unrecognized command: '%s'.", arg);
            checkAlreadySelected(!selectedOptions.contains(optParser.get().getOption()),
                    optParser.get().getOption(), "Option is already selected: %s.", optParser.get().getOption());

            if (groupMap.containsKey(optParser.get().getOption())) {
                // this option is part of a option group
                final SetView<Option> intersect = Sets.intersection(selectedOptions,
                        newLinkedHashSet(groupMap.get(optParser.get().getOption())));

                checkAlreadySelected(intersect.isEmpty(), optParser.get().getOption(),
                        "An option from the same group as '%s' has already been selected: " + "'%s'.",
                        optParser.get().getOption(), intersect);
            }

            selectedOptions.add(optParser.get().getOption());
            if (optParser.get().getOption().isHelpOption()) {
                return Optional.of(printHelp());
            }
            final List<String> arguments = newArrayList();
            // if a non-option string is following the current option, it must be
            // the argument of the current option.
            while (it.hasNext() && !parseOption(it.peek()).isPresent()) {
                arguments.add(it.next());
            }
            try {
                optParser.get().parse(arguments);
            } catch (IllegalArgumentException | IllegalStateException e) {
                throw new CliException(e.getMessage(), e, CauseType.HANDLER_FAILURE, optParser.get().getOption());
            }
        }
        return Optional.absent();
    }

    /**
     * @return The header of the menu.
     */
    public String getHeader() {
        return header;
    }

    /**
     * @return The footer of the menu.
     */
    public String getFooter() {
        return footer;
    }

    /**
     * @return The command-line syntax of the menu.
     */
    public String getCmdLineSyntax() {
        return cmdLineSyntax;
    }

    Optional<OptionParser> parseOption(String arg) {
        if (arg.charAt(0) == '-') {
            final String optName;
            if (arg.startsWith("--")) {
                optName = arg.substring(2);
            } else {
                optName = arg.substring(1);
            }
            if (optionMap.containsKey(optName)) {
                return Optional.of(optionMap.get(optName));
            }
        }
        return Optional.absent();
    }

    /**
     * @return The help message as defined by this menu.
     */
    public String printHelp() {
        return helpFormatter.format(this);
    }

    /**
     * @return A list containing all options sorted by their short name.
     */
    public ImmutableList<Option> getOptions() {
        final List<Option> options = newArrayList();
        for (final OptionParser exec : newLinkedHashSet(optionMap.values())) {
            options.add(exec.getOption());
        }
        Collections.sort(options, new Comparator<Option>() {
            @Override
            public int compare(@Nullable Option o1, @Nullable Option o2) {
                return verifyNotNull(o1).getShortName().compareTo(verifyNotNull(o2).getShortName());
            }
        });
        return ImmutableList.copyOf(options);
    }

    /**
     * Checks whether the specified option name is an option in this menu.
     * @param optionName The option name to check.
     * @return <code>true</code> if this menu has an option with the specified
     *         option name, <code>false</code> otherwise.
     */
    public boolean containsOption(String optionName) {
        return optionMap.containsKey(optionName);
    }

    /**
     * @return The set of option names this menu supports.
     */
    public ImmutableSet<String> getOptionNames() {
        return optionMap.keySet();
    }

    /**
     * Construct a new builder for creating a command-line interface menu.
     * @return A new builder instance.
     */
    public static Builder builder() {
        return new Builder();
    }

    static void unexpectedArgument(List<String> argument, Option option) {
        if (!argument.isEmpty()) {
            throw new CliException(
                    String.format("The option %s does not support an argument. Found '%s'.", option, argument),
                    CauseType.UNEXPECTED_ARG, option);
        }
    }

    /**
     * Builder for creating {@link Menu} instances.
     * @author Rinde van Lon
     */
    public static final class Builder {
        HelpFormatter helpFormatter;
        String header;
        String footer;
        String cmdLineSyntax;
        Map<String, OptionParser> optionMap;
        List<Set<Option>> groups;
        boolean buildingGroup;
        Set<String> optionNames;
        boolean addedHelpOption;

        Builder() {
            header = "";
            footer = "";
            cmdLineSyntax = "java -jar jarname <options>";
            optionMap = newLinkedHashMap();
            groups = newArrayList();
            buildingGroup = false;
            addedHelpOption = false;
            optionNames = newLinkedHashSet();
            helpFormatter = DefaultHelpFormatter.INSTANCE;
        }

        /**
         * Add an command-line option that expects an argument.
         * @param option The option instance.
         * @param subject The subject which will be passed to the handler.
         * @param handler The handler which will be called when this option is
         *          activated in the menu. The handler will receive all parsed
         *          arguments belonging to this option.
         * @param <V> The type of argument.
         * @param <S> The type of the subject.
         * @return This, as per the builder pattern.
         */
        public <V, S> Builder add(OptionArg<V> option, S subject, ArgHandler<S, V> handler) {
            add(new ArgParser<>(option, subject, handler));
            return this;
        }

        /**
         * Add an command-line option that does not expect an argument.
         * @param option The option instance.
         * @param subject The subject which will be passed to the handler.
         * @param handler The handler which will be called when this option is
         *          activated in the menu.
         * @param <S> The type of the subject.
         * @return This, as per the builder pattern.
         */
        public <S> Builder add(OptionNoArg option, S subject, NoArgHandler<S> handler) {
            add(new NoArgParser<>(option, subject, handler));
            return this;
        }

        /**
         * Add a help option. A help option is a special option that will trigger
         * the display of the help menu. A help option may not be added to a group.
         * @param sn The short name of the help option.
         * @param ln The long name of the help option.
         * @param desc The description of the help option.
         * @return This, as per the builder pattern.
         */
        public Builder addHelpOption(String sn, String ln, String desc) {
            checkState(!buildingGroup, "A help option can not be added to a group.");
            final OptionNoArg option = Option.builder(sn).longName(ln).description(desc).buildHelpOption();
            add(new HelpParser(option));
            addedHelpOption = true;
            return this;
        }

        /**
         * Sets a {@link HelpFormatter}. If this method is not called the
         * {@link DefaultHelpFormatter} will be used.
         * @param formatter The formatter to use.
         * @return This, as per the builder pattern.
         */
        public Builder helpFormatter(HelpFormatter formatter) {
            helpFormatter = formatter;
            return this;
        }

        /**
         * Flags the start of the creation of a new group. A group is a set of
         * options which may not be selected at the same time. All options that are
         * added after this method is called and before a call to
         * {@link #closeGroup()} are part of this group. A group must contain at
         * least 2 options, any attempt to create a group with less than 2 options
         * will throw an {@link IllegalArgumentException}. If a group has previously
         * been under construction this method will automatically call
         * {@link #closeGroup()} to close the previous group and start a new group.
         * <p>
         * <b>Example:</b><br>
         * This code will construct two groups, one containing two options and one
         * containing three options.
         * 
         * <pre>
         * {@code
         * Builder b = Menu.builder();
         * 
         * b.openGroup()
         * .add(..).add(..)
         * .openGroup()
         * .add(..).add(..).add(..)
         * .closeGroup();
         * }
         * </pre>
         * 
         * @return This, as per the builder pattern.
         */
        public Builder openGroup() {
            if (buildingGroup) {
                closeGroup();
            }
            buildingGroup = true;
            groups.add(Sets.<Option>newLinkedHashSet());
            return this;
        }

        /**
         * Flags the end of the creation of a group which was previously started
         * with {@link #openGroup()}.
         * @return This, as per the builder pattern.
         */
        public Builder closeGroup() {
            buildingGroup = false;
            final int groupOptions = groups.get(groups.size() - 1).size();
            checkArgument(groupOptions >= 2,
                    "At least two options need to be added to a group, found %s " + "option(s).", groupOptions);
            return this;
        }

        /**
         * Sets the header which may be displayed in the help menu. How it is shown
         * depends on the {@link HelpFormatter} that is used.
         * @param string The string to use as header.
         * @return This, as per the builder pattern.
         */
        public Builder header(String string) {
            header = string;
            return this;
        }

        /**
         * Sets the footer which may be displayed in the help menu. How it is shown
         * depends on the {@link HelpFormatter} that is used.
         * @param string The string to use as footer.
         * @return This, as per the builder pattern.
         */
        public Builder footer(String string) {
            footer = string;
            return this;
        }

        /**
         * Sets the command-line syntax, this can be displayed in the help menu. How
         * it is shown depends on the {@link HelpFormatter} that is used.
         * @param string The string that shows the command-line syntax.
         * @return This, as per the builder pattern.
         */
        public Builder commandLineSyntax(String string) {
            cmdLineSyntax = string;
            return this;
        }

        /**
         * Add the specified menu as a sub menu into the menu that this builder
         * instance is constructing. Each option from the specified menu will be
         * added to this builder with the specified prefixes. Help options are
         * ignored and will not be added to the new menu.
         * <p>
         * <b>Example:</b><br>
         * If a menu with options <code>(a, add), (b), (c, construct)</code> is
         * added with <code>shortPrefix = 's', longPrefix = 'sub.'</code>, the
         * resulting menu will be
         * <code>(sa, sub.add),(sb),(sc, sub.construct)</code>.
         * 
         * @param shortPrefix The prefix to use for the short option names.
         * @param longPrefix The prefix to use for the long option names.
         * @param menu The menu to add as a sub menu.
         * @return This, as per the builder pattern.
         */
        public Builder addSubMenu(String shortPrefix, String longPrefix, Menu menu) {
            checkArgument(shortPrefix.matches(Option.NAME_REGEX), "The short prefix may not be an empty string.");
            checkArgument(longPrefix.matches(Option.NAME_REGEX), "The long prefix may not be an empty string.");
            checkState(!buildingGroup, "A submenu can not be added inside a group. First close the group "
                    + "before adding a submenu.");

            final Set<OptionParser> newOptions = newLinkedHashSet(menu.optionMap.values());
            for (final Set<Option> group : menu.groups) {
                openGroup();
                for (final Option option : group) {
                    final OptionParser exec = menu.optionMap.get(option.getShortName());
                    add(adapt(exec, shortPrefix, longPrefix));
                    newOptions.remove(exec);
                }
                closeGroup();
            }
            for (final OptionParser exec : newOptions) {
                if (!exec.getOption().isHelpOption()) {
                    add(adapt(exec, shortPrefix, longPrefix));
                }
            }
            return this;
        }

        void checkDuplicateOption(String name) {
            checkArgument(!optionNames.contains(name), "Duplicate options are not allowed, found duplicate: '%s'.",
                    name, optionNames);
        }

        void add(OptionParser e) {
            final Option option = e.getOption();
            final String sn = option.getShortName();

            checkDuplicateOption(sn);
            optionNames.add(sn);
            optionMap.put(sn, e);
            if (option.getLongName().isPresent()) {
                final String ln = option.getLongName().get();
                checkDuplicateOption(ln);
                optionNames.add(ln);
                optionMap.put(ln, e);
            }

            if (buildingGroup) {
                groups.get(groups.size() - 1).add(option);
            }
        }

        static <T extends Option.Builder<?>> T adaptNames(T b, String sn, String ln) {
            b.shortName(sn + b.shortName);
            if (b.longName.isPresent()) {
                b.longName(ln + b.longName.get());
            }
            return b;
        }

        @SuppressWarnings({ "unchecked", "rawtypes" })
        static OptionParser adapt(OptionParser exec, String shortPrefix, String longPrefix) {
            final Option opt = exec.getOption();
            if (opt instanceof OptionArg<?>) {
                final OptionArg<?> adapted = adaptNames(Option.builder((OptionArg<?>) opt), shortPrefix, longPrefix)
                        .build();
                return ((ArgParser) exec).newInstance(adapted);
            }
            final OptionNoArg adapted = adaptNames(Option.builder((OptionNoArg) opt), shortPrefix, longPrefix)
                    .build();
            return ((NoArgParser<?>) exec).newInstance(adapted);
        }

        /**
         * Construct a new {@link Menu}.
         * @return A new instance containing the options as defined by this builder.
         */
        public Menu build() {
            checkArgument(addedHelpOption, "At least one help option is required for creating a menu.");
            return new Menu(this);
        }
    }

    interface OptionParser {
        /**
         * @return The option of this parser.
         */
        Option getOption();

        /**
         * Parse the arguments.
         * @param arguments The arguments to parse.
         */
        void parse(List<String> arguments);
    }

    static class HelpParser extends NoArgParser<Object> {
        @SuppressWarnings("null")
        HelpParser(OptionNoArg opt) {
            super(opt, null, null);
        }
    }

    static class NoArgParser<S> implements OptionParser {
        private final OptionNoArg option;
        private final S subject;
        private final NoArgHandler<S> handler;

        NoArgParser(OptionNoArg o, S s, NoArgHandler<S> h) {
            option = o;
            subject = s;
            handler = h;
        }

        @Override
        public void parse(List<String> argument) {
            unexpectedArgument(argument, option);
            handler.execute(subject);
        }

        @Override
        public Option getOption() {
            return option;
        }

        OptionParser newInstance(OptionNoArg o) {
            return new NoArgParser<>(o, subject, handler);
        }
    }

    static class ArgParser<S, V> implements OptionParser {
        private final OptionArg<V> option;
        private final S subject;
        private final ArgHandler<S, V> handler;

        ArgParser(OptionArg<V> o, S s, ArgHandler<S, V> h) {
            option = o;
            subject = s;
            handler = h;
        }

        @Override
        public void parse(List<String> arguments) {
            Optional<V> value;
            if (!arguments.isEmpty()) {
                value = Optional.of(option.argumentType.parse(option,
                        Joiner.on(ArgumentParser.ARG_LIST_SEPARATOR).join(arguments)));
            } else if (!option.isArgOptional()) {
                throw new CliException(
                        "The option " + option + " requires a " + option.argumentType.name() + " argument.",
                        CauseType.MISSING_ARG, option);
            } else {
                value = Optional.absent();
            }
            handler.execute(subject, value);
        }

        @Override
        public Option getOption() {
            return option;
        }

        OptionParser newInstance(OptionArg<V> o) {
            return new ArgParser<>(o, subject, handler);
        }
    }
}