Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class KotlinNativeExportPlugin : Plugin<Project> {
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),
Expand Down Expand Up @@ -119,6 +120,7 @@ class KotlinNativeExportPlugin : Plugin<Project> {
taskOutputDir.set(nativeBridgesDir)
taskJvmOutputDir.set(jvmProxiesDir)
taskJvmResourcesDir.set(jvmResourcesDir)
taskCacheDir.set(incrementalCacheDir)
taskPsiClasspath.from(psiClasspath)
}
// Keep old task name as alias
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KneClass>,
val dataClasses: List<KneDataClass>,
val enums: List<KneEnum>,
/** Keyed by function name, or `Receiver.name` for extension functions. */
val functions: List<Pair<String, KneFunction>>,
val interfaces: List<KneInterface>,
) : 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<PrescanDecl>,
val fragment: FileFragment,
val lookups: Map<String, SymbolInfo?>,
) : Serializable

/** IR → generated files mapping: the module that produced [outputHashes] (`root/relative/path` → SHA-256). */
data class CodegenSnapshot(
val module: KneModule,
val packageOrder: List<String>,
val jvmPackage: String,
val outputHashes: Map<String, String>,
) : 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<String, CachedSourceFile>,
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) }
Original file line number Diff line number Diff line change
@@ -1,77 +1,124 @@
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

/**
* 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<PsiParseWorkAction.Params> {

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<String>
val jvmPackage: Property<String>
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<String>
}

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<String, Map<String, String>> {
val outputs = mapOf(
NATIVE_BRIDGES to LinkedHashMap<String, String>(),
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
}

/**
* Generates GraalVM reachability metadata under META-INF/native-image/kne/.
* Includes reflection config, resource config, and FFM foreign downcall/upcall descriptors.
*/
private fun generateGraalVmMetadata(
resourcesRoot: java.io.File,
resources: MutableMap<String, String>,
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<String>()
Expand All @@ -96,17 +143,17 @@ abstract class PsiParseWorkAction : WorkAction<PsiParseWorkAction.Params> {
"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<Pair<List<String>, String?>>): String =
Expand All @@ -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 }""" }}
],
Expand All @@ -143,6 +190,12 @@ $downcallEntries
]$upcallSection
}
}
""")
"""
}

private companion object {
const val NATIVE_BRIDGES = "nativeBridges"
const val JVM_PROXIES = "jvmProxies"
const val JVM_RESOURCES = "jvmResources"
}
}
Loading
Loading