Skip to content

Commit 3cb6bcf

Browse files
committed
@ContributesRobot code generator
Add a new KSP code generator that generates the code for `@ContributesRobot` using Metro. The implementation is almost equivalent to the kotlin-inject implementation, except for that it's using Metro APIs. The big difference is that kotlin-inject implements map-multibindings by returning a `Pair<Abc, Def>` whereas Metro uses a `@ClassKey` annotation similar to Dagger 2. Therefore, we had to introduce the `@RobotKey` annotation specifically for Metro. See #115
1 parent b494b10 commit 3cb6bcf

File tree

9 files changed

+470
-1
lines changed

9 files changed

+470
-1
lines changed

metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/KotlinInjectExtensionSymbolProcessorProvider.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
66
import com.google.devtools.ksp.processing.SymbolProcessorProvider
77
import software.amazon.app.platform.ksp.CompositeSymbolProcessor
88
import software.amazon.app.platform.metro.processor.ContributesRendererProcessor
9+
import software.amazon.app.platform.metro.processor.ContributesRobotProcessor
910

1011
/** Entry point for KSP to pick up our [SymbolProcessor]. */
1112
@AutoService(SymbolProcessorProvider::class)
@@ -16,7 +17,11 @@ public class KotlinInjectExtensionSymbolProcessorProvider : SymbolProcessorProvi
1617
ContributesRendererProcessor(
1718
codeGenerator = environment.codeGenerator,
1819
logger = environment.logger,
19-
)
20+
),
21+
ContributesRobotProcessor(
22+
codeGenerator = environment.codeGenerator,
23+
logger = environment.logger,
24+
),
2025
)
2126
}
2227
}

metro-extensions/contribute/impl-code-generators/src/main/kotlin/software/amazon/app/platform/metro/MetroContextAware.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package software.amazon.app.platform.metro
22

3+
import com.google.devtools.ksp.symbol.KSAnnotation
4+
import com.google.devtools.ksp.symbol.KSType
35
import dev.zacsweers.metro.Inject
46
import dev.zacsweers.metro.Scope
57
import software.amazon.app.platform.ksp.ContextAware
@@ -10,4 +12,16 @@ internal interface MetroContextAware : ContextAware {
1012

1113
private val scopeFqName
1214
get() = Scope::class.requireQualifiedName()
15+
16+
fun KSAnnotation.isMetroScopeAnnotation(): Boolean {
17+
return annotationType.resolve().isMetroScopeAnnotation()
18+
}
19+
20+
private fun KSType.isMetroScopeAnnotation(): Boolean {
21+
return declaration.annotations.any {
22+
// Don't use requireQualifiedName(), because @ContributingAnnotation might not be
23+
// on the compile classpath.
24+
it.annotationType.resolve().declaration.qualifiedName?.asString() == scopeFqName
25+
}
26+
}
1327
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package software.amazon.app.platform.metro.processor
2+
3+
import com.google.devtools.ksp.KspExperimental
4+
import com.google.devtools.ksp.getAllSuperTypes
5+
import com.google.devtools.ksp.isAnnotationPresent
6+
import com.google.devtools.ksp.processing.CodeGenerator
7+
import com.google.devtools.ksp.processing.KSPLogger
8+
import com.google.devtools.ksp.processing.Resolver
9+
import com.google.devtools.ksp.processing.SymbolProcessor
10+
import com.google.devtools.ksp.symbol.KSAnnotated
11+
import com.google.devtools.ksp.symbol.KSClassDeclaration
12+
import com.squareup.kotlinpoet.AnnotationSpec
13+
import com.squareup.kotlinpoet.ClassName
14+
import com.squareup.kotlinpoet.FileSpec
15+
import com.squareup.kotlinpoet.FunSpec
16+
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
17+
import com.squareup.kotlinpoet.TypeSpec
18+
import com.squareup.kotlinpoet.asClassName
19+
import com.squareup.kotlinpoet.ksp.addOriginatingKSFile
20+
import com.squareup.kotlinpoet.ksp.toClassName
21+
import com.squareup.kotlinpoet.ksp.writeTo
22+
import dev.zacsweers.metro.AppScope
23+
import dev.zacsweers.metro.ContributesTo
24+
import dev.zacsweers.metro.Inject
25+
import dev.zacsweers.metro.IntoMap
26+
import dev.zacsweers.metro.Provider
27+
import dev.zacsweers.metro.Provides
28+
import software.amazon.app.platform.inject.robot.ContributesRobot
29+
import software.amazon.app.platform.ksp.decapitalize
30+
import software.amazon.app.platform.metro.METRO_LOOKUP_PACKAGE
31+
import software.amazon.app.platform.metro.MetroContextAware
32+
import software.amazon.app.platform.renderer.metro.RobotKey
33+
34+
/**
35+
* Generates the necessary code in order to support [ContributesRobot].
36+
*
37+
* If you use `@ContributesRobot(AbcScope::class)`, then this processor will generate a graph
38+
* interface, which gets contributed to this scope.
39+
*
40+
* ```
41+
* package app.platform.inject.metro.software.amazon.test
42+
*
43+
* @ContributesTo(scope = AbcScope::class)
44+
* public interface AbcRobotGraph {
45+
* @Provide
46+
* fun provideAbcRobot(): AbcRobot = AbcRobot()
47+
*
48+
* @Provides
49+
* @IntoMap
50+
* @RobotKey(AbcRobot::class)
51+
* fun provideAbcRobotIntoMap(
52+
* robot: Provider<AbcRobot>,
53+
* ): Robot = robot()
54+
* }
55+
* ```
56+
*/
57+
@OptIn(KspExperimental::class)
58+
internal class ContributesRobotProcessor(
59+
private val codeGenerator: CodeGenerator,
60+
override val logger: KSPLogger,
61+
) : SymbolProcessor, MetroContextAware {
62+
63+
private val robotClassName = ClassName("software.amazon.app.platform.robot", "Robot")
64+
private val robotFqName = robotClassName.canonicalName
65+
66+
private val robotKey = RobotKey::class.asClassName()
67+
68+
override fun process(resolver: Resolver): List<KSAnnotated> {
69+
resolver
70+
.getSymbolsWithAnnotation(ContributesRobot::class)
71+
.filterIsInstance<KSClassDeclaration>()
72+
.onEach {
73+
checkIsPublic(it)
74+
checkHasInjectAnnotation(it)
75+
checkNotSingleton(it)
76+
checkSuperType(it)
77+
checkAppScope(it)
78+
}
79+
.forEach { generateGraph(it) }
80+
81+
return emptyList()
82+
}
83+
84+
private fun generateGraph(clazz: KSClassDeclaration) {
85+
val packageName = "${METRO_LOOKUP_PACKAGE}.${clazz.packageName.asString()}"
86+
val graphClassName = ClassName(packageName, "${clazz.innerClassNames()}Graph")
87+
88+
val fileSpec =
89+
FileSpec.builder(graphClassName)
90+
.addType(
91+
TypeSpec.interfaceBuilder(graphClassName)
92+
.addOriginatingKSFile(clazz.requireContainingFile())
93+
.addAnnotation(
94+
AnnotationSpec.builder(ContributesTo::class)
95+
.addMember("%T::class", clazz.scope().type.toClassName())
96+
.build()
97+
)
98+
.apply {
99+
if (!clazz.isAnnotationPresent(Inject::class)) {
100+
addFunction(
101+
FunSpec.builder("provide${clazz.innerClassNames()}")
102+
.addAnnotation(Provides::class)
103+
.returns(clazz.toClassName())
104+
.addStatement("return %T()", clazz.toClassName())
105+
.build()
106+
)
107+
}
108+
}
109+
.addFunction(
110+
FunSpec.builder("provide${clazz.innerClassNames()}IntoMap")
111+
.addAnnotation(Provides::class)
112+
.addAnnotation(IntoMap::class)
113+
.addAnnotation(
114+
AnnotationSpec.builder(robotKey)
115+
.addMember("%T::class", clazz.toClassName())
116+
.build()
117+
)
118+
.addParameter(
119+
name = "robot",
120+
type = Provider::class.asClassName().parameterizedBy(clazz.toClassName()),
121+
)
122+
.returns(robotClassName)
123+
.addStatement("return robot()")
124+
.build()
125+
)
126+
.addProperty(name = clazz.innerClassNames().decapitalize(), type = clazz.toClassName())
127+
.build()
128+
)
129+
.build()
130+
131+
fileSpec.writeTo(codeGenerator, aggregating = false)
132+
}
133+
134+
private fun checkHasInjectAnnotation(clazz: KSClassDeclaration) {
135+
if (clazz.primaryConstructor?.parameters?.isNotEmpty() == true) {
136+
check(clazz.annotations.any { it.isAnnotation(injectFqName) }, clazz) {
137+
"${clazz.simpleName.asString()} must be annotated with @Inject when " +
138+
"injecting arguments into a robot."
139+
}
140+
}
141+
}
142+
143+
private fun checkNotSingleton(clazz: KSClassDeclaration) {
144+
check(clazz.annotations.none { it.isMetroScopeAnnotation() }, clazz) {
145+
"It's not allowed allowed for a robot to be a singleton, because the lifetime " +
146+
"of the robot is scoped to the robot() factory function. Remove the @" +
147+
clazz.annotations.first { it.isMetroScopeAnnotation() }.shortName.asString() +
148+
" annotation."
149+
}
150+
}
151+
152+
private fun checkSuperType(clazz: KSClassDeclaration) {
153+
val extendsRobot =
154+
clazz.getAllSuperTypes().any { it.declaration.requireQualifiedName() == robotFqName }
155+
156+
check(extendsRobot, clazz) {
157+
"In order to use @ContributesRobot, ${clazz.simpleName.asString()} must " +
158+
"implement $robotFqName."
159+
}
160+
}
161+
162+
private fun checkAppScope(clazz: KSClassDeclaration) {
163+
val scope = clazz.scope().type.declaration.requireQualifiedName()
164+
check(scope == AppScope::class.requireQualifiedName(), clazz) {
165+
"Robots can only be contributed to the AppScope for now. Scope $scope is unsupported."
166+
}
167+
}
168+
}

metro-extensions/contribute/impl-code-generators/src/test/kotlin/software/amazon/app/platform/inject/metro/CommonSourceCode.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package software.amazon.app.platform.inject.metro
55
import com.tschuchort.compiletesting.JvmCompilationResult
66
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
77
import software.amazon.test.TestRendererGraph
8+
import software.amazon.test.TestRobotGraph
89

910
internal val JvmCompilationResult.graphInterface: Class<*>
1011
get() = classLoader.loadClass("software.amazon.test.GraphInterface")
@@ -17,3 +18,12 @@ internal fun Class<*>.newTestRendererGraph(): TestRendererGraph {
1718
.single { it.name == "create" }
1819
.invoke(companionObject) as TestRendererGraph
1920
}
21+
22+
internal fun Class<*>.newTestRobotGraph(): TestRobotGraph {
23+
val companionObject = fields.single().get(null)
24+
return classes
25+
.single { it.simpleName == "Companion" }
26+
.declaredMethods
27+
.single { it.name == "create" }
28+
.invoke(companionObject) as TestRobotGraph
29+
}

0 commit comments

Comments
 (0)