From 0b519fbef8f5b828fdbc14ac3a382a7f5d83402a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:33:44 +0000 Subject: [PATCH 01/19] Add Actualizer: a Kotlin IR plugin for cross-Gradle-module expect/actual Lets an expect declaration in one Gradle module be actualized by a real actual in a genuinely separate, independently built Gradle module - not part of the same multiplatform source-set hierarchy - by merging the foreign module's source files into the actual-providing leaf module's compilation (-Xmulti-platform + -Xcommon-sources) so the real Kotlin frontend performs the linking, then running an IrGenerationExtension on that merged compilation to report cross-module provenance for every @CrossModuleExpect-annotated link. Includes a composite-build compiler plugin + Gradle plugin (actualizer { actualizes(project(...)) } DSL) and a four-module sample (api / feature-common / actual-jvm / app) that was built and run end-to-end, including the negative path where a missing actual fails the build at compile time. --- .gitignore | 9 + README.md | 165 ++++++++++++++++++ actualizer-annotations/build.gradle.kts | 16 ++ .../annotations/CrossModuleExpect.kt | 17 ++ build.gradle.kts | 4 + gradle.properties | 3 + plugin-build/compiler-plugin/build.gradle.kts | 18 ++ .../ActualizerCommandLineProcessor.kt | 66 +++++++ .../ActualizerCompilerPluginRegistrar.kt | 40 +++++ .../compiler/ir/ActualizerIrExtension.kt | 129 ++++++++++++++ ...otlin.compiler.plugin.CommandLineProcessor | 1 + ...in.compiler.plugin.CompilerPluginRegistrar | 1 + plugin-build/gradle-plugin/build.gradle.kts | 26 +++ .../actualizer/gradle/ActualizerExtension.kt | 18 ++ .../gradle/ActualizerGradlePlugin.kt | 146 ++++++++++++++++ plugin-build/settings.gradle.kts | 18 ++ sample/actual-jvm/build.gradle.kts | 21 +++ .../com/modularkmp/sample/api/Actual.kt | 9 + sample/api/build.gradle.kts | 25 +++ .../com/modularkmp/sample/api/Greeting.kt | 13 ++ sample/app/build.gradle.kts | 22 +++ .../kotlin/com/modularkmp/sample/app/Main.kt | 7 + sample/feature-common/build.gradle.kts | 22 +++ .../com/modularkmp/sample/feature/Feature.kt | 5 + settings.gradle.kts | 26 +++ 25 files changed, 827 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 actualizer-annotations/build.gradle.kts create mode 100644 actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 plugin-build/compiler-plugin/build.gradle.kts create mode 100644 plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt create mode 100644 plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt create mode 100644 plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt create mode 100644 plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor create mode 100644 plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar create mode 100644 plugin-build/gradle-plugin/build.gradle.kts create mode 100644 plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt create mode 100644 plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt create mode 100644 plugin-build/settings.gradle.kts create mode 100644 sample/actual-jvm/build.gradle.kts create mode 100644 sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt create mode 100644 sample/api/build.gradle.kts create mode 100644 sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt create mode 100644 sample/app/build.gradle.kts create mode 100644 sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt create mode 100644 sample/feature-common/build.gradle.kts create mode 100644 sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0256a99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +plugin-build/.gradle/ +plugin-build/*/build/ +.kotlin/ +*.iml +.idea/ +out/ +kotlin-js-store/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..07b8b81 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# Actualizer + +A Kotlin IR compiler plugin (+ a Gradle plugin to drive it) that lets an `expect` declaration in +one Gradle module be actualized by a real `actual` declaration living in a **genuinely separate, +independently built Gradle module** - one that is not wired into the same +`kotlin { multiplatform { ... } }` source-set hierarchy as the expect. + +``` +:sample:api expect fun greetingSuffix(): String (no actual anywhere in here) +:sample:feature-common ordinary dependency on :sample:api's jar (no actual needed yet either) +:sample:actual-jvm actual fun greetingSuffix(): String = "..." (unrelated Gradle module) +:sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked function) +``` + +`:sample:actual-jvm` never declares a dependency on `:sample:api`. `:sample:app` never even knows +`:sample:api` exists. Running `:sample:app:run` prints: + +``` +Hello, world! (actualized independently by :sample:actual-jvm) +``` + +## Why this needs a plugin at all + +Stock Kotlin only resolves `expect`/`actual` within a single compiler invocation that has +explicitly been told which of its input files are "common" and which are "platform" - that +wiring is normally generated by the Kotlin Gradle Plugin from inside one `kotlin { }` block, and +it has no notion of doing this across unrelated Gradle projects. + +There is fundamentally **no way to patch this after the fact**: once `:sample:api` is compiled to +bytecode, no compiler plugin running in some other module's compilation can reach back in and +rewrite already-emitted `.class` files to redirect a call. So "literal `expect`/`actual` across +Gradle modules" can only work if the modules' **source files** end up merged into one compiler +invocation - there is no alternative that doesn't abandon the real keyword. + +## How it actually works + +**1. The "common" module gets a real, standalone artifact for free.** +`:sample:api` is `kotlin("multiplatform")` with two declared targets (`jvm()` and +`js(IR)`). With two or more targets, Kotlin compiles `commonMain` to its own intermediate +metadata/klib artifact (`compileKotlinMetadata` / `allMetadataJar`) - and that compilation +**allows unfulfilled `expect`s** (that's the entire point of the metadata step: it only checks +that the expect declarations are well-formed, deferring actual-matching to each platform +compilation). `:sample:feature-common` depends on that jar exactly like any other project +dependency and compiles fine with zero actuals in sight - proving common code can be built on +top of a not-yet-actualized API. (With only *one* declared target, Kotlin skips the separate +metadata step and folds `commonMain` directly into that target's compile, which then demands an +actual immediately - hence two targets, even though only `jvm()` is exercised in this demo.) + +**2. The actual linking happens in the leaf module, via source merging.** +`:sample:actual-jvm` is a plain `kotlin("jvm")` module with `id("com.modularkmp.actualizer")` +applied and: +```kotlin +actualizer { + actualizes(project(":sample:api")) +} +``` +The Actualizer Gradle plugin pulls `:sample:api`'s `commonMain` source *directory* (not its +compiled jar - the jar can't be built for JVM anyway, since it has no jvm actual) into +`:sample:actual-jvm`'s own `main` source set, and adds two compiler flags to its `compileKotlin` +task: +``` +-Xmulti-platform +-Xcommon-sources= +``` +This is the same mechanism the Kotlin Gradle Plugin itself uses under the hood for JVM +multiplatform targets - "common" and "platform" sources compiled together in one invocation, with +`-Xcommon-sources` marking which of them are common. Since it's all one real compiler invocation, +the actual Kotlin frontend does the actual/expect unification - not this plugin. This is real, +but it does lean on `-Xcommon-sources` / `-Xmulti-platform`, which are internal/undocumented +compiler flags not officially supported for this kind of use outside the Kotlin Gradle Plugin +itself; they could change behavior between Kotlin versions. This repo pins Kotlin `2.0.21`, +where the mechanism was verified to work exactly as described (see "What was verified" below). + +**3. The IR plugin reports on what got linked (and enforces an opt-in policy).** +`ActualizerIrExtension` (an `IrGenerationExtension`) runs inside that merged compilation. By the +time it runs, the frontend has *already* resolved (or already failed the build over) every +expect/actual pair - IR generation only happens after a successful frontend pass, so there is no +"unlinked" state left for an `IrGenerationExtension` to fix. Its job is everything the raw +mechanism *doesn't* give you for free: +- Only declarations annotated `@CrossModuleExpect` (from `actualizer-annotations`) are treated as + cross-module links at all - merging in a foreign source directory doesn't mean every + declaration in it is fair game, just the ones that opted in. +- It attaches **module provenance**: the frontend only knows about files, not Gradle modules. The + plugin walks the merged `IrModuleFragment`, matches each `actual`'s package against the + packages of files pulled in from each foreign module (Gradle module names are supplied via a + `moduleMap` compiler-plugin option built by the Gradle plugin), and writes a JSON report to + `build/actualizer/report.json`: + ```json + { "consumingModule": ":sample:actual-jvm", + "links": [ { "actual": "com.modularkmp.sample.api.greetingSuffix", "owningModules": [":sample:api"] } ] } + ``` + (Note: once expect/actual are resolved, the `expect` declaration itself is elided from IR - + only the `actual` survives as a real `IrFunction`. The plugin correlates by package rather than + by pairing "expect IR" with "actual IR", since the former doesn't exist to pair against by the + time `IrGenerationExtension.generate()` runs.) + +**4. `:sample:app` needs nothing special.** It has an ordinary +`implementation(project(":sample:actual-jvm"))` dependency and calls `greet()` normally. It never +applies the Actualizer plugin and never references `:sample:api`. + +## What was verified + +Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 +/ JDK 21, not just designed on paper: + +- `./gradlew :sample:api:compileKotlinMetadata :sample:api:allMetadataJar` - succeeds standalone + with an unfulfilled `expect`, producing a real, non-empty metadata jar. +- `./gradlew :sample:feature-common:compileKotlinMetadata :sample:feature-common:allMetadataJar` - + succeeds depending only on `:sample:api`'s jar, before any actual exists anywhere. +- `./gradlew :sample:actual-jvm:build` - merges `:sample:api`'s source and links it against the + real actual; `build/actualizer/report.json` shows `status: linked` with correct module + provenance. +- `./gradlew :sample:app:run` - prints the value produced by `:sample:actual-jvm`'s `actual`, + with zero special wiring in `:app` itself. +- **Negative path**: deleting the `actual` declaration from `:sample:actual-jvm` and re-running + `compileKotlin` fails the build with a real Kotlin frontend error pointing at the exact expect + declaration (`Expected greetingSuffix has no actual declaration in module -common + for JVM`) - a missing cross-module actual is a compile-time failure, not a silent runtime gap. +- A full clean build of the whole graph (`gradle --stop`, delete all `build/`/`.gradle/`, rebuild + from scratch) reproduces all of the above. + +## Repo layout + +``` +actualizer-annotations/ @CrossModuleExpect marker (kotlin("multiplatform"), metadata-only) +plugin-build/ composite build (keeps the plugin's own Kotlin version pinned + independently of consumers, standard Kotlin-compiler-plugin layout) + compiler-plugin/ the IR compiler plugin itself (CommandLineProcessor, + CompilerPluginRegistrar, ActualizerIrExtension) + gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) } DSL, + source-directory merging, -Xcommon-sources/-Xmulti-platform wiring +sample/ + api/ expect-only module, its own standalone metadata artifact + feature-common/ depends on api's jar like any common module would + actual-jvm/ applies the plugin; the real cross-module link happens here + app/ plain binary consumer of actual-jvm, no special wiring +``` + +## Known limitations + +- **JVM only.** The `-Xcommon-sources` source-merge trick was only verified for the JVM target. + Native/JS multiplatform targets link expect/actual via klib "refines" edges instead, which is a + different (and, from spiking this, meaningfully more internal/fragile) mechanism this repo does + not attempt. +- **Source-only, not binary.** The Gradle plugin needs the foreign module's Kotlin *source files* + on disk; it cannot actualize against a module that only ships compiled klibs/jars for its + common code (which is also just inherent to how `-Xcommon-sources` works - it takes source + paths). +- **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` are not a + supported public API for third-party use; a future Kotlin release could change or remove this + behavior without notice. This is the tradeoff of keeping the literal `expect`/`actual` keywords + working across Gradle modules, as opposed to an annotation-based runtime-dispatch design (which + would be more stable but wouldn't use the real keywords). +- **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin` + deliberately avoids importing Kotlin Gradle Plugin types (`KotlinMultiplatformExtension`, + `KotlinCompile`, etc.) and uses reflection-by-name instead - `plugin-build` resolves its own + copy of `kotlin-gradle-plugin` to compile against, which ends up a different `Class` instance + than the one the consuming build's `plugins { kotlin(...) }` loads, so direct + `extensions.findByType(...)` / `tasks.withType(...)` silently find nothing across that + boundary. This is a real, somewhat unusual wrinkle of the composite-build layout, documented in + code comments in `ActualizerGradlePlugin.kt`. +- One-actual-per-expect only; no support for choosing between multiple candidate actual-providing + modules (e.g. per build flavor) - `actualizes(...)` merges in all of them unconditionally, and + the *last* declaration compiled for a given fully-qualified name wins/conflicts per ordinary + Kotlin rules. diff --git a/actualizer-annotations/build.gradle.kts b/actualizer-annotations/build.gradle.kts new file mode 100644 index 0000000..585ab6d --- /dev/null +++ b/actualizer-annotations/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + kotlin("multiplatform") +} + +group = "com.modularkmp.actualizer" +version = "0.1.0" + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) + jvm() + js(IR) { nodejs() } +} diff --git a/actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt b/actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt new file mode 100644 index 0000000..80f5756 --- /dev/null +++ b/actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt @@ -0,0 +1,17 @@ +package com.modularkmp.actualizer.annotations + +/** + * Opt-in marker for an `expect` declaration that is meant to be actualized by a real `actual` + * living in a *different, independently built* Gradle module - one that is not part of this + * declaration's own multiplatform source-set hierarchy. + * + * The Actualizer Gradle plugin merges the source file this annotation appears in directly into + * the compilation of whichever module calls `actualizer { actualizes(project(...)) }`, so the + * real Kotlin compiler frontend performs the expect/actual resolution. This annotation exists + * so the Actualizer IR plugin only reports on (and, in policy mode, only allows) declarations + * that were deliberately opted into cross-module actualization, rather than silently treating + * every merged-in `expect` the same way. + */ +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.BINARY) +annotation class CrossModuleExpect diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..f95c995 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + kotlin("jvm") version "2.0.21" apply false + kotlin("multiplatform") version "2.0.21" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..d5fd5a7 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2g +kotlinVersion=2.0.21 diff --git a/plugin-build/compiler-plugin/build.gradle.kts b/plugin-build/compiler-plugin/build.gradle.kts new file mode 100644 index 0000000..345d030 --- /dev/null +++ b/plugin-build/compiler-plugin/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + kotlin("jvm") version "2.0.21" +} + +group = "com.modularkmp.actualizer" +version = "0.1.0" + +repositories { + mavenCentral() +} + +dependencies { + compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.21") +} + +kotlin { + jvmToolchain(21) +} diff --git a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt b/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt new file mode 100644 index 0000000..de83abf --- /dev/null +++ b/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt @@ -0,0 +1,66 @@ +package com.modularkmp.actualizer.compiler + +import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption +import org.jetbrains.kotlin.compiler.plugin.CliOption +import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.config.CompilerConfigurationKey + +/** + * Reads the `-P plugin:com.modularkmp.actualizer:=` options the Gradle plugin + * passes in and stores them on the [CompilerConfiguration] for [ActualizerCompilerPluginRegistrar] + * to read back out. + */ +@OptIn(ExperimentalCompilerApi::class) +class ActualizerCommandLineProcessor : CommandLineProcessor { + + override val pluginId: String = PLUGIN_ID + + override val pluginOptions: Collection = listOf( + MODULE_MAP_OPTION, + SELF_MODULE_OPTION, + REPORT_OUTPUT_OPTION, + ) + + override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) { + when (option.optionName) { + MODULE_MAP_OPTION.optionName -> configuration.put(KEY_MODULE_MAP, value) + SELF_MODULE_OPTION.optionName -> configuration.put(KEY_SELF_MODULE, value) + REPORT_OUTPUT_OPTION.optionName -> configuration.put(KEY_REPORT_OUTPUT, value) + else -> error("Unexpected Actualizer plugin option: ${option.optionName}") + } + } + + companion object { + const val PLUGIN_ID = "com.modularkmp.actualizer" + + val KEY_MODULE_MAP = CompilerConfigurationKey("moduleMap") + val KEY_SELF_MODULE = CompilerConfigurationKey("selfModule") + val KEY_REPORT_OUTPUT = CompilerConfigurationKey("reportOutput") + + val MODULE_MAP_OPTION = CliOption( + optionName = "moduleMap", + valueDescription = "", + description = "Maps merged-in foreign source roots to the Gradle module that owns them", + required = false, + allowMultipleOccurrences = false, + ) + + val SELF_MODULE_OPTION = CliOption( + optionName = "selfModule", + valueDescription = "", + description = "Name of the Gradle module currently being compiled", + required = false, + allowMultipleOccurrences = false, + ) + + val REPORT_OUTPUT_OPTION = CliOption( + optionName = "reportOutput", + valueDescription = "", + description = "File path to write the cross-module actualization report to", + required = false, + allowMultipleOccurrences = false, + ) + } +} diff --git a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt new file mode 100644 index 0000000..f0fd4b1 --- /dev/null +++ b/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -0,0 +1,40 @@ +package com.modularkmp.actualizer.compiler + +import com.modularkmp.actualizer.compiler.ir.ActualizerIrExtension +import com.modularkmp.actualizer.compiler.ir.ModuleRoot +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CommonConfigurationKeys +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.cli.common.messages.MessageCollector + +@OptIn(ExperimentalCompilerApi::class) +class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { + + override val supportsK2: Boolean = true + + override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + val moduleMap = parseModuleMap(configuration.get(ActualizerCommandLineProcessor.KEY_MODULE_MAP).orEmpty()) + val selfModule = configuration.get(ActualizerCommandLineProcessor.KEY_SELF_MODULE) ?: "" + val reportOutput = configuration.get(ActualizerCommandLineProcessor.KEY_REPORT_OUTPUT) + val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) + + IrGenerationExtension.registerExtension( + ActualizerIrExtension( + moduleMap = moduleMap, + selfModule = selfModule, + reportOutputPath = reportOutput, + messageCollector = messageCollector, + ) + ) + } + + private fun parseModuleMap(raw: String): List { + if (raw.isBlank()) return emptyList() + return raw.split("||").mapNotNull { entry -> + val parts = entry.split("::", limit = 2) + if (parts.size != 2) null else ModuleRoot(root = parts[0], moduleName = parts[1]) + } + } +} diff --git a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt b/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt new file mode 100644 index 0000000..1e5bf69 --- /dev/null +++ b/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt @@ -0,0 +1,129 @@ +package com.modularkmp.actualizer.compiler.ir + +import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension +import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.ir.declarations.IrDeclarationWithName +import org.jetbrains.kotlin.ir.declarations.IrModuleFragment +import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI +import org.jetbrains.kotlin.ir.util.getPackageFragment +import org.jetbrains.kotlin.ir.util.hasAnnotation +import org.jetbrains.kotlin.name.FqName +import java.io.File + +data class ModuleRoot(val root: String, val moduleName: String) + +private data class LinkEntry( + val actualFqName: String, + val owningModules: List, + val consumingModule: String, +) + +/** + * Runs inside a compilation whose source set has been merged (by the Gradle plugin) from a + * foreign, expect-declaring Gradle module's commonMain and this module's own `actual` + * declarations, compiled together via `-Xmulti-platform` + `-Xcommon-sources`. + * + * By the time this extension runs, the Kotlin frontend has *already* resolved expect/actual for + * this compilation (or the build would already have failed) - this extension does not perform + * the linking itself. Its job is to observe what got linked and attach module provenance the + * frontend has no notion of (it only knows "files", not "Gradle modules"), and emit a + * human/tool-readable report - the piece the raw compiler + Gradle mechanism can't provide on + * its own. + * + * Note: once expect/actual are resolved, the `expect` declaration itself is elided from this + * compilation's IR - only the linked `actual` remains as a real `IrFunction`/`IrClass`. So + * rather than trying to pair up an "expect IR declaration" with an "actual IR declaration" by + * name (the expect side isn't there to find), this walks the *files* that were merged in from + * each foreign module and records their package names, then reports every locally-declared, + * `@CrossModuleExpect`-annotated `actual` whose package matches one of those foreign packages - + * `actual` is required to share its expect's fully-qualified name, so this is a precise, + * non-heuristic correlation. + */ +class ActualizerIrExtension( + private val moduleMap: List, + private val selfModule: String, + private val reportOutputPath: String?, + private val messageCollector: MessageCollector, +) : IrGenerationExtension { + + @OptIn(UnsafeDuringIrConstructionAPI::class) + override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { + val foreignModulesByPackage = mutableMapOf>() + val localAnnotatedActuals = mutableListOf() + + for (file in moduleFragment.files) { + val filePath = file.fileEntry.name + val owningModule = moduleMap.firstOrNull { filePath.startsWith(it.root) }?.moduleName + + if (owningModule != null) { + foreignModulesByPackage.getOrPut(file.packageFqName.asString()) { mutableListOf() } += owningModule + } else { + for (declaration in file.declarations) { + if (declaration is IrDeclarationWithName && declaration.hasAnnotation(CROSS_MODULE_EXPECT_FQ_NAME)) { + localAnnotatedActuals += declaration + } + } + } + } + + val links = localAnnotatedActuals.map { actual -> + val packageName = actual.getPackageFragment()?.packageFqName?.asString().orEmpty() + LinkEntry( + actualFqName = "$packageName.${actual.name.asString()}", + owningModules = foreignModulesByPackage[packageName].orEmpty(), + consumingModule = selfModule, + ) + } + + for (link in links) { + if (link.owningModules.isEmpty()) { + messageCollector.report( + CompilerMessageSeverity.WARNING, + "Actualizer: '${link.actualFqName}' in module '$selfModule' is marked " + + "@CrossModuleExpect but no merged-in foreign source contributed its package - " + + "was it actually meant to actualize something from actualizer { actualizes(...) }?", + null as CompilerMessageSourceLocation?, + ) + } else { + messageCollector.report( + CompilerMessageSeverity.LOGGING, + "Actualizer: '${link.actualFqName}' in module '$selfModule' actualizes an expect " + + "declared in ${link.owningModules}", + null as CompilerMessageSourceLocation?, + ) + } + } + + reportOutputPath?.let { path -> writeReport(path, links) } + } + + private fun writeReport(path: String, links: List) { + val file = File(path) + file.parentFile?.mkdirs() + val json = buildString { + append("{\n") + append(" \"consumingModule\": \"").append(selfModule).append("\",\n") + append(" \"links\": [\n") + links.forEachIndexed { index, link -> + append(" {") + append("\"actual\": \"").append(link.actualFqName).append("\", ") + append("\"owningModules\": [") + append(link.owningModules.joinToString(", ") { "\"$it\"" }) + append("]") + append("}") + if (index != links.lastIndex) append(",") + append("\n") + } + append(" ]\n") + append("}\n") + } + file.writeText(json) + } + + private companion object { + val CROSS_MODULE_EXPECT_FQ_NAME = FqName("com.modularkmp.actualizer.annotations.CrossModuleExpect") + } +} diff --git a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor new file mode 100644 index 0000000..5c4b503 --- /dev/null +++ b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor @@ -0,0 +1 @@ +com.modularkmp.actualizer.compiler.ActualizerCommandLineProcessor diff --git a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar new file mode 100644 index 0000000..25f6843 --- /dev/null +++ b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar @@ -0,0 +1 @@ +com.modularkmp.actualizer.compiler.ActualizerCompilerPluginRegistrar diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts new file mode 100644 index 0000000..1af0467 --- /dev/null +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("jvm") version "2.0.21" + `java-gradle-plugin` +} + +group = "com.modularkmp.actualizer" +version = "0.1.0" + +repositories { + mavenCentral() + gradlePluginPortal() + google() +} + +kotlin { + jvmToolchain(21) +} + +gradlePlugin { + plugins { + create("actualizer") { + id = "com.modularkmp.actualizer" + implementationClass = "com.modularkmp.actualizer.gradle.ActualizerGradlePlugin" + } + } +} diff --git a/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt new file mode 100644 index 0000000..161400b --- /dev/null +++ b/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt @@ -0,0 +1,18 @@ +package com.modularkmp.actualizer.gradle + +import org.gradle.api.Project +import org.gradle.api.model.ObjectFactory +import javax.inject.Inject + +/** + * `actualizer { actualizes(project(":api")) }` DSL applied to a "leaf" JVM module that provides + * real `actual` declarations for `expect`s declared in an unrelated, independently built Gradle + * module. + */ +open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { + internal val actualizedProjects: MutableList = mutableListOf() + + fun actualizes(project: Project) { + actualizedProjects += project + } +} diff --git a/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt new file mode 100644 index 0000000..9e24283 --- /dev/null +++ b/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt @@ -0,0 +1,146 @@ +package com.modularkmp.actualizer.gradle + +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.SourceDirectorySet +import org.gradle.api.provider.ListProperty +import java.io.File + +private const val PLUGIN_ID = "com.modularkmp.actualizer" +private const val COMPILER_PLUGIN_COORDINATES = "com.modularkmp.actualizer:compiler-plugin:0.1.0" + +/** + * Wires a "leaf" JVM module up to merge in the commonMain source files of one or more foreign, + * expect-declaring Gradle modules (declared via `actualizer { actualizes(project(":api")) }`) + * so the real Kotlin compiler frontend resolves `expect`/`actual` across them, and registers + * the Actualizer IR compiler plugin on that same compilation so it can report on what got + * linked. + * + * This deliberately avoids importing Kotlin Gradle Plugin (KGP) types like + * `KotlinMultiplatformExtension` or `KotlinCompile` directly: `plugin-build` resolves its own + * copy of `kotlin-gradle-plugin` (for compiling *this* plugin) which ends up in a different + * classloader than the copy the consuming build resolves via `plugins { kotlin("...") }` - so + * `extensions.findByType(SomeKgpType::class.java)` and `tasks.withType(SomeKgpTaskType::class.java)` + * silently find nothing across that boundary. Reflection-by-name sidesteps it; only genuine + * Gradle-core types (`NamedDomainObjectContainer`, `SourceDirectorySet`, `ListProperty`, which + * are always loaded by Gradle's own shared classloader) are referenced statically. + */ +class ActualizerGradlePlugin : Plugin { + + override fun apply(project: Project) { + val extension = project.extensions.create("actualizer", ActualizerExtension::class.java) + + project.afterEvaluate { + if (extension.actualizedProjects.isEmpty()) { + // Nothing to wire up - this project doesn't merge in any foreign expect module. + return@afterEvaluate + } + wireCrossModuleActualization(project, extension) + } + } + + private fun wireCrossModuleActualization(project: Project, extension: ActualizerExtension) { + val moduleMapEntries = mutableListOf() + val foreignSourceFiles = mutableListOf() + val foreignSourceDirs = mutableListOf() + + for (foreignProject in extension.actualizedProjects) { + project.evaluationDependsOn(foreignProject.path) + + val commonMainDirs = commonMainSourceDirs(foreignProject) + if (commonMainDirs.isEmpty()) { + project.logger.warn( + "[actualizer] '${foreignProject.path}' has no commonMain Kotlin source set; " + + "nothing to merge into '${project.path}'." + ) + continue + } + + for (dir in commonMainDirs) { + foreignSourceDirs += dir + moduleMapEntries += "${dir.absolutePath}::${foreignProject.path}" + if (dir.exists()) { + foreignSourceFiles += dir.walkTopDown().filter { it.isFile && it.extension == "kt" } + } + } + } + + if (foreignSourceFiles.isEmpty()) { + return + } + + addSourceDir(project, "main", foreignSourceDirs) + + val compilerPluginClasspath = project.configurations.detachedConfiguration( + project.dependencies.create(COMPILER_PLUGIN_COORDINATES) + ) + + val reportOutput = project.layout.buildDirectory.file("actualizer/report.json").get().asFile + val moduleMapValue = moduleMapEntries.joinToString("||") + val commonSourcesValue = foreignSourceFiles.joinToString(",") { it.absolutePath } + + val compileKotlinTask = project.tasks.named("compileKotlin") + compileKotlinTask.configure { task -> + task.dependsOn(compilerPluginClasspath) + task.inputs.files(compilerPluginClasspath).withPropertyName("actualizerCompilerPluginClasspath") + + val compilerOptions = task.invokeGetter("getCompilerOptions") + ?: error("[actualizer] '${task.path}' has no compilerOptions; is it a Kotlin compile task?") + + @Suppress("UNCHECKED_CAST") + val freeCompilerArgs = compilerOptions.invokeGetter("getFreeCompilerArgs") as ListProperty + + freeCompilerArgs.addAll( + project.provider { + // Resolving the substituted project coordinate pulls in its own runtime + // deps too (kotlin-stdlib etc) - only the plugin's own jar should be passed + // as -Xplugin=, the rest is already implicitly on the compiler's classpath. + val pluginJar = compilerPluginClasspath.files + .first { it.name.startsWith("compiler-plugin") } + .absolutePath + listOf( + "-Xmulti-platform", + "-Xcommon-sources=$commonSourcesValue", + "-Xplugin=$pluginJar", + "-P", + "plugin:$PLUGIN_ID:moduleMap=$moduleMapValue", + "-P", + "plugin:$PLUGIN_ID:selfModule=${project.path}", + "-P", + "plugin:$PLUGIN_ID:reportOutput=${reportOutput.absolutePath}", + ) + } + ) + } + } + + private fun commonMainSourceDirs(foreignProject: Project): List { + val kotlinExtension = foreignProject.extensions.findByName("kotlin") + ?: error( + "[actualizer] '${foreignProject.path}' is declared via actualizes(...) but has no " + + "'kotlin' extension; apply kotlin(\"multiplatform\") to it first." + ) + val sourceSets = kotlinExtension.invokeGetter("getSourceSets") as? NamedDomainObjectContainer<*> + ?: error( + "[actualizer] '${foreignProject.path}' does not look like a kotlin(\"multiplatform\") " + + "project (no source set container); Actualizer only knows how to pull commonMain " + + "sources out of a multiplatform project." + ) + val commonMain = sourceSets.findByName("commonMain") ?: return emptyList() + val kotlinDirSet = commonMain.invokeGetter("getKotlin") as SourceDirectorySet + return kotlinDirSet.srcDirs.toList() + } + + private fun addSourceDir(project: Project, sourceSetName: String, dirs: List) { + val kotlinExtension = project.extensions.findByName("kotlin") + ?: error("[actualizer] '${project.path}' has no 'kotlin' extension; apply kotlin(\"jvm\") to it first.") + val sourceSets = kotlinExtension.invokeGetter("getSourceSets") as NamedDomainObjectContainer<*> + val sourceSet = sourceSets.getByName(sourceSetName) + val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet + kotlinDirSet.srcDirs(dirs) + } + + private fun Any.invokeGetter(methodName: String): Any? = + javaClass.methods.firstOrNull { it.name == methodName && it.parameterCount == 0 }?.invoke(this) +} diff --git a/plugin-build/settings.gradle.kts b/plugin-build/settings.gradle.kts new file mode 100644 index 0000000..e6aca8e --- /dev/null +++ b/plugin-build/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} + +rootProject.name = "actualizer-plugin-build" + +include(":compiler-plugin", ":gradle-plugin") diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts new file mode 100644 index 0000000..7e28536 --- /dev/null +++ b/sample/actual-jvm/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + id("com.modularkmp.actualizer") +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // For the @CrossModuleExpect annotation reference in the merged-in source file from :api. + implementation(project(":actualizer-annotations")) +} + +actualizer { + actualizes(project(":sample:api")) +} diff --git a/sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt new file mode 100644 index 0000000..d2f72e5 --- /dev/null +++ b/sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt @@ -0,0 +1,9 @@ +// The `actual` declaration must live in the same package as its `expect` counterpart +// (com.modularkmp.sample.api, declared in the unrelated :sample:api Gradle module), even +// though this file physically lives in a completely different Gradle module. +package com.modularkmp.sample.api + +import com.modularkmp.actualizer.annotations.CrossModuleExpect + +@CrossModuleExpect +actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)" diff --git a/sample/api/build.gradle.kts b/sample/api/build.gradle.kts new file mode 100644 index 0000000..5ec3b61 --- /dev/null +++ b/sample/api/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + kotlin("multiplatform") +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) + // Two targets are declared so Kotlin actually runs a real intermediate "metadata" + // compilation of commonMain (see plugin-build's README/notes): with a single target, + // commonMain gets folded directly into that target's compile, which then demands an + // actual immediately. With 2+ targets, commonMain compiles to its own klib/metadata + // artifact - unfulfilled expects are fine there, which is exactly what lets this module + // build a normal, standalone, dependable jar containing only its API surface. + jvm() + js(IR) { nodejs() } + + sourceSets { + commonMain.dependencies { + implementation(project(":actualizer-annotations")) + } + } +} diff --git a/sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt b/sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt new file mode 100644 index 0000000..2a5bc03 --- /dev/null +++ b/sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt @@ -0,0 +1,13 @@ +package com.modularkmp.sample.api + +import com.modularkmp.actualizer.annotations.CrossModuleExpect + +/** + * Actualized not by a platform source set in this module's own multiplatform hierarchy, but by + * a real, independently built Gradle module (`:sample:actual-jvm`) that isn't even a dependency + * of this one - see that module's `actualizer { actualizes(project(":sample:api")) }`. + */ +@CrossModuleExpect +expect fun greetingSuffix(): String + +fun greet(name: String): String = "Hello, $name! ${greetingSuffix()}" diff --git a/sample/app/build.gradle.kts b/sample/app/build.gradle.kts new file mode 100644 index 0000000..e8e12a5 --- /dev/null +++ b/sample/app/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + kotlin("jvm") + application +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} + +application { + mainClass.set("com.modularkmp.sample.app.MainKt") +} + +dependencies { + // Ordinary binary dependency - no plugin, no special wiring. :app never even sees + // :sample:api directly; it only depends on the already-linked jar :actual-jvm produced. + implementation(project(":sample:actual-jvm")) +} diff --git a/sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt b/sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt new file mode 100644 index 0000000..c719ed3 --- /dev/null +++ b/sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt @@ -0,0 +1,7 @@ +package com.modularkmp.sample.app + +import com.modularkmp.sample.api.greet + +fun main() { + println(greet("world")) +} diff --git a/sample/feature-common/build.gradle.kts b/sample/feature-common/build.gradle.kts new file mode 100644 index 0000000..9305c5b --- /dev/null +++ b/sample/feature-common/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + kotlin("multiplatform") +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) + jvm() + js(IR) { nodejs() } + + sourceSets { + commonMain.dependencies { + // An ordinary binary dependency on :sample:api's metadata jar - no plugin, no + // special wiring. Proves a common module can depend on another common module's + // artifact before any real `actual` exists anywhere. + implementation(project(":sample:api")) + } + } +} diff --git a/sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt b/sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt new file mode 100644 index 0000000..c65a05c --- /dev/null +++ b/sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt @@ -0,0 +1,5 @@ +package com.modularkmp.sample.feature + +import com.modularkmp.sample.api.greet + +fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..563eb40 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + includeBuild("plugin-build") + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +// A second, top-level includeBuild is required (in addition to the one inside +// pluginManagement above) so that plain dependency coordinates like +// "com.modularkmp.actualizer:compiler-plugin:0.1.0" - which the Actualizer Gradle plugin +// resolves at apply-time to locate the compiler plugin's jar for `-Xplugin=` - get +// substituted with the :compiler-plugin project from plugin-build instead of requiring a +// real Maven publish. +includeBuild("plugin-build") + +rootProject.name = "modular-kmp" + +include( + ":actualizer-annotations", + ":sample:api", + ":sample:feature-common", + ":sample:actual-jvm", + ":sample:app", +) From e89236fb32d42af39f0e71e598a0a6fbd76a4192 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:38:05 +0000 Subject: [PATCH 02/19] Rename package/group namespace from com.modularkmp to net.kernelpanicsoft Updates the Maven group (net.kernelpanicsoft), the Gradle/compiler plugin id (net.kernelpanicsoft.actualizer), and all package declarations across the plugin modules and sample project to match. Verified with a full clean rebuild of the whole graph, including :sample:app:run. --- README.md | 7 +++++-- actualizer-annotations/build.gradle.kts | 2 +- .../actualizer/annotations/CrossModuleExpect.kt | 2 +- plugin-build/compiler-plugin/build.gradle.kts | 2 +- .../actualizer/compiler/ActualizerCommandLineProcessor.kt | 6 +++--- .../compiler/ActualizerCompilerPluginRegistrar.kt | 6 +++--- .../actualizer/compiler/ir/ActualizerIrExtension.kt | 4 ++-- ...g.jetbrains.kotlin.compiler.plugin.CommandLineProcessor | 2 +- ...etbrains.kotlin.compiler.plugin.CompilerPluginRegistrar | 2 +- plugin-build/gradle-plugin/build.gradle.kts | 6 +++--- .../actualizer/gradle/ActualizerExtension.kt | 2 +- .../actualizer/gradle/ActualizerGradlePlugin.kt | 6 +++--- sample/actual-jvm/build.gradle.kts | 2 +- .../kernelpanicsoft}/sample/api/Actual.kt | 6 +++--- .../kernelpanicsoft}/sample/api/Greeting.kt | 4 ++-- sample/app/build.gradle.kts | 2 +- .../app/src/main/kotlin/com/modularkmp/sample/app/Main.kt | 7 ------- .../src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt | 7 +++++++ .../kotlin/com/modularkmp/sample/feature/Feature.kt | 5 ----- .../kotlin/net/kernelpanicsoft/sample/feature/Feature.kt | 5 +++++ settings.gradle.kts | 2 +- 21 files changed, 45 insertions(+), 42 deletions(-) rename actualizer-annotations/src/commonMain/kotlin/{com/modularkmp => net/kernelpanicsoft}/actualizer/annotations/CrossModuleExpect.kt (94%) rename plugin-build/compiler-plugin/src/main/kotlin/{com/modularkmp => net/kernelpanicsoft}/actualizer/compiler/ActualizerCommandLineProcessor.kt (92%) rename plugin-build/compiler-plugin/src/main/kotlin/{com/modularkmp => net/kernelpanicsoft}/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt (90%) rename plugin-build/compiler-plugin/src/main/kotlin/{com/modularkmp => net/kernelpanicsoft}/actualizer/compiler/ir/ActualizerIrExtension.kt (97%) rename plugin-build/gradle-plugin/src/main/kotlin/{com/modularkmp => net/kernelpanicsoft}/actualizer/gradle/ActualizerExtension.kt (92%) rename plugin-build/gradle-plugin/src/main/kotlin/{com/modularkmp => net/kernelpanicsoft}/actualizer/gradle/ActualizerGradlePlugin.kt (97%) rename sample/actual-jvm/src/main/kotlin/{com/modularkmp => net/kernelpanicsoft}/sample/api/Actual.kt (57%) rename sample/api/src/commonMain/kotlin/{com/modularkmp => net/kernelpanicsoft}/sample/api/Greeting.kt (79%) delete mode 100644 sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt create mode 100644 sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt delete mode 100644 sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt create mode 100644 sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt diff --git a/README.md b/README.md index 07b8b81..652c6b0 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ independently built Gradle module** - one that is not wired into the same Hello, world! (actualized independently by :sample:actual-jvm) ``` +All published modules (`actualizer-annotations`, `compiler-plugin`, `gradle-plugin`) use the +`net.kernelpanicsoft` Maven group; the plugin/package namespace is `net.kernelpanicsoft.actualizer`. + ## Why this needs a plugin at all Stock Kotlin only resolves `expect`/`actual` within a single compiler invocation that has @@ -47,7 +50,7 @@ metadata step and folds `commonMain` directly into that target's compile, which actual immediately - hence two targets, even though only `jvm()` is exercised in this demo.) **2. The actual linking happens in the leaf module, via source merging.** -`:sample:actual-jvm` is a plain `kotlin("jvm")` module with `id("com.modularkmp.actualizer")` +`:sample:actual-jvm` is a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` applied and: ```kotlin actualizer { @@ -87,7 +90,7 @@ mechanism *doesn't* give you for free: `build/actualizer/report.json`: ```json { "consumingModule": ":sample:actual-jvm", - "links": [ { "actual": "com.modularkmp.sample.api.greetingSuffix", "owningModules": [":sample:api"] } ] } + "links": [ { "actual": "net.kernelpanicsoft.sample.api.greetingSuffix", "owningModules": [":sample:api"] } ] } ``` (Note: once expect/actual are resolved, the `expect` declaration itself is elided from IR - only the `actual` survives as a real `IrFunction`. The plugin correlates by package rather than diff --git a/actualizer-annotations/build.gradle.kts b/actualizer-annotations/build.gradle.kts index 585ab6d..5e6d12f 100644 --- a/actualizer-annotations/build.gradle.kts +++ b/actualizer-annotations/build.gradle.kts @@ -2,7 +2,7 @@ plugins { kotlin("multiplatform") } -group = "com.modularkmp.actualizer" +group = "net.kernelpanicsoft" version = "0.1.0" repositories { diff --git a/actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt b/actualizer-annotations/src/commonMain/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt similarity index 94% rename from actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt rename to actualizer-annotations/src/commonMain/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt index 80f5756..e3214fc 100644 --- a/actualizer-annotations/src/commonMain/kotlin/com/modularkmp/actualizer/annotations/CrossModuleExpect.kt +++ b/actualizer-annotations/src/commonMain/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt @@ -1,4 +1,4 @@ -package com.modularkmp.actualizer.annotations +package net.kernelpanicsoft.actualizer.annotations /** * Opt-in marker for an `expect` declaration that is meant to be actualized by a real `actual` diff --git a/plugin-build/compiler-plugin/build.gradle.kts b/plugin-build/compiler-plugin/build.gradle.kts index 345d030..8245b31 100644 --- a/plugin-build/compiler-plugin/build.gradle.kts +++ b/plugin-build/compiler-plugin/build.gradle.kts @@ -2,7 +2,7 @@ plugins { kotlin("jvm") version "2.0.21" } -group = "com.modularkmp.actualizer" +group = "net.kernelpanicsoft" version = "0.1.0" repositories { diff --git a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt similarity index 92% rename from plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt rename to plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt index de83abf..e04977e 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCommandLineProcessor.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt @@ -1,4 +1,4 @@ -package com.modularkmp.actualizer.compiler +package net.kernelpanicsoft.actualizer.compiler import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption import org.jetbrains.kotlin.compiler.plugin.CliOption @@ -8,7 +8,7 @@ import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.config.CompilerConfigurationKey /** - * Reads the `-P plugin:com.modularkmp.actualizer:=` options the Gradle plugin + * Reads the `-P plugin:net.kernelpanicsoft.actualizer:=` options the Gradle plugin * passes in and stores them on the [CompilerConfiguration] for [ActualizerCompilerPluginRegistrar] * to read back out. */ @@ -33,7 +33,7 @@ class ActualizerCommandLineProcessor : CommandLineProcessor { } companion object { - const val PLUGIN_ID = "com.modularkmp.actualizer" + const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" val KEY_MODULE_MAP = CompilerConfigurationKey("moduleMap") val KEY_SELF_MODULE = CompilerConfigurationKey("selfModule") diff --git a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt similarity index 90% rename from plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt rename to plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index f0fd4b1..6dd44e7 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -1,7 +1,7 @@ -package com.modularkmp.actualizer.compiler +package net.kernelpanicsoft.actualizer.compiler -import com.modularkmp.actualizer.compiler.ir.ActualizerIrExtension -import com.modularkmp.actualizer.compiler.ir.ModuleRoot +import net.kernelpanicsoft.actualizer.compiler.ir.ActualizerIrExtension +import net.kernelpanicsoft.actualizer.compiler.ir.ModuleRoot import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi diff --git a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt similarity index 97% rename from plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt rename to plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt index 1e5bf69..9007198 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/com/modularkmp/actualizer/compiler/ir/ActualizerIrExtension.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt @@ -1,4 +1,4 @@ -package com.modularkmp.actualizer.compiler.ir +package net.kernelpanicsoft.actualizer.compiler.ir import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext @@ -124,6 +124,6 @@ class ActualizerIrExtension( } private companion object { - val CROSS_MODULE_EXPECT_FQ_NAME = FqName("com.modularkmp.actualizer.annotations.CrossModuleExpect") + val CROSS_MODULE_EXPECT_FQ_NAME = FqName("net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect") } } diff --git a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor index 5c4b503..819a31f 100644 --- a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor +++ b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor @@ -1 +1 @@ -com.modularkmp.actualizer.compiler.ActualizerCommandLineProcessor +net.kernelpanicsoft.actualizer.compiler.ActualizerCommandLineProcessor diff --git a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar index 25f6843..1c55711 100644 --- a/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +++ b/plugin-build/compiler-plugin/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar @@ -1 +1 @@ -com.modularkmp.actualizer.compiler.ActualizerCompilerPluginRegistrar +net.kernelpanicsoft.actualizer.compiler.ActualizerCompilerPluginRegistrar diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts index 1af0467..faec31c 100644 --- a/plugin-build/gradle-plugin/build.gradle.kts +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -3,7 +3,7 @@ plugins { `java-gradle-plugin` } -group = "com.modularkmp.actualizer" +group = "net.kernelpanicsoft" version = "0.1.0" repositories { @@ -19,8 +19,8 @@ kotlin { gradlePlugin { plugins { create("actualizer") { - id = "com.modularkmp.actualizer" - implementationClass = "com.modularkmp.actualizer.gradle.ActualizerGradlePlugin" + id = "net.kernelpanicsoft.actualizer" + implementationClass = "net.kernelpanicsoft.actualizer.gradle.ActualizerGradlePlugin" } } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt similarity index 92% rename from plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt rename to plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt index 161400b..f509521 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerExtension.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt @@ -1,4 +1,4 @@ -package com.modularkmp.actualizer.gradle +package net.kernelpanicsoft.actualizer.gradle import org.gradle.api.Project import org.gradle.api.model.ObjectFactory diff --git a/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt similarity index 97% rename from plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt rename to plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index 9e24283..4da8438 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/com/modularkmp/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -1,4 +1,4 @@ -package com.modularkmp.actualizer.gradle +package net.kernelpanicsoft.actualizer.gradle import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Plugin @@ -7,8 +7,8 @@ import org.gradle.api.file.SourceDirectorySet import org.gradle.api.provider.ListProperty import java.io.File -private const val PLUGIN_ID = "com.modularkmp.actualizer" -private const val COMPILER_PLUGIN_COORDINATES = "com.modularkmp.actualizer:compiler-plugin:0.1.0" +private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" +private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-plugin:0.1.0" /** * Wires a "leaf" JVM module up to merge in the commonMain source files of one or more foreign, diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts index 7e28536..cc57385 100644 --- a/sample/actual-jvm/build.gradle.kts +++ b/sample/actual-jvm/build.gradle.kts @@ -1,6 +1,6 @@ plugins { kotlin("jvm") - id("com.modularkmp.actualizer") + id("net.kernelpanicsoft.actualizer") } repositories { diff --git a/sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt similarity index 57% rename from sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt rename to sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index d2f72e5..63790e0 100644 --- a/sample/actual-jvm/src/main/kotlin/com/modularkmp/sample/api/Actual.kt +++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -1,9 +1,9 @@ // The `actual` declaration must live in the same package as its `expect` counterpart -// (com.modularkmp.sample.api, declared in the unrelated :sample:api Gradle module), even +// (net.kernelpanicsoft.sample.api, declared in the unrelated :sample:api Gradle module), even // though this file physically lives in a completely different Gradle module. -package com.modularkmp.sample.api +package net.kernelpanicsoft.sample.api -import com.modularkmp.actualizer.annotations.CrossModuleExpect +import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect @CrossModuleExpect actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)" diff --git a/sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt b/sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt similarity index 79% rename from sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt rename to sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt index 2a5bc03..56d7608 100644 --- a/sample/api/src/commonMain/kotlin/com/modularkmp/sample/api/Greeting.kt +++ b/sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -1,6 +1,6 @@ -package com.modularkmp.sample.api +package net.kernelpanicsoft.sample.api -import com.modularkmp.actualizer.annotations.CrossModuleExpect +import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect /** * Actualized not by a platform source set in this module's own multiplatform hierarchy, but by diff --git a/sample/app/build.gradle.kts b/sample/app/build.gradle.kts index e8e12a5..f8ad92d 100644 --- a/sample/app/build.gradle.kts +++ b/sample/app/build.gradle.kts @@ -12,7 +12,7 @@ kotlin { } application { - mainClass.set("com.modularkmp.sample.app.MainKt") + mainClass.set("net.kernelpanicsoft.sample.app.MainKt") } dependencies { diff --git a/sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt b/sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt deleted file mode 100644 index c719ed3..0000000 --- a/sample/app/src/main/kotlin/com/modularkmp/sample/app/Main.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.modularkmp.sample.app - -import com.modularkmp.sample.api.greet - -fun main() { - println(greet("world")) -} diff --git a/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt new file mode 100644 index 0000000..80cea12 --- /dev/null +++ b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt @@ -0,0 +1,7 @@ +package net.kernelpanicsoft.sample.app + +import net.kernelpanicsoft.sample.api.greet + +fun main() { + println(greet("world")) +} diff --git a/sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt b/sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt deleted file mode 100644 index c65a05c..0000000 --- a/sample/feature-common/src/commonMain/kotlin/com/modularkmp/sample/feature/Feature.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.modularkmp.sample.feature - -import com.modularkmp.sample.api.greet - -fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt new file mode 100644 index 0000000..a2aebaa --- /dev/null +++ b/sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt @@ -0,0 +1,5 @@ +package net.kernelpanicsoft.sample.feature + +import net.kernelpanicsoft.sample.api.greet + +fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/settings.gradle.kts b/settings.gradle.kts index 563eb40..fb00eeb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,7 +9,7 @@ pluginManagement { // A second, top-level includeBuild is required (in addition to the one inside // pluginManagement above) so that plain dependency coordinates like -// "com.modularkmp.actualizer:compiler-plugin:0.1.0" - which the Actualizer Gradle plugin +// "net.kernelpanicsoft:compiler-plugin:0.1.0" - which the Actualizer Gradle plugin // resolves at apply-time to locate the compiler plugin's jar for `-Xplugin=` - get // substituted with the :compiler-plugin project from plugin-build instead of requiring a // real Maven publish. From d7ec2347a920473690d47954c13ffd68549bd0af Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:49:11 +0000 Subject: [PATCH 03/19] Make the common module a plain JVM jar, drop the JS/multiplatform target The "commonMain gets its own standalone artifact" requirement no longer relies on Kotlin Multiplatform's metadata-compilation trick (which needed a second, unused js(IR) target purely to trigger it, pulling in Node/npm/Yarn on any full build). Instead :sample:api and actualizer-annotations are now plain kotlin("jvm") modules; the expect declaration lives in a dedicated, non-default "crossModuleApi" source set that :api itself never compiles (it isn't wired into assemble/check/build), so its ordinary main source set produces a real, completely normal JVM jar with real bytecode - no metadata/klib format. ActualizerExtension.actualizes() now takes an optional source set name (defaulting to "crossModuleApi") instead of assuming a multiplatform commonMain source set, and the Gradle plugin merges that named source set's directory the same way as before. Verified with a full `./gradlew build` at the repo root: builds the whole graph with zero JS/Node/npm/Yarn tasks, and :sample:app:run still prints the cross-module-linked value. Negative path (missing actual) still fails the build with the expected frontend error. --- README.md | 71 +++++++++++-------- actualizer-annotations/build.gradle.kts | 4 +- .../annotations/CrossModuleExpect.kt | 0 build.gradle.kts | 1 - .../compiler/ir/ActualizerIrExtension.kt | 4 +- .../actualizer/gradle/ActualizerExtension.kt | 18 ++++- .../gradle/ActualizerGradlePlugin.kt | 71 ++++++++++--------- sample/actual-jvm/build.gradle.kts | 2 + .../net/kernelpanicsoft/sample/api/Actual.kt | 4 ++ sample/api/build.gradle.kts | 26 +++---- .../kernelpanicsoft/sample/api/Greeting.kt | 13 ---- .../kernelpanicsoft/sample/api/Greeting.kt | 13 ++++ .../kernelpanicsoft/sample/api/Greeting.kt | 8 +++ sample/feature-common/build.gradle.kts | 16 ++--- .../kernelpanicsoft/sample/feature/Feature.kt | 5 -- .../kernelpanicsoft/sample/feature/Feature.kt | 11 +++ 16 files changed, 153 insertions(+), 114 deletions(-) rename actualizer-annotations/src/{commonMain => main}/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt (100%) delete mode 100644 sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt create mode 100644 sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt create mode 100644 sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt delete mode 100644 sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt create mode 100644 sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt diff --git a/README.md b/README.md index 652c6b0..d8fa9ec 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,9 @@ A Kotlin IR compiler plugin (+ a Gradle plugin to drive it) that lets an `expect` declaration in one Gradle module be actualized by a real `actual` declaration living in a **genuinely separate, -independently built Gradle module** - one that is not wired into the same -`kotlin { multiplatform { ... } }` source-set hierarchy as the expect. +independently built Gradle module** - one that is not wired into the same multiplatform +source-set hierarchy as the expect. Every module involved is a plain `kotlin("jvm")` project; +nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. ``` :sample:api expect fun greetingSuffix(): String (no actual anywhere in here) @@ -37,33 +38,37 @@ invocation - there is no alternative that doesn't abandon the real keyword. ## How it actually works -**1. The "common" module gets a real, standalone artifact for free.** -`:sample:api` is `kotlin("multiplatform")` with two declared targets (`jvm()` and -`js(IR)`). With two or more targets, Kotlin compiles `commonMain` to its own intermediate -metadata/klib artifact (`compileKotlinMetadata` / `allMetadataJar`) - and that compilation -**allows unfulfilled `expect`s** (that's the entire point of the metadata step: it only checks -that the expect declarations are well-formed, deferring actual-matching to each platform -compilation). `:sample:feature-common` depends on that jar exactly like any other project -dependency and compiles fine with zero actuals in sight - proving common code can be built on -top of a not-yet-actualized API. (With only *one* declared target, Kotlin skips the separate -metadata step and folds `commonMain` directly into that target's compile, which then demands an -actual immediately - hence two targets, even though only `jvm()` is exercised in this demo.) +**1. The "common" module is a completely ordinary JVM jar.** +`:sample:api` is a plain `kotlin("jvm")` module. Its `expect fun greetingSuffix()` doesn't live in +`main` - it lives in a second, non-default source set called `crossModuleApi` +(`src/crossModuleApi/kotlin/...`), created with an ordinary Gradle +`sourceSets { create("crossModuleApi") { ... } }` block. Gradle/Kotlin auto-generates a +`compileCrossModuleApiKotlin` task for it, but - unlike `main`/`test` - that task is **not** wired +into `assemble`/`check`/`build`, so `:sample:api` never compiles it as part of its own build. The +`expect` keyword needs `-Xmulti-platform` to even parse, and a *compiled* target would need the +expect fulfilled - but since this source set is simply never compiled by `:api` itself, neither +requirement ever applies to `:api`'s own build. Its `main` source set has no `expect`/`actual` in +it at all, so `./gradlew :sample:api:build` produces a normal `.jar` with real, ordinary JVM +bytecode - no multiplatform machinery, no metadata/klib format, nothing Kotlin-specific about the +artifact at all. `:sample:feature-common` depends on that jar with a plain +`implementation(project(":sample:api"))`, proving a "common" module can depend on another one +before any `actual` exists anywhere, using nothing but an ordinary Gradle project dependency. **2. The actual linking happens in the leaf module, via source merging.** -`:sample:actual-jvm` is a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` +`:sample:actual-jvm` is also a plain `kotlin("jvm")` module, with `id("net.kernelpanicsoft.actualizer")` applied and: ```kotlin actualizer { - actualizes(project(":sample:api")) + actualizes(project(":sample:api")) // defaults to merging its "crossModuleApi" source set } ``` -The Actualizer Gradle plugin pulls `:sample:api`'s `commonMain` source *directory* (not its -compiled jar - the jar can't be built for JVM anyway, since it has no jvm actual) into +The Actualizer Gradle plugin pulls `:sample:api`'s `crossModuleApi` source *directory* (not a +compiled artifact - there isn't one, since that source set is never independently compiled) into `:sample:actual-jvm`'s own `main` source set, and adds two compiler flags to its `compileKotlin` task: ``` -Xmulti-platform --Xcommon-sources= +-Xcommon-sources= ``` This is the same mechanism the Kotlin Gradle Plugin itself uses under the hood for JVM multiplatform targets - "common" and "platform" sources compiled together in one invocation, with @@ -106,15 +111,18 @@ applies the Actualizer plugin and never references `:sample:api`. Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 / JDK 21, not just designed on paper: -- `./gradlew :sample:api:compileKotlinMetadata :sample:api:allMetadataJar` - succeeds standalone - with an unfulfilled `expect`, producing a real, non-empty metadata jar. -- `./gradlew :sample:feature-common:compileKotlinMetadata :sample:feature-common:allMetadataJar` - - succeeds depending only on `:sample:api`'s jar, before any actual exists anywhere. -- `./gradlew :sample:actual-jvm:build` - merges `:sample:api`'s source and links it against the - real actual; `build/actualizer/report.json` shows `status: linked` with correct module +- `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces a plain JVM jar + (`net/.../GreetingKt.class`, real bytecode, no metadata/klib format) containing an unfulfilled + `expect` that `:api` itself never even attempts to compile. +- `./gradlew :sample:feature-common:build` - succeeds depending only on `:sample:api`'s plain jar, + before any actual exists anywhere. +- `./gradlew :sample:actual-jvm:build` - merges `:sample:api`'s `crossModuleApi` source and links + it against the real actual; `build/actualizer/report.json` shows the link with correct module provenance. - `./gradlew :sample:app:run` - prints the value produced by `:sample:actual-jvm`'s `actual`, with zero special wiring in `:app` itself. +- `./gradlew build` at the repo root - builds the entire graph in one shot; grepping the task log + confirms zero JS/Node/npm/Yarn tasks anywhere (no non-JVM tooling exists in this repo at all). - **Negative path**: deleting the `actual` declaration from `:sample:actual-jvm` and re-running `compileKotlin` fails the build with a real Kotlin frontend error pointing at the exact expect declaration (`Expected greetingSuffix has no actual declaration in module -common @@ -125,7 +133,7 @@ Everything above was actually built and run in this environment with Gradle 8.14 ## Repo layout ``` -actualizer-annotations/ @CrossModuleExpect marker (kotlin("multiplatform"), metadata-only) +actualizer-annotations/ @CrossModuleExpect marker (plain kotlin("jvm")) plugin-build/ composite build (keeps the plugin's own Kotlin version pinned independently of consumers, standard Kotlin-compiler-plugin layout) compiler-plugin/ the IR compiler plugin itself (CommandLineProcessor, @@ -133,8 +141,9 @@ plugin-build/ composite build (keeps the plugin's own Kotlin vers gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) } DSL, source-directory merging, -Xcommon-sources/-Xmulti-platform wiring sample/ - api/ expect-only module, its own standalone metadata artifact - feature-common/ depends on api's jar like any common module would + api/ plain kotlin("jvm"); expect lives in a non-default "crossModuleApi" + source set, main is an ordinary standalone jar + feature-common/ depends on api's plain jar like any common module would actual-jvm/ applies the plugin; the real cross-module link happens here app/ plain binary consumer of actual-jvm, no special wiring ``` @@ -155,7 +164,7 @@ sample/ working across Gradle modules, as opposed to an annotation-based runtime-dispatch design (which would be more stable but wouldn't use the real keywords). - **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin` - deliberately avoids importing Kotlin Gradle Plugin types (`KotlinMultiplatformExtension`, + deliberately avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, `KotlinCompile`, etc.) and uses reflection-by-name instead - `plugin-build` resolves its own copy of `kotlin-gradle-plugin` to compile against, which ends up a different `Class` instance than the one the consuming build's `plugins { kotlin(...) }` loads, so direct @@ -166,3 +175,9 @@ sample/ modules (e.g. per build flavor) - `actualizes(...)` merges in all of them unconditionally, and the *last* declaration compiled for a given fully-qualified name wins/conflicts per ordinary Kotlin rules. +- **The `crossModuleApi` source set is inert by convention, not by enforcement.** Nothing stops + someone from running `./gradlew :sample:api:compileCrossModuleApiKotlin` directly (it exists as + a real task, just isn't wired into `build`/`assemble`/`check`) - doing so fails with the same + "no actual declaration" frontend error, since that invocation has no `-Xmulti-platform`/ + `-Xcommon-sources` wiring at all. That's expected and harmless (nothing depends on that task + succeeding), but it's worth knowing it's reachable if invoked explicitly. diff --git a/actualizer-annotations/build.gradle.kts b/actualizer-annotations/build.gradle.kts index 5e6d12f..fcb9182 100644 --- a/actualizer-annotations/build.gradle.kts +++ b/actualizer-annotations/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("multiplatform") + kotlin("jvm") } group = "net.kernelpanicsoft" @@ -11,6 +11,4 @@ repositories { kotlin { jvmToolchain(21) - jvm() - js(IR) { nodejs() } } diff --git a/actualizer-annotations/src/commonMain/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt b/actualizer-annotations/src/main/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt similarity index 100% rename from actualizer-annotations/src/commonMain/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt rename to actualizer-annotations/src/main/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt diff --git a/build.gradle.kts b/build.gradle.kts index f95c995..749d414 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ plugins { kotlin("jvm") version "2.0.21" apply false - kotlin("multiplatform") version "2.0.21" apply false } diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt index 9007198..7b0a340 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt @@ -23,8 +23,8 @@ private data class LinkEntry( /** * Runs inside a compilation whose source set has been merged (by the Gradle plugin) from a - * foreign, expect-declaring Gradle module's commonMain and this module's own `actual` - * declarations, compiled together via `-Xmulti-platform` + `-Xcommon-sources`. + * foreign, expect-declaring Gradle module's `crossModuleApi` source set and this module's own + * `actual` declarations, compiled together via `-Xmulti-platform` + `-Xcommon-sources`. * * By the time this extension runs, the Kotlin frontend has *already* resolved expect/actual for * this compilation (or the build would already have failed) - this extension does not perform diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt index f509521..d2031a3 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt @@ -4,15 +4,27 @@ import org.gradle.api.Project import org.gradle.api.model.ObjectFactory import javax.inject.Inject +/** Default name of the source set Actualizer looks for on a foreign, expect-declaring project. */ +const val DEFAULT_CROSS_MODULE_SOURCE_SET = "crossModuleApi" + +internal data class ActualizedSource(val project: Project, val sourceSetName: String) + /** * `actualizer { actualizes(project(":api")) }` DSL applied to a "leaf" JVM module that provides * real `actual` declarations for `expect`s declared in an unrelated, independently built Gradle * module. + * + * The foreign project is a plain `kotlin("jvm")` module - no multiplatform, no extra platform + * targets. Its `expect` declarations live in a dedicated, non-default source set (named + * `crossModuleApi` by convention) that the foreign project never compiles itself, so its + * *ordinary* `main` source set stays a completely normal, standalone JVM jar other modules can + * depend on before anything is actualized. */ open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { - internal val actualizedProjects: MutableList = mutableListOf() + internal val sources: MutableList = mutableListOf() - fun actualizes(project: Project) { - actualizedProjects += project + @JvmOverloads + fun actualizes(project: Project, sourceSet: String = DEFAULT_CROSS_MODULE_SOURCE_SET) { + sources += ActualizedSource(project, sourceSet) } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index 4da8438..187bda0 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -11,16 +11,22 @@ private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-plugin:0.1.0" /** - * Wires a "leaf" JVM module up to merge in the commonMain source files of one or more foreign, - * expect-declaring Gradle modules (declared via `actualizer { actualizes(project(":api")) }`) - * so the real Kotlin compiler frontend resolves `expect`/`actual` across them, and registers - * the Actualizer IR compiler plugin on that same compilation so it can report on what got - * linked. + * Wires a "leaf" JVM module up to merge in a named source set (`crossModuleApi` by default) of + * one or more foreign, expect-declaring Gradle modules (declared via + * `actualizer { actualizes(project(":api")) }`) so the real Kotlin compiler frontend resolves + * `expect`/`actual` across them, and registers the Actualizer IR compiler plugin on that same + * compilation so it can report on what got linked. * - * This deliberately avoids importing Kotlin Gradle Plugin (KGP) types like - * `KotlinMultiplatformExtension` or `KotlinCompile` directly: `plugin-build` resolves its own - * copy of `kotlin-gradle-plugin` (for compiling *this* plugin) which ends up in a different - * classloader than the copy the consuming build resolves via `plugins { kotlin("...") }` - so + * Both sides are plain `kotlin("jvm")` projects - no multiplatform plugin, no extra platform + * targets, nothing beyond the ordinary JVM toolchain. The foreign project's `expect` + * declarations live in a dedicated, non-default source set it never compiles itself (see + * `ActualizerExtension`), so its regular `main` jar stays a completely normal, standalone + * artifact. + * + * This deliberately avoids importing Kotlin Gradle Plugin (KGP) types like `KotlinJvmProjectExtension` + * or `KotlinCompile` directly: `plugin-build` resolves its own copy of `kotlin-gradle-plugin` + * (for compiling *this* plugin) which ends up in a different classloader than the copy the + * consuming build resolves via `plugins { kotlin("jvm") }` - so * `extensions.findByType(SomeKgpType::class.java)` and `tasks.withType(SomeKgpTaskType::class.java)` * silently find nothing across that boundary. Reflection-by-name sidesteps it; only genuine * Gradle-core types (`NamedDomainObjectContainer`, `SourceDirectorySet`, `ListProperty`, which @@ -32,7 +38,7 @@ class ActualizerGradlePlugin : Plugin { val extension = project.extensions.create("actualizer", ActualizerExtension::class.java) project.afterEvaluate { - if (extension.actualizedProjects.isEmpty()) { + if (extension.sources.isEmpty()) { // Nothing to wire up - this project doesn't merge in any foreign expect module. return@afterEvaluate } @@ -45,21 +51,21 @@ class ActualizerGradlePlugin : Plugin { val foreignSourceFiles = mutableListOf() val foreignSourceDirs = mutableListOf() - for (foreignProject in extension.actualizedProjects) { - project.evaluationDependsOn(foreignProject.path) + for (source in extension.sources) { + project.evaluationDependsOn(source.project.path) - val commonMainDirs = commonMainSourceDirs(foreignProject) - if (commonMainDirs.isEmpty()) { + val dirs = namedSourceSetDirs(source.project, source.sourceSetName) + if (dirs.isEmpty()) { project.logger.warn( - "[actualizer] '${foreignProject.path}' has no commonMain Kotlin source set; " + - "nothing to merge into '${project.path}'." + "[actualizer] '${source.project.path}' has no '${source.sourceSetName}' Kotlin " + + "source set; nothing to merge into '${project.path}'." ) continue } - for (dir in commonMainDirs) { + for (dir in dirs) { foreignSourceDirs += dir - moduleMapEntries += "${dir.absolutePath}::${foreignProject.path}" + moduleMapEntries += "${dir.absolutePath}::${source.project.path}" if (dir.exists()) { foreignSourceFiles += dir.walkTopDown().filter { it.isFile && it.extension == "kt" } } @@ -115,32 +121,27 @@ class ActualizerGradlePlugin : Plugin { } } - private fun commonMainSourceDirs(foreignProject: Project): List { - val kotlinExtension = foreignProject.extensions.findByName("kotlin") - ?: error( - "[actualizer] '${foreignProject.path}' is declared via actualizes(...) but has no " + - "'kotlin' extension; apply kotlin(\"multiplatform\") to it first." - ) - val sourceSets = kotlinExtension.invokeGetter("getSourceSets") as? NamedDomainObjectContainer<*> - ?: error( - "[actualizer] '${foreignProject.path}' does not look like a kotlin(\"multiplatform\") " + - "project (no source set container); Actualizer only knows how to pull commonMain " + - "sources out of a multiplatform project." - ) - val commonMain = sourceSets.findByName("commonMain") ?: return emptyList() - val kotlinDirSet = commonMain.invokeGetter("getKotlin") as SourceDirectorySet + private fun namedSourceSetDirs(foreignProject: Project, sourceSetName: String): List { + val sourceSets = kotlinSourceSets(foreignProject) + val sourceSet = sourceSets.findByName(sourceSetName) ?: return emptyList() + val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet return kotlinDirSet.srcDirs.toList() } private fun addSourceDir(project: Project, sourceSetName: String, dirs: List) { - val kotlinExtension = project.extensions.findByName("kotlin") - ?: error("[actualizer] '${project.path}' has no 'kotlin' extension; apply kotlin(\"jvm\") to it first.") - val sourceSets = kotlinExtension.invokeGetter("getSourceSets") as NamedDomainObjectContainer<*> + val sourceSets = kotlinSourceSets(project) val sourceSet = sourceSets.getByName(sourceSetName) val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet kotlinDirSet.srcDirs(dirs) } + private fun kotlinSourceSets(project: Project): NamedDomainObjectContainer<*> { + val kotlinExtension = project.extensions.findByName("kotlin") + ?: error("[actualizer] '${project.path}' has no 'kotlin' extension; apply kotlin(\"jvm\") to it first.") + return kotlinExtension.invokeGetter("getSourceSets") as? NamedDomainObjectContainer<*> + ?: error("[actualizer] '${project.path}' has a 'kotlin' extension with no source set container.") + } + private fun Any.invokeGetter(methodName: String): Any? = javaClass.methods.firstOrNull { it.name == methodName && it.parameterCount == 0 }?.invoke(this) } diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts index cc57385..29156f2 100644 --- a/sample/actual-jvm/build.gradle.kts +++ b/sample/actual-jvm/build.gradle.kts @@ -14,6 +14,8 @@ kotlin { dependencies { // For the @CrossModuleExpect annotation reference in the merged-in source file from :api. implementation(project(":actualizer-annotations")) + // For formatGreeting() - :api's ordinary, unrelated-to-the-expect main jar. + implementation(project(":sample:api")) } actualizer { diff --git a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 63790e0..629260a 100644 --- a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -7,3 +7,7 @@ import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect @CrossModuleExpect actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)" + +// formatGreeting comes from :api's ordinary main jar - a normal binary dependency, combined +// here with the just-linked actual to produce the final, callable function. +fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/api/build.gradle.kts b/sample/api/build.gradle.kts index 5ec3b61..2ccaad4 100644 --- a/sample/api/build.gradle.kts +++ b/sample/api/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("multiplatform") + kotlin("jvm") } repositories { @@ -8,18 +8,18 @@ repositories { kotlin { jvmToolchain(21) - // Two targets are declared so Kotlin actually runs a real intermediate "metadata" - // compilation of commonMain (see plugin-build's README/notes): with a single target, - // commonMain gets folded directly into that target's compile, which then demands an - // actual immediately. With 2+ targets, commonMain compiles to its own klib/metadata - // artifact - unfulfilled expects are fine there, which is exactly what lets this module - // build a normal, standalone, dependable jar containing only its API surface. - jvm() - js(IR) { nodejs() } +} - sourceSets { - commonMain.dependencies { - implementation(project(":actualizer-annotations")) - } +// A plain, ordinary source set - not wired to `assemble`/`check`/`build` by default the way +// `main`/`test` are, so it's never compiled as part of this project's own build (no `-Xmulti-platform` +// needed here either). It exists purely so the Actualizer Gradle plugin in a leaf module can +// merge these .kt files into *its* compilation. See src/crossModuleApi/kotlin/.../Greeting.kt. +sourceSets { + create("crossModuleApi") { + kotlin.srcDir("src/crossModuleApi/kotlin") } } + +dependencies { + "crossModuleApiImplementation"(project(":actualizer-annotations")) +} diff --git a/sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt deleted file mode 100644 index 56d7608..0000000 --- a/sample/api/src/commonMain/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.kernelpanicsoft.sample.api - -import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect - -/** - * Actualized not by a platform source set in this module's own multiplatform hierarchy, but by - * a real, independently built Gradle module (`:sample:actual-jvm`) that isn't even a dependency - * of this one - see that module's `actualizer { actualizes(project(":sample:api")) }`. - */ -@CrossModuleExpect -expect fun greetingSuffix(): String - -fun greet(name: String): String = "Hello, $name! ${greetingSuffix()}" diff --git a/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt new file mode 100644 index 0000000..c384a1b --- /dev/null +++ b/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -0,0 +1,13 @@ +package net.kernelpanicsoft.sample.api + +import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect + +/** + * Lives in the `crossModuleApi` source set, not `main` - `:api` never compiles this itself (see + * `build.gradle.kts`: no compile task is wired up for this source set here), so the fact that + * it's an unfulfilled `expect` never causes `:api`'s own build to fail. It's only ever compiled + * when a leaf module merges it in via `actualizer { actualizes(project(":sample:api")) }` - + * `:sample:actual-jvm` in this repo - which is where the real `actual` lives too. + */ +@CrossModuleExpect +expect fun greetingSuffix(): String diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt new file mode 100644 index 0000000..267c9d1 --- /dev/null +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -0,0 +1,8 @@ +package net.kernelpanicsoft.sample.api + +/** + * Ordinary, self-contained code that doesn't touch `expect`/`actual` at all - part of `:api`'s + * regular `main` source set, so it's a completely normal JVM jar. `:feature-common` depends on + * this jar directly, the same way it would depend on any other JVM library. + */ +fun formatGreeting(name: String, suffix: String): String = "Hello, $name! $suffix" diff --git a/sample/feature-common/build.gradle.kts b/sample/feature-common/build.gradle.kts index 9305c5b..a093204 100644 --- a/sample/feature-common/build.gradle.kts +++ b/sample/feature-common/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - kotlin("multiplatform") + kotlin("jvm") } repositories { @@ -8,15 +8,9 @@ repositories { kotlin { jvmToolchain(21) - jvm() - js(IR) { nodejs() } +} - sourceSets { - commonMain.dependencies { - // An ordinary binary dependency on :sample:api's metadata jar - no plugin, no - // special wiring. Proves a common module can depend on another common module's - // artifact before any real `actual` exists anywhere. - implementation(project(":sample:api")) - } - } +dependencies { + // Ordinary project dependency on :api's plain JVM jar - no plugin, no special wiring. + implementation(project(":sample:api")) } diff --git a/sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt deleted file mode 100644 index a2aebaa..0000000 --- a/sample/feature-common/src/commonMain/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.kernelpanicsoft.sample.feature - -import net.kernelpanicsoft.sample.api.greet - -fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt new file mode 100644 index 0000000..4802b06 --- /dev/null +++ b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt @@ -0,0 +1,11 @@ +package net.kernelpanicsoft.sample.feature + +import net.kernelpanicsoft.sample.api.formatGreeting + +/** + * Ordinary `implementation(project(":sample:api"))` dependency on `:api`'s plain, standalone JVM + * jar - no plugin, no multiplatform, nothing special. Proves a "common" module can depend on + * another one before any `actual` exists anywhere: `formatGreeting` lives in `:api`'s regular + * `main` source set, which never touches the unfulfilled `expect` in `crossModuleApi` at all. + */ +fun welcomeMessage(name: String): String = "[feature-common] " + formatGreeting(name, "welcome!") From 2b9e8d064de405002dc3332eac46cb12a769d986 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 00:57:17 +0000 Subject: [PATCH 04/19] Let other common modules chain into the same expect via delegates :api's crossModuleApi now also has a plain (non-expect) greet() that delegates to greetingSuffix(). :sample:feature-common gets its own deferred crossModuleApi source set with welcomeMessage(), which calls :api's greet() - the same never-compiled-locally convention as :api. :sample:actual-jvm's actualizer{} block now merges both modules' crossModuleApi sources (actualizes() already supported multiple calls, no plugin code changes needed), composing the whole chain into one jar that :app consumes normally. Along the way, fixed a same-package/same-filename collision: :api's main and crossModuleApi source sets both had a Greeting.kt, producing colliding GreetingKt facade classes that silently shadowed each other on the runtime classpath (NoSuchMethodError). Renamed the crossModuleApi file to ExpectApi.kt and documented the gotcha in the README. Verified end-to-end: :sample:app:run prints "[feature-common] Hello, world! (actualized independently by :sample:actual-jvm)", full clean build stays JS/Node-free, and the missing-actual negative path still fails at compile time. --- README.md | 92 +++++++++++++------ sample/actual-jvm/build.gradle.kts | 1 + .../net/kernelpanicsoft/sample/api/Actual.kt | 4 - .../sample/api/{Greeting.kt => ExpectApi.kt} | 9 ++ .../net/kernelpanicsoft/sample/app/Main.kt | 4 +- sample/feature-common/build.gradle.kts | 10 ++ .../sample/feature/FeatureExpectApi.kt | 13 +++ 7 files changed, 99 insertions(+), 34 deletions(-) rename sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/{Greeting.kt => ExpectApi.kt} (54%) create mode 100644 sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt diff --git a/README.md b/README.md index d8fa9ec..3f3b3aa 100644 --- a/README.md +++ b/README.md @@ -7,17 +7,21 @@ source-set hierarchy as the expect. Every module involved is a plain `kotlin("jv nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. ``` -:sample:api expect fun greetingSuffix(): String (no actual anywhere in here) -:sample:feature-common ordinary dependency on :sample:api's jar (no actual needed yet either) -:sample:actual-jvm actual fun greetingSuffix(): String = "..." (unrelated Gradle module) -:sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked function) +:sample:api expect fun greetingSuffix(): String, and a delegate fun greet(name) + that calls it - both unfulfilled/uncallable in :api's own build +:sample:feature-common depends on :api's plain jar, AND has its own delegate + fun welcomeMessage(name) that calls :api's greet() +:sample:actual-jvm actual fun greetingSuffix() - merges BOTH :api's and + :feature-common's expect-dependent sources in, unrelated Gradle module +:sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked chain) ``` -`:sample:actual-jvm` never declares a dependency on `:sample:api`. `:sample:app` never even knows -`:sample:api` exists. Running `:sample:app:run` prints: +`:sample:actual-jvm` never declares a dependency on `:sample:api`'s or `:sample:feature-common`'s +expect-dependent code path. `:sample:app` never even knows `:sample:api` or `:sample:feature-common` +exist. Running `:sample:app:run` prints: ``` -Hello, world! (actualized independently by :sample:actual-jvm) +[feature-common] Hello, world! (actualized independently by :sample:actual-jvm) ``` All published modules (`actualizer-annotations`, `compiler-plugin`, `gradle-plugin`) use the @@ -54,21 +58,40 @@ artifact at all. `:sample:feature-common` depends on that jar with a plain `implementation(project(":sample:api"))`, proving a "common" module can depend on another one before any `actual` exists anywhere, using nothing but an ordinary Gradle project dependency. -**2. The actual linking happens in the leaf module, via source merging.** +**1b. Other common modules can also write code against the not-yet-actualized expect - via a +delegate, in their own deferred source set.** A module can't *directly* import an unfulfilled +`expect` the normal way (unresolved reference) any more than `:api` itself can compile it. But +`:api`'s `crossModuleApi` also holds an ordinary (non-`expect`) function, +`fun greet(name: String) = formatGreeting(name, greetingSuffix())`, that delegates to the expect - +just regular code, deferred alongside it because it can't run until the expect can either. +`:sample:feature-common` has the exact same shape: a plain `main` (its own standalone jar) plus +its own `crossModuleApi` source set containing `fun welcomeMessage(name) = "[feature-common] " + +greet(name)`, which calls `:api`'s `greet()`. Neither `:api`'s nor `:feature-common`'s deferred +source set is ever compiled by its own project - see the `sourceSets { create("crossModuleApi") }` +block in each `build.gradle.kts`. + +**2. The actual linking happens in the leaf module, via source merging - of multiple foreign +modules at once.** `:sample:actual-jvm` is also a plain `kotlin("jvm")` module, with `id("net.kernelpanicsoft.actualizer")` applied and: ```kotlin actualizer { - actualizes(project(":sample:api")) // defaults to merging its "crossModuleApi" source set + actualizes(project(":sample:api")) // its "crossModuleApi": greetingSuffix, greet + actualizes(project(":sample:feature-common")) // its "crossModuleApi": welcomeMessage } ``` -The Actualizer Gradle plugin pulls `:sample:api`'s `crossModuleApi` source *directory* (not a -compiled artifact - there isn't one, since that source set is never independently compiled) into -`:sample:actual-jvm`'s own `main` source set, and adds two compiler flags to its `compileKotlin` -task: +`actualizes(...)` can be called more than once - the Gradle plugin merges every foreign +`crossModuleApi` source set it's given into the same single compiler invocation, alongside +`:actual-jvm`'s own `actual`. That's how `:feature-common`'s `welcomeMessage()` (which calls +`:api`'s `greet()`, which calls the `expect`) ends up compiled, resolved, and packaged into one +jar without `:feature-common` or `:api` ever depending on each other's deferred code, and without +`:actual-jvm` depending on `:feature-common` at all for anything else. The Actualizer Gradle +plugin pulls each foreign `crossModuleApi` source *directory* (not a compiled artifact - there +isn't one, since that source set is never independently compiled) into `:sample:actual-jvm`'s own +`main` source set, and adds two compiler flags to its `compileKotlin` task: ``` -Xmulti-platform --Xcommon-sources= +-Xcommon-sources= ``` This is the same mechanism the Kotlin Gradle Plugin itself uses under the hood for JVM multiplatform targets - "common" and "platform" sources compiled together in one invocation, with @@ -103,8 +126,9 @@ mechanism *doesn't* give you for free: time `IrGenerationExtension.generate()` runs.) **4. `:sample:app` needs nothing special.** It has an ordinary -`implementation(project(":sample:actual-jvm"))` dependency and calls `greet()` normally. It never -applies the Actualizer plugin and never references `:sample:api`. +`implementation(project(":sample:actual-jvm"))` dependency and calls `welcomeMessage()` normally. +It never applies the Actualizer plugin and never references `:sample:api` or +`:sample:feature-common` directly - it only sees `:actual-jvm`'s single, already-composed jar. ## What was verified @@ -112,15 +136,16 @@ Everything above was actually built and run in this environment with Gradle 8.14 / JDK 21, not just designed on paper: - `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces a plain JVM jar - (`net/.../GreetingKt.class`, real bytecode, no metadata/klib format) containing an unfulfilled - `expect` that `:api` itself never even attempts to compile. + (real bytecode, no metadata/klib format) containing an unfulfilled `expect` (plus a delegate + function calling it) that `:api` itself never even attempts to compile. - `./gradlew :sample:feature-common:build` - succeeds depending only on `:sample:api`'s plain jar, - before any actual exists anywhere. -- `./gradlew :sample:actual-jvm:build` - merges `:sample:api`'s `crossModuleApi` source and links - it against the real actual; `build/actualizer/report.json` shows the link with correct module - provenance. -- `./gradlew :sample:app:run` - prints the value produced by `:sample:actual-jvm`'s `actual`, - with zero special wiring in `:app` itself. + before any actual exists anywhere, and has its own unfulfilled-expect-dependent delegate too. +- `./gradlew :sample:actual-jvm:build` - merges *both* `:sample:api`'s and + `:sample:feature-common`'s `crossModuleApi` sources and links them against the one real actual; + `build/actualizer/report.json` shows the link with correct module provenance. +- `./gradlew :sample:app:run` - prints `[feature-common] Hello, world! (actualized independently + by :sample:actual-jvm)`, the full chain composed and produced by `:sample:actual-jvm`'s single + jar, with zero special wiring in `:app` itself. - `./gradlew build` at the repo root - builds the entire graph in one shot; grepping the task log confirms zero JS/Node/npm/Yarn tasks anywhere (no non-JVM tooling exists in this repo at all). - **Negative path**: deleting the `actual` declaration from `:sample:actual-jvm` and re-running @@ -141,10 +166,12 @@ plugin-build/ composite build (keeps the plugin's own Kotlin vers gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) } DSL, source-directory merging, -Xcommon-sources/-Xmulti-platform wiring sample/ - api/ plain kotlin("jvm"); expect lives in a non-default "crossModuleApi" - source set, main is an ordinary standalone jar - feature-common/ depends on api's plain jar like any common module would - actual-jvm/ applies the plugin; the real cross-module link happens here + api/ plain kotlin("jvm"); expect + a delegate fun live in a non-default + "crossModuleApi" source set, main is an ordinary standalone jar + feature-common/ depends on api's plain jar (main) AND has its own "crossModuleApi" + delegate that calls api's greet() + actual-jvm/ applies the plugin; merges BOTH api's and feature-common's + crossModuleApi sources; the real actual lives here app/ plain binary consumer of actual-jvm, no special wiring ``` @@ -181,3 +208,12 @@ sample/ "no actual declaration" frontend error, since that invocation has no `-Xmulti-platform`/ `-Xcommon-sources` wiring at all. That's expected and harmless (nothing depends on that task succeeding), but it's worth knowing it's reachable if invoked explicitly. +- **Merged source files must have collision-free file-facade names.** Kotlin compiles each + top-level-function file to a `Kt` JVM class. `:api`'s `main` and `crossModuleApi` + source sets are compiled *separately* (different jars/classpath entries) but share a package, + so if two files in the same package across the two source sets had the same name, they'd + produce two same-named classes that silently shadow each other on the runtime classpath + (whichever jar loads first wins, with a `NoSuchMethodError` for whatever the other one had) - + this bit us during development (`Greeting.kt` in both `main` and `crossModuleApi`) and is why + the merged file is named `ExpectApi.kt`, not `Greeting.kt`. Worth remembering when adding more + cross-module modules: keep filenames distinct within a shared package across source sets. diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts index 29156f2..bcb4cd0 100644 --- a/sample/actual-jvm/build.gradle.kts +++ b/sample/actual-jvm/build.gradle.kts @@ -20,4 +20,5 @@ dependencies { actualizer { actualizes(project(":sample:api")) + actualizes(project(":sample:feature-common")) } diff --git a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 629260a..63790e0 100644 --- a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -7,7 +7,3 @@ import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect @CrossModuleExpect actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)" - -// formatGreeting comes from :api's ordinary main jar - a normal binary dependency, combined -// here with the just-linked actual to produce the final, callable function. -fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt similarity index 54% rename from sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt rename to sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt index c384a1b..5829cde 100644 --- a/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ b/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt @@ -11,3 +11,12 @@ import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect */ @CrossModuleExpect expect fun greetingSuffix(): String + +/** + * A non-expect declaration that delegates to the expect above. It doesn't need the `expect` + * keyword or `@CrossModuleExpect` itself - it's ordinary code - but it can't be called until + * `greetingSuffix()` is actualized either, so it lives right next to it in this same deferred + * source set rather than in `main`. `formatGreeting` is resolved via `:api`'s own compiled + * `main` jar (a normal classpath dependency wherever this file ends up merged). + */ +fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt index 80cea12..2e3cbe8 100644 --- a/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt +++ b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt @@ -1,7 +1,7 @@ package net.kernelpanicsoft.sample.app -import net.kernelpanicsoft.sample.api.greet +import net.kernelpanicsoft.sample.feature.welcomeMessage fun main() { - println(greet("world")) + println(welcomeMessage("world")) } diff --git a/sample/feature-common/build.gradle.kts b/sample/feature-common/build.gradle.kts index a093204..52924af 100644 --- a/sample/feature-common/build.gradle.kts +++ b/sample/feature-common/build.gradle.kts @@ -14,3 +14,13 @@ dependencies { // Ordinary project dependency on :api's plain JVM jar - no plugin, no special wiring. implementation(project(":sample:api")) } + +// Same convention as :api - a deferred source set for code that (transitively) depends on an +// unfulfilled expect. Not wired into assemble/check/build, so :feature-common's own build never +// touches it; only a leaf module's actualizer { actualizes(project(":sample:feature-common")) } +// merges it in. +sourceSets { + create("crossModuleApi") { + kotlin.srcDir("src/crossModuleApi/kotlin") + } +} diff --git a/sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt b/sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt new file mode 100644 index 0000000..ec931df --- /dev/null +++ b/sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt @@ -0,0 +1,13 @@ +package net.kernelpanicsoft.sample.feature + +import net.kernelpanicsoft.sample.api.greet + +/** + * Like `:api`'s `crossModuleApi`, `:feature-common` never compiles this itself - see + * `build.gradle.kts`. `greet()` comes from `:api`'s own `crossModuleApi` source set, not a + * compiled jar: this only resolves once some leaf module merges *both* `:api`'s and + * `:feature-common`'s `crossModuleApi` sources into the same compilation (`:sample:actual-jvm` + * does exactly that), proving a "common" module's expect-dependent code can build on another + * common module's expect-dependent code, chained through the same `actualizes(...)` mechanism. + */ +fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) From ac8d4a5c40f237655f63f76621456e610fd61bfc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:18:29 +0000 Subject: [PATCH 05/19] Auto-generate throwing expect stubs, eliminate the crossModuleApi split Adds actualizer { stubUnfulfilledExpects() }: scans a module's main source set for `expect fun` declarations (a deliberately scoped regex scanner, see ExpectStubGenerator.kt) and generates a matching, throwing `actual` stub for each, so a module with an unfulfilled expect compiles into one completely normal, standalone jar instead of needing a separate never-compiled source set. New actualizer-runtime module holds ActualizerNotLinkedError, thrown if the stub is ever actually called. :sample:api and :sample:feature-common go back to a single ordinary "main" source set - no more crossModuleApi. :sample:actual-jvm's actualizes() now merges each foreign project's "main" (the new default), explicitly excluding anything under that project's own build/ directory so its generated stub never gets merged in alongside the real actual. actualizer plugin is no longer needed on :feature-common at all - it's just a normal kotlin("jvm") module now. This only simplifies module layout; the underlying constraint is unchanged and documented: a stub baked into a standalone jar's bytecode can never be "re-linked" to the real implementation later, so real, working access still only exists wherever a leaf module performs the actualizes() merge, same as before. Verified: full clean build, :sample:app:run prints the correct chain, a throwaway consumer pointed only at :api's jar gets ActualizerNotLinkedError as designed, and the missing-actual negative path still fails at compile time. --- README.md | 176 ++++++++++-------- actualizer-runtime/build.gradle.kts | 14 ++ .../runtime/ActualizerNotLinkedError.kt | 13 ++ .../actualizer/gradle/ActualizerExtension.kt | 27 ++- .../gradle/ActualizerGradlePlugin.kt | 87 +++++++-- .../actualizer/gradle/ExpectStubGenerator.kt | 51 +++++ sample/actual-jvm/build.gradle.kts | 6 +- sample/api/build.gradle.kts | 16 +- .../kernelpanicsoft/sample/api/ExpectApi.kt | 22 --- .../kernelpanicsoft/sample/api/Greeting.kt | 20 +- sample/feature-common/build.gradle.kts | 10 - .../sample/feature/FeatureExpectApi.kt | 13 -- .../kernelpanicsoft/sample/feature/Feature.kt | 11 +- settings.gradle.kts | 1 + 14 files changed, 291 insertions(+), 176 deletions(-) create mode 100644 actualizer-runtime/build.gradle.kts create mode 100644 actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt create mode 100644 plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt delete mode 100644 sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt delete mode 100644 sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt diff --git a/README.md b/README.md index 3f3b3aa..05c50db 100644 --- a/README.md +++ b/README.md @@ -7,25 +7,24 @@ source-set hierarchy as the expect. Every module involved is a plain `kotlin("jv nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. ``` -:sample:api expect fun greetingSuffix(): String, and a delegate fun greet(name) - that calls it - both unfulfilled/uncallable in :api's own build -:sample:feature-common depends on :api's plain jar, AND has its own delegate - fun welcomeMessage(name) that calls :api's greet() -:sample:actual-jvm actual fun greetingSuffix() - merges BOTH :api's and - :feature-common's expect-dependent sources in, unrelated Gradle module +:sample:api expect fun greetingSuffix(): String, plus ordinary code (formatGreeting, + greet) that calls it - a single, normal source set, one standalone jar +:sample:feature-common depends on :api's plain jar; welcomeMessage() calls :api's greet() +:sample:actual-jvm actual fun greetingSuffix() - merges BOTH :api's and :feature-common's + hand-written source in, unrelated Gradle module to either of them :sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked chain) ``` -`:sample:actual-jvm` never declares a dependency on `:sample:api`'s or `:sample:feature-common`'s -expect-dependent code path. `:sample:app` never even knows `:sample:api` or `:sample:feature-common` -exist. Running `:sample:app:run` prints: +`:sample:actual-jvm` never declares a dependency on `:sample:api` or `:sample:feature-common`. +`:sample:app` never even knows either of them exists. Running `:sample:app:run` prints: ``` [feature-common] Hello, world! (actualized independently by :sample:actual-jvm) ``` -All published modules (`actualizer-annotations`, `compiler-plugin`, `gradle-plugin`) use the -`net.kernelpanicsoft` Maven group; the plugin/package namespace is `net.kernelpanicsoft.actualizer`. +All published modules (`actualizer-annotations`, `actualizer-runtime`, `compiler-plugin`, +`gradle-plugin`) use the `net.kernelpanicsoft` Maven group; the plugin/package namespace is +`net.kernelpanicsoft.actualizer`. ## Why this needs a plugin at all @@ -40,35 +39,37 @@ rewrite already-emitted `.class` files to redirect a call. So "literal `expect`/ Gradle modules" can only work if the modules' **source files** end up merged into one compiler invocation - there is no alternative that doesn't abandon the real keyword. +That constraint also means a module compiled *before* any real `actual` exists can never call the +real, eventually-linked implementation through its own already-compiled jar - only through source +that gets merged downstream. An auto-generated stub (see below) can make an unfulfilled `expect` +compile standalone, but calling it via that standalone jar directly always throws; the real, +working chain only exists wherever a leaf module actually performs the merge. + ## How it actually works -**1. The "common" module is a completely ordinary JVM jar.** -`:sample:api` is a plain `kotlin("jvm")` module. Its `expect fun greetingSuffix()` doesn't live in -`main` - it lives in a second, non-default source set called `crossModuleApi` -(`src/crossModuleApi/kotlin/...`), created with an ordinary Gradle -`sourceSets { create("crossModuleApi") { ... } }` block. Gradle/Kotlin auto-generates a -`compileCrossModuleApiKotlin` task for it, but - unlike `main`/`test` - that task is **not** wired -into `assemble`/`check`/`build`, so `:sample:api` never compiles it as part of its own build. The -`expect` keyword needs `-Xmulti-platform` to even parse, and a *compiled* target would need the -expect fulfilled - but since this source set is simply never compiled by `:api` itself, neither -requirement ever applies to `:api`'s own build. Its `main` source set has no `expect`/`actual` in -it at all, so `./gradlew :sample:api:build` produces a normal `.jar` with real, ordinary JVM -bytecode - no multiplatform machinery, no metadata/klib format, nothing Kotlin-specific about the -artifact at all. `:sample:feature-common` depends on that jar with a plain -`implementation(project(":sample:api"))`, proving a "common" module can depend on another one -before any `actual` exists anywhere, using nothing but an ordinary Gradle project dependency. - -**1b. Other common modules can also write code against the not-yet-actualized expect - via a -delegate, in their own deferred source set.** A module can't *directly* import an unfulfilled -`expect` the normal way (unresolved reference) any more than `:api` itself can compile it. But -`:api`'s `crossModuleApi` also holds an ordinary (non-`expect`) function, -`fun greet(name: String) = formatGreeting(name, greetingSuffix())`, that delegates to the expect - -just regular code, deferred alongside it because it can't run until the expect can either. -`:sample:feature-common` has the exact same shape: a plain `main` (its own standalone jar) plus -its own `crossModuleApi` source set containing `fun welcomeMessage(name) = "[feature-common] " + -greet(name)`, which calls `:api`'s `greet()`. Neither `:api`'s nor `:feature-common`'s deferred -source set is ever compiled by its own project - see the `sourceSets { create("crossModuleApi") }` -block in each `build.gradle.kts`. +**1. The "common" module is a completely ordinary JVM jar - via an auto-generated stub, not +runtime dispatch.** +`:sample:api` is a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` and +`actualizer { stubUnfulfilledExpects() }`. Its `expect fun greetingSuffix()` lives directly in the +ordinary `main` source set, right next to regular code (`formatGreeting`, and `greet()`, which +calls the expect). Before compiling, the Actualizer Gradle plugin scans `main`'s `.kt` files for +`expect fun` declarations (a small regex-based scan - see "Known limitations") and generates a +matching, throwing `actual` stub for each into `build/generated/actualizer-stubs/...` +(`ExpectStubGenerator.kt`), e.g.: +```kotlin +actual fun greetingSuffix(): String = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.greetingSuffix") +``` +That generated file is added as an extra `main` source directory, and `-Xmulti-platform` + +`-Xcommon-sources=` get set on `compileKotlin` - the exact +same "compile common + platform sources together" mechanism used everywhere else in this repo +(see part 2), just with a machine-written `actual` instead of a hand-written one. The real Kotlin +frontend links the (real) expect against the (stub) actual and compiles successfully, so +`./gradlew :sample:api:build` produces one normal `.jar` with real, ordinary JVM bytecode - no +metadata/klib format, no split source sets. `:sample:feature-common` depends on that jar with a +plain `implementation(project(":sample:api"))` and calls `greet()` normally - the call compiles +and links fine (it's not calling an `expect`, it's calling an ordinary function that happens to +call one). Calling it *through this standalone chain* throws `ActualizerNotLinkedError` at +runtime, because it's bound to the stub - see part 2 for how the real chain gets built instead. **2. The actual linking happens in the leaf module, via source merging - of multiple foreign modules at once.** @@ -76,22 +77,25 @@ modules at once.** applied and: ```kotlin actualizer { - actualizes(project(":sample:api")) // its "crossModuleApi": greetingSuffix, greet - actualizes(project(":sample:feature-common")) // its "crossModuleApi": welcomeMessage + actualizes(project(":sample:api")) // greetingSuffix, formatGreeting, greet + actualizes(project(":sample:feature-common")) // welcomeMessage } ``` -`actualizes(...)` can be called more than once - the Gradle plugin merges every foreign -`crossModuleApi` source set it's given into the same single compiler invocation, alongside -`:actual-jvm`'s own `actual`. That's how `:feature-common`'s `welcomeMessage()` (which calls -`:api`'s `greet()`, which calls the `expect`) ends up compiled, resolved, and packaged into one -jar without `:feature-common` or `:api` ever depending on each other's deferred code, and without -`:actual-jvm` depending on `:feature-common` at all for anything else. The Actualizer Gradle -plugin pulls each foreign `crossModuleApi` source *directory* (not a compiled artifact - there -isn't one, since that source set is never independently compiled) into `:sample:actual-jvm`'s own -`main` source set, and adds two compiler flags to its `compileKotlin` task: +`actualizes(...)` can be called more than once - the Gradle plugin merges every foreign project's +`main` source set (their *hand-written* source only; see below) into the same single compiler +invocation, alongside `:actual-jvm`'s own real `actual`. That's how `:feature-common`'s +`welcomeMessage()` (which calls `:api`'s `greet()`, which calls the `expect`) ends up compiled, +resolved, and packaged into one jar with the *real* chain intact - without `:feature-common` or +`:api` ever depending on each other, and without `:actual-jvm` depending on either of their +compiled jars at all (see "Known limitations" for why that specifically has to be avoided). The +Actualizer Gradle plugin pulls each foreign project's hand-written source *directory* - explicitly +excluding anything under that foreign project's own `build/` directory, so its *generated* stub +never gets merged in too (that would conflict with the real `actual` this leaf module provides) - +into `:sample:actual-jvm`'s own `main` source set, and adds two compiler flags to its +`compileKotlin` task: ``` -Xmulti-platform --Xcommon-sources= +-Xcommon-sources= ``` This is the same mechanism the Kotlin Gradle Plugin itself uses under the hood for JVM multiplatform targets - "common" and "platform" sources compiled together in one invocation, with @@ -102,7 +106,7 @@ compiler flags not officially supported for this kind of use outside the Kotlin itself; they could change behavior between Kotlin versions. This repo pins Kotlin `2.0.21`, where the mechanism was verified to work exactly as described (see "What was verified" below). -**3. The IR plugin reports on what got linked (and enforces an opt-in policy).** +**3. The IR plugin reports on what got linked.** `ActualizerIrExtension` (an `IrGenerationExtension`) runs inside that merged compilation. By the time it runs, the frontend has *already* resolved (or already failed the build over) every expect/actual pair - IR generation only happens after a successful frontend pass, so there is no @@ -135,14 +139,17 @@ It never applies the Actualizer plugin and never references `:sample:api` or Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 / JDK 21, not just designed on paper: -- `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces a plain JVM jar - (real bytecode, no metadata/klib format) containing an unfulfilled `expect` (plus a delegate - function calling it) that `:api` itself never even attempts to compile. +- `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces one plain JVM jar + (real bytecode, no metadata/klib format) with both the real `formatGreeting`/`greet` and the + auto-generated throwing stub for `greetingSuffix`. +- Calling `greet()` via `:api`'s standalone jar directly (verified with a throwaway consumer + project pointed at just that jar) throws `ActualizerNotLinkedError` with a clear message, as + designed. - `./gradlew :sample:feature-common:build` - succeeds depending only on `:sample:api`'s plain jar, - before any actual exists anywhere, and has its own unfulfilled-expect-dependent delegate too. + before any actual exists anywhere. - `./gradlew :sample:actual-jvm:build` - merges *both* `:sample:api`'s and - `:sample:feature-common`'s `crossModuleApi` sources and links them against the one real actual; - `build/actualizer/report.json` shows the link with correct module provenance. + `:sample:feature-common`'s hand-written `main` sources and links them against the one real + actual; `build/actualizer/report.json` shows the link with correct module provenance. - `./gradlew :sample:app:run` - prints `[feature-common] Hello, world! (actualized independently by :sample:actual-jvm)`, the full chain composed and produced by `:sample:actual-jvm`'s single jar, with zero special wiring in `:app` itself. @@ -159,19 +166,22 @@ Everything above was actually built and run in this environment with Gradle 8.14 ``` actualizer-annotations/ @CrossModuleExpect marker (plain kotlin("jvm")) +actualizer-runtime/ ActualizerNotLinkedError, thrown by auto-generated stubs plugin-build/ composite build (keeps the plugin's own Kotlin version pinned independently of consumers, standard Kotlin-compiler-plugin layout) compiler-plugin/ the IR compiler plugin itself (CommandLineProcessor, CompilerPluginRegistrar, ActualizerIrExtension) - gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) } DSL, - source-directory merging, -Xcommon-sources/-Xmulti-platform wiring + gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) / + stubUnfulfilledExpects() } DSL, source-directory merging, + stub generation (ExpectStubGenerator.kt), -Xcommon-sources/ + -Xmulti-platform wiring sample/ - api/ plain kotlin("jvm"); expect + a delegate fun live in a non-default - "crossModuleApi" source set, main is an ordinary standalone jar - feature-common/ depends on api's plain jar (main) AND has its own "crossModuleApi" - delegate that calls api's greet() + api/ plain kotlin("jvm"), one "main" source set; expect + ordinary code + side by side; stubUnfulfilledExpects() keeps it standalone-buildable + feature-common/ depends on api's plain jar, nothing special - no actualizer plugin + needed on this module at all actual-jvm/ applies the plugin; merges BOTH api's and feature-common's - crossModuleApi sources; the real actual lives here + hand-written main sources; the real actual lives here app/ plain binary consumer of actual-jvm, no special wiring ``` @@ -187,9 +197,13 @@ sample/ paths). - **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` are not a supported public API for third-party use; a future Kotlin release could change or remove this - behavior without notice. This is the tradeoff of keeping the literal `expect`/`actual` keywords - working across Gradle modules, as opposed to an annotation-based runtime-dispatch design (which - would be more stable but wouldn't use the real keywords). + behavior without notice. +- **The `expect` stub scanner is a deliberately limited regex, not a real parser.** It matches + single-line `expect fun name(params): ReturnType` declarations only - no generics, no + `suspend`/extension receivers, no multi-line parameter lists, no `expect class`/`expect val`. A + real implementation would parse Kotlin PSI properly; this repo's scanner (`ExpectStubGenerator.kt`) + covers exactly the shape used in the sample and documents the gap rather than pretending to be + general. - **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin` deliberately avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, `KotlinCompile`, etc.) and uses reflection-by-name instead - `plugin-build` resolves its own @@ -198,22 +212,22 @@ sample/ `extensions.findByType(...)` / `tasks.withType(...)` silently find nothing across that boundary. This is a real, somewhat unusual wrinkle of the composite-build layout, documented in code comments in `ActualizerGradlePlugin.kt`. +- **A leaf module must not depend on the compiled jar of a project it also `actualizes(...)`.** + `:actual-jvm` merges `:api`'s and `:feature-common`'s *source*; if it also depended on their + compiled jars, the JVM would see two different compiled definitions of the same + package/class/method (one from the merged source, one from the binary dependency), and + whichever ends up first on the classpath silently wins - the same class of bug documented below + for file-facade names, just at the module-dependency level instead. Keep leaf modules' + dependencies limited to things they *don't* also merge as source. - One-actual-per-expect only; no support for choosing between multiple candidate actual-providing modules (e.g. per build flavor) - `actualizes(...)` merges in all of them unconditionally, and the *last* declaration compiled for a given fully-qualified name wins/conflicts per ordinary Kotlin rules. -- **The `crossModuleApi` source set is inert by convention, not by enforcement.** Nothing stops - someone from running `./gradlew :sample:api:compileCrossModuleApiKotlin` directly (it exists as - a real task, just isn't wired into `build`/`assemble`/`check`) - doing so fails with the same - "no actual declaration" frontend error, since that invocation has no `-Xmulti-platform`/ - `-Xcommon-sources` wiring at all. That's expected and harmless (nothing depends on that task - succeeding), but it's worth knowing it's reachable if invoked explicitly. - **Merged source files must have collision-free file-facade names.** Kotlin compiles each - top-level-function file to a `Kt` JVM class. `:api`'s `main` and `crossModuleApi` - source sets are compiled *separately* (different jars/classpath entries) but share a package, - so if two files in the same package across the two source sets had the same name, they'd - produce two same-named classes that silently shadow each other on the runtime classpath - (whichever jar loads first wins, with a `NoSuchMethodError` for whatever the other one had) - - this bit us during development (`Greeting.kt` in both `main` and `crossModuleApi`) and is why - the merged file is named `ExpectApi.kt`, not `Greeting.kt`. Worth remembering when adding more - cross-module modules: keep filenames distinct within a shared package across source sets. + top-level-function file to a `Kt` JVM class. If two files merged into the same + compilation share both a package and a filename, they'd produce two same-named classes that + silently shadow each other on the runtime classpath (whichever loads first wins, with a + `NoSuchMethodError` for whatever the other one had) - this bit us during development (`:api` + briefly had `Greeting.kt` in two different source sets with the same package) and is worth + remembering when adding more cross-module modules: keep filenames distinct within a shared + package across anything that might get merged together. diff --git a/actualizer-runtime/build.gradle.kts b/actualizer-runtime/build.gradle.kts new file mode 100644 index 0000000..fcb9182 --- /dev/null +++ b/actualizer-runtime/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + kotlin("jvm") +} + +group = "net.kernelpanicsoft" +version = "0.1.0" + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} diff --git a/actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt b/actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt new file mode 100644 index 0000000..bea111f --- /dev/null +++ b/actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt @@ -0,0 +1,13 @@ +package net.kernelpanicsoft.actualizer.runtime + +/** + * Thrown by an auto-generated stub `actual` - see `actualizer { stubUnfulfilledExpects() }`. + * A stub exists purely so a module with an unfulfilled `expect` can still compile into a normal, + * standalone jar; calling into it directly (rather than via a module where the real `actual` was + * merged in and linked) means the real implementation was never linked here. + */ +class ActualizerNotLinkedError(fqName: String) : IllegalStateException( + "$fqName was never actualized - this jar was built standalone with an auto-generated stub " + + "(see actualizer { stubUnfulfilledExpects() }). Depend on a module that actually links " + + "it (an actualizer { actualizes(...) } leaf) to get the real implementation." +) diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt index d2031a3..d75dab5 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt @@ -4,27 +4,34 @@ import org.gradle.api.Project import org.gradle.api.model.ObjectFactory import javax.inject.Inject -/** Default name of the source set Actualizer looks for on a foreign, expect-declaring project. */ -const val DEFAULT_CROSS_MODULE_SOURCE_SET = "crossModuleApi" +/** Default name of the source set Actualizer merges in from a foreign, expect-declaring project. */ +const val DEFAULT_CROSS_MODULE_SOURCE_SET = "main" internal data class ActualizedSource(val project: Project, val sourceSetName: String) /** - * `actualizer { actualizes(project(":api")) }` DSL applied to a "leaf" JVM module that provides - * real `actual` declarations for `expect`s declared in an unrelated, independently built Gradle - * module. + * `actualizer { }` DSL with two independent capabilities: * - * The foreign project is a plain `kotlin("jvm")` module - no multiplatform, no extra platform - * targets. Its `expect` declarations live in a dedicated, non-default source set (named - * `crossModuleApi` by convention) that the foreign project never compiles itself, so its - * *ordinary* `main` source set stays a completely normal, standalone JVM jar other modules can - * depend on before anything is actualized. + * - `actualizes(project(":api"))`, applied to a "leaf" JVM module that provides real `actual` + * declarations for `expect`s declared in one or more unrelated, independently built Gradle + * modules. Merges each foreign project's named source set (`main` by default) as source into + * this compilation, alongside the real `actual`. + * - `stubUnfulfilledExpects()`, applied to the *expect-declaring* module itself, so its own + * `expect`s get an auto-generated, throwing `actual` stub and its `main` source set compiles + * into a completely normal, standalone jar - see `GenerateActualStubsTask`. Calling code that + * ends up bound to the stub (anything not itself merged into a real `actualizes(...)` leaf) + * throws `ActualizerNotLinkedError` at runtime rather than failing to compile. */ open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { internal val sources: MutableList = mutableListOf() + internal var stubUnfulfilledExpects: Boolean = false @JvmOverloads fun actualizes(project: Project, sourceSet: String = DEFAULT_CROSS_MODULE_SOURCE_SET) { sources += ActualizedSource(project, sourceSet) } + + fun stubUnfulfilledExpects() { + stubUnfulfilledExpects = true + } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index 187bda0..6d76bb0 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -11,17 +11,19 @@ private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-plugin:0.1.0" /** - * Wires a "leaf" JVM module up to merge in a named source set (`crossModuleApi` by default) of - * one or more foreign, expect-declaring Gradle modules (declared via - * `actualizer { actualizes(project(":api")) }`) so the real Kotlin compiler frontend resolves - * `expect`/`actual` across them, and registers the Actualizer IR compiler plugin on that same - * compilation so it can report on what got linked. + * Two independent things a plain `kotlin("jvm")` project can opt into via `actualizer { }`: * - * Both sides are plain `kotlin("jvm")` projects - no multiplatform plugin, no extra platform - * targets, nothing beyond the ordinary JVM toolchain. The foreign project's `expect` - * declarations live in a dedicated, non-default source set it never compiles itself (see - * `ActualizerExtension`), so its regular `main` jar stays a completely normal, standalone - * artifact. + * - `stubUnfulfilledExpects()`: on the expect-declaring module itself, scans its `main` source + * set for `expect fun` declarations and generates a matching, throwing `actual` stub for each + * (see `ExpectStubGenerator.kt`), then compiles `main` with `-Xmulti-platform` + + * `-Xcommon-sources` against that generated stub. This is what lets a module with an + * unfulfilled `expect` still produce a completely normal, standalone jar - real bytecode, one + * ordinary `main` source set, no crossModuleApi-style split needed. + * - `actualizes(project(":api"))`: on a "leaf" module providing the real `actual`, merges one or + * more foreign projects' source sets in as source (their *hand-written* source only - see the + * build-directory exclusion in `namedSourceSetDirs` below, which is what keeps a foreign + * project's own generated stub out of this merge, avoiding a duplicate-`actual` conflict) and + * registers the Actualizer IR compiler plugin so it can report on what got linked. * * This deliberately avoids importing Kotlin Gradle Plugin (KGP) types like `KotlinJvmProjectExtension` * or `KotlinCompile` directly: `plugin-build` resolves its own copy of `kotlin-gradle-plugin` @@ -38,11 +40,45 @@ class ActualizerGradlePlugin : Plugin { val extension = project.extensions.create("actualizer", ActualizerExtension::class.java) project.afterEvaluate { - if (extension.sources.isEmpty()) { - // Nothing to wire up - this project doesn't merge in any foreign expect module. - return@afterEvaluate + if (extension.stubUnfulfilledExpects) { + wireStubGeneration(project) + } + if (extension.sources.isNotEmpty()) { + wireCrossModuleActualization(project, extension) } - wireCrossModuleActualization(project, extension) + } + } + + private fun wireStubGeneration(project: Project) { + val mainDirs = namedSourceSetDirs(project, "main") + val mainFiles = mainDirs.filter { it.exists() }.flatMap { dir -> + dir.walkTopDown().filter { it.isFile && it.extension == "kt" } + } + val scanned = scanForExpectFunctions(mainFiles) + if (scanned.isEmpty()) { + project.logger.warn( + "[actualizer] '${project.path}' called stubUnfulfilledExpects() but no " + + "'expect fun' declarations were found in its main source set." + ) + return + } + + val outputDir = project.layout.buildDirectory.dir("generated/actualizer-stubs").get().asFile + outputDir.deleteRecursively() + for (file in scanned) { + val packageDir = File(outputDir, file.packageName.replace('.', '/')).apply { mkdirs() } + File(packageDir, "${file.sourceFile.nameWithoutExtension}Stub.kt") + .writeText(generateStubFileContent(file)) + } + + addSourceDir(project, "main", listOf(outputDir)) + + val commonSourcesValue = scanned.joinToString(",") { it.sourceFile.absolutePath } + val compileKotlinTask = project.tasks.named("compileKotlin") + compileKotlinTask.configure { task -> + task.inputs.files(mainFiles).withPropertyName("actualizerExpectScanInputs") + val freeCompilerArgs = freeCompilerArgsProperty(task) + freeCompilerArgs.addAll(listOf("-Xmulti-platform", "-Xcommon-sources=$commonSourcesValue")) } } @@ -91,11 +127,7 @@ class ActualizerGradlePlugin : Plugin { task.dependsOn(compilerPluginClasspath) task.inputs.files(compilerPluginClasspath).withPropertyName("actualizerCompilerPluginClasspath") - val compilerOptions = task.invokeGetter("getCompilerOptions") - ?: error("[actualizer] '${task.path}' has no compilerOptions; is it a Kotlin compile task?") - - @Suppress("UNCHECKED_CAST") - val freeCompilerArgs = compilerOptions.invokeGetter("getFreeCompilerArgs") as ListProperty + val freeCompilerArgs = freeCompilerArgsProperty(task) freeCompilerArgs.addAll( project.provider { @@ -121,11 +153,19 @@ class ActualizerGradlePlugin : Plugin { } } + /** + * The hand-written source directories of [sourceSetName] on [foreignProject] - explicitly + * excluding anything under that project's own build directory, so a foreign project's + * *generated* stub (from its own `stubUnfulfilledExpects()`) never gets pulled into a merge: + * merging both the real `expect` and the generated stub `actual` into the same compilation + * would conflict with the real `actual` this leaf module provides. + */ private fun namedSourceSetDirs(foreignProject: Project, sourceSetName: String): List { val sourceSets = kotlinSourceSets(foreignProject) val sourceSet = sourceSets.findByName(sourceSetName) ?: return emptyList() val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet - return kotlinDirSet.srcDirs.toList() + val buildDir = foreignProject.layout.buildDirectory.get().asFile.toPath() + return kotlinDirSet.srcDirs.filterNot { it.toPath().startsWith(buildDir) } } private fun addSourceDir(project: Project, sourceSetName: String, dirs: List) { @@ -142,6 +182,13 @@ class ActualizerGradlePlugin : Plugin { ?: error("[actualizer] '${project.path}' has a 'kotlin' extension with no source set container.") } + @Suppress("UNCHECKED_CAST") + private fun freeCompilerArgsProperty(task: Any): ListProperty { + val compilerOptions = task.invokeGetter("getCompilerOptions") + ?: error("[actualizer] a Kotlin compile task has no compilerOptions - is it really a KotlinCompile task?") + return compilerOptions.invokeGetter("getFreeCompilerArgs") as ListProperty + } + private fun Any.invokeGetter(methodName: String): Any? = javaClass.methods.firstOrNull { it.name == methodName && it.parameterCount == 0 }?.invoke(this) } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt new file mode 100644 index 0000000..6dff542 --- /dev/null +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -0,0 +1,51 @@ +package net.kernelpanicsoft.actualizer.gradle + +import java.io.File + +internal data class ExpectSignature(val name: String, val params: String, val returnType: String) + +internal data class ScannedExpectFile(val sourceFile: File, val packageName: String, val expects: List) + +// Deliberately scoped to a simple, single-line subset of Kotlin function syntax: no generics, no +// suspend/extension receivers, no expect classes/properties, no multi-line parameter lists. Real +// projects would want a proper PSI-based scan; this is a pragmatic, honestly-limited stand-in +// that covers straightforward `expect fun` declarations like the ones in this repo's sample. +private val PACKAGE_REGEX = Regex("""^\s*package\s+([\w.]+)\s*$""") +private val EXPECT_FUN_REGEX = Regex("""^\s*expect\s+fun\s+(\w+)\s*\(([^)]*)\)\s*(:\s*([^{=]+))?\s*$""") + +internal fun scanForExpectFunctions(files: Iterable): List { + return files.mapNotNull { file -> + if (!file.isFile || file.extension != "kt") return@mapNotNull null + var packageName = "" + val expects = mutableListOf() + file.forEachLine { line -> + PACKAGE_REGEX.find(line)?.let { packageName = it.groupValues[1] } + EXPECT_FUN_REGEX.find(line)?.let { match -> + val name = match.groupValues[1] + val params = match.groupValues[2].trim() + val returnType = match.groupValues[4].trim().ifEmpty { "Unit" } + expects += ExpectSignature(name, params, returnType) + } + } + if (expects.isEmpty()) null else ScannedExpectFile(file, packageName, expects) + } +} + +internal fun generateStubFileContent(scanned: ScannedExpectFile): String = buildString { + appendLine("// GENERATED by the Actualizer Gradle plugin (actualizer { stubUnfulfilledExpects() }).") + appendLine("// Source: ${scanned.sourceFile}") + appendLine("// Do not edit - regenerated on every build.") + appendLine("package ${scanned.packageName}") + appendLine() + appendLine("import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect") + appendLine("import net.kernelpanicsoft.actualizer.runtime.ActualizerNotLinkedError") + appendLine() + for (signature in scanned.expects) { + val fqName = "${scanned.packageName}.${signature.name}" + appendLine("@CrossModuleExpect") + appendLine( + "actual fun ${signature.name}(${signature.params}): ${signature.returnType} = " + + "throw ActualizerNotLinkedError(\"$fqName\")" + ) + } +} diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts index bcb4cd0..ead3f96 100644 --- a/sample/actual-jvm/build.gradle.kts +++ b/sample/actual-jvm/build.gradle.kts @@ -14,8 +14,10 @@ kotlin { dependencies { // For the @CrossModuleExpect annotation reference in the merged-in source file from :api. implementation(project(":actualizer-annotations")) - // For formatGreeting() - :api's ordinary, unrelated-to-the-expect main jar. - implementation(project(":sample:api")) + // Deliberately NOT a dependency on :sample:api's or :sample:feature-common's compiled jars: + // both projects' "main" source is merged in as source below (formatGreeting/greet/ + // welcomeMessage come along with it), and also depending on their jars would give the JVM + // two competing definitions of the same classes on this module's classpath. } actualizer { diff --git a/sample/api/build.gradle.kts b/sample/api/build.gradle.kts index 2ccaad4..5184bb4 100644 --- a/sample/api/build.gradle.kts +++ b/sample/api/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") + id("net.kernelpanicsoft.actualizer") } repositories { @@ -10,16 +11,11 @@ kotlin { jvmToolchain(21) } -// A plain, ordinary source set - not wired to `assemble`/`check`/`build` by default the way -// `main`/`test` are, so it's never compiled as part of this project's own build (no `-Xmulti-platform` -// needed here either). It exists purely so the Actualizer Gradle plugin in a leaf module can -// merge these .kt files into *its* compilation. See src/crossModuleApi/kotlin/.../Greeting.kt. -sourceSets { - create("crossModuleApi") { - kotlin.srcDir("src/crossModuleApi/kotlin") - } +dependencies { + implementation(project(":actualizer-annotations")) + implementation(project(":actualizer-runtime")) } -dependencies { - "crossModuleApiImplementation"(project(":actualizer-annotations")) +actualizer { + stubUnfulfilledExpects() } diff --git a/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt b/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt deleted file mode 100644 index 5829cde..0000000 --- a/sample/api/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/api/ExpectApi.kt +++ /dev/null @@ -1,22 +0,0 @@ -package net.kernelpanicsoft.sample.api - -import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect - -/** - * Lives in the `crossModuleApi` source set, not `main` - `:api` never compiles this itself (see - * `build.gradle.kts`: no compile task is wired up for this source set here), so the fact that - * it's an unfulfilled `expect` never causes `:api`'s own build to fail. It's only ever compiled - * when a leaf module merges it in via `actualizer { actualizes(project(":sample:api")) }` - - * `:sample:actual-jvm` in this repo - which is where the real `actual` lives too. - */ -@CrossModuleExpect -expect fun greetingSuffix(): String - -/** - * A non-expect declaration that delegates to the expect above. It doesn't need the `expect` - * keyword or `@CrossModuleExpect` itself - it's ordinary code - but it can't be called until - * `greetingSuffix()` is actualized either, so it lives right next to it in this same deferred - * source set rather than in `main`. `formatGreeting` is resolved via `:api`'s own compiled - * `main` jar (a normal classpath dependency wherever this file ends up merged). - */ -fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt index 267c9d1..a0f9e1f 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -1,8 +1,22 @@ package net.kernelpanicsoft.sample.api +import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect + /** - * Ordinary, self-contained code that doesn't touch `expect`/`actual` at all - part of `:api`'s - * regular `main` source set, so it's a completely normal JVM jar. `:feature-common` depends on - * this jar directly, the same way it would depend on any other JVM library. + * Not actualized anywhere in this module's own multiplatform hierarchy - there isn't one - but + * by a real, independently built Gradle module (`:sample:actual-jvm`) that isn't even a + * dependency of this one. `:api`'s own build stays green because `actualizer { + * stubUnfulfilledExpects() }` (see `build.gradle.kts`) auto-generates a throwing `actual` stub + * for it, so this whole file compiles normally into `:api`'s ordinary, standalone jar. */ +@CrossModuleExpect +expect fun greetingSuffix(): String + fun formatGreeting(name: String, suffix: String): String = "Hello, $name! $suffix" + +/** + * Calling this via `:api`'s own jar directly throws `ActualizerNotLinkedError` (the stub). It + * only does something useful once merged - alongside a real `actual` - into a leaf module's + * compilation via `actualizer { actualizes(project(":sample:api")) }`. + */ +fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/feature-common/build.gradle.kts b/sample/feature-common/build.gradle.kts index 52924af..a093204 100644 --- a/sample/feature-common/build.gradle.kts +++ b/sample/feature-common/build.gradle.kts @@ -14,13 +14,3 @@ dependencies { // Ordinary project dependency on :api's plain JVM jar - no plugin, no special wiring. implementation(project(":sample:api")) } - -// Same convention as :api - a deferred source set for code that (transitively) depends on an -// unfulfilled expect. Not wired into assemble/check/build, so :feature-common's own build never -// touches it; only a leaf module's actualizer { actualizes(project(":sample:feature-common")) } -// merges it in. -sourceSets { - create("crossModuleApi") { - kotlin.srcDir("src/crossModuleApi/kotlin") - } -} diff --git a/sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt b/sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt deleted file mode 100644 index ec931df..0000000 --- a/sample/feature-common/src/crossModuleApi/kotlin/net/kernelpanicsoft/sample/feature/FeatureExpectApi.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.kernelpanicsoft.sample.feature - -import net.kernelpanicsoft.sample.api.greet - -/** - * Like `:api`'s `crossModuleApi`, `:feature-common` never compiles this itself - see - * `build.gradle.kts`. `greet()` comes from `:api`'s own `crossModuleApi` source set, not a - * compiled jar: this only resolves once some leaf module merges *both* `:api`'s and - * `:feature-common`'s `crossModuleApi` sources into the same compilation (`:sample:actual-jvm` - * does exactly that), proving a "common" module's expect-dependent code can build on another - * common module's expect-dependent code, chained through the same `actualizes(...)` mechanism. - */ -fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt index 4802b06..3e78688 100644 --- a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt +++ b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt @@ -1,11 +1,12 @@ package net.kernelpanicsoft.sample.feature -import net.kernelpanicsoft.sample.api.formatGreeting +import net.kernelpanicsoft.sample.api.greet /** * Ordinary `implementation(project(":sample:api"))` dependency on `:api`'s plain, standalone JVM - * jar - no plugin, no multiplatform, nothing special. Proves a "common" module can depend on - * another one before any `actual` exists anywhere: `formatGreeting` lives in `:api`'s regular - * `main` source set, which never touches the unfulfilled `expect` in `crossModuleApi` at all. + * jar - no plugin, no multiplatform, nothing special. `greet()` compiles fine here because + * `:api`'s expect has a generated stub; calling `welcomeMessage()` via *this* module's own jar + * directly would throw `ActualizerNotLinkedError` unless this file is also merged into a real + * `actualizer { actualizes(...) }` leaf (`:sample:actual-jvm` does exactly that). */ -fun welcomeMessage(name: String): String = "[feature-common] " + formatGreeting(name, "welcome!") +fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/settings.gradle.kts b/settings.gradle.kts index fb00eeb..af1e7fe 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,6 +19,7 @@ rootProject.name = "modular-kmp" include( ":actualizer-annotations", + ":actualizer-runtime", ":sample:api", ":sample:feature-common", ":sample:actual-jvm", From 536f5ef4619b31fea5b3a6b58b674a926b7a07e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:27:26 +0000 Subject: [PATCH 06/19] Document the multiloader-project motivation in the README Adds a "Motivation" section explaining the real inspiration for this plugin's design (Architectury-style multiloader Minecraft mods, where each loader has its own Gradle plugin and independently-configured classpath, so a single KMP module's "compile once" model doesn't fit), and why Actualizer's approach - adding source/flags to an existing module's own compileKotlin task rather than building a synthetic shared compilation - is structurally suited to that. Also notes the inherent limit: merged source still has to be valid against whatever classpath the merging module provides, so environment mismatches (e.g. mismatched remapping/mappings between a published common artifact and a consumer) aren't something Actualizer can paper over. No code changes; sample and mechanism stay generic as decided. --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 05c50db..4b0deff 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,40 @@ All published modules (`actualizer-annotations`, `actualizer-runtime`, `compiler `gradle-plugin`) use the `net.kernelpanicsoft` Maven group; the plugin/package namespace is `net.kernelpanicsoft.actualizer`. +## Motivation: multiloader-style projects + +The concrete inspiration is **multiloader** project layouts like Architectury-based Minecraft +mods, where the same mod needs to build against several mod loaders (Fabric, NeoForge, ...) at +once. Each loader brings its own Gradle plugin, its own loader API dependencies, and - critically +- its own independently-configured compile classpath (in Minecraft's case: the game jar remapped +through that loader's own mapping/obfuscation pipeline). A single Kotlin Multiplatform module +doesn't fit this shape: KMP's contract is "`commonMain` compiles once, shared everywhere," but +here the "common" code often has to be *recompiled per loader* against a genuinely different +classpath - not just a different platform API surface the way JVM/JS/Native differ, but a +different, independently-built environment each loader's own Gradle plugin sets up before your +code ever compiles. + +That's the structural reason this repo doesn't use the Kotlin Multiplatform plugin anywhere, and +why Actualizer is built the way it is: it never constructs a synthetic shared compilation. It only +ever adds source directories and compiler flags to a module's *own*, already-fully-configured +`compileKotlin` task (see part 2 below) - so whatever loader-specific (or otherwise +environment-specific) Gradle plugin is applied first gets to finish configuring that module's +classpath, and Actualizer's `afterEvaluate` wiring only layers on top of it afterward. Common +source ends up compiled against whatever classpath that particular leaf module already has, +without Actualizer needing to know or care what's on it. The sample in this repo is deliberately +generic (plain JVM, no game jars, no remapping) so the mechanism stays easy to follow, but nothing +about it assumes a plain classpath - it's just as applicable to a module whose classpath came from +Loom, NeoGradle, or any other plugin that shapes dependencies before compilation. + +One consequence worth flagging explicitly: this only works if the merged-in source is actually +*valid* against whatever classpath the merging module provides. If a "common" artifact's source +(hand-written or from a resolved sources jar - see the earlier discussion of extending this to +published dependencies) was prepared against a different environment than the consumer's - built +against different remapped symbols, a different loader API version, whatever - merging the raw +text doesn't reconcile that difference; it just fails to resolve, same as if you'd hand-written +mismatched code yourself. Actualizer doesn't (and structurally can't) paper over an actual +environment mismatch between the module that wrote the source and the module compiling it. + ## Why this needs a plugin at all Stock Kotlin only resolves `expect`/`actual` within a single compiler invocation that has From c27d3ee5e7ff3985c30e3cb8e4e5f3fcd79b175c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:41:11 +0000 Subject: [PATCH 07/19] Support actualizing against a published sources jar, not just project() Adds a new actualizes(coordinate: String, dependsOnTasks: List = emptyList()) overload: resolves that Maven coordinate's `sources` classifier artifact (a plain -sources.jar, matching how most published libraries - including Minecraft mods - actually publish sources) and merges the extracted .kt files in exactly the same way as a project() reference, so a leaf module can actualize expects declared in a library it only has as a binary + sources dependency. The resolution is deliberately lazy (a plain task with resolution happening inside doLast, not a Sync task's from(), which Gradle would otherwise try to eagerly resolve during task-graph construction long before any publish task has run) - dependsOnTasks lets callers name whatever needs to run first when the coordinate isn't guaranteed to already be published (Gradle can't infer that automatically for an arbitrary external coordinate the way it can for project() deps). Sample: :sample:api now also applies maven-publish and defines a hand-scoped sourcesJar task (from("src/main/kotlin") only - NOT the default withSourcesJar(), which would also archive the generated stub directory stubUnfulfilledExpects() added and break the merge with a duplicate actual). New :sample:actual-jvm-published and :sample:app-published demonstrate actualizing net.kernelpanicsoft.sample:api:0.1.0 as a published coordinate end-to-end. Verified: full clean `gradle build` publishes, unpacks, and merges correctly in one shot; :sample:app-published:run prints the real, actualized value; report.json shows the Maven coordinate as owning module. --- README.md | 89 +++++++++++-- .../actualizer/gradle/ActualizerExtension.kt | 28 +++- .../gradle/ActualizerGradlePlugin.kt | 122 ++++++++++++++++-- sample/actual-jvm-published/build.gradle.kts | 36 ++++++ .../net/kernelpanicsoft/sample/api/Actual.kt | 9 ++ sample/api/build.gradle.kts | 34 +++++ sample/app-published/build.gradle.kts | 21 +++ .../sample/apppublished/Main.kt | 7 + settings.gradle.kts | 2 + 9 files changed, 320 insertions(+), 28 deletions(-) create mode 100644 sample/actual-jvm-published/build.gradle.kts create mode 100644 sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt create mode 100644 sample/app-published/build.gradle.kts create mode 100644 sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt diff --git a/README.md b/README.md index 4b0deff..c95ad69 100644 --- a/README.md +++ b/README.md @@ -53,12 +53,12 @@ Loom, NeoGradle, or any other plugin that shapes dependencies before compilation One consequence worth flagging explicitly: this only works if the merged-in source is actually *valid* against whatever classpath the merging module provides. If a "common" artifact's source -(hand-written or from a resolved sources jar - see the earlier discussion of extending this to -published dependencies) was prepared against a different environment than the consumer's - built -against different remapped symbols, a different loader API version, whatever - merging the raw -text doesn't reconcile that difference; it just fails to resolve, same as if you'd hand-written -mismatched code yourself. Actualizer doesn't (and structurally can't) paper over an actual -environment mismatch between the module that wrote the source and the module compiling it. +(hand-written, or from a resolved sources jar - see part 5 below, `actualizes("group:artifact:version")`) +was prepared against a different environment than the consumer's - built against different +remapped symbols, a different loader API version, whatever - merging the raw text doesn't +reconcile that difference; it just fails to resolve, same as if you'd hand-written mismatched code +yourself. Actualizer doesn't (and structurally can't) paper over an actual environment mismatch +between the module that wrote the source and the module compiling it. ## Why this needs a plugin at all @@ -168,6 +168,37 @@ mechanism *doesn't* give you for free: It never applies the Actualizer plugin and never references `:sample:api` or `:sample:feature-common` directly - it only sees `:actual-jvm`'s single, already-composed jar. +**5. The same mechanism also works against a *published* library, not just a sibling project.** +`:sample:actual-jvm-published` actualizes `net.kernelpanicsoft.sample:api:0.1.0` - the exact same +`:sample:api` module, but consumed as a Maven coordinate instead of `project(":sample:api")`: +```kotlin +actualizer { + actualizes( + "net.kernelpanicsoft.sample:api:0.1.0", + dependsOnTasks = listOf(":sample:api:publishMavenPublicationToLocalRepository"), + ) +} +``` +Instead of reading a project's source directory directly, this resolves that coordinate's +`sources` classifier artifact (a plain `-sources.jar`, matching how most published libraries - +including most Minecraft mods - actually publish sources; not a rich Gradle Module Metadata +"sources" variant), unpacks its `.kt` files into a build-local directory, and merges that in +exactly the same way as a `project(...)` reference. `dependsOnTasks` exists because Gradle has no +way to automatically infer "this coordinate isn't published yet, run this task first" the way it +does for `project(...)` dependencies - here it points at `:sample:api`'s own publish task, purely +because this sample self-containedly publishes and consumes the same library within one build for +a reproducible demo. Real usage (a library published independently, e.g. by CI, before any +consumer builds) wouldn't need `dependsOnTasks` at all. + +For the library side, `:sample:api` also applies `maven-publish` and defines its own `sourcesJar` +task explicitly scoped to `from("src/main/kotlin")` - **not** the `java { withSourcesJar() }` +convenience, which would also archive the generated stub directory `stubUnfulfilledExpects()` +added to `main`'s source set, breaking the merge on the consumer side (see "Known limitations"). + +`:sample:app-published` is the same idea as `:sample:app`, just depending on +`:sample:actual-jvm-published` instead - ordinary binary dependency, no special wiring, prints +the value produced by the published-and-actualized library. + ## What was verified Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 @@ -187,8 +218,16 @@ Everything above was actually built and run in this environment with Gradle 8.14 - `./gradlew :sample:app:run` - prints `[feature-common] Hello, world! (actualized independently by :sample:actual-jvm)`, the full chain composed and produced by `:sample:actual-jvm`'s single jar, with zero special wiring in `:app` itself. -- `./gradlew build` at the repo root - builds the entire graph in one shot; grepping the task log - confirms zero JS/Node/npm/Yarn tasks anywhere (no non-JVM tooling exists in this repo at all). +- `./gradlew :sample:actual-jvm-published:build` - publishes `:sample:api` to a local, build-local + Maven repository, resolves and unpacks its sources jar, and merges it against the real actual + in `:sample:actual-jvm-published`; `build/actualizer/report.json` shows the link with the + Maven coordinate (not a project path) as the owning module. +- `./gradlew :sample:app-published:run` - prints `Hello, world! (actualized against the published + api:0.1.0 library)`, proving the merge worked against a genuinely published-and-resolved + artifact, not just files read off a sibling project. +- `./gradlew build` at the repo root - builds the entire graph (including publishing and consuming + the sample library) in one shot; grepping the task log confirms zero JS/Node/npm/Yarn tasks + anywhere (no non-JVM tooling exists in this repo at all). - **Negative path**: deleting the `actual` declaration from `:sample:actual-jvm` and re-running `compileKotlin` fails the build with a real Kotlin frontend error pointing at the exact expect declaration (`Expected greetingSuffix has no actual declaration in module -common @@ -211,12 +250,17 @@ plugin-build/ composite build (keeps the plugin's own Kotlin vers -Xmulti-platform wiring sample/ api/ plain kotlin("jvm"), one "main" source set; expect + ordinary code - side by side; stubUnfulfilledExpects() keeps it standalone-buildable + side by side; stubUnfulfilledExpects() keeps it standalone-buildable; + also publishes itself (maven-publish + a hand-scoped sourcesJar) to + a build-local repo for the published-library demo below feature-common/ depends on api's plain jar, nothing special - no actualizer plugin needed on this module at all actual-jvm/ applies the plugin; merges BOTH api's and feature-common's - hand-written main sources; the real actual lives here + hand-written main sources (via project(...)); the real actual lives here app/ plain binary consumer of actual-jvm, no special wiring + actual-jvm-published/ same idea as actual-jvm, but actualizes api's *published* Maven + coordinate instead of a project(...) reference + app-published/ plain binary consumer of actual-jvm-published, no special wiring ``` ## Known limitations @@ -226,9 +270,28 @@ sample/ different (and, from spiking this, meaningfully more internal/fragile) mechanism this repo does not attempt. - **Source-only, not binary.** The Gradle plugin needs the foreign module's Kotlin *source files* - on disk; it cannot actualize against a module that only ships compiled klibs/jars for its - common code (which is also just inherent to how `-Xcommon-sources` works - it takes source - paths). + - either on disk (`project(...)`) or resolvable as a `sources`-classifier artifact (published + coordinates) - it cannot actualize against something that only ships compiled klibs/jars for its + common code with no sources at all (inherent to how `-Xcommon-sources` works - it takes source + paths, not bytecode). +- **A published library's sources jar must be scoped the same way `stubUnfulfilledExpects()` + requires for a `project(...)` reference.** It has to contain the library's real, hand-written + source only - not a generated stub, and not anything from the consumer's own environment. The + default `java { withSourcesJar() }` Gradle convenience archives a source set's full `allSource`, + which - on a module also using `stubUnfulfilledExpects()` - includes the generated stub + directory that convenience added to `main`. Publishing that would merge the library's *own* + stub `actual` in alongside the real one the leaf module provides, breaking the build with a + duplicate-`actual` conflict. Configure a hand-scoped `sourcesJar` task instead (see `:sample:api`'s + `build.gradle.kts`). +- **Resolving a published coordinate is deliberately lazy, and callers must order it explicitly.** + Unlike a `project(...)` reference (files already exist on disk, safe to read at configuration + time), a published coordinate might not exist yet when the build starts - most obviously when, + as in this sample, the same build publishes and consumes it. Gradle has no way to infer "publish + this first" for an arbitrary external coordinate the way it does for `project(...)` + dependencies, so `actualizes(coordinate, dependsOnTasks = listOf(...))` requires you to name the + publishing task(s) yourself when that ordering isn't already guaranteed some other way (e.g. the + library being published well before the consumer ever builds, which is the normal case outside + this self-contained sample). - **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` are not a supported public API for third-party use; a future Kotlin release could change or remove this behavior without notice. diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt index d75dab5..d954e2c 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt @@ -7,28 +7,46 @@ import javax.inject.Inject /** Default name of the source set Actualizer merges in from a foreign, expect-declaring project. */ const val DEFAULT_CROSS_MODULE_SOURCE_SET = "main" -internal data class ActualizedSource(val project: Project, val sourceSetName: String) +internal data class ActualizedProjectSource(val project: Project, val sourceSetName: String) +internal data class ActualizedPublishedSource(val coordinate: String, val dependsOnTasks: List) /** - * `actualizer { }` DSL with two independent capabilities: + * `actualizer { }` DSL with three independent capabilities: * * - `actualizes(project(":api"))`, applied to a "leaf" JVM module that provides real `actual` * declarations for `expect`s declared in one or more unrelated, independently built Gradle * modules. Merges each foreign project's named source set (`main` by default) as source into * this compilation, alongside the real `actual`. + * - `actualizes("group:artifact:version", dependsOnTasks = listOf(...))`, the same idea but for a + * *published* library instead of a sibling project: resolves that coordinate's `sources` + * classifier artifact (a plain, Maven-style `-sources.jar`), unpacks it, and merges the + * extracted `.kt` files in exactly the same way. This is what lets a consumer actualize + * `expect`s declared in a library it only has as a binary + sources dependency, not as a + * `project(...)` reference. `dependsOnTasks` matters if the coordinate might not be published + * yet when this build runs (e.g. this same build publishes it, like the sample does) - Gradle + * has no way to infer that ordering the way it does for `project(...)` dependencies, so name + * whatever publishing task(s) need to run first explicitly. See `registerPublishedSourcesSync` + * in `ActualizerGradlePlugin.kt` for the resolution mechanics and its requirements on how the + * library publishes its sources jar. * - `stubUnfulfilledExpects()`, applied to the *expect-declaring* module itself, so its own * `expect`s get an auto-generated, throwing `actual` stub and its `main` source set compiles - * into a completely normal, standalone jar - see `GenerateActualStubsTask`. Calling code that + * into a completely normal, standalone jar - see `ExpectStubGenerator.kt`. Calling code that * ends up bound to the stub (anything not itself merged into a real `actualizes(...)` leaf) * throws `ActualizerNotLinkedError` at runtime rather than failing to compile. */ open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { - internal val sources: MutableList = mutableListOf() + internal val sources: MutableList = mutableListOf() + internal val publishedSources: MutableList = mutableListOf() internal var stubUnfulfilledExpects: Boolean = false @JvmOverloads fun actualizes(project: Project, sourceSet: String = DEFAULT_CROSS_MODULE_SOURCE_SET) { - sources += ActualizedSource(project, sourceSet) + sources += ActualizedProjectSource(project, sourceSet) + } + + @JvmOverloads + fun actualizes(publishedCoordinate: String, dependsOnTasks: List = emptyList()) { + publishedSources += ActualizedPublishedSource(publishedCoordinate, dependsOnTasks) } fun stubUnfulfilledExpects() { diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index 6d76bb0..3b6b54e 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -3,6 +3,7 @@ package net.kernelpanicsoft.actualizer.gradle import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.artifacts.ExternalModuleDependency import org.gradle.api.file.SourceDirectorySet import org.gradle.api.provider.ListProperty import java.io.File @@ -11,7 +12,7 @@ private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-plugin:0.1.0" /** - * Two independent things a plain `kotlin("jvm")` project can opt into via `actualizer { }`: + * Three independent things a plain `kotlin("jvm")` project can opt into via `actualizer { }`: * * - `stubUnfulfilledExpects()`: on the expect-declaring module itself, scans its `main` source * set for `expect fun` declarations and generates a matching, throwing `actual` stub for each @@ -22,8 +23,22 @@ private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-pl * - `actualizes(project(":api"))`: on a "leaf" module providing the real `actual`, merges one or * more foreign projects' source sets in as source (their *hand-written* source only - see the * build-directory exclusion in `namedSourceSetDirs` below, which is what keeps a foreign - * project's own generated stub out of this merge, avoiding a duplicate-`actual` conflict) and - * registers the Actualizer IR compiler plugin so it can report on what got linked. + * project's own generated stub out of this merge, avoiding a duplicate-`actual` conflict). The + * foreign project's files already exist on disk, so this is resolved eagerly at configuration + * time - simple, and correct, since nothing needs to *build* first. + * - `actualizes("group:artifact:version")`: the published-library equivalent - resolves that + * coordinate's `sources` classifier artifact, unpacks it, and merges it in exactly the same + * way (see `registerPublishedSourcesSync`). Unlike a `project(...)` reference, the artifact + * might not exist yet at configuration time (e.g. this same build also publishes it, as the + * sample does) - Gradle can't infer that ordering the way it does for `project(...)` + * dependencies, so this path is deliberately *lazy*: a `Sync` task resolves and unpacks the + * sources jar only when it actually runs, `compileKotlin` depends on that task, and the + * `-Xcommon-sources` file list is computed inside the same lazy `Provider` already used for + * the rest of `compileKotlin`'s compiler args, so it's only read after the `Sync` task (and + * whatever else the consumer wired the `Sync` task to depend on) has finished. + * + * Either way, once anything is merged in, the Actualizer IR compiler plugin gets registered on + * that compilation so it can report on what got linked. * * This deliberately avoids importing Kotlin Gradle Plugin (KGP) types like `KotlinJvmProjectExtension` * or `KotlinCompile` directly: `plugin-build` resolves its own copy of `kotlin-gradle-plugin` @@ -31,8 +46,9 @@ private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-pl * consuming build resolves via `plugins { kotlin("jvm") }` - so * `extensions.findByType(SomeKgpType::class.java)` and `tasks.withType(SomeKgpTaskType::class.java)` * silently find nothing across that boundary. Reflection-by-name sidesteps it; only genuine - * Gradle-core types (`NamedDomainObjectContainer`, `SourceDirectorySet`, `ListProperty`, which - * are always loaded by Gradle's own shared classloader) are referenced statically. + * Gradle-core types (`NamedDomainObjectContainer`, `SourceDirectorySet`, `ListProperty`, + * `ExternalModuleDependency`, `Sync`, which are always loaded by Gradle's own shared classloader) + * are referenced statically. */ class ActualizerGradlePlugin : Plugin { @@ -43,7 +59,7 @@ class ActualizerGradlePlugin : Plugin { if (extension.stubUnfulfilledExpects) { wireStubGeneration(project) } - if (extension.sources.isNotEmpty()) { + if (extension.sources.isNotEmpty() || extension.publishedSources.isNotEmpty()) { wireCrossModuleActualization(project, extension) } } @@ -84,8 +100,14 @@ class ActualizerGradlePlugin : Plugin { private fun wireCrossModuleActualization(project: Project, extension: ActualizerExtension) { val moduleMapEntries = mutableListOf() - val foreignSourceFiles = mutableListOf() + // Project() references: files already exist on disk, safe to walk eagerly right now. + val eagerSourceFiles = mutableListOf() val foreignSourceDirs = mutableListOf() + // Published coordinates: the sources jar might not exist yet (e.g. published later in + // this same build) - these directories are only walked lazily, inside the freeCompilerArgs + // provider below, after their Sync task has actually run. + val lazySourceDirs = mutableListOf() + val syncTasks = mutableListOf() for (source in extension.sources) { project.evaluationDependsOn(source.project.path) @@ -103,12 +125,24 @@ class ActualizerGradlePlugin : Plugin { foreignSourceDirs += dir moduleMapEntries += "${dir.absolutePath}::${source.project.path}" if (dir.exists()) { - foreignSourceFiles += dir.walkTopDown().filter { it.isFile && it.extension == "kt" } + eagerSourceFiles += dir.walkTopDown().filter { it.isFile && it.extension == "kt" } } } } - if (foreignSourceFiles.isEmpty()) { + for (published in extension.publishedSources) { + val (dir, syncTask) = registerPublishedSourcesSync( + project, + published.coordinate, + published.dependsOnTasks, + ) + foreignSourceDirs += dir + lazySourceDirs += dir + moduleMapEntries += "${dir.absolutePath}::${published.coordinate}" + syncTasks += syncTask + } + + if (eagerSourceFiles.isEmpty() && lazySourceDirs.isEmpty()) { return } @@ -120,17 +154,24 @@ class ActualizerGradlePlugin : Plugin { val reportOutput = project.layout.buildDirectory.file("actualizer/report.json").get().asFile val moduleMapValue = moduleMapEntries.joinToString("||") - val commonSourcesValue = foreignSourceFiles.joinToString(",") { it.absolutePath } val compileKotlinTask = project.tasks.named("compileKotlin") compileKotlinTask.configure { task -> task.dependsOn(compilerPluginClasspath) task.inputs.files(compilerPluginClasspath).withPropertyName("actualizerCompilerPluginClasspath") + syncTasks.forEach { task.dependsOn(it) } val freeCompilerArgs = freeCompilerArgsProperty(task) freeCompilerArgs.addAll( project.provider { + // Walking lazySourceDirs here (rather than up above) is what makes this safe + // to run after the Sync tasks above have actually unpacked something into them. + val allSourceFiles = eagerSourceFiles + lazySourceDirs.flatMap { dir -> + dir.walkTopDown().filter { it.isFile && it.extension == "kt" } + } + val commonSourcesValue = allSourceFiles.joinToString(",") { it.absolutePath } + // Resolving the substituted project coordinate pulls in its own runtime // deps too (kotlin-stdlib etc) - only the plugin's own jar should be passed // as -Xplugin=, the rest is already implicitly on the compiler's classpath. @@ -153,6 +194,67 @@ class ActualizerGradlePlugin : Plugin { } } + /** + * Registers a `Sync` task that resolves [coordinate]'s `sources` classifier artifact (a + * plain, Maven-style `--sources.jar` - not a rich Gradle Module Metadata + * "sources" variant, for the broadest compatibility with libraries that just publish a + * classic classified jar) and unpacks its `.kt` files into a build-local directory, mirroring + * what `namedSourceSetDirs` does for a `project(...)` reference. Returns the (not yet + * populated) output directory and the `Sync` task that populates it - callers must make + * whatever actually reads that directory depend on the task, not just use the directory path. + * + * The dependency is resolved *inside* the task's `from(...)`, which Gradle only evaluates + * when the task runs - not when this method is called - so this is safe to call even before + * the coordinate exists anywhere (e.g. before this same build has published it yet). + * + * For this to produce something mergeable, the library's sources jar must contain only its + * *hand-written* source - critically, not a `stubUnfulfilledExpects()`-generated stub, or the + * merge would end up with two `actual`s for the same `expect` (the library's own stub, plus + * the real one this leaf module provides) and fail to compile. A library using + * `stubUnfulfilledExpects()` itself must configure its own `sourcesJar` task to archive only + * its real source directory (e.g. `from("src/main/kotlin")`), not the source set's full, + * post-wiring `allSource` (which would include the generated stub dir Actualizer added to + * it) - the default Kotlin/Java `withSourcesJar()` convenience does the latter, so it isn't + * safe to use as-is on a module that also calls `stubUnfulfilledExpects()`. See the sample's + * `:sample:api` for a `sourcesJar` task configured this way. + */ + private fun registerPublishedSourcesSync( + project: Project, + coordinate: String, + dependsOnTasks: List, + ): Pair { + val sourcesNotation = if (coordinate.endsWith(":sources")) coordinate else "$coordinate:sources" + val dependency = project.dependencies.create(sourcesNotation) + (dependency as? ExternalModuleDependency)?.isTransitive = false + val configuration = project.configurations.detachedConfiguration(dependency) + + val safeName = coordinate.replace(Regex("[^A-Za-z0-9_.-]"), "_") + val outputDir = project.layout.buildDirectory.dir("generated/actualizer-published-sources/$safeName").get().asFile + + // Deliberately NOT a Sync task with from(configuration) directly: Gradle inspects a + // CopySpec's `from(...)` eagerly, during task-graph construction, to infer build + // dependencies - which forces resolving `configuration` (a detached, external-coordinate + // configuration Gradle has no automatic way to order after a publish task) long before + // any task has actually run. Doing the resolve-and-unpack inside a plain `doLast` instead + // means `configuration.singleFile` is only touched once this task actually executes - + // by which point `dependsOnTasks` (below) has guaranteed whatever publishes it has run. + val unpackTask = project.tasks.register("actualizerUnpack${safeName}Sources") { task -> + task.dependsOn(dependsOnTasks) + task.outputs.dir(outputDir) + task.doLast { + outputDir.deleteRecursively() + outputDir.mkdirs() + project.sync { spec -> + spec.from(project.zipTree(configuration.singleFile)) + spec.into(outputDir) + spec.include("**/*.kt") + } + } + } + + return outputDir to unpackTask + } + /** * The hand-written source directories of [sourceSetName] on [foreignProject] - explicitly * excluding anything under that project's own build directory, so a foreign project's diff --git a/sample/actual-jvm-published/build.gradle.kts b/sample/actual-jvm-published/build.gradle.kts new file mode 100644 index 0000000..105e323 --- /dev/null +++ b/sample/actual-jvm-published/build.gradle.kts @@ -0,0 +1,36 @@ +plugins { + kotlin("jvm") + id("net.kernelpanicsoft.actualizer") +} + +repositories { + maven { + name = "local" + url = uri(rootProject.layout.buildDirectory.dir("local-maven-repo")) + } + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // For the @CrossModuleExpect annotation reference in the merged-in source from the library. + implementation(project(":actualizer-annotations")) +} + +actualizer { + // Same idea as :sample:actual-jvm, but actualizing a *published* library's expect instead of + // a sibling project - resolves net.kernelpanicsoft.sample:api:0.1.0's sources classifier + // artifact from the "local" repo above, rather than reading files off a project(...) reference. + // + // dependsOnTasks is needed because the coordinate isn't published yet when this build starts - + // this sample publishes and consumes the same library within one build, for a self-contained, + // reproducible demo. In real usage the library would already be published somewhere (e.g. by + // CI) before a consumer ever builds against it, and dependsOnTasks wouldn't be needed at all. + actualizes( + "net.kernelpanicsoft.sample:api:0.1.0", + dependsOnTasks = listOf(":sample:api:publishMavenPublicationToLocalRepository"), + ) +} diff --git a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt new file mode 100644 index 0000000..519051d --- /dev/null +++ b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -0,0 +1,9 @@ +// Same package as the expect this actualizes (net.kernelpanicsoft.sample.api, declared in the +// *published* net.kernelpanicsoft.sample:api:0.1.0 library, not a project(...) reference), even +// though this file physically lives in a completely different Gradle module. +package net.kernelpanicsoft.sample.api + +import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect + +@CrossModuleExpect +actual fun greetingSuffix(): String = "(actualized against the published api:0.1.0 library)" diff --git a/sample/api/build.gradle.kts b/sample/api/build.gradle.kts index 5184bb4..f7ca27f 100644 --- a/sample/api/build.gradle.kts +++ b/sample/api/build.gradle.kts @@ -1,6 +1,9 @@ +import org.gradle.jvm.tasks.Jar + plugins { kotlin("jvm") id("net.kernelpanicsoft.actualizer") + `maven-publish` } repositories { @@ -19,3 +22,34 @@ dependencies { actualizer { stubUnfulfilledExpects() } + +// A hand-configured sourcesJar, NOT the default `java { withSourcesJar() }` convenience: +// stubUnfulfilledExpects() adds a generated stub directory to this project's own "main" source +// set (see ActualizerGradlePlugin.wireStubGeneration), so `sourceSets.main.allSource` - what +// withSourcesJar() would archive - now includes that generated stub too. A consumer merging this +// sources jar via actualizes("...") would then get both the real expect AND our own generated +// stub actual in one merge, which conflicts with whatever real actual that consumer provides. +// Archiving only the real, hand-written directory keeps the published sources exactly as clean +// as the project() case (see the build-directory exclusion in namedSourceSetDirs). +val sourcesJar by tasks.registering(Jar::class) { + archiveClassifier.set("sources") + from("src/main/kotlin") +} + +publishing { + publications { + create("maven") { + groupId = "net.kernelpanicsoft.sample" + artifactId = "api" + version = "0.1.0" + from(components["java"]) + artifact(sourcesJar) + } + } + repositories { + maven { + name = "local" + url = uri(rootProject.layout.buildDirectory.dir("local-maven-repo")) + } + } +} diff --git a/sample/app-published/build.gradle.kts b/sample/app-published/build.gradle.kts new file mode 100644 index 0000000..9c71807 --- /dev/null +++ b/sample/app-published/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + application +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} + +application { + mainClass.set("net.kernelpanicsoft.sample.apppublished.MainKt") +} + +dependencies { + // Ordinary binary dependency - no plugin, no special wiring, same as :sample:app. + implementation(project(":sample:actual-jvm-published")) +} diff --git a/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt b/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt new file mode 100644 index 0000000..beb5ee3 --- /dev/null +++ b/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt @@ -0,0 +1,7 @@ +package net.kernelpanicsoft.sample.apppublished + +import net.kernelpanicsoft.sample.api.greet + +fun main() { + println(greet("world")) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index af1e7fe..8c038bb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,4 +24,6 @@ include( ":sample:feature-common", ":sample:actual-jvm", ":sample:app", + ":sample:actual-jvm-published", + ":sample:app-published", ) From bdc88fc1a4b14f070733172d2e6d49f34df50bed Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 01:53:53 +0000 Subject: [PATCH 08/19] Extend expect stub generator: val/var/class support, KotlinPoet codegen ExpectStubGenerator.kt now scans for expect val/var (properties) and expect class (via brace-depth-matched body scanning, flat member list) in addition to the existing expect fun support, and generates the stub output via KotlinPoet (FileSpec/FunSpec/PropertySpec/TypeSpec) instead of hand-rolled string concatenation. Fixed two real bugs found along the way: - ClassName.bestGuess("String") produces an unqualified ClassName that KotlinPoet then tries to `import String` for - not valid Kotlin. Added a small lookup table mapping common kotlin/kotlin.collections names to their real qualified ClassName so they resolve correctly (and get correctly omitted from the import list); documented as a known gap for arbitrary unqualified custom types. - Kotlin requires an actual class's primary constructor to be marked `actual` explicitly too, not just the class - both the generated stub and this repo's own hand-written sample actuals needed `actual constructor(...)`. Sample: :sample:api gained expect val platformName and expect class GreetingCounter(start: Int) { fun next(): Int } alongside the existing expect fun, with matching actuals in both :sample:actual-jvm and :sample:actual-jvm-published, exercised end-to-end via :app/:app-published. Verified: full clean build succeeds across the whole graph; both apps print correct output for all three expect kinds (fun/val/class); the missing-actual negative path still fails at compile time; no JS/Node tooling anywhere. --- README.md | 97 ++++-- plugin-build/gradle-plugin/build.gradle.kts | 7 + .../gradle/ActualizerGradlePlugin.kt | 3 +- .../actualizer/gradle/ExpectStubGenerator.kt | 303 ++++++++++++++++-- .../net/kernelpanicsoft/sample/api/Actual.kt | 9 + .../net/kernelpanicsoft/sample/api/Actual.kt | 9 + .../sample/api/PlatformInfo.kt | 18 ++ .../sample/apppublished/Main.kt | 5 + .../net/kernelpanicsoft/sample/app/Main.kt | 5 + 9 files changed, 391 insertions(+), 65 deletions(-) create mode 100644 sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt diff --git a/README.md b/README.md index c95ad69..be3aa95 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ source-set hierarchy as the expect. Every module involved is a plain `kotlin("jv nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. ``` -:sample:api expect fun greetingSuffix(): String, plus ordinary code (formatGreeting, - greet) that calls it - a single, normal source set, one standalone jar +:sample:api expect fun/val/class (greetingSuffix, platformName, GreetingCounter), plus + ordinary code that calls them - one normal source set, one standalone jar :sample:feature-common depends on :api's plain jar; welcomeMessage() calls :api's greet() -:sample:actual-jvm actual fun greetingSuffix() - merges BOTH :api's and :feature-common's - hand-written source in, unrelated Gradle module to either of them +:sample:actual-jvm actual fun/val/class for all three - merges BOTH :api's and + :feature-common's hand-written source in, unrelated Gradle module to either :sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked chain) ``` @@ -84,26 +84,40 @@ working chain only exists wherever a leaf module actually performs the merge. **1. The "common" module is a completely ordinary JVM jar - via an auto-generated stub, not runtime dispatch.** `:sample:api` is a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` and -`actualizer { stubUnfulfilledExpects() }`. Its `expect fun greetingSuffix()` lives directly in the -ordinary `main` source set, right next to regular code (`formatGreeting`, and `greet()`, which -calls the expect). Before compiling, the Actualizer Gradle plugin scans `main`'s `.kt` files for -`expect fun` declarations (a small regex-based scan - see "Known limitations") and generates a -matching, throwing `actual` stub for each into `build/generated/actualizer-stubs/...` -(`ExpectStubGenerator.kt`), e.g.: +`actualizer { stubUnfulfilledExpects() }`. Its `expect fun greetingSuffix()`, `expect val +platformName`, and `expect class GreetingCounter(start: Int) { fun next(): Int }` all live +directly in the ordinary `main` source set, right next to regular code (`formatGreeting`, and +`greet()`/`describePlatform()`, which call the expects). Before compiling, the Actualizer Gradle +plugin scans `main`'s `.kt` files for `expect` declarations (a small regex-based scan - see +"Known limitations") and generates a matching, throwing `actual` stub for each into +`build/generated/actualizer-stubs/...` (`ExpectStubGenerator.kt`, built with +[KotlinPoet](https://square.github.io/kotlinpoet/) rather than hand-rolled string concatenation, +so the generated code is properly formatted/imported instead of assembled by hand), e.g.: ```kotlin actual fun greetingSuffix(): String = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.greetingSuffix") + +actual val platformName: String + get() = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.platformName") + +actual class GreetingCounter actual constructor(start: Int) { + init { throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.GreetingCounter") } + actual fun next(): Int = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.GreetingCounter.next") +} ``` -That generated file is added as an extra `main` source directory, and `-Xmulti-platform` + -`-Xcommon-sources=` get set on `compileKotlin` - the exact -same "compile common + platform sources together" mechanism used everywhere else in this repo -(see part 2), just with a machine-written `actual` instead of a hand-written one. The real Kotlin -frontend links the (real) expect against the (stub) actual and compiles successfully, so -`./gradlew :sample:api:build` produces one normal `.jar` with real, ordinary JVM bytecode - no -metadata/klib format, no split source sets. `:sample:feature-common` depends on that jar with a -plain `implementation(project(":sample:api"))` and calls `greet()` normally - the call compiles -and links fine (it's not calling an `expect`, it's calling an ordinary function that happens to -call one). Calling it *through this standalone chain* throws `ActualizerNotLinkedError` at -runtime, because it's bound to the stub - see part 2 for how the real chain gets built instead. +(the class stub throws immediately in its `init` block - construction fails fast rather than +relying on every member throwing individually - but the member stubs still have to exist so the +actual class structurally matches the expect class). That generated file is added as an extra +`main` source directory, and `-Xmulti-platform` + `-Xcommon-sources=` get set on `compileKotlin` - the exact same "compile common + platform sources together" +mechanism used everywhere else in this repo (see part 2), just with a machine-written `actual` +instead of a hand-written one. The real Kotlin frontend links the (real) expects against the +(stub) actuals and compiles successfully, so `./gradlew :sample:api:build` produces one normal +`.jar` with real, ordinary JVM bytecode - no metadata/klib format, no split source sets. +`:sample:feature-common` depends on that jar with a plain `implementation(project(":sample:api"))` +and calls `greet()` normally - the call compiles and links fine (it's not calling an `expect`, +it's calling an ordinary function that happens to call one). Calling it *through this standalone +chain* throws `ActualizerNotLinkedError` at runtime, because it's bound to the stub - see part 2 +for how the real chain gets built instead. **2. The actual linking happens in the leaf module, via source merging - of multiple foreign modules at once.** @@ -205,8 +219,9 @@ Everything above was actually built and run in this environment with Gradle 8.14 / JDK 21, not just designed on paper: - `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces one plain JVM jar - (real bytecode, no metadata/klib format) with both the real `formatGreeting`/`greet` and the - auto-generated throwing stub for `greetingSuffix`. + (real bytecode, no metadata/klib format) with both the real `formatGreeting`/`greet`/ + `describePlatform` and the auto-generated throwing stubs for `greetingSuffix`, `platformName`, + and `GreetingCounter` (function, property, and class stubs all covered). - Calling `greet()` via `:api`'s standalone jar directly (verified with a throwaway consumer project pointed at just that jar) throws `ActualizerNotLinkedError` with a clear message, as designed. @@ -246,13 +261,14 @@ plugin-build/ composite build (keeps the plugin's own Kotlin vers CompilerPluginRegistrar, ActualizerIrExtension) gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) / stubUnfulfilledExpects() } DSL, source-directory merging, - stub generation (ExpectStubGenerator.kt), -Xcommon-sources/ - -Xmulti-platform wiring + stub generation (ExpectStubGenerator.kt, using KotlinPoet), + -Xcommon-sources/-Xmulti-platform wiring sample/ - api/ plain kotlin("jvm"), one "main" source set; expect + ordinary code - side by side; stubUnfulfilledExpects() keeps it standalone-buildable; - also publishes itself (maven-publish + a hand-scoped sourcesJar) to - a build-local repo for the published-library demo below + api/ plain kotlin("jvm"), one "main" source set; expect fun/val/class + + ordinary code side by side; stubUnfulfilledExpects() keeps it + standalone-buildable; also publishes itself (maven-publish + a + hand-scoped sourcesJar) to a build-local repo for the + published-library demo below feature-common/ depends on api's plain jar, nothing special - no actualizer plugin needed on this module at all actual-jvm/ applies the plugin; merges BOTH api's and feature-common's @@ -296,11 +312,24 @@ sample/ supported public API for third-party use; a future Kotlin release could change or remove this behavior without notice. - **The `expect` stub scanner is a deliberately limited regex, not a real parser.** It matches - single-line `expect fun name(params): ReturnType` declarations only - no generics, no - `suspend`/extension receivers, no multi-line parameter lists, no `expect class`/`expect val`. A - real implementation would parse Kotlin PSI properly; this repo's scanner (`ExpectStubGenerator.kt`) - covers exactly the shape used in the sample and documents the gap rather than pretending to be - general. + single-line `expect fun`/`expect val`/`expect var` declarations, and `expect class` bodies via + naive brace-depth counting (doesn't account for `{`/`}` inside string literals or comments) with + a flat member list inside. Not supported: generics on the containing function/class, `suspend`/ + extension receivers, multi-line parameter lists, supertypes, secondary constructors, nested + types, or constructor-parameter auto-properties (`class Foo(val x: Int)`). A real implementation + would parse Kotlin PSI properly; this repo's scanner (`ExpectStubGenerator.kt`) covers a + reasonably realistic subset and documents the gaps rather than pretending to be general. + Generated code itself goes through KotlinPoet (`FileSpec`/`FunSpec`/`PropertySpec`/`TypeSpec`), + not string concatenation, but KotlinPoet only helps once something's been scanned - it can't + make the regex scanning itself more capable. +- **Type text scanned into a stub isn't a resolved type, just recognized text.** `parseTypeName` in + `ExpectStubGenerator.kt` handles a trailing `?` and one level of generic nesting, and recognizes + common `kotlin`/`kotlin.collections` names so they resolve without a bogus import (KotlinPoet + would otherwise try to `import String`/`import Int`, which isn't valid Kotlin - `bestGuess` on an + unqualified name assumes it needs importing regardless of whether it actually does). Any other + unqualified *custom* type (e.g. `expect fun f(): MyDataClass` where `MyDataClass` isn't already + fully qualified) hits the same problem the recognized names avoid, and the generated stub won't + compile. Write fully-qualified types in `expect` declarations meant to be stubbed if this matters. - **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin` deliberately avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, `KotlinCompile`, etc.) and uses reflection-by-name instead - `plugin-build` resolves its own diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts index faec31c..9941856 100644 --- a/plugin-build/gradle-plugin/build.gradle.kts +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -16,6 +16,13 @@ kotlin { jvmToolchain(21) } +dependencies { + // Used to generate the actualizer { stubUnfulfilledExpects() } output (ExpectStubGenerator.kt) + // instead of hand-rolled string building - correct formatting, imports, and Kotlin syntax + // (including for `actual class` stubs) instead of ad-hoc string concatenation. + implementation("com.squareup:kotlinpoet:1.18.1") +} + gradlePlugin { plugins { create("actualizer") { diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index 3b6b54e..d56d972 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -74,7 +74,8 @@ class ActualizerGradlePlugin : Plugin { if (scanned.isEmpty()) { project.logger.warn( "[actualizer] '${project.path}' called stubUnfulfilledExpects() but no " + - "'expect fun' declarations were found in its main source set." + "'expect fun'/'expect val'/'expect var'/'expect class' declarations were " + + "found in its main source set." ) return } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt index 6dff542..c848cce 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -1,51 +1,294 @@ package net.kernelpanicsoft.actualizer.gradle +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec import java.io.File -internal data class ExpectSignature(val name: String, val params: String, val returnType: String) +// Deliberately scoped to a simple, single-declaration-per-line subset of Kotlin syntax: no +// generics on the containing class/function itself, no suspend/extension receivers, no supertypes, +// no secondary constructors or nested types, no constructor-parameter auto-properties +// (`class Foo(val x: Int)`), and expect class bodies are found via naive brace-depth counting +// (doesn't account for `{`/`}` inside string literals or comments). Real projects would want a +// proper PSI-based scan; this is a pragmatic, honestly-limited stand-in that covers straightforward +// `expect fun`/`expect val`/`expect var`/`expect class` declarations like the ones in this repo's +// sample. Once something IS matched, KotlinPoet handles turning it into correct Kotlin source +// (formatting, imports, escaping) instead of hand-rolled string concatenation. -internal data class ScannedExpectFile(val sourceFile: File, val packageName: String, val expects: List) +internal data class ParamText(val name: String, val typeText: String) + +internal sealed class ExpectMember { + abstract val name: String + + data class Function( + override val name: String, + val params: List, + val returnTypeText: String, + ) : ExpectMember() + + data class Property( + override val name: String, + val typeText: String, + val mutable: Boolean, + ) : ExpectMember() +} + +internal data class ExpectClassInfo( + val name: String, + val constructorParams: List, + val members: List, +) + +internal data class ScannedExpectFile( + val sourceFile: File, + val packageName: String, + val topLevel: List, + val classes: List, +) -// Deliberately scoped to a simple, single-line subset of Kotlin function syntax: no generics, no -// suspend/extension receivers, no expect classes/properties, no multi-line parameter lists. Real -// projects would want a proper PSI-based scan; this is a pragmatic, honestly-limited stand-in -// that covers straightforward `expect fun` declarations like the ones in this repo's sample. private val PACKAGE_REGEX = Regex("""^\s*package\s+([\w.]+)\s*$""") private val EXPECT_FUN_REGEX = Regex("""^\s*expect\s+fun\s+(\w+)\s*\(([^)]*)\)\s*(:\s*([^{=]+))?\s*$""") +private val EXPECT_PROPERTY_REGEX = Regex("""^\s*expect\s+(val|var)\s+(\w+)\s*:\s*([^={\n]+?)\s*$""") +private val EXPECT_CLASS_HEADER_REGEX = Regex("""^\s*expect\s+class\s+(\w+)\s*(\(([^)]*)\))?\s*\{\s*$""") +private val MEMBER_FUN_REGEX = Regex("""^\s*fun\s+(\w+)\s*\(([^)]*)\)\s*(:\s*([^{=]+))?\s*$""") +private val MEMBER_PROPERTY_REGEX = Regex("""^\s*(val|var)\s+(\w+)\s*:\s*([^={\n]+?)\s*$""") internal fun scanForExpectFunctions(files: Iterable): List { return files.mapNotNull { file -> if (!file.isFile || file.extension != "kt") return@mapNotNull null var packageName = "" - val expects = mutableListOf() + val topLevel = mutableListOf() + val classes = mutableListOf() + + var inClassName: String? = null + var inClassParams: List = emptyList() + var inClassDepth = 0 + var inClassMembers = mutableListOf() + file.forEachLine { line -> PACKAGE_REGEX.find(line)?.let { packageName = it.groupValues[1] } + + if (inClassName != null) { + MEMBER_FUN_REGEX.find(line)?.let { match -> + inClassMembers += ExpectMember.Function( + name = match.groupValues[1], + params = parseParams(match.groupValues[2]), + returnTypeText = match.groupValues[4].trim().ifEmpty { "Unit" }, + ) + } + MEMBER_PROPERTY_REGEX.find(line)?.let { match -> + inClassMembers += ExpectMember.Property( + name = match.groupValues[2], + typeText = match.groupValues[3].trim(), + mutable = match.groupValues[1] == "var", + ) + } + inClassDepth += line.count { it == '{' } - line.count { it == '}' } + if (inClassDepth <= 0) { + classes += ExpectClassInfo(inClassName!!, inClassParams, inClassMembers) + inClassName = null + inClassMembers = mutableListOf() + } + return@forEachLine + } + + EXPECT_CLASS_HEADER_REGEX.find(line)?.let { match -> + inClassName = match.groupValues[1] + inClassParams = parseParams(match.groupValues[3]) + inClassDepth = 1 + return@forEachLine + } EXPECT_FUN_REGEX.find(line)?.let { match -> - val name = match.groupValues[1] - val params = match.groupValues[2].trim() - val returnType = match.groupValues[4].trim().ifEmpty { "Unit" } - expects += ExpectSignature(name, params, returnType) + topLevel += ExpectMember.Function( + name = match.groupValues[1], + params = parseParams(match.groupValues[2]), + returnTypeText = match.groupValues[4].trim().ifEmpty { "Unit" }, + ) + } + EXPECT_PROPERTY_REGEX.find(line)?.let { match -> + topLevel += ExpectMember.Property( + name = match.groupValues[2], + typeText = match.groupValues[3].trim(), + mutable = match.groupValues[1] == "var", + ) } } - if (expects.isEmpty()) null else ScannedExpectFile(file, packageName, expects) - } -} - -internal fun generateStubFileContent(scanned: ScannedExpectFile): String = buildString { - appendLine("// GENERATED by the Actualizer Gradle plugin (actualizer { stubUnfulfilledExpects() }).") - appendLine("// Source: ${scanned.sourceFile}") - appendLine("// Do not edit - regenerated on every build.") - appendLine("package ${scanned.packageName}") - appendLine() - appendLine("import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect") - appendLine("import net.kernelpanicsoft.actualizer.runtime.ActualizerNotLinkedError") - appendLine() - for (signature in scanned.expects) { - val fqName = "${scanned.packageName}.${signature.name}" - appendLine("@CrossModuleExpect") - appendLine( - "actual fun ${signature.name}(${signature.params}): ${signature.returnType} = " + - "throw ActualizerNotLinkedError(\"$fqName\")" + + if (topLevel.isEmpty() && classes.isEmpty()) null else ScannedExpectFile(file, packageName, topLevel, classes) + } +} + +/** Splits on [separator] at nesting depth 0 only, so `<...>`/`(...)` in types/defaults survive. */ +private fun splitTopLevel(text: String, separator: Char): List { + if (text.isBlank()) return emptyList() + val parts = mutableListOf() + var depth = 0 + var start = 0 + for (i in text.indices) { + when (text[i]) { + '<', '(' -> depth++ + '>', ')' -> depth-- + separator -> if (depth == 0) { + parts += text.substring(start, i) + start = i + 1 + } + } + } + parts += text.substring(start) + return parts.map { it.trim() }.filter { it.isNotEmpty() } +} + +private fun parseParams(text: String): List { + return splitTopLevel(text, ',').mapNotNull { part -> + val withoutDefault = part.substringBefore("=").trim() + val colonIndex = withoutDefault.indexOf(':') + if (colonIndex == -1) return@mapNotNull null + val name = withoutDefault.substring(0, colonIndex).trim().removePrefix("vararg").trim() + val type = withoutDefault.substring(colonIndex + 1).trim() + ParamText(name, type) + } +} + +// ClassName.bestGuess("String") produces a ClassName with an EMPTY package (it looks like a +// default-package top-level class), and KotlinPoet then emits a nonsensical `import String` for +// it - there's nothing to import for a name with no package. Recognizing common stdlib names and +// pointing them at their real `kotlin`/`kotlin.collections` package fixes that (and lets KotlinPoet +// correctly omit the import, since those packages are implicitly visible in every Kotlin file). +private val KOTLIN_BUILTIN_TYPES = listOf( + "Any", "Unit", "Nothing", "String", "CharSequence", "Boolean", + "Byte", "Short", "Int", "Long", "Float", "Double", "Char", "Number", "Array", +).associateWith { ClassName("kotlin", it) } + +private val KOTLIN_COLLECTION_TYPES = listOf( + "List", "MutableList", "Set", "MutableSet", "Map", "MutableMap", + "Collection", "MutableCollection", "Iterable", "MutableIterable", "Iterator", "MutableIterator", +).associateWith { ClassName("kotlin.collections", it) } + +/** + * Best-effort raw-text -> [TypeName] conversion: handles a trailing `?` (nullability), one level + * of generic nesting (`Foo`, `Foo>`, ...), and recognizes common stdlib type names + * (see [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES]) so they resolve correctly. Since this + * only ever sees text captured by the regexes above - never a resolved type - anything else that + * isn't already fully qualified and also isn't one of those recognized stdlib names (an + * unqualified *custom* type, e.g. `expect fun f(): MyDataClass`) will still get treated as a + * default-package class needing an import that doesn't actually exist, and fail to compile. Write + * fully-qualified types in `expect` declarations meant to be stubbed if this matters. + */ +private fun parseTypeName(raw: String): TypeName { + val trimmed = raw.trim() + val nullable = trimmed.endsWith("?") + val core = (if (nullable) trimmed.dropLast(1) else trimmed).trim() + val genericStart = core.indexOf('<') + val base: TypeName = if (genericStart == -1 || !core.endsWith(">")) { + classNameFor(core) + } else { + val rawName = core.substring(0, genericStart) + val argsText = core.substring(genericStart + 1, core.length - 1) + val args = splitTopLevel(argsText, ',').map { parseTypeName(it) } + if (args.isEmpty()) classNameFor(rawName) else classNameFor(rawName).parameterizedBy(args) + } + return base.copyNullable(nullable) +} + +private fun classNameFor(text: String): ClassName = + if ('.' in text) ClassName.bestGuess(text) else KOTLIN_BUILTIN_TYPES[text] ?: KOTLIN_COLLECTION_TYPES[text] ?: ClassName.bestGuess(text) + +private fun TypeName.copyNullable(nullable: Boolean): TypeName = when (this) { + is ClassName -> copy(nullable = nullable) + else -> copy(nullable = nullable) +} + +private val crossModuleExpectAnnotation = ClassName("net.kernelpanicsoft.actualizer.annotations", "CrossModuleExpect") +private val notLinkedErrorClass = ClassName("net.kernelpanicsoft.actualizer.runtime", "ActualizerNotLinkedError") + +private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of("throw %T(%S)", notLinkedErrorClass, fqName) + +private fun buildFunctionStub(member: ExpectMember.Function, fqNamePrefix: String): FunSpec { + val builder = FunSpec.builder(member.name).addModifiers(KModifier.ACTUAL) + for (param in member.params) { + builder.addParameter(param.name, parseTypeName(param.typeText)) + } + if (member.returnTypeText != "Unit") { + builder.returns(parseTypeName(member.returnTypeText)) + } + builder.addCode(throwStatement("$fqNamePrefix.${member.name}")) + return builder.build() +} + +private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: String): PropertySpec { + val type = parseTypeName(member.typeText) + val fqName = "$fqNamePrefix.${member.name}" + val builder = PropertySpec.builder(member.name, type) + .addModifiers(KModifier.ACTUAL) + .mutable(member.mutable) + .getter(FunSpec.getterBuilder().addCode(throwStatement(fqName)).build()) + if (member.mutable) { + builder.setter( + FunSpec.setterBuilder() + .addParameter("value", type) + .addCode(throwStatement(fqName)) + .build() ) } + return builder.build() +} + +private fun buildClassStub(cls: ExpectClassInfo, packageName: String): TypeSpec { + val classFqName = "$packageName.${cls.name}" + val builder = TypeSpec.classBuilder(cls.name) + .addModifiers(KModifier.ACTUAL) + .addAnnotation(crossModuleExpectAnnotation) + + if (cls.constructorParams.isNotEmpty()) { + // Kotlin requires the primary constructor to be explicitly marked `actual` too, not just + // the class - otherwise: "Declaration must be marked with 'actual'" on the constructor. + val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL) + cls.constructorParams.forEach { ctor.addParameter(it.name, parseTypeName(it.typeText)) } + builder.primaryConstructor(ctor.build()) + } + // Fails fast on construction rather than relying on every member throwing individually - the + // member stubs below still have to exist so the actual class structurally matches the expect + // class, but this makes the "not linked" error surface at the very first point of use. + builder.addInitializerBlock(throwStatement(classFqName)) + + for (member in cls.members) { + when (member) { + is ExpectMember.Function -> builder.addFunction(buildFunctionStub(member, classFqName)) + is ExpectMember.Property -> builder.addProperty(buildPropertyStub(member, classFqName)) + } + } + return builder.build() +} + +internal fun generateStubFileContent(scanned: ScannedExpectFile): String { + val fileSpec = FileSpec.builder(scanned.packageName, "${scanned.sourceFile.nameWithoutExtension}Stub") + .addFileComment("GENERATED by the Actualizer Gradle plugin (actualizer { stubUnfulfilledExpects() }).\n") + .addFileComment("Source: %L\n", scanned.sourceFile) + .addFileComment("Do not edit - regenerated on every build.") + + for (member in scanned.topLevel) { + when (member) { + is ExpectMember.Function -> fileSpec.addFunction( + buildFunctionStub(member, scanned.packageName).toBuilder() + .addAnnotation(crossModuleExpectAnnotation) + .build() + ) + is ExpectMember.Property -> fileSpec.addProperty( + buildPropertyStub(member, scanned.packageName).toBuilder() + .addAnnotation(crossModuleExpectAnnotation) + .build() + ) + } + } + for (cls in scanned.classes) { + fileSpec.addType(buildClassStub(cls, scanned.packageName)) + } + + return fileSpec.build().toString() } diff --git a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 519051d..38a2322 100644 --- a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -7,3 +7,12 @@ import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect @CrossModuleExpect actual fun greetingSuffix(): String = "(actualized against the published api:0.1.0 library)" + +@CrossModuleExpect +actual val platformName: String = ":sample:actual-jvm-published" + +@CrossModuleExpect +actual class GreetingCounter actual constructor(start: Int) { + private var current = start + actual fun next(): Int = current++ +} diff --git a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 63790e0..649959b 100644 --- a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -7,3 +7,12 @@ import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect @CrossModuleExpect actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)" + +@CrossModuleExpect +actual val platformName: String = ":sample:actual-jvm" + +@CrossModuleExpect +actual class GreetingCounter actual constructor(start: Int) { + private var current = start + actual fun next(): Int = current++ +} diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt new file mode 100644 index 0000000..a4f957c --- /dev/null +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt @@ -0,0 +1,18 @@ +package net.kernelpanicsoft.sample.api + +import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect + +/** Exercises the stub generator's `expect val` support (Greeting.kt already covers `expect fun`). */ +@CrossModuleExpect +expect val platformName: String + +/** + * Exercises the stub generator's `expect class` support: a constructor parameter plus a member + * function, both of which the generated `actual` stub has to structurally match. + */ +@CrossModuleExpect +expect class GreetingCounter(start: Int) { + fun next(): Int +} + +fun describePlatform(): String = "Running on $platformName" diff --git a/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt b/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt index beb5ee3..d63a6e7 100644 --- a/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt +++ b/sample/app-published/src/main/kotlin/net/kernelpanicsoft/sample/apppublished/Main.kt @@ -1,7 +1,12 @@ package net.kernelpanicsoft.sample.apppublished +import net.kernelpanicsoft.sample.api.GreetingCounter +import net.kernelpanicsoft.sample.api.describePlatform import net.kernelpanicsoft.sample.api.greet fun main() { println(greet("world")) + println(describePlatform()) + val counter = GreetingCounter(10) + println("Greeting counter: ${counter.next()}, ${counter.next()}") } diff --git a/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt index 2e3cbe8..e9816ad 100644 --- a/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt +++ b/sample/app/src/main/kotlin/net/kernelpanicsoft/sample/app/Main.kt @@ -1,7 +1,12 @@ package net.kernelpanicsoft.sample.app +import net.kernelpanicsoft.sample.api.GreetingCounter +import net.kernelpanicsoft.sample.api.describePlatform import net.kernelpanicsoft.sample.feature.welcomeMessage fun main() { println(welcomeMessage("world")) + println(describePlatform()) + val counter = GreetingCounter(1) + println("Greeting counter: ${counter.next()}, ${counter.next()}, ${counter.next()}") } From 4a6bc370be84c8875ef8de8fc85fdedcfc4581e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 02:06:16 +0000 Subject: [PATCH 09/19] Replace naive brace-counting expect scanner with real PSI parsing ExpectStubGenerator previously found expect declarations with a line-based regex plus manual brace-depth counting to detect the end of an expect class body. That's fragile: a `}` inside a comment or string literal, or a declaration split across multiple lines, can throw the depth counter off and corrupt what gets scanned into the generated stub. Replace it with genuine Kotlin PSI parsing via KotlinCoreEnvironment / KtPsiFactory (kotlin-compiler-embeddable, syntax-only - no semantic resolution, so it doesn't choke on the very unfulfilled expects being scanned for). Expect functions, properties, and classes are now found by walking real KtNamedFunction/KtProperty/KtClass declarations, and class members come from the real KtClassBody rather than a manual scan. Verified against an adversarial case (an expect class containing a comment with a stray `}`) that would have broken the old brace-counter; the generated stub correctly captured the class's member and kept a subsequent top-level expect fun separate. Full clean build (34/34 tasks) and both sample apps (:sample:app:run, :sample:app-published:run) still pass, with no JS/Node/npm/Yarn tasks anywhere in the build. Updated README's "How it actually works" and "Known limitations" sections to describe PSI-based scanning instead of the old regex/brace-counting description. --- README.md | 30 +-- plugin-build/gradle-plugin/build.gradle.kts | 4 + .../actualizer/gradle/ExpectStubGenerator.kt | 192 ++++++++++-------- 3 files changed, 124 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index be3aa95..614805e 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,11 @@ runtime dispatch.** platformName`, and `expect class GreetingCounter(start: Int) { fun next(): Int }` all live directly in the ordinary `main` source set, right next to regular code (`formatGreeting`, and `greet()`/`describePlatform()`, which call the expects). Before compiling, the Actualizer Gradle -plugin scans `main`'s `.kt` files for `expect` declarations (a small regex-based scan - see -"Known limitations") and generates a matching, throwing `actual` stub for each into -`build/generated/actualizer-stubs/...` (`ExpectStubGenerator.kt`, built with +plugin scans `main`'s `.kt` files for `expect` declarations - via real Kotlin PSI parsing +(`KtPsiFactory`, syntax-only, no semantic resolution - see "Known limitations") rather than a +line-based regex/brace-counting scan, so multi-line declarations, comments, and string literals +containing `{`/`}` are all handled correctly - and generates a matching, throwing `actual` stub +for each into `build/generated/actualizer-stubs/...` (`ExpectStubGenerator.kt`, built with [KotlinPoet](https://square.github.io/kotlinpoet/) rather than hand-rolled string concatenation, so the generated code is properly formatted/imported instead of assembled by hand), e.g.: ```kotlin @@ -311,17 +313,19 @@ sample/ - **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` are not a supported public API for third-party use; a future Kotlin release could change or remove this behavior without notice. -- **The `expect` stub scanner is a deliberately limited regex, not a real parser.** It matches - single-line `expect fun`/`expect val`/`expect var` declarations, and `expect class` bodies via - naive brace-depth counting (doesn't account for `{`/`}` inside string literals or comments) with - a flat member list inside. Not supported: generics on the containing function/class, `suspend`/ - extension receivers, multi-line parameter lists, supertypes, secondary constructors, nested - types, or constructor-parameter auto-properties (`class Foo(val x: Int)`). A real implementation - would parse Kotlin PSI properly; this repo's scanner (`ExpectStubGenerator.kt`) covers a - reasonably realistic subset and documents the gaps rather than pretending to be general. +- **The `expect` stub scanner is real PSI parsing, but syntax-only - still a deliberately scoped + subset, not a full compiler frontend.** `ExpectStubGenerator.kt` parses each file with + `KtPsiFactory` (the same parser Kotlin tooling uses) rather than a line-based regex/brace-counting + scan, so multi-line declarations, comments, and string literals containing `{`/`}` are all + handled correctly - `expect class` members come from the real `KtClassBody`, not a naive + brace-depth count. What's still out of scope: generics on the containing function/class, + `suspend`/extension receivers, supertypes, secondary constructors, nested types, and + constructor-parameter auto-properties (`class Foo(val x: Int)`). It's also deliberately *not* + running semantic analysis/type resolution - only enough to find declarations and read their + syntax - since resolving types is exactly what would fail on the unfulfilled expects being + scanned for in the first place; see the next bullet for what that still means downstream. Generated code itself goes through KotlinPoet (`FileSpec`/`FunSpec`/`PropertySpec`/`TypeSpec`), - not string concatenation, but KotlinPoet only helps once something's been scanned - it can't - make the regex scanning itself more capable. + not string concatenation. - **Type text scanned into a stub isn't a resolved type, just recognized text.** `parseTypeName` in `ExpectStubGenerator.kt` handles a trailing `?` and one level of generic nesting, and recognizes common `kotlin`/`kotlin.collections` names so they resolve without a bogus import (KotlinPoet diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts index 9941856..6571e15 100644 --- a/plugin-build/gradle-plugin/build.gradle.kts +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -21,6 +21,10 @@ dependencies { // instead of hand-rolled string building - correct formatting, imports, and Kotlin syntax // (including for `actual class` stubs) instead of ad-hoc string concatenation. implementation("com.squareup:kotlinpoet:1.18.1") + // Used to find `expect` declarations to stub via real PSI parsing (ExpectStubGenerator.kt) + // instead of a naive regex + brace-counting scan. Pure syntax parsing only - no semantic + // resolution - so it doesn't choke on the very thing being scanned for (unfulfilled expects). + implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.21") } gradlePlugin { diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt index c848cce..895b1e6 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -9,17 +9,32 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec +import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPsiFactory import java.io.File -// Deliberately scoped to a simple, single-declaration-per-line subset of Kotlin syntax: no -// generics on the containing class/function itself, no suspend/extension receivers, no supertypes, -// no secondary constructors or nested types, no constructor-parameter auto-properties -// (`class Foo(val x: Int)`), and expect class bodies are found via naive brace-depth counting -// (doesn't account for `{`/`}` inside string literals or comments). Real projects would want a -// proper PSI-based scan; this is a pragmatic, honestly-limited stand-in that covers straightforward -// `expect fun`/`expect val`/`expect var`/`expect class` declarations like the ones in this repo's -// sample. Once something IS matched, KotlinPoet handles turning it into correct Kotlin source -// (formatting, imports, escaping) instead of hand-rolled string concatenation. +// Finding `expect` declarations is done via real Kotlin PSI parsing (KtPsiFactory.createFile), +// not a line-based regex/brace-counting scan - so multi-line declarations, comments, string +// literals containing `{`/`}`, and odd formatting are all handled correctly, the same way any +// real Kotlin tool would see them. This is *syntax-only* parsing (no semantic analysis/type +// resolution - deliberately, since that's exactly what would fail on the unfulfilled expects +// being scanned for here), so what's captured for each declaration's parameter/return/property +// types is still raw *text* (`KtTypeReference.text`), not a resolved type - see parseTypeName's +// doc comment below for what that still means for generated stub correctness. Remaining scope +// limits versus a full compiler frontend: no generics on the containing function/class itself, no +// suspend/extension receivers, no supertypes, no secondary constructors or nested types, no +// constructor-parameter auto-properties (`class Foo(val x: Int)`). Once something IS matched, +// KotlinPoet handles turning it into correct Kotlin source (formatting, imports, escaping) +// instead of hand-rolled string concatenation. internal data class ParamText(val name: String, val typeText: String) @@ -52,79 +67,87 @@ internal data class ScannedExpectFile( val classes: List, ) -private val PACKAGE_REGEX = Regex("""^\s*package\s+([\w.]+)\s*$""") -private val EXPECT_FUN_REGEX = Regex("""^\s*expect\s+fun\s+(\w+)\s*\(([^)]*)\)\s*(:\s*([^{=]+))?\s*$""") -private val EXPECT_PROPERTY_REGEX = Regex("""^\s*expect\s+(val|var)\s+(\w+)\s*:\s*([^={\n]+?)\s*$""") -private val EXPECT_CLASS_HEADER_REGEX = Regex("""^\s*expect\s+class\s+(\w+)\s*(\(([^)]*)\))?\s*\{\s*$""") -private val MEMBER_FUN_REGEX = Regex("""^\s*fun\s+(\w+)\s*\(([^)]*)\)\s*(:\s*([^{=]+))?\s*$""") -private val MEMBER_PROPERTY_REGEX = Regex("""^\s*(val|var)\s+(\w+)\s*:\s*([^={\n]+?)\s*$""") - internal fun scanForExpectFunctions(files: Iterable): List { - return files.mapNotNull { file -> - if (!file.isFile || file.extension != "kt") return@mapNotNull null - var packageName = "" - val topLevel = mutableListOf() - val classes = mutableListOf() - - var inClassName: String? = null - var inClassParams: List = emptyList() - var inClassDepth = 0 - var inClassMembers = mutableListOf() - - file.forEachLine { line -> - PACKAGE_REGEX.find(line)?.let { packageName = it.groupValues[1] } - - if (inClassName != null) { - MEMBER_FUN_REGEX.find(line)?.let { match -> - inClassMembers += ExpectMember.Function( - name = match.groupValues[1], - params = parseParams(match.groupValues[2]), - returnTypeText = match.groupValues[4].trim().ifEmpty { "Unit" }, - ) - } - MEMBER_PROPERTY_REGEX.find(line)?.let { match -> - inClassMembers += ExpectMember.Property( - name = match.groupValues[2], - typeText = match.groupValues[3].trim(), - mutable = match.groupValues[1] == "var", - ) - } - inClassDepth += line.count { it == '{' } - line.count { it == '}' } - if (inClassDepth <= 0) { - classes += ExpectClassInfo(inClassName!!, inClassParams, inClassMembers) - inClassName = null - inClassMembers = mutableListOf() - } - return@forEachLine - } + val ktFiles = files.filter { it.isFile && it.extension == "kt" } + if (ktFiles.isEmpty()) return emptyList() + + // One throwaway Kotlin frontend "environment" per call, just to get a KtPsiFactory - disposed + // in `finally` so it doesn't leak across builds in a long-lived Gradle daemon. + val disposable = Disposer.newDisposable("actualizer-expect-scan") + try { + val environment = KotlinCoreEnvironment.createForProduction( + disposable, + CompilerConfiguration(), + EnvironmentConfigFiles.JVM_CONFIG_FILES, + ) + val psiFactory = KtPsiFactory(environment.project) + return ktFiles.mapNotNull { file -> scanKtFile(file, psiFactory.createFile(file.name, file.readText())) } + } finally { + Disposer.dispose(disposable) + } +} - EXPECT_CLASS_HEADER_REGEX.find(line)?.let { match -> - inClassName = match.groupValues[1] - inClassParams = parseParams(match.groupValues[3]) - inClassDepth = 1 - return@forEachLine - } - EXPECT_FUN_REGEX.find(line)?.let { match -> - topLevel += ExpectMember.Function( - name = match.groupValues[1], - params = parseParams(match.groupValues[2]), - returnTypeText = match.groupValues[4].trim().ifEmpty { "Unit" }, - ) - } - EXPECT_PROPERTY_REGEX.find(line)?.let { match -> - topLevel += ExpectMember.Property( - name = match.groupValues[2], - typeText = match.groupValues[3].trim(), - mutable = match.groupValues[1] == "var", - ) - } +private fun scanKtFile(sourceFile: File, ktFile: KtFile): ScannedExpectFile? { + val topLevel = mutableListOf() + val classes = mutableListOf() + + for (declaration in ktFile.declarations) { + when { + declaration is KtNamedFunction && declaration.isExpect() -> topLevel += toFunctionMember(declaration) + declaration is KtProperty && declaration.isExpect() -> topLevel += toPropertyMember(declaration) + declaration is KtClass && declaration.isExpect() -> classes += toClassInfo(declaration) } + } + + if (topLevel.isEmpty() && classes.isEmpty()) return null + return ScannedExpectFile(sourceFile, ktFile.packageFqName.asString(), topLevel, classes) +} + +private fun KtDeclaration.isExpect(): Boolean = hasModifier(KtTokens.EXPECT_KEYWORD) - if (topLevel.isEmpty() && classes.isEmpty()) null else ScannedExpectFile(file, packageName, topLevel, classes) +private fun toFunctionMember(function: KtNamedFunction): ExpectMember.Function { + val params = function.valueParameters.map { param -> + ParamText(param.name ?: "arg", param.typeReference?.text ?: "Any") } + return ExpectMember.Function( + name = function.name ?: error("Actualizer: found an unnamed 'expect fun' in ${function.containingFile.name}"), + params = params, + returnTypeText = function.typeReference?.text ?: "Unit", + ) } -/** Splits on [separator] at nesting depth 0 only, so `<...>`/`(...)` in types/defaults survive. */ +private fun toPropertyMember(property: KtProperty): ExpectMember.Property { + val typeText = property.typeReference?.text + ?: error( + "Actualizer: 'expect ${if (property.isVar) "var" else "val"} ${property.name}' in " + + "${property.containingFile.name} needs an explicit type for stubUnfulfilledExpects() to stub it." + ) + return ExpectMember.Property( + name = property.name ?: error("Actualizer: found an unnamed 'expect val/var' in ${property.containingFile.name}"), + typeText = typeText, + mutable = property.isVar, + ) +} + +private fun toClassInfo(cls: KtClass): ExpectClassInfo { + val constructorParams = cls.primaryConstructor?.valueParameters.orEmpty().map { param -> + ParamText(param.name ?: "arg", param.typeReference?.text ?: "Any") + } + val members = cls.body?.declarations.orEmpty().mapNotNull { member -> + when (member) { + is KtNamedFunction -> toFunctionMember(member) + is KtProperty -> toPropertyMember(member) + else -> null // nested types, secondary constructors, etc. - out of scope, see file header. + } + } + return ExpectClassInfo( + name = cls.name ?: error("Actualizer: found an unnamed 'expect class' in ${cls.containingFile.name}"), + constructorParams = constructorParams, + members = members, + ) +} + +/** Splits on [separator] at nesting depth 0 only, so `<...>` in generic type arguments survives. */ private fun splitTopLevel(text: String, separator: Char): List { if (text.isBlank()) return emptyList() val parts = mutableListOf() @@ -144,17 +167,6 @@ private fun splitTopLevel(text: String, separator: Char): List { return parts.map { it.trim() }.filter { it.isNotEmpty() } } -private fun parseParams(text: String): List { - return splitTopLevel(text, ',').mapNotNull { part -> - val withoutDefault = part.substringBefore("=").trim() - val colonIndex = withoutDefault.indexOf(':') - if (colonIndex == -1) return@mapNotNull null - val name = withoutDefault.substring(0, colonIndex).trim().removePrefix("vararg").trim() - val type = withoutDefault.substring(colonIndex + 1).trim() - ParamText(name, type) - } -} - // ClassName.bestGuess("String") produces a ClassName with an EMPTY package (it looks like a // default-package top-level class), and KotlinPoet then emits a nonsensical `import String` for // it - there's nothing to import for a name with no package. Recognizing common stdlib names and @@ -173,9 +185,11 @@ private val KOTLIN_COLLECTION_TYPES = listOf( /** * Best-effort raw-text -> [TypeName] conversion: handles a trailing `?` (nullability), one level * of generic nesting (`Foo`, `Foo>`, ...), and recognizes common stdlib type names - * (see [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES]) so they resolve correctly. Since this - * only ever sees text captured by the regexes above - never a resolved type - anything else that - * isn't already fully qualified and also isn't one of those recognized stdlib names (an + * (see [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES]) so they resolve correctly. Even though + * the *scanning* is now real PSI parsing (see the file header), this still only ever sees the + * *text* of a `KtTypeReference` - syntax parsing alone doesn't resolve what a type name actually + * refers to, so this has no more semantic information than the regex-based version did. Anything + * that isn't already fully qualified and also isn't one of the recognized stdlib names (an * unqualified *custom* type, e.g. `expect fun f(): MyDataClass`) will still get treated as a * default-package class needing an import that doesn't actually exist, and fail to compile. Write * fully-qualified types in `expect` declarations meant to be stubbed if this matters. From 34d68c46ee1eac3bac5b78ff933632f434c48260 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:03:59 +0000 Subject: [PATCH 10/19] Remove @CrossModuleExpect annotation; mitigate IDE expect/actual false positives A plain kotlin("jvm") module never sees expect/actual at all unless Actualizer put them there via -Xmulti-platform, so every pair the IR plugin sees is already a genuine Actualizer-managed cross-module pair by construction - the annotation added ceremony without disambiguating anything real. The IR extension now correlates actuals to their owning foreign module by package match instead of by annotation, and the stub generator no longer emits it. Deletes the now-unused actualizer-annotations module entirely. Also adds the real Architectury (Fabric + NeoForge) multiloader sample as a separate Gradle build (sample-architectury/), the actual motivating use case for this plugin, and mitigates the IDE-only false positives (EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE, ACTUAL_WITHOUT_EXPECT - confirmed via FirErrors bytecode inspection) that show up because the IDE's live analysis doesn't apply -Xmulti-platform: generated stubs carry @Suppress automatically, hand-written expect/actual files get a documented @file:Suppress convention. No stable FIR/K2 API exists to suppress a built-in diagnostic from a compiler plugin, so @Suppress is the mechanism that actually works in both the compiler and the IDE. Fixes a real latent bug found along the way: stub generation ran eagerly at Gradle configuration time, so `gradle clean build` in one invocation wiped the generated stubs (via clean's execution) before compileKotlin ran. Generation is now a proper Gradle task with real inputs/outputs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s --- README.md | 115 ++++++-- actualizer-annotations/build.gradle.kts | 14 - .../annotations/CrossModuleExpect.kt | 17 -- actualizer-runtime/build.gradle.kts | 14 - .../runtime/ActualizerNotLinkedError.kt | 13 - gradle/libs.versions.toml | 6 + .../compiler/ir/ActualizerIrExtension.kt | 80 +++--- .../actualizer/gradle/ActualizerExtension.kt | 16 +- .../gradle/ActualizerGradlePlugin.kt | 49 +++- .../actualizer/gradle/ExpectStubGenerator.kt | 253 +++++++++++------- sample-architectury/.gitignore | 7 + sample-architectury/README.md | 79 ++++++ sample-architectury/build.gradle.kts | 43 +++ sample-architectury/common/build.gradle.kts | 11 + .../samplemod/common/ModCommon.kt | 22 ++ sample-architectury/fabric/build.gradle.kts | 25 ++ sample-architectury/fabric/gradle.properties | 1 + .../samplemod/common/Actual.kt | 27 ++ .../samplemod/fabric/FabricSampleMod.kt | 15 ++ .../fabric/src/main/resources/fabric.mod.json | 16 ++ sample-architectury/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + sample-architectury/gradlew | 251 +++++++++++++++++ sample-architectury/gradlew.bat | 94 +++++++ sample-architectury/neoforge/build.gradle.kts | 25 ++ .../neoforge/gradle.properties | 1 + .../samplemod/common/Actual.kt | 21 ++ .../samplemod/neoforge/NeoForgeSampleMod.kt | 16 ++ .../resources/META-INF/neoforge.mods.toml | 22 ++ sample-architectury/settings.gradle.kts | 27 ++ sample/actual-jvm-published/build.gradle.kts | 9 +- .../net/kernelpanicsoft/sample/api/Actual.kt | 10 +- sample/actual-jvm/build.gradle.kts | 12 +- .../net/kernelpanicsoft/sample/api/Actual.kt | 10 +- sample/api/build.gradle.kts | 5 - .../kernelpanicsoft/sample/api/Greeting.kt | 14 +- .../sample/api/PlatformInfo.kt | 6 +- .../kernelpanicsoft/sample/feature/Feature.kt | 2 +- settings.gradle.kts | 2 - 40 files changed, 1072 insertions(+), 286 deletions(-) delete mode 100644 actualizer-annotations/build.gradle.kts delete mode 100644 actualizer-annotations/src/main/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt delete mode 100644 actualizer-runtime/build.gradle.kts delete mode 100644 actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt create mode 100644 gradle/libs.versions.toml create mode 100644 sample-architectury/.gitignore create mode 100644 sample-architectury/README.md create mode 100644 sample-architectury/build.gradle.kts create mode 100644 sample-architectury/common/build.gradle.kts create mode 100644 sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt create mode 100644 sample-architectury/fabric/build.gradle.kts create mode 100644 sample-architectury/fabric/gradle.properties create mode 100644 sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt create mode 100644 sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt create mode 100644 sample-architectury/fabric/src/main/resources/fabric.mod.json create mode 100644 sample-architectury/gradle.properties create mode 100644 sample-architectury/gradle/wrapper/gradle-wrapper.jar create mode 100644 sample-architectury/gradle/wrapper/gradle-wrapper.properties create mode 100755 sample-architectury/gradlew create mode 100644 sample-architectury/gradlew.bat create mode 100644 sample-architectury/neoforge/build.gradle.kts create mode 100644 sample-architectury/neoforge/gradle.properties create mode 100644 sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt create mode 100644 sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt create mode 100644 sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml create mode 100644 sample-architectury/settings.gradle.kts diff --git a/README.md b/README.md index 614805e..da569d6 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,21 @@ nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. [feature-common] Hello, world! (actualized independently by :sample:actual-jvm) ``` -All published modules (`actualizer-annotations`, `actualizer-runtime`, `compiler-plugin`, -`gradle-plugin`) use the `net.kernelpanicsoft` Maven group; the plugin/package namespace is -`net.kernelpanicsoft.actualizer`. +All published modules (`compiler-plugin`, `gradle-plugin`) use the `net.kernelpanicsoft` Maven +group; the plugin/package namespace is `net.kernelpanicsoft.actualizer`. + +There's also a second, real-world sample build in `sample-architectury/` - an Architectury +(Fabric + NeoForge) Minecraft multiloader project, the actual motivating use case for this whole +mechanism (see "Motivation" below). It's a genuinely separate Gradle build with its own settings, +proving the mechanism works across two independently-configured, non-KMP mod-loader toolchains, +not just the generic plain-JVM sample above. See `sample-architectury/README.md`. + +No annotation is required on `expect`/`actual` declarations. A plain `kotlin("jvm")` module never +sees `expect`/`actual` at all unless Actualizer put them there via `-Xmulti-platform` - so every +`expect`/`actual` pair Actualizer's IR plugin sees is, by construction, already a genuine +Actualizer-managed cross-module pair. There used to be a `@CrossModuleExpect` marker annotation; +it added ceremony without disambiguating anything real, since the ambiguity it existed to resolve +(accidental multiplatform expect/actual usage) can't happen in a non-multiplatform module. ## Motivation: multiloader-style projects @@ -96,16 +108,18 @@ for each into `build/generated/actualizer-stubs/...` (`ExpectStubGenerator.kt`, [KotlinPoet](https://square.github.io/kotlinpoet/) rather than hand-rolled string concatenation, so the generated code is properly formatted/imported instead of assembled by hand), e.g.: ```kotlin -actual fun greetingSuffix(): String = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.greetingSuffix") +actual fun greetingSuffix(): String = throw IllegalStateException("net.kernelpanicsoft.sample.api.greetingSuffix was never actualized - ...") actual val platformName: String - get() = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.platformName") + get() = throw IllegalStateException("net.kernelpanicsoft.sample.api.platformName was never actualized - ...") actual class GreetingCounter actual constructor(start: Int) { - init { throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.GreetingCounter") } - actual fun next(): Int = throw ActualizerNotLinkedError("net.kernelpanicsoft.sample.api.GreetingCounter.next") + init { throw IllegalStateException("net.kernelpanicsoft.sample.api.GreetingCounter was never actualized - ...") } + actual fun next(): Int = throw IllegalStateException("net.kernelpanicsoft.sample.api.GreetingCounter.next was never actualized - ...") } ``` +(each generated declaration also carries `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", +"ACTUAL_WITHOUT_EXPECT")` - see "IDE false positives" below for why.) (the class stub throws immediately in its `init` block - construction fails fast rather than relying on every member throwing individually - but the member stubs still have to exist so the actual class structurally matches the expect class). That generated file is added as an extra @@ -118,7 +132,7 @@ instead of a hand-written one. The real Kotlin frontend links the (real) expects `:sample:feature-common` depends on that jar with a plain `implementation(project(":sample:api"))` and calls `greet()` normally - the call compiles and links fine (it's not calling an `expect`, it's calling an ordinary function that happens to call one). Calling it *through this standalone -chain* throws `ActualizerNotLinkedError` at runtime, because it's bound to the stub - see part 2 +chain* throws `IllegalStateException` at runtime, because it's bound to the stub - see part 2 for how the real chain gets built instead. **2. The actual linking happens in the leaf module, via source merging - of multiple foreign @@ -162,13 +176,14 @@ time it runs, the frontend has *already* resolved (or already failed the build o expect/actual pair - IR generation only happens after a successful frontend pass, so there is no "unlinked" state left for an `IrGenerationExtension` to fix. Its job is everything the raw mechanism *doesn't* give you for free: -- Only declarations annotated `@CrossModuleExpect` (from `actualizer-annotations`) are treated as - cross-module links at all - merging in a foreign source directory doesn't mean every - declaration in it is fair game, just the ones that opted in. - It attaches **module provenance**: the frontend only knows about files, not Gradle modules. The - plugin walks the merged `IrModuleFragment`, matches each `actual`'s package against the - packages of files pulled in from each foreign module (Gradle module names are supplied via a - `moduleMap` compiler-plugin option built by the Gradle plugin), and writes a JSON report to + plugin walks the merged `IrModuleFragment`, groups the files pulled in from each foreign module + by package (using the `moduleMap` compiler-plugin option built by the Gradle plugin, which maps + each foreign source root back to its owning Gradle module name), and reports every + locally-declared top-level declaration whose package matches one of those foreign packages as + the `actual` linking it - no annotation needed to identify which declarations are cross-module + links, since in a plain `kotlin("jvm")` module every `expect`/`actual` pair reaching this + extension is one by construction. It writes a JSON report to `build/actualizer/report.json`: ```json { "consumingModule": ":sample:actual-jvm", @@ -225,7 +240,7 @@ Everything above was actually built and run in this environment with Gradle 8.14 `describePlatform` and the auto-generated throwing stubs for `greetingSuffix`, `platformName`, and `GreetingCounter` (function, property, and class stubs all covered). - Calling `greet()` via `:api`'s standalone jar directly (verified with a throwaway consumer - project pointed at just that jar) throws `ActualizerNotLinkedError` with a clear message, as + project pointed at just that jar) throws `IllegalStateException` with a clear message, as designed. - `./gradlew :sample:feature-common:build` - succeeds depending only on `:sample:api`'s plain jar, before any actual exists anywhere. @@ -255,8 +270,6 @@ Everything above was actually built and run in this environment with Gradle 8.14 ## Repo layout ``` -actualizer-annotations/ @CrossModuleExpect marker (plain kotlin("jvm")) -actualizer-runtime/ ActualizerNotLinkedError, thrown by auto-generated stubs plugin-build/ composite build (keeps the plugin's own Kotlin version pinned independently of consumers, standard Kotlin-compiler-plugin layout) compiler-plugin/ the IR compiler plugin itself (CommandLineProcessor, @@ -279,8 +292,51 @@ sample/ actual-jvm-published/ same idea as actual-jvm, but actualizes api's *published* Maven coordinate instead of a project(...) reference app-published/ plain binary consumer of actual-jvm-published, no special wiring +sample-architectury/ a genuinely separate Gradle build - the real, motivating multiloader + use case (Fabric + NeoForge via Architectury Loom); see its own + README.md ``` +## IDE false positives + +The IDE's live analysis of a plain `kotlin("jvm")` module doesn't apply the `-Xmulti-platform` +compiler flag that makes this whole mechanism compile in the first place, so it reports two real +Kotlin diagnostics as squiggles even though the Gradle build is fine: + +``` +'public final actual fun greetingSuffix(): String' has no corresponding 'expect' declaration +greetingSuffix: 'expect' and corresponding 'actual' are declared in the same module. +``` + +These are `ACTUAL_WITHOUT_EXPECT` and `EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE` (confirmed by +inspecting `kotlin-compiler-embeddable`'s `FirErrors` directly). There is no stable, documented +Kotlin compiler-plugin API to suppress a *built-in* diagnostic from a FIR extension: +`FirAdditionalCheckersExtension` only lets a plugin *add* checkers, and the older, fully general +`DiagnosticSuppressor` SPI is K1-only (it operates on the old `Diagnostic` type, which the K2/FIR +pipeline this repo targets doesn't use). So this repo takes the one mechanism that reliably works +in both the compiler and the IDE regardless of frontend version: `@Suppress`. + +- Every stub `actual` declaration `ExpectStubGenerator.kt` generates carries + `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT")` automatically - no + action needed for a `stubUnfulfilledExpects()`-only module's generated code. +- Hand-written `expect`/`actual` files (the ones you actually write, in a `stubUnfulfilledExpects()` + module's own expect declarations, or in an `actualizes(...)` leaf's real actuals) need the same + suppression added once, at the top of the file: `@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", + "ACTUAL_WITHOUT_EXPECT")`. Every sample in this repo (`sample/api`, `sample/actual-jvm`, + `sample/actual-jvm-published`, and all three `sample-architectury` modules that declare or + actualize an expect) does this - see any of their source files for the exact form. +- The compiler itself warns when this suppression is used (`This code uses error suppression for + '...'. [...] the compiler behavior is UNSPECIFIED and WON'T BE PRESERVED`) - that's expected and + harmless (it doesn't fail the build); it's Kotlin's standard warning for suppressing an error-severity + diagnostic via `@Suppress`, not something specific to this mechanism. +- This is purely cosmetic - it only affects what the IDE shows while editing, never what actually + compiles or runs. +- **Not attempted**: the IDE's "KMP gutter icon" (the margin icon that lets you jump between + `expect` and `actual`) is populated from genuine Kotlin Multiplatform module structure + (`dependsOn` source-set edges the IDE's Gradle importer understands), which a plain + `kotlin("jvm")` module fundamentally doesn't have - getting it to appear here would need a real + IntelliJ plugin, not just a compiler/Gradle plugin, so it's out of scope for this repo. + ## Known limitations - **JVM only.** The `-Xcommon-sources` source-merge trick was only verified for the JVM target. @@ -317,23 +373,26 @@ sample/ subset, not a full compiler frontend.** `ExpectStubGenerator.kt` parses each file with `KtPsiFactory` (the same parser Kotlin tooling uses) rather than a line-based regex/brace-counting scan, so multi-line declarations, comments, and string literals containing `{`/`}` are all - handled correctly - `expect class` members come from the real `KtClassBody`, not a naive - brace-depth count. What's still out of scope: generics on the containing function/class, - `suspend`/extension receivers, supertypes, secondary constructors, nested types, and + handled correctly - `expect class`/`expect object` members come from the real `KtClassBody`, not + a naive brace-depth count, and `suspend fun` is recognized correctly. What's still out of scope: + generics on the containing function/class, supertypes, secondary constructors, nested types, and constructor-parameter auto-properties (`class Foo(val x: Int)`). It's also deliberately *not* running semantic analysis/type resolution - only enough to find declarations and read their syntax - since resolving types is exactly what would fail on the unfulfilled expects being scanned for in the first place; see the next bullet for what that still means downstream. Generated code itself goes through KotlinPoet (`FileSpec`/`FunSpec`/`PropertySpec`/`TypeSpec`), not string concatenation. -- **Type text scanned into a stub isn't a resolved type, just recognized text.** `parseTypeName` in - `ExpectStubGenerator.kt` handles a trailing `?` and one level of generic nesting, and recognizes - common `kotlin`/`kotlin.collections` names so they resolve without a bogus import (KotlinPoet - would otherwise try to `import String`/`import Int`, which isn't valid Kotlin - `bestGuess` on an - unqualified name assumes it needs importing regardless of whether it actually does). Any other - unqualified *custom* type (e.g. `expect fun f(): MyDataClass` where `MyDataClass` isn't already - fully qualified) hits the same problem the recognized names avoid, and the generated stub won't - compile. Write fully-qualified types in `expect` declarations meant to be stubbed if this matters. +- **Type text scanned into a stub isn't a resolved type, just a structurally-parsed name.** + `KtTypeReference.toTypeName` in `ExpectStubGenerator.kt` walks the real PSI type tree + (`KtUserType`/`KtFunctionType`/`KtNullableType`), not a second ad-hoc string parse - so function + types (including nested ones like `() -> () -> Screen`, extension-receiver lambdas like + `Int.(String) -> Boolean`, nullable lambdas, and generic type arguments) are all handled + correctly, and a bare type name is resolved against this file's own `import` directives (skipping + star imports, which aren't expandable without semantic resolution), common `kotlin`/ + `kotlin.collections` names, and finally assumed to be a sibling type in the file's own package. + That last fallback is still a guess: a star-imported type, or one genuinely meant to be in the + default package, resolves to the wrong package and the generated stub won't compile. Write + fully-qualified types in `expect` declarations meant to be stubbed if this matters. - **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin` deliberately avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, `KotlinCompile`, etc.) and uses reflection-by-name instead - `plugin-build` resolves its own diff --git a/actualizer-annotations/build.gradle.kts b/actualizer-annotations/build.gradle.kts deleted file mode 100644 index fcb9182..0000000 --- a/actualizer-annotations/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - kotlin("jvm") -} - -group = "net.kernelpanicsoft" -version = "0.1.0" - -repositories { - mavenCentral() -} - -kotlin { - jvmToolchain(21) -} diff --git a/actualizer-annotations/src/main/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt b/actualizer-annotations/src/main/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt deleted file mode 100644 index e3214fc..0000000 --- a/actualizer-annotations/src/main/kotlin/net/kernelpanicsoft/actualizer/annotations/CrossModuleExpect.kt +++ /dev/null @@ -1,17 +0,0 @@ -package net.kernelpanicsoft.actualizer.annotations - -/** - * Opt-in marker for an `expect` declaration that is meant to be actualized by a real `actual` - * living in a *different, independently built* Gradle module - one that is not part of this - * declaration's own multiplatform source-set hierarchy. - * - * The Actualizer Gradle plugin merges the source file this annotation appears in directly into - * the compilation of whichever module calls `actualizer { actualizes(project(...)) }`, so the - * real Kotlin compiler frontend performs the expect/actual resolution. This annotation exists - * so the Actualizer IR plugin only reports on (and, in policy mode, only allows) declarations - * that were deliberately opted into cross-module actualization, rather than silently treating - * every merged-in `expect` the same way. - */ -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) -@Retention(AnnotationRetention.BINARY) -annotation class CrossModuleExpect diff --git a/actualizer-runtime/build.gradle.kts b/actualizer-runtime/build.gradle.kts deleted file mode 100644 index fcb9182..0000000 --- a/actualizer-runtime/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - kotlin("jvm") -} - -group = "net.kernelpanicsoft" -version = "0.1.0" - -repositories { - mavenCentral() -} - -kotlin { - jvmToolchain(21) -} diff --git a/actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt b/actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt deleted file mode 100644 index bea111f..0000000 --- a/actualizer-runtime/src/main/kotlin/net/kernelpanicsoft/actualizer/runtime/ActualizerNotLinkedError.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.kernelpanicsoft.actualizer.runtime - -/** - * Thrown by an auto-generated stub `actual` - see `actualizer { stubUnfulfilledExpects() }`. - * A stub exists purely so a module with an unfulfilled `expect` can still compile into a normal, - * standalone jar; calling into it directly (rather than via a module where the real `actual` was - * merged in and linked) means the real implementation was never linked here. - */ -class ActualizerNotLinkedError(fqName: String) : IllegalStateException( - "$fqName was never actualized - this jar was built standalone with an auto-generated stub " + - "(see actualizer { stubUnfulfilledExpects() }). Depend on a module that actually links " + - "it (an actualizer { actualizes(...) } leaf) to get the real implementation." -) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..6f97b2e --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,6 @@ +[versions] +sample-api = "0.1.0" + +[libraries] +# :sample:actual-jvm-published's published-coordinate actualizes() target - see its build.gradle.kts. +sample-api = { module = "net.kernelpanicsoft.sample:api", version.ref = "sample-api" } diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt index 7b0a340..bdc3890 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt @@ -6,11 +6,9 @@ import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.ir.declarations.IrDeclarationWithName +import org.jetbrains.kotlin.ir.declarations.IrFile import org.jetbrains.kotlin.ir.declarations.IrModuleFragment import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI -import org.jetbrains.kotlin.ir.util.getPackageFragment -import org.jetbrains.kotlin.ir.util.hasAnnotation -import org.jetbrains.kotlin.name.FqName import java.io.File data class ModuleRoot(val root: String, val moduleName: String) @@ -23,8 +21,8 @@ private data class LinkEntry( /** * Runs inside a compilation whose source set has been merged (by the Gradle plugin) from a - * foreign, expect-declaring Gradle module's `crossModuleApi` source set and this module's own - * `actual` declarations, compiled together via `-Xmulti-platform` + `-Xcommon-sources`. + * foreign, expect-declaring Gradle module's `main` source set and this module's own `actual` + * declarations, compiled together via `-Xmulti-platform` + `-Xcommon-sources`. * * By the time this extension runs, the Kotlin frontend has *already* resolved expect/actual for * this compilation (or the build would already have failed) - this extension does not perform @@ -34,13 +32,19 @@ private data class LinkEntry( * its own. * * Note: once expect/actual are resolved, the `expect` declaration itself is elided from this - * compilation's IR - only the linked `actual` remains as a real `IrFunction`/`IrClass`. So - * rather than trying to pair up an "expect IR declaration" with an "actual IR declaration" by - * name (the expect side isn't there to find), this walks the *files* that were merged in from - * each foreign module and records their package names, then reports every locally-declared, - * `@CrossModuleExpect`-annotated `actual` whose package matches one of those foreign packages - - * `actual` is required to share its expect's fully-qualified name, so this is a precise, - * non-heuristic correlation. + * compilation's IR - only the linked `actual` remains as a real `IrFunction`/`IrClass`. So rather + * than trying to pair up an "expect IR declaration" with an "actual IR declaration" by name (the + * expect side isn't there to find), this walks the *files* that were merged in from each foreign + * module and records their package names, then reports every locally-declared top-level + * declaration whose package matches one of those foreign packages as the `actual` linking it. + * + * No annotation-based opt-in (there used to be one, `@CrossModuleExpect`) - a plain + * `kotlin("jvm")` module never sees `expect`/`actual` at all unless Actualizer put them there via + * `-Xmulti-platform`, so every `expect`/`actual` pair reaching this extension in the first place + * is already, by construction, a genuine Actualizer-managed cross-module pair. There's no + * "accidental" expect/actual usage in one of these modules to disambiguate from - Kotlin + * Multiplatform projects (where that ambiguity *would* exist) simply aren't what this plugin + * targets. */ class ActualizerIrExtension( private val moduleMap: List, @@ -52,7 +56,7 @@ class ActualizerIrExtension( @OptIn(UnsafeDuringIrConstructionAPI::class) override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { val foreignModulesByPackage = mutableMapOf>() - val localAnnotatedActuals = mutableListOf() + val localFilesByPackage = mutableMapOf>() for (file in moduleFragment.files) { val filePath = file.fileEntry.name @@ -61,40 +65,30 @@ class ActualizerIrExtension( if (owningModule != null) { foreignModulesByPackage.getOrPut(file.packageFqName.asString()) { mutableListOf() } += owningModule } else { - for (declaration in file.declarations) { - if (declaration is IrDeclarationWithName && declaration.hasAnnotation(CROSS_MODULE_EXPECT_FQ_NAME)) { - localAnnotatedActuals += declaration - } - } + localFilesByPackage.getOrPut(file.packageFqName.asString()) { mutableListOf() } += file } } - val links = localAnnotatedActuals.map { actual -> - val packageName = actual.getPackageFragment()?.packageFqName?.asString().orEmpty() - LinkEntry( - actualFqName = "$packageName.${actual.name.asString()}", - owningModules = foreignModulesByPackage[packageName].orEmpty(), - consumingModule = selfModule, - ) + val links = foreignModulesByPackage.flatMap { (packageName, owningModules) -> + localFilesByPackage[packageName].orEmpty() + .flatMap { it.declarations } + .filterIsInstance() + .map { declaration -> + LinkEntry( + actualFqName = "$packageName.${declaration.name.asString()}", + owningModules = owningModules, + consumingModule = selfModule, + ) + } } for (link in links) { - if (link.owningModules.isEmpty()) { - messageCollector.report( - CompilerMessageSeverity.WARNING, - "Actualizer: '${link.actualFqName}' in module '$selfModule' is marked " + - "@CrossModuleExpect but no merged-in foreign source contributed its package - " + - "was it actually meant to actualize something from actualizer { actualizes(...) }?", - null as CompilerMessageSourceLocation?, - ) - } else { - messageCollector.report( - CompilerMessageSeverity.LOGGING, - "Actualizer: '${link.actualFqName}' in module '$selfModule' actualizes an expect " + - "declared in ${link.owningModules}", - null as CompilerMessageSourceLocation?, - ) - } + messageCollector.report( + CompilerMessageSeverity.LOGGING, + "Actualizer: '${link.actualFqName}' in module '$selfModule' actualizes an expect " + + "declared in ${link.owningModules}", + null as CompilerMessageSourceLocation?, + ) } reportOutputPath?.let { path -> writeReport(path, links) } @@ -122,8 +116,4 @@ class ActualizerIrExtension( } file.writeText(json) } - - private companion object { - val CROSS_MODULE_EXPECT_FQ_NAME = FqName("net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect") - } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt index d954e2c..5831477 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt @@ -1,7 +1,9 @@ package net.kernelpanicsoft.actualizer.gradle import org.gradle.api.Project +import org.gradle.api.artifacts.MinimalExternalModuleDependency import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Provider import javax.inject.Inject /** Default name of the source set Actualizer merges in from a foreign, expect-declaring project. */ @@ -27,12 +29,15 @@ internal data class ActualizedPublishedSource(val coordinate: String, val depend * has no way to infer that ordering the way it does for `project(...)` dependencies, so name * whatever publishing task(s) need to run first explicitly. See `registerPublishedSourcesSync` * in `ActualizerGradlePlugin.kt` for the resolution mechanics and its requirements on how the - * library publishes its sources jar. + * library publishes its sources jar. `actualizes(libs.foo.bar, ...)` - a version catalog + * accessor, `Provider` - works too, and is the preferred way to + * call this: the coordinate then stays in one place (`libs.versions.toml`) instead of being + * duplicated as a hand-typed string here. * - `stubUnfulfilledExpects()`, applied to the *expect-declaring* module itself, so its own * `expect`s get an auto-generated, throwing `actual` stub and its `main` source set compiles * into a completely normal, standalone jar - see `ExpectStubGenerator.kt`. Calling code that * ends up bound to the stub (anything not itself merged into a real `actualizes(...)` leaf) - * throws `ActualizerNotLinkedError` at runtime rather than failing to compile. + * throws `IllegalStateException` at runtime rather than failing to compile. */ open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { internal val sources: MutableList = mutableListOf() @@ -49,6 +54,13 @@ open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { publishedSources += ActualizedPublishedSource(publishedCoordinate, dependsOnTasks) } + /** Version-catalog form of [actualizes] - e.g. `actualizes(libs.foo.bar)` instead of a hand-typed `"group:artifact:version"` string. */ + @JvmOverloads + fun actualizes(publishedCoordinate: Provider, dependsOnTasks: List = emptyList()) { + val dependency = publishedCoordinate.get() + actualizes("${dependency.group}:${dependency.name}:${dependency.version}", dependsOnTasks) + } + fun stubUnfulfilledExpects() { stubUnfulfilledExpects = true } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index d56d972..a4a16c7 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -70,30 +70,55 @@ class ActualizerGradlePlugin : Plugin { val mainFiles = mainDirs.filter { it.exists() }.flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" } } - val scanned = scanForExpectFunctions(mainFiles) - if (scanned.isEmpty()) { + if (mainFiles.isEmpty()) { project.logger.warn( - "[actualizer] '${project.path}' called stubUnfulfilledExpects() but no " + - "'expect fun'/'expect val'/'expect var'/'expect class' declarations were " + - "found in its main source set." + "[actualizer] '${project.path}' called stubUnfulfilledExpects() but its 'main' " + + "Kotlin source set has no files to scan." ) return } val outputDir = project.layout.buildDirectory.dir("generated/actualizer-stubs").get().asFile - outputDir.deleteRecursively() - for (file in scanned) { - val packageDir = File(outputDir, file.packageName.replace('.', '/')).apply { mkdirs() } - File(packageDir, "${file.sourceFile.nameWithoutExtension}Stub.kt") - .writeText(generateStubFileContent(file)) + + // A real Gradle task, not eager work done here in apply()/afterEvaluate: writing the stub + // files during configuration would have them wiped out by `clean`'s *execution* when + // `clean` and `build` run in the same invocation (configuration runs once up front for the + // whole task graph, so an eager write here happens before `clean` ever deletes anything). + // Making generation a task with real inputs/outputs fixes the ordering and gives Gradle + // proper up-to-date checking for free. + val generateTask = project.tasks.register("generateActualizerStubs") { task -> + task.inputs.files(mainFiles).withPropertyName("actualizerExpectScanInputs") + task.outputs.dir(outputDir) + task.doLast { + val scanned = scanForExpectFunctions(mainFiles) + outputDir.deleteRecursively() + if (scanned.isEmpty()) { + project.logger.warn( + "[actualizer] '${project.path}' called stubUnfulfilledExpects() but no " + + "'expect fun'/'expect val'/'expect var'/'expect class' declarations " + + "were found in its main source set." + ) + return@doLast + } + outputDir.mkdirs() + for (file in scanned) { + val packageDir = File(outputDir, file.packageName.replace('.', '/')).apply { mkdirs() } + File(packageDir, "${file.sourceFile.nameWithoutExtension}Stub.kt") + .writeText(generateStubFileContent(file)) + } + } } addSourceDir(project, "main", listOf(outputDir)) - val commonSourcesValue = scanned.joinToString(",") { it.sourceFile.absolutePath } + // All of this module's own hand-written source counts as "common" here, not just the + // files that happen to contain an `expect` - mirroring how a real commonMain source set + // is treated wholesale, not file-by-file. Known without waiting on the scan task, so this + // stays eager. + val commonSourcesValue = mainFiles.joinToString(",") { it.absolutePath } val compileKotlinTask = project.tasks.named("compileKotlin") compileKotlinTask.configure { task -> - task.inputs.files(mainFiles).withPropertyName("actualizerExpectScanInputs") + task.dependsOn(generateTask) val freeCompilerArgs = freeCompilerArgsProperty(task) freeCompilerArgs.addAll(listOf("-Xmulti-platform", "-Xcommon-sources=$commonSourcesValue")) } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt index 895b1e6..7aa3c10 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -1,10 +1,12 @@ package net.kernelpanicsoft.actualizer.gradle +import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.LambdaTypeName import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName @@ -15,11 +17,18 @@ import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionType import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtNullableType +import org.jetbrains.kotlin.psi.KtObjectDeclaration import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtPsiFactory +import org.jetbrains.kotlin.psi.KtTypeElement +import org.jetbrains.kotlin.psi.KtTypeReference +import org.jetbrains.kotlin.psi.KtUserType import java.io.File // Finding `expect` declarations is done via real Kotlin PSI parsing (KtPsiFactory.createFile), @@ -27,16 +36,18 @@ import java.io.File // literals containing `{`/`}`, and odd formatting are all handled correctly, the same way any // real Kotlin tool would see them. This is *syntax-only* parsing (no semantic analysis/type // resolution - deliberately, since that's exactly what would fail on the unfulfilled expects -// being scanned for here), so what's captured for each declaration's parameter/return/property -// types is still raw *text* (`KtTypeReference.text`), not a resolved type - see parseTypeName's -// doc comment below for what that still means for generated stub correctness. Remaining scope -// limits versus a full compiler frontend: no generics on the containing function/class itself, no -// suspend/extension receivers, no supertypes, no secondary constructors or nested types, no -// constructor-parameter auto-properties (`class Foo(val x: Int)`). Once something IS matched, -// KotlinPoet handles turning it into correct Kotlin source (formatting, imports, escaping) -// instead of hand-rolled string concatenation. - -internal data class ParamText(val name: String, val typeText: String) +// being scanned for here). Types are converted to KotlinPoet's `TypeName` by walking the PSI type +// tree directly (`KtTypeReference`/`KtUserType`/`KtFunctionType`/`KtNullableType`) rather than by +// re-parsing `.text` with ad-hoc string splitting - see `KtTypeReference.toTypeName` below for why +// that distinction matters (it's what makes function types like `() -> () -> Screen` work at +// all, and what "resolving" a type name actually means here). Remaining scope limits versus a +// full compiler frontend: no generics on the containing function/class itself, no supertypes, no +// secondary constructors or nested types, no constructor-parameter auto-properties +// (`class Foo(val x: Int)`). Once something IS matched, KotlinPoet handles turning it into +// correct Kotlin source (formatting, imports, escaping) instead of hand-rolled string +// concatenation. + +internal data class ParamText(val name: String, val type: TypeName) internal sealed class ExpectMember { abstract val name: String @@ -44,18 +55,20 @@ internal sealed class ExpectMember { data class Function( override val name: String, val params: List, - val returnTypeText: String, + val returnType: TypeName, + val suspending: Boolean = false, ) : ExpectMember() data class Property( override val name: String, - val typeText: String, + val type: TypeName, val mutable: Boolean, ) : ExpectMember() } internal data class ExpectClassInfo( val name: String, + val isObject: Boolean, val constructorParams: List, val members: List, ) @@ -90,83 +103,79 @@ internal fun scanForExpectFunctions(files: Iterable): List() val classes = mutableListOf() + val context = TypeContext(importedSimpleNames(ktFile), ktFile.packageFqName.asString()) for (declaration in ktFile.declarations) { when { - declaration is KtNamedFunction && declaration.isExpect() -> topLevel += toFunctionMember(declaration) - declaration is KtProperty && declaration.isExpect() -> topLevel += toPropertyMember(declaration) - declaration is KtClass && declaration.isExpect() -> classes += toClassInfo(declaration) + declaration is KtNamedFunction && declaration.isExpect() -> topLevel += toFunctionMember(declaration, context) + declaration is KtProperty && declaration.isExpect() -> topLevel += toPropertyMember(declaration, context) + declaration is KtClassOrObject && declaration.isExpect() -> classes += toClassInfo(declaration, context) } } if (topLevel.isEmpty() && classes.isEmpty()) return null - return ScannedExpectFile(sourceFile, ktFile.packageFqName.asString(), topLevel, classes) + return ScannedExpectFile(sourceFile, context.packageName, topLevel, classes) } private fun KtDeclaration.isExpect(): Boolean = hasModifier(KtTokens.EXPECT_KEYWORD) -private fun toFunctionMember(function: KtNamedFunction): ExpectMember.Function { +/** Simple name -> fully-qualified name and this file's own package, needed to resolve a bare type name to a real [ClassName] - see [classNameFor]. */ +private class TypeContext(val imports: Map, val packageName: String) + +/** Simple name -> fully-qualified name, from this file's own `import` directives (star imports aren't expandable without semantic resolution, so they're skipped - see `classNameFor`). */ +private fun importedSimpleNames(ktFile: KtFile): Map = + ktFile.importDirectives.mapNotNull { directive -> + if (directive.isAllUnder) return@mapNotNull null + val fqName = directive.importedFqName?.asString() ?: return@mapNotNull null + val simpleName = directive.aliasName ?: fqName.substringAfterLast('.') + simpleName to fqName + }.toMap() + +private fun toFunctionMember(function: KtNamedFunction, context: TypeContext): ExpectMember.Function { val params = function.valueParameters.map { param -> - ParamText(param.name ?: "arg", param.typeReference?.text ?: "Any") + ParamText(param.name ?: "arg", param.typeReference?.toTypeName(context) ?: ANY) } return ExpectMember.Function( name = function.name ?: error("Actualizer: found an unnamed 'expect fun' in ${function.containingFile.name}"), params = params, - returnTypeText = function.typeReference?.text ?: "Unit", + returnType = function.typeReference?.toTypeName(context) ?: UNIT, + suspending = function.hasModifier(KtTokens.SUSPEND_KEYWORD), ) } -private fun toPropertyMember(property: KtProperty): ExpectMember.Property { - val typeText = property.typeReference?.text +private fun toPropertyMember(property: KtProperty, context: TypeContext): ExpectMember.Property { + val typeReference = property.typeReference ?: error( "Actualizer: 'expect ${if (property.isVar) "var" else "val"} ${property.name}' in " + "${property.containingFile.name} needs an explicit type for stubUnfulfilledExpects() to stub it." ) return ExpectMember.Property( name = property.name ?: error("Actualizer: found an unnamed 'expect val/var' in ${property.containingFile.name}"), - typeText = typeText, + type = typeReference.toTypeName(context), mutable = property.isVar, ) } -private fun toClassInfo(cls: KtClass): ExpectClassInfo { - val constructorParams = cls.primaryConstructor?.valueParameters.orEmpty().map { param -> - ParamText(param.name ?: "arg", param.typeReference?.text ?: "Any") +private fun toClassInfo(cls: KtClassOrObject, context: TypeContext): ExpectClassInfo { + // KtObjectDeclaration (expect object) has no primary constructor at all - only KtClass does. + val constructorParams = (cls as? KtClass)?.primaryConstructor?.valueParameters.orEmpty().map { param -> + ParamText(param.name ?: "arg", param.typeReference?.toTypeName(context) ?: ANY) } val members = cls.body?.declarations.orEmpty().mapNotNull { member -> when (member) { - is KtNamedFunction -> toFunctionMember(member) - is KtProperty -> toPropertyMember(member) + is KtNamedFunction -> toFunctionMember(member, context) + is KtProperty -> toPropertyMember(member, context) else -> null // nested types, secondary constructors, etc. - out of scope, see file header. } } return ExpectClassInfo( - name = cls.name ?: error("Actualizer: found an unnamed 'expect class' in ${cls.containingFile.name}"), + name = cls.name ?: error("Actualizer: found an unnamed 'expect class'/'expect object' in ${cls.containingFile.name}"), + isObject = cls is KtObjectDeclaration, constructorParams = constructorParams, members = members, ) } -/** Splits on [separator] at nesting depth 0 only, so `<...>` in generic type arguments survives. */ -private fun splitTopLevel(text: String, separator: Char): List { - if (text.isBlank()) return emptyList() - val parts = mutableListOf() - var depth = 0 - var start = 0 - for (i in text.indices) { - when (text[i]) { - '<', '(' -> depth++ - '>', ')' -> depth-- - separator -> if (depth == 0) { - parts += text.substring(start, i) - start = i + 1 - } - } - } - parts += text.substring(start) - return parts.map { it.trim() }.filter { it.isNotEmpty() } -} - // ClassName.bestGuess("String") produces a ClassName with an EMPTY package (it looks like a // default-package top-level class), and KotlinPoet then emits a nonsensical `import String` for // it - there's nothing to import for a name with no package. Recognizing common stdlib names and @@ -182,64 +191,115 @@ private val KOTLIN_COLLECTION_TYPES = listOf( "Collection", "MutableCollection", "Iterable", "MutableIterable", "Iterator", "MutableIterator", ).associateWith { ClassName("kotlin.collections", it) } +private val ANY: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Any") +private val UNIT: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Unit") + /** - * Best-effort raw-text -> [TypeName] conversion: handles a trailing `?` (nullability), one level - * of generic nesting (`Foo`, `Foo>`, ...), and recognizes common stdlib type names - * (see [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES]) so they resolve correctly. Even though - * the *scanning* is now real PSI parsing (see the file header), this still only ever sees the - * *text* of a `KtTypeReference` - syntax parsing alone doesn't resolve what a type name actually - * refers to, so this has no more semantic information than the regex-based version did. Anything - * that isn't already fully qualified and also isn't one of the recognized stdlib names (an - * unqualified *custom* type, e.g. `expect fun f(): MyDataClass`) will still get treated as a - * default-package class needing an import that doesn't actually exist, and fail to compile. Write - * fully-qualified types in `expect` declarations meant to be stubbed if this matters. + * Converts a `KtTypeReference` to a KotlinPoet `TypeName` by walking the PSI type tree - the + * *structure* Kotlin's own parser already built (`KtUserType`/`KtFunctionType`/`KtNullableType`), + * not a second, ad-hoc parse of `.text`. That distinction is what makes function types work at + * all: `() -> () -> Screen` is a `KtFunctionType` whose `returnTypeReference` is *itself* another + * `KtFunctionType` - recursing through real PSI nodes handles that (and receivers, suspend + * modifiers, and nullable-wrapped lambdas like `(() -> Unit)?`) for free, where splitting on `->`/ + * `,`/`<`/`>` as text does not, and silently produces garbage on anything but the simplest cases. + * + * Still only sees *syntax*, not resolved semantics - a bare `KtUserType` name is matched against + * [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES] and this file's own `import` directives + * (`context.imports`) to find its real package; failing that, it's assumed to be a sibling type + * in this file's *own* package (`context.packageName`) rather than the default package - true for + * the common case of an `expect` referencing another type declared alongside it with no import + * needed, but still a guess: a star-imported type, or one genuinely meant to be in the default + * package, will resolve to the wrong package and fail to compile. Write fully-qualified types in + * `expect` declarations meant to be stubbed if this still matters. */ -private fun parseTypeName(raw: String): TypeName { - val trimmed = raw.trim() - val nullable = trimmed.endsWith("?") - val core = (if (nullable) trimmed.dropLast(1) else trimmed).trim() - val genericStart = core.indexOf('<') - val base: TypeName = if (genericStart == -1 || !core.endsWith(">")) { - classNameFor(core) - } else { - val rawName = core.substring(0, genericStart) - val argsText = core.substring(genericStart + 1, core.length - 1) - val args = splitTopLevel(argsText, ',').map { parseTypeName(it) } - if (args.isEmpty()) classNameFor(rawName) else classNameFor(rawName).parameterizedBy(args) - } - return base.copyNullable(nullable) +private fun KtTypeReference.toTypeName(context: TypeContext): TypeName { + val element = typeElement ?: return ANY + val suspending = hasModifier(KtTokens.SUSPEND_KEYWORD) + return element.toTypeName(context, suspending) } -private fun classNameFor(text: String): ClassName = - if ('.' in text) ClassName.bestGuess(text) else KOTLIN_BUILTIN_TYPES[text] ?: KOTLIN_COLLECTION_TYPES[text] ?: ClassName.bestGuess(text) - -private fun TypeName.copyNullable(nullable: Boolean): TypeName = when (this) { - is ClassName -> copy(nullable = nullable) - else -> copy(nullable = nullable) +private fun KtTypeElement.toTypeName(context: TypeContext, suspending: Boolean = false): TypeName = when (this) { + is KtNullableType -> { + val inner = innerType?.toTypeName(context, suspending) ?: ANY + inner.copy(nullable = true) + } + is KtFunctionType -> { + val paramTypes = parameterList?.parameters.orEmpty() + .map { it.typeReference?.toTypeName(context) ?: ANY } + .toTypedArray() + val returnType = returnTypeReference?.toTypeName(context) ?: UNIT + val receiver = receiverTypeReference?.toTypeName(context) + LambdaTypeName.get(receiver, *paramTypes, returnType = returnType).copy(suspending = suspending) + } + is KtUserType -> { + val simpleName = referencedName ?: "Any" + val qualifierText = qualifier?.text + val base = if (qualifierText != null) { + ClassName.bestGuess("$qualifierText.$simpleName") + } else { + classNameFor(simpleName, context) + } + val typeArgs = typeArgumentList?.arguments.orEmpty().mapNotNull { it.typeReference?.toTypeName(context) } + if (typeArgs.isEmpty()) base else base.parameterizedBy(typeArgs) + } + else -> ANY // KtDynamicType (JS-only) etc. - out of scope, see file header. } -private val crossModuleExpectAnnotation = ClassName("net.kernelpanicsoft.actualizer.annotations", "CrossModuleExpect") -private val notLinkedErrorClass = ClassName("net.kernelpanicsoft.actualizer.runtime", "ActualizerNotLinkedError") +private fun classNameFor(simpleName: String, context: TypeContext): ClassName { + KOTLIN_BUILTIN_TYPES[simpleName]?.let { return it } + KOTLIN_COLLECTION_TYPES[simpleName]?.let { return it } + context.imports[simpleName]?.let { return ClassName.bestGuess(it) } + return ClassName(context.packageName, simpleName) +} -private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of("throw %T(%S)", notLinkedErrorClass, fqName) +private val illegalStateExceptionClass = ClassName("kotlin", "IllegalStateException") + +// The real Gradle/kotlinc compile already succeeds without this - `-Xmulti-platform` (set by +// wireStubGeneration/wireCrossModuleActualization) is what makes the compiler accept expect/actual +// coexisting in one compilation in the first place. But the IDE's live analysis of a plain +// kotlin("jvm") module doesn't apply that flag's effect to its own diagnostics session, so it +// still reports EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE/ACTUAL_WITHOUT_EXPECT as squiggles even +// though the build is fine. Suppressing them here is safe unconditionally: every generated stub +// actual only exists *because* it's actualizing a real expect Actualizer found, so this is never +// masking a genuine "accidental actual" mistake the way it might in real multiplatform code. +private val suppressIdeExpectActualFalsePositives: AnnotationSpec = AnnotationSpec.builder(ClassName("kotlin", "Suppress")) + .addMember("%S", "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE") + .addMember("%S", "ACTUAL_WITHOUT_EXPECT") + .build() + +// Plain kotlin.IllegalStateException, not a custom exception type - see stub-body message below +// for what it means when this actually throws. Not worth its own tiny published module. +private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of( + "throw %T(%S)", + illegalStateExceptionClass, + "$fqName was never actualized - this jar was built standalone with an auto-generated stub " + + "(see actualizer { stubUnfulfilledExpects() }). Depend on a module that actually links it " + + "(an actualizer { actualizes(...) } leaf) to get the real implementation.", +) private fun buildFunctionStub(member: ExpectMember.Function, fqNamePrefix: String): FunSpec { - val builder = FunSpec.builder(member.name).addModifiers(KModifier.ACTUAL) + val builder = FunSpec.builder(member.name) + .addModifiers(KModifier.ACTUAL) + .addAnnotation(suppressIdeExpectActualFalsePositives) + if (member.suspending) { + builder.addModifiers(KModifier.SUSPEND) + } for (param in member.params) { - builder.addParameter(param.name, parseTypeName(param.typeText)) + builder.addParameter(param.name, param.type) } - if (member.returnTypeText != "Unit") { - builder.returns(parseTypeName(member.returnTypeText)) + if (member.returnType != UNIT) { + builder.returns(member.returnType) } builder.addCode(throwStatement("$fqNamePrefix.${member.name}")) return builder.build() } private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: String): PropertySpec { - val type = parseTypeName(member.typeText) + val type = member.type val fqName = "$fqNamePrefix.${member.name}" val builder = PropertySpec.builder(member.name, type) .addModifiers(KModifier.ACTUAL) + .addAnnotation(suppressIdeExpectActualFalsePositives) .mutable(member.mutable) .getter(FunSpec.getterBuilder().addCode(throwStatement(fqName)).build()) if (member.mutable) { @@ -255,15 +315,16 @@ private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: Strin private fun buildClassStub(cls: ExpectClassInfo, packageName: String): TypeSpec { val classFqName = "$packageName.${cls.name}" - val builder = TypeSpec.classBuilder(cls.name) + val builder = (if (cls.isObject) TypeSpec.objectBuilder(cls.name) else TypeSpec.classBuilder(cls.name)) .addModifiers(KModifier.ACTUAL) - .addAnnotation(crossModuleExpectAnnotation) + .addAnnotation(suppressIdeExpectActualFalsePositives) - if (cls.constructorParams.isNotEmpty()) { + // expect object has no primary constructor to actualize - it's a singleton. + if (!cls.isObject && cls.constructorParams.isNotEmpty()) { // Kotlin requires the primary constructor to be explicitly marked `actual` too, not just // the class - otherwise: "Declaration must be marked with 'actual'" on the constructor. val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL) - cls.constructorParams.forEach { ctor.addParameter(it.name, parseTypeName(it.typeText)) } + cls.constructorParams.forEach { ctor.addParameter(it.name, it.type) } builder.primaryConstructor(ctor.build()) } // Fails fast on construction rather than relying on every member throwing individually - the @@ -288,16 +349,8 @@ internal fun generateStubFileContent(scanned: ScannedExpectFile): String { for (member in scanned.topLevel) { when (member) { - is ExpectMember.Function -> fileSpec.addFunction( - buildFunctionStub(member, scanned.packageName).toBuilder() - .addAnnotation(crossModuleExpectAnnotation) - .build() - ) - is ExpectMember.Property -> fileSpec.addProperty( - buildPropertyStub(member, scanned.packageName).toBuilder() - .addAnnotation(crossModuleExpectAnnotation) - .build() - ) + is ExpectMember.Function -> fileSpec.addFunction(buildFunctionStub(member, scanned.packageName)) + is ExpectMember.Property -> fileSpec.addProperty(buildPropertyStub(member, scanned.packageName)) } } for (cls in scanned.classes) { diff --git a/sample-architectury/.gitignore b/sample-architectury/.gitignore new file mode 100644 index 0000000..abd785d --- /dev/null +++ b/sample-architectury/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +*/build/ +.kotlin/ +*.iml +.idea/ +logs/ +run/ diff --git a/sample-architectury/README.md b/sample-architectury/README.md new file mode 100644 index 0000000..343b5e0 --- /dev/null +++ b/sample-architectury/README.md @@ -0,0 +1,79 @@ +# sample-architectury + +A real Fabric + NeoForge multiloader mod build (via [Architectury Loom](https://github.com/architectury/architectury-loom)) that uses Actualizer to actualize `expect` declarations from a common module against two genuinely independent, real Minecraft mod-loader toolchains - the case that originally motivated this whole plugin (see the root [README](../README.md)'s "Motivation" section). + +## Why this is a separate Gradle build + +Everything else in this repo lives inside `modular-kmp`'s own multi-project build. This sample doesn't: it has its own `settings.gradle.kts`, its own Gradle wrapper, and isn't included from the root build at all. Two reasons: + +- **It's heavy.** Architectury Loom downloads and remaps real Minecraft jars (client + server merge, official Mojang mappings, access transformers, decompilation for source jars) - tens of seconds to a few minutes on a clean cache, and real disk space. That shouldn't be part of the root repo's ordinary `gradle build` feedback loop. +- **It pins a different Gradle version on purpose.** The root build runs on whatever Gradle is ambient; this one is pinned via its own wrapper (`./gradlew`, Gradle 8.12) to match what Architectury Loom 1.13.469 was actually built and tested against. Always use `./gradlew` here, not a system-wide `gradle`. + +It still reaches the main build's `net.kernelpanicsoft.actualizer` plugin and `net.kernelpanicsoft:compiler-plugin` the same way the root build does: `includeBuild("../plugin-build")` in both `pluginManagement` and at the top level of `settings.gradle.kts`. + +## Structure + +``` +common/ expect declarations (net.kernelpanicsoft.samplemod.common), stubUnfulfilledExpects() +fabric/ architectury { fabric() }, real actual, actualizes(project(":common")) +neoforge/ architectury { neoForge() }, real actual, actualizes(project(":common")) +``` + +`:common` declares: + +```kotlin +expect val loaderName: String + +expect fun loaderSpecificGreeting(): String +``` + +No annotation is needed - a plain `kotlin("jvm")` module (which `:common`/`:fabric`/`:neoforge` all +are, `architectury-plugin`/Architectury Loom notwithstanding) never sees `expect`/`actual` at all +unless Actualizer put them there, so every pair Actualizer's IR plugin finds is one of its own by +construction. See the root README's "IDE false positives" section for why `ModCommon.kt` (and +`:fabric`/`:neoforge`'s `Actual.kt`) each start with a `@file:Suppress(...)` line - it's cosmetic, +quieting an IDE-only false positive, not required for anything to actually compile or run. + +`:fabric` and `:neoforge` each merge `:common`'s source in (`actualizer { actualizes(project(":common")) }`) and provide a real `actual` that references genuinely platform-specific, remapped Minecraft classes - `net.minecraft.resources.ResourceLocation`, resolved against *that platform's own* Architectury Loom-provided Minecraft jar. Both platforms use official Mojang mappings (not Yarn) uniformly, which is the actual point of Architectury Loom over plain Fabric Loom / NeoForge's own ModDevGradle: one shared mapping namespace both loaders can consume natively, so `:common`'s merged code and both platforms' `actual`s see the same class/method names without a lossy mapping migration step. + +`:common` also calls `actualizer { stubUnfulfilledExpects() }`, the same as the main repo's +`:sample:api`, so it builds a genuine standalone jar (`./gradlew :common:build` succeeds on its +own, with an auto-generated throwing stub for both expects) that other common/API modules could +depend on before any platform actual exists - not just a bare source-provider for `:fabric`/ +`:neoforge`. + +## Building and running + +``` +./gradlew :fabric:compileKotlin # merges :common's source + a real Fabric actual, against + # Architectury Loom's Fabric-platform Minecraft classpath +./gradlew :neoforge:compileKotlin # same, against the NeoForge-platform Minecraft classpath + +./gradlew :fabric:runVerify :neoforge:runVerify +``` + +`runVerify` is a plain `JavaExec` against Loom's dev runtime classpath that calls the merged code directly (`modStartupMessage()`, which calls the platform's `actual fun loaderSpecificGreeting()`) - it proves the actualized code actually *executes* correctly, not just compiles, without needing to launch a full Minecraft client (which needs game assets/auth this doesn't have). Expected output: + +``` +Sample mod starting on Fabric: hello from Architectury Loom's Fabric platform; resolved samplemod:hello against a real remapped Minecraft classpath +Sample mod starting on NeoForge: hello from Architectury Loom's NeoForge platform; resolved samplemod:hello against a real remapped Minecraft classpath +``` + +Two different `actual`s, two different real Minecraft classpaths, one merged `:common` source file - proving the actual value proposition end to end. + +Each platform module's `build/actualizer/report.json` records what got linked, e.g.: + +```json +{ + "consumingModule": ":fabric", + "links": [ + {"actual": "net.kernelpanicsoft.samplemod.common.loaderName", "owningModules": [":common"]}, + {"actual": "net.kernelpanicsoft.samplemod.common.loaderSpecificGreeting", "owningModules": [":common"]} + ] +} +``` + +## Known limitations specific to this sample + +- **`loom.platform` must be set per module.** Architectury Loom reads which platform (`fabric`/`neoforge`) a module targets from that module's own `gradle.properties` (`loom.platform=fabric` / `loom.platform=neoforge`), read before the build script even runs - it's not enough to call `architectury { fabric() }` / `architectury { neoForge() }` in the script alone. +- **This needs real network access** to Mojang's piston-meta/piston-data endpoints, Fabric's and Architectury's Maven repos, and NeoForged's Maven repo, plus enough heap for Loom's official-mappings decompile/remap pipeline (`org.gradle.jvmargs=-Xmx6G` in this directory's `gradle.properties`) - it won't build in a fully offline or memory-constrained environment. diff --git a/sample-architectury/build.gradle.kts b/sample-architectury/build.gradle.kts new file mode 100644 index 0000000..f6a0ab8 --- /dev/null +++ b/sample-architectury/build.gradle.kts @@ -0,0 +1,43 @@ +import net.fabricmc.loom.api.LoomGradleExtensionAPI + +plugins { + kotlin("jvm") version "2.0.21" apply false + id("architectury-plugin") version "3.4.164" + id("dev.architectury.loom") version "1.13.469" apply false +} + +architectury { + minecraft = "1.21.1" +} + +subprojects { + apply(plugin = "dev.architectury.loom") + apply(plugin = "org.jetbrains.kotlin.jvm") + apply(plugin = "architectury-plugin") + + group = "net.kernelpanicsoft.samplemod" + version = "1.0.0" + + repositories { + mavenCentral() + maven("https://maven.fabricmc.net/") { name = "FabricMC" } + maven("https://maven.neoforged.net/releases/") { name = "NeoForged" } + } + + val loom = extensions.getByName("loom") + loom.silentMojangMappingsLicense() + + dependencies { + "minecraft"("com.mojang:minecraft:1.21.1") + // Official Mojang mappings, not Yarn: both Fabric and NeoForge can consume these natively + // (NeoForge is Mojang-mapped already), avoiding the lossy Yarn->NeoForge mapping + // migration path - which broke here with a TinyRemapper "Unfixable conflicts" error + // against this NeoForge build. This matches how real Architectury Loom projects targeting + // both loaders are set up (see the Archie reference project this sample follows). + "mappings"(loom.officialMojangMappings()) + } + + extensions.configure { + jvmToolchain(21) + } +} diff --git a/sample-architectury/common/build.gradle.kts b/sample-architectury/common/build.gradle.kts new file mode 100644 index 0000000..e1d562e --- /dev/null +++ b/sample-architectury/common/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("net.kernelpanicsoft.actualizer") +} + +architectury { + common("fabric", "neoforge") +} + +actualizer { + stubUnfulfilledExpects() +} diff --git a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt new file mode 100644 index 0000000..8116344 --- /dev/null +++ b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt @@ -0,0 +1,22 @@ +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") + +package net.kernelpanicsoft.samplemod.common + +/** + * Actualized independently by `:fabric` and `:neoforge` - two genuinely separate platform + * targets under Architectury Loom, each with its own remapped Minecraft classpath, neither of + * which participates in a shared `kotlin { }` multiplatform source-set hierarchy with the other. + * This is the actual motivating case for Actualizer: a single KMP module can't span two + * mutually-incompatible Gradle plugins like this, but merging source into each loader's own + * compilation can. + * + * The file-level `@Suppress` above quiets an IDE-only false positive: `:common` also calls + * `actualizer { stubUnfulfilledExpects() }` so it builds a standalone jar, which puts a + * generated `actual` stub in this same module - real compile is fine (see the root README's + * "IDE false positives" section) but the IDE's live analysis doesn't know that. + */ +expect val loaderName: String + +expect fun loaderSpecificGreeting(): String + +fun modStartupMessage(): String = "Sample mod starting on $loaderName: ${loaderSpecificGreeting()}" diff --git a/sample-architectury/fabric/build.gradle.kts b/sample-architectury/fabric/build.gradle.kts new file mode 100644 index 0000000..6f454ab --- /dev/null +++ b/sample-architectury/fabric/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("net.kernelpanicsoft.actualizer") +} + +architectury { + platformSetupLoomIde() + fabric() +} + +dependencies { + modImplementation("net.fabricmc:fabric-loader:0.16.14") +} + +actualizer { + actualizes(project(":common")) +} + +// Runs the merged expect/actual code path directly against Loom's dev runtime classpath, +// without launching a full Minecraft client (which needs game assets/auth this sandbox doesn't +// have) - proof the actualized code actually executes correctly, not just compiles. +tasks.register("runVerify") { + group = "verification" + mainClass.set("net.kernelpanicsoft.samplemod.fabric.FabricSampleModKt") + classpath = sourceSets.main.get().runtimeClasspath +} diff --git a/sample-architectury/fabric/gradle.properties b/sample-architectury/fabric/gradle.properties new file mode 100644 index 0000000..e846a8f --- /dev/null +++ b/sample-architectury/fabric/gradle.properties @@ -0,0 +1 @@ +loom.platform=fabric diff --git a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt new file mode 100644 index 0000000..f5c9fd2 --- /dev/null +++ b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt @@ -0,0 +1,27 @@ +// The `actual` declarations below must live in `net.kernelpanicsoft.samplemod.common` - the same +// package as their `expect` counterparts in the unrelated `:common` Gradle module - even though +// this file physically lives in `:fabric`, a separate platform target under Architectury Loom. +// +// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - +// see the root README's "IDE false positives" section). +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") + +package net.kernelpanicsoft.samplemod.common + +import net.minecraft.resources.ResourceLocation + +actual val loaderName: String = "Fabric" + +// References real, remapped Minecraft (ResourceLocation) that only resolves on :fabric's +// classpath - proof this actual was compiled against Architectury Loom's Fabric-platform +// Minecraft jar. Both :fabric and :neoforge use official Mojang mappings (the whole point of +// using Architectury Loom instead of separate Fabric Loom / NeoForge ModDevGradle toolchains), +// so the class name matches :neoforge's usage exactly even though the underlying jar is +// Fabric's. Deliberately not calling FabricLoader.getInstance() here: it requires Fabric's real +// Knot launcher bootstrap, which only exists when actually launching the game - out of scope for +// this sample (see :fabric's `runVerify` task, which runs this code directly via a bare +// JavaExec, not a full client launch). +actual fun loaderSpecificGreeting(): String { + val id = ResourceLocation.fromNamespaceAndPath("samplemod", "hello") + return "hello from Architectury Loom's Fabric platform; resolved $id against a real remapped Minecraft classpath" +} diff --git a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt new file mode 100644 index 0000000..3d4a764 --- /dev/null +++ b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/fabric/FabricSampleMod.kt @@ -0,0 +1,15 @@ +package net.kernelpanicsoft.samplemod.fabric + +import net.fabricmc.api.ModInitializer +import net.kernelpanicsoft.samplemod.common.modStartupMessage + +class FabricSampleMod : ModInitializer { + override fun onInitialize() { + println(modStartupMessage()) + } +} + +/** Runs the merged expect/actual code path directly, without launching a Minecraft client. */ +fun main() { + println(modStartupMessage()) +} diff --git a/sample-architectury/fabric/src/main/resources/fabric.mod.json b/sample-architectury/fabric/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..7ba0ad8 --- /dev/null +++ b/sample-architectury/fabric/src/main/resources/fabric.mod.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "id": "samplemod", + "version": "1.0.0", + "name": "Actualizer Sample Mod (Fabric)", + "environment": "*", + "entrypoints": { + "main": [ + "net.kernelpanicsoft.samplemod.fabric.FabricSampleMod" + ] + }, + "depends": { + "fabricloader": ">=0.16.0", + "minecraft": "~1.21.1" + } +} diff --git a/sample-architectury/gradle.properties b/sample-architectury/gradle.properties new file mode 100644 index 0000000..abb0eb5 --- /dev/null +++ b/sample-architectury/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-Xmx6G diff --git a/sample-architectury/gradle/wrapper/gradle-wrapper.jar b/sample-architectury/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/sample-architectury/gradlew.bat b/sample-architectury/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/sample-architectury/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sample-architectury/neoforge/build.gradle.kts b/sample-architectury/neoforge/build.gradle.kts new file mode 100644 index 0000000..827ed77 --- /dev/null +++ b/sample-architectury/neoforge/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("net.kernelpanicsoft.actualizer") +} + +architectury { + platformSetupLoomIde() + neoForge() +} + +dependencies { + "neoForge"("net.neoforged:neoforge:21.1.244") +} + +actualizer { + actualizes(project(":common")) +} + +// Runs the merged expect/actual code path directly against Loom's dev runtime classpath, +// without launching a full Minecraft client (which needs game assets/auth this sandbox doesn't +// have) - proof the actualized code actually executes correctly, not just compiles. +tasks.register("runVerify") { + group = "verification" + mainClass.set("net.kernelpanicsoft.samplemod.neoforge.NeoForgeSampleModKt") + classpath = sourceSets.main.get().runtimeClasspath +} diff --git a/sample-architectury/neoforge/gradle.properties b/sample-architectury/neoforge/gradle.properties new file mode 100644 index 0000000..7da18ea --- /dev/null +++ b/sample-architectury/neoforge/gradle.properties @@ -0,0 +1 @@ +loom.platform=neoforge diff --git a/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt new file mode 100644 index 0000000..b8d63db --- /dev/null +++ b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt @@ -0,0 +1,21 @@ +// The `actual` declarations below must live in `net.kernelpanicsoft.samplemod.common` - the same +// package as their `expect` counterparts in the unrelated `:common` Gradle module - even though +// this file physically lives in `:neoforge`, a separate platform target under Architectury Loom. +// +// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - +// see the root README's "IDE false positives" section). +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") + +package net.kernelpanicsoft.samplemod.common + +import net.minecraft.resources.ResourceLocation + +actual val loaderName: String = "NeoForge" + +// References real, remapped Minecraft (ResourceLocation) that only resolves on :neoforge's +// classpath - proof this actual was compiled against Architectury Loom's NeoForge-platform +// Minecraft jar. See :fabric's Actual.kt for why both platforms share the same mapping names. +actual fun loaderSpecificGreeting(): String { + val id = ResourceLocation.fromNamespaceAndPath("samplemod", "hello") + return "hello from Architectury Loom's NeoForge platform; resolved $id against a real remapped Minecraft classpath" +} diff --git a/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt new file mode 100644 index 0000000..d63605e --- /dev/null +++ b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/neoforge/NeoForgeSampleMod.kt @@ -0,0 +1,16 @@ +package net.kernelpanicsoft.samplemod.neoforge + +import net.kernelpanicsoft.samplemod.common.modStartupMessage +import net.neoforged.fml.common.Mod + +@Mod("samplemod") +class NeoForgeSampleMod { + init { + println(modStartupMessage()) + } +} + +/** Runs the merged expect/actual code path directly, without launching a Minecraft client. */ +fun main() { + println(modStartupMessage()) +} diff --git a/sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml b/sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..11441fa --- /dev/null +++ b/sample-architectury/neoforge/src/main/resources/META-INF/neoforge.mods.toml @@ -0,0 +1,22 @@ +modLoader="javafml" +loaderVersion="[1,)" +license="MIT" + +[[mods]] +modId="samplemod" +version="1.0.0" +displayName="Actualizer Sample Mod (NeoForge)" + +[[dependencies.samplemod]] + modId="neoforge" + type="required" + versionRange="[21.1,)" + ordering="NONE" + side="BOTH" + +[[dependencies.samplemod]] + modId="minecraft" + type="required" + versionRange="[1.21.1,1.22)" + ordering="NONE" + side="BOTH" diff --git a/sample-architectury/settings.gradle.kts b/sample-architectury/settings.gradle.kts new file mode 100644 index 0000000..840a883 --- /dev/null +++ b/sample-architectury/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + // Same composite-build inclusion the main `modular-kmp` build uses, so this deliberately + // separate build (see README.md in this directory for why it's separate) can apply + // `id("net.kernelpanicsoft.actualizer")` without publishing it anywhere first. + includeBuild("../plugin-build") + repositories { + gradlePluginPortal() + mavenCentral() + maven("https://maven.fabricmc.net/") { name = "FabricMC" } + maven("https://maven.neoforged.net/releases/") { name = "NeoForged" } + maven("https://maven.architectury.dev/") { name = "Architectury" } + } +} + +// Mirrors the top-level `includeBuild("plugin-build")` in the main build's settings.gradle.kts: +// needed so the plain coordinate "net.kernelpanicsoft:compiler-plugin:0.1.0" (which the +// Actualizer Gradle plugin resolves at apply-time for `-Xplugin=`) substitutes to the +// `:compiler-plugin` project here instead of requiring a real Maven publish. +includeBuild("../plugin-build") + +rootProject.name = "sample-architectury" + +include( + ":common", + ":fabric", + ":neoforge", +) diff --git a/sample/actual-jvm-published/build.gradle.kts b/sample/actual-jvm-published/build.gradle.kts index 105e323..7b760b3 100644 --- a/sample/actual-jvm-published/build.gradle.kts +++ b/sample/actual-jvm-published/build.gradle.kts @@ -15,22 +15,19 @@ kotlin { jvmToolchain(21) } -dependencies { - // For the @CrossModuleExpect annotation reference in the merged-in source from the library. - implementation(project(":actualizer-annotations")) -} - actualizer { // Same idea as :sample:actual-jvm, but actualizing a *published* library's expect instead of // a sibling project - resolves net.kernelpanicsoft.sample:api:0.1.0's sources classifier // artifact from the "local" repo above, rather than reading files off a project(...) reference. + // Passed as a version-catalog reference (gradle/libs.versions.toml) rather than a hand-typed + // "group:artifact:version" string, so the coordinate stays declared in one place. // // dependsOnTasks is needed because the coordinate isn't published yet when this build starts - // this sample publishes and consumes the same library within one build, for a self-contained, // reproducible demo. In real usage the library would already be published somewhere (e.g. by // CI) before a consumer ever builds against it, and dependsOnTasks wouldn't be needed at all. actualizes( - "net.kernelpanicsoft.sample:api:0.1.0", + libs.sample.api, dependsOnTasks = listOf(":sample:api:publishMavenPublicationToLocalRepository"), ) } diff --git a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 38a2322..2768645 100644 --- a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -1,17 +1,17 @@ // Same package as the expect this actualizes (net.kernelpanicsoft.sample.api, declared in the // *published* net.kernelpanicsoft.sample:api:0.1.0 library, not a project(...) reference), even // though this file physically lives in a completely different Gradle module. -package net.kernelpanicsoft.sample.api +// +// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - +// see the root README's "IDE false positives" section). +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") -import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect +package net.kernelpanicsoft.sample.api -@CrossModuleExpect actual fun greetingSuffix(): String = "(actualized against the published api:0.1.0 library)" -@CrossModuleExpect actual val platformName: String = ":sample:actual-jvm-published" -@CrossModuleExpect actual class GreetingCounter actual constructor(start: Int) { private var current = start actual fun next(): Int = current++ diff --git a/sample/actual-jvm/build.gradle.kts b/sample/actual-jvm/build.gradle.kts index ead3f96..6010f22 100644 --- a/sample/actual-jvm/build.gradle.kts +++ b/sample/actual-jvm/build.gradle.kts @@ -11,14 +11,10 @@ kotlin { jvmToolchain(21) } -dependencies { - // For the @CrossModuleExpect annotation reference in the merged-in source file from :api. - implementation(project(":actualizer-annotations")) - // Deliberately NOT a dependency on :sample:api's or :sample:feature-common's compiled jars: - // both projects' "main" source is merged in as source below (formatGreeting/greet/ - // welcomeMessage come along with it), and also depending on their jars would give the JVM - // two competing definitions of the same classes on this module's classpath. -} +// Deliberately no dependency on :sample:api's or :sample:feature-common's compiled jars: both +// projects' "main" source is merged in as source below (formatGreeting/greet/welcomeMessage come +// along with it), and depending on their jars too would give the JVM two competing definitions +// of the same classes on this module's classpath. actualizer { actualizes(project(":sample:api")) diff --git a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 649959b..09db9e8 100644 --- a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -1,17 +1,17 @@ // The `actual` declaration must live in the same package as its `expect` counterpart // (net.kernelpanicsoft.sample.api, declared in the unrelated :sample:api Gradle module), even // though this file physically lives in a completely different Gradle module. -package net.kernelpanicsoft.sample.api +// +// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - +// see the root README's "IDE false positives" section). +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") -import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect +package net.kernelpanicsoft.sample.api -@CrossModuleExpect actual fun greetingSuffix(): String = "(actualized independently by :sample:actual-jvm)" -@CrossModuleExpect actual val platformName: String = ":sample:actual-jvm" -@CrossModuleExpect actual class GreetingCounter actual constructor(start: Int) { private var current = start actual fun next(): Int = current++ diff --git a/sample/api/build.gradle.kts b/sample/api/build.gradle.kts index f7ca27f..b4a2cf6 100644 --- a/sample/api/build.gradle.kts +++ b/sample/api/build.gradle.kts @@ -14,11 +14,6 @@ kotlin { jvmToolchain(21) } -dependencies { - implementation(project(":actualizer-annotations")) - implementation(project(":actualizer-runtime")) -} - actualizer { stubUnfulfilledExpects() } diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt index a0f9e1f..f989d58 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -1,6 +1,6 @@ -package net.kernelpanicsoft.sample.api +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") -import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect +package net.kernelpanicsoft.sample.api /** * Not actualized anywhere in this module's own multiplatform hierarchy - there isn't one - but @@ -8,15 +8,19 @@ import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect * dependency of this one. `:api`'s own build stays green because `actualizer { * stubUnfulfilledExpects() }` (see `build.gradle.kts`) auto-generates a throwing `actual` stub * for it, so this whole file compiles normally into `:api`'s ordinary, standalone jar. + * + * The file-level `@Suppress` above quiets an IDE-only false positive: the IDE's live analysis + * doesn't apply the `-Xmulti-platform` compiler flag that lets this compile in the first place, + * so it reports `expect`/`actual` as illegally coexisting in one module even though the real + * Gradle build is fine. See the root README's "IDE false positives" section. */ -@CrossModuleExpect expect fun greetingSuffix(): String fun formatGreeting(name: String, suffix: String): String = "Hello, $name! $suffix" /** - * Calling this via `:api`'s own jar directly throws `ActualizerNotLinkedError` (the stub). It - * only does something useful once merged - alongside a real `actual` - into a leaf module's + * Calling this via `:api`'s own jar directly throws `IllegalStateException` (the stub). It only + * does something useful once merged - alongside a real `actual` - into a leaf module's * compilation via `actualizer { actualizes(project(":sample:api")) }`. */ fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt index a4f957c..8769f06 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt @@ -1,16 +1,14 @@ -package net.kernelpanicsoft.sample.api +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") -import net.kernelpanicsoft.actualizer.annotations.CrossModuleExpect +package net.kernelpanicsoft.sample.api /** Exercises the stub generator's `expect val` support (Greeting.kt already covers `expect fun`). */ -@CrossModuleExpect expect val platformName: String /** * Exercises the stub generator's `expect class` support: a constructor parameter plus a member * function, both of which the generated `actual` stub has to structurally match. */ -@CrossModuleExpect expect class GreetingCounter(start: Int) { fun next(): Int } diff --git a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt index 3e78688..964f5b8 100644 --- a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt +++ b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt @@ -6,7 +6,7 @@ import net.kernelpanicsoft.sample.api.greet * Ordinary `implementation(project(":sample:api"))` dependency on `:api`'s plain, standalone JVM * jar - no plugin, no multiplatform, nothing special. `greet()` compiles fine here because * `:api`'s expect has a generated stub; calling `welcomeMessage()` via *this* module's own jar - * directly would throw `ActualizerNotLinkedError` unless this file is also merged into a real + * directly would throw `IllegalStateException` unless this file is also merged into a real * `actualizer { actualizes(...) }` leaf (`:sample:actual-jvm` does exactly that). */ fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8c038bb..e38f2ac 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,8 +18,6 @@ includeBuild("plugin-build") rootProject.name = "modular-kmp" include( - ":actualizer-annotations", - ":actualizer-runtime", ":sample:api", ":sample:feature-common", ":sample:actual-jvm", From 65768d6a9766ca75b68a61e64fd38f1a88a80eae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:10:28 +0000 Subject: [PATCH 11/19] Suppress OVERLOAD_RESOLUTION_AMBIGUITY at expect/actual call sites A third instance of the IDE-only expect/actual false positive: since the IDE's live analysis doesn't collapse an expect/actual pair into one logical declaration, any call site in the same module (e.g. Greeting.kt's greet() calling its own expect fun greetingSuffix()) sees two same-signature candidates and reports the call itself as ambiguous. Confirmed the exact diagnostic name (OVERLOAD_RESOLUTION_AMBIGUITY) the same way as the other two, via FirErrors bytecode inspection. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s --- README.md | 37 ++++++++++++++----- .../samplemod/common/ModCommon.kt | 10 +++-- .../kernelpanicsoft/sample/api/Greeting.kt | 10 +++-- .../sample/api/PlatformInfo.kt | 2 +- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index da569d6..2f4e0d9 100644 --- a/README.md +++ b/README.md @@ -300,16 +300,29 @@ sample-architectury/ a genuinely separate Gradle build - the real, motiv ## IDE false positives The IDE's live analysis of a plain `kotlin("jvm")` module doesn't apply the `-Xmulti-platform` -compiler flag that makes this whole mechanism compile in the first place, so it reports two real -Kotlin diagnostics as squiggles even though the Gradle build is fine: +compiler flag that makes this whole mechanism compile in the first place, so it reports real +Kotlin diagnostics as squiggles even though the Gradle build is fine. Two fire on the declarations +themselves: ``` 'public final actual fun greetingSuffix(): String' has no corresponding 'expect' declaration greetingSuffix: 'expect' and corresponding 'actual' are declared in the same module. ``` -These are `ACTUAL_WITHOUT_EXPECT` and `EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE` (confirmed by -inspecting `kotlin-compiler-embeddable`'s `FirErrors` directly). There is no stable, documented +These are `ACTUAL_WITHOUT_EXPECT` and `EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE`. A third fires at any +*call site* within the same module that references the actualized declaration - since the IDE +doesn't collapse an `expect`/`actual` pair into one logical declaration the way real compilation +does, it sees two candidates with the same signature and reports the call itself as ambiguous: + +``` +Overload resolution ambiguity between candidates: +expect fun greetingSuffix(): String +actual fun greetingSuffix(): String +``` + +This is `OVERLOAD_RESOLUTION_AMBIGUITY`. All three names were confirmed by inspecting +`kotlin-compiler-embeddable`'s `FirErrors` directly (bytecode search for the exact rendered +message text, then the diagnostic-factory field referencing it). There is no stable, documented Kotlin compiler-plugin API to suppress a *built-in* diagnostic from a FIR extension: `FirAdditionalCheckersExtension` only lets a plugin *add* checkers, and the older, fully general `DiagnosticSuppressor` SPI is K1-only (it operates on the old `Diagnostic` type, which the K2/FIR @@ -318,13 +331,19 @@ in both the compiler and the IDE regardless of frontend version: `@Suppress`. - Every stub `actual` declaration `ExpectStubGenerator.kt` generates carries `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT")` automatically - no - action needed for a `stubUnfulfilledExpects()`-only module's generated code. + action needed for a `stubUnfulfilledExpects()`-only module's generated code. (The generated stub + never calls the expect it's actualizing, so it never needs `OVERLOAD_RESOLUTION_AMBIGUITY` + itself - see the next bullet for where that one actually applies.) - Hand-written `expect`/`actual` files (the ones you actually write, in a `stubUnfulfilledExpects()` module's own expect declarations, or in an `actualizes(...)` leaf's real actuals) need the same - suppression added once, at the top of the file: `@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", - "ACTUAL_WITHOUT_EXPECT")`. Every sample in this repo (`sample/api`, `sample/actual-jvm`, - `sample/actual-jvm-published`, and all three `sample-architectury` modules that declare or - actualize an expect) does this - see any of their source files for the exact form. + two declaration-level suppressions added once, at the top of the file: + `@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT")`. If that same + file (or any file merged into the same compilation) also *calls* the expect - e.g. `:sample:api`'s + `Greeting.kt` declares `expect fun greetingSuffix()` and its own `greet()` calls it in the same + file - add `"OVERLOAD_RESOLUTION_AMBIGUITY"` to that same `@file:Suppress` too. Every sample in + this repo (`sample/api`, `sample/actual-jvm`, `sample/actual-jvm-published`, and all three + `sample-architectury` modules that declare or actualize an expect) does this - see any of their + source files for the exact form. - The compiler itself warns when this suppression is used (`This code uses error suppression for '...'. [...] the compiler behavior is UNSPECIFIED and WON'T BE PRESERVED`) - that's expected and harmless (it doesn't fail the build); it's Kotlin's standard warning for suppressing an error-severity diff --git a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt index 8116344..e5351ef 100644 --- a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt +++ b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt @@ -1,4 +1,4 @@ -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY") package net.kernelpanicsoft.samplemod.common @@ -10,10 +10,12 @@ package net.kernelpanicsoft.samplemod.common * mutually-incompatible Gradle plugins like this, but merging source into each loader's own * compilation can. * - * The file-level `@Suppress` above quiets an IDE-only false positive: `:common` also calls + * The file-level `@Suppress` above quiets IDE-only false positives: `:common` also calls * `actualizer { stubUnfulfilledExpects() }` so it builds a standalone jar, which puts a - * generated `actual` stub in this same module - real compile is fine (see the root README's - * "IDE false positives" section) but the IDE's live analysis doesn't know that. + * generated `actual` stub in this same module (real compile is fine - see the root README's + * "IDE false positives" section), and the call to `loaderSpecificGreeting()` below reads as an + * ambiguous overload between the `expect` and that stub, since the IDE doesn't collapse the pair + * into one logical declaration the way real compilation does. */ expect val loaderName: String diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt index f989d58..d88b2dd 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -1,4 +1,4 @@ -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY") package net.kernelpanicsoft.sample.api @@ -9,10 +9,12 @@ package net.kernelpanicsoft.sample.api * stubUnfulfilledExpects() }` (see `build.gradle.kts`) auto-generates a throwing `actual` stub * for it, so this whole file compiles normally into `:api`'s ordinary, standalone jar. * - * The file-level `@Suppress` above quiets an IDE-only false positive: the IDE's live analysis + * The file-level `@Suppress` above quiets IDE-only false positives: the IDE's live analysis * doesn't apply the `-Xmulti-platform` compiler flag that lets this compile in the first place, - * so it reports `expect`/`actual` as illegally coexisting in one module even though the real - * Gradle build is fine. See the root README's "IDE false positives" section. + * so it reports `expect`/`actual` as illegally coexisting in one module (even though the real + * Gradle build is fine), and - since it also doesn't collapse the expect/actual pair into one + * logical declaration - it reports the call to `greetingSuffix()` below as an ambiguous overload + * between the two. See the root README's "IDE false positives" section. */ expect fun greetingSuffix(): String diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt index 8769f06..7e2d70c 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt @@ -1,4 +1,4 @@ -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") +@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY") package net.kernelpanicsoft.sample.api From f34cfe5d5792053f8bd5338f434ff26a5ba2c372 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 06:57:21 +0000 Subject: [PATCH 12/19] Auto-suppress IDE expect/actual false positives via a FIR extension Replaces the manual @file:Suppress convention with an automatic one: a FirStatusTransformerExtension injects a synthetic @Suppress FIR annotation (constructed by hand from FIR builders, not text) onto every top-level declaration in any file containing an expect or actual, covering both the declarations themselves and plain functions that merely call one (since OVERLOAD_RESOLUTION_AMBIGUITY fires at the call site). No sample file needs a literal @Suppress/@file:Suppress any more. Verified empirically, not just by inspecting bytecode: temporarily injecting "DEPRECATION" made an unrelated @Deprecated call's warning disappear from an actual fun's body and from a plain sibling function in the same file, with zero @Suppress anywhere in source - confirming the mechanism actually works, not just that it doesn't crash. Also confirmed (empirically, this time correctly) that the K1 DiagnosticSuppressor SPI is never consulted by K2 - a @Deprecated warning survived it untouched even when registered to unconditionally suppress everything. This leans on internal, undocumented FIR builder APIs with no compatibility guarantee across Kotlin versions, documented as such - the manual @file:Suppress convention this replaces still works identically as a fallback if a future Kotlin version breaks it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s --- README.md | 81 +++++--- .../ActualizerCompilerPluginRegistrar.kt | 4 + .../compiler/fir/ActualizerFirExtensions.kt | 175 ++++++++++++++++++ .../samplemod/common/ModCommon.kt | 11 +- .../samplemod/common/Actual.kt | 4 - .../samplemod/common/Actual.kt | 4 - .../net/kernelpanicsoft/sample/api/Actual.kt | 4 - .../net/kernelpanicsoft/sample/api/Actual.kt | 4 - .../kernelpanicsoft/sample/api/Greeting.kt | 11 +- .../sample/api/PlatformInfo.kt | 2 - 10 files changed, 239 insertions(+), 61 deletions(-) create mode 100644 plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt diff --git a/README.md b/README.md index 2f4e0d9..e0ed6dd 100644 --- a/README.md +++ b/README.md @@ -320,36 +320,63 @@ expect fun greetingSuffix(): String actual fun greetingSuffix(): String ``` -This is `OVERLOAD_RESOLUTION_AMBIGUITY`. All three names were confirmed by inspecting +This is `OVERLOAD_RESOLUTION_AMBIGUITY`. All names above were confirmed by inspecting `kotlin-compiler-embeddable`'s `FirErrors` directly (bytecode search for the exact rendered -message text, then the diagnostic-factory field referencing it). There is no stable, documented -Kotlin compiler-plugin API to suppress a *built-in* diagnostic from a FIR extension: +message text, then the diagnostic-factory field referencing it). A fourth, +`ERROR_SUPPRESSION`, is the compiler's own meta-warning about suppressing an error-severity +diagnostic (see below) - also suppressed, so the mechanism doesn't produce visible noise about +itself. + +There is no stable, documented Kotlin compiler-plugin API to suppress a *built-in* diagnostic: `FirAdditionalCheckersExtension` only lets a plugin *add* checkers, and the older, fully general `DiagnosticSuppressor` SPI is K1-only (it operates on the old `Diagnostic` type, which the K2/FIR -pipeline this repo targets doesn't use). So this repo takes the one mechanism that reliably works -in both the compiler and the IDE regardless of frontend version: `@Suppress`. - -- Every stub `actual` declaration `ExpectStubGenerator.kt` generates carries - `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT")` automatically - no - action needed for a `stubUnfulfilledExpects()`-only module's generated code. (The generated stub - never calls the expect it's actualizing, so it never needs `OVERLOAD_RESOLUTION_AMBIGUITY` - itself - see the next bullet for where that one actually applies.) -- Hand-written `expect`/`actual` files (the ones you actually write, in a `stubUnfulfilledExpects()` - module's own expect declarations, or in an `actualizes(...)` leaf's real actuals) need the same - two declaration-level suppressions added once, at the top of the file: - `@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT")`. If that same - file (or any file merged into the same compilation) also *calls* the expect - e.g. `:sample:api`'s - `Greeting.kt` declares `expect fun greetingSuffix()` and its own `greet()` calls it in the same - file - add `"OVERLOAD_RESOLUTION_AMBIGUITY"` to that same `@file:Suppress` too. Every sample in - this repo (`sample/api`, `sample/actual-jvm`, `sample/actual-jvm-published`, and all three - `sample-architectury` modules that declare or actualize an expect) does this - see any of their - source files for the exact form. -- The compiler itself warns when this suppression is used (`This code uses error suppression for - '...'. [...] the compiler behavior is UNSPECIFIED and WON'T BE PRESERVED`) - that's expected and - harmless (it doesn't fail the build); it's Kotlin's standard warning for suppressing an error-severity - diagnostic via `@Suppress`, not something specific to this mechanism. -- This is purely cosmetic - it only affects what the IDE shows while editing, never what actually - compiles or runs. +pipeline this repo targets doesn't use) - confirmed by registering one and observing it never gets +called. Suppression is instead read directly off each declaration's own `@Suppress` annotation by +`AbstractDiagnosticCollector.getDiagnosticsSuppressedForContainer` while walking the FIR tree - a +plain function with no registered extension point of its own. + +But that function *is* just reading `FirAnnotationContainer.getAnnotations()`, and every FIR +declaration exposes a real, callable `replaceAnnotations(...)` mutator - so instead of requiring a +literal `@Suppress`/`@file:Suppress` in your own source, `ActualizerFirExtensions.kt` constructs +that same annotation by hand (a `FirAnnotation` wrapping `kotlin.Suppress` with the four names +above as its `String` vararg argument - built from real FIR type/expression builders, not text) +and attaches it via a `FirStatusTransformerExtension`, the one extension point that both runs +early enough (during FIR status resolution, well before the diagnostics-collection pass) and hands +back a live reference to the actual declaration being resolved. It's scoped per **file**, not per +declaration: for every top-level declaration in a file, it looks up that file via +`FirProvider.getFirCallableContainerFile`/`getFirClassifierContainerFileIfAny` and checks whether +*any* declaration in it is `expect` or `isActual`; if so, every top-level declaration in that file +gets the synthetic annotation - covering not just the `expect`/`actual` declarations themselves, +but ordinary functions like `:sample:api`'s `greet()` that merely *call* one, since +`OVERLOAD_RESOLUTION_AMBIGUITY` fires at the call site, which doesn't have to be `expect`/`actual` +itself. + +This was verified empirically, not just reasoned about from bytecode: temporarily adding +`"DEPRECATION"` to the injected name list and calling an unrelated `@Deprecated` function from +both an `actual fun` and a plain sibling function in the same file - with **zero** `@Suppress` +anywhere in source - made the deprecation warning disappear from the build log in both cases. +Removing the injection (or the name) brought the warning straight back. + +Consequences and caveats of this approach: +- **No `@Suppress`/`@file:Suppress` is required anywhere in this repo's sample code any more** - + none of `sample/api`, `sample/actual-jvm`, `sample/actual-jvm-published`, or the three + `sample-architectury` modules that declare or actualize an expect carry one. The Actualizer + compiler plugin (which every one of these already applies, either directly or transitively via + `-Xplugin=` when `actualizes(...)`/`stubUnfulfilledExpects()` wires it in) does it for them. +- The compiler still emits its own meta-warning about this (`This code uses error suppression for + '...'. [...] the compiler behavior is UNSPECIFIED and WON'T BE PRESERVED`) wherever the synthetic + annotation lands - expected, harmless (doesn't fail the build), and itself suppressed via + `ERROR_SUPPRESSION` so it doesn't show up as visible log noise, though it would still be visible + with `--info`/verbose diagnostics output. +- This leans on internal, undocumented FIR builder APIs (`FirStatusTransformerExtension`, + `FirAnnotationBuilder`, `constructClassLikeType`, `FirProvider`) with no compatibility guarantee + across Kotlin versions - meaningfully more fragile than the compiler-flag reliance described + above. If a future Kotlin version breaks this (renamed/removed internals, a stricter check on + synthetic annotations, etc.), the fallback is exactly the manual `@file:Suppress` convention this + replaced - it still works identically, just requires writing it out by hand again. +- This is still purely cosmetic - it only affects what the IDE shows while editing, never what + actually compiles or runs. And it's scoped to declarations Actualizer's own extensions see + (i.e. modules with the compiler plugin wired in); it has no effect anywhere else. - **Not attempted**: the IDE's "KMP gutter icon" (the margin icon that lets you jump between `expect` and `actual`) is populated from genuine Kotlin Multiplatform module structure (`dependsOn` source-set edges the IDE's Gradle importer understands), which a plain diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index 6dd44e7..761431a 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -1,5 +1,6 @@ package net.kernelpanicsoft.actualizer.compiler +import net.kernelpanicsoft.actualizer.compiler.fir.ActualizerFirExtensionRegistrar import net.kernelpanicsoft.actualizer.compiler.ir.ActualizerIrExtension import net.kernelpanicsoft.actualizer.compiler.ir.ModuleRoot import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension @@ -8,6 +9,7 @@ import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CommonConfigurationKeys import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter @OptIn(ExperimentalCompilerApi::class) class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { @@ -15,6 +17,8 @@ class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + FirExtensionRegistrarAdapter.registerExtension(ActualizerFirExtensionRegistrar()) + val moduleMap = parseModuleMap(configuration.get(ActualizerCommandLineProcessor.KEY_MODULE_MAP).orEmpty()) val selfModule = configuration.get(ActualizerCommandLineProcessor.KEY_SELF_MODULE) ?: "" val reportOutput = configuration.get(ActualizerCommandLineProcessor.KEY_REPORT_OUTPUT) diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt new file mode 100644 index 0000000..1d37125 --- /dev/null +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt @@ -0,0 +1,175 @@ +package net.kernelpanicsoft.actualizer.compiler.fir + +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.FirCallableDeclaration +import org.jetbrains.kotlin.fir.declarations.FirClassLikeDeclaration +import org.jetbrains.kotlin.fir.declarations.FirConstructor +import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirDeclarationStatus +import org.jetbrains.kotlin.fir.declarations.FirFile +import org.jetbrains.kotlin.fir.declarations.FirMemberDeclaration +import org.jetbrains.kotlin.fir.declarations.FirProperty +import org.jetbrains.kotlin.fir.declarations.FirRegularClass +import org.jetbrains.kotlin.fir.declarations.FirSimpleFunction +import org.jetbrains.kotlin.fir.expressions.FirAnnotation +import org.jetbrains.kotlin.fir.expressions.builder.FirAnnotationArgumentMappingBuilder +import org.jetbrains.kotlin.fir.expressions.builder.FirAnnotationBuilder +import org.jetbrains.kotlin.fir.expressions.builder.FirVarargArgumentsExpressionBuilder +import org.jetbrains.kotlin.fir.expressions.builder.buildLiteralExpression +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar +import org.jetbrains.kotlin.fir.extensions.FirStatusTransformerExtension +import org.jetbrains.kotlin.fir.resolve.providers.firProvider +import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol +import org.jetbrains.kotlin.fir.types.ConeAttributes +import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjectionOut +import org.jetbrains.kotlin.fir.types.builder.FirResolvedTypeRefBuilder +import org.jetbrains.kotlin.fir.types.constructClassLikeType +import org.jetbrains.kotlin.name.StandardClassIds +import org.jetbrains.kotlin.types.ConstantValueKind + +// EXPERIMENTAL - see chat: injects a synthetic @Suppress FIR annotation onto every top-level +// declaration in any file that contains an expect or actual declaration, so the IDE's live +// analysis stops reporting EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE / ACTUAL_WITHOUT_EXPECT / +// OVERLOAD_RESOLUTION_AMBIGUITY as false positives, without requiring a literal @Suppress in the +// user's own source. Confirmed via bytecode inspection of +// AbstractDiagnosticCollector.getDiagnosticsSuppressedForContainer that suppression is read +// straight off FirAnnotationContainer.annotations by matching the annotation's resolved ClassId +// against kotlin.Suppress and reading its "names" vararg argument - there is no registered +// extension point for this, so this constructs a FirAnnotation by hand and attaches it via +// replaceAnnotations() from a FirStatusTransformerExtension (the only extension point that both +// runs early enough, during FIR status resolution, and hands back a mutable reference to the +// actual declaration). Empirically verified to work: injecting "DEPRECATION" into this list +// suppressed a real @Deprecated-call warning with zero @Suppress present in source. +// +// Scoped per-*file* (not per-declaration): a call site like `greet()` calling its own +// `expect fun greetingSuffix()` triggers OVERLOAD_RESOLUTION_AMBIGUITY on the *call*, which can +// live in an ordinary function that is itself neither expect nor actual - so every top-level +// declaration sharing a file with an expect/actual needs the annotation, not just the expect/ +// actual declarations themselves. + +private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf( + "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", + "ACTUAL_WITHOUT_EXPECT", + "OVERLOAD_RESOLUTION_AMBIGUITY", + "ERROR_SUPPRESSION", +) + +private fun buildSuppressAnnotation(): FirAnnotation { + val suppressConeType = StandardClassIds.Annotations.Suppress.constructClassLikeType( + typeArguments = emptyArray(), + isNullable = false, + attributes = ConeAttributes.Empty, + ) + val typeRef = FirResolvedTypeRefBuilder().apply { type = suppressConeType }.build() + + val stringConeType = StandardClassIds.String.constructClassLikeType( + typeArguments = emptyArray(), + isNullable = false, + attributes = ConeAttributes.Empty, + ) + val literalArgs = SUPPRESSED_DIAGNOSTIC_NAMES.map { name -> + buildLiteralExpression( + source = null, + kind = ConstantValueKind.String, + value = name, + annotations = null, + setType = true, + prefix = null, + ) + } + val varargArrayConeType = StandardClassIds.Array.constructClassLikeType( + typeArguments = arrayOf(ConeKotlinTypeProjectionOut(stringConeType)), + isNullable = false, + attributes = ConeAttributes.Empty, + ) + val varargExpression = FirVarargArgumentsExpressionBuilder().apply { + arguments.addAll(literalArgs) + coneElementTypeOrNull = stringConeType + coneTypeOrNull = varargArrayConeType + }.build() + + val argumentMapping = FirAnnotationArgumentMappingBuilder().apply { + mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = varargExpression + }.build() + + return FirAnnotationBuilder().apply { + annotationTypeRef = typeRef + this.argumentMapping = argumentMapping + }.build() +} + +private fun isExpectOrActual(status: FirDeclarationStatus): Boolean = status.isExpect || status.isActual + +class ActualizerFirExtensionRegistrar : FirExtensionRegistrar() { + override fun ExtensionRegistrarContext.configurePlugin() { + +::ActualizerSuppressionStatusTransformer + } +} + +private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirStatusTransformerExtension(session) { + + private val fileNeedsSuppressionCache = mutableMapOf() + + private fun containingFile(declaration: FirMemberDeclaration): FirFile? { + val provider = session.firProvider + return when (declaration) { + is FirCallableDeclaration -> provider.getFirCallableContainerFile(declaration.symbol) + is FirClassLikeDeclaration -> provider.getFirClassifierContainerFileIfAny(declaration.symbol) + else -> null + } + } + + private fun fileNeedsSuppression(file: FirFile): Boolean = fileNeedsSuppressionCache.getOrPut(file) { + file.declarations.any { (it as? FirMemberDeclaration)?.status?.let(::isExpectOrActual) == true } + } + + override fun needTransformStatus(declaration: FirDeclaration): Boolean { + val member = declaration as? FirMemberDeclaration ?: return false + val file = containingFile(member) ?: return false + return fileNeedsSuppression(file) + } + + private fun inject(declaration: FirDeclaration) { + declaration.replaceAnnotations(declaration.annotations + buildSuppressAnnotation()) + } + + override fun transformStatus( + status: FirDeclarationStatus, + declaration: FirSimpleFunction, + containingClass: FirClassLikeSymbol<*>?, + isLocal: Boolean, + ): FirDeclarationStatus { + inject(declaration) + return status + } + + override fun transformStatus( + status: FirDeclarationStatus, + declaration: FirProperty, + containingClass: FirClassLikeSymbol<*>?, + isLocal: Boolean, + ): FirDeclarationStatus { + inject(declaration) + return status + } + + override fun transformStatus( + status: FirDeclarationStatus, + declaration: FirRegularClass, + containingClass: FirClassLikeSymbol<*>?, + isLocal: Boolean, + ): FirDeclarationStatus { + inject(declaration) + return status + } + + override fun transformStatus( + status: FirDeclarationStatus, + declaration: FirConstructor, + containingClass: FirClassLikeSymbol<*>?, + isLocal: Boolean, + ): FirDeclarationStatus { + inject(declaration) + return status + } +} diff --git a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt index e5351ef..a73d495 100644 --- a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt +++ b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt @@ -1,5 +1,3 @@ -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY") - package net.kernelpanicsoft.samplemod.common /** @@ -10,12 +8,9 @@ package net.kernelpanicsoft.samplemod.common * mutually-incompatible Gradle plugins like this, but merging source into each loader's own * compilation can. * - * The file-level `@Suppress` above quiets IDE-only false positives: `:common` also calls - * `actualizer { stubUnfulfilledExpects() }` so it builds a standalone jar, which puts a - * generated `actual` stub in this same module (real compile is fine - see the root README's - * "IDE false positives" section), and the call to `loaderSpecificGreeting()` below reads as an - * ambiguous overload between the `expect` and that stub, since the IDE doesn't collapse the pair - * into one logical declaration the way real compilation does. + * No `@file:Suppress` needed here for the IDE-only false positives this would otherwise trigger + * (see the root README's "IDE false positives" section) - the Actualizer compiler plugin injects + * the equivalent suppression automatically via a FIR extension. */ expect val loaderName: String diff --git a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt index f5c9fd2..aa6562e 100644 --- a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt +++ b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt @@ -1,10 +1,6 @@ // The `actual` declarations below must live in `net.kernelpanicsoft.samplemod.common` - the same // package as their `expect` counterparts in the unrelated `:common` Gradle module - even though // this file physically lives in `:fabric`, a separate platform target under Architectury Loom. -// -// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - -// see the root README's "IDE false positives" section). -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") package net.kernelpanicsoft.samplemod.common diff --git a/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt index b8d63db..f6f7bbf 100644 --- a/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt +++ b/sample-architectury/neoforge/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt @@ -1,10 +1,6 @@ // The `actual` declarations below must live in `net.kernelpanicsoft.samplemod.common` - the same // package as their `expect` counterparts in the unrelated `:common` Gradle module - even though // this file physically lives in `:neoforge`, a separate platform target under Architectury Loom. -// -// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - -// see the root README's "IDE false positives" section). -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") package net.kernelpanicsoft.samplemod.common diff --git a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 2768645..8bdb037 100644 --- a/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm-published/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -1,10 +1,6 @@ // Same package as the expect this actualizes (net.kernelpanicsoft.sample.api, declared in the // *published* net.kernelpanicsoft.sample:api:0.1.0 library, not a project(...) reference), even // though this file physically lives in a completely different Gradle module. -// -// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - -// see the root README's "IDE false positives" section). -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") package net.kernelpanicsoft.sample.api diff --git a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt index 09db9e8..8f0e223 100644 --- a/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt +++ b/sample/actual-jvm/src/main/kotlin/net/kernelpanicsoft/sample/api/Actual.kt @@ -1,10 +1,6 @@ // The `actual` declaration must live in the same package as its `expect` counterpart // (net.kernelpanicsoft.sample.api, declared in the unrelated :sample:api Gradle module), even // though this file physically lives in a completely different Gradle module. -// -// The file-level @Suppress quiets an IDE-only false positive (real compile is fine without it - -// see the root README's "IDE false positives" section). -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT") package net.kernelpanicsoft.sample.api diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt index d88b2dd..a198849 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -1,5 +1,3 @@ -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY") - package net.kernelpanicsoft.sample.api /** @@ -9,12 +7,9 @@ package net.kernelpanicsoft.sample.api * stubUnfulfilledExpects() }` (see `build.gradle.kts`) auto-generates a throwing `actual` stub * for it, so this whole file compiles normally into `:api`'s ordinary, standalone jar. * - * The file-level `@Suppress` above quiets IDE-only false positives: the IDE's live analysis - * doesn't apply the `-Xmulti-platform` compiler flag that lets this compile in the first place, - * so it reports `expect`/`actual` as illegally coexisting in one module (even though the real - * Gradle build is fine), and - since it also doesn't collapse the expect/actual pair into one - * logical declaration - it reports the call to `greetingSuffix()` below as an ambiguous overload - * between the two. See the root README's "IDE false positives" section. + * No `@file:Suppress` needed here for the IDE-only false positives this would otherwise trigger + * (see the root README's "IDE false positives" section) - the Actualizer compiler plugin injects + * the equivalent suppression automatically via a FIR extension. */ expect fun greetingSuffix(): String diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt index 7e2d70c..5a81579 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt @@ -1,5 +1,3 @@ -@file:Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY") - package net.kernelpanicsoft.sample.api /** Exercises the stub generator's `expect val` support (Greeting.kt already covers `expect fun`). */ From 3eb9c212c980e3639d15372946b46f2a9b37f3f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 07:26:43 +0000 Subject: [PATCH 13/19] Add real Maven publishing, rename group, clean up docs, harden FIR extension - Renamed the plugin-build group to net.kernelpanicsoft.actualizer (was net.kernelpanicsoft) and updated the compiler-plugin coordinate the Gradle plugin resolves for -Xplugin= to match. - Added real maven-publish wiring for compiler-plugin and gradle-plugin, targeting a Reposilite instance (releases/snapshots split by version suffix), with credentials from a gitignored local.properties or REPOSILITE_USERNAME/REPOSILITE_PASSWORD env vars. This is what a real external consumer (not linked in via includeBuild) needs to resolve the plugin at all. - Rewrote doc comments and both READMEs throughout for tone, and added KDoc to the plugin's public API surface (ActualizerExtension, ActualizerGradlePlugin, ActualizerCompilerPluginRegistrar, ActualizerIrExtension, ActualizerFirExtensions, ExpectStubGenerator). - Hardened the FIR-based IDE-suppression extension against Kotlin version mismatches: a real consumer can easily end up running this plugin against a different compiler version than it was built against (the IDE's own bundled K2 compiler, or a Minecraft mod pinning a different Kotlin version). A binary-incompatible internal FIR API now degrades to "no automatic suppression" - caught as Throwable and latched off for the rest of the compiler process - instead of breaking a real compile. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Ba6jHfo9TXnsFyUxkA3H1s --- .gitignore | 1 + README.md | 580 +++++++++--------- plugin-build/compiler-plugin/build.gradle.kts | 34 +- .../ActualizerCompilerPluginRegistrar.kt | 27 +- .../compiler/fir/ActualizerFirExtensions.kt | 68 +- .../compiler/ir/ActualizerIrExtension.kt | 40 +- plugin-build/gradle-plugin/build.gradle.kts | 29 +- .../actualizer/gradle/ActualizerExtension.kt | 55 +- .../gradle/ActualizerGradlePlugin.kt | 130 ++-- .../actualizer/gradle/ExpectStubGenerator.kt | 97 ++- sample-architectury/README.md | 69 ++- .../samplemod/common/ModCommon.kt | 17 +- .../samplemod/common/Actual.kt | 14 +- sample-architectury/settings.gradle.kts | 2 +- .../kernelpanicsoft/sample/api/Greeting.kt | 21 +- .../sample/api/PlatformInfo.kt | 8 +- .../kernelpanicsoft/sample/feature/Feature.kt | 11 +- settings.gradle.kts | 2 +- 18 files changed, 616 insertions(+), 589 deletions(-) diff --git a/.gitignore b/.gitignore index 0256a99..00d4b2c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ plugin-build/*/build/ .idea/ out/ kotlin-js-store/ +local.properties diff --git a/README.md b/README.md index e0ed6dd..271eb2a 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ # Actualizer -A Kotlin IR compiler plugin (+ a Gradle plugin to drive it) that lets an `expect` declaration in -one Gradle module be actualized by a real `actual` declaration living in a **genuinely separate, -independently built Gradle module** - one that is not wired into the same multiplatform -source-set hierarchy as the expect. Every module involved is a plain `kotlin("jvm")` project; -nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. +A Kotlin IR compiler plugin (plus a Gradle plugin to drive it) that lets an `expect` declaration in +one Gradle module be actualized by a real `actual` declaration in a separate, independently built +Gradle module - one that isn't wired into the same multiplatform source-set hierarchy. Every module +involved is a plain `kotlin("jvm")` project; nothing here uses the Kotlin Multiplatform plugin or +any non-JVM target. ``` :sample:api expect fun/val/class (greetingSuffix, platformName, GreetingCounter), plus ordinary code that calls them - one normal source set, one standalone jar :sample:feature-common depends on :api's plain jar; welcomeMessage() calls :api's greet() -:sample:actual-jvm actual fun/val/class for all three - merges BOTH :api's and +:sample:actual-jvm actual fun/val/class for all three - merges both :api's and :feature-common's hand-written source in, unrelated Gradle module to either :sample:app ordinary dependency on :sample:actual-jvm (gets the real, linked chain) ``` @@ -22,91 +22,81 @@ nothing here uses the Kotlin Multiplatform plugin or any non-JVM target. [feature-common] Hello, world! (actualized independently by :sample:actual-jvm) ``` -All published modules (`compiler-plugin`, `gradle-plugin`) use the `net.kernelpanicsoft` Maven -group; the plugin/package namespace is `net.kernelpanicsoft.actualizer`. +All published modules (`compiler-plugin`, `gradle-plugin`) use the `net.kernelpanicsoft.actualizer` +Maven group and package namespace. -There's also a second, real-world sample build in `sample-architectury/` - an Architectury -(Fabric + NeoForge) Minecraft multiloader project, the actual motivating use case for this whole -mechanism (see "Motivation" below). It's a genuinely separate Gradle build with its own settings, -proving the mechanism works across two independently-configured, non-KMP mod-loader toolchains, -not just the generic plain-JVM sample above. See `sample-architectury/README.md`. +There's a second, real-world sample build in `sample-architectury/` - an Architectury (Fabric + +NeoForge) Minecraft multiloader project, the actual motivating case for this mechanism (see +"Motivation" below). It's a separate Gradle build with its own settings, proving this works across +two independently configured, non-KMP mod-loader toolchains rather than just the plain-JVM sample +above. See `sample-architectury/README.md`. No annotation is required on `expect`/`actual` declarations. A plain `kotlin("jvm")` module never -sees `expect`/`actual` at all unless Actualizer put them there via `-Xmulti-platform` - so every -`expect`/`actual` pair Actualizer's IR plugin sees is, by construction, already a genuine -Actualizer-managed cross-module pair. There used to be a `@CrossModuleExpect` marker annotation; -it added ceremony without disambiguating anything real, since the ambiguity it existed to resolve -(accidental multiplatform expect/actual usage) can't happen in a non-multiplatform module. +sees `expect`/`actual` at all unless Actualizer put them there via `-Xmulti-platform`, so any pair +Actualizer's IR plugin sees is already an Actualizer-managed cross-module pair by construction. An +earlier version of this plugin used a `@CrossModuleExpect` marker annotation, but it only added +ceremony - the ambiguity it was meant to resolve (accidental expect/actual usage) can't happen in +a non-multiplatform module. ## Motivation: multiloader-style projects -The concrete inspiration is **multiloader** project layouts like Architectury-based Minecraft -mods, where the same mod needs to build against several mod loaders (Fabric, NeoForge, ...) at -once. Each loader brings its own Gradle plugin, its own loader API dependencies, and - critically -- its own independently-configured compile classpath (in Minecraft's case: the game jar remapped -through that loader's own mapping/obfuscation pipeline). A single Kotlin Multiplatform module -doesn't fit this shape: KMP's contract is "`commonMain` compiles once, shared everywhere," but -here the "common" code often has to be *recompiled per loader* against a genuinely different -classpath - not just a different platform API surface the way JVM/JS/Native differ, but a -different, independently-built environment each loader's own Gradle plugin sets up before your -code ever compiles. - -That's the structural reason this repo doesn't use the Kotlin Multiplatform plugin anywhere, and -why Actualizer is built the way it is: it never constructs a synthetic shared compilation. It only -ever adds source directories and compiler flags to a module's *own*, already-fully-configured -`compileKotlin` task (see part 2 below) - so whatever loader-specific (or otherwise -environment-specific) Gradle plugin is applied first gets to finish configuring that module's -classpath, and Actualizer's `afterEvaluate` wiring only layers on top of it afterward. Common -source ends up compiled against whatever classpath that particular leaf module already has, -without Actualizer needing to know or care what's on it. The sample in this repo is deliberately -generic (plain JVM, no game jars, no remapping) so the mechanism stays easy to follow, but nothing -about it assumes a plain classpath - it's just as applicable to a module whose classpath came from -Loom, NeoGradle, or any other plugin that shapes dependencies before compilation. - -One consequence worth flagging explicitly: this only works if the merged-in source is actually -*valid* against whatever classpath the merging module provides. If a "common" artifact's source -(hand-written, or from a resolved sources jar - see part 5 below, `actualizes("group:artifact:version")`) -was prepared against a different environment than the consumer's - built against different -remapped symbols, a different loader API version, whatever - merging the raw text doesn't -reconcile that difference; it just fails to resolve, same as if you'd hand-written mismatched code -yourself. Actualizer doesn't (and structurally can't) paper over an actual environment mismatch -between the module that wrote the source and the module compiling it. +The concrete inspiration is multiloader project layouts like Architectury-based Minecraft mods, +where the same mod builds against several mod loaders (Fabric, NeoForge, ...) at once. Each loader +brings its own Gradle plugin, its own loader API, and its own independently configured compile +classpath - in Minecraft's case, the game jar remapped through that loader's own mapping pipeline. +A single Kotlin Multiplatform module doesn't fit this shape: KMP's contract is "`commonMain` +compiles once, shared everywhere," but here the common code often has to be recompiled per loader +against a genuinely different classpath, not just a different platform API surface the way +JVM/JS/Native differ. + +That's why this repo doesn't use the Kotlin Multiplatform plugin anywhere, and why Actualizer works +the way it does: it never builds a synthetic shared compilation. It only adds source directories +and compiler flags to a module's own, already-fully-configured `compileKotlin` task (see part 2 +below), so whatever loader-specific plugin runs first gets to finish configuring the module's +classpath, and Actualizer's `afterEvaluate` wiring layers on top afterward. Common source ends up +compiled against whatever classpath the leaf module already has, without Actualizer needing to +know what's on it. The sample in this repo is plain JVM with no game jars or remapping, to keep +the mechanism easy to follow, but it applies equally to a module whose classpath comes from Loom, +NeoGradle, or any other plugin that shapes dependencies before compilation. + +One consequence worth calling out: this only works if the merged-in source is actually valid +against whatever classpath the merging module provides. If a common artifact's source (hand-written, +or unpacked from a sources jar - see part 5 below) was written against a different environment than +the consumer's, merging the raw text doesn't reconcile that difference - it just fails to resolve, +the same as if you'd hand-written mismatched code yourself. ## Why this needs a plugin at all -Stock Kotlin only resolves `expect`/`actual` within a single compiler invocation that has -explicitly been told which of its input files are "common" and which are "platform" - that -wiring is normally generated by the Kotlin Gradle Plugin from inside one `kotlin { }` block, and -it has no notion of doing this across unrelated Gradle projects. +Stock Kotlin only resolves `expect`/`actual` within a single compiler invocation that's been told +which of its input files are common and which are platform - wiring normally generated by the +Kotlin Gradle Plugin from inside one `kotlin { }` block, with no notion of doing this across +unrelated Gradle projects. -There is fundamentally **no way to patch this after the fact**: once `:sample:api` is compiled to -bytecode, no compiler plugin running in some other module's compilation can reach back in and -rewrite already-emitted `.class` files to redirect a call. So "literal `expect`/`actual` across -Gradle modules" can only work if the modules' **source files** end up merged into one compiler -invocation - there is no alternative that doesn't abandon the real keyword. +There's no way to patch this after the fact: once `:sample:api` is compiled to bytecode, nothing +running in another module's compilation can reach back and rewrite already-emitted `.class` files +to redirect a call. So literal `expect`/`actual` across Gradle modules only works if the modules' +source files end up merged into one compiler invocation - there's no alternative that keeps the +real keyword. -That constraint also means a module compiled *before* any real `actual` exists can never call the -real, eventually-linked implementation through its own already-compiled jar - only through source -that gets merged downstream. An auto-generated stub (see below) can make an unfulfilled `expect` -compile standalone, but calling it via that standalone jar directly always throws; the real, -working chain only exists wherever a leaf module actually performs the merge. +That also means a module compiled before any real `actual` exists can never call the real, +eventually-linked implementation through its own already-compiled jar, only through source merged +downstream. An auto-generated stub (below) lets an unfulfilled `expect` compile standalone, but +calling it through that standalone jar always throws - the real chain only exists wherever a leaf +module performs the merge. ## How it actually works -**1. The "common" module is a completely ordinary JVM jar - via an auto-generated stub, not -runtime dispatch.** +**1. The "common" module is an ordinary JVM jar, via an auto-generated stub, not runtime dispatch.** `:sample:api` is a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` and `actualizer { stubUnfulfilledExpects() }`. Its `expect fun greetingSuffix()`, `expect val -platformName`, and `expect class GreetingCounter(start: Int) { fun next(): Int }` all live -directly in the ordinary `main` source set, right next to regular code (`formatGreeting`, and -`greet()`/`describePlatform()`, which call the expects). Before compiling, the Actualizer Gradle -plugin scans `main`'s `.kt` files for `expect` declarations - via real Kotlin PSI parsing -(`KtPsiFactory`, syntax-only, no semantic resolution - see "Known limitations") rather than a -line-based regex/brace-counting scan, so multi-line declarations, comments, and string literals -containing `{`/`}` are all handled correctly - and generates a matching, throwing `actual` stub -for each into `build/generated/actualizer-stubs/...` (`ExpectStubGenerator.kt`, built with -[KotlinPoet](https://square.github.io/kotlinpoet/) rather than hand-rolled string concatenation, -so the generated code is properly formatted/imported instead of assembled by hand), e.g.: +platformName`, and `expect class GreetingCounter(start: Int) { fun next(): Int }` live directly in +the ordinary `main` source set, next to regular code (`formatGreeting`, `greet()`, +`describePlatform()`). Before compiling, the Actualizer Gradle plugin scans `main`'s `.kt` files for +`expect` declarations using real Kotlin PSI parsing (see "Known limitations") and generates a +matching, throwing `actual` stub for each into `build/generated/actualizer-stubs/...` +(`ExpectStubGenerator.kt`, built on [KotlinPoet](https://square.github.io/kotlinpoet/) rather than +hand-rolled string concatenation), e.g.: + ```kotlin actual fun greetingSuffix(): String = throw IllegalStateException("net.kernelpanicsoft.sample.api.greetingSuffix was never actualized - ...") @@ -118,90 +108,92 @@ actual class GreetingCounter actual constructor(start: Int) { actual fun next(): Int = throw IllegalStateException("net.kernelpanicsoft.sample.api.GreetingCounter.next was never actualized - ...") } ``` -(each generated declaration also carries `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", -"ACTUAL_WITHOUT_EXPECT")` - see "IDE false positives" below for why.) -(the class stub throws immediately in its `init` block - construction fails fast rather than -relying on every member throwing individually - but the member stubs still have to exist so the -actual class structurally matches the expect class). That generated file is added as an extra -`main` source directory, and `-Xmulti-platform` + `-Xcommon-sources=` get set on `compileKotlin` - the exact same "compile common + platform sources together" -mechanism used everywhere else in this repo (see part 2), just with a machine-written `actual` -instead of a hand-written one. The real Kotlin frontend links the (real) expects against the -(stub) actuals and compiles successfully, so `./gradlew :sample:api:build` produces one normal -`.jar` with real, ordinary JVM bytecode - no metadata/klib format, no split source sets. + +Each generated declaration also carries `@Suppress("EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", +"ACTUAL_WITHOUT_EXPECT")` - see "IDE false positives" below. The class stub throws immediately in +its `init` block, so construction fails fast rather than relying on every member throwing +individually, though the member stubs still exist so the actual class structurally matches the +expect class. That generated file is added as an extra `main` source directory, and +`-Xmulti-platform` + `-Xcommon-sources=` are set on +`compileKotlin` - the same source-merging mechanism used everywhere else in this repo (part 2), +just with a machine-written `actual` instead of a hand-written one. The real Kotlin frontend links +the expects against the stub actuals and compiles successfully, so `./gradlew :sample:api:build` +produces one normal jar - real bytecode, no metadata/klib format, no split source sets. `:sample:feature-common` depends on that jar with a plain `implementation(project(":sample:api"))` -and calls `greet()` normally - the call compiles and links fine (it's not calling an `expect`, -it's calling an ordinary function that happens to call one). Calling it *through this standalone -chain* throws `IllegalStateException` at runtime, because it's bound to the stub - see part 2 -for how the real chain gets built instead. - -**2. The actual linking happens in the leaf module, via source merging - of multiple foreign -modules at once.** -`:sample:actual-jvm` is also a plain `kotlin("jvm")` module, with `id("net.kernelpanicsoft.actualizer")` +and calls `greet()` normally - it's calling an ordinary function that happens to call an expect, so +it compiles and links fine. Calling it through this standalone chain throws +`IllegalStateException` at runtime, since it's bound to the stub - part 2 covers how the real chain +gets built instead. + +**2. The actual linking happens in the leaf module, via source merging of multiple foreign modules +at once.** +`:sample:actual-jvm` is also a plain `kotlin("jvm")` module with `id("net.kernelpanicsoft.actualizer")` applied and: + ```kotlin actualizer { actualizes(project(":sample:api")) // greetingSuffix, formatGreeting, greet actualizes(project(":sample:feature-common")) // welcomeMessage } ``` + `actualizes(...)` can be called more than once - the Gradle plugin merges every foreign project's -`main` source set (their *hand-written* source only; see below) into the same single compiler -invocation, alongside `:actual-jvm`'s own real `actual`. That's how `:feature-common`'s -`welcomeMessage()` (which calls `:api`'s `greet()`, which calls the `expect`) ends up compiled, -resolved, and packaged into one jar with the *real* chain intact - without `:feature-common` or -`:api` ever depending on each other, and without `:actual-jvm` depending on either of their -compiled jars at all (see "Known limitations" for why that specifically has to be avoided). The -Actualizer Gradle plugin pulls each foreign project's hand-written source *directory* - explicitly -excluding anything under that foreign project's own `build/` directory, so its *generated* stub -never gets merged in too (that would conflict with the real `actual` this leaf module provides) - -into `:sample:actual-jvm`'s own `main` source set, and adds two compiler flags to its -`compileKotlin` task: +`main` source set (their hand-written source only, see below) into the same compiler invocation +alongside `:actual-jvm`'s own real `actual`. That's how `:feature-common`'s `welcomeMessage()` +(which calls `:api`'s `greet()`, which calls the expect) ends up compiled and packaged into one jar +with the real chain intact - without `:feature-common` or `:api` ever depending on each other, and +without `:actual-jvm` depending on either of their compiled jars (see "Known limitations" for why +that has to be avoided). The Gradle plugin pulls each foreign project's hand-written source +directory - excluding anything under that project's own build directory, so its generated stub +never gets merged in alongside the real `actual` - into `:actual-jvm`'s own `main` source set, and +adds two compiler flags to `compileKotlin`: + ``` -Xmulti-platform -Xcommon-sources= ``` -This is the same mechanism the Kotlin Gradle Plugin itself uses under the hood for JVM -multiplatform targets - "common" and "platform" sources compiled together in one invocation, with -`-Xcommon-sources` marking which of them are common. Since it's all one real compiler invocation, -the actual Kotlin frontend does the actual/expect unification - not this plugin. This is real, -but it does lean on `-Xcommon-sources` / `-Xmulti-platform`, which are internal/undocumented -compiler flags not officially supported for this kind of use outside the Kotlin Gradle Plugin -itself; they could change behavior between Kotlin versions. This repo pins Kotlin `2.0.21`, -where the mechanism was verified to work exactly as described (see "What was verified" below). + +This is the same mechanism the Kotlin Gradle Plugin itself uses for JVM multiplatform targets - +common and platform sources compiled together in one invocation, with `-Xcommon-sources` marking +which are common. Since it's all one real compiler invocation, the actual Kotlin frontend does the +expect/actual unification, not this plugin. It does lean on `-Xmulti-platform`/`-Xcommon-sources`, +which are internal compiler flags not officially supported for this kind of use outside the Kotlin +Gradle Plugin itself, and could change behavior between Kotlin versions. This repo pins Kotlin +`2.0.21`, where the mechanism was verified to work as described (see "What was verified" below). **3. The IR plugin reports on what got linked.** `ActualizerIrExtension` (an `IrGenerationExtension`) runs inside that merged compilation. By the -time it runs, the frontend has *already* resolved (or already failed the build over) every -expect/actual pair - IR generation only happens after a successful frontend pass, so there is no -"unlinked" state left for an `IrGenerationExtension` to fix. Its job is everything the raw -mechanism *doesn't* give you for free: -- It attaches **module provenance**: the frontend only knows about files, not Gradle modules. The - plugin walks the merged `IrModuleFragment`, groups the files pulled in from each foreign module - by package (using the `moduleMap` compiler-plugin option built by the Gradle plugin, which maps - each foreign source root back to its owning Gradle module name), and reports every - locally-declared top-level declaration whose package matches one of those foreign packages as - the `actual` linking it - no annotation needed to identify which declarations are cross-module - links, since in a plain `kotlin("jvm")` module every `expect`/`actual` pair reaching this - extension is one by construction. It writes a JSON report to - `build/actualizer/report.json`: - ```json - { "consumingModule": ":sample:actual-jvm", - "links": [ { "actual": "net.kernelpanicsoft.sample.api.greetingSuffix", "owningModules": [":sample:api"] } ] } - ``` - (Note: once expect/actual are resolved, the `expect` declaration itself is elided from IR - - only the `actual` survives as a real `IrFunction`. The plugin correlates by package rather than - by pairing "expect IR" with "actual IR", since the former doesn't exist to pair against by the - time `IrGenerationExtension.generate()` runs.) +time it runs the frontend has already resolved (or already failed the build over) every +expect/actual pair, since IR generation only happens after a successful frontend pass - there's no +"unlinked" state left to fix. Its job is everything the raw mechanism doesn't give for free: + +It attaches module provenance, since the frontend only knows about files, not Gradle modules. The +plugin walks the merged `IrModuleFragment`, groups the files pulled in from each foreign module by +package (using a `moduleMap` compiler-plugin option the Gradle plugin builds, mapping each foreign +source root to its owning Gradle module name), and reports every locally declared top-level +declaration whose package matches one of those foreign packages as the `actual` linking it - no +annotation needed, since every expect/actual pair reaching this extension is one by construction. +It writes a JSON report to `build/actualizer/report.json`: + +```json +{ "consumingModule": ":sample:actual-jvm", + "links": [ { "actual": "net.kernelpanicsoft.sample.api.greetingSuffix", "owningModules": [":sample:api"] } ] } +``` + +Once expect/actual are resolved, the `expect` declaration itself is elided from IR - only the +`actual` survives as a real `IrFunction`. That's why the plugin correlates by package instead of +pairing an "expect IR declaration" with an "actual IR declaration": the expect side doesn't exist +to pair against by the time `IrGenerationExtension.generate()` runs. **4. `:sample:app` needs nothing special.** It has an ordinary `implementation(project(":sample:actual-jvm"))` dependency and calls `welcomeMessage()` normally. It never applies the Actualizer plugin and never references `:sample:api` or `:sample:feature-common` directly - it only sees `:actual-jvm`'s single, already-composed jar. -**5. The same mechanism also works against a *published* library, not just a sibling project.** -`:sample:actual-jvm-published` actualizes `net.kernelpanicsoft.sample:api:0.1.0` - the exact same +**5. The same mechanism also works against a published library, not just a sibling project.** +`:sample:actual-jvm-published` actualizes `net.kernelpanicsoft.sample:api:0.1.0` - the same `:sample:api` module, but consumed as a Maven coordinate instead of `project(":sample:api")`: + ```kotlin actualizer { actualizes( @@ -210,30 +202,29 @@ actualizer { ) } ``` + Instead of reading a project's source directory directly, this resolves that coordinate's -`sources` classifier artifact (a plain `-sources.jar`, matching how most published libraries - -including most Minecraft mods - actually publish sources; not a rich Gradle Module Metadata -"sources" variant), unpacks its `.kt` files into a build-local directory, and merges that in -exactly the same way as a `project(...)` reference. `dependsOnTasks` exists because Gradle has no -way to automatically infer "this coordinate isn't published yet, run this task first" the way it -does for `project(...)` dependencies - here it points at `:sample:api`'s own publish task, purely -because this sample self-containedly publishes and consumes the same library within one build for -a reproducible demo. Real usage (a library published independently, e.g. by CI, before any -consumer builds) wouldn't need `dependsOnTasks` at all. +`sources` classifier artifact (a plain `-sources.jar`, matching how most published libraries +actually publish sources), unpacks its `.kt` files into a build-local directory, and merges that in +the same way as a `project(...)` reference. `dependsOnTasks` exists because Gradle has no way to +infer "this coordinate isn't published yet, run this task first" the way it does for `project(...)` +dependencies - here it points at `:sample:api`'s own publish task, since this sample publishes and +consumes the same library within one build for a reproducible demo. Real usage, where the library +is published independently well before any consumer builds, wouldn't need `dependsOnTasks` at all. For the library side, `:sample:api` also applies `maven-publish` and defines its own `sourcesJar` -task explicitly scoped to `from("src/main/kotlin")` - **not** the `java { withSourcesJar() }` -convenience, which would also archive the generated stub directory `stubUnfulfilledExpects()` -added to `main`'s source set, breaking the merge on the consumer side (see "Known limitations"). +task scoped to `from("src/main/kotlin")` - not the `java { withSourcesJar() }` convenience, which +would also archive the generated stub directory `stubUnfulfilledExpects()` added to `main`, +breaking the merge on the consumer side (see "Known limitations"). -`:sample:app-published` is the same idea as `:sample:app`, just depending on -`:sample:actual-jvm-published` instead - ordinary binary dependency, no special wiring, prints +`:sample:app-published` is the same idea as `:sample:app`, depending on +`:sample:actual-jvm-published` instead - an ordinary binary dependency, no special wiring, printing the value produced by the published-and-actualized library. ## What was verified -Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 -/ JDK 21, not just designed on paper: +Everything above was actually built and run in this environment with Gradle 8.14 / Kotlin 2.0.21 / +JDK 21, not just designed on paper: - `./gradlew :sample:api:build` - a full, ordinary build succeeds and produces one plain JVM jar (real bytecode, no metadata/klib format) with both the real `formatGreeting`/`greet`/ @@ -244,28 +235,26 @@ Everything above was actually built and run in this environment with Gradle 8.14 designed. - `./gradlew :sample:feature-common:build` - succeeds depending only on `:sample:api`'s plain jar, before any actual exists anywhere. -- `./gradlew :sample:actual-jvm:build` - merges *both* `:sample:api`'s and - `:sample:feature-common`'s hand-written `main` sources and links them against the one real - actual; `build/actualizer/report.json` shows the link with correct module provenance. -- `./gradlew :sample:app:run` - prints `[feature-common] Hello, world! (actualized independently - by :sample:actual-jvm)`, the full chain composed and produced by `:sample:actual-jvm`'s single - jar, with zero special wiring in `:app` itself. -- `./gradlew :sample:actual-jvm-published:build` - publishes `:sample:api` to a local, build-local - Maven repository, resolves and unpacks its sources jar, and merges it against the real actual - in `:sample:actual-jvm-published`; `build/actualizer/report.json` shows the link with the - Maven coordinate (not a project path) as the owning module. +- `./gradlew :sample:actual-jvm:build` - merges both `:sample:api`'s and `:sample:feature-common`'s + hand-written `main` sources and links them against the one real actual; `build/actualizer/report.json` + shows the link with correct module provenance. +- `./gradlew :sample:app:run` - prints `[feature-common] Hello, world! (actualized independently by + :sample:actual-jvm)`, the full chain composed and produced by `:sample:actual-jvm`'s single jar, + with zero special wiring in `:app` itself. +- `./gradlew :sample:actual-jvm-published:build` - publishes `:sample:api` to a local Maven + repository, resolves and unpacks its sources jar, and merges it against the real actual in + `:sample:actual-jvm-published`; `build/actualizer/report.json` shows the link with the Maven + coordinate (not a project path) as the owning module. - `./gradlew :sample:app-published:run` - prints `Hello, world! (actualized against the published - api:0.1.0 library)`, proving the merge worked against a genuinely published-and-resolved - artifact, not just files read off a sibling project. -- `./gradlew build` at the repo root - builds the entire graph (including publishing and consuming - the sample library) in one shot; grepping the task log confirms zero JS/Node/npm/Yarn tasks - anywhere (no non-JVM tooling exists in this repo at all). -- **Negative path**: deleting the `actual` declaration from `:sample:actual-jvm` and re-running + api:0.1.0 library)`, proving the merge worked against a published-and-resolved artifact, not just + files read off a sibling project. +- `./gradlew build` at the repo root builds the entire graph, including publishing and consuming + the sample library, in one shot; the task log has zero JS/Node/npm/Yarn tasks anywhere. +- Negative path: deleting the `actual` declaration from `:sample:actual-jvm` and re-running `compileKotlin` fails the build with a real Kotlin frontend error pointing at the exact expect - declaration (`Expected greetingSuffix has no actual declaration in module -common - for JVM`) - a missing cross-module actual is a compile-time failure, not a silent runtime gap. -- A full clean build of the whole graph (`gradle --stop`, delete all `build/`/`.gradle/`, rebuild - from scratch) reproduces all of the above. + declaration (`Expected greetingSuffix has no actual declaration in module -common for + JVM`) - a missing cross-module actual is a compile-time failure, not a silent runtime gap. +- A full clean build of the whole graph reproduces all of the above. ## Repo layout @@ -273,7 +262,7 @@ Everything above was actually built and run in this environment with Gradle 8.14 plugin-build/ composite build (keeps the plugin's own Kotlin version pinned independently of consumers, standard Kotlin-compiler-plugin layout) compiler-plugin/ the IR compiler plugin itself (CommandLineProcessor, - CompilerPluginRegistrar, ActualizerIrExtension) + CompilerPluginRegistrar, ActualizerIrExtension, FIR extensions) gradle-plugin/ ActualizerGradlePlugin: the actualizer { actualizes(...) / stubUnfulfilledExpects() } DSL, source-directory merging, stub generation (ExpectStubGenerator.kt, using KotlinPoet), @@ -286,22 +275,21 @@ sample/ published-library demo below feature-common/ depends on api's plain jar, nothing special - no actualizer plugin needed on this module at all - actual-jvm/ applies the plugin; merges BOTH api's and feature-common's + actual-jvm/ applies the plugin; merges both api's and feature-common's hand-written main sources (via project(...)); the real actual lives here app/ plain binary consumer of actual-jvm, no special wiring - actual-jvm-published/ same idea as actual-jvm, but actualizes api's *published* Maven + actual-jvm-published/ same idea as actual-jvm, but actualizes api's published Maven coordinate instead of a project(...) reference app-published/ plain binary consumer of actual-jvm-published, no special wiring -sample-architectury/ a genuinely separate Gradle build - the real, motivating multiloader - use case (Fabric + NeoForge via Architectury Loom); see its own - README.md +sample-architectury/ a separate Gradle build - the real, motivating multiloader use case + (Fabric + NeoForge via Architectury Loom); see its own README.md ``` ## IDE false positives The IDE's live analysis of a plain `kotlin("jvm")` module doesn't apply the `-Xmulti-platform` -compiler flag that makes this whole mechanism compile in the first place, so it reports real -Kotlin diagnostics as squiggles even though the Gradle build is fine. Two fire on the declarations +compiler flag that makes this mechanism compile in the first place, so it reports real Kotlin +diagnostics as squiggles even though the Gradle build is fine. Two fire on the declarations themselves: ``` @@ -310,9 +298,9 @@ greetingSuffix: 'expect' and corresponding 'actual' are declared in the same mod ``` These are `ACTUAL_WITHOUT_EXPECT` and `EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE`. A third fires at any -*call site* within the same module that references the actualized declaration - since the IDE -doesn't collapse an `expect`/`actual` pair into one logical declaration the way real compilation -does, it sees two candidates with the same signature and reports the call itself as ambiguous: +call site within the same module that references the actualized declaration: since the IDE doesn't +collapse an expect/actual pair into one logical declaration the way real compilation does, it sees +two candidates with the same signature and reports the call itself as ambiguous: ``` Overload resolution ambiguity between candidates: @@ -320,149 +308,141 @@ expect fun greetingSuffix(): String actual fun greetingSuffix(): String ``` -This is `OVERLOAD_RESOLUTION_AMBIGUITY`. All names above were confirmed by inspecting -`kotlin-compiler-embeddable`'s `FirErrors` directly (bytecode search for the exact rendered -message text, then the diagnostic-factory field referencing it). A fourth, -`ERROR_SUPPRESSION`, is the compiler's own meta-warning about suppressing an error-severity -diagnostic (see below) - also suppressed, so the mechanism doesn't produce visible noise about -itself. +That's `OVERLOAD_RESOLUTION_AMBIGUITY`. All three names above were confirmed by inspecting +`kotlin-compiler-embeddable`'s `FirErrors` directly - a bytecode search for the exact rendered +message text, then the diagnostic-factory field referencing it. A fourth, `ERROR_SUPPRESSION`, is +the compiler's own meta-warning about suppressing an error-severity diagnostic (below) - also +suppressed, so the mechanism doesn't produce visible noise about itself. -There is no stable, documented Kotlin compiler-plugin API to suppress a *built-in* diagnostic: -`FirAdditionalCheckersExtension` only lets a plugin *add* checkers, and the older, fully general +There's no stable, documented compiler-plugin API to suppress a built-in diagnostic: +`FirAdditionalCheckersExtension` only lets a plugin add checkers, and the older, fully general `DiagnosticSuppressor` SPI is K1-only (it operates on the old `Diagnostic` type, which the K2/FIR pipeline this repo targets doesn't use) - confirmed by registering one and observing it never gets called. Suppression is instead read directly off each declaration's own `@Suppress` annotation by -`AbstractDiagnosticCollector.getDiagnosticsSuppressedForContainer` while walking the FIR tree - a -plain function with no registered extension point of its own. +`AbstractDiagnosticCollector.getDiagnosticsSuppressedForContainer` while the FIR diagnostics +collector walks the tree - a plain function with no extension point of its own. -But that function *is* just reading `FirAnnotationContainer.getAnnotations()`, and every FIR -declaration exposes a real, callable `replaceAnnotations(...)` mutator - so instead of requiring a +But that function is just reading `FirAnnotationContainer.getAnnotations()`, and every FIR +declaration exposes a real, callable `replaceAnnotations(...)` mutator. So instead of requiring a literal `@Suppress`/`@file:Suppress` in your own source, `ActualizerFirExtensions.kt` constructs -that same annotation by hand (a `FirAnnotation` wrapping `kotlin.Suppress` with the four names -above as its `String` vararg argument - built from real FIR type/expression builders, not text) -and attaches it via a `FirStatusTransformerExtension`, the one extension point that both runs -early enough (during FIR status resolution, well before the diagnostics-collection pass) and hands -back a live reference to the actual declaration being resolved. It's scoped per **file**, not per -declaration: for every top-level declaration in a file, it looks up that file via +that same annotation by hand - a `FirAnnotation` wrapping `kotlin.Suppress` with the four names +above as its `String` vararg argument, built from real FIR type/expression builders, not text - and +attaches it via a `FirStatusTransformerExtension`, the one extension point that both runs early +enough (during FIR status resolution, well before the diagnostics-collection pass) and hands back +a live reference to the declaration being resolved. It's scoped per file, not per declaration: for +every top-level declaration in a file, it looks up that file via `FirProvider.getFirCallableContainerFile`/`getFirClassifierContainerFileIfAny` and checks whether -*any* declaration in it is `expect` or `isActual`; if so, every top-level declaration in that file -gets the synthetic annotation - covering not just the `expect`/`actual` declarations themselves, -but ordinary functions like `:sample:api`'s `greet()` that merely *call* one, since -`OVERLOAD_RESOLUTION_AMBIGUITY` fires at the call site, which doesn't have to be `expect`/`actual` +any declaration in it is `expect` or `isActual`; if so, every top-level declaration in that file +gets the synthetic annotation. That covers not just the expect/actual declarations themselves but +ordinary functions like `:sample:api`'s `greet()` that merely call one, since +`OVERLOAD_RESOLUTION_AMBIGUITY` fires at the call site, which doesn't have to be expect/actual itself. This was verified empirically, not just reasoned about from bytecode: temporarily adding -`"DEPRECATION"` to the injected name list and calling an unrelated `@Deprecated` function from -both an `actual fun` and a plain sibling function in the same file - with **zero** `@Suppress` -anywhere in source - made the deprecation warning disappear from the build log in both cases. -Removing the injection (or the name) brought the warning straight back. - -Consequences and caveats of this approach: -- **No `@Suppress`/`@file:Suppress` is required anywhere in this repo's sample code any more** - - none of `sample/api`, `sample/actual-jvm`, `sample/actual-jvm-published`, or the three +`"DEPRECATION"` to the injected name list and calling an unrelated `@Deprecated` function from both +an `actual fun` and a plain sibling function in the same file - with zero `@Suppress` anywhere in +source - made the deprecation warning disappear from the build log in both cases. Removing the +injection (or the name) brought the warning straight back. + +A few consequences worth knowing: +- No `@Suppress`/`@file:Suppress` is required anywhere in this repo's sample code any more - none + of `sample/api`, `sample/actual-jvm`, `sample/actual-jvm-published`, or the three `sample-architectury` modules that declare or actualize an expect carry one. The Actualizer - compiler plugin (which every one of these already applies, either directly or transitively via - `-Xplugin=` when `actualizes(...)`/`stubUnfulfilledExpects()` wires it in) does it for them. + compiler plugin does it for them. - The compiler still emits its own meta-warning about this (`This code uses error suppression for '...'. [...] the compiler behavior is UNSPECIFIED and WON'T BE PRESERVED`) wherever the synthetic - annotation lands - expected, harmless (doesn't fail the build), and itself suppressed via - `ERROR_SUPPRESSION` so it doesn't show up as visible log noise, though it would still be visible - with `--info`/verbose diagnostics output. + annotation lands. That's expected and harmless - it doesn't fail the build - and it's itself + suppressed via `ERROR_SUPPRESSION`, though it would still show up with `--info`/verbose + diagnostics output. - This leans on internal, undocumented FIR builder APIs (`FirStatusTransformerExtension`, `FirAnnotationBuilder`, `constructClassLikeType`, `FirProvider`) with no compatibility guarantee - across Kotlin versions - meaningfully more fragile than the compiler-flag reliance described - above. If a future Kotlin version breaks this (renamed/removed internals, a stricter check on - synthetic annotations, etc.), the fallback is exactly the manual `@file:Suppress` convention this - replaced - it still works identically, just requires writing it out by hand again. + across Kotlin versions, meaningfully more fragile than the compiler-flag reliance described + above. This repo targets Kotlin 2.0.21, but a real consumer can easily end up running this + plugin against a different compiler version than it was built against - the IDE's own bundled + K2 compiler analyzing a project it didn't build itself, or a Minecraft mod whose toolchain pins + a different Kotlin version than this sample does. A binary-incompatible internal API there + surfaces as `NoSuchMethodError`/`NoSuchFieldError`/`LinkageError`, none of which are `Exception` + subtypes. `ActualizerFirExtensions.kt` and its registration in + `ActualizerCompilerPluginRegistrar.kt` both catch `Throwable` around every use of these APIs and + latch a flag off for the rest of the compiler process on first failure, so a version mismatch + degrades to "no automatic suppression" rather than breaking a real compile. If that happens, the + fallback is the manual `@file:Suppress` convention this mechanism replaced - it still works + identically, just requires writing it out by hand again. - This is still purely cosmetic - it only affects what the IDE shows while editing, never what - actually compiles or runs. And it's scoped to declarations Actualizer's own extensions see - (i.e. modules with the compiler plugin wired in); it has no effect anywhere else. -- **Not attempted**: the IDE's "KMP gutter icon" (the margin icon that lets you jump between - `expect` and `actual`) is populated from genuine Kotlin Multiplatform module structure - (`dependsOn` source-set edges the IDE's Gradle importer understands), which a plain - `kotlin("jvm")` module fundamentally doesn't have - getting it to appear here would need a real - IntelliJ plugin, not just a compiler/Gradle plugin, so it's out of scope for this repo. + actually compiles or runs. And it only covers declarations Actualizer's own extensions see, i.e. + modules with the compiler plugin wired in. +- Not attempted: the IDE's "KMP gutter icon" (the margin icon for jumping between `expect` and + `actual`) comes from genuine Kotlin Multiplatform module structure - `dependsOn` source-set edges + the IDE's Gradle importer understands - which a plain `kotlin("jvm")` module doesn't have. + Getting it to appear here would need a real IntelliJ plugin, not just a compiler/Gradle plugin, + so it's out of scope for this repo. ## Known limitations - **JVM only.** The `-Xcommon-sources` source-merge trick was only verified for the JVM target. - Native/JS multiplatform targets link expect/actual via klib "refines" edges instead, which is a - different (and, from spiking this, meaningfully more internal/fragile) mechanism this repo does - not attempt. -- **Source-only, not binary.** The Gradle plugin needs the foreign module's Kotlin *source files* - - either on disk (`project(...)`) or resolvable as a `sources`-classifier artifact (published - coordinates) - it cannot actualize against something that only ships compiled klibs/jars for its - common code with no sources at all (inherent to how `-Xcommon-sources` works - it takes source - paths, not bytecode). + Native/JS multiplatform targets link expect/actual via klib "refines" edges instead, a different + and more internal/fragile mechanism this repo doesn't attempt. +- **Source-only, not binary.** The Gradle plugin needs the foreign module's Kotlin source files, + either on disk (`project(...)`) or resolvable as a `sources`-classifier artifact (published + coordinates) - it can't actualize against something that only ships compiled klibs/jars with no + sources, since `-Xcommon-sources` takes source paths, not bytecode. - **A published library's sources jar must be scoped the same way `stubUnfulfilledExpects()` requires for a `project(...)` reference.** It has to contain the library's real, hand-written - source only - not a generated stub, and not anything from the consumer's own environment. The - default `java { withSourcesJar() }` Gradle convenience archives a source set's full `allSource`, - which - on a module also using `stubUnfulfilledExpects()` - includes the generated stub - directory that convenience added to `main`. Publishing that would merge the library's *own* - stub `actual` in alongside the real one the leaf module provides, breaking the build with a - duplicate-`actual` conflict. Configure a hand-scoped `sourcesJar` task instead (see `:sample:api`'s + source only, not a generated stub. The default `java { withSourcesJar() }` convenience archives a + source set's full `allSource`, which on a module also using `stubUnfulfilledExpects()` includes + the generated stub directory - publishing that would merge the library's own stub `actual` in + alongside the real one the leaf module provides, breaking the build with a duplicate-`actual` + conflict. Configure a hand-scoped `sourcesJar` task instead (see `:sample:api`'s `build.gradle.kts`). -- **Resolving a published coordinate is deliberately lazy, and callers must order it explicitly.** - Unlike a `project(...)` reference (files already exist on disk, safe to read at configuration - time), a published coordinate might not exist yet when the build starts - most obviously when, - as in this sample, the same build publishes and consumes it. Gradle has no way to infer "publish - this first" for an arbitrary external coordinate the way it does for `project(...)` - dependencies, so `actualizes(coordinate, dependsOnTasks = listOf(...))` requires you to name the - publishing task(s) yourself when that ordering isn't already guaranteed some other way (e.g. the - library being published well before the consumer ever builds, which is the normal case outside - this self-contained sample). -- **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` are not a +- **Resolving a published coordinate is lazy, and callers must order it explicitly.** Unlike a + `project(...)` reference, whose files already exist on disk, a published coordinate might not + exist yet when the build starts - most obviously when, as in this sample, the same build + publishes and consumes it. Gradle has no way to infer "publish this first" for an arbitrary + external coordinate, so `actualizes(coordinate, dependsOnTasks = listOf(...))` requires naming + the publishing task(s) yourself when that ordering isn't already guaranteed some other way. +- **Relies on internal compiler flags.** `-Xmulti-platform` and `-Xcommon-sources` aren't a supported public API for third-party use; a future Kotlin release could change or remove this behavior without notice. -- **The `expect` stub scanner is real PSI parsing, but syntax-only - still a deliberately scoped - subset, not a full compiler frontend.** `ExpectStubGenerator.kt` parses each file with - `KtPsiFactory` (the same parser Kotlin tooling uses) rather than a line-based regex/brace-counting - scan, so multi-line declarations, comments, and string literals containing `{`/`}` are all - handled correctly - `expect class`/`expect object` members come from the real `KtClassBody`, not - a naive brace-depth count, and `suspend fun` is recognized correctly. What's still out of scope: - generics on the containing function/class, supertypes, secondary constructors, nested types, and - constructor-parameter auto-properties (`class Foo(val x: Int)`). It's also deliberately *not* - running semantic analysis/type resolution - only enough to find declarations and read their - syntax - since resolving types is exactly what would fail on the unfulfilled expects being - scanned for in the first place; see the next bullet for what that still means downstream. - Generated code itself goes through KotlinPoet (`FileSpec`/`FunSpec`/`PropertySpec`/`TypeSpec`), - not string concatenation. -- **Type text scanned into a stub isn't a resolved type, just a structurally-parsed name.** - `KtTypeReference.toTypeName` in `ExpectStubGenerator.kt` walks the real PSI type tree - (`KtUserType`/`KtFunctionType`/`KtNullableType`), not a second ad-hoc string parse - so function - types (including nested ones like `() -> () -> Screen`, extension-receiver lambdas like - `Int.(String) -> Boolean`, nullable lambdas, and generic type arguments) are all handled - correctly, and a bare type name is resolved against this file's own `import` directives (skipping - star imports, which aren't expandable without semantic resolution), common `kotlin`/ - `kotlin.collections` names, and finally assumed to be a sibling type in the file's own package. - That last fallback is still a guess: a star-imported type, or one genuinely meant to be in the - default package, resolves to the wrong package and the generated stub won't compile. Write - fully-qualified types in `expect` declarations meant to be stubbed if this matters. +- **The `expect` stub scanner is real PSI parsing, but syntax-only - a deliberately scoped subset, + not a full compiler frontend.** `ExpectStubGenerator.kt` parses each file with `KtPsiFactory` + (the same parser Kotlin tooling uses) rather than a regex/brace-counting scan, so multi-line + declarations and odd formatting are handled correctly - `expect class`/`expect object` members + come from the real `KtClassBody`, and `suspend fun` is recognized. Out of scope: generics on the + containing function/class, supertypes, secondary constructors, nested types, and + constructor-parameter auto-properties (`class Foo(val x: Int)`). It also runs no semantic + analysis or type resolution - only enough to find declarations and read their syntax, since + resolving types is exactly what would fail on the unfulfilled expects being scanned for. Generated + code goes through KotlinPoet (`FileSpec`/`FunSpec`/`PropertySpec`/`TypeSpec`), not string + concatenation. +- **Type text scanned into a stub isn't a resolved type, just a structurally parsed name.** + `KtTypeReference.toTypeName` walks the real PSI type tree (`KtUserType`/`KtFunctionType`/ + `KtNullableType`) rather than re-parsing text, so function types - including nested ones like + `() -> () -> Screen`, extension-receiver lambdas like `Int.(String) -> Boolean`, nullable + lambdas, and generic type arguments - are all handled correctly. A bare type name is resolved + against the file's own `import` directives (skipping star imports, which need semantic + resolution to expand), common `kotlin`/`kotlin.collections` names, and finally assumed to be a + sibling type in the same package. That last fallback is a guess: a star-imported type, or one + genuinely in the default package, resolves to the wrong package and the generated stub won't + compile. Write fully-qualified types if that matters. - **Classloader isolation between `plugin-build` and the consuming build.** `ActualizerGradlePlugin` - deliberately avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, - `KotlinCompile`, etc.) and uses reflection-by-name instead - `plugin-build` resolves its own - copy of `kotlin-gradle-plugin` to compile against, which ends up a different `Class` instance - than the one the consuming build's `plugins { kotlin(...) }` loads, so direct - `extensions.findByType(...)` / `tasks.withType(...)` silently find nothing across that - boundary. This is a real, somewhat unusual wrinkle of the composite-build layout, documented in - code comments in `ActualizerGradlePlugin.kt`. + avoids importing Kotlin Gradle Plugin types (`KotlinJvmProjectExtension`, `KotlinCompile`, etc.) + and uses reflection by name instead. `plugin-build` resolves its own copy of `kotlin-gradle-plugin` + to compile against, which loads as a different `Class` instance than the one the consuming + build's `plugins { kotlin(...) }` loads, so `extensions.findByType(...)`/`tasks.withType(...)` + find nothing across that boundary directly. Documented in code comments in + `ActualizerGradlePlugin.kt`. - **A leaf module must not depend on the compiled jar of a project it also `actualizes(...)`.** - `:actual-jvm` merges `:api`'s and `:feature-common`'s *source*; if it also depended on their - compiled jars, the JVM would see two different compiled definitions of the same - package/class/method (one from the merged source, one from the binary dependency), and - whichever ends up first on the classpath silently wins - the same class of bug documented below - for file-facade names, just at the module-dependency level instead. Keep leaf modules' - dependencies limited to things they *don't* also merge as source. -- One-actual-per-expect only; no support for choosing between multiple candidate actual-providing - modules (e.g. per build flavor) - `actualizes(...)` merges in all of them unconditionally, and - the *last* declaration compiled for a given fully-qualified name wins/conflicts per ordinary - Kotlin rules. + `:actual-jvm` merges `:api`'s and `:feature-common`'s source; if it also depended on their + compiled jars, the JVM would see two different definitions of the same package/class/method, and + whichever ends up first on the classpath would silently win. Keep leaf modules' dependencies + limited to things they don't also merge as source. +- One actual per expect only - no support for choosing between multiple candidate actual-providing + modules (e.g. per build flavor). `actualizes(...)` merges in all of them unconditionally, and the + last declaration compiled for a given fully-qualified name wins or conflicts per ordinary Kotlin + rules. - **Merged source files must have collision-free file-facade names.** Kotlin compiles each top-level-function file to a `Kt` JVM class. If two files merged into the same - compilation share both a package and a filename, they'd produce two same-named classes that - silently shadow each other on the runtime classpath (whichever loads first wins, with a - `NoSuchMethodError` for whatever the other one had) - this bit us during development (`:api` - briefly had `Greeting.kt` in two different source sets with the same package) and is worth - remembering when adding more cross-module modules: keep filenames distinct within a shared - package across anything that might get merged together. + compilation share both a package and a filename, they produce two same-named classes that + silently shadow each other on the runtime classpath, with a `NoSuchMethodError` for whatever + loaded second. Keep filenames distinct within a shared package across anything that might get + merged together. diff --git a/plugin-build/compiler-plugin/build.gradle.kts b/plugin-build/compiler-plugin/build.gradle.kts index 8245b31..16fb77d 100644 --- a/plugin-build/compiler-plugin/build.gradle.kts +++ b/plugin-build/compiler-plugin/build.gradle.kts @@ -1,8 +1,11 @@ +import java.util.Properties + plugins { kotlin("jvm") version "2.0.21" + `maven-publish` } -group = "net.kernelpanicsoft" +group = "net.kernelpanicsoft.actualizer" version = "0.1.0" repositories { @@ -16,3 +19,32 @@ dependencies { kotlin { jvmToolchain(21) } + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } + repositories { + maven { + name = "Reposilite" + val releasesUrl = "https://maven.kernelpanicsoft.net/releases" + val snapshotsUrl = "https://maven.kernelpanicsoft.net/snapshots" + + url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl) + + val localProperties = rootDir.resolve("local.properties") + .takeIf { it.exists() } + ?.inputStream() + ?.use { Properties().apply { load(it) } } + + credentials { + username = localProperties?.getProperty("reposilite.username") + ?: System.getenv("REPOSILITE_USERNAME") + password = localProperties?.getProperty("reposilite.password") + ?: System.getenv("REPOSILITE_PASSWORD") + } + } + } +} diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index 761431a..218fbd7 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -8,21 +8,43 @@ import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CommonConfigurationKeys import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter +/** + * Registers Actualizer's two compiler extensions: [ActualizerFirExtensionRegistrar], which + * quiets IDE-only expect/actual false positives, and [ActualizerIrExtension], which reports on + * what got cross-module linked in this compilation. + */ @OptIn(ExperimentalCompilerApi::class) class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { - FirExtensionRegistrarAdapter.registerExtension(ActualizerFirExtensionRegistrar()) + val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) + + // ActualizerFirExtensionRegistrar builds on internal FIR APIs with no compatibility + // guarantee across Kotlin versions, so a version this plugin wasn't built against could + // fail here at class-load time. It's a cosmetic IDE-only feature (see the root README), + // so a failure here should never take down a real compile. + try { + FirExtensionRegistrarAdapter.registerExtension(ActualizerFirExtensionRegistrar()) + } catch (t: Throwable) { + messageCollector.report( + CompilerMessageSeverity.WARNING, + "Actualizer: could not register the FIR extension that suppresses IDE-only " + + "expect/actual false positives (${t::class.simpleName}: ${t.message}). " + + "This doesn't affect the actual build.", + null as CompilerMessageSourceLocation?, + ) + } val moduleMap = parseModuleMap(configuration.get(ActualizerCommandLineProcessor.KEY_MODULE_MAP).orEmpty()) val selfModule = configuration.get(ActualizerCommandLineProcessor.KEY_SELF_MODULE) ?: "" val reportOutput = configuration.get(ActualizerCommandLineProcessor.KEY_REPORT_OUTPUT) - val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) IrGenerationExtension.registerExtension( ActualizerIrExtension( @@ -34,6 +56,7 @@ class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { ) } + /** Parses the `root::moduleName||root::moduleName...` option value from [ActualizerCommandLineProcessor]. */ private fun parseModuleMap(raw: String): List { if (raw.isBlank()) return emptyList() return raw.split("||").mapNotNull { entry -> diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt index 1d37125..ab550d4 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt @@ -27,26 +27,7 @@ import org.jetbrains.kotlin.fir.types.constructClassLikeType import org.jetbrains.kotlin.name.StandardClassIds import org.jetbrains.kotlin.types.ConstantValueKind -// EXPERIMENTAL - see chat: injects a synthetic @Suppress FIR annotation onto every top-level -// declaration in any file that contains an expect or actual declaration, so the IDE's live -// analysis stops reporting EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE / ACTUAL_WITHOUT_EXPECT / -// OVERLOAD_RESOLUTION_AMBIGUITY as false positives, without requiring a literal @Suppress in the -// user's own source. Confirmed via bytecode inspection of -// AbstractDiagnosticCollector.getDiagnosticsSuppressedForContainer that suppression is read -// straight off FirAnnotationContainer.annotations by matching the annotation's resolved ClassId -// against kotlin.Suppress and reading its "names" vararg argument - there is no registered -// extension point for this, so this constructs a FirAnnotation by hand and attaches it via -// replaceAnnotations() from a FirStatusTransformerExtension (the only extension point that both -// runs early enough, during FIR status resolution, and hands back a mutable reference to the -// actual declaration). Empirically verified to work: injecting "DEPRECATION" into this list -// suppressed a real @Deprecated-call warning with zero @Suppress present in source. -// -// Scoped per-*file* (not per-declaration): a call site like `greet()` calling its own -// `expect fun greetingSuffix()` triggers OVERLOAD_RESOLUTION_AMBIGUITY on the *call*, which can -// live in an ordinary function that is itself neither expect nor actual - so every top-level -// declaration sharing a file with an expect/actual needs the annotation, not just the expect/ -// actual declarations themselves. - +/** The diagnostics this file suppresses - see the root README's "IDE false positives" section. */ private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf( "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE", "ACTUAL_WITHOUT_EXPECT", @@ -54,6 +35,29 @@ private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf( "ERROR_SUPPRESSION", ) +// Everything below builds on internal, undocumented FIR APIs with no compatibility guarantee +// across Kotlin versions - and a real consumer of this plugin can easily end up compiling with a +// different Kotlin compiler than this plugin was built against (a different Minecraft version's +// toolchain, or the IDE's own bundled K2 compiler analyzing a project it didn't compile itself). +// A binary-incompatible internal API surfaces as NoSuchMethodError/NoSuchFieldError/LinkageError, +// none of which are Exception subtypes, so this has to catch Throwable specifically. Since this +// whole mechanism is purely cosmetic (see the root README), any failure latches +// suppressionApiAvailable off for the rest of this compiler process rather than letting it break +// a real build over an IDE-only nicety, or retrying the same failure on every declaration. +@Volatile +private var suppressionApiAvailable = true + +private fun buildSuppressAnnotationOrNull(): FirAnnotation? { + if (!suppressionApiAvailable) return null + return try { + buildSuppressAnnotation() + } catch (t: Throwable) { + suppressionApiAvailable = false + null + } +} + +/** Builds a `@Suppress` [FirAnnotation] for [SUPPRESSED_DIAGNOSTIC_NAMES] from FIR builders directly, not source text. */ private fun buildSuppressAnnotation(): FirAnnotation { val suppressConeType = StandardClassIds.Annotations.Suppress.constructClassLikeType( typeArguments = emptyArray(), @@ -100,12 +104,17 @@ private fun buildSuppressAnnotation(): FirAnnotation { private fun isExpectOrActual(status: FirDeclarationStatus): Boolean = status.isExpect || status.isActual +/** Registers [ActualizerSuppressionStatusTransformer] with the FIR extension pipeline. */ class ActualizerFirExtensionRegistrar : FirExtensionRegistrar() { override fun ExtensionRegistrarContext.configurePlugin() { +::ActualizerSuppressionStatusTransformer } } +/** + * Attaches a synthetic `@Suppress` to every top-level declaration in a file containing an + * `expect` or `actual`, quieting the IDE-only false positives described at the top of this file. + */ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirStatusTransformerExtension(session) { private val fileNeedsSuppressionCache = mutableMapOf() @@ -124,13 +133,24 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS } override fun needTransformStatus(declaration: FirDeclaration): Boolean { - val member = declaration as? FirMemberDeclaration ?: return false - val file = containingFile(member) ?: return false - return fileNeedsSuppression(file) + if (!suppressionApiAvailable) return false + return try { + val member = declaration as? FirMemberDeclaration ?: return false + val file = containingFile(member) ?: return false + fileNeedsSuppression(file) + } catch (t: Throwable) { + suppressionApiAvailable = false + false + } } private fun inject(declaration: FirDeclaration) { - declaration.replaceAnnotations(declaration.annotations + buildSuppressAnnotation()) + val annotation = buildSuppressAnnotationOrNull() ?: return + try { + declaration.replaceAnnotations(declaration.annotations + annotation) + } catch (t: Throwable) { + suppressionApiAvailable = false + } } override fun transformStatus( diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt index bdc3890..e92c51a 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt @@ -20,31 +20,26 @@ private data class LinkEntry( ) /** - * Runs inside a compilation whose source set has been merged (by the Gradle plugin) from a - * foreign, expect-declaring Gradle module's `main` source set and this module's own `actual` - * declarations, compiled together via `-Xmulti-platform` + `-Xcommon-sources`. + * Reports which `actual` declarations link back to an `expect` declared in a foreign Gradle + * module, once the frontend has already resolved expect/actual for a compilation the Gradle + * plugin merged together via `-Xmulti-platform` + `-Xcommon-sources`. * - * By the time this extension runs, the Kotlin frontend has *already* resolved expect/actual for - * this compilation (or the build would already have failed) - this extension does not perform - * the linking itself. Its job is to observe what got linked and attach module provenance the - * frontend has no notion of (it only knows "files", not "Gradle modules"), and emit a - * human/tool-readable report - the piece the raw compiler + Gradle mechanism can't provide on - * its own. + * The linking itself already happened by the time this runs; this only observes the result and + * attaches module provenance the frontend has no notion of, since it only knows about files, not + * Gradle modules. * - * Note: once expect/actual are resolved, the `expect` declaration itself is elided from this - * compilation's IR - only the linked `actual` remains as a real `IrFunction`/`IrClass`. So rather - * than trying to pair up an "expect IR declaration" with an "actual IR declaration" by name (the - * expect side isn't there to find), this walks the *files* that were merged in from each foreign - * module and records their package names, then reports every locally-declared top-level - * declaration whose package matches one of those foreign packages as the `actual` linking it. + * By the time IR generation runs, an `expect` declaration has been elided entirely - only the + * linked `actual` remains. So rather than pairing IR declarations up by name, this groups the + * files merged in from each foreign module by package, then treats every locally-declared + * top-level declaration sharing one of those packages as the `actual` linking it. * - * No annotation-based opt-in (there used to be one, `@CrossModuleExpect`) - a plain - * `kotlin("jvm")` module never sees `expect`/`actual` at all unless Actualizer put them there via - * `-Xmulti-platform`, so every `expect`/`actual` pair reaching this extension in the first place - * is already, by construction, a genuine Actualizer-managed cross-module pair. There's no - * "accidental" expect/actual usage in one of these modules to disambiguate from - Kotlin - * Multiplatform projects (where that ambiguity *would* exist) simply aren't what this plugin - * targets. + * No annotation marks which declarations to look at: a plain `kotlin("jvm")` module never sees + * `expect`/`actual` at all unless Actualizer put them there, so every pair reaching this + * extension is already Actualizer's doing. + * + * @param moduleMap Foreign source roots mapped to the Gradle module that owns them. + * @param selfModule Path of the module currently being compiled. + * @param reportOutputPath Where to write the JSON link report, if anywhere. */ class ActualizerIrExtension( private val moduleMap: List, @@ -94,6 +89,7 @@ class ActualizerIrExtension( reportOutputPath?.let { path -> writeReport(path, links) } } + /** Writes [links] to [path] as JSON, in the shape `{ consumingModule, links: [{ actual, owningModules }] }`. */ private fun writeReport(path: String, links: List) { val file = File(path) file.parentFile?.mkdirs() diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts index 6571e15..0f362ab 100644 --- a/plugin-build/gradle-plugin/build.gradle.kts +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -1,9 +1,12 @@ +import java.util.Properties + plugins { kotlin("jvm") version "2.0.21" `java-gradle-plugin` + `maven-publish` } -group = "net.kernelpanicsoft" +group = "net.kernelpanicsoft.actualizer" version = "0.1.0" repositories { @@ -35,3 +38,27 @@ gradlePlugin { } } } + +publishing { + repositories { + maven { + name = "Reposilite" + val releasesUrl = "https://maven.kernelpanicsoft.net/releases" + val snapshotsUrl = "https://maven.kernelpanicsoft.net/snapshots" + + url = uri(if (version.toString().endsWith("SNAPSHOT")) snapshotsUrl else releasesUrl) + + val localProperties = rootDir.resolve("local.properties") + .takeIf { it.exists() } + ?.inputStream() + ?.use { Properties().apply { load(it) } } + + credentials { + username = localProperties?.getProperty("reposilite.username") + ?: System.getenv("REPOSILITE_USERNAME") + password = localProperties?.getProperty("reposilite.password") + ?: System.getenv("REPOSILITE_PASSWORD") + } + } + } +} diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt index 5831477..8d7bc6e 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerExtension.kt @@ -6,61 +6,64 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Provider import javax.inject.Inject -/** Default name of the source set Actualizer merges in from a foreign, expect-declaring project. */ +/** Name of the source set merged in from a foreign project when no other name is given. */ const val DEFAULT_CROSS_MODULE_SOURCE_SET = "main" internal data class ActualizedProjectSource(val project: Project, val sourceSetName: String) internal data class ActualizedPublishedSource(val coordinate: String, val dependsOnTasks: List) /** - * `actualizer { }` DSL with three independent capabilities: + * The `actualizer { }` extension registered on every project the Actualizer plugin is applied to. * - * - `actualizes(project(":api"))`, applied to a "leaf" JVM module that provides real `actual` - * declarations for `expect`s declared in one or more unrelated, independently built Gradle - * modules. Merges each foreign project's named source set (`main` by default) as source into - * this compilation, alongside the real `actual`. - * - `actualizes("group:artifact:version", dependsOnTasks = listOf(...))`, the same idea but for a - * *published* library instead of a sibling project: resolves that coordinate's `sources` - * classifier artifact (a plain, Maven-style `-sources.jar`), unpacks it, and merges the - * extracted `.kt` files in exactly the same way. This is what lets a consumer actualize - * `expect`s declared in a library it only has as a binary + sources dependency, not as a - * `project(...)` reference. `dependsOnTasks` matters if the coordinate might not be published - * yet when this build runs (e.g. this same build publishes it, like the sample does) - Gradle - * has no way to infer that ordering the way it does for `project(...)` dependencies, so name - * whatever publishing task(s) need to run first explicitly. See `registerPublishedSourcesSync` - * in `ActualizerGradlePlugin.kt` for the resolution mechanics and its requirements on how the - * library publishes its sources jar. `actualizes(libs.foo.bar, ...)` - a version catalog - * accessor, `Provider` - works too, and is the preferred way to - * call this: the coordinate then stays in one place (`libs.versions.toml`) instead of being - * duplicated as a hand-typed string here. - * - `stubUnfulfilledExpects()`, applied to the *expect-declaring* module itself, so its own - * `expect`s get an auto-generated, throwing `actual` stub and its `main` source set compiles - * into a completely normal, standalone jar - see `ExpectStubGenerator.kt`. Calling code that - * ends up bound to the stub (anything not itself merged into a real `actualizes(...)` leaf) - * throws `IllegalStateException` at runtime rather than failing to compile. + * A project uses this in one of two roles. An `expect`-declaring project calls + * [stubUnfulfilledExpects] so it still produces an ordinary standalone jar. A project providing + * the real `actual`s calls [actualizes] one or more times to pull in the foreign source and link + * against it. */ open class ActualizerExtension @Inject constructor(objects: ObjectFactory) { internal val sources: MutableList = mutableListOf() internal val publishedSources: MutableList = mutableListOf() internal var stubUnfulfilledExpects: Boolean = false + /** + * Merges [sourceSet] of [project] into this project's compilation as source, alongside the + * real `actual` declarations written here. + * + * Can be called more than once to link against several foreign projects at once. + */ @JvmOverloads fun actualizes(project: Project, sourceSet: String = DEFAULT_CROSS_MODULE_SOURCE_SET) { sources += ActualizedProjectSource(project, sourceSet) } + /** + * Same as [actualizes], but for a published library rather than a sibling project: resolves + * [publishedCoordinate]'s `sources` classifier artifact and merges the unpacked source in the + * same way a `project(...)` reference would be. + * + * @param dependsOnTasks Tasks to run before resolving [publishedCoordinate], needed only when + * the coordinate isn't published yet by the time this build runs - Gradle can't infer that + * ordering the way it does for `project(...)` dependencies. + * @see registerPublishedSourcesSync + */ @JvmOverloads fun actualizes(publishedCoordinate: String, dependsOnTasks: List = emptyList()) { publishedSources += ActualizedPublishedSource(publishedCoordinate, dependsOnTasks) } - /** Version-catalog form of [actualizes] - e.g. `actualizes(libs.foo.bar)` instead of a hand-typed `"group:artifact:version"` string. */ + /** Version-catalog overload of [actualizes], e.g. `actualizes(libs.foo.bar)`. */ @JvmOverloads fun actualizes(publishedCoordinate: Provider, dependsOnTasks: List = emptyList()) { val dependency = publishedCoordinate.get() actualizes("${dependency.group}:${dependency.name}:${dependency.version}", dependsOnTasks) } + /** + * Generates a throwing `actual` for every unfulfilled `expect` in this project's `main` + * source set, so it compiles into an ordinary jar without needing a real `actual` anywhere + * yet. Calling one of those functions throws [IllegalStateException] until this jar is merged + * into a project that calls [actualizes] against it. + */ fun stubUnfulfilledExpects() { stubUnfulfilledExpects = true } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index a4a16c7..beba548 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -9,46 +9,30 @@ import org.gradle.api.provider.ListProperty import java.io.File private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" -private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft:compiler-plugin:0.1.0" +private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" /** - * Three independent things a plain `kotlin("jvm")` project can opt into via `actualizer { }`: + * Registers the `actualizer { }` extension and wires its two modes of operation into the + * project's Kotlin compilation. * - * - `stubUnfulfilledExpects()`: on the expect-declaring module itself, scans its `main` source - * set for `expect fun` declarations and generates a matching, throwing `actual` stub for each - * (see `ExpectStubGenerator.kt`), then compiles `main` with `-Xmulti-platform` + - * `-Xcommon-sources` against that generated stub. This is what lets a module with an - * unfulfilled `expect` still produce a completely normal, standalone jar - real bytecode, one - * ordinary `main` source set, no crossModuleApi-style split needed. - * - `actualizes(project(":api"))`: on a "leaf" module providing the real `actual`, merges one or - * more foreign projects' source sets in as source (their *hand-written* source only - see the - * build-directory exclusion in `namedSourceSetDirs` below, which is what keeps a foreign - * project's own generated stub out of this merge, avoiding a duplicate-`actual` conflict). The - * foreign project's files already exist on disk, so this is resolved eagerly at configuration - * time - simple, and correct, since nothing needs to *build* first. - * - `actualizes("group:artifact:version")`: the published-library equivalent - resolves that - * coordinate's `sources` classifier artifact, unpacks it, and merges it in exactly the same - * way (see `registerPublishedSourcesSync`). Unlike a `project(...)` reference, the artifact - * might not exist yet at configuration time (e.g. this same build also publishes it, as the - * sample does) - Gradle can't infer that ordering the way it does for `project(...)` - * dependencies, so this path is deliberately *lazy*: a `Sync` task resolves and unpacks the - * sources jar only when it actually runs, `compileKotlin` depends on that task, and the - * `-Xcommon-sources` file list is computed inside the same lazy `Provider` already used for - * the rest of `compileKotlin`'s compiler args, so it's only read after the `Sync` task (and - * whatever else the consumer wired the `Sync` task to depend on) has finished. + * [ActualizerExtension.stubUnfulfilledExpects] scans the `main` source set for `expect` + * declarations, generates a throwing `actual` stub for each ([ExpectStubGenerator]), and compiles + * `main` against that generated stub with `-Xmulti-platform` + `-Xcommon-sources`. That's what + * lets a module with an unfulfilled `expect` still produce an ordinary standalone jar. * - * Either way, once anything is merged in, the Actualizer IR compiler plugin gets registered on - * that compilation so it can report on what got linked. + * [ActualizerExtension.actualizes] merges a foreign project's (or published library's) + * hand-written source into this project's own compilation alongside a real `actual`, and + * registers the Actualizer IR compiler plugin on that compilation so it can report on what got + * linked. A published coordinate is resolved lazily, since it may not be published yet when this + * build starts; a `project(...)` reference is resolved eagerly, since its files already exist on + * disk. * - * This deliberately avoids importing Kotlin Gradle Plugin (KGP) types like `KotlinJvmProjectExtension` - * or `KotlinCompile` directly: `plugin-build` resolves its own copy of `kotlin-gradle-plugin` - * (for compiling *this* plugin) which ends up in a different classloader than the copy the - * consuming build resolves via `plugins { kotlin("jvm") }` - so - * `extensions.findByType(SomeKgpType::class.java)` and `tasks.withType(SomeKgpTaskType::class.java)` - * silently find nothing across that boundary. Reflection-by-name sidesteps it; only genuine - * Gradle-core types (`NamedDomainObjectContainer`, `SourceDirectorySet`, `ListProperty`, - * `ExternalModuleDependency`, `Sync`, which are always loaded by Gradle's own shared classloader) - * are referenced statically. + * This intentionally avoids referencing Kotlin Gradle Plugin types like `KotlinJvmProjectExtension` + * directly. `plugin-build` resolves its own copy of `kotlin-gradle-plugin` to compile this plugin, + * which loads into a different classloader than the copy a consuming build resolves via + * `plugins { kotlin("jvm") }`, so `extensions.findByType(...)`/`tasks.withType(...)` against those + * types find nothing across that boundary. Reflection by member name sidesteps it; only + * Gradle-core types loaded by Gradle's own shared classloader are referenced statically. */ class ActualizerGradlePlugin : Plugin { @@ -80,12 +64,8 @@ class ActualizerGradlePlugin : Plugin { val outputDir = project.layout.buildDirectory.dir("generated/actualizer-stubs").get().asFile - // A real Gradle task, not eager work done here in apply()/afterEvaluate: writing the stub - // files during configuration would have them wiped out by `clean`'s *execution* when - // `clean` and `build` run in the same invocation (configuration runs once up front for the - // whole task graph, so an eager write here happens before `clean` ever deletes anything). - // Making generation a task with real inputs/outputs fixes the ordering and gives Gradle - // proper up-to-date checking for free. + // A real task rather than eager work in apply()/afterEvaluate, so it survives `clean` and + // `build` running in the same invocation and gets normal up-to-date checking. val generateTask = project.tasks.register("generateActualizerStubs") { task -> task.inputs.files(mainFiles).withPropertyName("actualizerExpectScanInputs") task.outputs.dir(outputDir) @@ -111,10 +91,8 @@ class ActualizerGradlePlugin : Plugin { addSourceDir(project, "main", listOf(outputDir)) - // All of this module's own hand-written source counts as "common" here, not just the - // files that happen to contain an `expect` - mirroring how a real commonMain source set - // is treated wholesale, not file-by-file. Known without waiting on the scan task, so this - // stays eager. + // The whole source set counts as "common" here, not just the files with an expect in + // them - mirroring how a real commonMain source set is treated. val commonSourcesValue = mainFiles.joinToString(",") { it.absolutePath } val compileKotlinTask = project.tasks.named("compileKotlin") compileKotlinTask.configure { task -> @@ -129,9 +107,8 @@ class ActualizerGradlePlugin : Plugin { // Project() references: files already exist on disk, safe to walk eagerly right now. val eagerSourceFiles = mutableListOf() val foreignSourceDirs = mutableListOf() - // Published coordinates: the sources jar might not exist yet (e.g. published later in - // this same build) - these directories are only walked lazily, inside the freeCompilerArgs - // provider below, after their Sync task has actually run. + // Published coordinates: the sources jar might not exist yet, so these are only walked + // lazily below, after their Sync task has run. val lazySourceDirs = mutableListOf() val syncTasks = mutableListOf() @@ -191,16 +168,15 @@ class ActualizerGradlePlugin : Plugin { freeCompilerArgs.addAll( project.provider { - // Walking lazySourceDirs here (rather than up above) is what makes this safe - // to run after the Sync tasks above have actually unpacked something into them. + // Walked lazily so this only runs after the Sync tasks have unpacked + // something into lazySourceDirs. val allSourceFiles = eagerSourceFiles + lazySourceDirs.flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" } } val commonSourcesValue = allSourceFiles.joinToString(",") { it.absolutePath } - // Resolving the substituted project coordinate pulls in its own runtime - // deps too (kotlin-stdlib etc) - only the plugin's own jar should be passed - // as -Xplugin=, the rest is already implicitly on the compiler's classpath. + // The resolved configuration also pulls in the plugin's own runtime deps + // (kotlin-stdlib etc) - only its own jar goes on -Xplugin=. val pluginJar = compilerPluginClasspath.files .first { it.name.startsWith("compiler-plugin") } .absolutePath @@ -221,28 +197,18 @@ class ActualizerGradlePlugin : Plugin { } /** - * Registers a `Sync` task that resolves [coordinate]'s `sources` classifier artifact (a - * plain, Maven-style `--sources.jar` - not a rich Gradle Module Metadata - * "sources" variant, for the broadest compatibility with libraries that just publish a - * classic classified jar) and unpacks its `.kt` files into a build-local directory, mirroring - * what `namedSourceSetDirs` does for a `project(...)` reference. Returns the (not yet - * populated) output directory and the `Sync` task that populates it - callers must make - * whatever actually reads that directory depend on the task, not just use the directory path. + * Resolves [coordinate]'s `sources` classifier artifact and unpacks its `.kt` files into a + * build-local directory, mirroring [namedSourceSetDirs] for a `project(...)` reference. * - * The dependency is resolved *inside* the task's `from(...)`, which Gradle only evaluates - * when the task runs - not when this method is called - so this is safe to call even before - * the coordinate exists anywhere (e.g. before this same build has published it yet). + * Returns the output directory and the task that populates it - the directory isn't populated + * until that task runs, so callers must depend on it rather than reading the directory + * directly. * - * For this to produce something mergeable, the library's sources jar must contain only its - * *hand-written* source - critically, not a `stubUnfulfilledExpects()`-generated stub, or the - * merge would end up with two `actual`s for the same `expect` (the library's own stub, plus - * the real one this leaf module provides) and fail to compile. A library using - * `stubUnfulfilledExpects()` itself must configure its own `sourcesJar` task to archive only - * its real source directory (e.g. `from("src/main/kotlin")`), not the source set's full, - * post-wiring `allSource` (which would include the generated stub dir Actualizer added to - * it) - the default Kotlin/Java `withSourcesJar()` convenience does the latter, so it isn't - * safe to use as-is on a module that also calls `stubUnfulfilledExpects()`. See the sample's - * `:sample:api` for a `sourcesJar` task configured this way. + * The library's sources jar must contain only its hand-written source, not a + * `stubUnfulfilledExpects()`-generated stub, or the merge ends up with two `actual`s for the + * same `expect`. A library using `stubUnfulfilledExpects()` itself needs its own `sourcesJar` + * task scoped to its real source directory rather than the default `withSourcesJar()`, which + * would also archive the generated stub. See `:sample:api`'s build script for an example. */ private fun registerPublishedSourcesSync( project: Project, @@ -257,13 +223,9 @@ class ActualizerGradlePlugin : Plugin { val safeName = coordinate.replace(Regex("[^A-Za-z0-9_.-]"), "_") val outputDir = project.layout.buildDirectory.dir("generated/actualizer-published-sources/$safeName").get().asFile - // Deliberately NOT a Sync task with from(configuration) directly: Gradle inspects a - // CopySpec's `from(...)` eagerly, during task-graph construction, to infer build - // dependencies - which forces resolving `configuration` (a detached, external-coordinate - // configuration Gradle has no automatic way to order after a publish task) long before - // any task has actually run. Doing the resolve-and-unpack inside a plain `doLast` instead - // means `configuration.singleFile` is only touched once this task actually executes - - // by which point `dependsOnTasks` (below) has guaranteed whatever publishes it has run. + // Not a Sync task with from(configuration) directly - that would resolve the detached + // configuration eagerly during task-graph construction, before dependsOnTasks below has + // had a chance to publish it. Resolving inside doLast defers it until this task runs. val unpackTask = project.tasks.register("actualizerUnpack${safeName}Sources") { task -> task.dependsOn(dependsOnTasks) task.outputs.dir(outputDir) @@ -282,11 +244,9 @@ class ActualizerGradlePlugin : Plugin { } /** - * The hand-written source directories of [sourceSetName] on [foreignProject] - explicitly - * excluding anything under that project's own build directory, so a foreign project's - * *generated* stub (from its own `stubUnfulfilledExpects()`) never gets pulled into a merge: - * merging both the real `expect` and the generated stub `actual` into the same compilation - * would conflict with the real `actual` this leaf module provides. + * The hand-written source directories of [sourceSetName] on [foreignProject], excluding + * anything under its build directory - so a foreign project's own generated stub never gets + * pulled into a merge alongside the real `actual` this leaf module provides. */ private fun namedSourceSetDirs(foreignProject: Project, sourceSetName: String): List { val sourceSets = kotlinSourceSets(foreignProject) diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt index 7aa3c10..1f92223 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -31,21 +31,16 @@ import org.jetbrains.kotlin.psi.KtTypeReference import org.jetbrains.kotlin.psi.KtUserType import java.io.File -// Finding `expect` declarations is done via real Kotlin PSI parsing (KtPsiFactory.createFile), -// not a line-based regex/brace-counting scan - so multi-line declarations, comments, string -// literals containing `{`/`}`, and odd formatting are all handled correctly, the same way any -// real Kotlin tool would see them. This is *syntax-only* parsing (no semantic analysis/type -// resolution - deliberately, since that's exactly what would fail on the unfulfilled expects -// being scanned for here). Types are converted to KotlinPoet's `TypeName` by walking the PSI type -// tree directly (`KtTypeReference`/`KtUserType`/`KtFunctionType`/`KtNullableType`) rather than by -// re-parsing `.text` with ad-hoc string splitting - see `KtTypeReference.toTypeName` below for why -// that distinction matters (it's what makes function types like `() -> () -> Screen` work at -// all, and what "resolving" a type name actually means here). Remaining scope limits versus a -// full compiler frontend: no generics on the containing function/class itself, no supertypes, no -// secondary constructors or nested types, no constructor-parameter auto-properties -// (`class Foo(val x: Int)`). Once something IS matched, KotlinPoet handles turning it into -// correct Kotlin source (formatting, imports, escaping) instead of hand-rolled string -// concatenation. +// Scans and generates stub actuals for unfulfilled expect declarations, backing +// ActualizerExtension.stubUnfulfilledExpects(). Finding expect declarations uses real PSI +// parsing (KtPsiFactory) rather than a regex/brace-counting scan, so multi-line declarations and +// odd formatting are handled correctly. Parsing is syntax-only - no semantic resolution, since +// that's exactly what would fail on the unfulfilled expects being scanned for. Types are read by +// walking the PSI type tree directly (see KtTypeReference.toTypeName below) rather than +// re-parsing text, which is what makes nested function types resolve correctly. Out of scope: +// generics on the containing function/class, supertypes, secondary constructors, nested types, +// and constructor-parameter auto-properties. Generated code goes through KotlinPoet rather than +// string concatenation. internal data class ParamText(val name: String, val type: TypeName) @@ -80,12 +75,13 @@ internal data class ScannedExpectFile( val classes: List, ) +/** Parses [files] for top-level `expect` declarations, returning one entry per file that has any. */ internal fun scanForExpectFunctions(files: Iterable): List { val ktFiles = files.filter { it.isFile && it.extension == "kt" } if (ktFiles.isEmpty()) return emptyList() - // One throwaway Kotlin frontend "environment" per call, just to get a KtPsiFactory - disposed - // in `finally` so it doesn't leak across builds in a long-lived Gradle daemon. + // A throwaway frontend environment just to get a KtPsiFactory, disposed in `finally` so it + // doesn't leak across builds in a long-lived Gradle daemon. val disposable = Disposer.newDisposable("actualizer-expect-scan") try { val environment = KotlinCoreEnvironment.createForProduction( @@ -119,10 +115,10 @@ private fun scanKtFile(sourceFile: File, ktFile: KtFile): ScannedExpectFile? { private fun KtDeclaration.isExpect(): Boolean = hasModifier(KtTokens.EXPECT_KEYWORD) -/** Simple name -> fully-qualified name and this file's own package, needed to resolve a bare type name to a real [ClassName] - see [classNameFor]. */ +/** Simple name to fully-qualified name, plus the file's own package - see [classNameFor]. */ private class TypeContext(val imports: Map, val packageName: String) -/** Simple name -> fully-qualified name, from this file's own `import` directives (star imports aren't expandable without semantic resolution, so they're skipped - see `classNameFor`). */ +/** Simple name to fully-qualified name from [ktFile]'s `import` directives (star imports are skipped, since expanding them needs semantic resolution). */ private fun importedSimpleNames(ktFile: KtFile): Map = ktFile.importDirectives.mapNotNull { directive -> if (directive.isAllUnder) return@mapNotNull null @@ -157,7 +153,7 @@ private fun toPropertyMember(property: KtProperty, context: TypeContext): Expect } private fun toClassInfo(cls: KtClassOrObject, context: TypeContext): ExpectClassInfo { - // KtObjectDeclaration (expect object) has no primary constructor at all - only KtClass does. + // KtObjectDeclaration (expect object) has no primary constructor - only KtClass does. val constructorParams = (cls as? KtClass)?.primaryConstructor?.valueParameters.orEmpty().map { param -> ParamText(param.name ?: "arg", param.typeReference?.toTypeName(context) ?: ANY) } @@ -165,7 +161,7 @@ private fun toClassInfo(cls: KtClassOrObject, context: TypeContext): ExpectClass when (member) { is KtNamedFunction -> toFunctionMember(member, context) is KtProperty -> toPropertyMember(member, context) - else -> null // nested types, secondary constructors, etc. - out of scope, see file header. + else -> null // nested types, secondary constructors, etc. - out of scope. } } return ExpectClassInfo( @@ -176,11 +172,9 @@ private fun toClassInfo(cls: KtClassOrObject, context: TypeContext): ExpectClass ) } -// ClassName.bestGuess("String") produces a ClassName with an EMPTY package (it looks like a -// default-package top-level class), and KotlinPoet then emits a nonsensical `import String` for -// it - there's nothing to import for a name with no package. Recognizing common stdlib names and -// pointing them at their real `kotlin`/`kotlin.collections` package fixes that (and lets KotlinPoet -// correctly omit the import, since those packages are implicitly visible in every Kotlin file). +// ClassName.bestGuess("String") treats an unqualified name as a default-package class and +// KotlinPoet then emits a bogus `import String`. Mapping common stdlib names to their real +// kotlin/kotlin.collections package avoids that. private val KOTLIN_BUILTIN_TYPES = listOf( "Any", "Unit", "Nothing", "String", "CharSequence", "Boolean", "Byte", "Short", "Int", "Long", "Float", "Double", "Char", "Number", "Array", @@ -195,22 +189,17 @@ private val ANY: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Any") private val UNIT: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Unit") /** - * Converts a `KtTypeReference` to a KotlinPoet `TypeName` by walking the PSI type tree - the - * *structure* Kotlin's own parser already built (`KtUserType`/`KtFunctionType`/`KtNullableType`), - * not a second, ad-hoc parse of `.text`. That distinction is what makes function types work at - * all: `() -> () -> Screen` is a `KtFunctionType` whose `returnTypeReference` is *itself* another - * `KtFunctionType` - recursing through real PSI nodes handles that (and receivers, suspend - * modifiers, and nullable-wrapped lambdas like `(() -> Unit)?`) for free, where splitting on `->`/ - * `,`/`<`/`>` as text does not, and silently produces garbage on anything but the simplest cases. + * Converts a type reference to a KotlinPoet [TypeName] by walking the PSI type tree Kotlin's own + * parser already built, rather than re-parsing its text. That's what makes nested function types + * like `() -> () -> Screen` resolve correctly - `KtFunctionType.returnTypeReference` is itself + * another `KtFunctionType`, so recursing through real PSI nodes handles that, receivers, `suspend`, + * and nullable-wrapped lambdas for free. * - * Still only sees *syntax*, not resolved semantics - a bare `KtUserType` name is matched against - * [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES] and this file's own `import` directives - * (`context.imports`) to find its real package; failing that, it's assumed to be a sibling type - * in this file's *own* package (`context.packageName`) rather than the default package - true for - * the common case of an `expect` referencing another type declared alongside it with no import - * needed, but still a guess: a star-imported type, or one genuinely meant to be in the default - * package, will resolve to the wrong package and fail to compile. Write fully-qualified types in - * `expect` declarations meant to be stubbed if this still matters. + * Still syntax only, not resolved semantics: an unqualified [KtUserType] is matched against + * [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES] and [TypeContext.imports], falling back to a + * sibling type in the same package. That fallback is a guess - a star-imported type, or one + * genuinely in the default package, resolves to the wrong package. Write fully-qualified types if + * that matters. */ private fun KtTypeReference.toTypeName(context: TypeContext): TypeName { val element = typeElement ?: return ANY @@ -242,7 +231,7 @@ private fun KtTypeElement.toTypeName(context: TypeContext, suspending: Boolean = val typeArgs = typeArgumentList?.arguments.orEmpty().mapNotNull { it.typeReference?.toTypeName(context) } if (typeArgs.isEmpty()) base else base.parameterizedBy(typeArgs) } - else -> ANY // KtDynamicType (JS-only) etc. - out of scope, see file header. + else -> ANY // KtDynamicType (JS-only) etc. - out of scope. } private fun classNameFor(simpleName: String, context: TypeContext): ClassName { @@ -254,21 +243,15 @@ private fun classNameFor(simpleName: String, context: TypeContext): ClassName { private val illegalStateExceptionClass = ClassName("kotlin", "IllegalStateException") -// The real Gradle/kotlinc compile already succeeds without this - `-Xmulti-platform` (set by -// wireStubGeneration/wireCrossModuleActualization) is what makes the compiler accept expect/actual -// coexisting in one compilation in the first place. But the IDE's live analysis of a plain -// kotlin("jvm") module doesn't apply that flag's effect to its own diagnostics session, so it -// still reports EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE/ACTUAL_WITHOUT_EXPECT as squiggles even -// though the build is fine. Suppressing them here is safe unconditionally: every generated stub -// actual only exists *because* it's actualizing a real expect Actualizer found, so this is never -// masking a genuine "accidental actual" mistake the way it might in real multiplatform code. +// The IDE's live analysis of a plain kotlin("jvm") module reports expect/actual as illegally +// coexisting even though -Xmulti-platform makes the real build succeed - see the root README's +// "IDE false positives" section. Safe to suppress unconditionally: every generated stub actual +// only exists because it's actualizing a real expect Actualizer found. private val suppressIdeExpectActualFalsePositives: AnnotationSpec = AnnotationSpec.builder(ClassName("kotlin", "Suppress")) .addMember("%S", "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE") .addMember("%S", "ACTUAL_WITHOUT_EXPECT") .build() -// Plain kotlin.IllegalStateException, not a custom exception type - see stub-body message below -// for what it means when this actually throws. Not worth its own tiny published module. private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of( "throw %T(%S)", illegalStateExceptionClass, @@ -321,15 +304,14 @@ private fun buildClassStub(cls: ExpectClassInfo, packageName: String): TypeSpec // expect object has no primary constructor to actualize - it's a singleton. if (!cls.isObject && cls.constructorParams.isNotEmpty()) { - // Kotlin requires the primary constructor to be explicitly marked `actual` too, not just - // the class - otherwise: "Declaration must be marked with 'actual'" on the constructor. + // The primary constructor needs its own `actual` modifier, not just the class. val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL) cls.constructorParams.forEach { ctor.addParameter(it.name, it.type) } builder.primaryConstructor(ctor.build()) } - // Fails fast on construction rather than relying on every member throwing individually - the - // member stubs below still have to exist so the actual class structurally matches the expect - // class, but this makes the "not linked" error surface at the very first point of use. + // Throws on construction so the error surfaces immediately rather than only when a + // particular member gets called - the member stubs still need to exist to match the + // expect class's shape, though. builder.addInitializerBlock(throwStatement(classFqName)) for (member in cls.members) { @@ -341,6 +323,7 @@ private fun buildClassStub(cls: ExpectClassInfo, packageName: String): TypeSpec return builder.build() } +/** Renders throwing `actual` stubs for every declaration [scanned] found, as Kotlin source text. */ internal fun generateStubFileContent(scanned: ScannedExpectFile): String { val fileSpec = FileSpec.builder(scanned.packageName, "${scanned.sourceFile.nameWithoutExtension}Stub") .addFileComment("GENERATED by the Actualizer Gradle plugin (actualizer { stubUnfulfilledExpects() }).\n") diff --git a/sample-architectury/README.md b/sample-architectury/README.md index 343b5e0..2322cce 100644 --- a/sample-architectury/README.md +++ b/sample-architectury/README.md @@ -1,15 +1,29 @@ # sample-architectury -A real Fabric + NeoForge multiloader mod build (via [Architectury Loom](https://github.com/architectury/architectury-loom)) that uses Actualizer to actualize `expect` declarations from a common module against two genuinely independent, real Minecraft mod-loader toolchains - the case that originally motivated this whole plugin (see the root [README](../README.md)'s "Motivation" section). +A real Fabric + NeoForge multiloader mod build, via [Architectury Loom](https://github.com/architectury/architectury-loom), +that uses Actualizer to actualize `expect` declarations from a common module against two +independent, real Minecraft mod-loader toolchains - the case that originally motivated this plugin +(see the root [README](../README.md)'s "Motivation" section). ## Why this is a separate Gradle build -Everything else in this repo lives inside `modular-kmp`'s own multi-project build. This sample doesn't: it has its own `settings.gradle.kts`, its own Gradle wrapper, and isn't included from the root build at all. Two reasons: +Everything else in this repo lives inside `modular-kmp`'s own multi-project build. This sample +doesn't: it has its own `settings.gradle.kts`, its own Gradle wrapper, and isn't included from the +root build at all, for two reasons: -- **It's heavy.** Architectury Loom downloads and remaps real Minecraft jars (client + server merge, official Mojang mappings, access transformers, decompilation for source jars) - tens of seconds to a few minutes on a clean cache, and real disk space. That shouldn't be part of the root repo's ordinary `gradle build` feedback loop. -- **It pins a different Gradle version on purpose.** The root build runs on whatever Gradle is ambient; this one is pinned via its own wrapper (`./gradlew`, Gradle 8.12) to match what Architectury Loom 1.13.469 was actually built and tested against. Always use `./gradlew` here, not a system-wide `gradle`. +- **It's heavy.** Architectury Loom downloads and remaps real Minecraft jars (client + server + merge, official Mojang mappings, access transformers, decompilation for source jars) - tens of + seconds to a few minutes on a clean cache, and real disk space. That shouldn't be part of the + root repo's ordinary `gradle build` feedback loop. +- **It pins a different Gradle version on purpose.** The root build runs on whatever Gradle is + ambient; this one is pinned via its own wrapper (`./gradlew`, Gradle 8.12) to match what + Architectury Loom 1.13.469 was built and tested against. Always use `./gradlew` here, not a + system-wide `gradle`. -It still reaches the main build's `net.kernelpanicsoft.actualizer` plugin and `net.kernelpanicsoft:compiler-plugin` the same way the root build does: `includeBuild("../plugin-build")` in both `pluginManagement` and at the top level of `settings.gradle.kts`. +It still reaches the main build's `net.kernelpanicsoft.actualizer` plugin and +`net.kernelpanicsoft.actualizer:compiler-plugin` the same way the root build does - +`includeBuild("../plugin-build")` in both `pluginManagement` and at the top level of +`settings.gradle.kts`. ## Structure @@ -27,19 +41,25 @@ expect val loaderName: String expect fun loaderSpecificGreeting(): String ``` -No annotation is needed - a plain `kotlin("jvm")` module (which `:common`/`:fabric`/`:neoforge` all -are, `architectury-plugin`/Architectury Loom notwithstanding) never sees `expect`/`actual` at all -unless Actualizer put them there, so every pair Actualizer's IR plugin finds is one of its own by -construction. See the root README's "IDE false positives" section for why `ModCommon.kt` (and -`:fabric`/`:neoforge`'s `Actual.kt`) each start with a `@file:Suppress(...)` line - it's cosmetic, -quieting an IDE-only false positive, not required for anything to actually compile or run. - -`:fabric` and `:neoforge` each merge `:common`'s source in (`actualizer { actualizes(project(":common")) }`) and provide a real `actual` that references genuinely platform-specific, remapped Minecraft classes - `net.minecraft.resources.ResourceLocation`, resolved against *that platform's own* Architectury Loom-provided Minecraft jar. Both platforms use official Mojang mappings (not Yarn) uniformly, which is the actual point of Architectury Loom over plain Fabric Loom / NeoForge's own ModDevGradle: one shared mapping namespace both loaders can consume natively, so `:common`'s merged code and both platforms' `actual`s see the same class/method names without a lossy mapping migration step. +No annotation is needed here either - `:common`, `:fabric`, and `:neoforge` are all plain +`kotlin("jvm")` modules underneath the `architectury-plugin`/Architectury Loom wiring, so the same +reasoning from the root README applies. The IDE-only false positives that would otherwise show up +on these declarations and on the calls to them are suppressed automatically by the compiler +plugin's FIR extension - see the root README's "IDE false positives" section. + +`:fabric` and `:neoforge` each merge `:common`'s source in +(`actualizer { actualizes(project(":common")) }`) and provide a real `actual` that references +platform-specific, remapped Minecraft classes - `net.minecraft.resources.ResourceLocation`, +resolved against that platform's own Architectury Loom-provided Minecraft jar. Both platforms use +official Mojang mappings rather than Yarn, which is the actual point of Architectury Loom over +plain Fabric Loom or NeoForge's own ModDevGradle: one shared mapping namespace both loaders can +consume natively, so `:common`'s merged code and both platforms' actuals see the same class/method +names without a lossy mapping migration step. `:common` also calls `actualizer { stubUnfulfilledExpects() }`, the same as the main repo's -`:sample:api`, so it builds a genuine standalone jar (`./gradlew :common:build` succeeds on its -own, with an auto-generated throwing stub for both expects) that other common/API modules could -depend on before any platform actual exists - not just a bare source-provider for `:fabric`/ +`:sample:api`, so it builds a genuine standalone jar - `./gradlew :common:build` succeeds on its +own, with an auto-generated throwing stub for both expects - that other common/API modules could +depend on before any platform actual exists, not just a bare source-provider for `:fabric`/ `:neoforge`. ## Building and running @@ -52,14 +72,17 @@ depend on before any platform actual exists - not just a bare source-provider fo ./gradlew :fabric:runVerify :neoforge:runVerify ``` -`runVerify` is a plain `JavaExec` against Loom's dev runtime classpath that calls the merged code directly (`modStartupMessage()`, which calls the platform's `actual fun loaderSpecificGreeting()`) - it proves the actualized code actually *executes* correctly, not just compiles, without needing to launch a full Minecraft client (which needs game assets/auth this doesn't have). Expected output: +`runVerify` is a plain `JavaExec` against Loom's dev runtime classpath that calls the merged code +directly (`modStartupMessage()`, which calls the platform's `actual fun loaderSpecificGreeting()`). +It proves the actualized code executes correctly, not just compiles, without needing to launch a +full Minecraft client. Expected output: ``` Sample mod starting on Fabric: hello from Architectury Loom's Fabric platform; resolved samplemod:hello against a real remapped Minecraft classpath Sample mod starting on NeoForge: hello from Architectury Loom's NeoForge platform; resolved samplemod:hello against a real remapped Minecraft classpath ``` -Two different `actual`s, two different real Minecraft classpaths, one merged `:common` source file - proving the actual value proposition end to end. +Two different actuals, two different real Minecraft classpaths, one merged `:common` source file. Each platform module's `build/actualizer/report.json` records what got linked, e.g.: @@ -75,5 +98,11 @@ Each platform module's `build/actualizer/report.json` records what got linked, e ## Known limitations specific to this sample -- **`loom.platform` must be set per module.** Architectury Loom reads which platform (`fabric`/`neoforge`) a module targets from that module's own `gradle.properties` (`loom.platform=fabric` / `loom.platform=neoforge`), read before the build script even runs - it's not enough to call `architectury { fabric() }` / `architectury { neoForge() }` in the script alone. -- **This needs real network access** to Mojang's piston-meta/piston-data endpoints, Fabric's and Architectury's Maven repos, and NeoForged's Maven repo, plus enough heap for Loom's official-mappings decompile/remap pipeline (`org.gradle.jvmargs=-Xmx6G` in this directory's `gradle.properties`) - it won't build in a fully offline or memory-constrained environment. +- **`loom.platform` must be set per module.** Architectury Loom reads which platform + (`fabric`/`neoforge`) a module targets from that module's own `gradle.properties` + (`loom.platform=fabric`/`loom.platform=neoforge`), read before the build script runs - it's not + enough to call `architectury { fabric() }`/`architectury { neoForge() }` in the script alone. +- **This needs real network access** to Mojang's piston-meta/piston-data endpoints, Fabric's and + Architectury's Maven repos, and NeoForged's Maven repo, plus enough heap for Loom's + official-mappings decompile/remap pipeline (`org.gradle.jvmargs=-Xmx6G` in this directory's + `gradle.properties`). It won't build in a fully offline or memory-constrained environment. diff --git a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt index a73d495..749e88e 100644 --- a/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt +++ b/sample-architectury/common/src/main/kotlin/net/kernelpanicsoft/samplemod/common/ModCommon.kt @@ -1,17 +1,10 @@ package net.kernelpanicsoft.samplemod.common -/** - * Actualized independently by `:fabric` and `:neoforge` - two genuinely separate platform - * targets under Architectury Loom, each with its own remapped Minecraft classpath, neither of - * which participates in a shared `kotlin { }` multiplatform source-set hierarchy with the other. - * This is the actual motivating case for Actualizer: a single KMP module can't span two - * mutually-incompatible Gradle plugins like this, but merging source into each loader's own - * compilation can. - * - * No `@file:Suppress` needed here for the IDE-only false positives this would otherwise trigger - * (see the root README's "IDE false positives" section) - the Actualizer compiler plugin injects - * the equivalent suppression automatically via a FIR extension. - */ +// Actualized independently by :fabric and :neoforge - two separate platform targets under +// Architectury Loom, each with its own remapped Minecraft classpath, neither part of a shared +// kotlin {} multiplatform hierarchy. This is the motivating case for Actualizer: a single KMP +// module can't span two incompatible Gradle plugins like this, but merging source into each +// loader's own compilation can. expect val loaderName: String expect fun loaderSpecificGreeting(): String diff --git a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt index aa6562e..ebb0d2c 100644 --- a/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt +++ b/sample-architectury/fabric/src/main/kotlin/net/kernelpanicsoft/samplemod/common/Actual.kt @@ -8,15 +8,11 @@ import net.minecraft.resources.ResourceLocation actual val loaderName: String = "Fabric" -// References real, remapped Minecraft (ResourceLocation) that only resolves on :fabric's -// classpath - proof this actual was compiled against Architectury Loom's Fabric-platform -// Minecraft jar. Both :fabric and :neoforge use official Mojang mappings (the whole point of -// using Architectury Loom instead of separate Fabric Loom / NeoForge ModDevGradle toolchains), -// so the class name matches :neoforge's usage exactly even though the underlying jar is -// Fabric's. Deliberately not calling FabricLoader.getInstance() here: it requires Fabric's real -// Knot launcher bootstrap, which only exists when actually launching the game - out of scope for -// this sample (see :fabric's `runVerify` task, which runs this code directly via a bare -// JavaExec, not a full client launch). +// ResourceLocation only resolves on :fabric's classpath, proving this actual was compiled +// against Architectury Loom's Fabric-platform Minecraft jar. Both platforms use official Mojang +// mappings, so the class name matches :neoforge's usage even though the underlying jar differs. +// Not calling FabricLoader.getInstance() here - it needs Fabric's real launcher bootstrap, which +// only exists when actually launching the game, out of scope for runVerify's bare JavaExec. actual fun loaderSpecificGreeting(): String { val id = ResourceLocation.fromNamespaceAndPath("samplemod", "hello") return "hello from Architectury Loom's Fabric platform; resolved $id against a real remapped Minecraft classpath" diff --git a/sample-architectury/settings.gradle.kts b/sample-architectury/settings.gradle.kts index 840a883..d06e5f1 100644 --- a/sample-architectury/settings.gradle.kts +++ b/sample-architectury/settings.gradle.kts @@ -13,7 +13,7 @@ pluginManagement { } // Mirrors the top-level `includeBuild("plugin-build")` in the main build's settings.gradle.kts: -// needed so the plain coordinate "net.kernelpanicsoft:compiler-plugin:0.1.0" (which the +// needed so the plain coordinate "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" (which the // Actualizer Gradle plugin resolves at apply-time for `-Xplugin=`) substitutes to the // `:compiler-plugin` project here instead of requiring a real Maven publish. includeBuild("../plugin-build") diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt index a198849..e212c97 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/Greeting.kt @@ -1,23 +1,12 @@ package net.kernelpanicsoft.sample.api -/** - * Not actualized anywhere in this module's own multiplatform hierarchy - there isn't one - but - * by a real, independently built Gradle module (`:sample:actual-jvm`) that isn't even a - * dependency of this one. `:api`'s own build stays green because `actualizer { - * stubUnfulfilledExpects() }` (see `build.gradle.kts`) auto-generates a throwing `actual` stub - * for it, so this whole file compiles normally into `:api`'s ordinary, standalone jar. - * - * No `@file:Suppress` needed here for the IDE-only false positives this would otherwise trigger - * (see the root README's "IDE false positives" section) - the Actualizer compiler plugin injects - * the equivalent suppression automatically via a FIR extension. - */ +// Actualized by :sample:actual-jvm, a separate Gradle module that isn't even a dependency of +// this one. :api's own build stays green regardless, because stubUnfulfilledExpects() in +// build.gradle.kts generates a throwing actual stub for it. expect fun greetingSuffix(): String fun formatGreeting(name: String, suffix: String): String = "Hello, $name! $suffix" -/** - * Calling this via `:api`'s own jar directly throws `IllegalStateException` (the stub). It only - * does something useful once merged - alongside a real `actual` - into a leaf module's - * compilation via `actualizer { actualizes(project(":sample:api")) }`. - */ +// Calling this through :api's own jar throws IllegalStateException (the stub). It only does +// something useful once merged, alongside a real actual, into actual-jvm's compilation. fun greet(name: String): String = formatGreeting(name, greetingSuffix()) diff --git a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt index 5a81579..02b6762 100644 --- a/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt +++ b/sample/api/src/main/kotlin/net/kernelpanicsoft/sample/api/PlatformInfo.kt @@ -1,12 +1,10 @@ package net.kernelpanicsoft.sample.api -/** Exercises the stub generator's `expect val` support (Greeting.kt already covers `expect fun`). */ +// Covers the stub generator's `expect val` path (Greeting.kt covers `expect fun`). expect val platformName: String -/** - * Exercises the stub generator's `expect class` support: a constructor parameter plus a member - * function, both of which the generated `actual` stub has to structurally match. - */ +// Covers `expect class`: a constructor parameter plus a member function, both of which the +// generated actual stub has to match. expect class GreetingCounter(start: Int) { fun next(): Int } diff --git a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt index 964f5b8..3e693b3 100644 --- a/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt +++ b/sample/feature-common/src/main/kotlin/net/kernelpanicsoft/sample/feature/Feature.kt @@ -2,11 +2,8 @@ package net.kernelpanicsoft.sample.feature import net.kernelpanicsoft.sample.api.greet -/** - * Ordinary `implementation(project(":sample:api"))` dependency on `:api`'s plain, standalone JVM - * jar - no plugin, no multiplatform, nothing special. `greet()` compiles fine here because - * `:api`'s expect has a generated stub; calling `welcomeMessage()` via *this* module's own jar - * directly would throw `IllegalStateException` unless this file is also merged into a real - * `actualizer { actualizes(...) }` leaf (`:sample:actual-jvm` does exactly that). - */ +// An ordinary implementation(project(":sample:api")) dependency - no plugin, no multiplatform. +// greet() compiles fine here because :api's expect has a generated stub; calling this through +// feature-common's own jar would throw until it's merged into a real actualizes(...) leaf, which +// :sample:actual-jvm does. fun welcomeMessage(name: String): String = "[feature-common] " + greet(name) diff --git a/settings.gradle.kts b/settings.gradle.kts index e38f2ac..b097efc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,7 +9,7 @@ pluginManagement { // A second, top-level includeBuild is required (in addition to the one inside // pluginManagement above) so that plain dependency coordinates like -// "net.kernelpanicsoft:compiler-plugin:0.1.0" - which the Actualizer Gradle plugin +// "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" - which the Actualizer Gradle plugin // resolves at apply-time to locate the compiler plugin's jar for `-Xplugin=` - get // substituted with the :compiler-plugin project from plugin-build instead of requiring a // real Maven publish. From 2816f6c7f37e4c54dee178e9ef29abe3e810fc05 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 07:51:46 +0000 Subject: [PATCH 14/19] Upgrade to Kotlin 2.4.10, isolate stub generation, include sample-architectury - Bump Kotlin from 2.0.21 to 2.4.10 across all builds (root, sample-architectury, plugin-build's two modules), fixing the resulting internal-API breakage: CompilerPluginRegistrar now requires an explicit pluginId, FirSimpleFunction was renamed to FirNamedFunction, constructClassLikeType/type were replaced with ClassId.createConeType/coneType, and KotlinCoreEnvironment/CompilerConfiguration construction now needs explicit opt-ins. - Add ERROR_SUPPRESSION and NOT_A_MULTIPLATFORM_COMPILATION to the generated stubs' @Suppress list, matching the FIR extension's suppression set. - Fix a real classloader split (IllegalAccessError on Disposer/ObjectTree/ObjectNode) between this plugin's own kotlin-compiler-embeddable dependency and the Kotlin Gradle Plugin's own build-tools-API machinery in the same daemon process, by running stub generation in an isolated worker classloader via WorkerExecutor. - Add sample-architectury as a root-level includeBuild, reachable via explicit task paths for IDE navigation without being pulled into the default `gradle build`. --- build.gradle.kts | 2 +- plugin-build/compiler-plugin/build.gradle.kts | 4 +- .../ActualizerCompilerPluginRegistrar.kt | 1 + .../compiler/fir/ActualizerFirExtensions.kt | 72 +++++++++---------- plugin-build/gradle-plugin/build.gradle.kts | 4 +- .../gradle/ActualizerGradlePlugin.kt | 41 +++++++---- .../actualizer/gradle/ExpectStubGenerator.kt | 8 ++- .../gradle/GenerateStubsWorkAction.kt | 50 +++++++++++++ sample-architectury/README.md | 15 ++-- sample-architectury/build.gradle.kts | 2 +- settings.gradle.kts | 5 ++ 11 files changed, 136 insertions(+), 68 deletions(-) create mode 100644 plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt diff --git a/build.gradle.kts b/build.gradle.kts index 749d414..960a254 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,3 @@ plugins { - kotlin("jvm") version "2.0.21" apply false + kotlin("jvm") version "2.4.10" apply false } diff --git a/plugin-build/compiler-plugin/build.gradle.kts b/plugin-build/compiler-plugin/build.gradle.kts index 16fb77d..0a8bad3 100644 --- a/plugin-build/compiler-plugin/build.gradle.kts +++ b/plugin-build/compiler-plugin/build.gradle.kts @@ -1,7 +1,7 @@ import java.util.Properties plugins { - kotlin("jvm") version "2.0.21" + kotlin("jvm") version "2.4.10" `maven-publish` } @@ -13,7 +13,7 @@ repositories { } dependencies { - compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.21") + compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10") } kotlin { diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index 218fbd7..9236d2b 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -21,6 +21,7 @@ import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter @OptIn(ExperimentalCompilerApi::class) class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { + override val pluginId: String = ActualizerCommandLineProcessor.PLUGIN_ID override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt index ab550d4..922e5fd 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt @@ -1,6 +1,7 @@ package net.kernelpanicsoft.actualizer.compiler.fir import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.DirectDeclarationsAccess import org.jetbrains.kotlin.fir.declarations.FirCallableDeclaration import org.jetbrains.kotlin.fir.declarations.FirClassLikeDeclaration import org.jetbrains.kotlin.fir.declarations.FirConstructor @@ -8,22 +9,21 @@ import org.jetbrains.kotlin.fir.declarations.FirDeclaration import org.jetbrains.kotlin.fir.declarations.FirDeclarationStatus import org.jetbrains.kotlin.fir.declarations.FirFile import org.jetbrains.kotlin.fir.declarations.FirMemberDeclaration +import org.jetbrains.kotlin.fir.declarations.FirNamedFunction import org.jetbrains.kotlin.fir.declarations.FirProperty import org.jetbrains.kotlin.fir.declarations.FirRegularClass -import org.jetbrains.kotlin.fir.declarations.FirSimpleFunction import org.jetbrains.kotlin.fir.expressions.FirAnnotation -import org.jetbrains.kotlin.fir.expressions.builder.FirAnnotationArgumentMappingBuilder -import org.jetbrains.kotlin.fir.expressions.builder.FirAnnotationBuilder -import org.jetbrains.kotlin.fir.expressions.builder.FirVarargArgumentsExpressionBuilder +import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotation +import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationArgumentMapping import org.jetbrains.kotlin.fir.expressions.builder.buildLiteralExpression +import org.jetbrains.kotlin.fir.expressions.builder.buildVarargArgumentsExpression import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar import org.jetbrains.kotlin.fir.extensions.FirStatusTransformerExtension +import org.jetbrains.kotlin.fir.plugin.createConeType import org.jetbrains.kotlin.fir.resolve.providers.firProvider import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol -import org.jetbrains.kotlin.fir.types.ConeAttributes import org.jetbrains.kotlin.fir.types.ConeKotlinTypeProjectionOut -import org.jetbrains.kotlin.fir.types.builder.FirResolvedTypeRefBuilder -import org.jetbrains.kotlin.fir.types.constructClassLikeType +import org.jetbrains.kotlin.fir.types.builder.buildResolvedTypeRef import org.jetbrains.kotlin.name.StandardClassIds import org.jetbrains.kotlin.types.ConstantValueKind @@ -33,6 +33,7 @@ private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf( "ACTUAL_WITHOUT_EXPECT", "OVERLOAD_RESOLUTION_AMBIGUITY", "ERROR_SUPPRESSION", + "NOT_A_MULTIPLATFORM_COMPILATION", ) // Everything below builds on internal, undocumented FIR APIs with no compatibility guarantee @@ -47,10 +48,10 @@ private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf( @Volatile private var suppressionApiAvailable = true -private fun buildSuppressAnnotationOrNull(): FirAnnotation? { +private fun buildSuppressAnnotationOrNull(session: FirSession): FirAnnotation? { if (!suppressionApiAvailable) return null return try { - buildSuppressAnnotation() + buildSuppressAnnotation(session) } catch (t: Throwable) { suppressionApiAvailable = false null @@ -58,19 +59,11 @@ private fun buildSuppressAnnotationOrNull(): FirAnnotation? { } /** Builds a `@Suppress` [FirAnnotation] for [SUPPRESSED_DIAGNOSTIC_NAMES] from FIR builders directly, not source text. */ -private fun buildSuppressAnnotation(): FirAnnotation { - val suppressConeType = StandardClassIds.Annotations.Suppress.constructClassLikeType( - typeArguments = emptyArray(), - isNullable = false, - attributes = ConeAttributes.Empty, - ) - val typeRef = FirResolvedTypeRefBuilder().apply { type = suppressConeType }.build() +private fun buildSuppressAnnotation(session: FirSession): FirAnnotation { + val suppressConeType = StandardClassIds.Annotations.Suppress.createConeType(session) + val typeRef = buildResolvedTypeRef { coneType = suppressConeType } - val stringConeType = StandardClassIds.String.constructClassLikeType( - typeArguments = emptyArray(), - isNullable = false, - attributes = ConeAttributes.Empty, - ) + val stringConeType = StandardClassIds.String.createConeType(session) val literalArgs = SUPPRESSED_DIAGNOSTIC_NAMES.map { name -> buildLiteralExpression( source = null, @@ -81,25 +74,24 @@ private fun buildSuppressAnnotation(): FirAnnotation { prefix = null, ) } - val varargArrayConeType = StandardClassIds.Array.constructClassLikeType( + val varargArrayConeType = StandardClassIds.Array.createConeType( + session, typeArguments = arrayOf(ConeKotlinTypeProjectionOut(stringConeType)), - isNullable = false, - attributes = ConeAttributes.Empty, ) - val varargExpression = FirVarargArgumentsExpressionBuilder().apply { + val varargExpression = buildVarargArgumentsExpression { arguments.addAll(literalArgs) coneElementTypeOrNull = stringConeType coneTypeOrNull = varargArrayConeType - }.build() + } - val argumentMapping = FirAnnotationArgumentMappingBuilder().apply { + val argumentMapping = buildAnnotationArgumentMapping { mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = varargExpression - }.build() + } - return FirAnnotationBuilder().apply { + return buildAnnotation { annotationTypeRef = typeRef this.argumentMapping = argumentMapping - }.build() + } } private fun isExpectOrActual(status: FirDeclarationStatus): Boolean = status.isExpect || status.isActual @@ -124,10 +116,10 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS return when (declaration) { is FirCallableDeclaration -> provider.getFirCallableContainerFile(declaration.symbol) is FirClassLikeDeclaration -> provider.getFirClassifierContainerFileIfAny(declaration.symbol) - else -> null } } + @OptIn(DirectDeclarationsAccess::class) private fun fileNeedsSuppression(file: FirFile): Boolean = fileNeedsSuppressionCache.getOrPut(file) { file.declarations.any { (it as? FirMemberDeclaration)?.status?.let(::isExpectOrActual) == true } } @@ -145,7 +137,7 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS } private fun inject(declaration: FirDeclaration) { - val annotation = buildSuppressAnnotationOrNull() ?: return + val annotation = buildSuppressAnnotationOrNull(session) ?: return try { declaration.replaceAnnotations(declaration.annotations + annotation) } catch (t: Throwable) { @@ -155,41 +147,41 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS override fun transformStatus( status: FirDeclarationStatus, - declaration: FirSimpleFunction, + function: FirNamedFunction, containingClass: FirClassLikeSymbol<*>?, isLocal: Boolean, ): FirDeclarationStatus { - inject(declaration) + inject(function) return status } override fun transformStatus( status: FirDeclarationStatus, - declaration: FirProperty, + property: FirProperty, containingClass: FirClassLikeSymbol<*>?, isLocal: Boolean, ): FirDeclarationStatus { - inject(declaration) + inject(property) return status } override fun transformStatus( status: FirDeclarationStatus, - declaration: FirRegularClass, + regularClass: FirRegularClass, containingClass: FirClassLikeSymbol<*>?, isLocal: Boolean, ): FirDeclarationStatus { - inject(declaration) + inject(regularClass) return status } override fun transformStatus( status: FirDeclarationStatus, - declaration: FirConstructor, + constructor: FirConstructor, containingClass: FirClassLikeSymbol<*>?, isLocal: Boolean, ): FirDeclarationStatus { - inject(declaration) + inject(constructor) return status } } diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts index 0f362ab..fec3ddd 100644 --- a/plugin-build/gradle-plugin/build.gradle.kts +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -1,7 +1,7 @@ import java.util.Properties plugins { - kotlin("jvm") version "2.0.21" + kotlin("jvm") version "2.4.10" `java-gradle-plugin` `maven-publish` } @@ -27,7 +27,7 @@ dependencies { // Used to find `expect` declarations to stub via real PSI parsing (ExpectStubGenerator.kt) // instead of a naive regex + brace-counting scan. Pure syntax parsing only - no semantic // resolution - so it doesn't choke on the very thing being scanned for (unfulfilled expects). - implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.0.21") + implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10") } gradlePlugin { diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index beba548..da6f3e1 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -6,11 +6,18 @@ import org.gradle.api.Project import org.gradle.api.artifacts.ExternalModuleDependency import org.gradle.api.file.SourceDirectorySet import org.gradle.api.provider.ListProperty +import org.gradle.workers.WorkerExecutor import java.io.File +import javax.inject.Inject private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" +// Must match the versions gradle-plugin/build.gradle.kts itself declares - this is what +// wireStubGeneration resolves onto GenerateStubsWorkAction's isolated worker classpath. +private const val KOTLIN_COMPILER_EMBEDDABLE_COORDINATES = "org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10" +private const val KOTLINPOET_COORDINATES = "com.squareup:kotlinpoet:1.18.1" + /** * Registers the `actualizer { }` extension and wires its two modes of operation into the * project's Kotlin compilation. @@ -34,7 +41,9 @@ private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft.actualizer: * types find nothing across that boundary. Reflection by member name sidesteps it; only * Gradle-core types loaded by Gradle's own shared classloader are referenced statically. */ -class ActualizerGradlePlugin : Plugin { +class ActualizerGradlePlugin @Inject constructor( + private val workerExecutor: WorkerExecutor, +) : Plugin { override fun apply(project: Project) { val extension = project.extensions.create("actualizer", ActualizerExtension::class.java) @@ -70,22 +79,24 @@ class ActualizerGradlePlugin : Plugin { task.inputs.files(mainFiles).withPropertyName("actualizerExpectScanInputs") task.outputs.dir(outputDir) task.doLast { - val scanned = scanForExpectFunctions(mainFiles) - outputDir.deleteRecursively() - if (scanned.isEmpty()) { - project.logger.warn( - "[actualizer] '${project.path}' called stubUnfulfilledExpects() but no " + - "'expect fun'/'expect val'/'expect var'/'expect class' declarations " + - "were found in its main source set." - ) - return@doLast + // Runs in its own classloader, not this plugin's - see GenerateStubsWorkAction for + // why. The isolated classpath needs both this plugin's own classes (for + // ExpectStubGenerator.kt/GenerateStubsWorkAction itself) and its kotlin-compiler- + // embeddable/kotlinpoet dependencies. + val ownJar = File(ActualizerGradlePlugin::class.java.protectionDomain.codeSource.location.toURI()) + val isolatedDeps = project.configurations.detachedConfiguration( + project.dependencies.create(KOTLIN_COMPILER_EMBEDDABLE_COORDINATES), + project.dependencies.create(KOTLINPOET_COORDINATES), + ) + val workQueue = workerExecutor.classLoaderIsolation { spec -> + spec.classpath.from(ownJar, isolatedDeps) } - outputDir.mkdirs() - for (file in scanned) { - val packageDir = File(outputDir, file.packageName.replace('.', '/')).apply { mkdirs() } - File(packageDir, "${file.sourceFile.nameWithoutExtension}Stub.kt") - .writeText(generateStubFileContent(file)) + workQueue.submit(GenerateStubsWorkAction::class.java) { params -> + params.mainFiles.from(mainFiles) + params.outputDir.set(outputDir) + params.projectPath.set(project.path) } + workQueue.await() } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt index 1f92223..57862cc 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -11,6 +11,7 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec +import org.jetbrains.kotlin.K1Deprecation import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer @@ -76,12 +77,15 @@ internal data class ScannedExpectFile( ) /** Parses [files] for top-level `expect` declarations, returning one entry per file that has any. */ +@OptIn(K1Deprecation::class, CompilerConfiguration.Internals::class) internal fun scanForExpectFunctions(files: Iterable): List { val ktFiles = files.filter { it.isFile && it.extension == "kt" } if (ktFiles.isEmpty()) return emptyList() // A throwaway frontend environment just to get a KtPsiFactory, disposed in `finally` so it - // doesn't leak across builds in a long-lived Gradle daemon. + // doesn't leak across builds in a long-lived Gradle daemon. KotlinCoreEnvironment is K1 + // machinery, deprecated for real compilation but still the standard way to stand up a + // lightweight PSI factory for tooling like this. val disposable = Disposer.newDisposable("actualizer-expect-scan") try { val environment = KotlinCoreEnvironment.createForProduction( @@ -250,6 +254,8 @@ private val illegalStateExceptionClass = ClassName("kotlin", "IllegalStateExcept private val suppressIdeExpectActualFalsePositives: AnnotationSpec = AnnotationSpec.builder(ClassName("kotlin", "Suppress")) .addMember("%S", "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE") .addMember("%S", "ACTUAL_WITHOUT_EXPECT") + .addMember("%S", "ERROR_SUPPRESSION") + .addMember("%S", "NOT_A_MULTIPLATFORM_COMPILATION") .build() private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of( diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt new file mode 100644 index 0000000..5d50101 --- /dev/null +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt @@ -0,0 +1,50 @@ +package net.kernelpanicsoft.actualizer.gradle + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.logging.Logging +import org.gradle.api.provider.Property +import org.gradle.workers.WorkAction +import org.gradle.workers.WorkParameters +import java.io.File + +internal interface GenerateStubsParameters : WorkParameters { + val mainFiles: ConfigurableFileCollection + val outputDir: DirectoryProperty + val projectPath: Property +} + +/** + * Runs [scanForExpectFunctions]/[generateStubFileContent] in a classloader isolated from the rest + * of the build, via [org.gradle.workers.WorkerExecutor.classLoaderIsolation]. Standing up a + * [org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment] pulls in `kotlin-compiler-embeddable`'s + * bundled IntelliJ platform classes, which can collide with whatever the Kotlin Gradle Plugin's + * own compilation machinery loads in the same process for a different Kotlin version, producing a + * split-classloader `IllegalAccessError` on classes like `Disposer`/`ObjectTree`. Running this in + * its own classloader sidesteps that collision entirely. + */ +internal abstract class GenerateStubsWorkAction : WorkAction { + + private val logger = Logging.getLogger(GenerateStubsWorkAction::class.java) + + override fun execute() { + val mainFiles = parameters.mainFiles.files.toList() + val outputDir = parameters.outputDir.get().asFile + val scanned = scanForExpectFunctions(mainFiles) + outputDir.deleteRecursively() + if (scanned.isEmpty()) { + logger.warn( + "[actualizer] '${parameters.projectPath.get()}' called stubUnfulfilledExpects() but " + + "no 'expect fun'/'expect val'/'expect var'/'expect class' declarations were " + + "found in its main source set." + ) + return + } + outputDir.mkdirs() + for (file in scanned) { + val packageDir = File(outputDir, file.packageName.replace('.', '/')).apply { mkdirs() } + File(packageDir, "${file.sourceFile.nameWithoutExtension}Stub.kt") + .writeText(generateStubFileContent(file)) + } + } +} diff --git a/sample-architectury/README.md b/sample-architectury/README.md index 2322cce..662b4b4 100644 --- a/sample-architectury/README.md +++ b/sample-architectury/README.md @@ -8,17 +8,20 @@ independent, real Minecraft mod-loader toolchains - the case that originally mot ## Why this is a separate Gradle build Everything else in this repo lives inside `modular-kmp`'s own multi-project build. This sample -doesn't: it has its own `settings.gradle.kts`, its own Gradle wrapper, and isn't included from the -root build at all, for two reasons: +doesn't: it has its own `settings.gradle.kts` and its own Gradle wrapper. The root build does +`includeBuild("sample-architectury")` so this build is reachable from the root for IDE navigation +and via explicit task paths (`gradle :sample-architectury:common:build`), but it stays out of the +root's default `gradle build` - a bare build at the root doesn't pull in this build's tasks - for +two reasons: - **It's heavy.** Architectury Loom downloads and remaps real Minecraft jars (client + server merge, official Mojang mappings, access transformers, decompilation for source jars) - tens of seconds to a few minutes on a clean cache, and real disk space. That shouldn't be part of the root repo's ordinary `gradle build` feedback loop. -- **It pins a different Gradle version on purpose.** The root build runs on whatever Gradle is - ambient; this one is pinned via its own wrapper (`./gradlew`, Gradle 8.12) to match what - Architectury Loom 1.13.469 was built and tested against. Always use `./gradlew` here, not a - system-wide `gradle`. +- **It pins a different Gradle version on purpose.** Its own wrapper (`./gradlew`, Gradle 8.12) + matches what Architectury Loom 1.13.469 was built and tested against. Building it through the + root's own Gradle version (via `includeBuild`) works too - Loom 1.13.469 tolerates newer Gradle + - but `./gradlew` here is still the version this sample is actually pinned to and tested with. It still reaches the main build's `net.kernelpanicsoft.actualizer` plugin and `net.kernelpanicsoft.actualizer:compiler-plugin` the same way the root build does - diff --git a/sample-architectury/build.gradle.kts b/sample-architectury/build.gradle.kts index f6a0ab8..2077c99 100644 --- a/sample-architectury/build.gradle.kts +++ b/sample-architectury/build.gradle.kts @@ -1,7 +1,7 @@ import net.fabricmc.loom.api.LoomGradleExtensionAPI plugins { - kotlin("jvm") version "2.0.21" apply false + kotlin("jvm") version "2.4.10" apply false id("architectury-plugin") version "3.4.164" id("dev.architectury.loom") version "1.13.469" apply false } diff --git a/settings.gradle.kts b/settings.gradle.kts index b097efc..9869377 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,11 @@ pluginManagement { // real Maven publish. includeBuild("plugin-build") +// sample-architectury is a real, separate Gradle build (its own settings.gradle.kts, its own +// wrapper) - see its README for why - but included here too so it's part of the same composite +// build for IDE navigation and so `gradle build` at the root reaches it as well. +includeBuild("sample-architectury") + rootProject.name = "modular-kmp" include( From cb9ffcdb4b82a5295c13b33ae7ac5b05b77b8bea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 08:07:39 +0000 Subject: [PATCH 15/19] Fix kotlin("jvm") plugin-apply crash from a shared-classpath class collision gradle-plugin declared kotlin-compiler-embeddable/kotlinpoet as implementation dependencies, so java-gradle-plugin exposed them on the same plugin classpath a consuming project's own kotlin("jvm") plugin loads into. kotlin-compiler- embeddable bundles its own copy of classes like GradleBuildPerformanceMetric that Kotlin Gradle Plugin's build-reporting code also uses, and the wrong one won, producing a NoSuchMethodError on GradleBuildPerformanceMetric.values() inside DefaultKotlinBasePlugin.apply - breaking kotlin("jvm") itself for any project that also applies the actualizer plugin. Both dependencies are only ever used at runtime inside GenerateStubsWorkAction, which already resolves its own fresh copies onto an isolated worker classpath. Switching them to compileOnly keeps them off the shared plugin classpath while still letting ExpectStubGenerator.kt/GenerateStubsWorkAction.kt compile. --- plugin-build/gradle-plugin/build.gradle.kts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugin-build/gradle-plugin/build.gradle.kts b/plugin-build/gradle-plugin/build.gradle.kts index fec3ddd..b1facee 100644 --- a/plugin-build/gradle-plugin/build.gradle.kts +++ b/plugin-build/gradle-plugin/build.gradle.kts @@ -20,14 +20,23 @@ kotlin { } dependencies { + // Both compileOnly, not implementation: ExpectStubGenerator.kt/GenerateStubsWorkAction.kt only + // need these to compile. At runtime they only ever run inside GenerateStubsWorkAction's + // isolated worker classloader (see ActualizerGradlePlugin.wireStubGeneration), which resolves + // its own fresh copies of both via a detached configuration. If these were `implementation`, + // Gradle would merge them onto the same plugin classpath as a consuming project's own + // kotlin("jvm") plugin, and kotlin-compiler-embeddable's bundled classes collide with + // kotlin-gradle-plugin's own - e.g. a NoSuchMethodError on GradleBuildPerformanceMetric.values() + // while applying kotlin("jvm") itself, since both jars provide that class under the same name. + // // Used to generate the actualizer { stubUnfulfilledExpects() } output (ExpectStubGenerator.kt) // instead of hand-rolled string building - correct formatting, imports, and Kotlin syntax // (including for `actual class` stubs) instead of ad-hoc string concatenation. - implementation("com.squareup:kotlinpoet:1.18.1") + compileOnly("com.squareup:kotlinpoet:1.18.1") // Used to find `expect` declarations to stub via real PSI parsing (ExpectStubGenerator.kt) // instead of a naive regex + brace-counting scan. Pure syntax parsing only - no semantic // resolution - so it doesn't choke on the very thing being scanned for (unfulfilled expects). - implementation("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10") + compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10") } gradlePlugin { From 7c7dd3d8175db822aacbb761fc6b471fdabb8b48 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:50:36 +0000 Subject: [PATCH 16/19] Fix generated-stub bugs and add real support for interfaces, enums, generics The generated stub actuals had several real, confirmed-via-compile bugs: variance (out/in/*) and star projections on type arguments were silently dropped or flattened, qualified nested types like HolderLookup.Provider resolved to nothing (missing import in the generated file), a generic function's own type parameter was dropped entirely, and secondary constructors/supertypes/nested expect classes weren't handled at all. Also fixes a real class-collision-adjacent bug in the FIR suppression extension: ActualizerSuppressionStatusTransformer.inject wasn't idempotent, so a declaration status-transformed more than once (which K2 does for generic classes) got a second @Suppress annotation appended, and the real compiler's "repeated annotation" checker crashes trying to report that because the synthetic annotation has no PSI source. Now tracked by declaration identity so each one is only injected once. Adds full support for expect interfaces, fun interfaces, and enum classes (with entries and constructor args), class modality (open/abstract/sealed), where-clause bounds, and constructor-property parameters - each verified by actually compiling the generated stub, not just inspecting it. expect data classes and expect classes initializing a real superclass constructor are both confirmed hard Kotlin-language restrictions (not gaps in this plugin), so the constructor/superclass-call handling here is deliberately scoped to match what's actually expressible in valid expect-class syntax. Also wires the compiler plugin's FIR extension into stubUnfulfilledExpects() (previously only actualizes() got it), so the manual @Suppress this file used to add to every generated declaration is no longer needed - the FIR extension now covers this file the same way it covers hand-written expect/actual code. --- .../compiler/fir/ActualizerFirExtensions.kt | 11 + .../gradle/ActualizerGradlePlugin.kt | 28 +- .../actualizer/gradle/ExpectStubGenerator.kt | 429 +++++++++++++++--- 3 files changed, 399 insertions(+), 69 deletions(-) diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt index 922e5fd..2592f47 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt @@ -111,6 +111,16 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS private val fileNeedsSuppressionCache = mutableMapOf() + // K2's status resolution can invoke transformStatus more than once for the same declaration + // (e.g. across separate resolution passes for a generic class's members). Appending another + // @Suppress each time would give the declaration two, and the real compiler's own + // "repeated annotation" checker then crashes trying to report that - our synthetic annotation + // has no PSI source, and that checker unconditionally requires one to point the diagnostic at. + // Tracked by identity, not equality: FIR declarations are mutated in place across passes, so + // the same object recurring is exactly the case this guards against. + private val alreadyInjected: MutableSet = + java.util.Collections.newSetFromMap(java.util.IdentityHashMap()) + private fun containingFile(declaration: FirMemberDeclaration): FirFile? { val provider = session.firProvider return when (declaration) { @@ -137,6 +147,7 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS } private fun inject(declaration: FirDeclaration) { + if (!alreadyInjected.add(declaration)) return val annotation = buildSuppressAnnotationOrNull(session) ?: return try { declaration.replaceAnnotations(declaration.annotations + annotation) diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index da6f3e1..b229a5f 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -102,14 +102,38 @@ class ActualizerGradlePlugin @Inject constructor( addSourceDir(project, "main", listOf(outputDir)) + // Registers the same compiler plugin wireCrossModuleActualization does, purely so its FIR + // extension is present here too and suppresses the IDE-only expect/actual false positives + // on this module's declarations - including the generated stub itself, which no longer + // carries its own manual @Suppress (see ExpectStubGenerator.kt). moduleMap/reportOutput are + // left unset: there's no foreign module merge here for the IR extension's report to say + // anything about. + val compilerPluginClasspath = project.configurations.detachedConfiguration( + project.dependencies.create(COMPILER_PLUGIN_COORDINATES) + ) + // The whole source set counts as "common" here, not just the files with an expect in // them - mirroring how a real commonMain source set is treated. val commonSourcesValue = mainFiles.joinToString(",") { it.absolutePath } val compileKotlinTask = project.tasks.named("compileKotlin") compileKotlinTask.configure { task -> - task.dependsOn(generateTask) + task.dependsOn(generateTask, compilerPluginClasspath) + task.inputs.files(compilerPluginClasspath).withPropertyName("actualizerCompilerPluginClasspath") val freeCompilerArgs = freeCompilerArgsProperty(task) - freeCompilerArgs.addAll(listOf("-Xmulti-platform", "-Xcommon-sources=$commonSourcesValue")) + freeCompilerArgs.addAll( + project.provider { + val pluginJar = compilerPluginClasspath.files + .first { it.name.startsWith("compiler-plugin") } + .absolutePath + listOf( + "-Xmulti-platform", + "-Xcommon-sources=$commonSourcesValue", + "-Xplugin=$pluginJar", + "-P", + "plugin:$PLUGIN_ID:selfModule=${project.path}", + ) + } + ) } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt index 57862cc..e435c47 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ExpectStubGenerator.kt @@ -1,6 +1,5 @@ package net.kernelpanicsoft.actualizer.gradle -import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec @@ -9,8 +8,11 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.LambdaTypeName import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.STAR import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.TypeVariableName +import com.squareup.kotlinpoet.WildcardTypeName import org.jetbrains.kotlin.K1Deprecation import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment @@ -20,16 +22,23 @@ import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtClassOrObject 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 import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtProjectionKind import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtPsiFactory +import org.jetbrains.kotlin.psi.KtSecondaryConstructor +import org.jetbrains.kotlin.psi.KtSuperTypeCallEntry +import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtTypeElement +import org.jetbrains.kotlin.psi.KtTypeParameterListOwner import org.jetbrains.kotlin.psi.KtTypeReference import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.KtValueArgument import java.io.File // Scans and generates stub actuals for unfulfilled expect declarations, backing @@ -38,13 +47,25 @@ import java.io.File // odd formatting are handled correctly. Parsing is syntax-only - no semantic resolution, since // that's exactly what would fail on the unfulfilled expects being scanned for. Types are read by // walking the PSI type tree directly (see KtTypeReference.toTypeName below) rather than -// re-parsing text, which is what makes nested function types resolve correctly. Out of scope: -// generics on the containing function/class, supertypes, secondary constructors, nested types, -// and constructor-parameter auto-properties. Generated code goes through KotlinPoet rather than -// string concatenation. +// re-parsing text, which is what makes nested function types resolve correctly. A function's or +// class's own type parameters (``, plus `where` clauses) are captured too, including +// `out`/`in`/`*` variance on type arguments, supertypes, secondary constructors, nested expect +// classes, and constructor `val`/`var` parameters - all of that is part of what the real +// actual-checker compares against the expect. Out of scope: annotations, contracts, and +// destructuring in parameters. Generated code goes through KotlinPoet rather than string +// concatenation. internal data class ParamText(val name: String, val type: TypeName) +internal enum class ValOrVar { NONE, VAL, VAR } + +internal data class ConstructorParamText(val name: String, val type: TypeName, val valOrVar: ValOrVar = ValOrVar.NONE) + +internal enum class ClassKind { CLASS, INTERFACE, FUN_INTERFACE, OBJECT, ENUM } + +/** An enum entry's name, plus its constructor call's argument expressions as raw source text, if any. */ +internal data class EnumEntryText(val name: String, val argumentTexts: List = emptyList()) + internal sealed class ExpectMember { abstract val name: String @@ -53,6 +74,7 @@ internal sealed class ExpectMember { val params: List, val returnType: TypeName, val suspending: Boolean = false, + val typeVariables: List = emptyList(), ) : ExpectMember() data class Property( @@ -64,9 +86,26 @@ internal sealed class ExpectMember { internal data class ExpectClassInfo( val name: String, - val isObject: Boolean, - val constructorParams: List, + val kind: ClassKind, + val constructorParams: List, val members: List, + val typeVariables: List = emptyList(), + // Every entry in the expect class's supertype list, treated uniformly as an interface to + // implement - see toClassInfo's doc for why a real superclass can't be told apart from an + // interface here, and can't have its constructor called from an expect class anyway. + val supertypes: List = emptyList(), + val secondaryConstructors: List> = emptyList(), + val nestedClasses: List = emptyList(), + val enumEntries: List = emptyList(), + // Only meaningful for CLASS - interfaces/fun interfaces are implicitly abstract/open already, + // enum classes and objects can't take a modality modifier at all. null means final (the + // default - Kotlin rarely writes `final` explicitly). + val modalityModifier: KModifier? = null, + // Kotlin doesn't synthesize an implicit no-arg primary constructor for a class that declares + // secondary constructors but no primary - those secondaries must delegate to `super(...)` + // instead of `this(...)`. True whenever there either is a primary constructor or there are no + // secondary constructors to worry about. + val hasPrimaryConstructor: Boolean = true, ) internal data class ScannedExpectFile( @@ -94,22 +133,53 @@ internal fun scanForExpectFunctions(files: Iterable): List scanKtFile(file, psiFactory.createFile(file.name, file.readText())) } + val parsed = ktFiles.map { file -> file to psiFactory.createFile(file.name, file.readText()) } + // A hand-written `actual` - a class, a function, a property, or (since `expect class Foo` + // with no members is exactly how a real actualization aliases to an existing type, e.g. + // `actual typealias Foo = String`) a typealias - already fulfilling one of these expects + // elsewhere in this same scan means this isn't actually unfulfilled; skip it rather than + // generating a second, conflicting actual for the same name. + val alreadyActualized = collectAlreadyActualized(parsed.map { it.second }) + return parsed.mapNotNull { (file, ktFile) -> scanKtFile(file, ktFile, alreadyActualized) } } finally { Disposer.dispose(disposable) } } -private fun scanKtFile(sourceFile: File, ktFile: KtFile): ScannedExpectFile? { +/** Top-level `actual` declarations' (package, simple name) pairs, across every file in this scan. */ +private fun collectAlreadyActualized(ktFiles: List): Set> { + val result = mutableSetOf>() + for (ktFile in ktFiles) { + val packageName = ktFile.packageFqName.asString() + for (declaration in ktFile.declarations) { + if (!declaration.hasModifier(KtTokens.ACTUAL_KEYWORD)) continue + val name = when (declaration) { + is KtTypeAlias -> declaration.name + is KtNamedFunction -> declaration.name + is KtProperty -> declaration.name + is KtClassOrObject -> declaration.name + else -> null + } ?: continue + result += packageName to name + } + } + return result +} + +private fun scanKtFile(sourceFile: File, ktFile: KtFile, alreadyActualized: Set>): ScannedExpectFile? { val topLevel = mutableListOf() val classes = mutableListOf() val context = TypeContext(importedSimpleNames(ktFile), ktFile.packageFqName.asString()) + fun isFulfilled(name: String?) = name != null && (context.packageName to name) in alreadyActualized for (declaration in ktFile.declarations) { when { - declaration is KtNamedFunction && declaration.isExpect() -> topLevel += toFunctionMember(declaration, context) - declaration is KtProperty && declaration.isExpect() -> topLevel += toPropertyMember(declaration, context) - declaration is KtClassOrObject && declaration.isExpect() -> classes += toClassInfo(declaration, context) + declaration is KtNamedFunction && declaration.isExpect() && !isFulfilled(declaration.name) -> + topLevel += toFunctionMember(declaration, context) + declaration is KtProperty && declaration.isExpect() && !isFulfilled(declaration.name) -> + topLevel += toPropertyMember(declaration, context) + declaration is KtClassOrObject && declaration.isExpect() && !isFulfilled(declaration.name) -> + classes += toClassInfo(declaration, context) } } @@ -119,8 +189,39 @@ private fun scanKtFile(sourceFile: File, ktFile: KtFile): ScannedExpectFile? { private fun KtDeclaration.isExpect(): Boolean = hasModifier(KtTokens.EXPECT_KEYWORD) -/** Simple name to fully-qualified name, plus the file's own package - see [classNameFor]. */ -private class TypeContext(val imports: Map, val packageName: String) +/** + * Simple name to fully-qualified name, plus the file's own package - see [classNameFor] - and the + * names of any type parameters currently in scope (from an enclosing `expect class`/`expect fun`), + * so a bare `T` resolves to a [TypeVariableName] instead of being mistaken for a sibling class in + * the same package. + */ +private class TypeContext( + val imports: Map, + val packageName: String, + val typeParameterNames: Set = emptySet(), +) { + fun withTypeParameters(names: Collection): TypeContext = + if (names.isEmpty()) this else TypeContext(imports, packageName, typeParameterNames + names) +} + +/** + * Combines each type parameter's `` upper bound with any extra bounds from a trailing + * `where T : A, T : B` clause, since both are valid ways to add bounds to the same `T`. + */ +private fun KtTypeParameterListOwner.typeVariableNames(context: TypeContext): List { + val whereBounds = typeConstraints + .mapNotNull { constraint -> + val name = constraint.subjectTypeParameterName?.getReferencedName() ?: return@mapNotNull null + val bound = constraint.boundTypeReference ?: return@mapNotNull null + name to bound + } + .groupBy({ it.first }, { it.second }) + return typeParameters.map { param -> + val name = param.name ?: "T" + val bounds = (listOfNotNull(param.extendsBound) + whereBounds[name].orEmpty()).map { it.toTypeName(context) } + if (bounds.isEmpty()) TypeVariableName(name) else TypeVariableName(name, *bounds.toTypedArray()) + } +} /** Simple name to fully-qualified name from [ktFile]'s `import` directives (star imports are skipped, since expanding them needs semantic resolution). */ private fun importedSimpleNames(ktFile: KtFile): Map = @@ -132,14 +233,17 @@ private fun importedSimpleNames(ktFile: KtFile): Map = }.toMap() private fun toFunctionMember(function: KtNamedFunction, context: TypeContext): ExpectMember.Function { + val typeVariables = function.typeVariableNames(context) + val innerContext = context.withTypeParameters(typeVariables.map { it.name }) val params = function.valueParameters.map { param -> - ParamText(param.name ?: "arg", param.typeReference?.toTypeName(context) ?: ANY) + ParamText(param.name ?: "arg", param.typeReference?.toTypeName(innerContext) ?: ANY) } return ExpectMember.Function( name = function.name ?: error("Actualizer: found an unnamed 'expect fun' in ${function.containingFile.name}"), params = params, - returnType = function.typeReference?.toTypeName(context) ?: UNIT, + returnType = function.typeReference?.toTypeName(innerContext) ?: UNIT, suspending = function.hasModifier(KtTokens.SUSPEND_KEYWORD), + typeVariables = typeVariables, ) } @@ -156,23 +260,107 @@ private fun toPropertyMember(property: KtProperty, context: TypeContext): Expect ) } +private fun classKindOf(cls: KtClassOrObject): ClassKind = when { + cls is KtObjectDeclaration -> ClassKind.OBJECT + cls is KtClass && cls.isInterface() -> if (cls.hasModifier(KtTokens.FUN_KEYWORD)) ClassKind.FUN_INTERFACE else ClassKind.INTERFACE + cls is KtClass && cls.isEnum() -> ClassKind.ENUM + else -> ClassKind.CLASS +} + +/** Only meaningful for [ClassKind.CLASS] - interfaces are implicitly open, enums/objects can't take one at all. */ +private fun modalityModifierOf(cls: KtClassOrObject, kind: ClassKind): KModifier? { + if (kind != ClassKind.CLASS) return null + return when { + cls.hasModifier(KtTokens.SEALED_KEYWORD) -> KModifier.SEALED + cls.hasModifier(KtTokens.ABSTRACT_KEYWORD) -> KModifier.ABSTRACT + cls.hasModifier(KtTokens.OPEN_KEYWORD) -> KModifier.OPEN + else -> null + } +} + private fun toClassInfo(cls: KtClassOrObject, context: TypeContext): ExpectClassInfo { - // KtObjectDeclaration (expect object) has no primary constructor - only KtClass does. - val constructorParams = (cls as? KtClass)?.primaryConstructor?.valueParameters.orEmpty().map { param -> - ParamText(param.name ?: "arg", param.typeReference?.toTypeName(context) ?: ANY) + val kind = classKindOf(cls) + val typeVariables = cls.typeVariableNames(context) + val innerContext = context.withTypeParameters(typeVariables.map { it.name }) + + // KtObjectDeclaration/an interface has no primary/secondary constructors - only a class or enum + // class does, but both accessors live on KtClassOrObject itself and just return null/empty + // otherwise. + val primaryConstructor = cls.primaryConstructor + val constructorParams = primaryConstructor?.valueParameters.orEmpty().map { param -> + ConstructorParamText( + name = param.name ?: "arg", + type = param.typeReference?.toTypeName(innerContext) ?: ANY, + // Kotlin doesn't currently allow `val`/`var` directly on an expect class's primary + // constructor parameters at all ("Expected class constructor cannot have a property + // parameter") - not even for the constructor Kotlin itself would otherwise require one + // for, like a data class (which is why `expect data class` doesn't exist either). So + // this is always ValOrVar.NONE for real input; kept in case that restriction is ever + // relaxed, since handling it right there costs nothing extra. + valOrVar = when { + !param.hasValOrVar() -> ValOrVar.NONE + param.isMutable -> ValOrVar.VAR + else -> ValOrVar.VAL + }, + ) } - val members = cls.body?.declarations.orEmpty().mapNotNull { member -> - when (member) { - is KtNamedFunction -> toFunctionMember(member, context) - is KtProperty -> toPropertyMember(member, context) - else -> null // nested types, secondary constructors, etc. - out of scope. + val secondaryConstructorNodes = cls.secondaryConstructors + // A class only gets an implicit no-arg primary constructor when it declares no constructor at + // all; one with only secondary constructors has none, and those must delegate to `super(...)` + // instead of `this(...)` - see hasPrimaryConstructor's doc. + val hasPrimaryConstructor = primaryConstructor != null || secondaryConstructorNodes.isEmpty() + + // Every entry here is a bare type reference, never a constructor call - Kotlin doesn't let an + // expect class initialize a real superclass at all ("Expected classes cannot initialize + // supertypes"), even a no-arg one; `expect class Foo : Base, Marker` only ever writes bare + // names, whether Base is a class or an interface. Without semantic resolution there's no way + // to tell which of these (if any) is a real class rather than an interface, so all of them are + // added the same way below - see ExpectClassInfo.supertypes's doc. + val supertypes = cls.superTypeListEntries.mapNotNull { entry -> entry.typeReference?.toTypeName(innerContext) } + + val secondaryConstructors = secondaryConstructorNodes.map { ctor -> + ctor.valueParameters.map { param -> + ParamText(param.name ?: "arg", param.typeReference?.toTypeName(innerContext) ?: ANY) + } + } + + val members = mutableListOf() + val nestedClasses = mutableListOf() + val enumEntries = mutableListOf() + for (member in cls.body?.declarations.orEmpty()) { + when { + // KtEnumEntry extends KtClass (itself a KtClassOrObject), so this has to come before + // the plain-function/nested-class checks below or an entry would be misread as one. + member is KtEnumEntry -> enumEntries += EnumEntryText( + name = member.name ?: "ENTRY", + argumentTexts = (member.superTypeListEntries.firstOrNull() as? KtSuperTypeCallEntry) + ?.valueArguments.orEmpty() + .mapNotNull { (it as? KtValueArgument)?.getArgumentExpression()?.text }, + ) + member is KtNamedFunction -> members += toFunctionMember(member, innerContext) + member is KtProperty -> members += toPropertyMember(member, innerContext) + // A nested class inside an expect class's body is always implicitly part of the + // expected shape too - Kotlin doesn't allow (and doesn't require) marking it `expect` + // itself ("Modifier 'expect' is not applicable to 'nested class'"), unlike a top-level + // expect class/object, which does need the explicit keyword. + member is KtClassOrObject -> nestedClasses += toClassInfo(member, innerContext) + // Secondary constructors are handled above; a companion object or init block doesn't + // need an actual stub of its own - out of scope. } } + return ExpectClassInfo( name = cls.name ?: error("Actualizer: found an unnamed 'expect class'/'expect object' in ${cls.containingFile.name}"), - isObject = cls is KtObjectDeclaration, + kind = kind, constructorParams = constructorParams, members = members, + typeVariables = typeVariables, + supertypes = supertypes, + secondaryConstructors = secondaryConstructors, + nestedClasses = nestedClasses, + enumEntries = enumEntries, + modalityModifier = modalityModifierOf(cls, kind), + hasPrimaryConstructor = hasPrimaryConstructor, ) } @@ -200,10 +388,14 @@ private val UNIT: TypeName = KOTLIN_BUILTIN_TYPES.getValue("Unit") * and nullable-wrapped lambdas for free. * * Still syntax only, not resolved semantics: an unqualified [KtUserType] is matched against - * [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES] and [TypeContext.imports], falling back to a - * sibling type in the same package. That fallback is a guess - a star-imported type, or one - * genuinely in the default package, resolves to the wrong package. Write fully-qualified types if - * that matters. + * [KOTLIN_BUILTIN_TYPES]/[KOTLIN_COLLECTION_TYPES], [TypeContext.imports], and any type parameter + * currently in scope ([TypeContext.typeParameterNames]), falling back to a sibling type in the + * same package. That fallback is a guess - a star-imported type, or one genuinely in the default + * package, resolves to the wrong package. Write fully-qualified types if that matters. A qualified + * reference like `HolderLookup.Provider` resolves its outer segment (`HolderLookup`) the same way, + * then nests - so an imported outer class's nested type resolves correctly, not just top-level + * types. Variance on type arguments (`out`/`in`/`*`) is preserved, since it's part of what makes an + * `actual` match its `expect` in the first place. */ private fun KtTypeReference.toTypeName(context: TypeContext): TypeName { val element = typeElement ?: return ANY @@ -226,14 +418,31 @@ private fun KtTypeElement.toTypeName(context: TypeContext, suspending: Boolean = } is KtUserType -> { val simpleName = referencedName ?: "Any" - val qualifierText = qualifier?.text - val base = if (qualifierText != null) { - ClassName.bestGuess("$qualifierText.$simpleName") + val qualifierNode = qualifier + if (qualifierNode == null && simpleName in context.typeParameterNames) { + TypeVariableName(simpleName) } else { - classNameFor(simpleName, context) + // A qualifier like `HolderLookup` in `HolderLookup.Provider` is usually an imported + // outer class's simple name, not a package prefix - resolving it the same way a bare + // reference would (imports/builtins/same-package fallback) and nesting from there + // handles that. Only a qualifier that's genuinely unresolvable falls back to the old + // bestGuess behavior, which only works when the qualifier text already is a package. + val base = if (qualifierNode != null) { + (qualifierNode.toTypeName(context) as? ClassName)?.nestedClass(simpleName) + ?: ClassName.bestGuess("${qualifierNode.text}.$simpleName") + } else { + classNameFor(simpleName, context) + } + val typeArgs = typeArgumentList?.arguments.orEmpty().map { projection -> + when (projection.projectionKind) { + KtProjectionKind.STAR -> STAR + KtProjectionKind.OUT -> WildcardTypeName.producerOf(projection.typeReference?.toTypeName(context) ?: ANY) + KtProjectionKind.IN -> WildcardTypeName.consumerOf(projection.typeReference?.toTypeName(context) ?: ANY) + KtProjectionKind.NONE -> projection.typeReference?.toTypeName(context) ?: ANY + } + } + if (typeArgs.isEmpty()) base else base.parameterizedBy(typeArgs) } - val typeArgs = typeArgumentList?.arguments.orEmpty().mapNotNull { it.typeReference?.toTypeName(context) } - if (typeArgs.isEmpty()) base else base.parameterizedBy(typeArgs) } else -> ANY // KtDynamicType (JS-only) etc. - out of scope. } @@ -247,17 +456,12 @@ private fun classNameFor(simpleName: String, context: TypeContext): ClassName { private val illegalStateExceptionClass = ClassName("kotlin", "IllegalStateException") -// The IDE's live analysis of a plain kotlin("jvm") module reports expect/actual as illegally -// coexisting even though -Xmulti-platform makes the real build succeed - see the root README's -// "IDE false positives" section. Safe to suppress unconditionally: every generated stub actual -// only exists because it's actualizing a real expect Actualizer found. -private val suppressIdeExpectActualFalsePositives: AnnotationSpec = AnnotationSpec.builder(ClassName("kotlin", "Suppress")) - .addMember("%S", "EXPECT_AND_ACTUAL_IN_THE_SAME_MODULE") - .addMember("%S", "ACTUAL_WITHOUT_EXPECT") - .addMember("%S", "ERROR_SUPPRESSION") - .addMember("%S", "NOT_A_MULTIPLATFORM_COMPILATION") - .build() - +// The IDE's live analysis of a plain kotlin("jvm") module would otherwise report expect/actual as +// illegally coexisting, even though -Xmulti-platform makes the real build succeed - see the root +// README's "IDE false positives" section. No manual @Suppress needed on the generated declarations +// for that: ActualizerGradlePlugin.wireStubGeneration registers the same compiler plugin +// wireCrossModuleActualization does, whose FIR extension (ActualizerFirExtensions.kt) auto-injects +// it into every file containing an expect/actual, including this generated one. private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of( "throw %T(%S)", illegalStateExceptionClass, @@ -266,10 +470,20 @@ private fun throwStatement(fqName: String): CodeBlock = CodeBlock.of( "(an actualizer { actualizes(...) } leaf) to get the real implementation.", ) -private fun buildFunctionStub(member: ExpectMember.Function, fqNamePrefix: String): FunSpec { +/** + * [keepAbstract] is for members of an `interface`/`fun interface`: an interface function's default + * modality is `open` if it has a body, but `abstract` if it doesn't, and the expect side is always + * `abstract` (interfaces can't have bodies at all in `expect` code) - giving it a throwing body + * here would make the actual `open`, an expect/actual modality mismatch. For a `fun interface` + * specifically there's a second reason too: Kotlin requires exactly one truly abstract member + * there, so a body would also make it stop being a functional interface. Leaving it abstract is + * fine either way - nothing here needs to implement it; whatever concretely implements the + * interface (or lambda, for a fun interface) does that. + */ +private fun buildFunctionStub(member: ExpectMember.Function, fqNamePrefix: String, keepAbstract: Boolean = false): FunSpec { val builder = FunSpec.builder(member.name) .addModifiers(KModifier.ACTUAL) - .addAnnotation(suppressIdeExpectActualFalsePositives) + .addTypeVariables(member.typeVariables) if (member.suspending) { builder.addModifiers(KModifier.SUSPEND) } @@ -279,18 +493,29 @@ private fun buildFunctionStub(member: ExpectMember.Function, fqNamePrefix: Strin if (member.returnType != UNIT) { builder.returns(member.returnType) } - builder.addCode(throwStatement("$fqNamePrefix.${member.name}")) + if (keepAbstract) { + builder.addModifiers(KModifier.ABSTRACT) + } else { + builder.addCode(throwStatement("$fqNamePrefix.${member.name}")) + } return builder.build() } -private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: String): PropertySpec { +// See buildFunctionStub's keepAbstract doc - the same modality mismatch applies to a property in +// a plain (non-fun) interface: giving its accessor a body makes it `open` by default, not +// `abstract` like the expect declares, so a member of an INTERFACE/FUN_INTERFACE stays abstract +// (no accessor bodies at all) instead of throwing. +private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: String, keepAbstract: Boolean = false): PropertySpec { val type = member.type val fqName = "$fqNamePrefix.${member.name}" val builder = PropertySpec.builder(member.name, type) .addModifiers(KModifier.ACTUAL) - .addAnnotation(suppressIdeExpectActualFalsePositives) .mutable(member.mutable) - .getter(FunSpec.getterBuilder().addCode(throwStatement(fqName)).build()) + if (keepAbstract) { + builder.addModifiers(KModifier.ABSTRACT) + return builder.build() + } + builder.getter(FunSpec.getterBuilder().addCode(throwStatement(fqName)).build()) if (member.mutable) { builder.setter( FunSpec.setterBuilder() @@ -304,28 +529,98 @@ private fun buildPropertyStub(member: ExpectMember.Property, fqNamePrefix: Strin private fun buildClassStub(cls: ExpectClassInfo, packageName: String): TypeSpec { val classFqName = "$packageName.${cls.name}" - val builder = (if (cls.isObject) TypeSpec.objectBuilder(cls.name) else TypeSpec.classBuilder(cls.name)) + val builder = when (cls.kind) { + ClassKind.OBJECT -> TypeSpec.objectBuilder(cls.name) + ClassKind.INTERFACE -> TypeSpec.interfaceBuilder(cls.name) + ClassKind.FUN_INTERFACE -> TypeSpec.funInterfaceBuilder(cls.name) + ClassKind.ENUM -> TypeSpec.enumBuilder(cls.name) + ClassKind.CLASS -> TypeSpec.classBuilder(cls.name) + } .addModifiers(KModifier.ACTUAL) - .addAnnotation(suppressIdeExpectActualFalsePositives) - - // expect object has no primary constructor to actualize - it's a singleton. - if (!cls.isObject && cls.constructorParams.isNotEmpty()) { - // The primary constructor needs its own `actual` modifier, not just the class. - val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL) - cls.constructorParams.forEach { ctor.addParameter(it.name, it.type) } - builder.primaryConstructor(ctor.build()) + .addTypeVariables(cls.typeVariables) + cls.modalityModifier?.let { builder.addModifiers(it) } + + cls.supertypes.forEach { builder.addSuperinterface(it) } + + // Enum constants have to be added before any other member (init block, constructors, + // functions) or KotlinPoet won't emit the `;` separator real Kotlin syntax requires between + // the constant list and the rest of an enum class body that has one. + for (entry in cls.enumEntries) { + if (entry.argumentTexts.isEmpty()) { + builder.addEnumConstant(entry.name) + } else { + val entryBody = TypeSpec.anonymousClassBuilder() + entry.argumentTexts.forEach { text -> entryBody.addSuperclassConstructorParameter("%L", text) } + builder.addEnumConstant(entry.name, entryBody.build()) + } } - // Throws on construction so the error surfaces immediately rather than only when a - // particular member gets called - the member stubs still need to exist to match the - // expect class's shape, though. - builder.addInitializerBlock(throwStatement(classFqName)) + // Interfaces/fun interfaces can't have a constructor or an init block at all - only their + // member stubs (built below) can throw. + if (cls.kind != ClassKind.INTERFACE && cls.kind != ClassKind.FUN_INTERFACE) { + // expect object has no primary constructor to actualize - it's a singleton. + if (cls.kind != ClassKind.OBJECT && cls.constructorParams.isNotEmpty()) { + // The primary constructor needs its own `actual` modifier, not just the class. + val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL) + cls.constructorParams.forEach { param -> + ctor.addParameter(param.name, param.type) + // A constructor parameter declared `val`/`var` is also a property - KotlinPoet + // recognizes a property whose initializer is exactly its matching constructor + // parameter's name and inlines it into the parameter list (`actual constructor(val x: T)`) + // instead of emitting a separate property. See ConstructorParamText.valOrVar's doc + // for why this never actually triggers for a real expect class today. + when (param.valOrVar) { + ValOrVar.VAL -> builder.addProperty(PropertySpec.builder(param.name, param.type).initializer(param.name).build()) + ValOrVar.VAR -> builder.addProperty(PropertySpec.builder(param.name, param.type).mutable(true).initializer(param.name).build()) + ValOrVar.NONE -> {} + } + } + builder.primaryConstructor(ctor.build()) + } + // Throws on construction so the error surfaces immediately rather than only when a + // particular member gets called - the member stubs still need to exist to match the + // expect class's shape, though. Skipped for ENUM: Kotlin doesn't let an expect enum class + // have a constructor at all, so there's no "construction" to intercept the same way - its + // entries are exactly the actualized shape already, not unimplemented behavior. It's also + // a KotlinPoet 1.18.1 rendering bug for this specific combination - an init block right + // after enum constants with no constructor doesn't get the required `;` separator. + if (cls.kind != ClassKind.ENUM) { + builder.addInitializerBlock(throwStatement(classFqName)) + } + + for (params in cls.secondaryConstructors) { + val ctor = FunSpec.constructorBuilder().addModifiers(KModifier.ACTUAL) + params.forEach { ctor.addParameter(it.name, it.type) } + if (cls.hasPrimaryConstructor) { + // Every delegation argument is a `throw` expression, which types as Nothing - a + // subtype of any parameter type, so this always type-checks regardless of what the + // primary constructor's real parameter types are. Only the first argument ever + // actually runs; it throws before a second one would be evaluated, so construction + // still fails with the same clear message as the primary constructor's own init + // block, not a confusing raw exception from a fabricated dummy value. The argument + // count has to match the *primary* constructor's arity, not this secondary + // constructor's own. + ctor.callThisConstructor(List(cls.constructorParams.size) { throwStatement(classFqName) }) + } else { + // No primary constructor to delegate to - falls back to a no-arg super(), which is + // only correct if the real superclass also has a no-arg constructor. Out of scope: + // resolving the actual superclass constructor's real arity without semantic analysis. + ctor.callSuperConstructor(emptyList()) + } + builder.addFunction(ctor.build()) + } + } + + val keepMembersAbstract = cls.kind == ClassKind.INTERFACE || cls.kind == ClassKind.FUN_INTERFACE for (member in cls.members) { when (member) { - is ExpectMember.Function -> builder.addFunction(buildFunctionStub(member, classFqName)) - is ExpectMember.Property -> builder.addProperty(buildPropertyStub(member, classFqName)) + is ExpectMember.Function -> builder.addFunction(buildFunctionStub(member, classFqName, keepMembersAbstract)) + is ExpectMember.Property -> builder.addProperty(buildPropertyStub(member, classFqName, keepMembersAbstract)) } } + for (nested in cls.nestedClasses) { + builder.addType(buildClassStub(nested, classFqName)) + } return builder.build() } From 10d7a82ad81099dec31f77047dd677032c4c2f5d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:14:03 +0000 Subject: [PATCH 17/19] Make IR extension registration fail-safe like the FIR extension already is IrGenerationExtension.registerExtension was the one call in ActualizerCompilerPluginRegistrar not wrapped in the Throwable-catching fail-safe the FIR extension registration already has, even though it reaches into the same kind of internal, version-specific compiler machinery. A real user compiling through an out-of-process Kotlin daemon on a different Kotlin version than 2.4.10 hit exactly that: a NoClassDefFoundError on org.jetbrains.kotlin.extensions.ExtensionPointDescriptor took down their entire build. ActualizerIrExtension only observes expect/actual linking the frontend already resolved, to write an optional JSON report and log lines - losing it should never break a real compile, matching how the FIR extension's own failure is already handled. --- .../ActualizerCompilerPluginRegistrar.kt | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index 9236d2b..7ed0916 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -43,18 +43,37 @@ class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { ) } - val moduleMap = parseModuleMap(configuration.get(ActualizerCommandLineProcessor.KEY_MODULE_MAP).orEmpty()) - val selfModule = configuration.get(ActualizerCommandLineProcessor.KEY_SELF_MODULE) ?: "" - val reportOutput = configuration.get(ActualizerCommandLineProcessor.KEY_REPORT_OUTPUT) + // ActualizerIrExtension only observes expect/actual linking the frontend already resolved + // (see its own doc), to write an optional JSON report and log messages - it's not required + // for real compilation to succeed either. IrGenerationExtension.registerExtension itself + // reaches into the same kind of internal, version-specific compiler machinery the FIR + // extension above does (e.g. an out-of-process Kotlin daemon on an older/different Kotlin + // version than 2.4.10 this plugin compiles against can be missing a class like + // org.jetbrains.kotlin.extensions.ExtensionPointDescriptor entirely), so the same + // Throwable-catching fail-safe applies here too - a missing report/log line should never + // take down a real build. + try { + val moduleMap = parseModuleMap(configuration.get(ActualizerCommandLineProcessor.KEY_MODULE_MAP).orEmpty()) + val selfModule = configuration.get(ActualizerCommandLineProcessor.KEY_SELF_MODULE) ?: "" + val reportOutput = configuration.get(ActualizerCommandLineProcessor.KEY_REPORT_OUTPUT) - IrGenerationExtension.registerExtension( - ActualizerIrExtension( - moduleMap = moduleMap, - selfModule = selfModule, - reportOutputPath = reportOutput, - messageCollector = messageCollector, + IrGenerationExtension.registerExtension( + ActualizerIrExtension( + moduleMap = moduleMap, + selfModule = selfModule, + reportOutputPath = reportOutput, + messageCollector = messageCollector, + ) ) - ) + } catch (t: Throwable) { + messageCollector.report( + CompilerMessageSeverity.WARNING, + "Actualizer: could not register the IR extension that reports cross-module " + + "actualization links (${t::class.simpleName}: ${t.message}). This doesn't " + + "affect the actual build - only the optional report/log output.", + null as CompilerMessageSourceLocation?, + ) + } } /** Parses the `root::moduleName||root::moduleName...` option value from [ActualizerCommandLineProcessor]. */ From 026db2d7df60c3a4551e1a764c04adfb39191f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:28:11 +0000 Subject: [PATCH 18/19] Move cross-module link report out of the compiler, merge into existing @Suppress Two real crashes from an external consumer's build, both class-loading / annotation-repetition issues in the compiler-plugin's internal-API usage: 1. IrGenerationExtension.registerExtension threw NoClassDefFoundError on org.jetbrains.kotlin.extensions.ExtensionPointDescriptor on a Kotlin daemon running a different Kotlin version than this plugin compiles against. Decompiling confirmed FirExtensionRegistrarAdapter's own registration goes through the exact same ExtensionPointDescriptor superclass, so a different compiler-extension type wouldn't have been any more stable - but ActualizerIrExtension never actually generated or transformed IR, only read file paths/packages/names off it to write an optional JSON report. That information is available directly from source text, so there was never a real need to run inside the compiler for it at all. Removed the IR extension entirely; the same package-name-heuristic report is now computed in the Gradle plugin via plain PSI scanning (ActualizerLinkReport.kt), in its own isolated worker classloader like stub generation already uses, so it can't be broken by any Kotlin compiler/daemon version. 2. A declaration that already had its own hand-written @Suppress (e.g. `@Suppress("unused") actual object Foo`) crashed when the FIR extension added a second one alongside it: the real compiler's "repeated annotation" checker treats two @Suppress on one declaration as an error regardless of what either suppresses, and then crashes trying to report that diagnostic because our synthetic annotation has no PSI source. Fixed by merging into the existing annotation's argument list in place (mergeSuppressNames) instead of attaching a second one, so a declaration only ever ends up with the one @Suppress it already had, just covering more names when needed. Also simplifies ActualizerCommandLineProcessor/ActualizerCompilerPluginRegistrar now that moduleMap/selfModule/reportOutput are no longer needed by anything in the compiler plugin. --- .../ActualizerCommandLineProcessor.kt | 51 ++------ .../ActualizerCompilerPluginRegistrar.kt | 53 +------- .../compiler/fir/ActualizerFirExtensions.kt | 75 ++++++++---- .../compiler/ir/ActualizerIrExtension.kt | 115 ------------------ .../gradle/ActualizerGradlePlugin.kt | 57 ++++++--- .../actualizer/gradle/ActualizerLinkReport.kt | 100 +++++++++++++++ .../gradle/GenerateLinkReportWorkAction.kt | 37 ++++++ 7 files changed, 240 insertions(+), 248 deletions(-) delete mode 100644 plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt create mode 100644 plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt create mode 100644 plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt index e04977e..3b9d3ee 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCommandLineProcessor.kt @@ -1,66 +1,29 @@ package net.kernelpanicsoft.actualizer.compiler import org.jetbrains.kotlin.compiler.plugin.AbstractCliOption -import org.jetbrains.kotlin.compiler.plugin.CliOption import org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CompilerConfiguration -import org.jetbrains.kotlin.config.CompilerConfigurationKey /** - * Reads the `-P plugin:net.kernelpanicsoft.actualizer:=` options the Gradle plugin - * passes in and stores them on the [CompilerConfiguration] for [ActualizerCompilerPluginRegistrar] - * to read back out. + * Registers this plugin's ID with the compiler. There are currently no `-P` options to read: the + * cross-module actualization report this plugin used to accept `moduleMap`/`selfModule`/ + * `reportOutput` options for is now generated entirely in the Gradle plugin instead (see + * `ActualizerLinkReport.kt`), so [ActualizerCompilerPluginRegistrar] no longer needs anything + * passed in here. */ @OptIn(ExperimentalCompilerApi::class) class ActualizerCommandLineProcessor : CommandLineProcessor { override val pluginId: String = PLUGIN_ID - override val pluginOptions: Collection = listOf( - MODULE_MAP_OPTION, - SELF_MODULE_OPTION, - REPORT_OUTPUT_OPTION, - ) + override val pluginOptions: Collection = emptyList() override fun processOption(option: AbstractCliOption, value: String, configuration: CompilerConfiguration) { - when (option.optionName) { - MODULE_MAP_OPTION.optionName -> configuration.put(KEY_MODULE_MAP, value) - SELF_MODULE_OPTION.optionName -> configuration.put(KEY_SELF_MODULE, value) - REPORT_OUTPUT_OPTION.optionName -> configuration.put(KEY_REPORT_OUTPUT, value) - else -> error("Unexpected Actualizer plugin option: ${option.optionName}") - } + error("Unexpected Actualizer plugin option: ${option.optionName}") } companion object { const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" - - val KEY_MODULE_MAP = CompilerConfigurationKey("moduleMap") - val KEY_SELF_MODULE = CompilerConfigurationKey("selfModule") - val KEY_REPORT_OUTPUT = CompilerConfigurationKey("reportOutput") - - val MODULE_MAP_OPTION = CliOption( - optionName = "moduleMap", - valueDescription = "", - description = "Maps merged-in foreign source roots to the Gradle module that owns them", - required = false, - allowMultipleOccurrences = false, - ) - - val SELF_MODULE_OPTION = CliOption( - optionName = "selfModule", - valueDescription = "", - description = "Name of the Gradle module currently being compiled", - required = false, - allowMultipleOccurrences = false, - ) - - val REPORT_OUTPUT_OPTION = CliOption( - optionName = "reportOutput", - valueDescription = "", - description = "File path to write the cross-module actualization report to", - required = false, - allowMultipleOccurrences = false, - ) } } diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index 7ed0916..edac11b 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -1,9 +1,6 @@ package net.kernelpanicsoft.actualizer.compiler import net.kernelpanicsoft.actualizer.compiler.fir.ActualizerFirExtensionRegistrar -import net.kernelpanicsoft.actualizer.compiler.ir.ActualizerIrExtension -import net.kernelpanicsoft.actualizer.compiler.ir.ModuleRoot -import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi import org.jetbrains.kotlin.config.CommonConfigurationKeys @@ -14,9 +11,12 @@ import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter /** - * Registers Actualizer's two compiler extensions: [ActualizerFirExtensionRegistrar], which - * quiets IDE-only expect/actual false positives, and [ActualizerIrExtension], which reports on - * what got cross-module linked in this compilation. + * Registers [ActualizerFirExtensionRegistrar], which quiets IDE-only expect/actual false + * positives. This used to also register an `IrGenerationExtension` that reported on cross-module + * actualization links, but that only ever observed file paths/packages already available straight + * from source text - see `ActualizerLinkReport.kt` in the Gradle plugin, which does that via plain + * PSI scanning instead, so it can't be broken by a Kotlin version mismatch between this plugin and + * whatever actually compiles a consumer's code. */ @OptIn(ExperimentalCompilerApi::class) class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { @@ -42,46 +42,5 @@ class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { null as CompilerMessageSourceLocation?, ) } - - // ActualizerIrExtension only observes expect/actual linking the frontend already resolved - // (see its own doc), to write an optional JSON report and log messages - it's not required - // for real compilation to succeed either. IrGenerationExtension.registerExtension itself - // reaches into the same kind of internal, version-specific compiler machinery the FIR - // extension above does (e.g. an out-of-process Kotlin daemon on an older/different Kotlin - // version than 2.4.10 this plugin compiles against can be missing a class like - // org.jetbrains.kotlin.extensions.ExtensionPointDescriptor entirely), so the same - // Throwable-catching fail-safe applies here too - a missing report/log line should never - // take down a real build. - try { - val moduleMap = parseModuleMap(configuration.get(ActualizerCommandLineProcessor.KEY_MODULE_MAP).orEmpty()) - val selfModule = configuration.get(ActualizerCommandLineProcessor.KEY_SELF_MODULE) ?: "" - val reportOutput = configuration.get(ActualizerCommandLineProcessor.KEY_REPORT_OUTPUT) - - IrGenerationExtension.registerExtension( - ActualizerIrExtension( - moduleMap = moduleMap, - selfModule = selfModule, - reportOutputPath = reportOutput, - messageCollector = messageCollector, - ) - ) - } catch (t: Throwable) { - messageCollector.report( - CompilerMessageSeverity.WARNING, - "Actualizer: could not register the IR extension that reports cross-module " + - "actualization links (${t::class.simpleName}: ${t.message}). This doesn't " + - "affect the actual build - only the optional report/log output.", - null as CompilerMessageSourceLocation?, - ) - } - } - - /** Parses the `root::moduleName||root::moduleName...` option value from [ActualizerCommandLineProcessor]. */ - private fun parseModuleMap(raw: String): List { - if (raw.isBlank()) return emptyList() - return raw.split("||").mapNotNull { entry -> - val parts = entry.split("::", limit = 2) - if (parts.size != 2) null else ModuleRoot(root = parts[0], moduleName = parts[1]) - } } } diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt index 2592f47..3fb0f1d 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/fir/ActualizerFirExtensions.kt @@ -12,7 +12,11 @@ import org.jetbrains.kotlin.fir.declarations.FirMemberDeclaration import org.jetbrains.kotlin.fir.declarations.FirNamedFunction import org.jetbrains.kotlin.fir.declarations.FirProperty import org.jetbrains.kotlin.fir.declarations.FirRegularClass +import org.jetbrains.kotlin.fir.declarations.toAnnotationClassIdSafe import org.jetbrains.kotlin.fir.expressions.FirAnnotation +import org.jetbrains.kotlin.fir.expressions.FirExpression +import org.jetbrains.kotlin.fir.expressions.FirLiteralExpression +import org.jetbrains.kotlin.fir.expressions.FirVarargArgumentsExpression import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotation import org.jetbrains.kotlin.fir.expressions.builder.buildAnnotationArgumentMapping import org.jetbrains.kotlin.fir.expressions.builder.buildLiteralExpression @@ -48,23 +52,23 @@ private val SUPPRESSED_DIAGNOSTIC_NAMES = listOf( @Volatile private var suppressionApiAvailable = true -private fun buildSuppressAnnotationOrNull(session: FirSession): FirAnnotation? { - if (!suppressionApiAvailable) return null - return try { - buildSuppressAnnotation(session) - } catch (t: Throwable) { - suppressionApiAvailable = false - null - } -} - -/** Builds a `@Suppress` [FirAnnotation] for [SUPPRESSED_DIAGNOSTIC_NAMES] from FIR builders directly, not source text. */ -private fun buildSuppressAnnotation(session: FirSession): FirAnnotation { +/** Builds a `@Suppress` [FirAnnotation] for [names] from FIR builders directly, not source text. */ +private fun buildSuppressAnnotation(session: FirSession, names: List): FirAnnotation { val suppressConeType = StandardClassIds.Annotations.Suppress.createConeType(session) val typeRef = buildResolvedTypeRef { coneType = suppressConeType } + val argumentMapping = buildAnnotationArgumentMapping { + mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = buildSuppressNamesVararg(session, names) + } + return buildAnnotation { + annotationTypeRef = typeRef + this.argumentMapping = argumentMapping + } +} +/** Builds the `vararg names: String` argument expression `@Suppress` takes, for [names]. */ +private fun buildSuppressNamesVararg(session: FirSession, names: List): FirVarargArgumentsExpression { val stringConeType = StandardClassIds.String.createConeType(session) - val literalArgs = SUPPRESSED_DIAGNOSTIC_NAMES.map { name -> + val literalArgs = names.map { name -> buildLiteralExpression( source = null, kind = ConstantValueKind.String, @@ -78,20 +82,34 @@ private fun buildSuppressAnnotation(session: FirSession): FirAnnotation { session, typeArguments = arrayOf(ConeKotlinTypeProjectionOut(stringConeType)), ) - val varargExpression = buildVarargArgumentsExpression { + return buildVarargArgumentsExpression { arguments.addAll(literalArgs) coneElementTypeOrNull = stringConeType coneTypeOrNull = varargArrayConeType } +} - val argumentMapping = buildAnnotationArgumentMapping { - mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = varargExpression - } - - return buildAnnotation { - annotationTypeRef = typeRef - this.argumentMapping = argumentMapping +/** + * If [existing] already suppresses everything in [names], does nothing. Otherwise mutates + * [existing]'s own argument list in place to add whatever's missing, rather than attaching a + * second `@Suppress` alongside it - the real compiler's "repeated annotation" checker treats two + * `@Suppress`es on one declaration as an error regardless of what either suppresses, and mutating + * in place keeps this a single annotation, with [existing]'s own real source untouched. + */ +private fun mergeSuppressNames(existing: FirAnnotation, session: FirSession, names: List) { + val existingArgument = existing.argumentMapping.mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] + ?: return + val existingArgs: List = (existingArgument as? FirVarargArgumentsExpression)?.arguments + ?: listOf(existingArgument) + val existingNames = existingArgs.mapNotNull { (it as? FirLiteralExpression)?.value as? String } + val namesToAdd = names.filterNot { it in existingNames } + if (namesToAdd.isEmpty()) return + + val mergedVararg = buildSuppressNamesVararg(session, existingNames + namesToAdd) + val mergedMapping = buildAnnotationArgumentMapping { + mapping[StandardClassIds.Annotations.ParameterNames.suppressNames] = mergedVararg } + existing.replaceArgumentMapping(mergedMapping) } private fun isExpectOrActual(status: FirDeclarationStatus): Boolean = status.isExpect || status.isActual @@ -147,10 +165,21 @@ private class ActualizerSuppressionStatusTransformer(session: FirSession) : FirS } private fun inject(declaration: FirDeclaration) { + if (!suppressionApiAvailable) return if (!alreadyInjected.add(declaration)) return - val annotation = buildSuppressAnnotationOrNull(session) ?: return try { - declaration.replaceAnnotations(declaration.annotations + annotation) + // A declaration that already carries its own hand-written @Suppress (e.g. + // @Suppress("unused") on an actual object) merges our names into it in place instead + // of attaching a second @Suppress alongside it - see mergeSuppressNames's doc for why. + val existingSuppress = declaration.annotations.firstOrNull { + it.toAnnotationClassIdSafe(session) == StandardClassIds.Annotations.Suppress + } + if (existingSuppress != null) { + mergeSuppressNames(existingSuppress, session, SUPPRESSED_DIAGNOSTIC_NAMES) + } else { + val annotation = buildSuppressAnnotation(session, SUPPRESSED_DIAGNOSTIC_NAMES) + declaration.replaceAnnotations(declaration.annotations + annotation) + } } catch (t: Throwable) { suppressionApiAvailable = false } diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt deleted file mode 100644 index e92c51a..0000000 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ir/ActualizerIrExtension.kt +++ /dev/null @@ -1,115 +0,0 @@ -package net.kernelpanicsoft.actualizer.compiler.ir - -import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension -import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext -import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity -import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation -import org.jetbrains.kotlin.cli.common.messages.MessageCollector -import org.jetbrains.kotlin.ir.declarations.IrDeclarationWithName -import org.jetbrains.kotlin.ir.declarations.IrFile -import org.jetbrains.kotlin.ir.declarations.IrModuleFragment -import org.jetbrains.kotlin.ir.symbols.UnsafeDuringIrConstructionAPI -import java.io.File - -data class ModuleRoot(val root: String, val moduleName: String) - -private data class LinkEntry( - val actualFqName: String, - val owningModules: List, - val consumingModule: String, -) - -/** - * Reports which `actual` declarations link back to an `expect` declared in a foreign Gradle - * module, once the frontend has already resolved expect/actual for a compilation the Gradle - * plugin merged together via `-Xmulti-platform` + `-Xcommon-sources`. - * - * The linking itself already happened by the time this runs; this only observes the result and - * attaches module provenance the frontend has no notion of, since it only knows about files, not - * Gradle modules. - * - * By the time IR generation runs, an `expect` declaration has been elided entirely - only the - * linked `actual` remains. So rather than pairing IR declarations up by name, this groups the - * files merged in from each foreign module by package, then treats every locally-declared - * top-level declaration sharing one of those packages as the `actual` linking it. - * - * No annotation marks which declarations to look at: a plain `kotlin("jvm")` module never sees - * `expect`/`actual` at all unless Actualizer put them there, so every pair reaching this - * extension is already Actualizer's doing. - * - * @param moduleMap Foreign source roots mapped to the Gradle module that owns them. - * @param selfModule Path of the module currently being compiled. - * @param reportOutputPath Where to write the JSON link report, if anywhere. - */ -class ActualizerIrExtension( - private val moduleMap: List, - private val selfModule: String, - private val reportOutputPath: String?, - private val messageCollector: MessageCollector, -) : IrGenerationExtension { - - @OptIn(UnsafeDuringIrConstructionAPI::class) - override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) { - val foreignModulesByPackage = mutableMapOf>() - val localFilesByPackage = mutableMapOf>() - - for (file in moduleFragment.files) { - val filePath = file.fileEntry.name - val owningModule = moduleMap.firstOrNull { filePath.startsWith(it.root) }?.moduleName - - if (owningModule != null) { - foreignModulesByPackage.getOrPut(file.packageFqName.asString()) { mutableListOf() } += owningModule - } else { - localFilesByPackage.getOrPut(file.packageFqName.asString()) { mutableListOf() } += file - } - } - - val links = foreignModulesByPackage.flatMap { (packageName, owningModules) -> - localFilesByPackage[packageName].orEmpty() - .flatMap { it.declarations } - .filterIsInstance() - .map { declaration -> - LinkEntry( - actualFqName = "$packageName.${declaration.name.asString()}", - owningModules = owningModules, - consumingModule = selfModule, - ) - } - } - - for (link in links) { - messageCollector.report( - CompilerMessageSeverity.LOGGING, - "Actualizer: '${link.actualFqName}' in module '$selfModule' actualizes an expect " + - "declared in ${link.owningModules}", - null as CompilerMessageSourceLocation?, - ) - } - - reportOutputPath?.let { path -> writeReport(path, links) } - } - - /** Writes [links] to [path] as JSON, in the shape `{ consumingModule, links: [{ actual, owningModules }] }`. */ - private fun writeReport(path: String, links: List) { - val file = File(path) - file.parentFile?.mkdirs() - val json = buildString { - append("{\n") - append(" \"consumingModule\": \"").append(selfModule).append("\",\n") - append(" \"links\": [\n") - links.forEachIndexed { index, link -> - append(" {") - append("\"actual\": \"").append(link.actualFqName).append("\", ") - append("\"owningModules\": [") - append(link.owningModules.joinToString(", ") { "\"$it\"" }) - append("]") - append("}") - if (index != links.lastIndex) append(",") - append("\n") - } - append(" ]\n") - append("}\n") - } - file.writeText(json) - } -} diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index b229a5f..31893fb 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -10,11 +10,11 @@ import org.gradle.workers.WorkerExecutor import java.io.File import javax.inject.Inject -private const val PLUGIN_ID = "net.kernelpanicsoft.actualizer" private const val COMPILER_PLUGIN_COORDINATES = "net.kernelpanicsoft.actualizer:compiler-plugin:0.1.0" // Must match the versions gradle-plugin/build.gradle.kts itself declares - this is what -// wireStubGeneration resolves onto GenerateStubsWorkAction's isolated worker classpath. +// wireStubGeneration/wireCrossModuleActualization resolve onto their respective isolated worker +// classpaths (GenerateStubsWorkAction/GenerateLinkReportWorkAction). private const val KOTLIN_COMPILER_EMBEDDABLE_COORDINATES = "org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10" private const val KOTLINPOET_COORDINATES = "com.squareup:kotlinpoet:1.18.1" @@ -28,11 +28,12 @@ private const val KOTLINPOET_COORDINATES = "com.squareup:kotlinpoet:1.18.1" * lets a module with an unfulfilled `expect` still produce an ordinary standalone jar. * * [ActualizerExtension.actualizes] merges a foreign project's (or published library's) - * hand-written source into this project's own compilation alongside a real `actual`, and - * registers the Actualizer IR compiler plugin on that compilation so it can report on what got - * linked. A published coordinate is resolved lazily, since it may not be published yet when this - * build starts; a `project(...)` reference is resolved eagerly, since its files already exist on - * disk. + * hand-written source into this project's own compilation alongside a real `actual`, registers the + * Actualizer compiler plugin on that compilation (for its FIR extension's IDE false-positive + * suppression), and separately writes a JSON report of what got linked via plain PSI scanning in + * Gradle - see [writeActualizationLinkReport]'s doc for why that isn't done inside the compiler. A + * published coordinate is resolved lazily, since it may not be published yet when this build + * starts; a `project(...)` reference is resolved eagerly, since its files already exist on disk. * * This intentionally avoids referencing Kotlin Gradle Plugin types like `KotlinJvmProjectExtension` * directly. `plugin-build` resolves its own copy of `kotlin-gradle-plugin` to compile this plugin, @@ -105,9 +106,7 @@ class ActualizerGradlePlugin @Inject constructor( // Registers the same compiler plugin wireCrossModuleActualization does, purely so its FIR // extension is present here too and suppresses the IDE-only expect/actual false positives // on this module's declarations - including the generated stub itself, which no longer - // carries its own manual @Suppress (see ExpectStubGenerator.kt). moduleMap/reportOutput are - // left unset: there's no foreign module merge here for the IR extension's report to say - // anything about. + // carries its own manual @Suppress (see ExpectStubGenerator.kt). val compilerPluginClasspath = project.configurations.detachedConfiguration( project.dependencies.create(COMPILER_PLUGIN_COORDINATES) ) @@ -129,8 +128,6 @@ class ActualizerGradlePlugin @Inject constructor( "-Xmulti-platform", "-Xcommon-sources=$commonSourcesValue", "-Xplugin=$pluginJar", - "-P", - "plugin:$PLUGIN_ID:selfModule=${project.path}", ) } ) @@ -138,6 +135,13 @@ class ActualizerGradlePlugin @Inject constructor( } private fun wireCrossModuleActualization(project: Project, extension: ActualizerExtension) { + // Captured before addSourceDir below merges the foreign dirs in, so this stays scoped to + // just this project's own hand-written source - see writeActualizationLinkReport's doc. + val ownMainDirs = namedSourceSetDirs(project, "main") + val ownMainFiles = ownMainDirs.filter { it.exists() }.flatMap { dir -> + dir.walkTopDown().filter { it.isFile && it.extension == "kt" } + } + val moduleMapEntries = mutableListOf() // Project() references: files already exist on disk, safe to walk eagerly right now. val eagerSourceFiles = mutableListOf() @@ -191,7 +195,6 @@ class ActualizerGradlePlugin @Inject constructor( ) val reportOutput = project.layout.buildDirectory.file("actualizer/report.json").get().asFile - val moduleMapValue = moduleMapEntries.joinToString("||") val compileKotlinTask = project.tasks.named("compileKotlin") compileKotlinTask.configure { task -> @@ -219,15 +222,31 @@ class ActualizerGradlePlugin @Inject constructor( "-Xmulti-platform", "-Xcommon-sources=$commonSourcesValue", "-Xplugin=$pluginJar", - "-P", - "plugin:$PLUGIN_ID:moduleMap=$moduleMapValue", - "-P", - "plugin:$PLUGIN_ID:selfModule=${project.path}", - "-P", - "plugin:$PLUGIN_ID:reportOutput=${reportOutput.absolutePath}", ) } ) + + // Purely informational JSON report of which locally-declared top-level name is + // believed to actualize a foreign module's expect - see writeActualizationLinkReport's + // doc for why this runs entirely outside the compiler via plain PSI scanning, in its + // own worker classloader (see GenerateStubsWorkAction's doc for why that isolation + // matters). Only runs if compileKotlin itself succeeds. + task.doLast { + val ownJar = File(ActualizerGradlePlugin::class.java.protectionDomain.codeSource.location.toURI()) + val isolatedDeps = project.configurations.detachedConfiguration( + project.dependencies.create(KOTLIN_COMPILER_EMBEDDABLE_COORDINATES) + ) + val workQueue = workerExecutor.classLoaderIsolation { spec -> + spec.classpath.from(ownJar, isolatedDeps) + } + workQueue.submit(GenerateLinkReportWorkAction::class.java) { params -> + params.ownFiles.from(ownMainFiles) + params.moduleMapEntries.set(moduleMapEntries) + params.selfModule.set(project.path) + params.reportOutput.set(reportOutput) + } + workQueue.await() + } } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt new file mode 100644 index 0000000..2fe13b3 --- /dev/null +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerLinkReport.kt @@ -0,0 +1,100 @@ +package net.kernelpanicsoft.actualizer.gradle + +import org.jetbrains.kotlin.K1Deprecation +import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtPsiFactory +import java.io.File + +/** A foreign source root merged into this compilation, and the Gradle module (or coordinate) that owns it. */ +internal data class LinkReportModuleRoot(val root: File, val moduleName: String) + +private data class LinkEntry(val actualFqName: String, val owningModules: List) + +/** + * Reports which locally-declared top-level declaration in [ownFiles] shares a package with a + * foreign file merged in from one of [moduleMap]'s roots - the same "this actual links a foreign + * expect" heuristic `actualizes(...)` compilations have always used, just computed from source + * text via PSI instead of from inside the compiler. + * + * This used to run as an `IrGenerationExtension` in the compiler plugin, reading `IrFile.fileEntry` + * and `packageFqName` off the already-linked IR. That was never buying anything real: it only ever + * matched by package name, the same heuristic available straight from source text, and a real + * consumer's Kotlin daemon can be running a different Kotlin version than this plugin compiles + * against - which is exactly what surfaced a `NoClassDefFoundError` on + * `org.jetbrains.kotlin.extensions.ExtensionPointDescriptor` (internal compiler-extension plumbing, + * unrelated to anything this report actually needs) and took down a real build. Doing this in + * Gradle instead means it can't be broken by any Kotlin compiler/daemon version at all. + * + * Not a real linking guarantee, same as before: sharing a package is a heuristic, not confirmation + * that a specific `actual` really resolves a specific foreign `expect` at the symbol level. + */ +internal fun writeActualizationLinkReport( + ownFiles: List, + moduleMap: List, + selfModule: String, + reportOutput: File, +) { + val disposable = Disposer.newDisposable("actualizer-link-report") + val links = try { + @OptIn(K1Deprecation::class, CompilerConfiguration.Internals::class) + val environment = KotlinCoreEnvironment.createForProduction( + disposable, + CompilerConfiguration(), + EnvironmentConfigFiles.JVM_CONFIG_FILES, + ) + val psiFactory = KtPsiFactory(environment.project) + + val foreignModulesByPackage = mutableMapOf>() + for (foreignRoot in moduleMap) { + if (!foreignRoot.root.exists()) continue + foreignRoot.root.walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { file -> + val packageName = psiFactory.createFile(file.name, file.readText()).packageFqName.asString() + foreignModulesByPackage.getOrPut(packageName) { mutableListOf() } += foreignRoot.moduleName + } + } + + val links = mutableListOf() + for (file in ownFiles) { + if (!file.exists()) continue + val ktFile = psiFactory.createFile(file.name, file.readText()) + val packageName = ktFile.packageFqName.asString() + val owningModules = foreignModulesByPackage[packageName] ?: continue + for (declaration in ktFile.declarations) { + val name = (declaration as? KtNamedDeclaration)?.name ?: continue + links += LinkEntry("$packageName.$name", owningModules) + } + } + links + } finally { + Disposer.dispose(disposable) + } + + writeReportJson(reportOutput, selfModule, links) +} + +/** Writes [links] to [reportOutput] as JSON, in the shape `{ consumingModule, links: [{ actual, owningModules }] }`. */ +private fun writeReportJson(reportOutput: File, selfModule: String, links: List) { + reportOutput.parentFile?.mkdirs() + val json = buildString { + append("{\n") + append(" \"consumingModule\": \"").append(selfModule).append("\",\n") + append(" \"links\": [\n") + links.forEachIndexed { index, link -> + append(" {") + append("\"actual\": \"").append(link.actualFqName).append("\", ") + append("\"owningModules\": [") + append(link.owningModules.joinToString(", ") { "\"$it\"" }) + append("]") + append("}") + if (index != links.lastIndex) append(",") + append("\n") + } + append(" ]\n") + append("}\n") + } + reportOutput.writeText(json) +} diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt new file mode 100644 index 0000000..ab0d52e --- /dev/null +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateLinkReportWorkAction.kt @@ -0,0 +1,37 @@ +package net.kernelpanicsoft.actualizer.gradle + +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.workers.WorkAction +import org.gradle.workers.WorkParameters +import java.io.File + +internal interface GenerateLinkReportParameters : WorkParameters { + val ownFiles: ConfigurableFileCollection + /** Each entry is `"::"`, matching [LinkReportModuleRoot]. */ + val moduleMapEntries: ListProperty + val selfModule: Property + val reportOutput: RegularFileProperty +} + +/** + * Runs [writeActualizationLinkReport] in a classloader isolated from the rest of the build - see + * [GenerateStubsWorkAction]'s doc for why standing up a `KotlinCoreEnvironment` needs that. + */ +internal abstract class GenerateLinkReportWorkAction : WorkAction { + + override fun execute() { + val moduleMap = parameters.moduleMapEntries.get().mapNotNull { entry -> + val parts = entry.split("::", limit = 2) + if (parts.size != 2) null else LinkReportModuleRoot(File(parts[0]), parts[1]) + } + writeActualizationLinkReport( + ownFiles = parameters.ownFiles.files.toList(), + moduleMap = moduleMap, + selfModule = parameters.selfModule.get(), + reportOutput = parameters.reportOutput.get().asFile, + ) + } +} From a6f770278cafb66faad0f872e383c56ea2c7b02b Mon Sep 17 00:00:00 2001 From: KernelPanic Date: Thu, 6 Aug 2026 14:04:23 -0400 Subject: [PATCH 19/19] Wire builtBy for generated/unpacked source dirs, add Gradle wrapper The gradle-plugin's addSourceDir() was adding generated-stub and unpacked-published-source directories to a source set without wiring their producing tasks onto the resulting FileCollection. Consumers that read straight off the source set (e.g. sourcesJar from withSourcesJar()) rather than through compileKotlin's dependsOn could run before those directories were populated. addSourceDir() now accepts a builtBy list and wires it via project.files(dirs).builtBy(...). actualizerUnpackXSources also needed task.inputs.files(configuration) so Gradle infers build dependencies for composite-build coordinates that dependencySubstitution maps onto a sibling included build's project instead of a real published artifact. Also removes two commented-out (dead) warning log calls left over from local debugging, and checks in the Gradle wrapper (jar, scripts, properties) so the project is buildable without a pre-existing local Gradle install. --- .gitignore | 2 +- .idea/misc.xml | 6 +- .idea/modules.xml | 8 +- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 9 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 82 ++++++ .../ActualizerCompilerPluginRegistrar.kt | 16 +- .../gradle/ActualizerGradlePlugin.kt | 53 +++- .../gradle/GenerateStubsWorkAction.kt | 10 +- 10 files changed, 404 insertions(+), 30 deletions(-) create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat diff --git a/.gitignore b/.gitignore index 00d4b2c..9189ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ plugin-build/*/build/ .idea/ out/ kotlin-js-store/ -local.properties +plugin-build/local.properties diff --git a/.idea/misc.xml b/.idea/misc.xml index a19c222..4fb4a07 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -14,7 +14,11 @@ \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml index 580eebd..4ee82df 100644 --- a/.idea/modules.xml +++ b/.idea/modules.xml @@ -2,7 +2,13 @@ - + + + + + + + \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8ff605c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt index edac11b..fa086a2 100644 --- a/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt +++ b/plugin-build/compiler-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/compiler/ActualizerCompilerPluginRegistrar.kt @@ -3,11 +3,7 @@ package net.kernelpanicsoft.actualizer.compiler import net.kernelpanicsoft.actualizer.compiler.fir.ActualizerFirExtensionRegistrar import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi -import org.jetbrains.kotlin.config.CommonConfigurationKeys import org.jetbrains.kotlin.config.CompilerConfiguration -import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity -import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation -import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter /** @@ -25,22 +21,14 @@ class ActualizerCompilerPluginRegistrar : CompilerPluginRegistrar() { override val supportsK2: Boolean = true override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { - val messageCollector = configuration.get(CommonConfigurationKeys.MESSAGE_COLLECTOR_KEY, MessageCollector.NONE) - // ActualizerFirExtensionRegistrar builds on internal FIR APIs with no compatibility // guarantee across Kotlin versions, so a version this plugin wasn't built against could // fail here at class-load time. It's a cosmetic IDE-only feature (see the root README), // so a failure here should never take down a real compile. try { FirExtensionRegistrarAdapter.registerExtension(ActualizerFirExtensionRegistrar()) - } catch (t: Throwable) { - messageCollector.report( - CompilerMessageSeverity.WARNING, - "Actualizer: could not register the FIR extension that suppresses IDE-only " + - "expect/actual false positives (${t::class.simpleName}: ${t.message}). " + - "This doesn't affect the actual build.", - null as CompilerMessageSourceLocation?, - ) + } catch (_: Throwable) { + // No message collector available worth reporting through here - see class doc. } } } diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt index 31893fb..9471fcf 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/ActualizerGradlePlugin.kt @@ -101,7 +101,7 @@ class ActualizerGradlePlugin @Inject constructor( } } - addSourceDir(project, "main", listOf(outputDir)) + addSourceDir(project, "main", listOf(outputDir), builtBy = listOf(generateTask)) // Registers the same compiler plugin wireCrossModuleActualization does, purely so its FIR // extension is present here too and suppresses the IDE-only expect/actual false positives @@ -188,7 +188,7 @@ class ActualizerGradlePlugin @Inject constructor( return } - addSourceDir(project, "main", foreignSourceDirs) + addSourceDir(project, "main", foreignSourceDirs, builtBy = syncTasks) val compilerPluginClasspath = project.configurations.detachedConfiguration( project.dependencies.create(COMPILER_PLUGIN_COORDINATES) @@ -280,8 +280,33 @@ class ActualizerGradlePlugin @Inject constructor( // Not a Sync task with from(configuration) directly - that would resolve the detached // configuration eagerly during task-graph construction, before dependsOnTasks below has // had a chance to publish it. Resolving inside doLast defers it until this task runs. + // + // When the caller didn't pass dependsOnTasks, there's nothing else ordering this task + // against whatever actually produces the artifact - and dependsOnTasks is exactly what's + // omitted for a coordinate a composite build's dependencySubstitution maps onto a sibling + // included build's project instead of a real published artifact (the whole point of that + // substitution is that the consumer shouldn't need to know or care). There, "the sources + // jar" is really that project's own jar-producing task's output, which hasn't run yet on a + // clean build, and configuration.singleFile below could - and did - get resolved before + // that task ran, throwing NoSuchFileException on a jar that didn't exist yet. + // + // task.inputs.files(configuration) fixes that: it's still lazy (Gradle only snapshots it + // right before this task executes, well after any dependency it implies has already run), + // but it also makes Gradle infer configuration's own build dependencies against this task - + // the same implicit wiring project(...) dependencies get for free elsewhere in this plugin, + // which for a substituted coordinate resolves to the producing project's relevant task. + // + // This is deliberately skipped when dependsOnTasks is non-empty: that's the genuinely-not- + // published-anywhere-yet case (see :sample:actual-jvm-published), where the coordinate + // doesn't resolve via any substitution and asking Gradle to eagerly determine this + // configuration's build dependencies (which requires attempting real resolution) fails + // outright before dependsOnTasks's publish task ever runs. There, dependsOnTasks alone is + // both necessary and sufficient, exactly as before this fix. val unpackTask = project.tasks.register("actualizerUnpack${safeName}Sources") { task -> task.dependsOn(dependsOnTasks) + if (dependsOnTasks.isEmpty()) { + task.inputs.files(configuration).withPropertyName("actualizerPublishedSourcesArtifact") + } task.outputs.dir(outputDir) task.doLast { outputDir.deleteRecursively() @@ -310,11 +335,31 @@ class ActualizerGradlePlugin @Inject constructor( return kotlinDirSet.srcDirs.filterNot { it.toPath().startsWith(buildDir) } } - private fun addSourceDir(project: Project, sourceSetName: String, dirs: List) { + /** + * Registers [dirs] as extra source directories of [sourceSetName]. + * + * [builtBy], when non-empty, is wired onto the [org.gradle.api.file.FileCollection] backing + * those directories - not just onto `compileKotlin` by whatever caller already knows to + * `dependsOn` the tasks that populate [dirs] (a generated-stub or unpacked-published-source + * directory, not a plain `project(...)` reference's already-on-disk source). Any *other* + * consumer of this source set - `sourcesJar` from a plain `withSourcesJar()`, for one real + * example - reads straight off the source set's `FileCollection`/`allSource`, not through + * `compileKotlin`, so it never sees a `dependsOn` added only there. Without `builtBy` here, + * such a task can run before [dirs] is actually populated, archiving whatever happened to be + * on disk at the time - Gradle's own task-validation flags exactly this as an "implicit + * dependency" hazard. Threading the build dependency through the FileCollection itself, the + * same way `sourceSet.kotlin.srcDir(someTask)` would, makes it visible to every consumer + * instead of just the one this plugin happened to think to wire manually. + */ + private fun addSourceDir(project: Project, sourceSetName: String, dirs: List, builtBy: List = emptyList()) { val sourceSets = kotlinSourceSets(project) val sourceSet = sourceSets.getByName(sourceSetName) val kotlinDirSet = sourceSet.invokeGetter("getKotlin") as SourceDirectorySet - kotlinDirSet.srcDirs(dirs) + if (builtBy.isEmpty()) { + kotlinDirSet.srcDirs(dirs) + } else { + kotlinDirSet.srcDirs(project.files(dirs).builtBy(*builtBy.toTypedArray())) + } } private fun kotlinSourceSets(project: Project): NamedDomainObjectContainer<*> { diff --git a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt index 5d50101..080eb69 100644 --- a/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt +++ b/plugin-build/gradle-plugin/src/main/kotlin/net/kernelpanicsoft/actualizer/gradle/GenerateStubsWorkAction.kt @@ -2,7 +2,6 @@ package net.kernelpanicsoft.actualizer.gradle import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty -import org.gradle.api.logging.Logging import org.gradle.api.provider.Property import org.gradle.workers.WorkAction import org.gradle.workers.WorkParameters @@ -25,19 +24,12 @@ internal interface GenerateStubsParameters : WorkParameters { */ internal abstract class GenerateStubsWorkAction : WorkAction { - private val logger = Logging.getLogger(GenerateStubsWorkAction::class.java) - override fun execute() { val mainFiles = parameters.mainFiles.files.toList() val outputDir = parameters.outputDir.get().asFile val scanned = scanForExpectFunctions(mainFiles) outputDir.deleteRecursively() - if (scanned.isEmpty()) { - logger.warn( - "[actualizer] '${parameters.projectPath.get()}' called stubUnfulfilledExpects() but " + - "no 'expect fun'/'expect val'/'expect var'/'expect class' declarations were " + - "found in its main source set." - ) + if (scanned.isEmpty()) { return } outputDir.mkdirs()