I wanted to create gradle plugin to allow me to do...
# community-support
s
I wanted to create gradle plugin to allow me to do something like the slack gradle plugin, which is to apply my plugin to my feature module, and be able to do:
Copy code
myPlugin {
  // Pick if it's an android or jvm module, if it's android or not, have options to configure compose compiler etc.
  androidLibrary {
    withCompose()
  }
  jvmLibrary() // this OR androidLibrary etc.
  // plus other optional configuration
  kotlinxSerialization()
  ksp()
}
I was reading “Gradle plugins and extensions: A primer for the bemused” to get an idea of how to structure this along with trying to follow the docs. I feel like I am having a problem of not properly understanding the order in which I should be doing things and I am doing some very critical mistake in how I am structuring everything. This is the first time I try to do this, so I will post my current approach in the thread and any help would be greatly appreciated!
I got something like this in place:
Copy code
abstract class HedvigGradlePluginExtension @Inject constructor(objectFactory: ObjectFactory) {
    internal val library: LibraryHandler = objectFactory.newInstance()

    fun library(action: Action<in LibraryHandler>) {
        library.isConfigured.set(true)
        library.isConfigured.disallowChanges()
        action.execute(library)
    }

    companion object {
        internal fun Project.hedvig(): HedvigGradlePluginExtension {
            return extensions.create<HedvigGradlePluginExtension>("hedvig", project.objects)
        }
    }
}

abstract class LibraryHandler @Inject constructor(
    objectFactory: ObjectFactory,
) {
    internal val isConfigured: Property<Boolean> = objectFactory.property<Boolean>()
    internal val jvm: Property<Boolean> = objectFactory.property<Boolean>().convention(false)
    internal val android: Property<Boolean> = objectFactory.property<Boolean>().convention(false)
    internal val compose: Property<Boolean> = objectFactory.property<Boolean>().convention(false)

    fun jvm() {
        jvm.set(true)
        jvm.disallowChanges()
    }

    fun android() {
        android.set(true)
        android.disallowChanges()
    }

    fun compose() {
        compose.set(true)
        compose.disallowChanges()
    }
}
and on the plugin side:
Copy code
class HedvigGradlePlugin : Plugin<Project> {
    override fun apply(project: Project) {
        val libs = with(project) { the<LibrariesForLibs>() }
        val pluginManager = project.pluginManager
        val hedvig = project.hedvig()

        if (hedvig.library.isConfigured.get()) {
            if (hedvig.library.android.get() && hedvig.library.jvm.get()) {
                error("Module: [${project.name}] can not both be an android and a jvm library.")
            }
            if (hedvig.library.android.get()) {
                pluginManager.apply(LibraryConventionPlugin::class.java) // LibraryConventionPlugin etc. are other custom plugins I have which apply AGP and so on inside their `apply` function
            }
            if (hedvig.library.jvm.get()) {
                pluginManager.apply(LibraryJvmConventionPlugin::class.java)
            }
            if (hedvig.library.compose.get()) {
                pluginManager.apply(LibraryComposeConventionPlugin::class.java)
            }
        }
    }
}
And so far the DSL seems to work well, I get autocompletion etc. However it seems like the properties that I am setting are not evaluated yet at this point, so they always default to whatever I’ve put as
convention()
for them. Googling some more, I figured that I do get the real values if I wrap everything starting with
if (hedvig.library.isConfigured.get()) {
in a
project.afterEvaluate {
then all the properties are in fact set to what they should be. However the problem then becomes that I am configuring the other plugins too late. In particular I am getting an exception from the android gradle plugin which says:
Copy code
It is too late to set compileSdk It has already been read to configure this project. Consider either moving this call to be during evaluation, or using the variant API.
Which is a result of me trying to configure it inside
afterEvaluate
. Am I taking a completely wrong approach to this? Is there a way to be able to have such a dsl set some properties, and still read them before evaluation so that I can conditionally apply other plugins too?
m
Move all your
apply
logic to your extension.
It's all imperative code under the hood so the order matters
This also means you would move moving this part (applying the android plugin) outside the
plugins {}
block
Copy code
if (hedvig.library.android.get()) {
                pluginManager.apply(LibraryConventionPlugin::class.java) // LibraryConventionPlugin etc. are other custom plugins I have which apply AGP and so on inside their `apply` function
            }
So you lose the autogenerated accessors
tldr; you can't apply plugins based on user input and have autogenerated accessors at the same time
You either have to: 1. Let your users apply the dependent plugins in the
plugins {}
block and have your plugin react with
pluginManager.withId{}
2. Or switch to completely imperative code and you lose the generated accessors I like 1. better because it also allows users of your plugin to control the version they are applying (unless you're in a convention plugin where you control all the classpath and this is less of an issue)
s
Hmm yeah this is in fact a convention plugin, and I do control the classpath. My entire hope with this is that I could simplify my feature module Gradle files by only applying this one plugin and configuring everything through a dsl instead. If I have to define the android plugin for example it defeats the purpose of what I had in mind. How would that imperative approach look like then? Inside my extension I would need to apply the plugin directly where I have the
fun compose()
for example? Without setting some property to "mark" it to do it later?
m
Inside my extension I would need to apply the plugin directly where I have the
fun compose()
for example?
Exactly
And you need to replace
android { }
blocks with stuff like:
Copy code
project.extensions.getByName("android") {
  this as BaseExtension<*,*,*,*,*>
  // usual stuff here
  defaultConfig { ... }
}
So it's a bit more verbose but I like it better personally, it removes the magic and makes the dependencies explicit
s
Hmm I feel like I must've misunderstood some part here. Stripping down my plugin + extension to just this:
Copy code
class HedvigGradlePlugin : Plugin<Project> {
  override fun apply(project: Project) {
    project.hedvig()
  }
}

abstract class HedvigGradlePluginExtension @Inject constructor(
  private val pluginManager: PluginManager,
) {
  fun library() {
    pluginManager.apply(LibraryConventionPlugin::class.java)
    pluginManager.apply(LibraryComposeConventionPlugin::class.java)
  }
  companion object {
    internal fun Project.hedvig(): HedvigGradlePluginExtension {
      return extensions.create<HedvigGradlePluginExtension>("hedvig", project.pluginManager)
    }
  }
}
And later applying it in my module like:
Copy code
plugins {
  id("hedvig.gradle.plugin")
}

hedvig {
  library()
}
(Which plugin I've created by doing this in the place I have the rest of my convention plugins)
Copy code
gradlePlugin {
  fun createPlugin(id: String, className: String) {
    plugins.create(id) {
      this.id = id
      implementationClass = className
    }
  }
  createPlugin("hedvig.gradle.plugin", "HedvigGradlePlugin")
}
Where LibraryConventionPlugin and LibraryComposeConventionPlugin (here and here is what they call internally) are pre-existing plugins. These are what I was previously calling individually on each of my modules, but I was hoping to re-use here. It seems like I am still not setting up everything properly. Not sure if I am again somehow doing something "too late". The error I get when I try to sync in my IDE is that I get
Unresolved reference: implementation
, making me understand that those two plugins are just not applied in time properly Does this by itself feel like it should just work already as-is or not really?
v
Where are you getting that? You didn't show that relevant part. If you do that in the file doing the
library()
call, then yes. Such type-safe accessors are only available for things Gradle knows will be there at runtime. That means for anything that plugins create unconditionally when applied using the
plugins { ... }
block. But the plugin you apply does not do that. You only apply the plugins that add the
implementation
configuration from your extension's function, so no type-safe accessors get generated. You would need to do
Copy code
val implementation by configurations.existing
dependencies {
    implementation(...)
}
If you want the type-safe accessors, the configuration needs to be created by something you apply in your
plugins
block without condition. You could e. g. have a plugin
hedvig.library.gradle.plugin
that then applies those plugins directly in its
apply
method, then you would have the accessors you miss.
s
You are right, sorry for the partial data here. I am getting these errors over at my gradle module where I am trying to apply this plugin of mine. The code in that build.gradle.kts file looks like this:
Copy code
plugins {
  id("hedvig.gradle.plugin") // <- This is applying my plugin I am trying to create
  // here come other plugins that are applied to this project. *not* including kotlin or android gradle plugins
}

hedvig {
  library()
}

dependencies {
  // Here is what crashes when I mean `implementation` is not found
  implementation(libs...)
  api(libs...)
}
So if I understand you correctly, due to the fact that this gradle module does not actually know about the fact that
"hedvig.gradle.plugin"
will internally apply the kotlin gradle plugin, the android gradle plugin etc, it can't understand where
dependencies
is even coming from, right? Since the kotlin gradle plugin is only conditionally applied, therefore the dependencies block is not always there. This is what we mean here by "losing access to the autogen accessors" then? Am I closer to understanding this now?
👍 1
I would optimally then want to unconditionally apply my gradle plugins to always get the right things generated for me. However I do want to have the flexibility here of either adding the
org.jetbrains.kotlin.android
or the
org.jetbrains.kotlin.jvm
or in the future the one for multiplatform depending on what I put in my
hedvig {}
dsl. Doing
val implementation by configurations.existing
actually does seem to work, that's very interesting! Is there a way for me to perhaps expose these from inside my plugin so that I can still have my call site omit adding
Copy code
val implementation by configurations.existing
val api by configurations.existing
val debugImplementation by configurations.existing
val debugApi by configurations.existing
val testImplementation by configurations.existing
etc. in all of my modules? Or if not, am I perhaps trying to take down a path I should not be taking here? I am even considering going down this path in the first place as I want to make it easier to let some of my modules start supporting KMP, and I want us to more eagerly make more of our modules be jvm only when they don't need to be android ones. If I continue adding yet more stringly-typed convention plugins which I must then remember to call in each module, like:
Copy code
plugins {
 // in each module, I need to be careful to call the right combination of these. typically hedvig.[WHATPLATFORM].[compose | omit this entirely], depending on what it needs to target, and what other plugins need to be applied in there, like the compose gradle plugin
 id("hedvig.android.library")
 id("hedvig.android.library.compose")
 id("hedvig.jvm.library")
 id("hedvig.jvm.library.compose")
 id("hedvig.multiplatform.library")
 id("hedvig.multiplatform.library.compose")
 // and so on
}
It starts getting a bit uncomfortable, and prone to mistakes since it's all just not type-safe strings. And what we end up doing instead is just keep things as just
Copy code
plugins{
 id("hedvig.android.library")
 id("hedvig.android.library.compose")
}
Since it's the easiest to do. So we keep many modules as android modules since "it already works" even if flipping to a jvm module might be trivial.
m
Is there a way for me to perhaps expose these from inside my plugin so that I can still have my call site omit adding
You can create configurations in
Plugin.apply()
and expose them in your extension:
Copy code
abstract class HedvigGradlePluginExtension @Inject constructor(
  private val pluginManager: PluginManager,
  private val debugImplementation: Configuration,
  private val implementation: Configuration,
  ...
)
Or if you create them later on make them part of your DSL
Copy code
ckass HedvigGradlePluginExtension {
  fun library(action: Action<LibrarySpec>) {
    pluginManager.apply("someOtherPlugin")
    val librarySpec = LibrarySpec(
      project.configurations.getByName("debugImplementation")
      project.configurations.getByName("implementation")
      // ...
    )
    action.execute(librarySpec)
  }
}
v
Is there a way for me to perhaps expose these from inside my plugin so that I can still have my call site omit adding
You can, but you definitely shouldn't. If you create such from your plugin code, then they will always be available even if no
library()
or similar is called. This will then work when the consumers script is compiled, but fail at runtime. Which is exactly why those accessors are only generated if Gradle is pretty sure it will be there at runtime, so that it already fails at script compilation time, not at runtime and only if that code-path is actually hit.
But having it like
Copy code
hedvig {
    library {
        dependencies {
            implementation(...)
        }
    }
}
or similar could for example work
1
Gradle even has support for adding something like that, which then is exactly like the dependencies blocks in the JVM test suite blocks.
s
Right, so I'd need to provide the right scope inside the Action lambda there myself. Where inside my extension I'd need to somehow get a hold of everything I'd need, since at that point I would know for a fact that the jvm kotlin plugin for example was added
👍 1
Thought I’d share this here because why not. Thanks to your help I got all of it working here! https://github.com/HedvigInsurance/android/pull/2279/files#diff-57f30c62b12ed6aec35bf38965db8791d425d96c73c6fb2b8bd623108b4896bc A typical feature module file changed now from this:
Copy code
plugins {
  id("hedvig.android.feature")
  id("hedvig.android.ktlint")
  id("hedvig.android.library")
  id("hedvig.android.library.compose")
  alias(libs.plugins.apollo)
  alias(libs.plugins.serialization)
  alias(libs.plugins.dependencyAnalysis)
  alias(libs.plugins.squareSortDependencies)
}

dependencies {
  ...
}

apollo {
  service("octopus") {
    packageName = "octopus"
    dependsOn(projects.apolloOctopusPublic, true)
  }
}
to this:
Copy code
plugins {
  id("hedvig.gradle.plugin")
  id("hedvig.android.library")
}

hedvig {
  apollo("octopus")
  serialization()
  compose() // bonus points that this works regardless of it it's an android or jvm module
}

dependencies {
  ...
}
I did after all do the tradeoff where my plugin will not conditionally apply the necessary android or jvm configuration, each module makes their own decision for that as a separate plugin. That way I still get all the autogenerated accessors for android related stuff, but I moved the configuration for the rest of the things (like apollo-kotlin) inside my extension as I was suggested to do here. And I even got a good idea of how I could improve all of this moving forward in various ways, since I now do at least somewhat understand what I am doing with all of this gradle stuff 😄 There’s no way I could’ve figured all of this out without both your help, I really really appreciate it!
🙌 1