Exposing an "API" via kotlin-dsl script plugin

Hi,

I’m trying to write kotlin-dsl script/convention plugin that allow the consumer to set a property / calla function.

The convention plugins are set via the usual includedBuild pointing to a separate gradle project that has the kotlin-dsl plugin. Then in the actual convention plugin, e.g. for java, I would like to write something like this (using kotlin extentions methods)

plugins {
    id("sandbox.java-conventions")
}

java {
    configureJavaToolChain(
      20,
      listOf("jdk.incubator.concurrent")
    )
}

Or if using property extensions

java {
    javaToolchain Version = 20,
    addedModules = listOf("jdk.incubator.concurrent")
}

Here’s how I started to write this, notice the JavaCompile and KotlinCompile tasks are configured in the body by calling tasks.withType.
While this code compiles the method configureJavaToolChain does not seem to be reachable from the consumer. Also ideally the function approach doesn’t seem to scale much if I want to add other parameters, properties looks more appealing but also have the same visibility issues.

So I’m unsure how to proceed from this point.

plugins {
    id("java-library")
}

fun JavaPluginExtension.configureJavaToolChain(
    javaVersion: Int,
    // other parameters
    addedModules: List<String> = listOf(),
) {
    toolchain {
        languageVersion = JavaLanguageVersion.of(javaVersion)
    }

    tasks.withType<JavaCompile> {
        // sets release or source & target
        options.compilerArgs.addAll(
            // transform to proper args the added modules
        )
    }

    tasks.withType<JavaExec>().configureEach {
        // Need to set the toolchain https://github.com/gradle/gradle/issues/16791
        javaLauncher.set(javaToolchains.launcherFor(java.toolchain))
        jvmArgs(
            // transform to proper args the added modules
        )
    }
}

I’m currently experimenting on a public personal repo, if needed that may five more context.

Thanks in advance for any help

Imagine the content of a Kotlin script as the body of a class, which it effectively is.
So your extension function is only scoped to the body of that class.
Move it out to a regular .kt file.

I understand the concept, but since methods are public by default I assumed the methods or properties were accessible.

Also another question functions like JavaPluginExtension.configureJavaToolChain in the above example are actually accessing multiple things, e.g. this method “scope” is JavaPluginExtension so it’s only applicable if the java plugin has been applied and this method is also accessing Project items like tasks. Such method cannot be moved in a common .kt because the global scope won’t be Project. How should I approach the problem ?

I was thinking about creating extension properties on JavaPluginExtension in a regular kt file, and access them from the script with more or less the content of the method above. How does that sound ?

Another exemple in a common.kt, this property wouldn’t compile, because objects is not accessible from JavaPluginExtensions (only form Project).

var JavaPluginExtension.myJvmVendor: Property<JvmVendorSpec>
    get() = objects.property(JvmVendorSpec::class)

I understand the concept, but since methods are public by default I assumed the methods or properties were accessible.

That’s not so much a Gradle question, but more a question for some Kotlin community.
But for example consider, that the extension method within the “class body” also has that class’ instance as a receiver and thus could access its fields and methods, even private ones.
If you could use the extension method from outside the class, how would the receiver instance be defined?

Also another question functions like JavaPluginExtension.configureJavaToolChain in the above example are actually accessing multiple things, e.g. this method “scope” is JavaPluginExtension so it’s only applicable if the java plugin has been applied

Which is not a problem, because if the java plugin is not applied, you do not get hold of an instance of that extension and thus cannot call the extension function.

and this method is also accessing Project items like tasks. Such method cannot be moved in a common .kt because the global scope won’t be Project. How should I approach the problem ?

Well, that could be a problem. I’m not sure how to mitigate that out of the box, because I usually don’t provide Kotlin DSL specific things like extension functions, but do it in a DSL agnostic way. So for example, I’d register an extension on the project or on the java plugin extension or wherever it should be, that contains the method to be called and that gets the project on instantiation, …

I was thinking about creating extension properties on JavaPluginExtension in a regular kt file, and access them from the script with more or less the content of the method above. How does that sound ?

I’m not fully sure what you mean, but if it works for you, why not? :slight_smile:

Another exemple in a common.kt, this property wouldn’t compile, because objects is not accessible from JavaPluginExtensions (only form Project).

Yeah, same as above. Do it the Gradle way, not the Kotlin way and it will probably work much better.
So e.g. add an extension called myJvmVendor with the Property<JvmVendorSpec> public type and the objects.property call result as instance, or similar.

1 Like

Thank you ! Indeed using an extension is clearly a better approach I think.

EDIT: :warning: DISCLAIMER this code has some problems as discussed below

For future readers I can now write some thing like this now :

plugins {
    id("sandbox.java-conventions")
}

javaConvention {
    languageVersion = 20
    useRelease.set(true)
    addedModules.addAll("jdk.incubator.foreign")
}

This is baked by the interface declared in a .kt file

interface JavaConventionExtension {
    val languageVersion: Property<Int>
    val useRelease: Property<Boolean>
    val addedModules: ListProperty<String>
}

The convention plugin registers the extension and default values

val javaConventions: JavaConventionExtension =
    project.extensions.create(
        "javaConvention",
        JavaConventionExtension::class.java
    )
javaConventions.languageVersion.convention(11)
javaConventions.useRelease.convention(true)

And configures java extension and tasks

java {
    toolchain {
        languageVersion.set(javaConventions.languageVersion.map(JavaLanguageVersion::of))
    }
}

tasks {
    withType<JavaCompile> {
        if (javaConventions.useRelease.get()) {
            options.release.set(javaConventions.languageVersion)
        } else {
            sourceCompatibility = javaConventions.languageVersion.get().toString()
            targetCompatibility = javaConventions.languageVersion.get().toString()
        }
        options.compilerArgumentProviders.add(
            javaConventions.addedModules
                .map {
                    CommandLineArgumentProvider {
                        it.map { "--add-modules=$it" }
                    }
                }
                .get()
        )
    }

    // ...
}

Did you test that?
I don’t think it will work.

You use tasks.withType<...> { ... } which per-se is bad, as it eagerly configures all tasks of that type, instead you should almost always do tasks.withType<...>().configureEach { ... }.

But either way you evaluate your logic as soon as those tasks are configured.
And while doing so, you eagerly evaluate your extension properties, making their laziness void.

When using configureEach, you will maybe not recognize the problem, as it will only happen if something then eagerly configures those tasks before the extension is configured.

But without the configureEach it should already break, as you break the task-configuration avoidance yourself and thus eagerly configure the tasks and thus eagerly evaluate your extension.

You instead need to wire providers together if possible and if not possible find other ways.
You could for example instead of having a val useRelease: Property<Boolean>, have a fun useRelease() and in that do the according logic to react to the consumer calling that function. Or fun useRelease(doIt: Boolean, languageVersion: Int) or something like that.

1 Like

Ha yes, I noticed the issue, when moving the configuration from modules to convention I forgot to report the .configureEach, added it after writing the answer ! But you uncovered a few things I wasn’t entirely aware. Typically I experienced the problem you described in a submodule that was configuring the task before my extension.

In the convention I’m trying to set up useRelease is just about using the --release on javac, so a fun useRelease() should really only act on the JavaCompile regardless of the version. That said I think I get the point of having an actual function implementation that configuring the elements mentioned above.
I need to wrap around my head around this.

1 Like