diff --git a/src/main/java/org/gradlex/javamodule/moduleinfo/AutomaticModuleNameUtil.java b/src/main/java/org/gradlex/javamodule/moduleinfo/AutomaticModuleNameUtil.java new file mode 100644 index 0000000..b267665 --- /dev/null +++ b/src/main/java/org/gradlex/javamodule/moduleinfo/AutomaticModuleNameUtil.java @@ -0,0 +1,153 @@ +package org.gradlex.javamodule.moduleinfo; + +import java.io.File; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Implementation based on 'jdk.internal.module.ModulePath#deriveModuleDescriptor' and related methods. + */ +class AutomaticModuleNameUtil { + + private static final Pattern DASH_VERSION = Pattern.compile("-(\\d+(\\.|$))"); + private static final Pattern NON_ALPHANUM = Pattern.compile("[^A-Za-z0-9]"); + private static final Pattern REPEATING_DOTS = Pattern.compile("(\\.)(\\1)+"); + private static final Pattern LEADING_DOTS = Pattern.compile("^\\."); + private static final Pattern TRAILING_DOTS = Pattern.compile("\\.$"); + + static String automaticModulNameFromFileName(File jarFile) { + // Derive the version, and the module name if needed, from JAR file name + String fn = jarFile.getName(); + int i = fn.lastIndexOf(File.separator); + if (i != -1) + fn = fn.substring(i + 1); + + // drop ".jar" + String name = fn.substring(0, fn.length() - 4); + + // find first occurrence of -${NUMBER}. or -${NUMBER}$ + Matcher matcher = DASH_VERSION.matcher(name); + if (matcher.find()) { + name = name.substring(0, matcher.start()); + } + return checkValidModuleName(cleanModuleName(name)); + } + + private static String cleanModuleName(String mn) { + // replace non-alphanumeric + mn = NON_ALPHANUM.matcher(mn).replaceAll("."); + + // collapse repeating dots + mn = REPEATING_DOTS.matcher(mn).replaceAll("."); + + // drop leading dots + if (!mn.isEmpty() && mn.charAt(0) == '.') + mn = LEADING_DOTS.matcher(mn).replaceAll(""); + + // drop trailing dots + int len = mn.length(); + if (len > 0 && mn.charAt(len-1) == '.') + mn = TRAILING_DOTS.matcher(mn).replaceAll(""); + + return mn; + } + + public static String checkValidModuleName(String name) { + int next; + int off = 0; + while ((next = name.indexOf('.', off)) != -1) { + String id = name.substring(off, next); + if (!isJavaIdentifier(id)) { + throw new IllegalArgumentException(name + ": Invalid module name" + + ": '" + id + "' is not a Java identifier"); + } + off = next+1; + } + String last = name.substring(off); + if (!isJavaIdentifier(last)) { + throw new IllegalArgumentException(name + ": Invalid module name" + + ": '" + last + "' is not a Java identifier"); + } + return name; + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private static boolean isJavaIdentifier(String str) { + if (str.isEmpty() || RESERVED.contains(str)) + return false; + + int first = Character.codePointAt(str, 0); + if (!Character.isJavaIdentifierStart(first)) + return false; + + int i = Character.charCount(first); + while (i < str.length()) { + int cp = Character.codePointAt(str, i); + if (!Character.isJavaIdentifierPart(cp)) + return false; + i += Character.charCount(cp); + } + + return true; + } + + // keywords, boolean and null literals, not allowed in identifiers + private static final List RESERVED = Arrays.asList( + "abstract", + "assert", + "boolean", + "break", + "byte", + "case", + "catch", + "char", + "class", + "const", + "continue", + "default", + "do", + "double", + "else", + "enum", + "extends", + "final", + "finally", + "float", + "for", + "goto", + "if", + "implements", + "import", + "instanceof", + "int", + "interface", + "long", + "native", + "new", + "package", + "private", + "protected", + "public", + "return", + "short", + "static", + "strictfp", + "super", + "switch", + "synchronized", + "this", + "throw", + "throws", + "transient", + "try", + "void", + "volatile", + "while", + "true", + "false", + "null", + "_" + ); +} diff --git a/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPlugin.java b/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPlugin.java index 602cff5..a53da66 100644 --- a/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPlugin.java +++ b/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPlugin.java @@ -64,6 +64,7 @@ public void apply(Project project) { ExtraJavaModuleInfoPluginExtension extension = project.getExtensions().create("extraJavaModuleInfo", ExtraJavaModuleInfoPluginExtension.class); extension.getFailOnMissingModuleInfo().convention(true); extension.getFailOnAutomaticModules().convention(false); + extension.getAutoCreateAutomaticModules().convention(false); // setup the transform and the tasks for all projects in the build project.getPlugins().withType(JavaPlugin.class).configureEach(javaPlugin -> { @@ -173,6 +174,7 @@ private void registerTransform(String fileExtension, Project project, ExtraJavaM p.getModuleSpecs().set(extension.getModuleSpecs()); p.getFailOnMissingModuleInfo().set(extension.getFailOnMissingModuleInfo()); p.getFailOnAutomaticModules().set(extension.getFailOnAutomaticModules()); + p.getAutoCreateAutomaticModules().set(extension.getAutoCreateAutomaticModules()); // See: https://github.com/adammurdoch/dependency-graph-as-task-inputs/blob/main/plugins/src/main/java/TestPlugin.java Provider> artifacts = project.provider(() -> diff --git a/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPluginExtension.java b/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPluginExtension.java index c71e4f8..39a137a 100644 --- a/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPluginExtension.java +++ b/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoPluginExtension.java @@ -40,6 +40,7 @@ public abstract class ExtraJavaModuleInfoPluginExtension { public abstract MapProperty getModuleSpecs(); public abstract Property getFailOnMissingModuleInfo(); public abstract Property getFailOnAutomaticModules(); + public abstract Property getAutoCreateAutomaticModules(); /** * Add full module information for a given Jar file. diff --git a/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoTransform.java b/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoTransform.java index 2ddc235..e531bd7 100644 --- a/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoTransform.java +++ b/src/main/java/org/gradlex/javamodule/moduleinfo/ExtraJavaModuleInfoTransform.java @@ -48,12 +48,12 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.TreeSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeSet; import java.util.jar.JarEntry; import java.util.jar.JarInputStream; import java.util.jar.JarOutputStream; @@ -64,6 +64,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipException; +import static org.gradlex.javamodule.moduleinfo.AutomaticModuleNameUtil.automaticModulNameFromFileName; import static org.gradlex.javamodule.moduleinfo.FilePathToModuleCoordinates.gaCoordinatesFromFilePathMatch; import static org.gradlex.javamodule.moduleinfo.FilePathToModuleCoordinates.versionFromFilePath; @@ -84,18 +85,28 @@ public abstract class ExtraJavaModuleInfoTransform implements TransformAction getModuleSpecs(); + @Input Property getFailOnMissingModuleInfo(); + @Input Property getFailOnAutomaticModules(); + + @Input + Property getAutoCreateAutomaticModules(); + @Input ListProperty getMergeJarIds(); + @InputFiles ListProperty getMergeJars(); + @Input MapProperty> getCompileClasspathDependencies(); + @Input MapProperty> getRuntimeClasspathDependencies(); + @Input MapProperty> getAnnotationProcessorClasspathDependencies(); } @@ -135,12 +146,13 @@ public void transform(TransformOutputs outputs) { throw new RuntimeException("Found an automatic module: " + originalJar.getName()); } outputs.file(originalJar); + } else if (parameters.getAutoCreateAutomaticModules().get()) { + String automaticName = automaticModulNameFromFileName(originalJar); + addAutomaticModuleName(originalJar, getModuleJar(outputs, originalJar), new AutomaticModuleName(originalJar.getName(), automaticName)); + } else if (parameters.getFailOnMissingModuleInfo().get()) { + throw new RuntimeException("Not a module and no mapping defined: " + originalJar.getName()); } else { - if (parameters.getFailOnMissingModuleInfo().get()) { - throw new RuntimeException("Not a module and no mapping defined: " + originalJar.getName()); - } else { - outputs.file(originalJar); - } + outputs.file(originalJar); } } @@ -175,7 +187,7 @@ private boolean isModule(File jar) { // - https://github.com/jjohannes/extra-java-module-info/issues/78 return true; } - try (JarInputStream inputStream = new JarInputStream(Files.newInputStream(jar.toPath()))) { + try (JarInputStream inputStream = new JarInputStream(Files.newInputStream(jar.toPath()))) { boolean isMultiReleaseJar = containsMultiReleaseJarEntry(inputStream); ZipEntry next = inputStream.getNextEntry(); while (next != null) { @@ -351,7 +363,7 @@ private byte[] addModuleInfo(ModuleInfo moduleInfo, Map> pr allDependencies.addAll(compileDependencies); allDependencies.addAll(runtimeDependencies); allDependencies.addAll(annotationProcessorDependencies); - for (String ga: allDependencies) { + for (String ga : allDependencies) { String moduleName = gaToModuleName(ga); if (compileDependencies.contains(ga) && !runtimeDependencies.contains(ga)) { moduleVisitor.visitRequire(moduleName, Opcodes.ACC_STATIC_PHASE, null); @@ -381,7 +393,7 @@ private byte[] addModuleInfo(ModuleInfo moduleInfo, Map> pr List implementations = entry.getValue(); if (!moduleInfo.ignoreServiceProviders.contains(name)) { moduleVisitor.visitProvide(name.replace('.', '/'), - implementations.stream().map(impl -> impl.replace('.','/')).toArray(String[]::new)); + implementations.stream().map(impl -> impl.replace('.', '/')).toArray(String[]::new)); } } moduleVisitor.visitEnd(); @@ -461,4 +473,5 @@ private String gaToModuleName(String ga) { private static boolean isModuleInfoClass(String jarEntryName) { return "module-info.class".equals(jarEntryName) || MODULE_INFO_CLASS_MRJAR_PATH.matcher(jarEntryName).matches(); } + } diff --git a/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/AbstractFunctionalTest.groovy b/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/AbstractFunctionalTest.groovy index 23c112a..f362e20 100644 --- a/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/AbstractFunctionalTest.groovy +++ b/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/AbstractFunctionalTest.groovy @@ -693,4 +693,113 @@ org.apache.qpid.server.management.plugin.ConfiguredObjectRegistrationImpl''') run().task(':run').outcome == TaskOutcome.SUCCESS } + def "can add module information to legacy library"() { + given: + file("src/main/java/org/gradle/sample/app/Main.java") << """ + package org.gradle.sample.app; + + import com.google.gson.Gson; + import org.apache.commons.beanutils.BeanUtils; + import org.apache.commons.cli.CommandLine; + import org.apache.commons.cli.CommandLineParser; + import org.apache.commons.cli.DefaultParser; + import org.apache.commons.cli.Options; + import org.apache.commons.lang3.StringUtils; + import org.gradle.sample.app.data.Message; + + public class Main { + + public static void main(String[] args) throws Exception { + Options options = new Options(); + options.addOption("json", true, "data to parse"); + options.addOption("debug", false, "prints module infos"); + CommandLineParser parser = new DefaultParser(); + CommandLine cmd = parser.parse(options, args); + + if (cmd.hasOption("debug")) { + printModuleDebug(Main.class); + printModuleDebug(Gson.class); + printModuleDebug(StringUtils.class); + printModuleDebug(CommandLine.class); + printModuleDebug(BeanUtils.class); + } + + String json = cmd.getOptionValue("json"); + Message message = new Gson().fromJson(json == null ? "{}" : json, Message.class); + + Object copy = BeanUtils.cloneBean(message); + System.out.println(); + System.out.println("Original: " + copy.toString()); + System.out.println("Copy: " + copy.toString()); + + } + + private static void printModuleDebug(Class clazz) { + System.out.println(clazz.getModule().getName() + " - " + clazz.getModule().getDescriptor().version().get()); + } + + } + """ + file("src/main/java/org/gradle/sample/app/data/Message.java") << """ + package org.gradle.sample.app.data; + + import java.util.List; + import java.util.ArrayList; + + public class Message { + private String message; + private List receivers = new ArrayList<>(); + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getReceivers() { + return receivers; + } + + public void setReceivers(List receivers) { + this.receivers = receivers; + } + + @Override + public String toString() { + return "Message{message='" + message + '\\'' + + ", receivers=" + receivers + '}'; + } + } + """ + file("src/main/java/module-info.java") << """ + module org.gradle.sample.app { + exports org.gradle.sample.app; + opens org.gradle.sample.app.data; // allow Gson to access via reflection + + requires com.google.gson; + requires org.apache.commons.lang3; + requires commons.cli; + requires commons.beanutils; + } + """ + buildFile << """ + dependencies { + implementation("com.google.code.gson:gson:2.8.6") // real module + implementation("net.bytebuddy:byte-buddy:1.10.9") // real module with multi-release jar + implementation("org.apache.commons:commons-lang3:3.10") // automatic module + implementation("commons-beanutils:commons-beanutils:1.9.4") // plain library (also brings in other libraries transitively) + implementation("commons-cli:commons-cli:1.4") // plain library + } + + extraJavaModuleInfo { + autoCreateAutomaticModules = true + } + """ + + expect: + build().task(':compileJava').outcome == TaskOutcome.SUCCESS + } + } diff --git a/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/EdgeCasesFunctionalTest.groovy b/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/EdgeCasesFunctionalTest.groovy index 4fd5b8b..80d9705 100644 --- a/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/EdgeCasesFunctionalTest.groovy +++ b/src/test/groovy/org/gradlex/javamodule/moduleinfo/test/EdgeCasesFunctionalTest.groovy @@ -177,4 +177,21 @@ class EdgeCasesFunctionalTest extends Specification { expect: run().task(':run').outcome == TaskOutcome.SUCCESS } + + def "autoCreateAutomaticModules produces a build time error for invalid module names"() { + given: + buildFile << """ + dependencies { + implementation("org.nd4j:nd4j-native-api:0.9.1") + } + + extraJavaModuleInfo { + autoCreateAutomaticModules = true + } + """ + + expect: + def result = failRun() + result.output.contains "nd4j.native.api: Invalid module name: 'native' is not a Java identifier" + } }