Skip to content

Instantly share code, notes, and snippets.

@carlosame
Last active December 1, 2021 15:11
Show Gist options
  • Save carlosame/a8c8ccc0146ab64086c4cc0bbafb4e00 to your computer and use it in GitHub Desktop.
Save carlosame/a8c8ccc0146ab64086c4cc0bbafb4e00 to your computer and use it in GitHub Desktop.
Compile 'module-info' separately in Gradle

Compile module-info separately in Gradle

To make projects compatible with both Java 8 and modular JDKs, a popular strategy is to compile the project so it produces Java 8 bytecode, and separately compile the module-info.java file targeting a modular JDK version.

The Apache Maven documentation includes an example of how to achieve that, but the Gradle user guide lacks it. This document presents an approach that could be included in a convention plugin, and requires Gradle 7 or higher (you could try to adapt it to Gradle 6.x by explicitly enabling inferModulePath in compileJava, but modularity is only a full feature in version 7).


The tasks

The following snippet configures the compileJava task and adds three tasks:

  • compileLegacyJava to compile the codebase with Java 8, except module-info.java.

  • checkLegacyJava verifies that a sample project class was effectively compiled towards version 8 bytecode.

  • jvmVersionAttribute sets the org.gradle.jvm.version attribute to 8, so the metadata that Gradle generates advertises Java 8 as the minimum required Java version (otherwise it would be 11).

If your project is simple enough you could omit the second task, but sometimes plugins or other tasks can mess with the configuration so it is generally a good idea to keep it.

The module-info.java file is compiled to Java 11 bytecode, as version 11 is currently the lowest modular JDK that is still supported:

java {
    sourceCompatibility = JavaVersion.VERSION_11
}

compileJava {
    includes = ['module-info.java']
    dependsOn compileLegacyJava
    classpath = sourceSets.main.compileClasspath
}

tasks.register('compileLegacyJava', JavaCompile) {
    description = 'Compile to Java 8 bytecode, except module-info'
    dependsOn configurations.compileClasspath
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
    source = sourceSets.main.java
    classpath = sourceSets.main.compileClasspath
    destinationDirectory = sourceSets.main.java.destinationDirectory
    modularity.inferModulePath = false
    excludes = ['module-info.java']
}

// Check bytecode version, in case some other task screws it
tasks.register('checkLegacyJava') {
    description = 'Check that classes are Java 8 bytecode (except module-info)'
    enabled = enabled && !project.getPluginManager().hasPlugin('eclipse')
    def classdir = sourceSets.main.output.classesDirs.files.stream().findAny().get()
    def classfiles = fileTree(classdir).matching({it.exclude('module-info.class')}).files
    doFirst() {
        if (!classfiles.isEmpty()) {
            def classfile = classfiles.stream().findAny().get()
            if (classfile != null) {
                def classbytes = classfile.bytes
                def bcversion = classbytes[6] * 128 + classbytes[7]
                if (bcversion != 52) {
                    throw new GradleException("Bytecode on " + classfile +
                        " is not valid Java 8. Version should be 52, instead is " + bcversion)
                }
            }
        }
    }
}

// Set the 'org.gradle.jvm.version' attribute to 8
tasks.register('jvmVersionAttribute') {
    description = "Set the correct 'org.gradle.jvm.version' attribute"
    dependsOn compileJava
    if (!project.getPluginManager().hasPlugin('eclipse')) {
        def jvmVersionAttribute = Attribute.of('org.gradle.jvm.version', Integer)
        configurations.each {
            if (it.canBeConsumed) {
                def categoryAttr = it.attributes.getAttribute(Category.CATEGORY_ATTRIBUTE)
                if (categoryAttr != null && categoryAttr.name == Category.LIBRARY) {
                    def usageAttr = it.attributes.getAttribute(Usage.USAGE_ATTRIBUTE)
                    if (usageAttr != null && (usageAttr.name == Usage.JAVA_API
                            || usageAttr.name == Usage.JAVA_RUNTIME)) {
                        it.attributes.attribute(jvmVersionAttribute, 8)
                    }
                }
            }
        }
    }
}

classes.dependsOn jvmVersionAttribute
classes.finalizedBy checkLegacyJava
jar.dependsOn checkLegacyJava

Dependency variants

If you are configuring a multi-module build which has dependency variants, you should put the jvmVersionAttribute dependency declaration in the same file(s) where the variants are declared, see "Usage in multi-module builds".

If your project has no dependency variants, you can omit the jvmVersionAttribute task and instead use this:

configurations {
    def jvmVersionAttribute = Attribute.of('org.gradle.jvm.version', Integer)
    apiElements {
        attributes {
            attribute(jvmVersionAttribute, 8)
        }
    }
    runtimeElements {
        attributes {
            attribute(jvmVersionAttribute, 8)
        }
    }
}

Usage in multi-module builds

In a multi-module project, you may want to include the task declarations in a convention plugin (those plugins are often located in ${rootDir}/buildSrc/src/main/groovy, if you use Groovy scripts).

If you declare dependency variants in your subprojects (as opposed to doing that in the convention plugin), you need to remove the classes.dependsOn jvmVersionAttribute dependency declaration from the plugin, and instead put them on each subproject's build.gradle file:

classes.dependsOn jvmVersionAttribute

Projects with additional compilation tasks

In complex projects that have multiple customized tasks, you may have to adjust some of those tasks to accommodate the above compileLegacyJava compilation task. For example, in those cases sometimes it is a good idea to add a dependsOn jvmVersionAttribute to checkLegacyJava.

Similarly, if your Java compiler needs a specific configuration you may need to modify the task accordingly, preferably using a generic block that modifies all the relevant tasks, for example:

tasks.withType(JavaCompile) {
    options.encoding = 'UTF-8'
}

The code presented in this gist works in most cases but is not intended as an universal, self-contained solution. Customizations may be required.


Interactions with the Eclipse IDE

When you import a project into the Eclipse IDE (or execute Gradle > Refresh Gradle Project), it is generally a good idea to run a command-line build first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment