diff --git a/common/src/main/java/org/implab/gradle/common/sources/GenericSourceSet.java b/common/src/main/java/org/implab/gradle/common/sources/GenericSourceSet.java --- a/common/src/main/java/org/implab/gradle/common/sources/GenericSourceSet.java +++ b/common/src/main/java/org/implab/gradle/common/sources/GenericSourceSet.java @@ -134,7 +134,11 @@ public abstract class GenericSourceSet * * @throws InvalidUserDataException if the output was not declared */ - public ConfigurableFileCollection output(String name) { + public FileCollection output(String name) { + return configurableOutput(name); + } + + private ConfigurableFileCollection configurableOutput(String name) { requireDeclaredOutput(name); return outputs.computeIfAbsent(name, key -> objects.fileCollection()); } @@ -153,7 +157,7 @@ public abstract class GenericSourceSet * Registers files produced elsewhere under the given output. */ public void registerOutput(String name, Object... files) { - output(name).from(files); + configurableOutput(name).from(files); } /** @@ -163,7 +167,7 @@ public abstract class GenericSourceSet */ public void registerOutput(String name, TaskProvider task, Function mapper) { - output(name).from(task.map(mapper::apply)) + configurableOutput(name).from(task.map(mapper::apply)) .builtBy(task); } diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java @@ -14,6 +14,15 @@ import org.implab.gradle.common.core.lan import groovy.lang.Closure; import groovy.lang.DelegatesTo; +/** + * Artifact model for one topology variant declared in + * {@link VariantArtifactsExtension}. + * + *

A {@code VariantArtifact} groups one or more + * {@link VariantArtifactSlot artifact representation slots}. The primary slot + * becomes the main artifact of {@code Elements}; remaining slots are + * published as secondary outgoing variants by {@link VariantArtifactsPlugin}. + */ @NonNullByDefault public class VariantArtifact implements Named { private final String name; diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactSlot.java b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactSlot.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactSlot.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactSlot.java @@ -1,7 +1,13 @@ package org.implab.gradle.common.sources; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Stream; import javax.inject.Inject; @@ -14,10 +20,44 @@ import org.implab.gradle.common.core.lan import groovy.lang.Closure; import groovy.lang.DelegatesTo; +/** + * One artifact representation slot inside {@link VariantArtifact}. + * + *

+ * The DSL exposed by this type is topology-aware sugar over an internal + * contribution model: + *

+ * + *

+ * Internally the slot stores contribution resolvers rather than raw output + * names. Each contribution can later materialize itself against the + * variant-specific source-set bindings and return: + *

+ * + *

+ * Validation is intentionally separated from materialization: the slot keeps + * topology references in {@link #referencedRoleNames()} and + * {@link #referencedLayerNames()}, while the actual contribution pipeline is + * exposed through {@link #bindings()}. + */ @NonNullByDefault public class VariantArtifactSlot implements Named { private final String name; - private final List rules = new ArrayList<>(); + private final List bindings = new ArrayList<>(); + private final Set referencedRoleNames = new LinkedHashSet<>(); + private final Set referencedLayerNames = new LinkedHashSet<>(); private boolean finalized; @Inject @@ -31,7 +71,7 @@ public class VariantArtifactSlot impleme } public void fromVariant(Action configure) { - addRules(BindingSelector.variant(), configure); + addContributions(context -> true, configure); } public void fromVariant( @@ -40,8 +80,9 @@ public class VariantArtifactSlot impleme } public void fromRole(String roleName, Action configure) { - addRules(BindingSelector.role(VariantArtifact.normalize(roleName, "role name must not be null or blank")), - configure); + var normalizedRoleName = VariantArtifact.normalize(roleName, "role name must not be null or blank"); + addContributions(context -> context.roleName().equals(normalizedRoleName), configure); + referencedRoleNames.add(normalizedRoleName); } public void fromRole( @@ -51,8 +92,9 @@ public class VariantArtifactSlot impleme } public void fromLayer(String layerName, Action configure) { - addRules(BindingSelector.layer(VariantArtifact.normalize(layerName, "layer name must not be null or blank")), - configure); + var normalizedLayerName = VariantArtifact.normalize(layerName, "layer name must not be null or blank"); + addContributions(context -> context.layerName().equals(normalizedLayerName), configure); + referencedLayerNames.add(normalizedLayerName); } public void fromLayer( @@ -61,78 +103,136 @@ public class VariantArtifactSlot impleme fromLayer(layerName, Closures.action(configure)); } - List bindingRules() { - return List.copyOf(rules); + /** + * Adds one direct slot contribution. + * + *

The supplied object is forwarded as-is to {@code files.from(...)} + * during slot materialization and does not depend on + * {@link VariantSourcesExtension#whenBound(Action)} callbacks. + */ + public void from(Object files) { + ensureMutable("configure sources"); + + if (files == null) + throw new InvalidUserDataException("slot source must not be null"); + + var key = BindingKey.newUniqueKey("direct slot input for '" + name + "'"); + bindings.add((contexts, consumer) -> consumer.accept(new ResolvedBinding(key, files))); + } + + List bindings() { + return List.copyOf(bindings); + } + + Set referencedRoleNames() { + return Set.copyOf(referencedRoleNames); + } + + Set referencedLayerNames() { + return Set.copyOf(referencedLayerNames); } void finalizeModel() { finalized = true; } - private void addRules(BindingSelector selector, Action configure) { + private void addContributions( + Predicate selector, + Action configure) { ensureMutable("configure sources"); var spec = new OutputSelectionSpec(selector); configure.execute(spec); - rules.addAll(spec.rules()); + spec.accept(bindings::add); } private void ensureMutable(String operation) { if (finalized) - throw new InvalidUserDataException("Variant artifact slot '" + name + "' is finalized and cannot " + operation); + throw new InvalidUserDataException( + "Variant artifact slot '" + name + "' is finalized and cannot " + operation); } + /** + * Local DSL buffer for one {@code fromVariant/fromRole/fromLayer} block. + * + *

+ * The spec accumulates contributions locally and flushes them to the + * owning slot only after the outer configure block completes successfully. + */ public final class OutputSelectionSpec { - private final BindingSelector selector; - private final List rules = new ArrayList<>(); + private final Predicate selector; + private final List bindings = new ArrayList<>(); - private OutputSelectionSpec(BindingSelector selector) { + private OutputSelectionSpec(Predicate selector) { this.selector = selector; } public void output(String name) { - rules.add(new BindingRule(selector, - VariantArtifact.normalize(name, "output name must not be null or blank"))); + var outputName = VariantArtifact.normalize(name, "output name must not be null or blank"); + bindings.add((contexts, consumer) -> contexts.stream() + .filter(selector) + .map(context -> resolveOutput(context, outputName)) + .forEach(consumer)); } public void output(String name, String... extra) { - output(name); - for (var item : extra) - output(item); + Stream.concat(Stream.of(name), Stream.of(extra)) + .forEach(this::output); } - private List rules() { - return List.copyOf(rules); + private ResolvedBinding resolveOutput(SourceSetUsageBinding context, String outputName) { + var key = new SourceSetOutputKey(context.sourceSetName(), outputName); + var files = context.sourceSet().map(sourceSet -> sourceSet.output(outputName)); + return new ResolvedBinding(key, files); + } + + void accept(Consumer consumer) { + bindings.forEach(consumer); } } - record BindingRule(BindingSelector selector, String outputName) { - boolean matches(SourceSetUsageBinding context) { - return switch (selector.kind()) { - case VARIANT -> true; - case ROLE -> selector.value().equals(context.roleName()); - case LAYER -> selector.value().equals(context.layerName()); + @FunctionalInterface + interface BindingResolver { + void resolve(Collection contexts, Consumer consumer); + } + + /** + * Materialized slot contribution for one concrete source-set binding. + */ + record ResolvedBinding(BindingKey key, Object files) { + } + + /** + * Marker key for deduplicating logical slot inputs during materialization. + * + *

+ * Semantic keys such as {@link SourceSetOutputKey} collapse repeated + * references to the same logical output. Identity keys created via + * {@link #newUniqueKey()} or {@link #newUniqueKey(String)} can be used by contributions + * that must flow through the same pipeline but should never be merged. + */ + interface BindingKey { + static BindingKey newUniqueKey(String hint) { + return new BindingKey() { + @Override + public String toString() { + return hint; + } }; } + + static BindingKey newUniqueKey() { + return newUniqueKey("unnamed"); + } } - record BindingSelector(SelectorKind kind, String value) { - static BindingSelector variant() { - return new BindingSelector(SelectorKind.VARIANT, ""); - } - - static BindingSelector role(String roleName) { - return new BindingSelector(SelectorKind.ROLE, roleName); - } - - static BindingSelector layer(String layerName) { - return new BindingSelector(SelectorKind.LAYER, layerName); + /** + * Stable dedupe key for one named output of one resolved source set. + */ + record SourceSetOutputKey(String sourceSetName, String outputName) implements BindingKey { + @Override + public String toString() { + return "sourceSet '" + sourceSetName + "' output '" + outputName + "'"; } } - - enum SelectorKind { - VARIANT, - ROLE, - LAYER - } } diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsExtension.java b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsExtension.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsExtension.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsExtension.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import javax.inject.Inject; @@ -17,6 +18,22 @@ import org.implab.gradle.common.core.lan import groovy.lang.Closure; import groovy.lang.DelegatesTo; +/** + * Root DSL and lifecycle holder for the {@code variantArtifacts} model. + * + *

This extension sits on top of the build topology defined by + * {@link BuildVariantsExtension} and the source-set materialization performed by + * {@link VariantSourcesExtension}: + *

+ */ @NonNullByDefault public abstract class VariantArtifactsExtension { private final NamedDomainObjectContainer variants; @@ -111,67 +128,92 @@ public abstract class VariantArtifactsEx private void validate(BuildVariantsExtension topology) { var errors = new ArrayList(); - for (var variantArtifact : variants) { - var topologyVariant = topology.find(variantArtifact.getName()); - if (topologyVariant.isEmpty()) { - errors.add("Variant artifact '" + variantArtifact.getName() + "' references unknown variant '" - + variantArtifact.getName() + "'"); - continue; - } + for (var variantArtifact : variants) + validateVariantArtifact(topology, variantArtifact, errors); + + throwIfInvalid(errors); + } - validateVariantArtifact(variantArtifact, topologyVariant.get(), errors); + private static void validateVariantArtifact( + BuildVariantsExtension topology, + VariantArtifact variantArtifact, + List errors) { + var topologyVariant = topology.find(variantArtifact.getName()); + if (topologyVariant.isEmpty()) { + errors.add("Variant artifact '" + variantArtifact.getName() + "' references unknown variant '" + + variantArtifact.getName() + "'"); + return; } - if (!errors.isEmpty()) { - var message = new StringBuilder("Invalid variantArtifacts model:"); - for (var error : errors) - message.append("\n - ").append(error); + var topologyScope = TopologyScope.from(topologyVariant.get()); + validateTopologyReferences(variantArtifact, topologyScope, errors); + validatePrimarySlot(variantArtifact, errors); + } - throw new InvalidUserDataException(message.toString()); + private static void validateTopologyReferences( + VariantArtifact variantArtifact, + TopologyScope topologyScope, + List errors) { + for (var slot : variantArtifact.getSlots()) { + validateSlotReferences(variantArtifact, slot, "role", slot.referencedRoleNames(), topologyScope.roleNames(), errors); + validateSlotReferences(variantArtifact, slot, "layer", slot.referencedLayerNames(), topologyScope.layerNames(), errors); } } - private static void validateVariantArtifact(VariantArtifact variantArtifact, BuildVariant topologyVariant, List errors) { - var roleNames = new LinkedHashSet(); - var layerNames = new LinkedHashSet(); + private static void validatePrimarySlot(VariantArtifact variantArtifact, List errors) { + if (variantArtifact.getSlots().isEmpty()) + return; - for (var role : topologyVariant.getRoles()) { - roleNames.add(role.getName()); - layerNames.addAll(role.getLayers().getOrElse(List.of())); + if (variantArtifact.findPrimarySlotName().isEmpty()) { + errors.add("Variant artifact '" + variantArtifact.getName() + + "' must declare primary slot because it has multiple slots"); + return; } - if (!variantArtifact.getSlots().isEmpty()) { - if (variantArtifact.findPrimarySlotName().isEmpty()) { - errors.add("Variant artifact '" + variantArtifact.getName() - + "' must declare primary slot because it has multiple slots"); - } else { - var primarySlotName = variantArtifact.requirePrimarySlotName(); - if (variantArtifact.findSlot(primarySlotName).isEmpty()) { - errors.add("Variant artifact '" + variantArtifact.getName() - + "' declares unknown primary slot '" + primarySlotName + "'"); - } + var primarySlotName = variantArtifact.requirePrimarySlotName(); + if (variantArtifact.findSlot(primarySlotName).isEmpty()) { + errors.add("Variant artifact '" + variantArtifact.getName() + + "' declares unknown primary slot '" + primarySlotName + "'"); + } + } + + private static void validateSlotReferences( + VariantArtifact variantArtifact, + VariantArtifactSlot slot, + String referenceKind, + Set referencedNames, + Set knownNames, + List errors) { + for (var referencedName : referencedNames) { + if (!knownNames.contains(referencedName)) { + errors.add("Variant artifact '" + variantArtifact.getName() + "', slot '" + slot.getName() + + "' references unknown " + referenceKind + " '" + referencedName + "'"); } } + } - for (var slot : variantArtifact.getSlots()) { - for (var rule : slot.bindingRules()) { - switch (rule.selector().kind()) { - case VARIANT -> { - } - case ROLE -> { - if (!roleNames.contains(rule.selector().value())) { - errors.add("Variant artifact '" + variantArtifact.getName() + "', slot '" + slot.getName() - + "' references unknown role '" + rule.selector().value() + "'"); - } - } - case LAYER -> { - if (!layerNames.contains(rule.selector().value())) { - errors.add("Variant artifact '" + variantArtifact.getName() + "', slot '" + slot.getName() - + "' references unknown layer '" + rule.selector().value() + "'"); - } - } - } + private static void throwIfInvalid(List errors) { + if (errors.isEmpty()) + return; + + var message = new StringBuilder("Invalid variantArtifacts model:"); + for (var error : errors) + message.append("\n - ").append(error); + + throw new InvalidUserDataException(message.toString()); + } + + private record TopologyScope(Set roleNames, Set layerNames) { + private static TopologyScope from(BuildVariant topologyVariant) { + var roleNames = new LinkedHashSet(); + var layerNames = new LinkedHashSet(); + + for (var role : topologyVariant.getRoles()) { + roleNames.add(role.getName()); + layerNames.addAll(role.getLayers().getOrElse(List.of())); } + + return new TopologyScope(Set.copyOf(roleNames), Set.copyOf(layerNames)); } } diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsPlugin.java rename from common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java rename to common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsPlugin.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsPlugin.java @@ -16,8 +16,8 @@ import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; import org.implab.gradle.common.core.lang.Strings; -public abstract class VariantsArtifactsPlugin implements Plugin { - private static final Logger logger = Logging.getLogger(VariantsArtifactsPlugin.class); +public abstract class VariantArtifactsPlugin implements Plugin { + private static final Logger logger = Logging.getLogger(VariantArtifactsPlugin.class); public static final String VARIANT_ARTIFACTS_EXTENSION_NAME = "variantArtifacts"; @Override @@ -33,6 +33,7 @@ public abstract class VariantsArtifactsP var variantArtifactsResolver = new VariantArtifactsResolver(target.getObjects()); var artifactAssemblies = new ArtifactAssemblyRegistry(target.getObjects(), target.getTasks()); + // Bind variant artifacts resolution to variant sources registration, so that artifact resolution can be performed variantSources.whenBound(variantArtifactsResolver::recordBinding); variants.whenFinalized(model -> { diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsResolver.java b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsResolver.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsResolver.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsResolver.java @@ -9,7 +9,43 @@ import org.eclipse.jdt.annotation.NonNul import org.gradle.api.file.ConfigurableFileCollection; import org.gradle.api.file.FileCollection; import org.gradle.api.model.ObjectFactory; +import org.implab.gradle.common.sources.VariantArtifactSlot.BindingKey; +/** + * Resolves artifact-slot inputs from already bound variant source-set usages. + * + *

This type is the bridge between two models: + *

    + *
  • {@link VariantSourcesExtension}, which emits resolved + * {@link SourceSetUsageBinding variant/role/layer -> source-set} bindings;
  • + *
  • {@link VariantArtifactSlot}, which exposes a DSL over slot contributions + * and stores the resulting {@link VariantArtifactSlot.BindingResolver + * contribution resolvers}.
  • + *
+ * + *

The resolver records each emitted {@link SourceSetUsageBinding} and later + * materializes a {@link FileCollection} for one concrete variant/slot pair. + * For each variant/slot pair it asks the slot to materialize its contributions, + * passes in the resolved source-set bindings for that variant, and + * deduplicates resulting inputs by {@link BindingKey}. Contributions that do + * not depend on topology bindings can still emit direct inputs even when that + * binding collection is empty. The returned files are then typically wired into + * an {@link ArtifactAssembly} as its sources. + * + *

Direct clients are infrastructure code rather than build scripts. The + * typical usage pattern is: + *

    + *
  1. create one resolver per project;
  2. + *
  3. subscribe {@link #recordBinding(SourceSetUsageBinding)} to + * {@link VariantSourcesExtension#whenBound(org.gradle.api.Action)};
  4. + *
  5. call {@link #files(String, VariantArtifactSlot)} while registering an + * {@link ArtifactAssembly} or another consumer that needs the slot inputs.
  6. + *
+ * + *

Build-script users normally do not instantiate this class directly. They + * configure {@code variantArtifacts}, and {@link VariantArtifactsPlugin} uses + * this resolver internally to turn slot rules into assembly inputs. + */ @NonNullByDefault public final class VariantArtifactsResolver { private final ObjectFactory objects; @@ -19,40 +55,67 @@ public final class VariantArtifactsResol this.objects = objects; } + /** + * Records one resolved variant source-set usage. + * + *

Intended to be used as a callback target for + * {@link VariantSourcesExtension#whenBound(org.gradle.api.Action)}. + * + * @param context resolved variant/role/layer usage bound to a source set + */ public void recordBinding(SourceSetUsageBinding context) { boundContexts.add(context); } + /** + * Returns all source-set outputs selected by the given slot for the given + * variant. + * + *

The result is built from recorded {@link SourceSetUsageBinding} + * instances whose {@link SourceSetUsageBinding#variantName()} matches + * {@code variantName}. Each matching binding is then fed into the slot + * contribution pipeline; if multiple contributions resolve to the same + * {@link BindingKey}, that source is included only once. + * + *

This method does not validate the model; validation is expected to be + * performed earlier by {@link VariantArtifactsExtension}. Unknown variants + * or slots with no matching rules simply produce an empty collection. + * + * @param variantName variant whose bound source-set usages should be scanned + * @param slot slot definition that selects which outputs should be included + * @return lazily wired file collection for the selected outputs + */ public FileCollection files(String variantName, VariantArtifactSlot slot) { - var files = objects.fileCollection(); - var boundOutputs = new LinkedHashSet(); + var builder = new FileCollectionBuilder(); + var contexts = boundContexts.stream() + .filter(context -> variantName.equals(context.variantName())) + .toList(); - boundContexts.stream() - .filter(context -> variantName.equals(context.variantName())) - .forEach(context -> bindMatchingOutputs(files, boundOutputs, slot, context)); + slot.bindings().forEach(binding -> binding.resolve(contexts, builder::addOutput)); - return files; + return builder.build(); } - private static void bindMatchingOutputs( - ConfigurableFileCollection files, - Set boundOutputs, - VariantArtifactSlot slot, - SourceSetUsageBinding context) { - slot.bindingRules().stream() - .filter(rule -> rule.matches(context)) - .forEach(rule -> bindOutput(files, boundOutputs, context, rule.outputName())); + /** + * Local materialization helper for one {@link #files(String, VariantArtifactSlot)} + * call. + */ + class FileCollectionBuilder { + private final ConfigurableFileCollection files; + private final Set boundOutputs = new LinkedHashSet<>(); + + FileCollectionBuilder() { + this.files = objects.fileCollection(); + } + + FileCollection build() { + return files; + } + + void addOutput(VariantArtifactSlot.ResolvedBinding binding) { + if (boundOutputs.add(binding.key())) + files.from(binding.files()); + } } - private static void bindOutput( - ConfigurableFileCollection files, - Set boundOutputs, - SourceSetUsageBinding context, - String outputName) { - var key = context.sourceSetName() + "\u0000" + outputName; - if (!boundOutputs.add(key)) - return; - - files.from(context.sourceSet().map(sourceSet -> sourceSet.output(outputName))); - } } diff --git a/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties --- a/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties +++ b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties @@ -1,1 +1,1 @@ -implementation-class=org.implab.gradle.common.sources.VariantsArtifactsPlugin +implementation-class=org.implab.gradle.common.sources.VariantArtifactsPlugin diff --git a/common/src/test/java/org/implab/gradle/common/sources/VariantsArtifactsPluginFunctionalTest.java b/common/src/test/java/org/implab/gradle/common/sources/VariantsArtifactsPluginFunctionalTest.java --- a/common/src/test/java/org/implab/gradle/common/sources/VariantsArtifactsPluginFunctionalTest.java +++ b/common/src/test/java/org/implab/gradle/common/sources/VariantsArtifactsPluginFunctionalTest.java @@ -186,6 +186,111 @@ class VariantsArtifactsPluginFunctionalT } @Test + void materializesDirectSlotInputsWithoutVariantSourceBindings() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile("inputs/bundle.js", "console.log('bundle')\n"); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + variantArtifacts { + variant('browser') { + primarySlot('bundle') { + from(layout.projectDirectory.file('inputs/bundle.js')) + } + } + } + + tasks.register('probe') { + dependsOn 'processBrowserBundle' + + doLast { + def bundleDir = layout.buildDirectory.dir('variant-artifacts/browser/bundle').get().asFile + assert new File(bundleDir, 'bundle.js').exists() + println('primary=' + variantArtifacts.requireVariant('browser').requirePrimarySlotName()) + } + } + """); + + BuildResult result = runner("probe").build(); + + assertTrue(result.getOutput().contains("primary=bundle")); + assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test + void combinesDirectAndTopologyAwareSlotInputs() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile("inputs/base.js", "console.log('base')\n"); + writeFile("inputs/marker.txt", "marker\n"); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + variantSources { + bind('main') { + configureSourceSet { + declareOutputs('js') + } + } + + whenBound { ctx -> + ctx.configureSourceSet { + registerOutput('js', layout.projectDirectory.file('inputs/base.js')) + } + } + } + + variantArtifacts { + variant('browser') { + primarySlot('bundle') { + fromVariant { + output('js') + } + from(layout.projectDirectory.file('inputs/marker.txt')) + } + } + } + + tasks.register('probe') { + dependsOn 'processBrowserBundle' + + doLast { + def bundleDir = layout.buildDirectory.dir('variant-artifacts/browser/bundle').get().asFile + assert new File(bundleDir, 'base.js').exists() + assert new File(bundleDir, 'marker.txt').exists() + } + } + """); + + BuildResult result = runner("probe").build(); + + assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test void failsOnUnknownVariantReference() throws Exception { assertBuildFails(""" plugins { @@ -475,13 +580,13 @@ class VariantsArtifactsPluginFunctionalT private static List pluginClasspath() { try { - var classesDir = Path.of(VariantsArtifactsPlugin.class + var classesDir = Path.of(VariantArtifactsPlugin.class .getProtectionDomain() .getCodeSource() .getLocation() .toURI()); - var markerResource = VariantsArtifactsPlugin.class.getClassLoader() + var markerResource = VariantArtifactsPlugin.class.getClassLoader() .getResource("META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties"); assertNotNull(markerResource, "Plugin marker resource is missing from test classpath"); diff --git a/common/variant-artifacts-plugin.md b/common/variant-artifacts-plugin.md --- a/common/variant-artifacts-plugin.md +++ b/common/variant-artifacts-plugin.md @@ -98,16 +98,43 @@ build variant: - graph selection build variant-а; - artifact selection внутри уже выбранного variant-а. -### slot bindings +### slot contributions и DSL + +`slot('')` описывает artifact representation не как один файл или одну +задачу, а как набор contributions, которые потом materialize-ятся в отдельный +`ArtifactAssembly`. + +Текущий DSL поддерживает два вида contributions: -`slot('')` описывает, какие outputs из `variantSources` войдут в artifact -representation этого slot-а. +- topology-aware: + - `fromVariant { output(...) }` + - `fromRole('') { output(...) }` + - `fromLayer('') { output(...) }` +- direct: + - `from(someFileOrProviderOrTaskOutput)` + +Смысл DSL по слоям: -Binding rules: +- `fromVariant/fromRole/fromLayer` выбирают область topology model, в которой + contribution активен; +- `output(...)` выбирает named output соответствующего `GenericSourceSet`; +- `from(Object)` добавляет direct contribution, не зависящий от + `variantSources` bindings; +- итоговый contribution при materialization: + - проверяет, подходит ли текущий `SourceSetUsageBinding`; + - выдает object для `files.from(...)`; + - при необходимости выдает `BindingKey`, если такой contribution должен + схлопываться по logical identity. -- `fromVariant { output(...) }` -- `fromRole('') { output(...) }` -- `fromLayer('') { output(...) }` +Связь slot-а с остальной моделью: + +- `variants` задает topology variant/role/layer; +- `variantSources` превращает topology в concrete `SourceSetUsageBinding`; +- `variantArtifacts.slot(...)` описывает, какие bindings надо включить в slot; +- `VariantArtifactsResolver` превращает contributions slot-а в `FileCollection`; +- `VariantArtifactsPlugin` регистрирует для slot-а отдельный `ArtifactAssembly`; +- `OutgoingVariantPublication` и `OutgoingArtifactSlotPublication` публикуют + уже собранные slot artifacts наружу. Каждый slot materialize-ится в отдельный `ArtifactAssembly`: @@ -254,7 +281,7 @@ artifact representation. Проверяется: - variant существует в topology model; -- slot bindings не ссылаются на неизвестные role/layer; +- slot contributions не ссылаются на неизвестные role/layer; - при нескольких slots указан `primarySlot`; - `primarySlot` ссылается на существующий slot. @@ -281,21 +308,37 @@ artifact representation. ### VariantArtifactSlot +- `from(Object)`; - `fromVariant(...)`; - `fromRole(String, ...)`; - `fromLayer(String, ...)`. +Внутренняя модель: + +- slot хранит contributions, а не строковые rules; +- `fromVariant/fromRole/fromLayer` создают topology-aware contributions; +- `from(Object)` создает direct contribution, который materialize-ится даже + если у variant-а нет ни одного `SourceSetUsageBinding`; +- slot отдельно хранит topology references для validation: + `referencedRoleNames()` и `referencedLayerNames()`. + ### OutputSelectionSpec - `output(name)`; - `output(name, extra...)`. +`OutputSelectionSpec` это внутренний DSL-buffer для одного блока +`fromVariant/fromRole/fromLayer`. Он локально накапливает contributions и +передает их в slot только после успешного завершения configure-блока. + ## KEY CLASSES - `VariantsArtifactsPlugin` — plugin adapter и materialization outgoing variants. - `VariantArtifactsExtension` — root DSL и lifecycle. - `VariantArtifact` — outgoing build variant model. - `VariantArtifactSlot` — artifact representation slot. +- `VariantArtifactsResolver` — adapter между `variantSources` bindings и + contribution model slot-а. - `OutgoingVariantPublication` — payload variant-level publication callback. - `OutgoingArtifactSlotPublication` — payload per-slot publication callback. - `ArtifactAssembly` — assembled files for a slot.