Skip to content

Commit

Permalink
Provide option to hide the merged interface
Browse files Browse the repository at this point in the history
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
  • Loading branch information
vRallev committed Oct 25, 2024
1 parent dd8a5a3 commit aed2ab6
Show file tree
Hide file tree
Showing 9 changed files with 649 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<KSAnnotated> =
getSymbolsWithAnnotation(annotation.requireQualifiedName())

Expand All @@ -206,4 +211,6 @@ internal interface ContextAware {
get() = requireQualifiedName()
.split(".")
.joinToString(separator = "") { it.capitalize() }

val KSClassDeclaration.mergedClassName get() = "${innerClassNames()}Merged"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -64,6 +65,12 @@ class KotlinInjectExtensionSymbolProcessorProvider : SymbolProcessorProvider {
logger = environment.logger,
),
)
addIfEnabled(
GenerateKotlinInjectComponentProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
)
}

return CompositeSymbolProcessor(symbolProcessors)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TestComponent>.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<String>()

@Suppress("ReturnCount")
override fun process(resolver: Resolver): List<KSAnnotated> {
resolver
.getSymbolsWithAnnotation(MergeComponent::class)
.filterIsInstance<KSClassDeclaration>()
.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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -95,6 +96,10 @@ internal class MergeComponentProcessor(
.filterIsInstance<KSClassDeclaration>()
.distinctBy { it.requireQualifiedName() }
.filter { it.requireQualifiedName() !in processedComponents }
.filter { it.isAnnotationPresent(Component::class) }
.onEach {
checkSuperTypeDeclared(it)
}
.toList()

// Nothing to do.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,11 +27,15 @@ internal val Class<*>.generatedComponent: Class<*>
internal val JvmCompilationResult.contributesRenderer: Class<*>
get() = classLoader.loadClass("software.amazon.test.ContributesRenderer")

internal fun <T : Any> Class<*>.newComponent(): T {
internal fun <T : Any> 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<*>
Expand Down
Loading

0 comments on commit aed2ab6

Please sign in to comment.