# HG changeset patch # User cin # Date 2026-03-14 21:39:40 # Node ID 6f01b47b894dc2ece29053524782d715ed9d11aa # Parent 414a5d71eaa5a86517af00d4374d5e99bbd57c41 Added variant artifacts model diff --git a/common/src/main/java/org/implab/gradle/common/sources/ArtifactAssembly.java b/common/src/main/java/org/implab/gradle/common/sources/ArtifactAssembly.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/ArtifactAssembly.java @@ -0,0 +1,53 @@ +package org.implab.gradle.common.sources; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Named; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.Directory; +import org.gradle.api.file.FileCollection; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Copy; +import org.gradle.api.tasks.TaskProvider; + +@NonNullByDefault +public final class ArtifactAssembly implements Named { + private final String name; + private final ConfigurableFileCollection sources; + private final ConfigurableFileCollection output; + private final Provider outputDirectory; + private final TaskProvider task; + + ArtifactAssembly( + String name, + ConfigurableFileCollection sources, + Provider outputDirectory, + TaskProvider task, + ConfigurableFileCollection output) { + this.name = name; + this.sources = sources; + this.outputDirectory = outputDirectory; + this.task = task; + this.output = output; + } + + @Override + public String getName() { + return name; + } + + public ConfigurableFileCollection getSources() { + return sources; + } + + public FileCollection getOutput() { + return output; + } + + public Provider getOutputDirectory() { + return outputDirectory; + } + + public TaskProvider getTask() { + return task; + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/ArtifactAssemblyRegistry.java b/common/src/main/java/org/implab/gradle/common/sources/ArtifactAssemblyRegistry.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/ArtifactAssemblyRegistry.java @@ -0,0 +1,64 @@ +package org.implab.gradle.common.sources; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.Directory; +import org.gradle.api.model.ObjectFactory; +import org.gradle.api.provider.Provider; +import org.gradle.api.tasks.Copy; +import org.gradle.api.tasks.TaskContainer; +import org.gradle.language.base.plugins.LifecycleBasePlugin; + +@NonNullByDefault +public final class ArtifactAssemblyRegistry { + private final ObjectFactory objects; + private final TaskContainer tasks; + private final Map assemblies = new LinkedHashMap<>(); + + public ArtifactAssemblyRegistry(ObjectFactory objects, TaskContainer tasks) { + this.objects = objects; + this.tasks = tasks; + } + + public ArtifactAssembly register( + String name, + String taskName, + Provider outputDirectory, + Action configureSources) { + if (assemblies.containsKey(name)) { + throw new InvalidUserDataException("Artifact assembly '" + name + "' is already registered"); + } + + var sources = objects.fileCollection(); + configureSources.execute(sources); + + var task = tasks.register(taskName, Copy.class, copy -> { + copy.setGroup(LifecycleBasePlugin.BUILD_GROUP); + copy.into(outputDirectory); + copy.from(sources); + }); + + var output = objects.fileCollection() + .from(outputDirectory) + .builtBy(task); + + var assembly = new ArtifactAssembly(name, sources, outputDirectory, task, output); + assemblies.put(name, assembly); + return assembly; + } + + public Optional find(String name) { + return Optional.ofNullable(assemblies.get(name)); + } + + public ArtifactAssembly require(String name) { + return find(name) + .orElseThrow(() -> new InvalidUserDataException("Artifact assembly '" + name + "' isn't registered")); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/BuildArtifactSlot.java b/common/src/main/java/org/implab/gradle/common/sources/BuildArtifactSlot.java deleted file mode 100644 --- a/common/src/main/java/org/implab/gradle/common/sources/BuildArtifactSlot.java +++ /dev/null @@ -1,22 +0,0 @@ -package org.implab.gradle.common.sources; - -import javax.inject.Inject; - -import org.gradle.api.Named; - -/** - * Named output slot reserved by a variant. - */ -public abstract class BuildArtifactSlot implements Named { - private final String name; - - @Inject - public BuildArtifactSlot(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } -} diff --git a/common/src/main/java/org/implab/gradle/common/sources/BuildVariant.java b/common/src/main/java/org/implab/gradle/common/sources/BuildVariant.java --- a/common/src/main/java/org/implab/gradle/common/sources/BuildVariant.java +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildVariant.java @@ -12,9 +12,6 @@ import org.gradle.api.Action; import org.gradle.api.InvalidUserDataException; import org.gradle.api.Named; import org.gradle.api.model.ObjectFactory; -import org.gradle.api.provider.Provider; -import org.gradle.api.provider.ProviderFactory; -import org.gradle.api.attributes.Attribute; import groovy.lang.Closure; @@ -23,18 +20,12 @@ public abstract class BuildVariant imple private final ObjectFactory objects; private boolean finalized; - /** - * Variant aggregate parts. - */ - private final VariantAttributes attributes; private final LinkedHashMap roles = new LinkedHashMap<>(); - private final LinkedHashMap artifactSlots = new LinkedHashMap<>(); @Inject - public BuildVariant(String name, ObjectFactory objects, ProviderFactory providers) { + public BuildVariant(String name, ObjectFactory objects) { this.name = name; this.objects = objects; - attributes = new VariantAttributes(providers); } @Override @@ -42,32 +33,6 @@ public abstract class BuildVariant imple return name; } - /** - * Generic variant attributes interpreted by adapters. - */ - public VariantAttributes getAttributes() { - return attributes; - } - - public void attributes(Action action) { - ensureMutable("configure attributes"); - action.execute(new AttributesSpec(attributes)); - } - - public void attributes(Closure configure) { - attributes(Closures.action(configure)); - } - - public void attribute(Attribute key, T value) { - ensureMutable("set attributes"); - attributes.attribute(key, value); - } - - public void attributeProvider(Attribute key, Provider value) { - ensureMutable("set attributes"); - attributes.attributeProvider(key, value); - } - public Collection getRoles() { return Collections.unmodifiableCollection(roles.values()); } @@ -107,45 +72,6 @@ public abstract class BuildVariant imple "Variant '" + this.name + "' doesn't define role '" + name + "'")); } - public Collection getArtifactSlots() { - return Collections.unmodifiableCollection(artifactSlots.values()); - } - - public void artifactSlots(Action action) { - ensureMutable("configure artifact slots"); - action.execute(new ArtifactSlotsSpec()); - } - - public void artifactSlots(Closure configure) { - artifactSlots(Closures.action(configure)); - } - - public BuildArtifactSlot artifactSlot(String name) { - return artifactSlot(name, it -> { - }); - } - - public BuildArtifactSlot artifactSlot(String name, Action configure) { - ensureMutable("configure artifact slots"); - var slot = artifactSlots.computeIfAbsent(name, this::newArtifactSlot); - configure.execute(slot); - return slot; - } - - public BuildArtifactSlot artifactSlot(String name, Closure configure) { - return artifactSlot(name, Closures.action(configure)); - } - - public Optional findArtifactSlot(String name) { - return Optional.ofNullable(artifactSlots.get(name)); - } - - public BuildArtifactSlot requireArtifactSlot(String name) { - return findArtifactSlot(name) - .orElseThrow(() -> new InvalidUserDataException( - "Variant '" + this.name + "' doesn't define artifact slot '" + name + "'")); - } - void finalizeModel() { if (finalized) return; @@ -153,7 +79,6 @@ public abstract class BuildVariant imple for (var role : roles.values()) role.finalizeModel(); - attributes.finalizeModel(); finalized = true; } @@ -161,10 +86,6 @@ public abstract class BuildVariant imple return objects.newInstance(BuildRole.class, roleName); } - private BuildArtifactSlot newArtifactSlot(String slotName) { - return objects.newInstance(BuildArtifactSlot.class, slotName); - } - private void ensureMutable(String operation) { if (finalized) throw new InvalidUserDataException("Variant '" + name + "' is finalized and cannot " + operation); @@ -195,74 +116,4 @@ public abstract class BuildVariant imple return BuildVariant.this.requireRole(name); } } - - public final class ArtifactSlotsSpec { - public BuildArtifactSlot artifactSlot(String name, Action configure) { - return BuildVariant.this.artifactSlot(name, configure); - } - - public BuildArtifactSlot artifactSlot(String name, Closure configure) { - return BuildVariant.this.artifactSlot(name, configure); - } - - public BuildArtifactSlot artifactSlot(String name) { - return BuildVariant.this.artifactSlot(name); - } - - public Collection getAll() { - return BuildVariant.this.getArtifactSlots(); - } - - public Optional find(String name) { - return BuildVariant.this.findArtifactSlot(name); - } - - public BuildArtifactSlot require(String name) { - return BuildVariant.this.requireArtifactSlot(name); - } - } - - public static final class AttributesSpec { - private final VariantAttributes attributes; - - AttributesSpec(VariantAttributes attributes) { - this.attributes = attributes; - } - - public void attribute(Attribute key, T value) { - attributes.attribute(key, value); - } - - public void attributeProvider(Attribute key, Provider value) { - attributes.attributeProvider(key, value); - } - - public void string(String name, String value) { - attribute(Attribute.of(name, String.class), value); - } - - public void string(String name, Provider value) { - attributeProvider(Attribute.of(name, String.class), value); - } - - public void bool(String name, boolean value) { - attribute(Attribute.of(name, Boolean.class), value); - } - - public void bool(String name, Provider value) { - attributeProvider(Attribute.of(name, Boolean.class), value); - } - - public void integer(String name, int value) { - attribute(Attribute.of(name, Integer.class), value); - } - - public void integer(String name, Provider value) { - attributeProvider(Attribute.of(name, Integer.class), value); - } - - public VariantAttributes asAttributes() { - return attributes; - } - } } diff --git a/common/src/main/java/org/implab/gradle/common/sources/BuildVariantsExtension.java b/common/src/main/java/org/implab/gradle/common/sources/BuildVariantsExtension.java --- a/common/src/main/java/org/implab/gradle/common/sources/BuildVariantsExtension.java +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildVariantsExtension.java @@ -193,11 +193,11 @@ public abstract class BuildVariantsExten return; } - validateRoleAndArtifactNames(variant, errors); + validateRoleNames(variant, errors); validateRoleMappings(variant, layersByName, errors); } - private static void validateRoleAndArtifactNames(BuildVariant variant, List errors) { + private static void validateRoleNames(BuildVariant variant, List errors) { var roleNames = new LinkedHashSet(); for (var role : variant.getRoles()) { var roleName = normalize(role.getName()); @@ -209,18 +209,6 @@ public abstract class BuildVariantsExten errors.add("Variant '" + variant.getName() + "' contains duplicated role name '" + roleName + "'"); } } - - var slotNames = new LinkedHashSet(); - for (var slot : variant.getArtifactSlots()) { - var slotName = normalize(slot.getName()); - if (slotName == null) { - errors.add("Variant '" + variant.getName() + "' contains blank artifact slot name"); - continue; - } - if (!slotNames.add(slotName)) { - errors.add("Variant '" + variant.getName() + "' contains duplicated artifact slot name '" + slotName + "'"); - } - } } private static void validateRoleMappings(BuildVariant variant, Map layersByName, diff --git a/common/src/main/java/org/implab/gradle/common/sources/OutgoingVariantPublication.java b/common/src/main/java/org/implab/gradle/common/sources/OutgoingVariantPublication.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/OutgoingVariantPublication.java @@ -0,0 +1,38 @@ +package org.implab.gradle.common.sources; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.implab.gradle.common.core.lang.Closures; +import org.gradle.api.Action; +import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.artifacts.Configuration; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +@NonNullByDefault +public record OutgoingVariantPublication( + String variantName, + String slotName, + BuildVariant topologyVariant, + VariantArtifact variantArtifact, + VariantArtifactSlot slot, + ArtifactAssembly assembly, + NamedDomainObjectProvider configuration) { + public void configureConfiguration(Action action) { + configuration.configure(action); + } + + public void configureConfiguration( + @DelegatesTo(value = Configuration.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + configureConfiguration(Closures.action(action)); + } + + public void configureAssembly(Action action) { + action.execute(assembly); + } + + public void configureAssembly( + @DelegatesTo(value = ArtifactAssembly.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + configureAssembly(Closures.action(action)); + } +} 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java @@ -0,0 +1,94 @@ +package org.implab.gradle.common.sources; + +import java.util.Optional; + +import javax.inject.Inject; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.Named; +import org.gradle.api.NamedDomainObjectContainer; +import org.implab.gradle.common.core.lang.Closures; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +@NonNullByDefault +public class VariantArtifact implements Named { + private final String name; + private final NamedDomainObjectContainer slots; + private boolean finalized; + + @Inject + public VariantArtifact(String name, NamedDomainObjectContainer slots) { + this.name = normalize(name, "variant artifact name must not be null or blank"); + this.slots = slots; + + slots.all(slot -> { + if (finalized) + throw new InvalidUserDataException( + "Variant artifact '" + this.name + "' is finalized and cannot add slot '" + slot.getName() + "'"); + }); + } + + @Override + public String getName() { + return name; + } + + public NamedDomainObjectContainer getSlots() { + return slots; + } + + public VariantArtifactSlot slot(String name) { + return slot(name, slot -> { + }); + } + + public VariantArtifactSlot slot(String name, Action configure) { + ensureMutable("configure slots"); + var slot = slots.maybeCreate(normalize(name, "slot name must not be null or blank")); + configure.execute(slot); + return slot; + } + + public VariantArtifactSlot slot( + String name, + @DelegatesTo(value = VariantArtifactSlot.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + return slot(name, Closures.action(configure)); + } + + public Optional findSlot(String slotName) { + return Optional.ofNullable(slots.findByName(normalize(slotName, "slot name must not be null or blank"))); + } + + public VariantArtifactSlot requireSlot(String slotName) { + var normalizedSlotName = normalize(slotName, "slot name must not be null or blank"); + return Optional.ofNullable(slots.findByName(normalizedSlotName)) + .orElseThrow(() -> new InvalidUserDataException( + "Variant artifact '" + name + "' doesn't declare slot '" + normalizedSlotName + "'")); + } + + void finalizeModel() { + if (finalized) + return; + + for (var slot : slots) + slot.finalizeModel(); + + finalized = true; + } + + static String normalize(String value, String message) { + return Optional.ofNullable(value) + .map(String::trim) + .filter(trimmed -> !trimmed.isEmpty()) + .orElseThrow(() -> new InvalidUserDataException(message)); + } + + private void ensureMutable(String operation) { + if (finalized) + throw new InvalidUserDataException("Variant artifact '" + name + "' is finalized and cannot " + operation); + } +} 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactSlot.java @@ -0,0 +1,138 @@ +package org.implab.gradle.common.sources; + +import java.util.ArrayList; +import java.util.List; + +import javax.inject.Inject; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.Named; +import org.implab.gradle.common.core.lang.Closures; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +@NonNullByDefault +public class VariantArtifactSlot implements Named { + private final String name; + private final List rules = new ArrayList<>(); + private boolean finalized; + + @Inject + public VariantArtifactSlot(String name) { + this.name = VariantArtifact.normalize(name, "slot name must not be null or blank"); + } + + @Override + public String getName() { + return name; + } + + public void fromVariant(Action configure) { + addRules(BindingSelector.variant(), configure); + } + + public void fromVariant( + @DelegatesTo(value = OutputSelectionSpec.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + fromVariant(Closures.action(configure)); + } + + public void fromRole(String roleName, Action configure) { + addRules(BindingSelector.role(VariantArtifact.normalize(roleName, "role name must not be null or blank")), + configure); + } + + public void fromRole( + String roleName, + @DelegatesTo(value = OutputSelectionSpec.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + fromRole(roleName, Closures.action(configure)); + } + + public void fromLayer(String layerName, Action configure) { + addRules(BindingSelector.layer(VariantArtifact.normalize(layerName, "layer name must not be null or blank")), + configure); + } + + public void fromLayer( + String layerName, + @DelegatesTo(value = OutputSelectionSpec.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + fromLayer(layerName, Closures.action(configure)); + } + + List bindingRules() { + return List.copyOf(rules); + } + + void finalizeModel() { + finalized = true; + } + + private void addRules(BindingSelector selector, Action configure) { + ensureMutable("configure sources"); + + var spec = new OutputSelectionSpec(selector); + configure.execute(spec); + rules.addAll(spec.rules()); + } + + private void ensureMutable(String operation) { + if (finalized) + throw new InvalidUserDataException("Variant artifact slot '" + name + "' is finalized and cannot " + operation); + } + + public final class OutputSelectionSpec { + private final BindingSelector selector; + private final List rules = new ArrayList<>(); + + private OutputSelectionSpec(BindingSelector 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"))); + } + + public void output(String name, String... extra) { + output(name); + for (var item : extra) + output(item); + } + + private List rules() { + return List.copyOf(rules); + } + } + + 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()); + }; + } + } + + 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); + } + } + + 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsExtension.java @@ -0,0 +1,169 @@ +package org.implab.gradle.common.sources; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.NamedDomainObjectContainer; +import org.gradle.api.model.ObjectFactory; +import org.implab.gradle.common.core.lang.Closures; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +@NonNullByDefault +public abstract class VariantArtifactsExtension { + private final NamedDomainObjectContainer variants; + private final ObjectFactory objects; + private final List> outgoingVariantActions = new ArrayList<>(); + private final List outgoingVariants = new ArrayList<>(); + private boolean finalized; + + @Inject + public VariantArtifactsExtension(ObjectFactory objects) { + this.objects = objects; + variants = objects.domainObjectContainer(VariantArtifact.class, this::newVariantArtifact); + + variants.all(variant -> { + if (finalized) + throw new InvalidUserDataException( + "variantArtifacts model is finalized and cannot add variant '" + variant.getName() + "'"); + }); + } + + public NamedDomainObjectContainer getVariants() { + return variants; + } + + public VariantArtifact variant(String name) { + return variant(name, variant -> { + }); + } + + public VariantArtifact variant(String name, Action configure) { + ensureMutable("configure variants"); + var variant = variants.maybeCreate(VariantArtifact.normalize(name, "variant name must not be null or blank")); + configure.execute(variant); + return variant; + } + + public VariantArtifact variant( + String name, + @DelegatesTo(value = VariantArtifact.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + return variant(name, Closures.action(configure)); + } + + public Optional findVariant(String variantName) { + return Optional + .ofNullable(variants.findByName(VariantArtifact.normalize(variantName, "variant name must not be null or blank"))); + } + + public VariantArtifact requireVariant(String variantName) { + var normalizedVariantName = VariantArtifact.normalize(variantName, "variant name must not be null or blank"); + return findVariant(normalizedVariantName) + .orElseThrow(() -> new InvalidUserDataException( + "Variant artifacts do not declare variant '" + normalizedVariantName + "'")); + } + + public void whenOutgoingVariant(Action action) { + outgoingVariantActions.add(action); + for (var publication : outgoingVariants) + action.execute(publication); + } + + public void whenOutgoingVariant( + @DelegatesTo(value = OutgoingVariantPublication.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenOutgoingVariant(Closures.action(action)); + } + + public boolean isFinalized() { + return finalized; + } + + void finalizeModel(BuildVariantsExtension topology) { + if (finalized) + return; + + validate(topology); + + for (var variant : variants) + variant.finalizeModel(); + + finalized = true; + } + + void notifyOutgoingVariant(OutgoingVariantPublication publication) { + outgoingVariants.add(publication); + for (var action : outgoingVariantActions) + action.execute(publication); + } + + private VariantArtifact newVariantArtifact(String name) { + return objects.newInstance(VariantArtifact.class, name, objects.domainObjectContainer(VariantArtifactSlot.class)); + } + + 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; + } + + validateVariantArtifact(variantArtifact, topologyVariant.get(), errors); + } + + if (!errors.isEmpty()) { + var message = new StringBuilder("Invalid variantArtifacts model:"); + for (var error : errors) + message.append("\n - ").append(error); + + throw new InvalidUserDataException(message.toString()); + } + } + + private static void validateVariantArtifact(VariantArtifact variantArtifact, BuildVariant topologyVariant, List errors) { + var roleNames = new LinkedHashSet(); + var layerNames = new LinkedHashSet(); + + for (var role : topologyVariant.getRoles()) { + roleNames.add(role.getName()); + layerNames.addAll(role.getLayers().getOrElse(List.of())); + } + + 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 void ensureMutable(String operation) { + if (finalized) + throw new InvalidUserDataException("variantArtifacts model is finalized and cannot " + operation); + } +} 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifactsResolver.java @@ -0,0 +1,58 @@ +package org.implab.gradle.common.sources; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; +import org.gradle.api.model.ObjectFactory; + +@NonNullByDefault +public final class VariantArtifactsResolver { + private final ObjectFactory objects; + private final List boundContexts = new ArrayList<>(); + + public VariantArtifactsResolver(ObjectFactory objects) { + this.objects = objects; + } + + public void recordBinding(SourceSetUsageBinding context) { + boundContexts.add(context); + } + + public FileCollection files(String variantName, VariantArtifactSlot slot) { + var files = objects.fileCollection(); + var boundOutputs = new LinkedHashSet(); + + boundContexts.stream() + .filter(context -> variantName.equals(context.variantName())) + .forEach(context -> bindMatchingOutputs(files, boundOutputs, slot, context)); + + return files; + } + + 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())); + } + + 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/java/org/implab/gradle/common/sources/VariantAttributes.java b/common/src/main/java/org/implab/gradle/common/sources/VariantAttributes.java deleted file mode 100644 --- a/common/src/main/java/org/implab/gradle/common/sources/VariantAttributes.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.implab.gradle.common.sources; - -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; - -import org.gradle.api.InvalidUserDataException; -import org.gradle.api.attributes.Attribute; -import org.gradle.api.provider.ProviderFactory; -import org.gradle.api.provider.Provider; - -/** - * Typed attribute storage used by build variants. - */ -public final class VariantAttributes { - private final ProviderFactory providers; - private final LinkedHashMap, Provider> values = new LinkedHashMap<>(); - private final Provider emptyValueProvider; - private boolean finalized; - - VariantAttributes(ProviderFactory providers) { - this.providers = providers; - this.emptyValueProvider = providers.provider(() -> null); - } - - public void attribute(Attribute key, T value) { - ensureMutable("set attribute '" + key.getName() + "'"); - attributeProvider(key, providers.provider(() -> value)); - } - - public void attributeProvider(Attribute key, Provider value) { - ensureMutable("set attribute provider '" + key.getName() + "'"); - values.put(key, value); - } - - @SuppressWarnings("unchecked") - public Provider find(Attribute key) { - return providers - .provider(() -> (Provider) values.getOrDefault(key, emptyValueProvider)) - .flatMap(provider -> provider); - } - - public Provider require(Attribute key) { - var value = find(key); - if (!value.isPresent()) - throw new InvalidUserDataException("Attribute '" + key.getName() + "' doesn't have a value"); - - return value; - } - - public boolean contains(Attribute key) { - return values.containsKey(key); - } - - public int size() { - return values.size(); - } - - public Map, Provider> asMap() { - return Collections.unmodifiableMap(values); - } - - void finalizeModel() { - finalized = true; - } - - private void ensureMutable(String operation) { - if (finalized) - throw new InvalidUserDataException("Variant attributes are finalized and cannot " + operation); - } -} diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantSourcesExtension.java b/common/src/main/java/org/implab/gradle/common/sources/VariantSourcesExtension.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantSourcesExtension.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantSourcesExtension.java @@ -17,6 +17,7 @@ import org.gradle.api.Action; import org.gradle.api.InvalidUserDataException; import org.gradle.api.NamedDomainObjectContainer; import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.file.ProjectLayout; import org.gradle.api.model.ObjectFactory; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; @@ -34,6 +35,7 @@ public abstract class VariantSourcesExte private static final Logger logger = Logging.getLogger(VariantSourcesExtension.class); private static final Pattern SOURCE_SET_NAME_TOKEN = Pattern.compile("\\{([A-Za-z][A-Za-z0-9]*)\\}"); + private final ProjectLayout layout; private final NamedDomainObjectContainer bindings; private final List> registeredActions = new ArrayList<>(); private final List> boundActions = new ArrayList<>(); @@ -44,7 +46,8 @@ public abstract class VariantSourcesExte private boolean sourceSetsRegistered; @Inject - public VariantSourcesExtension(ObjectFactory objects) { + public VariantSourcesExtension(ObjectFactory objects, ProjectLayout layout) { + this.layout = layout; bindings = objects.domainObjectContainer(BuildLayerBinding.class); } @@ -163,7 +166,8 @@ public abstract class VariantSourcesExte .map(layerName -> new LayerUsage( variant.getName(), role.getName(), - normalize(layerName, "Layer name in variant '" + variant.getName() + "' and role '" + role.getName() + "' must not be null or blank"))))); + normalize(layerName, "Layer name in variant '" + variant.getName() + + "' and role '" + role.getName() + "' must not be null or blank"))))); } private void registerLayerUsage(LayerUsage usage, NamedDomainObjectContainer sources) { @@ -176,7 +180,13 @@ public abstract class VariantSourcesExte ensureSourceSetNameBoundToSingleLayer(sourceSetName, usage.layerName()); var isNewSourceSet = !sourceSetsByName.containsKey(sourceSetName); var sourceSet = sourceSetsByName.computeIfAbsent(sourceSetName, - name -> sources.register(name)); + name -> { + var ssp = sources.register(name); + ssp.configure(x -> { + x.getSourceSetDir().set(layout.getProjectDirectory().dir("src/" + usage.layerName())); + }); + return ssp; + }); var binding = new SourceSetUsageBinding( usage.variantName(), @@ -252,7 +262,8 @@ public abstract class VariantSourcesExte var result = sanitizeName(resolved); if (result.isEmpty()) - throw new InvalidUserDataException("sourceSetNamePattern '" + pattern + "' resolved to empty source set name"); + throw new InvalidUserDataException( + "sourceSetNamePattern '" + pattern + "' resolved to empty source set name"); return result; } diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java b/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java @@ -0,0 +1,96 @@ +package org.implab.gradle.common.sources; + +import org.gradle.api.GradleException; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.artifacts.Configuration; +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 static final String VARIANT_ARTIFACTS_EXTENSION_NAME = "variantArtifacts"; + + @Override + public void apply(Project target) { + logger.debug("Registering '{}' extension on project '{}'", VARIANT_ARTIFACTS_EXTENSION_NAME, target.getPath()); + + target.getPluginManager().apply(VariantsSourcesPlugin.class); + + var variants = VariantsPlugin.getVariantsExtension(target); + var variantSources = target.getExtensions().getByType(VariantSourcesExtension.class); + var variantArtifacts = target.getExtensions() + .create(VARIANT_ARTIFACTS_EXTENSION_NAME, VariantArtifactsExtension.class); + var variantArtifactsResolver = new VariantArtifactsResolver(target.getObjects()); + var artifactAssemblies = new ArtifactAssemblyRegistry(target.getObjects(), target.getTasks()); + + variantSources.whenBound(variantArtifactsResolver::recordBinding); + + variants.whenFinalized(model -> { + logger.debug("Finalizing variantArtifacts model on project '{}'", target.getPath()); + variantArtifacts.finalizeModel(model); + materializeOutgoingVariants(target, model, variantArtifacts, variantArtifactsResolver, artifactAssemblies); + logger.debug("variantArtifacts model finalized on project '{}'", target.getPath()); + }); + } + + public static VariantArtifactsExtension getVariantArtifactsExtension(Project target) { + var extension = target.getExtensions().findByType(VariantArtifactsExtension.class); + + if (extension == null) { + logger.error("variantArtifacts extension '{}' isn't found on project '{}'", + VARIANT_ARTIFACTS_EXTENSION_NAME, + target.getPath()); + throw new GradleException("variantArtifacts extension isn't found"); + } + + logger.debug("Resolved '{}' extension on project '{}'", VARIANT_ARTIFACTS_EXTENSION_NAME, target.getPath()); + + return extension; + } + + private static void materializeOutgoingVariants( + Project project, + BuildVariantsExtension topology, + VariantArtifactsExtension variantArtifacts, + VariantArtifactsResolver variantArtifactsResolver, + ArtifactAssemblyRegistry artifactAssemblies) { + for (var variantArtifact : variantArtifacts.getVariants()) { + var topologyVariant = topology.require(variantArtifact.getName()); + for (var slot : variantArtifact.getSlots()) { + var assembly = artifactAssemblies.register( + variantArtifact.getName() + Strings.capitalize(slot.getName()), + "process" + Strings.capitalize(variantArtifact.getName()) + Strings.capitalize(slot.getName()), + project.getLayout().getBuildDirectory() + .dir("variant-artifacts/" + variantArtifact.getName() + "/" + slot.getName()), + files -> files.from(variantArtifactsResolver.files(variantArtifact.getName(), slot))); + var configuration = createOutgoingConfiguration(project, variantArtifact.getName(), slot.getName(), assembly); + + variantArtifacts.notifyOutgoingVariant(new OutgoingVariantPublication( + variantArtifact.getName(), + slot.getName(), + topologyVariant, + variantArtifact, + slot, + assembly, + configuration)); + } + } + } + + private static org.gradle.api.NamedDomainObjectProvider createOutgoingConfiguration( + Project project, + String variantName, + String slotName, + ArtifactAssembly assembly) { + var configName = variantName + Strings.capitalize(slotName) + "Elements"; + return project.getConfigurations().consumable(configName, config -> { + config.setVisible(true); + config.setDescription("Consumable assembled artifacts for variant '" + variantName + "', slot '" + slotName + "'"); + config.getOutgoing().artifact(assembly.getOutput().getSingleFile(), published -> { + published.builtBy(assembly.getOutput().getBuildDependencies()); + }); + }); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/package-info.java b/common/src/main/java/org/implab/gradle/common/sources/package-info.java --- a/common/src/main/java/org/implab/gradle/common/sources/package-info.java +++ b/common/src/main/java/org/implab/gradle/common/sources/package-info.java @@ -1,5 +1,6 @@ /** - * Source model and DSL for variants/sources integration. + * Source model and DSL for variant topology, source bindings, artifact assembly + * and outgoing publication integration. * *

Naming convention for callbacks and lifecycle hooks: *

    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 new file mode 100644 --- /dev/null +++ b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties @@ -0,0 +1,1 @@ +implementation-class=org.implab.gradle.common.sources.VariantsArtifactsPlugin 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 new file mode 100644 --- /dev/null +++ b/common/src/test/java/org/implab/gradle/common/sources/VariantsArtifactsPluginFunctionalTest.java @@ -0,0 +1,294 @@ +package org.implab.gradle.common.sources; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.gradle.testkit.runner.BuildResult; +import org.gradle.testkit.runner.GradleRunner; +import org.gradle.testkit.runner.TaskOutcome; +import org.gradle.testkit.runner.UnexpectedBuildFailure; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VariantsArtifactsPluginFunctionalTest { + private static final String SETTINGS_FILE = "settings.gradle"; + private static final String BUILD_FILE = "build.gradle"; + private static final String ROOT_NAME = "rootProject.name = 'variants-artifacts-fixture'\n"; + + @TempDir + Path testProjectDir; + + @Test + void materializesVariantArtifactsAndInvokesOutgoingHooks() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile("inputs/base.js", "console.log('base')\n"); + writeFile("inputs/amd.js", "console.log('amd')\n"); + writeFile("inputs/mainJs.txt", "mainJs marker\n"); + writeFile("inputs/amdJs.txt", "amdJs marker\n"); + writeFile(BUILD_FILE, """ + import org.gradle.api.attributes.Attribute + + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('mainBase') + layer('mainAmd') + + variant('browser') { + role('main') { + layers('mainBase', 'mainAmd') + } + } + } + + variantSources { + bind('mainBase') { + configureSourceSet { + declareOutputs('js') + } + } + + bind('mainAmd') { + configureSourceSet { + declareOutputs('js') + } + } + + whenBound { ctx -> + if (ctx.sourceSetName() == 'browserMainBase') { + ctx.configureSourceSet { + registerOutput('js', layout.projectDirectory.file('inputs/base.js')) + } + } + + if (ctx.sourceSetName() == 'browserMainAmd') { + ctx.configureSourceSet { + registerOutput('js', layout.projectDirectory.file('inputs/amd.js')) + } + } + } + } + + variantArtifacts { + variant('browser') { + slot('mainJs') { + fromRole('main') { + output('js') + } + } + + slot('amdJs') { + fromLayer('mainAmd') { + output('js') + } + } + } + + whenOutgoingVariant { publication -> + publication.configureAssembly { + sources.from(layout.projectDirectory.file("inputs/${publication.slotName()}.txt")) + } + + publication.configureConfiguration { + attributes.attribute(Attribute.of('test.slot', String), publication.slotName()) + } + } + } + + tasks.register('probe') { + dependsOn 'processBrowserMainJs', 'processBrowserAmdJs' + + doLast { + def mainDir = layout.buildDirectory.dir('variant-artifacts/browser/mainJs').get().asFile + def amdDir = layout.buildDirectory.dir('variant-artifacts/browser/amdJs').get().asFile + + assert new File(mainDir, 'base.js').exists() + assert new File(mainDir, 'amd.js').exists() + assert new File(mainDir, 'mainJs.txt').exists() + + assert !new File(amdDir, 'base.js').exists() + assert new File(amdDir, 'amd.js').exists() + assert new File(amdDir, 'amdJs.txt').exists() + + def mainElements = configurations.getByName('browserMainJsElements') + def attr = mainElements.attributes.getAttribute(Attribute.of('test.slot', String)) + + println('mainAttr=' + attr) + println('configurations=' + [mainElements.name, configurations.getByName('browserAmdJsElements').name].sort().join(',')) + } + } + """); + + BuildResult result = runner("probe").build(); + + assertTrue(result.getOutput().contains("mainAttr=mainJs")); + assertTrue(result.getOutput().contains("configurations=browserAmdJsElements,browserMainJsElements")); + assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test + void failsOnUnknownVariantReference() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + } + + variantArtifacts { + variant('browser') { + slot('mainJs') { + fromVariant { + output('js') + } + } + } + } + """, "Variant artifact 'browser' references unknown variant 'browser'"); + } + + @Test + void failsOnUnknownRoleReference() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + variantArtifacts { + variant('browser') { + slot('mainJs') { + fromRole('test') { + output('js') + } + } + } + } + """, "Variant artifact 'browser', slot 'mainJs' references unknown role 'test'"); + } + + @Test + void failsOnLayerReferenceOutsideVariantTopology() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('mainBase') + layer('extra') + + variant('browser') { + role('main') { + layers('mainBase') + } + } + } + + variantArtifacts { + variant('browser') { + slot('extraJs') { + fromLayer('extra') { + output('js') + } + } + } + } + """, "Variant artifact 'browser', slot 'extraJs' references unknown layer 'extra'"); + } + + @Test + void failsOnLateMutationAfterFinalize() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + afterEvaluate { + variantArtifacts.variant('late') { + slot('js') { + fromVariant { + output('js') + } + } + } + } + """, "variantArtifacts model is finalized and cannot configure variants"); + } + + private GradleRunner runner(String... arguments) { + return GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withPluginClasspath(pluginClasspath()) + .withArguments(arguments) + .forwardOutput(); + } + + private void assertBuildFails(String buildScript, String expectedError) throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, buildScript); + + var ex = assertThrows(UnexpectedBuildFailure.class, () -> runner("help").build()); + var output = ex.getBuildResult().getOutput(); + + assertTrue(output.contains(expectedError), () -> "Expected [" + expectedError + "] in output:\n" + output); + } + + private static List pluginClasspath() { + try { + var classesDir = Path.of(VariantsArtifactsPlugin.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + + var markerResource = VariantsArtifactsPlugin.class.getClassLoader() + .getResource("META-INF/gradle-plugins/org.implab.gradle-variants-artifacts.properties"); + + assertNotNull(markerResource, "Plugin marker resource is missing from test classpath"); + + var markerPath = Path.of(markerResource.toURI()); + var resourcesDir = markerPath.getParent().getParent().getParent(); + + return List.of(classesDir.toFile(), resourcesDir.toFile()); + } catch (Exception e) { + throw new RuntimeException("Unable to build plugin classpath for test", e); + } + } + + private void writeFile(String relativePath, String content) throws IOException { + Path path = testProjectDir.resolve(relativePath); + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + } +} diff --git a/common/src/test/java/org/implab/gradle/common/sources/VariantsPluginFunctionalTest.java b/common/src/test/java/org/implab/gradle/common/sources/VariantsPluginFunctionalTest.java --- a/common/src/test/java/org/implab/gradle/common/sources/VariantsPluginFunctionalTest.java +++ b/common/src/test/java/org/implab/gradle/common/sources/VariantsPluginFunctionalTest.java @@ -41,32 +41,25 @@ class VariantsPluginFunctionalTest { } variant('browser') { - attributes { - string('jsRuntime', 'browser') - string('jsModule', 'amd') - } role('main') { layers('mainBase', 'mainAmd') } - artifactSlot('mainCompiled') } } tasks.register('probe') { doLast { def browser = variants.require('browser') - println('attributes=' + browser.attributes.size()) println('roles=' + browser.roles.size()) - println('slots=' + browser.artifactSlots.size()) + println('roleLayers=' + browser.requireRole('main').layers.get().join(',')) } } """); BuildResult result = runner("probe").build(); - assertTrue(result.getOutput().contains("attributes=2")); assertTrue(result.getOutput().contains("roles=1")); - assertTrue(result.getOutput().contains("slots=1")); + assertTrue(result.getOutput().contains("roleLayers=mainBase,mainAmd")); assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); }