Add platform mapping function.

Introduces a new SkyFunction which reads a platform mapping file, parses its contents and produces a platform mapping sky value which can then be used to apply the mapping to configurations (in the form of BuildConfigurationValue.Key).

The file's location is obtained from the newly introduced flag --platform_mappings and defaults to //:platform_mappings.

Note that this logic is not in use anywhere yet because the key mapping has not been applied. This will follow in a future CL.

Step 4/N towards the platforms mapping functionality for https://github.com/bazelbuild/bazel/issues/6426

RELNOTES: None.
PiperOrigin-RevId: 239043475
diff --git a/src/main/java/com/google/devtools/build/lib/analysis/PlatformOptions.java b/src/main/java/com/google/devtools/build/lib/analysis/PlatformOptions.java
index 788dc3a..391aae3 100644
--- a/src/main/java/com/google/devtools/build/lib/analysis/PlatformOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/analysis/PlatformOptions.java
@@ -20,6 +20,8 @@
 import com.google.devtools.build.lib.analysis.config.BuildConfiguration.LabelListConverter;
 import com.google.devtools.build.lib.analysis.config.FragmentOptions;
 import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.util.OptionsUtils;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.common.options.Converters.CommaSeparatedOptionListConverter;
 import com.google.devtools.common.options.Option;
 import com.google.devtools.common.options.OptionDocumentationCategory;
@@ -39,6 +41,13 @@
   public static final Label LEGACY_DEFAULT_TARGET_PLATFORM =
       Label.parseAbsoluteUnchecked("@bazel_tools//platforms:target_platform");
 
+  /**
+   * Main workspace-relative location to use when the user does not explicitly set {@code
+   * --platform_mappings}.
+   */
+  public static final PathFragment DEFAULT_PLATFORM_MAPPINGS =
+      PathFragment.create("platform_mappings");
+
   @Option(
       name = "host_platform",
       oldName = "experimental_host_platform",
@@ -169,6 +178,23 @@
               + " java_runtime.")
   public boolean useToolchainResolutionForJavaRules;
 
+  @Option(
+      name = "platform_mappings",
+      converter = OptionsUtils.EmptyToNullRelativePathFragmentConverter.class,
+      defaultValue = "",
+      documentationCategory = OptionDocumentationCategory.TOOLCHAIN,
+      effectTags = {
+        OptionEffectTag.AFFECTS_OUTPUTS,
+        OptionEffectTag.CHANGES_INPUTS,
+        OptionEffectTag.LOADING_AND_ANALYSIS
+      },
+      help =
+          "The location of a mapping file that describes which platform to use if none is set or "
+              + "which flags to set when a platform already exists. Must be relative to the main "
+              + "workspace root. Defaults to 'platform_mappings' (a file directly under the "
+              + "workspace root).")
+  public PathFragment platformMappings;
+
   @Override
   public PlatformOptions getHost() {
     PlatformOptions host = (PlatformOptions) getDefault();
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunction.java
new file mode 100644
index 0000000..33694f4
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunction.java
@@ -0,0 +1,309 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.skyframe;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.actions.FileValue;
+import com.google.devtools.build.lib.actions.MissingInputFileException;
+import com.google.devtools.build.lib.analysis.BlazeDirectories;
+import com.google.devtools.build.lib.analysis.config.BuildOptions;
+import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.cmdline.LabelSyntaxException;
+import com.google.devtools.build.lib.cmdline.RepositoryName;
+import com.google.devtools.build.lib.events.Location;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.Root;
+import com.google.devtools.build.lib.vfs.RootedPath;
+import com.google.devtools.build.skyframe.SkyFunction;
+import com.google.devtools.build.skyframe.SkyFunctionException;
+import com.google.devtools.build.skyframe.SkyKey;
+import com.google.devtools.build.skyframe.SkyValue;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.Nullable;
+
+/**
+ * Function that reads the contents of a mapping file specified in {@code --platform_mappings} and
+ * parses them for use in a {@link PlatformMappingValue}.
+ *
+ * <p>Note that this class only parses the mapping-file specific format, parsing (and validation) of
+ * flags contained therein is left to the invocation of {@link
+ * PlatformMappingValue#map(BuildConfigurationValue.Key, BuildOptions)}.
+ */
+public class PlatformMappingFunction implements SkyFunction {
+
+  private final BlazeDirectories blazeDirectories;
+
+  public PlatformMappingFunction(BlazeDirectories blazeDirectories) {
+    this.blazeDirectories = blazeDirectories;
+  }
+
+  @Nullable
+  @Override
+  public SkyValue compute(SkyKey skyKey, Environment env)
+      throws PlatformMappingException, InterruptedException {
+    PlatformMappingValue.Key platformMappingKey = (PlatformMappingValue.Key) skyKey.argument();
+    PathFragment workspaceRelativeMappingPath =
+        platformMappingKey.getWorkspaceRelativeMappingPath();
+
+    Root workspaceRoot = Root.fromPath(blazeDirectories.getWorkspace());
+    RootedPath rootedMappingPath =
+        RootedPath.toRootedPath(workspaceRoot, workspaceRelativeMappingPath);
+    FileValue fileValue = (FileValue) env.getValue(FileValue.key(rootedMappingPath));
+    if (fileValue == null) {
+      return null;
+    }
+
+    if (!fileValue.exists()) {
+      if (!platformMappingKey.wasExplicitlySetByUser()) {
+        // If no flag was passed and the default mapping file does not exist treat this as if the
+        // mapping file was empty rather than an error.
+        return PlatformMappingValue.EMPTY;
+      }
+      throw new PlatformMappingException(
+          new MissingInputFileException(
+              String.format(
+                  "--platform_mappings was set to '%s' but no such file exists relative to the "
+                      + "top-level workspace, '%s'",
+                  workspaceRelativeMappingPath, workspaceRoot),
+              Location.BUILTIN),
+          SkyFunctionException.Transience.PERSISTENT);
+    }
+    if (fileValue.isDirectory()) {
+      throw new PlatformMappingException(
+          new MissingInputFileException(
+              String.format(
+                  "--platform_mappings was set to '%s' relative to the top-level workspace '%s' but"
+                      + "that path refers to a directory, not a file",
+                  workspaceRelativeMappingPath, workspaceRoot),
+              Location.BUILTIN),
+          SkyFunctionException.Transience.PERSISTENT);
+    }
+
+    Iterable<String> lines;
+    try {
+      lines =
+          FileSystemUtils.readLines(fileValue.realRootedPath().asPath(), StandardCharsets.UTF_8);
+    } catch (IOException e) {
+      throw new PlatformMappingException(e, SkyFunctionException.Transience.PERSISTENT);
+    }
+
+    return new Parser(lines.iterator()).parse().toPlatformMappingValue();
+  }
+
+  @Nullable
+  @Override
+  public String extractTag(SkyKey skyKey) {
+    return null;
+  }
+
+  @VisibleForTesting
+  static class PlatformMappingException extends SkyFunctionException {
+
+    public PlatformMappingException(Exception cause, Transience transience) {
+      super(cause, transience);
+    }
+  }
+
+  @VisibleForTesting
+  static class Parser {
+
+    private final Iterator<String> lines;
+
+    /**
+     * Using an optional to represent the next line with contents, {@link Optional#empty()} if we
+     * reached end of file.
+     *
+     * <p>Stores the current non-comment, non-empty, non-whitespace line. Don't access the field
+     * directly, it can either be "used up" by calling {@link #consume()} or retrieved without
+     * moving on by calling {@link #peek()}.
+     */
+    private Optional<String> line;
+
+    Parser(Iterator<String> lines) {
+      this.lines = lines;
+    }
+
+    Mappings parse() throws PlatformMappingException {
+      goToNextContentLine();
+
+      if (!line.isPresent()) {
+        return new Mappings(ImmutableMap.of(), ImmutableMap.of());
+      }
+
+      Map<Label, Collection<String>> platformsToFlags = ImmutableMap.of();
+      Map<Collection<String>, Label> flagsToPlatforms = ImmutableMap.of();
+
+      if (!peek().equalsIgnoreCase("platforms:") && !peek().equalsIgnoreCase("flags:")) {
+        throwParsingException("Expected 'platforms:' or 'flags:' but got " + peek());
+      }
+
+      if (peek().equalsIgnoreCase("platforms:")) {
+        consume();
+        platformsToFlags = platformsToFlags();
+      }
+
+      if (line.isPresent()) {
+        if (!peek().equalsIgnoreCase("flags:")) {
+          throwParsingException("Expected 'flags:' but got " + peek());
+        }
+        consume();
+        flagsToPlatforms = flagsToPlatforms();
+      }
+
+      if (line.isPresent()) {
+        throwParsingException("Expected end of file but got " + peek());
+      }
+      return new Mappings(platformsToFlags, flagsToPlatforms);
+    }
+
+    private Map<Label, Collection<String>> platformsToFlags() throws PlatformMappingException {
+      ImmutableMap.Builder<Label, Collection<String>> platformsToFlags = ImmutableMap.builder();
+      while (line.isPresent() && !peek().equalsIgnoreCase("flags:")) {
+        Label platform = platform();
+        Collection<String> flags = flags();
+        platformsToFlags.put(platform, flags);
+      }
+
+      return platformsToFlags.build();
+    }
+
+    private Label platform() throws PlatformMappingException {
+      if (!line.isPresent()) {
+        throwParsingException("Expected platform label but got end of file");
+      }
+      String label = consume();
+
+      Label platform;
+      try {
+        ImmutableMap<RepositoryName, RepositoryName> emptyRepositoryMapping = ImmutableMap.of();
+        // It is ok for us to use an empty repository mapping in this instance because all platform
+        // labels used in the mapping file should be relative to the root repository. Repository
+        // mappings however only apply within a repository imported by the root repository.
+        platform = Label.parseAbsolute(label, emptyRepositoryMapping);
+      } catch (LabelSyntaxException e) {
+        throw new PlatformMappingException(
+            new PlatformMappingParsingException("Expected platform label but got " + label, e),
+            SkyFunctionException.Transience.PERSISTENT);
+      }
+      return platform;
+    }
+
+    private Collection<String> flags() throws PlatformMappingException {
+      ImmutableSet.Builder<String> flags = ImmutableSet.builder();
+      // Note: Short form flags are not supported.
+      while (lineIsFlag()) {
+        flags.add(consume());
+      }
+      ImmutableSet<String> parsedFlags = flags.build();
+      if (parsedFlags.isEmpty()) {
+        if (!line.isPresent()) {
+          throwParsingException("Expected a flag but got end of file");
+        }
+        throwParsingException(
+            "Expected a standard format flag (starting with --) but got " + peek());
+      }
+
+      return parsedFlags;
+    }
+
+    private Map<Collection<String>, Label> flagsToPlatforms() throws PlatformMappingException {
+      ImmutableMap.Builder<Collection<String>, Label> flagsToPlatforms = ImmutableMap.builder();
+      while (lineIsFlag()) {
+        Collection<String> flags = flags();
+        Label platform = platform();
+        flagsToPlatforms.put(flags, platform);
+      }
+      return flagsToPlatforms.build();
+    }
+
+    private String consume() {
+      Preconditions.checkState(
+          line.isPresent(), "Must make sure that a line exists before consuming.");
+      String value = line.get();
+      goToNextContentLine();
+      return value;
+    }
+
+    private String peek() {
+      Preconditions.checkState(
+          line.isPresent(), "Must make sure that a line exists before peeking.");
+      return line.get();
+    }
+
+    private void throwParsingException(String message) throws PlatformMappingException {
+      throw new PlatformMappingException(
+          new PlatformMappingParsingException(message), SkyFunctionException.Transience.PERSISTENT);
+    }
+
+    private boolean lineIsFlag() {
+      return line.isPresent() && peek().startsWith("--");
+    }
+
+    private void goToNextContentLine() {
+      while (lines.hasNext()) {
+        String line = lines.next().trim();
+        if (line.isEmpty() || line.startsWith("#")) {
+          continue;
+        }
+        this.line = Optional.of(line);
+        return;
+      }
+      line = Optional.empty();
+    }
+  }
+
+  /**
+   * Simple data holder to make testing easier. Only for use internal to this file/tests thereof.
+   */
+  @VisibleForTesting
+  static class Mappings {
+    final Map<Label, Collection<String>> platformsToFlags;
+    final Map<Collection<String>, Label> flagsToPlatforms;
+
+    Mappings(
+        Map<Label, Collection<String>> platformsToFlags,
+        Map<Collection<String>, Label> flagsToPlatforms) {
+      this.platformsToFlags = platformsToFlags;
+      this.flagsToPlatforms = flagsToPlatforms;
+    }
+
+    PlatformMappingValue toPlatformMappingValue() {
+      return new PlatformMappingValue(platformsToFlags, flagsToPlatforms);
+    }
+  }
+
+  /**
+   * Inner wrapper exception to work around the fact that {@link SkyFunctionException} cannot carry
+   * a message of its own.
+   */
+  private static class PlatformMappingParsingException extends Exception {
+    public PlatformMappingParsingException(String message) {
+      super(message);
+    }
+
+    public PlatformMappingParsingException(String message, Throwable cause) {
+      super(message, cause);
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingValue.java b/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingValue.java
index 4ad16cb..1c90fa6 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingValue.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/PlatformMappingValue.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Interner;
 import com.google.common.collect.Iterables;
 import com.google.devtools.build.lib.analysis.PlatformOptions;
@@ -24,7 +25,7 @@
 import com.google.devtools.build.lib.concurrent.BlazeInterners;
 import com.google.devtools.build.lib.concurrent.ThreadSafety;
 import com.google.devtools.build.lib.skyframe.serialization.autocodec.AutoCodec;
-import com.google.devtools.build.lib.vfs.RootedPath;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.build.skyframe.SkyFunctionName;
 import com.google.devtools.build.skyframe.SkyKey;
 import com.google.devtools.build.skyframe.SkyValue;
@@ -35,6 +36,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import javax.annotation.Nullable;
 
 /**
  * Stores contents of a platforms/flags mapping file for transforming one {@link
@@ -46,22 +48,54 @@
  */
 public final class PlatformMappingValue implements SkyValue {
 
+  public static final PlatformMappingValue EMPTY =
+      new PlatformMappingValue(ImmutableMap.of(), ImmutableMap.of());
+
   /** Key for {@link PlatformMappingValue} based on the location of the mapping file. */
   @ThreadSafety.Immutable
   @AutoCodec
   public static final class Key implements SkyKey {
     private static final Interner<Key> interner = BlazeInterners.newWeakInterner();
 
-    private final RootedPath path;
-
-    private Key(RootedPath path) {
-      this.path = path;
+    /**
+     * Creates a new platform mappings key with the given, main workspace-relative path to the
+     * mappings file, typically derived from the {@code --platform_mappings} flag.
+     *
+     * <p>If the path is {@code null} the {@link PlatformOptions#DEFAULT_PLATFORM_MAPPINGS default
+     * path} will be used and the key marked as not having been set by a user.
+     *
+     * @param workspaceRelativeMappingPath main workspace relative path to the mappings file or
+     *     {@code null} if the default location should be used
+     */
+    public static Key create(@Nullable PathFragment workspaceRelativeMappingPath) {
+      if (workspaceRelativeMappingPath == null) {
+        return create(PlatformOptions.DEFAULT_PLATFORM_MAPPINGS, false);
+      } else {
+        return create(workspaceRelativeMappingPath, true);
+      }
     }
 
-    @AutoCodec.VisibleForSerialization
     @AutoCodec.Instantiator
-    static Key create(RootedPath path) {
-      return interner.intern(new Key(path));
+    @AutoCodec.VisibleForSerialization
+    static Key create(PathFragment workspaceRelativeMappingPath, boolean wasExplicitlySetByUser) {
+      return interner.intern(new Key(workspaceRelativeMappingPath, wasExplicitlySetByUser));
+    }
+
+    private final PathFragment path;
+    private final boolean wasExplicitlySetByUser;
+
+    private Key(PathFragment path, boolean wasExplicitlySetByUser) {
+      this.path = path;
+      this.wasExplicitlySetByUser = wasExplicitlySetByUser;
+    }
+
+    /** Returns the main-workspace relative path this mapping's mapping file can be found at. */
+    public PathFragment getWorkspaceRelativeMappingPath() {
+      return path;
+    }
+
+    public boolean wasExplicitlySetByUser() {
+      return wasExplicitlySetByUser;
     }
 
     @Override
@@ -78,17 +112,21 @@
         return false;
       }
       Key key = (Key) o;
-      return Objects.equals(path, key.path);
+      return Objects.equals(path, key.path) && wasExplicitlySetByUser == key.wasExplicitlySetByUser;
     }
 
     @Override
     public int hashCode() {
-      return Objects.hash(path);
+      return Objects.hash(path, wasExplicitlySetByUser);
     }
 
     @Override
     public String toString() {
-      return "PlatformMappingValue.Key{" + "path=" + path + '}';
+      return "PlatformMappingValue.Key{path="
+          + path
+          + ", wasExplicitlySetByUser="
+          + wasExplicitlySetByUser
+          + "}";
     }
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SkyFunctions.java b/src/main/java/com/google/devtools/build/lib/skyframe/SkyFunctions.java
index 8bfa5df..21f5ad1 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SkyFunctions.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SkyFunctions.java
@@ -118,7 +118,7 @@
   public static final SkyFunctionName BUILD_INFO = SkyFunctionName.createHermetic("BUILD_INFO");
   public static final SkyFunctionName WORKSPACE_NAME =
       SkyFunctionName.createHermetic("WORKSPACE_NAME");
-  static final SkyFunctionName PLATFORM_MAPPING =
+  public static final SkyFunctionName PLATFORM_MAPPING =
       SkyFunctionName.createHermetic("PLATFORM_MAPPING");
   static final SkyFunctionName COVERAGE_REPORT = SkyFunctionName.createHermetic("COVERAGE_REPORT");
   public static final SkyFunctionName REPOSITORY = SkyFunctionName.createHermetic("REPOSITORY");
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
index 2e84d5e..eb44df0 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
@@ -587,6 +587,7 @@
     map.put(SkyFunctions.REPOSITORY_MAPPING, new RepositoryMappingFunction());
     map.put(SkyFunctions.RESOLVED_HASH_VALUES, new ResolvedHashesFunction());
     map.put(SkyFunctions.RESOLVED_FILE, new ResolvedFileFunction());
+    map.put(SkyFunctions.PLATFORM_MAPPING, new PlatformMappingFunction(directories));
     map.putAll(extraSkyFunctions);
     return map.build();
   }
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunctionParserTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunctionParserTest.java
new file mode 100644
index 0000000..6d7a9fa
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunctionParserTest.java
@@ -0,0 +1,354 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.cmdline.LabelSyntaxException;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link PlatformMappingFunction}. */
+@RunWith(JUnit4.class)
+public class PlatformMappingFunctionParserTest {
+
+  private static final Label PLATFORM1 = Label.parseAbsoluteUnchecked("//platforms:one");
+  private static final Label PLATFORM2 = Label.parseAbsoluteUnchecked("//platforms:two");
+
+  @Test
+  public void testParse() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "platforms:",
+            "  //platforms:one",
+            "    --cpu=one",
+            "  //platforms:two",
+            "    --cpu=two",
+            "flags:",
+            "  --cpu=one",
+            "    //platforms:one",
+            "  --cpu=two",
+            "    //platforms:two");
+
+    assertThat(mappings.platformsToFlags.keySet()).containsExactly(PLATFORM1, PLATFORM2);
+    assertThat(mappings.platformsToFlags.get(PLATFORM1)).containsExactly("--cpu=one");
+    assertThat(mappings.platformsToFlags.get(PLATFORM2)).containsExactly("--cpu=two");
+
+    assertThat(mappings.flagsToPlatforms.keySet())
+        .containsExactly(ImmutableSet.of("--cpu=one"), ImmutableSet.of("--cpu=two"));
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=one"))).isEqualTo(PLATFORM1);
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=two"))).isEqualTo(PLATFORM2);
+  }
+
+  @Test
+  public void testParseComment() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "# A mapping file!",
+            "platforms:",
+            "  # comment1",
+            "  //platforms:one",
+            "# comment2",
+            "    --cpu=one",
+            "  //platforms:two",
+            "    --cpu=two",
+            "flags:",
+            "# another comment",
+            "  --cpu=one",
+            "    //platforms:one",
+            "  --cpu=two",
+            "    //platforms:two");
+
+    assertThat(mappings.platformsToFlags.keySet()).containsExactly(PLATFORM1, PLATFORM2);
+    assertThat(mappings.platformsToFlags.get(PLATFORM1)).containsExactly("--cpu=one");
+    assertThat(mappings.platformsToFlags.get(PLATFORM2)).containsExactly("--cpu=two");
+
+    assertThat(mappings.flagsToPlatforms.keySet())
+        .containsExactly(ImmutableSet.of("--cpu=one"), ImmutableSet.of("--cpu=two"));
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=one"))).isEqualTo(PLATFORM1);
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=two"))).isEqualTo(PLATFORM2);
+  }
+
+  @Test
+  public void testParseWhitespace() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "",
+            "platforms:",
+            "  ",
+            "  //platforms:one",
+            "",
+            "    --cpu=one",
+            "    //platforms:two    ",
+            "      --cpu=two ",
+            "flags:",
+            "           ",
+            "",
+            "--cpu=one",
+            "  //platforms:one",
+            "  --cpu=two",
+            "  //platforms:two");
+
+    assertThat(mappings.platformsToFlags.keySet()).containsExactly(PLATFORM1, PLATFORM2);
+    assertThat(mappings.platformsToFlags.get(PLATFORM1)).containsExactly("--cpu=one");
+    assertThat(mappings.platformsToFlags.get(PLATFORM2)).containsExactly("--cpu=two");
+
+    assertThat(mappings.flagsToPlatforms.keySet())
+        .containsExactly(ImmutableSet.of("--cpu=one"), ImmutableSet.of("--cpu=two"));
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=one"))).isEqualTo(PLATFORM1);
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=two"))).isEqualTo(PLATFORM2);
+  }
+
+  @Test
+  public void testParseMultipleFlagsInPlatform() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "platforms:",
+            "  //platforms:one",
+            "    --cpu=one",
+            "    --compilation_mode=dbg",
+            "  //platforms:two",
+            "    --cpu=two");
+
+    assertThat(mappings.platformsToFlags.keySet()).containsExactly(PLATFORM1, PLATFORM2);
+    assertThat(mappings.platformsToFlags.get(PLATFORM1))
+        .containsExactly("--cpu=one", "--compilation_mode=dbg");
+  }
+
+  @Test
+  public void testParseMultipleFlagsInFlags() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "flags:",
+            "  --compilation_mode=dbg",
+            "  --cpu=one",
+            "    //platforms:one",
+            "  --cpu=two",
+            "    //platforms:two");
+
+    assertThat(mappings.flagsToPlatforms.keySet())
+        .containsExactly(
+            ImmutableSet.of("--cpu=one", "--compilation_mode=dbg"), ImmutableSet.of("--cpu=two"));
+    assertThat(
+            mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=one", "--compilation_mode=dbg")))
+        .isEqualTo(PLATFORM1);
+  }
+
+  @Test
+  public void testParseOnlyPlatforms() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "platforms:", // Force line break
+            "  //platforms:one", // Force line break
+            "    --cpu=one" // Force line break
+            );
+
+    assertThat(mappings.platformsToFlags.keySet()).containsExactly(PLATFORM1);
+    assertThat(mappings.platformsToFlags.get(PLATFORM1)).containsExactly("--cpu=one");
+    assertThat(mappings.flagsToPlatforms).isEmpty();
+  }
+
+  @Test
+  public void testParseOnlyFlags() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "flags:", // Force line break
+            "  --cpu=one", // Force line break
+            "    //platforms:one" // Force line break
+            );
+
+    assertThat(mappings.flagsToPlatforms.keySet()).containsExactly(ImmutableSet.of("--cpu=one"));
+    assertThat(mappings.flagsToPlatforms.get(ImmutableSet.of("--cpu=one"))).isEqualTo(PLATFORM1);
+    assertThat(mappings.platformsToFlags).isEmpty();
+  }
+
+  @Test
+  public void testParseEmpty() throws Exception {
+    PlatformMappingFunction.Mappings mappings = parse();
+
+    assertThat(mappings.flagsToPlatforms).isEmpty();
+    assertThat(mappings.platformsToFlags).isEmpty();
+  }
+
+  @Test
+  public void testParseEmptySections() throws Exception {
+    PlatformMappingFunction.Mappings mappings = parse("platforms:", "flags:");
+
+    assertThat(mappings.flagsToPlatforms).isEmpty();
+    assertThat(mappings.platformsToFlags).isEmpty();
+  }
+
+  @Test
+  public void testParseCommentOnly() throws Exception {
+    PlatformMappingFunction.Mappings mappings = parse("#No mappings");
+
+    assertThat(mappings.flagsToPlatforms).isEmpty();
+    assertThat(mappings.platformsToFlags).isEmpty();
+  }
+
+  @Test
+  public void testParseExtraPlatformInFlags() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "flags:", // Force line break
+                    "  --cpu=one", // Force line break
+                    "    //platforms:one", // Force line break
+                    "    //platforms:two" // Force line break
+                    ));
+
+    assertThat(exception).hasMessageThat().contains("//platforms:two");
+  }
+
+  @Test
+  public void testParsePlatformWithoutFlags() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "platforms:", // Force line break
+                    "  //platforms:one" // Force line break
+                    ));
+
+    assertThat(exception).hasMessageThat().contains("end of file");
+  }
+
+  @Test
+  public void testParseFlagsWithoutPlatform() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "flags:", // Force line break
+                    "  --cpu=one" // Force line break
+                    ));
+
+    assertThat(exception).hasMessageThat().contains("end of file");
+  }
+
+  @Test
+  public void testParseCommentEndOfFile() throws Exception {
+    PlatformMappingFunction.Mappings mappings =
+        parse(
+            "platforms:", // Force line break
+            "  //platforms:one", // Force line break
+            "    --cpu=one", // Force line break
+            "# No more mappings" // Force line break
+            );
+
+    assertThat(mappings.platformsToFlags).isNotEmpty();
+  }
+
+  @Test
+  public void testParseUnknownSection() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "platform:", // Force line break
+                    "  //platforms:one", // Force line break
+                    "    --cpu=one" // Force line break
+                    ));
+
+    assertThat(exception).hasMessageThat().contains("platform:");
+
+    PlatformMappingFunction.PlatformMappingException exception2 =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "platforms:",
+                    "  //platforms:one",
+                    "    --cpu=one",
+                    "flag:",
+                    "  --cpu=one",
+                    "    //platforms:one"));
+
+    assertThat(exception).hasMessageThat().contains("platform");
+  }
+
+  @Test
+  public void testParsePlatformsInvalidPlatformLabel() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "platforms:", // Force line break
+                    "  @@@", // Force line break
+                    "    --cpu=one"));
+
+    assertThat(exception).hasMessageThat().contains("@@@");
+    assertThat(exception).hasCauseThat().hasCauseThat().isInstanceOf(LabelSyntaxException.class);
+  }
+
+  @Test
+  public void testParseFlagsInvalidPlatformLabel() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "flags:", // Force line break
+                    "  --cpu=one", // Force line break
+                    "    @@@"));
+
+    assertThat(exception).hasMessageThat().contains("@@@");
+    assertThat(exception).hasCauseThat().hasCauseThat().isInstanceOf(LabelSyntaxException.class);
+  }
+
+  @Test
+  public void testParsePlatformsInvalidFlag() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "platforms:", // Force line break
+                    "  //platforms:one", // Force line break
+                    "    -cpu=one"));
+
+    assertThat(exception).hasMessageThat().contains("-cpu");
+  }
+
+  @Test
+  public void testParseFlagsInvalidFlag() throws Exception {
+    PlatformMappingFunction.PlatformMappingException exception =
+        assertThrows(
+            PlatformMappingFunction.PlatformMappingException.class,
+            () ->
+                parse(
+                    "flags:", // Force line break
+                    "  -cpu=one", // Force line break
+                    "    //platforms:one"));
+
+    assertThat(exception).hasMessageThat().contains("-cpu");
+  }
+
+  private PlatformMappingFunction.Mappings parse(String... lines)
+      throws PlatformMappingFunction.PlatformMappingException {
+    return new PlatformMappingFunction.Parser(ImmutableList.copyOf(lines).iterator()).parse();
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunctionTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunctionTest.java
new file mode 100644
index 0000000..194fa03
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingFunctionTest.java
@@ -0,0 +1,159 @@
+// Copyright 2019 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.devtools.build.lib.skyframe;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.devtools.build.lib.analysis.PlatformOptions.LEGACY_DEFAULT_TARGET_PLATFORM;
+import static com.google.devtools.build.lib.testutil.MoreAsserts.assertThrows;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.devtools.build.lib.actions.MissingInputFileException;
+import com.google.devtools.build.lib.analysis.PlatformConfiguration;
+import com.google.devtools.build.lib.analysis.PlatformOptions;
+import com.google.devtools.build.lib.analysis.config.BuildConfiguration;
+import com.google.devtools.build.lib.analysis.config.BuildOptions;
+import com.google.devtools.build.lib.analysis.config.FragmentOptions;
+import com.google.devtools.build.lib.analysis.util.BuildViewTestCase;
+import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.rules.repository.RepositoryDelegatorFunction;
+import com.google.devtools.build.lib.skyframe.util.SkyframeExecutorTestUtils;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.skyframe.EvaluationResult;
+import com.google.devtools.common.options.OptionsParsingException;
+import java.util.Set;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Unit tests for {@link PlatformMappingFunction}.
+ *
+ * <p>Note that all parsing tests are located in {@link PlatformMappingFunctionParserTest}.
+ */
+@RunWith(JUnit4.class)
+public class PlatformMappingFunctionTest extends BuildViewTestCase {
+
+  // We don't actually care about the contents of this set other than that it is passed intact
+  // through the mapping logic. The platform fragment in it is purely an example, it could be any
+  // set of fragments.
+  private static final Set<Class<? extends BuildConfiguration.Fragment>> PLATFORM_FRAGMENT_CLASS =
+      ImmutableSet.of(PlatformConfiguration.class);
+
+  private static final ImmutableList<Class<? extends FragmentOptions>>
+      BUILD_CONFIG_PLATFORM_OPTIONS =
+          ImmutableList.of(BuildConfiguration.Options.class, PlatformOptions.class);
+
+  private static final Label PLATFORM1 = Label.parseAbsoluteUnchecked("//platforms:one");
+
+  private static final BuildOptions DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS =
+      getDefaultBuildConfigPlatformOptions();
+  private static final BuildOptions.OptionsDiffForReconstruction EMPTY_DIFF =
+      BuildOptions.diffForReconstruction(
+          DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS, DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS);
+
+  @Test
+  public void testMappingFileDoesNotExist() throws Exception {
+    MissingInputFileException exception =
+        assertThrows(
+            MissingInputFileException.class,
+            () ->
+                executeFunction(
+                    PlatformMappingValue.Key.create(PathFragment.create("random_location"))));
+    assertThat(exception).hasMessageThat().contains("random_location");
+  }
+
+  @Test
+  public void testMappingFileDoesNotExistDefaultLocation() throws Exception {
+    PlatformMappingValue platformMappingValue =
+        executeFunction(PlatformMappingValue.Key.create(null));
+
+    BuildConfigurationValue.Key key =
+        BuildConfigurationValue.key(PLATFORM_FRAGMENT_CLASS, EMPTY_DIFF);
+
+    BuildConfigurationValue.Key mapped =
+        platformMappingValue.map(key, DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS);
+
+    assertThat(toMappedOptions(mapped).get(PlatformOptions.class).platforms)
+        .containsExactly(LEGACY_DEFAULT_TARGET_PLATFORM);
+  }
+
+  @Test
+  public void testMappingFileIsDirectory() throws Exception {
+    scratch.dir("somedir");
+
+    MissingInputFileException exception =
+        assertThrows(
+            MissingInputFileException.class,
+            () -> executeFunction(PlatformMappingValue.Key.create(PathFragment.create("somedir"))));
+    assertThat(exception).hasMessageThat().contains("somedir");
+  }
+
+  @Test
+  public void testMappingFileIsRead() throws Exception {
+    scratch.file(
+        "my_mapping_file",
+        "platforms:", // Force line break
+        "  //platforms:one", // Force line break
+        "    --cpu=one");
+
+    PlatformMappingValue platformMappingValue =
+        executeFunction(PlatformMappingValue.Key.create(PathFragment.create("my_mapping_file")));
+
+    BuildOptions modifiedOptions = DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS.clone();
+    modifiedOptions.get(PlatformOptions.class).platforms = ImmutableList.of(PLATFORM1);
+
+    BuildConfigurationValue.Key mapped =
+        platformMappingValue.map(
+            keyForOptions(modifiedOptions), DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS);
+
+    assertThat(toMappedOptions(mapped).get(BuildConfiguration.Options.class).cpu).isEqualTo("one");
+  }
+
+  private PlatformMappingValue executeFunction(PlatformMappingValue.Key key) throws Exception {
+    SkyframeExecutor skyframeExecutor = getSkyframeExecutor();
+    skyframeExecutor.injectExtraPrecomputedValues(
+        ImmutableList.of(
+            PrecomputedValue.injected(
+                RepositoryDelegatorFunction.RESOLVED_FILE_INSTEAD_OF_WORKSPACE,
+                Optional.absent())));
+    EvaluationResult<PlatformMappingValue> result =
+        SkyframeExecutorTestUtils.evaluate(skyframeExecutor, key, /*keepGoing=*/ false, reporter);
+    if (result.hasError()) {
+      throw result.getError(key).getException();
+    }
+    return result.get(key);
+  }
+
+  private BuildOptions toMappedOptions(BuildConfigurationValue.Key mapped) {
+    return DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS.applyDiff(mapped.getOptionsDiff());
+  }
+
+  private static BuildOptions getDefaultBuildConfigPlatformOptions() {
+    try {
+      return BuildOptions.of(BUILD_CONFIG_PLATFORM_OPTIONS);
+    } catch (OptionsParsingException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private BuildConfigurationValue.Key keyForOptions(BuildOptions modifiedOptions) {
+    BuildOptions.OptionsDiffForReconstruction diff =
+        BuildOptions.diffForReconstruction(DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS, modifiedOptions);
+
+    return BuildConfigurationValue.key(PLATFORM_FRAGMENT_CLASS, diff);
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingValueTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingValueTest.java
index af50470..88cdf7b 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingValueTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/PlatformMappingValueTest.java
@@ -27,6 +27,7 @@
 import com.google.devtools.build.lib.analysis.config.CompilationMode;
 import com.google.devtools.build.lib.analysis.config.FragmentOptions;
 import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.vfs.PathFragment;
 import com.google.devtools.common.options.OptionsParsingException;
 import java.util.Collection;
 import java.util.Set;
@@ -203,6 +204,23 @@
     assertThat(keyForOptions(modifiedOptions)).isEqualTo(mapped);
   }
 
+  @Test
+  public void testDefaultKey() {
+    PlatformMappingValue.Key key = PlatformMappingValue.Key.create(null);
+
+    assertThat(key.getWorkspaceRelativeMappingPath())
+        .isEqualTo(PlatformOptions.DEFAULT_PLATFORM_MAPPINGS);
+    assertThat(key.wasExplicitlySetByUser()).isFalse();
+  }
+
+  @Test
+  public void testCustomKey() {
+    PlatformMappingValue.Key key = PlatformMappingValue.Key.create(PathFragment.create("my/path"));
+
+    assertThat(key.getWorkspaceRelativeMappingPath()).isEqualTo(PathFragment.create("my/path"));
+    assertThat(key.wasExplicitlySetByUser()).isTrue();
+  }
+
   private BuildOptions toMappedOptions(BuildConfigurationValue.Key mapped) {
     return DEFAULT_BUILD_CONFIG_PLATFORM_OPTIONS.applyDiff(mapped.getOptionsDiff());
   }