Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BCV-MU settings plugin requires Kotlin plugin is defined as a buildscript dependency #1

Open
aSemy opened this issue Mar 2, 2023 · 5 comments · May be fixed by #11, #15 or #25
Open

BCV-MU settings plugin requires Kotlin plugin is defined as a buildscript dependency #1

aSemy opened this issue Mar 2, 2023 · 5 comments · May be fixed by #11, #15 or #25

Comments

@aSemy
Copy link
Contributor

aSemy commented Mar 2, 2023

When applying BCV-MU via the settings plugin, if any subproject is Kotlin/Multiplatform (and probably Android too) KGP is required as a buildscript dependency.

// settings.gradle.kts

buildscript {
  dependencies {
    // BCV-MU requires the Kotlin Gradle Plugin classes are present 
    classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10")
  }
}

plugins {
  id("dev.adamko.kotlin.binary-compatibility-validator") version "$bcvMuVersion"
}

Without this, BCV-MU fails because the KGP classes it uses to fetch the dependencies are missing.

@Fleshgrinder explained more here: Kotlin/binary-compatibility-validator#88 (comment)

Gradle uses classpath isolation with inheritance. The classpath of settings is isolated from the projects, and projects inherit the classpath of their settings. But, an init script for instance is entirely isolated from everything else.

Defining a dependency as compileOnly means that our classpath at runtime does not contain the classes we used for compilation. Since settings is isolated from projects we never see their classes, hence, we cannot find the classes we defined as compileOnly since nobody added them to our runtime classpath.

Us adding the classes to our runtime classpath (either by using implementation or combining compileOnly with runtimeOnly which is implementation) would force the dependency upon our consumers. Since projects inherit from settings they would inherit our version of whatever we added to our runtime classpath. This is obviously not what we want.

This is a classic problem with Gradle plugins. A good way to solve it is by shadowing the dependencies. We actually do not require a dependency on the Gradle Kotlin DSL, we only require a dependency on its API (org.jetbrains.kotlin:kotlin-gradle-plugin-api) (reducing the amount of code to shadow). By using the API we lose the KotlinMultiplatformExtension class, but we could rewrite the code to use an alternative instead that works just as well:

  private fun createKotlinMultiplatformTargets(project: Project, extension: BCVProjectExtension) {
    project.pluginManager.withPlugin("kotlin-multiplatform") {
      project.extensions.getByType<KotlinTargetsContainer>().targets.forEach {
        val platformType = it.platformType
        if (platformType == KotlinPlatformType.jvm || platformType == KotlinPlatformType.androidJvm) {
          extension.targets.register(it.targetName) {
            enabled.convention(true)
            it.compilations.configureEach {
              inputClasses.from(output.classesDirs)
            }
          }
        }
      }
    }
  }

With that it should work. Maybe there is also a way to change the current classloader, since the current thread within the project.pluginManager.withPlugin("kotlin-multiplatform") block can load the class. But, I do not know how or if this is at all possible (sounds dangerous, hopefully it is not allowed).

I have attempted to implement the proposed solution. Using KGP-api works well, but I have still encountered problems when using Shadow:

  • With isEnableRelocation = true, Gradle doesn't seem to pick up the KotlinTargetsContainer extension

    An exception occurred applying plugin request [id: 'org.jetbrains.kotlin.multiplatform', version: '1.7.20']
    > Failed to apply plugin 'org.jetbrains.kotlin.multiplatform'.
       > Extension of type 'KotlinTargetsContainer' does not exist. Currently registered extension types: [ExtraPropertiesExtension, BCVProjectExtension, KotlinMultiplatformExtension, KotlinTestsRegistry, BasePluginExtension, DefaultArtifactPublicationSet, SourceSetContainer, ReportingExtension, JavaToolchainService, JavaPluginExtension, KotlinArtifactsExtensionImpl]
    
  • With With isEnableRelocation = false and Kotlin/JVM subprojects there's some sort of problem

    An exception occurred applying plugin request [id: 'org.jetbrains.kotlin.jvm', version: '1.7.20']
    > Failed to apply plugin 'org.jetbrains.kotlin.jvm'.
       > Could not create an instance of type org.jetbrains.kotlin.gradle.plugin.mpp.KotlinWithJavaTarget.
          > Could not generate a decorated class for type KotlinWithJavaTarget.
             > Cannot have abstract method KotlinTarget.attributes().
    
  • I've also encountered a weird 'wasm' error with Shadowing and Kotlin Multiplatform subprojects

    Output:
    
    FAILURE: Build failed with an exception.
    
    * What went wrong:
    wasm
    
    * Try:
    > Run with --info or --debug option to get more log output.
    > Run with --scan to get full insights.
    
    * Exception is:
    java.lang.NoSuchFieldError: wasm
        at org.jetbrains.kotlin.gradle.targets.js.ir.KotlinWasmTargetPreset.<init>(KotlinWasmTargetPreset.kt:20)
        at org.jetbrains.kotlin.gradle.plugin.mpp.KotlinMultiplatformPlugin.setupDefaultPresets(KotlinMultiplatformPlugin.kt:159)
        at org.jetbrains.kotlin.gradle.plugin.mpp.KotlinMultiplatformPlugin.apply(KotlinMultiplatformPlugin.kt:79)
        at org.jetbrains.kotlin.gradle.plugin.mpp.KotlinMultiplatformPlugin.apply(KotlinMultiplatformPlugin.kt:47)
        at org.jetbrains.kotlin.gradle.plugin.KotlinBasePluginWrapper.apply(KotlinPluginWrapper.kt:183)
        at org.jetbrains.kotlin.gradle.plugin.AbstractKotlinMultiplatformPluginWrapper.apply(KotlinPluginWrapper.kt:297)
        at org.jetbrains.kotlin.gradle.plugin.KotlinMultiplatformPluginWrapper.apply(PluginWrappers.kt:74)
    

As there's a workaround, and the settings plugin is still experimental, this isn't critical to fix right now. I'm making this issue to collect any info in once place.

I'll commit the progress I've made so far, and the tests.

@aSemy aSemy changed the title BCV-MU settings plugin requires Kotlin plugin is defined as a dependency BCV-MU settings plugin requires Kotlin plugin is defined as a buildscript dependency Mar 2, 2023
@Fleshgrinder
Copy link

We cannot use the relocated symbol to find an extension, because our relocated symbol not only has a different name, but also originates from a different classpath and is thus considered to not be the same thing at all. We cannot even cast from one to the other, despite us knowing that they are the same (well, we actually don't, because we don't know what version of the plugin the user applied, anyway).

We can retrieve the extension by name and use runtime reflection to force it into our desired target type.

aSemy added a commit that referenced this issue Mar 3, 2023
* allow for nested project paths in GradleProjectTest util, and minor doc update

* rename testGradleVersion to supportedGradleVersion

* make testMavenPublication task dependency more explicit

* update from ignoredMarkers to nonPublicMarkers in test case

* update from using KGP to KGP-api

* allow setting default BCV target values in BCVSettingsPlugin

* update test assertions

* Add settings plugin test

* update README example for settings plugin

* try using Shadow plugin... which didn't work, but it might with different config? #1 (comment)

* add kotest-datatest in libs.versions.toml

* commit code style

* update formatting
@martinbonnin
Copy link

martinbonnin commented Jan 11, 2024

Edit: never mind, I didn't realize this was about a settings plugin...

What about failing if KGP is not found? After all, BCV requires KGP to be in the classpath. This could either be done using Gradle APIs:

pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { found = true; doStuff() }
pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { found = true; doStuff() }
pluginManager.withPlugin("org.jetbrains.kotlin.android") { found = true; doStuff() }
afterEvaluate {
  check(found) {
    "BCV requires the Kotlin Gradle Plugin to be applied"
  }
}

or with reflection

// Wildly untested, probably crashes but you get the general idea
try {
  val version = Class.forName("org.jetbrains.kotlin.gradle.plugin.KotlinPluginWrapperKt")
    .getMethod("getKotlinPluginVersion")
    .invoke(null, project)

   when(version) {
      "1.9.21" -> doStuff1921()
      ...
   } 
} catch (e: Exception) {
  error("BCV requires the Kotlin Gradle Plugin to be in the classpath")
}

Gradle API feels a bit safer (no reflection) but reflection might be required if KGP ever does breaking API changes (I don't think the KGP API is stable just yet).

@aSemy
Copy link
Contributor Author

aSemy commented Jan 12, 2024

@martinbonnin This issue is about applying BCV in settings.gradle.kts, so the Kotlin plugins won't be applied. And even if that check is done on each subproject, the Gradle classpath is weird, so the KotlinPluginWrapper class won't be present e.g. gradle/gradle#27218

A workaround would have to use some sort of duck-typing, which is difficult in JVM...

It would be nice if Kotlin/JS's dynamic was possible in JVM!

@martinbonnin
Copy link

This issue is about applying BCV in settings.gradle.kts

Oh sorry I missed that 🤦 . Feel free to ignore my comment.

FWIW, Gradle has plans for a way to add a plugin to all projects while being project isolation compatible. See gradle/gradle#22514. That might be a good solution long term.

@martinbonnin
Copy link

That being said, I'm curious now...

Is the settings classloader a parent of every project classloader? If yes, then you could pull KGP as an implementation dependency of BCV-MU and tell users to not add KGP in their build scripts (i.e. remove from buildSrc/build.gradle or remove .version("1.9.21")). Maybe even detect that and fail if this happens (if that's possible, not sure...). This way you're guaranteed there's only one KGP version in the classpath.

Of course this is quite different from the usual ways to apply KGP but I think it'd be the only "correct" way to proceed?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment