Add a converter for relative, null-able path fragments.

RELNOTES: None.
PiperOrigin-RevId: 238653219
diff --git a/src/main/java/com/google/devtools/build/lib/util/OptionsUtils.java b/src/main/java/com/google/devtools/build/lib/util/OptionsUtils.java
index 8594a9c..4ce2631 100644
--- a/src/main/java/com/google/devtools/build/lib/util/OptionsUtils.java
+++ b/src/main/java/com/google/devtools/build/lib/util/OptionsUtils.java
@@ -15,16 +15,16 @@
 package com.google.devtools.build.lib.util;
 
 import com.google.common.base.Preconditions;
+import com.google.common.base.StandardSystemProperty;
 import com.google.common.collect.ImmutableList;
 import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.common.options.Converter;
+import com.google.devtools.common.options.OptionsParsingException;
 import com.google.devtools.common.options.OptionsParsingResult;
 import com.google.devtools.common.options.ParsedOptionDescription;
 import java.util.List;
 
-/**
- * Blaze-specific option utilities.
- */
+/** Blaze-specific option utilities. */
 public final class OptionsUtils {
 
   /**
@@ -46,8 +46,8 @@
   }
 
   /**
-   * Returns a string representation of the non-hidden explicitly or implicitly
-   * specified options; option values are shell-escaped.
+   * Returns a string representation of the non-hidden explicitly or implicitly specified options;
+   * option values are shell-escaped.
    */
   public static String asShellEscapedString(OptionsParsingResult options) {
     return asShellEscapedString(options.asCompleteListOfParsedOptions());
@@ -86,27 +86,19 @@
   }
 
   /**
-   * Returns a string representation of the non-hidden explicitly or implicitly
-   * specified options, filtering out any sensitive options; option values are
-   * shell-escaped.
+   * Returns a string representation of the non-hidden explicitly or implicitly specified options,
+   * filtering out any sensitive options; option values are shell-escaped.
    */
   public static String asFilteredShellEscapedString(OptionsParsingResult options) {
     return asFilteredShellEscapedString(options, options.asCompleteListOfParsedOptions());
   }
 
-  /**
-   * Converter from String to PathFragment.
-   */
-  public static class PathFragmentConverter
-      implements Converter<PathFragment> {
+  /** Converter from String to PathFragment. */
+  public static class PathFragmentConverter implements Converter<PathFragment> {
 
     @Override
     public PathFragment convert(String input) {
-      String path = Preconditions.checkNotNull(input);
-      if (!path.isEmpty() && path.startsWith("~/")) {
-        path = path.replace("~", System.getProperty("user.home"));
-      }
-      return PathFragment.create(path);
+      return convertOptionsPathFragment(Preconditions.checkNotNull(input));
     }
 
     @Override
@@ -115,6 +107,30 @@
     }
   }
 
+  /** Converter from String to PathFragment. If the input is empty returns {@code null} instead. */
+  public static class EmptyToNullRelativePathFragmentConverter implements Converter<PathFragment> {
+
+    @Override
+    public PathFragment convert(String input) throws OptionsParsingException {
+      if (input.isEmpty()) {
+        return null;
+      }
+
+      PathFragment pathFragment = convertOptionsPathFragment(input);
+
+      if (pathFragment.isAbsolute()) {
+        throw new OptionsParsingException("Expected relative path but got '" + input + "'.");
+      }
+
+      return pathFragment;
+    }
+
+    @Override
+    public String getTypeDescription() {
+      return "a relative path";
+    }
+  }
+
   /** Converts from a colon-separated list of strings into a list of PathFragment instances. */
   public static class PathFragmentListConverter implements Converter<ImmutableList<PathFragment>> {
 
@@ -123,10 +139,7 @@
       ImmutableList.Builder<PathFragment> result = ImmutableList.builder();
       for (String piece : input.split(":")) {
         if (!piece.isEmpty()) {
-          if (piece.startsWith("~/")) {
-            piece = piece.replace("~", System.getProperty("user.home"));
-          }
-          result.add(PathFragment.create(piece));
+          result.add(convertOptionsPathFragment(piece));
         }
       }
       return result.build();
@@ -137,4 +150,11 @@
       return "a colon-separated list of paths";
     }
   }
+
+  private static PathFragment convertOptionsPathFragment(String path) {
+    if (!path.isEmpty() && path.startsWith("~/")) {
+      path = path.replace("~", StandardSystemProperty.USER_HOME.value());
+    }
+    return PathFragment.create(path);
+  }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java b/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java
index fb69921..24a2b25 100644
--- a/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java
+++ b/src/test/java/com/google/devtools/build/lib/util/OptionsUtilsTest.java
@@ -14,6 +14,7 @@
 package com.google.devtools.build.lib.util;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertThrows;
 
 import com.google.devtools.build.lib.util.OptionsUtils.PathFragmentConverter;
 import com.google.devtools.build.lib.util.OptionsUtils.PathFragmentListConverter;
@@ -25,58 +26,52 @@
 import com.google.devtools.common.options.OptionPriority.PriorityCategory;
 import com.google.devtools.common.options.OptionsBase;
 import com.google.devtools.common.options.OptionsParser;
+import com.google.devtools.common.options.OptionsParsingException;
 import java.util.Arrays;
 import java.util.List;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
-/**
- * Test for {@link OptionsUtils}.
- */
+/** Test for {@link OptionsUtils}. */
 @RunWith(JUnit4.class)
 public class OptionsUtilsTest {
 
   public static class IntrospectionExample extends OptionsBase {
     @Option(
-      name = "alpha",
-      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "alpha"
-    )
+        name = "alpha",
+        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "alpha")
     public String alpha;
 
     @Option(
-      name = "beta",
-      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "beta"
-    )
+        name = "beta",
+        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "beta")
     public String beta;
 
     @Option(
-      name = "gamma",
-      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "gamma"
-    )
+        name = "gamma",
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "gamma")
     public String gamma;
 
     @Option(
-      name = "delta",
-      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "delta"
-    )
+        name = "delta",
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "delta")
     public String delta;
 
     @Option(
-      name = "echo",
-      metadataTags = {OptionMetadataTag.HIDDEN},
-      documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "echo"
-    )
+        name = "echo",
+        metadataTags = {OptionMetadataTag.HIDDEN},
+        documentationCategory = OptionDocumentationCategory.UNDOCUMENTED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "echo")
     public String echo;
   }
 
@@ -103,19 +98,17 @@
 
   public static class BooleanOpts extends OptionsBase {
     @Option(
-      name = "b_one",
-      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "true"
-    )
+        name = "b_one",
+        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "true")
     public boolean bOne;
 
     @Option(
-      name = "b_two",
-      documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
-      effectTags = {OptionEffectTag.NO_OP},
-      defaultValue = "false"
-    )
+        name = "b_two",
+        documentationCategory = OptionDocumentationCategory.UNCATEGORIZED,
+        effectTags = {OptionEffectTag.NO_OP},
+        defaultValue = "false")
     public boolean bTwo;
   }
 
@@ -194,4 +187,25 @@
     assertThat(convertOne("foo/bar/baz")).isEqualTo(fragment("foo/bar/baz"));
     assertThat(convertOne("~/foo")).isEqualTo(fragment(System.getProperty("user.home") + "/foo"));
   }
+
+  @Test
+  public void emptyPathFragmentToNull() throws Exception {
+    assertThat(new OptionsUtils.EmptyToNullRelativePathFragmentConverter().convert("")).isNull();
+  }
+
+  @Test
+  public void absolutePathFragmentThrows() throws Exception {
+    OptionsParsingException exception =
+        assertThrows(
+            OptionsParsingException.class,
+            () -> new OptionsUtils.EmptyToNullRelativePathFragmentConverter().convert("/abs"));
+
+    assertThat(exception).hasMessageThat().contains("/abs");
+  }
+
+  @Test
+  public void relativePathFragment() throws Exception {
+    assertThat(new OptionsUtils.EmptyToNullRelativePathFragmentConverter().convert("path/to/me"))
+        .isEqualTo(PathFragment.create("path/to/me"));
+  }
 }