From aed2ab645c129f960c84eca6a560ff95d5d0babe Mon Sep 17 00:00:00 2001 From: Ralf Wondratschek Date: Fri, 25 Oct 2024 13:24:54 -0700 Subject: [PATCH] Provide option to hide the merged interface Until now it was required to add the merged interface as super type, e.g. ``` @Component @MergeComponent(AppScope::class) interface AppComponent : AppComponentMerged ``` With the new mechanism the `@Component` annotation and the super type can be omitted. In this case we will generate the final kotlin-inject component under the hood. This removes boilerplate and brings us closer to the original Anvil design. Further, this will help with #20 in KMP scenarios where generated code cannot be access from common code. Fixes #8 --- .../kotlin/inject/anvil/ContextAware.kt | 7 + ...nInjectExtensionSymbolProcessorProvider.kt | 7 + .../GenerateKotlinInjectComponentProcessor.kt | 187 +++++++++ .../processor/MergeComponentProcessor.kt | 16 +- .../kotlin/inject/anvil/CommonSourceCode.kt | 11 +- ...erateKotlinInjectComponentProcessorTest.kt | 377 ++++++++++++++++++ .../processor/MergeComponentProcessorTest.kt | 25 ++ .../kotlin/inject/anvil/MergeComponent.kt | 22 +- .../anvil/sample/AndroidAppComponent.kt | 8 +- 9 files changed, 649 insertions(+), 11 deletions(-) create mode 100644 compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessor.kt create mode 100644 compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessorTest.kt diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt index dba362b..f0a4fc7 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContextAware.kt @@ -13,6 +13,7 @@ import com.google.devtools.ksp.symbol.KSFile import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.google.devtools.ksp.symbol.KSNode import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueParameter import com.google.devtools.ksp.symbol.Visibility import me.tatarka.inject.annotations.Qualifier import me.tatarka.inject.annotations.Scope @@ -191,6 +192,10 @@ internal interface ContextAware { fun KSDeclaration.requireQualifiedName(): String = requireQualifiedName(this@ContextAware) + fun KSValueParameter.requireName(): String = requireNotNull(name, this) { + "The name of the parameter $this was null." + }.asString() + fun Resolver.getSymbolsWithAnnotation(annotation: KClass<*>): Sequence = getSymbolsWithAnnotation(annotation.requireQualifiedName()) @@ -206,4 +211,6 @@ internal interface ContextAware { get() = requireQualifiedName() .split(".") .joinToString(separator = "") { it.capitalize() } + + val KSClassDeclaration.mergedClassName get() = "${innerClassNames()}Merged" } diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt index 0ccce3d..dfb9c02 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/KotlinInjectExtensionSymbolProcessorProvider.kt @@ -8,6 +8,7 @@ import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesBinding import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesSubcomponentFactoryProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesSubcomponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.ContributesToProcessor +import software.amazon.lastmile.kotlin.inject.anvil.processor.GenerateKotlinInjectComponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.MergeComponentProcessor import software.amazon.lastmile.kotlin.inject.anvil.processor.extend.ContributingAnnotationProcessor @@ -64,6 +65,12 @@ class KotlinInjectExtensionSymbolProcessorProvider : SymbolProcessorProvider { logger = environment.logger, ), ) + addIfEnabled( + GenerateKotlinInjectComponentProcessor( + codeGenerator = environment.codeGenerator, + logger = environment.logger, + ), + ) } return CompositeSymbolProcessor(symbolProcessors) diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessor.kt new file mode 100644 index 0000000..93b47bc --- /dev/null +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessor.kt @@ -0,0 +1,187 @@ +@file:OptIn(KspExperimental::class) + +package software.amazon.lastmile.kotlin.inject.anvil.processor + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier.ABSTRACT +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asTypeName +import com.squareup.kotlinpoet.ksp.addOriginatingKSFile +import com.squareup.kotlinpoet.ksp.toAnnotationSpec +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo +import me.tatarka.inject.annotations.Component +import software.amazon.lastmile.kotlin.inject.anvil.ContextAware +import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent +import software.amazon.lastmile.kotlin.inject.anvil.addOriginAnnotation +import kotlin.reflect.KClass + +/** + * Generates the final kotlin-inject component when [MergeComponent] is found without the + * [Component] annotation, e.g. + * ``` + * package software.amazon.test + * + * @MergeComponent(AppScope::class) + * @SingleIn(AppScope::class) + * interface TestComponent + * ``` + * Will generate: + * ``` + * package software.amazon.test + * + * @MergeComponent(AppScope::class) + * @Component + * @SingleIn(AppScope::class) + * interface KotlinInjectTestComponent : KotlinInjectTestComponentMerged + * ``` + * + * Parameters are supported as well, e.g. + * ``` + * package software.amazon.test + * + * @MergeComponent(AppScope::class) + * @SingleIn(AppScope::class) + * abstract class TestComponent( + * @get:Provides val string: String, + * ) + * ``` + * Will generate: + * ``` + * package software.amazon.test + * + * @MergeComponent(AppScope::class) + * @Component + * @SingleIn(AppScope::class) + * abstract class KotlinInjectTestComponent(string: String) : KotlinInjectTestComponentMerged(string) + * ``` + * + * This processor will also add a function to make instantiating the generated component easier. + * The function delegates the call to the final kotlin-inject component. For the example above + * the following function would be generated: + * ``` + * fun KClass.create(string: String): TestComponent { + * return KotlinInjectTestComponent::class.create(string) + * } + * ``` + */ +internal class GenerateKotlinInjectComponentProcessor( + private val codeGenerator: CodeGenerator, + override val logger: KSPLogger, +) : SymbolProcessor, ContextAware { + + private val processedComponents = mutableSetOf() + + @Suppress("ReturnCount") + override fun process(resolver: Resolver): List { + resolver + .getSymbolsWithAnnotation(MergeComponent::class) + .filterIsInstance() + .filter { it.requireQualifiedName() !in processedComponents } + .filter { !it.isAnnotationPresent(Component::class) } + .onEach { + checkIsPublic(it) + checkHasScope(it) + } + .forEach { + generateKotlinInjectComponent(it) + + processedComponents += it.requireQualifiedName() + } + + return emptyList() + } + + @Suppress("LongMethod") + private fun generateKotlinInjectComponent(clazz: KSClassDeclaration) { + val className = ClassName( + packageName = clazz.packageName.asString(), + simpleNames = listOf("KotlinInject${clazz.innerClassNames()}"), + ) + + val isInterface = clazz.classKind == ClassKind.INTERFACE + val parameters = clazz.primaryConstructor?.parameters ?: emptyList() + val parametersAsSpec = parameters.map { + ParameterSpec + .builder( + name = it.requireName(), + type = it.type.toTypeName(), + ) + .build() + } + + val classBuilder = if (isInterface) { + TypeSpec + .interfaceBuilder(className) + .addSuperinterface(clazz.toClassName()) + } else { + TypeSpec + .classBuilder(className) + .addModifiers(ABSTRACT) + .superclass(clazz.toClassName()) + .apply { + if (parameters.isNotEmpty()) { + primaryConstructor( + FunSpec.constructorBuilder() + .addParameters(parametersAsSpec) + .build(), + ) + addSuperclassConstructorParameter( + parameters.joinToString { it.requireName() }, + ) + } + } + } + + val fileSpec = FileSpec.builder(className) + .addType( + classBuilder + .addOriginatingKSFile(clazz.requireContainingFile()) + .addOriginAnnotation(clazz) + .addAnnotation(Component::class) + .addAnnotation(clazz.findAnnotation(MergeComponent::class).toAnnotationSpec()) + .apply { + clazz.annotations + .filter { it.isKotlinInjectScopeAnnotation() } + .singleOrNull() + ?.toAnnotationSpec() + ?.let { addAnnotation(it) } + } + .addSuperinterface( + className.peerClass("KotlinInject${clazz.mergedClassName}"), + ) + .build(), + ) + .addFunction( + FunSpec + .builder("create") + .receiver( + KClass::class.asTypeName().parameterizedBy(clazz.toClassName()), + ) + .addParameters(parametersAsSpec) + .returns(clazz.toClassName()) + .addStatement( + "return %T::class.create(${parametersAsSpec.joinToString { it.name }})", + className, + ) + .build(), + ) + .build() + + fileSpec.writeTo(codeGenerator, aggregating = false) + } +} diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessor.kt index 365932f..3675830 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessor.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessor.kt @@ -18,6 +18,7 @@ import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo +import me.tatarka.inject.annotations.Component import software.amazon.lastmile.kotlin.inject.anvil.ContextAware import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding import software.amazon.lastmile.kotlin.inject.anvil.ContributesSubcomponent @@ -95,6 +96,10 @@ internal class MergeComponentProcessor( .filterIsInstance() .distinctBy { it.requireQualifiedName() } .filter { it.requireQualifiedName() !in processedComponents } + .filter { it.isAnnotationPresent(Component::class) } + .onEach { + checkSuperTypeDeclared(it) + } .toList() // Nothing to do. @@ -141,7 +146,7 @@ internal class MergeComponentProcessor( ) { val className = ClassName( packageName = clazz.packageName.asString(), - simpleNames = listOf("${clazz.innerClassNames()}Merged"), + simpleNames = listOf(clazz.mergedClassName), ) val scope = clazz.scope() @@ -234,4 +239,13 @@ internal class MergeComponentProcessor( clazz.originOrNull() } } + + private fun checkSuperTypeDeclared(clazz: KSClassDeclaration) { + check(clazz.superTypes.map { it.toString() }.any { it == clazz.mergedClassName }, clazz) { + "${clazz.simpleName.asString()} is annotated with @MergeComponent and @Component. " + + "It's required to add ${clazz.mergedClassName} as super type to " + + "${clazz.simpleName.asString()}. If you don't want to add the super manually, " + + "then you must remove the @Component annotation." + } + } } diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/CommonSourceCode.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/CommonSourceCode.kt index 89a7c23..f56e482 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/CommonSourceCode.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/CommonSourceCode.kt @@ -4,6 +4,7 @@ package software.amazon.lastmile.kotlin.inject.anvil import com.tschuchort.compiletesting.JvmCompilationResult import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.descriptors.runtime.structure.primitiveByWrapper import software.amazon.lastmile.kotlin.inject.anvil.internal.Origin import java.lang.reflect.Field import java.lang.reflect.Modifier @@ -26,11 +27,15 @@ internal val Class<*>.generatedComponent: Class<*> internal val JvmCompilationResult.contributesRenderer: Class<*> get() = classLoader.loadClass("software.amazon.test.ContributesRenderer") -internal fun Class<*>.newComponent(): T { +internal fun Class<*>.newComponent(vararg arguments: Any): T { @Suppress("UNCHECKED_CAST") return classLoader.loadClass("$packageName.Inject$simpleName") - .getDeclaredConstructor() - .newInstance() as T + .getDeclaredConstructor( + *arguments.map { arg -> + arg::class.java.primitiveByWrapper ?: arg::class.java + }.toTypedArray(), + ) + .newInstance(*arguments) as T } internal val Class<*>.mergedComponent: Class<*> diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessorTest.kt new file mode 100644 index 0000000..1b654f2 --- /dev/null +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/GenerateKotlinInjectComponentProcessorTest.kt @@ -0,0 +1,377 @@ +@file:OptIn(ExperimentalCompilerApi::class) + +package software.amazon.lastmile.kotlin.inject.anvil.processor + +import assertk.assertThat +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isTrue +import com.tschuchort.compiletesting.JvmCompilationResult +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test +import software.amazon.lastmile.kotlin.inject.anvil.compile +import software.amazon.lastmile.kotlin.inject.anvil.componentInterface +import software.amazon.lastmile.kotlin.inject.anvil.inner +import software.amazon.lastmile.kotlin.inject.anvil.newComponent +import java.lang.reflect.Method + +class GenerateKotlinInjectComponentProcessorTest { + + @Test + fun `the kotlin-inject component is generated with merged components`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(string: String) : Base + + @ContributesTo(AppScope::class) + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + interface ComponentInterface { + val base: Base + } + """, + ) { + val component = componentInterface.kotlinInjectComponent.newComponent() + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + } + } + + @Test + fun `the kotlin-inject component is generated with merged components for an inner class`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(string: String) : Base + + @ContributesTo(AppScope::class) + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + interface ComponentInterface { + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + interface Inner { + val base: Base + } + } + """, + ) { + val component = componentInterface.inner.kotlinInjectComponent.newComponent() + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + } + } + + @Test + fun `the kotlin-inject component is generated with merged components for an abstract class`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(string: String) : Base + + @ContributesTo(AppScope::class) + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + abstract class ComponentInterface { + abstract val base: Base + } + """, + ) { + val component = componentInterface.kotlinInjectComponent.newComponent() + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + } + } + + @Test + fun `an abstract class supports parameters`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.ForScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(@ForScope(AppScope::class) val string: String, val int: Int) : Base { + override fun toString(): String = string + int + } + + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + abstract class ComponentInterface( + @get:Provides @get:ForScope(AppScope::class) val string: String, + @get:Provides val int: Int, + ) { + abstract val base: Base + } + """, + useKsp2 = false, + ) { + val component = componentInterface.kotlinInjectComponent.newComponent("", 5) + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + } + } + + @Test + fun `the kotlin-inject component is generated with merged components without a scope`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + + interface Base + + @Inject + @ContributesBinding(AppScope::class) + class Impl(string: String) : Base + + @ContributesTo(AppScope::class) + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + @MergeComponent(AppScope::class) + interface ComponentInterface { + val base: Base + } + """, + ) { + val component = componentInterface.kotlinInjectComponent.newComponent() + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + } + } + + @Test + fun `excluded types are excluded in the final component`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(string: String) : Base + + @ContributesTo(AppScope::class) + interface ImplComponent { + val base: Base + } + + @MergeComponent(AppScope::class, exclude = [ImplComponent::class]) + @SingleIn(AppScope::class) + interface ComponentInterface + """, + ) { + val component = componentInterface.kotlinInjectComponent.newComponent() + + assertThat(component::class.java.methods.map { it.name }).doesNotContain("getBase") + } + } + + @Test + fun `a function is generated to create a component`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(string: String) : Base + + @ContributesTo(AppScope::class) + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + interface ComponentInterface { + val base: Base + } + """, + ) { + // Note that this invokes the generated function and verifies that + // KClass is the receiver type. + val component = componentInterface.kotlinInjectComponent.createFunction + .invoke(null, componentInterface.kotlin) + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + } + } + + @Test + fun `a function is generated to create a component with parameters`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Inject + import me.tatarka.inject.annotations.Provides + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.ForScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + @ContributesBinding(AppScope::class) + class Impl(@ForScope(AppScope::class) val string: String, val int: Int) : Base { + override fun toString(): String = string + int + } + + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + abstract class ComponentInterface( + @get:Provides @get:ForScope(AppScope::class) val string: String, + @get:Provides val int: Int, + ) { + abstract val base: Base + } + """, + useKsp2 = false, + ) { + // Note that this invokes the generated function and verifies that + // KClass is the receiver type. + val component = componentInterface.kotlinInjectComponent.createFunction + .invoke(null, componentInterface.kotlin, "hello", 6) + + val implValue = component::class.java.methods + .single { it.name == "getBase" } + .invoke(component) + + assertThat(impl.isInstance(implValue)).isTrue() + assertThat(implValue?.toString()).isEqualTo("hello6") + } + } + + private val JvmCompilationResult.impl: Class<*> + get() = classLoader.loadClass("software.amazon.test.Impl") + + private val Class<*>.kotlinInjectComponent: Class<*> + get() = classLoader.loadClass( + "$packageName.KotlinInject" + + canonicalName.substring(packageName.length + 1).replace(".", ""), + ) + + private val Class<*>.createFunction: Method + get() = classLoader.loadClass("${canonicalName}Kt").methods.single { it.name == "create" } +} diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessorTest.kt index 5aca6ef..133a35d 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessorTest.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/MergeComponentProcessorTest.kt @@ -388,6 +388,31 @@ class MergeComponentProcessorTest { } } + @Test + fun `the super type must be declared`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Component + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + + @Component + @MergeComponent(AppScope::class) + abstract class ComponentInterface + """, + exitCode = COMPILATION_ERROR, + ) { + assertThat(messages).contains( + "ComponentInterface is annotated with @MergeComponent and " + + "@Component. It's required to add ComponentInterfaceMerged as super " + + "type to ComponentInterface. If you don't want to add the super manually, " + + "then you must remove the @Component annotation.", + ) + } + } + @Test fun `using a different kotlin-inject scope with marker scopes is allowed`() { compile( diff --git a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt index ee57e49..1fd08fb 100644 --- a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt +++ b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeComponent.kt @@ -4,9 +4,25 @@ import kotlin.annotation.AnnotationTarget.CLASS import kotlin.reflect.KClass /** - * Will merge all contributed component interfaces in a single interface. The generated interface - * needs to be manually added as super type to this component, e.g. + * Will merge all contributed component interfaces in a single interface. It's not required + * to add the original `@Component` annotation from kotlin-inject to your component. This + * annotation will generate the final kotlin-inject component under the hood: + * ``` + * @MergeComponent(AppScope::class) + * @SingleIn(AppScope::class) + * abstract class AppComponent( + * ... + * ) + * ``` + * Through an extension function on the class object the component can be instantiated: + * ``` + * val component = AppComponent::class.create(...) + * ``` * + * Note that in this example `AppComponent` will not implement all contributed interfaces directly. + * Instead, the final generated kotlin-inject component will contain all contributions. If this + * is important, e.g. for better IDE support, then you can the `@Component` annotation directly + * to the class with the super type: * ``` * @Component * @MergeComponent(AppScope::class) @@ -18,6 +34,8 @@ import kotlin.reflect.KClass * The `@MergeComponent` annotation will generate the `AppComponentMerged` interface in the * same package as `AppComponent`. * + * ## Exclusions + * * It's possible to exclude any automatically added component interfaces with the [exclude] * parameter if needed. * diff --git a/sample/app/src/androidMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/AndroidAppComponent.kt b/sample/app/src/androidMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/AndroidAppComponent.kt index e588232..64cbbfc 100644 --- a/sample/app/src/androidMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/AndroidAppComponent.kt +++ b/sample/app/src/androidMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/sample/AndroidAppComponent.kt @@ -1,7 +1,6 @@ package software.amazon.lastmile.kotlin.inject.anvil.sample import android.app.Application -import me.tatarka.inject.annotations.Component import me.tatarka.inject.annotations.Provides import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent @@ -9,14 +8,13 @@ import software.amazon.lastmile.kotlin.inject.anvil.SingleIn /** * Concrete application component for Android using the scope [SingleIn] [AppScope]. - * [AndroidAppComponentMerged] is a generated interface. Through this merged interface + * The final kotlin-inject component is generated and will extend * [ApplicationIdProviderComponent], other contributed component interfaces and contributed - * bindings such as from [AndroidApplicationIdProvider] are implemented. + * bindings such as from [AndroidApplicationIdProvider]. * * Note that this component lives in an Android source folder and therefore types such as * [Application] can be provided in the object graph. */ -@Component @MergeComponent(AppScope::class) @SingleIn(AppScope::class) abstract class AndroidAppComponent( @@ -24,4 +22,4 @@ abstract class AndroidAppComponent( * The Android application that is provided to this object graph. */ @get:Provides val application: Application, -) : AndroidAppComponentMerged +)