/*
* Copyright (C) 2025 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 com.android.kotlin.compiler.client
import com.android.kotlin.compiler.cli.CacheMarker
import com.android.kotlin.compiler.cli.CacheMarkerError
import com.android.kotlin.compiler.cli.HelpArgument
import com.android.kotlin.compiler.cli.parseArgs
import com.android.kotlin.compiler.snapshotter.fileToSnapshotFile
import java.io.File
import java.net.URLClassLoader
import java.util.UUID
import kotlin.system.exitProcess
import org.jetbrains.kotlin.buildtools.api.CompilationResult
import org.jetbrains.kotlin.buildtools.api.CompilationService
import org.jetbrains.kotlin.buildtools.api.ExperimentalBuildToolsApi
import org.jetbrains.kotlin.buildtools.api.ProjectId
import org.jetbrains.kotlin.buildtools.api.SourcesChanges
import org.jetbrains.kotlin.buildtools.api.jvm.ClasspathSnapshotBasedIncrementalCompilationApproachParameters
private val ARGUMENT_PARSERS =
listOf(
BuildDirArgument(),
BuildFileArgument(),
BuildHistoryFileArgument(),
ClassPathArgument(),
Debug(),
HelpArgument(),
JvmArgument(),
LogDirArgument(),
OutputDirArgument(),
PluginArgument(),
RunFilesArgument(),
RootDirArgument(),
SourceDeltaArgument(),
Verbose(),
WorkingDirArgument(),
XBuildFileArgument(),
SourcesArgument(), // must come last
)
val USAGE_TEXT =
"""
Usage: kotlin-incremental-client -root-dir=
[options] [kotlinc options] [-- ]
"""
.trimIndent()
val ADDITIONAL_HELP =
"""
EXAMPLES
========
kotlin-incremental-client -root-dir=/tmp/helloworld -- HelloWorld.kt
kotlin-incremental-client -root-dir=/tmp/helloworld -build-file=HelloWorldBuild.xml
kotlin-incremental-client -root-dir=/tmp/helloworld -output-dir=out -- HelloWorld.kt
"""
.trimIndent()
fun main(args: Array) {
val opts = ClientOptions()
ARGUMENT_PARSERS.forEach { it.setupDefault(opts) }
if (
!parseArgs(
args,
opts,
ARGUMENT_PARSERS,
System.out,
System.err,
USAGE_TEXT,
ADDITIONAL_HELP,
)
) {
exitProcess(-1)
}
if (opts.sources.isEmpty() && (opts.buildFile == null || opts.buildFileSources.isEmpty())) {
println("No sources or build file specified. Exiting.")
exitProcess(0)
}
val cacheMarker = CacheMarker(opts.workingDir)
val result = btaCompilation(opts, cacheMarker)
when (result) {
CompilationResult.COMPILATION_SUCCESS -> {
writeCacheMarker(cacheMarker)
}
CompilationResult.COMPILATION_ERROR -> {
writeCacheMarker(cacheMarker)
exitProcess(-1)
}
CompilationResult.COMPILATION_OOM_ERROR -> {
// Assume the cache is invalid and don't write the marker.
println("Out of Memory")
exitProcess(-2)
}
CompilationResult.COMPILER_INTERNAL_ERROR -> {
println("Internal compiler error. Please report to https://kotl.in/issue")
exitProcess(-3)
}
}
}
fun writeCacheMarker(marker: CacheMarker) {
if (!marker.write()) {
println("Failed to write cache marker. Your next build will not be incremental.")
}
}
fun btaCompilation(opts: ClientOptions, cacheMarker: CacheMarker): CompilationResult {
val kotlincArgs = mutableListOf()
if (opts.buildFile != null) {
if (opts.buildFileModuleName != null) {
kotlincArgs.add("-module-name")
kotlincArgs.add(opts.buildFileModuleName!!)
}
if (!opts.buildFileFriendDirs.isEmpty()) {
kotlincArgs.add("-Xfriend-paths=" + opts.buildFileFriendDirs.joinToString(","))
}
}
kotlincArgs.add("-d=${opts.outputDir.absolutePath}")
kotlincArgs.addAll(opts.passThroughArgs)
kotlincArgs.addAll(opts.sources)
kotlincArgs.addAll(opts.buildFileJavaSources)
if (!cacheMarker.isValid()) {
println("Invalid or missing cache. Triggering full compile.")
opts.workingDir.delete()
opts.outputDir.delete()
} else {
if (!cacheMarker.remove()) {
throw CacheMarkerError("Failed to remove cache marker. Aborting the build.")
}
}
return doBtaCompilation(
opts.sources + opts.buildFileSources,
opts.classPath + opts.buildFileClassPaths,
opts.workingDir,
opts.outputDir,
opts.sourceDeltaFile,
kotlincArgs,
opts.jvmArgs,
Logger(opts.verbose, opts.debug),
)
}
@OptIn(ExperimentalBuildToolsApi::class)
fun doBtaCompilation(
sources: List,
classPath: List,
workingDirectory: File,
outputDirectory: File,
sourceDeltaFile: File?,
args: List,
jvmArgs: List,
logger: Logger,
): CompilationResult {
var anyMissing = false
sources.forEach {
if (!File(it).exists()) {
logger.error("Missing source: $it")
anyMissing = true
}
}
if (anyMissing) {
return CompilationResult.COMPILATION_ERROR
}
val loader =
URLClassLoader(
classPath.map { File(it).toURI().toURL() }.toTypedArray() +
// Need to include this code's own jar in the classpath.
arrayOf(ClientOptions::class.java.protectionDomain?.codeSource?.location)
)
val service = CompilationService.loadImplementation(loader)
val executionConfig = service.makeCompilerExecutionStrategyConfiguration()
// TODO: investigate using the daemon.
// Right now, it hangs (https://youtrack.jetbrains.com/issue/KT-75142/)
// executionConfig.useDaemonStrategy(jvmArgs)
executionConfig.useInProcessStrategy()
val compilationConfig = service.makeJvmCompilationConfiguration()
val cpsnapshotParameters = getClasspathSnapshotParameters(workingDirectory, classPath)
val incJvmCompilationConfig =
compilationConfig.makeClasspathSnapshotBasedIncrementalCompilationConfiguration()
var sourceChanges: SourcesChanges = SourcesChanges.Unknown
if (!outputDirectory.exists()) {
incJvmCompilationConfig.forceNonIncrementalMode(true)
} else if (sourceDeltaFile != null) {
sourceChanges = parseSourceChanges(sourceDeltaFile)
}
compilationConfig.useIncrementalCompilation(
workingDirectory,
sourceChanges,
cpsnapshotParameters,
incJvmCompilationConfig,
)
compilationConfig.useLogger(logger)
val pid = ProjectId.ProjectUUID(UUID.randomUUID())
val mArgs = args.toMutableList()
mArgs.add("-cp")
mArgs.add(classPath.joinToString(":"))
return service.compileJvm(
pid,
executionConfig,
compilationConfig,
sources.map { File(it) },
mArgs,
)
}
@OptIn(ExperimentalBuildToolsApi::class)
fun getClasspathSnapshotParameters(
workingDirectory: File,
classPath: List,
): ClasspathSnapshotBasedIncrementalCompilationApproachParameters {
val cps = File(workingDirectory.parentFile, "shrunk-classpath-snapshot.bin")
val cpsFiles =
classPath.mapNotNull {
val cpFile = File(it)
if (!cpFile.exists()) {
throw RuntimeException("classpath entry does not exist: $it")
}
val snf = fileToSnapshotFile(cpFile)
if (!snf.exists()) {
null
} else {
snf
}
}
return ClasspathSnapshotBasedIncrementalCompilationApproachParameters(
newClasspathSnapshotFiles = cpsFiles,
shrunkClasspathSnapshot = cps,
)
}
fun parseSourceChanges(sourceDeltaFile: File): SourcesChanges.Known {
val modifiedList = mutableListOf()
val removedList = mutableListOf()
for (entry in sourceDeltaFile.readText().split(" ")) {
if (entry.length < 1) {
continue
}
val f = File(entry.substring(1))
when {
entry.startsWith("+") -> {
if (!f.exists()) {
throw RuntimeException(
"Supplied file diff contains modified file that does not exist: $entry"
)
}
modifiedList.add(f.absoluteFile)
}
entry.startsWith("-") -> {
/*
if (f.exists()) {
throw RuntimeException(
"Supplied file diff contains removed file that exist: $entry"
)
}
*/
removedList.add(f.absoluteFile)
}
else -> {
throw RuntimeException(
"Supplied file diff contains entry that can not be parsed: $entry"
)
}
}
}
return SourcesChanges.Known(modifiedFiles = modifiedList, removedFiles = removedList)
}