diff --git a/common/build.gradle b/common/build.gradle --- a/common/build.gradle +++ b/common/build.gradle @@ -14,9 +14,8 @@ java { dependencies { compileOnly libs.jdt.annotations - implementation libs.bundles.jackson - - api gradleApi() + api gradleApi(), + libs.bundles.jackson } task printVersion{ diff --git a/common/src/main/java/org/implab/gradle/common/SourcesPlugin.java b/common/src/main/java/org/implab/gradle/common/SourcesPlugin.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/SourcesPlugin.java @@ -0,0 +1,60 @@ +package org.implab.gradle.common; + +import org.gradle.api.Action; +import org.gradle.api.Plugin; +import org.gradle.api.Project; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.gradle.api.tasks.Copy; +import org.gradle.api.tasks.Delete; +import org.gradle.api.tasks.TaskContainer; +import org.gradle.api.tasks.TaskProvider; +import org.gradle.language.base.plugins.LifecycleBasePlugin; +import org.implab.gradle.common.files.GenericSourceSet; +import org.implab.gradle.common.utils.Strings; + +/** + * This plugin creates a {@code sources} extension which is + * a container for {@link GenericSourceSet}. + * + */ +public abstract class SourcesPlugin implements Plugin { + private final Logger logger = Logging.getLogger(SourcesPlugin.class); + + private final String POLICY_NAME = "SourcesPolicy"; + + private final String EXTENSION_NAME = "sources"; + + @Override + public void apply(Project target) { + target.getPlugins().apply(LifecycleBasePlugin.class); + + var sources = target.getObjects().domainObjectContainer(GenericSourceSet.class); + var tasks = target.getTasks(); + var clean = tasks.named(LifecycleBasePlugin.CLEAN_TASK_NAME); + + target.getExtensions().add(EXTENSION_NAME, sources); + + sources.configureEach(configureSourceSet(tasks, clean)); + } + + private Action configureSourceSet(TaskContainer tasks, TaskProvider clean) { + return sourceSet -> { + var name = sourceSet.getName(); + + logger.info("{}: Processing source set '{}'", POLICY_NAME, name); + + var taskName = "clean" + Strings.capitalize(sourceSet.getName()); + + logger.info("{}: Register task '{}' of type [{}]", POLICY_NAME, taskName, Copy.class.getTypeName()); + + var task = tasks.register(taskName, Delete.class, self -> { + self.delete(sourceSet.getOutputsDir()); + }); + + clean.configure(self -> { + self.dependsOn(task); + }); + }; + } +} diff --git a/common/src/main/java/org/implab/gradle/common/dsl/RedirectFromSpec.java b/common/src/main/java/org/implab/gradle/common/dsl/RedirectFromSpec.java --- a/common/src/main/java/org/implab/gradle/common/dsl/RedirectFromSpec.java +++ b/common/src/main/java/org/implab/gradle/common/dsl/RedirectFromSpec.java @@ -6,9 +6,13 @@ import java.util.Optional; import java.util.function.Supplier; import org.gradle.api.provider.Provider; +import org.gradle.util.Configurable; import org.implab.gradle.common.exec.RedirectFrom; +import org.implab.gradle.common.utils.Closures; -public class RedirectFromSpec { +import groovy.lang.Closure; + +public class RedirectFromSpec implements Configurable { private Supplier streamRedirect; public boolean isRedirected() { @@ -46,4 +50,12 @@ public class RedirectFromSpec { public void empty() { streamRedirect = () -> null; } + + @Override + public RedirectFromSpec configure(Closure cl) { + Closures.apply(cl, this); + return this; + } + + } diff --git a/common/src/main/java/org/implab/gradle/common/dsl/RedirectToSpec.java b/common/src/main/java/org/implab/gradle/common/dsl/RedirectToSpec.java --- a/common/src/main/java/org/implab/gradle/common/dsl/RedirectToSpec.java +++ b/common/src/main/java/org/implab/gradle/common/dsl/RedirectToSpec.java @@ -9,10 +9,14 @@ import java.util.function.Supplier; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.gradle.api.provider.Provider; +import org.gradle.util.Configurable; import org.implab.gradle.common.exec.RedirectTo; +import org.implab.gradle.common.utils.Closures; + +import groovy.lang.Closure; @NonNullByDefault -public class RedirectToSpec { +public class RedirectToSpec implements Configurable { private Supplier streamRedirect; public boolean isRedirected() { @@ -62,4 +66,10 @@ public class RedirectToSpec { public void discard() { streamRedirect = () -> null; } + + @Override + public RedirectToSpec configure(Closure cl) { + Closures.apply(cl, this); + return this; + } } diff --git a/common/src/main/java/org/implab/gradle/common/dsl/ShellSpecMixin.java b/common/src/main/java/org/implab/gradle/common/dsl/ShellSpecMixin.java deleted file mode 100644 --- a/common/src/main/java/org/implab/gradle/common/dsl/ShellSpecMixin.java +++ /dev/null @@ -1,15 +0,0 @@ -package org.implab.gradle.common.dsl; - -import org.implab.gradle.common.exec.Shell; - -public interface ShellSpecMixin { - void setShell(Shell shell); - - default void useEchoShell() { - setShell(Shell.echo()); - } - - default void useSystemShell() { - setShell(Shell.system()); - } -} diff --git a/common/src/main/java/org/implab/gradle/common/files/GenericSourceSet.java b/common/src/main/java/org/implab/gradle/common/files/GenericSourceSet.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/files/GenericSourceSet.java @@ -0,0 +1,136 @@ +package org.implab.gradle.common.files; + +import java.io.File; +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +import org.gradle.api.Named; +import org.gradle.api.NamedDomainObjectContainer; +import org.gradle.api.file.DirectoryProperty; +import org.gradle.api.file.FileCollection; +import org.gradle.api.file.ProjectLayout; +import org.gradle.api.file.SourceDirectorySet; +import org.gradle.api.logging.Logger; +import org.gradle.api.logging.Logging; +import org.gradle.api.model.ObjectFactory; +import org.gradle.util.Configurable; +import org.implab.gradle.common.utils.Closures; + +import groovy.lang.Closure; + +public abstract class GenericSourceSet + implements Named, Configurable { + private final Logger logger = Logging.getLogger(GenericSourceSet.class); + + private final String name; + + private final NamedDomainObjectContainer sourceDirectorySets; + + private final NamedDomainObjectContainer outputs; + + private final FileCollection allOutputs; + + private final FileCollection allSourceDirectories; + + private final ObjectFactory objects; + + private final Set declaredOutputs = new HashSet<>(); + + @Inject + public GenericSourceSet(String name, ObjectFactory objects, ProjectLayout layout) { + this.name = name; + this.objects = objects; + + sourceDirectorySets = objects.domainObjectContainer( + SourceDirectorySet.class, + this::createSourceDirectorySet); + + outputs = objects.domainObjectContainer(GenericSourceSetOutput.class); + + allSourceDirectories = objects.fileCollection().from(sourceDirectoriesProvider()); + + allOutputs = objects.fileCollection().from(outputsProvider()); + + outputs.addRule("Register a declared set on demand", this::registerOutputOnDemand); + + getSourceSetDir().convention(layout + .getProjectDirectory() + .dir(Paths.get("src", name).toString())); + + getOutputsDir().convention(layout + .getBuildDirectory() + .dir(name)); + } + + @Override + public String getName() { + return name; + } + + public abstract DirectoryProperty getSourceSetDir(); + + public abstract DirectoryProperty getOutputsDir(); + + public NamedDomainObjectContainer getSets() { + return sourceDirectorySets; + } + + public NamedDomainObjectContainer getOutputs() { + return outputs; + } + + public FileCollection getAllOutputs() { + return allOutputs; + } + + public FileCollection getAllSourceDirectories() { + return allSourceDirectories; + } + + public FileCollection output(String name) { + return getOutputs().getAt(name).getFileCollection(); + } + + public void declareOutputs(String name, String... extra) { + declaredOutputs.add(Objects.requireNonNull(name, "declareOutputs: The output name cannot be null")); + for (var x : extra) + declaredOutputs.add(Objects.requireNonNull(x, "declareOutputs: The output name cannot be null")); + } + + @Override + public GenericSourceSet configure(@SuppressWarnings("rawtypes") Closure configure) { + Closures.apply(configure, this); + return this; + } + + private void registerOutputOnDemand(String outputName) { + logger.info("SourceSet '{}': registerOutputOnDemand '{}'", name, outputName); + + if (declaredOutputs.contains(outputName)) + outputs.register(outputName); + } + + private SourceDirectorySet createSourceDirectorySet(String name) { + return objects.sourceDirectorySet(name, name); + } + + private Callable> outputsProvider() { + return () -> outputs.stream() + .map(GenericSourceSetOutput::getFileCollection) + .toList(); + } + + private Callable> sourceDirectoriesProvider() { + return () -> sourceDirectorySets.stream() + .flatMap(x -> x.getSrcDirs().stream()) + .collect(Collectors.toSet()); + } + +} diff --git a/common/src/main/java/org/implab/gradle/common/files/GenericSourceSetOutput.java b/common/src/main/java/org/implab/gradle/common/files/GenericSourceSetOutput.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/files/GenericSourceSetOutput.java @@ -0,0 +1,46 @@ +package org.implab.gradle.common.files; + +import org.gradle.api.Action; +import org.gradle.api.Named; +import org.gradle.api.file.ConfigurableFileCollection; +import org.gradle.api.file.FileCollection; +import org.gradle.api.model.ObjectFactory; +import org.gradle.util.Configurable; +import org.implab.gradle.common.utils.Closures; + +import groovy.lang.Closure; + +/** Simple wrapper to add {@link Named} to {@link FileCollection} */ +public abstract class GenericSourceSetOutput + implements Named, Configurable { + + private final String name; + + private final ConfigurableFileCollection outputFileCollection; + + public GenericSourceSetOutput(String name, ObjectFactory objects) { + this.name = name; + outputFileCollection = objects.fileCollection(); + } + + public FileCollection getFileCollection() { + return outputFileCollection; + } + + @Override + public String getName() { + return name; + } + + @Override + public GenericSourceSetOutput configure(@SuppressWarnings("rawtypes") Closure cl) { + Closures.apply(cl, outputFileCollection); + return this; + } + + public GenericSourceSetOutput configure(Action cl) { + cl.execute(outputFileCollection); + return this; + } + +} diff --git a/common/src/main/java/org/implab/gradle/common/json/DefaultJsonArraySpec.java b/common/src/main/java/org/implab/gradle/common/json/DefaultJsonArraySpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/DefaultJsonArraySpec.java @@ -0,0 +1,29 @@ +package org.implab.gradle.common.json; + + +import org.gradle.api.provider.Provider; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +public class DefaultJsonArraySpec implements JsonArraySpec { + + private final List values = new ArrayList<>(); + + @Override + public void add(Object value) { + if (value instanceof Provider) { + throw new IllegalArgumentException( + "Providers are not allowed inside JSON arrays; " + + "use top-level metadata.set(\"key\", provider) instead." + ); + } + values.add(value); + } + + public List toList() { + return Collections.unmodifiableList(values); + } +} \ No newline at end of file diff --git a/common/src/main/java/org/implab/gradle/common/json/DefaultJsonObjectSpec.java b/common/src/main/java/org/implab/gradle/common/json/DefaultJsonObjectSpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/DefaultJsonObjectSpec.java @@ -0,0 +1,26 @@ +package org.implab.gradle.common.json; + +import org.gradle.api.provider.Provider; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class DefaultJsonObjectSpec implements GroovyObjectSpec { + + private final Map values = new LinkedHashMap<>(); + + @Override + public void set(String key, Object value) { + if (value instanceof Provider) { + throw new IllegalArgumentException( + "Providers are not allowed inside nested JSON objects; " + + "use top-level metadata.set(\"" + key + "\", provider) instead."); + } + values.put(key, value); + } + + public Map toMap() { + return Collections.unmodifiableMap(values); + } +} \ No newline at end of file diff --git a/common/src/main/java/org/implab/gradle/common/json/GroovyObjectSpec.java b/common/src/main/java/org/implab/gradle/common/json/GroovyObjectSpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/GroovyObjectSpec.java @@ -0,0 +1,38 @@ +package org.implab.gradle.common.json; + +import java.util.Arrays; + +import groovy.lang.Closure; +import groovy.lang.MissingMethodException; + +public interface GroovyObjectSpec extends JsonObjectSpec { + + default void propertyMissing(String name, Object value) { + set(name, value); + } + + default Object methodMissing(String name, Object args) { + Object[] arr = (Object[]) args; + + // author { ... }, repository { ... } + if (arr.length == 1 && arr[0] instanceof Closure) { + DefaultJsonObjectSpec spec = new DefaultJsonObjectSpec(); + Closure cl = (Closure) arr[0]; + cl.setDelegate(spec); + cl.setResolveStrategy(Closure.DELEGATE_FIRST); + cl.call(); + set(name, spec.toMap()); + return null; + } else { + boolean hasClosure = Arrays.stream(arr) + .anyMatch(a -> a instanceof Closure); + + if (!hasClosure) { + set(name, Arrays.asList(arr)); + return null; + } + } + + throw new MissingMethodException(name, getClass(), arr); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/utils/Json.java b/common/src/main/java/org/implab/gradle/common/json/Json.java rename from common/src/main/java/org/implab/gradle/common/utils/Json.java rename to common/src/main/java/org/implab/gradle/common/json/Json.java --- a/common/src/main/java/org/implab/gradle/common/utils/Json.java +++ b/common/src/main/java/org/implab/gradle/common/json/Json.java @@ -1,8 +1,10 @@ -package org.implab.gradle.common.utils; +package org.implab.gradle.common.json; import java.io.File; import java.io.IOException; +import java.util.Map; import java.util.function.Consumer; +import java.util.function.Function; import org.implab.gradle.common.utils.LazyValue; @@ -37,8 +39,8 @@ public class Json { return mapper.get(); } - public static Consumer fileWriter(Object value) { - return file -> { + public static Consumer fileWriter(File file) { + return value -> { try { jsonMapper().writeValue(file, value); } catch (IOException e) { @@ -79,6 +81,15 @@ public class Json { } } + public static Map parseMap(String json, Class clazz) { + return parse(json, new TypeReference<>() { + }); + } + + public static Function> mapParser(Class clazz) { + return json -> parseMap(json, clazz); + } + public static T read(File file, TypeReference type) { try { return jsonMapper().readValue(file, type); @@ -87,6 +98,11 @@ public class Json { } } + public static Map readMap(File file, Class clazz) { + return read(file, new TypeReference<>() { + }); + } + public static T updateValue(T target, Object source) { try { return jsonMapper().updateValue(target, source); diff --git a/common/src/main/java/org/implab/gradle/common/json/JsonArraySpec.java b/common/src/main/java/org/implab/gradle/common/json/JsonArraySpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/JsonArraySpec.java @@ -0,0 +1,20 @@ +package org.implab.gradle.common.json; + +import org.gradle.api.Action; + +public interface JsonArraySpec { + + void add(Object value); + + default void obj(Action action) { + DefaultJsonObjectSpec child = new DefaultJsonObjectSpec(); + action.execute(child); + add(child.toMap()); + } + + default void arr(Action action) { + DefaultJsonArraySpec child = new DefaultJsonArraySpec(); + action.execute(child); + add(child.toList()); + } +} \ No newline at end of file diff --git a/common/src/main/java/org/implab/gradle/common/json/JsonMapSpec.java b/common/src/main/java/org/implab/gradle/common/json/JsonMapSpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/JsonMapSpec.java @@ -0,0 +1,23 @@ +package org.implab.gradle.common.json; + +import java.io.File; + +import org.gradle.api.Action; +import org.gradle.api.file.RegularFile; +import org.gradle.api.provider.Provider; + +public interface JsonMapSpec extends GroovyObjectSpec { + void from(File file, Action action); + + default void from(File file) { + from(file, spec -> { + }); + } + + void from(Provider fileProvider, Action action); + + default void from(Provider fileProvider) { + from(fileProvider, spec -> { + }); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/json/JsonObjectSpec.java b/common/src/main/java/org/implab/gradle/common/json/JsonObjectSpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/JsonObjectSpec.java @@ -0,0 +1,20 @@ +package org.implab.gradle.common.json; + +import org.gradle.api.Action; + +public interface JsonObjectSpec { + + void set(String key, Object value); + + default void obj(String key, Action action) { + DefaultJsonObjectSpec child = new DefaultJsonObjectSpec(); + action.execute(child); + set(key, child.toMap()); + } + + default void arr(String key, Action action) { + DefaultJsonArraySpec child = new DefaultJsonArraySpec(); + action.execute(child); + set(key, child.toList()); + } +} \ No newline at end of file diff --git a/common/src/main/java/org/implab/gradle/common/json/MapImportSpec.java b/common/src/main/java/org/implab/gradle/common/json/MapImportSpec.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/json/MapImportSpec.java @@ -0,0 +1,66 @@ +package org.implab.gradle.common.json; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.gradle.api.Action; +import org.implab.gradle.common.utils.Closures; + +import groovy.lang.Closure; + +/** + * Простая include/exclude-маска по ключам верхнего уровня. + */ +public class MapImportSpec { + + private final Set includes = new LinkedHashSet<>(); + private final Set excludes = new LinkedHashSet<>(); + + public void include(String... keys) { + includes.addAll(Arrays.asList(keys)); + } + + public void exclude(String... keys) { + excludes.addAll(Arrays.asList(keys)); + } + + public Set getIncludes() { + return includes; + } + + public Set getExcludes() { + return excludes; + } + + public boolean hasIncludes() { + return !includes.isEmpty(); + } + + public boolean isExcluded(String key) { + return excludes.contains(key); + } + + public boolean isIncluded(String key) { + return !hasIncludes() || includes.contains(key); + } + + public boolean matches(String key) { + return isIncluded(key) && !isExcluded(key); + } + + public Predicate toPredicate() { + return this::matches; + } + + public static Predicate buildPredicate(Action action) { + var spec = new MapImportSpec(); + action.execute(spec); + return spec.toPredicate(); + } + + public static Predicate buildPredicate(Closure closure) { + return buildPredicate(Closures.action(closure)); + } +} \ No newline at end of file diff --git a/common/src/main/java/org/implab/gradle/common/tasks/WriteJson.java b/common/src/main/java/org/implab/gradle/common/tasks/WriteJson.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/tasks/WriteJson.java @@ -0,0 +1,181 @@ +package org.implab.gradle.common.tasks; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.gradle.api.Action; +import org.gradle.api.DefaultTask; +import org.gradle.api.file.RegularFile; +import org.gradle.api.file.RegularFileProperty; +import org.gradle.api.provider.MapProperty; +import org.gradle.api.provider.Provider; +import org.gradle.api.provider.ProviderFactory; +import org.gradle.api.tasks.Input; +import org.gradle.api.tasks.Internal; +import org.gradle.api.tasks.Optional; +import org.gradle.api.tasks.OutputFile; +import org.gradle.api.tasks.SkipWhenEmpty; +import org.gradle.api.tasks.TaskAction; +import org.implab.gradle.common.json.MapImportSpec; +import org.implab.gradle.common.json.Json; +import org.implab.gradle.common.json.JsonObjectSpec; +import org.implab.gradle.common.utils.Closures; +import org.implab.gradle.common.utils.Properties; + +import groovy.lang.Closure; + +/** + * A Gradle task that writes JSON content to a file. + * + * This task allows you to build up a JSON object through various methods and + * write it to a file. + * You can add content directly, import from other JSON files, or merge in map + * data with optional filtering. + * + *

Usage Example:

+ * + *
+ * tasks.register('generateConfig', WriteJson) {
+ *     outputFile = file('build/config.json')
+ *     content {
+ *         version = '1.0.0'
+ *         name = 'myapp'
+ *     }
+ *     from file('src/base-config.json')
+ *     putAll providers.provider { ['debug': true] }
+ * }
+ * 
+ * + *

Properties:

+ *
    + *
  • content - A map of string keys to object values that + * will be serialized to JSON
  • + *
  • outputFile - The file where the JSON will be + * written
  • + *
+ * + *

Methods:

+ *
    + *
  • content() - Configure content using a closure or + * action
  • + *
  • from() - Import JSON content from a file with optional + * key filtering
  • + *
  • putAll() - Merge a map provider with optional key + * filtering
  • + *
+ * + * @since 1.0 + */ +@NonNullByDefault +public abstract class WriteJson extends DefaultTask { + + private final ProviderFactory providers; + + private final JsonObjectSpec contentSpec = new JsonObjectSpec() { + @Override + public void set(String key, Object value) { + Properties.putMapEntry(getContent(), key, value); + } + }; + + @Input + @Optional + public abstract MapProperty getContent(); + + @OutputFile + public abstract RegularFileProperty getOutputFile(); + + @Internal + public Provider getJson() { + return getContent().map(Json::stringify); + } + + @Inject + public WriteJson(ProviderFactory providers) { + this.providers = providers; + } + + public void content(Action configure) { + configure.execute(contentSpec); + } + + public void content(Closure configure) { + Closures.apply(configure, contentSpec); + } + + public void from(RegularFile file, Closure configure) { + importContents(file, MapImportSpec.buildPredicate(configure)); + } + + public void from(RegularFile file, Action configure) { + importContents(file, MapImportSpec.buildPredicate(configure)); + } + + public void from(RegularFile file) { + importContents(file, x -> true); + } + + public void from(Provider file, Closure configure) { + importContents(file, MapImportSpec.buildPredicate(configure)); + } + + public void from(Provider file, Action configure) { + importContents(file, MapImportSpec.buildPredicate(configure)); + } + + public void from(Provider file) { + importContents(file, x -> true); + } + + public void putAll(Provider> mapProvider, Action configure) { + importMap(mapProvider, MapImportSpec.buildPredicate(configure)); + } + + public void putAll(Provider> mapProvider, Closure configure) { + importMap(mapProvider, MapImportSpec.buildPredicate(configure)); + } + + public void putAll(Provider> mapProvider) { + importMap(mapProvider, x -> true); + } + + private void importContents(Provider file, Predicate keyPredicate) { + var jsonProvider = providers.fileContents(file).getAsText() + .map(Json.mapParser(Object.class)::apply); + + importMap(jsonProvider, keyPredicate); + } + + private void importContents(RegularFile file, Predicate keyPredicate) { + var jsonProvider = providers.fileContents(file).getAsText() + .map(Json.mapParser(Object.class)::apply); + + importMap(jsonProvider, keyPredicate); + } + + private void importMap(Provider> mapProvider, Predicate keyPredicate) { + var filteredProvider = mapProvider.map(v -> v.entrySet().stream() + .filter(pair -> keyPredicate.test(pair.getKey()) && Objects.nonNull(pair.getValue())) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> b, + LinkedHashMap::new))); + + getContent().putAll(filteredProvider); + } + + @TaskAction + public void run() { + var data = getContent().getOrElse(Map.of()); + var file = getOutputFile().getAsFile().get(); + Json.write(file, data); + } + +} diff --git a/common/src/main/java/org/implab/gradle/common/utils/Closures.java b/common/src/main/java/org/implab/gradle/common/utils/Closures.java --- a/common/src/main/java/org/implab/gradle/common/utils/Closures.java +++ b/common/src/main/java/org/implab/gradle/common/utils/Closures.java @@ -27,6 +27,5 @@ public final class Closures { c.setResolveStrategy(Closure.DELEGATE_FIRST); c.setDelegate(target); c.call(target); - } } diff --git a/common/src/main/java/org/implab/gradle/common/utils/Extensions.java b/common/src/main/java/org/implab/gradle/common/utils/Extensions.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/utils/Extensions.java @@ -0,0 +1,15 @@ +package org.implab.gradle.common.utils; + +import java.util.function.Consumer; + +import org.gradle.api.plugins.ExtensionContainer; + +public final class Extensions { + private Extensions() { + } + + public static Consumer> registerClass(ExtensionContainer extensions) { + var extra = extensions.getExtraProperties(); + return clazz -> extra.set(clazz.getSimpleName(), clazz); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/utils/JsonDelegate.java b/common/src/main/java/org/implab/gradle/common/utils/JsonDelegate.java deleted file mode 100644 --- a/common/src/main/java/org/implab/gradle/common/utils/JsonDelegate.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.implab.gradle.common.utils; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Objects; -import java.util.function.Function; -import java.util.stream.Stream; - -import groovy.lang.Closure; -import groovy.lang.GroovyObjectSupport; - -public class JsonDelegate extends GroovyObjectSupport { - private final Map content; - - private JsonDelegate() { - content = new LinkedHashMap<>(); - } - - private JsonDelegate(Map content) { - this.content = new LinkedHashMap<>(content); - } - - public Object invokeMethod(String name, Object args) { - Object val = null; - if (args != null && Object[].class.isAssignableFrom(args.getClass())) { - Object[] arr = (Object[]) args; - if (arr.length == 1) { - val = processValue(arr[0], content.get(name)); - } else if (arr.length == 2 && arr[1] instanceof Closure c) { - if (arr[0] instanceof Iterable iter) { - val = processStream(Values.stream(iter.iterator()), c); - } else if(arr[0] != null && arr[0].getClass().isArray()) { - val = processStream(Stream.of((Object)arr[0]), c); - } else { - val = arr; - } - } else { - val = arr; - } - } - - this.content.put(name, val); - return val; - } - - private Object processValue(Object value, Object original) { - if (value instanceof Closure c) { - return Objects.nonNull(original) ? of(c, original) : of(c); - } else { - return value; - } - } - - private Object processStream(Stream stream, Closure c) { - return stream.map(transform(c)).toArray(); - } - - public static Map of(Closure c) { - return invoke((Closure) c.clone(), new JsonDelegate()); - } - - public static Function> transform(Closure c) { - return o -> of(c, o); - } - - public static Map of(Closure c, Object o) { - return invoke((Closure) c.curry(o), new JsonDelegate()); - } - - public static Map with(Closure c, Map m) { - return invoke((Closure) c.clone(), new JsonDelegate(m)); - } - - private static Map invoke(Closure c, JsonDelegate d) { - c.setDelegate(d); - c.setResolveStrategy(1); - c.call(); - return d.getContent(); - } - - public Map getContent() { - return this.content; - } -} diff --git a/common/src/main/java/org/implab/gradle/common/utils/OperatingSystem.java b/common/src/main/java/org/implab/gradle/common/utils/OperatingSystem.java --- a/common/src/main/java/org/implab/gradle/common/utils/OperatingSystem.java +++ b/common/src/main/java/org/implab/gradle/common/utils/OperatingSystem.java @@ -7,8 +7,6 @@ import org.implab.gradle.common.utils.os public interface OperatingSystem { - public static OperatingSystem CURRENT = SystemResolver.current(); - public static final String WINDOWS_FAMILY = "windows"; public static final String LINUX_FAMILY = "linux"; @@ -30,6 +28,6 @@ public interface OperatingSystem { Optional which(String cmd, Iterable paths); public static OperatingSystem current() { - return CURRENT; + return SystemResolver.current(); } } diff --git a/common/src/main/java/org/implab/gradle/common/utils/Properties.java b/common/src/main/java/org/implab/gradle/common/utils/Properties.java --- a/common/src/main/java/org/implab/gradle/common/utils/Properties.java +++ b/common/src/main/java/org/implab/gradle/common/utils/Properties.java @@ -3,7 +3,6 @@ package org.implab.gradle.common.utils; import java.util.HashMap; import java.util.Map; import java.util.function.Function; - import org.gradle.api.Action; import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.MapProperty; @@ -17,7 +16,24 @@ public final class Properties { mergeMap(property, map, Function.identity()); } - public static void mergeMap(MapProperty property, Map map, Function mapper) { + public static void putMapEntry(MapProperty property, K key, Object value, + Function mapper) { + if (value instanceof Provider) + property.put(key, ((Provider) value).map(mapper::apply)); + else + property.put(key, mapper.apply(value)); + } + + public static void putMapEntry(MapProperty property, K key, Object value) { + putMapEntry(property, key, value, Function.identity()); + } + + public static void putMapEntry(MapProperty property, K key, Object value, Class valueType) { + putMapEntry(property, key, value, valueType::cast); + } + + public static void mergeMap(MapProperty property, Map map, + Function mapper) { map.forEach((k, v) -> { if (v instanceof Provider) property.put(k, ((Provider) v).map(mapper::apply)); @@ -30,7 +46,8 @@ public final class Properties { mergeList(property, values, Function.identity()); } - public static void mergeList(ListProperty property, Iterable values, Function mapper) { + public static void mergeList(ListProperty property, Iterable values, + Function mapper) { values.forEach(v -> { if (v instanceof Provider) property.add(((Provider) v).map(mapper::apply)); @@ -39,11 +56,12 @@ public final class Properties { }); } - public static void configureMap(MapProperty prop, Action> configure) { + public static void configureMap(MapProperty prop, Action> configure) { configureMap(prop, configure, Function.identity()); } - public static void configureMap(MapProperty prop, Action> configure, Function mapper) { + public static void configureMap(MapProperty prop, Action> configure, + Function mapper) { var map = new HashMap(); configure.execute(map); mergeMap(prop, map, mapper); diff --git a/common/src/main/java/org/implab/gradle/common/utils/SemVersion.java b/common/src/main/java/org/implab/gradle/common/utils/SemVersion.java new file mode 100644 --- /dev/null +++ b/common/src/main/java/org/implab/gradle/common/utils/SemVersion.java @@ -0,0 +1,316 @@ +package org.implab.gradle.common.utils; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Immutable Semantic Version (SemVer 2.0.0) of the form: + * + *
+ * MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
+ * 
+ * + * Ordering (as defined by {@link #compareTo(SemVersion)}) follows + * the SemVer 2.0.0 precedence rules: + *
    + *
  • Compare {@code major}, then {@code minor}, then {@code patch}.
  • + *
  • Pre-release versions have lower precedence than the corresponding + * normal version.
  • + *
  • Build metadata does not affect precedence.
  • + *
+ * + * Public API does not use {@code null} for semantic values: + * {@link Optional} is used for pre-release and build metadata. + */ +public record SemVersion( + int major, + int minor, + int patch, + Optional preRelease, + Optional buildMetadata) implements Comparable { + + // Pattern close to the official SemVer 2.0.0 recommendation. + // Groups: + // 1 - major + // 2 - minor + // 3 - patch + // 4 - pre-release (without '-') + // 5 - build metadata (without '+') + private static final Pattern SEMVER_PATTERN = Pattern.compile( + "^(0|[1-9]\\d*)" + // major + "\\.(0|[1-9]\\d*)" + // minor + "\\.(0|[1-9]\\d*)" + // patch + "(?:-((?:0|[1-9]\\d*|[0-9A-Za-z-][0-9A-Za-z-]*)" + + "(?:\\.(?:0|[1-9]\\d*|[0-9A-Za-z-][0-9A-Za-z-]*))*" + + "))?" + // pre-release + "(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$" // build metadata + ); + + /** + * Compact constructor with basic invariants. + */ + public SemVersion { + if (major < 0 || minor < 0 || patch < 0) { + throw new IllegalArgumentException("Version numbers must be >= 0"); + } + // Be tolerant internally, but never expose null Optionals. + preRelease = (preRelease != null) ? preRelease : Optional.empty(); + buildMetadata = (buildMetadata != null) ? buildMetadata : Optional.empty(); + } + + /** + * Creates a version without pre-release and build metadata. + * + * @param major non-negative MAJOR number + * @param minor non-negative MINOR number + * @param patch non-negative PATCH number + */ + public static SemVersion of(int major, int minor, int patch) { + return new SemVersion(major, minor, patch, Optional.empty(), Optional.empty()); + } + + /** + * Creates a version from components. + * + * @param major non-negative MAJOR number + * @param minor non-negative MINOR number + * @param patch non-negative PATCH number + * @param preRelease optional pre-release part (without '-') + * @param buildMetadata optional build metadata (without '+') + */ + public static SemVersion of( + int major, + int minor, + int patch, + Optional preRelease, + Optional buildMetadata) { + return new SemVersion( + major, + minor, + patch, + Objects.requireNonNull(preRelease, "preRelease"), + Objects.requireNonNull(buildMetadata, "buildMetadata")); + } + + /** + * Parses a SemVer string of the form + * {@code MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]}. + * + * @param value the string to parse (must not be {@code null}) + * @return a new {@code SemVersion} + * @throws IllegalArgumentException if the string is not a valid SemVer + */ + public static SemVersion parse(String value) { + Objects.requireNonNull(value, "value"); + + Matcher m = SEMVER_PATTERN.matcher(value); + if (!m.matches()) { + throw new IllegalArgumentException("Invalid SemVer: " + value); + } + + int major = Integer.parseInt(m.group(1)); + int minor = Integer.parseInt(m.group(2)); + int patch = Integer.parseInt(m.group(3)); + + String pre = m.group(4); + String meta = m.group(5); + + Optional preOpt = Optional.ofNullable(pre); + Optional metaOpt = Optional.ofNullable(meta); + + // Extra validation for numeric pre-release identifiers (no leading zeros) + preOpt.ifPresent(SemVersion::validatePreReleaseIdentifiers); + + return new SemVersion(major, minor, patch, preOpt, metaOpt); + } + + private static void validatePreReleaseIdentifiers(String preRelease) { + String[] parts = preRelease.split("\\."); + for (String p : parts) { + if (isNumericIdentifier(p)) { + // numeric identifiers must not have leading zeros (except "0") + if (p.length() > 1 && p.charAt(0) == '0') { + throw new IllegalArgumentException( + "Numeric pre-release identifier must not contain leading zeros: " + p); + } + } + } + } + + private static boolean isNumericIdentifier(String s) { + int len = s.length(); + if (len == 0) { + return false; + } + for (int i = 0; i < len; i++) { + char c = s.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } + + /** + * Returns {@code true} if this version has a pre-release part. + * + * @return {@code true} if {@link #preRelease()} is present + */ + public boolean isPreRelease() { + return preRelease().isPresent(); + } + + /** + * Returns {@code true} if this version is considered "stable". + *

+ * By convention: + *

    + *
  • MAJOR must be greater than 0
  • + *
  • No pre-release part is present
  • + *
+ * + * @return {@code true} if this version is a stable release + */ + public boolean isStable() { + return !isPreRelease() && major() > 0; + } + + /** + * Returns {@code true} if this version has strictly lower precedence + * than the given {@code other} version according to + * {@link #compareTo(SemVersion)}. + * + * @param other the version to compare to + * @return {@code true} if {@code this.compareTo(other) < 0} + */ + public boolean isBefore(SemVersion other) { + return compareTo(other) < 0; + } + + /** + * Returns {@code true} if this version has strictly higher precedence + * than the given {@code other} version according to + * {@link #compareTo(SemVersion)}. + * + * @param other the version to compare to + * @return {@code true} if {@code this.compareTo(other) > 0} + */ + public boolean isAfter(SemVersion other) { + return compareTo(other) > 0; + } + + /** + * Canonical SemVer string representation. + *

+ * This method is also used by Jackson during serialization. + * + * @return canonical SemVer string, e.g. {@code "1.2.3-alpha+build.1"} + */ + @JsonValue + public String asString() { + StringBuilder sb = new StringBuilder() + .append(major).append('.') + .append(minor).append('.') + .append(patch); + + preRelease.ifPresent(pr -> sb.append('-').append(pr)); + buildMetadata.ifPresent(md -> sb.append('+').append(md)); + + return sb.toString(); + } + + /** + * Creates a {@code SemVersion} from a canonical SemVer string. + *

+ * Jackson will use this factory method when deserializing from a JSON string. + * + * @param value canonical SemVer string + * @return parsed {@code SemVersion} + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static SemVersion fromJson(String value) { + return parse(value); + } + + /** + * Compares this version to another {@link SemVersion} according to the + * SemVer 2.0.0 precedence rules. + *

+ * Build metadata is ignored for ordering. + */ + @Override + public int compareTo(SemVersion other) { + // 1. major / minor / patch + int c = Integer.compare(this.major, other.major); + if (c != 0) + return c; + + c = Integer.compare(this.minor, other.minor); + if (c != 0) + return c; + + c = Integer.compare(this.patch, other.patch); + if (c != 0) + return c; + + // 2. pre-release (absence > presence) + boolean thisHasPre = this.preRelease.isPresent(); + boolean otherHasPre = other.preRelease.isPresent(); + + if (!thisHasPre && !otherHasPre) { + return 0; + } else if (!thisHasPre) { + // normal version > pre-release + return 1; + } else if (!otherHasPre) { + return -1; + } + + // 3. both have pre-release: compare identifiers + return comparePreRelease(this.preRelease.get(), other.preRelease.get()); + } + + private static int comparePreRelease(String a, String b) { + String[] aParts = a.split("\\."); + String[] bParts = b.split("\\."); + + int len = Math.min(aParts.length, bParts.length); + for (int i = 0; i < len; i++) { + String ai = aParts[i]; + String bi = bParts[i]; + + boolean aNum = isNumericIdentifier(ai); + boolean bNum = isNumericIdentifier(bi); + + if (aNum && bNum) { + int aiVal = Integer.parseInt(ai); + int biVal = Integer.parseInt(bi); + int c = Integer.compare(aiVal, biVal); + if (c != 0) + return c; + } else if (aNum && !bNum) { + // numeric identifiers have lower precedence than non-numeric + return -1; + } else if (!aNum && bNum) { + return 1; + } else { + int c = ai.compareTo(bi); + if (c != 0) + return c; + } + } + + // If all common identifiers are equal, the shorter one has lower precedence + return Integer.compare(aParts.length, bParts.length); + } + + @Override + public String toString() { + return asString(); + } +} diff --git a/common/src/main/java/org/implab/gradle/common/utils/Values.java b/common/src/main/java/org/implab/gradle/common/utils/Values.java --- a/common/src/main/java/org/implab/gradle/common/utils/Values.java +++ b/common/src/main/java/org/implab/gradle/common/utils/Values.java @@ -4,10 +4,7 @@ import java.text.MessageFormat; import java.util.Iterator; import java.util.Spliterators; import java.util.Optional; -import java.util.Set; -import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -60,6 +57,24 @@ public final class Values { return provider.get(); } + public static boolean parseBoolean(Object value) { + if (value instanceof Boolean) { + return (Boolean) value; + } else { + var text = toString(value); + switch (text != null ? text.toLowerCase() : "") { + case "true", "yes", "1" -> { + return true; + } + case "false", "no", "0", "" -> { + return false; + } + default -> throw new IllegalArgumentException( + MessageFormat.format("Cannot parse boolean value from ''{0}''", text)); + } + } + } + private static class ArrayIterator implements Iterator { private final T[] data; diff --git a/common/src/main/java/org/implab/gradle/common/utils/os/SystemResolver.java b/common/src/main/java/org/implab/gradle/common/utils/os/SystemResolver.java --- a/common/src/main/java/org/implab/gradle/common/utils/os/SystemResolver.java +++ b/common/src/main/java/org/implab/gradle/common/utils/os/SystemResolver.java @@ -1,15 +1,23 @@ package org.implab.gradle.common.utils.os; +import org.implab.gradle.common.utils.LazyValue; import org.implab.gradle.common.utils.OperatingSystem; public class SystemResolver { - public static OperatingSystem current() { + + private final static LazyValue current = new LazyValue<>(SystemResolver::resolveCurrent); + + private static OperatingSystem resolveCurrent() { var osName = System.getProperty("os.name"); var osVersion = System.getProperty("os.version"); return forName(osName, osVersion); } + public static OperatingSystem current() { + return current.get(); + } + public static OperatingSystem forName(String os, String version) { var osName = os.toLowerCase();