Skip to content

Commit

Permalink
When DGP is used in a pre-compiled script plugin, correctly generate …
Browse files Browse the repository at this point in the history
…accessors and disable warning logs (#3770)

Gradle generates accessors in a temp project that doesn't have Gradle properties. This breaks the `enableV2` flag.

This workaround detects when Gradle is generating accessors, and if so, tries to discover the `gradle.properties`.
  • Loading branch information
adam-enko authored Sep 19, 2024
1 parent a2c3e0e commit 7733051
Show file tree
Hide file tree
Showing 2 changed files with 267 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
package org.jetbrains.dokka.gradle.internal

import org.gradle.TaskExecutionRequest
import org.gradle.api.Action
import org.gradle.api.GradleException
import org.gradle.api.Project
Expand All @@ -12,6 +13,8 @@ import org.gradle.api.provider.Provider
import org.gradle.api.services.BuildService
import org.gradle.api.services.BuildServiceParameters
import org.gradle.kotlin.dsl.extra
import java.io.File
import java.util.*

/**
* Internal utility service for managing Dokka Plugin features and warnings.
Expand Down Expand Up @@ -193,56 +196,125 @@ internal abstract class PluginFeaturesService : BuildService<PluginFeaturesServi
* Register a new [PluginFeaturesService], or get an existing instance.
*/
val Project.pluginFeaturesService: PluginFeaturesService
get() {
val setFlags = Action<Params> {
v2PluginEnabled.set(getFlag(V2_PLUGIN_ENABLED_FLAG))
v2PluginNoWarn.set(getFlag(V2_PLUGIN_NO_WARN_FLAG_PRETTY).orElse(getFlag(V2_PLUGIN_NO_WARN_FLAG)))
v2PluginMigrationHelpersEnabled.set(getFlag(V2_PLUGIN_MIGRATION_HELPERS_FLAG))
k2AnalysisEnabled.set(getFlag(K2_ANALYSIS_ENABLED_FLAG))
k2AnalysisNoWarn.set(
getFlag(K2_ANALYSIS_NO_WARN_FLAG_PRETTY)
.orElse(getFlag(K2_ANALYSIS_NO_WARN_FLAG))
)
}

return try {
gradle.sharedServices.registerIfAbsent(PluginFeaturesService::class) {
parameters(setFlags)
// This service was successfully registered, so it is considered 'primary'.
parameters.primaryService.set(true)
get() = getOrCreateService(project)

private fun getOrCreateService(project: Project): PluginFeaturesService {
val configureServiceParams = serviceParamsConfiguration(project)

return try {
project.gradle.sharedServices.registerIfAbsent(PluginFeaturesService::class) {
parameters(configureServiceParams)
// This service was successfully registered, so it is considered 'primary'.
parameters.primaryService.set(true)
}.get()
} catch (ex: ClassCastException) {
try {
// Recover from Gradle bug: re-register the service, but don't mark it as 'primary'.
project.gradle.sharedServices.registerIfAbsent(
PluginFeaturesService::class,
classLoaderScoped = true,
) {
parameters(configureServiceParams)
parameters.primaryService.set(false)
}.get()
} catch (ex: ClassCastException) {
try {
// Recover from Gradle bug: re-register the service, but don't mark it as 'primary'.
gradle.sharedServices.registerIfAbsent(
PluginFeaturesService::class,
classLoaderScoped = true,
) {
parameters(setFlags)
parameters.primaryService.set(false)
}.get()
} catch (ex: ClassCastException) {
throw GradleException(
"Failed to register BuildService. Please report this problem https://github.com/gradle/gradle/issues/17559",
ex
)
}
throw GradleException(
"Failed to register BuildService. Please report this problem https://github.com/gradle/gradle/issues/17559",
ex
)
}
}
}

private fun Project.getFlag(flag: String): Provider<Boolean> =
providers
.gradleProperty(flag)
.forUseAtConfigurationTimeCompat()
.orElse(
// Note: Enabling/disabling features via extra-properties is only intended for unit tests.
// (Because org.gradle.testfixtures.ProjectBuilder doesn't support mocking Gradle properties.
// But maybe soon! https://github.com/gradle/gradle/pull/30002)
project
.provider { project.extra.properties[flag]?.toString() }
.forUseAtConfigurationTimeCompat()
/**
* Return an [Action] that will configure [PluginFeaturesService.Params], based on detected plugin flags.
*/
private fun serviceParamsConfiguration(
project: Project
): Action<Params> {

/** Find a flag for [PluginFeaturesService]. */
fun getFlag(flag: String): Provider<Boolean> =
project.providers
.gradleProperty(flag)
.forUseAtConfigurationTimeCompat()
.orElse(
// Note: Enabling/disabling features via extra-properties is only intended for unit tests.
// (Because org.gradle.testfixtures.ProjectBuilder doesn't support mocking Gradle properties.
// But maybe soon! https://github.com/gradle/gradle/pull/30002)
project
.provider { project.extra.properties[flag]?.toString() }
.forUseAtConfigurationTimeCompat()
)
.map(String::toBoolean)


return Action {
v2PluginEnabled.set(getFlag(V2_PLUGIN_ENABLED_FLAG))
v2PluginNoWarn.set(getFlag(V2_PLUGIN_NO_WARN_FLAG_PRETTY).orElse(getFlag(V2_PLUGIN_NO_WARN_FLAG)))
v2PluginMigrationHelpersEnabled.set(getFlag(V2_PLUGIN_MIGRATION_HELPERS_FLAG))
k2AnalysisEnabled.set(getFlag(K2_ANALYSIS_ENABLED_FLAG))
k2AnalysisNoWarn.set(
getFlag(K2_ANALYSIS_NO_WARN_FLAG_PRETTY)
.orElse(getFlag(K2_ANALYSIS_NO_WARN_FLAG))
)
.map(String::toBoolean)

configureParamsDuringAccessorsGeneration(project)
}
}

/**
* We use a Gradle flag to control whether DGP is in v1 or v2 mode.
* This flag dynamically changes the behaviour of DGP at runtime.
*
* However, there is a particular situation where this flag can't be detected:
* When Dokka is applied to a precompiled script plugin and Gradle generates Kotlin DSL accessors.
*
* When Gradle is generating such accessors, it creates a temporary project, totally disconnected
* from the main build. The temporary project has no access to any Gradle properties.
* As such, no Dokka flags can be detected, resulting in unexpected behaviour.
*
* We work around this by first detecting when Gradle is generating accessors
* (see [isGradleGeneratingAccessors]), and secondly by manually discovering a suitable
* `gradle.properties` file (see [findGradlePropertiesFile]) and reading its values.
*
* This is a workaround and can be removed with DGPv1
* https://youtrack.jetbrains.com/issue/KT-71027/
*/
private fun Params.configureParamsDuringAccessorsGeneration(project: Project) {
try {
if (project.isGradleGeneratingAccessors()) {
logger.info("Gradle is generating accessors. Discovering Dokka Gradle Plugin flags manually. ${project.gradle.rootProject.name} | ${project.gradle.rootProject.rootDir}")

// Disable all warnings, regardless of the discovered flag values.
// Log messages will be printed too soon and aren't useful for users.
v2PluginNoWarn.set(true)

// Because Gradle is generating accessors, it won't give us access to Gradle properties
// defined for the main project. So, we must discover `gradle.properties` ourselves.
val propertiesFile = project.findGradlePropertiesFile()

val properties = Properties().apply {
propertiesFile?.reader()?.use { reader ->
load(reader)
}
}

// These are the only flags that are important when Gradle is generating accessors,
// because they control what accessors DGP registers.
properties[V2_PLUGIN_ENABLED_FLAG]?.toString()?.toBoolean()?.let {
v2PluginEnabled.set(it)
}
properties[V2_PLUGIN_MIGRATION_HELPERS_FLAG]?.toString()?.toBoolean()?.let {
v2PluginMigrationHelpersEnabled.set(it)
}
}
} catch (t: Throwable) {
// Ignore all errors.
// This is just a temporary util. It doesn't need to be stable long-term,
// and we don't want to risk breaking people's projects.
}
}

/**
* Draw a pretty ascii border around some text.
Expand All @@ -264,3 +336,62 @@ internal abstract class PluginFeaturesService : BuildService<PluginFeaturesServi
}
}
}


/**
* Determine if Gradle is generating DSL script accessors for precompiled script plugins.
*
* When Gradle generates accessors, it creates an empty project in a temporary directory and runs no tasks.
* So, we can guess whether Gradle is generating accessors based on this information.
*/
private fun Project.isGradleGeneratingAccessors(): Boolean {
if (gradle.rootProject.name != "gradle-kotlin-dsl-accessors") {
return false
}

if (gradle.taskGraph.allTasks.isNotEmpty()) {
return false
}

/**
* When a Gradle build is executed with no requested tasks and no arguments then
* Gradle runs a single 'default' task that has no values.
*/
fun TaskExecutionRequest.isDefaultTask(): Boolean =
projectPath == null && args.isEmpty() && rootDir == null

val taskRequest = gradle.startParameter.taskRequests.singleOrNull() ?: return false
if (!taskRequest.isDefaultTask()) return false

// Gradle generates accessors in a temporary project in a temporary directory, e.g.
// `build-logic/build/tmp/generatePrecompiledScriptPluginAccessors/accessors373648437747350006`
// The last directory has a random name, so we can drop that and check if the other segments match.
val rootProjectPath = gradle.rootProject.rootDir.invariantSeparatorsPath
return rootProjectPath
.substringBeforeLast("/")
.endsWith("build/tmp/generatePrecompiledScriptPluginAccessors")
}


/**
* Walk up the file tree until we discover a `gradle.properties` file.
*
* Note that this function will harm Configuration Cache, because it accesses files during the configuration phase.
* It must only be used
*/
private fun Project.findGradlePropertiesFile(): File? {
return generateSequence(project.projectDir) { it.parentFile }
.takeWhile { it != it.parentFile && it.exists() }

// Add an arbitrary limit to stop infinite scanning, just in case something goes wrong
.take(50)

// Skip the first 5 directories, to get to the actual project directory.
// <actual project dir>/build/tmp/generatePrecompiledScriptPluginAccessors/accessors373648437747350006
// ^5 ^4 ^3 ^2 ^1
.drop(5)

.map { it.resolve("gradle.properties") }

.firstOrNull { it.exists() && it.isFile }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
* Copyright 2014-2024 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license.
*/
package org.jetbrains.dokka.gradle

import io.kotest.core.spec.style.FunSpec
import org.gradle.testkit.runner.TaskOutcome.*
import org.jetbrains.dokka.gradle.internal.DokkaConstants
import org.jetbrains.dokka.gradle.utils.*

class BuildSrcKotlinDslAccessorsTest : FunSpec({

val project = initProjectWithBuildSrcConvention()

context("when DGPv2 is enabled") {
project
.runner
.addArguments(
":compileKotlin",
"--project-dir", "buildSrc",
)
.build {
test("expect DGPv2 can be used in a convention plugin") {
shouldHaveTasksWithAnyOutcome(":compileKotlin" to listOf(SUCCESS, UP_TO_DATE, FROM_CACHE))
}
}
}
})

private fun initProjectWithBuildSrcConvention(
rootProjectName: String? = null,
config: GradleProjectTest.() -> Unit = {},
): GradleProjectTest {

return gradleKtsProjectTest(
projectLocation = "BuildSrcKotlinDslAccessorsTest",
rootProjectName = rootProjectName,
) {

buildGradleKts = """
|plugins {
| kotlin("jvm") version embeddedKotlinVersion
| id("org.jetbrains.dokka") version "${DokkaConstants.DOKKA_VERSION}"
|}
|
""".trimMargin()

dir("buildSrc") {
buildGradleKts = """
|plugins {
| `kotlin-dsl`
|}
|
|dependencies {
| implementation("org.jetbrains.dokka:dokka-gradle-plugin:${DokkaConstants.DOKKA_VERSION}")
|}
|
""".trimMargin()


settingsGradleKts = """
|rootProject.name = "buildSrc"
|
|${settingsRepositories()}
|
""".trimMargin()

createFile(
"src/main/kotlin/dokka-convention.gradle.kts",
/* language=TEXT */ """
|plugins {
| id("org.jetbrains.dokka")
|}
|
|dokka {
| moduleName.set("custom-module-name")
|}
|
""".trimMargin()
)
}

gradleProperties {
dokka {
v2Plugin = true
v2MigrationHelpers = true
}
}

config()
}
}

0 comments on commit 7733051

Please sign in to comment.