// Copyright 2014 The Bazel Authors. All rights reserved.
//
// 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.google.devtools.build.lib.runtime;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Joiner;
import com.google.common.base.Predicates;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ListMultimap;
import com.google.devtools.build.lib.events.Event;
import com.google.devtools.build.lib.events.EventHandler;
import com.google.devtools.build.lib.events.ExtendedEventHandler;
import com.google.devtools.build.lib.runtime.commands.ProjectFileSupport;
import com.google.devtools.build.lib.runtime.proto.InvocationPolicyOuterClass.InvocationPolicy;
import com.google.devtools.build.lib.util.ExitCode;
import com.google.devtools.build.lib.vfs.FileSystemUtils;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.common.options.InvocationPolicyEnforcer;
import com.google.devtools.common.options.OptionDefinition;
import com.google.devtools.common.options.OptionPriority.PriorityCategory;
import com.google.devtools.common.options.OptionValueDescription;
import com.google.devtools.common.options.OptionsParser;
import com.google.devtools.common.options.OptionsParsingException;
import com.google.devtools.common.options.OptionsProvider;
import com.google.devtools.common.options.ParsedOptionDescription;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.logging.Level;

/**
 * Handles parsing the blaze command arguments.
 *
 * <p>This class manages rc options, default options, and invocation policy.
 */
public abstract class BlazeOptionHandler {
  // Keep in sync with options added in OptionProcessor::AddRcfileArgsAndOptions()
  private static final ImmutableSet<String> INTERNAL_COMMAND_OPTIONS =
      ImmutableSet.of(
          "rc_source",
          "default_override",
          "isatty",
          "terminal_columns",
          "ignore_client_env",
          "client_env",
          "client_cwd");

  protected final BlazeRuntime runtime;
  protected final OptionsParser optionsParser;
  protected final BlazeWorkspace workspace;
  protected final BlazeCommand command;
  protected final Command commandAnnotation;
  protected final InvocationPolicy invocationPolicy;
  protected final List<String> rcfileNotes = new ArrayList<>();

  private BlazeOptionHandler(
      BlazeRuntime runtime,
      BlazeWorkspace workspace,
      BlazeCommand command,
      Command commandAnnotation,
      OptionsParser optionsParser,
      InvocationPolicy invocationPolicy) {
    this.runtime = runtime;
    this.workspace = workspace;
    this.command = command;
    this.commandAnnotation = commandAnnotation;
    this.optionsParser = optionsParser;
    this.invocationPolicy = invocationPolicy;
  }

  public static BlazeOptionHandler getHandler(
      BlazeRuntime runtime,
      BlazeWorkspace workspace,
      BlazeCommand command,
      Command commandAnnotation,
      OptionsParser optionsParser,
      InvocationPolicy invocationPolicy,
      boolean expandConfigsInPlace) {
    if (expandConfigsInPlace) {
      return new InPlaceConfigExpansionRcHandler(
          runtime, workspace, command, commandAnnotation, optionsParser, invocationPolicy);
    } else {
      return new FixPointConfigExpansionRcHandler(
          runtime, workspace, command, commandAnnotation, optionsParser, invocationPolicy);
    }
  }

  // Return options as OptionsProvider so the options can't be easily modified after we've
  // applied the invocation policy.
  OptionsProvider getOptionsResult() {
    return optionsParser;
  }

  public List<String> getRcfileNotes() {
    return rcfileNotes;
  }

  /**
   * Only some commands work if cwd != workspaceSuffix in Blaze. In that case, also check if Blaze
   * was called from the output directory and fail if it was.
   */
  private ExitCode checkCwdInWorkspace(EventHandler eventHandler) {
    if (!commandAnnotation.mustRunInWorkspace()) {
      return ExitCode.SUCCESS;
    }

    if (!workspace.getDirectories().inWorkspace()) {
      eventHandler.handle(
          Event.error(
              "The '"
                  + commandAnnotation.name()
                  + "' command is only supported from within a workspace."));
      return ExitCode.COMMAND_LINE_ERROR;
    }

    Path workspacePath = workspace.getWorkspace();
    // TODO(kchodorow): Remove this once spaces are supported.
    if (workspacePath.getPathString().contains(" ")) {
      eventHandler.handle(
          Event.error(
              runtime.getProductName()
                  + " does not currently work properly from paths "
                  + "containing spaces ("
                  + workspace
                  + ")."));
      return ExitCode.LOCAL_ENVIRONMENTAL_ERROR;
    }

    Path doNotBuild =
        workspacePath.getParentDirectory().getRelative(BlazeWorkspace.DO_NOT_BUILD_FILE_NAME);

    if (doNotBuild.exists()) {
      if (!commandAnnotation.canRunInOutputDirectory()) {
        eventHandler.handle(Event.error(getNotInRealWorkspaceError(doNotBuild)));
        return ExitCode.COMMAND_LINE_ERROR;
      } else {
        eventHandler.handle(
            Event.warn(
                runtime.getProductName() + " is run from output directory. This is unsound."));
      }
    }
    return ExitCode.SUCCESS;
  }

  /**
   * Parses the unconditional options from .rc files for the current command.
   *
   * <p>This is not as trivial as simply taking the list of options for the specified command
   * because commands can inherit arguments from each other, and we have to respect that (e.g. if an
   * option is specified for 'build', it needs to take effect for the 'test' command, too). More
   * specific commands should have priority over the broader commands (say a "build" option that
   * conflicts with a "common" option should override the common one regardless of order.)
   *
   * <p>For each command, the options are parsed in rc order. This uses the master rc file first,
   * and follows import statements. This is the order in which they were passed by the client.
   */
  void parseRcOptions(
      EventHandler eventHandler, ListMultimap<String, RcChunkOfArgs> commandToRcArgs)
      throws OptionsParsingException {
    for (String commandToParse : getCommandNamesToParse(commandAnnotation)) {
      // Get all args defined for this command (or "common"), grouped by rc chunk.
      for (RcChunkOfArgs rcArgs : commandToRcArgs.get(commandToParse)) {
        if (!rcArgs.args.isEmpty()) {
          String inherited = commandToParse.equals(commandAnnotation.name()) ? "" : "Inherited ";
          String source =
              rcArgs.rcFile.equals("client")
                  ? "Options provided by the client"
                  : String.format(
                      "Reading rc options for '%s' from %s",
                      commandAnnotation.name(), rcArgs.rcFile);
          rcfileNotes.add(
              String.format(
                  "%s:\n  %s'%s' options: %s",
                  source, inherited, commandToParse, Joiner.on(' ').join(rcArgs.args)));
        }
        optionsParser.parse(PriorityCategory.RC_FILE, rcArgs.rcFile, rcArgs.args);
      }
    }
  }

  /**
   * Expand the values of --config according to the definitions provided in the rc files and the
   * applicable command.
   */
  abstract void expandConfigOptions(
      EventHandler eventHandler, ListMultimap<String, RcChunkOfArgs> commandToRcArgs)
      throws OptionsParsingException;

  private void parseArgsAndConfigs(List<String> args, ExtendedEventHandler eventHandler)
      throws OptionsParsingException {
    Path workspaceDirectory = workspace.getWorkspace();
    // TODO(ulfjack): The working directory is passed by the client as part of CommonCommandOptions,
    // and we can't know it until after we've parsed the options, so use the workspace for now.
    Path workingDirectory = workspace.getWorkspace();

    Function<OptionDefinition, String> commandOptionSourceFunction =
        option -> {
          if (INTERNAL_COMMAND_OPTIONS.contains(option.getOptionName())) {
            return "options generated by " + runtime.getProductName() + " launcher";
          } else {
            return "command line options";
          }
        };

    // Explicit command-line options:
    List<String> cmdLineAfterCommand = args.subList(1, args.size());
    optionsParser.parseWithSourceFunction(
        PriorityCategory.COMMAND_LINE, commandOptionSourceFunction, cmdLineAfterCommand);

    // Command-specific options from .blazerc passed in via --default_override and --rc_source.
    ClientOptions rcFileOptions = optionsParser.getOptions(ClientOptions.class);
    ListMultimap<String, RcChunkOfArgs> commandToRcArgs =
        structureRcOptionsAndConfigs(
            eventHandler,
            rcFileOptions.rcSource,
            rcFileOptions.optionsOverrides,
            runtime.getCommandMap().keySet());
    parseRcOptions(eventHandler, commandToRcArgs);

    if (commandAnnotation.builds()) {
      // splits project files from targets in the traditional sense
      ProjectFileSupport.handleProjectFiles(
          eventHandler,
          runtime.getProjectFileProvider(),
          workspaceDirectory,
          workingDirectory,
          optionsParser,
          commandAnnotation.name());
    }

    expandConfigOptions(eventHandler, commandToRcArgs);
  }

  /**
   * Parses the options, taking care not to generate any output to outErr, return, or throw an
   * exception.
   *
   * @return ExitCode.SUCCESS if everything went well, or some other value if not
   */
  ExitCode parseOptions(List<String> args, ExtendedEventHandler eventHandler) {
    // The initialization code here was carefully written to parse the options early before we call
    // into the BlazeModule APIs, which means we must not generate any output to outErr, return, or
    // throw an exception. All the events happening here are instead stored in a temporary event
    // handler, and later replayed.
    ExitCode earlyExitCode = checkCwdInWorkspace(eventHandler);
    if (!earlyExitCode.equals(ExitCode.SUCCESS)) {
      return earlyExitCode;
    }

    try {
      parseArgsAndConfigs(args, eventHandler);
      // Allow the command to edit the options.
      command.editOptions(optionsParser);
      // Migration of --watchfs to a command option.
      // TODO(ulfjack): Get rid of the startup option and drop this code.
      if (runtime.getStartupOptionsProvider().getOptions(BlazeServerStartupOptions.class).watchFS) {
        try {
          optionsParser.parse("--watchfs");
        } catch (OptionsParsingException e) {
          // This should never happen.
          throw new IllegalStateException(e);
        }
      }
      // Merge the invocation policy that is user-supplied, from the command line, and any
      // invocation policy that was added by a module. The module one goes 'first,' so the user
      // one has priority.
      InvocationPolicy combinedPolicy =
          InvocationPolicy.newBuilder()
              .mergeFrom(runtime.getModuleInvocationPolicy())
              .mergeFrom(invocationPolicy)
              .build();
      InvocationPolicyEnforcer optionsPolicyEnforcer =
          new InvocationPolicyEnforcer(combinedPolicy, Level.INFO);
      // Enforce the invocation policy. It is intentional that this is the last step in preparing
      // the options. The invocation policy is used in security-critical contexts, and may be used
      // as a last resort to override flags. That means that the policy can override flags set in
      // BlazeCommand.editOptions, so the code needs to be safe regardless of the actual flag
      // values. At the time of this writing, editOptions was only used as a convenience feature or
      // to improve the user experience, but not required for safety or correctness.
      optionsPolicyEnforcer.enforce(optionsParser, commandAnnotation.name());
      // Print warnings for odd options usage
      for (String warning : optionsParser.getWarnings()) {
        eventHandler.handle(Event.warn(warning));
      }
      CommonCommandOptions commonOptions = optionsParser.getOptions(CommonCommandOptions.class);
      for (String warning : commonOptions.deprecationWarnings) {
        eventHandler.handle(Event.warn(warning));
      }
    } catch (OptionsParsingException e) {
      eventHandler.handle(Event.error(e.getMessage()));
      return ExitCode.COMMAND_LINE_ERROR;
    }
    return ExitCode.SUCCESS;
  }

  /**
   * Implements the legacy way of expanding blazerc and config expansions.
   *
   * <p>--config options are expanded in a fixed point expansion at the end of the rc option block.
   * Their expansion is defined in the rc file, and is triggered by the presence of a {@code
   * --config=someConfigName} option somewhere in the user's options. The resulting option order is
   * {@code <rc options> <config expansions> <command line options>}, where all of the config values
   * mentioned are in the mid segment, regardless of whether they were defined in an rc file, on the
   * command line, or in the expansion of other config values that were triggered earlier. If the
   * same config value is provided twice (say, once on the command line and once in another config
   * expansion) it will only be expanded once.
   *
   * <p>This behavior is relatively well-defined, but the final order of options is not intuitive.
   * As a simple example, consider a user specified command line with --config=foo last. Most users
   * expect the expansion of --config=foo to override earlier flags in their command line if
   * necessary, but this is not the case. This is why we are phasing out this behavior. We will
   * maintain the old behavior to avoid breaking users during a transition period.
   */
  static final class FixPointConfigExpansionRcHandler extends BlazeOptionHandler {

    private FixPointConfigExpansionRcHandler(
        BlazeRuntime runtime,
        BlazeWorkspace workspace,
        BlazeCommand command,
        Command commandAnnotation,
        OptionsParser optionsParser,
        InvocationPolicy invocationPolicy) {
      super(runtime, workspace, command, commandAnnotation, optionsParser, invocationPolicy);
    }

    @Override
    void expandConfigOptions(
        EventHandler eventHandler, ListMultimap<String, RcChunkOfArgs> commandToRcArgs)
        throws OptionsParsingException {
      // Fix-point iteration until all configs are loaded.
      List<String> configsLoaded = ImmutableList.of();
      Set<String> unknownConfigs = new LinkedHashSet<>();
      CommonCommandOptions commonOptions = optionsParser.getOptions(CommonCommandOptions.class);
      while (!commonOptions.configs.equals(configsLoaded)) {
        // Parse the configs we have not seen yet.
        Set<String> missingConfigs = new LinkedHashSet<>(commonOptions.configs);
        missingConfigs.removeAll(configsLoaded);
        parseConfigOptionsForCommand(commandToRcArgs, missingConfigs, unknownConfigs);
        // Refresh the list of config values we've processed to be the list of config values we had
        // before the call to parseConfigOptionsForCommand.
        configsLoaded = commonOptions.configs;
        commonOptions = optionsParser.getOptions(CommonCommandOptions.class);
      }
      if (!unknownConfigs.isEmpty()) {
        if (commonOptions.allowUndefinedConfigs) {
          eventHandler.handle(
              Event.warn(
                  "Config values are not defined in any .rc file: "
                      + Joiner.on(", ").join(unknownConfigs)));
        } else {
          throw new OptionsParsingException(
              "Config values are not defined in any .rc file: "
                  + Joiner.on(", ").join(unknownConfigs));
        }
      }
    }

    /**
     * Go through the configs given and parse their expansion if a definition was found.
     *
     * @param configs the configs for which to parse options.
     * @param unknownConfigs a collection that the method will populate with the config values in
     *     {@code configs} that none of the .rc files had entries for.
     */
    private void parseConfigOptionsForCommand(
        ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
        Collection<String> configs,
        Collection<String> unknownConfigs)
        throws OptionsParsingException {
      Set<String> knownConfigs = new HashSet<>();
      for (String commandToParse : getCommandNamesToParse(commandAnnotation)) {
        for (String config : configs) {
          String configDef = commandToParse + ":" + config;
          for (RcChunkOfArgs rcArgs : commandToRcArgs.get(configDef)) {
            // Track that we've found at least 1 definition for this config value.
            knownConfigs.add(config);
            rcfileNotes.add(
                String.format(
                    "Found applicable config definition %s in file %s: %s",
                    configDef, rcArgs.rcFile, String.join(" ", rcArgs.args)));
            optionsParser.parse(PriorityCategory.RC_FILE, rcArgs.rcFile, rcArgs.args);
          }
        }
      }
      if (configs.size() > knownConfigs.size()) {
        configs
            .stream()
            .filter(Predicates.not(Predicates.in(knownConfigs)))
            .forEachOrdered(unknownConfigs::add);
      }
    }
  }

  /**
   * Expands the rc's options in {@code <rc options> <command line>} order, with config options
   * expanded wherever the --config option was mentioned.
   *
   * <p>This requires a bit more care than the in-place expansion. We need to be wary of potential
   * cycles in the definitions of config values, and should warn when a single --config expansion
   * contradicts itself.
   */
  static final class InPlaceConfigExpansionRcHandler extends BlazeOptionHandler {

    private InPlaceConfigExpansionRcHandler(
        BlazeRuntime runtime,
        BlazeWorkspace workspace,
        BlazeCommand command,
        Command commandAnnotation,
        OptionsParser optionsParser,
        InvocationPolicy invocationPolicy) {
      super(runtime, workspace, command, commandAnnotation, optionsParser, invocationPolicy);
    }

    @Override
    void expandConfigOptions(
        EventHandler eventHandler, ListMultimap<String, RcChunkOfArgs> commandToRcArgs)
        throws OptionsParsingException {

      OptionValueDescription configValueDescription =
          optionsParser.getOptionValueDescription("config");
      if (configValueDescription == null
          || configValueDescription.getCanonicalInstances() == null) {
        // No --config values were set, we can avoid this whole thing.
        return;
      }

      // Find the base set of configs. This does not include the config options that might be
      // recursively incuded.
      ImmutableList<ParsedOptionDescription> configInstances =
          ImmutableList.copyOf(configValueDescription.getCanonicalInstances());

      // Expand the configs that are mentioned in the input. Flatten these expansions before parsing
      // them, to preserve order.
      for (ParsedOptionDescription configInstance : configInstances) {
        String configValueToExpand = (String) configInstance.getConvertedValue();
        List<String> expansion = getExpansion(eventHandler, commandToRcArgs, configValueToExpand);
        optionsParser.parseArgsFixedAsExpansionOfOption(
            configInstance, String.format("expanded from --%s", configValueToExpand), expansion);
      }

      // At this point, we've expanded everything, identify duplicates, if any, to warn about
      // re-application.
      List<String> configs = optionsParser.getOptions(CommonCommandOptions.class).configs;
      Set<String> configSet = new HashSet<>();
      LinkedHashSet<String> duplicateConfigs = new LinkedHashSet<>();
      for (String configValue : configs) {
        if (!configSet.add(configValue)) {
          duplicateConfigs.add(configValue);
        }
      }
      if (!duplicateConfigs.isEmpty()) {
        eventHandler.handle(Event.warn(
            String.format(
                "The following configs were expanded more than once: %s. For repeatable flags, "
                    + "repeats are counted twice and may lead to unexpected behavior.",
                duplicateConfigs)));
      }
    }

    private List<String> getExpansion(
        EventHandler eventHandler,
        ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
        String configToExpand)
        throws OptionsParsingException {
      LinkedHashSet<String> configAncestorSet = new LinkedHashSet<>();
      configAncestorSet.add(configToExpand);
      List<String> longestChain = new ArrayList<>();
      List<String> finalExpansion =
          getExpansion(
              eventHandler, commandToRcArgs, configAncestorSet, configToExpand, longestChain);

      // In order to prevent warning about a long chain of 13 configs at the 10, 11, 12, and 13
      // point, we identify the longest chain for this 'high-level' --config found and only warn
      // about it once. This may mean we missed a fork where each branch was independently long
      // enough to warn, but the single warning should convey the message reasonably.
      if (longestChain.size() >= 10) {
        eventHandler.handle(
            Event.warn(
                String.format(
                    "There is a recursive chain of configs %s configs long: %s. This seems "
                        + "excessive, and might be hiding errors.",
                    longestChain.size(), longestChain)));
      }
      return finalExpansion;
    }

    /**
     * @param configAncestorSet is the chain of configs that have led to this one getting expanded.
     *     This should only contain the configs that expanded, recursively, to this one, and should
     *     not contain "siblings," as it is used to detect cycles. {@code build:foo --config=bar},
     *     {@code build:bar --config=foo}, is a cycle, detected because this list will be [foo, bar]
     *     when we find another 'foo' to expand. However, {@code build:foo --config=bar}, {@code
     *     build:foo --config=bar} is not a cycle just because bar is expanded twice, and the 1st
     *     bar should not be in the parents list of the second bar.
     * @param longestChain will be populated with the longest inheritance chain of configs.
     */
    private List<String> getExpansion(
        EventHandler eventHandler,
        ListMultimap<String, RcChunkOfArgs> commandToRcArgs,
        LinkedHashSet<String> configAncestorSet,
        String configToExpand,
        List<String> longestChain)
        throws OptionsParsingException {
      List<String> expansion = new ArrayList<>();
      boolean foundDefinition = false;
      // The expansion order of rc files is first by command priority, and then in the order the
      // rc files were read, respecting import statement placement.
      for (String commandToParse : getCommandNamesToParse(commandAnnotation)) {
        String configDef = commandToParse + ":" + configToExpand;
        for (RcChunkOfArgs rcArgs : commandToRcArgs.get(configDef)) {
          foundDefinition = true;
          rcfileNotes.add(
              String.format(
                  "Found applicable config definition %s in file %s: %s",
                  configDef, rcArgs.rcFile, String.join(" ", rcArgs.args)));

          // For each arg in the rcARgs chunk, we first check if it is a config, and if so, expand
          // it in place. We avoid cycles by tracking the parents of this config.
          for (String arg : rcArgs.args) {
            expansion.add(arg);
            if (arg.length() >= 8 && arg.substring(0, 8).equals("--config")) {
              // We have a config. For sanity, because we don't want to worry about formatting,
              // we will only accept --config=value, and will not accept value on a following line.
              int charOfConfigValue = arg.indexOf('=');
              if (charOfConfigValue < 0) {
                throw new OptionsParsingException(
                    String.format(
                        "In file %s, the definition of config %s expands to another config "
                            + "that either has no value or is not in the form --config=value. For "
                            + "recursive config definitions, please do not provide the value in a "
                            + "separate token, such as in the form '--config value'.",
                        rcArgs.rcFile, configToExpand));
              }
              String newConfigValue = arg.substring(charOfConfigValue + 1);
              LinkedHashSet<String> extendedConfigAncestorSet =
                  new LinkedHashSet<>(configAncestorSet);
              if (!extendedConfigAncestorSet.add(newConfigValue)) {
                throw new OptionsParsingException(
                    String.format(
                        "Config expansion has a cycle: config value %s expands to itself, "
                            + "see inheritance chain %s",
                        newConfigValue, extendedConfigAncestorSet));
              }
              if (extendedConfigAncestorSet.size() > longestChain.size()) {
                longestChain.clear();
                longestChain.addAll(extendedConfigAncestorSet);
              }

              expansion.addAll(
                  getExpansion(
                      eventHandler,
                      commandToRcArgs,
                      extendedConfigAncestorSet,
                      newConfigValue,
                      longestChain));
            }
          }
        }
      }

      if (!foundDefinition) {
        String warning = "Config value " + configToExpand + " is not defined in any .rc file";
        CommonCommandOptions commonOptions = optionsParser.getOptions(CommonCommandOptions.class);
        if (commonOptions.allowUndefinedConfigs) {
          eventHandler.handle(Event.warn(warning));
        } else {
          throw new OptionsParsingException(warning);
        }
      }
      return expansion;
    }
  }

  private static List<String> getCommandNamesToParse(Command commandAnnotation) {
    List<String> result = new ArrayList<>();
    result.add("common");
    getCommandNamesToParseHelper(commandAnnotation, result);
    return result;
  }

  private static void getCommandNamesToParseHelper(
      Command commandAnnotation, List<String> accumulator) {
    for (Class<? extends BlazeCommand> base : commandAnnotation.inherits()) {
      getCommandNamesToParseHelper(base.getAnnotation(Command.class), accumulator);
    }
    accumulator.add(commandAnnotation.name());
  }

  private String getNotInRealWorkspaceError(Path doNotBuildFile) {
    String message =
        String.format(
            "%1$s should not be called from a %1$s output directory. ", runtime.getProductName());
    try {
      String realWorkspace = new String(FileSystemUtils.readContentAsLatin1(doNotBuildFile));
      message += String.format("The pertinent workspace directory is: '%s'", realWorkspace);
    } catch (IOException e) {
      // We are exiting anyway.
    }

    return message;
  }

  /**
   * We receive the rc file arguments from the client in an order that maintains the location of
   * "import" statements, expanding the imported rc file in place so that its args override previous
   * args in the file and are overridden by later arguments. We cannot group the args by rc file for
   * parsing, as we would lose this ordering, so we store them in these "chunks."
   *
   * <p>Each chunk comes from a single rc file, but the args stored here may not contain the entire
   * file if its contents were interrupted by an import statement.
   */
  static class RcChunkOfArgs {
    public RcChunkOfArgs(String rcFile, List<String> args) {
      this.rcFile = rcFile;
      this.args = args;
    }

    // The name of the rc file, usually a path.
    String rcFile;
    // The list of arguments specified in this rc "chunk". This is all for a single command (or
    // command:config definition), as different commands will be grouped together, so this list of
    // arguments can all be parsed as a continuous group.
    List<String> args;

    @Override
    public boolean equals(Object o) {
      if (o instanceof RcChunkOfArgs) {
        RcChunkOfArgs other = (RcChunkOfArgs) o;
        return rcFile.equals(other.rcFile) && args.equals(other.args);
      }
      return false;
    }

    @Override
    public int hashCode() {
      return rcFile.hashCode() + args.hashCode();
    }
  }

  /**
   * The rc options are passed via {@link ClientOptions#optionsOverrides} and {@link
   * ClientOptions#rcSource}, which is basically a line-by-line transfer of the rc files read by the
   * client. This is not a particularly useful format for expanding the options, so this method
   * structures the list so that it is easier to find the arguments that apply to a command, or to
   * find the definitions of a config value.
   */
  @VisibleForTesting
  static ListMultimap<String, RcChunkOfArgs> structureRcOptionsAndConfigs(
      EventHandler eventHandler,
      List<String> rcFiles,
      List<ClientOptions.OptionOverride> rawOverrides,
      Set<String> validCommands) {
    ListMultimap<String, RcChunkOfArgs> commandToRcArgs = ArrayListMultimap.create();

    String lastRcFile = null;
    ListMultimap<String, String> commandToArgMapForLastRc = null;
    for (ClientOptions.OptionOverride override : rawOverrides) {
      if (override.blazeRc < 0 || override.blazeRc >= rcFiles.size()) {
        eventHandler.handle(
            Event.warn("inconsistency in generated command line args. Ignoring bogus argument\n"));
        continue;
      }
      String rcFile = rcFiles.get(override.blazeRc);
      String command = override.command;
      int index = command.indexOf(':');
      if (index > 0) {
        command = command.substring(0, index);
      }
      if (!validCommands.contains(command) && !command.equals("common")) {
        eventHandler.handle(
            Event.warn(
                "while reading option defaults file '"
                    + rcFile
                    + "':\n"
                    + "  invalid command name '"
                    + override.command
                    + "'."));
        continue;
      }

      // We've moved on to another rc file "chunk," store the accumulated args from the last one.
      if (!rcFile.equals(lastRcFile)) {
        if (lastRcFile != null) {
          // Go through the various commands identified in this rc file (or chunk of file) and
          // store them grouped first by command, then by rc chunk.
          for (String commandKey : commandToArgMapForLastRc.keySet()) {
            commandToRcArgs.put(
                commandKey,
                new RcChunkOfArgs(lastRcFile, commandToArgMapForLastRc.get(commandKey)));
          }
        }
        lastRcFile = rcFile;
        commandToArgMapForLastRc = ArrayListMultimap.create();
      }

      commandToArgMapForLastRc.put(override.command, override.option);
    }
    if (lastRcFile != null) {
      // Once again, for this last rc file chunk, store them grouped by command.
      for (String commandKey : commandToArgMapForLastRc.keySet()) {
        commandToRcArgs.put(
            commandKey, new RcChunkOfArgs(lastRcFile, commandToArgMapForLastRc.get(commandKey)));
      }
    }

    return commandToRcArgs;
  }
}
