blob: 626256046bc72da800d46e1e8ede2ccad468d660 [file] [log] [blame]
// Copyright 2023 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.rules.starlarkdocextract;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Verify.verify;
import static com.google.common.base.Verify.verifyNotNull;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.devtools.build.lib.packages.ImplicitOutputsFunction.fromTemplates;
import static com.google.devtools.build.lib.packages.Type.BOOLEAN;
import static com.google.devtools.build.lib.packages.Types.STRING_LIST;
import static java.util.stream.Collectors.partitioningBy;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteSource;
import com.google.devtools.build.lib.actions.ActionConflictException;
import com.google.devtools.build.lib.actions.Artifact;
import com.google.devtools.build.lib.analysis.ConfiguredTarget;
import com.google.devtools.build.lib.analysis.FileProvider;
import com.google.devtools.build.lib.analysis.RuleConfiguredTargetBuilder;
import com.google.devtools.build.lib.analysis.RuleConfiguredTargetFactory;
import com.google.devtools.build.lib.analysis.RuleContext;
import com.google.devtools.build.lib.analysis.Runfiles;
import com.google.devtools.build.lib.analysis.RunfilesProvider;
import com.google.devtools.build.lib.analysis.actions.BinaryFileWriteAction;
import com.google.devtools.build.lib.analysis.actions.FileWriteAction;
import com.google.devtools.build.lib.cmdline.BazelModuleContext;
import com.google.devtools.build.lib.cmdline.Label;
import com.google.devtools.build.lib.cmdline.RepositoryMapping;
import com.google.devtools.build.lib.cmdline.RepositoryName;
import com.google.devtools.build.lib.collect.nestedset.NestedSet;
import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
import com.google.devtools.build.lib.collect.nestedset.Order;
import com.google.devtools.build.lib.packages.ImplicitOutputsFunction.SafeImplicitOutputsFunction;
import com.google.devtools.build.lib.profiler.Profiler;
import com.google.devtools.build.lib.profiler.SilentCloseable;
import com.google.devtools.build.lib.skyframe.BzlLoadFailedException;
import com.google.devtools.build.lib.skyframe.BzlLoadValue;
import com.google.devtools.build.lib.skyframe.RepositoryMappingValue;
import com.google.devtools.build.lib.skyframe.RepositoryMappingValue.RepositoryMappingResolutionException;
import com.google.devtools.build.lib.starlarkdocextract.LabelRenderer;
import com.google.devtools.build.lib.starlarkdocextract.ModuleInfoExtractor;
import com.google.devtools.build.lib.starlarkdocextract.StardocOutputProtos.ModuleInfo;
import com.google.devtools.build.skyframe.SkyFunction;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import com.google.protobuf.TextFormat;
import java.io.IOException;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import javax.annotation.Nullable;
import net.starlark.java.eval.Module;
/** Implementation of the {@code starlark_doc_extract} rule. */
public class StarlarkDocExtract implements RuleConfiguredTargetFactory {
static final String SRC_ATTR = "src";
static final String DEPS_ATTR = "deps";
static final String SYMBOL_NAMES_ATTR = "symbol_names";
static final String RENDER_MAIN_REPO_NAME = "render_main_repo_name";
static final SafeImplicitOutputsFunction BINARYPROTO_OUT = fromTemplates("%{name}.binaryproto");
static final SafeImplicitOutputsFunction TEXTPROTO_OUT = fromTemplates("%{name}.textproto");
@Override
@Nullable
public ConfiguredTarget create(RuleContext ruleContext)
throws ActionConflictException, InterruptedException, RuleErrorException {
RepositoryMappingValue mainRepositoryMappingValue = getMainRepositoryMappingValue(ruleContext);
RepositoryMapping repositoryMapping = mainRepositoryMappingValue.getRepositoryMapping();
Module module = loadModule(ruleContext, repositoryMapping);
if (module == null) {
// Skyframe restart
verify(
ruleContext.getAnalysisEnvironment().getSkyframeEnv().valuesMissing()
&& !ruleContext.hasErrors());
return null;
}
verifyModuleDeps(ruleContext, module, repositoryMapping);
Optional<String> mainRepoName = Optional.empty();
if (ruleContext.attributes().get(RENDER_MAIN_REPO_NAME, BOOLEAN)) {
mainRepoName = mainRepositoryMappingValue.getAssociatedModuleName();
if (mainRepoName.isEmpty()) {
mainRepoName = Optional.of(ruleContext.getWorkspaceName());
}
}
ModuleInfo moduleInfo =
getModuleInfo(ruleContext, module, new LabelRenderer(repositoryMapping, mainRepoName));
NestedSet<Artifact> filesToBuild =
new NestedSetBuilder<Artifact>(Order.STABLE_ORDER)
.add(createBinaryProtoOutput(ruleContext, moduleInfo))
.build();
// Textproto output isn't in filesToBuild: we want to create it only if explicitly requested.
createTextProtoOutput(ruleContext, moduleInfo);
return new RuleConfiguredTargetBuilder(ruleContext)
.setFilesToBuild(filesToBuild)
.addProvider(
RunfilesProvider.class,
RunfilesProvider.simple(
new Runfiles.Builder(ruleContext.getWorkspaceName())
.addRunfiles(ruleContext, RunfilesProvider.DEFAULT_RUNFILES)
.addTransitiveArtifacts(filesToBuild)
.build()))
.build();
}
/**
* Loads the Starlark module from the source file given by the rule's {@code src} attribute.
*
* @throws RuleErrorException and reports an error in the rule if the {@code src} attribute refers
* to multiple or zero files, a generated file, or a source file which cannot be loaded or
* parsed
* @return the module object, or null on Skyframe restart
*/
@Nullable
private static Module loadModule(RuleContext ruleContext, RepositoryMapping repositoryMapping)
throws RuleErrorException, InterruptedException {
try (SilentCloseable c = Profiler.instance().profile("BzlDocDump.loadModule")) {
// Note attr schema validates that src is a .bzl or .scl file.
Label label = getSourceFileLabel(ruleContext, SRC_ATTR, repositoryMapping);
// Note getSkyframeEnv() cannot be null while creating a configured target.
SkyFunction.Environment env = ruleContext.getAnalysisEnvironment().getSkyframeEnv();
BzlLoadValue bzlLoadValue;
try {
// TODO(b/276733504): support loading modules in @_builtins
bzlLoadValue =
(BzlLoadValue)
env.getValueOrThrow(BzlLoadValue.keyForBuild(label), BzlLoadFailedException.class);
} catch (BzlLoadFailedException e) {
ruleContext.attributeError(SRC_ATTR, e.getMessage());
throw new RuleErrorException(e);
}
if (bzlLoadValue == null) {
// Skyframe restart
return null;
}
return bzlLoadValue.getModule();
}
}
/**
* Retrieves the label of the singular source artifact from a given attribute. Note that we can't
* simply use {@code ruleContext.attributes().get(attrName, LABEL)} because that does not resolve
* aliases and filegroups.
*
* @throws RuleErrorException if the source is not a singular source artifact, meaning its label
* cannot be used as a label for a Starlark load()
*/
private static Label getSourceFileLabel(
RuleContext ruleContext, String attrName, RepositoryMapping repositoryMapping)
throws RuleErrorException {
Artifact artifact = ruleContext.getPrerequisiteArtifact(attrName);
ruleContext.assertNoErrors();
// If ruleContext.getPrerequisiteArtifact() set no errors, we know artifact != null
if (!artifact.isSourceArtifact()) {
RuleErrorException error =
new RuleErrorException(
String.format(
"%s is not a source file and cannot be loaded in Starlark",
formatDerivedArtifact(artifact, repositoryMapping)));
ruleContext.attributeError(attrName, error.getMessage());
throw error;
}
return verifyNotNull(artifact.getOwner());
}
private static String formatDerivedArtifact(
Artifact artifact, RepositoryMapping repositoryMapping) {
checkArgument(!artifact.isSourceArtifact());
return String.format(
"%s (generated by rule %s)",
artifact.getRepositoryRelativePath(),
artifact.getOwner().getDisplayForm(repositoryMapping));
}
/**
* Verifies that the module's transitive loads are a subset of the source artifacts in
* files-to-build of the rule's deps.
*
* @throws RuleErrorException if that is not the case.
*/
// TODO(https://github.com/bazelbuild/bazel/issues/18599): to avoid flattening deps, we could use
// either (a) a new, native bzl_library-like rule that verifies strict deps, or (b) a new native
// aspect that verifies strict deps for the existing bzl_library rule. Ideally, however, we ought
// to get rid of the deps attribute (and the need to verify it) altogether; that requires new
// dependency machinery for `bazel query` to use the Starlark load graph for collecting the
// dependencies of starlark_doc_extract's src.
private static void verifyModuleDeps(
RuleContext ruleContext, Module module, RepositoryMapping repositoryMapping)
throws RuleErrorException {
// Note attr schema validates that deps are .bzl or .scl files.
Map<Boolean, ImmutableSet<Artifact>> flattenedDepsPartitionedByIsSource =
ruleContext.getPrerequisites(DEPS_ATTR).stream()
// TODO(https://github.com/bazelbuild/bazel/issues/18599): we are using FileProvider
// instead of StarlarkLibraryInfo only because StarlarkLibraryInfo is defined in
// bazel_skylib, not natively in Bazel.
.flatMap(dep -> dep.getProvider(FileProvider.class).getFilesToBuild().toList().stream())
.collect(partitioningBy(Artifact::isSourceArtifact, toImmutableSet()));
// bzl_library targets may contain both source artifacts and derived artifacts (e.g. generated
// .bzl files for tests); only the source artifacts can be load()-ed by Bazel.
ImmutableSet<Artifact> flattenedDepsSourceArtifacts =
flattenedDepsPartitionedByIsSource.getOrDefault(true, ImmutableSet.of());
ImmutableSet<Artifact> flattenedDepsDerivedArtifacts =
flattenedDepsPartitionedByIsSource.getOrDefault(false, ImmutableSet.of());
ImmutableList<String> topmostUnknownLoads =
getTopmostUnknownLoads(
module,
flattenedDepsSourceArtifacts.stream()
.map(artifact -> verifyNotNull(artifact.getOwner()))
.collect(toImmutableSet()),
repositoryMapping);
if (!topmostUnknownLoads.isEmpty()) {
StringBuilder errorMessageBuilder =
new StringBuilder("missing bzl_library targets for Starlark module(s) ")
.append(Joiner.on(", ").join(topmostUnknownLoads));
if (!flattenedDepsDerivedArtifacts.isEmpty()) {
// TODO(arostovtsev): we ought to print only the derived artifacts having the same
// root-relative path as topmostUnknownLoads.
errorMessageBuilder
.append("\nNote the following are generated file(s) and cannot be loaded in Starlark: ")
.append(
Joiner.on(", ")
.join(
flattenedDepsDerivedArtifacts.stream()
.map(artifact -> formatDerivedArtifact(artifact, repositoryMapping))
.iterator()));
}
RuleErrorException error = new RuleErrorException(errorMessageBuilder.toString());
ruleContext.attributeError(DEPS_ATTR, error.getMessage());
throw error;
}
}
/**
* Finds the topmost modules that are transitively loaded by the given module but not mentioned in
* the given set of known modules, and returns these modules' display forms.
*
* <p>Unknown modules that are only referenced by other unknown modules are not included.
*/
private static ImmutableList<String> getTopmostUnknownLoads(
Module module, ImmutableSet<Label> knownModules, RepositoryMapping repositoryMapping) {
ImmutableList.Builder<String> unknown = ImmutableList.builder();
Set<Label> visited = new LinkedHashSet<>();
BazelModuleContext.visitLoadGraphRecursively(
BazelModuleContext.of(module).loads(),
label -> {
if (!visited.add(label)) {
return false;
}
if (!knownModules.contains(label)) {
unknown.add(label.getDisplayForm(repositoryMapping));
return false;
}
return true;
});
return unknown.build();
}
/** Returns the main repository's repo mapping value. */
private static RepositoryMappingValue getMainRepositoryMappingValue(RuleContext ruleContext)
throws RuleErrorException, InterruptedException {
RepositoryMappingValue repositoryMappingValue;
try {
repositoryMappingValue =
(RepositoryMappingValue)
ruleContext
.getAnalysisEnvironment()
.getSkyframeEnv()
.getValueOrThrow(
RepositoryMappingValue.key(RepositoryName.MAIN),
RepositoryMappingResolutionException.class);
} catch (RepositoryMappingResolutionException e) {
ruleContext.ruleError(e.getMessage());
throw new RuleErrorException(e);
}
verifyNotNull(repositoryMappingValue);
return repositoryMappingValue;
}
private static ModuleInfo getModuleInfo(
RuleContext ruleContext, Module module, LabelRenderer labelRenderer)
throws RuleErrorException {
ModuleInfo moduleInfo;
try {
moduleInfo =
new ModuleInfoExtractor(getWantedSymbolPredicate(ruleContext), labelRenderer)
.extractFrom(module);
} catch (ModuleInfoExtractor.ExtractionException e) {
ruleContext.ruleError(e.getMessage());
throw new RuleErrorException(e);
}
return moduleInfo;
}
private static Predicate<String> getWantedSymbolPredicate(RuleContext ruleContext) {
ImmutableList<String> symbolNames =
ImmutableList.copyOf(ruleContext.attributes().get(SYMBOL_NAMES_ATTR, STRING_LIST));
if (symbolNames.isEmpty()) {
return name -> true;
} else {
return symbolNames::contains;
}
}
private static Artifact createBinaryProtoOutput(RuleContext ruleContext, ModuleInfo moduleInfo)
throws InterruptedException {
Artifact binaryProtoOutput = ruleContext.getImplicitOutputArtifact(BINARYPROTO_OUT);
ruleContext.registerAction(
new BinaryFileWriteAction(
ruleContext.getActionOwner(),
binaryProtoOutput,
ByteSource.wrap(moduleInfo.toByteArray()),
/* makeExecutable= */ false));
return binaryProtoOutput;
}
@Nullable
@CanIgnoreReturnValue
private static Artifact createTextProtoOutput(RuleContext ruleContext, ModuleInfo moduleInfo)
throws InterruptedException, RuleErrorException {
Artifact textProtoOutput = ruleContext.getImplicitOutputArtifact(TEXTPROTO_OUT);
StringBuilder textprotoBuilder = new StringBuilder();
try {
TextFormat.printer().print(moduleInfo, textprotoBuilder);
} catch (IOException e) {
ruleContext.ruleError(e.getMessage());
throw new RuleErrorException(e);
}
ruleContext.registerAction(
FileWriteAction.create(
ruleContext,
textProtoOutput,
textprotoBuilder.toString(),
/* makeExecutable= */ false));
return textProtoOutput;
}
}