# HG changeset patch # User cin # Date 2026-02-24 20:59:55 # Node ID 2ed527593ad49eac29569b4c9fa45f4c69374c49 # Parent 0045f97cbd4fc46370df9c387d0089b60e580689 implemented variants model, variants-sources adapter diff --git a/common/build.gradle b/common/build.gradle --- a/common/build.gradle +++ b/common/build.gradle @@ -16,6 +16,10 @@ dependencies { api gradleApi(), libs.bundles.jackson + + testImplementation gradleTestKit() + testImplementation "org.junit.jupiter:junit-jupiter-api:5.11.4" + testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.11.4" } task printVersion{ @@ -25,6 +29,10 @@ task printVersion{ } } +test { + useJUnitPlatform() +} + publishing { repositories { ivy { @@ -39,4 +47,4 @@ publishing { } } } -} \ No newline at end of file +} diff --git a/common/readme.md b/common/readme.md new file mode 100644 --- /dev/null +++ b/common/readme.md @@ -0,0 +1,135 @@ +# Gradle Common Sources Model + +## NAME + +`gradle-common/common` — набор плагинов для моделирования вариантов сборки, +материализации source sets и интеграции этой модели с toolchain-адаптерами. + +## SYNOPSIS + +```groovy +plugins { + id 'org.implab.gradle-variants-sources' +} + +variants { + layer('mainBase') + layer('mainAmd') + + variant('browser') { + role('main') { layers('mainBase', 'mainAmd') } + link('mainBase', 'mainAmd', 'ts:api') + } +} + +variantSources { + bind('mainBase') { + configureSourceSet { + declareOutputs('compiled') + } + } + + bind('mainAmd').sourceSetNamePattern = '{variant}{layerCap}' + + whenRegistered { sourceSetName() } + + whenBound { ctx -> + ctx.configureSourceSet { + declareOutputs('typings') + } + } +} +``` + +## DESCRIPTION + +Модуль состоит из трех логических частей: + +- `variants` — декларативная доменная модель сборки; +- `sources` — модель физически материализуемых source sets; +- `variantSources` — адаптер, который связывает первые две модели. + +Ниже раскрытие каждой части. + +### variants + +`variants` задает структуру пространства сборки: какие есть слои, какие роли +используют эти слои в каждом варианте, какие направленные связи между слоями +существуют. Модель не создает задачи и не привязана к TS/JS. + +Практический смысл: + +- формализовать архитектуру сборки; +- централизовать валидацию связей; +- дать адаптерам единый источник правды. + +### sources + +`sources` описывает независимые source sets (`GenericSourceSet`) с именованными +outputs. Это уже "физический" уровень, к которому удобно привязывать задачи, +артефакты и task inputs/outputs. + +Практический смысл: + +- создать единый контракт по входам/выходам; +- регистрировать результаты задач как outputs source set; +- минимизировать ручные `dependsOn` за счет модели outputs. + +### variantSources + +`variantSources` материализует source sets на основе `variants`, применяет +конфигурацию layer-bindings и отдает события (`whenRegistered`, `whenBound`) для +адаптеров других плагинов. + +Практический смысл: + +- переводить логическую модель `variants` в executable-модель `sources`; +- навешивать политики toolchain на materialized source sets; +- синхронизировать плагины через replayable callback-контракт. + +## DOMAIN MODEL + +- `BuildLayer` — глобальный идентификатор слоя. +- `BuildVariant` — агрегат ролей, связей, атрибутов, артефактных слотов. +- `BuildRole` — роль внутри варианта, содержит ссылки на layer names. +- `BuildLink` — ориентированная связь `from -> to` в графе определенного `kind`. +- `GenericSourceSet` — materialized набор исходников и outputs. +- `BuildLayerBinding` — правила materialization source set для конкретного layer. +- `SourceSetContext` — контекст callback-событий materialization. + +## EVENT CONTRACT + +- `whenRegistered`: + - событие нового уникального source set name; + - replayable. +- `whenBound`: + - событие каждой usage-связки `variant/role/layer`; + - replayable. + +Closure callbacks работают в delegate-first режиме (`@DelegatesTo`). Для +вложенных closure рекомендуется явный параметр (`ctx -> ...`). + +## KEY CLASSES + +- `SourcesPlugin` — регистрирует extension `sources`. +- `GenericSourceSet` — модель источников/outputs для конкретного имени. +- `VariantsPlugin` — регистрирует extension `variants` и lifecycle finalize. +- `BuildVariantsExtension` — корневой API модели вариантов. +- `BuildVariant` — API ролей, links, attributes и artifact slots варианта. +- `VariantsSourcesPlugin` — применяет `variants` + `sources` и запускает адаптер. +- `VariantSourcesExtension` — API bind/events materialization. +- `BuildLayerBinding` — слой-конкретный DSL для имени и конфигурации source set. +- `SourceSetContext` — payload событий и sugar `configureSourceSet(...)`. + +## NOTES + +- Marker ids: + - `org.implab.gradle-variants` + - `org.implab.gradle-variants-sources` +- `SourcesPlugin` пока class-only (без marker id). + +## SEE ALSO + +- `sources-plugin.md` +- `variants-plugin.md` +- `variant-sources-plugin.md` diff --git a/common/sources-plugin.md b/common/sources-plugin.md new file mode 100644 --- /dev/null +++ b/common/sources-plugin.md @@ -0,0 +1,83 @@ +# Sources Plugin + +## NAME + +`SourcesPlugin` и extension `sources`. + +## SYNOPSIS + +```groovy +// Обычно подключается транзитивно через org.implab.gradle-variants-sources + +sources { + register('main') { + declareOutputs('compiled', 'typings') + + sets { + ts { srcDir 'src/main/ts' } + js { srcDir 'src/main/js' } + } + } +} +``` + +## DESCRIPTION + +`SourcesPlugin` регистрирует extension `sources` типа +`NamedDomainObjectContainer`. + +`GenericSourceSet` — это автономный source bundle с четким контрактом outputs. + +### sourceSetDir + +Базовый каталог набора. Конвенция по умолчанию: `src/`. + +### outputsDir + +Базовый каталог результатов набора. Конвенция по умолчанию: `build/`. + +### sets + +Контейнер `SourceDirectorySet` внутри `GenericSourceSet`. Используется для +логического разделения подпапок (например `ts`, `js`, `typings`). + +### outputs contract + +Outputs именованные и должны быть объявлены заранее: + +- `declareOutputs(...)` — декларация доступных output keys; +- `output(name)` — доступ к `ConfigurableFileCollection` для output key; +- `registerOutput(...)` — регистрация output из файлов или task provider. + +Такой контракт упрощает wiring задач через inputs/outputs без ручного +`dependsOn`. + +## API + +### SourcesPlugin + +- `apply(Project)` — добавляет extension `sources` в проект. +- `getSourcesExtension(Project)` — возвращает контейнер `GenericSourceSet`. + +### GenericSourceSet + +- `getSourceSetDir()` — root directory источников набора. +- `getOutputsDir()` — root directory результатов набора. +- `getSets()` — контейнер поднаборов `SourceDirectorySet`. +- `declareOutputs(...)` — объявляет разрешенные output names. +- `output(name)` — возвращает `FileCollection` для конкретного output. +- `registerOutput(name, files...)` — добавляет файлы в output. +- `registerOutput(name, task, mapper)` — связывает output с task provider. +- `getAllOutputs()` — агрегированный `FileCollection` всех outputs. +- `getAllSourceDirectories()` — агрегированный `FileCollection` всех source dirs. + +## KEY CLASSES + +- `SourcesPlugin` — регистрация extension `sources`. +- `GenericSourceSet` — модель источников и outputs. + +## NOTES + +- Обращение к `output(name)` без предварительного `declareOutputs(name)` + приводит к ошибке валидации. +- Плагин `sources` сейчас без marker id. 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildArtifactSlot.java @@ -0,0 +1,22 @@ +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/BuildLayer.java b/common/src/main/java/org/implab/gradle/common/sources/BuildLayer.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildLayer.java @@ -0,0 +1,22 @@ +package org.implab.gradle.common.sources; + +import javax.inject.Inject; + +import org.gradle.api.Named; + +/** + * Global layer declaration used by build variants. + */ +public abstract class BuildLayer implements Named { + private final String name; + + @Inject + public BuildLayer(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/BuildLayerBinding.java b/common/src/main/java/org/implab/gradle/common/sources/BuildLayerBinding.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildLayerBinding.java @@ -0,0 +1,120 @@ +package org.implab.gradle.common.sources; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import org.implab.gradle.common.core.lang.Closures; +import org.gradle.api.Action; +import org.gradle.api.Named; +import org.gradle.api.NamedDomainObjectProvider; +import org.gradle.api.provider.Property; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +/** + * Maps a logical layer to per-source-set hooks. + */ +public abstract class BuildLayerBinding implements Named { + public static final String DEFAULT_SOURCE_SET_NAME_PATTERN = "{variant}{layerCap}"; + + private final String name; + + private final List> sourceSetConfigureActions = new ArrayList<>(); + private final List> registeredActions = new ArrayList<>(); + private final List> boundActions = new ArrayList<>(); + private final List> registeredSourceSets = new ArrayList<>(); + private final List registeredContexts = new ArrayList<>(); + private final List boundContexts = new ArrayList<>(); + private final Set registeredSourceSetNames = new LinkedHashSet<>(); + + @Inject + public BuildLayerBinding(String name) { + this.name = name; + getSourceSetNamePattern().convention(DEFAULT_SOURCE_SET_NAME_PATTERN); + } + + @Override + public String getName() { + return name; + } + + public abstract Property getSourceSetNamePattern(); + + /** + * Action applied to every registered source set for this layer. + * Already registered source sets are configured immediately (replay). + */ + public void configureSourceSet(Action configure) { + sourceSetConfigureActions.add(configure); + for (var sourceSet : registeredSourceSets) + sourceSet.configure(configure); + } + + public void configureSourceSet( + @DelegatesTo(value = GenericSourceSet.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + configureSourceSet(Closures.action(configure)); + } + + /** + * Layer-local callback fired after source-set registration. + * Already emitted contexts are delivered immediately (replay). + * For simple callbacks you can use delegate-only style + * (for example {@code whenRegistered { sourceSetName() }}). + * For nested closures prefer explicit parameter + * ({@code whenRegistered { ctx -> ... }}). + */ + public void whenRegistered(Action action) { + registeredActions.add(action); + for (var context : registeredContexts) + action.execute(context); + } + + public void whenRegistered( + @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenRegistered(Closures.action(action)); + } + + /** + * Layer-local callback fired for every resolved variant/role/layer usage. + * Already emitted contexts are delivered immediately (replay). + * For simple callbacks you can use delegate-only style + * (for example {@code whenBound { variantName() }}). + * For nested closures prefer explicit parameter + * ({@code whenBound { ctx -> ... }}). + */ + public void whenBound(Action action) { + boundActions.add(action); + for (var context : boundContexts) + action.execute(context); + } + + public void whenBound( + @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenBound(Closures.action(action)); + } + + void notifyRegistered(SourceSetContext context) { + if (registeredSourceSetNames.add(context.sourceSetName())) { + var sourceSet = context.sourceSet(); + registeredSourceSets.add(sourceSet); + + for (var action : sourceSetConfigureActions) + sourceSet.configure(action); + } + + registeredContexts.add(context); + for (var action : registeredActions) + action.execute(context); + } + + void notifyBound(SourceSetContext context) { + boundContexts.add(context); + for (var action : boundActions) + action.execute(context); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/BuildLink.java b/common/src/main/java/org/implab/gradle/common/sources/BuildLink.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildLink.java @@ -0,0 +1,41 @@ +package org.implab.gradle.common.sources; + +import javax.inject.Inject; + +import org.gradle.api.Named; +import org.gradle.api.provider.Property; + +/** + * Directed relation between two layers within a variant. + */ +public abstract class BuildLink implements Named { + private final String name; + + @Inject + public BuildLink(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + public abstract Property getFrom(); + + public abstract Property getTo(); + + public abstract Property getKind(); + + public void from(String value) { + getFrom().set(value); + } + + public void to(String value) { + getTo().set(value); + } + + public void kind(String value) { + getKind().set(value); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/BuildRole.java b/common/src/main/java/org/implab/gradle/common/sources/BuildRole.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/BuildRole.java @@ -0,0 +1,41 @@ +package org.implab.gradle.common.sources; + +import java.util.ArrayList; +import java.util.Objects; + +import javax.inject.Inject; + +import org.gradle.api.Named; +import org.gradle.api.provider.ListProperty; + +/** + * Role binding inside a variant, points to layer names. + */ +public abstract class BuildRole implements Named { + private final String name; + + @Inject + public BuildRole(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + public abstract ListProperty getLayers(); + + /** + * Binds this role to one or more declared layers. + */ + public void layers(String layer, String... extra) { + var values = new ArrayList(1 + extra.length); + + values.add(Objects.requireNonNull(layer, "Layer name is required")); + for (var item : extra) + values.add(Objects.requireNonNull(item, "Layer name is required")); + + getLayers().addAll(values); + } +} 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 @@ -1,29 +1,315 @@ package org.implab.gradle.common.sources; -import java.util.HashMap; -import java.util.Map; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Pattern; +import javax.inject.Inject; + +import org.implab.gradle.common.core.lang.Closures; import org.gradle.api.Action; import org.gradle.api.Named; -import org.gradle.api.NamedDomainObjectProvider; +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; + +public abstract class BuildVariant implements Named { + private static final Pattern INVALID_NAME_CHAR = Pattern.compile("[^A-Za-z0-9_.-]"); + + private final String name; + private final ObjectFactory objects; + + /** + * Variant aggregate parts. + */ + private final VariantAttributes attributes; + private final LinkedHashMap roles = new LinkedHashMap<>(); + private final LinkedHashMap links = new LinkedHashMap<>(); + private final LinkedHashMap artifactSlots = new LinkedHashMap<>(); + + @Inject + public BuildVariant(String name, ObjectFactory objects, ProviderFactory providers) { + this.name = name; + this.objects = objects; + attributes = new VariantAttributes(providers); + } + + @Override + public String getName() { + return name; + } -import org.gradle.api.artifacts.ConfigurationPublications; -import org.gradle.api.plugins.ExtensionAware; + /** + * Generic variant attributes interpreted by adapters. + */ + public VariantAttributes getAttributes() { + return attributes; + } + + public void attributes(Action action) { + action.execute(new AttributesSpec(attributes)); + } + + public void attributes(Closure configure) { + attributes(Closures.action(configure)); + } -public abstract class BuildVariant implements Named, ExtensionAware { + public void attribute(Attribute key, T value) { + attributes.attribute(key, value); + } + + public void attributeProvider(Attribute key, Provider value) { + attributes.attributeProvider(key, value); + } + + public Collection getRoles() { + return Collections.unmodifiableCollection(roles.values()); + } + + public void roles(Action action) { + action.execute(new RolesSpec()); + } + + public void roles(Closure configure) { + roles(Closures.action(configure)); + } - private final Map variantSourceSets = new HashMap<>(); + public BuildRole role(String name, Action configure) { + var role = roles.computeIfAbsent(name, this::newRole); + configure.execute(role); + return role; + } + + public BuildRole role(String name, Closure configure) { + return role(name, Closures.action(configure)); + } + + public BuildRole role(String name) { + return role(name, r -> { + }); + } + + public BuildRole getRoleByName(String name) { + return roles.get(name); + } + + public Collection getLinks() { + return Collections.unmodifiableCollection(links.values()); + } + + public void links(Action action) { + action.execute(new LinksSpec()); + } + + public void links(Closure configure) { + links(Closures.action(configure)); + } - private final SourceSetsSpec sourceSetsSpec = new SourceSetsSpec(); + public BuildLink link(String from, String to, String kind, Action configure) { + return link(defaultLinkName(from, to, kind), link -> { + link.from(from); + link.to(to); + link.kind(kind); + configure.execute(link); + }); + } + + public BuildLink link(String from, String to, String kind, Closure configure) { + return link(from, to, kind, Closures.action(configure)); + } + + public BuildLink link(String from, String to, String kind) { + return link(from, to, kind, it -> { + }); + } + + public BuildLink link(String name, Action configure) { + var link = links.computeIfAbsent(name, this::newLink); + configure.execute(link); + return link; + } - public void sourceSets(Action action) { - action.execute(sourceSetsSpec); + public BuildLink link(String name, Closure configure) { + return link(name, Closures.action(configure)); + } + + public BuildLink getLinkByName(String name) { + return links.get(name); + } + + public Collection getArtifactSlots() { + return Collections.unmodifiableCollection(artifactSlots.values()); + } + + public void artifactSlots(Action action) { + action.execute(new ArtifactSlotsSpec()); + } + + public void artifactSlots(Closure configure) { + artifactSlots(Closures.action(configure)); + } + + public BuildArtifactSlot artifactSlot(String name) { + return artifactSlot(name, it -> { + }); } - public class SourceSetsSpec { - void add(NamedDomainObjectProvider sourceSet) { - var variantSourceSet = new VariantSourceSet(sourceSet); - variantSourceSets.put(sourceSet.getName(), variantSourceSet); + public BuildArtifactSlot artifactSlot(String name, Action configure) { + 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 BuildArtifactSlot getArtifactSlotByName(String name) { + return artifactSlots.get(name); + } + + Set declaredLayerNames() { + var result = new LinkedHashSet(); + + for (var role : roles.values()) + result.addAll(role.getLayers().getOrElse(java.util.List.of())); + + return result; + } + + private BuildRole newRole(String roleName) { + return objects.newInstance(BuildRole.class, roleName); + } + + private BuildLink newLink(String linkName) { + return objects.newInstance(BuildLink.class, linkName); + } + + private BuildArtifactSlot newArtifactSlot(String slotName) { + return objects.newInstance(BuildArtifactSlot.class, slotName); + } + + private static String defaultLinkName(String from, String to, String kind) { + return "link_" + sanitize(from) + "__" + sanitize(to) + "__" + sanitize(kind); + } + + private static String sanitize(String value) { + return INVALID_NAME_CHAR.matcher(String.valueOf(value)).replaceAll("_"); + } + + public final class RolesSpec { + public BuildRole role(String name, Action configure) { + return BuildVariant.this.role(name, configure); + } + + public BuildRole role(String name, Closure configure) { + return BuildVariant.this.role(name, configure); + } + + public BuildRole role(String name) { + return BuildVariant.this.role(name); + } + + public Collection getAll() { + return BuildVariant.this.getRoles(); + } + + public BuildRole getByName(String name) { + return BuildVariant.this.getRoleByName(name); + } + } + + public final class LinksSpec { + public BuildLink link(String from, String to, String kind, Action configure) { + return BuildVariant.this.link(from, to, kind, configure); + } + + public BuildLink link(String from, String to, String kind, Closure configure) { + return BuildVariant.this.link(from, to, kind, configure); + } + + public BuildLink link(String from, String to, String kind) { + return BuildVariant.this.link(from, to, kind); + } + + public Collection getAll() { + return BuildVariant.this.getLinks(); + } + + public BuildLink getByName(String name) { + return BuildVariant.this.getLinkByName(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 BuildArtifactSlot getByName(String name) { + return BuildVariant.this.getArtifactSlotByName(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 @@ -4,48 +4,268 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import javax.inject.Inject; +import org.implab.gradle.common.core.lang.Closures; import org.gradle.api.Action; +import org.gradle.api.InvalidUserDataException; +import org.gradle.api.NamedDomainObjectContainer; import org.gradle.api.model.ObjectFactory; +import groovy.lang.Closure; + public abstract class BuildVariantsExtension { - private final ObjectFactory objects; - private final Map variants = new HashMap<>(); - private final List> listeners = new ArrayList<>(); + private final NamedDomainObjectContainer layers; + private final NamedDomainObjectContainer variants; + private final List> finalizedActions = new ArrayList<>(); + private boolean finalized; @Inject public BuildVariantsExtension(ObjectFactory objects) { - this.objects = objects; + layers = objects.domainObjectContainer(BuildLayer.class); + variants = objects.domainObjectContainer(BuildVariant.class); + } + + public NamedDomainObjectContainer getLayers() { + return layers; + } + + public NamedDomainObjectContainer getVariants() { + return variants; + } + + public void layers(Action> action) { + action.execute(layers); + } + + public void layers(Closure configure) { + layers(Closures.action(configure)); } - private BuildVariant newVariant(String name) { - return objects.newInstance(BuildVariant.class, name); + public void variants(Action> action) { + action.execute(variants); + } + + public void variants(Closure configure) { + variants(Closures.action(configure)); + } + + public BuildLayer layer(String name, Action configure) { + var layer = layers.maybeCreate(name); + configure.execute(layer); + return layer; + } + + public BuildLayer layer(String name, Closure configure) { + return layer(name, Closures.action(configure)); + } + + public BuildLayer layer(String name) { + return layer(name, it -> { + }); } public BuildVariant variant(String name, Action configure) { - BuildVariant v = variants.computeIfAbsent(name, this::newVariant); - configure.execute(v); - for (Action l : listeners) { - l.execute(v); - } - return v; + var variant = variants.maybeCreate(name); + configure.execute(variant); + return variant; + } + + public BuildVariant variant(String name, Closure configure) { + return variant(name, Closures.action(configure)); + } + + public BuildVariant variant(String name) { + return variant(name, it -> { + }); } public void all(Action action) { - variants.values().forEach(action::execute); - listeners.add(action); + variants.all(action); + } + + public void all(Closure configure) { + all(Closures.action(configure)); } public Collection getAll() { - return Collections.unmodifiableCollection(variants.values()); + var all = new ArrayList(); + variants.forEach(all::add); + return Collections.unmodifiableList(all); } public BuildVariant getByName(String name) { - return variants.get(name); + return variants.findByName(name); + } + + public void whenFinalized(Action action) { + if (finalized) { + action.execute(this); + return; + } + finalizedActions.add(action); + } + + public void whenFinalized(Closure configure) { + whenFinalized(Closures.action(configure)); + } + + public boolean isFinalized() { + return finalized; + } + + public void finalizeModel() { + if (finalized) + return; + + validate(); + finalized = true; + + var actions = new ArrayList<>(finalizedActions); + finalizedActions.clear(); + for (var action : actions) + action.execute(this); + } + + public void validate() { + var errors = new ArrayList(); + + var layersByName = new LinkedHashMap(); + for (var layer : layers) + layersByName.put(layer.getName(), layer); + + for (var variant : variants) + validateVariant(variant, layersByName, errors); + + if (!errors.isEmpty()) { + var message = new StringBuilder("Invalid variants model:"); + for (var error : errors) + message.append("\n - ").append(error); + + throw new InvalidUserDataException(message.toString()); + } + } + + private static void validateVariant(BuildVariant variant, Map layersByName, List errors) { + var variantLayers = validateRoleMappings(variant, layersByName, errors); + validateLinks(variant, variantLayers, errors); + } + + private static Set validateRoleMappings(BuildVariant variant, Map layersByName, + List errors) { + var variantLayers = new LinkedHashSet(); + + for (var role : variant.getRoles()) { + for (var layerName : role.getLayers().getOrElse(List.of())) { + if (isBlank(layerName)) { + errors.add("Variant '" + variant.getName() + "', role '" + role.getName() + "' contains blank layer name"); + continue; + } + + var layer = layersByName.get(layerName); + if (layer == null) { + errors.add("Variant '" + variant.getName() + "' references unknown layer '" + layerName + "'"); + continue; + } + + variantLayers.add(layerName); + } + } + + return variantLayers; } + private static void validateLinks(BuildVariant variant, Set variantLayers, List errors) { + var seenLinks = new HashSet(); + var edgesByKind = new HashMap>>(); + + for (var link : variant.getLinks()) { + var from = normalize(link.getFrom().getOrNull()); + var to = normalize(link.getTo().getOrNull()); + var kind = normalize(link.getKind().getOrNull()); + + if (from == null || to == null || kind == null) { + errors.add("Variant '" + variant.getName() + "' has incomplete link '" + link.getName() + + "' (from/to/kind are required)"); + continue; + } + + if (!variantLayers.contains(from)) { + errors.add("Variant '" + variant.getName() + "' link '" + link.getName() + "' references unknown source layer '" + + from + "'"); + continue; + } + + if (!variantLayers.contains(to)) { + errors.add("Variant '" + variant.getName() + "' link '" + link.getName() + "' references unknown target layer '" + + to + "'"); + continue; + } + + var linkKey = from + "\u0000" + to + "\u0000" + kind; + if (!seenLinks.add(linkKey)) { + errors.add("Variant '" + variant.getName() + "' has duplicated link tuple (from='" + from + + "', to='" + to + "', kind='" + kind + "')"); + } + + edgesByKind + .computeIfAbsent(kind, x -> new LinkedHashMap<>()) + .computeIfAbsent(from, x -> new LinkedHashSet<>()) + .add(to); + } + + for (var entry : edgesByKind.entrySet()) { + if (hasCycle(variantLayers, entry.getValue())) { + errors.add("Variant '" + variant.getName() + "' contains cycle in links with kind '" + entry.getKey() + "'"); + } + } + } + + private static boolean hasCycle(Set nodes, Map> edges) { + var state = new HashMap(); + + for (var node : nodes) { + if (dfs(node, state, edges)) + return true; + } + + return false; + } + + private static boolean dfs(String node, Map state, Map> edges) { + var current = state.getOrDefault(node, 0); + if (current == 1) + return true; + if (current == 2) + return false; + + state.put(node, 1); + + for (var next : edges.getOrDefault(node, Set.of())) { + if (dfs(next, state, edges)) + return true; + } + + state.put(node, 2); + return false; + } + + private static String normalize(String value) { + if (value == null) + return null; + + var trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private static boolean isBlank(String value) { + return normalize(value) == null; + } } 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 @@ -31,7 +31,7 @@ import org.implab.gradle.common.core.lan import groovy.lang.Closure; /** - * A configurable source set abstraction with named output roles. + * A configurable source set abstraction with named outputs. * *

* Each instance aggregates multiple {@link SourceDirectorySet source sets} @@ -42,10 +42,10 @@ import groovy.lang.Closure; *

* *

- * Outputs are grouped by roles to make task wiring explicit. A role must be - * declared with {@link #declareRoles(String, String...)} (or the synonym - * {@link #declareOutputs(String, String...)}) before files can be registered - * against it. Attempting to register or retrieve an undeclared role results in + * Outputs are grouped by names to make task wiring explicit. An output must be + * declared with {@link #declareOutputs(String, String...)} before files can be + * registered against it. Attempting to register or retrieve an undeclared + * output results in * {@link InvalidUserDataException}. *

*/ @@ -63,7 +63,7 @@ public abstract class GenericSourceSet private final ObjectFactory objects; - private final Set declaredRoles = new HashSet<>(); + private final Set declaredOutputs = new HashSet<>(); @Inject public GenericSourceSet(String name, ObjectFactory objects, ProjectLayout layout) { @@ -115,7 +115,7 @@ public abstract class GenericSourceSet } /** - * All registered outputs grouped across roles. + * All registered outputs grouped across output names. */ public FileCollection getAllOutputs() { return allOutputs; @@ -129,35 +129,28 @@ public abstract class GenericSourceSet } /** - * Returns the file collection for the specified output role, creating it + * Returns the file collection for the specified output name, creating it * if necessary. * - * @throws InvalidUserDataException if the role was not declared + * @throws InvalidUserDataException if the output was not declared */ public ConfigurableFileCollection output(String name) { - requireDeclaredRole(name); + requireDeclaredOutput(name); return outputs.computeIfAbsent(name, key -> objects.fileCollection()); } /** - * Declares allowed output roles. Roles must be declared before registering + * Declares allowed output names. Outputs must be declared before registering * files under them. */ - public void declareRoles(String name, String... extra) { - declaredRoles.add(Objects.requireNonNull(name, "declareRoles: The output name cannot be null")); + public void declareOutputs(String name, String... extra) { + declaredOutputs.add(Objects.requireNonNull(name, "declareOutputs: The output name cannot be null")); for (var x : extra) - declaredRoles.add(Objects.requireNonNull(x, "declareRoles: The output name cannot be null")); + declaredOutputs.add(Objects.requireNonNull(x, "declareOutputs: The output name cannot be null")); } /** - * Alias for {@link #declareRoles(String, String...)} kept for DSL clarity. - */ - public void declareOutputs(String name, String... extra) { - declareRoles(name, extra); - } - - /** - * Registers files produced elsewhere under the given role. + * Registers files produced elsewhere under the given output. */ public void registerOutput(String name, Object... files) { output(name).from(files); @@ -188,10 +181,10 @@ public abstract class GenericSourceSet return objects.sourceDirectorySet(name, name); } - private void requireDeclaredRole(String roleName) { - if (!declaredRoles.contains(roleName)) { + private void requireDeclaredOutput(String outputName) { + if (!declaredOutputs.contains(outputName)) { throw new InvalidUserDataException( - "Output role '" + roleName + "' is not declared for source set '" + name + "'"); + "Output '" + outputName + "' is not declared for source set '" + name + "'"); } } diff --git a/common/src/main/java/org/implab/gradle/common/sources/SourceSetContext.java b/common/src/main/java/org/implab/gradle/common/sources/SourceSetContext.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/SourceSetContext.java @@ -0,0 +1,41 @@ +package org.implab.gradle.common.sources; + +import org.implab.gradle.common.core.lang.Closures; +import org.gradle.api.Action; +import org.gradle.api.NamedDomainObjectProvider; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +/** + * Immutable context for a {@link GenericSourceSet} registered for a resolved + * variant/layer pair. + * + *

Used as callback payload when source sets are registered or bound in + * {@link VariantSourcesExtension} and then dispatched via + * {@link VariantSourcesExtension#whenRegistered(org.gradle.api.Action)}, + * {@link VariantSourcesExtension#whenBound(org.gradle.api.Action)}, + * {@link BuildLayerBinding#whenRegistered(org.gradle.api.Action)} and + * {@link BuildLayerBinding#whenBound(org.gradle.api.Action)}. + * + * @param variantName variant name from the build-variants model + * @param roleName role name inside the resolved variant + * @param layerName normalized layer name used to register the source set + * @param sourceSetName source-set name registered in the container + * @param sourceSet provider of the registered source set (realized later by Gradle on demand) + */ +public record SourceSetContext( + String variantName, + String roleName, + String layerName, + String sourceSetName, + NamedDomainObjectProvider sourceSet) { + public void configureSourceSet(Action action) { + sourceSet.configure(action); + } + + public void configureSourceSet( + @DelegatesTo(value = GenericSourceSet.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + configureSourceSet(Closures.action(action)); + } +} 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantAttributes.java @@ -0,0 +1,46 @@ +package org.implab.gradle.common.sources; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +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<>(); + + VariantAttributes(ProviderFactory providers) { + this.providers = providers; + } + + public void attribute(Attribute key, T value) { + attributeProvider(key, providers.provider(() -> value)); + } + + public void attributeProvider(Attribute key, Provider value) { + values.put(key, value); + } + + @SuppressWarnings("unchecked") + public Provider get(Attribute key) { + return (Provider) values.get(key); + } + + public boolean contains(Attribute key) { + return values.containsKey(key); + } + + public int size() { + return values.size(); + } + + public Map, Provider> asMap() { + return Collections.unmodifiableMap(values); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantSourceSet.java b/common/src/main/java/org/implab/gradle/common/sources/VariantSourceSet.java deleted file mode 100644 --- a/common/src/main/java/org/implab/gradle/common/sources/VariantSourceSet.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.implab.gradle.common.sources; - -import org.gradle.api.NamedDomainObjectProvider; - -public class VariantSourceSet { - private final NamedDomainObjectProvider sourceSet; - - String getName() { - return sourceSet.getName(); - } - - public VariantSourceSet(NamedDomainObjectProvider sourceSet) { - this.sourceSet = sourceSet; - } - - public NamedDomainObjectProvider getSourceSet() { - return sourceSet; - } -} 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantSourcesExtension.java @@ -0,0 +1,283 @@ +package org.implab.gradle.common.sources; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import javax.inject.Inject; + +import org.implab.gradle.common.core.lang.Closures; +import org.implab.gradle.common.core.lang.Strings; +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.NamedDomainObjectProvider; +import org.gradle.api.model.ObjectFactory; + +import groovy.lang.Closure; +import groovy.lang.DelegatesTo; + +/** + * Adapter extension that materializes source sets for variant/layer pairs. + */ +@NonNullByDefault +public abstract class VariantSourcesExtension { + private static final Pattern INVALID_NAME_CHAR = Pattern.compile("[^A-Za-z0-9_.-]"); + private static final Pattern SOURCE_SET_NAME_TOKEN = Pattern.compile("\\{([A-Za-z][A-Za-z0-9]*)\\}"); + + private final ObjectFactory objects; + private final NamedDomainObjectContainer bindings; + private final List> registeredActions = new ArrayList<>(); + private final List> boundActions = new ArrayList<>(); + private final List registeredContexts = new ArrayList<>(); + private final List boundContexts = new ArrayList<>(); + private final LinkedHashMap> sourceSetsByName = new LinkedHashMap<>(); + private final LinkedHashMap sourceSetLayersByName = new LinkedHashMap<>(); + + @Inject + public VariantSourcesExtension(ObjectFactory objects) { + this.objects = objects; + bindings = objects.domainObjectContainer(BuildLayerBinding.class); + } + + public NamedDomainObjectContainer getBindings() { + return bindings; + } + + public void bindings(Action> action) { + action.execute(bindings); + } + + public void bindings( + @DelegatesTo(value = NamedDomainObjectContainer.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + bindings(Closures.action(action)); + } + + public BuildLayerBinding bind(String layer) { + return bindings.maybeCreate(normalize(layer)); + } + + /** + * Configures per-layer binding. + */ + public BuildLayerBinding bind(String layer, Action configure) { + var binding = bind(layer); + configure.execute(binding); + return binding; + } + + public BuildLayerBinding bind(String layer, + @DelegatesTo(value = BuildLayerBinding.class, strategy = Closure.DELEGATE_FIRST) Closure configure) { + return bind(layer, Closures.action(configure)); + } + + /** + * Global callback fired for each registered source-set context. + * Already emitted contexts are delivered immediately (replay). + * For simple callbacks you can use delegate-only style + * (for example {@code whenRegistered { sourceSetName() }}). + * For nested closures prefer explicit parameter + * ({@code whenRegistered { ctx -> ... }}). + */ + public void whenRegistered(Action action) { + registeredActions.add(action); + for (var context : registeredContexts) + action.execute(context); + } + + public void whenRegistered( + @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenRegistered(Closures.action(action)); + } + + public void whenRegistered(String variantName, Action action) { + var normalizedVariantName = normalize(variantName, "variantName must not be null or blank"); + whenRegistered(filterByVariant(normalizedVariantName, action)); + } + + public void whenRegistered(String variantName, + @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenRegistered(variantName, Closures.action(action)); + } + + /** + * Global callback fired for every resolved variant/role/layer usage. + * Already emitted contexts are delivered immediately (replay). + * For simple callbacks you can use delegate-only style + * (for example {@code whenBound { variantName() }}). + * For nested closures prefer explicit parameter + * ({@code whenBound { ctx -> ... }}). + */ + public void whenBound(Action action) { + boundActions.add(action); + for (var context : boundContexts) + action.execute(context); + } + + public void whenBound( + @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenBound(Closures.action(action)); + } + + public void whenBound(String variantName, Action action) { + var normalizedVariantName = normalize(variantName, "variantName must not be null or blank"); + whenBound(filterByVariant(normalizedVariantName, action)); + } + + public void whenBound(String variantName, + @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure action) { + whenBound(variantName, Closures.action(action)); + } + + void registerSourceSets(BuildVariantsExtension variants, NamedDomainObjectContainer sources) { + validateBindings(variants); + layerUsages(variants).forEach(usage -> materializeLayerUsage(usage, sources)); + } + + private Stream layerUsages(BuildVariantsExtension variants) { + return variants.getVariants().stream() + .flatMap(variant -> variant.getRoles().stream() + .flatMap(role -> role.getLayers().getOrElse(List.of()).stream() + .map(layerName -> new LayerUsage( + variant.getName(), + role.getName(), + normalize(layerName))))); + } + + private void materializeLayerUsage(LayerUsage usage, NamedDomainObjectContainer sources) { + var resolvedBinding = bind(usage.layerName()); + var sourceSetNamePattern = resolvedBinding.getSourceSetNamePattern(); + sourceSetNamePattern.finalizeValueOnRead(); + + var sourceSetName = sourceSetName(usage, sourceSetNamePattern.get()); + + ensureSourceSetNameBoundToSingleLayer(sourceSetName, usage.layerName()); + var isNewSourceSet = !sourceSetsByName.containsKey(sourceSetName); + var sourceSet = sourceSetsByName.computeIfAbsent(sourceSetName, + name -> sources.register(name)); + + var context = new SourceSetContext( + usage.variantName(), + usage.roleName(), + usage.layerName(), + sourceSetName, + sourceSet); + + if (isNewSourceSet) { + resolvedBinding.notifyRegistered(context); + notifyRegistered(context); + } + + resolvedBinding.notifyBound(context); + notifyBound(context); + } + + private void notifyRegistered(SourceSetContext context) { + registeredContexts.add(context); + for (var action : registeredActions) + action.execute(context); + } + + private void notifyBound(SourceSetContext context) { + boundContexts.add(context); + for (var action : boundActions) + action.execute(context); + } + + private static Action filterByVariant(String variantName, + Action action) { + return context -> { + if (variantName.equals(context.variantName())) + action.execute(context); + }; + } + + private void ensureSourceSetNameBoundToSingleLayer(String sourceSetName, String layerName) { + var existingLayer = sourceSetLayersByName.putIfAbsent(sourceSetName, layerName); + if (existingLayer != null && !existingLayer.equals(layerName)) { + throw new InvalidUserDataException("Source set '" + sourceSetName + "' is resolved from multiple layers: '" + + existingLayer + "' and '" + layerName + "'"); + } + } + + private void validateBindings(BuildVariantsExtension variants) { + var knownLayerNames = new java.util.LinkedHashSet(); + for (var layer : variants.getLayers()) + knownLayerNames.add(layer.getName()); + + var errors = new ArrayList(); + for (var binding : bindings) { + if (!knownLayerNames.contains(binding.getName())) { + errors.add("Layer binding '" + binding.getName() + "' references unknown layer"); + } + } + + if (!errors.isEmpty()) { + var message = new StringBuilder("Invalid variantSources model:"); + for (var error : errors) + message.append("\n - ").append(error); + throw new InvalidUserDataException(message.toString()); + } + } + + private static String sourceSetName(LayerUsage usage, String pattern) { + var normalizedPattern = normalize(pattern, "sourceSetNamePattern must not be null or blank"); + var resolved = resolveSourceSetNamePattern(normalizedPattern, usage); + var result = sanitize(resolved); + + if (result.isEmpty()) + throw new InvalidUserDataException("sourceSetNamePattern '" + pattern + "' resolved to empty source set name"); + + return result; + } + + private static String resolveSourceSetNamePattern(String pattern, LayerUsage usage) { + var matcher = SOURCE_SET_NAME_TOKEN.matcher(pattern); + var output = new StringBuffer(); + + while (matcher.find()) { + var token = matcher.group(1); + matcher.appendReplacement(output, Matcher.quoteReplacement(tokenValue(token, usage))); + } + matcher.appendTail(output); + + return output.toString(); + } + + private static String tokenValue(String token, LayerUsage usage) { + return switch (token) { + case "variant" -> sanitize(usage.variantName()); + case "variantCap" -> Strings.capitalize(sanitize(usage.variantName())); + case "role" -> sanitize(usage.roleName()); + case "roleCap" -> Strings.capitalize(sanitize(usage.roleName())); + case "layer" -> sanitize(usage.layerName()); + case "layerCap" -> Strings.capitalize(sanitize(usage.layerName())); + default -> throw new InvalidUserDataException( + "sourceSetNamePattern contains unsupported token '{" + token + "}'"); + }; + } + + private static String sanitize(String value) { + return INVALID_NAME_CHAR.matcher(value).replaceAll("_"); + } + + private static String normalize(String value) { + return normalize(value, "Value must not be null or blank"); + } + + private static String normalize(String value, String errorMessage) { + if (value == null) + throw new InvalidUserDataException(errorMessage); + var trimmed = value.trim(); + if (trimmed.isEmpty()) + throw new InvalidUserDataException(errorMessage); + return trimmed; + } + + private record LayerUsage(String variantName, String roleName, String layerName) { + } +} diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantsPlugin.java b/common/src/main/java/org/implab/gradle/common/sources/VariantsPlugin.java --- a/common/src/main/java/org/implab/gradle/common/sources/VariantsPlugin.java +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantsPlugin.java @@ -4,23 +4,24 @@ import org.gradle.api.GradleException; import org.gradle.api.Plugin; import org.gradle.api.Project; +/** + * Registers {@code variants} extension for build-variant modeling. + */ public abstract class VariantsPlugin implements Plugin { public static final String VARIANTS_EXTENSION_NAME = "variants"; @Override public void apply(Project target) { - var variants = target.getObjects().newInstance(BuildVariantsExtension.class); - target.getExtensions().add(VARIANTS_EXTENSION_NAME, variants); + var variants = target.getExtensions().create(VARIANTS_EXTENSION_NAME, BuildVariantsExtension.class); + target.afterEvaluate(project -> variants.finalizeModel()); } public static BuildVariantsExtension getVariantsExtension(Project target) { - var extensions = target.getExtensions(); - - var extension = extensions.getByType(BuildVariantsExtension.class); + var extension = target.getExtensions().findByType(BuildVariantsExtension.class); if (extension == null) throw new GradleException("Variants extension isn't found"); + return extension; } - } diff --git a/common/src/main/java/org/implab/gradle/common/sources/VariantsSourcesPlugin.java b/common/src/main/java/org/implab/gradle/common/sources/VariantsSourcesPlugin.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/VariantsSourcesPlugin.java @@ -0,0 +1,25 @@ +package org.implab.gradle.common.sources; + +import org.gradle.api.Plugin; +import org.gradle.api.Project; + +/** + * Binds variant layers to materialized source sets. + */ +public abstract class VariantsSourcesPlugin implements Plugin { + public static final String VARIANT_SOURCES_EXTENSION_NAME = "variantSources"; + + @Override + public void apply(Project target) { + target.getPluginManager().apply(VariantsPlugin.class); + target.getPluginManager().apply(SourcesPlugin.class); + + var variants = VariantsPlugin.getVariantsExtension(target); + var sources = SourcesPlugin.getSourcesExtension(target); + + var variantSources = target.getExtensions() + .create(VARIANT_SOURCES_EXTENSION_NAME, VariantSourcesExtension.class); + + variants.whenFinalized(model -> variantSources.registerSourceSets(model, sources)); + } +} 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 new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/sources/package-info.java @@ -0,0 +1,15 @@ +/** + * Source model and DSL for variants/sources integration. + * + *

Naming convention for callbacks and lifecycle hooks: + *

    + *
  • {@code whenXxx(...)}: register callback (supports replay where documented);
  • + *
  • {@code configureXxx(...)}: configure model elements;
  • + *
  • {@code notifyXxx(...)}: internal event dispatch helpers (not part of public DSL).
  • + *
+ * + *

Closure-based callbacks use delegate-first resolution via + * {@code @DelegatesTo}. Delegate-only style is suitable for simple callbacks. + * For nested closures prefer explicit callback parameters ({@code ctx -> ...}). + */ +package org.implab.gradle.common.sources; diff --git a/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-sources.properties b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-sources.properties new file mode 100644 --- /dev/null +++ b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants-sources.properties @@ -0,0 +1,1 @@ +implementation-class=org.implab.gradle.common.sources.VariantsSourcesPlugin diff --git a/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants.properties b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants.properties new file mode 100644 --- /dev/null +++ b/common/src/main/resources/META-INF/gradle-plugins/org.implab.gradle-variants.properties @@ -0,0 +1,1 @@ +implementation-class=org.implab.gradle.common.sources.VariantsPlugin 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 new file mode 100644 --- /dev/null +++ b/common/src/test/java/org/implab/gradle/common/sources/VariantsPluginFunctionalTest.java @@ -0,0 +1,290 @@ +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 VariantsPluginFunctionalTest { + 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-fixture'\n"; + + @TempDir + Path testProjectDir; + + @Test + void configuresVariantModelWithDsl() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('mainBase') { + } + + layer('mainAmd') { + } + + variant('browser') { + attributes { + string('jsRuntime', 'browser') + string('jsModule', 'amd') + } + role('main') { + layers('mainBase', 'mainAmd') + } + link('mainBase', 'mainAmd', 'ts:api') + artifactSlot('mainCompiled') + } + } + + tasks.register('probe') { + doLast { + def browser = variants.getByName('browser') + println('attributes=' + browser.attributes.size()) + println('roles=' + browser.roles.size()) + println('links=' + browser.links.size()) + println('slots=' + browser.artifactSlots.size()) + } + } + """); + + BuildResult result = runner("probe").build(); + + assertTrue(result.getOutput().contains("attributes=2")); + assertTrue(result.getOutput().contains("roles=1")); + assertTrue(result.getOutput().contains("links=1")); + assertTrue(result.getOutput().contains("slots=1")); + assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test + void failsOnUnknownLayerReference() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('mainBase') { + } + + variant('browser') { + role('main') { + layers('mainBase', 'missingLayer') + } + } + } + """, "references unknown layer 'missingLayer'"); + } + + @Test + void failsOnCycleInLinksByKind() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('a') + layer('b') + + variant('browser') { + role('main') { + layers('a', 'b') + } + link('a', 'b', 'ts:api') + link('b', 'a', 'ts:api') + } + } + """, "contains cycle in links with kind 'ts:api'"); + } + + @Test + void allowsUsingLayerFromDifferentVariantRole() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('mainBase') + + variant('browser') { + role('test') { + layers('mainBase') + } + } + } + """); + + BuildResult result = runner("help").build(); + assertTrue(result.getOutput().contains("BUILD SUCCESSFUL")); + } + + @Test + void failsOnIncompleteLink() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('a') + layer('b') + + variant('browser') { + role('main') { + layers('a', 'b') + } + link('l1') { + from('a') + to('b') + } + } + } + """, "has incomplete link 'l1'"); + } + + @Test + void failsOnDuplicatedLinkTuple() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('a') + layer('b') + + variant('browser') { + role('main') { + layers('a', 'b') + } + link('first') { + from('a') + to('b') + kind('ts:api') + } + link('second') { + from('a') + to('b') + kind('ts:api') + } + } + } + """, "has duplicated link tuple (from='a', to='b', kind='ts:api')"); + } + + @Test + void failsOnUnknownSourceLayerInLink() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('a') { + } + + variant('browser') { + role('main') { + layers('a') + } + link('l1') { + from('missing') + to('a') + kind('ts:api') + } + } + } + """, "references unknown source layer 'missing'"); + } + + @Test + void failsOnUnknownTargetLayerInLink() throws Exception { + assertBuildFails(""" + plugins { + id 'org.implab.gradle-variants' + } + + variants { + layer('a') { + } + + variant('browser') { + role('main') { + layers('a') + } + link('l1') { + from('a') + to('missing') + kind('ts:api') + } + } + } + """, "references unknown target layer 'missing'"); + } + + 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(BuildVariant.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + + var markerResource = VariantsPlugin.class.getClassLoader() + .getResource("META-INF/gradle-plugins/org.implab.gradle-variants.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/VariantsSourcesPluginFunctionalTest.java b/common/src/test/java/org/implab/gradle/common/sources/VariantsSourcesPluginFunctionalTest.java new file mode 100644 --- /dev/null +++ b/common/src/test/java/org/implab/gradle/common/sources/VariantsSourcesPluginFunctionalTest.java @@ -0,0 +1,366 @@ +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 VariantsSourcesPluginFunctionalTest { + 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-sources-fixture'\n"; + + @TempDir + Path testProjectDir; + + @Test + void materializesVariantSourceSetsAndFiresCallbacks() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-sources' + } + + variants { + layer('mainBase') + layer('mainAmd') + + variant('browser') { + role('main') { layers('mainBase', 'mainAmd') } + } + + variant('node') { + role('main') { layers('mainBase') } + } + } + + def events = [] + def localEvents = [] + + variantSources { + bind('mainBase') { + configureSourceSet { + declareOutputs('compiled') + } + } + bind('mainAmd') { + configureSourceSet { + declareOutputs('compiled') + } + } + bind('mainAmd').whenRegistered { ctx -> + localEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}" + } + whenRegistered { ctx -> + events << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}" + } + } + + tasks.register('probe') { + doLast { + println("sources=" + sources.collect { it.name }.sort().join(',')) + println("events=" + events.sort().join('|')) + println("local=" + localEvents.sort().join('|')) + + def base = sources.getByName('browserMainBase') + def amd = sources.getByName('browserMainAmd') + def nodeBase = sources.getByName('nodeMainBase') + + base.output('compiled') + amd.output('compiled') + nodeBase.output('compiled') + + println('outputs=ok') + } + } + """); + + BuildResult result = runner("probe").build(); + + assertTrue(result.getOutput().contains("sources=browserMainAmd,browserMainBase,nodeMainBase")); + assertTrue(result.getOutput().contains( + "events=browser:main:mainAmd:browserMainAmd|browser:main:mainBase:browserMainBase|node:main:mainBase:nodeMainBase")); + assertTrue(result.getOutput().contains("local=browser:main:mainAmd:browserMainAmd")); + assertTrue(result.getOutput().contains("outputs=ok")); + assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test + void supportsTrailingClosureOnBind() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-sources' + } + + variants { + layer('main') + variant('browser') { + role('main') { layers('main') } + } + } + + variantSources { + bind('main') { + configureSourceSet { + declareOutputs('compiled') + } + } + } + + tasks.register('probe') { + doLast { + def ss = sources.getByName('browserMain') + ss.output('compiled') + println('bindClosure=ok') + } + } + """); + + BuildResult result = runner("probe").build(); + assertTrue(result.getOutput().contains("bindClosure=ok")); + } + + @Test + void failsOnUnknownLayerBinding() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-sources' + } + + variants { + layer('main') + variant('browser') { + role('main') { layers('main') } + } + } + + variantSources { + bind('missing') + } + """); + + var ex = assertThrows(UnexpectedBuildFailure.class, () -> runner("help").build()); + assertTrue(ex.getBuildResult().getOutput().contains("Layer binding 'missing' references unknown layer")); + } + + @Test + void exposesProviderInSourceSetRegisteredContext() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-sources' + } + + variants { + layer('main') + variant('browser') { + role('main') { layers('main') } + } + } + + variantSources { + whenRegistered { + configureSourceSet { + declareOutputs('generated') + } + } + } + + tasks.register('probe') { + doLast { + def ss = sources.getByName('browserMain') + ss.output('generated') + println('contextProvider=ok') + } + } + """); + + BuildResult result = runner("probe").build(); + assertTrue(result.getOutput().contains("contextProvider=ok")); + } + + @Test + void replaysLateBindingsAndCallbacksAfterMaterialization() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-sources' + } + + variants { + layer('main') + variant('browser') { + role('main') { layers('main') } + } + } + + def events = [] + + afterEvaluate { + variantSources { + bind('main') { + configureSourceSet { + declareOutputs('late') + } + } + + bind('main').whenRegistered { ctx -> + events << "layer:${ctx.sourceSetName()}" + } + + whenRegistered { ctx -> + events << "global:${ctx.sourceSetName()}" + } + } + } + + tasks.register('probe') { + doLast { + def ss = sources.getByName('browserMain') + ss.output('late') + println("events=" + events.sort().join('|')) + println('lateReplay=ok') + } + } + """); + + BuildResult result = runner("probe").build(); + assertTrue(result.getOutput().contains("events=global:browserMain|layer:browserMain")); + assertTrue(result.getOutput().contains("lateReplay=ok")); + } + + @Test + void supportsSourceSetNamePatternAndSharedRegistration() throws Exception { + writeFile(SETTINGS_FILE, ROOT_NAME); + writeFile(BUILD_FILE, """ + plugins { + id 'org.implab.gradle-variants-sources' + } + + variants { + layer('main') + + variant('browser') { + role('main') { layers('main') } + } + + variant('node') { + role('main') { layers('main') } + } + } + + def registeredEvents = [] + def browserRegisteredEvents = [] + def boundEvents = [] + def browserBoundEvents = [] + def localBoundEvents = [] + + variantSources { + bind('main').sourceSetNamePattern = '{layer}' + + bind('main') { + configureSourceSet { + declareOutputs('compiled') + } + } + + bind('main') { + whenBound { + localBoundEvents << "${variantName()}:${roleName()}:${layerName()}:${sourceSetName()}" + } + } + + whenRegistered { ctx -> + registeredEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}" + } + + whenRegistered('browser') { ctx -> + browserRegisteredEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}" + } + + whenBound { ctx -> + boundEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}" + } + + whenBound('browser') { ctx -> + browserBoundEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}" + } + } + + tasks.register('probe') { + doLast { + println("sources=" + sources.collect { it.name }.sort().join(',')) + + def main = sources.getByName('main') + main.output('compiled') + + println("registered=" + registeredEvents.sort().join('|')) + println("browserRegistered=" + browserRegisteredEvents.sort().join('|')) + println("bound=" + boundEvents.sort().join('|')) + println("browserBound=" + browserBoundEvents.sort().join('|')) + println("localBound=" + localBoundEvents.sort().join('|')) + println('sharedPattern=ok') + } + } + """); + + BuildResult result = runner("probe").build(); + assertTrue(result.getOutput().contains("sources=main")); + assertTrue(result.getOutput().contains("registered=browser:main:main:main")); + assertTrue(result.getOutput().contains("browserRegistered=browser:main:main:main")); + assertTrue(result.getOutput().contains("bound=browser:main:main:main|node:main:main:main")); + assertTrue(result.getOutput().contains("browserBound=browser:main:main:main")); + assertTrue(result.getOutput().contains("localBound=browser:main:main:main|node:main:main:main")); + assertTrue(result.getOutput().contains("sharedPattern=ok")); + } + + private GradleRunner runner(String... arguments) { + return GradleRunner.create() + .withProjectDir(testProjectDir.toFile()) + .withPluginClasspath(pluginClasspath()) + .withArguments(arguments) + .forwardOutput(); + } + + private static List pluginClasspath() { + try { + var classesDir = Path.of(VariantsSourcesPlugin.class + .getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + + var markerResource = VariantsSourcesPlugin.class.getClassLoader() + .getResource("META-INF/gradle-plugins/org.implab.gradle-variants-sources.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/variant-sources-plugin.md b/common/variant-sources-plugin.md new file mode 100644 --- /dev/null +++ b/common/variant-sources-plugin.md @@ -0,0 +1,145 @@ +# Variant Sources Plugin + +## NAME + +`VariantsSourcesPlugin` и extension `variantSources`. + +## SYNOPSIS + +```groovy +plugins { + id 'org.implab.gradle-variants-sources' +} + +variants { + layer('main') + + variant('browser') { + role('main') { layers('main') } + } + + variant('node') { + role('main') { layers('main') } + } +} + +variantSources { + bind('main').sourceSetNamePattern = '{layer}' + + bind('main') { + configureSourceSet { + declareOutputs('compiled') + } + } + + whenRegistered { sourceSetName() } + whenBound('browser') { roleName() } +} +``` + +## DESCRIPTION + +`VariantsSourcesPlugin` применяет `VariantsPlugin` и `SourcesPlugin`, затем +материализует source sets из модели `variants`. + +Точка запуска materialization: + +- `variants.whenFinalized(model -> registerSourceSets(...))` + +### materialization + +Для каждой usage-связки `variant/role/layer` вычисляется имя source set, +регистрируется `GenericSourceSet` (если он еще не существует), затем +вызываются callbacks. + +### binding + +`bind('')` возвращает `BuildLayerBinding` и задает policy для этого +слоя: + +- как именовать source set; +- как конфигурировать source set; +- какие callbacks вызвать на registration/binding. + +### sourceSetNamePattern + +`sourceSetNamePattern` определяет naming policy materialized source set. + +Default: + +- `{variant}{layerCap}` + +Tokens: + +- `{variant}`, `{variantCap}` +- `{role}`, `{roleCap}` +- `{layer}`, `{layerCap}` + +Имя санитизируется (`[^A-Za-z0-9_.-] -> _`). + +Ограничение: + +- один `sourceSetName` не может быть порожден разными слоями. + +## EVENTS + +### whenRegistered + +- callback на новый уникальный source set; +- replayable; +- при shared source set срабатывает один раз. + +### whenBound + +- callback на каждую usage-связку `variant/role/layer`; +- replayable; +- подходит для per-usage логики. + +### variant filter + +Глобальные callbacks поддерживают фильтр по варианту: + +- `whenRegistered(String variantName, ...)` +- `whenBound(String variantName, ...)` + +## SOURCE SET CONTEXT + +`SourceSetContext` содержит: + +- `variantName`, `roleName`, `layerName`, `sourceSetName`; +- `sourceSet` (`NamedDomainObjectProvider`). + +Sugar: + +- `configureSourceSet(Action|Closure)`. + +## API + +### VariantSourcesExtension + +- `bind(String)` — получить/создать binding по имени слоя. +- `bind(String, Action|Closure)` — сконфигурировать binding. +- `bindings(Action|Closure)` — контейнерная конфигурация bindings. +- `whenRegistered(...)` — глобальные callbacks регистрации source set. +- `whenBound(...)` — глобальные callbacks usage-binding. + +### BuildLayerBinding + +- `sourceSetNamePattern` — naming policy для source set слоя. +- `configureSourceSet(...)` — слойная конфигурация `GenericSourceSet`. +- `whenRegistered(...)` — callbacks регистрации в рамках слоя. +- `whenBound(...)` — callbacks usage-binding в рамках слоя. + +## KEY CLASSES + +- `VariantsSourcesPlugin` — точка входа plugin adapter. +- `VariantSourcesExtension` — глобальный DSL bind/events. +- `BuildLayerBinding` — layer-local policy и callbacks. +- `SourceSetContext` — payload callbacks и sugar-конфигурирование. + +## NOTES + +- `sourceSetNamePattern` фиксируется при первом чтении в materialization + (`finalizeValueOnRead`). +- Closure callbacks используют delegate-first. +- Для вложенных closure лучше явный параметр (`ctx -> ...`). diff --git a/common/variants-plugin.md b/common/variants-plugin.md new file mode 100644 --- /dev/null +++ b/common/variants-plugin.md @@ -0,0 +1,127 @@ +# Variants Plugin + +## NAME + +`VariantsPlugin` и extension `variants`. + +## SYNOPSIS + +```groovy +plugins { + id 'org.implab.gradle-variants' +} + +variants { + layer('mainBase') + layer('mainAmd') + + variant('browser') { + attributes { + string('jsRuntime', 'browser') + string('jsModule', 'amd') + } + + role('main') { + layers('mainBase', 'mainAmd') + } + + link('mainBase', 'mainAmd', 'ts:api') + artifactSlot('mainCompiled') + } +} +``` + +## DESCRIPTION + +`VariantsPlugin` задает доменную модель сборки и ее валидацию. Плагин не +регистрирует compile/copy/bundle задачи напрямую. + +### layers + +Глобальные логические слои. Служат единым словарем имен, на которые затем +ссылаются роли и связи. + +### variants + +Именованные варианты исполнения/пакетирования (`browser`, `node`, и т.д.). +Вариант агрегирует роли, связи, атрибуты и artifact slots. + +### roles + +Роль описывает набор слоев в пределах варианта (`main`, `test`, `tools`). +Одна роль может ссылаться на несколько слоев. + +### links + +`link(from, to, kind)` — ориентированная связь между слоями внутри варианта. + +`kind` задает независимый тип графа (например `ts:api`, `bundle:runtime`). Это +позволяет вести несколько параллельных графов зависимостей над теми же слоями. + +Практические сценарии использования `link` в адаптерах: + +- расчет topological order по выбранному `kind`; +- wiring task inputs/outputs между слоями; +- проверка допустимости дополнительных pipeline-зависимостей. + +### attributes + +Typed-атрибуты (`Attribute -> Provider`) для передачи параметров в +адаптеры и публикацию артефактов. + +### artifact slots + +Именованные слоты ожидаемых артефактов варианта. Используются как контракт +между моделью варианта и плагинами, создающими/публикующими результаты. + +## VALIDATION + +В `finalizeModel()` выполняется проверка: + +- роль не может ссылаться на неизвестный layer; +- пустые имена layer запрещены; +- у link обязательны `from`, `to`, `kind`; +- `from`/`to` должны входить в слойную область варианта; +- tuple `(from, to, kind)` должен быть уникален; +- циклы в графе одного `kind` запрещены. + +## LIFECYCLE + +- `VariantsPlugin` вызывает `variants.finalizeModel()` на `afterEvaluate`. +- `whenFinalized(...)` replayable. + +## API + +### BuildVariantsExtension + +- `layer(...)` — объявление или конфигурация `BuildLayer`. +- `variant(...)` — объявление или конфигурация `BuildVariant`. +- `layers { ... }`, `variants { ... }` — контейнерный DSL. +- `all(...)` — callback для всех вариантов. +- `getAll()`, `getByName(name)` — доступ к вариантам. +- `validate()` — явный запуск валидации. +- `finalizeModel()` — валидация + финализация модели. +- `whenFinalized(...)` — callback по завершенной модели (replayable). + +### BuildVariant + +- `attributes { ... }` — атрибуты варианта (+ sugar `string/bool/integer`). +- `role(...)`, `roles { ... }` — роли варианта. +- `link(...)`, `links { ... }` — связи слоев внутри варианта. +- `artifactSlot(...)`, `artifactSlots { ... }` — артефактные слоты. + +## KEY CLASSES + +- `VariantsPlugin` — точка входа плагина. +- `BuildVariantsExtension` — root extension и lifecycle. +- `BuildVariant` — агрегатная модель варианта. +- `BuildLayer` — модель слоя. +- `BuildRole` — модель роли. +- `BuildLink` — модель направленной связи. +- `BuildArtifactSlot` — модель артефактного слота. +- `VariantAttributes` — typed wrapper для variant attributes. + +## NOTES + +- Модель `variants` intentionally agnostic к toolchain. +- Интеграция с задачами выполняется через `variantSources` и адаптеры.