Search code examples
kotlingradlegradle-kotlin-dslkotlin-script

Is there a way to instantinate KTS script engine in the Gradle KTS?


I want to use 3d party library in my project build process. Library methods requires ScriptEngine. When I'm trying to instantiate it i got an error:

java.lang.IllegalArgumentException: Unable to construct script definition: Unable to load base class kotlin.script.experimental.api.KotlinType@42842bb8
    at kotlin.script.experimental.host.ConfigurationFromTemplateKt.getTemplateClass(configurationFromTemplate.kt:189)
    at kotlin.script.experimental.host.ConfigurationFromTemplateKt.createScriptDefinitionFromTemplate(configurationFromTemplate.kt:36)
    at kotlin.script.experimental.jsr223.KotlinJsr223DefaultScriptEngineFactory.<init>(KotlinJsr223DefaultScriptEngineFactory.kt:74)
    at ce.domain.usecase.load.LoadMetaFilesForTargetUseCase.invoke(LoadMetaFilesUseCase.kt:17)
    at ce.domain.usecase.entry.BuildProjectUseCase.invoke(BuildProjectUseCase.kt:24)
    at ce.domain.usecase.entry.BuildProjectUseCase.invoke$default(BuildProjectUseCase.kt:18)
    at Build_gradle$$$result$1.invoke(build.gradle.kts:68)
    at Build_gradle$$$result$1.invoke(build.gradle.kts:60)
    at org.gradle.kotlin.dsl.ProjectExtensionsKt$sam$org_gradle_api_Action$0.execute(ProjectExtensions.kt)
    at org.gradle.api.internal.tasks.DefaultTaskContainer.create(DefaultTaskContainer.java:368)
    at org.gradle.kotlin.dsl.ProjectExtensionsKt.task(ProjectExtensions.kt:147)
    at Build_gradle.<init>(build.gradle.kts:60)
    ...

I've reproduced issue with simple grdale project: Sample project gradle:

import kotlin.script.experimental.jsr223.KotlinJsr223DefaultScriptEngineFactory

plugins {
    kotlin("jvm") version "1.8.21"
    application
}

repositories {
    mavenCentral()
}

buildscript {
    repositories {
        mavenCentral()
        google()
    }
    dependencies {
        classpath("org.jetbrains.kotlin:kotlin-scripting-jsr223:1.8.10")
        classpath("org.jetbrains.kotlin:kotlin-scripting-common:1.8.10")
        classpath("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.8.10")
        classpath("org.jetbrains.kotlin:kotlin-reflect")
    }
}

dependencies {
    testImplementation(kotlin("test"))
}

abstract class TestProjectTask : DefaultTask() {
    @get: InputFile
    abstract val projectFile: RegularFileProperty

    @TaskAction
    fun execute() {
        try {
            val eng2 = KotlinJsr223DefaultScriptEngineFactory().getScriptEngine()
            println("Project file = ${projectFile.get()} $eng2")
            val worker = Worker()
            worker.doWork(eng2, projectFile.asFile.get().absolutePath)
        } catch (err: Throwable) {
            err.printStackTrace()
        }
    }
}

task("hello2", TestProjectTask::class) {
    projectFile.set(File("./project.kts"))
}

KotlinJsr223DefaultScriptEngineFactory().getScriptEngine() always throws same exception.


Solution

  • Thanks to work done by contributors in this issue and the linked comment thread, the answer is relative simple, even if discovering how to do it was not! I've cleaned up the provided workaround.

    Summary:

    • Create a Gradle task for running the Kotlin script
    • fetch the required compilation and runtime dependencies
    • Run K2JVMCompiler to generate the sources

    I recommend using a buildSrc convention plugin to set up the requisite logic. It helps keep the build scripts cleaner and more declarative, and setup-logic is contained within buildSrc.

    Kotlin dependencies

    First, make sure that the K2JVMCompiler class is available.

    If you're working in a single build.gradle.kts, then this can be achieved by applying the Kotlin plugin:

    // build.gradle.kts
    
    plugins {
      kotlin("jvm") version "1.8.22"
    }
    

    Or if writing a plugin/pre-compiled script plugin, add a dependency on the Kotlin Gradle Plugin in the project's build.gradle.kts.

    A compile-time dependency on kotlin-compiler-embeddable is required for accessing the K2JVMCompiler class.

    // buildSrc/build.gradle.kts
    plugins {
      `kotlin-dsl`
    }
    
    repositories {
      mavenCentral()
    }
    
    dependencies {
      implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22")
    
      // required for K2JVMCompiler::class - will be provided at runtime by Gradle
      compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:1.8.22")
    }
    

    ⚠️ Note that adding a dependency on KGP in buildSrc/build.gradle.kts means that all other KGP versions must be removed.

    // build.gradle.kts
    plugins {
      kotlin("jvm") // no version needed - it's set in buildSrc/build.gradle.kts
    }
    

    Run task

    Next, let's create the task that will be used to run the .main.kts files.

    In order to run a Kotlin script, we need a few things:

    • the location of the Kotlin script (obviously!)
    • the classpath used to compile the Kotlin script
    • the classpath used to run the Kotlin script

    In order to follow Gradle best practices, tracking task's input and output files is also important (but is not strictly required).

    // buildSrc/src/main/kotlin/RunKotlinScript.kt
    
    import org.gradle.api.DefaultTask
    import org.gradle.api.file.ConfigurableFileCollection
    import org.gradle.api.file.RegularFileProperty
    import org.gradle.api.tasks.*
    import org.gradle.process.ExecOperations
    import javax.inject.Inject
    
    /** Task for running Kotlin Scripts */
    abstract class RunKotlinScript @Inject constructor(
        private val executor: ExecOperations
    ) : DefaultTask() {
        /** Location of the `.kts` file (required) */
        @get:InputFile
        abstract val script: RegularFileProperty
    
        /** (optional) Files that the script uses as an input */
        @get:InputFiles
        @get:Optional
        abstract val scriptInputs: ConfigurableFileCollection
    
        /** (optional) Files that the script produces as output */
        @get:OutputFiles
        abstract val scriptOutputs: ConfigurableFileCollection
    
        @get:Classpath
        abstract val compileClasspath: ConfigurableFileCollection
    
        @get:Classpath
        abstract val runtimeClasspath: ConfigurableFileCollection
    
        init {
            group = "run kts"
            description = "Runs a Kotlin script"
        }
    
        @TaskAction
        fun run() {
            val scriptPath = script.get().asFile.invariantSeparatorsPath
            val runtimeClasspath = runtimeClasspath.asPath
    
            executor.javaexec {
                classpath(compileClasspath)
                mainClass.set(org.jetbrains.kotlin.cli.jvm.K2JVMCompiler::class.qualifiedName)
                args(
                    "-no-stdlib",
                    "-no-reflect",
                    "-classpath", runtimeClasspath,
                    "-script", scriptPath,
                )
            }
        }
    }
    

    (As previously mentioned, it's best to do this in a buildSrc directory, but you can paste this task into a regular build.gradle.kts too.)

    Compile and Runtime dependencies

    Let's use a pre-compiled convention plugin to define how to fetch the dependencies needed to compile and run a Kotlin script.

    // buildSrc/src/main/kotlin/kotlin-script-runner.gradle.kts
    
    plugins {
      kotlin("jvm") // no version needed - it's set in buildSrc/build.gradle.kts
    }
    
    // Fetch dependencies necessary to compile and run kts scripts inside Gradle,
    // so installing the kotlin CLI is not required (e.g. on CI/CD, or Heroku)
    val ktsCompileClasspath by configurations.creating<Configuration> {
        description = "Dependencies used to compile Kotlin scripts"
        isCanBeConsumed = false
    }
    
    val ktsRuntimeClasspath by configurations.creating<Configuration> {
        description = "Dependencies used to run Kotlin scripts"
        isCanBeConsumed = false
        // only fetch direct dependencies - the scripting context will pull in other dependencies
        isTransitive = false
    }
    
    dependencies {
        // add compile-time dependencies on the regular and scripting Kotlin compilers
        ktsCompileClasspath(kotlin("compiler"))
        ktsCompileClasspath(kotlin("scripting-compiler"))
        // only depend on Kotlin main-kts for runtime
        ktsRuntimeClasspath(kotlin("main-kts"))
    }
    

    We now have two Configurations that contain the requisite dependencies. In the same convention plugin, let's add those dependencies to all RunKotlinScript tasks.

    // buildSrc/src/main/kotlin/kotlin-script-runner.gradle.kts
    
    // ...
    
    tasks.withType<RunKotlinScript>().configureEach {
        runtimeClasspath.from(ktsRuntimeClasspath)
        compileClasspath.from(ktsCompileClasspath)
    }
    

    Creating run tasks

    This convention plugin can be applied to any script in the project:

    // my-subproject/build.gradle.kts
    
    plugins {
      `kotlin-script-runner`
    }
    

    and then you can create a task, which will be correctly configured

    // my-subproject/build.gradle.kts
    
    
    tasks.register<RunKotlinScript>("runDoSomethingKts") {
        script.set(file("scripts/do-something.main.kts"))
        scriptOutputs.from("scripts/out.txt")    
        scriptInputs.from("script/input.txt")
    }
    

    and can be run, using Gradle

    ./gradlew runDoSomethingKts