Skip to content

Commit 7166ae8

Browse files
Voyager screen codegen support
1 parent 3865580 commit 7166ae8

File tree

5 files changed

+176
-52
lines changed

5 files changed

+176
-52
lines changed

ksp/core-annotations/common/src/dev/programadorthi/routing/annotation/Route.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package dev.programadorthi.routing.annotation
22

3-
@Target(AnnotationTarget.FUNCTION)
3+
@Target(
4+
AnnotationTarget.CLASS,
5+
AnnotationTarget.CONSTRUCTOR,
6+
AnnotationTarget.FUNCTION,
7+
)
48
public annotation class Route(
59
val path: String = "",
610
val name: String = "",

ksp/core-processor/jvm/src/dev/programadorthi/routing/ksp/RoutingProcessor.kt

Lines changed: 116 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,22 @@ import com.google.devtools.ksp.getVisibility
77
import com.google.devtools.ksp.processing.CodeGenerator
88
import com.google.devtools.ksp.processing.Dependencies
99
import com.google.devtools.ksp.processing.KSBuiltIns
10+
import com.google.devtools.ksp.processing.KSPLogger
1011
import com.google.devtools.ksp.processing.Resolver
1112
import com.google.devtools.ksp.processing.SymbolProcessor
1213
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
1314
import com.google.devtools.ksp.processing.SymbolProcessorProvider
15+
import com.google.devtools.ksp.symbol.ClassKind
1416
import com.google.devtools.ksp.symbol.FunctionKind
1517
import com.google.devtools.ksp.symbol.KSAnnotated
18+
import com.google.devtools.ksp.symbol.KSClassDeclaration
19+
import com.google.devtools.ksp.symbol.KSDeclaration
1620
import com.google.devtools.ksp.symbol.KSFile
1721
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
1822
import com.google.devtools.ksp.symbol.KSType
1923
import com.google.devtools.ksp.symbol.KSValueParameter
2024
import com.google.devtools.ksp.symbol.Visibility
25+
import com.squareup.kotlinpoet.ClassName
2126
import com.squareup.kotlinpoet.CodeBlock
2227
import com.squareup.kotlinpoet.FileSpec
2328
import com.squareup.kotlinpoet.FunSpec
@@ -35,22 +40,21 @@ public class RoutingProcessorProvider : SymbolProcessorProvider {
3540
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
3641
return RoutingProcessor(
3742
codeGenerator = environment.codeGenerator,
38-
options = environment.options
43+
options = environment.options,
44+
logger = environment.logger,
3945
)
4046
}
4147
}
4248

4349
private class RoutingProcessor(
4450
private val codeGenerator: CodeGenerator,
4551
private val options: Map<String, String>,
52+
private val logger: KSPLogger,
4653
) : SymbolProcessor {
4754
private var invoked = false
4855

4956
private val fileName: String
50-
get() = options["Routing_Module_Name"] ?: "Module"
51-
52-
private val composableEnabled: Boolean
53-
get() = options["Routing_Compose_Enable"]?.toBooleanStrictOrNull() ?: false
57+
get() = (options[FLAG_ROUTING_MODULE_NAME] ?: "Module") + "Routes"
5458

5559
override fun process(resolver: Resolver): List<KSAnnotated> {
5660
if (invoked) {
@@ -66,17 +70,9 @@ private class RoutingProcessor(
6670

6771
resolver
6872
.getSymbolsWithAnnotation(Route::class.java.name)
69-
.filterIsInstance<KSFunctionDeclaration>()
70-
.forEach { func ->
71-
val qualifiedName = func.qualifiedName?.asString()
72-
check(func.functionKind == FunctionKind.TOP_LEVEL) {
73-
"$qualifiedName fun must be a top level fun"
74-
}
75-
check(func.getVisibility() != Visibility.PRIVATE) {
76-
"$qualifiedName fun must not be private"
77-
}
78-
func.containingFile?.let(ksFiles::add)
79-
func.wrapFunctionWithHandle(qualifiedName, configureSpec, resolver)
73+
.filterIsInstance<KSDeclaration>()
74+
.forEach { symbol ->
75+
symbol.transform(ksFiles, configureSpec, resolver)
8076
}
8177

8278
configureSpec
@@ -87,28 +83,80 @@ private class RoutingProcessor(
8783
}
8884

8985
@OptIn(KspExperimental::class)
90-
private fun KSFunctionDeclaration.wrapFunctionWithHandle(
91-
qualifiedName: String?,
86+
private fun KSDeclaration.transform(
87+
ksFiles: MutableSet<KSFile>,
9288
configureSpec: FunSpec.Builder,
9389
resolver: Resolver
9490
) {
91+
val qualifiedName = qualifiedName?.asString() ?: return
92+
logger.info(">>>> transforming: $qualifiedName")
93+
check(getVisibility() != Visibility.PRIVATE) {
94+
"$qualifiedName must not be private"
95+
}
96+
containingFile?.let(ksFiles::add)
9597
val routeAnnotation = checkNotNull(getAnnotationsByType(Route::class).firstOrNull()) {
96-
"Invalid state because a @Route was not found to '$qualifiedName'"
98+
"Invalid state because is missing @Route to '$qualifiedName'"
99+
}
100+
when (this) {
101+
is KSFunctionDeclaration -> {
102+
check(functionKind == FunctionKind.TOP_LEVEL) {
103+
"$qualifiedName must be a top level fun"
104+
}
105+
logger.info(">>>> transforming fun: $qualifiedName")
106+
wrapFunctionWithHandle(routeAnnotation, qualifiedName, configureSpec, resolver, null)
107+
}
108+
109+
is KSClassDeclaration -> {
110+
check(classKind == ClassKind.OBJECT || classKind == ClassKind.CLASS) {
111+
"$qualifiedName must be a class or object. ${classKind.type} is not supported"
112+
}
113+
check(superTypes.any { type ->
114+
type.resolve().declaration.qualifiedName?.asString() == VOYAGER_SCREEN_QUALIFIED_NAME
115+
}) {
116+
"@Route can be applied to object or class that inherit from '$VOYAGER_SCREEN_QUALIFIED_NAME' only"
117+
}
118+
logger.info(">>>> transforming class: $qualifiedName")
119+
declarations
120+
.filterIsInstance<KSFunctionDeclaration>()
121+
.filter { func -> func.simpleName.asString() == CONSTRUCTOR_NAME }
122+
.forEach { constructor ->
123+
val annotation = constructor.getAnnotationsByType(Route::class).firstOrNull() ?: routeAnnotation
124+
constructor.wrapFunctionWithHandle(
125+
annotation,
126+
qualifiedName,
127+
configureSpec,
128+
resolver,
129+
classKind
130+
)
131+
}
132+
}
133+
134+
else -> error("$qualifiedName is not supported. Class and top level fun are supported only")
97135
}
136+
}
137+
138+
private fun KSFunctionDeclaration.wrapFunctionWithHandle(
139+
routeAnnotation: Route,
140+
qualifiedName: String,
141+
configureSpec: FunSpec.Builder,
142+
resolver: Resolver,
143+
classKind: ClassKind?,
144+
) {
98145
val isRegexRoute = routeAnnotation.regex.isNotBlank()
99146
check(isRegexRoute || routeAnnotation.path.isNotBlank()) {
100-
"Using @Route a path or a regex is required"
147+
"@Route requires a path or a regex"
101148
}
102149
check(!isRegexRoute || routeAnnotation.name.isBlank()) {
103-
"@Route with regex can't be named"
150+
"@Route having regex can't be named"
104151
}
105152

106-
val isComposable = annotations.any { it.shortName.asString() == "Composable" }
153+
val isScreen = classKind != null
154+
val isComposable = !isScreen && annotations.any { it.shortName.asString() == "Composable" }
107155

108156
if (isRegexRoute) {
109-
check(!isComposable) {
157+
check(!isComposable && !isScreen) {
110158
// TODO: Add regex support to composable handle
111-
"Combining @Route(regex = ...) and @Composable are not supported for $qualifiedName"
159+
"$qualifiedName has @Route(regex = ...) that cannot be applied to @Composable or Voyager Screen"
112160
}
113161
if (routeAnnotation.method.isBlank()) {
114162
configureSpec
@@ -135,10 +183,12 @@ private class RoutingProcessor(
135183
routeAnnotation.name.isBlank() -> "name = null"
136184
else -> """name = "${routeAnnotation.name}""""
137185
}
138-
var memberName = handle
139-
if (composableEnabled && isComposable) {
140-
memberName = composable
186+
val memberName = when {
187+
isComposable -> composable
188+
isScreen -> screen
189+
else -> handle
141190
}
191+
logger.info(">>>> transforming -> name: $named and member: $memberName")
142192
if (routeAnnotation.method.isBlank()) {
143193
configureSpec
144194
.beginControlFlow("%M(path = %S, $named)", memberName, routeAnnotation.path)
@@ -150,7 +200,7 @@ private class RoutingProcessor(
150200
}
151201
}
152202

153-
val codeBlock = generateHandleBody(isRegexRoute, routeAnnotation, resolver, qualifiedName)
203+
val codeBlock = generateHandleBody(isRegexRoute, routeAnnotation, resolver, qualifiedName, classKind)
154204

155205
configureSpec
156206
.addCode(codeBlock)
@@ -161,16 +211,27 @@ private class RoutingProcessor(
161211
isRegexRoute: Boolean,
162212
routeAnnotation: Route,
163213
resolver: Resolver,
164-
qualifiedName: String?
214+
qualifiedName: String,
215+
classKind: ClassKind?,
165216
): CodeBlock {
166-
val funcMember = MemberName(packageName.asString(), simpleName.asString())
167217
val funcBuilder = CodeBlock.builder()
168218
val hasZeroOrOneParameter = parameters.size < 2
169-
if (hasZeroOrOneParameter) {
170-
funcBuilder.add(FUN_INVOKE_START, funcMember)
171-
} else {
172-
funcBuilder
173-
.addStatement(FUN_INVOKE_START, funcMember)
219+
val funName = simpleName.asString()
220+
val member: Any = when {
221+
classKind != null -> ClassName(packageName.asString(), qualifiedName.split(".").last())
222+
else -> MemberName(packageName.asString(), funName)
223+
}
224+
val template = when (classKind) {
225+
ClassKind.OBJECT -> FUN_TYPE_INVOKE
226+
ClassKind.CLASS -> FUN_TYPE_INVOKE_START
227+
else -> FUN_MEMBER_INVOKE_START
228+
}
229+
logger.info(">>>> fun name: $funName -> template: $template -> member: $member")
230+
when {
231+
classKind == ClassKind.OBJECT -> funcBuilder.addStatement(template, member)
232+
hasZeroOrOneParameter -> funcBuilder.add(template, member)
233+
else -> funcBuilder
234+
.addStatement(template, member)
174235
.indent()
175236
}
176237

@@ -202,15 +263,17 @@ private class RoutingProcessor(
202263
}
203264
}
204265

205-
if (hasZeroOrOneParameter) {
206-
funcBuilder.addStatement(FUN_INVOKE_END)
207-
} else {
208-
funcBuilder
209-
.unindent()
210-
.addStatement(FUN_INVOKE_END)
266+
if (classKind == ClassKind.OBJECT) {
267+
return funcBuilder.build()
268+
}
269+
270+
if (hasZeroOrOneParameter.not()) {
271+
funcBuilder.unindent()
211272
}
212273

213-
return funcBuilder.build()
274+
return funcBuilder
275+
.addStatement(FUN_INVOKE_END)
276+
.build()
214277
}
215278

216279
@OptIn(KspExperimental::class)
@@ -336,7 +399,7 @@ private class RoutingProcessor(
336399
FileSpec
337400
.builder(
338401
packageName = "dev.programadorthi.routing.generated",
339-
fileName = "${fileName}Routes",
402+
fileName = fileName,
340403
)
341404
.addFileComment("Generated by Kotlin Routing")
342405
.addFunction(this)
@@ -374,6 +437,7 @@ private class RoutingProcessor(
374437
}
375438

376439
private companion object {
440+
private val screen = MemberName("dev.programadorthi.routing.voyager", "screen")
377441
private val composable = MemberName("dev.programadorthi.routing.compose", "composable")
378442
private val handle = MemberName("dev.programadorthi.routing.core", "handle")
379443
private val routeMethod = MemberName("dev.programadorthi.routing.core", "RouteMethod")
@@ -385,9 +449,17 @@ private class RoutingProcessor(
385449
private const val CALL_PROPERTY_TEMPLATE = """%L = %M.%L%L"""
386450
private const val BODY_TEMPLATE = "%L = %M.%M()%L"
387451
private const val FUN_INVOKE_END = ")"
388-
private const val FUN_INVOKE_START = "%M("
452+
private const val FUN_MEMBER_INVOKE_START = "%M("
453+
private const val FUN_TYPE_INVOKE = "%T"
454+
private const val FUN_TYPE_INVOKE_START = "$FUN_TYPE_INVOKE("
389455
private const val PATH_TEMPLATE = """%L = %M.parameters["%L"]%L"""
390456
private const val TAILCARD_TEMPLATE = """%L = %M.parameters.getAll("%L")%L"""
457+
458+
private const val FLAG_ROUTING_MODULE_NAME = "Routing_Module_Name"
459+
460+
private const val VOYAGER_SCREEN_QUALIFIED_NAME = "cafe.adriel.voyager.core.screen.Screen"
461+
462+
private const val CONSTRUCTOR_NAME = "<init>"
391463
}
392464

393465
}

samples/ksp-sample/build.gradle.kts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,11 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
33
plugins {
44
kotlin("multiplatform")
55
alias(libs.plugins.ksp)
6-
id("dev.programadorthi.routing") version "0.0.99"
6+
//id("dev.programadorthi.routing") version "0.0.99"
77
alias(libs.plugins.jetbrains.compose)
88
alias(libs.plugins.compose.compiler)
99
}
1010

11-
ksp {
12-
arg("Routing_Compose_Enable", "true")
13-
}
14-
1511
kotlin {
1612
jvm {
1713
@OptIn(ExperimentalKotlinGradlePluginApi::class)
@@ -25,17 +21,22 @@ kotlin {
2521
dependencies {
2622
implementation(projects.core)
2723
implementation(projects.integration.compose)
24+
implementation(projects.integration.voyager)
2825
implementation(projects.ksp.coreAnnotations)
2926
implementation(compose.runtime)
3027
}
3128
}
3229
}
3330
}
3431

35-
configurations.all {
32+
dependencies {
33+
add("kspJvm", projects.ksp.coreProcessor)
34+
}
35+
36+
/*configurations.all {
3637
resolutionStrategy.dependencySubstitution {
3738
substitute(module("dev.programadorthi.routing:core"))
3839
.using(project(":core"))
3940
.because("KSP gradle plugin have maven central dependencies")
4041
}
41-
}
42+
}*/

samples/ksp-sample/src/commonMain/kotlin/dev/programadorthi/routing/sample/Composables.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ fun composeTailcard(param: List<String>?) {
4444
println(">>>> Tailcard params: $param")
4545
}
4646

47+
/*@Route(regex = ".+/hello")
48+
@Composable
49+
fun composeRegex1() {
50+
println(">>>> Routing with regex")
51+
}*/
52+
4753
@Route("/compose-with-body")
4854
@Composable
4955
fun composeWithBody(@Body user: User) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package dev.programadorthi.routing.sample
2+
3+
import androidx.compose.runtime.Composable
4+
import cafe.adriel.voyager.core.screen.Screen
5+
import dev.programadorthi.routing.annotation.Body
6+
import dev.programadorthi.routing.annotation.Route
7+
8+
@Route("/screen")
9+
class Screen1 : Screen {
10+
@Composable
11+
override fun Content() {}
12+
}
13+
14+
@Route("/screen-object")
15+
object Screen2 : Screen {
16+
@Composable
17+
override fun Content() {}
18+
}
19+
20+
@Route("/screen/{id}")
21+
class Screen3(id: Int) : Screen {
22+
@Composable
23+
override fun Content() {}
24+
}
25+
26+
@Route("/screen/{name}")
27+
class Screen4(name: String) : Screen {
28+
29+
@Route("/screen/{age}")
30+
constructor(age: Int) : this("empty")
31+
32+
@Composable
33+
override fun Content() {}
34+
}
35+
36+
@Route("/screen-with-body")
37+
class Screen5(@Body user: User) : Screen {
38+
39+
@Composable
40+
override fun Content() {}
41+
}

0 commit comments

Comments
 (0)