This document captures all learnings about FIR (Frontend Intermediate Representation) processing in Kotlin compiler plugins, including mechanisms, constraints, and solutions discovered during Koin plugin development.
- FIR vs IR: Fundamental Differences
- FIR Extension API
- Source Element Types
- KMP Multi-Phase Compilation
- Synthetic File Generation
- Cross-Module Discovery
- FIR to IR Communication
- Common Pitfalls and Solutions
| Capability | FIR Phase | IR Phase |
|---|---|---|
| Create new declarations (classes, functions, properties) | ✅ | ❌ |
| Add function/property bodies | ❌ | ✅ |
| Transform existing code | ❌ | ✅ |
| Access classes from dependencies (JARs) | ✅ (via symbolProvider) | ✅ (via referenceClass) |
| Generate metadata-visible symbols | ✅ | ✅ (via metadataDeclarationRegistrar) |
FIR Phase: "I declare that MyModule.module() exists"
↓
IR Phase: "I fill the body: module { buildSingle(...) }"
FIR cannot add bodies because it runs before type resolution is complete. IR cannot create new top-level declarations because the symbol table is already finalized.
@OptIn(ExperimentalTopLevelDeclarationsGenerationApi::class)
class MyFirGenerator(session: FirSession) : FirDeclarationGenerationExtension(session) {
// 1. Register predicates to find annotated classes
override fun FirDeclarationPredicateRegistrar.registerPredicates() {
register(LookupPredicate.create { annotated(MY_ANNOTATION) })
}
// 2. Declare what callable IDs we will generate
override fun getTopLevelCallableIds(): Set<CallableId> {
return setOf(CallableId(packageName, functionName))
}
// 3. Generate function symbols (no bodies yet!)
override fun generateFunctions(
callableId: CallableId,
context: MemberGenerationContext?
): List<FirNamedFunctionSymbol> {
return listOf(createTopLevelFunction(Key, callableId, returnType) {
extensionReceiverType(receiverType)
valueParameter(paramName, paramType)
}.symbol)
}
// 4. Claim ownership of packages for generated symbols
override fun hasPackage(packageFqName: FqName): Boolean {
return packageFqName == myGeneratedPackage || super.hasPackage(packageFqName)
}
object Key : GeneratedDeclarationKey()
}| Method | Purpose | When Called |
|---|---|---|
registerPredicates() |
Register annotation lookups | Early, before analysis |
getTopLevelCallableIds() |
Declare generated symbols | During symbol collection |
generateFunctions() |
Create function symbols | When symbols are resolved |
generateProperties() |
Create property symbols | When symbols are resolved |
hasPackage() |
Claim package ownership | During package resolution |
- No symbolProvider in getTopLevelCallableIds(): Causes infinite recursion
- No bodies in FIR: Only declarations, bodies are filled in IR
- Lazy initialization: Use
by lazy {}for caches that depend on predicates
FIR uses different source element types depending on how the class was loaded:
When: Direct source files in the current compilation unit (standard JVM/JS builds)
when (source) {
is KtPsiSourceElement -> {
// Direct PSI access available
val psi = source.psi
val file = psi?.containingFile
val fileName = file?.name // e.g., "MyModule.kt"
}
}When: KMP source files (no direct PSI access due to metadata-based analysis)
val sourceKind = source?.kind
val isRealSource = sourceKind?.toString()?.contains("RealSourceElementKind") == true
if (isRealSource) {
// This is a source file, but we can't get filename from PSI
// Need to use synthetic file name
}When: Classes from compiled dependencies (JARs, klibs)
if (source == null || !isRealSource) {
// Skip - this class is from a dependency
// Its module() function is already compiled in the JAR
}source?.kind
│
├── is KtPsiSourceElement
│ └── Get filename from PSI: source.psi?.containingFile?.name
│
├── is KtLightSourceElement with RealSourceElementKind
│ └── Source file, use synthetic filename (KMP)
│
└── null or other
└── Skip (dependency class from JAR)
In KMP projects, FIR runs in separate phases for each source set:
Phase 1: commonMain
├── Sees: expect classes, common classes
├── Generates: module() for common classes
└── Output: Synthetic files (or metadata)
Phase 2: androidMain (or iosMain, etc.)
├── Sees: actual classes + commonMain metadata
├── Generates: module() for actual classes
└── Output: Platform-specific synthetic files
Problem: Generating module() for expect classes causes duplicate definitions.
Solution: Skip expect classes, only generate for actual classes:
private val moduleClasses: List<FirClassSymbol<*>> by lazy {
session.predicateBasedProvider.getSymbolsByPredicate(modulePredicate)
.filterIsInstance<FirClassSymbol<*>>()
.filter { classSymbol ->
// Skip expect classes - only actual classes should have module()
val isExpect = classSymbol.rawStatus.isExpect
if (isExpect) {
log(" Skipping expect class: ${classSymbol.classId}")
}
!isExpect
}
}// Detect if we're compiling for K/Native
val isNativeTarget = session.moduleData.platform.isNative()
// Detect if class is actual (vs expect)
val isActual = classSymbol.rawStatus.isActualKotlin 2.3.0+ added containingFileName parameter to createTopLevelFunction:
createTopLevelFunction(
Key,
callableId,
returnType,
containingFileName = "MyFile.kt" // Where to place the function
) {
extensionReceiverType(type)
}val containingFile = when (source) {
is KtPsiSourceElement -> source.psi?.containingFile?.name
else -> null
}Pro: Functions appear in the same file as their class Con: Not available for KtLightSourceElement (KMP)
Deterministic naming based on class ID:
fun syntheticFileName(classId: ClassId, suffix: String): String {
val parts = sequence {
yieldAll(classId.packageFqName.pathSegments().map { it.asString() })
yield(classId.shortClassName.asString())
yield(suffix)
}
val fileName = parts
.map { segment -> segment.replaceFirstChar { it.uppercaseChar() } }
.joinToString(separator = "")
.replaceFirstChar { it.lowercaseChar() }
return "$fileName.kt"
}
// Examples:
// "com.example.DataModule" + "Module" -> "comExampleDataModuleModule.kt"
// "feature.FeatureModule" + "Configuration" -> "featureFeatureModuleConfiguration.kt"Pro: Deterministic, works on all platforms including K/Native Pro: Unique per class, no overwrites between phases Con: Long file names for deep packages
val className = classSymbol.classId.shortClassName.asString()
val packageName = classSymbol.classId.packageFqName.asString().replace(".", "_")
val effectiveFileName = "__Module_${packageName}_${className}__.kt"Pro: Unique per class Con: Non-deterministic format, verbose
Kotlin/Native has specific constraints around FIR-generated synthetic files that affect the plugin.
Synthetic files cause "source file count mismatch" errors during K/Native compilation.
When K/Native generates Objective-C headers for iOS frameworks, it fails to find source files for FIR-generated declarations:
e: kotlin.NotImplementedError: An operation is not implemented.
at org.jetbrains.kotlin.backend.common.serialization.LegacyDescriptorUtilsKt.findSourceFile
at org.jetbrains.kotlin.backend.konan.objcexport.ObjCExportHeaderGenerator.translatePackageFragments
This error originates from the Kotlin compiler itself (not our plugin), but is triggered by our FIR-generated declarations that use synthetic file names.
The fix is to detect K/Native and skip function generation when no real source file is available:
// Check if we're compiling for a Kotlin/Native target
private val isNativeTarget: Boolean by lazy {
session.moduleData.platform.isNative()
}
// In generateFunctions()
if (containingFile == null && isNativeTarget) {
log("Skipping function on Native target (no source file)")
return@mapNotNull null
}| Declaration Type | Behavior on Native |
|---|---|
module() extension with real source file |
Generated normally |
module() extension with synthetic file |
Skipped |
| Hint functions (always synthetic) | Skipped |
Hint functions are skipped on Native, but this is acceptable because:
- Cross-module discovery happens via the registry populated during earlier compilation phases
- Native targets typically consume modules from JVM/common compilations where hints are generated
- The
module()functions from dependencies are already in klibs
In KMP, source files are detected differently than JVM:
val containingFile = when (source) {
is KtPsiSourceElement -> {
// JVM/JS: Direct PSI access
source.psi?.containingFile?.name
}
else -> {
// KMP: Check if it's a real source (not metadata)
val isRealSource = source?.kind?.toString()?.contains("RealSourceElementKind") == true
if (isRealSource) {
// Use synthetic file name (will be skipped on Native)
syntheticFileName(classId, "Module")
} else {
// Dependency from JAR - skip
null
}
}
}IR phase cannot iterate classes from dependencies (JARs). How do we discover @Configuration modules from other modules?
Generate marker functions that encode module metadata:
// Generated in hints package
package org.koin.plugin.hints
fun configuration_default(contributed: MyModule): Unit = error("Stub!")
fun configuration_test(contributed: TestModule): Unit = error("Stub!")override fun generateFunctions(
callableId: CallableId,
context: MemberGenerationContext?
): List<FirNamedFunctionSymbol> {
if (callableId.packageName == HINTS_PACKAGE) {
val label = labelFromHintFunctionName(callableId.callableName.asString())
val modulesWithLabel = configurationModules.filter { it.labels.contains(label) }
return modulesWithLabel.map { configModule ->
createTopLevelFunction(
Key,
callableId,
session.builtinTypes.unitType.coneType,
containingFileName = syntheticFileName(configModule.classSymbol.classId, "Configuration")
) {
valueParameter(Name.identifier("contributed"), moduleType)
}.symbol
}
}
}private fun discoverModulesFromHintsIfNeeded() {
for (label in labelsToQuery) {
val functionName = hintFunctionNameForLabel(label)
// Query symbolProvider for hint functions in dependencies
val hintFunctions = session.symbolProvider.getTopLevelFunctionSymbols(
HINTS_PACKAGE, // FqName("org.koin.plugin.hints")
functionName // Name("configuration_default")
)
// Extract module class from parameter type
for (hintFunc in hintFunctions) {
val paramType = hintFunc.fir.valueParameters.firstOrNull()?.returnTypeRef?.coneType
val moduleClassId = paramType?.classId
if (moduleClassId != null) {
KoinConfigurationRegistry.registerJarModule(
moduleClassId.asSingleFqName().asString(),
label
)
}
}
}
}Critical: Without this, hints won't be visible to downstream modules:
// In IR transformer
context.metadataDeclarationRegistrar.registerFunctionAsMetadataVisible(declaration)FIR must claim ownership of the hints package:
override fun hasPackage(packageFqName: FqName): Boolean {
if (packageFqName == HINTS_PACKAGE && configurationModules.isNotEmpty()) {
return true
}
return super.hasPackage(packageFqName)
}Without this, KMP builds fail with "Module doesn't contain package" errors.
FIR and IR phases run in different classloaders. Static registries don't work:
// DOESN'T WORK
object Registry {
val modules = mutableSetOf<String>() // Different instance in each classloader!
}object KoinConfigurationRegistry {
private const val MODULES_PROPERTY = "koin.plugin.configuration.modules"
// FIR writes
fun registerModule(moduleClassName: String, labels: List<String>) {
synchronized(System.getProperties()) {
val labelMap = parseProperty()
for (label in labels) {
labelMap.getOrPut(label) { mutableSetOf() }.add(moduleClassName)
}
System.setProperty(MODULES_PROPERTY, serializeProperty(labelMap))
}
}
// IR reads
fun getModuleClassNamesForLabels(labels: List<String>): Set<String> {
val labelMap = parseProperty()
return labels.flatMap { labelMap[it] ?: emptySet() }.toSet()
}
// Serialization: "label1:mod1,mod2;label2:mod1,mod3"
private fun serializeProperty(labelMap: Map<String, Set<String>>): String {
return labelMap.entries.joinToString(";") { (label, modules) ->
"$label:${modules.joinToString(",")}"
}
}
}- Survive classloader boundaries
- Thread-safe with synchronization
- Simple key-value storage
- JVM-global (single compilation process)
Problem:
override fun getTopLevelCallableIds(): Set<CallableId> {
// BAD: symbolProvider calls getTopLevelCallableIds() internally
val functions = session.symbolProvider.getTopLevelFunctionSymbols(...)
}Solution: Move symbolProvider queries to generateFunctions() or use lazy initialization:
override fun generateFunctions(...): List<FirNamedFunctionSymbol> {
discoverModulesFromHintsIfNeeded() // Safe here
// ...
}Problem: Source type changes between registerPredicates() and generateFunctions() in KMP.
Solution: Capture source file info during initial discovery:
private data class ModuleClassInfo(
val classSymbol: FirClassSymbol<*>,
val containingFileName: String? // Captured at discovery time
)
private val moduleClassInfos: List<ModuleClassInfo> by lazy {
session.predicateBasedProvider.getSymbolsByPredicate(modulePredicate)
.mapNotNull { classSymbol ->
val fileName = extractFileName(classSymbol) // Capture NOW
ModuleClassInfo(classSymbol, fileName)
}
}Problem: Both commonMain and androidMain phases generate to __GENERATED__.kt, causing overwrites.
Solution: Use unique file names per class (see Synthetic File Generation).
Problem: KMP builds fail with "Module doesn't contain package org.koin.plugin.hints".
Solution: Override hasPackage():
override fun hasPackage(packageFqName: FqName): Boolean {
if (packageFqName == HINTS_PACKAGE) return true
return super.hasPackage(packageFqName)
}Problem: Hint functions generated in module A are not visible in module B.
Solution: Register as metadata-visible in IR phase:
context.metadataDeclarationRegistrar.registerFunctionAsMetadataVisible(declaration)Problem: JVM uses KtPsiSourceElement, KMP uses KtLightSourceElement.
Solution: Handle both:
val containingFile = when (source) {
is KtPsiSourceElement -> source.psi?.containingFile?.name
else -> {
val isRealSource = source?.kind?.toString()?.contains("RealSourceElementKind") == true
if (isRealSource) syntheticFileName(classId, "Module") else null
}
}Problem: module() generated for both expect and actual classes.
Solution: Filter out expect classes:
.filter { !classSymbol.rawStatus.isExpect }Problem: iOS/macOS framework linking fails with NotImplementedError: An operation is not implemented in findSourceFile.
Cause: K/Native's ObjC export phase tries to find source files for FIR-generated declarations that use synthetic file names.
Solution: Skip function generation on Native targets when no real source file is available:
private val isNativeTarget: Boolean by lazy {
session.moduleData.platform.isNative()
}
// In generateFunctions()
val containingFile = when (source) {
is KtPsiSourceElement -> source.psi?.containingFile?.name
else -> null
}
if (containingFile == null && isNativeTarget) {
return@mapNotNull null // Skip synthetic generation on Native
}Note: This error comes from the Kotlin compiler, not our plugin code, but is triggered by our synthetic declarations.
1. registerPredicates() - Register what to look for
2. getTopLevelCallableIds() - Declare what will be generated
3. generateFunctions() - Create function symbols (no bodies)
4. generateProperties() - Create property symbols (no bodies)
| Source Type | Platform | PSI Access | Action |
|---|---|---|---|
KtPsiSourceElement |
JVM/JS | Yes | Use source.psi?.containingFile?.name |
KtLightSourceElement + RealSourceElementKind |
KMP | No | Use synthetic file name |
null / other |
Dependency | No | Skip (already compiled) |
// Find classes by predicate
session.predicateBasedProvider.getSymbolsByPredicate(predicate)
// Query functions from dependencies
session.symbolProvider.getTopLevelFunctionSymbols(packageName, functionName)
// Create function symbol
createTopLevelFunction(key, callableId, returnType, containingFileName) { ... }
// Check platform
session.moduleData.platform.isNative()
// Check expect/actual
classSymbol.rawStatus.isExpect
classSymbol.rawStatus.isActual| Version | Feature |
|---|---|
| 2.3.0+ | containingFileName parameter in createTopLevelFunction |
| 2.3.20-Beta1 | ObjC export still fails with synthetic files (requires skip on Native) |
| All K2 | FIR + IR phases required |
| Platform | PSI Source | Synthetic Files | ObjC Export |
|---|---|---|---|
| JVM | KtPsiSourceElement |
Works | N/A |
| JS | KtPsiSourceElement |
Works | N/A |
| iOS/macOS | KtLightSourceElement |
Skip required | Fails without skip |
| watchOS/tvOS | KtLightSourceElement |
Skip required | Fails without skip |
| Linux/Windows | KtLightSourceElement |
Skip required | N/A |