TestKotlinCompiler.kt

/*
 * Copyright 2021 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.room.compiler.processing.util.compiler

import androidx.room.compiler.processing.util.DiagnosticMessage
import androidx.room.compiler.processing.util.Source
import androidx.room.compiler.processing.util.compiler.steps.CompilationStepArguments
import androidx.room.compiler.processing.util.compiler.steps.CompilationStepResult
import androidx.room.compiler.processing.util.compiler.steps.JavaSourceCompilationStep
import androidx.room.compiler.processing.util.compiler.steps.KaptCompilationStep
import androidx.room.compiler.processing.util.compiler.steps.KotlinSourceCompilationStep
import androidx.room.compiler.processing.util.compiler.steps.KspCompilationStep
import com.google.devtools.ksp.processing.SymbolProcessorProvider
import java.io.File
import javax.annotation.processing.Processor
import javax.tools.Diagnostic

/**
 * Compilation runner for kotlin using kotlin CLI tool
 */
data class TestCompilationArguments(
    /**
     * List of source files for the compilation
     */
    val sources: List<Source>,
    /**
     * Additional classpath for the compilation
     */
    val classpath: List<File> = emptyList(),
    /**
     * If `true` (default), the classpath of the current process will be included in the
     * classpath list.
     */
    val inheritClasspath: Boolean = true,
    /**
     * Arguments for the java compiler. This will be used when both running KAPT and also java
     * compiler.
     */
    val javacArguments: List<String> = emptyList(),
    /**
     * Arguments for the kotlin compiler. This will be used when both running KAPT and also KSP.
     */
    val kotlincArguments: List<String> = emptyList(),
    /**
     * List of annotation processors to be run by KAPT.
     */
    val kaptProcessors: List<Processor> = emptyList(),
    /**
     * List of symbol processor providers to be run by KSP.
     */
    val symbolProcessorProviders: List<SymbolProcessorProvider> = emptyList(),
    /**
     * Map of annotation/symbol processor options. Used for both KAPT and KSP.
     */
    val processorOptions: Map<String, String> = emptyMap()
)

/**
 * Result of a test compilation.
 */
data class TestCompilationResult(
    /**
     * true if the compilation succeeded, false otherwise.
     */
    val success: Boolean,
    /**
     * List of generated source files by the compilation.
     */
    val generatedSources: List<Source>,
    /**
     * Diagnostic messages that were reported during compilation.
     */
    val diagnostics: Map<Diagnostic.Kind, List<DiagnosticMessage>>,
    /**
     * List of classpath folders that contain the produced .class files.
     */
    val outputClasspath: List<File>
)

/**
 * Ensures the list of sources has at least 1 kotlin file, if not, adds one.
 */
internal fun TestCompilationArguments.withAtLeastOneKotlinSource(): TestCompilationArguments {
    val hasKotlinSource = sources.any {
        it is Source.KotlinSource
    }
    if (hasKotlinSource) return this
    return copy(
        sources = sources + Source.kotlin(
            "SyntheticSource",
            code = """
                package xprocessing.generated
                class SyntheticKotlinSource
            """.trimIndent()
        )
    )
}

/**
 * Copies the [Source] file into the given root directories based on file type.
 */
private fun Source.copyTo(
    kotlinRootDir: File,
    javaRootDir: File
): File {
    val locationRoot = when (this) {
        is Source.KotlinSource -> kotlinRootDir
        is Source.JavaSource -> javaRootDir
    }
    val location = locationRoot.resolve(relativePath)
    check(!location.exists()) {
        "duplicate source file: $location ($this)"
    }
    location.parentFile.mkdirs()
    location.writeText(contents, Charsets.UTF_8)
    return location
}

/**
 * Converts [TestCompilationArguments] into the internal [CompilationStepArguments] type.
 *
 * This involves copying sources into the working directory.
 */
private fun TestCompilationArguments.toInternal(
    workingDir: File
): CompilationStepArguments {
    val (kotlinRoot, javaRoot) = workingDir.resolve("src").let {
        it.resolve("kotlin") to it.resolve("java")
    }
    // copy sources based on type.
    sources.map {
        it.copyTo(kotlinRootDir = kotlinRoot, javaRootDir = javaRoot)
    }
    return CompilationStepArguments(
        sourceSets = listOfNotNull(
            javaRoot.toSourceSet(),
            kotlinRoot.toSourceSet()
        ),
        additionalClasspaths = classpath,
        inheritClasspaths = inheritClasspath,
        javacArguments = javacArguments,
        kotlincArguments = kotlincArguments
    )
}

/**
 * Executes a build for the given [TestCompilationArguments].
 */
fun compile(
    /**
     * The temporary directory to use during compilation
     */
    workingDir: File,
    /**
     * The compilation arguments
     */
    arguments: TestCompilationArguments,
): TestCompilationResult {
    val steps = listOf(
        KaptCompilationStep(arguments.kaptProcessors, arguments.processorOptions),
        KspCompilationStep(arguments.symbolProcessorProviders, arguments.processorOptions),
        KotlinSourceCompilationStep,
        JavaSourceCompilationStep
    )
    workingDir.ensureEmptyDirectory()

    val initialArgs = arguments.toInternal(workingDir.resolve("input"))
    val initial = listOf(
        CompilationStepResult(
            success = true,
            generatedSourceRoots = emptyList(),
            diagnostics = emptyList(),
            nextCompilerArguments = initialArgs,
            outputClasspath = emptyList()
        )
    )
    val resultFromEachStep = steps.fold(initial) { prevResults, step ->
        val prev = prevResults.last()
        if (prev.success) {
            prevResults + step.execute(
                workingDir = workingDir.resolve(step.name),
                arguments = prev.nextCompilerArguments
            )
        } else {
            prevResults
        }
    }
    val combinedDiagnostics = mutableMapOf<Diagnostic.Kind, MutableList<DiagnosticMessage>>()
    resultFromEachStep.forEach { result ->
        result.diagnostics.forEach { diagnostic ->
            combinedDiagnostics.getOrPut(
                diagnostic.kind
            ) {
                mutableListOf()
            }.add(diagnostic)
        }
    }
    return TestCompilationResult(
        success = resultFromEachStep.all { it.success },
        generatedSources = resultFromEachStep.flatMap { it.generatedSources },
        diagnostics = combinedDiagnostics,
        outputClasspath = resultFromEachStep.flatMap { it.outputClasspath }
    )
}

internal fun File.ensureEmptyDirectory() {
    if (exists()) {
        check(isDirectory) {
            "$this cannot be a file"
        }
        val existingFiles = listFiles()
        check(existingFiles == null || existingFiles.isEmpty()) {
            "$this must be empty, found: ${existingFiles?.joinToString("\n")}"
        }
    } else {
        check(this.mkdirs()) {
            "failed to create working directory ($this)"
        }
    }
}