Skip to content

Commit

Permalink
build(gradle): set up the BCV for JS API based on the TypeScript defi…
Browse files Browse the repository at this point in the history
…nitions

Signed-off-by: Artyom Shendrik <[email protected]>
  • Loading branch information
amal committed May 20, 2023
1 parent 625502e commit 252e5d8
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 83 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import com.android.build.gradle.LibraryExtension
import impl.hasExtension
import js.api.KotlinJsApiBuildTask
import js.api.configureJsApiTasks
import kotlinx.validation.ApiValidationExtension
import kotlinx.validation.KotlinApiCompareTask
import org.gradle.api.Project
Expand Down Expand Up @@ -28,7 +28,7 @@ private fun Project.setupBinaryCompatibilityValidatorMultiplatform(config: Binar
applyBinaryCompatibilityValidator(config)

if (config?.jsApiChecks != false) {
KotlinJsApiBuildTask.setupTask(project = this, multiplatformExtension)
configureJsApiTasks(multiplatformExtension)
}

tasks.withType<KotlinApiCompareTask> {
Expand Down Expand Up @@ -62,16 +62,19 @@ private fun getTargetForTaskName(taskName: String): ApiTarget? {
return when (targetName) {
"android" -> ApiTarget.ANDROID
"jvm" -> ApiTarget.JVM
"js" -> ApiTarget.JS
else -> error("Unsupported API check task name: $taskName")
}
}

private fun Project.isMultiplatformApiTargetAllowed(target: ApiTarget): Boolean = when (target) {
ApiTarget.ANDROID -> isMultiplatformTargetEnabled(Target.ANDROID)
ApiTarget.JVM -> isMultiplatformTargetEnabled(Target.JVM)
ApiTarget.JS -> isMultiplatformTargetEnabled(Target.JS)
}

private enum class ApiTarget {
ANDROID,
JVM,
JS,
}
3 changes: 2 additions & 1 deletion gradle/plugins/setup/src/main/kotlin/SetupMultiplatform.kt
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ fun KotlinMultiplatformExtension.setupSourceSets(block: MultiplatformSourceSets.
internal enum class Target {
ANDROID,
JVM,
JS,
}

internal fun Project.isMultiplatformTargetEnabled(target: Target): Boolean =
Expand All @@ -262,8 +263,8 @@ internal fun KotlinTargetsContainer.isMultiplatformTargetEnabled(target: Target)
when (it.platformType) {
KotlinPlatformType.androidJvm -> target == Target.ANDROID
KotlinPlatformType.jvm -> target == Target.JVM
KotlinPlatformType.js -> target == Target.JS
KotlinPlatformType.common,
KotlinPlatformType.js,
KotlinPlatformType.native,
KotlinPlatformType.wasm,
-> false
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
package js.api

import impl.isTestRelated
import kotlinx.validation.KotlinApiBuildTask
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.plugins.JavaBasePlugin
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.*
import org.gradle.kotlin.dsl.withType
import org.gradle.work.NormalizeLineEndings
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsBinaryMode
import org.jetbrains.kotlin.gradle.targets.js.ir.JsIrBinary
import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget
import java.io.File

/**
*
* @TODO Replace with Copy task?
*
* @see kotlinx.validation.KotlinApiBuildTask
* @see kotlinx.validation.configureApiTasks
Expand All @@ -36,13 +29,11 @@ internal abstract class KotlinJsApiBuildTask : DefaultTask() {
abstract val generatedDefinitions: ConfigurableFileCollection

@get:OutputFile
var outputFile: File = project.buildDir.resolve(API_PATH).resolve(project.name + EXT)
abstract val outputFile: RegularFileProperty

init {
val projectName = project.name
enabled = apiCheckEnabled(projectName, project.apiValidationExtensionOrNull)
group = JavaBasePlugin.VERIFICATION_GROUP
description = "Collects built Kotlin TS definitions as API for 'js' compilations of $projectName. " +
// 'group' is not specified deliberately, so it will be hidden from ./gradlew tasks.
description = "Collects built Kotlin TS definitions as API for 'js' compilations of ${project.name}. " +
"Complementary task and shouldn't be called manually"
}

Expand All @@ -55,71 +46,10 @@ internal abstract class KotlinJsApiBuildTask : DefaultTask() {
if (files.size > 1) {
logger.warn("Ambigous generated definitions, taking only first: $generatedDefinitions")
}
files.first().copyTo(outputFile)
files.first().copyTo(outputFile.asFile.get(), overwrite = true)
}

internal companion object {
private const val API_PATH = "api/js"
private const val NAME = "apiJsBuild"
private const val EXT = ".d.ts"

internal fun setupTask(
project: Project,
extension: KotlinMultiplatformExtension,
): TaskProvider<KotlinJsApiBuildTask>? {
// https://scans.gradle.com/s/5slylgpxgulk2/timeline

if (!apiCheckEnabled(project.name, project.apiValidationExtensionOrNull)) {
return null
}

val apiJsBuild = project.tasks.register(NAME, KotlinJsApiBuildTask::class.java)

project.afterEvaluate {
val compilations = extension.targets.asSequence()
.filterIsInstance<KotlinJsIrTarget>()
.flatMap { it.compilations }
.filter { !it.isTestRelated() }

var noCompilations = true
var noBinaries = true

@Suppress("INVISIBLE_MEMBER")
val binaries = compilations.flatMap { noCompilations = false; it.binaries }
.filterIsInstance<JsIrBinary>()
.filter { it.generateTs && it.mode == KotlinJsBinaryMode.PRODUCTION }

for (binary in binaries) {
noBinaries = false
val linkTask = binary.linkTask
apiJsBuild.configure {
val files = linkTask.flatMap {
provider {
it.destinationDirectory.asFileTree.matching { include("*$EXT") }.files
}
}
generatedDefinitions.from(files)
dependsOn(linkTask)
}
}

if (noBinaries) {
apiJsBuild.configure { enabled = false }
if (!noCompilations) {
logger.warn(
"Please, enable TS definitions with `generateTypeScriptDefinitions()` for :${project.name}. " +
"No production binaries found with TS definitions enabled. " +
"Kotlin JS API verification is not possible."
)
}
} else {
project.tasks.withType<KotlinApiBuildTask> {
finalizedBy(apiJsBuild)
}
}
}

return apiJsBuild
}
internal const val EXT = ".d.ts"
}
}
194 changes: 192 additions & 2 deletions gradle/plugins/setup/src/main/kotlin/js/api/Utils.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
package js.api

import impl.isTestRelated
import js.api.KotlinJsApiBuildTask.Companion.EXT
import kotlinx.validation.API_DIR
import kotlinx.validation.ApiValidationExtension
import kotlinx.validation.KotlinApiCompareTask
import kotlinx.validation.task
import org.gradle.api.Project
import org.gradle.api.Task
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.Sync
import org.gradle.api.tasks.TaskProvider
import org.gradle.kotlin.dsl.withType
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
import org.jetbrains.kotlin.gradle.targets.js.dsl.KotlinJsBinaryMode
import org.jetbrains.kotlin.gradle.targets.js.ir.JsIrBinary
import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrCompilation
import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrLink
import org.jetbrains.kotlin.gradle.targets.js.ir.KotlinJsIrTarget
import java.io.File

internal val Project.apiValidationExtensionOrNull: ApiValidationExtension?

private val Project.apiValidationExtensionOrNull: ApiValidationExtension?
get() {
val clazz: Class<*> = try {
ApiValidationExtension::class.java
Expand All @@ -15,6 +34,177 @@ internal val Project.apiValidationExtensionOrNull: ApiValidationExtension?
.firstOrNull { it != null } as ApiValidationExtension?
}

fun apiCheckEnabled(projectName: String, extension: ApiValidationExtension?): Boolean {
private fun apiCheckEnabled(projectName: String, extension: ApiValidationExtension?): Boolean {
return extension == null || projectName !in extension.ignoredProjects && !extension.validationDisabled
}

/**
*
* @see kotlinx.validation.DirConfig
*/
private enum class DirConfig {
/**
* `api` directory for .api files.
* Used in single target projects.
*/
COMMON,

/**
* Target-based directory, used in multitarget setups.
* E.g. for the project with targets jvm and android,
* the resulting paths will be
* `/api/jvm/project.api` and `/api/android/project.api`.
*/
TARGET_DIR,
}

/**
*
* @see kotlinx.validation.TargetConfig
*/
private class TargetConfig(
project: Project,
val targetName: String? = null,
private val dirConfig: Provider<DirConfig>? = null,
) {
private val apiDirProvider = project.provider { API_DIR }

fun apiTaskName(suffix: String) = when (targetName) {
null, "" -> "api$suffix"
else -> "${targetName}Api$suffix"
}

val apiDir
get() = dirConfig?.map { dirConfig ->
when (dirConfig) {
DirConfig.COMMON -> API_DIR
else -> "$API_DIR/$targetName"
}
} ?: apiDirProvider
}


/**
*
* @see kotlinx.validation.BinaryCompatibilityValidatorPlugin.configureMultiplatformPlugin
*/
internal fun Project.configureJsApiTasks(
kotlin: KotlinMultiplatformExtension,
) {
val extension = apiValidationExtensionOrNull
if (!apiCheckEnabled(name, extension)) {
return
}

// Common BCV tasks for multiplatform
val commonApiDump = tasks.named("apiDump")
val commonApiCheck = tasks.named("apiCheck")

// Follow the strategy of BCV plugin.
// API is not overrided as extension is different.
val jvmTargetCountProvider = provider {
kotlin.targets.count {
it.platformType in arrayOf(
KotlinPlatformType.jvm,
KotlinPlatformType.androidJvm,
)
}
}
val dirConfig = jvmTargetCountProvider.map {
if (it == 1) DirConfig.COMMON else DirConfig.TARGET_DIR
}

kotlin.targets.withType<KotlinJsIrTarget>().configureEach {
val targetConfig = TargetConfig(project, this.name, dirConfig)
compilations.matching { it.name == "main" && !it.isTestRelated() }.configureEach {
configureKotlinCompilation(this, extension, targetConfig, commonApiDump, commonApiCheck)
}
}
}

private fun Project.configureKotlinCompilation(
compilation: KotlinJsIrCompilation,
extension: ApiValidationExtension?,
targetConfig: TargetConfig,
commonApiDump: TaskProvider<Task>? = null,
commonApiCheck: TaskProvider<Task>? = null,
) {
val projectName = project.name
val apiDirProvider = targetConfig.apiDir
val apiBuildDir = apiDirProvider.map { buildDir.resolve(it) }

@Suppress("INVISIBLE_MEMBER")
val binaries = compilation.binaries
.withType<JsIrBinary>()
.matching { it.generateTs && it.mode == KotlinJsBinaryMode.PRODUCTION }

if (binaries.isEmpty()) {
logger.warn(
"Please, enable TS definitions with `generateTypeScriptDefinitions()` for :${project.name}, $compilation. " +
"No production binaries found with TS definitions enabled. " +
"Kotlin JS API verification is not possible."
)
}

val apiBuild = project.tasks.register(targetConfig.apiTaskName("Build"), KotlinJsApiBuildTask::class.java) {
// Do not enable task for empty umbrella modules
isEnabled = apiCheckEnabled(projectName, extension) &&
compilation.allKotlinSourceSets.any { it.kotlin.srcDirs.any { d -> d.exists() } }

val linkTasks: Provider<Set<KotlinJsIrLink>> = provider {
binaries.mapTo(LinkedHashSet()) { it.linkTask.get() }
}
val definitions: Provider<Set<File>> = linkTasks.flatMap {
provider {
it.flatMapTo(LinkedHashSet()) {
it.destinationDirectory.asFileTree.matching { include("*$EXT") }.files
}
}
}
generatedDefinitions.from(definitions)
dependsOn(linkTasks)

outputFile.set(apiBuildDir.get().resolve(project.name + EXT))
}
configureCheckTasks(apiBuildDir, apiBuild, extension, targetConfig, commonApiDump, commonApiCheck)
}

@Suppress("LongParameterList")
private fun Project.configureCheckTasks(
apiBuildDir: Provider<File>,
apiBuild: TaskProvider<out Task>,
extension: ApiValidationExtension?,
targetConfig: TargetConfig,
commonApiDump: TaskProvider<out Task>? = null,
commonApiCheck: TaskProvider<out Task>? = null,
) {
val projectName = project.name
val apiCheckDir = targetConfig.apiDir.map {
projectDir.resolve(it).also { r ->
logger.debug("Configuring api for {} to {}", targetConfig.targetName ?: "jvm", r)
}
}
val apiCheck = task<KotlinApiCompareTask>(targetConfig.apiTaskName("Check")) {
isEnabled = apiCheckEnabled(projectName, extension) && apiBuild.map { it.enabled }.getOrElse(true)
group = "verification"
description = "Checks signatures of public API against the golden value in API folder for $projectName"
compareApiDumps(apiReferenceDir = apiCheckDir.get(), apiBuildDir = apiBuildDir.get())
dependsOn(apiBuild)
}

val apiDump = task<Sync>(targetConfig.apiTaskName("Dump")) {
isEnabled = apiCheckEnabled(projectName, extension) && apiBuild.map { it.enabled }.getOrElse(true)
group = "other"
description = "Syncs API from build dir to ${targetConfig.apiDir} dir for $projectName"
from(apiBuildDir)
into(apiCheckDir)
dependsOn(apiBuild)
}

commonApiDump?.configure { dependsOn(apiDump) }

when (commonApiCheck) {
null -> project.tasks.named("check").configure { dependsOn(apiCheck) }
else -> commonApiCheck.configure { dependsOn(apiCheck) }
}
}

0 comments on commit 252e5d8

Please sign in to comment.