Classpath woes in included build scripts

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:

  1. 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.
  2. The only stated workaround we’ve seen is, “use a buildscript block to set up the included build script with the same classpath”.
  3. 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:

  1. 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.
  2. Has anything at all been done in the past 10 years to make this less awkward?

I cannot say much on what was changed or not.
But I don’t think much changed.
And it also cannot really be changed without breaking builds in strange ways.
Both solutions the one implemented and the one you suggest have their own quirks and problems you just not think about right now.
If the situation were the other way around (if possible at all technically), there would probably be at least as many voices asking for the current behavior as are now asking for the other behavior.

I’d indeed have also suggested to just not use those regular script plugins, especially with Kotlin DSL they are awkward as you also do not get type-safe accessors generated for them. Instead, convention plugins, for example implemented as precompiled script plugins either in buildSrc or - what I prefer - in included builds, are usually the way to go to properly organize build logic in an idiomatic way. This way the build scripts can be as idiomatic and DSL-like as possible and often just apply one or two convention plugins and declare some dependencies for that project.

Yeah, certainly if it were a simpler project I would probably consider moving them to buildSrc. It’s a bit more messy in this one because we have included scripts scattered all throughout the project, which are sometimes used from one subproject, sometimes by all subprojects of one top-level project, sometimes by multiple top-level projects.

The ones used by multiple top-level projects I’ve been slowly refactoring into a shared build plugin. The ones used only by individual projects don’t make quite as much sense in there because they’re not really shared. It seems like whatever I end up doing it’s not going to be any cleaner than what we already have.

In some cases moving them to a precompiled script plugin is non-trivial because they depend on some combination of plugins to even work.

Maybe there’s a good way to clean all this up, but it is looking like a very long road, with no good examples to follow in other projects.

If it depends on plugins, apply those plugins. If it should do work in case the plugin is applied but nothing if not, use pluginManager.withPlugin to react to the plugin being applied.

Regarding the one-use convention plugins, you can just make an included build for them that you only include and use where you need it.