2017-09-17

New output directories in IntelliJ IDEA 2017 causes problems in Gradle tasks

To be able to add build cache, Gradle 4 introduced new compilation-output directory layout. Since version 4, every language present in your source set has its own output directory. This means that your Java file main/java/JavaClass.java will be compiled into build/classes/java/main/JavaClass.class and your Kotlin file will end up in build/classes/kotlin/main/KotlinClass.class. That is the reason, why Gradle's SourceSetOutput.getClassesDir() method, returning a single path became deprecated and a new method getClassesDirs() returning collection was added.

Unfortunately IDEA does not support multiple output directories per source set and Gradle's change caused some problems. To address that, JetBrains changed an existing output directory to new one that differs from Gradle's so there wouldn't be any interference.

Problem

The problem is, sometimes you need to access classes compiled by IDEA from a Gradle task. For example if you are doing an instrumentation or some kind of other byte code manipulation. In my case I am using ActiveJDBC and Ebean ORMs, which both needs to instrument compiled classes. Of course, I could use javaagent but that's not the point.

So, when I launch an application from IDEA, the process is following:

  1. Classes are compiled by IDEA, into out/classes/... directory (formerly it was build/classes/...).
  2. Gradle instrumentation task is executed. The task looks for compiled classes in build/classes/... but none is found.
  3. Application is started and fails shortly after with an error message "Are you sure, your classes has been instrumented?".

Solution #1 (insufficient in some cases)

To solve the problem, Vladislav Soronka of JetBrains suggested adding idea plugin into Gradle build script. The plugin allows to reconfigure IDEA's output directories.

apply plugin: 'idea'

idea {
    module {
        outputDir file('build/classes/main')
        testOutputDir file('build/classes/test')
    }
}

Unfortunately this sets the same output directory for classes and resources. So instead of having build/classes directory for your class files and build/resources for your resources, you will end up with everything in the build/classes. Of course, former build/resources directory is not goint to be put on classpath of Java application started by IDEA.

In my case this caused problems with JavaScript and CSS styles preprocessing, which I run as another Gradle task.

Solution #2 (final)

Best solution I was able to come with is to create a parametrized build. That means I've modified my instrumentation and resources preprocessing scripts to use one set of output directories when executed by Gradle command line client, and other set of directories when started from IDEA. In my case, I've used project properties to determine whether the build was started from IDEA. Environment variables instead of project properties can be used as well.

First of all, I've created idea.gradle build script. The script contains a simple resolve method. Gradle does not allow functions to be shared between build scripts, so I had to define it as a closure in an extension property.

// IDEA 2017 changed output directories from Gradle compliant build/... to
// out/... because of compatibility issues with Gradle 4. Because of that, the
// instrumentation, less compilation or JavaScript routes generation doesn't
// work.
//
// To fix the issue we need to tell postprocessing scripts where to find
// compiled classes and/or output resources (because of classpath). This
// can be done by passing -Pidea2017 argument to Gradle.
//
// gradle -Pidea2017 instrumentModels
//
ext {
    resolveOutputPath = { project, sourceSet ->
        gradleOutput = project.sourceSets.main.output
        idea2017OutputPath = project.file('out/production').toPath()
        boolean idea2017 = project.hasProperty('idea2017')

        switch (sourceSet) {
            case "classes":
                return idea2017 ? idea2017OutputPath.resolve('classes') : gradleOutput.classesDir.toPath()
            case "resources":
                return idea2017 ? idea2017OutputPath.resolve('resources') : gradleOutput.resourcesDir.toPath()
                break;
            default:
                throw new IllegalArgumentException("Unknown source set " + sourceSet)
        }
    }
}

Then, in my instrumentation build script I defined output directory simply by calling the closure. This is a snippet from my ActiveJDBC instrumentation build script:

apply from 'idea.gradle'

Instrumentation instrumentation = new Instrumentation()
instrumentation.outputDirectory = resolveOutputPath(project, "classes")
...

Finally, I specified project property in a "before launch" run configuration.

Gradle 4 and the future

As it was mentioned before, Gradle 4 introduced multiple output directories. That means: a method project.sourceSets.main.output.classesDir is currently marked as deprecated and will be removed soon. Some kind of refactoring towards new classesDirs method will be necessary in the future. For now everything should work without problems.