modernized JPMS check and dependency export plugins
All checks were successful
CI / build (push) Successful in 1m57s

This commit is contained in:
2025-02-08 00:48:20 +08:00
parent e0d8628188
commit 04d50e5a52
8 changed files with 299 additions and 216 deletions

View File

@@ -8,6 +8,9 @@ import org.gradle.api.plugins.ObjectConfigurationAction;
import org.gradle.api.provider.Provider;
public class DependencyExportPlugin implements Plugin<Project> {
public static final String DEPENDENCY_EXPORT_GROUP = "dependency-export";
@Override
public void apply(Project project) {
project.apply(new Action<ObjectConfigurationAction>() {

View File

@@ -24,6 +24,7 @@ import org.gradle.api.plugins.JavaPlugin;
import org.gradle.api.plugins.JavaPluginConvention;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.Classpath;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.Internal;
@@ -49,6 +50,8 @@ import java.util.stream.Collector;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static net.woggioni.gradle.dependency.export.DependencyExportPlugin.DEPENDENCY_EXPORT_GROUP;
public class ExportDependencies extends DefaultTask {
@Getter(onMethod_ = { @Input })
@@ -74,6 +77,7 @@ public class ExportDependencies extends DefaultTask {
private final JavaPluginConvention javaPluginConvention;
@InputFiles
@Classpath
public Provider<FileCollection> getConfigurationFiles() {
return configurationName.map(this::fetchConfiguration);
}
@@ -96,6 +100,7 @@ public class ExportDependencies extends DefaultTask {
@Inject
public ExportDependencies(ObjectFactory objects) {
setGroup(DEPENDENCY_EXPORT_GROUP);
javaPluginConvention = getProject().getConvention().getPlugin(JavaPluginConvention.class);
configurationName = objects.property(String.class).convention(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME);
Provider<File> defaultOutputFileProvider =

View File

@@ -10,29 +10,37 @@ import org.gradle.api.model.ObjectFactory;
import org.gradle.api.plugins.JavaPluginConvention;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.CacheableTask;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.Internal;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.PathSensitive;
import org.gradle.api.tasks.PathSensitivity;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.options.Option;
import javax.inject.Inject;
import java.io.File;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import static net.woggioni.gradle.dependency.export.DependencyExportPlugin.DEPENDENCY_EXPORT_GROUP;
@CacheableTask
public class RenderDependencies extends DefaultTask {
@Getter(onMethod_ = { @InputFile })
@Getter(onMethod_ = {@InputFile, @PathSensitive(PathSensitivity.NONE)})
private Provider<File> sourceFile;
@Getter(onMethod_ = { @Input})
@Getter(onMethod_ = {@Input})
private final Property<String> format;
@Getter(onMethod_ = { @Input })
@Getter(onMethod_ = {@Input})
private final Property<String> graphvizExecutable;
@Getter
@@ -45,6 +53,7 @@ public class RenderDependencies extends DefaultTask {
return outputFile.map(RegularFile::getAsFile).map(File::getAbsolutePath).getOrNull();
}
@Optional
@OutputFile
public Provider<File> getResult() {
return outputFile.map(RegularFile::getAsFile);
@@ -64,33 +73,46 @@ public class RenderDependencies extends DefaultTask {
}
public void setExportTask(Provider<ExportDependencies> taskProvider) {
dependsOn(taskProvider);
sourceFile = taskProvider.flatMap(ExportDependencies::getResult);
}
@Inject
public RenderDependencies(ObjectFactory objects) {
setGroup(DEPENDENCY_EXPORT_GROUP);
sourceFile = objects.property(File.class);
javaPluginConvention = getProject().getConvention().getPlugin(JavaPluginConvention.class);
format = objects.property(String.class).convention("xlib");
graphvizExecutable = objects.property(String.class).convention("dot");
Provider<File> defaultOutputFileProvider =
getProject().provider(() -> new File(javaPluginConvention.getDocsDir(), "renderedDependencies"));
outputFile = objects.fileProperty().convention(getProject().getLayout().file(defaultOutputFileProvider));
outputFile = objects.fileProperty().convention(getProject().getLayout().file(defaultOutputFileProvider)
.zip(format, (file, type) -> Objects.equals("xlib", type) ? null : file));
getOutputs().upToDateWhen(t -> outputFile.isPresent());
}
@TaskAction
@SneakyThrows
void run() {
Path destination = outputFile
.map(RegularFile::getAsFile)
.map(File::toPath)
.get();
List<String> cmd = Arrays.asList(
java.util.Optional<Path> destination = java.util.Optional.of(
outputFile
.map(RegularFile::getAsFile)
.map(File::toPath)
)
.filter(Provider::isPresent)
.map(Provider::get);
List<String> cmd = new ArrayList<>(Arrays.asList(
graphvizExecutable.get(),
"-T" + format.get(),
"-o" + destination,
sourceFile.get().toString()
);
"-T" + format.get()
));
if (destination.isPresent()) {
cmd.add("-o");
cmd.add(destination.get().toString());
}
cmd.add(sourceFile.get().toString());
int returnCode = new ProcessBuilder(cmd).inheritIO().start().waitFor();
if (returnCode != 0) {
throw new GradleException("Error invoking graphviz");

View File

@@ -7,6 +7,7 @@ import org.gradle.api.file.FileCollection;
import org.gradle.api.file.ProjectLayout;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.BasePluginExtension;
import org.gradle.api.plugins.ExtensionContainer;
import org.gradle.api.plugins.JavaApplication;
@@ -48,6 +49,8 @@ public abstract class NativeImageTask extends Exec {
public abstract Property<Boolean> getBuildStaticImage();
@Input
public abstract Property<Boolean> getEnableFallback();
@Input
public abstract Property<Boolean> getLinkAtBuildTime();
@Input
public abstract Property<String> getMainClass();
@@ -61,15 +64,17 @@ public abstract class NativeImageTask extends Exec {
@OutputFile
protected abstract RegularFileProperty getOutputFile();
private final Logger logger;
private static final Logger log = Logging.getLogger(NativeImageTask.class);
public NativeImageTask() {
Project project = getProject();
logger = project.getLogger();
setGroup(GRAALVM_TASK_GROUP);
setDescription("Create a native image of the application using GraalVM");
getUseMusl().convention(false);
getBuildStaticImage().convention(false);
getEnableFallback().convention(false);
getLinkAtBuildTime().convention(false);
ExtensionContainer ext = project.getExtensions();
JavaApplication javaApplication = ext.findByType(JavaApplication.class);
if(!Objects.isNull(javaApplication)) {
@@ -111,6 +116,9 @@ public abstract class NativeImageTask extends Exec {
if(getUseMusl().get()) {
result.add("--libc=musl");
}
if(getLinkAtBuildTime().get()) {
result.add("--link-at-build-time");
}
JavaModuleDetector javaModuleDetector = getJavaModuleDetector();
boolean useJpms = getMainModule().isPresent();
FileCollection classpath = getClasspath().get();

View File

@@ -1,5 +1,5 @@
lys.catalog.version=2025.01.31
version.myGradlePlugins=2025.02.05
lys.catalog.version=2025.02.05
version.myGradlePlugins=2025.02.08
version.gradle=8.12
gitea.maven.url = https://gitea.woggioni.net/api/packages/woggioni/maven

View File

@@ -1,216 +1,43 @@
package net.woggioni.gradle.jpms.check
import groovy.json.JsonBuilder
import groovy.transform.Canonical
import groovy.transform.CompileStatic
import groovy.xml.MarkupBuilder
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.result.ResolvedArtifactResult
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.jar.JarFile
import java.util.stream.Collectors
import java.util.stream.Stream
import java.util.zip.ZipFile
import org.gradle.api.file.RegularFile
import org.gradle.api.plugins.JavaPlugin
import org.gradle.api.plugins.ReportingBasePlugin
import org.gradle.api.reporting.ReportingExtension
class JPMSCheckPlugin implements Plugin<Project> {
@Canonical
@CompileStatic
private class CheckResult {
ResolvedArtifactResult dep
String automaticModuleName
boolean multiReleaseJar
boolean moduleInfo
boolean getJpmsFriendly() {
return automaticModuleName != null || moduleInfo
}
@Override
boolean equals(Object other) {
if(other == null) {
return false
} else if(other.class == CheckResult.class) {
return dep?.id?.componentIdentifier == ((CheckResult) other).dep?.id?.componentIdentifier
} else {
return false
}
}
@Override
int hashCode() {
return dep.id.componentIdentifier.hashCode()
}
}
@CompileStatic
private Stream<CheckResult> computeResults(Stream<ResolvedArtifactResult> artifacts) {
return artifacts.filter { ResolvedArtifactResult res ->
res.file.exists() && res.file.name.endsWith(".jar")
}.<CheckResult>map { resolvedArtifact ->
JarFile jarFile = new JarFile(resolvedArtifact.file).with {
if (it.isMultiRelease()) {
new JarFile(
resolvedArtifact.file,
false,
ZipFile.OPEN_READ,
Runtime.version()
)
} else {
it
}
}
String automaticModuleName = jarFile.manifest?.with {it.mainAttributes.getValue("Automatic-Module-Name") }
def moduleInfoEntry = jarFile.getJarEntry("module-info.class")
new CheckResult(
resolvedArtifact,
automaticModuleName,
jarFile.isMultiRelease(),
moduleInfoEntry != null
)
}
}
private void createHtmlReport(Project project, Stream<CheckResult> checkResults, Writer writer) {
def builder = new MarkupBuilder(writer)
int friendly = 0
int total = 0
def results = checkResults.peek { CheckResult res ->
total += 1
if(res.jpmsFriendly) friendly += 1
}.collect(Collectors.toList())
builder.html {
head {
meta name: "viewport", content: "width=device-width, initial-scale=1"
InputStream resourceStream = getClass().classLoader.getResourceAsStream('net/woggioni/plugins/jpms/check/github-markdown.css')
resourceStream.withReader { Reader reader ->
style reader.text
}
body {
article(class: 'markdown-body') {
h1 "Project ${project.group}:${project.name}:${project.version}", style: "text-align: center;"
div {
table {
thead {
tr {
th "JPMS friendly"
th "Not JPMS friendly", colspan: 2
th "Total", colspan: 2
}
}
tbody {
tr {
td friendly, style: "text-align: center;"
td total - friendly, style: "text-align: center;", colspan: 2
td total, style: "text-align: center;", colspan: 2
}
}
thead {
th "Name"
th "Multi-release jar"
th "Automatic-Module-Name"
th "Module descriptor"
th "JPMS friendly"
}
tbody {
results.forEach {res ->
String color = res.jpmsFriendly ? "#dfd" : "fdd"
tr(style: "background-color:$color;") {
td res.dep.id.displayName
td style: "text-align: center;", res.multiReleaseJar ? "✓" : "✕"
td style: "text-align: center;", res.automaticModuleName ?: "n/a"
td style: "text-align: center;", res.moduleInfo ? "✓" : "✕"
td style: "text-align: center;", res.jpmsFriendly ? "✓" : "✕"
}
total += 1
if(res.jpmsFriendly) friendly += 1
}
}
}
}
}
}
}
}
}
@CompileStatic
private createJsonReport(Stream<CheckResult> checkResults, Writer writer) {
def builder = new JsonBuilder()
builder (checkResults.map {
[
name: it.dep.id.componentIdentifier.displayName,
automaticModuleName: it.automaticModuleName,
isMultiReleaseJar: it.multiReleaseJar,
hasModuleInfo: it.moduleInfo,
jpmsFriendly: it.jpmsFriendly
]
}.collect(Collectors.toList()))
builder.writeTo(writer)
}
@Override
@CompileStatic
void apply(Project project) {
project.tasks.register("jpms-check") {task ->
boolean recursive = project.properties["jpms-check.recursive"]?.with(Boolean.&parseBoolean) ?: false
String cfgName = project.properties["jpms-check.configurationName"] ?: "default"
String outputFormat = project.properties["jpms-check.outputFormat"] ?: "html"
Path outputFile = project.properties["jpms-check.outputFile"]?.with {
Paths.get(it as String)
} ?: with {
project.pluginManager.apply(ReportingBasePlugin.class)
project.tasks.register("jpms-check", JPMSCheckTask) {task ->
ReportingExtension reporting = project.extensions.getByType(ReportingExtension.class)
boolean recursive = project.properties["jpms-check.recursive"]?.with(Object.&toString)?.with(Boolean.&parseBoolean) ?: false
String cfgName = project.properties["jpms-check.configurationName"] ?: JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME
OutputFormat defaultOutputFormat = (project.properties["jpms-check.outputFormat"]
?.with(Object.&toString)
?.with(OutputFormat.&valueOf)
?: OutputFormat.html)
task.getConfigurationName().convention(cfgName)
task.getRecursive().convention(recursive)
task.outputFormat.convention(defaultOutputFormat)
task.getOutputFile().convention(reporting.baseDirectory.zip(task.getOutputFormat(), { dir, outputFormat ->
RegularFile result = null
switch(outputFormat) {
case "html":
Paths.get(project.buildDir.path, "jpms-report.html")
case OutputFormat.html:
result = dir.file( "jpms-report.html")
break
case "json":
Paths.get(project.buildDir.path, "jpms-report.json")
case OutputFormat.json:
result = dir.file( "jpms-report.json")
break
default:
throw new IllegalArgumentException("Unsupported output format: $outputFormat")
}
}
task.doLast {
Stream<Project> projects = Stream.of(project)
if(recursive) {
projects = Stream.concat(projects, project.subprojects.stream())
}
Set<CheckResult> results = projects.flatMap {
Configuration requestedConfiguration = (project.configurations.<Configuration>find { Configuration cfg ->
cfg.canBeResolved && cfg.name == cfgName
} ?: {
def resolvableConfigurations = "[" + project.configurations
.grep { Configuration cfg -> cfg.canBeResolved }
.collect { "'${it.name}'" }
.join(",") + "]"
throw new GradleException("Configuration '$cfgName' doesn't exist or cannot be resolved, " +
"resolvable configurations in this project are " + resolvableConfigurations)
}) as Configuration
computeResults(requestedConfiguration.incoming.artifacts.artifacts.stream())
}.collect(Collectors.toSet())
Files.createDirectories(outputFile.parent)
Files.newBufferedWriter(outputFile).withWriter {
Stream<CheckResult> resultStream = results.stream().sorted(Comparator.<CheckResult, String>comparing { CheckResult res ->
res.dep.id.componentIdentifier.displayName
})
switch(outputFormat) {
case "html":
createHtmlReport(project, resultStream, it)
break
case "json":
createJsonReport(resultStream, it)
break
default:
throw new IllegalArgumentException("Unsupported output format: $outputFormat")
}
}
}
result
}))
}
}
}

View File

@@ -0,0 +1,213 @@
package net.woggioni.gradle.jpms.check
import groovy.json.JsonBuilder
import groovy.transform.Canonical
import groovy.transform.CompileStatic
import groovy.xml.MarkupBuilder
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.Project
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.result.ResolvedArtifactResult
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import java.nio.file.Files
import java.nio.file.Path
import java.util.jar.JarFile
import java.util.stream.Collectors
import java.util.stream.Stream
import java.util.zip.ZipFile
abstract class JPMSCheckTask extends DefaultTask {
@Input
abstract Property<String> getConfigurationName()
@Input
abstract Property<Boolean> getRecursive()
@Input
abstract Property<OutputFormat> getOutputFormat()
@OutputFile
abstract RegularFileProperty getOutputFile()
@Canonical
@CompileStatic
private class CheckResult {
ResolvedArtifactResult dep
String automaticModuleName
boolean multiReleaseJar
boolean moduleInfo
boolean getJpmsFriendly() {
return automaticModuleName != null || moduleInfo
}
@Override
boolean equals(Object other) {
if(other == null) {
return false
} else if(other.class == CheckResult.class) {
return dep?.id?.componentIdentifier == ((CheckResult) other).dep?.id?.componentIdentifier
} else {
return false
}
}
@Override
int hashCode() {
return dep.id.componentIdentifier.hashCode()
}
}
@CompileStatic
private Stream<CheckResult> computeResults(Stream<ResolvedArtifactResult> artifacts) {
return artifacts.filter { ResolvedArtifactResult res ->
res.file.exists() && res.file.name.endsWith(".jar")
}.<CheckResult>map { resolvedArtifact ->
JarFile jarFile = new JarFile(resolvedArtifact.file).with {
if (it.isMultiRelease()) {
new JarFile(
resolvedArtifact.file,
false,
ZipFile.OPEN_READ,
Runtime.version()
)
} else {
it
}
}
String automaticModuleName = jarFile.manifest?.with {it.mainAttributes.getValue("Automatic-Module-Name") }
def moduleInfoEntry = jarFile.getJarEntry("module-info.class")
new CheckResult(
resolvedArtifact,
automaticModuleName,
jarFile.isMultiRelease(),
moduleInfoEntry != null
)
}
}
private void createHtmlReport(Project project, Stream<CheckResult> checkResults, Writer writer) {
def builder = new MarkupBuilder(writer)
int friendly = 0
int total = 0
def results = checkResults.peek { CheckResult res ->
total += 1
if(res.jpmsFriendly) friendly += 1
}.collect(Collectors.toList())
builder.html {
head {
meta name: "viewport", content: "width=device-width, initial-scale=1"
InputStream resourceStream = getClass().classLoader.getResourceAsStream('net/woggioni/plugins/jpms/check/github-markdown.css')
resourceStream.withReader { Reader reader ->
style reader.text
}
body {
article(class: 'markdown-body') {
h1 "Project ${project.group}:${project.name}:${project.version}", style: "text-align: center;"
div {
table {
thead {
tr {
th "JPMS friendly"
th "Not JPMS friendly", colspan: 2
th "Total", colspan: 2
}
}
tbody {
tr {
td friendly, style: "text-align: center;"
td total - friendly, style: "text-align: center;", colspan: 2
td total, style: "text-align: center;", colspan: 2
}
}
thead {
th "Name"
th "Multi-release jar"
th "Automatic-Module-Name"
th "Module descriptor"
th "JPMS friendly"
}
tbody {
results.forEach {res ->
String color = res.jpmsFriendly ? "#dfd" : "fdd"
tr(style: "background-color:$color;") {
td res.dep.id.displayName
td style: "text-align: center;", res.multiReleaseJar ? "✓" : "✕"
td style: "text-align: center;", res.automaticModuleName ?: "n/a"
td style: "text-align: center;", res.moduleInfo ? "✓" : "✕"
td style: "text-align: center;", res.jpmsFriendly ? "✓" : "✕"
}
total += 1
if(res.jpmsFriendly) friendly += 1
}
}
}
}
}
}
}
}
}
@CompileStatic
private createJsonReport(Stream<CheckResult> checkResults, Writer writer) {
def builder = new JsonBuilder()
builder (checkResults.map {
[
name: it.dep.id.componentIdentifier.displayName,
automaticModuleName: it.automaticModuleName,
isMultiReleaseJar: it.multiReleaseJar,
hasModuleInfo: it.moduleInfo,
jpmsFriendly: it.jpmsFriendly
]
}.collect(Collectors.toList()))
builder.writeTo(writer)
}
@TaskAction
@CompileStatic
def createReport() {
String cfgName = configurationName.get()
Path outputFile = outputFile.get().asFile.toPath()
Stream<Project> projects = Stream.of(project)
if(recursive.get()) {
projects = Stream.concat(projects, project.subprojects.stream())
}
Set<CheckResult> results = projects.flatMap {
Configuration requestedConfiguration = (project.configurations.<Configuration>find { Configuration cfg ->
cfg.canBeResolved && cfg.name == cfgName
} ?: {
def resolvableConfigurations = "[" + project.configurations
.grep { Configuration cfg -> cfg.canBeResolved }
.collect { "'${it.name}'" }
.join(",") + "]"
throw new GradleException("Configuration '$cfgName' doesn't exist or cannot be resolved, " +
"resolvable configurations in this project are " + resolvableConfigurations)
}) as Configuration
computeResults(requestedConfiguration.incoming.artifacts.artifacts.stream())
}.collect(Collectors.toSet())
Files.createDirectories(outputFile.parent)
Files.newBufferedWriter(outputFile).withWriter {
Stream<CheckResult> resultStream = results.stream().sorted(Comparator.<CheckResult, String>comparing { CheckResult res ->
res.dep.id.componentIdentifier.displayName
})
switch(outputFormat.get()) {
case OutputFormat.html:
createHtmlReport(project, resultStream, it)
break
case OutputFormat.json:
createJsonReport(resultStream, it)
break
default:
throw new IllegalArgumentException("Unsupported output format: $outputFormat")
}
}
}
}

View File

@@ -0,0 +1,5 @@
package net.woggioni.gradle.jpms.check
enum OutputFormat {
html, json
}