From cf5e2c50b495fb93e1c30eae86b62a62f59bebfd Mon Sep 17 00:00:00 2001 From: Ralf Wondratschek Date: Wed, 11 Sep 2024 21:38:31 -0700 Subject: [PATCH] Support scopes with parameters That's a significant change and changes the API surface. kotlin-inject supports scopes with and without parameters. This change implements a similar API as Anvil for Dagger 2 used to provide, where scope references can be added to annotations, e.g. ```kotlin @ContributesTo(AppScope::class) interface ContributedComponentInterface @Component @MergeComponent(AppScope::class) interface MergedComponent ``` The existing mechanism by annotating classes with the scope annotation is still working: ```kotlin @ContributesTo @SingleInAppScope interface ContributedComponentInterface @Component @MergeComponent @SingleInAppScope interface MergedComponent ``` Most of the logic changed in the way we resolve the scope where to merge code, which happens during compilation. There is an additional change for generated code, where we no longer add the scope to generated interfaces and instead rely on the `@Origin` annotation to determine the scope. That simplifies code generation and is backwards compatible. Resolves #1. --- compiler/build.gradle | 1 + .../kotlin/inject/anvil/ContextAware.kt | 105 ++++- .../kotlin/inject/anvil/MergeScope.kt | 157 +++++++ .../lastmile/kotlin/inject/anvil/Util.kt | 21 + .../processor/ContributesBindingProcessor.kt | 83 +--- ...ContributesSubcomponentFactoryProcessor.kt | 5 +- .../ContributesSubcomponentProcessor.kt | 12 +- .../anvil/processor/ContributesToProcessor.kt | 5 +- .../processor/MergeComponentProcessor.kt | 10 +- .../inject/anvil/MergeScopeParserTest.kt | 394 ++++++++++++++++++ .../ContributesBindingProcessorTest.kt | 68 +-- ...ributesSubcomponentFactoryProcessorTest.kt | 35 +- .../ContributesSubcomponentProcessorTest.kt | 87 ++++ .../processor/ContributesToProcessorTest.kt | 31 +- .../processor/MergeComponentProcessorTest.kt | 139 +++++- gradle/libs.versions.toml | 2 +- runtime/api/android/runtime.api | 4 + runtime/api/jvm/runtime.api | 4 + runtime/api/runtime.klib.api | 23 +- .../kotlin/inject/anvil/ContributesBinding.kt | 2 +- .../inject/anvil/ContributesSubcomponent.kt | 15 +- .../kotlin/inject/anvil/ContributesTo.kt | 13 +- .../kotlin/inject/anvil/MergeComponent.kt | 5 + 23 files changed, 1024 insertions(+), 197 deletions(-) create mode 100644 compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScope.kt create mode 100644 compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScopeParserTest.kt diff --git a/compiler/build.gradle b/compiler/build.gradle index 892be49..4f2b35d 100644 --- a/compiler/build.gradle +++ b/compiler/build.gradle @@ -22,6 +22,7 @@ dependencies { // Gives us access to annotations. implementation libs.kotlin.inject.runtime + testImplementation project(':runtime-optional') testImplementation libs.assertk testImplementation libs.kotlin.compile.testing.core testImplementation libs.kotlin.compile.testing.ksp 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 1ba6844..d9e3199 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 @@ -64,21 +64,97 @@ internal interface ContextAware { check(clazz.classKind == ClassKind.INTERFACE, clazz, lazyMessage) } - fun KSClassDeclaration.scope(): KSAnnotation { + fun checkHasScope(clazz: KSClassDeclaration) { + // Ensures that the value is non-null. + clazz.scope() + } + + fun KSClassDeclaration.scope(): MergeScope { return requireNotNull(scopeOrNull(), this) { "Couldn't find scope annotation for $this." } } - fun KSClassDeclaration.scopeOrNull(): KSAnnotation? = - annotations.firstOrNull { isScopeAnnotation(it) } + private fun KSClassDeclaration.scopeOrNull(): MergeScope? { + val annotationsWithScopeParameter = annotations.filter { + // Avoid scope annotations themselves, e.g. that skips `@SingleIn` and include + // annotations with a "scope" parameter, e.g. `@ContributesTo`. + !isScopeAnnotation(it) && it.hasScopeParameter() + }.toList() + + return if (annotationsWithScopeParameter.isEmpty()) { + annotations.firstOrNull { isScopeAnnotation(it) } + ?.let { MergeScope(this@ContextAware, it) } + } else { + scopeForAnnotationsWithScopeParameters(this, annotationsWithScopeParameter) + } + } + + fun isScopeAnnotation(annotation: KSAnnotation): Boolean { + return isScopeAnnotation(annotation.annotationType.resolve()) + } - private fun isScopeAnnotation(annotation: KSAnnotation): Boolean { - return annotation.annotationType.resolve().declaration.annotations.any { + private fun isScopeAnnotation(type: KSType): Boolean { + return type.declaration.annotations.any { it.annotationType.resolve().declaration.requireQualifiedName() == scopeFqName } } + private fun KSAnnotation.hasScopeParameter(): Boolean { + return (annotationType.resolve().declaration as? KSClassDeclaration) + ?.primaryConstructor?.parameters?.firstOrNull()?.name?.asString() == "scope" + } + + private fun scopeForAnnotationsWithScopeParameters( + clazz: KSClassDeclaration, + annotations: List, + ): MergeScope { + val explicitScopes = annotations.mapNotNull { annotation -> + annotation.scopeParameter(this) + } + + val classScope = clazz.annotations.firstOrNull { isScopeAnnotation(it) } + ?.let { MergeScope(this, it) } + + if (explicitScopes.isNotEmpty()) { + check(explicitScopes.size == annotations.size, clazz) { + "If one annotation has an explicit scope, then all " + + "annotations must specify an explicit scope." + } + + explicitScopes.scan( + explicitScopes.first().declaration.requireQualifiedName(), + ) { previous, next -> + check(previous == next.declaration.requireQualifiedName(), clazz) { + "All explicit scopes on annotations must be the same." + } + previous + } + + val explicitScope = explicitScopes.first() + val explicitScopeIsScope = isScopeAnnotation(explicitScope) + + return if (explicitScopeIsScope) { + MergeScope( + contextAware = this, + annotationType = explicitScope, + markerType = null, + ) + } else { + MergeScope( + contextAware = this, + annotationType = null, + markerType = explicitScope, + ) + } + } + + return requireNotNull(classScope, clazz) { + "Couldn't find scope for ${clazz.simpleName.asString()}. For unscoped " + + "objects it is required to specify the target scope on the annotation." + } + } + fun KSClassDeclaration.origin(): KSClassDeclaration { val annotation = findAnnotation(Origin::class) @@ -98,11 +174,6 @@ internal interface ContextAware { fun KSClassDeclaration.findAnnotations(annotation: KClass): List { val fqName = annotation.requireQualifiedName() return annotations.filter { it.isAnnotation(fqName) }.toList() - .also { - check(it.isNotEmpty(), this) { - "Couldn't find the @${annotation.simpleName} annotation for $this." - } - } } fun KSAnnotation.isAnnotation(fqName: String): Boolean { @@ -117,23 +188,11 @@ internal interface ContextAware { "Containing file was null for $this" } - fun KSDeclaration.requireQualifiedName(): String = - requireNotNull(qualifiedName?.asString(), this) { - "Qualified name was null for $this" - } - - fun KClass<*>.requireQualifiedName(): String = requireNotNull(qualifiedName) { - "Qualified name was null for $this" - } + fun KSDeclaration.requireQualifiedName(): String = requireQualifiedName(this@ContextAware) fun Resolver.getSymbolsWithAnnotation(annotation: KClass<*>): Sequence = getSymbolsWithAnnotation(annotation.requireQualifiedName()) - fun KSAnnotation.isSameAs(other: KSAnnotation): Boolean { - return annotationType.resolve().declaration.requireQualifiedName() == - other.annotationType.resolve().declaration.requireQualifiedName() - } - fun KSDeclaration.innerClassNames(separator: String = ""): String { val classNames = requireQualifiedName().substring(packageName.asString().length + 1) return classNames.replace(".", separator) diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScope.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScope.kt new file mode 100644 index 0000000..27c5df8 --- /dev/null +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScope.kt @@ -0,0 +1,157 @@ +package software.amazon.lastmile.kotlin.inject.anvil + +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ksp.toAnnotationSpec +import com.squareup.kotlinpoet.ksp.toClassName + +/** + * Represents the destination of contributed types and which types should be merged during + * the merge phase. There is complexity to this problem, because `kotlin-inject` didn't + * support parameters for scopes initially and our Anvil extensions added support for that. Later, + * we started supporting parameters, which changed the API. E.g. one could use: + * ``` + * @ContributesTo(AppScope::class) + * interface ContributedComponentInterface + * + * @Component + * @MergeComponent(AppScope::class) + * interface MergedComponent + * ``` + * Or the old way: + * ``` + * @ContributesTo + * @SingleInAppScope + * interface ContributedComponentInterface + * + * @Component + * @MergeComponent + * @SingleInAppScope + * interface MergedComponent + * ``` + */ +internal sealed class MergeScope { + /** + * The fully qualified name of the annotation used as scope, e.g. + * ``` + * @ContributesTo + * @SingleInAppScope + * interface Abc + * ``` + * Note that the annotation itself is annotated with `@Scope`. + * + * The value is `null`, when only a marker is used, e.g. + * ``` + * @ContributesTo(AppScope::class) + * interface Abc + * ``` + * + * If the `scope` parameter is used and the argument is annotated with `@Scope`, then + * this value is non-null, e.g. for this: + * ``` + * @ContributesBinding(scope = SingleInAppScope::class) + * class Binding : SuperType + * ``` + */ + abstract val annotationFqName: String? + + /** + * A marker for a scope that isn't itself annotated with `@Scope`, e.g. + * ``` + * @ContributesTo(AppScope::class) + * interface Abc + * ``` + * + * The value is null, if no marker is used, e.g. + * ``` + * @ContributesTo + * @SingleInAppScope + * interface Abc + * ``` + * + * The value is also null, when the `scope` parameter is used and the argument is annotated + * with `@Scope`, e.g. + * ``` + * @ContributesBinding(scope = SingleInAppScope::class) + * class Binding : SuperType + * ``` + */ + abstract val markerFqName: String? + + /** + * A reference to the scope. + * + * [markerFqName] is preferred, because it allows us to decouple contributions from + * kotlin-inject's scoping mechanism. E.g. imagine someone using `@Singleton` as a scope, and + * they'd like to adopt kotlin-inject-anvil with `@ContributesTo(AppScope::class)`. Because we + * prefer the marker, this would be supported. + */ + val fqName: String get() = requireNotNull(markerFqName ?: annotationFqName) + + abstract fun toAnnotationSpec(): AnnotationSpec + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is MergeScope) return false + + if (fqName != other.fqName) return false + + return true + } + + override fun hashCode(): Int { + return fqName.hashCode() + } + + private class MarkerBasedMergeScope( + override val annotationFqName: String, + override val markerFqName: String?, + private val ksAnnotation: KSAnnotation, + ) : MergeScope() { + override fun toAnnotationSpec(): AnnotationSpec { + return ksAnnotation.toAnnotationSpec() + } + } + + private class AnnotationBasedMergeScope( + override val annotationFqName: String?, + override val markerFqName: String?, + private val ksType: KSType, + ) : MergeScope() { + override fun toAnnotationSpec(): AnnotationSpec { + return AnnotationSpec.builder(ksType.toClassName()).build() + } + } + + companion object { + operator fun invoke( + contextAware: ContextAware, + annotationType: KSType?, + markerType: KSType?, + ): MergeScope { + val nonNullType = contextAware.requireNotNull(markerType ?: annotationType, null) { + "Couldn't determine scope. No scope annotation nor marker found." + } + + return AnnotationBasedMergeScope( + annotationFqName = annotationType?.declaration?.requireQualifiedName(contextAware), + markerFqName = markerType?.declaration?.requireQualifiedName(contextAware), + ksType = nonNullType, + ) + } + + operator fun invoke( + contextAware: ContextAware, + ksAnnotation: KSAnnotation, + ): MergeScope { + return MarkerBasedMergeScope( + annotationFqName = ksAnnotation.annotationType.resolve().declaration + .requireQualifiedName(contextAware), + markerFqName = ksAnnotation.scopeParameter(contextAware)?.declaration + ?.requireQualifiedName(contextAware), + ksAnnotation = ksAnnotation, + ) + } + } +} diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Util.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Util.kt index 081849c..f392612 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Util.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/Util.kt @@ -3,12 +3,15 @@ package software.amazon.lastmile.kotlin.inject.anvil import com.google.devtools.ksp.isDefault import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSValueArgument import com.squareup.kotlinpoet.Annotatable import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ksp.toClassName import software.amazon.lastmile.kotlin.inject.anvil.internal.Origin import java.util.Locale +import kotlin.reflect.KClass /** * The package in which code is generated that should be picked up during the merging phase. @@ -62,3 +65,21 @@ internal fun KSAnnotation.argumentAt(name: String): KSValueArgument? { return arguments.find { it.name?.asString() == name } ?.takeUnless { it.isDefault() } } + +internal fun KSDeclaration.requireQualifiedName(contextAware: ContextAware): String = + contextAware.requireNotNull(qualifiedName?.asString(), this) { + "Qualified name was null for $this" + } + +internal fun KClass<*>.requireQualifiedName(): String = requireNotNull(qualifiedName) { + "Qualified name was null for $this" +} + +internal fun KSAnnotation.scopeParameter(contextAware: ContextAware): KSType? { + return arguments.firstOrNull { it.name?.asString() == "scope" } + ?.let { it.value as? KSType } + ?.takeIf { + it.declaration.requireQualifiedName(contextAware) != + Unit::class.requireQualifiedName() + } +} diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessor.kt index 8c8b6bd..b6cb14f 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessor.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessor.kt @@ -24,6 +24,8 @@ import software.amazon.lastmile.kotlin.inject.anvil.LOOKUP_PACKAGE import software.amazon.lastmile.kotlin.inject.anvil.addOriginAnnotation import software.amazon.lastmile.kotlin.inject.anvil.argumentOfTypeAt import software.amazon.lastmile.kotlin.inject.anvil.decapitalize +import software.amazon.lastmile.kotlin.inject.anvil.requireQualifiedName +import kotlin.reflect.KClass /** * Generates the code for [ContributesBinding]. @@ -43,7 +45,6 @@ import software.amazon.lastmile.kotlin.inject.anvil.decapitalize * ``` * package $LOOKUP_PACKAGE * - * @SingleInAppScope * @Origin(ComponentInterface::class) * interface SoftwareAmazonTestRealAuthenticator { * @Provides fun provideRealAuthenticatorAuthenticator( @@ -65,6 +66,7 @@ internal class ContributesBindingProcessor( .filterIsInstance() .onEach { checkIsPublic(it) + checkHasScope(it) } .forEach { generateComponentInterface(it) @@ -77,11 +79,9 @@ internal class ContributesBindingProcessor( private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE, clazz.safeClassName) - val annotations = clazz.findAnnotations(ContributesBinding::class) + val annotations = clazz.findAnnotationsAtLeastOne(ContributesBinding::class) checkNoDuplicateBoundTypes(clazz, annotations) - val scope = scope(clazz, annotations) - val boundTypes = annotations .map { GeneratedFunction( @@ -96,7 +96,6 @@ internal class ContributesBindingProcessor( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) - .addAnnotation(scope.toClassName()) .addOriginAnnotation(clazz) .addFunctions( boundTypes.map { function -> @@ -158,70 +157,6 @@ internal class ContributesBindingProcessor( } } - private fun scope( - clazz: KSClassDeclaration, - annotations: List, - ): KSType { - val explicitScopes = annotations.mapNotNull { annotation -> - annotation.arguments.firstOrNull { it.name?.asString() == "scope" } - ?.let { it.value as? KSType } - ?.takeIf { - it.declaration.requireQualifiedName() != - Annotation::class.requireQualifiedName() - } - } - - val classScope = clazz.scopeOrNull()?.annotationType?.resolve() - - if (explicitScopes.isNotEmpty()) { - check(explicitScopes.size == annotations.size, clazz) { - "If one @ContributesBinding annotation has an explicit scope, then all " + - "annotations must specify an explicit scope." - } - - explicitScopes.scan( - explicitScopes.first().declaration.requireQualifiedName(), - ) { previous, next -> - check(previous == next.declaration.requireQualifiedName(), clazz) { - "All explicit scopes on @ContributesBinding annotations must be the same." - } - previous - } - - val explicitScope = explicitScopes.first() - - if (classScope != null) { - check( - classScope.declaration.requireQualifiedName() == - explicitScope.declaration.requireQualifiedName(), - clazz, - ) { - "A scope was defined explicitly on the @ContributesBinding annotation " + - "`${explicitScope.declaration.requireQualifiedName()}` and the class " + - "itself is scoped using " + - "`${classScope.declaration.requireQualifiedName()}`. It's not allowed " + - "to mix different scopes." - } - } - - check(classScope == null, clazz) { - "A scope was defined explicitly on the @ContributesBinding annotation " + - "`${explicitScope.declaration.requireQualifiedName()}` and the class itself " + - "is scoped using `${classScope!!.declaration.requireQualifiedName()}`. In " + - "this case the explicit scope on the @ContributesBinding annotation can be " + - "removed." - } - - return explicitScope - } - - return requireNotNull(classScope, clazz) { - "Couldn't find scope for ${clazz.simpleName.asString()}. For unscoped " + - "objects it is required to specify the target scope on the @ContributesBinding " + - "annotation." - } - } - private fun boundTypeFromAnnotation(annotation: KSAnnotation): KSType? { return annotation.arguments.firstOrNull { it.name?.asString() == "boundType" } ?.let { it.value as? KSType } @@ -266,6 +201,16 @@ internal class ContributesBindingProcessor( } } + private fun KSClassDeclaration.findAnnotationsAtLeastOne( + annotation: KClass, + ): List { + return findAnnotations(annotation).also { + check(it.isNotEmpty(), this) { + "Couldn't find the @${annotation.simpleName} annotation for $this." + } + } + } + private inner class GeneratedFunction( boundType: KSType, val multibinding: Boolean, diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessor.kt index 769ee2c..6a9ec8f 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessor.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessor.kt @@ -12,7 +12,6 @@ import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.addOriginatingKSFile -import com.squareup.kotlinpoet.ksp.toAnnotationSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import software.amazon.lastmile.kotlin.inject.anvil.ContextAware @@ -46,7 +45,6 @@ import software.amazon.lastmile.kotlin.inject.anvil.internal.Subcomponent * ``` * package $LOOKUP_PACKAGE * - * @ParentScope * @Origin(Subcomponent.Factory::class) * @Subcomponent * interface SoftwareAmazonTestSubcomponentFactory : Subcomponent.Factory @@ -66,6 +64,7 @@ internal class ContributesSubcomponentFactoryProcessor( checkIsPublic(it) checkInnerClass(it) checkSingleFunction(it) + checkHasScope(it) } .forEach { generateComponentInterfaceForFactory(it) @@ -88,14 +87,12 @@ internal class ContributesSubcomponentFactoryProcessor( private fun generateComponentInterfaceForFactory(factory: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE, factory.safeClassName) - val scope = factory.scope() val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(factory.requireContainingFile()) - .addAnnotation(scope.toAnnotationSpec()) .addOriginAnnotation(factory) .addAnnotation(Subcomponent::class) .addSuperinterface(factory.toClassName()) diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessor.kt index 452fce4..3a0b93e 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessor.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessor.kt @@ -13,7 +13,6 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.ksp.toAnnotationSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.toTypeName import com.squareup.kotlinpoet.ksp.writeTo @@ -23,6 +22,7 @@ import software.amazon.lastmile.kotlin.inject.anvil.ContextAware import software.amazon.lastmile.kotlin.inject.anvil.ContributesSubcomponent import software.amazon.lastmile.kotlin.inject.anvil.LOOKUP_PACKAGE import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent +import software.amazon.lastmile.kotlin.inject.anvil.MergeScope import software.amazon.lastmile.kotlin.inject.anvil.addOriginAnnotation import software.amazon.lastmile.kotlin.inject.anvil.internal.Subcomponent @@ -118,7 +118,15 @@ internal class ContributesSubcomponentProcessor( factoryInterface: KSClassDeclaration, generatedFactoryInterface: KSClassDeclaration, ): ClassName { - val scope = subcomponent.scope() + val scope = requireNotNull( + value = subcomponent.annotations + .firstOrNull { isScopeAnnotation(it) } + ?.let { MergeScope(this, it) }, + symbol = subcomponent, + ) { + "A scope like @SingleIn(Abc::class) is missing." + } + val function = factoryInterface.factoryFunctions().single() val parameters = function.parameters.map { diff --git a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessor.kt b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessor.kt index 4c67e6e..ac8dcca 100644 --- a/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessor.kt +++ b/compiler/src/main/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessor.kt @@ -10,7 +10,6 @@ import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.addOriginatingKSFile -import com.squareup.kotlinpoet.ksp.toAnnotationSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import software.amazon.lastmile.kotlin.inject.anvil.ContextAware @@ -35,7 +34,6 @@ import software.amazon.lastmile.kotlin.inject.anvil.addOriginAnnotation * ``` * package $LOOKUP_PACKAGE * - * @SingleInAppScope * @Origin(ComponentInterface::class) * interface SoftwareAmazonTestComponentInterface : ComponentInterface * ``` @@ -51,6 +49,7 @@ internal class ContributesToProcessor( .onEach { checkIsInterface(it) checkIsPublic(it) + checkHasScope(it) } .forEach { generateComponentInterface(it) @@ -61,14 +60,12 @@ internal class ContributesToProcessor( private fun generateComponentInterface(clazz: KSClassDeclaration) { val componentClassName = ClassName(LOOKUP_PACKAGE, clazz.safeClassName) - val scope = clazz.scope() val fileSpec = FileSpec.builder(componentClassName) .addType( TypeSpec .interfaceBuilder(componentClassName) .addOriginatingKSFile(clazz.requireContainingFile()) - .addAnnotation(scope.toAnnotationSpec()) .addOriginAnnotation(clazz) .addSuperinterface(clazz.toClassName()) .build(), 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 edbce84..e389068 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 @@ -16,7 +16,6 @@ import com.google.devtools.ksp.symbol.KSType import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.ksp.toAnnotationSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.writeTo import software.amazon.lastmile.kotlin.inject.anvil.ContextAware @@ -28,6 +27,7 @@ import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent import software.amazon.lastmile.kotlin.inject.anvil.OPTION_CONTRIBUTING_ANNOTATIONS import software.amazon.lastmile.kotlin.inject.anvil.extend.ContributingAnnotation import software.amazon.lastmile.kotlin.inject.anvil.internal.Subcomponent +import software.amazon.lastmile.kotlin.inject.anvil.requireQualifiedName /** * Generates the code for [MergeComponent]. @@ -151,8 +151,11 @@ internal class MergeComponentProcessor( val componentInterfaces = resolver.getDeclarationsFromPackage(LOOKUP_PACKAGE) .filterIsInstance() - .filter { it.scope().isSameAs(scope) } - .filter { it.origin().requireQualifiedName() !in excludeNames } + .filter { contributedInterface -> + val origin = contributedInterface.origin() + origin.scope() == scope && + origin.requireQualifiedName() !in excludeNames + } .filter { !it.isAnnotationPresent(Subcomponent::class) || it.contributedSubcomponent().requireQualifiedName() !in excludeNames @@ -168,7 +171,6 @@ internal class MergeComponentProcessor( .addType( TypeSpec .interfaceBuilder(className) - .addAnnotation(scope.toAnnotationSpec()) .addSuperinterfaces( generatedSubcomponents + componentInterfaces.map { it.toClassName() }, ) diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScopeParserTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScopeParserTest.kt new file mode 100644 index 0000000..42bdac3 --- /dev/null +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/MergeScopeParserTest.kt @@ -0,0 +1,394 @@ +@file:OptIn(ExperimentalCompilerApi::class) + +package software.amazon.lastmile.kotlin.inject.anvil + +import assertk.Assert +import assertk.all +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.prop +import com.google.devtools.ksp.getClassDeclarationByName +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.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode.OK +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test + +class MergeScopeParserTest { + + @Test + fun `the scope can be parsed from a scope annotation with zero args`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + + @ContributesTo + @SingleInAppScope + interface ComponentInterface + + @ContributesBinding + @SingleInAppScope + interface Binding : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.ComponentInterface").scope()).isEqualTo( + fqName = "software.amazon.test.SingleInAppScope", + annotationFqName = "software.amazon.test.SingleInAppScope", + markerFqName = null, + ) + assertThat(resolver.clazz("software.amazon.test.Binding").scope()).isEqualTo( + fqName = "software.amazon.test.SingleInAppScope", + annotationFqName = "software.amazon.test.SingleInAppScope", + markerFqName = null, + ) + } + } + + @Test + fun `the scope can be parsed from a @ContributesBinding annotation`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + + @ContributesBinding(scope = SingleInAppScope::class) + interface Binding : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.Binding").scope()).isEqualTo( + fqName = "software.amazon.test.SingleInAppScope", + annotationFqName = "software.amazon.test.SingleInAppScope", + markerFqName = null, + ) + } + } + + @Test + fun `the scope can be parsed from a @ContributesTo annotation`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + + @ContributesTo(AppScope::class) + interface ComponentInterface1 + + @ContributesTo(SingleInAppScope::class) + interface ComponentInterface2 + """, + ) { resolver -> + assertThat( + resolver.clazz("software.amazon.test.ComponentInterface1").scope(), + ).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + assertThat( + resolver.clazz("software.amazon.test.ComponentInterface2").scope(), + ).isEqualTo( + fqName = "software.amazon.test.SingleInAppScope", + annotationFqName = "software.amazon.test.SingleInAppScope", + markerFqName = null, + ) + } + } + + @Test + fun `the scope can be parsed from a @ContributesSubcomponent annotation`() { + compileInPlace( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Scope + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesSubcomponent + + @Scope + annotation class ChildScope + + @ContributesSubcomponent(AppScope::class) + interface SubcomponentInterface1 { + @ContributesSubcomponent.Factory(String::class) + interface Factory { + fun createSubcomponentInterface(): SubcomponentInterface1 + } + } + + @ContributesSubcomponent + @SingleInAppScope + interface SubcomponentInterface2 { + @ContributesSubcomponent.Factory + @ChildScope + interface Factory { + fun createSubcomponentInterface(): SubcomponentInterface2 + } + } + """, + ) { resolver -> + assertThat( + resolver.clazz("software.amazon.test.SubcomponentInterface1").scope(), + ).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + assertThat( + resolver.clazz("software.amazon.test.SubcomponentInterface1.Factory").scope(), + ).isEqualTo( + fqName = "kotlin.String", + annotationFqName = null, + markerFqName = "kotlin.String", + ) + + assertThat( + resolver.clazz("software.amazon.test.SubcomponentInterface2").scope(), + ).isEqualTo( + fqName = "software.amazon.test.SingleInAppScope", + annotationFqName = "software.amazon.test.SingleInAppScope", + markerFqName = null, + ) + assertThat( + resolver.clazz("software.amazon.test.SubcomponentInterface2.Factory").scope(), + ).isEqualTo( + fqName = "software.amazon.test.ChildScope", + annotationFqName = "software.amazon.test.ChildScope", + markerFqName = null, + ) + } + } + + @Test + fun `the scope can be parsed from a @MergeComponent annotation`() { + compileInPlace( + """ + 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 + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + @Component + @MergeComponent(AppScope::class) + abstract class ComponentInterface1 : ComponentInterface1Merged + + @Component + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + abstract class ComponentInterface2 : ComponentInterface2Merged + + @Component + @MergeComponent + @SingleIn(AppScope::class) + abstract class ComponentInterface3 : ComponentInterface3Merged + """, + ) { resolver -> + assertThat( + resolver.clazz("software.amazon.test.ComponentInterface1").scope(), + ).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + + assertThat( + resolver.clazz("software.amazon.test.ComponentInterface2").scope(), + ).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + + assertThat( + resolver.clazz("software.amazon.test.ComponentInterface3").scope(), + ).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = "software.amazon.lastmile.kotlin.inject.anvil.SingleIn", + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + } + } + + @Test + fun `the scope can be parsed from a @ContributesBinding annotation without a parameter name`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + + @ContributesBinding(SingleInAppScope::class) + interface Binding : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.Binding").scope()).isEqualTo( + fqName = "software.amazon.test.SingleInAppScope", + annotationFqName = "software.amazon.test.SingleInAppScope", + markerFqName = null, + ) + } + } + + @Test + fun `the scope can be parsed from a scope annotation with a marker`() { + compileInPlace( + """ + package software.amazon.test + + 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.SingleIn + + @ContributesTo + @SingleIn(AppScope::class) + interface ComponentInterface + + @ContributesBinding + @SingleIn(AppScope::class) + interface Binding : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.ComponentInterface").scope()).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = "software.amazon.lastmile.kotlin.inject.anvil.SingleIn", + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + + assertThat(resolver.clazz("software.amazon.test.Binding").scope()).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = "software.amazon.lastmile.kotlin.inject.anvil.SingleIn", + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + } + } + + @Test + fun `the scope can be parsed from an annotation without explicit scope annotation`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + @ContributesBinding(AppScope::class) + interface Binding1 : CharSequence + + @ContributesBinding(multibinding = true, scope = AppScope::class) + interface Binding2 : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.Binding1").scope()).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + assertThat(resolver.clazz("software.amazon.test.Binding2").scope()).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + } + } + + @Test + fun `the marker scope is used and a different actual scope can be used for kotlin-inject`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + + @ContributesBinding(AppScope::class) + @SingleInAppScope + interface Binding : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.Binding").scope()).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + } + } + + @Test + fun `the marker scope is used and a different actual scope can be used for kotlin-inject with a different marker`() { + compileInPlace( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + @ContributesBinding(AppScope::class) + @SingleIn(String::class) + interface Binding : CharSequence + """, + ) { resolver -> + assertThat(resolver.clazz("software.amazon.test.Binding").scope()).isEqualTo( + fqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + annotationFqName = null, + markerFqName = "software.amazon.lastmile.kotlin.inject.anvil.AppScope", + ) + } + } + + private fun symbolProcessorProvider( + block: ContextAware.(Resolver) -> Unit, + ): SymbolProcessorProvider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return object : SymbolProcessor, ContextAware { + override fun process(resolver: Resolver): List { + block(this, resolver) + return emptyList() + } + + override val logger: KSPLogger = environment.logger + } + } + } + + private fun compileInPlace( + @Language("kotlin") vararg sources: String, + block: ContextAware.(Resolver) -> Unit, + ) { + Compilation() + .configureKotlinInjectAnvilProcessor( + symbolProcessorProviders = setOf(symbolProcessorProvider(block)), + ) + .compile(*sources) { + assertThat(exitCode).isEqualTo(OK) + } + } + + private fun Resolver.clazz(name: String) = requireNotNull(getClassDeclarationByName(name)) + + private fun Assert.isEqualTo( + fqName: String, + annotationFqName: String?, + markerFqName: String?, + ) { + all { + prop(MergeScope::fqName).isEqualTo(fqName) + prop(MergeScope::annotationFqName).isEqualTo(annotationFqName) + prop(MergeScope::markerFqName).isEqualTo(markerFqName) + } + } +} diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessorTest.kt index 3f12319..ee8ed1e 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessorTest.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesBindingProcessorTest.kt @@ -20,7 +20,6 @@ import software.amazon.lastmile.kotlin.inject.anvil.isAnnotatedWith import software.amazon.lastmile.kotlin.inject.anvil.isNotAnnotatedWith import software.amazon.lastmile.kotlin.inject.anvil.origin import software.amazon.lastmile.kotlin.inject.anvil.otherScopeSource -import software.amazon.test.SingleInAppScope class ContributesBindingProcessorTest { @@ -44,7 +43,6 @@ class ContributesBindingProcessorTest { val generatedComponent = impl.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(impl) val method = generatedComponent.declaredMethods.single() @@ -77,7 +75,6 @@ class ContributesBindingProcessorTest { val generatedComponent = impl.inner.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(impl.inner) val method = generatedComponent.declaredMethods.single() @@ -185,66 +182,10 @@ class ContributesBindingProcessorTest { val generatedComponent = impl.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(impl) } } - @Test - fun `it's an error to set the scope explicitly when the class is scoped`() { - compile( - """ - package software.amazon.test - - import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding - import me.tatarka.inject.annotations.Inject - - interface Base - - @Inject - @SingleInAppScope - @ContributesBinding(scope = SingleInAppScope::class) - class Impl : Base - """, - exitCode = COMPILATION_ERROR, - ) { - assertThat(messages).contains( - "A scope was defined explicitly on the @ContributesBinding annotation " + - "`software.amazon.test.SingleInAppScope` and the class itself is scoped " + - "using `software.amazon.test.SingleInAppScope`. In this case the explicit " + - "scope on the @ContributesBinding annotation can be removed.", - ) - } - } - - @Test - fun `it's an error to set the scope explicitly when the class is scoped - different scopes`() { - compile( - """ - package software.amazon.test - - import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding - import me.tatarka.inject.annotations.Inject - - interface Base - - @Inject - @OtherScope - @ContributesBinding(scope = SingleInAppScope::class) - class Impl : Base - """, - otherScopeSource, - exitCode = COMPILATION_ERROR, - ) { - assertThat(messages).contains( - "A scope was defined explicitly on the @ContributesBinding annotation " + - "`software.amazon.test.SingleInAppScope` and the class itself is scoped " + - "using `software.amazon.test.OtherScope`. It's not allowed to mix different " + - "scopes.", - ) - } - } - @Test fun `it's an error to not specify the scope for unscoped bindings`() { compile( @@ -264,7 +205,7 @@ class ContributesBindingProcessorTest { ) { assertThat(messages).contains( "Couldn't find scope for Impl. For unscoped objects it is required " + - "to specify the target scope on the @ContributesBinding annotation.", + "to specify the target scope on the annotation.", ) } } @@ -291,7 +232,6 @@ class ContributesBindingProcessorTest { val generatedComponent = impl.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(impl) with(generatedComponent.declaredMethods.single { it.name == "provideImplBase" }) { @@ -329,7 +269,7 @@ class ContributesBindingProcessorTest { exitCode = COMPILATION_ERROR, ) { assertThat(messages).contains( - "All explicit scopes on @ContributesBinding annotations must be the same.", + "All explicit scopes on annotations must be the same.", ) } } @@ -356,7 +296,7 @@ class ContributesBindingProcessorTest { exitCode = COMPILATION_ERROR, ) { assertThat(messages).contains( - "If one @ContributesBinding annotation has an explicit scope, " + + "If one annotation has an explicit scope, " + "then all annotations must specify an explicit scope.", ) } @@ -408,7 +348,6 @@ class ContributesBindingProcessorTest { val generatedComponent = impl.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(impl) val method = generatedComponent.declaredMethods.single() @@ -441,7 +380,6 @@ class ContributesBindingProcessorTest { val generatedComponent = impl.generatedComponent assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(impl) assertThat(generatedComponent.declaredMethods).hasSize(2) diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessorTest.kt index 40318a3..b216fd5 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessorTest.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentFactoryProcessorTest.kt @@ -15,9 +15,7 @@ import org.junit.jupiter.api.Test import software.amazon.lastmile.kotlin.inject.anvil.LOOKUP_PACKAGE import software.amazon.lastmile.kotlin.inject.anvil.compile import software.amazon.lastmile.kotlin.inject.anvil.generatedComponent -import software.amazon.lastmile.kotlin.inject.anvil.isAnnotatedWith import software.amazon.lastmile.kotlin.inject.anvil.origin -import kotlin.reflect.KClass class ContributesSubcomponentFactoryProcessorTest { @@ -45,7 +43,33 @@ class ContributesSubcomponentFactoryProcessorTest { assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) assertThat(generatedComponent.interfaces).containsExactly(subcomponent.factory) - assertThat(generatedComponent).isAnnotatedWith(parentScope) + assertThat(generatedComponent.origin).isEqualTo(subcomponent.factory) + } + } + + @Test + fun `a component interface is generated in the lookup package for a contributed subcomponent factory using a scope marker`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesSubcomponent + + @ContributesSubcomponent(AppScope::class) + interface SubcomponentInterface { + @ContributesSubcomponent.Factory(String::class) + interface Factory { + fun createSubcomponentInterface(): SubcomponentInterface + } + } + """, + scopesSource, + ) { + val generatedComponent = subcomponent.factory.generatedComponent + + assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) + assertThat(generatedComponent.interfaces).containsExactly(subcomponent.factory) assertThat(generatedComponent.origin).isEqualTo(subcomponent.factory) } } @@ -302,11 +326,6 @@ class ContributesSubcomponentFactoryProcessorTest { private val JvmCompilationResult.subcomponent: Class<*> get() = classLoader.loadClass("software.amazon.test.SubcomponentInterface") - @Suppress("UNCHECKED_CAST") - private val JvmCompilationResult.parentScope: KClass - get() = classLoader.loadClass("software.amazon.test.ParentScope").kotlin - as KClass - private val Class<*>.factory: Class<*> get() = classes.single { it.simpleName == "Factory" } diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessorTest.kt index a817184..1182e83 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessorTest.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesSubcomponentProcessorTest.kt @@ -72,6 +72,59 @@ class ContributesSubcomponentProcessorTest { } } + @Test + fun `a contributed subcomponent is generated when the parent is merged using marker scopes`() { + compile( + """ + package software.amazon.test + + 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.ContributesSubcomponent + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + @MergeComponent + @Component + @SingleIn(AppScope::class) + interface ComponentInterface : ComponentInterfaceMerged + + @ContributesSubcomponent(String::class) + @SingleIn(String::class) + interface OtherComponent { + @ContributesSubcomponent.Factory(AppScope::class) + interface Parent { + fun otherComponent(): OtherComponent + } + } + + @ContributesTo(String::class) + interface ChildComponent { + @Provides + @SingleIn(String::class) + fun provideString(): String = "abc" + + val string: String + } + """, + ) { + val component = componentInterface.newComponent() + val childComponent = component::class.java.methods + .single { it.name == "otherComponent" } + .invoke(component) + + assertThat(childComponent).isNotNull() + + val string = childComponent::class.java.methods + .single { it.name == "getString" } + .invoke(childComponent) + + assertThat(string).isEqualTo("abc") + } + } + @Test fun `contributions to the child scope from a previous compilation are picked up`() { val previousResult1 = compile(scopesSource) @@ -248,6 +301,40 @@ class ContributesSubcomponentProcessorTest { } } + @Test + fun `a contributed subcomponent can be excluded using marker scopes`() { + 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.ContributesSubcomponent + import software.amazon.lastmile.kotlin.inject.anvil.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + @MergeComponent(exclude = [OtherComponent::class]) + @Component + @SingleIn(AppScope::class) + interface ComponentInterface : ComponentInterfaceMerged + + @ContributesSubcomponent(String::class) + interface OtherComponent { + @ContributesSubcomponent.Factory(AppScope::class) + interface Parent { + fun otherComponent(): OtherComponent + } + } + """, + ) { + val component = componentInterface.newComponent() + + assertThat( + component::class.java.methods.filter { it.name == "otherComponent" }, + ).isEmpty() + } + } + @Test fun `contributed subcomponents can be chained`() { compile( diff --git a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessorTest.kt b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessorTest.kt index e775dd8..97532ac 100644 --- a/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessorTest.kt +++ b/compiler/src/test/kotlin/software/amazon/lastmile/kotlin/inject/anvil/processor/ContributesToProcessorTest.kt @@ -14,9 +14,7 @@ import software.amazon.lastmile.kotlin.inject.anvil.compile import software.amazon.lastmile.kotlin.inject.anvil.componentInterface import software.amazon.lastmile.kotlin.inject.anvil.generatedComponent import software.amazon.lastmile.kotlin.inject.anvil.inner -import software.amazon.lastmile.kotlin.inject.anvil.isAnnotatedWith import software.amazon.lastmile.kotlin.inject.anvil.origin -import software.amazon.test.SingleInAppScope class ContributesToProcessorTest { @@ -37,7 +35,28 @@ class ContributesToProcessorTest { assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) assertThat(generatedComponent.interfaces).containsExactly(componentInterface) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) + assertThat(generatedComponent.origin).isEqualTo(componentInterface) + } + } + + @Test + fun `a component interface is generated in the lookup package for a contributed component interface using a scope marker`() { + compile( + """ + package software.amazon.test + + import software.amazon.lastmile.kotlin.inject.anvil.AppScope + import software.amazon.lastmile.kotlin.inject.anvil.ContributesTo + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + @ContributesTo(AppScope::class) + interface ComponentInterface + """, + ) { + val generatedComponent = componentInterface.generatedComponent + + assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) + assertThat(generatedComponent.interfaces).containsExactly(componentInterface) assertThat(generatedComponent.origin).isEqualTo(componentInterface) } } @@ -61,7 +80,6 @@ class ContributesToProcessorTest { assertThat(generatedComponent.packageName).isEqualTo(LOOKUP_PACKAGE) assertThat(generatedComponent.interfaces).containsExactly(componentInterface.inner) - assertThat(generatedComponent).isAnnotatedWith(SingleInAppScope::class) assertThat(generatedComponent.origin).isEqualTo(componentInterface.inner) } } @@ -115,7 +133,10 @@ class ContributesToProcessorTest { """, exitCode = COMPILATION_ERROR, ) { - assertThat(messages).contains("Couldn't find scope annotation for ComponentInterface.") + assertThat(messages).contains( + "Couldn't find scope for ComponentInterface. For unscoped " + + "objects it is required to specify the target scope on the annotation.", + ) } } } 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 706dcec..0c1a03f 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 @@ -69,6 +69,54 @@ class MergeComponentProcessorTest { } } + @Test + fun `component interfaces are merged using marker scopes`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Component + 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.MergeComponent + import software.amazon.lastmile.kotlin.inject.anvil.SingleIn + + interface Base + + @Inject + @SingleIn(AppScope::class) + class Impl : Base { + + @ContributesTo(AppScope::class) + interface Component { + @Provides fun provideImpl(impl: Impl): Base = impl + + val string: String + } + } + + @ContributesTo(AppScope::class) + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + @Component + @MergeComponent(AppScope::class) + @SingleIn(AppScope::class) + abstract class ComponentInterface : ComponentInterfaceMerged { + abstract val base: Base + } + """, + ) { + assertThat(componentInterface.mergedComponent).isNotNull() + + assertThat(stringComponent.isAssignableFrom(componentInterface)).isTrue() + assertThat(implComponent.isAssignableFrom(componentInterface)).isTrue() + } + } + @Test fun `component interfaces are merged into inner class`() { compile( @@ -155,6 +203,41 @@ class MergeComponentProcessorTest { } } + @Test + fun `contributed bindings are merged using marker scopes`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Component + 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.MergeComponent + + interface Base + + @Inject + @ContributesBinding(AppScope::class) + class Impl : Base + + @Component + @MergeComponent(AppScope::class) + abstract class ComponentInterface : ComponentInterfaceMerged { + abstract val base: Base + } + """, + ) { + assertThat(componentInterface.mergedComponent).isNotNull() + + assertThat( + classLoader.loadClass("$LOOKUP_PACKAGE.SoftwareAmazonTestImpl") + .isAssignableFrom(componentInterface), + ).isTrue() + } + } + @Test fun `component interfaces from previous compilations are merged`() { val previousCompilation = compile( @@ -230,7 +313,10 @@ class MergeComponentProcessorTest { """, exitCode = COMPILATION_ERROR, ) { - assertThat(messages).contains("Couldn't find scope annotation for ComponentInterface.") + assertThat(messages).contains( + "Couldn't find scope for ComponentInterface. For unscoped " + + "objects it is required to specify the target scope on the annotation.", + ) } } @@ -360,7 +446,7 @@ class MergeComponentProcessorTest { } @Component - @MergeComponent([StringComponent::class]) + @MergeComponent(OtherScope::class, [StringComponent::class]) @OtherScope abstract class ComponentInterface : ComponentInterfaceMerged { abstract val base: Base @@ -410,6 +496,55 @@ class MergeComponentProcessorTest { } } + @Test + fun `using a different kotlin-inject scope with marker scopes is allowed`() { + compile( + """ + package software.amazon.test + + import me.tatarka.inject.annotations.Component + 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.MergeComponent + + interface Base + + @Inject + @OtherScope + class Impl : Base { + + @ContributesTo(AppScope::class) + @OtherScope + interface Component { + @Provides fun provideImpl(impl: Impl): Base = impl + } + } + + // Note this is contributed to a different scope. + @ContributesTo + @OtherScope + interface StringComponent { + @Provides fun provideString(): String = "abc" + } + + @Component + @MergeComponent(AppScope::class) + @OtherScope + abstract class ComponentInterface : ComponentInterfaceMerged { + abstract val base: Base + } + """, + otherScopeSource, + ) { + assertThat(componentInterface.mergedComponent).isNotNull() + + assertThat(implComponent.isAssignableFrom(componentInterface)).isTrue() + assertThat(stringComponent.isAssignableFrom(componentInterface)).isFalse() + } + } + private val JvmCompilationResult.stringComponent: Class<*> get() = classLoader.loadClass("software.amazon.test.StringComponent") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0366708..ae3745e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlin-compile-testing = "0.4.1" kotlin-hierarchy = "1.1" kotlin-inject = "0.7.1" # This is a snapshot build with the latest fixes. We need that in the sample app. -kotlin-inject-bugfix = "0.7.2-20240710.214910-9" +kotlin-inject-bugfix = "0.7.2-20240911.020938-12" kotlin-poet = "1.17.0" kotlinx-binaryCompatibilityValidator = "0.16.2" ksp = "1.9.24-1.0.20" diff --git a/runtime/api/android/runtime.api b/runtime/api/android/runtime.api index c553634..d748e44 100644 --- a/runtime/api/android/runtime.api +++ b/runtime/api/android/runtime.api @@ -9,16 +9,20 @@ public abstract interface annotation class software/amazon/lastmile/kotlin/injec } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent : java/lang/annotation/Annotation { + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent$Factory : java/lang/annotation/Annotation { + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/ContributesTo : java/lang/annotation/Annotation { + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/MergeComponent : java/lang/annotation/Annotation { public abstract fun exclude ()[Ljava/lang/Class; + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/extend/ContributingAnnotation : java/lang/annotation/Annotation { diff --git a/runtime/api/jvm/runtime.api b/runtime/api/jvm/runtime.api index c553634..d748e44 100644 --- a/runtime/api/jvm/runtime.api +++ b/runtime/api/jvm/runtime.api @@ -9,16 +9,20 @@ public abstract interface annotation class software/amazon/lastmile/kotlin/injec } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent : java/lang/annotation/Annotation { + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent$Factory : java/lang/annotation/Annotation { + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/ContributesTo : java/lang/annotation/Annotation { + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/MergeComponent : java/lang/annotation/Annotation { public abstract fun exclude ()[Ljava/lang/Class; + public abstract fun scope ()Ljava/lang/Class; } public abstract interface annotation class software/amazon/lastmile/kotlin/inject/anvil/extend/ContributingAnnotation : java/lang/annotation/Annotation { diff --git a/runtime/api/runtime.klib.api b/runtime/api/runtime.klib.api index e462159..069352b 100644 --- a/runtime/api/runtime.klib.api +++ b/runtime/api/runtime.klib.api @@ -22,31 +22,42 @@ open annotation class software.amazon.lastmile.kotlin.inject.anvil.internal/Subc } open annotation class software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding : kotlin/Annotation { // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding|null[0] - constructor (kotlin.reflect/KClass =..., kotlin.reflect/KClass<*> =..., kotlin/Boolean =...) // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.|(kotlin.reflect.KClass;kotlin.reflect.KClass<*>;kotlin.Boolean){}[0] + constructor (kotlin.reflect/KClass<*> =..., kotlin.reflect/KClass<*> =..., kotlin/Boolean =...) // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.|(kotlin.reflect.KClass<*>;kotlin.reflect.KClass<*>;kotlin.Boolean){}[0] final val boundType // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.boundType|{}boundType[0] final fun (): kotlin.reflect/KClass<*> // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.boundType.|(){}[0] final val multibinding // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.multibinding|{}multibinding[0] final fun (): kotlin/Boolean // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.multibinding.|(){}[0] final val scope // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.scope|{}scope[0] - final fun (): kotlin.reflect/KClass // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.scope.|(){}[0] + final fun (): kotlin.reflect/KClass<*> // software.amazon.lastmile.kotlin.inject.anvil/ContributesBinding.scope.|(){}[0] } open annotation class software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent : kotlin/Annotation { // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent|null[0] - constructor () // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.|(){}[0] + constructor (kotlin.reflect/KClass<*> =...) // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.|(kotlin.reflect.KClass<*>){}[0] + + final val scope // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.scope|{}scope[0] + final fun (): kotlin.reflect/KClass<*> // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.scope.|(){}[0] open annotation class Factory : kotlin/Annotation { // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.Factory|null[0] - constructor () // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.Factory.|(){}[0] + constructor (kotlin.reflect/KClass<*> =...) // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.Factory.|(kotlin.reflect.KClass<*>){}[0] + + final val scope // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.Factory.scope|{}scope[0] + final fun (): kotlin.reflect/KClass<*> // software.amazon.lastmile.kotlin.inject.anvil/ContributesSubcomponent.Factory.scope.|(){}[0] } } open annotation class software.amazon.lastmile.kotlin.inject.anvil/ContributesTo : kotlin/Annotation { // software.amazon.lastmile.kotlin.inject.anvil/ContributesTo|null[0] - constructor () // software.amazon.lastmile.kotlin.inject.anvil/ContributesTo.|(){}[0] + constructor (kotlin.reflect/KClass<*> =...) // software.amazon.lastmile.kotlin.inject.anvil/ContributesTo.|(kotlin.reflect.KClass<*>){}[0] + + final val scope // software.amazon.lastmile.kotlin.inject.anvil/ContributesTo.scope|{}scope[0] + final fun (): kotlin.reflect/KClass<*> // software.amazon.lastmile.kotlin.inject.anvil/ContributesTo.scope.|(){}[0] } open annotation class software.amazon.lastmile.kotlin.inject.anvil/MergeComponent : kotlin/Annotation { // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent|null[0] - constructor (kotlin/Array> =...) // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent.|(kotlin.Array>){}[0] + constructor (kotlin.reflect/KClass<*> =..., kotlin/Array> =...) // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent.|(kotlin.reflect.KClass<*>;kotlin.Array>){}[0] final val exclude // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent.exclude|{}exclude[0] final fun (): kotlin/Array> // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent.exclude.|(){}[0] + final val scope // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent.scope|{}scope[0] + final fun (): kotlin.reflect/KClass<*> // software.amazon.lastmile.kotlin.inject.anvil/MergeComponent.scope.|(){}[0] } diff --git a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesBinding.kt b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesBinding.kt index ad18f34..1b458cd 100644 --- a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesBinding.kt +++ b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesBinding.kt @@ -82,7 +82,7 @@ public annotation class ContributesBinding( /** * The scope in which to include this contributed binding. */ - val scope: KClass = Annotation::class, + val scope: KClass<*> = Unit::class, /** * The type that this class is bound to. When injecting [boundType] the concrete class will be * this annotated class. diff --git a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent.kt b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent.kt index f3face5..80301b1 100644 --- a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent.kt +++ b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesSubcomponent.kt @@ -1,6 +1,7 @@ package software.amazon.lastmile.kotlin.inject.anvil import kotlin.annotation.AnnotationTarget.CLASS +import kotlin.reflect.KClass /** * Generates a subcomponent when the parent component interface is merged. @@ -102,7 +103,12 @@ import kotlin.annotation.AnnotationTarget.CLASS * ``` */ @Target(CLASS) -public annotation class ContributesSubcomponent { +public annotation class ContributesSubcomponent( + /** + * The scope in which to include this contributed component interface. + */ + val scope: KClass<*> = Unit::class, +) { /** * A factory for the contributed subcomponent. * @@ -112,5 +118,10 @@ public annotation class ContributesSubcomponent { * The factory interface must have a single function with the contributed subcomponent as * return type. Parameters are supported as mentioned in [ContributesSubcomponent]. */ - public annotation class Factory + public annotation class Factory( + /** + * The scope in which to include this contributed component interface. + */ + val scope: KClass<*> = Unit::class, + ) } diff --git a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesTo.kt b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesTo.kt index 6cbfa8a..de6490b 100644 --- a/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesTo.kt +++ b/runtime/src/commonMain/kotlin/software/amazon/lastmile/kotlin/inject/anvil/ContributesTo.kt @@ -1,12 +1,18 @@ package software.amazon.lastmile.kotlin.inject.anvil import kotlin.annotation.AnnotationTarget.CLASS +import kotlin.reflect.KClass /** * Marks a component interface to be included in the dependency graph in the given `scope`. * The processor will automatically add the interface as super type to the final component * marked with [MergeComponent]. + * ``` + * @ContributesTo(AppScope::class) + * interface ComponentInterface { .. } + * ``` * + * Or another example where the scope on the component interface is used. * ``` * @ContributesTo * @SingleInAppScope @@ -14,4 +20,9 @@ import kotlin.annotation.AnnotationTarget.CLASS * ``` */ @Target(CLASS) -public annotation class ContributesTo +public annotation class ContributesTo( + /** + * The scope in which to include this contributed component interface. + */ + val scope: KClass<*> = Unit::class, +) 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 0c52326..7c067e5 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 @@ -33,6 +33,11 @@ import kotlin.reflect.KClass */ @Target(CLASS) public annotation class MergeComponent( + /** + * The scope in which to include this contributed component interface. + */ + val scope: KClass<*> = Unit::class, + /** * List of component interfaces that are contributed to the same scope, but should be * excluded from the component.