diff --git a/gradle-plugin/src/main/kotlin/com/google/devtools/ksp/gradle/KspAATask.kt b/gradle-plugin/src/main/kotlin/com/google/devtools/ksp/gradle/KspAATask.kt index 6280e72bce..6c0238396b 100644 --- a/gradle-plugin/src/main/kotlin/com/google/devtools/ksp/gradle/KspAATask.kt +++ b/gradle-plugin/src/main/kotlin/com/google/devtools/ksp/gradle/KspAATask.kt @@ -36,8 +36,10 @@ import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.Configuration import org.gradle.api.attributes.Attribute +import org.gradle.api.attributes.Usage import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileCollection import org.gradle.api.logging.LogLevel import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty @@ -103,6 +105,13 @@ abstract class KspAATask @Inject constructor( @get:Nested abstract val commandLineArgumentProviders: ListProperty + // Metadata JSONs from dependencies indicating cross-compilation support. + // Declared as input so Gradle runs the exporting tasks before this task executes. + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + @get:Optional + abstract val crossCompilationMetadata: ConfigurableFileCollection + @get:ServiceReference abstract val isolatedClassLoaderCacheBuildService: Property @@ -445,13 +454,7 @@ abstract class KspAATask @Inject constructor( it.name == konanTargetName } if (!isHostSupported) { - val isKlibCrossCompilationEnabled = getKlibCrossCompilationSupport( - project, - kotlinCompilation - ) - kspAATask.onlyIf { - isKlibCrossCompilationEnabled.get() - } + configureCrossCompilationSkipCondition(project, kotlinCompilation, kspAATask) } } @@ -468,27 +471,110 @@ abstract class KspAATask @Inject constructor( return kspTaskProvider } + private const val ENABLE_KLIBS_CROSSCOMPILATION_PROPERTY = "kotlin.native.enableKlibsCrossCompilation" + private const val CROSS_COMPILATION_SHARED_DATA_KEY = "crossCompilationMetadata" + private val KOTLIN_PROJECT_SHARED_DATA_ATTRIBUTE = + Attribute.of("org.jetbrains.kotlin.project-shared-data", String::class.java) + /** - * Returns a Provider indicating whether klib cross-compilation is enabled. - * For Kotlin 2.3.20-Beta2+, uses KotlinNativeCompilation.crossCompilationSupported. - * For earlier versions, falls back to checking the gradle property kotlin.native.enableKlibsCrossCompilation. + * Configures [kspAATask] to skip when cross-compilation is not possible on the current host, + * mirroring how KGP skips compile tasks (see KGP's KotlinCreateNativeCompileTasksSideEffect). + * + * To remain compatible with the Configuration Cache, we split KGP's check: + * 1. Target-level check (evaluated during configuration): Checks host compatibility, + * cross-compilation properties, and cinterops. + * 2. Dependency-level check (evaluated during execution): Reads shared metadata JSONs + * from project dependencies (declared as task inputs to ensure correct task ordering). + * + * TODO(https://youtrack.jetbrains.com/issue/KT-87411): Replace with the public KGP API once available. */ - private fun getKlibCrossCompilationSupport( + private fun configureCrossCompilationSkipCondition( project: Project, - kotlinCompilation: KotlinNativeCompilation - ): Provider { + kotlinCompilation: KotlinNativeCompilation, + kspAATask: KspAATask, + ) { val kotlinVersion = project.getKotlinPluginVersion() - val isSupportCrossCompilationPropertyAvailable = + val isCrossCompilationMetadataAvailable = KotlinToolingVersion(kotlinVersion) >= KotlinToolingVersion("2.3.20-Beta2") - return if (isSupportCrossCompilationPropertyAvailable) { - kotlinCompilation.crossCompilationSupported - } else { - // Fallback for Kotlin versions before 2.3.20-Beta2 - project.providers.gradleProperty( - "kotlin.native.enableKlibsCrossCompilation" - ).orElse("false").map { it.toBoolean() } + val isKlibCrossCompilationEnabled = getKlibCrossCompilationEnabledProvider(project) + + if (!isCrossCompilationMetadataAvailable) { + kspAATask.onlyIf { isKlibCrossCompilationEnabled.get() } + return + } + + val target = kotlinCompilation.target + val hasCinterops = project.objects.property(Boolean::class.java).convention(false) + target.compilations.configureEach { compilation -> + compilation.cinterops.configureEach { + hasCinterops.set(true) + } } + + val isTargetCrossCompilationPossible = isKlibCrossCompilationEnabled.zip(hasCinterops) { klibCrossCompilationEnabled, targetHasCinterops -> + HostManager.hostOrNull != null && + klibCrossCompilationEnabled && + !targetHasCinterops + } + kspAATask.onlyIf("Cross compilation should be supported on host") { + isTargetCrossCompilationPossible.get() + } + + val metadataFiles = getCrossCompilationMetadataFiles(project, kotlinCompilation) + kspAATask.crossCompilationMetadata.from(metadataFiles) + kspAATask.onlyIf("Cross compilation should be possible with project dependencies") { task -> + (task as KspAATask).crossCompilationMetadata.files.all(::isCrossCompilationSupportedBy) + } + } + + private fun getKlibCrossCompilationEnabledProvider(project: Project): Provider { + val gradlePropertiesSetting = project.providers.gradleProperty(ENABLE_KLIBS_CROSSCOMPILATION_PROPERTY) + .map { it.toBoolean() } + .orElse(true) + + val subprojectPropertiesSetting = project.providers + .fileContents(project.layout.projectDirectory.file("gradle.properties")) + .asText + .map { text -> + val props = Properties().apply { load(java.io.StringReader(text)) } + props.getProperty(ENABLE_KLIBS_CROSSCOMPILATION_PROPERTY)?.toBoolean() + } + + return subprojectPropertiesSetting.orElse(gradlePropertiesSetting) + } + + private fun getCrossCompilationMetadataFiles( + project: Project, + kotlinCompilation: KotlinNativeCompilation + ): FileCollection { + val configurationProvider = project.configurations.named(kotlinCompilation.compileDependencyConfigurationName) + val sharedDataUsage = project.objects.named(Usage::class.java, "kotlin-project-shared-data") + val filesProvider = configurationProvider.map { configuration -> + configuration.incoming.artifactView { view -> + view.isLenient = true + view.attributes.attribute(Usage.USAGE_ATTRIBUTE, sharedDataUsage) + view.attributes.attribute(KOTLIN_PROJECT_SHARED_DATA_ATTRIBUTE, CROSS_COMPILATION_SHARED_DATA_KEY) + view.attributes.attribute( + Attribute.of("artifactType", String::class.java), + "kotlin-project-shared-data-$CROSS_COMPILATION_SHARED_DATA_KEY" + ) + }.files + } + return project.files(filesProvider) + } + + /** + * Parses the cross-compilation metadata JSON exported by KGP. + * Missing or malformed files default to supported (matching KGP's parser). + */ + private fun isCrossCompilationSupportedBy(metadataFile: File): Boolean { + val content = runCatching { metadataFile.readText() }.getOrNull() ?: return true + return runCatching { + val parser = groovy.json.JsonSlurper() + val map = parser.parseText(content) as Map<*, *> + map["crossCompilationSupported"] as? Boolean + }.getOrNull() ?: true } } } diff --git a/integration-tests/src/test/kotlin/com/google/devtools/ksp/test/primary/KMPImplementedIT.kt b/integration-tests/src/test/kotlin/com/google/devtools/ksp/test/primary/KMPImplementedIT.kt index 0a31825e3d..fc4d2b6466 100644 --- a/integration-tests/src/test/kotlin/com/google/devtools/ksp/test/primary/KMPImplementedIT.kt +++ b/integration-tests/src/test/kotlin/com/google/devtools/ksp/test/primary/KMPImplementedIT.kt @@ -291,18 +291,34 @@ class AnnoOnProperty { Assume.assumeFalse(System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) val gradleRunner = GradleRunner.create().withProjectDir(project.root) - // Warmup build to download and extract Kotlin Native compiler - gradleRunner.withArguments( - "--no-configuration-cache", - ":workload-linuxX64:assemble" - ).build() + // Add a klib-only iosArm64 target to workload-linuxX64 and to its project dependency + // (:annotations), so that the skip condition has cross-compilation metadata of a + // project dependency to consult. + val buildScript = File(project.root, "workload-linuxX64/build.gradle.kts") + buildScript.writeText( + buildScript.readText().replace( + "linuxX64() {", + "iosArm64() {\n }\n linuxX64() {" + ) + ) + buildScript.appendText("\ndependencies {\n add(\"kspIosArm64\", project(\":test-processor\"))\n}\n") + val annotationsBuildScript = File(project.root, "annotations/build.gradle.kts") + annotationsBuildScript.writeText( + annotationsBuildScript.readText().replace( + "linuxX64() {", + "iosArm64() {\n }\n linuxX64() {" + ) + ) + project.appendProperty( + "org.gradle.configuration-cache.inputs.unsafe.ignore.file-system-checks=~/.konan/**" + ) - // First run: calculates and stores configuration cache val result1 = gradleRunner.withArguments( "--configuration-cache", "--configuration-cache-problems=fail", - ":workload-linuxX64:assemble" + ":workload-linuxX64:compileKotlinIosArm64" ).build() + Assert.assertEquals(TaskOutcome.SUCCESS, result1.task(":workload-linuxX64:kspKotlinIosArm64")?.outcome) Assert.assertTrue( "Expected 'Configuration cache entry stored.' but output was:\n${result1.output}", result1.output.contains("Configuration cache entry stored.") @@ -312,7 +328,7 @@ class AnnoOnProperty { val result2 = gradleRunner.withArguments( "--configuration-cache", "--configuration-cache-problems=fail", - ":workload-linuxX64:assemble" + ":workload-linuxX64:compileKotlinIosArm64" ).build() Assert.assertTrue( "Expected 'Reusing configuration cache.' but output was:\n${result2.output}",