From 23a46c1faec58e39630e6cfaf726f89781a780ff Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:09:08 -0600 Subject: [PATCH 1/7] Switch to using a processor based off the one currently in circuit-code-gen --- .idea/uiDesigner.xml | 124 +++ gradle/libs.versions.toml | 7 + sample/build.gradle.kts | 1 + .../circuitkotlininject/sample/Main.kt | 14 +- .../sample/other/OtherScreen.kt | 30 + ui-injector-annotations/build.gradle.kts | 5 +- .../annotations/CircuitInject.kt | 2 +- ui-injector-processor/build.gradle.kts | 9 +- .../CircuitSymbolProcessorProvider.kt | 858 ++++++++++++++++++ .../processor/UiInjectorProcessor.kt | 39 +- ...processing.SymbolProcessorProvider.backup} | 0 11 files changed, 1070 insertions(+), 19 deletions(-) create mode 100644 .idea/uiDesigner.xml create mode 100644 sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt create mode 100644 ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt rename ui-injector-processor/src/main/resources/META-INF/services/{com.google.devtools.ksp.processing.SymbolProcessorProvider => com.google.devtools.ksp.processing.SymbolProcessorProvider.backup} (100%) diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml new file mode 100644 index 0000000..2b63946 --- /dev/null +++ b/.idea/uiDesigner.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1bf70a..f07980d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,8 @@ [versions] +anvil = "2.4.8" app-cash-sqldelight = "2.0.0" circuit = "0.17.0" +dagger = "2.49" io-ktor = "2.3.6" kotlinInject = "0.6.3" kotlinpoet = "1.15.2" @@ -12,7 +14,12 @@ plugin-ktlint = "11.3.2" plugin-mavenpublish = "0.21.0" [libraries] +anvil-annotations = { module = "com.squareup.anvil:annotations", version.ref = "anvil" } +autoService-annotations = { module = "com.google.auto.service:auto-service-annotations", version = "1.1.1" } +autoService-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version = "1.1.0" } +circuit-codegen-annotations = { module = "com.slack.circuit:circuit-codegen-annotations", version.ref = "circuit" } circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" } +dagger = { module = "com.google.dagger:dagger", version.ref = "dagger" } kotlin-inject-compiler-ksp = { module = "me.tatarka.inject:kotlin-inject-compiler-ksp", version.ref = "kotlinInject" } kotlin-inject-runtime = { module = "me.tatarka.inject:kotlin-inject-runtime", version.ref = "kotlinInject" } kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } diff --git a/sample/build.gradle.kts b/sample/build.gradle.kts index ca16f1f..6201378 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -22,4 +22,5 @@ dependencies { ksp { arg("circuit.codegen.package", "com.joshafeinberg.circuitkotlininject.sample") arg("circuit.codegen.parent.component", "com.joshafeinberg.circuitkotlininject.sample.ParentComponent") + arg("circuit.codegen.mode", "kotlin_inject") } \ No newline at end of file diff --git a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt index d2f1652..304090e 100644 --- a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt +++ b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt @@ -6,14 +6,15 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.joshafeinberg.circuitkotlininject.processors.annotations.CircuitInject +import com.joshafeinberg.circuitkotlininject.annotations.CircuitInject +import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.CircuitContent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen -import me.tatarka.inject.annotations.Component -import me.tatarka.inject.annotations.Provides +import com.slack.circuit.runtime.ui.Ui +import me.tatarka.inject.annotations.* fun main() = application { val parentComponent = remember { ParentComponent::class.create() } @@ -40,7 +41,10 @@ abstract class ParentComponent { } -@CircuitInject(MyScreen::class, MyScreen.MyScreenState::class) +@Scope +annotation class AppScope + +@com.slack.circuit.codegen.annotations.CircuitInject(MyScreen::class, AppScope::class) @Composable fun MyScreen(state: MyScreen.MyScreenState, modifier: Modifier) { Text(state.visibleString) @@ -54,7 +58,7 @@ data object MyScreen : Screen { } -@CircuitInject(MyScreen::class, MyScreen.MyScreenState::class) +@com.slack.circuit.codegen.annotations.CircuitInject(MyScreen::class, AppScope::class) class MyScreenPresenter(private val injectedString: String) : Presenter { @Composable override fun present(): MyScreen.MyScreenState { diff --git a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt new file mode 100644 index 0000000..ea7a201 --- /dev/null +++ b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt @@ -0,0 +1,30 @@ +package com.joshafeinberg.circuitkotlininject.sample.other + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.joshafeinberg.circuitkotlininject.sample.AppScope +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.presenter.Presenter +import com.slack.circuit.runtime.screen.Screen + +@com.slack.circuit.codegen.annotations.CircuitInject(OtherScreen::class, AppScope::class) +@Composable +fun OtherScreen(modifier: Modifier) { + Text("Other Screen") +} + +data object OtherScreen : Screen { + + data object OtherScreenState : CircuitUiState + +} + +@com.slack.circuit.codegen.annotations.CircuitInject(OtherScreen::class, AppScope::class) +class OtherScreenPresenter(private val injectedString: String) : Presenter { + @Composable + override fun present(): OtherScreen.OtherScreenState { + return OtherScreen.OtherScreenState + } + +} \ No newline at end of file diff --git a/ui-injector-annotations/build.gradle.kts b/ui-injector-annotations/build.gradle.kts index 4876c17..625aa43 100644 --- a/ui-injector-annotations/build.gradle.kts +++ b/ui-injector-annotations/build.gradle.kts @@ -7,13 +7,14 @@ kotlin { jvm { jvmToolchain(17) } - js(IR) { + /*js(IR) { browser() - } + }*/ sourceSets { commonMain { dependencies { compileOnly(libs.circuit.foundation) + api(libs.circuit.codegen.annotations) } } } diff --git a/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt b/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt index 577323e..c183f2a 100644 --- a/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt +++ b/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt @@ -5,4 +5,4 @@ import com.slack.circuit.runtime.screen.Screen import kotlin.reflect.KClass @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) -annotation class CircuitInject(val screen: KClass, val state: KClass) +annotation class CircuitInject(val screen: KClass) diff --git a/ui-injector-processor/build.gradle.kts b/ui-injector-processor/build.gradle.kts index 7a3bf2e..30e8600 100644 --- a/ui-injector-processor/build.gradle.kts +++ b/ui-injector-processor/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") - alias(libs.plugins.mavenpublish) + alias(libs.plugins.ksp) + // alias(libs.plugins.mavenpublish) } dependencies { @@ -8,4 +9,10 @@ dependencies { implementation(libs.ksp.processor) implementation(libs.kotlinpoet) implementation(libs.kotlinpoet.ksp) + implementation(libs.dagger) + implementation(libs.anvil.annotations) + implementation(libs.kotlin.inject.runtime) + implementation(libs.autoService.annotations) + + ksp(libs.autoService.ksp) } diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt new file mode 100644 index 0000000..03367e2 --- /dev/null +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt @@ -0,0 +1,858 @@ +// Copyright (C) 2022 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("UnsafeCallOnNullableType") + +package com.slack.circuit.codegen + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.containingFile +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.processing.* +import com.google.devtools.ksp.symbol.* +import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.kotlinpoet.* +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.ksp.addOriginatingKSFile +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo +import dagger.assisted.AssistedFactory +import java.util.Locale +import javax.inject.Inject +import kotlin.reflect.KClass + +private const val CIRCUIT_RUNTIME_BASE_PACKAGE = "com.slack.circuit.runtime" +private const val DAGGER_PACKAGE = "dagger" +private const val DAGGER_HILT_PACKAGE = "$DAGGER_PACKAGE.hilt" +private const val DAGGER_HILT_CODEGEN_PACKAGE = "$DAGGER_HILT_PACKAGE.codegen" +private const val DAGGER_MULTIBINDINGS_PACKAGE = "$DAGGER_PACKAGE.multibindings" +private const val CIRCUIT_RUNTIME_UI_PACKAGE = "$CIRCUIT_RUNTIME_BASE_PACKAGE.ui" +private const val CIRCUIT_RUNTIME_SCREEN_PACKAGE = "$CIRCUIT_RUNTIME_BASE_PACKAGE.screen" +private const val CIRCUIT_RUNTIME_PRESENTER_PACKAGE = "$CIRCUIT_RUNTIME_BASE_PACKAGE.presenter" +private const val KOTLIN_INJECT_BASE_PACKAGE = "me.tatarka.inject.annotations" +private val MODIFIER = ClassName("androidx.compose.ui", "Modifier") +private val CIRCUIT_INJECT_ANNOTATION = + ClassName("com.slack.circuit.codegen.annotations", "CircuitInject") +private val CIRCUIT = ClassName("com.slack.circuit.foundation", "Circuit") +private val CIRCUIT_PRESENTER = ClassName(CIRCUIT_RUNTIME_PRESENTER_PACKAGE, "Presenter") +private val CIRCUIT_PRESENTER_FACTORY = CIRCUIT_PRESENTER.nestedClass("Factory") +private val CIRCUIT_UI = ClassName(CIRCUIT_RUNTIME_UI_PACKAGE, "Ui") +private val CIRCUIT_UI_FACTORY = CIRCUIT_UI.nestedClass("Factory") +private val CIRCUIT_UI_STATE = ClassName(CIRCUIT_RUNTIME_BASE_PACKAGE, "CircuitUiState") +private val SCREEN = ClassName(CIRCUIT_RUNTIME_SCREEN_PACKAGE, "Screen") +private val NAVIGATOR = ClassName(CIRCUIT_RUNTIME_BASE_PACKAGE, "Navigator") +private val CIRCUIT_CONTEXT = ClassName(CIRCUIT_RUNTIME_BASE_PACKAGE, "CircuitContext") +private val DAGGER_MODULE = ClassName(DAGGER_PACKAGE, "Module") +private val DAGGER_BINDS = ClassName(DAGGER_PACKAGE, "Binds") +private val DAGGER_INSTALL_IN = ClassName(DAGGER_HILT_PACKAGE, "InstallIn") +private val DAGGER_ORIGINATING_ELEMENT = + ClassName(DAGGER_HILT_CODEGEN_PACKAGE, "OriginatingElement") +private val DAGGER_INTO_SET = ClassName(DAGGER_MULTIBINDINGS_PACKAGE, "IntoSet") +private val KOTLIN_INJECT_COMPONENT = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "Component") +private val KOTLIN_INJECT_INTO_SET = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "IntoSet") +private val KOTLIN_INJECT_PROVIDES = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "Provides") +private const val MODULE = "Module" +private const val FACTORY = "Factory" +private const val CIRCUIT_CODEGEN_MODE = "circuit.codegen.mode" +private const val CIRCUIT_COMPONENT_PACKAGE = "circuit.codegen.package" +private const val CIRCUIT_PARENT_COMPONENT = "circuit.codegen.parent.component" + +@AutoService(SymbolProcessorProvider::class) +public class CircuitSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return CircuitSymbolProcessor( + environment.logger, + environment.codeGenerator, + environment.options, + environment.platforms + ) + } +} + +private class CircuitSymbols private constructor(resolver: Resolver) { + val modifier = resolver.loadKSType(MODIFIER.canonicalName) + val circuitUiState = resolver.loadKSType(CIRCUIT_UI_STATE.canonicalName) + val screen = resolver.loadKSType(SCREEN.canonicalName) + val navigator = resolver.loadKSType(NAVIGATOR.canonicalName) + + companion object { + fun create(resolver: Resolver): CircuitSymbols? { + @Suppress("SwallowedException") + return try { + CircuitSymbols(resolver) + } catch (e: IllegalStateException) { + null + } + } + } +} + +private fun Resolver.loadKSType(name: String): KSType = + loadOptionalKSType(name) ?: error("Could not find $name in classpath") + +private fun Resolver.loadOptionalKSType(name: String?): KSType? { + if (name == null) return null + return getClassDeclarationByName(getKSNameFromString(name))?.asType(emptyList()) +} + +private class CircuitSymbolProcessor( + private val logger: KSPLogger, + private val codeGenerator: CodeGenerator, + private val options: Map, + private val platforms: List, +) : SymbolProcessor { + + // todo - this is bad + private val bindMethodsToCreate = mutableListOf() + + override fun process(resolver: Resolver): List { + val symbols = CircuitSymbols.create(resolver) ?: return emptyList() + val codegenMode = + options[CIRCUIT_CODEGEN_MODE].let { mode -> + if (mode == null) { + CodegenMode.ANVIL + } else { + CodegenMode.entries.find { it.name.equals(mode, ignoreCase = true) } + ?: run { + logger.error("Unrecognised option for codegen mode \"$mode\".") + return emptyList() + } + } + } + + if (!codegenMode.supportsPlatforms(platforms)) { + logger.error("Unsupported platforms for codegen mode ${codegenMode.name}. $platforms") + return emptyList() + } + + val componentPackage = options[CIRCUIT_COMPONENT_PACKAGE] + if (componentPackage == null) { + logger.error("Should set component's package") + } + + val parentComponent = options[CIRCUIT_PARENT_COMPONENT]?.let { + ClassName.bestGuess(it.trim()) + } + + resolver.getSymbolsWithAnnotation(CIRCUIT_INJECT_ANNOTATION.canonicalName).forEach { + annotatedElement -> + when (annotatedElement) { + is KSClassDeclaration -> + generateFactory(annotatedElement, InstantiationType.CLASS, symbols, codegenMode) + is KSFunctionDeclaration -> + generateFactory(annotatedElement, InstantiationType.FUNCTION, symbols, codegenMode) + else -> + logger.error( + "CircuitInject is only applicable on classes and functions.", + annotatedElement + ) + } + } + + createComponentFile( + bindMethodsToCreate, + componentPackage, + parentComponent?.let { listOf(it) } + ) + + + return emptyList() + } + + private fun generateFactory( + annotatedElement: KSAnnotated, + instantiationType: InstantiationType, + symbols: CircuitSymbols, + codegenMode: CodegenMode, + ) { + val circuitInjectAnnotation = + annotatedElement.annotations.first { + it.annotationType.resolve().declaration.qualifiedName?.asString() == + CIRCUIT_INJECT_ANNOTATION.canonicalName + } + val screenKSType = circuitInjectAnnotation.arguments[0].value as KSType + val screenIsObject = + screenKSType.declaration.let { it is KSClassDeclaration && it.classKind == ClassKind.OBJECT } + val screenType = screenKSType.toTypeName() + val scope = (circuitInjectAnnotation.arguments[1].value as KSType).toTypeName() + + val factoryData = + computeFactoryData(annotatedElement, symbols, screenKSType, instantiationType, codegenMode, logger) + ?: return + + val className = + factoryData.className.replaceFirstChar { char -> + char.takeIf { char.isLowerCase() }?.run { uppercase(Locale.getDefault()) } + ?: char.toString() + } + + val builder = + TypeSpec.classBuilder(className + FACTORY) + .primaryConstructor( + FunSpec.constructorBuilder() + .addAnnotation(codegenMode.injectAnnotation()) + .addParameters(factoryData.constructorParams) + .build() + ) + .apply { + if (factoryData.constructorParams.isNotEmpty()) { + for (param in factoryData.constructorParams) { + addProperty( + PropertySpec.builder(param.name, param.type, KModifier.PRIVATE) + .initializer(param.name) + .build() + ) + } + } + + codegenMode.annotateFactory(builder = this, scope = scope) + } + val screenBranch = + if (screenIsObject) { + CodeBlock.of("%T", screenType) + } else { + CodeBlock.of("is·%T", screenType) + } + val typeSpec = + when (factoryData.factoryType) { + FactoryType.PRESENTER -> + builder.buildPresenterFactory(annotatedElement, screenBranch, factoryData.codeBlock) + FactoryType.UI -> + builder.buildUiFactory(annotatedElement, screenBranch, factoryData.codeBlock) + } + + // Note: We can't directly reference the top-level class declaration for top-level functions in + // kotlin. For annotatedElements which as top-level functions, topLevelClass will be null. + val topLevelDeclaration = (annotatedElement as KSDeclaration).topLevelDeclaration() + val topLevelClass = (topLevelDeclaration as? KSClassDeclaration)?.toClassName() + + val originatingFile = listOfNotNull(annotatedElement.containingFile) + + bindMethodsToCreate.add( + KotlinInjectBindingResult( + className + FACTORY, + factoryData.packageName, + factoryData.factoryType, + originatingFile + ) + ) + + FileSpec.get(factoryData.packageName, typeSpec) + .writeTo( + codeGenerator = codeGenerator, + aggregating = false, + originatingKSFiles = originatingFile + ) + + val additionalType = + codegenMode.produceAdditionalTypeSpec( + factoryType = factoryData.factoryType, + factory = ClassName(factoryData.packageName, className + FACTORY), + scope = scope, + topLevelClass = topLevelClass + ) ?: return + + FileSpec.get(factoryData.packageName, additionalType) + .writeTo( + codeGenerator = codeGenerator, + aggregating = false, + originatingKSFiles = originatingFile + ) + } + + private data class FactoryData( + val className: String, + val packageName: String, + val factoryType: FactoryType, + val constructorParams: List, + val codeBlock: CodeBlock, + ) + + /** Computes the data needed to generate a factory. */ + // Detekt and ktfmt don't agree on whether or not the rectangle rule makes for readable code. + @Suppress("ComplexMethod", "LongMethod", "ReturnCount") + @OptIn(KspExperimental::class) + private fun computeFactoryData( + annotatedElement: KSAnnotated, + symbols: CircuitSymbols, + screenKSType: KSType, + instantiationType: InstantiationType, + codegenMode: CodegenMode, + logger: KSPLogger, + ): FactoryData? { + val className: String + val packageName: String + val factoryType: FactoryType + val constructorParams = mutableListOf() + val codeBlock: CodeBlock + + when (instantiationType) { + InstantiationType.FUNCTION -> { + val fd = annotatedElement as KSFunctionDeclaration + fd.checkVisibility(logger) { + return null + } + val name = annotatedElement.simpleName.getShortName() + className = name + packageName = fd.packageName.asString() + factoryType = + if (name.endsWith("Presenter")) { + FactoryType.PRESENTER + } else { + FactoryType.UI + } + val assistedParams = + fd.assistedParameters(symbols, logger, screenKSType, codegenMode, factoryType == FactoryType.PRESENTER) + codeBlock = + when (factoryType) { + FactoryType.PRESENTER -> + CodeBlock.of( + "%M·{·%M(%L)·}", + MemberName(CIRCUIT_RUNTIME_PRESENTER_PACKAGE, "presenterOf"), + MemberName(packageName, name), + assistedParams + ) + FactoryType.UI -> { + // State param is optional + val stateParam = + fd.parameters.singleOrNull { parameter -> + symbols.circuitUiState.isAssignableFrom(parameter.type.resolve()) + } + + // Modifier param is required + val modifierParam = + fd.parameters.singleOrNull { parameter -> + symbols.modifier.isAssignableFrom(parameter.type.resolve()) + } + ?: run { + logger.error("UI composable functions must have a Modifier parameter!", fd) + return null + } + + /* + Diagram of what goes into generating a function! + - State parameter is _optional_ and can be omitted if it's static state. + - When omitted, the argument becomes _ and the param is omitted entirely. + - is either the State or CircuitUiState if no state param is used. + - Modifier parameter is required. + - Assisted parameters can be 0 or more extra supported assisted parameters. + + Optional state param + Optional state arg │ + │ │ Required modifier param + │ Req modifier arg │ │ + ┌─── ui function │ │ │ │ Any assisted params + │ │ │ Composable │ │ │ + │ State type │ │ │ │ │ │ + │ │ │ │ │ │ │ │ + │ │ │ │ │ │ │ │ + └──────┴───── ──┴── ───┴──── ──┴───── ───────┴───── ────────┴────────── ────────┴──────── + ui { state, modifier -> Function(state = state, modifier = modifier, ) } + ──────────────────────────────────────────────────────────────────────────────────────────────────── + + Diagram generated with asciiflow. You can make new ones or edit with this link. + https://asciiflow.com/#/share/eJzVVM1KxDAQfpVhTgr1IizLlt2CCF4F9ZhLdGclkKbd%2FMCW0rfwcXwan8SsWW27sd0qXixzmDTffN83bSY1Kp4TpspJmaDkFWlMsWa4Y5guZvOEYeWzy%2FnCZ5Z21i8Ywg%2Be29KKQnEJxnJLUHLNc8bUKIjr55jo7eXVR1R62Dplo4Xc0dYJTWvIi7XYCNIDnnpVvqjF9%2BzF2sFlsBvCCdg49bTvsVcx4vtb2u7ySlXAjRHG%2BlY%2BOjBBdYSqza6LvCwMf5Q0VS%2FqDjq%2FzVYlDfV1zPMrpWHSf6w1JeDz4HfejGCH9ybrTQT%2BUan%2FEk4s7%2Fdj%2F%2BAPUQZ1uAOSdtwuMrg5TM9ZuB9WEWb1lSawPBqL7Bwahg027%2Byjz8s%3D) + */ + + @Suppress("IfThenToElvis") // The elvis is less readable here + val stateType = + if (stateParam == null) CIRCUIT_UI_STATE else stateParam.type.resolve().toTypeName() + val stateArg = if (stateParam == null) "_" else "state" + val stateParamBlock = + if (stateParam == null) CodeBlock.of("") + else CodeBlock.of("%L·=·state,·", stateParam.name!!.getShortName()) + val modifierParamBlock = + CodeBlock.of("%L·=·modifier", modifierParam.name!!.getShortName()) + val assistedParamsBlock = + if (assistedParams.isEmpty()) { + CodeBlock.of("") + } else { + CodeBlock.of(",·%L", assistedParams) + } + CodeBlock.of( + "%M<%T>·{·%L,·modifier·->·%M(%L%L%L)·}", + MemberName(CIRCUIT_RUNTIME_UI_PACKAGE, "ui"), + stateType, + stateArg, + MemberName(packageName, name), + stateParamBlock, + modifierParamBlock, + assistedParamsBlock + ) + } + } + } + InstantiationType.CLASS -> { + val cd = annotatedElement as KSClassDeclaration + cd.checkVisibility(logger) { + return null + } + val isAssisted = cd.isAnnotationPresent(AssistedFactory::class) + val creatorOrConstructor: KSFunctionDeclaration? + val targetClass: KSClassDeclaration + if (isAssisted) { + val creatorFunction = cd.getAllFunctions().filter { it.isAbstract }.single() + creatorOrConstructor = creatorFunction + targetClass = creatorFunction.returnType!!.resolve().declaration as KSClassDeclaration + targetClass.checkVisibility(logger) { + return null + } + } else { + creatorOrConstructor = cd.primaryConstructor + targetClass = cd + } + val useProvider = + !isAssisted && creatorOrConstructor?.isAnnotationPresent(codegenMode.injectAnnotation()) == true + className = targetClass.simpleName.getShortName() + packageName = targetClass.packageName.asString() + factoryType = + targetClass + .getAllSuperTypes() + .mapNotNull { + when (it.declaration.qualifiedName?.asString()) { + CIRCUIT_UI.canonicalName -> FactoryType.UI + CIRCUIT_PRESENTER.canonicalName -> FactoryType.PRESENTER + else -> null + } + } + .firstOrNull() + ?: run { + logger.error( + "Factory must be for a UI or Presenter class, but was " + + "${targetClass.qualifiedName?.asString()}. " + + "Supertypes: ${targetClass.getAllSuperTypes().toList()}", + targetClass + ) + return null + } + val assistedParams = + if (useProvider) { + // Nothing to do here, we'll just use the provider directly. + CodeBlock.of("") + } else { + creatorOrConstructor?.assistedParameters( + symbols, + logger, + screenKSType, + codegenMode, + allowNavigator = factoryType == FactoryType.PRESENTER + ) + } + codeBlock = + if (useProvider) { + // Inject a Provider that we'll call get() on. + constructorParams.add( + ParameterSpec.builder( + "provider", + ClassName("javax.inject", "Provider") + .parameterizedBy(targetClass.toClassName()) + ) + .build() + ) + CodeBlock.of("provider.get()") + } else if (isAssisted) { + // Inject the target class's assisted factory that we'll call its create() on. + constructorParams.add(ParameterSpec.builder("factory", cd.toClassName()).build()) + CodeBlock.of( + "factory.%L(%L)", + creatorOrConstructor!!.simpleName.getShortName(), + assistedParams + ) + } else { + if (codegenMode == CodegenMode.KOTLIN_INJECT) { + cd.primaryConstructor?.parameters?.forEach { parameter -> + constructorParams.add(ParameterSpec.builder(parameter.name!!.asString(), parameter.type.toTypeName()).build()) + } + } + // Simple constructor call, no injection. + CodeBlock.of("%T(%L)", targetClass.toClassName(), assistedParams) + } + } + } + return FactoryData(className, packageName, factoryType, constructorParams, codeBlock) + } + + private fun createComponentFile( + symbols: List, + componentPackage: String?, + parentClasses: List? + ) { + val bindMethods = symbols.map { bindingResult -> + if (bindingResult.factoryType == FactoryType.PRESENTER) { + PropertySpec.builder("bind", + CIRCUIT_PRESENTER_FACTORY, KModifier.PROTECTED) + .receiver(ClassName(bindingResult.factoryPackage, bindingResult.factoryName)) + .getter( + FunSpec.getterBuilder() + .addAnnotation(KOTLIN_INJECT_PROVIDES) + .addAnnotation(KOTLIN_INJECT_INTO_SET) + .addStatement("return this") + .build(), + ) + .build() + } else { + PropertySpec.builder("bind", + CIRCUIT_UI_FACTORY, KModifier.PROTECTED) + .receiver(ClassName(bindingResult.factoryPackage, bindingResult.factoryName)) + .getter( + FunSpec.getterBuilder() + .addAnnotation(KOTLIN_INJECT_PROVIDES) + .addAnnotation(KOTLIN_INJECT_INTO_SET) + .addStatement("return this") + .build(), + ) + .build() + } + } + + val injectableCircuitProperty = PropertySpec.builder("circuit", CIRCUIT, KModifier.ABSTRACT) + .build() + + val circuitProvider = FunSpec.builder("providesCircuit") + .addAnnotation(KOTLIN_INJECT_PROVIDES) + .returns(CIRCUIT) + .addParameter("uiFactories", Set::class.asClassName().parameterizedBy(CIRCUIT_UI_FACTORY)) + .addParameter("presenterFactories", Set::class.asClassName().parameterizedBy(CIRCUIT_PRESENTER_FACTORY)) + .addStatement("return %T.Builder().addUiFactories(uiFactories).addPresenterFactories(presenterFactories).build()", CIRCUIT) + .build() + + val parameters = parentClasses?.map { + ParameterSpec.builder(it.simpleName.lowercase(Locale.getDefault()), it) + .build() + } + + val properties = parentClasses?.map { + val name = it.simpleName.lowercase(Locale.getDefault()) + PropertySpec.builder(name, it) + .addAnnotation(KOTLIN_INJECT_COMPONENT) + .initializer(name) + .build() + } + + val classSpec = TypeSpec.classBuilder("CircuitComponent") + .addAnnotation(KOTLIN_INJECT_COMPONENT) + .addModifiers(KModifier.INTERNAL, KModifier.ABSTRACT) + .primaryConstructor( + FunSpec.constructorBuilder() + .apply { + parameters?.let { + addParameters(it) + } + } + .build(), + ) + .apply { + properties?.let { + addProperties(it) + } + } + .addProperty(injectableCircuitProperty) + .addFunction(circuitProvider) + .addProperties(bindMethods.toList()) + .build() + + try { + FileSpec.builder(componentPackage ?: "", "CircuitComponent") + .addType(classSpec) + .build() + .writeTo( + codeGenerator, + Dependencies(false, *symbols.flatMap { it.originatingFiles }.toList().toTypedArray()) + ) + } catch (e: Exception) { + logger.warn("This is bad but I'll figure it out later") + } + } +} + +private data class AssistedType( + val factoryName: String, + val type: TypeName, + val name: String, +) + +/** + * Returns a [CodeBlock] representation of all named assisted parameters on this + * [KSFunctionDeclaration] to be used in generated invocation code. + * + * Example: this function + * + * ```kotlin + * @Composable + * fun HomePresenter(screen: Screen, navigator: Navigator) + * ``` + * + * Yields this CodeBlock: `screen = screen, navigator = navigator` + */ +private fun KSFunctionDeclaration.assistedParameters( + symbols: CircuitSymbols, + logger: KSPLogger, + screenType: KSType, + codegenMode: CodegenMode, + allowNavigator: Boolean, +): CodeBlock { + return buildSet { + for (param in parameters) { + fun MutableSet.addOrError(element: E) { + val added = add(element) + if (!added) { + logger.error("Multiple parameters of type $element are not allowed.", param) + } + } + + val type = param.type.resolve() + when { + type.isInstanceOf(symbols.screen) -> { + if (screenType.isSameDeclarationAs(type)) { + addOrError(AssistedType("screen", type.toTypeName(), param.name!!.getShortName())) + } else { + logger.error("Screen type mismatch. Expected $screenType but found $type", param) + } + } + type.isInstanceOf(symbols.navigator) -> { + if (allowNavigator) { + addOrError(AssistedType("navigator", type.toTypeName(), param.name!!.getShortName())) + } else { + logger.error( + "Navigator type mismatch. Navigators are not injectable on this type.", + param + ) + } + } + type.isInstanceOf(symbols.circuitUiState) || + type.isInstanceOf(symbols.modifier) -> Unit + codegenMode == CodegenMode.KOTLIN_INJECT -> { + addOrError(AssistedType(param.name!!.asString(), param.type.resolve().toTypeName(), param.name!!.asString())) + } + } + } + } + .toList() + .map { CodeBlock.of("${it.name} = ${it.factoryName}") } + .joinToCode(",·") +} + +private fun KSType.isSameDeclarationAs(type: KSType): Boolean { + return this.declaration == type.declaration +} + +private fun KSType.isInstanceOf(type: KSType): Boolean { + return type.isAssignableFrom(this) +} + +private fun TypeSpec.Builder.buildUiFactory( + originatingSymbol: KSAnnotated, + screenBranch: CodeBlock, + instantiationCodeBlock: CodeBlock, +): TypeSpec { + return addSuperinterface(CIRCUIT_UI_FACTORY) + .addFunction( + FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE) + .addParameter("screen", SCREEN) + .addParameter("context", CIRCUIT_CONTEXT) + .returns(CIRCUIT_UI.parameterizedBy(STAR).copy(nullable = true)) + .beginControlFlow("return·when·(screen)") + .addStatement("%L·->·%L", screenBranch, instantiationCodeBlock) + .addStatement("else·->·null") + .endControlFlow() + .build() + ) + .addOriginatingKSFile(originatingSymbol.containingFile!!) + .build() +} + +private fun TypeSpec.Builder.buildPresenterFactory( + originatingSymbol: KSAnnotated, + screenBranch: CodeBlock, + instantiationCodeBlock: CodeBlock, +): TypeSpec { + // The TypeSpec below will generate something similar to the following. + // public class AboutPresenterFactory : Presenter.Factory { + // public override fun create( + // screen: Screen, + // navigator: Navigator, + // context: CircuitContext, + // ): Presenter<*>? = when (screen) { + // is AboutScreen -> AboutPresenter() + // is AboutScreen -> presenterOf { AboutPresenter() } + // else -> null + // } + // } + + return addSuperinterface(CIRCUIT_PRESENTER_FACTORY) + .addFunction( + FunSpec.builder("create") + .addModifiers(KModifier.OVERRIDE) + .addParameter("screen", SCREEN) + .addParameter("navigator", NAVIGATOR) + .addParameter("context", CIRCUIT_CONTEXT) + .returns(CIRCUIT_PRESENTER.parameterizedBy(STAR).copy(nullable = true)) + .beginControlFlow("return when (screen)") + .addStatement("%L·->·%L", screenBranch, instantiationCodeBlock) + .addStatement("else·->·null") + .endControlFlow() + .build() + ) + .addOriginatingKSFile(originatingSymbol.containingFile!!) + .build() +} + +private enum class FactoryType { + PRESENTER, + UI +} + +private enum class InstantiationType { + FUNCTION, + CLASS +} + +private enum class CodegenMode { + /** + * The Anvil Codegen mode + * + * This mode annotates generated factory types with [ContributesMultibinding], allowing for Anvil + * to automatically wire the generated class up to Dagger's multibinding system within a given + * scope (e.g. AppScope). + * + * ```kotlin + * @ContributesMultibinding(AppScope::class) + * public class FavoritesPresenterFactory @Inject constructor( + * private val factory: FavoritesPresenter.Factory, + * ) : Presenter.Factory { ... } + * ``` + */ + ANVIL { + override fun supportsPlatforms(platforms: List): Boolean { + // Anvil only supports JVM & Android + return platforms.all { it is JvmPlatformInfo } + } + + override fun annotateFactory(builder: TypeSpec.Builder, scope: TypeName) { + builder.addAnnotation( + AnnotationSpec.builder(ContributesMultibinding::class).addMember("%T::class", scope).build() + ) + } + }, + + /** + * The Hilt Codegen mode + * + * This mode provides an additional type, a Hilt module, which binds the generated factory, wiring + * up multibinding in the Hilt DI framework. The scope provided via [CircuitInject] is used to + * define the dagger component the factory provider is installed in. + * + * ```kotlin + * @Module + * @InstallIn(SingletonComponent::class) + * @OriginatingElement(topLevelClass = FavoritesPresenter::class) + * public abstract class FavoritesPresenterFactoryModule { + * @Binds + * @IntoSet + * public abstract + * fun bindFavoritesPresenterFactory(favoritesPresenterFactory: FavoritesPresenterFactory): + * Presenter.Factory + * } + * ``` + */ + HILT { + override fun supportsPlatforms(platforms: List): Boolean { + // Hilt only supports JVM & Android + return platforms.all { it is JvmPlatformInfo } + } + + override fun produceAdditionalTypeSpec( + factory: ClassName, + factoryType: FactoryType, + scope: TypeName, + topLevelClass: ClassName?, + ): TypeSpec { + val moduleAnnotations = + listOfNotNull( + AnnotationSpec.builder(DAGGER_MODULE).build(), + AnnotationSpec.builder(DAGGER_INSTALL_IN).addMember("%T::class", scope).build(), + topLevelClass?.let { + AnnotationSpec.builder(DAGGER_ORIGINATING_ELEMENT) + .addMember("%L = %T::class", "topLevelClass", topLevelClass) + .build() + } + ) + + val providerAnnotations = + listOf( + AnnotationSpec.builder(DAGGER_BINDS).build(), + AnnotationSpec.builder(DAGGER_INTO_SET).build(), + ) + + val providerReturns = + if (factoryType == FactoryType.UI) { + CIRCUIT_UI_FACTORY + } else { + CIRCUIT_PRESENTER_FACTORY + } + + val factoryName = factory.simpleName + + val providerSpec = + FunSpec.builder("bind${factoryName}") + .addModifiers(KModifier.ABSTRACT) + .addAnnotations(providerAnnotations) + .addParameter(name = factoryName.replaceFirstChar { it.lowercase() }, type = factory) + .returns(providerReturns) + .build() + + return TypeSpec.classBuilder(factory.peerClass(factoryName + MODULE)) + .addModifiers(KModifier.ABSTRACT) + .addAnnotations(moduleAnnotations) + .addFunction(providerSpec) + .build() + } + }, + KOTLIN_INJECT { + override fun supportsPlatforms(platforms: List): Boolean { + return true + } + + override fun injectAnnotation(): KClass = me.tatarka.inject.annotations.Inject::class + }; + + open fun annotateFactory(builder: TypeSpec.Builder, scope: TypeName) {} + + open fun produceAdditionalTypeSpec( + factory: ClassName, + factoryType: FactoryType, + scope: TypeName, + topLevelClass: ClassName?, + ): TypeSpec? { + return null + } + + abstract fun supportsPlatforms(platforms: List): Boolean + + open fun injectAnnotation(): KClass = Inject::class +} + +private inline fun KSDeclaration.checkVisibility(logger: KSPLogger, returnBody: () -> Unit) { + if (!getVisibility().isVisible) { + logger.error("CircuitInject is not applicable to private or local functions and classes.", this) + returnBody() + } +} + +private fun KSDeclaration.topLevelDeclaration(): KSDeclaration { + return parentDeclaration?.topLevelDeclaration() ?: this +} + +private val Visibility.isVisible: Boolean + get() = this != Visibility.PRIVATE && this != Visibility.LOCAL + +private data class KotlinInjectBindingResult( + val factoryName: String, + val factoryPackage: String, + val factoryType: FactoryType, + val originatingFiles: List, +) \ No newline at end of file diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt index 85299a0..6d74124 100644 --- a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt @@ -60,6 +60,7 @@ class UiInjectorProcessor( private val codeGenerator: CodeGenerator, ) : SymbolProcessor { override fun process(resolver: Resolver): List { + val circuitSymbols = CircuitSymbols.create(resolver) ?: return emptyList() val componentPackage = options[CIRCUIT_COMPONENT_PACKAGE] if (componentPackage == null) { logger.error("Should set component's package") @@ -75,7 +76,6 @@ class UiInjectorProcessor( .map { ksAnnotated -> val circuitInject = ksAnnotated.annotations.firstOrNull { it.shortName.asString() == CircuitInject::class.simpleName } val screen = circuitInject.getScreenQualifiedRoute() - val state = circuitInject.getStateQualifiedRoute() when (ksAnnotated) { is KSFunctionDeclaration -> NeededInjector( @@ -83,7 +83,7 @@ class UiInjectorProcessor( ksAnnotated.simpleName.getShortName(), ksAnnotated.packageName.asString(), screen, - state, + ksAnnotated.parameters.firstOrNull { circuitSymbols.circuitUiState.isAssignableFrom(it.type.resolve()) }?.type?.resolve(), null, ) is KSClassDeclaration -> NeededInjector( @@ -91,7 +91,7 @@ class UiInjectorProcessor( ksAnnotated.simpleName.getShortName(), ksAnnotated.packageName.asString(), screen, - state, + null, ksAnnotated.primaryConstructor?.parameters, ) else -> { @@ -122,13 +122,6 @@ class UiInjectorProcessor( return this.arguments.firstOrNull { it.name?.asString() == "screen" }?.value as? KSType } - private fun KSAnnotation?.getStateQualifiedRoute(): KSType? { - if (this == null) { - error("State is not provided") - } - return this.arguments.firstOrNull { it.name?.asString() == "state" }?.value as? KSType - } - /** * Creates all the [CIRCUIT_UI_FACTORY] and [CIRCUIT_PRESENTER_FACTORY]. Currently only supports * Presenters defined as a class @@ -336,3 +329,29 @@ class UiInjectorProcessor( val isPresenter: Boolean = name.endsWith("Presenter") } } + +private class CircuitSymbols private constructor(resolver: Resolver) { + val modifier = resolver.loadKSType(MODIFIER.canonicalName) + val circuitUiState = resolver.loadKSType(CIRCUIT_UI_STATE.canonicalName) + val screen = resolver.loadKSType(SCREEN.canonicalName) + val navigator = resolver.loadKSType(NAVIGATOR.canonicalName) + + companion object { + fun create(resolver: Resolver): CircuitSymbols? { + @Suppress("SwallowedException") + return try { + CircuitSymbols(resolver) + } catch (e: IllegalStateException) { + null + } + } + } +} + +private fun Resolver.loadKSType(name: String): KSType = + loadOptionalKSType(name) ?: error("Could not find $name in classpath") + +private fun Resolver.loadOptionalKSType(name: String?): KSType? { + if (name == null) return null + return getClassDeclarationByName(getKSNameFromString(name))?.asType(emptyList()) +} diff --git a/ui-injector-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/ui-injector-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider.backup similarity index 100% rename from ui-injector-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider rename to ui-injector-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider.backup From 0639e2202eb6d666a51327ac2153ef740fd68147 Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:38:03 -0600 Subject: [PATCH 2/7] Include annotation as Circuit doesn't support js yet --- .../artifacts/ui_injector_annotations_js.xml | 4 +- .../circuitkotlininject/sample/Main.kt | 7 +- ui-injector-annotations/build.gradle.kts | 6 +- .../annotations/CircuitInject.kt | 128 +++++++++++++++++- .../annotations/CircuitInjectOld.kt | 7 + .../processor/UiInjectorProcessor.kt | 6 +- 6 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInjectOld.kt diff --git a/.idea/artifacts/ui_injector_annotations_js.xml b/.idea/artifacts/ui_injector_annotations_js.xml index 9899437..d54842a 100644 --- a/.idea/artifacts/ui_injector_annotations_js.xml +++ b/.idea/artifacts/ui_injector_annotations_js.xml @@ -1,8 +1,6 @@ $PROJECT_DIR$/ui-injector-annotations/build/libs - - - + \ No newline at end of file diff --git a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt index 304090e..2b1dd73 100644 --- a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt +++ b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt @@ -6,15 +6,14 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.window.Window import androidx.compose.ui.window.application -import com.joshafeinberg.circuitkotlininject.annotations.CircuitInject -import com.slack.circuit.foundation.Circuit import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.CircuitContent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen -import com.slack.circuit.runtime.ui.Ui -import me.tatarka.inject.annotations.* +import me.tatarka.inject.annotations.Component +import me.tatarka.inject.annotations.Provides +import me.tatarka.inject.annotations.Scope fun main() = application { val parentComponent = remember { ParentComponent::class.create() } diff --git a/ui-injector-annotations/build.gradle.kts b/ui-injector-annotations/build.gradle.kts index 625aa43..0f69259 100644 --- a/ui-injector-annotations/build.gradle.kts +++ b/ui-injector-annotations/build.gradle.kts @@ -7,14 +7,14 @@ kotlin { jvm { jvmToolchain(17) } - /*js(IR) { + js(IR) { browser() - }*/ + } sourceSets { commonMain { dependencies { compileOnly(libs.circuit.foundation) - api(libs.circuit.codegen.annotations) + // api(libs.circuit.codegen.annotations) } } } diff --git a/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt b/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt index c183f2a..0f92798 100644 --- a/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt +++ b/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInject.kt @@ -1,8 +1,130 @@ -package com.joshafeinberg.circuitkotlininject.annotations +// Copyright (C) 2022 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.codegen.annotations +import androidx.compose.runtime.Composable +import com.slack.circuit.foundation.Circuit import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.screen.Screen +import com.slack.circuit.runtime.ui.Ui import kotlin.reflect.KClass -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) -annotation class CircuitInject(val screen: KClass) +/** + * This annotation is used to mark a UI or presenter class or function for code generation. When + * annotated, the type's corresponding factory will be generated and keyed with the defined [screen] + * . + * + * The generated factories are then contributed to Anvil via + * [com.squareup.anvil.annotations.ContributesMultibinding] and scoped with the provided [scope] + * key. + * + * ## Classes + * + * [Presenter] and [Ui] classes can be annotated and have their corresponding [Presenter.Factory] or + * [Ui.Factory] classes generated for them. + * + * **Presenter** + * + * ```kotlin + * @CircuitInject(HomeScreen::class, AppScope::class) + * class HomePresenter @Inject constructor(...) : Presenter { ... } + * + * // Generates + * @ContributesMultibinding(AppScope::class) + * class HomePresenterFactory @Inject constructor() : Presenter.Factory { ... } + * ``` + * + * **UI** + * + * ```kotlin + * @CircuitInject(HomeScreen::class, AppScope::class) + * class HomeUi @Inject constructor(...) : Ui { ... } + * + * // Generates + * @ContributesMultibinding(AppScope::class) + * class HomeUiFactory @Inject constructor() : Ui.Factory { ... } + * ``` + * + * ## Functions + * + * Simple functions can be annotated and have a corresponding [Presenter.Factory] generated. This is + * primarily useful for simple cases where a class is just technical tedium. + * + * **Requirements** + * - Presenter function names _must_ end in `Presenter`, otherwise they will be treated as UI + * functions. + * - Presenter functions _must_ return a [CircuitUiState] type. + * - UI functions can optionally accept a [CircuitUiState] type as a parameter, but it is not + * required. + * - UI functions _must_ return [Unit]. + * - Both presenter and UI functions _must_ be [Composable]. + * + * **Presenter** + * + * ```kotlin + * @CircuitInject(HomeScreen::class, AppScope::class) + * @Composable + * fun HomePresenter(): HomeState { ... } + * + * // Generates + * @ContributesMultibinding(AppScope::class) + * class HomePresenterFactory @Inject constructor() : Presenter.Factory { ... } + * ``` + * + * **UI** + * + * ```kotlin + * @CircuitInject(HomeScreen::class, AppScope::class) + * @Composable + * fun Home(state: HomeState) { ... } + * + * // Generates + * @ContributesMultibinding(AppScope::class) + * class HomeUiFactory @Inject constructor() : Ui.Factory { ... } + * ``` + * + * ## Assisted injection + * + * Any type that is offered in [Presenter.Factory] and [Ui.Factory] can be offered as an assisted + * injection to types using Dagger [dagger.assisted.AssistedInject]. For these cases, the + * [dagger.assisted.AssistedFactory] -annotated interface should be annotated with [CircuitInject] + * instead of the enclosing class. + * + * Types available for assisted injection are: + * - [Screen] – the screen key used to create the [Presenter] or [Ui]. + * - [Navigator] – (presenters only) + * - [Circuit] + * + * Each should only be defined at-most once. + * + * **Examples** + * + * ```kotlin + * // Function example + * @CircuitInject(HomeScreen::class, AppScope::class) + * @Composable + * fun HomePresenter(screen: Screen, navigator: Navigator): HomeState { ... } + * + * // Class example + * class HomePresenter @AssistedInject constructor( + * @Assisted screen: Screen, + * @Assisted navigator: Navigator, + * ... + * ) : Presenter { + * // ... + * + * @CircuitInject(HomeScreen::class, AppScope::class) + * @AssistedFactory + * fun interface Factory { + * fun create(screen: Screen, navigator: Navigator): HomePresenter + * } + * } + * ``` + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +public annotation class CircuitInject( + val screen: KClass, + val scope: KClass<*>, +) \ No newline at end of file diff --git a/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInjectOld.kt b/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInjectOld.kt new file mode 100644 index 0000000..b7b8bd8 --- /dev/null +++ b/ui-injector-annotations/src/commonMain/kotlin/com/joshafeinberg/circuitkotlininject/annotations/CircuitInjectOld.kt @@ -0,0 +1,7 @@ +package com.joshafeinberg.circuitkotlininject.annotations + +import com.slack.circuit.runtime.screen.Screen +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +annotation class CircuitInjectOld(val screen: KClass) diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt index 6d74124..b838ade 100644 --- a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt @@ -15,7 +15,7 @@ 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.validate -import com.joshafeinberg.circuitkotlininject.annotations.CircuitInject +import com.joshafeinberg.circuitkotlininject.annotations.CircuitInjectOld import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec @@ -71,10 +71,10 @@ class UiInjectorProcessor( } val symbols = resolver - .getSymbolsWithAnnotation(CircuitInject::class.qualifiedName!!) + .getSymbolsWithAnnotation(CircuitInjectOld::class.qualifiedName!!) .filter(KSNode::validate) .map { ksAnnotated -> - val circuitInject = ksAnnotated.annotations.firstOrNull { it.shortName.asString() == CircuitInject::class.simpleName } + val circuitInject = ksAnnotated.annotations.firstOrNull { it.shortName.asString() == CircuitInjectOld::class.simpleName } val screen = circuitInject.getScreenQualifiedRoute() when (ksAnnotated) { From 393ad6db15793a68980d8465ad3041b475c42712 Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sat, 2 Dec 2023 22:50:44 -0600 Subject: [PATCH 3/7] Fix maven publish --- .idea/artifacts/ui_injector_annotations_js.xml | 4 +++- build.gradle.kts | 2 ++ ui-injector-processor/build.gradle.kts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.idea/artifacts/ui_injector_annotations_js.xml b/.idea/artifacts/ui_injector_annotations_js.xml index d54842a..9899437 100644 --- a/.idea/artifacts/ui_injector_annotations_js.xml +++ b/.idea/artifacts/ui_injector_annotations_js.xml @@ -1,6 +1,8 @@ $PROJECT_DIR$/ui-injector-annotations/build/libs - + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 90cb64d..c98b4f1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,4 +11,6 @@ allprojects { plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.ktlint) apply true + alias(libs.plugins.ksp) apply false + alias(libs.plugins.mavenpublish) apply false } diff --git a/ui-injector-processor/build.gradle.kts b/ui-injector-processor/build.gradle.kts index 30e8600..554c176 100644 --- a/ui-injector-processor/build.gradle.kts +++ b/ui-injector-processor/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("jvm") alias(libs.plugins.ksp) - // alias(libs.plugins.mavenpublish) + alias(libs.plugins.mavenpublish) } dependencies { From 0db8a424e944b0caf1046145aca805fec9f92aba Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sat, 2 Dec 2023 23:07:48 -0600 Subject: [PATCH 4/7] Fix some type issues --- .../joshafeinberg/circuitkotlininject/sample/Main.kt | 5 ++++- .../processor/CircuitSymbolProcessorProvider.kt | 12 ++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt index 2b1dd73..d3c83eb 100644 --- a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt +++ b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/Main.kt @@ -58,7 +58,10 @@ data object MyScreen : Screen { } @com.slack.circuit.codegen.annotations.CircuitInject(MyScreen::class, AppScope::class) -class MyScreenPresenter(private val injectedString: String) : Presenter { +class MyScreenPresenter( + private val injectedString: String, + private val screen: MyScreen, +) : Presenter { @Composable override fun present(): MyScreen.MyScreenState { return MyScreen.MyScreenState(injectedString) diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt index 03367e2..1d3aebb 100644 --- a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt @@ -464,7 +464,15 @@ private class CircuitSymbolProcessor( } else { if (codegenMode == CodegenMode.KOTLIN_INJECT) { cd.primaryConstructor?.parameters?.forEach { parameter -> - constructorParams.add(ParameterSpec.builder(parameter.name!!.asString(), parameter.type.toTypeName()).build()) + val resolvedType = parameter.type.resolve() + if (!resolvedType.isInstanceOf(symbols.screen) && !resolvedType.isInstanceOf(symbols.navigator)) { + constructorParams.add( + ParameterSpec.builder( + parameter.name!!.asString(), + resolvedType.toTypeName() + ).build() + ) + } } } // Simple constructor call, no injection. @@ -631,7 +639,7 @@ private fun KSFunctionDeclaration.assistedParameters( } } .toList() - .map { CodeBlock.of("${it.name} = ${it.factoryName}") } + .map { CodeBlock.of("${it.name} = ${it.factoryName} as ${it.type}") } .joinToCode(",·") } From f0bb129a5234062ceca49c611d055f17a0624da2 Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sat, 2 Dec 2023 23:18:59 -0600 Subject: [PATCH 5/7] Remove typecast and fix data object issues in when clause --- .../processor/CircuitSymbolProcessorProvider.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt index 1d3aebb..044a06e 100644 --- a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt @@ -175,7 +175,7 @@ private class CircuitSymbolProcessor( } val screenKSType = circuitInjectAnnotation.arguments[0].value as KSType val screenIsObject = - screenKSType.declaration.let { it is KSClassDeclaration && it.classKind == ClassKind.OBJECT } + screenKSType.declaration.let { it is KSClassDeclaration && it.classKind == ClassKind.OBJECT && !it.modifiers.contains(Modifier.DATA) } val screenType = screenKSType.toTypeName() val scope = (circuitInjectAnnotation.arguments[1].value as KSType).toTypeName() @@ -639,7 +639,7 @@ private fun KSFunctionDeclaration.assistedParameters( } } .toList() - .map { CodeBlock.of("${it.name} = ${it.factoryName} as ${it.type}") } + .map { CodeBlock.of("${it.name} = ${it.factoryName}") } .joinToCode(",·") } From 100fd9de5bc788402adfa71ac63cea6bb7bf3439 Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sun, 3 Dec 2023 12:26:59 -0600 Subject: [PATCH 6/7] Move component generator to its own processor --- .../sample/other/OtherScreen.kt | 2 +- .../CircuitSymbolProcessorProvider.kt | 156 ++----------- .../KotlinInjectComponentProcessorProvider.kt | 210 ++++++++++++++++++ 3 files changed, 229 insertions(+), 139 deletions(-) create mode 100644 ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/KotlinInjectComponentProcessorProvider.kt diff --git a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt index ea7a201..e8211fa 100644 --- a/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt +++ b/sample/src/main/java/com/joshafeinberg/circuitkotlininject/sample/other/OtherScreen.kt @@ -21,7 +21,7 @@ data object OtherScreen : Screen { } @com.slack.circuit.codegen.annotations.CircuitInject(OtherScreen::class, AppScope::class) -class OtherScreenPresenter(private val injectedString: String) : Presenter { +class OtherScreenPresenter : Presenter { @Composable override fun present(): OtherScreen.OtherScreenState { return OtherScreen.OtherScreenState diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt index 044a06e..959a7a5 100644 --- a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/CircuitSymbolProcessorProvider.kt @@ -32,11 +32,9 @@ private const val DAGGER_MULTIBINDINGS_PACKAGE = "$DAGGER_PACKAGE.multibindings" private const val CIRCUIT_RUNTIME_UI_PACKAGE = "$CIRCUIT_RUNTIME_BASE_PACKAGE.ui" private const val CIRCUIT_RUNTIME_SCREEN_PACKAGE = "$CIRCUIT_RUNTIME_BASE_PACKAGE.screen" private const val CIRCUIT_RUNTIME_PRESENTER_PACKAGE = "$CIRCUIT_RUNTIME_BASE_PACKAGE.presenter" -private const val KOTLIN_INJECT_BASE_PACKAGE = "me.tatarka.inject.annotations" private val MODIFIER = ClassName("androidx.compose.ui", "Modifier") private val CIRCUIT_INJECT_ANNOTATION = ClassName("com.slack.circuit.codegen.annotations", "CircuitInject") -private val CIRCUIT = ClassName("com.slack.circuit.foundation", "Circuit") private val CIRCUIT_PRESENTER = ClassName(CIRCUIT_RUNTIME_PRESENTER_PACKAGE, "Presenter") private val CIRCUIT_PRESENTER_FACTORY = CIRCUIT_PRESENTER.nestedClass("Factory") private val CIRCUIT_UI = ClassName(CIRCUIT_RUNTIME_UI_PACKAGE, "Ui") @@ -51,14 +49,9 @@ private val DAGGER_INSTALL_IN = ClassName(DAGGER_HILT_PACKAGE, "InstallIn") private val DAGGER_ORIGINATING_ELEMENT = ClassName(DAGGER_HILT_CODEGEN_PACKAGE, "OriginatingElement") private val DAGGER_INTO_SET = ClassName(DAGGER_MULTIBINDINGS_PACKAGE, "IntoSet") -private val KOTLIN_INJECT_COMPONENT = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "Component") -private val KOTLIN_INJECT_INTO_SET = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "IntoSet") -private val KOTLIN_INJECT_PROVIDES = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "Provides") private const val MODULE = "Module" private const val FACTORY = "Factory" private const val CIRCUIT_CODEGEN_MODE = "circuit.codegen.mode" -private const val CIRCUIT_COMPONENT_PACKAGE = "circuit.codegen.package" -private const val CIRCUIT_PARENT_COMPONENT = "circuit.codegen.parent.component" @AutoService(SymbolProcessorProvider::class) public class CircuitSymbolProcessorProvider : SymbolProcessorProvider { @@ -104,9 +97,6 @@ private class CircuitSymbolProcessor( private val options: Map, private val platforms: List, ) : SymbolProcessor { - - // todo - this is bad - private val bindMethodsToCreate = mutableListOf() override fun process(resolver: Resolver): List { val symbols = CircuitSymbols.create(resolver) ?: return emptyList() @@ -128,22 +118,14 @@ private class CircuitSymbolProcessor( return emptyList() } - val componentPackage = options[CIRCUIT_COMPONENT_PACKAGE] - if (componentPackage == null) { - logger.error("Should set component's package") - } - - val parentComponent = options[CIRCUIT_PARENT_COMPONENT]?.let { - ClassName.bestGuess(it.trim()) - } - - resolver.getSymbolsWithAnnotation(CIRCUIT_INJECT_ANNOTATION.canonicalName).forEach { - annotatedElement -> + resolver.getSymbolsWithAnnotation(CIRCUIT_INJECT_ANNOTATION.canonicalName).forEach { annotatedElement -> when (annotatedElement) { is KSClassDeclaration -> generateFactory(annotatedElement, InstantiationType.CLASS, symbols, codegenMode) + is KSFunctionDeclaration -> generateFactory(annotatedElement, InstantiationType.FUNCTION, symbols, codegenMode) + else -> logger.error( "CircuitInject is only applicable on classes and functions.", @@ -152,13 +134,6 @@ private class CircuitSymbolProcessor( } } - createComponentFile( - bindMethodsToCreate, - componentPackage, - parentComponent?.let { listOf(it) } - ) - - return emptyList() } @@ -175,7 +150,11 @@ private class CircuitSymbolProcessor( } val screenKSType = circuitInjectAnnotation.arguments[0].value as KSType val screenIsObject = - screenKSType.declaration.let { it is KSClassDeclaration && it.classKind == ClassKind.OBJECT && !it.modifiers.contains(Modifier.DATA) } + screenKSType.declaration.let { + it is KSClassDeclaration && it.classKind == ClassKind.OBJECT && !it.modifiers.contains( + Modifier.DATA + ) + } val screenType = screenKSType.toTypeName() val scope = (circuitInjectAnnotation.arguments[1].value as KSType).toTypeName() @@ -220,6 +199,7 @@ private class CircuitSymbolProcessor( when (factoryData.factoryType) { FactoryType.PRESENTER -> builder.buildPresenterFactory(annotatedElement, screenBranch, factoryData.codeBlock) + FactoryType.UI -> builder.buildUiFactory(annotatedElement, screenBranch, factoryData.codeBlock) } @@ -230,15 +210,6 @@ private class CircuitSymbolProcessor( val topLevelClass = (topLevelDeclaration as? KSClassDeclaration)?.toClassName() val originatingFile = listOfNotNull(annotatedElement.containingFile) - - bindMethodsToCreate.add( - KotlinInjectBindingResult( - className + FACTORY, - factoryData.packageName, - factoryData.factoryType, - originatingFile - ) - ) FileSpec.get(factoryData.packageName, typeSpec) .writeTo( @@ -305,7 +276,13 @@ private class CircuitSymbolProcessor( FactoryType.UI } val assistedParams = - fd.assistedParameters(symbols, logger, screenKSType, codegenMode, factoryType == FactoryType.PRESENTER) + fd.assistedParameters( + symbols, + logger, + screenKSType, + codegenMode, + factoryType == FactoryType.PRESENTER + ) codeBlock = when (factoryType) { FactoryType.PRESENTER -> @@ -315,6 +292,7 @@ private class CircuitSymbolProcessor( MemberName(packageName, name), assistedParams ) + FactoryType.UI -> { // State param is optional val stateParam = @@ -385,6 +363,7 @@ private class CircuitSymbolProcessor( } } } + InstantiationType.CLASS -> { val cd = annotatedElement as KSClassDeclaration cd.checkVisibility(logger) { @@ -482,98 +461,6 @@ private class CircuitSymbolProcessor( } return FactoryData(className, packageName, factoryType, constructorParams, codeBlock) } - - private fun createComponentFile( - symbols: List, - componentPackage: String?, - parentClasses: List? - ) { - val bindMethods = symbols.map { bindingResult -> - if (bindingResult.factoryType == FactoryType.PRESENTER) { - PropertySpec.builder("bind", - CIRCUIT_PRESENTER_FACTORY, KModifier.PROTECTED) - .receiver(ClassName(bindingResult.factoryPackage, bindingResult.factoryName)) - .getter( - FunSpec.getterBuilder() - .addAnnotation(KOTLIN_INJECT_PROVIDES) - .addAnnotation(KOTLIN_INJECT_INTO_SET) - .addStatement("return this") - .build(), - ) - .build() - } else { - PropertySpec.builder("bind", - CIRCUIT_UI_FACTORY, KModifier.PROTECTED) - .receiver(ClassName(bindingResult.factoryPackage, bindingResult.factoryName)) - .getter( - FunSpec.getterBuilder() - .addAnnotation(KOTLIN_INJECT_PROVIDES) - .addAnnotation(KOTLIN_INJECT_INTO_SET) - .addStatement("return this") - .build(), - ) - .build() - } - } - - val injectableCircuitProperty = PropertySpec.builder("circuit", CIRCUIT, KModifier.ABSTRACT) - .build() - - val circuitProvider = FunSpec.builder("providesCircuit") - .addAnnotation(KOTLIN_INJECT_PROVIDES) - .returns(CIRCUIT) - .addParameter("uiFactories", Set::class.asClassName().parameterizedBy(CIRCUIT_UI_FACTORY)) - .addParameter("presenterFactories", Set::class.asClassName().parameterizedBy(CIRCUIT_PRESENTER_FACTORY)) - .addStatement("return %T.Builder().addUiFactories(uiFactories).addPresenterFactories(presenterFactories).build()", CIRCUIT) - .build() - - val parameters = parentClasses?.map { - ParameterSpec.builder(it.simpleName.lowercase(Locale.getDefault()), it) - .build() - } - - val properties = parentClasses?.map { - val name = it.simpleName.lowercase(Locale.getDefault()) - PropertySpec.builder(name, it) - .addAnnotation(KOTLIN_INJECT_COMPONENT) - .initializer(name) - .build() - } - - val classSpec = TypeSpec.classBuilder("CircuitComponent") - .addAnnotation(KOTLIN_INJECT_COMPONENT) - .addModifiers(KModifier.INTERNAL, KModifier.ABSTRACT) - .primaryConstructor( - FunSpec.constructorBuilder() - .apply { - parameters?.let { - addParameters(it) - } - } - .build(), - ) - .apply { - properties?.let { - addProperties(it) - } - } - .addProperty(injectableCircuitProperty) - .addFunction(circuitProvider) - .addProperties(bindMethods.toList()) - .build() - - try { - FileSpec.builder(componentPackage ?: "", "CircuitComponent") - .addType(classSpec) - .build() - .writeTo( - codeGenerator, - Dependencies(false, *symbols.flatMap { it.originatingFiles }.toList().toTypedArray()) - ) - } catch (e: Exception) { - logger.warn("This is bad but I'll figure it out later") - } - } } private data class AssistedType( @@ -857,10 +744,3 @@ private fun KSDeclaration.topLevelDeclaration(): KSDeclaration { private val Visibility.isVisible: Boolean get() = this != Visibility.PRIVATE && this != Visibility.LOCAL - -private data class KotlinInjectBindingResult( - val factoryName: String, - val factoryPackage: String, - val factoryType: FactoryType, - val originatingFiles: List, -) \ No newline at end of file diff --git a/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/KotlinInjectComponentProcessorProvider.kt b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/KotlinInjectComponentProcessorProvider.kt new file mode 100644 index 0000000..74a6853 --- /dev/null +++ b/ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/KotlinInjectComponentProcessorProvider.kt @@ -0,0 +1,210 @@ +package com.joshafeinberg.circuitkotlininject.processor + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.closestClassDeclaration +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +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.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSFile +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo +import java.util.* + +private const val CIRCUIT_RUNTIME_BASE_PACKAGE = "com.slack.circuit.runtime" +private const val KOTLIN_INJECT_BASE_PACKAGE = "me.tatarka.inject.annotations" +private const val CIRCUIT_RUNTIME_UI_PACKAGE = "${CIRCUIT_RUNTIME_BASE_PACKAGE}.ui" +private const val CIRCUIT_RUNTIME_PRESENTER_PACKAGE = "${CIRCUIT_RUNTIME_BASE_PACKAGE}.presenter" +private val CIRCUIT = ClassName("com.slack.circuit.foundation", "Circuit") +private val CIRCUIT_UI = ClassName(CIRCUIT_RUNTIME_UI_PACKAGE, "Ui") +private val CIRCUIT_UI_FACTORY = CIRCUIT_UI.nestedClass("Factory") +private val CIRCUIT_PRESENTER = ClassName(CIRCUIT_RUNTIME_PRESENTER_PACKAGE, "Presenter") +private val CIRCUIT_PRESENTER_FACTORY = CIRCUIT_PRESENTER.nestedClass("Factory") +private val KOTLIN_INJECT_COMPONENT = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "Component") +private val KOTLIN_INJECT_INTO_SET = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "IntoSet") +private val KOTLIN_INJECT_PROVIDES = ClassName(KOTLIN_INJECT_BASE_PACKAGE, "Provides") +private const val CIRCUIT_CODEGEN_MODE = "circuit.codegen.mode" +private const val CIRCUIT_COMPONENT_PACKAGE = "circuit.codegen.package" +private const val CIRCUIT_PARENT_COMPONENT = "circuit.codegen.parent.component" + +@AutoService(SymbolProcessorProvider::class) +public class KotlinInjectComponentProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return KotlinInjectComponentProcessor( + environment.logger, + environment.codeGenerator, + environment.options, + ) + } +} + +private class KotlinInjectComponentProcessor( + private val logger: KSPLogger, + private val codeGenerator: CodeGenerator, + private val options: Map, +) : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (options[CIRCUIT_CODEGEN_MODE] != "kotlin_inject") { + return emptyList() + } + + val componentPackage = options[CIRCUIT_COMPONENT_PACKAGE] + if (componentPackage == null) { + logger.error("Should set component's package") + } + + val parentComponent = options[CIRCUIT_PARENT_COMPONENT]?.let { + ClassName.bestGuess(it.trim()) + } + + val bindingResults = mutableListOf() + + resolver.getNewFiles().forEach { file -> + file.declarations.forEach { declarations -> + declarations.closestClassDeclaration()?.let { classDeclaration -> + val isCircuitFactory = classDeclaration.superTypes.any { + val typeName = it.resolve().toTypeName() + typeName == CIRCUIT_UI_FACTORY || typeName == CIRCUIT_PRESENTER_FACTORY + } + if (isCircuitFactory) { + bindingResults.add( + KotlinInjectBindingResult( + classDeclaration.simpleName.asString(), + classDeclaration.packageName.asString(), + classDeclaration.superTypes.any { it.resolve().toTypeName() == CIRCUIT_PRESENTER_FACTORY }, + originatingFiles = listOf(file) + ) + ) + } + } + } + } + + createComponentFile( + bindingResults, + componentPackage, + parentComponent?.let { listOf(it) }, + ) + + return emptyList() + } + + private fun createComponentFile( + symbols: List, + componentPackage: String?, + parentClasses: List? + ) { + if (symbols.isEmpty()) { + return + } + + val bindMethods = symbols.map { bindingResult -> + if (bindingResult.isPresenter) { + PropertySpec.builder( + "bind", + CIRCUIT_PRESENTER_FACTORY, KModifier.PROTECTED + ) + .receiver(ClassName(bindingResult.factoryPackage, bindingResult.factoryName)) + .getter( + FunSpec.getterBuilder() + .addAnnotation(KOTLIN_INJECT_PROVIDES) + .addAnnotation(KOTLIN_INJECT_INTO_SET) + .addStatement("return this") + .build(), + ) + .build() + } else { + PropertySpec.builder( + "bind", + CIRCUIT_UI_FACTORY, KModifier.PROTECTED + ) + .receiver(ClassName(bindingResult.factoryPackage, bindingResult.factoryName)) + .getter( + FunSpec.getterBuilder() + .addAnnotation(KOTLIN_INJECT_PROVIDES) + .addAnnotation(KOTLIN_INJECT_INTO_SET) + .addStatement("return this") + .build(), + ) + .build() + } + } + + val injectableCircuitProperty = PropertySpec.builder("circuit", CIRCUIT, KModifier.ABSTRACT) + .build() + + val circuitProvider = FunSpec.builder("providesCircuit") + .addAnnotation(KOTLIN_INJECT_PROVIDES) + .returns(CIRCUIT) + .addParameter("uiFactories", Set::class.asClassName().parameterizedBy(CIRCUIT_UI_FACTORY)) + .addParameter("presenterFactories", Set::class.asClassName().parameterizedBy(CIRCUIT_PRESENTER_FACTORY)) + .addStatement("return %T.Builder().addUiFactories(uiFactories).addPresenterFactories(presenterFactories).build()", + CIRCUIT + ) + .build() + + val parameters = parentClasses?.map { + ParameterSpec.builder(it.simpleName.lowercase(Locale.getDefault()), it) + .build() + } + + val properties = parentClasses?.map { + val name = it.simpleName.lowercase(Locale.getDefault()) + PropertySpec.builder(name, it) + .addAnnotation(KOTLIN_INJECT_COMPONENT) + .initializer(name) + .build() + } + + val classSpec = TypeSpec.classBuilder("CircuitComponent") + .addAnnotation(KOTLIN_INJECT_COMPONENT) + .addModifiers(KModifier.INTERNAL, KModifier.ABSTRACT) + .primaryConstructor( + FunSpec.constructorBuilder() + .apply { + parameters?.let { + addParameters(it) + } + } + .build(), + ) + .apply { + properties?.let { + addProperties(it) + } + } + .addProperty(injectableCircuitProperty) + .addFunction(circuitProvider) + .addProperties(bindMethods.toList()) + .build() + + FileSpec.builder(componentPackage ?: "", "CircuitComponent") + .addType(classSpec) + .build() + .writeTo( + codeGenerator, + Dependencies(false, *symbols.flatMap { it.originatingFiles }.toList().toTypedArray()) + ) + } + +} + +private data class KotlinInjectBindingResult( + val factoryName: String, + val factoryPackage: String, + val isPresenter: Boolean, + val originatingFiles: List, +) From 6a3374e449118199308cfbb7109614775dd6558e Mon Sep 17 00:00:00 2001 From: Josh Feinberg <15068619+joshafeinberg@users.noreply.github.com> Date: Sun, 3 Dec 2023 14:18:07 -0600 Subject: [PATCH 7/7] Delete old code --- .idea/gradle.xml | 1 + .../processor/UiInjectorProcessor.kt | 357 ------------------ .../processor/UiInjectorProcessorProvider.kt | 16 - 3 files changed, 1 insertion(+), 373 deletions(-) delete mode 100644 ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessor.kt delete mode 100644 ui-injector-processor/src/main/kotlin/com/joshafeinberg/circuitkotlininject/processor/UiInjectorProcessorProvider.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml index d2d826a..90e979c 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -1,5 +1,6 @@ +