| // Copyright 2024 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.devtools.build.skyframe.SkyFunctionException.Transience.PERSISTENT; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.collect.ImmutableMap; | |
| import com.google.devtools.build.lib.cmdline.Label; | |
| import com.google.devtools.build.lib.cmdline.LabelSyntaxException; | |
| import com.google.devtools.build.lib.packages.StarlarkInfoNoSchema; | |
| import com.google.devtools.build.lib.skyframe.ProjectValue.BuildableUnit; | |
| import com.google.devtools.build.lib.skyframe.ProjectValue.EnforcementPolicy; | |
| 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.util.Collection; | |
| import java.util.LinkedHashMap; | |
| import java.util.List; | |
| import java.util.Locale; | |
| import java.util.Map; | |
| import java.util.Map.Entry; | |
| import javax.annotation.Nullable; | |
| import net.starlark.java.eval.Dict; | |
| import net.starlark.java.eval.Starlark; | |
| import net.starlark.java.eval.StarlarkList; | |
| /** A {@link SkyFunction} that loads metadata from a PROJECT.scl file. */ | |
| public class ProjectFunction implements SkyFunction { | |
| /** The top level reserved globals in the PROJECT.scl file. */ | |
| private enum ReservedGlobals { | |
| /** | |
| * Forward-facing PROJECT.scl structure: a single top-level "project" variable that contains all | |
| * project data in nested data structures. | |
| */ | |
| PROJECT("project"); | |
| private final String key; | |
| ReservedGlobals(String key) { | |
| this.key = key; | |
| } | |
| String getKey() { | |
| return key; | |
| } | |
| } | |
| private static final String ENFORCEMENT_POLICY = "enforcement_policy"; | |
| @Nullable | |
| @Override | |
| public SkyValue compute(SkyKey skyKey, Environment env) | |
| throws ProjectFunctionException, InterruptedException { | |
| ProjectValue.Key key = (ProjectValue.Key) skyKey.argument(); | |
| BzlLoadValue bzlLoadValue; | |
| try { | |
| bzlLoadValue = | |
| (BzlLoadValue) | |
| env.getValueOrThrow( | |
| BzlLoadValue.keyForBuild(key.getProjectFile()), BzlLoadFailedException.class); | |
| } catch (BzlLoadFailedException e) { | |
| throw new ProjectFunctionException(e, PERSISTENT); | |
| } | |
| if (bzlLoadValue == null) { | |
| return null; | |
| } | |
| Object projectRaw = bzlLoadValue.getModule().getGlobal(ReservedGlobals.PROJECT.getKey()); | |
| return switch (projectRaw) { | |
| case null -> | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| "Project files must define exactly one top-level variable called \"project\"")); | |
| case Dict<?, ?> asDict -> { | |
| Label actualProjectFile = maybeResolveAlias(key.getProjectFile(), asDict, bzlLoadValue); | |
| if (!actualProjectFile.equals(key.getProjectFile())) { | |
| // This is an alias for another project file. Delegate there. | |
| // TODO: b/382265245 - handle cycles, including self references. | |
| yield env.getValueOrThrow( | |
| new ProjectValue.Key(actualProjectFile), ProjectFunctionException.class); | |
| } | |
| yield parseLegacyProjectSchema(asDict, key.getProjectFile()); | |
| } | |
| case StarlarkInfoNoSchema starlarkInfo -> | |
| parseProtoProjectSchema(starlarkInfo, key.getProjectFile()); | |
| default -> | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format( | |
| "%s variable: expected a map of string to objects, got %s", | |
| ReservedGlobals.PROJECT.getKey(), projectRaw.getClass()))); | |
| }; | |
| } | |
| /** | |
| * Parses the proto-based PROJECT.scl implementation. | |
| * | |
| * @param starlarkInfo the raw Starlark {@link StarlarkInfoNoSchema} that {@code project} is set | |
| * to | |
| * @param projectFile name of the project file | |
| */ | |
| private static ProjectValue parseProtoProjectSchema( | |
| StarlarkInfoNoSchema starlarkInfo, Label projectFile) throws ProjectFunctionException { | |
| Map<String, BuildableUnit> buildableUnitsBuilder = new LinkedHashMap<>(); | |
| Collection<?> buildableUnits = | |
| checkAndCast( | |
| starlarkInfo.getValue("buildable_units"), | |
| Collection.class, | |
| /* defaultValue= */ null, | |
| "buildable_units must be a list of buildable unit definitions"); | |
| for (Object rawBuildableUnit : buildableUnits) { | |
| ImmutableList.Builder<String> targetPatternsBuilder = ImmutableList.builder(); | |
| ImmutableList.Builder<String> flagsBuilder = ImmutableList.builder(); | |
| StarlarkInfoNoSchema buildableUnitStruct = | |
| checkAndCast( | |
| rawBuildableUnit, | |
| StarlarkInfoNoSchema.class, | |
| /* defaultValue= */ null, | |
| "buildable_units entries must be structured objects"); | |
| String buildableUnitName = | |
| checkAndCast( | |
| buildableUnitStruct.getValue("name"), | |
| String.class, | |
| /* defaultValue= */ null, | |
| "buildable_unit names must be strings"); | |
| String buildableUnitDescription = | |
| checkAndCast( | |
| buildableUnitStruct.getValue("description"), | |
| String.class, | |
| /* defaultValue= */ buildableUnitName, | |
| "buildable_unit descriptions must be strings"); | |
| boolean isDefault = | |
| checkAndCast( | |
| buildableUnitStruct.getValue("is_default"), | |
| Boolean.class, | |
| /* defaultValue= */ false, | |
| "is_default must be a boolean"); | |
| Collection<?> targetPatterns = | |
| checkAndCast( | |
| buildableUnitStruct.getValue("target_patterns"), | |
| Collection.class, | |
| /* defaultValue= */ ImmutableList.of(), | |
| "target_patterns must be a list of strings"); | |
| for (Object targetPattern : targetPatterns) { | |
| targetPatternsBuilder.add( | |
| checkAndCast( | |
| targetPattern, | |
| String.class, | |
| /* defaultValue= */ null, | |
| "target_patterns entries must be strings")); | |
| } | |
| Collection<?> flags = | |
| checkAndCast( | |
| buildableUnitStruct.getValue("flags"), | |
| Collection.class, | |
| /* defaultValue= */ ImmutableList.of(), | |
| "flags must be a list of strings"); | |
| for (Object flag : flags) { | |
| flagsBuilder.add( | |
| checkAndCast( | |
| flag, String.class, /* defaultValue= */ null, "flags entries must be strings")); | |
| } | |
| // TODO: b/413130912: cleanly fail when multiple buildable units have the same name. | |
| BuildableUnit buildableUnit = null; | |
| try { | |
| buildableUnit = | |
| BuildableUnit.create( | |
| buildableUnitName, | |
| targetPatternsBuilder.build(), | |
| buildableUnitDescription, | |
| flagsBuilder.build(), | |
| isDefault); | |
| } catch (LabelSyntaxException e) { | |
| throw new ProjectFunctionException(e); | |
| } | |
| if (buildableUnitsBuilder.put(buildableUnitName, buildableUnit) != null) { | |
| throw new ProjectFunctionException( | |
| new BadProjectFileException( | |
| String.format( | |
| "buildable_unit name='%s' is repeated. Buildable units must have unique names.", | |
| buildableUnitName))); | |
| } | |
| } | |
| ImmutableList<String> alwaysAllowedConfigs = | |
| parseAlwaysAllowedConfigs(starlarkInfo.getValue("always_allowed_configs")); | |
| return new ProjectValue( | |
| parseEnforcementPolicy(starlarkInfo.getValue(ENFORCEMENT_POLICY), projectFile), | |
| parseProjectDirectories(starlarkInfo.getValue("project_directories")), | |
| ImmutableMap.copyOf(buildableUnitsBuilder), | |
| alwaysAllowedConfigs.isEmpty() ? null : alwaysAllowedConfigs, | |
| projectFile); | |
| } | |
| /** | |
| * Parses the first PROJECT.scl implementation (pre-proto schema). | |
| * | |
| * @param dict the raw Starlark {@link Dict} that {@code project} is set to | |
| * @param projectFile name of the project file | |
| */ | |
| private static ProjectValue parseLegacyProjectSchema(Dict<?, ?> dict, Label projectFile) | |
| throws ProjectFunctionException { | |
| ImmutableMap.Builder<String, BuildableUnit> buildableUnitsBuilder = ImmutableMap.builder(); | |
| for (Object k : dict.keySet()) { | |
| if (!(k instanceof String)) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format( | |
| "%s variable: expected string key, got element of %s", | |
| ReservedGlobals.PROJECT.getKey(), k.getClass()))); | |
| } | |
| } | |
| String defaultConfig = null; | |
| Object defaultConfigRaw = dict.get("default_config"); | |
| if (defaultConfigRaw != null) { | |
| String defaultConfigString = | |
| checkAndCast( | |
| defaultConfigRaw, | |
| String.class, | |
| /* defaultValue= */ null, | |
| "default_config must be a string matching a configs variable definition"); | |
| defaultConfig = defaultConfigString; | |
| } | |
| boolean foundDefaultConfig = false; | |
| if (dict.containsKey("configs")) { | |
| ImmutableMap<String, Collection<String>> configs = | |
| parseConfigs(dict.get("configs"), "configs"); | |
| for (String config : configs.keySet()) { | |
| boolean isDefault = defaultConfig != null && config.equals(defaultConfig); | |
| if (isDefault) { | |
| foundDefaultConfig = true; | |
| } | |
| BuildableUnit buildableUnit = null; | |
| try { | |
| buildableUnit = | |
| BuildableUnit.create( | |
| /* name= */ config, | |
| /* targetPatterns= */ ImmutableList.of(), | |
| /* description= */ "", | |
| ImmutableList.copyOf(configs.get(config)), | |
| isDefault); | |
| } catch (LabelSyntaxException e) { | |
| throw new ProjectFunctionException(e); | |
| } | |
| buildableUnitsBuilder.put(config, buildableUnit); | |
| } | |
| } | |
| if (defaultConfig != null && !foundDefaultConfig) { | |
| throw new ProjectFunctionException( | |
| new BadProjectFileException( | |
| "default_config must be a string matching a configs variable definition")); | |
| } | |
| return new ProjectValue( | |
| parseEnforcementPolicy(dict.get(ENFORCEMENT_POLICY), projectFile), | |
| parseProjectDirectories(dict.get("active_directories")), | |
| dict.containsKey("configs") ? buildableUnitsBuilder.buildOrThrow() : null, | |
| parseAlwaysAllowedConfigs(dict.get("always_allowed_configs")), | |
| projectFile); | |
| } | |
| private static ImmutableMap<String, Collection<String>> parseConfigs( | |
| Object configsRaw, String variableName) throws ProjectFunctionException { | |
| // This project file doesn't define configs, so it must not be used for canonical configs. | |
| if (configsRaw == null) { | |
| return ImmutableMap.of(); | |
| } | |
| ImmutableMap.Builder<String, Collection<String>> configs = ImmutableMap.builder(); | |
| boolean expectedConfigsType = false; | |
| if (configsRaw instanceof Dict<?, ?> configsAsDict) { | |
| expectedConfigsType = true; | |
| for (var entry : configsAsDict.entrySet()) { | |
| if (!(entry.getKey() instanceof String key | |
| && entry.getValue() instanceof Collection<?> values)) { | |
| expectedConfigsType = false; | |
| break; | |
| } | |
| ImmutableList.Builder<String> valuesBuilder = ImmutableList.builder(); | |
| for (var value : values) { | |
| if (!(value instanceof String val)) { | |
| expectedConfigsType = false; | |
| break; | |
| } | |
| valuesBuilder.add(val); | |
| } | |
| configs.put(key, valuesBuilder.build()); | |
| } | |
| } | |
| if (!expectedConfigsType) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format( | |
| "%s variable must be a map of strings to lists of strings", variableName))); | |
| } | |
| return configs.buildOrThrow(); | |
| } | |
| private static ImmutableList<String> parseAlwaysAllowedConfigs(Object alwaysAllowedConfigsRaw) | |
| throws ProjectFunctionException { | |
| if (alwaysAllowedConfigsRaw == null) { | |
| return ImmutableList.of(); | |
| } | |
| Collection<?> alwaysAllowedConfigs = | |
| checkAndCast( | |
| alwaysAllowedConfigsRaw, | |
| Collection.class, | |
| /* defaultValue= */ ImmutableList.of(), | |
| "always_allowed_configs must be a list of strings"); | |
| ImmutableList.Builder<String> alwaysAllowedConfigsBuilder = ImmutableList.builder(); | |
| for (Object config : alwaysAllowedConfigs) { | |
| alwaysAllowedConfigsBuilder.add( | |
| checkAndCast( | |
| config, | |
| String.class, | |
| /* defaultValue= */ null, | |
| "always_allowed_configs entires must be strings")); | |
| } | |
| return alwaysAllowedConfigsBuilder.build(); | |
| } | |
| private static ImmutableMap<String, Collection<String>> parseProjectDirectories( | |
| Object activeDirectoriesRaw) throws ProjectFunctionException { | |
| @SuppressWarnings("unchecked") | |
| ImmutableMap<String, Collection<String>> activeDirectories = | |
| switch (activeDirectoriesRaw) { | |
| case null -> ImmutableMap.of(); | |
| case Dict<?, ?> dict -> { | |
| ImmutableMap.Builder<String, Collection<String>> builder = ImmutableMap.builder(); | |
| for (Entry<?, ?> entry : dict.entrySet()) { | |
| Object k = entry.getKey(); | |
| if (!(k instanceof String activeDirectoriesKey)) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| "expected string, got element of " + k.getClass())); | |
| } | |
| Object values = entry.getValue(); | |
| if (!(values instanceof Collection<?> activeDirectoriesValues)) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| "expected list, got element of " + values.getClass())); | |
| } | |
| for (Object activeDirectory : activeDirectoriesValues) { | |
| if (!(activeDirectory instanceof String)) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| "expected a list of strings, got element of " | |
| + activeDirectory.getClass())); | |
| } | |
| } | |
| builder.put(activeDirectoriesKey, (Collection<String>) values); | |
| } | |
| yield builder.buildOrThrow(); | |
| } | |
| case List<?> list -> { | |
| // The proto schema doesn't need a map. Read a list and store as a {"default": [list}]} | |
| // map to preserve backward compatibility. | |
| ImmutableList.Builder<String> builder = ImmutableList.builder(); | |
| for (Object activeDirectory : list) { | |
| builder.add( | |
| checkAndCast( | |
| activeDirectory, | |
| String.class, | |
| /* defaultValue= */ null, | |
| "project_directories is a list of strings")); | |
| } | |
| yield ImmutableMap.of("default", builder.build()); | |
| } | |
| default -> | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| "expected a map of string to list of strings, got " | |
| + activeDirectoriesRaw.getClass())); | |
| }; | |
| if (!activeDirectories.isEmpty() && activeDirectories.get("default") == null) { | |
| throw new ProjectFunctionException( | |
| new ActiveDirectoriesException( | |
| "non-empty active_directories must contain the 'default' key")); | |
| } | |
| return activeDirectories; | |
| } | |
| private static boolean isDefaultValue(Object v) { | |
| if (((v instanceof StarlarkList<?> asList) && asList.isEmpty())) { | |
| return true; | |
| } | |
| if (v == Starlark.NONE) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| private static EnforcementPolicy parseEnforcementPolicy( | |
| Object enforcementPolicyRaw, Label projectFile) throws ProjectFunctionException { | |
| if (enforcementPolicyRaw == null || isDefaultValue(enforcementPolicyRaw)) { | |
| // Default if unspecified. | |
| return EnforcementPolicy.WARN; | |
| } | |
| try { | |
| return EnforcementPolicy.fromString(enforcementPolicyRaw.toString().toLowerCase(Locale.ROOT)); | |
| } catch (IllegalArgumentException e) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException(e.getMessage() + " in " + projectFile)); | |
| } | |
| } | |
| /** | |
| * If this is an alias for another project file, returns its label. Else returns the original | |
| * key's label. | |
| * | |
| * <p>See {@link ProjectValue#maybeResolveAlias} for schema details. | |
| * | |
| * @throws ProjectFunctionException if the alias schema isn't valid or the actual reference isn't | |
| * a valid label. | |
| */ | |
| private static Label maybeResolveAlias( | |
| Label originalProjectFile, Dict<?, ?> project, BzlLoadValue bzlLoadValue) | |
| throws ProjectFunctionException { | |
| if (project.get("actual") == null) { | |
| return originalProjectFile; | |
| } else if (!(project.get("actual") instanceof String)) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format( | |
| "project[\"actual\"]: expected string, got %s", project.get("actual")))); | |
| } else if (project.keySet().size() > 1) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format( | |
| "project[\"actual\"] is present, but other keys are present as well: %s", | |
| project.keySet()))); | |
| } else if (bzlLoadValue.getModule().getGlobals().keySet().size() > 1) { | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format( | |
| "project global variable is present, but other globals are present as well: %s", | |
| bzlLoadValue.getModule().getGlobals().keySet()))); | |
| } | |
| try { | |
| return Label.parseCanonical((String) project.get("actual")); | |
| } catch (LabelSyntaxException e) { | |
| throw new ProjectFunctionException(e); | |
| } | |
| } | |
| private static final class TypecheckFailureException extends Exception { | |
| TypecheckFailureException(String msg) { | |
| super(msg); | |
| } | |
| } | |
| private static final class ActiveDirectoriesException extends Exception { | |
| ActiveDirectoriesException(String msg) { | |
| super(msg); | |
| } | |
| } | |
| private static final class BadProjectFileException extends Exception { | |
| BadProjectFileException(String msg) { | |
| super(msg); | |
| } | |
| } | |
| /** Exception thrown by {@link ProjectFunction}. */ | |
| public static final class ProjectFunctionException extends SkyFunctionException { | |
| ProjectFunctionException(TypecheckFailureException cause) { | |
| super(cause, PERSISTENT); | |
| } | |
| ProjectFunctionException(ActiveDirectoriesException cause) { | |
| super(cause, PERSISTENT); | |
| } | |
| ProjectFunctionException(BzlLoadFailedException e, Transience transience) { | |
| super(e, transience); | |
| } | |
| ProjectFunctionException(LabelSyntaxException cause) { | |
| super(cause, PERSISTENT); | |
| } | |
| ProjectFunctionException(BadProjectFileException cause) { | |
| super(cause, PERSISTENT); | |
| } | |
| } | |
| /** | |
| * Checks that {@code rawValue} is an instance of {@code clazz}. If so, returns it cast to that | |
| * type. Else if its an empty {@link StarlarkList} and {@code defaultValue} is not null, returns | |
| * {@code defaultValue}. Else throws a {@link ProjectFunctionException}. | |
| * | |
| * <p>Note that all unspecified protolark settings default to an empty {@code StarlarkList}. | |
| */ | |
| private static <T> T checkAndCast( | |
| Object rawValue, Class<T> clazz, @Nullable Object defaultValue, String errorMessage) | |
| throws ProjectFunctionException { | |
| if (clazz.isInstance(rawValue)) { | |
| return clazz.cast(rawValue); | |
| } | |
| if (defaultValue != null && isDefaultValue(rawValue)) { | |
| return clazz.cast(defaultValue); | |
| } | |
| throw new ProjectFunctionException( | |
| new TypecheckFailureException( | |
| String.format("%s, got %s", errorMessage, rawValue.getClass()))); | |
| } | |
| } |