How we got here:
I had a convenience method for converting a Provider<PluginDependency> to a Provider<ExternalModuleDependency>, which I’m sure people have seen before:
/**
* Some additional syntax sugar so you can more easily convert a `libs.plugins`
* accessor for use in the `dependencies` block.
*/
fun Provider<PluginDependency>.asDependencyProvider(): Provider<ExternalModuleDependency> =
this.map { pluginDependency ->
// Exploiting standard Gradle conventions for locating the plugin's
// artifact as there does not appear to be a public way to get this info.
// Maybe there is, probably involving reading files directly from
// the repo...
val pluginId = pluginDependency.pluginId
val id = DefaultModuleIdentifier.newId(pluginId, "$pluginId.gradle.plugin")
// Crazy that this thing _demands_ the version constraint be mutable, but OK, fine.
val versionConstraint = DefaultMutableVersionConstraint(pluginDependency.version)
val configuration: String? = null
DefaultExternalModuleDependency(id, versionConstraint, configuration)
}
The usage looks like:
dependencies {
implementation(libs.plugins.kotlin.multiplatform.asDependencyProvider())
implementation(libs.plugins.compose.multiplatform.asDependencyProvider())
implementation(libs.plugins.compose.compiler.asDependencyProvider())
implementation(libs.plugins.dokka.asDependencyProvider())
implementation(libs.plugins.kotest.asDependencyProvider())
implementation(libs.plugins.ksp.asDependencyProvider())
}
I imagine some people just duplicate the entries in their libs.versions.toml file and go on with their day, especially when it’s for work, but I rather enjoy removing unnecessary duplication in my build. So, after some time, I decided I didn’t want to have so many of these .asDependencyProvider() things, so I started trying to golf down the API. Initially, I made some convenience overloads:
fun DependencyHandler.add(
configurationName: String,
dependencyNotation: Provider<PluginDependency>,
) {
// This will always return `null`, which is why we didn't bother returning it
add(configurationName, dependencyNotation.asDependencyProvider())
}
fun DependencyHandler.implementation(dependencyNotation: Provider<PluginDependency>) {
// These parameter names are LOAD BEARING
add(configurationName = "implementation", dependencyNotation = dependencyNotation)
}
So now you can drop the extra calls:
dependencies {
implementation(libs.plugins.compose.multiplatform)
implementation(libs.plugins.compose.compiler)
implementation(libs.plugins.dokka)
implementation(libs.plugins.kotest)
implementation(libs.plugins.ksp)
}
And it works, but not without some quirks:
- Hard-coding
implementationis technically cheating because who knows whether a configuration with that name exists, really. (Well, I guess for a Gradle plugin build, it does, but it still feels bad to be applying that plugin when you don’t really need to…) - It’s super easy to fail to import the overloaded method, and you get no compiler warning when it happens, just a failed build the next build. IDEA, at least, is not the best at method overloads being defined in more than one place. (Or not the best at extension methods which happen to be named like the other methods, however you want to phrase that.)
- I even shot myself in the foot with this while implementing it - turns out Kotlin prefers to call the
Anyoverload on the actual interface, even if I’m trying to call a more specific overload defined in the same source file as the caller. I got around it by adding parameter names, but there is no way that will hold forever. If Gradle ever rewrites that in Kotlin, and they happen to choose the same parameter names? Bang.
- I even shot myself in the foot with this while implementing it - turns out Kotlin prefers to call the
- I would still need to write a heck of a lot more to complete it.
- Example:
createwould be nice sometimes
- Example:
This led to thinking:
⇒ Why can’t I just override create somehow?
⇒ Hang on, Gradle has all sorts of different things it can convert into a Dependency, surely they didn’t do that in one big if-else statement
⇒ Oh, NotationConverter and NotationParser exist
⇒ Oh, they use some kind of pluggable service registration?!
Anyway, my excitement was stomped when I discovered that the actual NotationParser used at runtime seemed hard-coded with only half a dozen or so supported input types. But there are certainly a lot of layers of indirection in how those things are being created, so:
- Is there any way to plug into this, at any level? (I know it’s internal API, but sometimes the benefits do outweigh the drawbacks)
- Is there some other way to get the same sort of behaviour? (The best idea I was able to pull out of any LLMs was to use global init scripts, and yeah, with that, I can certainly apply plugins that might be able to get it to work, but there isn’t any clean way to have those per-project, is there?)
- Is Gradle’s lack of automatic conversion for this just an oversight, or was it done deliberately? I figure keeping the interfaces separate was deliberate, but it surely didn’t take long until one of Gradle’s own developers hit this. I mean, they make more plugins than almost anyone else… surely?
Maybe some plugin like kotlin-dsl or java-gradle-plugin would be a place for this sort of machinery, because it really seems to be something that you only ever want when writing Gradle plugins. But maybe it’s just something that was never implemented because the hook doesn’t exist yet.