From ba8f56a21f07fe269f9de92ecbb726bd56d91a23 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 5 Oct 2026 21:33:28 +0300 Subject: [PATCH] feat: cacheable and incremental bridge generation Map each source file to a cached prescan and IR fragment, re-parsing only files whose content changed or whose looked-up symbols now resolve differently. Skip codegen when the merged API is unchanged, rewrite only changed outputs and delete stale ones. Merge sources in a deterministic order and mark the task @CacheableTask. --- README.md | 14 +- .../nna/plugin/KotlinNativeExportPlugin.kt | 2 + .../nna/plugin/analysis/ParseCache.kt | 97 ++++ .../nna/plugin/analysis/PsiParseWorkAction.kt | 103 +++- .../nna/plugin/analysis/PsiSourceParser.kt | 467 +++++++++--------- .../nna/plugin/analysis/SymbolTable.kt | 205 ++++++++ .../nna/plugin/codegen/GeneratedOutputs.kt | 60 +++ .../nucleusframework/nna/plugin/ir/KneIR.kt | 35 +- .../plugin/tasks/GenerateNativeBridgesTask.kt | 77 ++- .../PsiSourceParserIncrementalTest.kt | 155 ++++++ .../plugin/codegen/GeneratedOutputsTest.kt | 33 ++ 11 files changed, 949 insertions(+), 299 deletions(-) create mode 100644 plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/ParseCache.kt create mode 100644 plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/SymbolTable.kt create mode 100644 plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputs.kt create mode 100644 plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParserIncrementalTest.kt create mode 100644 plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputsTest.kt diff --git a/README.md b/README.md index 35d0d9a..59435e3 100644 --- a/README.md +++ b/README.md @@ -464,10 +464,11 @@ The generated `KneRuntime` uses a three-tier loading strategy: ### Build performance & Gradle Cache -Currently, the bridge generation task is marked as `@DisableCachingByDefault` because the Kotlin PSI source analysis is not yet fully cacheable. +`generateKneNativeBridges` is a `@CacheableTask` and works incrementally between executions: -- **Status**: The plugin performs a full re-scan and code generation on every build if sources change. -- **Future work**: Implement proper Gradle build caching and incremental compilation by mapping source files to specific IR outputs, allowing faster builds for large projects. +- **Build cache**: outputs depend only on relative source paths/contents, the PSI classpath and the extension configuration, so they are restored `FROM-CACHE` across checkouts and machines (enable with `org.gradle.caching=true`). Sources are merged in a deterministic order, so the generated code is reproducible. +- **Source file → IR mapping**: each source file maps to a cached prescan (its type declarations) and IR fragment, kept in `build/kne/incremental` (Gradle local state). A file is re-parsed only if its content changed, or if a symbol it looked up now resolves differently (e.g. a referenced data class gained a field). The PSI environment is not even started when nothing needs parsing. +- **IR → outputs mapping**: when the merged API is unchanged (e.g. only function bodies were edited), code generation is skipped. Otherwise only generated files whose content changed are rewritten and stale ones are deleted, so the downstream incremental Kotlin compilation only recompiles what actually changed. ### GraalVM native-image support @@ -617,11 +618,14 @@ plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ ├── ir/ # Intermediate representation (inspired by SirModule) │ └── KneIR.kt # KneModule, KneClass, KneFunction, KneType... ├── analysis/ -│ ├── PsiSourceParser.kt # Kotlin PSI-based source parser (kotlin-compiler-embeddable) +│ ├── PsiSourceParser.kt # Kotlin PSI-based source parser (kotlin-compiler-embeddable), incremental per file +│ ├── SymbolTable.kt # PSI-free symbol table + type resolution with lookup tracking +│ ├── ParseCache.kt # Persistent source file → IR / IR → outputs cache │ └── PsiParseWorkAction.kt # Gradle Worker for isolated PSI classloader ├── codegen/ │ ├── NativeBridgeGenerator.kt # @CName + StableRef bridges (inspired by @_cdecl thunks) -│ └── FfmProxyGenerator.kt # JVM proxy classes with FFM (inspired by FFMSwift2JavaGenerator) +│ ├── FfmProxyGenerator.kt # JVM proxy classes with FFM (inspired by FFMSwift2JavaGenerator) +│ └── GeneratedOutputs.kt # Write-if-changed output sync + stale file removal ├── tasks/ │ └── GenerateNativeBridgesTask.kt # Single task: PSI parse + native bridges + JVM proxies + GraalVM metadata ├── KotlinNativeExportExtension.kt diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/KotlinNativeExportPlugin.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/KotlinNativeExportPlugin.kt index d64c902..3cea1b4 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/KotlinNativeExportPlugin.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/KotlinNativeExportPlugin.kt @@ -63,6 +63,7 @@ class KotlinNativeExportPlugin : Plugin { val nativeBridgesDir = project.layout.buildDirectory.dir("generated/kne/nativeBridges") val jvmProxiesDir = project.layout.buildDirectory.dir("generated/kne/jvmProxies") val jvmResourcesDir = project.layout.buildDirectory.dir("generated/kne/jvmResources") + val incrementalCacheDir = project.layout.buildDirectory.dir("kne/incremental") // Detect the first native target and its source sets. // Convention: use src/nativeMain if it exists (shared native source set), @@ -119,6 +120,7 @@ class KotlinNativeExportPlugin : Plugin { taskOutputDir.set(nativeBridgesDir) taskJvmOutputDir.set(jvmProxiesDir) taskJvmResourcesDir.set(jvmResourcesDir) + taskCacheDir.set(incrementalCacheDir) taskPsiClasspath.from(psiClasspath) } // Keep old task name as alias diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/ParseCache.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/ParseCache.kt new file mode 100644 index 0000000..b7110ab --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/ParseCache.kt @@ -0,0 +1,97 @@ +package dev.nucleusframework.nna.plugin.analysis + +import dev.nucleusframework.nna.plugin.ir.KneClass +import dev.nucleusframework.nna.plugin.ir.KneDataClass +import dev.nucleusframework.nna.plugin.ir.KneEnum +import dev.nucleusframework.nna.plugin.ir.KneFunction +import dev.nucleusframework.nna.plugin.ir.KneInterface +import dev.nucleusframework.nna.plugin.ir.KneModule +import java.io.File +import java.io.InputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.ObjectStreamClass +import java.io.Serializable +import java.security.MessageDigest + +/** IR declarations contributed by a single source file, merged in source order into the [KneModule]. */ +data class FileFragment( + val packageName: String, + val classes: List, + val dataClasses: List, + val enums: List, + /** Keyed by function name, or `Receiver.name` for extension functions. */ + val functions: List>, + val interfaces: List, +) : Serializable + +/** + * Source file → IR mapping. [prescan] depends only on the file content; [fragment] additionally + * depends on the global symbols listed in [lookups], so it is reused only while they are unchanged. + */ +data class CachedSourceFile( + val contentHash: String, + val isCommon: Boolean, + val prescan: List, + val fragment: FileFragment, + val lookups: Map, +) : Serializable + +/** IR → generated files mapping: the module that produced [outputHashes] (`root/relative/path` → SHA-256). */ +data class CodegenSnapshot( + val module: KneModule, + val packageOrder: List, + val jvmPackage: String, + val outputHashes: Map, +) : Serializable { + + fun matches(module: KneModule, jvmPackage: String): Boolean = + this.jvmPackage == jvmPackage && this.module == module && packageOrder == module.packages.toList() +} + +/** + * Incremental state persisted between task executions. [key] identifies the plugin and parser + * versions that produced it; a mismatch discards the whole cache. + */ +data class ParseCache( + val key: String, + val files: Map, + val codegen: CodegenSnapshot?, +) : Serializable { + + fun write(file: File) { + file.parentFile.mkdirs() + val tmp = File(file.parentFile, file.name + ".tmp") + ObjectOutputStream(tmp.outputStream().buffered()).use { it.writeObject(this) } + if (!tmp.renameTo(file)) { + tmp.copyTo(file, overwrite = true) + tmp.delete() + } + } + + companion object { + /** Reads the cache, or returns null when it is missing, corrupt, or produced with a different [key]. */ + fun read(file: File, key: String): ParseCache? { + if (!file.isFile) return null + return try { + PluginObjectInputStream(file.inputStream().buffered()).use { it.readObject() as? ParseCache } + ?.takeIf { it.key == key } + } catch (_: Exception) { + null + } + } + } + + /** Resolves classes through the plugin's classloader (the worker's isolated loader), not the caller's. */ + private class PluginObjectInputStream(input: InputStream) : ObjectInputStream(input) { + override fun resolveClass(desc: ObjectStreamClass): Class<*> = + try { + Class.forName(desc.name, false, ParseCache::class.java.classLoader) + } catch (_: ClassNotFoundException) { + super.resolveClass(desc) + } + } +} + +internal fun sha256(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiParseWorkAction.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiParseWorkAction.kt index 1ecd9cb..f1856a1 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiParseWorkAction.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiParseWorkAction.kt @@ -1,10 +1,14 @@ package dev.nucleusframework.nna.plugin.analysis import dev.nucleusframework.nna.plugin.codegen.FfmProxyGenerator +import dev.nucleusframework.nna.plugin.codegen.GeneratedOutputs import dev.nucleusframework.nna.plugin.codegen.NativeBridgeGenerator import dev.nucleusframework.nna.plugin.ir.KneModule import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.logging.Logging +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters @@ -12,52 +16,96 @@ import org.gradle.workers.WorkParameters /** * Gradle Worker Action that runs PSI parsing + code generation inside an isolated classloader. * Both parsing AND generation run in the worker to avoid serialization issues with KneType singletons. + * + * The work is incremental across executions thanks to a [ParseCache] stored in [Params.cacheFile]: + * unchanged source files reuse their cached IR fragments, code generation is skipped when the + * merged module is unchanged, and only output files whose content changed are rewritten. */ abstract class PsiParseWorkAction : WorkAction { interface Params : WorkParameters { - val nativeSourceFiles: ConfigurableFileCollection + /** Common sources first, then native sources, in deterministic merge order. */ + val sourceFiles: ConfigurableFileCollection + /** Subset of [sourceFiles] that comes from commonMain. */ val commonSourceFiles: ConfigurableFileCollection val libName: Property val jvmPackage: Property val nativeBridgesDir: DirectoryProperty val jvmProxiesDir: DirectoryProperty val jvmResourcesDir: DirectoryProperty + val cacheFile: RegularFileProperty + /** Identifies the plugin/parser version; a different key invalidates the cache. */ + val cacheKey: Property } + private val logger = Logging.getLogger(PsiParseWorkAction::class.java) + override fun execute() { - val nativeFiles = parameters.nativeSourceFiles.files val commonFiles = parameters.commonSourceFiles.files + val sources = parameters.sourceFiles.files.map { PsiSourceParser.Source(it, isCommon = it in commonFiles) } val libName = parameters.libName.get() - val configuredPackage = parameters.jvmPackage.get() + val cacheFile = parameters.cacheFile.get().asFile + val cacheKey = parameters.cacheKey.get() + val previous = ParseCache.read(cacheFile, cacheKey) - val module = PsiSourceParser().parse(nativeFiles, libName, commonFiles) + val result = PsiSourceParser().parseIncremental(sources, libName, previous?.files.orEmpty()) + val module = result.module + logger.lifecycle("kne: Parsed ${result.parsedFiles} of ${result.files.size} source file(s) [PSI]") // Auto-detect package from sources if not explicitly configured - val jvmPackage = configuredPackage.ifEmpty { + val jvmPackage = parameters.jvmPackage.get().ifEmpty { module.packages.firstOrNull() ?: "" } - // Generate native bridges - val nativeBridgesDir = parameters.nativeBridgesDir.get().asFile - if (module.classes.isNotEmpty() || module.enums.isNotEmpty() || module.functions.isNotEmpty()) { - nativeBridgesDir.mkdirs() - val bridgeCode = NativeBridgeGenerator().generate(module) - nativeBridgesDir.resolve("kne_bridges.kt").writeText(bridgeCode) + val roots = mapOf( + NATIVE_BRIDGES to parameters.nativeBridgesDir.get().asFile, + JVM_PROXIES to parameters.jvmProxiesDir.get().asFile, + JVM_RESOURCES to parameters.jvmResourcesDir.get().asFile, + ) + val snapshot = previous?.codegen + val codegen = if (snapshot != null && snapshot.matches(module, jvmPackage) && GeneratedOutputs.hashes(roots) == snapshot.outputHashes) { + logger.lifecycle("kne: API unchanged, generated sources are up to date") + snapshot + } else { + val sync = GeneratedOutputs.sync(roots, generate(module, jvmPackage)) + logger.lifecycle("kne: Updated ${sync.written} and deleted ${sync.deleted} generated file(s)") + CodegenSnapshot(module, module.packages.toList(), jvmPackage, sync.hashes) } - // Generate JVM proxies + GraalVM metadata - if (jvmPackage.isNotEmpty() && (module.classes.isNotEmpty() || module.enums.isNotEmpty() || module.functions.isNotEmpty())) { - val pkgDir = parameters.jvmProxiesDir.get().asFile.resolve(jvmPackage.replace('.', '/')) - pkgDir.mkdirs() + try { + ParseCache(cacheKey, result.files, codegen).write(cacheFile) + } catch (e: Exception) { + // The cache is an optimisation only; the next run falls back to a full parse + logger.warn("kne: Could not write incremental cache: ${e.message}") + cacheFile.delete() + } + } + + /** Generates every output file from the module, grouped by output root. */ + private fun generate(module: KneModule, jvmPackage: String): Map> { + val outputs = mapOf( + NATIVE_BRIDGES to LinkedHashMap(), + JVM_PROXIES to LinkedHashMap(), + JVM_RESOURCES to LinkedHashMap(), + ) + val hasApi = module.classes.isNotEmpty() || module.enums.isNotEmpty() || module.functions.isNotEmpty() + if (!hasApi) return outputs + + // Native bridges + outputs.getValue(NATIVE_BRIDGES)["kne_bridges.kt"] = NativeBridgeGenerator().generate(module) + + // JVM proxies + GraalVM metadata + if (jvmPackage.isNotEmpty()) { + val pkgPath = jvmPackage.replace('.', '/') val generator = FfmProxyGenerator() generator.generate(module, jvmPackage).forEach { (filename, content) -> - pkgDir.resolve(filename).writeText(content) + outputs.getValue(JVM_PROXIES)["$pkgPath/$filename"] = content } // Generate GraalVM reachability metadata for native-image support - generateGraalVmMetadata(parameters.jvmResourcesDir.get().asFile, module, jvmPackage, generator) + generateGraalVmMetadata(outputs.getValue(JVM_RESOURCES), module, jvmPackage, generator) } + return outputs } /** @@ -65,13 +113,12 @@ abstract class PsiParseWorkAction : WorkAction { * Includes reflection config, resource config, and FFM foreign downcall/upcall descriptors. */ private fun generateGraalVmMetadata( - resourcesRoot: java.io.File, + resources: MutableMap, module: KneModule, jvmPackage: String, generator: FfmProxyGenerator, ) { - val metaDir = resourcesRoot.resolve("META-INF/native-image/kne/${module.libName}") - metaDir.mkdirs() + val metaDir = "META-INF/native-image/kne/${module.libName}" // Collect all generated class FQNs for reflection val classNames = mutableListOf() @@ -96,17 +143,17 @@ abstract class PsiParseWorkAction : WorkAction { "allDeclaredFields": true }""" } - metaDir.resolve("reflect-config.json").writeText("[\n$reflectEntries\n]\n") + resources["$metaDir/reflect-config.json"] = "[\n$reflectEntries\n]\n" // resource-config.json - metaDir.resolve("resource-config.json").writeText("""{ + resources["$metaDir/resource-config.json"] = """{ "resources": { "includes": [ { "pattern": "\\Qkne/native/\\E.*" } ] } } -""") +""" // reachability-metadata.json — FFM foreign downcall + upcall descriptors fun formatEntries(descriptors: Set, String?>>): String = @@ -130,7 +177,7 @@ $upcallEntries "" } - metaDir.resolve("reachability-metadata.json").writeText("""{ + resources["$metaDir/reachability-metadata.json"] = """{ "reflection": [ ${classNames.joinToString(",\n") { """ { "type": "$it", "allDeclaredConstructors": true, "allDeclaredMethods": true }""" }} ], @@ -143,6 +190,12 @@ $downcallEntries ]$upcallSection } } -""") +""" + } + + private companion object { + const val NATIVE_BRIDGES = "nativeBridges" + const val JVM_PROXIES = "jvmProxies" + const val JVM_RESOURCES = "jvmResources" } } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt index c4dcaaa..66bbf96 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt @@ -15,12 +15,14 @@ import dev.nucleusframework.nna.plugin.ir.KneType import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.Disposable import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer import org.jetbrains.kotlin.idea.KotlinFileType import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtEnumEntry +import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtFunctionType import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtNullableType @@ -37,195 +39,209 @@ import java.io.File * AST-based parser using Kotlin PSI from kotlin-compiler-embeddable. * Runs inside an isolated classloader (Gradle Worker API) to avoid conflicts * with Gradle's own Kotlin runtime. + * + * Parsing is incremental: every source file maps to a [CachedSourceFile] (prescan + IR fragment). + * A file is re-parsed only when its content changed, or when a global symbol it looked up + * (see [RecordingScope]) now resolves differently. The PSI environment is only created when + * at least one file has to be parsed. */ class PsiSourceParser { + /** One input file. Sources are merged in list order (common sources first). */ + data class Source(val file: File, val isCommon: Boolean) + + class Result( + val module: KneModule, + /** Source file → IR mapping, keyed by absolute path; feed it back as `previous` on the next run. */ + val files: Map, + val parsedFiles: Int, + ) + fun parse(files: Collection, libName: String, commonFiles: Collection): KneModule { - val disposable = Disposer.newDisposable("KnePsiParser") - try { - // IntelliJ PathManager requires idea.home.path - val tmpHome = java.nio.file.Files.createTempDirectory("kne-psi").toFile() - tmpHome.resolve("product-info.json").writeText("""{"buildNumber":"999.SNAPSHOT"}""") - System.setProperty("idea.home.path", tmpHome.absolutePath) + val sources = commonFiles.filter { it.extension == "kt" }.map { Source(it, isCommon = true) } + + files.filter { it.extension == "kt" }.map { Source(it, isCommon = false) } + return parseIncremental(sources, libName, emptyMap()).module + } - val appEnv = KotlinCoreApplicationEnvironment.create(disposable, KotlinCoreApplicationEnvironmentMode.Production) - appEnv.registerFileType(KotlinFileType.INSTANCE, "kt") - appEnv.registerParserDefinition(org.jetbrains.kotlin.parsing.KotlinParserDefinition()) - val projEnv = KotlinCoreProjectEnvironment(disposable, appEnv) - val psiFactory = KtPsiFactory(projEnv.project) - - val ktFiles = files.filter { it.extension == "kt" } - val commonKtFiles = commonFiles.filter { it.extension == "kt" } - - // Phase 1: prescan type names - val knownClasses = mutableMapOf() - val knownEnums = mutableMapOf() - val knownInterfaces = mutableMapOf() - val rawDataClasses = mutableMapOf>() - val commonDataClassNames = mutableSetOf() - val commonClassNames = mutableSetOf() - val commonInterfaceNames = mutableSetOf() - // Track modifiers for classes found during prescan - val sealedClassNames = mutableSetOf() - val openClassNames = mutableSetOf() - val abstractClassNames = mutableSetOf() - - fun prescanDeclarations(declarations: List, pkg: String, parentFq: String? = null) { - for (decl in declarations) { - if (decl !is KtClass) continue - val name = decl.name ?: continue - val fq = if (parentFq != null) "$parentFq.$name" - else if (pkg.isNotEmpty()) "$pkg.$name" else name - when { - decl.isEnum() -> knownEnums[name] = fq - decl.isData() -> rawDataClasses[name] = Triple(fq, decl, pkg) - decl.isInterface() -> knownInterfaces[name] = fq - else -> { - knownClasses[name] = fq - if (decl.hasModifier(KtTokens.SEALED_KEYWORD)) sealedClassNames.add(name) - if (decl.hasModifier(KtTokens.OPEN_KEYWORD)) openClassNames.add(name) - if (decl.hasModifier(KtTokens.ABSTRACT_KEYWORD)) abstractClassNames.add(name) - prescanDeclarations(decl.declarations.toList(), pkg, fq) - } - } - } + fun parseIncremental(sources: List, libName: String, previous: Map): Result { + PsiFiles().use { psi -> + class Entry(val source: Source, val key: String, val text: String, val hash: String, val cached: CachedSourceFile?, val prescan: List) + + // Phase 1: prescan type declarations (reused as long as the content is unchanged) + val entries = sources.distinctBy { it.file.absoluteFile }.map { source -> + val key = source.file.absolutePath + val text = source.file.readText() + val hash = sha256(text.toByteArray()) + val cached = previous[key]?.takeIf { it.contentHash == hash && it.isCommon == source.isCommon } + Entry(source, key, text, hash, cached, cached?.prescan ?: prescan(psi.parse(source.file, text))) } - val commonFileSet = commonKtFiles.toSet() - for (file in commonKtFiles + ktFiles) { - val ktFile = psiFactory.createFile(file.name, file.readText()) - val pkg = ktFile.packageFqName.asString() - val classKeysBefore = knownClasses.keys.toSet() - val ifaceKeysBefore = knownInterfaces.keys.toSet() - val dcKeysBefore = rawDataClasses.keys.toSet() - prescanDeclarations(ktFile.declarations.toList(), pkg) - if (file in commonFileSet) { - (rawDataClasses.keys - dcKeysBefore).forEach { commonDataClassNames.add(it) } - (knownInterfaces.keys - ifaceKeysBefore).forEach { commonInterfaceNames.add(it) } - (knownClasses.keys - classKeysBefore).forEach { commonClassNames.add(it) } + // Phase 2: global symbol table (cheap, always rebuilt from the prescans) + val table = SymbolTable.build(entries.map { it.prescan to it.source.isCommon }) + + // Phase 3: IR fragment per file, reused while every symbol it looked up is unchanged + var parsedFiles = 0 + val files = LinkedHashMap() + for (entry in entries) { + val cached = entry.cached?.takeIf { cached -> + cached.lookups.all { (name, info) -> table.info(name) == info } + } + files[entry.key] = cached ?: run { + parsedFiles++ + val ktFile = psi.parse(entry.source.file, entry.text) + val scope = RecordingScope(table) + val fragment = parseFragment(ktFile, scope, entry.source.isCommon) + CachedSourceFile(entry.hash, entry.source.isCommon, entry.prescan, fragment, scope.lookups.toMap()) } } - // Phase 2: resolve data class fields iteratively - val knownDataClasses = mutableMapOf>>() - var changed = true - while (changed) { - changed = false - for ((name, info) in rawDataClasses) { - if (name in knownDataClasses) continue - val fields = resolveDataClassFields(info.second, knownEnums, knownClasses, knownDataClasses) - if (fields != null) { knownDataClasses[name] = Pair(info.first, fields); changed = true } + return Result(merge(libName, files.values.map { it.fragment }, table), files, parsedFiles) + } + } + + /** Lazily-created PSI environment; parsed files are memoized for the duration of one run. */ + private class PsiFiles : AutoCloseable { + private var disposable: Disposable? = null + private var tmpHome: File? = null + private val factory: KtPsiFactory by lazy { + val root = Disposer.newDisposable("KnePsiParser").also { disposable = it } + // IntelliJ PathManager requires idea.home.path + val home = java.nio.file.Files.createTempDirectory("kne-psi").toFile().also { tmpHome = it } + home.resolve("product-info.json").writeText("""{"buildNumber":"999.SNAPSHOT"}""") + System.setProperty("idea.home.path", home.absolutePath) + + val appEnv = KotlinCoreApplicationEnvironment.create(root, KotlinCoreApplicationEnvironmentMode.Production) + appEnv.registerFileType(KotlinFileType.INSTANCE, "kt") + appEnv.registerParserDefinition(org.jetbrains.kotlin.parsing.KotlinParserDefinition()) + KtPsiFactory(KotlinCoreProjectEnvironment(root, appEnv).project) + } + private val parsed = HashMap() + + fun parse(file: File, text: String): KtFile = parsed.getOrPut(file) { factory.createFile(file.name, text) } + + override fun close() { + tmpHome?.deleteRecursively() + disposable?.let(Disposer::dispose) + } + } + + private fun prescan(ktFile: KtFile): List { + val decls = mutableListOf() + val pkg = ktFile.packageFqName.asString() + + fun visit(declarations: List, parentFq: String?) { + for (decl in declarations) { + if (decl !is KtClass) continue + val name = decl.name ?: continue + val fq = if (parentFq != null) "$parentFq.$name" + else if (pkg.isNotEmpty()) "$pkg.$name" else name + when { + decl.isEnum() -> decls += PrescanDecl(name, fq, DeclKind.ENUM) + decl.isData() -> decls += PrescanDecl(name, fq, DeclKind.DATA_CLASS, dataParams(decl)) + decl.isInterface() -> decls += PrescanDecl(name, fq, DeclKind.INTERFACE) + else -> { + decls += PrescanDecl(name, fq, DeclKind.CLASS) + visit(decl.declarations.toList(), fq) + } } } - for ((name, info) in rawDataClasses) { - if (name !in knownDataClasses) knownClasses[name] = info.first - } + } + visit(ktFile.declarations.toList(), null) + return decls + } - val typeMaps = TypeMaps(knownClasses, knownEnums, knownDataClasses, knownInterfaces) - - // Phase 3: parse all files (deduplicate by fqName) - val classMap = mutableMapOf() - val dataClassMap = mutableMapOf() - val enumMap = mutableMapOf() - val functionMap = mutableMapOf() - val interfaceMap = mutableMapOf() - val packages = mutableSetOf() - - for (file in commonKtFiles + ktFiles) { - val isCommonFile = file in commonFileSet - val ktFile = psiFactory.createFile(file.name, file.readText()) - val pkg = ktFile.packageFqName.asString() - if (pkg.isNotEmpty()) packages.add(pkg) - val imports = ktFile.importDirectives.map { it.text } - - fun processDeclarations(declarations: List, parentSimpleName: String?, isTopLevel: Boolean) { - for (decl in declarations) { - if (decl.isPrivateOrInternal()) continue - when { - decl is KtClass && decl.isEnum() -> parseEnum(decl, pkg, typeMaps) - ?.copy(source = sourceDecl(decl, imports)) - ?.let { enumMap.putIfAbsent(it.fqName, it) } - decl is KtClass && decl.isData() -> { - val name = decl.name ?: continue - val dcInfo = knownDataClasses[name] ?: continue - val fq = dcInfo.first - dataClassMap.putIfAbsent( - fq, - KneDataClass( - name, fq, dcInfo.second, - isCommon = name in commonDataClassNames, - source = sourceDecl(decl, imports), - ), - ) - } - decl is KtClass && decl.isInterface() -> { - val iface = parseInterface(decl, pkg, typeMaps, isCommon = isCommonFile) ?: continue - interfaceMap.putIfAbsent(iface.fqName, iface) - } - decl is KtClass -> { - val cls = parseClass(decl, pkg, typeMaps, isCommon = isCommonFile) ?: continue - val correctFq = knownClasses[decl.name ?: ""] ?: cls.fqName - val qualifiedCls = if (parentSimpleName != null) { - cls.copy(simpleName = "${parentSimpleName}_${cls.simpleName}", fqName = correctFq) - } else cls.copy(fqName = correctFq) - classMap.putIfAbsent(qualifiedCls.fqName, qualifiedCls) - // Only recurse for nested classes, not into class body - processDeclarations(decl.declarations.toList(), qualifiedCls.simpleName, false) - } - // Top-level functions (including extension functions) - isTopLevel && decl is KtNamedFunction -> { - val fn = parseFunction(decl, typeMaps) ?: continue - // Use a unique key for extension functions to avoid collisions - val key = if (fn.isExtension && fn.receiverType != null) { - val receiverName = when (val rt = fn.receiverType) { - is KneType.OBJECT -> rt.simpleName - is KneType.INTERFACE -> rt.simpleName - else -> rt.jvmTypeName - } - "${receiverName}.${fn.name}" - } else fn.name - functionMap.putIfAbsent(key, fn) + private fun dataParams(ktClass: KtClass): List? = + ktClass.primaryConstructor?.valueParameters?.map { param -> + DataParamSyntax(param.name, param.hasValOrVar(), param.typeReference.toSyntax()) + } + + private fun parseFragment(ktFile: KtFile, scope: RecordingScope, isCommonFile: Boolean): FileFragment { + val pkg = ktFile.packageFqName.asString() + val imports = ktFile.importDirectives.map { it.text } + val classes = mutableListOf() + val dataClasses = mutableListOf() + val enums = mutableListOf() + val functions = mutableListOf>() + val interfaces = mutableListOf() + + fun processDeclarations(declarations: List, parentSimpleName: String?, isTopLevel: Boolean) { + for (decl in declarations) { + if (decl.isPrivateOrInternal()) continue + when { + decl is KtClass && decl.isEnum() -> parseEnum(decl, pkg, scope) + ?.copy(source = sourceDecl(decl, imports)) + ?.let { enums += it } + decl is KtClass && decl.isData() -> { + val name = decl.name ?: continue + val dcInfo = scope.dataClass(name) ?: continue + dataClasses += KneDataClass( + name, dcInfo.first, dcInfo.second, + isCommon = scope.isCommonDataClass(name), + source = sourceDecl(decl, imports), + ) + } + decl is KtClass && decl.isInterface() -> { + interfaces += parseInterface(decl, pkg, scope, isCommon = isCommonFile) ?: continue + } + decl is KtClass -> { + val cls = parseClass(decl, pkg, scope, isCommon = isCommonFile) ?: continue + val correctFq = scope.classFq(decl.name ?: "") ?: cls.fqName + val qualifiedCls = if (parentSimpleName != null) { + cls.copy(simpleName = "${parentSimpleName}_${cls.simpleName}", fqName = correctFq) + } else cls.copy(fqName = correctFq) + classes += qualifiedCls + // Only recurse for nested classes, not into class body + processDeclarations(decl.declarations.toList(), qualifiedCls.simpleName, false) + } + // Top-level functions (including extension functions) + isTopLevel && decl is KtNamedFunction -> { + val fn = parseFunction(decl, scope) ?: continue + // Use a unique key for extension functions to avoid collisions + val key = if (fn.isExtension && fn.receiverType != null) { + val receiverName = when (val rt = fn.receiverType) { + is KneType.OBJECT -> rt.simpleName + is KneType.INTERFACE -> rt.simpleName + else -> rt.jvmTypeName } - } + "${receiverName}.${fn.name}" + } else fn.name + functions += key to fn } } - processDeclarations(ktFile.declarations.toList(), null, true) - } - - // Ensure common data classes are present even if only defined in commonMain - for (name in commonDataClassNames) { - val dcInfo = knownDataClasses[name] ?: continue - dataClassMap.putIfAbsent(dcInfo.first, KneDataClass(name, dcInfo.first, dcInfo.second, isCommon = true)) } - - tmpHome.deleteRecursively() - - return KneModule(libName, packages, classMap.values.toList(), dataClassMap.values.toList(), enumMap.values.toList(), functionMap.values.toList(), interfaceMap.values.toList()) - } finally { - Disposer.dispose(disposable) } + processDeclarations(ktFile.declarations.toList(), null, true) + return FileFragment(pkg, classes, dataClasses, enums, functions, interfaces) } - private data class TypeMaps( - val classes: Map, - val enums: Map, - val dataClasses: Map>> = emptyMap(), - val interfaces: Map = emptyMap(), - ) + /** Merges per-file fragments in source order; the first declaration of a given FQ name wins. */ + private fun merge(libName: String, fragments: List, table: SymbolTable): KneModule { + val classMap = LinkedHashMap() + val dataClassMap = LinkedHashMap() + val enumMap = LinkedHashMap() + val functionMap = LinkedHashMap() + val interfaceMap = LinkedHashMap() + val packages = LinkedHashSet() + + for (fragment in fragments) { + if (fragment.packageName.isNotEmpty()) packages += fragment.packageName + fragment.classes.forEach { classMap.putIfAbsent(it.fqName, it) } + fragment.dataClasses.forEach { dataClassMap.putIfAbsent(it.fqName, it) } + fragment.enums.forEach { enumMap.putIfAbsent(it.fqName, it) } + fragment.functions.forEach { (key, fn) -> functionMap.putIfAbsent(key, fn) } + fragment.interfaces.forEach { interfaceMap.putIfAbsent(it.fqName, it) } + } - private fun resolveDataClassFields( - ktClass: KtClass, knownEnums: Map, - knownClasses: Map, knownDataClasses: Map>>, - ): List? { - val params = ktClass.primaryConstructor?.valueParameters ?: return null - val fields = mutableListOf() - for (param in params) { - if (!param.hasValOrVar()) continue - val name = param.name ?: return null - val type = resolveType(param.typeReference, knownEnums, knownClasses, knownDataClasses) ?: return null - fields.add(KneParam(name, type)) + // Ensure common data classes are present even if only defined in commonMain + for (name in table.commonDataClassNames) { + val (fq, fields) = table.dataClasses[name] ?: continue + dataClassMap.putIfAbsent(fq, KneDataClass(name, fq, fields, isCommon = true)) } - return if (fields.isNotEmpty()) fields else null + + return KneModule( + libName, packages, classMap.values.toList(), dataClassMap.values.toList(), + enumMap.values.toList(), functionMap.values.toList(), interfaceMap.values.toList(), + ) } /** Captures the declaration text (incl. KDoc and annotations) re-indented to top level. */ @@ -235,19 +251,19 @@ class PsiSourceParser { return KneSourceDecl(text, imports) } - private fun parseClass(ktClass: KtClass, pkg: String, typeMaps: TypeMaps, isCommon: Boolean = false): KneClass? { + private fun parseClass(ktClass: KtClass, pkg: String, scope: RecordingScope, isCommon: Boolean = false): KneClass? { val name = ktClass.name ?: return null val fq = if (pkg.isNotEmpty()) "$pkg.$name" else name val rawCtorParams = ktClass.primaryConstructor?.valueParameters ?: emptyList() val ctorParams = rawCtorParams.mapNotNull { param -> val pName = param.name ?: return@mapNotNull null - val type = resolveTypeFromMaps(param.typeReference, typeMaps) ?: return@mapNotNull null + val type = resolveType(param.typeReference, scope) ?: return@mapNotNull null KneParam(pName, type, hasDefault = param.hasDefaultValue()) } // Constructor val/var params are properties — collect them for getter/setter generation val ctorProperties = rawCtorParams.filter { it.hasValOrVar() }.mapNotNull { param -> val pName = param.name ?: return@mapNotNull null - val type = resolveTypeFromMaps(param.typeReference, typeMaps) ?: return@mapNotNull null + val type = resolveType(param.typeReference, scope) ?: return@mapNotNull null KneProperty(pName, type, param.isMutable) } @@ -261,13 +277,11 @@ class PsiSourceParser { val implementedInterfaces = mutableListOf() for (entry in ktClass.superTypeListEntries) { val typeName = entry.typeReference?.text?.substringBefore("<")?.trim() ?: continue - when { - typeName in typeMaps.interfaces -> { - typeMaps.interfaces[typeName]?.let { implementedInterfaces.add(it) } - } - typeName in typeMaps.classes -> { - if (superClass == null) superClass = typeMaps.classes[typeName] - } + val interfaceFq = scope.interfaceFq(typeName) + if (interfaceFq != null) { + implementedInterfaces.add(interfaceFq) + } else if (superClass == null) { + superClass = scope.classFq(typeName) } } @@ -305,19 +319,19 @@ class PsiSourceParser { when (decl) { is KtNamedFunction -> { if (decl.name?.startsWith("_") == true) continue - parseFunction(decl, typeMaps)?.let { methods.add(it) } + parseFunction(decl, scope)?.let { methods.add(it) } } is KtProperty -> { val propName = decl.name ?: continue if (propName in ctorParamNames) continue - parseProperty(decl, typeMaps)?.let { properties.add(it) } + parseProperty(decl, scope)?.let { properties.add(it) } } is KtObjectDeclaration -> if (decl.isCompanion()) { for (cd in decl.declarations) { if (cd.isPrivateOrInternal()) continue when (cd) { - is KtNamedFunction -> parseFunction(cd, typeMaps)?.let { companionMethods.add(it) } - is KtProperty -> parseProperty(cd, typeMaps)?.let { companionProperties.add(it) } + is KtNamedFunction -> parseFunction(cd, scope)?.let { companionMethods.add(it) } + is KtProperty -> parseProperty(cd, scope)?.let { companionProperties.add(it) } } } } @@ -331,7 +345,7 @@ class PsiSourceParser { ) } - private fun parseInterface(ktClass: KtClass, pkg: String, typeMaps: TypeMaps, isCommon: Boolean = false): KneInterface? { + private fun parseInterface(ktClass: KtClass, pkg: String, scope: RecordingScope, isCommon: Boolean = false): KneInterface? { val name = ktClass.name ?: return null val fq = if (pkg.isNotEmpty()) "$pkg.$name" else name val methods = mutableListOf() @@ -341,7 +355,7 @@ class PsiSourceParser { // Extract super interfaces for (entry in ktClass.superTypeListEntries) { val typeName = entry.typeReference?.text?.substringBefore("<")?.trim() ?: continue - typeMaps.interfaces[typeName]?.let { superInterfaces.add(it) } + scope.interfaceFq(typeName)?.let { superInterfaces.add(it) } } for (decl in ktClass.declarations) { @@ -349,15 +363,15 @@ class PsiSourceParser { when (decl) { is KtNamedFunction -> { if (decl.name?.startsWith("_") == true) continue - parseFunction(decl, typeMaps)?.let { methods.add(it) } + parseFunction(decl, scope)?.let { methods.add(it) } } - is KtProperty -> parseProperty(decl, typeMaps)?.let { properties.add(it) } + is KtProperty -> parseProperty(decl, scope)?.let { properties.add(it) } } } return KneInterface(name, fq, methods, properties, superInterfaces, isCommon = isCommon) } - private fun parseEnum(ktClass: KtClass, pkg: String, typeMaps: TypeMaps): KneEnum? { + private fun parseEnum(ktClass: KtClass, pkg: String, scope: RecordingScope): KneEnum? { val name = ktClass.name ?: return null val fq = if (pkg.isNotEmpty()) "$pkg.$name" else name @@ -371,7 +385,7 @@ class PsiSourceParser { val rawCtorParams = ktClass.primaryConstructor?.valueParameters ?: emptyList() val ctorParams = rawCtorParams.map { param -> val pName = param.name ?: return KneEnum(name, fq, entries) - val type = resolveTypeFromMaps(param.typeReference, typeMaps) + val type = resolveType(param.typeReference, scope) ?: return KneEnum(name, fq, entries) KneParam(pName, type, hasDefault = param.hasDefaultValue()) } @@ -392,87 +406,50 @@ class PsiSourceParser { KneEnumEntry(entryName, args) } - private fun parseFunction(fn: KtNamedFunction, typeMaps: TypeMaps): KneFunction? { + private fun parseFunction(fn: KtNamedFunction, scope: RecordingScope): KneFunction? { val name = fn.name ?: return null if (name == "init") return null val isSuspend = fn.hasModifier(KtTokens.SUSPEND_KEYWORD) val isOverride = fn.hasModifier(KtTokens.OVERRIDE_KEYWORD) val params = fn.valueParameters.mapNotNull { param -> val pName = param.name ?: return@mapNotNull null - val type = resolveTypeFromMaps(param.typeReference, typeMaps) ?: return@mapNotNull null + val type = resolveType(param.typeReference, scope) ?: return@mapNotNull null KneParam(pName, type) } - val returnType = fn.typeReference?.let { resolveTypeFromMaps(it, typeMaps) } ?: KneType.UNIT + val returnType = fn.typeReference?.let { resolveType(it, scope) } ?: KneType.UNIT // Extension function detection val receiverTypeRef = fn.receiverTypeReference - val receiverType = if (receiverTypeRef != null) resolveTypeFromMaps(receiverTypeRef, typeMaps) else null + val receiverType = if (receiverTypeRef != null) resolveType(receiverTypeRef, scope) else null val isExtension = receiverType != null return KneFunction(name, params, returnType, isSuspend = isSuspend, isExtension = isExtension, receiverType = receiverType, isOverride = isOverride) } - private fun parseProperty(prop: KtProperty, typeMaps: TypeMaps): KneProperty? { + private fun parseProperty(prop: KtProperty, scope: RecordingScope): KneProperty? { val name = prop.name ?: return null - val type = resolveTypeFromMaps(prop.typeReference, typeMaps) ?: return null + val type = resolveType(prop.typeReference, scope) ?: return null val isOverride = prop.hasModifier(KtTokens.OVERRIDE_KEYWORD) return KneProperty(name, type, prop.isVar, isOverride = isOverride) } - private fun resolveTypeFromMaps(typeRef: KtTypeReference?, typeMaps: TypeMaps): KneType? = - resolveType(typeRef, typeMaps.enums, typeMaps.classes, typeMaps.dataClasses, typeMaps.interfaces) - - private fun resolveType( - typeRef: KtTypeReference?, knownEnums: Map, - knownClasses: Map, knownDataClasses: Map>>, - knownInterfaces: Map = emptyMap(), - ): KneType? { - val typeElem = typeRef?.typeElement ?: return null - if (typeElem is KtNullableType) { - val inner = resolveTypeElement(typeElem.innerType, knownEnums, knownClasses, knownDataClasses, knownInterfaces) ?: return null - return if (inner == KneType.UNIT || inner is KneType.NULLABLE) inner else KneType.NULLABLE(inner) - } - return resolveTypeElement(typeElem, knownEnums, knownClasses, knownDataClasses, knownInterfaces) - } + private fun resolveType(typeRef: KtTypeReference?, scope: TypeScope): KneType? = + TypeResolver.resolve(typeRef.toSyntax(), scope) - private fun resolveTypeElement( - typeElem: KtTypeElement?, knownEnums: Map, - knownClasses: Map, knownDataClasses: Map>>, - knownInterfaces: Map = emptyMap(), - ): KneType? { - if (typeElem == null) return null - if (typeElem is KtFunctionType) { - val paramTypes = typeElem.parameters.mapNotNull { p -> - resolveType(p.typeReference, knownEnums, knownClasses, knownDataClasses, knownInterfaces) - } - val returnType = resolveType(typeElem.returnTypeReference, knownEnums, knownClasses, knownDataClasses, knownInterfaces) ?: KneType.UNIT - val supported = setOf(KneType.INT, KneType.LONG, KneType.DOUBLE, KneType.FLOAT, KneType.BOOLEAN, KneType.BYTE, KneType.SHORT, KneType.STRING) - fun ok(t: KneType) = t in supported || t == KneType.BYTE_ARRAY || t is KneType.DATA_CLASS || t is KneType.ENUM || t is KneType.OBJECT || t is KneType.INTERFACE || t is KneType.LIST || t is KneType.SET || t is KneType.MAP - fun okRet(t: KneType) = ok(t) || t == KneType.UNIT - if (paramTypes.any { !ok(it) } || !okRet(returnType)) return null - return KneType.FUNCTION(paramTypes, returnType) - } - if (typeElem is KtUserType) { - val name = typeElem.referencedName ?: return null - val typeArgs = typeElem.typeArguments.mapNotNull { arg -> - resolveType(arg.typeReference, knownEnums, knownClasses, knownDataClasses, knownInterfaces) - } - return when (name) { - "Int" -> KneType.INT; "Long" -> KneType.LONG; "Double" -> KneType.DOUBLE; "Float" -> KneType.FLOAT - "Boolean" -> KneType.BOOLEAN; "Byte" -> KneType.BYTE; "Short" -> KneType.SHORT - "String" -> KneType.STRING; "ByteArray" -> KneType.BYTE_ARRAY; "Unit" -> KneType.UNIT - "List", "MutableList" -> if (typeArgs.size == 1) KneType.LIST(typeArgs[0]) else null - "Set", "MutableSet" -> if (typeArgs.size == 1) KneType.SET(typeArgs[0]) else null - "Map", "MutableMap" -> if (typeArgs.size == 2) KneType.MAP(typeArgs[0], typeArgs[1]) else null - "Flow" -> if (typeArgs.size == 1) KneType.FLOW(typeArgs[0]) else null - else -> knownEnums[name]?.let { KneType.ENUM(it, name) } - ?: knownDataClasses[name]?.let { KneType.DATA_CLASS(it.first, name, it.second) } - ?: knownInterfaces[name]?.let { KneType.INTERFACE(it, name) } - ?: knownClasses[name]?.let { KneType.OBJECT(it, name) } - } - } - return null + private fun KtTypeReference?.toSyntax(): TypeSyntax? = this?.typeElement?.let(::syntaxOf) + + private fun syntaxOf(elem: KtTypeElement?): TypeSyntax? = when (elem) { + null -> null + is KtNullableType -> TypeSyntax.Nullable(syntaxOf(elem.innerType)) + is KtFunctionType -> TypeSyntax.Function( + elem.parameters.map { it.typeReference.toSyntax() }, + elem.returnTypeReference.toSyntax(), + ) + is KtUserType -> elem.referencedName + ?.let { name -> TypeSyntax.User(name, elem.typeArguments.map { it.typeReference.toSyntax() }) } + ?: TypeSyntax.Unsupported + else -> TypeSyntax.Unsupported } private fun KtDeclaration.isPrivateOrInternal(): Boolean { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/SymbolTable.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/SymbolTable.kt new file mode 100644 index 0000000..17547a9 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/SymbolTable.kt @@ -0,0 +1,205 @@ +package dev.nucleusframework.nna.plugin.analysis + +import dev.nucleusframework.nna.plugin.ir.KneParam +import dev.nucleusframework.nna.plugin.ir.KneType +import java.io.Serializable + +/** + * PSI-free view of a type reference. Lets type resolution run on cached per-file data, + * so unchanged source files never need to be re-parsed. + */ +sealed interface TypeSyntax : Serializable { + data class User(val name: String, val args: List) : TypeSyntax + data class Function(val params: List, val returnType: TypeSyntax?) : TypeSyntax + data class Nullable(val inner: TypeSyntax?) : TypeSyntax + data object Unsupported : TypeSyntax +} + +enum class DeclKind { ENUM, DATA_CLASS, INTERFACE, CLASS } + +/** A primary-constructor parameter of a data class, kept unresolved until the global symbol table exists. */ +data class DataParamSyntax(val name: String?, val isProperty: Boolean, val type: TypeSyntax?) : Serializable + +/** A type declaration discovered by the prescan phase. [dataParams] is null when there is no primary constructor. */ +data class PrescanDecl( + val name: String, + val fqName: String, + val kind: DeclKind, + val dataParams: List? = null, +) : Serializable + +/** Resolved data class: FQ name + resolved fields. */ +typealias ResolvedDataClass = Pair> + +/** + * Everything the symbol table knows about a simple name. Per-file parse results record the + * [SymbolInfo] of every name they looked up; a cached result stays valid as long as those + * snapshots still match the current table. + */ +data class SymbolInfo( + val enumFq: String?, + val dataClass: ResolvedDataClass?, + val interfaceFq: String?, + val classFq: String?, + val isCommonDataClass: Boolean, +) : Serializable + +/** Name-based lookups used to resolve [TypeSyntax] into [KneType]. */ +interface TypeScope { + fun enumFq(name: String): String? + fun dataClass(name: String): ResolvedDataClass? + fun interfaceFq(name: String): String? + fun classFq(name: String): String? +} + +/** Global symbol table built from the prescan results of all source files, in merge order. */ +class SymbolTable private constructor( + private val enums: Map, + val dataClasses: Map, + private val interfaces: Map, + private val classes: Map, + val commonDataClassNames: Set, +) { + + fun info(name: String): SymbolInfo? { + val info = SymbolInfo( + enumFq = enums[name], + dataClass = dataClasses[name], + interfaceFq = interfaces[name], + classFq = classes[name], + isCommonDataClass = name in commonDataClassNames, + ) + return info.takeUnless { it == UNKNOWN } + } + + companion object { + private val UNKNOWN = SymbolInfo(null, null, null, null, false) + + /** Builds the table from `(prescan, isCommon)` pairs, one per source file, in merge order. */ + fun build(files: List, Boolean>>): SymbolTable { + val enums = LinkedHashMap() + val interfaces = LinkedHashMap() + val classes = LinkedHashMap() + val rawDataClasses = LinkedHashMap() + val commonDataClassNames = LinkedHashSet() + + for ((decls, isCommon) in files) { + val dataClassesBefore = rawDataClasses.keys.toSet() + for (decl in decls) { + when (decl.kind) { + DeclKind.ENUM -> enums[decl.name] = decl.fqName + DeclKind.DATA_CLASS -> rawDataClasses[decl.name] = decl + DeclKind.INTERFACE -> interfaces[decl.name] = decl.fqName + DeclKind.CLASS -> classes[decl.name] = decl.fqName + } + } + if (isCommon) commonDataClassNames += rawDataClasses.keys - dataClassesBefore + } + + // Resolve data class fields iteratively (a data class may nest another one) + val dataClasses = LinkedHashMap() + val scope = object : TypeScope { + override fun enumFq(name: String) = enums[name] + override fun dataClass(name: String) = dataClasses[name] + override fun interfaceFq(name: String): String? = null + override fun classFq(name: String) = classes[name] + } + var changed = true + while (changed) { + changed = false + for ((name, decl) in rawDataClasses) { + if (name in dataClasses) continue + val fields = resolveDataClassFields(decl.dataParams, scope) ?: continue + dataClasses[name] = decl.fqName to fields + changed = true + } + } + // Unresolvable data classes are exported as regular classes + for ((name, decl) in rawDataClasses) { + if (name !in dataClasses) classes[name] = decl.fqName + } + + return SymbolTable(enums, dataClasses, interfaces, classes, commonDataClassNames) + } + + private fun resolveDataClassFields(params: List?, scope: TypeScope): List? { + params ?: return null + val fields = mutableListOf() + for (param in params) { + if (!param.isProperty) continue + val name = param.name ?: return null + val type = TypeResolver.resolve(param.type, scope) ?: return null + fields.add(KneParam(name, type)) + } + return fields.ifEmpty { null } + } + } +} + +/** [TypeScope] over a [SymbolTable] that records every name it is asked about. */ +class RecordingScope(private val table: SymbolTable) : TypeScope { + + private val recorded = LinkedHashMap() + + /** Snapshot of every looked-up name and what it resolved to. */ + val lookups: Map get() = recorded + + fun info(name: String): SymbolInfo? = + if (name in recorded) recorded[name] else table.info(name).also { recorded[name] = it } + + override fun enumFq(name: String) = info(name)?.enumFq + override fun dataClass(name: String) = info(name)?.dataClass + override fun interfaceFq(name: String) = info(name)?.interfaceFq + override fun classFq(name: String) = info(name)?.classFq + fun isCommonDataClass(name: String) = info(name)?.isCommonDataClass == true +} + +object TypeResolver { + + private val CALLBACK_PRIMITIVES = setOf( + KneType.INT, KneType.LONG, KneType.DOUBLE, KneType.FLOAT, + KneType.BOOLEAN, KneType.BYTE, KneType.SHORT, KneType.STRING, + ) + + fun resolve(type: TypeSyntax?, scope: TypeScope): KneType? { + if (type is TypeSyntax.Nullable) { + val inner = resolveElement(type.inner, scope) ?: return null + return if (inner == KneType.UNIT || inner is KneType.NULLABLE) inner else KneType.NULLABLE(inner) + } + return resolveElement(type, scope) + } + + private fun resolveElement(type: TypeSyntax?, scope: TypeScope): KneType? = when (type) { + is TypeSyntax.Function -> resolveFunction(type, scope) + is TypeSyntax.User -> resolveUser(type, scope) + else -> null + } + + private fun resolveFunction(type: TypeSyntax.Function, scope: TypeScope): KneType? { + val paramTypes = type.params.mapNotNull { resolve(it, scope) } + val returnType = resolve(type.returnType, scope) ?: KneType.UNIT + fun ok(t: KneType) = t in CALLBACK_PRIMITIVES || t == KneType.BYTE_ARRAY || t is KneType.DATA_CLASS || + t is KneType.ENUM || t is KneType.OBJECT || t is KneType.INTERFACE || + t is KneType.LIST || t is KneType.SET || t is KneType.MAP + if (paramTypes.any { !ok(it) } || !(ok(returnType) || returnType == KneType.UNIT)) return null + return KneType.FUNCTION(paramTypes, returnType) + } + + private fun resolveUser(type: TypeSyntax.User, scope: TypeScope): KneType? { + val name = type.name + val typeArgs = type.args.mapNotNull { resolve(it, scope) } + return when (name) { + "Int" -> KneType.INT; "Long" -> KneType.LONG; "Double" -> KneType.DOUBLE; "Float" -> KneType.FLOAT + "Boolean" -> KneType.BOOLEAN; "Byte" -> KneType.BYTE; "Short" -> KneType.SHORT + "String" -> KneType.STRING; "ByteArray" -> KneType.BYTE_ARRAY; "Unit" -> KneType.UNIT + "List", "MutableList" -> if (typeArgs.size == 1) KneType.LIST(typeArgs[0]) else null + "Set", "MutableSet" -> if (typeArgs.size == 1) KneType.SET(typeArgs[0]) else null + "Map", "MutableMap" -> if (typeArgs.size == 2) KneType.MAP(typeArgs[0], typeArgs[1]) else null + "Flow" -> if (typeArgs.size == 1) KneType.FLOW(typeArgs[0]) else null + else -> scope.enumFq(name)?.let { KneType.ENUM(it, name) } + ?: scope.dataClass(name)?.let { KneType.DATA_CLASS(it.first, name, it.second) } + ?: scope.interfaceFq(name)?.let { KneType.INTERFACE(it, name) } + ?: scope.classFq(name)?.let { KneType.OBJECT(it, name) } + } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputs.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputs.kt new file mode 100644 index 0000000..0cdbcd8 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputs.kt @@ -0,0 +1,60 @@ +package dev.nucleusframework.nna.plugin.codegen + +import dev.nucleusframework.nna.plugin.analysis.sha256 +import java.io.File + +/** + * Keeps output directories in sync with generated content: only files whose content changed are + * rewritten and stale files are deleted, so downstream incremental compilation only sees real changes. + * + * Outputs are grouped by root name (`root name → relative path → content`); hashes are keyed `root/relative/path`. + */ +object GeneratedOutputs { + + class SyncResult(val written: Int, val deleted: Int, val hashes: Map) + + /** SHA-256 of every file currently present under [roots]. */ + fun hashes(roots: Map): Map = + roots.flatMap { (rootName, dir) -> + dir.regularFiles().map { (path, file) -> "$rootName/$path" to sha256(file.readBytes()) } + }.toMap() + + fun sync(roots: Map, outputs: Map>): SyncResult { + var written = 0 + var deleted = 0 + val hashes = LinkedHashMap() + + for ((rootName, dir) in roots) { + dir.mkdirs() + val expected = outputs[rootName].orEmpty() + val existing = dir.regularFiles() + + for ((path, file) in existing) { + if (path !in expected) { + file.delete() + deleted++ + } + } + for ((path, content) in expected.toSortedMap()) { + val bytes = content.toByteArray() + val file = dir.resolve(path) + if (existing[path]?.readBytes()?.contentEquals(bytes) != true) { + file.parentFile.mkdirs() + file.writeBytes(bytes) + written++ + } + hashes["$rootName/$path"] = sha256(bytes) + } + if (deleted > 0) dir.deleteEmptyDirectories() + } + return SyncResult(written, deleted, hashes) + } + + private fun File.regularFiles(): Map = + if (!isDirectory) emptyMap() + else walkTopDown().filter { it.isFile }.associateBy { it.relativeTo(this).invariantSeparatorsPath } + + private fun File.deleteEmptyDirectories() { + walkBottomUp().filter { it != this && it.isDirectory && it.list().isNullOrEmpty() }.forEach { it.delete() } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt index 8a6f1ab..7c3156e 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt @@ -96,27 +96,42 @@ data class KneParam( ) : Serializable sealed class KneType : Serializable { - object INT : KneType() - object LONG : KneType() - object DOUBLE : KneType() - object FLOAT : KneType() - object BOOLEAN : KneType() - object BYTE : KneType() - object SHORT : KneType() - object STRING : KneType() - object UNIT : KneType() + data object INT : KneType() + data object LONG : KneType() + data object DOUBLE : KneType() + data object FLOAT : KneType() + data object BOOLEAN : KneType() + data object BYTE : KneType() + data object SHORT : KneType() + data object STRING : KneType() + data object UNIT : KneType() data class OBJECT(val fqName: String, val simpleName: String) : KneType() data class INTERFACE(val fqName: String, val simpleName: String) : KneType() data class ENUM(val fqName: String, val simpleName: String) : KneType() data class NULLABLE(val inner: KneType) : KneType() data class FUNCTION(val paramTypes: List, val returnType: KneType) : KneType() data class DATA_CLASS(val fqName: String, val simpleName: String, val fields: List) : KneType() - object BYTE_ARRAY : KneType() + data object BYTE_ARRAY : KneType() data class LIST(val elementType: KneType) : KneType() data class SET(val elementType: KneType) : KneType() data class MAP(val keyType: KneType, val valueType: KneType) : KneType() data class FLOW(val elementType: KneType) : KneType() + /** Preserves singleton identity when deserialized from the incremental cache. */ + protected fun readResolve(): Any = when (this) { + INT -> INT + LONG -> LONG + DOUBLE -> DOUBLE + FLOAT -> FLOAT + BOOLEAN -> BOOLEAN + BYTE -> BYTE + SHORT -> SHORT + STRING -> STRING + UNIT -> UNIT + BYTE_ARRAY -> BYTE_ARRAY + else -> this + } + /** The FFM ValueLayout constant name for this type. */ val ffmLayout: String get() = when (this) { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/tasks/GenerateNativeBridgesTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/tasks/GenerateNativeBridgesTask.kt index 7f1744d..0b415d8 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/tasks/GenerateNativeBridgesTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/tasks/GenerateNativeBridgesTask.kt @@ -4,14 +4,23 @@ import dev.nucleusframework.nna.plugin.analysis.PsiParseWorkAction import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileCollection import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.api.tasks.* -import org.gradle.work.DisableCachingByDefault import org.gradle.workers.WorkerExecutor +import java.io.File +import java.security.MessageDigest import javax.inject.Inject -@DisableCachingByDefault(because = "Bridge generation depends on source analysis that is not yet cacheable") +/** + * Generates native bridges, JVM proxies and GraalVM metadata from the native sources. + * + * Cacheable: outputs depend only on the (relative) source contents, the PSI classpath and the + * configuration inputs. Between non-cached executions, the worker keeps a per-source-file IR cache in + * [taskCacheDir] so that only changed source files are re-parsed and only changed outputs are rewritten. + */ +@CacheableTask abstract class GenerateNativeBridgesTask : DefaultTask() { @get:Inject @@ -22,10 +31,12 @@ abstract class GenerateNativeBridgesTask : DefaultTask() { @get:InputFiles @get:SkipWhenEmpty + @get:IgnoreEmptyDirectories @get:PathSensitive(PathSensitivity.RELATIVE) abstract val taskNativeSources: ConfigurableFileCollection @get:InputFiles + @get:IgnoreEmptyDirectories @get:PathSensitive(PathSensitivity.RELATIVE) abstract val taskCommonSources: ConfigurableFileCollection @@ -47,23 +58,24 @@ abstract class GenerateNativeBridgesTask : DefaultTask() { @get:OutputDirectory abstract val taskJvmResourcesDir: DirectoryProperty + /** Incremental parse state; never stored in the build cache and wiped when outputs come from it. */ + @get:LocalState + abstract val taskCacheDir: DirectoryProperty + @TaskAction fun generate() { - taskOutputDir.get().asFile.apply { deleteRecursively(); mkdirs() } - taskJvmOutputDir.get().asFile.apply { deleteRecursively(); mkdirs() } - - val ktFiles = taskNativeSources.asFileTree.filter { it.extension == "kt" }.files + val ktFiles = taskNativeSources.orderedKotlinFiles() if (ktFiles.isEmpty()) { + listOf(taskOutputDir, taskJvmOutputDir, taskJvmResourcesDir).forEach { + it.get().asFile.apply { deleteRecursively(); mkdirs() } + } logger.lifecycle("kne: No Kotlin sources found, skipping."); return } + val commonKtFiles = taskCommonSources.orderedKotlinFiles() - val commonKtFiles = taskCommonSources.asFileTree.filter { it.extension == "kt" }.files - logger.lifecycle("kne: Parsing ${ktFiles.size} native + ${commonKtFiles.size} common source file(s) [PSI]...") - - val pluginJarUrl = PsiParseWorkAction::class.java.protectionDomain?.codeSource?.location - val pluginJar = taskObjectFactory.fileCollection().apply { - pluginJarUrl?.let { from(java.io.File(it.toURI())) } - } + val pluginLocation = PsiParseWorkAction::class.java.protectionDomain?.codeSource?.location + ?.let { File(it.toURI()) } + val pluginJar = taskObjectFactory.fileCollection().apply { pluginLocation?.let { from(it) } } val workQueue = taskWorkerExecutor.classLoaderIsolation { classpath.from(taskPsiClasspath) @@ -71,13 +83,50 @@ abstract class GenerateNativeBridgesTask : DefaultTask() { } workQueue.submit(PsiParseWorkAction::class.java) { - nativeSourceFiles.from(ktFiles) + sourceFiles.from(commonKtFiles + ktFiles) commonSourceFiles.from(commonKtFiles) libName.set(taskLibName) jvmPackage.set(taskJvmPackage) nativeBridgesDir.set(taskOutputDir) jvmProxiesDir.set(taskJvmOutputDir) jvmResourcesDir.set(taskJvmResourcesDir) + cacheFile.set(taskCacheDir.file("parse-cache.bin")) + cacheKey.set(cacheKey(pluginLocation)) + } + } + + /** + * Kotlin files in a stable order: roots in declaration order, files sorted by relative path within + * each root. The merge order decides which duplicate declaration wins and the package order, so + * it must not depend on file system traversal order. + */ + private fun FileCollection.orderedKotlinFiles(): List = files.flatMap { root -> + when { + root.isFile -> listOf(root).filter { it.extension == "kt" } + root.isDirectory -> root.walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .sortedBy { it.relativeTo(root).invariantSeparatorsPath } + .toList() + else -> emptyList() + } + }.distinct() + + /** Identifies the plugin build and PSI classpath that produce the incremental cache. */ + private fun cacheKey(pluginLocation: File?): String { + val digest = MessageDigest.getInstance("SHA-256") + fun update(value: Any?) = digest.update("$value\n".toByteArray()) + update(CACHE_FORMAT_VERSION) + val pluginFiles = when { + pluginLocation == null -> emptySequence() + pluginLocation.isDirectory -> pluginLocation.walkTopDown().filter { it.isFile }.sortedBy { it.path } + else -> sequenceOf(pluginLocation) } + (pluginFiles + taskPsiClasspath.files.asSequence()).forEach { update("${it.path}:${it.length()}:${it.lastModified()}") } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + private companion object { + /** Bump when the cache layout or the parsing semantics change. */ + const val CACHE_FORMAT_VERSION = 1 } } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParserIncrementalTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParserIncrementalTest.kt new file mode 100644 index 0000000..ca53af6 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParserIncrementalTest.kt @@ -0,0 +1,155 @@ +package dev.nucleusframework.nna.plugin.analysis + +import dev.nucleusframework.nna.plugin.ir.KneType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class PsiSourceParserIncrementalTest { + + @get:Rule + val tmp = TemporaryFolder() + + private val parser = PsiSourceParser() + + private fun source(name: String, content: String, isCommon: Boolean = false): PsiSourceParser.Source { + val file = File(tmp.root, name).apply { parentFile.mkdirs(); writeText(content.trimIndent()) } + return PsiSourceParser.Source(file, isCommon) + } + + private fun parse(sources: List, previous: PsiSourceParser.Result? = null) = + parser.parseIncremental(sources, "demo", previous?.files.orEmpty()) + + /** An incremental result must always be identical to a from-scratch parse. */ + private fun assertMatchesFullParse(sources: List, result: PsiSourceParser.Result) { + val full = parse(sources).module + assertEquals(full, result.module) + assertEquals(full.packages.toList(), result.module.packages.toList()) + } + + @Test + fun `unchanged sources are not re-parsed`() { + val sources = listOf( + source("A.kt", "package demo\nclass A { fun b(): B = B() }"), + source("B.kt", "package demo\nclass B { fun x(): Int = 1 }"), + ) + val first = parse(sources) + assertEquals(2, first.parsedFiles) + + val second = parse(sources, first) + assertEquals(0, second.parsedFiles) + assertEquals(first.module, second.module) + } + + @Test + fun `body-only edit re-parses only the edited file`() { + val a = source("A.kt", "package demo\nclass A { fun b(): B = B() }") + val b = source("B.kt", "package demo\nclass B { fun x(): Int = 1 }") + val first = parse(listOf(a, b)) + + b.file.writeText("package demo\nclass B { fun x(): Int = 42 }") + val second = parse(listOf(a, b), first) + + assertEquals(1, second.parsedFiles) + assertMatchesFullParse(listOf(a, b), second) + } + + @Test + fun `adding a referenced type re-parses dependent files`() { + val a = source("A.kt", "package demo\nclass A { fun widget(): Widget? = null }") + val other = source("Other.kt", "package demo\nclass Other { fun x(): Int = 1 }") + val first = parse(listOf(a, other)) + val methodsBefore = first.module.classes.single { it.simpleName == "A" }.methods + assertTrue("Unknown types are dropped", methodsBefore.none { it.name == "widget" && it.returnType != KneType.UNIT }) + + val widget = source("Widget.kt", "package demo\nclass Widget") + val sources = listOf(a, other, widget) + val second = parse(sources, first) + + // Widget.kt is new and A.kt looked up `Widget`; Other.kt is untouched + assertEquals(2, second.parsedFiles) + val widgetFn = second.module.classes.single { it.simpleName == "A" }.methods.single { it.name == "widget" } + assertEquals(KneType.NULLABLE(KneType.OBJECT("demo.Widget", "Widget")), widgetFn.returnType) + assertMatchesFullParse(sources, second) + } + + @Test + fun `data class field change propagates to users of the data class`() { + val point = source("Point.kt", "package demo\ndata class Point(val x: Int, val y: Int)") + val user = source("User.kt", "package demo\nclass Canvas { fun draw(p: Point) {} }") + val first = parse(listOf(point, user)) + + point.file.writeText("package demo\ndata class Point(val x: Int, val y: Int, val z: Int)") + val second = parse(listOf(point, user), first) + + assertEquals(2, second.parsedFiles) + val param = second.module.classes.single().methods.single().params.single().type as KneType.DATA_CLASS + assertEquals(listOf("x", "y", "z"), param.fields.map { it.name }) + assertMatchesFullParse(listOf(point, user), second) + } + + @Test + fun `removing a file drops its declarations`() { + val a = source("A.kt", "package demo\nclass A") + val b = source("B.kt", "package demo\nclass B { fun a(): A = A() }") + val first = parse(listOf(a, b)) + + val second = parse(listOf(b), first) + assertEquals(listOf("B"), second.module.classes.map { it.simpleName }) + assertTrue("B looked up A, so it must be re-parsed", second.parsedFiles == 1) + assertMatchesFullParse(listOf(b), second) + } + + @Test + fun `moving a file between common and native invalidates it`() { + val native = source("Shared.kt", "package demo\ndata class Shared(val v: Int)") + val first = parse(listOf(native)) + assertTrue(first.module.dataClasses.single().isCommon.not()) + + val common = native.copy(isCommon = true) + val second = parse(listOf(common), first) + assertEquals(1, second.parsedFiles) + assertTrue(second.module.dataClasses.single().isCommon) + } + + @Test + fun `cache survives serialization round trip`() { + val sources = listOf( + source("Api.kt", """ + package demo + enum class Mode { A, B } + data class Point(val x: Int, val y: Double) + class Api { + fun flags(): List = emptyList() + fun mode(m: Mode): String = "" + fun point(cb: (Point) -> Unit) {} + } + """), + ) + val first = parse(sources) + val cacheFile = File(tmp.root, "cache/parse-cache.bin") + ParseCache("key", first.files, null).write(cacheFile) + + assertNull(ParseCache.read(cacheFile, "other-key")) + val restored = ParseCache.read(cacheFile, "key")!! + assertEquals(first.files, restored.files) + + val method = restored.files.values.single().fragment.classes.single().methods.first { it.name == "flags" } + assertSame(KneType.BOOLEAN, (method.returnType as KneType.LIST).elementType) + + val second = parser.parseIncremental(sources, "demo", restored.files) + assertEquals(0, second.parsedFiles) + assertEquals(first.module, second.module) + } + + @Test + fun `corrupt cache is ignored`() { + val cacheFile = File(tmp.root, "parse-cache.bin").apply { writeText("garbage") } + assertNull(ParseCache.read(cacheFile, "key")) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputsTest.kt new file mode 100644 index 0000000..0318e23 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/GeneratedOutputsTest.kt @@ -0,0 +1,33 @@ +package dev.nucleusframework.nna.plugin.codegen + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class GeneratedOutputsTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `only changed files are rewritten and stale files are deleted`() { + val root = tmp.newFolder("out") + val roots = mapOf("proxies" to root) + + val first = GeneratedOutputs.sync(roots, mapOf("proxies" to mapOf("a/A.kt" to "A", "a/B.kt" to "B", "c/C.kt" to "C"))) + assertEquals(3, first.written) + val untouched = File(root, "a/A.kt").apply { setLastModified(1_000) } + + val second = GeneratedOutputs.sync(roots, mapOf("proxies" to mapOf("a/A.kt" to "A", "a/B.kt" to "B2"))) + assertEquals(1, second.written) + assertEquals(1, second.deleted) + assertEquals(1_000, untouched.lastModified()) + assertEquals("B2", File(root, "a/B.kt").readText()) + assertFalse("Empty directories are pruned", File(root, "c").exists()) + + assertEquals(second.hashes, GeneratedOutputs.hashes(roots)) + } +}