Skip to content

Commit

Permalink
Support scopes with parameters
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
vRallev committed Sep 12, 2024
1 parent 390114a commit cf5e2c5
Show file tree
Hide file tree
Showing 23 changed files with 1,024 additions and 197 deletions.
1 change: 1 addition & 0 deletions compiler/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<KSAnnotation>,
): 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)

Expand All @@ -98,11 +174,6 @@ internal interface ContextAware {
fun KSClassDeclaration.findAnnotations(annotation: KClass<out Annotation>): List<KSAnnotation> {
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 {
Expand All @@ -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<KSAnnotated> =
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}
}
Loading

0 comments on commit cf5e2c5

Please sign in to comment.