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();
   }