diff --git a/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/fir/KMapperFirMappingChecker.kt b/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/fir/KMapperFirMappingChecker.kt index e39244f..65efc1b 100644 --- a/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/fir/KMapperFirMappingChecker.kt +++ b/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/fir/KMapperFirMappingChecker.kt @@ -162,7 +162,8 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess return resolvedTypeArgument?.resolveConstructorFields() } - private fun ConeKotlinType.resolveConstructorFields(): List { + private fun ConeKotlinType.resolveConstructorFields(visited: MutableSet = mutableSetOf()): List { + if (!visited.add(this)) return emptyList() val classSymbol = toRegularClassSymbol(session) if (classSymbol?.moduleData is FirBinaryDependenciesModuleData) return emptyList() val primaryConstructor = classSymbol?.constructors(session)?.firstOrNull() @@ -171,7 +172,7 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess name = parameter.name, type = parameter.resolvedReturnType, hasDefaultValue = parameter.hasDefaultValue, - fields = parameter.resolvedReturnType.resolveConstructorFields() + fields = parameter.resolvedReturnType.resolveConstructorFields(visited) ) }.orEmpty() } @@ -181,7 +182,8 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess return resolvedTypeArgument?.resolvePropertyFields() } - private fun ConeKotlinType.resolvePropertyFields(): List { + private fun ConeKotlinType.resolvePropertyFields(visited: MutableSet = mutableSetOf()): List { + if (!visited.add(this)) return emptyList() val classSymbol = toRegularClassSymbol(session) return classSymbol?.declaredProperties(session) .orEmpty() @@ -190,7 +192,7 @@ class KMapperFirMappingChecker(val collector: MessageCollector, private val sess name = property.name, type = property.resolvedReturnType, hasDefaultValue = property.resolvedDefaultValue != null, - fields = property.resolvedReturnType.resolvePropertyFields() + fields = property.resolvedReturnType.resolvePropertyFields(visited) ) } } diff --git a/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/ir/KMapperIrBuildMapperVisitor.kt b/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/ir/KMapperIrBuildMapperVisitor.kt index 35a2a53..97ae65a 100644 --- a/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/ir/KMapperIrBuildMapperVisitor.kt +++ b/compiler-plugin/src/main/kotlin/community/flock/kmapper/compiler/ir/KMapperIrBuildMapperVisitor.kt @@ -119,14 +119,33 @@ class KMapperIrBuildMapperVisitor( val fromTypeArgument = expression.typeArguments[1] ?: error("Could not resolve source type for mapper") val remapper = object : IrElementTransformerVoid() { + // The mapper lambda has two parameters: + // 1. Extension receiver ($this$mapper): the TO type being constructed + // 2. Regular parameter (it): the FROM source instance + // When extracting value expressions from the lambda body, we need to remap + // both parameters. The FROM parameter (it) is replaced with the actual receiver. + // The TO parameter ($this$mapper) may be captured in nested lambda closures + // even if not explicitly used; we must also remap it to avoid dangling references + // after the lambda is removed from the IR tree. + val itParamSymbol = callArgument?.function?.parameters + ?.firstOrNull { it.kind == IrParameterKind.Regular || it.kind == IrParameterKind.Context } + ?.symbol + val thisParamSymbol = callArgument?.function?.parameters + ?.firstOrNull { it.kind == IrParameterKind.ExtensionReceiver } + ?.symbol + override fun visitGetValue(expression: IrGetValue): IrExpression { val transformedGetValue = super.visitGetValue(expression) - val itParamSymbol = callArgument?.function?.parameters - ?.firstOrNull { it.kind == IrParameterKind.Regular || it.kind == IrParameterKind.Context } - ?.symbol if (expression.symbol == itParamSymbol) { return receiverArgument.deepCopyWithSymbols() } + // The TO extension receiver ($this$mapper) may be captured by nested lambdas. + // Since the TO object is being constructed (not yet available), we replace + // references with the receiver argument. This is safe because the captured + // reference is never actually accessed at runtime — only present in the closure. + if (expression.symbol == thisParamSymbol) { + return receiverArgument.deepCopyWithSymbols() + } return transformedGetValue } } diff --git a/test-integration/src/test/kotlin/community/flock/kmapper/BasicMappingTest.kt b/test-integration/src/test/kotlin/community/flock/kmapper/BasicMappingTest.kt index 014fc4c..d71665f 100644 --- a/test-integration/src/test/kotlin/community/flock/kmapper/BasicMappingTest.kt +++ b/test-integration/src/test/kotlin/community/flock/kmapper/BasicMappingTest.kt @@ -642,6 +642,172 @@ class BasicMappingTest { } } + @Test + fun shouldCompile_valueClassAutoMapInDslBlock() { + IntegrationTest(options) + .file("App.kt") { + $$""" + |package sample + | + |import community.flock.kmapper.mapper + | + |@JvmInline + |value class ModelId(val value: String) + |@JvmInline + |value class TaskDesc(val value: String) + | + |data class Run( + | val id: String, + | val modelIdentifier: ModelId, + | val taskDescription: TaskDesc, + | val isViewed: Boolean, + |) + | + |data class RunDto( + | val id: String, + | val modelIdentifier: String, + | val taskDescription: String, + | val isViewed: Boolean, + |) + | + |fun Run.toDto(): RunDto = mapper { + | isViewed = it.isViewed + |} + | + |fun main() { + | val run = Run(id = "1", modelIdentifier = ModelId("gpt-4"), taskDescription = TaskDesc("do stuff"), isViewed = true) + | val dto = run.toDto() + | println(dto) + |} + | + """.trimMargin() + } + .compileSuccess { output -> + assertTrue( + output.contains("RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true)"), + "Expected RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true) in output but got: $output" + ) + } + } + + @Test + fun shouldCompile_valueClassAutoMapInDslBlockWithSerializable() { + IntegrationTest( + options.copy( + additionalPlugins = listOf("""kotlin("plugin.serialization") version "${options.kotlinVersion}""""), + additionalDependencies = listOf("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1"), + ) + ) + .file("App.kt") { + $$""" + |package sample + | + |import community.flock.kmapper.mapper + |import kotlinx.serialization.Serializable + |import kotlinx.serialization.SerialName + | + |@JvmInline + |value class ModelId(val value: String) + |@JvmInline + |value class TaskDesc(val value: String) + | + |data class Run( + | val id: String, + | val modelIdentifier: ModelId, + | val taskDescription: TaskDesc, + | val isViewed: Boolean, + |) + | + |@Serializable + |@SerialName("RunDto") + |data class RunDto( + | val id: String, + | val modelIdentifier: String, + | val taskDescription: String, + | val isViewed: Boolean, + |) + | + |fun Run.toDto(): RunDto = mapper { + | isViewed = it.isViewed + |} + | + |fun main() { + | val run = Run(id = "1", modelIdentifier = ModelId("gpt-4"), taskDescription = TaskDesc("do stuff"), isViewed = true) + | val dto = run.toDto() + | println(dto) + |} + | + """.trimMargin() + } + .compileSuccess { output -> + assertTrue( + output.contains("RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true)"), + "Expected RunDto(id=1, modelIdentifier=gpt-4, taskDescription=do stuff, isViewed=true) in output but got: $output" + ) + } + } + + @Test + fun shouldCompile_selfReferencingType() { + IntegrationTest(options) + .file("App.kt") { + $$""" + |package sample + | + |import community.flock.kmapper.mapper + | + |data class Category(val name: String, val parent: Category?) + |data class CategoryDto(val name: String, val parent: Category?) + | + |fun main() { + | val category = Category("child", Category("root", null)) + | val dto: CategoryDto = category.mapper() + | println(dto) + |} + | + """.trimMargin() + } + .compileSuccess { output -> + assertTrue( + output.contains("CategoryDto(name=child, parent=Category(name=root, parent=null))"), + "Expected CategoryDto(name=child, parent=Category(name=root, parent=null)) in output" + ) + } + } + + @Test + fun shouldCompile_nestedLambdaInMapperBlock() { + IntegrationTest(options) + .file("App.kt") { + $$""" + |package sample + | + |import community.flock.kmapper.mapper + | + |data class User(val firstName: String, val tags: List) + |data class UserDto(val name: String, val tags: List) + | + |fun User.toDto(): UserDto = mapper { + | name = it.firstName + | tags = it.tags.map { tag -> tag.uppercase() } + |} + | + |fun main() { + | val user = User("Alice", listOf("kotlin", "java")) + | val dto = user.toDto() + | println(dto) + |} + | + """.trimMargin() + } + .compileSuccess { output -> + assertTrue( + output.contains("UserDto(name=Alice, tags=[KOTLIN, JAVA])"), + "Expected UserDto(name=Alice, tags=[KOTLIN, JAVA]) in output" + ) + } + } + @Test fun shouldFail_missingParameterAge() { IntegrationTest(options)