diff --git a/common/src/main/java/org/implab/gradle/common/sources/OutgoingArtifactSlotPublication.java b/common/src/main/java/org/implab/gradle/common/sources/OutgoingArtifactSlotPublication.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/OutgoingArtifactSlotPublication.java @@ -0,0 +1,66 @@ +package org.implab.gradle.common.sources; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Action; +import org.gradle.api.attributes.AttributeContainer; +import org.gradle.api.attributes.HasConfigurableAttributes; +import org.implab.gradle.common.core.lang.Closures; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +@NonNullByDefault +public final class OutgoingArtifactSlotPublication { + private final String slotName; + private final boolean primary; + private final VariantArtifactSlot slot; + private final ArtifactAssembly assembly; + private final HasConfigurableAttributes attributesCarrier; + + OutgoingArtifactSlotPublication( + String slotName, + boolean primary, + VariantArtifactSlot slot, + ArtifactAssembly assembly, + HasConfigurableAttributes attributesCarrier) { + this.slotName = slotName; + this.primary = primary; + this.slot = slot; + this.assembly = assembly; + this.attributesCarrier = attributesCarrier; + } + + public String slotName() { + return slotName; + } + + public boolean primary() { + return primary; + } + + public VariantArtifactSlot slot() { + return slot; + } + + public ArtifactAssembly assembly() { + return assembly; + } + + 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)); + } + + public void configureArtifactAttributes(Action action) { + attributesCarrier.attributes(action); + } + + public void configureArtifactAttributes( + @DelegatesTo(value = AttributeContainer.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + configureArtifactAttributes(Closures.action(action)); + } +} 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 --- a/common/src/main/java/org/implab/gradle/common/sources/OutgoingVariantPublication.java +++ b/common/src/main/java/org/implab/gradle/common/sources/OutgoingVariantPublication.java @@ -3,36 +3,87 @@ 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 org.gradle.api.InvalidUserDataException; 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 final class OutgoingVariantPublication { + private final String variantName; + private final BuildVariant topologyVariant; + private final VariantArtifact variantArtifact; + private final Configuration configuration; + private final OutgoingArtifactSlotPublication primarySlot; + private final java.util.List slots; + + public OutgoingVariantPublication( + String variantName, + BuildVariant topologyVariant, + VariantArtifact variantArtifact, + Configuration configuration, + OutgoingArtifactSlotPublication primarySlot, + java.util.List slots) { + this.variantName = variantName; + this.topologyVariant = topologyVariant; + this.variantArtifact = variantArtifact; + this.configuration = configuration; + this.primarySlot = primarySlot; + this.slots = java.util.List.copyOf(slots); + } + + public String variantName() { + return variantName; + } + + public BuildVariant topologyVariant() { + return topologyVariant; + } + + public VariantArtifact variantArtifact() { + return variantArtifact; + } + + public Configuration configuration() { + return configuration; + } + + public OutgoingArtifactSlotPublication primarySlot() { + return primarySlot; + } + + public java.util.List slots() { + return slots; + } + + public java.util.List secondarySlots() { + return slots.stream() + .filter(slotPublication -> !slotPublication.primary()) + .toList(); + } + + public java.util.Optional findSlot(String slotName) { + var normalizedSlotName = VariantArtifact.normalize(slotName, "slot name must not be null or blank"); + return slots.stream() + .filter(slotPublication -> normalizedSlotName.equals(slotPublication.slotName())) + .findFirst(); + } + + public OutgoingArtifactSlotPublication requireSlot(String slotName) { + var normalizedSlotName = VariantArtifact.normalize(slotName, "slot name must not be null or blank"); + return findSlot(normalizedSlotName) + .orElseThrow(() -> new InvalidUserDataException( + "Outgoing publication for variant '" + variantName + "' doesn't declare slot '" + + normalizedSlotName + "'")); + } + public void configureConfiguration(Action action) { - configuration.configure(action); + action.execute(configuration); } 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 --- a/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantArtifact.java @@ -18,6 +18,7 @@ import groovy.lang.DelegatesTo; public class VariantArtifact implements Named { private final String name; private final NamedDomainObjectContainer slots; + private String primarySlotName; private boolean finalized; @Inject @@ -63,6 +64,46 @@ public class VariantArtifact implements return Optional.ofNullable(slots.findByName(normalize(slotName, "slot name must not be null or blank"))); } + public void primarySlot(String slotName) { + ensureMutable("configure primary slot"); + primarySlotName = normalize(slotName, "primary slot name must not be null or blank"); + } + + public VariantArtifactSlot primarySlot(String slotName, Action configure) { + ensureMutable("configure primary slot"); + var slot = slot(slotName, configure); + primarySlot(slot.getName()); + return slot; + } + + public VariantArtifactSlot primarySlot( + String slotName, + @DelegatesTo(value = VariantArtifactSlot.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + return primarySlot(slotName, Closures.action(configure)); + } + + public Optional findPrimarySlotName() { + return Optional.ofNullable(primarySlotName) + .or(() -> slots.getNames().size() == 1 ? Optional.of(slots.iterator().next().getName()) : Optional.empty()); + } + + public String requirePrimarySlotName() { + return findPrimarySlotName() + .orElseThrow(() -> new InvalidUserDataException( + "Variant artifact '" + name + "' must declare primary slot because it has multiple slots")); + } + + public Optional findPrimarySlot() { + return findPrimarySlotName().flatMap(this::findSlot); + } + + public VariantArtifactSlot requirePrimarySlot() { + var resolvedPrimarySlotName = requirePrimarySlotName(); + return findSlot(resolvedPrimarySlotName) + .orElseThrow(() -> new InvalidUserDataException( + "Variant artifact '" + name + "' declares unknown primary slot '" + resolvedPrimarySlotName + "'")); + } + public VariantArtifactSlot requireSlot(String slotName) { var normalizedSlotName = normalize(slotName, "slot name must not be null or blank"); return Optional.ofNullable(slots.findByName(normalizedSlotName)) 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 @@ -140,6 +140,19 @@ public abstract class VariantArtifactsEx layerNames.addAll(role.getLayers().getOrElse(List.of())); } + 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 + "'"); + } + } + } + for (var slot : variantArtifact.getSlots()) { for (var rule : slot.bindingRules()) { switch (rule.selector().kind()) { 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 --- a/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantsArtifactsPlugin.java @@ -1,9 +1,17 @@ package org.implab.gradle.common.sources; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.ArrayList; +import java.util.stream.Collectors; +import java.util.stream.Stream; + 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.artifacts.ConfigurationPublications; +import org.gradle.api.artifacts.ConfigurationVariant; import org.gradle.api.logging.Logger; import org.gradle.api.logging.Logging; import org.implab.gradle.common.core.lang.Strings; @@ -56,41 +64,117 @@ public abstract class VariantsArtifactsP 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, + variantArtifacts.getVariants().stream() + .filter(variantArtifact -> !variantArtifact.getSlots().isEmpty()) + .forEach(variantArtifact -> materializeOutgoingVariant( + project, + topology.require(variantArtifact.getName()), variantArtifact, - slot, - assembly, - configuration)); - } - } + variantArtifactsResolver, + artifactAssemblies, + variantArtifacts)); } - private static org.gradle.api.NamedDomainObjectProvider createOutgoingConfiguration( + private static void materializeOutgoingVariant( + Project project, + BuildVariant topologyVariant, + VariantArtifact variantArtifact, + VariantArtifactsResolver variantArtifactsResolver, + ArtifactAssemblyRegistry artifactAssemblies, + VariantArtifactsExtension variantArtifacts) { + var assemblies = variantArtifact.getSlots().stream() + .collect(Collectors.toMap( + VariantArtifactSlot::getName, + slot -> registerAssembly(project, variantArtifactsResolver, artifactAssemblies, variantArtifact, slot), + (left, right) -> left, + LinkedHashMap::new)); + + var primarySlot = variantArtifact.requirePrimarySlot(); + var configuration = createOutgoingConfiguration(project, variantArtifact.getName(), primarySlot.getName()); + var primaryAssembly = assemblies.get(primarySlot.getName()); + publishPrimaryArtifact(configuration, primaryAssembly); + var primaryPublication = new OutgoingArtifactSlotPublication( + primarySlot.getName(), + true, + primarySlot, + primaryAssembly, + configuration); + var secondarySlots = variantArtifact.getSlots().stream() + .filter(slot -> !slot.getName().equals(primarySlot.getName())) + .map(slot -> new SecondarySlot(slot, assemblies.get(slot.getName()))) + .toList(); + var secondaryPublications = new ArrayList(secondarySlots.size()); + secondarySlots.forEach(secondarySlot -> { + var secondaryVariant = configuration.getOutgoing().getVariants().create(secondarySlot.slot().getName()); + publishSecondaryArtifact(secondaryVariant, secondarySlot.assembly()); + secondaryPublications.add(new OutgoingArtifactSlotPublication( + secondarySlot.slot().getName(), + false, + secondarySlot.slot(), + secondarySlot.assembly(), + secondaryVariant)); + }); + + var slotPublications = Stream.concat( + Stream.of(primaryPublication), + secondaryPublications.stream()) + .toList(); + + variantArtifacts.notifyOutgoingVariant(new OutgoingVariantPublication( + variantArtifact.getName(), + topologyVariant, + variantArtifact, + configuration, + primaryPublication, + slotPublications)); + } + + private static ArtifactAssembly registerAssembly( + Project project, + VariantArtifactsResolver variantArtifactsResolver, + ArtifactAssemblyRegistry artifactAssemblies, + VariantArtifact variantArtifact, + VariantArtifactSlot slot) { + return 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))); + } + + private static Configuration createOutgoingConfiguration( Project project, String variantName, - String slotName, - ArtifactAssembly assembly) { - var configName = variantName + Strings.capitalize(slotName) + "Elements"; + String primarySlotName) { + var configName = variantName + "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()); - }); + config.setDescription("Consumable assembled artifacts for variant '" + variantName + + "' with primary slot '" + primarySlotName + "'"); + }).get(); + } + + private static void publishPrimaryArtifact(Configuration configuration, ArtifactAssembly assembly) { + publishArtifact(configuration.getOutgoing(), assembly); + } + + private static void publishSecondaryArtifact(ConfigurationVariant variant, ArtifactAssembly assembly) { + publishArtifact(variant, assembly); + } + + private static void publishArtifact(ConfigurationPublications outgoing, ArtifactAssembly assembly) { + outgoing.artifact(assembly.getOutput().getSingleFile(), published -> { + published.builtBy(assembly.getOutput().getBuildDependencies()); }); } + + private static void publishArtifact(ConfigurationVariant variant, ArtifactAssembly assembly) { + variant.artifact(assembly.getOutput().getSingleFile(), published -> { + published.builtBy(assembly.getOutput().getBuildDependencies()); + }); + } + + private record SecondarySlot(VariantArtifactSlot slot, ArtifactAssembly assembly) { + } } 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 @@ -9,6 +9,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.stream.Collectors; import org.gradle.testkit.runner.BuildResult; import org.gradle.testkit.runner.GradleRunner; @@ -80,7 +81,7 @@ class VariantsArtifactsPluginFunctionalT variantArtifacts { variant('browser') { - slot('mainJs') { + primarySlot('mainJs') { fromRole('main') { output('js') } @@ -94,12 +95,14 @@ class VariantsArtifactsPluginFunctionalT } whenOutgoingVariant { publication -> - publication.configureAssembly { - sources.from(layout.projectDirectory.file("inputs/${publication.slotName()}.txt")) - } + publication.slots().each { slotPublication -> + slotPublication.configureAssembly { + sources.from(layout.projectDirectory.file("inputs/${slotPublication.slotName()}.txt")) + } - publication.configureConfiguration { - attributes.attribute(Attribute.of('test.slot', String), publication.slotName()) + slotPublication.configureArtifactAttributes { + attribute(Attribute.of('test.slot', String), slotPublication.slotName()) + } } } } @@ -119,23 +122,70 @@ class VariantsArtifactsPluginFunctionalT 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)) + def elements = configurations.getByName('browserElements') + def primaryAttr = elements.attributes.getAttribute(Attribute.of('test.slot', String)) + def amdVariant = elements.outgoing.variants.getByName('amdJs') + def amdAttr = amdVariant.attributes.getAttribute(Attribute.of('test.slot', String)) - println('mainAttr=' + attr) - println('configurations=' + [mainElements.name, configurations.getByName('browserAmdJsElements').name].sort().join(',')) + println('primarySlot=' + variantArtifacts.requireVariant('browser').requirePrimarySlotName()) + println('primaryAttr=' + primaryAttr) + println('amdAttr=' + amdAttr) + println('configurations=' + configurations.matching { it.name == 'browserElements' }.collect { it.name }.join(',')) + println('secondaryVariants=' + elements.outgoing.variants.collect { it.name }.sort().join(',')) } } """); BuildResult result = runner("probe").build(); - assertTrue(result.getOutput().contains("mainAttr=mainJs")); - assertTrue(result.getOutput().contains("configurations=browserAmdJsElements,browserMainJsElements")); + assertTrue(result.getOutput().contains("primarySlot=mainJs")); + assertTrue(result.getOutput().contains("primaryAttr=mainJs")); + assertTrue(result.getOutput().contains("amdAttr=amdJs")); + assertTrue(result.getOutput().contains("configurations=browserElements")); + assertTrue(result.getOutput().contains("secondaryVariants=amdJs")); assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); } @Test + void allowsSingleSlotVariantWithoutExplicitPrimarySlot() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + variantArtifacts { + variant('browser') { + slot('typesPackage') { + fromVariant { + output('types') + } + } + } + } + + tasks.register('probe') { + doLast { + println('primary=' + variantArtifacts.requireVariant('browser').requirePrimarySlotName()) + } + } + """); + + BuildResult result = runner("probe").build(); + assertTrue(result.getOutput().contains("primary=typesPackage")); + } + + @Test void failsOnUnknownVariantReference() throws Exception { assertBuildFails(""" plugins { @@ -188,6 +238,41 @@ class VariantsArtifactsPluginFunctionalT } @Test + void failsWhenPrimarySlotIsMissingForMultipleSlots() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants-artifacts' + } + + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + variantArtifacts { + variant('browser') { + slot('typesPackage') { + fromVariant { + output('types') + } + } + + slot('js') { + fromVariant { + output('js') + } + } + } + } + """, "Variant artifact 'browser' must declare primary slot because it has multiple slots"); + } + + @Test void failsOnLayerReferenceOutsideVariantTopology() throws Exception { assertBuildFails(""" plugins { @@ -246,6 +331,130 @@ class VariantsArtifactsPluginFunctionalT """, "variantArtifacts model is finalized and cannot configure variants"); } + @Test + void preservesPrimaryResolutionAndAllowsSecondaryArtifactSelection() throws Exception { + writeFile(SETTINGS_FILE, """ + rootProject.name = 'variants-artifacts-fixture' + include 'producer', 'consumer' + """); + writeFile("producer/inputs/types.d.ts", "export type Foo = string\n"); + writeFile("producer/inputs/index.js", "export const foo = 'bar'\n"); + var buildscriptClasspath = pluginClasspath().stream() + .map(File::getAbsolutePath) + .map(path -> "'" + path.replace("\\", "\\\\") + "'") + .collect(Collectors.joining(", ")); + writeFile(BUILD_FILE, """ + buildscript { + dependencies { + classpath files(%s) + } + } + + import org.gradle.api.attributes.Attribute + + def variantAttr = Attribute.of('test.variant', String) + def slotAttr = Attribute.of('test.slot', String) + + subprojects { + apply plugin: 'org.implab.gradle-variants-artifacts' + } + + project(':producer') { + variants { + layer('main') + + variant('browser') { + role('main') { + layers('main') + } + } + } + + variantSources { + bind('main') { + configureSourceSet { + declareOutputs('types', 'js') + } + } + + whenBound { ctx -> + ctx.configureSourceSet { + registerOutput('types', layout.projectDirectory.file('inputs/types.d.ts')) + registerOutput('js', layout.projectDirectory.file('inputs/index.js')) + } + } + } + + variantArtifacts { + variant('browser') { + primarySlot('typesPackage') { + fromVariant { + output('types') + } + } + + slot('js') { + fromVariant { + output('js') + } + } + } + + whenOutgoingVariant { publication -> + publication.configureConfiguration { + attributes.attribute(variantAttr, publication.variantName()) + } + + publication.primarySlot().configureArtifactAttributes { + attribute(slotAttr, publication.primarySlot().slotName()) + } + + publication.requireSlot('js').configureArtifactAttributes { + attribute(slotAttr, 'js') + } + } + } + } + + project(':consumer') { + configurations { + compileView { + canBeResolved = true + canBeConsumed = false + canBeDeclared = true + attributes { + attribute(variantAttr, 'browser') + attribute(slotAttr, 'typesPackage') + } + } + } + + dependencies { + compileView project(':producer') + } + + tasks.register('probe') { + doLast { + def compileFiles = configurations.compileView.files.collect { it.name }.sort().join(',') + def jsFiles = configurations.compileView.incoming.artifactView { + attributes { + attribute(slotAttr, 'js') + } + }.files.files.collect { it.name }.sort().join(',') + + println('compileFiles=' + compileFiles) + println('jsFiles=' + jsFiles) + } + } + } + """.formatted(buildscriptClasspath)); + + BuildResult result = runner(":consumer:probe").build(); + + assertTrue(result.getOutput().contains("compileFiles=typesPackage")); + assertTrue(result.getOutput().contains("jsFiles=js")); + } + private GradleRunner runner(String... arguments) { return GradleRunner.create() .withProjectDir(testProjectDir.toFile()) diff --git a/common/variant-artifacts-plugin.md b/common/variant-artifacts-plugin.md new file mode 100644 --- /dev/null +++ b/common/variant-artifacts-plugin.md @@ -0,0 +1,311 @@ +# Variant Artifacts Plugin + +## NAME + +`VariantsArtifactsPlugin` и extension `variantArtifacts`. + +## SYNOPSIS + +```groovy +import org.gradle.api.attributes.Attribute + +plugins { + id 'org.implab.gradle-variants-artifacts' +} + +def variantAttr = Attribute.of('test.variant', String) +def slotAttr = Attribute.of('test.slot', String) + +variants { + layer('main') + + variant('browser') { + role('main') { layers('main') } + } +} + +variantSources { + bind('main') { + configureSourceSet { + declareOutputs('types', 'js', 'resources') + } + } +} + +variantArtifacts { + variant('browser') { + primarySlot('typesPackage') { + fromVariant { + output('types') + } + } + + slot('js') { + fromVariant { + output('js') + } + } + + slot('resources') { + fromVariant { + output('resources') + } + } + } + + whenOutgoingVariant { publication -> + publication.configureConfiguration { + attributes.attribute(variantAttr, publication.variantName()) + } + + publication.primarySlot().configureArtifactAttributes { + attribute(slotAttr, publication.primarySlot().slotName()) + } + + publication.requireSlot('js').configureArtifactAttributes { + attribute(slotAttr, 'js') + } + + publication.requireSlot('resources').configureArtifactAttributes { + attribute(slotAttr, 'resources') + } + } +} +``` + +## DESCRIPTION + +`VariantsArtifactsPlugin` применяет `VariantsSourcesPlugin`, затем строит +outgoing publication model поверх `variantSources`. + +### publication model + +Для каждого `variantArtifacts.variant('')` публикуется один outgoing +build variant: + +- primary configuration `Elements`; +- primary artifact slot на самой configuration; +- secondary variants внутри `configuration.outgoing.variants` для остальных slots. + +Пример: + +- `browserElements` +- primary slot: `typesPackage` +- secondary variants: `js`, `resources` + +Это разделяет: + +- graph selection build variant-а; +- artifact selection внутри уже выбранного variant-а. + +### slot bindings + +`slot('')` описывает, какие outputs из `variantSources` войдут в artifact +representation этого slot-а. + +Binding rules: + +- `fromVariant { output(...) }` +- `fromRole('') { output(...) }` +- `fromLayer('') { output(...) }` + +Каждый slot materialize-ится в отдельный `ArtifactAssembly`: + +- task: `process`; +- output dir: `build/variant-artifacts//`. + +### primary slot + +Primary slot задает artifact, который публикуется как основной artifact +configuration `Elements`. + +Формы DSL: + +```groovy +variant('browser') { + primarySlot('typesPackage') + + slot('typesPackage') { + fromVariant { output('types') } + } +} +``` + +или sugar: + +```groovy +variant('browser') { + primarySlot('typesPackage') { + fromVariant { output('types') } + } +} +``` + +Правила: + +- если slot один, он считается primary неявно; +- если slots несколько, `primarySlot(...)` обязателен; +- `primarySlot` должен ссылаться на существующий slot. + +## LIFECYCLE + +- `VariantsArtifactsPlugin` ждет `variants.whenFinalized(...)`; +- после этого валидирует `variantArtifacts`; +- регистрирует `ArtifactAssembly` по каждому slot; +- materialize-ит outgoing publications; +- вызывает `whenOutgoingVariant(...)`; +- callbacks replayable. + +После finalize мутации `variantArtifacts` запрещены. + +## EVENTS + +### whenOutgoingVariant + +Replayable callback на готовую outgoing publication variant-а. + +Подходит для: + +- настройки общих attributes build variant-а один раз; +- настройки per-slot artifact attributes; +- доконфигурации `ArtifactAssembly`. + +## PAYLOAD TYPES + +### OutgoingVariantPublication + +Содержит: + +- `variantName()`; +- `topologyVariant()`; +- `variantArtifact()`; +- `configuration()` — primary `Elements`; +- `primarySlot()`; +- `slots()` — все slot publications; +- `secondarySlots()`; +- `findSlot(name)`, `requireSlot(name)`. + +Sugar: + +- `configureConfiguration(Action|Closure)`. + +### OutgoingArtifactSlotPublication + +Содержит: + +- `slotName()`; +- `primary()`; +- `slot()` — модель `VariantArtifactSlot`; +- `assembly()`. + +Sugar: + +- `configureAssembly(Action|Closure)`; +- `configureArtifactAttributes(Action|Closure)`. + +`configureArtifactAttributes(...)` пишет attributes: + +- в `Configuration.attributes` для primary slot; +- в `ConfigurationVariant.attributes` для secondary slot. + +## CONSUMER SIDE + +### primary resolution + +Обычное inter-project resolution выбирает primary artifact `Elements`. + +Пример: + +```groovy +configurations { + compileView { + canBeResolved = true + canBeConsumed = false + canBeDeclared = true + attributes { + attribute(variantAttr, 'browser') + attribute(slotAttr, 'typesPackage') + } + } +} + +dependencies { + compileView project(':producer') +} +``` + +### artifact selection for secondary slots + +Secondary artifacts выбираются через `artifactView`. + +```groovy +def jsFiles = configurations.compileView.incoming.artifactView { + attributes { + attribute(slotAttr, 'js') + } +}.files +``` + +Здесь graph variant уже выбран, а `artifactView` выбирает нужный secondary +artifact representation. + +## VALIDATION + +Проверяется: + +- variant существует в topology model; +- slot bindings не ссылаются на неизвестные role/layer; +- при нескольких slots указан `primarySlot`; +- `primarySlot` ссылается на существующий slot. + +## API + +### VariantArtifactsExtension + +- `variant(String)` — получить/создать variant artifact model; +- `variant(String, Action|Closure)` — сконфигурировать variant artifact; +- `getVariants()` — контейнер variant artifacts; +- `findVariant(name)`, `requireVariant(name)`; +- `whenOutgoingVariant(...)`. + +### VariantArtifact + +- `slot(String)` — получить/создать slot; +- `slot(String, Action|Closure)` — сконфигурировать slot; +- `primarySlot(String)` — назначить primary slot; +- `primarySlot(String, Action|Closure)` — sugar: configure slot + mark as primary; +- `getSlots()`; +- `findSlot(name)`, `requireSlot(name)`; +- `findPrimarySlotName()`, `requirePrimarySlotName()`; +- `findPrimarySlot()`, `requirePrimarySlot()`. + +### VariantArtifactSlot + +- `fromVariant(...)`; +- `fromRole(String, ...)`; +- `fromLayer(String, ...)`. + +### OutputSelectionSpec + +- `output(name)`; +- `output(name, extra...)`. + +## KEY CLASSES + +- `VariantsArtifactsPlugin` — plugin adapter и materialization outgoing variants. +- `VariantArtifactsExtension` — root DSL и lifecycle. +- `VariantArtifact` — outgoing build variant model. +- `VariantArtifactSlot` — artifact representation slot. +- `OutgoingVariantPublication` — payload variant-level publication callback. +- `OutgoingArtifactSlotPublication` — payload per-slot publication callback. +- `ArtifactAssembly` — assembled files for a slot. + +## NOTES + +- `common` не навязывает доменную логику выбора primary slot. +- `common` не фиксирует значения `usage`, `libraryelements` и прочих + slot-specific attributes. +- `common` не смешивает эту модель с отдельными publish осями вроде package + metadata. +- Closure callbacks используют delegate-first; для вложенных closure удобнее + явный параметр (`publication -> ...`, `slotPublication -> ...`).