This document provides everything you need to understand, debug, and develop the Koin Compiler Plugin.
- Project Architecture
- Compilation Flow
- Key Files Reference
- Enabling Debug Logging
- Transformation Examples
- Running Tests
- Common Issues & Debugging
- Development Workflow
- IR Inspection Techniques
- Cross-Module Discovery (Limitations)
- IDE Resolution of
.module()and Other Generated Symbols - Useful Commands Cheatsheet
koin-compiler-plugin/
├── koin-compiler-plugin/ # The compiler plugin (FIR + IR)
│ ├── src/org/koin/compiler/plugin/
│ │ ├── fir/ # FIR phase (declaration generation)
│ │ │ ├── KoinModuleFirGenerator.kt
│ │ │ └── KoinPluginRegistrar.kt
│ │ ├── ir/ # IR phase (code transformation)
│ │ │ ├── KoinIrExtension.kt
│ │ │ ├── KoinAnnotationProcessor.kt
│ │ │ ├── KoinDSLTransformer.kt
│ │ │ ├── KoinStartTransformer.kt
│ │ │ └── KoinHintTransformer.kt
│ │ ├── KoinConfigurationRegistry.kt
│ │ ├── KoinCommandLineProcessor.kt
│ │ └── KoinPluginComponentRegistrar.kt
│ ├── testData/ # Test input files
│ ├── test-fixtures/ # Test framework
│ └── test-gen/ # Generated test classes
│
├── koin-compiler-gradle-plugin/ # Gradle plugin for easy integration
│
└── test-apps/ # Test samples (separate Gradle project)
├── sample-app/ # KMP sample application
│ └── src/
│ ├── jvmMain/ # Main source code
│ └── jvmTest/ # Tests
└── sample-feature-module/ # Multi-module test
META-INF/services/
├── org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
│ └── KoinPluginComponentRegistrar.kt
│ └── Registers: KoinPluginRegistrar (FIR) + KoinIrExtension (IR)
│
└── org.jetbrains.kotlin.compiler.plugin.CommandLineProcessor
└── KoinCommandLineProcessor.kt
└── Plugin ID: "io.insert-koin.compiler.plugin"
Understanding the compilation flow is critical for debugging:
┌─────────────────────────────────────────────────────────────────────────────┐
│ KOTLIN COMPILATION PHASES │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 1: SOURCE PARSING │ │
│ │ .kt files → Abstract Syntax Tree (AST) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 2: FIR (Frontend IR) │ │
│ │ File: KoinModuleFirGenerator.kt │ │
│ │ │ │
│ │ What happens: │ │
│ │ 1. Scans for @Module @ComponentScan classes via predicate │ │
│ │ 2. GENERATES declarations (no bodies yet): │ │
│ │ - val MyModule.module: Module (extension property) │ │
│ │ - fun org.koin.plugin.hints.configuration_default(): Nothing │ │
│ │ 3. Populates KoinConfigurationRegistry with module names │ │
│ │ │ │
│ │ Key methods: │ │
│ │ - registerPredicates() → Registers @Module lookup │ │
│ │ - getTopLevelCallableIds() → Returns what to generate │ │
│ │ - generateProperties() → Creates .module extension │ │
│ │ - generateFunctions() → Creates hint functions │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 3: IR (Intermediate Representation) │ │
│ │ File: KoinIrExtension.kt │ │
│ │ │ │
│ │ Sub-phase 0: KoinHintTransformer │ │
│ │ └── Fills bodies for FIR-generated hint functions │ │
│ │ - getConfigurationModuleClasses() → listOf("module.fqn") │ │
│ │ - hint functions → error("never call") │ │
│ │ │ │
│ │ Sub-phase 1: KoinAnnotationProcessor │ │
│ │ └── Scans @Singleton/@Factory/@KoinViewModel classes │ │
│ │ └── FILLS BODY of FIR-generated .module property: │ │
│ │ val MyModule.module = module { │ │
│ │ buildSingle(A::class, null) { A(get()) } │ │
│ │ buildFactory(B::class, null) { B(get(), get()) } │ │
│ │ } │ │
│ │ │ │
│ │ Sub-phase 2: KoinDSLTransformer │ │
│ │ └── Transforms DSL calls: │ │
│ │ single<T>() → buildSingle(T::class, null) { T(get()) } │ │
│ │ scope.create(::T) → T(scope.get(), scope.get()) │ │
│ │ │ │
│ │ Sub-phase 3: KoinStartTransformer │ │
│ │ └── Transforms app entry points: │ │
│ │ startKoin<MyApp>() → startKoinWith(modules, lambda) │ │
│ │ - Discovers @Configuration modules │ │
│ │ - Injects modules from @KoinApplication annotation │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ PHASE 4: BYTECODE GENERATION │ │
│ │ IR → .class files (JVM) / .js files (JS) / native binary │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
- FIR Phase: Can only CREATE declarations (classes, functions, properties). Cannot add code bodies.
- IR Phase: Can MODIFY existing code, add bodies, transform calls. Cannot create new top-level declarations.
This is why .module property is declared in FIR but its body is filled in IR.
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/KoinPluginRegistrar.kt
class KoinPluginRegistrar : FirExtensionRegistrar() {
override fun ExtensionRegistrarContext.configurePlugin() {
+::KoinModuleFirGenerator // Register our FIR extension
}
}Purpose: Entry point for FIR phase. Registers KoinModuleFirGenerator.
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/fir/KoinModuleFirGenerator.kt
Purpose: Generates declarations during FIR phase.
Key Data Structures:
// Predicate to find @Module annotated classes
private val modulePredicate = LookupPredicate.create { annotated(MODULE_ANNOTATION) }
// Cached module classes (lazy evaluation)
private val moduleClasses: List<FirClassSymbol<*>> by lazy { ... }
// Cached @Configuration modules
private val configurationModules: List<FirClassSymbol<*>> by lazy { ... }Key Methods:
| Method | Purpose |
|---|---|
registerPredicates() |
Registers @Module annotation for lookup |
getTopLevelCallableIds() |
Returns CallableIds to generate (properties + functions) |
generateProperties() |
Creates val T.module: Module extension property |
generateFunctions() |
Creates hint functions in org.koin.plugin.hints |
discoverModulesFromHintsIfNeeded() |
Queries symbolProvider for hint functions in dependencies |
What Gets Generated:
For a class:
@Module @ComponentScan @Configuration
class MyModuleFIR generates:
val MyModule.module: Module(extension property, no body)fun org.koin.plugin.hints.configuration_default(contributed: MyModule): Unit(hint function for cross-module discovery)
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinIrExtension.kt
Purpose: Orchestrates all IR transformations in correct order.
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
// Phase 0: Generate bodies for FIR-generated functions
moduleFragment.transform(KoinHintTransformer(pluginContext), null)
// Phase 1: Process annotations, fill .module property bodies
val annotationProcessor = KoinAnnotationProcessor(pluginContext, messageCollector)
annotationProcessor.collectAnnotations(moduleFragment)
annotationProcessor.generateModuleExtensions(moduleFragment)
// Phase 2: Transform single<T>() / create(::T) calls
moduleFragment.transform(KoinDSLTransformer(pluginContext, messageCollector), null)
// Phase 3: Transform startKoin<T>() calls
moduleFragment.transform(KoinStartTransformer(pluginContext, moduleFragment, messageCollector), null)
}Order matters! Each transformer depends on the previous one's output.
Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinAnnotationProcessor.kt
Purpose: Processes @Singleton, @Factory, @KoinViewModel, @Scoped annotations.
Key Data Structures:
data class ModuleClass(
val irClass: IrClass,
val scanPackages: List<String>,
val definitionFunctions: List<DefinitionFunction>,
val includedModules: List<IrClass>
)
data class DefinitionClass(
val irClass: IrClass,
val definitionType: DefinitionType, // SINGLE, FACTORY, SCOPED, VIEW_MODEL, WORKER
val bindings: List<IrClass>, // Auto-detected interfaces
val scopeClass: IrClass?, // From @Scope(MyScope::class)
val scopeArchetype: ScopeArchetype?, // @ViewModelScope, @ActivityScope, etc.
val createdAtStart: Boolean
)
enum class DefinitionType {
SINGLE, FACTORY, SCOPED, VIEW_MODEL, WORKER
}Key Methods:
| Method | Purpose |
|---|---|
collectAnnotations() |
Visits all classes, collects annotated ones |
processClass() |
Checks if class has @Module or @Singleton/etc |
generateModuleExtensions() |
Fills body of FIR-generated .module property |
buildClassDefinitionCall() |
Creates buildSingle(T::class, ...) { T(get()) } |
createDefinitionLambda() |
Creates the { scope, params -> T(get(), get()) } lambda |
generateKoinArgumentForParameter() |
Decides: get(), getOrNull(), inject(), getProperty() |
Annotation Detection Logic:
private fun getDefinitionType(declaration: IrDeclaration): DefinitionType? {
return when {
declaration.hasAnnotation(singletonFqName) -> DefinitionType.SINGLE
declaration.hasAnnotation(singleFqName) -> DefinitionType.SINGLE
declaration.hasAnnotation(factoryFqName) -> DefinitionType.FACTORY
declaration.hasAnnotation(scopedFqName) -> DefinitionType.SCOPED
declaration.hasAnnotation(koinViewModelFqName) -> DefinitionType.VIEW_MODEL
declaration.hasAnnotation(koinWorkerFqName) -> DefinitionType.WORKER
// JSR-330 support
declaration.hasAnnotation(jakartaSingletonFqName) -> DefinitionType.SINGLE
declaration.hasAnnotation(jakartaInjectFqName) -> DefinitionType.FACTORY
else -> null
}
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinDSLTransformer.kt
Purpose: Transforms single<T>(), factory<T>(), scope.create(::T) calls.
Key Methods:
| Method | Purpose |
|---|---|
visitCall() |
Entry point - checks if call matches our patterns |
handleTypeParameterCall() |
Handles single<T>() → buildSingle(T::class, ...) |
handleScopeCreate() |
Handles scope.create(::T) → T(scope.get(), ...) |
findTargetFunction() |
Finds the target function (buildSingle, buildFactory, etc.) |
createDefinitionLambda() |
Creates { scope, params -> T(get(), get()) } |
Target Function Mapping:
private val targetFunctionNames = mapOf(
singleName to Name.identifier("buildSingle"),
factoryName to Name.identifier("buildFactory"),
scopedName to Name.identifier("buildScoped"),
viewModelName to Name.identifier("buildViewModel"),
workerName to Name.identifier("buildWorker")
)Matching Logic in visitCall():
// Must be one of our function names
if (functionName != createName && functionName != singleName && ...) {
return transformedCall
}
// Receiver must be from Koin package
val receiverPackage = receiverClassifier.packageFqName?.asString()
if (!receiverPackage.startsWith("org.koin.core") &&
!receiverPackage.startsWith("org.koin.dsl")) {
return transformedCall
}
// For type parameter syntax: single<T>()
if (transformedCall.valueArgumentsCount == 0 &&
transformedCall.typeArgumentsCount >= 1 &&
extensionReceiver != null) {
return handleTypeParameterCall(...)
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinStartTransformer.kt
Purpose: Transforms startKoin<MyApp>() to inject modules.
Key Methods:
| Method | Purpose |
|---|---|
visitCall() |
Detects startKoin/koinApplication calls |
extractModulesFromKoinApplicationAnnotation() |
Gets modules from @KoinApplication |
extractExplicitModules() |
Gets modules from modules = [...] parameter |
discoverLocalConfigurationModules() |
Scans current compilation for @Configuration |
discoverModulesFromHints() |
Tries to find modules from dependencies |
buildModuleGetCall() |
Creates MyModule().module expression |
Discovery Strategies:
private fun discoverModulesFromHints(): List<IrClass> {
// Strategy 1: Local hints from moduleFragment
for (file in moduleFragment.files) {
if (file.packageFqName == hintsPackage) { ... }
}
// Strategy 2: Query via IR (limited - can't enumerate)
// Note: IR cannot enumerate package contents from dependencies
// Strategy 3: In-memory registry (same compilation only)
val registryClassNames = KoinConfigurationRegistry.getAllModuleClassNames()
for (moduleClassName in registryClassNames) {
val classId = ClassId.topLevel(FqName(moduleClassName))
val moduleClass = context.referenceClass(classId)?.owner
// ...
}
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinHintTransformer.kt
Purpose: Fills bodies for FIR-generated hint functions.
override fun visitSimpleFunction(declaration: IrSimpleFunction): IrSimpleFunction {
if (fqName?.parent() == hintsPackage && declaration.body == null) {
if (declaration.name == registryFunctionName) {
// Generate: return listOf("module1.fqn", "module2.fqn")
declaration.body = generateRegistryFunctionBody(declaration)
} else {
// Generate: throw error("Hint function - never call")
declaration.body = generateHintFunctionBody(declaration)
}
}
return declaration
}Location: koin-compiler-plugin/src/org/koin/compiler/plugin/KoinConfigurationRegistry.kt
Purpose: Cross-phase communication between FIR and IR.
object KoinConfigurationRegistry {
private val localModuleClassNames = mutableSetOf<String>()
private val jarModuleClassNames = mutableSetOf<String>()
fun registerLocalModule(moduleClassName: String) { ... }
fun registerJarModule(moduleClassName: String) { ... }
fun getLocalModuleClassNames(): Set<String> { ... }
fun getAllModuleClassNames(): Set<String> { ... }
fun clear() { ... }
}CRITICAL LIMITATION: This registry is per-JVM instance. Each Gradle compilation task runs in a separate context, so registry is NOT shared across compilation tasks!
Enable logging via the koinCompiler extension in your build.gradle.kts:
koinCompiler {
userLogs = true // Component detection logs (what's being processed)
debugLogs = true // Internal processing logs (verbose)
unsafeDslChecks = true // Validates create() is the only instruction in lambda (default: true)
skipDefaultValues = true // Skip injection for parameters with default values (default: true)
}View logs during compilation:
cd test-apps
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin"Log prefixes:
| Prefix | Phase | Level |
|---|---|---|
[Koin] |
IR | User |
[Koin-Debug] |
IR | Debug |
[Koin-FIR] |
FIR | User |
[Koin-Debug-FIR] |
FIR | Debug |
With userLogs = true:
w: [Koin] @Module/@ComponentScan on class MyModule
w: [Koin] Scanning packages: examples.annotations
w: [Koin] @Singleton on class MyService
w: [Koin] @Named("production")
w: [Koin] Intercepting single<MyClass>() on Module
w: [Koin] Skipping injection for parameter 'timeout' - using default value
w: [Koin] Intercepting startKoin<MyApp>()
w: [Koin] -> Injecting modules: examples.annotations.MyModule
w: [Koin-FIR] Found 1 @Configuration modules
With debugLogs = true (additional verbose output):
w: [Koin-Debug-FIR] Looking for @Configuration modules among 2 @Module classes
w: [Koin-Debug-FIR] -> examples.annotations.MyModule: @Configuration=true
w: [Koin-Debug-FIR] Adding 1 hint functions to getTopLevelCallableIds()
w: [Koin-Debug] visitCall: org.koin.plugin.module.dsl.single | args=0 | typeArgs=1
w: [Koin-Debug] Creating definition lambda for MyService
Input (user code):
val myModule = module {
single<MyService>()
}Matched by: KoinDSLTransformer.handleTypeParameterCall()
Output (after transformation):
val myModule = module {
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}
}Input:
koin.scope.create(::MyService)Matched by: KoinDSLTransformer.handleScopeCreate()
Output:
MyService(koin.scope.get(), koin.scope.get())Input:
@Module @ComponentScan
class MyModule
@Singleton
class MyService(val repo: Repository, val logger: Logger?)Processed by: KoinAnnotationProcessor
Generated (fills FIR-generated property body):
val MyModule.module: Module get() = module {
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}
}Input:
@Singleton
@Named("production")
class ProductionService : ServiceOutput:
buildSingle(ProductionService::class, named("production")) { scope, params ->
ProductionService()
}.bind(Service::class)Input:
@Singleton
class Consumer(@Named("production") val service: Service)Output:
buildSingle(Consumer::class, null) { scope, params ->
Consumer(scope.get(named("production")))
}Input:
@Factory
class MyClass(@InjectedParam val id: Int, val service: Service)Output:
buildFactory(MyClass::class, null) { scope, params ->
MyClass(params.get(), scope.get())
}Usage: koin.get<MyClass> { parametersOf(42) }
Input:
@Singleton
class Config(@Property("server.url") val serverUrl: String)Output:
buildSingle(Config::class, null) { scope, params ->
Config(scope.getProperty("server.url"))
}Input:
@Singleton
class MyService(val required: A, val optional: B? = null)Output:
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.get(), scope.getOrNull())
}Input:
@Singleton
class MyService(val lazyDep: Lazy<HeavyService>)Output:
buildSingle(MyService::class, null) { scope, params ->
MyService(scope.inject())
}Input:
@Singleton
class Aggregator(val handlers: List<Handler>)Output:
buildSingle(Aggregator::class, null) { scope, params ->
Aggregator(scope.getAll())
}Input:
@KoinApplication(modules = [MyModule::class])
object MyApp
fun main() {
startKoin<MyApp> {
printLogger()
}
}Output:
fun main() {
startKoinWith(listOf(MyModule().module)) {
printLogger()
}
}./gradlew :koin-compiler-plugin:testTests use Kotlin's internal test framework with .kt files in testData/.
cd test-apps
./gradlew :sample-app:jvmTestcd test-apps
./gradlew :sample-app:jvmTest --tests "examples.annotations.AnnotationsConfigTest"
./gradlew :sample-app:jvmTest --tests "examples.DSLTest"cd test-apps
./gradlew :sample-app:jvmRuncd test-apps
./gradlew :sample-app:jvmTest --info 2>&1 | grep -E "(PASSED|FAILED|Koin-Plugin)"./gradlew :koin-compiler-plugin:test -Pupdate.testdata=trueSymptom: startKoin<MyApp>() logs "No modules to inject"
Debug Steps:
-
Check
@KoinApplicationannotation:@KoinApplication(modules = [MyModule::class]) // Explicit is best object MyApp
-
Check compilation logs for hint generation:
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "Configuration"
-
Verify modules are in same compilation unit (cross-module discovery is limited)
Symptom: MyModule().module doesn't compile
Debug Steps:
-
Verify class has BOTH annotations:
@Module // Required @ComponentScan // Required class MyModule
-
Check compilation logs:
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "getTopLevelCallableIds"
-
For KMP projects, if you encounter issues, try disabling incremental compilation:
# gradle.properties kotlin.incremental.multiplatform=false
Symptom: single<T>() not transformed, runtime error
Debug Steps:
-
Add log in
visitCall():log("visitCall: ${callee.fqNameWhenAvailable} receiver=${receiver.type}") -
Check receiver type is from Koin:
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "visitCall"
-
Verify import is correct:
import org.koin.dsl.module import org.koin.core.module.dsl.single // NOT org.koin.dsl.single
Symptom: Missing qualifiers, nullable not handled
Debug Steps:
-
Check
generateKoinArgumentForParameter()in logs:log("Generating arg for ${param.name}: type=${param.type}, nullable=${param.type.isMarkedNullable()}") -
Verify annotation FQNames:
log("Annotations on ${param.name}: ${param.annotations.map { it.type.classFqName }}")
Symptom: Compilation fails on iOS/macOS with file mismatch
Cause: FIR generators running on wrong source sets
Fix: Already applied - hint generation only when configurationModules.isNotEmpty()
Symptom: Hint functions disappear, cross-module discovery fails
Cause: Multiple FIR sessions (commonMain, jvmMain) writing to same hints package
Fix: Check in getTopLevelCallableIds():
if (configurationModules.isNotEmpty()) {
// Generate hints
} else if (moduleClasses.isEmpty()) {
// Skip - empty compilation
} else {
// Has @Module but no @Configuration - just trigger
}Symptom: Runtime error like java.lang.NoSuchMethodError: No static method module(PlatformComponentModule)
Cause: Multiple FIR phases (commonMain, androidMain) generating to same synthetic file name, later phases overwriting earlier ones.
Debug Steps:
-
Check FIR logs for duplicate file generation:
./gradlew :composeApp:assembleDebug 2>&1 | grep "GENERATED"
Look for multiple classes generating to
__GENERATED__CALLABLES__Kt.kt -
Check which classes are in the generated file:
javap -p build/tmp/kotlin-classes/debug/com/example/__GENERATED__CALLABLES__Kt.class
-
Verify unique file names are used:
ls build/tmp/kotlin-classes/debug/com/example/__GENERATED__*Should see:
__GENERATED__AppModule__KtKt.class,__GENERATED__PlatformComponentModule__KtKt.class, etc.
Fix: Already implemented - uses unique file names per class: __GENERATED__${className}__Kt.kt
Symptom: Compilation error or "no body for FIR-generated function" for expect classes
Cause: FIR generator not filtering out expect classes
Debug Steps:
-
Check FIR logs for expect class handling:
./gradlew :composeApp:assembleDebug 2>&1 | grep -E "(expect|Skipping)"
Should see:
Skipping expect class: com/example/PlatformComponentModule -
Verify
rawStatus.isExpectcheck inKoinModuleFirGenerator.kt
Fix: Already implemented - expect classes are filtered:
.filter { classSymbol -> !classSymbol.rawStatus.isExpect }Symptom: K/Native compilation fails with "The number of source files (X) does not match the number of IrFiles (Y)"
Cause: FIR generating synthetic files for metadata classes on K/Native targets
Debug Steps:
-
Check FIR logs for K/Native skipping:
./gradlew :composeApp:compileKotlinIosArm64 2>&1 | grep "K/Native"
Should see:
Skipping module() for ... (from metadata, K/Native) -
Verify platform detection:
val isNativeTarget = session.moduleData.platform.isNative()
Fix: Already implemented - synthetic file generation skipped on K/Native
Symptom: IR phase can't find module() function for a module in @Module(includes = [...])
Cause: Module is from different source set (e.g., androidMain including commonMain module)
Debug Steps:
-
Check IR logs for module resolution:
./gradlew :composeApp:assembleDebug 2>&1 | grep "Found module()"
-
Look for cross-source-set lookup:
Found module() in moduleFragment for DataModule # Same source set Found module() via context for PlatformComponentModule # Cross source set
Fix: Already implemented - uses findModuleFunctionViaContext() fallback for cross-compilation lookup
Symptom: Compilation fails with error about synthetic accessors for Kotlin object
Cause: Code calling constructor on a Kotlin object (singleton) instead of using the instance
Debug Steps:
-
Check if the module is defined as
object:@Module object MyModule // Object - should use MyModule.INSTANCE -
Verify
isObjectcheck in IR:./gradlew :composeApp:assembleDebug 2>&1 | grep "object"
Fix: Already implemented - uses irGetObject() for object modules:
val instanceExpression = if (includedModuleClass.isObject) {
builder.irGetObject(includedModuleClass.symbol)
} else {
builder.irCallConstructor(constructor.symbol, emptyList())
}Symptom: After updating the plugin, IntelliJ shows class symbols (like A, B, C, etc.) as red/unresolved in test-apps or other projects using the plugin.
Cause: IntelliJ has cached the old plugin version and hasn't picked up the newly installed one from Maven Local.
Fix Steps (in order of least to most aggressive):
-
Reinstall plugin to Maven Local:
./install.sh
-
Refresh Gradle in IntelliJ:
- Open the Gradle tool window (View → Tool Windows → Gradle)
- Click the refresh button (🔄 icon)
-
Invalidate IntelliJ caches:
- Go to
File→Invalidate Caches... - Check "Clear file system cache and Local History"
- Click
Invalidate and Restart
- Go to
-
Clean and reimport project (last resort):
cd test-apps rm -rf .gradle build */build .idea/*.xml
Then reopen the project in IntelliJ and let it reimport.
Prevention: When actively developing the plugin, consider keeping test-apps in a separate IntelliJ window and refreshing Gradle after each ./install.sh.
Symptom: Compile error at an @KoinViewModel-annotated class:
[Koin] @KoinViewModel definition 'com.app.MyViewModel' cannot be generated:
'buildViewModel' is not on classpath. Add dependency: io.insert-koin:koin-core-viewmodel
Same shape for @KoinWorker → io.insert-koin:koin-android-workmanager.
Cause: @KoinViewModel / @KoinWorker annotations live in koin-annotations (always available via koin-core), but the runtime DSL that backs them (Module.buildViewModel / Module.buildWorker) ships in a separate artifact. If you only have koin-core + koin-annotations, the plugin can't generate a working definition for those annotations.
Before the check existed (pre-RC2.3), the plugin silently skipped the definition; you got NoDefinitionFoundException at runtime. RC2.3+ fails loudly with the fix instruction.
Fix:
// build.gradle.kts
dependencies {
implementation("io.insert-koin:koin-core-viewmodel:4.2.1") // for @KoinViewModel
implementation("io.insert-koin:koin-android-workmanager:4.2.1") // for @KoinWorker (Android)
}The error fires once per annotation type per compilation — if you see it for @KoinViewModel, all ViewModel definitions in that compilation are blocked until the artifact is added.
# 1. Edit compiler-plugin code
# e.g., koin-compiler-plugin/src/org/koin/compiler/plugin/ir/KoinDSLTransformer.kt
# 2. Publish to Maven Local
./install.sh
# 3. Test changes
cd test-apps
./gradlew clean :sample-app:jvmTest# From project root
./install.sh && cd test-apps && ./gradlew clean :sample-app:jvmTestcd test-apps
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin-Plugin\]"-
Identify the phase: FIR (new declarations) or IR (transform code)?
-
For IR transformations:
- Add to appropriate transformer (
KoinDSLTransformer,KoinAnnotationProcessor) - Pattern match in
visitCall()orvisitClass() - Create the transformed IR using
DeclarationIrBuilder
- Add to appropriate transformer (
-
For FIR declarations:
- Add to
KoinModuleFirGenerator - Return new CallableId in
getTopLevelCallableIds() - Generate in
generateProperties()orgenerateFunctions()
- Add to
-
Add target function (if needed):
- Add to
plugin-support/src/commonMain/kotlin/org/koin/plugin/module/dsl/ - Create stub function (what user writes) and target function (what plugin transforms to)
- Note:
plugin-supportis included in Koin (koin-annotations), coordinate with Koin releases
- Add to
The recommended approach is to enable debugLogs in your Gradle configuration:
koinCompiler {
debugLogs = true
}This provides detailed output about IR transformations without modifying plugin source code.
# After compilation
cd test-apps/sample-app/build/classes/kotlin/jvm/main
# Decompile a class
javap -c -p examples/annotations/MyModule.class
# View all hints
javap -c org/koin/compiler/hints/*.class- Open
compiler-pluginin IntelliJ - Set breakpoint in any transformer
- Run test with debugger:
./gradlew :koin-compiler-plugin:test --debug-jvm
- Connect IntelliJ debugger to port 5005
When compiling Module B that depends on Module A (already compiled JAR):
- FIR can query
symbolProvider.getTopLevelFunctionSymbols()across JARs - IR CANNOT enumerate package contents from dependencies
FIR Phase (KoinModuleFirGenerator.kt):
fun discoverModulesFromHintsIfNeeded() {
val callableNames = session.symbolProvider.symbolNamesProvider
.getTopLevelCallableNamesInPackage(HINTS_PACKAGE)
for (name in callableNames) {
val funcSymbols = session.symbolProvider
.getTopLevelFunctionSymbols(HINTS_PACKAGE, name)
// Extract module class from parameter type
}
}IR Phase (KoinStartTransformer.kt):
fun discoverModulesFromHints(): List<IrClass> {
// Strategy 1: Local hints (same compilation)
// Strategy 2: IR query (limited)
// Strategy 3: Registry (same JVM only)
}- Registry is per-JVM: Each Gradle task runs separately
- IR can't enumerate: Must know exact function names
- Empty compilations overwrite: Fixed by checking
configurationModules.isNotEmpty()
Use explicit module specification:
@KoinApplication(modules = [ModuleA::class, ModuleB::class])
object MyAppThe plugin uses metadata registration for cross-module visibility:
pluginContext.metadataDeclarationRegistrar.registerFunctionAsMetadataVisible(function)This makes FIR-generated functions visible in downstream IR phases.
Code compiles and runs, but Android Studio / IntelliJ shows red squiggles on:
MyModule().module()— the FIR-generated extension function on@Module-annotated classes- Other symbols emitted via FIR declaration generation
Gradle compilation and IDE source analysis are two independent pipelines that each load Kotlin plugins on their own:
| Pipeline | Where it runs | Plugin loading |
|---|---|---|
compileKotlin |
Kotlin daemon launched by Gradle | KGP puts our JAR on the daemon's plugin classpath. The daemon reads META-INF/services/...CompilerPluginRegistrar, instantiates our registrar, runs our FIR + IR extensions. Generated declarations land in .class files. |
| IDE analysis | The IntelliJ / AS process itself, via the Kotlin Analysis API (K2 mode) | Separate plugin loading mechanism. JetBrains gates which compiler plugins are auto-loaded into IDE analysis because a broken plugin can corrupt the editor session. |
The plugin runs correctly in compilation — the function exists in bytecode, references resolve, behavior at runtime is correct. The IDE just doesn't run our FIR extension during its own analysis, so it never sees .module() and flags the call as unresolved.
This is not a misconfiguration on our side. We register the FIR extension exactly as required for compilation. The gap is on the IDE side: the K2 Analysis API doesn't automatically pick up arbitrary compiler plugins yet.
- Compilation: ✅ correct, including in CI
- Runtime: ✅ correct
- IDE resolution: ❌ red squiggle on
.module()and similar generated symbols (depending on Kotlin / AS version and K2 mode)
- Use K2 IDE mode — Settings → Languages & Frameworks → Kotlin → "Enable K2 Kotlin mode". Restart the IDE. On recent Kotlin / AS combinations, K2 mode resolves more compiler-plugin-generated symbols correctly. Try this first; many reports are simply K1-mode resolution.
- Ignore the red — code compiles and runs. The IDE will not block builds, only highlighting.
- Invalidate caches — when switching K1↔K2 or after a Kotlin/plugin version bump, an IDE caches invalidate-and-restart sometimes unblocks resolution.
Three paths, none of them free:
| Approach | Effort | Trade-off |
|---|---|---|
| Ship a dedicated IntelliJ plugin that registers the FIR extension into the Analysis API | High | Separate JAR with IDE-version-compatibility matrix; the "correct" fix but real maintenance cost |
Generate .module() as actual .kt source files (KSP-style codegen) instead of FIR declarations |
Medium | Real source files are always IDE-visible; trades elegance of FIR extension for a build-time codegen step |
| Wait on / push JetBrains' K2 Analysis API auto-discovery channel for compiler plugins | None (on us) | Outside our control; the auto-discovery channel is incrementally rolling out across Kotlin versions |
Tracked as a known limitation. No immediate action — the red squiggle is cosmetic and compilation is sound.
- Same root cause affects any other declaration we generate via FIR (e.g.,
qualifier(contributed: T)hint functions if they were ever referenced from user code, which they aren't). - Compose Compiler avoids this by being deeply integrated into the IDE-side Kotlin plugin (built into IntelliJ's Kotlin support). KSP avoids it by emitting real source files.
# Build plugin and publish to Maven Local
./install.sh
# Build plugin only (no publish)
./gradlew :koin-compiler-plugin:build
# Clean everything
./gradlew clean
cd test-apps && ./gradlew clean# Plugin unit tests
./gradlew :koin-compiler-plugin:test
# Sample app tests
cd test-apps && ./gradlew :sample-app:jvmTest
# Specific test
./gradlew :sample-app:jvmTest --tests "examples.annotations.AnnotationsConfigTest"
# Run sample
./gradlew :sample-app:jvmRun# View plugin logs during compilation
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin-Plugin\]"
# View FIR logs (enable debugLogs in koinCompiler config)
./gradlew :sample-app:compileKotlinJvm 2>&1 | grep "\[Koin"
# Decompile generated bytecode
javap -c -p build/classes/kotlin/jvm/main/examples/annotations/MyModule.class
# List generated hints
ls -la build/classes/kotlin/jvm/main/org/koin/compiler/hints/# Quick rebuild + test
./install.sh && cd test-apps && ./gradlew :sample-app:jvmTest
# Compile only (faster)
cd test-apps && ./gradlew :sample-app:compileKotlinJvm
# Force clean rebuild
cd test-apps && ./gradlew clean :sample-app:compileKotlinJvm --no-build-cache# iOS compilation
cd test-apps && ./gradlew :sample-app:compileKotlinIosSimulatorArm64
# All native targets
./gradlew :sample-app:compileKotlinNative
# JS compilation
./gradlew :sample-app:compileKotlinJs| File | Phase | Purpose |
|---|---|---|
KoinPluginComponentRegistrar.kt |
- | Plugin entry point |
KoinPluginRegistrar.kt |
FIR | Registers FIR extensions |
KoinModuleFirGenerator.kt |
FIR | Generates .module property + hints |
KoinConfigurationRegistry.kt |
FIR→IR | Cross-phase communication |
KoinIrExtension.kt |
IR | Orchestrates IR transformers |
KoinHintTransformer.kt |
IR-0 | Fills hint function bodies |
KoinAnnotationProcessor.kt |
IR-1 | Processes @Singleton etc |
KoinDSLTransformer.kt |
IR-2 | Transforms single() |
KoinStartTransformer.kt |
IR-3 | Transforms startKoin() |
Last updated: 2026-02-02