##// END OF EJS Templates
separated SourceSetRegistration, SourceSetUsageBinding
cin -
r31:414a5d71eaa5 default
parent child
Show More
@@ -0,0 +1,33
1 package org.implab.gradle.common.sources;
2
3 import org.implab.gradle.common.core.lang.Closures;
4 import org.gradle.api.Action;
5 import org.gradle.api.NamedDomainObjectProvider;
6
7 import groovy.lang.Closure;
8 import groovy.lang.DelegatesTo;
9
10 /**
11 * Immutable payload for a newly registered {@link GenericSourceSet}.
12 *
13 * <p>Used as callback payload for
14 * {@link VariantSourcesExtension#whenRegistered(org.gradle.api.Action)} and
15 * {@link BuildLayerBinding#whenRegistered(org.gradle.api.Action)}.
16 *
17 * @param layerName normalized layer name that owns the registration
18 * @param sourceSetName source-set name registered in the container
19 * @param sourceSet provider of the registered source set (realized later by Gradle on demand)
20 */
21 public record SourceSetRegistration(
22 String layerName,
23 String sourceSetName,
24 NamedDomainObjectProvider<GenericSourceSet> sourceSet) {
25 public void configureSourceSet(Action<? super GenericSourceSet> action) {
26 sourceSet.configure(action);
27 }
28
29 public void configureSourceSet(
30 @DelegatesTo(value = GenericSourceSet.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
31 configureSourceSet(Closures.action(action));
32 }
33 }
@@ -0,0 +1,37
1 package org.implab.gradle.common.sources;
2
3 import org.implab.gradle.common.core.lang.Closures;
4 import org.gradle.api.Action;
5 import org.gradle.api.NamedDomainObjectProvider;
6
7 import groovy.lang.Closure;
8 import groovy.lang.DelegatesTo;
9
10 /**
11 * Immutable payload for a resolved variant/role/layer usage bound to a source set.
12 *
13 * <p>Used as callback payload for
14 * {@link VariantSourcesExtension#whenBound(org.gradle.api.Action)} and
15 * {@link BuildLayerBinding#whenBound(org.gradle.api.Action)}.
16 *
17 * @param variantName variant name from the build-variants model
18 * @param roleName role name inside the resolved variant
19 * @param layerName normalized layer name used to resolve the source set
20 * @param sourceSetName source-set name registered in the container
21 * @param sourceSet provider of the registered source set (realized later by Gradle on demand)
22 */
23 public record SourceSetUsageBinding(
24 String variantName,
25 String roleName,
26 String layerName,
27 String sourceSetName,
28 NamedDomainObjectProvider<GenericSourceSet> sourceSet) {
29 public void configureSourceSet(Action<? super GenericSourceSet> action) {
30 sourceSet.configure(action);
31 }
32
33 public void configureSourceSet(
34 @DelegatesTo(value = GenericSourceSet.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
35 configureSourceSet(Closures.action(action));
36 }
37 }
@@ -1,132 +1,134
1 1 # Gradle Common Sources Model
2 2
3 3 ## NAME
4 4
5 5 `gradle-common/common` — набор плагинов для моделирования вариантов сборки,
6 6 регистрации source sets и интеграции этой модели с toolchain-адаптерами.
7 7
8 8 ## SYNOPSIS
9 9
10 10 ```groovy
11 11 plugins {
12 12 id 'org.implab.gradle-variants-sources'
13 13 }
14 14
15 15 variants {
16 16 layer('mainBase')
17 17 layer('mainAmd')
18 18
19 19 variant('browser') {
20 20 role('main') { layers('mainBase', 'mainAmd') }
21 21 }
22 22 }
23 23
24 24 variantSources {
25 25 bind('mainBase') {
26 26 configureSourceSet {
27 27 declareOutputs('compiled')
28 28 }
29 29 }
30 30
31 31 bind('mainAmd').sourceSetNamePattern = '{variant}{layerCap}'
32 32
33 33 whenRegistered { sourceSetName() }
34 34
35 35 whenBound { ctx ->
36 36 ctx.configureSourceSet {
37 37 declareOutputs('typings')
38 38 }
39 39 }
40 40 }
41 41 ```
42 42
43 43 ## DESCRIPTION
44 44
45 45 Модуль состоит из трех логических частей:
46 46
47 47 - `variants` — декларативная доменная модель сборки;
48 48 - `sources` — модель физически регистрируемых source sets;
49 49 - `variantSources` — адаптер, который связывает первые две модели.
50 50
51 51 Ниже раскрытие каждой части.
52 52
53 53 ### variants
54 54
55 55 `variants` задает структуру пространства сборки: какие есть слои, какие роли
56 56 используют эти слои в каждом варианте, какие есть атрибуты и artifact slots.
57 57 Модель не создает задачи и не привязана к TS/JS.
58 58
59 59 Практический смысл:
60 60
61 61 - формализовать архитектуру сборки;
62 62 - дать адаптерам единый источник правды.
63 63
64 64 ### sources
65 65
66 66 `sources` описывает независимые source sets (`GenericSourceSet`) с именованными
67 67 outputs. Это уже "физический" уровень, к которому удобно привязывать задачи,
68 68 артефакты и task inputs/outputs.
69 69
70 70 Практический смысл:
71 71
72 72 - создать единый контракт по входам/выходам;
73 73 - регистрировать результаты задач как outputs source set;
74 74 - минимизировать ручные `dependsOn` за счет модели outputs.
75 75
76 76 ### variantSources
77 77
78 78 `variantSources` регистрирует source sets на основе `variants`, применяет
79 79 конфигурацию layer-bindings и отдает события (`whenRegistered`, `whenBound`) для
80 80 адаптеров других плагинов.
81 81
82 82 Практический смысл:
83 83
84 84 - переводить логическую модель `variants` в executable-модель `sources`;
85 85 - навешивать политики toolchain на зарегистрированные source sets;
86 86 - синхронизировать плагины через replayable callback-контракт.
87 87
88 88 ## DOMAIN MODEL
89 89
90 90 - `BuildLayer` — глобальный идентификатор слоя.
91 91 - `BuildVariant` — агрегат ролей, атрибутов, артефактных слотов.
92 92 - `BuildRole` — роль внутри варианта, содержит ссылки на layer names.
93 93 - `GenericSourceSet` — зарегистрированный набор исходников и outputs.
94 94 - `BuildLayerBinding` — правила registration source set для конкретного layer.
95 - `SourceSetContext` — контекст callback-событий registration.
95 - `SourceSetRegistration` — payload события регистрации source set.
96 - `SourceSetUsageBinding` — payload события usage-binding.
96 97
97 98 ## EVENT CONTRACT
98 99
99 100 - `whenRegistered`:
100 101 - событие нового уникального source set name;
101 102 - replayable.
102 103 - `whenBound`:
103 104 - событие каждой usage-связки `variant/role/layer`;
104 105 - replayable.
105 106
106 107 Closure callbacks работают в delegate-first режиме (`@DelegatesTo`). Для
107 108 вложенных closure рекомендуется явный параметр (`ctx -> ...`).
108 109
109 110 ## KEY CLASSES
110 111
111 112 - `SourcesPlugin` — регистрирует extension `sources`.
112 113 - `GenericSourceSet` — модель источников/outputs для конкретного имени.
113 114 - `VariantsPlugin` — регистрирует extension `variants` и lifecycle finalize.
114 115 - `BuildVariantsExtension` — корневой API модели вариантов.
115 116 - `BuildVariant` — API ролей, attributes и artifact slots варианта.
116 117 - `VariantsSourcesPlugin` — применяет `variants` + `sources` и запускает адаптер.
117 118 - `VariantSourcesExtension` — API bind/events registration.
118 119 - `BuildLayerBinding` — слой-конкретный DSL для имени и конфигурации source set.
119 - `SourceSetContext` — payload событий и sugar `configureSourceSet(...)`.
120 - `SourceSetRegistration` — payload `whenRegistered(...)`.
121 - `SourceSetUsageBinding` — payload `whenBound(...)`.
120 122
121 123 ## NOTES
122 124
123 125 - Marker ids:
124 126 - `org.implab.gradle-variants`
125 127 - `org.implab.gradle-variants-sources`
126 128 - `SourcesPlugin` пока class-only (без marker id).
127 129
128 130 ## SEE ALSO
129 131
130 132 - `sources-plugin.md`
131 133 - `variants-plugin.md`
132 134 - `variant-sources-plugin.md`
@@ -1,120 +1,120
1 1 package org.implab.gradle.common.sources;
2 2
3 3 import java.util.ArrayList;
4 4 import java.util.LinkedHashSet;
5 5 import java.util.List;
6 6 import java.util.Set;
7 7
8 8 import javax.inject.Inject;
9 9
10 10 import org.implab.gradle.common.core.lang.Closures;
11 11 import org.gradle.api.Action;
12 12 import org.gradle.api.Named;
13 13 import org.gradle.api.NamedDomainObjectProvider;
14 14 import org.gradle.api.provider.Property;
15 15
16 16 import groovy.lang.Closure;
17 17 import groovy.lang.DelegatesTo;
18 18
19 19 /**
20 20 * Maps a logical layer to per-source-set hooks.
21 21 */
22 22 public abstract class BuildLayerBinding implements Named {
23 23 public static final String DEFAULT_SOURCE_SET_NAME_PATTERN = "{variant}{layerCap}";
24 24
25 25 private final String name;
26 26
27 27 private final List<Action<? super GenericSourceSet>> sourceSetConfigureActions = new ArrayList<>();
28 private final List<Action<? super SourceSetContext>> registeredActions = new ArrayList<>();
29 private final List<Action<? super SourceSetContext>> boundActions = new ArrayList<>();
28 private final List<Action<? super SourceSetRegistration>> registeredActions = new ArrayList<>();
29 private final List<Action<? super SourceSetUsageBinding>> boundActions = new ArrayList<>();
30 30 private final List<NamedDomainObjectProvider<GenericSourceSet>> registeredSourceSets = new ArrayList<>();
31 private final List<SourceSetContext> registeredContexts = new ArrayList<>();
32 private final List<SourceSetContext> boundContexts = new ArrayList<>();
31 private final List<SourceSetRegistration> registeredContexts = new ArrayList<>();
32 private final List<SourceSetUsageBinding> boundContexts = new ArrayList<>();
33 33 private final Set<String> registeredSourceSetNames = new LinkedHashSet<>();
34 34
35 35 @Inject
36 36 public BuildLayerBinding(String name) {
37 37 this.name = name;
38 38 getSourceSetNamePattern().convention(DEFAULT_SOURCE_SET_NAME_PATTERN);
39 39 }
40 40
41 41 @Override
42 42 public String getName() {
43 43 return name;
44 44 }
45 45
46 46 public abstract Property<String> getSourceSetNamePattern();
47 47
48 48 /**
49 49 * Action applied to every registered source set for this layer.
50 50 * Already registered source sets are configured immediately (replay).
51 51 */
52 52 public void configureSourceSet(Action<? super GenericSourceSet> configure) {
53 53 sourceSetConfigureActions.add(configure);
54 54 for (var sourceSet : registeredSourceSets)
55 55 sourceSet.configure(configure);
56 56 }
57 57
58 58 public void configureSourceSet(
59 59 @DelegatesTo(value = GenericSourceSet.class, strategy = Closure.DELEGATE_FIRST) Closure<?> configure) {
60 60 configureSourceSet(Closures.action(configure));
61 61 }
62 62
63 63 /**
64 64 * Layer-local callback fired after source-set registration.
65 * Already emitted contexts are delivered immediately (replay).
65 * Already emitted registrations are delivered immediately (replay).
66 66 * For simple callbacks you can use delegate-only style
67 67 * (for example {@code whenRegistered { sourceSetName() }}).
68 68 * For nested closures prefer explicit parameter
69 69 * ({@code whenRegistered { ctx -> ... }}).
70 70 */
71 public void whenRegistered(Action<? super SourceSetContext> action) {
71 public void whenRegistered(Action<? super SourceSetRegistration> action) {
72 72 registeredActions.add(action);
73 73 for (var context : registeredContexts)
74 74 action.execute(context);
75 75 }
76 76
77 77 public void whenRegistered(
78 @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
78 @DelegatesTo(value = SourceSetRegistration.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
79 79 whenRegistered(Closures.action(action));
80 80 }
81 81
82 82 /**
83 83 * Layer-local callback fired for every resolved variant/role/layer usage.
84 * Already emitted contexts are delivered immediately (replay).
84 * Already emitted usage bindings are delivered immediately (replay).
85 85 * For simple callbacks you can use delegate-only style
86 86 * (for example {@code whenBound { variantName() }}).
87 87 * For nested closures prefer explicit parameter
88 88 * ({@code whenBound { ctx -> ... }}).
89 89 */
90 public void whenBound(Action<? super SourceSetContext> action) {
90 public void whenBound(Action<? super SourceSetUsageBinding> action) {
91 91 boundActions.add(action);
92 92 for (var context : boundContexts)
93 93 action.execute(context);
94 94 }
95 95
96 96 public void whenBound(
97 @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
97 @DelegatesTo(value = SourceSetUsageBinding.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
98 98 whenBound(Closures.action(action));
99 99 }
100 100
101 void notifyRegistered(SourceSetContext context) {
102 if (registeredSourceSetNames.add(context.sourceSetName())) {
103 var sourceSet = context.sourceSet();
101 void notifyRegistered(SourceSetRegistration registration) {
102 if (registeredSourceSetNames.add(registration.sourceSetName())) {
103 var sourceSet = registration.sourceSet();
104 104 registeredSourceSets.add(sourceSet);
105 105
106 106 for (var action : sourceSetConfigureActions)
107 107 sourceSet.configure(action);
108 108 }
109 109
110 registeredContexts.add(context);
110 registeredContexts.add(registration);
111 111 for (var action : registeredActions)
112 action.execute(context);
112 action.execute(registration);
113 113 }
114 114
115 void notifyBound(SourceSetContext context) {
116 boundContexts.add(context);
115 void notifyBound(SourceSetUsageBinding binding) {
116 boundContexts.add(binding);
117 117 for (var action : boundActions)
118 action.execute(context);
118 action.execute(binding);
119 119 }
120 120 }
@@ -1,303 +1,297
1 1 package org.implab.gradle.common.sources;
2 2
3 3 import java.util.ArrayList;
4 4 import java.util.LinkedHashMap;
5 5 import java.util.List;
6 6 import java.util.regex.Matcher;
7 7 import java.util.regex.Pattern;
8 8 import java.util.stream.Stream;
9 9
10 10 import javax.inject.Inject;
11 11
12 12 import org.implab.gradle.common.core.lang.Closures;
13 13 import org.implab.gradle.common.core.lang.Strings;
14 14 import org.eclipse.jdt.annotation.NonNullByDefault;
15 15 import org.eclipse.jdt.annotation.Nullable;
16 16 import org.gradle.api.Action;
17 17 import org.gradle.api.InvalidUserDataException;
18 18 import org.gradle.api.NamedDomainObjectContainer;
19 19 import org.gradle.api.NamedDomainObjectProvider;
20 20 import org.gradle.api.model.ObjectFactory;
21 21 import org.gradle.api.logging.Logger;
22 22 import org.gradle.api.logging.Logging;
23 23
24 24 import groovy.lang.Closure;
25 25 import groovy.lang.DelegatesTo;
26 26
27 27 import static org.implab.gradle.common.core.lang.Strings.sanitizeName;
28 28
29 29 /**
30 30 * Adapter extension that registers source sets for variant/layer pairs.
31 31 */
32 32 @NonNullByDefault
33 33 public abstract class VariantSourcesExtension {
34 34 private static final Logger logger = Logging.getLogger(VariantSourcesExtension.class);
35 35 private static final Pattern SOURCE_SET_NAME_TOKEN = Pattern.compile("\\{([A-Za-z][A-Za-z0-9]*)\\}");
36 36
37 37 private final NamedDomainObjectContainer<BuildLayerBinding> bindings;
38 private final List<Action<? super SourceSetContext>> registeredActions = new ArrayList<>();
39 private final List<Action<? super SourceSetContext>> boundActions = new ArrayList<>();
40 private final List<SourceSetContext> registeredContexts = new ArrayList<>();
41 private final List<SourceSetContext> boundContexts = new ArrayList<>();
38 private final List<Action<? super SourceSetRegistration>> registeredActions = new ArrayList<>();
39 private final List<Action<? super SourceSetUsageBinding>> boundActions = new ArrayList<>();
40 private final List<SourceSetRegistration> registeredContexts = new ArrayList<>();
41 private final List<SourceSetUsageBinding> boundContexts = new ArrayList<>();
42 42 private final LinkedHashMap<String, NamedDomainObjectProvider<GenericSourceSet>> sourceSetsByName = new LinkedHashMap<>();
43 43 private final LinkedHashMap<String, String> sourceSetLayersByName = new LinkedHashMap<>();
44 44 private boolean sourceSetsRegistered;
45 45
46 46 @Inject
47 47 public VariantSourcesExtension(ObjectFactory objects) {
48 48 bindings = objects.domainObjectContainer(BuildLayerBinding.class);
49 49 }
50 50
51 51 public NamedDomainObjectContainer<BuildLayerBinding> getBindings() {
52 52 return bindings;
53 53 }
54 54
55 55 public void bindings(Action<? super NamedDomainObjectContainer<BuildLayerBinding>> action) {
56 56 action.execute(bindings);
57 57 }
58 58
59 59 public void bindings(
60 60 @DelegatesTo(value = NamedDomainObjectContainer.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
61 61 bindings(Closures.action(action));
62 62 }
63 63
64 64 public BuildLayerBinding bind(String layer) {
65 65 return bindings.maybeCreate(normalize(layer, "Layer name must not be null or blank"));
66 66 }
67 67
68 68 /**
69 69 * Configures per-layer binding.
70 70 */
71 71 public BuildLayerBinding bind(String layer, Action<? super BuildLayerBinding> configure) {
72 72 var binding = bind(layer);
73 73 configure.execute(binding);
74 74 return binding;
75 75 }
76 76
77 77 public BuildLayerBinding bind(String layer,
78 78 @DelegatesTo(value = BuildLayerBinding.class, strategy = Closure.DELEGATE_FIRST) Closure<?> configure) {
79 79 return bind(layer, Closures.action(configure));
80 80 }
81 81
82 82 /**
83 * Global callback fired for each registered source-set context.
84 * Already emitted contexts are delivered immediately (replay).
83 * Global callback fired for each registered source set.
84 * Already emitted registrations are delivered immediately (replay).
85 85 * For simple callbacks you can use delegate-only style
86 86 * (for example {@code whenRegistered { sourceSetName() }}).
87 87 * For nested closures prefer explicit parameter
88 88 * ({@code whenRegistered { ctx -> ... }}).
89 89 */
90 public void whenRegistered(Action<? super SourceSetContext> action) {
90 public void whenRegistered(Action<? super SourceSetRegistration> action) {
91 91 registeredActions.add(action);
92 92 for (var context : registeredContexts)
93 93 action.execute(context);
94 94 }
95 95
96 96 public void whenRegistered(
97 @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
97 @DelegatesTo(value = SourceSetRegistration.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
98 98 whenRegistered(Closures.action(action));
99 99 }
100 100
101 public void whenRegistered(String variantName, Action<? super SourceSetContext> action) {
102 var normalizedVariantName = normalize(variantName, "variantName must not be null or blank");
103 whenRegistered(filterByVariant(normalizedVariantName, action));
104 }
105
106 public void whenRegistered(String variantName,
107 @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
108 whenRegistered(variantName, Closures.action(action));
109 }
110
111 101 /**
112 102 * Global callback fired for every resolved variant/role/layer usage.
113 * Already emitted contexts are delivered immediately (replay).
103 * Already emitted usage bindings are delivered immediately (replay).
114 104 * For simple callbacks you can use delegate-only style
115 105 * (for example {@code whenBound { variantName() }}).
116 106 * For nested closures prefer explicit parameter
117 107 * ({@code whenBound { ctx -> ... }}).
118 108 */
119 public void whenBound(Action<? super SourceSetContext> action) {
109 public void whenBound(Action<? super SourceSetUsageBinding> action) {
120 110 boundActions.add(action);
121 111 for (var context : boundContexts)
122 112 action.execute(context);
123 113 }
124 114
125 115 public void whenBound(
126 @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
116 @DelegatesTo(value = SourceSetUsageBinding.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
127 117 whenBound(Closures.action(action));
128 118 }
129 119
130 public void whenBound(String variantName, Action<? super SourceSetContext> action) {
120 public void whenBound(String variantName, Action<? super SourceSetUsageBinding> action) {
131 121 var normalizedVariantName = normalize(variantName, "variantName must not be null or blank");
132 122 whenBound(filterByVariant(normalizedVariantName, action));
133 123 }
134 124
135 125 public void whenBound(String variantName,
136 @DelegatesTo(value = SourceSetContext.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
126 @DelegatesTo(value = SourceSetUsageBinding.class, strategy = Closure.DELEGATE_FIRST) Closure<?> action) {
137 127 whenBound(variantName, Closures.action(action));
138 128 }
139 129
140 130 void registerSourceSets(BuildVariantsExtension variants, NamedDomainObjectContainer<GenericSourceSet> sources) {
141 131 if (sourceSetsRegistered) {
142 132 throw new InvalidUserDataException("variantSources source sets are already registered");
143 133 }
144 134
145 135 validateBindings(variants);
146 136
147 137 var usages = layerUsages(variants).toList();
148 138 var registeredBefore = registeredContexts.size();
149 139 var boundBefore = boundContexts.size();
150 140
151 141 logger.debug(
152 142 "Starting variant source-set registration (variants={}, layers={}, bindings={}, usages={})",
153 143 variants.getVariants().size(),
154 144 variants.getLayers().size(),
155 145 bindings.size(),
156 146 usages.size());
157 147
158 148 usages.forEach(usage -> registerLayerUsage(usage, sources));
159 149
160 150 logger.debug(
161 151 "Completed variant source-set registration (newSourceSets={}, newBounds={}, totalSourceSets={})",
162 152 registeredContexts.size() - registeredBefore,
163 153 boundContexts.size() - boundBefore,
164 154 sourceSetsByName.size());
165 155
166 156 sourceSetsRegistered = true;
167 157 }
168 158
169 159 private Stream<LayerUsage> layerUsages(BuildVariantsExtension variants) {
170 160 return variants.getVariants().stream()
171 161 .flatMap(variant -> variant.getRoles().stream()
172 162 .flatMap(role -> role.getLayers().getOrElse(List.of()).stream()
173 163 .map(layerName -> new LayerUsage(
174 164 variant.getName(),
175 165 role.getName(),
176 166 normalize(layerName, "Layer name in variant '" + variant.getName() + "' and role '" + role.getName() + "' must not be null or blank")))));
177 167 }
178 168
179 169 private void registerLayerUsage(LayerUsage usage, NamedDomainObjectContainer<GenericSourceSet> sources) {
180 170 var resolvedBinding = bind(usage.layerName());
181 171 var sourceSetNamePattern = resolvedBinding.getSourceSetNamePattern();
182 172 sourceSetNamePattern.finalizeValueOnRead();
183 173
184 174 var sourceSetName = sourceSetName(usage, sourceSetNamePattern.get());
185 175
186 176 ensureSourceSetNameBoundToSingleLayer(sourceSetName, usage.layerName());
187 177 var isNewSourceSet = !sourceSetsByName.containsKey(sourceSetName);
188 178 var sourceSet = sourceSetsByName.computeIfAbsent(sourceSetName,
189 179 name -> sources.register(name));
190 180
191 var context = new SourceSetContext(
181 var binding = new SourceSetUsageBinding(
192 182 usage.variantName(),
193 183 usage.roleName(),
194 184 usage.layerName(),
195 185 sourceSetName,
196 186 sourceSet);
197 187
198 188 if (isNewSourceSet) {
199 resolvedBinding.notifyRegistered(context);
200 notifyRegistered(context);
189 var registration = new SourceSetRegistration(
190 usage.layerName(),
191 sourceSetName,
192 sourceSet);
193 resolvedBinding.notifyRegistered(registration);
194 notifyRegistered(registration);
201 195 }
202 196
203 resolvedBinding.notifyBound(context);
204 notifyBound(context);
197 resolvedBinding.notifyBound(binding);
198 notifyBound(binding);
205 199 }
206 200
207 private void notifyRegistered(SourceSetContext context) {
208 registeredContexts.add(context);
201 private void notifyRegistered(SourceSetRegistration registration) {
202 registeredContexts.add(registration);
209 203 for (var action : registeredActions)
210 action.execute(context);
204 action.execute(registration);
211 205 }
212 206
213 private void notifyBound(SourceSetContext context) {
214 boundContexts.add(context);
207 private void notifyBound(SourceSetUsageBinding binding) {
208 boundContexts.add(binding);
215 209 for (var action : boundActions)
216 action.execute(context);
210 action.execute(binding);
217 211 }
218 212
219 private static Action<? super SourceSetContext> filterByVariant(String variantName,
220 Action<? super SourceSetContext> action) {
221 return context -> {
222 if (variantName.equals(context.variantName()))
223 action.execute(context);
213 private static Action<? super SourceSetUsageBinding> filterByVariant(String variantName,
214 Action<? super SourceSetUsageBinding> action) {
215 return binding -> {
216 if (variantName.equals(binding.variantName()))
217 action.execute(binding);
224 218 };
225 219 }
226 220
227 221 private void ensureSourceSetNameBoundToSingleLayer(String sourceSetName, String layerName) {
228 222 var existingLayer = sourceSetLayersByName.putIfAbsent(sourceSetName, layerName);
229 223 if (existingLayer != null && !existingLayer.equals(layerName)) {
230 224 throw new InvalidUserDataException("Source set '" + sourceSetName + "' is resolved from multiple layers: '"
231 225 + existingLayer + "' and '" + layerName + "'");
232 226 }
233 227 }
234 228
235 229 private void validateBindings(BuildVariantsExtension variants) {
236 230 var knownLayerNames = new java.util.LinkedHashSet<String>();
237 231 for (var layer : variants.getLayers())
238 232 knownLayerNames.add(layer.getName());
239 233
240 234 var errors = new ArrayList<String>();
241 235 for (var binding : bindings) {
242 236 if (!knownLayerNames.contains(binding.getName())) {
243 237 errors.add("Layer binding '" + binding.getName() + "' references unknown layer");
244 238 }
245 239 }
246 240
247 241 if (!errors.isEmpty()) {
248 242 var message = new StringBuilder("Invalid variantSources model:");
249 243 for (var error : errors)
250 244 message.append("\n - ").append(error);
251 245 throw new InvalidUserDataException(message.toString());
252 246 }
253 247 }
254 248
255 249 private static String sourceSetName(LayerUsage usage, String pattern) {
256 250 var normalizedPattern = normalize(pattern, "sourceSetNamePattern must not be null or blank");
257 251 var resolved = resolveSourceSetNamePattern(normalizedPattern, usage);
258 252 var result = sanitizeName(resolved);
259 253
260 254 if (result.isEmpty())
261 255 throw new InvalidUserDataException("sourceSetNamePattern '" + pattern + "' resolved to empty source set name");
262 256
263 257 return result;
264 258 }
265 259
266 260 private static String resolveSourceSetNamePattern(String pattern, LayerUsage usage) {
267 261 var matcher = SOURCE_SET_NAME_TOKEN.matcher(pattern);
268 262 var output = new StringBuffer();
269 263
270 264 while (matcher.find()) {
271 265 var token = matcher.group(1);
272 266 matcher.appendReplacement(output, Matcher.quoteReplacement(tokenValue(token, usage)));
273 267 }
274 268 matcher.appendTail(output);
275 269
276 270 return output.toString();
277 271 }
278 272
279 273 private static String tokenValue(String token, LayerUsage usage) {
280 274 return switch (token) {
281 275 case "variant" -> sanitizeName(usage.variantName());
282 276 case "variantCap" -> Strings.capitalize(sanitizeName(usage.variantName()));
283 277 case "role" -> sanitizeName(usage.roleName());
284 278 case "roleCap" -> Strings.capitalize(sanitizeName(usage.roleName()));
285 279 case "layer" -> sanitizeName(usage.layerName());
286 280 case "layerCap" -> Strings.capitalize(sanitizeName(usage.layerName()));
287 281 default -> throw new InvalidUserDataException(
288 282 "sourceSetNamePattern contains unsupported token '{" + token + "}'");
289 283 };
290 284 }
291 285
292 286 private static String normalize(@Nullable String value, String errorMessage) {
293 287 if (value == null)
294 288 throw new InvalidUserDataException(errorMessage);
295 289 var trimmed = value.trim();
296 290 if (trimmed.isEmpty())
297 291 throw new InvalidUserDataException(errorMessage);
298 292 return trimmed;
299 293 }
300 294
301 295 private record LayerUsage(String variantName, String roleName, String layerName) {
302 296 }
303 297 }
@@ -1,366 +1,367
1 1 package org.implab.gradle.common.sources;
2 2
3 3 import static org.junit.jupiter.api.Assertions.assertNotNull;
4 4 import static org.junit.jupiter.api.Assertions.assertThrows;
5 5 import static org.junit.jupiter.api.Assertions.assertTrue;
6 6
7 7 import java.io.File;
8 8 import java.io.IOException;
9 9 import java.nio.file.Files;
10 10 import java.nio.file.Path;
11 11 import java.util.List;
12 12
13 13 import org.gradle.testkit.runner.BuildResult;
14 14 import org.gradle.testkit.runner.GradleRunner;
15 15 import org.gradle.testkit.runner.TaskOutcome;
16 16 import org.gradle.testkit.runner.UnexpectedBuildFailure;
17 17 import org.junit.jupiter.api.Test;
18 18 import org.junit.jupiter.api.io.TempDir;
19 19
20 20 class VariantsSourcesPluginFunctionalTest {
21 21 private static final String SETTINGS_FILE = "settings.gradle";
22 22 private static final String BUILD_FILE = "build.gradle";
23 23 private static final String ROOT_NAME = "rootProject.name = 'variants-sources-fixture'\n";
24 24
25 25 @TempDir
26 26 Path testProjectDir;
27 27
28 28 @Test
29 29 void registersVariantSourceSetsAndFiresCallbacks() throws Exception {
30 30 writeFile(SETTINGS_FILE, ROOT_NAME);
31 31 writeFile(BUILD_FILE, """
32 32 plugins {
33 33 id 'org.implab.gradle-variants-sources'
34 34 }
35 35
36 36 variants {
37 37 layer('mainBase')
38 38 layer('mainAmd')
39 39
40 40 variant('browser') {
41 41 role('main') { layers('mainBase', 'mainAmd') }
42 42 }
43 43
44 44 variant('node') {
45 45 role('main') { layers('mainBase') }
46 46 }
47 47 }
48 48
49 49 def events = []
50 50 def localEvents = []
51 51
52 52 variantSources {
53 53 bind('mainBase') {
54 54 configureSourceSet {
55 55 declareOutputs('compiled')
56 56 }
57 57 }
58 58 bind('mainAmd') {
59 59 configureSourceSet {
60 60 declareOutputs('compiled')
61 61 }
62 62 }
63 63 bind('mainAmd').whenRegistered { ctx ->
64 localEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}"
64 localEvents << "${ctx.layerName()}:${ctx.sourceSetName()}"
65 65 }
66 66 whenRegistered { ctx ->
67 events << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}"
67 events << "${ctx.layerName()}:${ctx.sourceSetName()}"
68 68 }
69 69 }
70 70
71 71 tasks.register('probe') {
72 72 doLast {
73 73 println("sources=" + sources.collect { it.name }.sort().join(','))
74 74 println("events=" + events.sort().join('|'))
75 75 println("local=" + localEvents.sort().join('|'))
76 76
77 77 def base = sources.getByName('browserMainBase')
78 78 def amd = sources.getByName('browserMainAmd')
79 79 def nodeBase = sources.getByName('nodeMainBase')
80 80
81 81 base.output('compiled')
82 82 amd.output('compiled')
83 83 nodeBase.output('compiled')
84 84
85 85 println('outputs=ok')
86 86 }
87 87 }
88 88 """);
89 89
90 90 BuildResult result = runner("probe").build();
91 91
92 92 assertTrue(result.getOutput().contains("sources=browserMainAmd,browserMainBase,nodeMainBase"));
93 assertTrue(result.getOutput().contains(
94 "events=browser:main:mainAmd:browserMainAmd|browser:main:mainBase:browserMainBase|node:main:mainBase:nodeMainBase"));
95 assertTrue(result.getOutput().contains("local=browser:main:mainAmd:browserMainAmd"));
93 assertTrue(result.getOutput().contains("events=mainAmd:browserMainAmd|mainBase:browserMainBase|mainBase:nodeMainBase"));
94 assertTrue(result.getOutput().contains("local=mainAmd:browserMainAmd"));
96 95 assertTrue(result.getOutput().contains("outputs=ok"));
97 96 assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS);
98 97 }
99 98
100 99 @Test
101 100 void supportsTrailingClosureOnBind() throws Exception {
102 101 writeFile(SETTINGS_FILE, ROOT_NAME);
103 102 writeFile(BUILD_FILE, """
104 103 plugins {
105 104 id 'org.implab.gradle-variants-sources'
106 105 }
107 106
108 107 variants {
109 108 layer('main')
110 109 variant('browser') {
111 110 role('main') { layers('main') }
112 111 }
113 112 }
114 113
115 114 variantSources {
116 115 bind('main') {
117 116 configureSourceSet {
118 117 declareOutputs('compiled')
119 118 }
120 119 }
121 120 }
122 121
123 122 tasks.register('probe') {
124 123 doLast {
125 124 def ss = sources.getByName('browserMain')
126 125 ss.output('compiled')
127 126 println('bindClosure=ok')
128 127 }
129 128 }
130 129 """);
131 130
132 131 BuildResult result = runner("probe").build();
133 132 assertTrue(result.getOutput().contains("bindClosure=ok"));
134 133 }
135 134
136 135 @Test
137 136 void failsOnUnknownLayerBinding() throws Exception {
138 137 writeFile(SETTINGS_FILE, ROOT_NAME);
139 138 writeFile(BUILD_FILE, """
140 139 plugins {
141 140 id 'org.implab.gradle-variants-sources'
142 141 }
143 142
144 143 variants {
145 144 layer('main')
146 145 variant('browser') {
147 146 role('main') { layers('main') }
148 147 }
149 148 }
150 149
151 150 variantSources {
152 151 bind('missing')
153 152 }
154 153 """);
155 154
156 155 var ex = assertThrows(UnexpectedBuildFailure.class, () -> runner("help").build());
157 156 assertTrue(ex.getBuildResult().getOutput().contains("Layer binding 'missing' references unknown layer"));
158 157 }
159 158
160 159 @Test
161 void exposesProviderInSourceSetRegisteredContext() throws Exception {
160 void exposesProviderInSourceSetRegistration() throws Exception {
162 161 writeFile(SETTINGS_FILE, ROOT_NAME);
163 162 writeFile(BUILD_FILE, """
164 163 plugins {
165 164 id 'org.implab.gradle-variants-sources'
166 165 }
167 166
168 167 variants {
169 168 layer('main')
170 169 variant('browser') {
171 170 role('main') { layers('main') }
172 171 }
173 172 }
174 173
175 174 variantSources {
176 175 whenRegistered {
177 176 configureSourceSet {
178 177 declareOutputs('generated')
179 178 }
180 179 }
181 180 }
182 181
183 182 tasks.register('probe') {
184 183 doLast {
185 184 def ss = sources.getByName('browserMain')
186 185 ss.output('generated')
187 186 println('contextProvider=ok')
188 187 }
189 188 }
190 189 """);
191 190
192 191 BuildResult result = runner("probe").build();
193 192 assertTrue(result.getOutput().contains("contextProvider=ok"));
194 193 }
195 194
196 195 @Test
197 196 void replaysLateBindingsAndCallbacksAfterRegistration() throws Exception {
198 197 writeFile(SETTINGS_FILE, ROOT_NAME);
199 198 writeFile(BUILD_FILE, """
200 199 plugins {
201 200 id 'org.implab.gradle-variants-sources'
202 201 }
203 202
204 203 variants {
205 204 layer('main')
206 205 variant('browser') {
207 206 role('main') { layers('main') }
208 207 }
209 208 }
210 209
211 210 def events = []
212 211
213 212 afterEvaluate {
214 213 variantSources {
215 214 bind('main') {
216 215 configureSourceSet {
217 216 declareOutputs('late')
218 217 }
219 218 }
220 219
221 220 bind('main').whenRegistered { ctx ->
222 221 events << "layer:${ctx.sourceSetName()}"
223 222 }
224 223
225 224 whenRegistered { ctx ->
226 225 events << "global:${ctx.sourceSetName()}"
227 226 }
228 227 }
229 228 }
230 229
231 230 tasks.register('probe') {
232 231 doLast {
233 232 def ss = sources.getByName('browserMain')
234 233 ss.output('late')
235 234 println("events=" + events.sort().join('|'))
236 235 println('lateReplay=ok')
237 236 }
238 237 }
239 238 """);
240 239
241 240 BuildResult result = runner("probe").build();
242 241 assertTrue(result.getOutput().contains("events=global:browserMain|layer:browserMain"));
243 242 assertTrue(result.getOutput().contains("lateReplay=ok"));
244 243 }
245 244
246 245 @Test
247 246 void supportsSourceSetNamePatternAndSharedRegistration() throws Exception {
248 247 writeFile(SETTINGS_FILE, ROOT_NAME);
249 248 writeFile(BUILD_FILE, """
250 249 plugins {
251 250 id 'org.implab.gradle-variants-sources'
252 251 }
253 252
254 253 variants {
255 254 layer('main')
256 255
257 256 variant('browser') {
258 257 role('main') { layers('main') }
259 258 }
260 259
261 260 variant('node') {
262 261 role('main') { layers('main') }
263 262 }
264 263 }
265 264
266 265 def registeredEvents = []
267 def browserRegisteredEvents = []
268 266 def boundEvents = []
269 267 def browserBoundEvents = []
268 def localRegisteredEvents = []
270 269 def localBoundEvents = []
271 270
272 271 variantSources {
273 272 bind('main').sourceSetNamePattern = '{layer}'
274 273
275 274 bind('main') {
276 275 configureSourceSet {
277 276 declareOutputs('compiled')
278 277 }
279 278 }
280 279
281 280 bind('main') {
281 whenRegistered {
282 localRegisteredEvents << "${layerName()}:${sourceSetName()}"
283 }
284 }
285
286 bind('main') {
282 287 whenBound {
283 288 localBoundEvents << "${variantName()}:${roleName()}:${layerName()}:${sourceSetName()}"
284 289 }
285 290 }
286 291
287 292 whenRegistered { ctx ->
288 registeredEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}"
289 }
290
291 whenRegistered('browser') { ctx ->
292 browserRegisteredEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}"
293 registeredEvents << "${ctx.layerName()}:${ctx.sourceSetName()}"
293 294 }
294 295
295 296 whenBound { ctx ->
296 297 boundEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}"
297 298 }
298 299
299 300 whenBound('browser') { ctx ->
300 301 browserBoundEvents << "${ctx.variantName()}:${ctx.roleName()}:${ctx.layerName()}:${ctx.sourceSetName()}"
301 302 }
302 303 }
303 304
304 305 tasks.register('probe') {
305 306 doLast {
306 307 println("sources=" + sources.collect { it.name }.sort().join(','))
307 308
308 309 def main = sources.getByName('main')
309 310 main.output('compiled')
310 311
311 312 println("registered=" + registeredEvents.sort().join('|'))
312 println("browserRegistered=" + browserRegisteredEvents.sort().join('|'))
313 println("localRegistered=" + localRegisteredEvents.sort().join('|'))
313 314 println("bound=" + boundEvents.sort().join('|'))
314 315 println("browserBound=" + browserBoundEvents.sort().join('|'))
315 316 println("localBound=" + localBoundEvents.sort().join('|'))
316 317 println('sharedPattern=ok')
317 318 }
318 319 }
319 320 """);
320 321
321 322 BuildResult result = runner("probe").build();
322 323 assertTrue(result.getOutput().contains("sources=main"));
323 assertTrue(result.getOutput().contains("registered=browser:main:main:main"));
324 assertTrue(result.getOutput().contains("browserRegistered=browser:main:main:main"));
324 assertTrue(result.getOutput().contains("registered=main:main"));
325 assertTrue(result.getOutput().contains("localRegistered=main:main"));
325 326 assertTrue(result.getOutput().contains("bound=browser:main:main:main|node:main:main:main"));
326 327 assertTrue(result.getOutput().contains("browserBound=browser:main:main:main"));
327 328 assertTrue(result.getOutput().contains("localBound=browser:main:main:main|node:main:main:main"));
328 329 assertTrue(result.getOutput().contains("sharedPattern=ok"));
329 330 }
330 331
331 332 private GradleRunner runner(String... arguments) {
332 333 return GradleRunner.create()
333 334 .withProjectDir(testProjectDir.toFile())
334 335 .withPluginClasspath(pluginClasspath())
335 336 .withArguments(arguments)
336 337 .forwardOutput();
337 338 }
338 339
339 340 private static List<File> pluginClasspath() {
340 341 try {
341 342 var classesDir = Path.of(VariantsSourcesPlugin.class
342 343 .getProtectionDomain()
343 344 .getCodeSource()
344 345 .getLocation()
345 346 .toURI());
346 347
347 348 var markerResource = VariantsSourcesPlugin.class.getClassLoader()
348 349 .getResource("META-INF/gradle-plugins/org.implab.gradle-variants-sources.properties");
349 350
350 351 assertNotNull(markerResource, "Plugin marker resource is missing from test classpath");
351 352
352 353 var markerPath = Path.of(markerResource.toURI());
353 354 var resourcesDir = markerPath.getParent().getParent().getParent();
354 355
355 356 return List.of(classesDir.toFile(), resourcesDir.toFile());
356 357 } catch (Exception e) {
357 358 throw new RuntimeException("Unable to build plugin classpath for test", e);
358 359 }
359 360 }
360 361
361 362 private void writeFile(String relativePath, String content) throws IOException {
362 363 Path path = testProjectDir.resolve(relativePath);
363 364 Files.createDirectories(path.getParent());
364 365 Files.writeString(path, content);
365 366 }
366 367 }
@@ -1,145 +1,155
1 1 # Variant Sources Plugin
2 2
3 3 ## NAME
4 4
5 5 `VariantsSourcesPlugin` и extension `variantSources`.
6 6
7 7 ## SYNOPSIS
8 8
9 9 ```groovy
10 10 plugins {
11 11 id 'org.implab.gradle-variants-sources'
12 12 }
13 13
14 14 variants {
15 15 layer('main')
16 16
17 17 variant('browser') {
18 18 role('main') { layers('main') }
19 19 }
20 20
21 21 variant('node') {
22 22 role('main') { layers('main') }
23 23 }
24 24 }
25 25
26 26 variantSources {
27 27 bind('main').sourceSetNamePattern = '{layer}'
28 28
29 29 bind('main') {
30 30 configureSourceSet {
31 31 declareOutputs('compiled')
32 32 }
33 33 }
34 34
35 35 whenRegistered { sourceSetName() }
36 36 whenBound('browser') { roleName() }
37 37 }
38 38 ```
39 39
40 40 ## DESCRIPTION
41 41
42 42 `VariantsSourcesPlugin` применяет `VariantsPlugin` и `SourcesPlugin`, затем
43 43 регистрирует source sets из модели `variants`.
44 44
45 45 Точка запуска registration:
46 46
47 47 - `variants.whenFinalized(model -> registerSourceSets(...))`
48 48
49 49 ### registration
50 50
51 51 Для каждой usage-связки `variant/role/layer` вычисляется имя source set,
52 52 регистрируется `GenericSourceSet` (если он еще не существует), затем
53 53 вызываются callbacks.
54 54
55 55 ### binding
56 56
57 57 `bind('<layer>')` возвращает `BuildLayerBinding` и задает policy для этого
58 58 слоя:
59 59
60 60 - как именовать source set;
61 61 - как конфигурировать source set;
62 62 - какие callbacks вызвать на registration/binding.
63 63
64 64 ### sourceSetNamePattern
65 65
66 66 `sourceSetNamePattern` определяет naming policy зарегистрированного source set.
67 67
68 68 Default:
69 69
70 70 - `{variant}{layerCap}`
71 71
72 72 Tokens:
73 73
74 74 - `{variant}`, `{variantCap}`
75 75 - `{role}`, `{roleCap}`
76 76 - `{layer}`, `{layerCap}`
77 77
78 78 Имя санитизируется (`[^A-Za-z0-9_.-] -> _`).
79 79
80 80 Ограничение:
81 81
82 82 - один `sourceSetName` не может быть порожден разными слоями.
83 83
84 84 ## EVENTS
85 85
86 86 ### whenRegistered
87 87
88 88 - callback на новый уникальный source set;
89 89 - replayable;
90 90 - при shared source set срабатывает один раз.
91 91
92 92 ### whenBound
93 93
94 94 - callback на каждую usage-связку `variant/role/layer`;
95 95 - replayable;
96 96 - подходит для per-usage логики.
97 97
98 98 ### variant filter
99 99
100 Глобальные callbacks поддерживают фильтр по варианту:
100 Фильтр по варианту поддерживает только usage-binding:
101 101
102 - `whenRegistered(String variantName, ...)`
103 102 - `whenBound(String variantName, ...)`
104 103
105 ## SOURCE SET CONTEXT
104 ## PAYLOAD TYPES
105
106 `SourceSetRegistration` содержит:
106 107
107 `SourceSetContext` содержит:
108 - `layerName`, `sourceSetName`;
109 - `sourceSet` (`NamedDomainObjectProvider<GenericSourceSet>`).
110
111 Sugar:
112
113 - `configureSourceSet(Action|Closure)`.
114
115 `SourceSetUsageBinding` содержит:
108 116
109 117 - `variantName`, `roleName`, `layerName`, `sourceSetName`;
110 118 - `sourceSet` (`NamedDomainObjectProvider<GenericSourceSet>`).
111 119
112 120 Sugar:
113 121
114 122 - `configureSourceSet(Action|Closure)`.
115 123
116 124 ## API
117 125
118 126 ### VariantSourcesExtension
119 127
120 128 - `bind(String)` — получить/создать binding по имени слоя.
121 129 - `bind(String, Action|Closure)` — сконфигурировать binding.
122 130 - `bindings(Action|Closure)` — контейнерная конфигурация bindings.
123 131 - `whenRegistered(...)` — глобальные callbacks регистрации source set.
124 132 - `whenBound(...)` — глобальные callbacks usage-binding.
133 - `whenBound(String variantName, ...)` — usage-binding callbacks с variant-filter.
125 134
126 135 ### BuildLayerBinding
127 136
128 137 - `sourceSetNamePattern` — naming policy для source set слоя.
129 138 - `configureSourceSet(...)` — слойная конфигурация `GenericSourceSet`.
130 139 - `whenRegistered(...)` — callbacks регистрации в рамках слоя.
131 140 - `whenBound(...)` — callbacks usage-binding в рамках слоя.
132 141
133 142 ## KEY CLASSES
134 143
135 144 - `VariantsSourcesPlugin` — точка входа plugin adapter.
136 145 - `VariantSourcesExtension` — глобальный DSL bind/events.
137 146 - `BuildLayerBinding` — layer-local policy и callbacks.
138 - `SourceSetContext` — payload callbacks и sugar-конфигурирование.
147 - `SourceSetRegistration` — payload регистрации source set.
148 - `SourceSetUsageBinding` — payload usage-binding.
139 149
140 150 ## NOTES
141 151
142 152 - `sourceSetNamePattern` фиксируется при первом чтении в registration
143 153 (`finalizeValueOnRead`).
144 154 - Closure callbacks используют delegate-first.
145 155 - Для вложенных closure лучше явный параметр (`ctx -> ...`).
1 NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now