Skip to content

Commit

Permalink
Merge pull request #62 from amzn/rwo/expect
Browse files Browse the repository at this point in the history
Support `expect / actual` for generated factory functions using `@CreateComponent`
  • Loading branch information
vRallev authored Oct 28, 2024
2 parents de21fda + c162fa0 commit 0a96521
Show file tree
Hide file tree
Showing 13 changed files with 526 additions and 30 deletions.
75 changes: 60 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ interface AppIdComponent {
class RealAuthenticator : Authenticator

// The final kotlin-inject component.
// see the section on "Usage > Merging" to understand
// how AppComponentMerged is generated and must be used.
@Component
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
interface AppComponent : AppComponentMerged
interface AppComponent

// Instantiate the component at runtime.
val component = AppComponent::class.create()
```
From the above example code snippet:

* `AppIdComponent` will be made a super type of `AppComponent` and the
* `AppIdComponent` will be made a super type of the final component and the
provider method is known to the object graph, so you can inject and use AppId anywhere.
* A binding for `RealAuthenticator` will be generated and the type `Authenticator` can safely be injected anywhere.
* Note that neither `AppIdComponent` nor `RealAuthenticator` need to be referenced anywhere else in your code.
Expand Down Expand Up @@ -129,7 +129,6 @@ add it to the final component.
@ContributesBinding(AppScope::class, multibinding = true)
class LoggingInterceptor : Interceptor

@Component
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class AppComponent {
Expand Down Expand Up @@ -165,23 +164,69 @@ object graph at runtime:
@SingleIn(AppScope::class)
interface AppComponent
```
In order to pick up all contributions, you must add the `@MergeComponent` annotation:
In order to pick up all contributions, you must change the `@Component` annotation to
`@MergeComponent`:
```kotlin
@Component
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
interface AppComponent
```
This will generate a new interface `AppComponentMerged` in the same package as `AppComponent`.
This generated interface must be added as super type:
This will generate a new component class with the original `@Component` annotation and merge all
contributions to the scope `AppScope`.

To instantiate the component at runtime, call the generated `create()` function:
```kotlin
val component = AppComponent::class.create()
```

#### Parameters

Parameters are supported the same way as with `kotlin-inject`:
```kotlin
@Component
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
interface AppComponent : AppComponentMerged
abstract class AppComponent(
@get:Provides val userId: String,
)

val component = AppComponent::class.create("userId")
```

#### Kotlin Multiplatform

With Kotlin Multiplatform there is a high chance that the generated code cannot be referenced
from common Kotlin code or from common platform code like `iosMain`. This is due to how
[common source folders are separated from platform source folders](https://kotlinlang.org/docs/whatsnew20.html#separation-of-common-and-platform-sources-during-compilation).
For more details and recommendations setting up kotlin-inject in Kotlin Multiplatform projects
see the [official guide](https://github.com/evant/kotlin-inject/blob/main/docs/multiplatform.md).

To address this issue, you can define an `expect fun` in the common source code next to
component class itself. The `actual fun` will be generated and create the component. The
function must be annotated with `@MergeComponent.CreateComponent`. It's optional to have a
receiver type of `KClass` with your component type as argument. The number of parameters
must match the arguments of your component and the return type must be your component, e.g.
your component in common code could be declared as:
```kotlin
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class)
abstract class AppComponent(
@get:Provides userId: String,
)

// Create this function next to your component class. The actual function will be generated.
@CreateComponent
expect fun create(appId: String): AppComponent

// Or with receiver type:
@CreateComponent
expect fun KClass<AppComponent>.create(appId: String): AppComponent
```
The generated `actual fun` will be generated and will look like this:
```kotlin
actual fun create(appId: String): AppComponent {
return KotlinInjectAppComponent::class.create(appId)
}
```
With this setup any contribution is automatically merged. These steps have to be repeated for
every component in your project.

### Scopes

Expand All @@ -202,7 +247,6 @@ the `kotlin-inject` components or to make instances a singleton in a scope, e.g.
@ContributesBinding(AppScope::class)
class RealAuthenticator : Authenticator

@Component
@MergeComponent(AppScope::class)
@SingleIn(AppScope::class) // scope for kotlin-inject
interface AppComponent
Expand Down Expand Up @@ -238,6 +282,7 @@ and build logic on top of them.
For example, assume this is your annotation:
```kotlin
@Target(CLASS)
@ContributingAnnotation // see below for details
annotation class MyCustomAnnotation
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ internal interface ContextAware {
}

fun checkIsPublic(
clazz: KSClassDeclaration,
declaration: KSDeclaration,
lazyMessage: () -> String = { "Contributed component interfaces must be public." },
) {
check(clazz.getVisibility() == Visibility.PUBLIC, clazz, lazyMessage)
check(declaration.getVisibility() == Visibility.PUBLIC, declaration, lazyMessage)
}

fun checkIsInterface(
Expand Down
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.CreateComponentProcessor
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 @@ -71,6 +72,12 @@ class KotlinInjectExtensionSymbolProcessorProvider : SymbolProcessorProvider {
logger = environment.logger,
),
)
addIfEnabled(
CreateComponentProcessor(
codeGenerator = environment.codeGenerator,
logger = environment.logger,
),
)
}

return CompositeSymbolProcessor(symbolProcessors)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
@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.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSTypeReference
import com.google.devtools.ksp.symbol.Modifier
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier.ACTUAL
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.asTypeName
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.requireQualifiedName
import kotlin.reflect.KClass

/**
* This processor will generate a function to make instantiating a generated kotlin-inject
* component easier. The function delegates the call to the final kotlin-inject component.
* ```
* package software.amazon.test
*
* @MergeComponent(AppScope::class)
* @SingleIn(AppScope::class)
* abstract class TestComponent(
* @get:Provides val string: String,
* )
*
* @CreateComponent
* expect fun createTestComponent(string: String): TestComponent
* ```
* Will generate:
* ```
* actual fun createTestComponent(string: String): TestComponent {
* return KotlinInjectTestComponent::class.create(string)
* }
* ```
*/
internal class CreateComponentProcessor(
private val codeGenerator: CodeGenerator,
override val logger: KSPLogger,
) : SymbolProcessor, ContextAware {

private val kclassFqName = KClass::class.requireQualifiedName()

override fun process(resolver: Resolver): List<KSAnnotated> {
resolver
.getSymbolsWithAnnotation(MergeComponent.CreateComponent::class)
.filterIsInstance<KSFunctionDeclaration>()
.onEach { function ->
checkIsPublic(function) {
"Factory functions for components annotated with `@CreateComponent` must be public."
}
checkKotlinInjectComponentWillBeGenerated(function)
checkReceiverType(function)
checkArguments(function)
checkIsExpectFunction(function)
}
.forEach {
generateActualFunction(it)
}

return emptyList()
}

private fun generateActualFunction(function: KSFunctionDeclaration) {
val component = (function.requireReturnType().resolve().declaration as KSClassDeclaration)
.toClassName()
val generatedComponent = component.peerClass("KotlinInject${component.simpleName}")

function.requireContainingFile()

val parametersAsSpec = function.parameters.map {
ParameterSpec
.builder(
name = it.requireName(),
type = it.type.toTypeName(),
)
.build()
}

val fileSpec = FileSpec
.builder(
packageName = function.packageName.asString(),
fileName = function.requireContainingFile().fileName.substringBefore(".kt") +
"CreateComponent",
)
.addFunction(
FunSpec
.builder(function.simpleName.asString())
.apply {
if (function.extensionReceiver != null) {
receiver(
KClass::class.asTypeName().parameterizedBy(component),
)
}
}
.addModifiers(ACTUAL)
.addParameters(parametersAsSpec)
.returns(component)
.addStatement(
"return %T::class.create(${parametersAsSpec.joinToString { it.name }})",
generatedComponent,
)
.build(),
)
.build()

fileSpec.writeTo(codeGenerator, aggregating = false)
}

private fun checkKotlinInjectComponentWillBeGenerated(function: KSFunctionDeclaration) {
val componentClass = function.requireReturnType().resolve().declaration
check(componentClass.isAnnotationPresent(MergeComponent::class), function) {
"The return type ${componentClass.requireQualifiedName()} is not annotated with `@MergeComponent`."
}
check(!componentClass.isAnnotationPresent(Component::class), function) {
"The return type ${componentClass.requireQualifiedName()} should not be annotated " +
"with `@Component`. In this scenario use the built-in annotations from " +
"kotlin-inject itself."
}
}

private fun checkReceiverType(function: KSFunctionDeclaration) {
val receiverType =
function.extensionReceiver?.resolve()?.declaration?.requireQualifiedName() ?: return
check(receiverType == kclassFqName, function) {
"Only a receiver type on KClass<YourComponent> is supported."
}

val receiverArgument =
function.extensionReceiver?.resolve()?.arguments?.singleOrNull()?.type
?.resolve()?.declaration?.requireQualifiedName()
val returnType = function.requireReturnType().resolve().declaration.requireQualifiedName()
check(receiverArgument == returnType, function) {
"Only a receiver type on KClass<YourComponent> is supported. The argument was different."
}
}

private fun checkArguments(function: KSFunctionDeclaration) {
val componentParameters =
(function.requireReturnType().resolve().declaration as? KSClassDeclaration)
?.primaryConstructor?.parameters ?: emptyList()

check(componentParameters.size == function.parameters.size, function) {
"The number of arguments for the function doesn't match the number of arguments of the component."
}
}

private fun checkIsExpectFunction(function: KSFunctionDeclaration) {
check(Modifier.EXPECT in function.modifiers, function) {
"Only expect functions can be annotated with @MergeComponent.CreateComponent. " +
"In non-common Kotlin Multiplatform code use the generated `create` extension " +
"function on the class object: YourComponent.create(..)."
}
}

private fun KSFunctionDeclaration.requireReturnType(): KSTypeReference {
return requireNotNull(returnType, this) {
"Couldn't determine return type for $this"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ fun compile(
previousCompilationResult: JvmCompilationResult? = null,
moduleName: String? = null,
useKsp2: Boolean = true,
multiplatform: Boolean = false,
exitCode: KotlinCompilation.ExitCode = KotlinCompilation.ExitCode.OK,
block: JvmCompilationResult.() -> Unit = { },
): JvmCompilationResult {
Expand All @@ -158,6 +159,7 @@ fun compile(
if (moduleName != null) {
this.moduleName = moduleName
}
this.multiplatform = multiplatform
}

if (previousCompilationResult != null) {
Expand Down
Loading

0 comments on commit 0a96521

Please sign in to comment.