We have a number of build scripts which were bumped to files, sometimes just to get large irrelevant logic out of the main build scripts, other times to use them from other places.
In most of these cases, it was possible to convert these to Kotlin DSL without any hassle. In other cases, classpath woes prevent us doing so. So we have 8 or so build scripts which are stuck in Groovy until we have a solution to this problem.
This issue with classpaths in included build scripts has been mentioned many times. For example, there was this post in 2012, when it was said:
We are aware that there’s some awkwardness here and will address it at some point.
“Some awkwardness” was probably the understatement of the year for 2012, but 10 years have passed since that post, so I wonder whether anything has been done by this point.
A quick summary of the issue:
- When you include a build script from a main build script, the included build script does not inherit the classpath of the main build script.
- The only stated workaround we’ve seen is, “use a buildscript block to set up the included build script with the same classpath”.
- If you have more than one plugin on the classpath in the main build script, to perform the suggested workaround, you have to include all those same plugins in the included build script, even if it only needs one of them. This is because a class with a given name is considered not equal due to coming from a different classloader - and Gradle only seems to share the classloaders when the classpath is exactly the same (it could be using one classloader per dependency and sharing them in all situations, but is not).
So for example, if your main script has this:
buildscript {
dependencies {
id("org.jetbrains.gradle.plugin.idea-ext")
application
}
}
apply(from = "idea.gradle.kts")
If idea.gradle.kts
has this, it doesn’t work:
idea {
project {
settings {
"MyApp"(Application) { // ERROR - Application cannot be resolved
mainClass = "acme.App"
moduleName = getProject().idea.module.name
}
}
}
}
And this doesn’t work either, even though intuitively it should:
buildscript {
dependencies {
id("org.jetbrains.gradle.plugin.idea-ext")
}
}
idea {
project {
settings {
"MyApp"(Application) { // ERROR - Application can be resolved but is not the right class!
mainClass = "acme.App"
moduleName = getProject().idea.module.name
}
}
}
}
Only this works:
buildscript {
dependencies {
id("org.jetbrains.gradle.plugin.idea-ext")
// Including a number of irrelevant plugins just to make the class loader the same object
application
}
}
idea {
project {
settings {
"MyApp"(Application::class) {
mainClass = "acme.App"
moduleName = getProject().idea.module.name
}
}
}
}
This trips people up fairly frequently, because any time they add a new plugin somewhere, the build can potentially break until you add the same plugin somewhere else, even if it doesn’t seem like it should be needed there. And in one situation, the two projects which wanted to include the script had different classpaths, making it impossible to use this workaround anyway.
There are a couple of common workarounds seen for this when the script is written in Groovy:
- Assign a variable called
Application
to a property of the project - Assign the classloader in the main script to a property of the project and then use that to look classes up in the other script.
In Kotlin DSL, these work in some situations but not all. e.g., if you want to call configure<SomeClass>
or some other method with a generic parameter, you need to give it an actual class.
Another workaround which I’ve seen suggested once or twice was moving these sorts of scripts to buildSrc
as precompiled script plugins. That may work… it isn’t entirely clear how to do this for all the scripts in our collection because it’s a complex project where these sorts of build scripts occur at all levels of the project structure, but it seems like it works for at least one of them.
All in all, it really seems like giving included scripts their own classpath was a bad solution to a nonexistent problem - all we wanted was just a way to bust giant build script logic out into separate files so it could be better organised, but what we got was this classpath hell. In contrast, had included scripts simply been included naïvely as strings, as if the content occurred in the main build script, it would have been simpler to implement, and easier to understand how it worked, and everyone would have got their scripts working on the first try, instead of burning weeks of dev time trying to figure out how to make it work.
Kotlinscript itself now has a @file:Import
annotation which can be used to include script content inline in the file, which would definitely give us what we want. I tried this as well, but it looks like Gradle doesn’t yet support it, presumably just because the version of Kotlin it’s using is still too old to get this feature.
Questions:
- Does Gradle intend to get support for
@file:Import
at some point? If it does, we may very well switch to that for all inclusions. - Has anything at all been done in the past 10 years to make this less awkward?