Add a task to hierarchically assemble multiplatform resources

^KT-65540
This commit is contained in:
Timofey Solonin
2024-02-26 17:59:37 +01:00
committed by Space Team
parent 74628c0394
commit 5ddc7b47ef
5 changed files with 741 additions and 0 deletions
@@ -765,6 +765,17 @@ object KotlinToolingDiagnostics {
)
}
private val resourcesBugReportRequest get() = "This is likely a bug in Kotlin Gradle Plugin configuration. Please report this issue to https://kotl.in/issue."
object ResourcePublishedMoreThanOncePerTarget : ToolingDiagnosticFactory(ERROR) {
operator fun invoke(targetName: String) = build(
"""
Only one resources publication per target $targetName is allowed.
$resourcesBugReportRequest
""".trimIndent()
)
}
object DependencyDoesNotPhysicallyExist : ToolingDiagnosticFactory(WARNING) {
operator fun invoke(dependency: File) = build(
"""
@@ -0,0 +1,176 @@
/*
* Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.gradle.plugin.mpp.resources
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.DuplicatesStrategy
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.FileTree
import org.gradle.api.file.ProjectLayout
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.*
import org.gradle.work.DisableCachingByDefault
import org.jetbrains.kotlin.incremental.deleteDirectoryContents
import java.io.File
import javax.inject.Inject
@DisableCachingByDefault
internal abstract class AssembleHierarchicalResourcesTask : DefaultTask() {
internal class Resource(
@get:Internal
val resourcesBaseDirectory: Provider<File>,
includes: List<String>,
excludes: List<String>,
layout: ProjectLayout,
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
val resourcesFileTree: Provider<FileTree> = layout.dir(resourcesBaseDirectory).map { baseDirectory ->
baseDirectory.asFileTree.matching { patternFilter ->
includes.forEach { patternFilter.include(it) }
excludes.forEach { patternFilter.exclude(it) }
}
}
)
@get:Inject
abstract val fileSystem: FileSystemOperations
@get:Inject
abstract val projectLayout: ProjectLayout
@get:Nested
abstract val resourceDirectoriesByLevel: ListProperty<List<Resource>>
/**
* Relative resource placement is part of this task to prepare the output directory as is for consumption by the .zip publication or the
* self-resources resolution tasks
*/
@get:Input
abstract val relativeResourcePlacement: Property<File>
@get:OutputDirectory
abstract val outputDirectory: DirectoryProperty
@TaskAction
fun action() {
val directoriesToCopy = resourcesSourceSetWalk.directoriesToCopy(
resourceDirectoriesByLevel.get()
).unwrapOrThrow()
val outputDirectoryFile = outputDirectory.get().asFile
outputDirectoryFile.deleteDirectoryContents()
fileSystem.copy { copy ->
directoriesToCopy.forEach { dir ->
copy.from(dir)
}
copy.into(outputDirectoryFile.resolve(relativeResourcePlacement.get()))
copy.duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
if (outputDirectoryFile.listFiles()?.isEmpty() != false) {
// Output an empty directory for the zip task
outputDirectoryFile.resolve(relativeResourcePlacement.get()).mkdirs()
}
}
private val resourcesSourceSetWalk = SourceSetWalk(
fileSystem = object : FileSystem<Resource> {
override fun walk(root: Resource): Sequence<File> = root.resourcesFileTree.get().asSequence()
},
basePathFromResource = { it.resourcesBaseDirectory.get() },
fileTreeToCopyFromResource = { it.resourcesFileTree.get() },
)
internal interface FileSystem<RootEntity> {
fun walk(root: RootEntity): Sequence<File>
fun exists(file: File): Boolean = file.exists()
fun isDirectory(file: File): Boolean = file.isDirectory
}
internal class SourceSetWalk<Resource, FileTreeToCopy>(
val fileSystem: FileSystem<Resource>,
val basePathFromResource: (Resource) -> (File),
val fileTreeToCopyFromResource: (Resource) -> (FileTreeToCopy),
) {
sealed class Result<T> {
data class ToCopy<T>(val value: T) : Result<T>()
data class Collision<T>(val left: File, val right: File) : Result<T>()
data class NotDirectory<T>(val path: File) : Result<T>()
fun unwrapOrThrow(): T {
when (this) {
is Collision -> error("There is a duplicate resource in a source set level:\n${left.canonicalPath}\n${right.canonicalPath}")
is NotDirectory -> error("Path $path is not a directory")
is ToCopy -> return value
}
}
}
fun directoriesToCopy(
leveledResources: List<List<Resource>>
): Result<List<FileTreeToCopy>> {
// 1. Is case of a collision between levels, files seen at next levels overwrite files from previous levels. E.g. iosMain/res/foo
// resources overwrites commonMain/res/foo
val directoriesToCopy = mutableListOf<FileTreeToCopy>()
leveledResources.onEach { level ->
val relativePathsSeenAtThisLevel: MutableMap<String, File> = mutableMapOf()
level.onEach { resource ->
when (
val result = discoverAndAppendResourceDirectory(
resource = resource,
relativePathsSeenAtThisLevel = relativePathsSeenAtThisLevel,
)
) {
is Result.Collision -> return Result.Collision(result.left, result.right)
is Result.NotDirectory -> return Result.NotDirectory(result.path)
is Result.ToCopy -> directoriesToCopy.add(result.value)
}
}
}
return Result.ToCopy(directoriesToCopy)
}
private fun discoverAndAppendResourceDirectory(
resource: Resource,
relativePathsSeenAtThisLevel: MutableMap<String, File>,
): Result<FileTreeToCopy>? {
val resourcesDirectory = basePathFromResource(resource)
if (!fileSystem.exists(resourcesDirectory)) return null
if (!fileSystem.isDirectory(resourcesDirectory)) return Result.NotDirectory(resourcesDirectory)
val collisions: List<Result.Collision<FileTreeToCopy>> =
fileSystem.walk(resource).map<File, Result.Collision<FileTreeToCopy>?>{ child ->
if (fileSystem.isDirectory(child)) {
return@map null
}
val relativePath = child.toRelativeString(resourcesDirectory)
relativePathsSeenAtThisLevel[relativePath]?.let { alreadySeenFile ->
return@map Result.Collision(
child,
alreadySeenFile,
)
}
relativePathsSeenAtThisLevel[relativePath] = child
return@map null
}.mapNotNull { it }.toList()
if (collisions.isNotEmpty()) {
return collisions.first()
}
return Result.ToCopy(fileTreeToCopyFromResource(resource))
}
}
}
@@ -0,0 +1,75 @@
/*
* Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.gradle.plugin.mpp.resources
import org.gradle.api.file.Directory
import org.gradle.api.provider.Provider
import org.gradle.api.tasks.TaskProvider
import org.jetbrains.kotlin.gradle.plugin.*
import org.jetbrains.kotlin.gradle.plugin.KotlinPluginLifecycle
import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinToolingDiagnostics
import org.jetbrains.kotlin.gradle.plugin.diagnostics.reportDiagnostic
import org.jetbrains.kotlin.gradle.tasks.locateTask
import org.jetbrains.kotlin.gradle.tasks.registerTask
import org.jetbrains.kotlin.tooling.core.withClosureGroupingByDistance
internal fun KotlinCompilation<*>.assembleHierarchicalResources(
targetNamePrefix: String,
resources: KotlinTargetResourcesPublicationImpl.TargetResources,
): Provider<Directory> = registerAssembleHierarchicalResourcesTaskProvider(
targetNamePrefix,
resources,
).flatMap { it.outputDirectory }
internal fun KotlinCompilation<*>.registerAssembleHierarchicalResourcesTaskProvider(
targetNamePrefix: String,
resources: KotlinTargetResourcesPublicationImpl.TargetResources,
): TaskProvider<AssembleHierarchicalResourcesTask> {
val taskName = "${targetNamePrefix}CopyHierarchicalMultiplatformResources"
val existingTask = project.tasks.locateTask<AssembleHierarchicalResourcesTask>(taskName)
if (existingTask != null) {
project.reportDiagnostic(KotlinToolingDiagnostics.ResourcePublishedMoreThanOncePerTarget(targetNamePrefix))
return existingTask
}
return project.registerTask<AssembleHierarchicalResourcesTask>(taskName) { assembleResources ->
project.launchInStage(KotlinPluginLifecycle.Stage.AfterFinaliseRefinesEdges) {
val resourceDirectoriesByLevel = splitResourceDirectoriesBySourceSetLevel(
resources = resources,
rootSourceSets = kotlinSourceSets.sortedBy { it.name },
)
val outputDirectory = project.layout.buildDirectory.dir(
"${KotlinTargetResourcesPublicationImpl.MULTIPLATFORM_RESOURCES_DIRECTORY}/assemble-hierarchically/${targetNamePrefix}"
)
resourceDirectoriesByLevel.forEach { level ->
assembleResources.resourceDirectoriesByLevel.add(
level.map {
AssembleHierarchicalResourcesTask.Resource(
it.resourcesBaseDirectory,
it.includes,
it.excludes,
project.layout,
)
}
)
}
assembleResources.relativeResourcePlacement.set(resources.relativeResourcePlacement)
assembleResources.outputDirectory.set(outputDirectory)
}
}
}
private fun splitResourceDirectoriesBySourceSetLevel(
resources: KotlinTargetResourcesPublicationImpl.TargetResources,
rootSourceSets: List<KotlinSourceSet>,
): List<List<KotlinTargetResourcesPublication.ResourceRoot>> {
return rootSourceSets.withClosureGroupingByDistance { it.dependsOn }.map { sourceSets ->
sourceSets.map { sourceSet ->
resources.resourcePathForSourceSet(sourceSet)
}
}.reversed()
}
@@ -0,0 +1,332 @@
/*
* Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
@file:Suppress("UNCHECKED_CAST")
package org.jetbrains.kotlin.gradle.unitTests
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.AssembleHierarchicalResourcesTask
import org.jetbrains.kotlin.utils.addToStdlib.popLast
import org.junit.Test
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.test.assertEquals
class AssembleHierarchicalResourcesTaskSourceSetWalkTests {
@Test
fun `test simple resource root`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.ToCopy(
listOf(
absolutePath("commonMain")
)
),
directoriesToCopy(
hashMapOf(
"commonMain" to mapOf(
"res" to mapOf(
"foo" to null,
)
)
),
listOf(
listOf(
absolutePath("commonMain")
)
)
),
)
}
@Test
fun `test collision on the top level`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.Collision(
absolutePath("commonMain2", "res", "collision").toFile(),
absolutePath("commonMain", "res", "collision").toFile(),
),
directoriesToCopy(
hashMapOf(
"commonMain" to mapOf(
"res" to mapOf(
"collision" to null,
)
),
"commonMain2" to mapOf(
"res" to mapOf(
"collision" to null,
)
),
),
listOf(
listOf(
absolutePath("commonMain"),
absolutePath("commonMain2"),
)
)
),
)
}
@Test
fun `test collision across levels`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.ToCopy(
listOf(
absolutePath("iosMain"),
absolutePath("commonMain"),
)
),
directoriesToCopy(
hashMapOf(
"commonMain" to mapOf(
"res" to mapOf(
"collision" to null,
)
),
"iosMain" to mapOf(
"res" to mapOf(
"collision" to null,
)
)
),
listOf(
listOf(
absolutePath("iosMain"),
),
listOf(
absolutePath("commonMain"),
),
)
),
)
}
@Test
fun `test collision only in directories`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.ToCopy(
listOf(
absolutePath("commonMain"),
absolutePath("commonMain2"),
)
),
directoriesToCopy(
hashMapOf(
"commonMain" to mapOf(
"res" to mapOf(
"foo" to null,
)
),
"commonMain2" to mapOf(
"res" to mapOf(
"bar" to null,
)
),
),
listOf(
listOf(
absolutePath("commonMain"),
absolutePath("commonMain2"),
)
)
),
)
}
@Test
fun `test collision at higher level`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.Collision(
absolutePath("commonMain2", "res", "collision").toFile(),
absolutePath("commonMain", "res", "collision").toFile(),
),
directoriesToCopy(
hashMapOf(
"commonMain" to mapOf(
"res" to mapOf(
"collision" to null,
)
),
"commonMain2" to mapOf(
"res" to mapOf(
"collision" to null,
)
),
"iosMain" to mapOf(
"res" to mapOf(
"collision" to null,
)
),
),
listOf(
listOf(
absolutePath("iosMain")
),
listOf(
absolutePath("commonMain"),
absolutePath("commonMain2"),
)
)
),
)
}
@Test
fun `test non-existent directories`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.ToCopy(
emptyList()
),
directoriesToCopy(
hashMapOf(),
listOf(
listOf(
absolutePath("commonMain")
),
)
),
)
}
@Test
fun `test empty directories`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.ToCopy(
listOf(
absolutePath("commonMain")
)
),
directoriesToCopy(
hashMapOf(
"commonMain" to emptyMap<String, Any>()
),
listOf(
listOf(
absolutePath("commonMain")
),
)
),
)
}
@Test
fun `test not a directory`() {
assertEquals(
AssembleHierarchicalResourcesTask.SourceSetWalk.Result.NotDirectory(
absolutePath("commonMain").toFile(),
),
directoriesToCopy(
hashMapOf(
"commonMain" to null,
),
listOf(
listOf(
absolutePath("commonMain")
),
)
),
)
}
private fun absolutePath(first: String, vararg next: String): Path {
return Paths.get("").toAbsolutePath().root.resolve(Paths.get(first, *next))
}
private fun directoriesToCopy(
fileSystem: HashMap<String, Any?>,
resourcesSplitByLevel: List<List<Path>>,
): AssembleHierarchicalResourcesTask.SourceSetWalk.Result<List<Path>> {
val fakeFs = buildFakeFileSystem(contents = fileSystem)
return AssembleHierarchicalResourcesTask.SourceSetWalk<File, Path>(
fileSystem = object : AssembleHierarchicalResourcesTask.FileSystem<File> {
override fun walk(root: File): Sequence<File> = fakeFs.walkFileSystemFrom(root.toPath()).map {
it.path.toFile()
}
override fun exists(file: File): Boolean = fakeFs.exists(file.toPath())
override fun isDirectory(file: File): Boolean = fakeFs.fileSystemAt(file.toPath()) is FakeFileSystem.FakeDirectory
},
basePathFromResource = { it },
fileTreeToCopyFromResource = { it.toPath() }
).directoriesToCopy(
resourcesSplitByLevel.map { it.map { it.toFile() } },
)
}
fun buildFakeFileSystem(
path: Path = Paths.get("").toAbsolutePath().root,
contents: Map<String, Any?>
): FakeFileSystem {
return FakeFileSystem.FakeDirectory(
path,
contents.mapValues {
val value = it.value
return@mapValues when (value) {
is Map<*, *> -> buildFakeFileSystem(
path.resolve(it.key),
value as Map<String, Any>,
)
null -> FakeFileSystem.FakeFile(path.resolve(it.key))
else -> error("Unexpected component ${value} in fake fs")
}
}
)
}
sealed class FakeFileSystem(val path: Path) {
class FakeFile(path: Path) : FakeFileSystem(path)
class FakeDirectory(path: Path, val contents: Map<String, FakeFileSystem>) : FakeFileSystem(path)
fun fileSystemAt(path: Path): FakeFileSystem? {
if (this is FakeFile) error("File has no sub filesystem")
(this as FakeDirectory)
val components: MutableList<String> = path.map { it.fileName?.toString().orEmpty() }.reversed().toMutableList()
var current = contents
var value: FakeFileSystem? = null
while (components.isNotEmpty()) {
value = current[components.popLast()]
when (value) {
is FakeDirectory -> current = value.contents
is FakeFile -> if (components.isNotEmpty()) return null
null -> return null
}
}
return value
}
fun subdirectoryAt(path: Path): FakeDirectory {
when (val directory = fileSystemAt(path)) {
is FakeDirectory -> return directory
is FakeFile -> error("Path $path is a file")
null -> error("Subdirectory $path doesn't exist")
}
}
fun exists(path: Path): Boolean = fileSystemAt(path) != null
fun walkFileSystemFrom(path: Path): Sequence<FakeFileSystem> {
return sequence {
walkFileSystem(subdirectoryAt(path))
}
}
private suspend fun SequenceScope<FakeFileSystem>.walkFileSystem(
file: FakeFileSystem,
) {
if (file is FakeFile) return yield(file)
(file as FakeDirectory)
yield(file)
file.contents.values.forEach {
walkFileSystem(it)
}
}
}
}
@@ -0,0 +1,147 @@
/*
* Copyright 2010-2024 JetBrains s.r.o. and Kotlin Programming Language contributors.
* Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file.
*/
package org.jetbrains.kotlin.gradle.unitTests
import org.gradle.api.Project
import org.gradle.api.tasks.TaskProvider
import org.jetbrains.kotlin.gradle.dsl.multiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinCompilation
import org.jetbrains.kotlin.gradle.plugin.diagnostics.KotlinToolingDiagnostics
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.AssembleHierarchicalResourcesTask
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublication
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.KotlinTargetResourcesPublicationImpl
import org.jetbrains.kotlin.gradle.plugin.mpp.resources.registerAssembleHierarchicalResourcesTaskProvider
import org.jetbrains.kotlin.gradle.util.assertContainsDiagnostic
import org.jetbrains.kotlin.gradle.util.buildProjectWithMPP
import org.jetbrains.kotlin.gradle.util.runLifecycleAwareTest
import org.junit.Test
import java.io.File
import kotlin.test.assertEquals
class AssembleHierarchicalResourcesTaskTests {
@Test
fun `test copying order - matches default target hierarchy`() {
with(buildProjectWithMPP()) {
val kotlin = multiplatformExtension
val compilation = kotlin.linuxArm64().compilations.getByName("main")
evaluate()
assertEquals(
listOf(
listOf("commonMain"),
listOf("nativeMain"),
listOf("linuxMain"),
listOf("linuxArm64Main"),
),
resourceDirectoriesCopyingOrder(
compilation
)
)
}
}
@Test
fun `test copying order - with additional source sets in platform source set`() {
with(buildProjectWithMPP()) {
val kotlin = multiplatformExtension
val compilation = kotlin.jvm().compilations.getByName("main")
val a = kotlin.sourceSets.create("a")
compilation.defaultSourceSet.dependsOn(a)
evaluate()
assertEquals(
listOf(
listOf("a", "commonMain"),
listOf("jvmMain"),
),
resourceDirectoriesCopyingOrder(
compilation
)
)
}
}
@Test
fun `test copying order - with source sets more remote than common`() {
with(buildProjectWithMPP()) {
val kotlin = multiplatformExtension
val compilation = kotlin.jvm().compilations.getByName("main")
val a = kotlin.sourceSets.create("a")
val b = kotlin.sourceSets.create("b")
val c = kotlin.sourceSets.create("c")
val d = kotlin.sourceSets.create("d")
val e = kotlin.sourceSets.create("e")
compilation.defaultSourceSet.dependsOn(a)
compilation.defaultSourceSet.dependsOn(b)
a.dependsOn(c)
b.dependsOn(d)
d.dependsOn(e)
evaluate()
assertEquals(
listOf(
listOf("e"),
listOf("c", "d"),
listOf("a", "b", "commonMain"),
listOf("jvmMain"),
),
resourceDirectoriesCopyingOrder(
compilation
)
)
}
}
@Test
fun `test registering multiple resources assembling tasks - results in a diagnostic`() {
with(buildProjectWithMPP()) {
val kotlin = multiplatformExtension
val compilation = kotlin.jvm().compilations.getByName("main")
evaluate()
registerFakeResourcesTask(compilation)
registerFakeResourcesTask(compilation)
assertContainsDiagnostic(KotlinToolingDiagnostics.ResourcePublishedMoreThanOncePerTarget)
}
}
private fun Project.resourceDirectoriesCopyingOrder(
compilation: KotlinCompilation<*>,
): List<List<String>> {
return registerFakeResourcesTask(compilation).get().resourceDirectoriesByLevel.get().map { resourcesLevel ->
resourcesLevel.map { resource ->
resource.resourcesBaseDirectory.get().name
}
}
}
private fun Project.registerFakeResourcesTask(compilation: KotlinCompilation<*>): TaskProvider<AssembleHierarchicalResourcesTask> {
return compilation.registerAssembleHierarchicalResourcesTaskProvider(
"test",
resources = KotlinTargetResourcesPublicationImpl.TargetResources(
{ ss ->
KotlinTargetResourcesPublication.ResourceRoot(
provider { File(ss.name) },
emptyList(),
emptyList(),
)
},
provider { File("stub") }
)
)
}
}