Add output_root_symlinks attribute to ninja_build rule

output_root_symlinks allow to list output paths under output_root, that should be treated as symlink artifacts.
In combination with --experimental_allow_unresolved_symlinks flag, this allows Ninja actions to create symlinks, not pointing to the existing file.

Closes #10892.

PiperOrigin-RevId: 298821497
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaBuild.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaBuild.java
index f52aae9..89b84b9 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaBuild.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaBuild.java
@@ -71,7 +71,8 @@
             graphProvider.getWorkingDirectory(),
             createSrcsMap(ruleContext),
             depsMapBuilder.build(),
-            symlinksMapBuilder.build());
+            symlinksMapBuilder.build(),
+            graphProvider.getOutputRootSymlinks());
     if (ruleContext.hasErrors()) {
       return null;
     }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraph.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraph.java
index 42e8d8e..7c75e51 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraph.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraph.java
@@ -14,6 +14,7 @@
 
 package com.google.devtools.build.lib.bazel.rules.ninja.actions;
 
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
@@ -50,6 +51,7 @@
 import com.google.devtools.build.skyframe.SkyKey;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.stream.Collectors;
@@ -81,6 +83,8 @@
         PathFragment.create(ruleContext.attributes().get("working_directory", Type.STRING));
     List<String> outputRootInputs =
         ruleContext.attributes().get("output_root_inputs", Type.STRING_LIST);
+    List<String> outputRootSymlinks =
+        ruleContext.attributes().get("output_root_symlinks", Type.STRING_LIST);
 
     Environment env = ruleContext.getAnalysisEnvironment().getSkyframeEnv();
     establishDependencyOnNinjaFiles(env, mainArtifact, ninjaSrcs);
@@ -98,7 +102,8 @@
             workingDirectory,
             ImmutableSortedMap.of(),
             ImmutableSortedMap.of(),
-            ImmutableSortedMap.of());
+            ImmutableSortedMap.of(),
+            ImmutableSortedSet.of());
     if (ruleContext.hasErrors()) {
       return null;
     }
@@ -126,7 +131,11 @@
               outputRoot,
               workingDirectory,
               targetsPreparer.getUsualTargets(),
-              targetsPreparer.getPhonyTargetsMap());
+              targetsPreparer.getPhonyTargetsMap(),
+              outputRootSymlinks.stream()
+                  .map(PathFragment::create)
+                  .collect(
+                      toImmutableSortedSet(Comparator.comparing(PathFragment::getPathString))));
 
       NestedSet<Artifact> filesToBuild =
           createSymlinkActions(
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphArtifactsHelper.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphArtifactsHelper.java
index 4c99e69..3cd4e3d 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphArtifactsHelper.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphArtifactsHelper.java
@@ -17,6 +17,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.Artifact.DerivedArtifact;
 import com.google.devtools.build.lib.actions.ArtifactRoot;
@@ -42,6 +43,7 @@
 
   private final ImmutableSortedMap<PathFragment, Artifact> depsNameToArtifact;
   private final ImmutableSortedMap<PathFragment, Artifact> symlinkPathToArtifact;
+  private final ImmutableSortedSet<PathFragment> outputRootSymlinks;
   private final ImmutableSortedMap<PathFragment, Artifact> srcsMap;
 
   /**
@@ -54,7 +56,9 @@
    * @param srcsMap mapping between the path fragment and artifact for the files passed in 'srcs'
    *     attribute
    * @param depsNameToArtifact mapping between the path fragment in the Ninja file and prebuilt
-   * @param symlinkPathToArtifact
+   * @param symlinkPathToArtifact mapping of paths to artifacts for input symlinks under output_root
+   * @param outputRootSymlinks list of output paths for which symlink artifacts should be created,
+   *     paths are relative to the output_root.
    */
   NinjaGraphArtifactsHelper(
       RuleContext ruleContext,
@@ -62,13 +66,15 @@
       PathFragment workingDirectory,
       ImmutableSortedMap<PathFragment, Artifact> srcsMap,
       ImmutableSortedMap<PathFragment, Artifact> depsNameToArtifact,
-      ImmutableSortedMap<PathFragment, Artifact> symlinkPathToArtifact) {
+      ImmutableSortedMap<PathFragment, Artifact> symlinkPathToArtifact,
+      ImmutableSortedSet<PathFragment> outputRootSymlinks) {
     this.ruleContext = ruleContext;
     this.outputRootPath = outputRootPath;
     this.workingDirectory = workingDirectory;
     this.srcsMap = srcsMap;
     this.depsNameToArtifact = depsNameToArtifact;
     this.symlinkPathToArtifact = symlinkPathToArtifact;
+    this.outputRootSymlinks = outputRootSymlinks;
     Path execRoot =
         Preconditions.checkNotNull(ruleContext.getConfiguration())
             .getDirectories()
@@ -87,10 +93,15 @@
                   + " path '%s' is not allowed.",
               pathRelativeToWorkingDirectory));
     }
-    DerivedArtifact derivedArtifact =
-        ruleContext.getDerivedArtifact(
-            pathRelativeToWorkspaceRoot.relativeTo(outputRootPath), derivedOutputRoot);
-    return derivedArtifact;
+    // If the path was declared as output symlink, create a symlink artifact.
+    if (outputRootSymlinks.contains(pathRelativeToWorkspaceRoot.relativeTo(outputRootPath))) {
+      return ruleContext
+          .getAnalysisEnvironment()
+          .getSymlinkArtifact(
+              pathRelativeToWorkspaceRoot.relativeTo(outputRootPath), derivedOutputRoot);
+    }
+    return ruleContext.getDerivedArtifact(
+        pathRelativeToWorkspaceRoot.relativeTo(outputRootPath), derivedOutputRoot);
   }
 
   Artifact getInputArtifact(PathFragment pathRelativeToWorkingDirectory)
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphProvider.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphProvider.java
index 22ab3ae..7bb50bb 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphProvider.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphProvider.java
@@ -15,6 +15,7 @@
 package com.google.devtools.build.lib.bazel.rules.ninja.actions;
 
 import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.ImmutableSortedSet;
 import com.google.devtools.build.lib.analysis.TransitiveInfoProvider;
 import com.google.devtools.build.lib.bazel.rules.ninja.parser.NinjaTarget;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.Immutable;
@@ -30,16 +31,19 @@
   private final PathFragment workingDirectory;
   private final ImmutableSortedMap<PathFragment, NinjaTarget> usualTargets;
   private final ImmutableSortedMap<PathFragment, PhonyTarget> phonyTargetsMap;
+  private final ImmutableSortedSet<PathFragment> outputRootSymlinks;
 
   public NinjaGraphProvider(
       PathFragment outputRoot,
       PathFragment workingDirectory,
       ImmutableSortedMap<PathFragment, NinjaTarget> usualTargets,
-      ImmutableSortedMap<PathFragment, PhonyTarget> phonyTargetsMap) {
+      ImmutableSortedMap<PathFragment, PhonyTarget> phonyTargetsMap,
+      ImmutableSortedSet<PathFragment> outputRootSymlinks) {
     this.outputRoot = outputRoot;
     this.workingDirectory = workingDirectory;
     this.usualTargets = usualTargets;
     this.phonyTargetsMap = phonyTargetsMap;
+    this.outputRootSymlinks = outputRootSymlinks;
   }
 
   public PathFragment getOutputRoot() {
@@ -57,4 +61,9 @@
   public ImmutableSortedMap<PathFragment, PhonyTarget> getPhonyTargetsMap() {
     return phonyTargetsMap;
   }
+
+  /** Output paths under output_root, that should be treated as symlink artifacts. */
+  public ImmutableSortedSet<PathFragment> getOutputRootSymlinks() {
+    return outputRootSymlinks;
+  }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphRule.java b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphRule.java
index b95fac2..29d9b23 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphRule.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/rules/ninja/actions/NinjaGraphRule.java
@@ -78,6 +78,14 @@
                         + " <execroot>/<output_root> will be a separate directory, not a"
                         + " symlink.</p>"))
         .add(
+            attr("output_root_symlinks", STRING_LIST)
+                .value(ImmutableList.of())
+                .setDoc(
+                    "<p>Output paths under output_root, that should be treated as symlink"
+                        + " artifacts.</p><p>In combination with"
+                        + " --experimental_allow_unresolved_symlinks flag, this allows Ninja"
+                        + " actions to create symlinks, not pointing to the existing file.</p>"))
+        .add(
             attr("working_directory", STRING)
                 .value("")
                 .setDoc(
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaBuildTest.java b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaBuildTest.java
index c597f65..5fa0b1f 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaBuildTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/rules/ninja/NinjaBuildTest.java
@@ -366,4 +366,43 @@
     assertThat(commandLines.get(0).commandLine.toString())
         .endsWith("cd build_config && executable -d out_file.d ../input > out_file");
   }
+
+  @Test
+  public void testCreateOutputSymlinkArtifacts() throws Exception {
+    rewriteWorkspace(
+        "workspace(name = 'test')",
+        "dont_symlink_directories_in_execroot(paths = ['build_config'])");
+
+    scratch.file(
+        "build_config/build.ninja",
+        "rule symlink_rule",
+        "  command = ln -s fictive-file ${out}",
+        "build dangling_symlink: symlink_rule");
+
+    ConfiguredTarget configuredTarget =
+        scratchConfiguredTarget(
+            "",
+            "ninja_target",
+            "ninja_graph(name = 'graph', output_root = 'build_config',",
+            " working_directory = 'build_config',",
+            " main = 'build_config/build.ninja',",
+            " output_root_symlinks = ['dangling_symlink'])",
+            "ninja_build(name = 'ninja_target', ninja_graph = 'graph',",
+            " output_groups= {'main': ['dangling_symlink']})");
+    assertThat(configuredTarget).isInstanceOf(RuleConfiguredTarget.class);
+    RuleConfiguredTarget ninjaConfiguredTarget = (RuleConfiguredTarget) configuredTarget;
+    ImmutableList<ActionAnalysisMetadata> actions = ninjaConfiguredTarget.getActions();
+    assertThat(actions).hasSize(1);
+
+    ActionAnalysisMetadata action = Iterables.getOnlyElement(actions);
+    Artifact primaryOutput = action.getPrimaryOutput();
+    assertThat(primaryOutput.isSymlink()).isTrue();
+    assertThat(action).isInstanceOf(NinjaAction.class);
+
+    List<CommandLineAndParamFileInfo> commandLines =
+        ((NinjaAction) action).getCommandLines().getCommandLines();
+    assertThat(commandLines).hasSize(1);
+    assertThat(commandLines.get(0).commandLine.toString())
+        .endsWith("cd build_config && ln -s fictive-file dangling_symlink");
+  }
 }