Add new option categorization and tagging information to HelpCommand's output.

If setting flag --use_new_category_enum, group the options by the new categories in both the command line output and the "everything-as-html" output used for the generated docs at https://bazel.build/versions/master/docs/command-line-reference.html. In the html output, the effect and metadata tags are listed for each option, with links to their descriptions at the bottom of the page. The tags only appear in the terminal output in -l/--long/--help_verbosity=long, and only the names appear.

This is still experimental as the majority of options do not yet use the new categorization system. The new output can be seen in command-line-reference.html format by adding the new flag to the "help everything-as-html" line in //src/main/java/com/google/devtools/build/lib:gen_command-line-reference.

The html output is in the same order as before (by blaze rule, with inherited options not repeated), which means it still has duplicate options, that should ideally be fixed separately. I propose either dropping the high-level grouping and just grouping the options by documentation category, or potentially grouping them by optionsbase in some non-class-naming way, and listing the commands that they apply to, as more and more optionsbases are used by multiple commands without being inherited (for example, all BuildEventServiceOptions are listed 20 times). People probably use ctrl-f to navigate this page anyway. Once we know that we only list each option once, we can actually have links to the options, which will make it possible to have links in the expansion lists.

Issue #3758

RELNOTES: added experimental --use_new_category_enum to the help command to output options grouped by the new type of category.
PiperOrigin-RevId: 170116553
diff --git a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java
index 52f3447..6c6b4f3 100644
--- a/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java
+++ b/src/main/java/com/google/devtools/build/docgen/BuildEncyclopediaGenerator.java
@@ -32,7 +32,7 @@
             + "The product name (-n), rule class provider (-p) and at least one input_dir\n"
             + "(-i) must be specified.\n");
     System.err.println(
-        parser.describeOptions(
+        parser.describeOptionsWithDeprecatedCategories(
             Collections.<String, String>emptyMap(), OptionsParser.HelpVerbosity.LONG));
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandUtils.java b/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandUtils.java
index 5f57b73..07573fa 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandUtils.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandUtils.java
@@ -21,7 +21,6 @@
 import com.google.devtools.build.lib.util.ResourceFileLoader;
 import com.google.devtools.common.options.OptionsBase;
 import com.google.devtools.common.options.OptionsParser;
-
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
@@ -114,21 +113,42 @@
    * Returns the expansion of the specified help topic.
    *
    * @param topic the name of the help topic; used in %{command} expansion.
-   * @param help the text template of the help message. Certain %{x} variables
-   *        will be expanded. A prefix of "resource:" means use the .jar
-   *        resource of that name.
-   * @param categoryDescriptions a mapping from option category names to
-   *        descriptions, passed to {@link OptionsParser#describeOptions}.
-   * @param helpVerbosity a tri-state verbosity option selecting between just
-   *        names, names and syntax, and full description.
+   * @param help the text template of the help message. Certain %{x} variables will be expanded. A
+   *     prefix of "resource:" means use the .jar resource of that name.
+   * @param categoryDescriptions a mapping from option category names to descriptions, passed to
+   *     {@link OptionsParser#describeOptionsWithDeprecatedCategories}.
+   * @param helpVerbosity a tri-state verbosity option selecting between just names, names and
+   *     syntax, and full description.
    * @param productName the product name
    */
-  public static String expandHelpTopic(String topic, String help,
+  public static final String expandHelpTopic(
+      String topic,
+      String help,
       Class<? extends BlazeCommand> commandClass,
       Collection<Class<? extends OptionsBase>> options,
       Map<String, String> categoryDescriptions,
       OptionsParser.HelpVerbosity helpVerbosity,
       String productName) {
+    return expandHelpTopic(
+        topic,
+        help,
+        commandClass,
+        options,
+        categoryDescriptions,
+        helpVerbosity,
+        productName,
+        false);
+  }
+
+  public static final String expandHelpTopic(
+      String topic,
+      String help,
+      Class<? extends BlazeCommand> commandClass,
+      Collection<Class<? extends OptionsBase>> options,
+      Map<String, String> categoryDescriptions,
+      OptionsParser.HelpVerbosity helpVerbosity,
+      String productName,
+      boolean useNewCategoryEnum) {
     OptionsParser parser = OptionsParser.newOptionsParser(options);
 
     String template;
@@ -137,8 +157,12 @@
       try {
         template = ResourceFileLoader.loadResource(commandClass, resourceName);
       } catch (IOException e) {
-        throw new IllegalStateException("failed to load help resource '" + resourceName
-                                        + "' due to I/O error: " + e.getMessage(), e);
+        throw new IllegalStateException(
+            "failed to load help resource '"
+                + resourceName
+                + "' due to I/O error: "
+                + e.getMessage(),
+            e);
       }
     } else {
       template = help;
@@ -148,10 +172,16 @@
       throw new IllegalStateException("Help template for '" + topic + "' omits %{options}!");
     }
 
-    String optionStr =
-        parser
-            .describeOptions(categoryDescriptions, helpVerbosity)
-            .replace("%{product}", productName);
+    String optionStr;
+    if (useNewCategoryEnum) {
+      optionStr =
+          parser.describeOptions(productName, helpVerbosity).replace("%{product}", productName);
+    } else {
+      optionStr =
+          parser
+              .describeOptionsWithDeprecatedCategories(categoryDescriptions, helpVerbosity)
+              .replace("%{product}", productName);
+    }
     return template
             .replace("%{product}", productName)
             .replace("%{command}", topic)
@@ -166,10 +196,10 @@
   /**
    * The help page for this command.
    *
-   * @param categoryDescriptions a mapping from option category names to
-   *        descriptions, passed to {@link OptionsParser#describeOptions}.
-   * @param verbosity a tri-state verbosity option selecting between just names,
-   *        names and syntax, and full description.
+   * @param categoryDescriptions a mapping from option category names to descriptions, passed to
+   *     {@link OptionsParser#describeOptionsWithDeprecatedCategories}.
+   * @param verbosity a tri-state verbosity option selecting between just names, names and syntax,
+   *     and full description.
    */
   public static String getUsage(
       Class<? extends BlazeCommand> commandClass,
@@ -177,7 +207,8 @@
       OptionsParser.HelpVerbosity verbosity,
       Iterable<BlazeModule> blazeModules,
       ConfiguredRuleClassProvider ruleClassProvider,
-      String productName) {
+      String productName,
+      boolean useNewCategoryEnum) {
     Command commandAnnotation = commandClass.getAnnotation(Command.class);
     return BlazeCommandUtils.expandHelpTopic(
         commandAnnotation.name(),
@@ -186,6 +217,7 @@
         BlazeCommandUtils.getOptions(commandClass, blazeModules, ruleClassProvider),
         categoryDescriptions,
         verbosity,
-        productName);
+        productName,
+        useNewCategoryEnum);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/HelpCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/HelpCommand.java
index 8c3821b..8b1d9a8 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/HelpCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/HelpCommand.java
@@ -43,6 +43,8 @@
 import com.google.devtools.common.options.OptionDefinition;
 import com.google.devtools.common.options.OptionDocumentationCategory;
 import com.google.devtools.common.options.OptionEffectTag;
+import com.google.devtools.common.options.OptionFilterDescriptions;
+import com.google.devtools.common.options.OptionMetadataTag;
 import com.google.devtools.common.options.OptionsBase;
 import com.google.devtools.common.options.OptionsParser;
 import com.google.devtools.common.options.OptionsProvider;
@@ -113,15 +115,25 @@
       help = "Show only the names of the options, not their types or meanings."
     )
     public Void showShortFormOptions;
+
+    @Option(
+      name = "use_new_category_enum",
+      defaultValue = "false",
+      documentationCategory = OptionDocumentationCategory.LOGGING,
+      effectTags = {OptionEffectTag.AFFECTS_OUTPUTS, OptionEffectTag.TERMINAL_OUTPUT},
+      metadataTags = {OptionMetadataTag.EXPERIMENTAL}
+    )
+    public boolean useNewCategoryEnum;
   }
 
   /**
-   * Returns a map that maps option categories to descriptive help strings for categories that
-   * are not part of the Bazel core.
+   * Returns a map that maps option categories to descriptive help strings for categories that are
+   * not part of the Bazel core.
    */
-  private static ImmutableMap<String, String> getOptionCategories(BlazeRuntime runtime) {
+  @Deprecated
+  private static ImmutableMap<String, String> getDeprecatedOptionCategoriesDescriptions(
+      String name) {
     ImmutableMap.Builder<String, String> optionCategoriesBuilder = ImmutableMap.builder();
-    String name = runtime.getProductName();
     optionCategoriesBuilder
         .put("checking", String.format(
              "Checking options, which control %s's error checking and/or warnings", name))
@@ -180,27 +192,40 @@
       return ExitCode.COMMAND_LINE_ERROR;
     }
     String helpSubject = options.getResidue().get(0);
-    if (helpSubject.equals("startup_options")) {
-      emitBlazeVersionInfo(outErr, runtime.getProductName());
-      emitStartupOptions(
-          outErr, helpOptions.helpVerbosity, runtime, getOptionCategories(runtime));
-      return ExitCode.SUCCESS;
-    } else if (helpSubject.equals("target-syntax")) {
-      emitBlazeVersionInfo(outErr, runtime.getProductName());
-      emitTargetSyntaxHelp(outErr, getOptionCategories(runtime), runtime.getProductName());
-      return ExitCode.SUCCESS;
-    } else if (helpSubject.equals("info-keys")) {
-      emitInfoKeysHelp(env, outErr);
-      return ExitCode.SUCCESS;
-    } else if (helpSubject.equals("completion")) {
-      emitCompletionHelp(runtime, outErr);
-      return ExitCode.SUCCESS;
-    } else if (helpSubject.equals("flags-as-proto")) {
-      emitFlagsAsProtoHelp(runtime, outErr);
-      return ExitCode.SUCCESS;
-    } else if (helpSubject.equals("everything-as-html")) {
-      new HtmlEmitter(runtime).emit(outErr);
-      return ExitCode.SUCCESS;
+    String productName = runtime.getProductName();
+    // Go through the custom subjects before going through Bazel commands.
+    switch (helpSubject) {
+      case "startup_options":
+        emitBlazeVersionInfo(outErr, runtime.getProductName());
+        emitStartupOptions(
+            outErr,
+            helpOptions.helpVerbosity,
+            runtime,
+            getDeprecatedOptionCategoriesDescriptions(productName),
+            helpOptions.useNewCategoryEnum);
+        return ExitCode.SUCCESS;
+      case "target-syntax":
+        emitBlazeVersionInfo(outErr, runtime.getProductName());
+        emitTargetSyntaxHelp(
+            outErr,
+            getDeprecatedOptionCategoriesDescriptions(productName),
+            productName,
+            helpOptions.useNewCategoryEnum);
+
+        return ExitCode.SUCCESS;
+      case "info-keys":
+        emitInfoKeysHelp(env, outErr);
+        return ExitCode.SUCCESS;
+      case "completion":
+        emitCompletionHelp(runtime, outErr);
+        return ExitCode.SUCCESS;
+      case "flags-as-proto":
+        emitFlagsAsProtoHelp(runtime, outErr);
+        return ExitCode.SUCCESS;
+      case "everything-as-html":
+        new HtmlEmitter(runtime, helpOptions.useNewCategoryEnum).emit(outErr);
+        return ExitCode.SUCCESS;
+      default: // fall out
     }
 
     BlazeCommand command = runtime.getCommandMap().get(helpSubject);
@@ -218,14 +243,17 @@
         return ExitCode.COMMAND_LINE_ERROR;
       }
     }
-    emitBlazeVersionInfo(outErr, runtime.getProductName());
-    outErr.printOut(BlazeCommandUtils.getUsage(
-        command.getClass(),
-        getOptionCategories(runtime),
-        helpOptions.helpVerbosity,
-        runtime.getBlazeModules(),
-        runtime.getRuleClassProvider(),
-        runtime.getProductName()));
+    emitBlazeVersionInfo(outErr, productName);
+    outErr.printOut(
+        BlazeCommandUtils.getUsage(
+            command.getClass(),
+            getDeprecatedOptionCategoriesDescriptions(productName),
+            helpOptions.helpVerbosity,
+            runtime.getBlazeModules(),
+            runtime.getRuleClassProvider(),
+            productName,
+            helpOptions.useNewCategoryEnum));
+
     return ExitCode.SUCCESS;
   }
 
@@ -235,16 +263,22 @@
     outErr.printOut(String.format("%80s\n", line));
   }
 
-  private void emitStartupOptions(OutErr outErr, OptionsParser.HelpVerbosity helpVerbosity,
-      BlazeRuntime runtime, ImmutableMap<String, String> optionCategories) {
+  private void emitStartupOptions(
+      OutErr outErr,
+      OptionsParser.HelpVerbosity helpVerbosity,
+      BlazeRuntime runtime,
+      ImmutableMap<String, String> optionCategories,
+      boolean useNewCategoryEnum) {
     outErr.printOut(
-        BlazeCommandUtils.expandHelpTopic("startup_options",
+        BlazeCommandUtils.expandHelpTopic(
+            "startup_options",
             "resource:startup_options.txt",
             getClass(),
             BlazeCommandUtils.getStartupOptions(runtime.getBlazeModules()),
             optionCategories,
             helpVerbosity,
-            runtime.getProductName()));
+            runtime.getProductName(),
+            useNewCategoryEnum));
   }
 
   private void emitCompletionHelp(BlazeRuntime runtime, OutErr outErr) {
@@ -345,15 +379,21 @@
     return ImmutableSortedMap.copyOf(runtime.getCommandMap());
   }
 
-  private void emitTargetSyntaxHelp(OutErr outErr, ImmutableMap<String, String> optionCategories,
-      String productName) {
-    outErr.printOut(BlazeCommandUtils.expandHelpTopic("target-syntax",
-                                    "resource:target-syntax.txt",
-                                    getClass(),
-                                    ImmutableList.<Class<? extends OptionsBase>>of(),
-                                    optionCategories,
-                                    OptionsParser.HelpVerbosity.MEDIUM,
-                                    productName));
+  private void emitTargetSyntaxHelp(
+      OutErr outErr,
+      ImmutableMap<String, String> optionCategories,
+      String productName,
+      boolean useNewCategoryEnum) {
+    outErr.printOut(
+        BlazeCommandUtils.expandHelpTopic(
+            "target-syntax",
+            "resource:target-syntax.txt",
+            getClass(),
+            ImmutableList.<Class<? extends OptionsBase>>of(),
+            optionCategories,
+            OptionsParser.HelpVerbosity.MEDIUM,
+            productName,
+            useNewCategoryEnum));
   }
 
   private void emitInfoKeysHelp(CommandEnvironment env, OutErr outErr) {
@@ -400,11 +440,19 @@
 
   private static final class HtmlEmitter {
     private final BlazeRuntime runtime;
-    private final ImmutableMap<String, String> optionCategories;
+    private final ImmutableMap<String, String> deprecatedOptionCategoryDescriptions;
+    private final boolean useNewCategoriesEnum;
 
-    private HtmlEmitter(BlazeRuntime runtime) {
+    private HtmlEmitter(BlazeRuntime runtime, boolean useNewCategoriesEnum) {
       this.runtime = runtime;
-      this.optionCategories = getOptionCategories(runtime);
+      this.useNewCategoriesEnum = useNewCategoriesEnum;
+      String productName = runtime.getProductName();
+      if (useNewCategoriesEnum) {
+        this.deprecatedOptionCategoryDescriptions = null;
+      } else {
+        this.deprecatedOptionCategoryDescriptions =
+            getDeprecatedOptionCategoriesDescriptions(productName);
+      }
     }
 
     private void emit(OutErr outErr) {
@@ -478,14 +526,67 @@
           result.append("\n");
         }
       }
+
+      // Describe the tags once, any mentions above should link to these descriptions.
+      if (useNewCategoriesEnum) {
+        String productName = runtime.getProductName();
+        ImmutableMap<OptionEffectTag, String> effectTagDescriptions =
+            OptionFilterDescriptions.getOptionEffectTagDescription(productName);
+        result.append("<h3>Option Effect Tags</h3>\n");
+        result.append("<table>\n");
+        for (OptionEffectTag tag : OptionEffectTag.values()) {
+          String tagDescription = effectTagDescriptions.get(tag);
+
+          result.append("<tr>\n");
+          result.append(
+              String.format(
+                  "<td id=\"effect_tag_%s\"><code>%s</code></td>\n",
+                  tag, tag.name().toLowerCase()));
+          result.append(String.format("<td>%s</td>\n", HTML_ESCAPER.escape(tagDescription)));
+          result.append("</tr>\n");
+        }
+        result.append("</table>\n");
+
+        ImmutableMap<OptionMetadataTag, String> metadataTagDescriptions =
+            OptionFilterDescriptions.getOptionMetadataTagDescription(productName);
+        result.append("<h3>Option Metadata Tags</h3>\n");
+        result.append("<table>\n");
+        for (OptionMetadataTag tag : OptionMetadataTag.values()) {
+          // skip the tags that are reserved for undocumented flags.
+          if (!tag.equals(OptionMetadataTag.HIDDEN) && !tag.equals(OptionMetadataTag.INTERNAL)) {
+            String tagDescription = metadataTagDescriptions.get(tag);
+
+            result.append("<tr>\n");
+            result.append(
+                String.format(
+                    "<td id=\"metadata_tag_%s\"><code>%s</code></td>\n",
+                    tag, tag.name().toLowerCase()));
+            result.append(String.format("<td>%s</td>\n", HTML_ESCAPER.escape(tagDescription)));
+            result.append("</tr>\n");
+          }
+        }
+        result.append("</table>\n");
+      }
+
       outErr.printOut(result.toString());
     }
 
     private void appendOptionsHtml(
         StringBuilder result, Iterable<Class<? extends OptionsBase>> optionsClasses) {
       OptionsParser parser = OptionsParser.newOptionsParser(optionsClasses);
-      result.append(parser.describeOptionsHtml(optionCategories, HTML_ESCAPER)
-          .replace("%{product}", runtime.getProductName()));
+      String productName = runtime.getProductName();
+      if (useNewCategoriesEnum) {
+        result.append(
+            parser
+                .describeOptionsHtml(HTML_ESCAPER, productName)
+                .replace("%{product}", productName));
+      } else {
+        result.append(
+            parser
+                .describeOptionsHtmlWithDeprecatedCategories(
+                    deprecatedOptionCategoryDescriptions, HTML_ESCAPER)
+                .replace("%{product}", productName));
+      }
     }
 
     private static String capitalize(String s) {
@@ -510,3 +611,4 @@
     void visit(String commandName, Command commandAnnotation, OptionsParser parser);
   }
 }
+
diff --git a/src/main/java/com/google/devtools/common/options/OptionFilterDescriptions.java b/src/main/java/com/google/devtools/common/options/OptionFilterDescriptions.java
new file mode 100644
index 0000000..4c6efef
--- /dev/null
+++ b/src/main/java/com/google/devtools/common/options/OptionFilterDescriptions.java
@@ -0,0 +1,175 @@
+// Copyright 2017 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.common.options;
+
+import com.google.common.collect.ImmutableMap;
+
+/**
+ * Provides descriptions of the options filters, for use in generated documentation and usage text.
+ */
+public class OptionFilterDescriptions {
+
+  /** The order that the categories should be listed in. */
+  static OptionDocumentationCategory[] documentationOrder = {
+    OptionDocumentationCategory.BAZEL_CLIENT_OPTIONS,
+    OptionDocumentationCategory.EXECUTION_STRATEGY,
+    OptionDocumentationCategory.TOOLCHAIN,
+    OptionDocumentationCategory.OUTPUT_SELECTION,
+    OptionDocumentationCategory.OUTPUT_PARAMETERS,
+    OptionDocumentationCategory.SIGNING,
+    OptionDocumentationCategory.TESTING,
+    OptionDocumentationCategory.QUERY,
+    OptionDocumentationCategory.BUILD_TIME_OPTIMIZATION,
+    OptionDocumentationCategory.LOGGING,
+    OptionDocumentationCategory.GENERIC_INPUTS,
+    OptionDocumentationCategory.UNCATEGORIZED
+  };
+
+  static ImmutableMap<OptionDocumentationCategory, String> getOptionCategoriesEnumDescription(
+      String productName) {
+    ImmutableMap.Builder<OptionDocumentationCategory, String> optionCategoriesBuilder =
+        ImmutableMap.builder();
+    optionCategoriesBuilder
+        .put(
+            OptionDocumentationCategory.UNCATEGORIZED,
+            "Miscellaneous options, not otherwise categorized.")
+        .put( // Here for completeness, the help output should not include this option.
+            OptionDocumentationCategory.UNDOCUMENTED,
+            "This feature should not be documented, as it is not meant for general use")
+        .put(
+            OptionDocumentationCategory.BAZEL_CLIENT_OPTIONS,
+            "Options that appear before the command and are parsed by the client")
+        .put(
+            OptionDocumentationCategory.LOGGING,
+            "Options that affect the verbosity, format or location of logging")
+        .put(OptionDocumentationCategory.EXECUTION_STRATEGY, "Options that control build execution")
+        .put(
+            OptionDocumentationCategory.BUILD_TIME_OPTIMIZATION,
+            "Options that trigger optimizations of the build time")
+        .put(
+            OptionDocumentationCategory.OUTPUT_SELECTION,
+            "Options that control the output of the command")
+        .put(
+            OptionDocumentationCategory.OUTPUT_PARAMETERS,
+            "Options that let the user configure the intended output, affecting its value, as "
+                + "opposed to its existence")
+        .put(
+            OptionDocumentationCategory.SIGNING,
+            "Options that affect the signing outputs of a build")
+        .put(
+            OptionDocumentationCategory.TESTING,
+            "Options that govern the behavior of the test environment or test runner")
+        .put(
+            OptionDocumentationCategory.TOOLCHAIN,
+            "Options that configure the toolchain used for action execution")
+        .put(OptionDocumentationCategory.QUERY, "Options relating to query output and semantics")
+        .put(
+            OptionDocumentationCategory.GENERIC_INPUTS,
+            "Options specifying or altering a generic input to a Bazel command that does not fall "
+                + "into other categories.");
+    return optionCategoriesBuilder.build();
+  }
+
+  public static ImmutableMap<OptionEffectTag, String> getOptionEffectTagDescription(
+      String productName) {
+    ImmutableMap.Builder<OptionEffectTag, String> effectTagDescriptionBuilder =
+        ImmutableMap.builder();
+    effectTagDescriptionBuilder
+        .put(OptionEffectTag.UNKNOWN, "This option has unknown, or undocumented, effect.")
+        .put(OptionEffectTag.NO_OP, "This option has literally no effect.")
+        .put(
+            OptionEffectTag.LOSES_INCREMENTAL_STATE,
+            "Changing the value of this option can cause significant loss of incremental "
+                + "state, which slows builds. State could be lost due to a server restart or to "
+                + "invalidation of a large part of the dependency graph.")
+        .put(
+            OptionEffectTag.CHANGES_INPUTS,
+            "This option actively changes the inputs that "
+                + productName
+                + " considers for the build, such as filesystem restrictions, repository versions, "
+                + "or other options.")
+        .put(
+            OptionEffectTag.AFFECTS_OUTPUTS,
+            "This option affects "
+                + productName
+                + "'s outputs. This tag is intentionally broad, can include transitive affects, "
+                + "and does not specify the type of output it affects.")
+        .put(
+            OptionEffectTag.BUILD_FILE_SEMANTICS,
+            "This option affects the semantics of BUILD or .bzl files.")
+        .put(
+            OptionEffectTag.BAZEL_INTERNAL_CONFIGURATION,
+            "This option affects settings of "
+                + productName
+                + "-internal machinery. This tag does not, on its own, mean that build artifacts "
+                + "are affected.")
+        .put(
+            OptionEffectTag.LOADING_AND_ANALYSIS,
+            "This option affects the loading and analysis of dependencies, and the building "
+                + "of the dependency graph.")
+        .put(
+            OptionEffectTag.EXECUTION,
+            "This option affects the execution phase, such as sandboxing or remote execution "
+                + "related options.")
+        .put(
+            OptionEffectTag.HOST_MACHINE_RESOURCE_OPTIMIZATIONS,
+            "This option triggers an optimization that may be machine specific and is not "
+                + "guaranteed to work on all machines. The optimization could include a tradeoff "
+                + "with other aspects of performance, such as memory or cpu cost.")
+        .put(
+            OptionEffectTag.EAGERNESS_TO_EXIT,
+            "This option changes how eagerly "
+                + productName
+                + " will exit from a failure, where a choice between continuing despite the "
+                + "failure and ending the invocation exists.")
+        .put(
+            OptionEffectTag.BAZEL_MONITORING,
+            "This option is used to monitor " + productName + "'s behavior and performance.")
+        .put(
+            OptionEffectTag.TERMINAL_OUTPUT,
+            "This option affects " + productName + "'s terminal output.")
+        .put(
+            OptionEffectTag.ACTION_OPTIONS,
+            "This option changes the command line arguments of one or more build actions.")
+        .put(
+            OptionEffectTag.TEST_RUNNER,
+            "This option changes the testrunner environment of the build.");
+    return effectTagDescriptionBuilder.build();
+  }
+
+  public static ImmutableMap<OptionMetadataTag, String> getOptionMetadataTagDescription(
+      String productName) {
+    ImmutableMap.Builder<OptionMetadataTag, String> effectTagDescriptionBuilder =
+        ImmutableMap.builder();
+    effectTagDescriptionBuilder
+        .put(
+            OptionMetadataTag.EXPERIMENTAL,
+            "This option triggers an experimental feature with no guarantees of functionality.")
+        .put(
+            OptionMetadataTag.INCOMPATIBLE_CHANGE,
+            "This option triggers a breaking change. Use this option to test your migration "
+                + "readiness or get early access to the new feature")
+        .put(
+            OptionMetadataTag.DEPRECATED,
+            "This option is deprecated. It might be that the feature it affects is deprecated, "
+                + "or that another method of supplying the information is preferred.")
+        .put(
+            OptionMetadataTag.HIDDEN, // Here for completeness, these options are UNDOCUMENTED.
+            "This option should not be used by a user, and should not be logged.")
+        .put(
+            OptionMetadataTag.INTERNAL, // Here for completeness, these options are UNDOCUMENTED.
+            "This option isn't even a option, and should not be logged.");
+    return effectTagDescriptionBuilder.build();
+  }
+}
diff --git a/src/main/java/com/google/devtools/common/options/OptionsParser.java b/src/main/java/com/google/devtools/common/options/OptionsParser.java
index 28c2206..2b4bd2a 100644
--- a/src/main/java/com/google/devtools/common/options/OptionsParser.java
+++ b/src/main/java/com/google/devtools/common/options/OptionsParser.java
@@ -17,8 +17,10 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.escape.Escaper;
 import com.google.devtools.common.options.OptionDefinition.NotAnOptionException;
 import java.lang.reflect.Constructor;
@@ -32,6 +34,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Map.Entry;
 import java.util.Set;
 import java.util.function.Consumer;
 import java.util.function.Function;
@@ -224,7 +227,9 @@
   public void parseAndExitUponError(OptionPriority priority, String source, String[] args) {
     for (String arg : args) {
       if (arg.equals("--help")) {
-        System.out.println(describeOptions(ImmutableMap.of(), HelpVerbosity.LONG));
+        System.out.println(
+            describeOptionsWithDeprecatedCategories(ImmutableMap.of(), HelpVerbosity.LONG));
+
         System.exit(0);
       }
     }
@@ -283,6 +288,78 @@
    * Returns a description of all the options this parser can digest. In addition to {@link Option}
    * annotations, this method also interprets {@link OptionsUsage} annotations which give an
    * intuitive short description for the options. Options of the same category (see {@link
+   * OptionDocumentationCategory}) will be grouped together.
+   *
+   * @param productName the name of this product (blaze, bazel)
+   * @param helpVerbosity if {@code long}, the options will be described verbosely, including their
+   *     types, defaults and descriptions. If {@code medium}, the descriptions are omitted, and if
+   *     {@code short}, the options are just enumerated.
+   */
+  public String describeOptions(String productName, HelpVerbosity helpVerbosity) {
+    StringBuilder desc = new StringBuilder();
+    LinkedHashMap<OptionDocumentationCategory, List<OptionDefinition>> optionsByCategory =
+        getOptionsSortedByCategory();
+    ImmutableMap<OptionDocumentationCategory, String> optionCategoryDescriptions =
+        OptionFilterDescriptions.getOptionCategoriesEnumDescription(productName);
+    for (Entry<OptionDocumentationCategory, List<OptionDefinition>> e :
+        optionsByCategory.entrySet()) {
+      String categoryDescription = optionCategoryDescriptions.get(e.getKey());
+      List<OptionDefinition> categorizedOptionList = e.getValue();
+
+      // Describe the category if we're going to end up using it at all.
+      if (!categorizedOptionList.isEmpty()) {
+        desc.append("\n").append(categoryDescription).append(":\n");
+      }
+      // Describe the options in this category.
+      for (OptionDefinition optionDef : categorizedOptionList) {
+        OptionsUsage.getUsage(optionDef, desc, helpVerbosity, impl.getOptionsData(), true);
+      }
+    }
+
+    return desc.toString().trim();
+  }
+
+  /**
+   * @return all documented options loaded in this parser, grouped by categories in display order.
+   */
+  private LinkedHashMap<OptionDocumentationCategory, List<OptionDefinition>>
+      getOptionsSortedByCategory() {
+    OptionsData data = impl.getOptionsData();
+    if (data.getOptionsClasses().isEmpty()) {
+      return new LinkedHashMap<>();
+    }
+
+    // Get the documented options grouped by category.
+    ListMultimap<OptionDocumentationCategory, OptionDefinition> optionsByCategories =
+        ArrayListMultimap.create();
+    for (Class<? extends OptionsBase> optionsClass : data.getOptionsClasses()) {
+      for (OptionDefinition optionDefinition :
+          OptionsData.getAllOptionDefinitionsForClass(optionsClass)) {
+        // Only track documented options.
+        if (optionDefinition.getDocumentationCategory()
+            != OptionDocumentationCategory.UNDOCUMENTED) {
+          optionsByCategories.put(optionDefinition.getDocumentationCategory(), optionDefinition);
+        }
+      }
+    }
+
+    // Put the categories into display order and sort the options in each category.
+    LinkedHashMap<OptionDocumentationCategory, List<OptionDefinition>> sortedCategoriesToOptions =
+        new LinkedHashMap<>(OptionFilterDescriptions.documentationOrder.length, 1);
+    for (OptionDocumentationCategory category : OptionFilterDescriptions.documentationOrder) {
+      List<OptionDefinition> optionList = optionsByCategories.get(category);
+      if (optionList != null) {
+        optionList.sort(OptionDefinition.BY_OPTION_NAME);
+        sortedCategoriesToOptions.put(category, optionList);
+      }
+    }
+    return sortedCategoriesToOptions;
+  }
+
+  /**
+   * Returns a description of all the options this parser can digest. In addition to {@link Option}
+   * annotations, this method also interprets {@link OptionsUsage} annotations which give an
+   * intuitive short description for the options. Options of the same category (see {@link
    * Option#category}) will be grouped together.
    *
    * @param categoryDescriptions a mapping from category names to category descriptions.
@@ -291,7 +368,8 @@
    *     types, defaults and descriptions. If {@code medium}, the descriptions are omitted, and if
    *     {@code short}, the options are just enumerated.
    */
-  public String describeOptions(
+  @Deprecated
+  public String describeOptionsWithDeprecatedCategories(
       Map<String, String> categoryDescriptions, HelpVerbosity helpVerbosity) {
     OptionsData data = impl.getOptionsData();
     StringBuilder desc = new StringBuilder();
@@ -318,7 +396,8 @@
 
         if (optionDefinition.getDocumentationCategory()
             != OptionDocumentationCategory.UNDOCUMENTED) {
-          OptionsUsage.getUsage(optionDefinition, desc, helpVerbosity, impl.getOptionsData());
+          OptionsUsage.getUsage(
+              optionDefinition, desc, helpVerbosity, impl.getOptionsData(), false);
         }
       }
     }
@@ -326,17 +405,17 @@
   }
 
   /**
-   * Returns a description of all the options this parser can digest.
-   * In addition to {@link Option} annotations, this method also
-   * interprets {@link OptionsUsage} annotations which give an intuitive short
-   * description for the options.
+   * Returns a description of all the options this parser can digest. In addition to {@link Option}
+   * annotations, this method also interprets {@link OptionsUsage} annotations which give an
+   * intuitive short description for the options.
    *
-   * @param categoryDescriptions a mapping from category names to category
-   *   descriptions.  Options of the same category (see {@link
-   *   Option#category}) will be grouped together, preceded by the description
-   *   of the category.
+   * @param categoryDescriptions a mapping from category names to category descriptions. Options of
+   *     the same category (see {@link Option#category}) will be grouped together, preceded by the
+   *     description of the category.
    */
-  public String describeOptionsHtml(Map<String, String> categoryDescriptions, Escaper escaper) {
+  @Deprecated
+  public String describeOptionsHtmlWithDeprecatedCategories(
+      Map<String, String> categoryDescriptions, Escaper escaper) {
     OptionsData data = impl.getOptionsData();
     StringBuilder desc = new StringBuilder();
     if (!data.getOptionsClasses().isEmpty()) {
@@ -366,7 +445,7 @@
 
         if (optionDefinition.getDocumentationCategory()
             != OptionDocumentationCategory.UNDOCUMENTED) {
-          OptionsUsage.getUsageHtml(optionDefinition, desc, escaper, impl.getOptionsData());
+          OptionsUsage.getUsageHtml(optionDefinition, desc, escaper, impl.getOptionsData(), false);
         }
       }
       desc.append("</dl>\n");
@@ -375,6 +454,37 @@
   }
 
   /**
+   * Returns a description of all the options this parser can digest. In addition to {@link Option}
+   * annotations, this method also interprets {@link OptionsUsage} annotations which give an
+   * intuitive short description for the options.
+   */
+  public String describeOptionsHtml(Escaper escaper, String productName) {
+    StringBuilder desc = new StringBuilder();
+    LinkedHashMap<OptionDocumentationCategory, List<OptionDefinition>> optionsByCategory =
+        getOptionsSortedByCategory();
+    ImmutableMap<OptionDocumentationCategory, String> optionCategoryDescriptions =
+        OptionFilterDescriptions.getOptionCategoriesEnumDescription(productName);
+
+    for (Entry<OptionDocumentationCategory, List<OptionDefinition>> e :
+        optionsByCategory.entrySet()) {
+      desc.append("<dl>");
+      String categoryDescription = optionCategoryDescriptions.get(e.getKey());
+      List<OptionDefinition> categorizedOptionsList = e.getValue();
+
+      // Describe the category if we're going to end up using it at all.
+      if (!categorizedOptionsList.isEmpty()) {
+        desc.append(escaper.escape(categoryDescription)).append(":\n");
+      }
+      // Describe the options in this category.
+      for (OptionDefinition optionDef : categorizedOptionsList) {
+        OptionsUsage.getUsageHtml(optionDef, desc, escaper, impl.getOptionsData(), true);
+      }
+      desc.append("</dl>\n");
+    }
+    return desc.toString();
+  }
+
+  /**
    * Returns a string listing the possible flag completion for this command along with the command
    * completion if any. See {@link OptionsUsage#getCompletion(OptionDefinition, StringBuilder)} for
    * more details on the format for the flag completion.
diff --git a/src/main/java/com/google/devtools/common/options/OptionsParserImpl.java b/src/main/java/com/google/devtools/common/options/OptionsParserImpl.java
index 221dcf0..38248bf 100644
--- a/src/main/java/com/google/devtools/common/options/OptionsParserImpl.java
+++ b/src/main/java/com/google/devtools/common/options/OptionsParserImpl.java
@@ -90,10 +90,9 @@
         }
       };
 
-  /**
-   * Create a new parser object
-   */
+  /** Create a new parser object. Do not accept a null OptionsData object. */
   OptionsParserImpl(OptionsData optionsData) {
+    Preconditions.checkNotNull(optionsData);
     this.optionsData = optionsData;
   }
 
diff --git a/src/main/java/com/google/devtools/common/options/OptionsUsage.java b/src/main/java/com/google/devtools/common/options/OptionsUsage.java
index 68a460e..ecc7a6b 100644
--- a/src/main/java/com/google/devtools/common/options/OptionsUsage.java
+++ b/src/main/java/com/google/devtools/common/options/OptionsUsage.java
@@ -21,7 +21,11 @@
 import com.google.common.escape.Escaper;
 import java.text.BreakIterator;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import javax.annotation.Nullable;
 
 /** A renderer for usage messages for any combination of options classes. */
@@ -42,7 +46,7 @@
         new ArrayList<>(OptionsData.getAllOptionDefinitionsForClass(optionsClass));
     optionDefinitions.sort(OptionDefinition.BY_OPTION_NAME);
     for (OptionDefinition optionDefinition : optionDefinitions) {
-      getUsage(optionDefinition, usage, OptionsParser.HelpVerbosity.LONG, data);
+      getUsage(optionDefinition, usage, OptionsParser.HelpVerbosity.LONG, data, false);
     }
   }
 
@@ -95,19 +99,33 @@
 
   }
 
+  // Placeholder tag "UNKNOWN" is ignored.
+  private static boolean shouldEffectTagBeListed(OptionEffectTag effectTag) {
+    return !effectTag.equals(OptionEffectTag.UNKNOWN);
+  }
+
+  // Tags that only apply to undocumented options are excluded.
+  private static boolean shouldMetadataTagBeListed(OptionMetadataTag metadataTag) {
+    return !metadataTag.equals(OptionMetadataTag.HIDDEN)
+        && !metadataTag.equals(OptionMetadataTag.INTERNAL);
+  }
+
   /** Appends the usage message for a single option-field message to 'usage'. */
   static void getUsage(
       OptionDefinition optionDefinition,
       StringBuilder usage,
       OptionsParser.HelpVerbosity helpVerbosity,
-      OptionsData optionsData) {
+      OptionsData optionsData,
+      boolean includeTags) {
     String flagName = getFlagName(optionDefinition);
     String typeDescription = getTypeDescription(optionDefinition);
     usage.append("  --").append(flagName);
-    if (helpVerbosity == OptionsParser.HelpVerbosity.SHORT) { // just the name
+    if (helpVerbosity == OptionsParser.HelpVerbosity.SHORT) {
       usage.append('\n');
       return;
     }
+
+    // Add the option's type and default information. Stop there for "medium" verbosity.
     if (optionDefinition.getAbbreviation() != '\0') {
       usage.append(" [-").append(optionDefinition.getAbbreviation()).append(']');
     }
@@ -127,9 +145,12 @@
       usage.append(")");
     }
     usage.append("\n");
-    if (helpVerbosity == OptionsParser.HelpVerbosity.MEDIUM) { // just the name and type.
+    if (helpVerbosity == OptionsParser.HelpVerbosity.MEDIUM) {
       return;
     }
+
+    // For verbosity "long," add the full description and expansion, along with the tag
+    // information if requested.
     if (!optionDefinition.getHelpText().isEmpty()) {
       usage.append(paragraphFill(optionDefinition.getHelpText(), /*indent=*/ 4, /*width=*/ 80));
       usage.append('\n');
@@ -151,9 +172,28 @@
       for (String req : optionDefinition.getImplicitRequirements()) {
         requiredMsg.append(req).append(" ");
       }
-      usage.append(paragraphFill(requiredMsg.toString(), /*indent=*/ 6, /*width=*/ 80));
+      usage.append(paragraphFill(requiredMsg.toString(), 6, 80)); // (indent, width)
       usage.append('\n');
     }
+    if (!includeTags) {
+      return;
+    }
+
+    // If we are expected to include the tags, add them for high verbosity.
+    Stream<OptionEffectTag> effectTagStream =
+        Arrays.stream(optionDefinition.getOptionEffectTags())
+            .filter(OptionsUsage::shouldEffectTagBeListed);
+    Stream<OptionMetadataTag> metadataTagStream =
+        Arrays.stream(optionDefinition.getOptionMetadataTags())
+            .filter(OptionsUsage::shouldMetadataTagBeListed);
+    String tagList =
+        Stream.concat(effectTagStream, metadataTagStream)
+            .map(tag -> tag.toString().toLowerCase())
+            .collect(Collectors.joining(", "));
+    if (!tagList.isEmpty()) {
+      usage.append(paragraphFill("Tags: " + tagList, 6, 80)); // (indent, width)
+      usage.append("\n");
+    }
   }
 
   /** Append the usage message for a single option-field message to 'usage'. */
@@ -161,7 +201,8 @@
       OptionDefinition optionDefinition,
       StringBuilder usage,
       Escaper escaper,
-      OptionsData optionsData) {
+      OptionsData optionsData,
+      boolean includeTags) {
     String plainFlagName = optionDefinition.getOptionName();
     String flagName = getFlagName(optionDefinition);
     String valueDescription = optionDefinition.getValueTypeHelpText();
@@ -215,7 +256,10 @@
         Preconditions.checkArgument(!expansion.isEmpty());
         expandsMsg = new StringBuilder("Expands to:<br/>\n");
         for (String exp : expansion) {
-          // TODO(ulfjack): Can we link to the expanded flags here?
+          // TODO(ulfjack): We should link to the expanded flags, but unfortunately we don't
+          // currently guarantee that all flags are only printed once. A flag in an OptionBase that
+          // is included by 2 different commands, but not inherited through a parent command, will
+          // be printed multiple times.
           expandsMsg
               .append("&nbsp;&nbsp;<code>")
               .append(escaper.escape(exp))
@@ -225,6 +269,32 @@
       usage.append(expandsMsg.toString());
     }
 
+    // Add effect tags, if not UNKNOWN, and metadata tags, if not empty.
+    if (includeTags) {
+      Stream<OptionEffectTag> effectTagStream =
+          Arrays.stream(optionDefinition.getOptionEffectTags())
+              .filter(OptionsUsage::shouldEffectTagBeListed);
+      Stream<OptionMetadataTag> metadataTagStream =
+          Arrays.stream(optionDefinition.getOptionMetadataTags())
+              .filter(OptionsUsage::shouldMetadataTagBeListed);
+      String tagList =
+          Stream.concat(
+                  effectTagStream.map(
+                      tag ->
+                          String.format(
+                              "<a href=\"#effect_tag_%s\"><code>%s</code></a>",
+                              tag, tag.name().toLowerCase())),
+                  metadataTagStream.map(
+                      tag ->
+                          String.format(
+                              "<a href=\"#metadata_tag_%s\"><code>%s</code></a>",
+                              tag, tag.name().toLowerCase())))
+              .collect(Collectors.joining(", "));
+      if (!tagList.isEmpty()) {
+        usage.append("<br>Tags: \n").append(tagList);
+      }
+    }
+
     usage.append("</dd>\n");
   }
 
@@ -263,8 +333,10 @@
       builder.append("={auto,yes,no}\n");
       builder.append("--no").append(flagName).append("\n");
     } else if (fieldType.isEnum()) {
-      builder.append("={")
-          .append(COMMA_JOINER.join(fieldType.getEnumConstants()).toLowerCase()).append("}\n");
+      builder
+          .append("={")
+          .append(COMMA_JOINER.join(fieldType.getEnumConstants()).toLowerCase(Locale.ENGLISH))
+          .append("}\n");
     } else if (fieldType.getSimpleName().equals("Label")) {
       // String comparison so we don't introduce a dependency to com.google.devtools.build.lib.
       builder.append("=label\n");
diff --git a/src/test/java/com/google/devtools/common/options/OptionFilterDescriptionsTest.java b/src/test/java/com/google/devtools/common/options/OptionFilterDescriptionsTest.java
new file mode 100644
index 0000000..a8d2814
--- /dev/null
+++ b/src/test/java/com/google/devtools/common/options/OptionFilterDescriptionsTest.java
@@ -0,0 +1,73 @@
+// Copyright 2017 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.common.options;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.ArrayList;
+import java.util.Collections;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests that we have descriptions for every option tag. */
+@RunWith(JUnit4.class)
+public class OptionFilterDescriptionsTest {
+
+  @Test
+  public void documentationOrderIncludesAllDocumentedCategories() {
+    // Expect the documentation order to include everything but the undocumented category.
+    ArrayList<OptionDocumentationCategory> docOrderPlusUndocumented = new ArrayList<>();
+    Collections.addAll(docOrderPlusUndocumented, OptionFilterDescriptions.documentationOrder);
+    docOrderPlusUndocumented.add(OptionDocumentationCategory.UNDOCUMENTED);
+
+    assertThat(OptionDocumentationCategory.values())
+        .asList()
+        .containsExactlyElementsIn(docOrderPlusUndocumented);
+  }
+
+  @Test
+  public void optionDocumentationCategoryDescriptionsContainsAllCategories() {
+    // Check that we have a description for all valid option categories.
+    ImmutableMap<OptionDocumentationCategory, String> optionCategoryDescriptions =
+        OptionFilterDescriptions.getOptionCategoriesEnumDescription("blaze");
+
+    assertThat(OptionDocumentationCategory.values())
+        .asList()
+        .containsExactlyElementsIn(optionCategoryDescriptions.keySet());
+  }
+
+  @Test
+  public void optionEffectTagDescriptionsContainsAllTags() {
+    // Check that we have a description for all valid option tags.
+    ImmutableMap<OptionEffectTag, String> optionEffectTagDescription =
+        OptionFilterDescriptions.getOptionEffectTagDescription("blaze");
+
+    assertThat(OptionEffectTag.values())
+        .asList()
+        .containsExactlyElementsIn(optionEffectTagDescription.keySet());
+  }
+
+  @Test
+  public void optionMetadataTagDescriptionsContainsAllTags() {
+    // Check that we have a description for all valid option tags.
+    ImmutableMap<OptionMetadataTag, String> optionMetadataTagDescription =
+        OptionFilterDescriptions.getOptionMetadataTagDescription("blaze");
+
+    assertThat(OptionMetadataTag.values())
+        .asList()
+        .containsExactlyElementsIn(optionMetadataTagDescription.keySet());
+  }
+}
diff --git a/src/test/java/com/google/devtools/common/options/OptionsParserTest.java b/src/test/java/com/google/devtools/common/options/OptionsParserTest.java
index 41ad1f3..491facc 100644
--- a/src/test/java/com/google/devtools/common/options/OptionsParserTest.java
+++ b/src/test/java/com/google/devtools/common/options/OptionsParserTest.java
@@ -486,7 +486,7 @@
         params,
         ImmutableList.of(
             "--baz\r\n'hello\nworld'\r\n--foo\r\nhello\\\r\nworld\r\n\r\n"
-            + "--nodoc\r\n\"hello\r\nworld\""),
+                + "--nodoc\r\n\"hello\r\nworld\""),
         StandardCharsets.UTF_8,
         StandardOpenOption.CREATE);
 
@@ -1111,7 +1111,8 @@
     // that an options parser be constructed.
     OptionsParser parser = OptionsParser.newOptionsParser(ExpansionOptions.class);
     String usage =
-        parser.describeOptions(ImmutableMap.<String, String>of(), OptionsParser.HelpVerbosity.LONG);
+        parser.describeOptionsWithDeprecatedCategories(
+            ImmutableMap.<String, String>of(), OptionsParser.HelpVerbosity.LONG);
     assertThat(usage).contains("  --expands\n      Expands to: --underlying=from_expansion");
     assertThat(usage).contains("  --expands_by_function\n      Expands to: --expands");
   }
@@ -1256,8 +1257,9 @@
     OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyWarningOptions.class);
     parser.parse("--second=second", "--first=first");
     assertThat(parser.getWarnings())
-        .containsExactly("Option 'second' is implicitly defined by "
-                         + "option 'first'; the implicitly set value overrides the previous one");
+        .containsExactly(
+            "Option 'second' is implicitly defined by "
+                + "option 'first'; the implicitly set value overrides the previous one");
   }
 
   @Test
@@ -1267,8 +1269,9 @@
     assertThat(parser.getWarnings()).isEmpty();
     parser.parse("--second=second");
     assertThat(parser.getWarnings())
-        .containsExactly("A new value for option 'second' overrides a"
-                         + " previous implicit setting of that option by option 'first'");
+        .containsExactly(
+            "A new value for option 'second' overrides a"
+                + " previous implicit setting of that option by option 'first'");
   }
 
   @Test
@@ -1276,8 +1279,9 @@
     OptionsParser parser = OptionsParser.newOptionsParser(ImplicitDependencyWarningOptions.class);
     parser.parse("--first=first", "--second=second");
     assertThat(parser.getWarnings())
-        .containsExactly("Option 'second' is implicitly defined by "
-                         + "option 'first'; the implicitly set value overrides the previous one");
+        .containsExactly(
+            "Option 'second' is implicitly defined by "
+                + "option 'first'; the implicitly set value overrides the previous one");
   }
 
   @Test
@@ -1287,8 +1291,8 @@
     assertThat(parser.getWarnings()).isEmpty();
     parser.parse("--third=third");
     assertThat(parser.getWarnings())
-        .containsExactly("Option 'second' is implicitly defined by both "
-                         + "option 'first' and option 'third'");
+        .containsExactly(
+            "Option 'second' is implicitly defined by both " + "option 'first' and option 'third'");
   }
 
   @Test
@@ -1476,9 +1480,10 @@
   public void warningForExpansionOverridingExplicitOption() throws Exception {
     OptionsParser parser = OptionsParser.newOptionsParser(ExpansionWarningOptions.class);
     parser.parse("--underlying=underlying", "--first");
-    assertThat(parser.getWarnings()).containsExactly(
-        "The option 'first' was expanded and now overrides a "
-        + "previous explicitly specified option 'underlying'");
+    assertThat(parser.getWarnings())
+        .containsExactly(
+            "The option 'first' was expanded and now overrides a "
+                + "previous explicitly specified option 'underlying'");
   }
 
   @Test
diff --git a/src/test/java/com/google/devtools/common/options/OptionsUsageTest.java b/src/test/java/com/google/devtools/common/options/OptionsUsageTest.java
index 6e665b4..c7554a9 100644
--- a/src/test/java/com/google/devtools/common/options/OptionsUsageTest.java
+++ b/src/test/java/com/google/devtools/common/options/OptionsUsageTest.java
@@ -35,130 +35,208 @@
     data = OptionsParser.getOptionsDataInternal(TestOptions.class);
   }
 
-  private String getHtmlUsage(String fieldName) {
+  private String getHtmlUsageWithoutTags(String fieldName) {
     StringBuilder builder = new StringBuilder();
     OptionsUsage.getUsageHtml(
-        data.getOptionDefinitionFromName(fieldName), builder, HTML_ESCAPER, data);
+        data.getOptionDefinitionFromName(fieldName), builder, HTML_ESCAPER, data, false);
     return builder.toString();
   }
 
-  private String getTerminalUsage(String fieldName, HelpVerbosity verbosity) {
+  private String getHtmlUsageWithTags(String fieldName) {
     StringBuilder builder = new StringBuilder();
-    OptionsUsage.getUsage(data.getOptionDefinitionFromName(fieldName), builder, verbosity, data);
+    OptionsUsage.getUsageHtml(
+        data.getOptionDefinitionFromName(fieldName), builder, HTML_ESCAPER, data, true);
+    return builder.toString();
+  }
+
+  private String getTerminalUsageWithoutTags(String fieldName, HelpVerbosity verbosity) {
+    StringBuilder builder = new StringBuilder();
+    OptionsUsage.getUsage(
+        data.getOptionDefinitionFromName(fieldName), builder, verbosity, data, false);
+    return builder.toString();
+  }
+
+  /**
+   * Tests the future behavior of the options usage output. For short & medium verbosity, this
+   * should be the same as the current default
+   */
+  private String getTerminalUsageWithTags(String fieldName, HelpVerbosity verbosity) {
+    StringBuilder builder = new StringBuilder();
+    OptionsUsage.getUsage(
+        data.getOptionDefinitionFromName(fieldName), builder, verbosity, data, true);
     return builder.toString();
   }
 
   @Test
   public void stringValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_string", HelpVerbosity.SHORT)).isEqualTo("  --test_string\n");
+    assertThat(getTerminalUsageWithoutTags("test_string", HelpVerbosity.SHORT))
+        .isEqualTo("  --test_string\n");
+    assertThat(getTerminalUsageWithoutTags("test_string", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_string", HelpVerbosity.SHORT));
   }
 
   @Test
   public void stringValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_string", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_string", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_string (a string; default: \"test string default\")\n");
+    assertThat(getTerminalUsageWithoutTags("test_string", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_string", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void stringValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_string", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_string", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_string (a string; default: \"test string default\")\n"
                 + "    a string-valued option to test simple option operations\n");
+    assertThat(getTerminalUsageWithTags("test_string", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_string (a string; default: \"test string default\")\n"
+                + "    a string-valued option to test simple option operations\n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void stringValue_htmlOutput() {
-    assertThat(getHtmlUsage("test_string"))
+    assertThat(getHtmlUsageWithoutTags("test_string"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_string\"></a>"
                 + "--test_string=&lt;a string&gt</code> default: \"test string default\"</dt>\n"
                 + "<dd>\n"
                 + "a string-valued option to test simple option operations\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_string"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_string\"></a>"
+                + "--test_string=&lt;a string&gt</code> default: \"test string default\"</dt>\n"
+                + "<dd>\n"
+                + "a string-valued option to test simple option operations\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void intValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("expanded_c", HelpVerbosity.SHORT)).isEqualTo("  --expanded_c\n");
+    assertThat(getTerminalUsageWithoutTags("expanded_c", HelpVerbosity.SHORT))
+        .isEqualTo("  --expanded_c\n");
+    assertThat(getTerminalUsageWithoutTags("expanded_c", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("expanded_c", HelpVerbosity.SHORT));
   }
 
   @Test
   public void intValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("expanded_c", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("expanded_c", HelpVerbosity.MEDIUM))
         .isEqualTo("  --expanded_c (an integer; default: \"12\")\n");
+    assertThat(getTerminalUsageWithoutTags("expanded_c", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("expanded_c", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void intValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("expanded_c", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("expanded_c", HelpVerbosity.LONG))
         .isEqualTo(
             "  --expanded_c (an integer; default: \"12\")\n"
                 + "    an int-value'd flag used to test expansion logic\n");
+    assertThat(getTerminalUsageWithTags("expanded_c", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --expanded_c (an integer; default: \"12\")\n"
+                + "    an int-value'd flag used to test expansion logic\n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void intValue_htmlOutput() {
-    assertThat(getHtmlUsage("expanded_c"))
+    assertThat(getHtmlUsageWithoutTags("expanded_c"))
         .isEqualTo(
             "<dt><code><a name=\"flag--expanded_c\"></a>"
                 + "--expanded_c=&lt;an integer&gt</code> default: \"12\"</dt>\n"
                 + "<dd>\n"
                 + "an int-value&#39;d flag used to test expansion logic\n"
                 + "</dd>\n");
-  }
-
-  @Test
-  public void booleanValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("expanded_a", HelpVerbosity.SHORT))
-        .isEqualTo("  --[no]expanded_a\n");
-  }
-
-  @Test
-  public void booleanValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("expanded_a", HelpVerbosity.MEDIUM))
-        .isEqualTo("  --[no]expanded_a (a boolean; default: \"true\")\n");
-  }
-
-  @Test
-  public void booleanValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("expanded_a", HelpVerbosity.LONG))
-        .isEqualTo("  --[no]expanded_a (a boolean; default: \"true\")\n");
-  }
-
-  @Test
-  public void booleanValue_htmlOutput() {
-    assertThat(getHtmlUsage("expanded_a"))
+    assertThat(getHtmlUsageWithTags("expanded_c"))
         .isEqualTo(
-            "<dt><code><a name=\"flag--expanded_a\"></a>"
-                + "--[no]expanded_a</code> default: \"true\"</dt>\n"
+            "<dt><code><a name=\"flag--expanded_c\"></a>"
+                + "--expanded_c=&lt;an integer&gt</code> default: \"12\"</dt>\n"
                 + "<dd>\n"
+                + "an int-value&#39;d flag used to test expansion logic\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
                 + "</dd>\n");
   }
 
   @Test
+  public void booleanValue_shortTerminalOutput() {
+    assertThat(getTerminalUsageWithoutTags("expanded_a", HelpVerbosity.SHORT))
+        .isEqualTo("  --[no]expanded_a\n");
+    assertThat(getTerminalUsageWithoutTags("expanded_a", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("expanded_a", HelpVerbosity.SHORT));
+  }
+
+  @Test
+  public void booleanValue_mediumTerminalOutput() {
+    assertThat(getTerminalUsageWithoutTags("expanded_a", HelpVerbosity.MEDIUM))
+        .isEqualTo("  --[no]expanded_a (a boolean; default: \"true\")\n");
+    assertThat(getTerminalUsageWithoutTags("expanded_a", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("expanded_a", HelpVerbosity.MEDIUM));
+  }
+
+  @Test
+  public void booleanValue_longTerminalOutput() {
+    assertThat(getTerminalUsageWithoutTags("expanded_a", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --[no]expanded_a (a boolean; default: \"true\")\n"
+                + "    A boolean flag with unknown effect to test tagless usage text.\n");
+    // This flag has no useful tags, to verify that the tag line is omitted, so the usage line
+    // should be the same in both tag and tag-free world.
+    assertThat(getTerminalUsageWithoutTags("expanded_a", HelpVerbosity.LONG))
+        .isEqualTo(getTerminalUsageWithTags("expanded_a", HelpVerbosity.LONG));
+  }
+
+  @Test
+  public void booleanValue_htmlOutput() {
+    assertThat(getHtmlUsageWithoutTags("expanded_a"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--expanded_a\"></a>"
+                + "--[no]expanded_a</code> default: \"true\"</dt>\n"
+                + "<dd>\n"
+                + "A boolean flag with unknown effect to test tagless usage text.\n"
+                + "</dd>\n");
+    assertThat(getHtmlUsageWithoutTags("expanded_a")).isEqualTo(getHtmlUsageWithTags("expanded_a"));
+  }
+
+  @Test
   public void multipleValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_multiple_string", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("test_multiple_string", HelpVerbosity.SHORT))
         .isEqualTo("  --test_multiple_string\n");
+    assertThat(getTerminalUsageWithoutTags("test_multiple_string", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_multiple_string", HelpVerbosity.SHORT));
   }
 
   @Test
   public void multipleValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_multiple_string", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_multiple_string", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_multiple_string (a string; may be used multiple times)\n");
+    assertThat(getTerminalUsageWithoutTags("test_multiple_string", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_multiple_string", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void multipleValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_multiple_string", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_multiple_string", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_multiple_string (a string; may be used multiple times)\n"
                 + "    a repeatable string-valued flag with its own unhelpful help text\n");
+    assertThat(getTerminalUsageWithTags("test_multiple_string", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_multiple_string (a string; may be used multiple times)\n"
+                + "    a repeatable string-valued flag with its own unhelpful help text\n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void multipleValue_htmlOutput() {
-    assertThat(getHtmlUsage("test_multiple_string"))
+    assertThat(getHtmlUsageWithoutTags("test_multiple_string"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_multiple_string\"></a>"
                 + "--test_multiple_string=&lt;a string&gt</code> "
@@ -166,32 +244,52 @@
                 + "<dd>\n"
                 + "a repeatable string-valued flag with its own unhelpful help text\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_multiple_string"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_multiple_string\"></a>"
+                + "--test_multiple_string=&lt;a string&gt</code> "
+                + "multiple uses are accumulated</dt>\n"
+                + "<dd>\n"
+                + "a repeatable string-valued flag with its own unhelpful help text\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void customConverterValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_list_converters", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("test_list_converters", HelpVerbosity.SHORT))
         .isEqualTo("  --test_list_converters\n");
+    assertThat(getTerminalUsageWithoutTags("test_list_converters", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_list_converters", HelpVerbosity.SHORT));
   }
 
   @Test
   public void customConverterValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_list_converters", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_list_converters", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_list_converters (a list of strings; may be used multiple times)\n");
+    assertThat(getTerminalUsageWithoutTags("test_list_converters", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_list_converters", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void customConverterValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_list_converters", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_list_converters", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_list_converters (a list of strings; may be used multiple times)\n"
                 + "    a repeatable flag that accepts lists, but doesn't want to have lists of \n"
                 + "    lists as a final type\n");
+    assertThat(getTerminalUsageWithTags("test_list_converters", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_list_converters (a list of strings; may be used multiple times)\n"
+                + "    a repeatable flag that accepts lists, but doesn't want to have lists of \n"
+                + "    lists as a final type\n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void customConverterValue_htmlOutput() {
-    assertThat(getHtmlUsage("test_list_converters"))
+    assertThat(getHtmlUsageWithoutTags("test_list_converters"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_list_converters\"></a>"
                 + "--test_list_converters=&lt;a list of strings&gt</code> "
@@ -200,33 +298,55 @@
                 + "a repeatable flag that accepts lists, but doesn&#39;t want to have lists of \n"
                 + "lists as a final type\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_list_converters"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_list_converters\"></a>"
+                + "--test_list_converters=&lt;a list of strings&gt</code> "
+                + "multiple uses are accumulated</dt>\n"
+                + "<dd>\n"
+                + "a repeatable flag that accepts lists, but doesn&#39;t want to have lists of \n"
+                + "lists as a final type\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void staticExpansionOption_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("test_expansion", HelpVerbosity.SHORT))
         .isEqualTo("  --test_expansion\n");
+    assertThat(getTerminalUsageWithoutTags("test_expansion", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_expansion", HelpVerbosity.SHORT));
   }
 
   @Test
   public void staticExpansionOption_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_expansion", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_expansion\n");
+    assertThat(getTerminalUsageWithoutTags("test_expansion", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_expansion", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void staticExpansionOption_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_expansion", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_expansion\n"
                 + "    this expands to an alphabet soup.\n"
                 + "      Expands to: --noexpanded_a --expanded_b=false --expanded_c 42 --\n"
                 + "      expanded_d bar \n");
+    assertThat(getTerminalUsageWithTags("test_expansion", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_expansion\n"
+                + "    this expands to an alphabet soup.\n"
+                + "      Expands to: --noexpanded_a --expanded_b=false --expanded_c 42 --\n"
+                + "      expanded_d bar \n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void staticExpansionOption_htmlOutput() {
-    assertThat(getHtmlUsage("test_expansion"))
+    assertThat(getHtmlUsageWithoutTags("test_expansion"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_expansion\"></a>"
                 + "--test_expansion</code></dt>\n"
@@ -241,33 +361,68 @@
                 + "&nbsp;&nbsp;<code>--expanded_d</code><br/>\n"
                 + "&nbsp;&nbsp;<code>bar</code><br/>\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_expansion"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_expansion\"></a>"
+                + "--test_expansion</code></dt>\n"
+                + "<dd>\n"
+                + "this expands to an alphabet soup.\n"
+                + "<br/>\n"
+                + "Expands to:<br/>\n"
+                + "&nbsp;&nbsp;<code>--noexpanded_a</code><br/>\n"
+                + "&nbsp;&nbsp;<code>--expanded_b=false</code><br/>\n"
+                + "&nbsp;&nbsp;<code>--expanded_c</code><br/>\n"
+                + "&nbsp;&nbsp;<code>42</code><br/>\n"
+                + "&nbsp;&nbsp;<code>--expanded_d</code><br/>\n"
+                + "&nbsp;&nbsp;<code>bar</code><br/>\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void recursiveExpansionOption_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_recursive_expansion_top_level", HelpVerbosity.SHORT))
+    assertThat(
+            getTerminalUsageWithoutTags("test_recursive_expansion_top_level", HelpVerbosity.SHORT))
         .isEqualTo("  --test_recursive_expansion_top_level\n");
+    assertThat(
+            getTerminalUsageWithoutTags("test_recursive_expansion_top_level", HelpVerbosity.SHORT))
+        .isEqualTo(
+            getTerminalUsageWithTags("test_recursive_expansion_top_level", HelpVerbosity.SHORT));
   }
 
   @Test
   public void recursiveExpansionOption_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_recursive_expansion_top_level", HelpVerbosity.MEDIUM))
+    assertThat(
+            getTerminalUsageWithoutTags("test_recursive_expansion_top_level", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_recursive_expansion_top_level\n");
+    assertThat(
+            getTerminalUsageWithoutTags("test_recursive_expansion_top_level", HelpVerbosity.MEDIUM))
+        .isEqualTo(
+            getTerminalUsageWithTags("test_recursive_expansion_top_level", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void recursiveExpansionOption_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_recursive_expansion_top_level", HelpVerbosity.LONG))
+    assertThat(
+            getTerminalUsageWithoutTags("test_recursive_expansion_top_level", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_recursive_expansion_top_level\n"
                 + "    Lets the children do all the work.\n"
                 + "      Expands to: --test_recursive_expansion_middle1 --\n"
                 + "      test_recursive_expansion_middle2 \n");
+    assertThat(getTerminalUsageWithTags("test_recursive_expansion_top_level", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_recursive_expansion_top_level\n"
+                + "    Lets the children do all the work.\n"
+                + "      Expands to: --test_recursive_expansion_middle1 --\n"
+                + "      test_recursive_expansion_middle2 \n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void recursiveExpansionOption_htmlOutput() {
-    assertThat(getHtmlUsage("test_recursive_expansion_top_level"))
+    assertThat(getHtmlUsageWithoutTags("test_recursive_expansion_top_level"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_recursive_expansion_top_level\"></a>"
                 + "--test_recursive_expansion_top_level</code></dt>\n"
@@ -278,33 +433,57 @@
                 + "&nbsp;&nbsp;<code>--test_recursive_expansion_middle1</code><br/>\n"
                 + "&nbsp;&nbsp;<code>--test_recursive_expansion_middle2</code><br/>\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_recursive_expansion_top_level"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_recursive_expansion_top_level\"></a>"
+                + "--test_recursive_expansion_top_level</code></dt>\n"
+                + "<dd>\n"
+                + "Lets the children do all the work.\n"
+                + "<br/>\n"
+                + "Expands to:<br/>\n"
+                + "&nbsp;&nbsp;<code>--test_recursive_expansion_middle1</code><br/>\n"
+                + "&nbsp;&nbsp;<code>--test_recursive_expansion_middle2</code><br/>\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void expansionToMultipleValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion_to_repeatable", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("test_expansion_to_repeatable", HelpVerbosity.SHORT))
         .isEqualTo("  --test_expansion_to_repeatable\n");
+    assertThat(getTerminalUsageWithoutTags("test_expansion_to_repeatable", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_expansion_to_repeatable", HelpVerbosity.SHORT));
   }
 
   @Test
   public void expansionToMultipleValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion_to_repeatable", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_expansion_to_repeatable", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_expansion_to_repeatable\n");
+    assertThat(getTerminalUsageWithoutTags("test_expansion_to_repeatable", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_expansion_to_repeatable", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void expansionToMultipleValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion_to_repeatable", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_expansion_to_repeatable", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_expansion_to_repeatable\n"
                 + "    Go forth and multiply, they said.\n"
                 + "      Expands to: --test_multiple_string=expandedFirstValue --\n"
                 + "      test_multiple_string=expandedSecondValue \n");
+    assertThat(getTerminalUsageWithTags("test_expansion_to_repeatable", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_expansion_to_repeatable\n"
+                + "    Go forth and multiply, they said.\n"
+                + "      Expands to: --test_multiple_string=expandedFirstValue --\n"
+                + "      test_multiple_string=expandedSecondValue \n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void expansionToMultipleValue_htmlOutput() {
-    assertThat(getHtmlUsage("test_expansion_to_repeatable"))
+    assertThat(getHtmlUsageWithoutTags("test_expansion_to_repeatable"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_expansion_to_repeatable\"></a>"
                 + "--test_expansion_to_repeatable</code></dt>\n"
@@ -315,33 +494,57 @@
                 + "&nbsp;&nbsp;<code>--test_multiple_string=expandedFirstValue</code><br/>\n"
                 + "&nbsp;&nbsp;<code>--test_multiple_string=expandedSecondValue</code><br/>\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_expansion_to_repeatable"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_expansion_to_repeatable\"></a>"
+                + "--test_expansion_to_repeatable</code></dt>\n"
+                + "<dd>\n"
+                + "Go forth and multiply, they said.\n"
+                + "<br/>\n"
+                + "Expands to:<br/>\n"
+                + "&nbsp;&nbsp;<code>--test_multiple_string=expandedFirstValue</code><br/>\n"
+                + "&nbsp;&nbsp;<code>--test_multiple_string=expandedSecondValue</code><br/>\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void implicitRequirementOption_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_implicit_requirement", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("test_implicit_requirement", HelpVerbosity.SHORT))
         .isEqualTo("  --test_implicit_requirement\n");
+    assertThat(getTerminalUsageWithoutTags("test_implicit_requirement", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_implicit_requirement", HelpVerbosity.SHORT));
   }
 
   @Test
   public void implicitRequirementOption_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_implicit_requirement", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_implicit_requirement", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_implicit_requirement (a string; default: \"direct implicit\")\n");
+    assertThat(getTerminalUsageWithoutTags("test_implicit_requirement", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_implicit_requirement", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void implicitRequirementOption_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_implicit_requirement", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_implicit_requirement", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_implicit_requirement (a string; default: \"direct implicit\")\n"
                 + "    this option really needs that other one, isolation of purpose has failed.\n"
                 + "      Using this option will also add: --implicit_requirement_a=implicit \n"
                 + "      requirement, required \n");
+    assertThat(getTerminalUsageWithTags("test_implicit_requirement", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_implicit_requirement (a string; default: \"direct implicit\")\n"
+                + "    this option really needs that other one, isolation of purpose has failed.\n"
+                + "      Using this option will also add: --implicit_requirement_a=implicit \n"
+                + "      requirement, required \n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void implicitRequirementOption_htmlOutput() {
-    assertThat(getHtmlUsage("test_implicit_requirement"))
+    assertThat(getHtmlUsageWithoutTags("test_implicit_requirement"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_implicit_requirement\"></a>"
                 + "--test_implicit_requirement=&lt;a string&gt</code> "
@@ -349,32 +552,52 @@
                 + "<dd>\n"
                 + "this option really needs that other one, isolation of purpose has failed.\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_implicit_requirement"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_implicit_requirement\"></a>"
+                + "--test_implicit_requirement=&lt;a string&gt</code> "
+                + "default: \"direct implicit\"</dt>\n"
+                + "<dd>\n"
+                + "this option really needs that other one, isolation of purpose has failed.\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void expansionFunctionOptionThatReadsUserValue_shortTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion_function", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("test_expansion_function", HelpVerbosity.SHORT))
         .isEqualTo("  --test_expansion_function\n");
+    assertThat(getTerminalUsageWithoutTags("test_expansion_function", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_expansion_function", HelpVerbosity.SHORT));
   }
 
   @Test
   public void expansionFunctionOptionThatReadsUserValue_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion_function", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("test_expansion_function", HelpVerbosity.MEDIUM))
         .isEqualTo("  --test_expansion_function\n");
+    assertThat(getTerminalUsageWithoutTags("test_expansion_function", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_expansion_function", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void expansionFunctionOptionThatReadsUserValue_longTerminalOutput() {
-    assertThat(getTerminalUsage("test_expansion_function", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("test_expansion_function", HelpVerbosity.LONG))
         .isEqualTo(
             "  --test_expansion_function\n"
                 + "    this is for testing expansion-by-function functionality.\n"
                 + "      Expands to unknown options.\n");
+    assertThat(getTerminalUsageWithTags("test_expansion_function", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_expansion_function\n"
+                + "    this is for testing expansion-by-function functionality.\n"
+                + "      Expands to unknown options.\n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void expansionFunctionOptionThatReadsUserValue_htmlOutput() {
-    assertThat(getHtmlUsage("test_expansion_function"))
+    assertThat(getHtmlUsageWithoutTags("test_expansion_function"))
         .isEqualTo(
             "<dt><code><a name=\"flag--test_expansion_function\"></a>"
                 + "--test_expansion_function</code></dt>\n"
@@ -383,32 +606,53 @@
                 + "<br/>\n"
                 + "Expands to unknown options.<br/>\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_expansion_function"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_expansion_function\"></a>"
+                + "--test_expansion_function</code></dt>\n"
+                + "<dd>\n"
+                + "this is for testing expansion-by-function functionality.\n"
+                + "<br/>\n"
+                + "Expands to unknown options.<br/>\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
   }
 
   @Test
   public void expansionFunctionOptionThatExpandsBasedOnOtherLoadedOptions_shortTerminalOutput() {
-    assertThat(getTerminalUsage("prefix_expansion", HelpVerbosity.SHORT))
+    assertThat(getTerminalUsageWithoutTags("prefix_expansion", HelpVerbosity.SHORT))
         .isEqualTo("  --prefix_expansion\n");
+    assertThat(getTerminalUsageWithoutTags("prefix_expansion", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("prefix_expansion", HelpVerbosity.SHORT));
   }
 
   @Test
   public void expansionFunctionOptionThatExpandsBasedOnOtherLoadedOptions_mediumTerminalOutput() {
-    assertThat(getTerminalUsage("prefix_expansion", HelpVerbosity.MEDIUM))
+    assertThat(getTerminalUsageWithoutTags("prefix_expansion", HelpVerbosity.MEDIUM))
         .isEqualTo("  --prefix_expansion\n");
+    assertThat(getTerminalUsageWithoutTags("prefix_expansion", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("prefix_expansion", HelpVerbosity.MEDIUM));
   }
 
   @Test
   public void expansionFunctionOptionThatExpandsBasedOnOtherLoadedOptions_longTerminalOutput() {
-    assertThat(getTerminalUsage("prefix_expansion", HelpVerbosity.LONG))
+    assertThat(getTerminalUsageWithoutTags("prefix_expansion", HelpVerbosity.LONG))
         .isEqualTo(
             "  --prefix_expansion\n"
                 + "    Expands to all options with a specific prefix.\n"
                 + "      Expands to: --specialexp_bar --specialexp_foo \n");
+    assertThat(getTerminalUsageWithTags("prefix_expansion", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --prefix_expansion\n"
+                + "    Expands to all options with a specific prefix.\n"
+                + "      Expands to: --specialexp_bar --specialexp_foo \n"
+                + "      Tags: no_op\n");
   }
 
   @Test
   public void expansionFunctionOptionThatExpandsBasedOnOtherLoadedOptions_htmlOutput() {
-    assertThat(getHtmlUsage("prefix_expansion"))
+    assertThat(getHtmlUsageWithoutTags("prefix_expansion"))
         .isEqualTo(
             "<dt><code><a name=\"flag--prefix_expansion\"></a>"
                 + "--prefix_expansion</code></dt>\n"
@@ -419,5 +663,80 @@
                 + "&nbsp;&nbsp;<code>--specialexp_bar</code><br/>\n"
                 + "&nbsp;&nbsp;<code>--specialexp_foo</code><br/>\n"
                 + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("prefix_expansion"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--prefix_expansion\"></a>"
+                + "--prefix_expansion</code></dt>\n"
+                + "<dd>\n"
+                + "Expands to all options with a specific prefix.\n"
+                + "<br/>\n"
+                + "Expands to:<br/>\n"
+                + "&nbsp;&nbsp;<code>--specialexp_bar</code><br/>\n"
+                + "&nbsp;&nbsp;<code>--specialexp_foo</code><br/>\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_NO_OP\"><code>no_op</code></a>"
+                + "</dd>\n");
+  }
+
+  @Test
+  public void tagHeavyExpansionOption_shortTerminalOutput() {
+    assertThat(getTerminalUsageWithoutTags("test_void_expansion_function", HelpVerbosity.SHORT))
+        .isEqualTo("  --test_void_expansion_function\n");
+    assertThat(getTerminalUsageWithoutTags("test_void_expansion_function", HelpVerbosity.SHORT))
+        .isEqualTo(getTerminalUsageWithTags("test_void_expansion_function", HelpVerbosity.SHORT));
+  }
+
+  @Test
+  public void tagHeavyExpansionOption_mediumTerminalOutput() {
+    assertThat(getTerminalUsageWithoutTags("test_void_expansion_function", HelpVerbosity.MEDIUM))
+        .isEqualTo("  --test_void_expansion_function\n");
+    assertThat(getTerminalUsageWithoutTags("test_void_expansion_function", HelpVerbosity.MEDIUM))
+        .isEqualTo(getTerminalUsageWithTags("test_void_expansion_function", HelpVerbosity.MEDIUM));
+  }
+
+  @Test
+  public void tagHeavyExpansionOption_longTerminalOutput() {
+    assertThat(getTerminalUsageWithoutTags("test_void_expansion_function", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_void_expansion_function\n"
+                + "    Listing a ton of random tags to test the usage output.\n"
+                + "      Expands to: --expanded_d void expanded \n");
+    assertThat(getTerminalUsageWithTags("test_void_expansion_function", HelpVerbosity.LONG))
+        .isEqualTo(
+            "  --test_void_expansion_function\n"
+                + "    Listing a ton of random tags to test the usage output.\n"
+                + "      Expands to: --expanded_d void expanded \n"
+                + "      Tags: action_options, test_runner, terminal_output, experimental\n");
+  }
+
+  @Test
+  public void tagHeavyExpansionOption_htmlOutput() {
+    assertThat(getHtmlUsageWithoutTags("test_void_expansion_function"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_void_expansion_function\"></a>"
+                + "--test_void_expansion_function</code></dt>\n"
+                + "<dd>\n"
+                + "Listing a ton of random tags to test the usage output.\n"
+                + "<br/>\n"
+                + "Expands to:<br/>\n"
+                + "&nbsp;&nbsp;<code>--expanded_d</code><br/>\n"
+                + "&nbsp;&nbsp;<code>void expanded</code><br/>\n"
+                + "</dd>\n");
+    assertThat(getHtmlUsageWithTags("test_void_expansion_function"))
+        .isEqualTo(
+            "<dt><code><a name=\"flag--test_void_expansion_function\"></a>"
+                + "--test_void_expansion_function</code></dt>\n"
+                + "<dd>\n"
+                + "Listing a ton of random tags to test the usage output.\n"
+                + "<br/>\n"
+                + "Expands to:<br/>\n"
+                + "&nbsp;&nbsp;<code>--expanded_d</code><br/>\n"
+                + "&nbsp;&nbsp;<code>void expanded</code><br/>\n"
+                + "<br>Tags: \n"
+                + "<a href=\"#effect_tag_ACTION_OPTIONS\"><code>action_options</code></a>, "
+                + "<a href=\"#effect_tag_TEST_RUNNER\"><code>test_runner</code></a>, "
+                + "<a href=\"#effect_tag_TERMINAL_OUTPUT\"><code>terminal_output</code></a>, "
+                + "<a href=\"#metadata_tag_EXPERIMENTAL\"><code>experimental</code></a>"
+                + "</dd>\n");
   }
 }
diff --git a/src/test/java/com/google/devtools/common/options/TestOptions.java b/src/test/java/com/google/devtools/common/options/TestOptions.java
index 04aeee9..4eb1a2d 100644
--- a/src/test/java/com/google/devtools/common/options/TestOptions.java
+++ b/src/test/java/com/google/devtools/common/options/TestOptions.java
@@ -141,8 +141,9 @@
   @Option(
     name = "expanded_a",
     documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-    effectTags = {OptionEffectTag.NO_OP},
-    defaultValue = "true"
+    effectTags = {OptionEffectTag.UNKNOWN},
+    defaultValue = "true",
+    help = "A boolean flag with unknown effect to test tagless usage text."
   )
   public boolean expandedA;
 
@@ -283,8 +284,14 @@
     name = "test_void_expansion_function",
     defaultValue = "null",
     documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-    effectTags = {OptionEffectTag.NO_OP},
-    expansionFunction = TestVoidExpansionFunction.class
+    effectTags = {
+      OptionEffectTag.ACTION_OPTIONS,
+      OptionEffectTag.TEST_RUNNER,
+      OptionEffectTag.TERMINAL_OUTPUT
+    },
+    metadataTags = {OptionMetadataTag.EXPERIMENTAL},
+    expansionFunction = TestVoidExpansionFunction.class,
+    help = "Listing a ton of random tags to test the usage output."
   )
   public Void testVoidExpansionFunction;