Search code examples
kotlingradlemultiplatform

Kotlin multiproject project setup


I'm having difficulties setting up my kotlin multiproject project. I've already spent hours of reading the documentation, but I get the feeling that I am getting nowhere. Maybe someone here could help me out and tell me how I need to adjust my build script.

My project setup is (or should be):

root
|-> src
    |-> commonMain
        |-> kotlin
    |-> commonTest
        |-> kotlin
    |-> jvmMain
        |-> kotlin
    |-> jvmTest
        |-> kotlin
    |-> nativeMain
        |-> kotlin
        |-> cpp
    |-> nativeTest
        |-> kotlin
        |-> cpp

The directories named "cpp" under nativeMain and nativeTest will contain additional platform specific code written in c++, which will depend on the C library generated by Kotlin/Native.

Currently, I'm trying to achieve the following: Generate a jar file that contains all the classes from {commonMain, commonTest, jvmMain, jvmTest}. Specifically, I want to include JUnit's ConsoleLauncher in my test-jar, so I have added a dependency on implementation("org.junit.platform:junit-platform-console-standalone:1.10.1") to my jvmTest sourceSet.

Running the gradle task jvmTest successfully launches all my test, but it does not generate a jar file (apparently, at least I can't find it). Is there a way to generate the jar with a gradle task?

Here is my build.gradle.kts script:

plugins {
    java
    id("java-library")
    kotlin("multiplatform") version "1.9.20"
}

repositories {
    mavenCentral()
}

dependencies {
    implementation(kotlin("stdlib"))
}

kotlin {

    jvm("jvm") {
        compilations.all {
            kotlinOptions {
                jvmTarget = "17"
            }
        }
    }

    linuxX64("linux")
    mingwX64("windows")

    targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
        binaries {
            sharedLib {
                baseName = if(name == "windows") "libnative" else "native"
            }
        }
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation(kotlin("stdlib"))
                implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6")
                implementation("org.junit.platform:junit-platform-console-standalone:1.10.1")
            }
        }
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test-common"))
                implementation(kotlin("test-annotations-common"))
            }
        }
        val windowsMain by getting {
            dependsOn(sourceSets["commonMain"])
        }
        val windowsTest by getting {
            dependsOn(sourceSets["commonTest"])
        }
        val linuxMain by getting {
            dependsOn(sourceSets["commonMain"])
        }
        val linuxTest by getting {
            dependsOn(sourceSets["commonTest"])
            dependencies {
                implementation(kotlin("test"))
            }
        }
        val jvmMain by getting {
            dependsOn(sourceSets["commonMain"])
        }
        val jvmTest by getting {
            dependsOn(sourceSets["commonTest"])
            dependencies {
                implementation("org.junit.platform:junit-platform-console-standalone:1.10.1")

                implementation(kotlin("test"))
                implementation(kotlin("test-junit5"))

                // needed by IDEA?
                implementation("org.junit.jupiter:junit-jupiter-engine:5.10.1")
                implementation("org.junit.jupiter:junit-jupiter-params:5.10.1")
                implementation("org.junit.jupiter:junit-jupiter-api:5.10.1")
            }
        }
    }
}

tasks.withType<Wrapper> {
    gradleVersion = "8.4"
    distributionType = Wrapper.DistributionType.ALL
}


tasks.named<Test>("jvmTest") {
    useJUnitPlatform()
    filter {
        isFailOnNoMatchingTests = false
    }
    testLogging {
        showExceptions = true
        showStandardStreams = true
        events = setOf(
            org.gradle.api.tasks.testing.logging.TestLogEvent.FAILED,
            //org.gradle.api.tasks.testing.logging.TestLogEvent.PASSED,
            org.gradle.api.tasks.testing.logging.TestLogEvent.SKIPPED
        )
        exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
        afterSuite(
            KotlinClosure2({
                desc: TestDescriptor, result: TestResult ->
                    if (desc.parent == null) {
                        // Only execute on the outermost suite
                        val color = if(result.resultType == TestResult.ResultType.SUCCESS) Colors.green else Colors.red
                        val reset = Colors.reset

                        println("")
                        println(" **** Result: $color ${result.resultType} $reset ****")
                        println("  >    Tests: ${result.testCount}")
                        println("  >   Passed: ${result.successfulTestCount}")
                        println("  >   Failed: ${result.failedTestCount}")
                        println("  >  Skipped: ${result.skippedTestCount}")
                    }
            })
        )
    }
}

Additionally, when I create a separate gradle task, it doesn't seem to find the same sourceSets as the kotlin plugin. With this gradle task, I only see the sourceSets "main" and "test", which (I suppose) don't contain any code:

tasks.register("printSourceSetsInfo") {
    doLast {
        sourceSets.all { sourceSet ->
            println("Source set: ${sourceSet.name}")
            println("   - Output directory: ${sourceSet.output}")
            println("   - Source directories: ${sourceSet.allSource.srcDirs}")
            println("   - Resources directories: ${sourceSet.resources.srcDirs}")
            println("   - Compile classpath: ${sourceSet.compileClasspath}")
            println("   - Runtime classpath: ${sourceSet.runtimeClasspath}")
            println()
           true
        }
    }
}

tasks.register("packageTests", Jar::class) {
    val jvmTestSourceSet = sourceSets.findByName("jvmTest") ?: sourceSets.findByName("testJvm") ?: sourceSets.findByName("test")

    if (jvmTestSourceSet != null) {
        from(jvmTestSourceSet.output)
       archiveFileName = "acteo-kotlin-tests.jar"
       destinationDirectory = file("build/libs/")
    } else {
       println("JVM test source set not found. Check your project configuration.")
       println("Available source sets: ${sourceSets.names.joinToString(", ")}")
        //throw GradleException("JVM test source set not found")
    }
}

I find this strange/confusing and I wonder, how I would set up specific tasks to generate my cpp code at some stage (as I would need to access the source sets in a dedicated cpp block instead of the kotlin block). Maybe someone can give me a hint as well? But maybe this is something for a separate question once I get to the point of writing additional cpp code...


Solution

  • Gradle and the Kotlin plugin

    Gradle is a powerful build tool and everything you need is there, but it can take a bit of digging at times. Realistically, you have to look through the code of the Kotlin Gradle plugin to see how it's put together and to write extra tasks just how you want them.

    In a Kotlin multiplatform project, all configuration sits on the Kotlin multiplatform extension which can be accessed using kotlin in the build.gradle.kts file. This extension is the central place where all the Kotlin multiplatform configuration sits and everything you want should be somewhere in that object.

    Kotlin source sets

    Additionally, when I create a separate gradle task, it doesn't seem to find the same sourceSets as the kotlin plugin.

    The short answer is: when you use the top-level sourceSets accessor in your build.gradle.kts file, you are accessing the Java source sets, not the Kotlin ones.

    As you probably know, Gradle in its original intent was to be a build tool for Java programs, and there are a range of plugins provided by Gradle designed for Java programs, such as the Java plugin, that follow well-known conventions.

    When a Kotlin multiplatform plugin has a JVM target, it applies the Java base plugin to make use of some of those conventions. But Kotlin multiplatform also sets up its own parallel system of source sets and compilations and in general those should be used for writing additional tasks.

    You can access Kotlin source sets inside the Kotlin extension like so (as you did when configuring your project):

    kotlin {
       sourceSets {
          // Configure source sets
       }
    }
    

    Generating JAR files

    Is there a way to generate the jar with a gradle task?

    Absolutely there is. This kind of task is really the raison d'être of Gradle.

    You're right that a test JAR is not created by default. But you can create any JAR you want by writing a task of type Jar (docs), as you had begun to.

    In Kotlin you can write:

    tasks.register<Jar>("createTestJar") {
       archiveClassifier.set("test")
       from(kotlin.jvm().compilations.get("test").output)
    }
    

    Adding dependencies to a JAR

    Specifically, I want to include JUnit's ConsoleLauncher in my test-jar

    You'll also soon discover that dependencies are not packaged into a JAR by default. In fact it is discouraged because in general it is the job of the consumer of a JAR to get hold of whatever dependencies are needed. But there are certainly some good reasons to package dependencies into what's called a shadow or fat JAR.

    This answer is too long already so I won't suggest any more code to you but you can start reading more in the Gradle documentation. Just be sure to pick up the Kotlin compilation outputs, not the Java plugin ones.

    You may also want to look at configurations which are Gradle's way of grouping dependencies (in order to control which groups of dependencies go into your JAR and which don't).