diff --git a/CHANGELOG.md b/CHANGELOG.md index 9819f1352e..0c3ee91816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ Unreleased - See the [circuit-retained README](https://github.com/slackhq/circuit/tree/main/circuit-retained) for more details. - **NOTE:** This is phase one of a multi-phase migration to the first-party API. +### Changed + +- **SubCircuit:** `@SubCircuitInject` is now handled by the main `circuit-codegen` processor. + `circuitx-subcircuit-codegen` is now a relocation pointer to `circuit-codegen`, but prefer + depending on `circuit-codegen` directly. + ### Fixed - **SubCircuit:** Fix `subcircuit-codegen` Metro mode (`subcircuit.codegen.mode=metro`). It previously diff --git a/build-logic/src/main/kotlin/circuit.publish.gradle.kts b/build-logic/src/main/kotlin/circuit.publish.gradle.kts index 51f75d5541..bbe80b5fd6 100644 --- a/build-logic/src/main/kotlin/circuit.publish.gradle.kts +++ b/build-logic/src/main/kotlin/circuit.publish.gradle.kts @@ -49,7 +49,7 @@ configure { // Dependency guard configuration configure { - if (project.name == "circuit-codegen" || project.name == "subcircuit-codegen") { + if (project.name == "circuit-codegen") { configuration("runtimeClasspath") { baselineMap = { // Remove the version diff --git a/circuit-codegen/build.gradle.kts b/circuit-codegen/build.gradle.kts index 9144e873a7..7d7e110647 100644 --- a/circuit-codegen/build.gradle.kts +++ b/circuit-codegen/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { testImplementation(libs.kct) testImplementation(libs.kct.ksp) testImplementation(libs.kotlin.compilerEmbeddable) + testImplementation(libs.kotlin.test) testImplementation(libs.ksp) testImplementation(libs.ksp.api) testImplementation(libs.metro) diff --git a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitNames.kt b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitNames.kt index 8f9f6efe6d..428133087d 100644 --- a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitNames.kt +++ b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitNames.kt @@ -31,6 +31,21 @@ internal object CircuitNames { val SCREEN = ClassName(CIRCUIT_RUNTIME_SCREEN_PACKAGE, "Screen") val NAVIGATOR = ClassName(CIRCUIT_RUNTIME_BASE_PACKAGE, "Navigator") val CIRCUIT_CONTEXT = ClassName(CIRCUIT_RUNTIME_BASE_PACKAGE, "CircuitContext") + + // SubCircuit target. Referenced by string only so circuit-codegen keeps no compile dependency on + // the circuitx-subcircuit runtime; these types are resolved from the consumer's classpath when a + // `@SubCircuitInject` is present. + const val SUB_CIRCUIT_PACKAGE = "com.slack.circuit.subcircuit" + val SUB_CIRCUIT_INJECT_ANNOTATION = ClassName(SUB_CIRCUIT_PACKAGE, "SubCircuitInject") + val SUB_PRESENTER = ClassName(SUB_CIRCUIT_PACKAGE, "SubPresenter") + val SUB_PRESENTER_FACTORY = ClassName(SUB_CIRCUIT_PACKAGE, "SubPresenterFactory") + val SUB_UI = ClassName(SUB_CIRCUIT_PACKAGE, "SubUi") + val SUB_UI_FACTORY = ClassName(SUB_CIRCUIT_PACKAGE, "SubUiFactory") + val SUB_CIRCUIT_UI_STATE = ClassName(SUB_CIRCUIT_PACKAGE, "SubCircuitUiState") + val SUB_SCREEN = ClassName(SUB_CIRCUIT_PACKAGE, "SubScreen") + const val SUB_PRESENTER_FACTORY_SUFFIX = "_SubPresenterFactory" + const val SUB_UI_FACTORY_SUFFIX = "_SubUiFactory" + val DAGGER_MODULE = ClassName(DAGGER_PACKAGE, "Module") val DAGGER_BINDS = ClassName(DAGGER_PACKAGE, "Binds") val DAGGER_INSTALL_IN = ClassName(DAGGER_HILT_PACKAGE, "InstallIn") diff --git a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitOptions.kt b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitOptions.kt index 7032e300a6..3f5844be1c 100644 --- a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitOptions.kt +++ b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitOptions.kt @@ -14,12 +14,17 @@ internal data class CircuitOptions( const val LENIENT = "circuit.codegen.lenient" const val USE_JAVAX_ONLY = "circuit.codegen.useJavaxOnly" + // `subcircuit.codegen.*` fallbacks for the SubCircuit target; the keys above take precedence. + private const val LEGACY_MODE = "subcircuit.codegen.mode" + private const val LEGACY_LENIENT = "subcircuit.codegen.lenient" + private const val LEGACY_USE_JAVAX_ONLY = "subcircuit.codegen.useJavaxOnly" + internal val UNKNOWN = CircuitOptions(CodegenMode.UNKNOWN, lenient = false, useJavaxOnly = false) fun load(options: Map, logger: KSPLogger): CircuitOptions { val mode = - options[MODE].let { mode -> + (options[MODE] ?: options[LEGACY_MODE]).let { mode -> if (mode == null) { CodegenMode.ANVIL } else { @@ -36,8 +41,9 @@ internal data class CircuitOptions( return UNKNOWN } - val lenient = options[LENIENT]?.toBoolean() ?: false - val useJavaxOnly = options[USE_JAVAX_ONLY]?.toBoolean() ?: false + val lenient = (options[LENIENT] ?: options[LEGACY_LENIENT])?.toBoolean() ?: false + val useJavaxOnly = + (options[USE_JAVAX_ONLY] ?: options[LEGACY_USE_JAVAX_ONLY])?.toBoolean() ?: false return CircuitOptions(mode = mode, lenient = lenient, useJavaxOnly = useJavaxOnly) } } diff --git a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbolProcessorProvider.kt b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbolProcessorProvider.kt index 5adcb15baf..e603c36920 100644 --- a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbolProcessorProvider.kt +++ b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbolProcessorProvider.kt @@ -34,9 +34,7 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.LambdaTypeName import com.squareup.kotlinpoet.MemberName import com.squareup.kotlinpoet.ParameterSpec -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.STAR import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.joinToCode @@ -79,23 +77,27 @@ private class CircuitSymbolProcessor( return emptyList() } - resolver - .getSymbolsWithAnnotation(CircuitNames.CIRCUIT_INJECT_ANNOTATION.canonicalName) - .forEach { annotatedElement -> + // The same processor handles both the Circuit (@CircuitInject) and SubCircuit + // (@SubCircuitInject) targets. Only the annotations present on the consumer's classpath match + // anything, so a plain Circuit or SubCircuit consumer only ever generates its own factories. + for (target in listOf(CodegenTarget.Circuit, CodegenTarget.SubCircuit)) { + resolver.getSymbolsWithAnnotation(target.injectAnnotation.canonicalName).forEach { + annotatedElement -> when (annotatedElement) { is KSClassDeclaration -> - generateFactory(annotatedElement, InstantiationType.CLASS, symbols) + generateFactory(annotatedElement, InstantiationType.CLASS, symbols, target) is KSFunctionDeclaration -> - generateFactory(annotatedElement, InstantiationType.FUNCTION, symbols) + generateFactory(annotatedElement, InstantiationType.FUNCTION, symbols, target) else -> logger.error( - "CircuitInject is only applicable on classes and functions.", + "${target.annotationSimpleName} is only applicable on classes and functions.", annotatedElement, ) } } + } return emptyList() } @@ -104,46 +106,42 @@ private class CircuitSymbolProcessor( annotatedElement: KSAnnotated, instantiationType: InstantiationType, symbols: CircuitSymbols, + target: CodegenTarget, ) { - val circuitInjectAnnotation = - annotatedElement.getKSAnnotationsWithLeniency(CircuitNames.CIRCUIT_INJECT_ANNOTATION).single() + val injectAnnotation = + annotatedElement.getKSAnnotationsWithLeniency(target.injectAnnotation).single() // If we annotated a class, check that the class isn't using assisted inject. If so, error and // return if (instantiationType == InstantiationType.CLASS) { - (annotatedElement as KSClassDeclaration).checkForAssistedInjection(mode) { + (annotatedElement as KSClassDeclaration).checkForAssistedInjection(mode, target) { return } } - val screenKSType = circuitInjectAnnotation.arguments[0].value as KSType + val screenKSType = injectAnnotation.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 scope = (injectAnnotation.arguments[1].value as KSType).toTypeName() val factoryData = - computeFactoryData( - annotatedElement, - symbols, - screenKSType, - instantiationType, - logger, - mode = mode, - ) ?: return + computeFactoryData(annotatedElement, symbols, screenKSType, instantiationType, target) + ?: return - val className = + val baseName = factoryData.className.replaceFirstChar { char -> char.takeIf { char.isLowerCase() }?.run { uppercase(Locale.US) } ?: char.toString() } + val generatedClassName = baseName + target.factorySuffix(factoryData.factoryType) // 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. + // kotlin. For annotatedElements which are top-level functions, topLevelClass will be null. val topLevelDeclaration = (annotatedElement as KSDeclaration).topLevelDeclaration() val topLevelClass = (topLevelDeclaration as? KSClassDeclaration)?.toClassName() val builder = - TypeSpec.classBuilder(className + CircuitNames.FACTORY).apply { + TypeSpec.classBuilder(generatedClassName).apply { // Add the `@Inject` annotation to the appropriate place val constructorBuilder = FunSpec.constructorBuilder().addParameters(factoryData.constructorParams) @@ -165,11 +163,12 @@ private class CircuitSymbolProcessor( } mode.annotateFactory(builder = this, scope = scope) - if (topLevelClass != null && mode.originAnnotation != null) { + val originAnnotation = mode.originAnnotation + if (topLevelClass != null && originAnnotation != null) { addAnnotation( - AnnotationSpec.builder(mode.originAnnotation.className) + AnnotationSpec.builder(originAnnotation.className) .apply { - val parameterName = mode.originAnnotation.parameterName + val parameterName = originAnnotation.parameterName if (parameterName != null) { addMember("%L = %T::class", parameterName, topLevelClass) } else { @@ -189,23 +188,14 @@ private class CircuitSymbolProcessor( } val typeSpec = - when (factoryData.factoryType) { - FactoryType.PRESENTER -> - builder.buildPresenterFactory( - annotatedElement, - screenBranch, - factoryData.codeBlock, - factoryData.hoistedBindings, - ) - - FactoryType.UI -> - builder.buildUiFactory( - annotatedElement, - screenBranch, - factoryData.codeBlock, - factoryData.hoistedBindings, - ) - } + builder.buildFactory( + annotatedElement, + target, + factoryData.factoryType, + screenBranch, + factoryData.codeBlock, + factoryData.hoistedBindings, + ) val originatingFile = listOfNotNull(annotatedElement.containingFile) @@ -219,9 +209,10 @@ private class CircuitSymbolProcessor( val additionalType = mode.produceAdditionalTypeSpec( factoryType = factoryData.factoryType, - factory = ClassName(factoryData.packageName, className + CircuitNames.FACTORY), + factory = ClassName(factoryData.packageName, generatedClassName), scope = scope, topLevelClass = topLevelClass, + target = target, ) ?: return FileSpec.get(factoryData.packageName, additionalType) @@ -240,8 +231,20 @@ private class CircuitSymbolProcessor( } } + private inline fun KSDeclaration.checkVisibility(target: CodegenTarget, returnBody: () -> Unit) { + if (!getVisibility().isVisible) { + logger.error( + "${target.annotationSimpleName} is not applicable to private or local functions and " + + "classes.", + this, + ) + returnBody() + } + } + private inline fun KSClassDeclaration.checkForAssistedInjection( mode: CodegenMode, + target: CodegenTarget, exit: () -> Nothing, ) { val assistedInjectClassName = mode.runtime.assistedInject ?: return @@ -255,8 +258,9 @@ private class CircuitSymbolProcessor( val suffix = if (assistedFactory != null) " (${assistedFactory.qualifiedName?.asString()})" else "" logger.error( - "When using @CircuitInject with an @AssistedInject-annotated class, you must " + - "put the @CircuitInject annotation on the @AssistedFactory-annotated nested class$suffix.", + "When using @${target.annotationSimpleName} with an @AssistedInject-annotated class, " + + "you must put the @${target.annotationSimpleName} annotation on the " + + "@AssistedFactory-annotated nested class$suffix.", this, ) exit() @@ -316,433 +320,470 @@ private class CircuitSymbolProcessor( symbols: CircuitSymbols, screenKSType: KSType, instantiationType: InstantiationType, - logger: KSPLogger, - mode: CodegenMode, + target: CodegenTarget, ): FactoryData? { - val className: String - val packageName: String - val factoryType: FactoryType + return when (instantiationType) { + InstantiationType.FUNCTION -> + computeFunctionFactoryData(annotatedElement, symbols, screenKSType, target) + InstantiationType.CLASS -> + computeClassFactoryData(annotatedElement, symbols, screenKSType, target) + } + } + + @Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount") + private fun computeFunctionFactoryData( + annotatedElement: KSAnnotated, + symbols: CircuitSymbols, + screenKSType: KSType, + target: CodegenTarget, + ): FactoryData? { + val fd = annotatedElement as KSFunctionDeclaration + fd.checkVisibility(target) { + return null + } + val name = annotatedElement.simpleName.getShortName() + val packageName = fd.packageName.asString() + val factoryType = target.functionFactoryType(name) + val constructorParams = mutableListOf() val hoistedBindings = 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 circuitProvidedParams = - fd.assistedParameters( - symbols = symbols, - logger = logger, - screenType = screenKSType, - allowNavigator = factoryType == FactoryType.PRESENTER, - includeParameterNames = true, - ) + val circuitProvidedParams = + if (target.providesAssistedParamsToFunctions) { + fd.assistedParameters( + symbols = symbols, + target = target, + screenType = screenKSType, + allowNavigator = factoryType == FactoryType.PRESENTER, + includeParameterNames = true, + ) + } else { + CodeBlock.of("") + } - // Any function parameter that isn't a circuit-provided type is treated as an injected - // dependency: added to the factory constructor as a provider (`Provider` or `() -> T`, - // depending on the mode) and invoked when the function is called. - // - // Exception: if the parameter type is already an indirect reference (Provider, Lazy, or - // a Kotlin function type), we pass it through as-is without re-wrapping or invoking it. - // - // Non-pass-through providers are hoisted to a local `val` outside the presenterOf/ui - // lambda so they're only invoked once per `create()` call, otherwise the composable - // block would re-invoke the provider on every recomposition. - val injectedParamBlocks = mutableListOf() - for (param in fd.parameters) { - val type = param.type.resolve() - if (type.isCircuitProvidedParameter(symbols, factoryType)) continue - val paramName = param.name!!.getShortName() - if (type.isPassThroughProvider(mode)) { - constructorParams.add( - ParameterSpec.builder(paramName, type.toPassThroughTypeName()).build() - ) - injectedParamBlocks.add(CodeBlock.of("%L·=·%L", paramName, paramName)) - } else { - constructorParams.add( - ParameterSpec.builder(paramName, mode.runtime.asProvider(type.toTypeName(), options)) - .build() - ) - hoistedBindings.add( - CodeBlock.of( - "val·%L·=·%L", - paramName, - mode.runtime.getProviderBlock(CodeBlock.of(paramName)), - ) - ) - injectedParamBlocks.add(CodeBlock.of("%L·=·%L", paramName, paramName)) - } - } + // Any function parameter that isn't a framework-provided type is treated as an injected + // dependency: added to the factory constructor as a provider (`Provider` or `() -> T`, + // depending on the mode) and invoked when the function is called. + // + // Exception: if the parameter type is already an indirect reference (Provider, Lazy, or a + // Kotlin function type), we pass it through as-is without re-wrapping or invoking it. + // + // Non-pass-through providers are hoisted to a local `val` outside the presenterOf/ui lambda so + // they're only invoked once per `create()` call, otherwise the composable block would re-invoke + // the provider on every recomposition. + val injectedParamBlocks = mutableListOf() + for (param in fd.parameters) { + val type = param.type.resolve() + if (target.isProvidedFunctionParameter(type, factoryType, symbols)) continue + val paramName = param.name!!.getShortName() + if (type.isPassThroughProvider(mode)) { + constructorParams.add( + ParameterSpec.builder(paramName, type.toPassThroughTypeName()).build() + ) + injectedParamBlocks.add(CodeBlock.of("%L·=·%L", paramName, paramName)) + } else { + constructorParams.add( + ParameterSpec.builder(paramName, mode.runtime.asProvider(type.toTypeName(), options)) + .build() + ) + hoistedBindings.add( + CodeBlock.of( + "val·%L·=·%L", + paramName, + mode.runtime.getProviderBlock(CodeBlock.of(paramName)), + ) + ) + injectedParamBlocks.add(CodeBlock.of("%L·=·%L", paramName, paramName)) + } + } - val assistedParams = - when { - injectedParamBlocks.isEmpty() -> circuitProvidedParams - circuitProvidedParams.isEmpty() -> injectedParamBlocks.joinToCode(",·") - else -> (listOf(circuitProvidedParams) + injectedParamBlocks).joinToCode(",·") - } + val assistedParams = + when { + injectedParamBlocks.isEmpty() -> circuitProvidedParams + circuitProvidedParams.isEmpty() -> injectedParamBlocks.joinToCode(",·") + else -> (listOf(circuitProvidedParams) + injectedParamBlocks).joinToCode(",·") + } - // Check we have a return type - if (factoryType == FactoryType.PRESENTER) { - val returnType = fd.returnType?.resolve() - val isInvalid = returnType == null || !returnType.isInstanceOf(symbols.circuitUiState) - if (isInvalid) { - logger.error("Presenter functions must return a UiState.", fd.returnType ?: fd) - return null - } - } + // Check we have a return type + if (factoryType == FactoryType.PRESENTER) { + val returnType = fd.returnType?.resolve() + val isInvalid = returnType == null || !returnType.isInstanceOf(target.uiState(symbols)) + if (isInvalid) { + logger.error("Presenter functions must return a UiState.", fd.returnType ?: fd) + return null + } + } - codeBlock = - when (factoryType) { - FactoryType.PRESENTER -> - CodeBlock.of( - "%M·{·%M(%L)·}", - MemberName(CircuitNames.CIRCUIT_RUNTIME_PRESENTER_PACKAGE, "presenterOf"), - MemberName(packageName, name), - assistedParams, - ) + val codeBlock = + when (factoryType) { + FactoryType.PRESENTER -> + CodeBlock.of( + "%M·{·%M(%L)·}", + MemberName(CircuitNames.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()) - } + FactoryType.UI -> { + // State param is optional + val stateParam = + fd.parameters.singleOrNull { parameter -> + target.uiState(symbols).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 - } + // Modifier param is required + val modifierParam = + fd.parameters.singleOrNull { parameter -> + symbols.modifier.isAssignableFrom(parameter.type.resolve()) + } + ?: run { + logger.error( + "${target.uiNoun} 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) CircuitNames.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(CircuitNames.CIRCUIT_RUNTIME_UI_PACKAGE, "ui"), - stateType, - stateArg, - MemberName(packageName, name), - stateParamBlock, - modifierParamBlock, - assistedParamsBlock, - ) + /* + 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, ) } + ──────────────────────────────────────────────────────────────────────────────────────────────────── + */ + + @Suppress("IfThenToElvis") // The elvis is less readable here + val stateType = + if (stateParam == null) target.defaultUiState + 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( + "%L<%T>·{·%L,·modifier·->·%M(%L%L%L)·}", + target.uiFunctionWrapperPrefix(), + stateType, + stateArg, + MemberName(packageName, name), + stateParamBlock, + modifierParamBlock, + assistedParamsBlock, + ) + } } - InstantiationType.CLASS -> { - val declaration = annotatedElement as KSClassDeclaration - declaration.checkVisibility(logger) { - return null - } - val injectableConstructor by - lazy(NONE) { - declaration.findConstructorAnnotatedWith(mode.runtime.inject(options)) - ?: declaration.primaryConstructor - } - val assistedKSParams by - lazy(NONE) { - injectableConstructor - ?.parameters - ?.filter { it.isAnnotationPresentWithLeniency(mode.runtime.assisted) } - .orEmpty() - } + return FactoryData( + name, + packageName, + factoryType, + constructorParams, + codeBlock, + hoistedBindings, + ) + } - val isAssisted = - assistedKSParams.isNotEmpty() || - mode.runtime.assistedFactory?.let { declaration.isAnnotationPresentWithLeniency(it) } == - true - - val creatorOrConstructor: KSFunctionDeclaration? - val targetClass: KSClassDeclaration - if (isAssisted) { - if (mode == KOTLIN_INJECT_ANVIL) { - creatorOrConstructor = injectableConstructor - targetClass = declaration - } else { - val creatorFunction = declaration.getAllFunctions().filter { it.isAbstract }.single() - creatorOrConstructor = creatorFunction - targetClass = creatorFunction.returnType!!.resolve().declaration as KSClassDeclaration - targetClass.checkVisibility(logger) { + @Suppress("CyclomaticComplexMethod", "LongMethod", "ReturnCount") + private fun computeClassFactoryData( + annotatedElement: KSAnnotated, + symbols: CircuitSymbols, + screenKSType: KSType, + target: CodegenTarget, + ): FactoryData? { + val declaration = annotatedElement as KSClassDeclaration + declaration.checkVisibility(target) { + return null + } + val constructorParams = mutableListOf() + val injectableConstructor by + lazy(NONE) { + declaration.findConstructorAnnotatedWith(mode.runtime.inject(options)) + ?: declaration.primaryConstructor + } + val assistedKSParams by + lazy(NONE) { + injectableConstructor + ?.parameters + ?.filter { it.isAnnotationPresentWithLeniency(mode.runtime.assisted) } + .orEmpty() + } + + val isAssisted = + assistedKSParams.isNotEmpty() || + mode.runtime.assistedFactory?.let { declaration.isAnnotationPresentWithLeniency(it) } == + true + + val creatorOrConstructor: KSFunctionDeclaration? + val targetClass: KSClassDeclaration + if (isAssisted) { + if (mode == KOTLIN_INJECT_ANVIL) { + creatorOrConstructor = injectableConstructor + targetClass = declaration + } else { + val abstractFunctions = declaration.getAllFunctions().filter { it.isAbstract }.toList() + val creatorFunction = + abstractFunctions.singleOrNull() + ?: run { + logger.error( + "@${target.annotationSimpleName} on an @AssistedFactory requires exactly one " + + "abstract create function, but ${declaration.qualifiedName?.asString()} has " + + "${abstractFunctions.size}.", + declaration, + ) return null } + creatorOrConstructor = creatorFunction + targetClass = + creatorFunction.returnType?.resolve()?.declaration as? KSClassDeclaration + ?: run { + logger.error( + "Could not resolve the return type of " + + "${creatorFunction.simpleName.getShortName()}.", + creatorFunction, + ) + return null + } + targetClass.checkVisibility(target) { + return null + } + } + } else { + creatorOrConstructor = injectableConstructor + targetClass = declaration + } + val useProvider = + !isAssisted && + mode.filterValidInjectionSites(listOfNotNull(creatorOrConstructor, declaration)).any { + injectionSite -> + mode.runtime.declarationInjects(options).any { annotation -> + injectionSite.isAnnotationPresentWithLeniency(annotation) } - } else { - creatorOrConstructor = injectableConstructor - targetClass = declaration } - val useProvider = - !isAssisted && - mode.filterValidInjectionSites(listOfNotNull(creatorOrConstructor, declaration)).any { - injectionSite -> - mode.runtime.declarationInjects(options).any { annotation -> - injectionSite.isAnnotationPresentWithLeniency(annotation) - } + if (!isAssisted && !useProvider) { + logger.error(target.notInjectableError(), declaration) + return null + } + val className = + target.classBaseName( + annotatedDeclaration = declaration, + targetClass = targetClass, + isAssisted = isAssisted, + isKotlinInjectAnvil = mode == KOTLIN_INJECT_ANVIL, + ) + val packageName = target.classPackageName(declaration, targetClass) + val factoryType = + targetClass + .getAllSuperTypes() + .mapNotNull { + target.factoryTypeForSuperType(it.declaration.qualifiedName?.asString().orEmpty()) + } + .firstOrNull() + ?: run { + val annotationsString = + declaration.annotations.toList().joinToString { + it.annotationType.resolve().toTypeName().toString() } - if (!isAssisted && !useProvider) { logger.error( - "@CircuitInject-annotated classes must be injectable: annotate the class or a " + - "constructor with @Inject.", - declaration, + "Factory must be for a ${target.uiNoun} or ${target.presenterNoun} class, but was " + + "${targetClass.qualifiedName?.asString()}.\n" + + "Supertypes: ${targetClass.getAllSuperTypes().toList()}.\n" + + "isAssisted? ${isAssisted}\n" + + "Annotations: $annotationsString}", + targetClass, ) return null } - className = targetClass.simpleName.getShortName() - packageName = targetClass.packageName.asString() - factoryType = - targetClass - .getAllSuperTypes() - .mapNotNull { - when (it.declaration.qualifiedName?.asString()) { - CircuitNames.CIRCUIT_UI.canonicalName -> FactoryType.UI - CircuitNames.CIRCUIT_PRESENTER.canonicalName -> FactoryType.PRESENTER - else -> null - } - } - .firstOrNull() - ?: run { - val annotationsString = - declaration.annotations.toList().joinToString { - it.annotationType.resolve().toTypeName().toString() - } - logger.error( - "Factory must be for a UI or Presenter class, but was " + - "${targetClass.qualifiedName?.asString()}.\n" + - "Supertypes: ${targetClass.getAllSuperTypes().toList()}.\n" + - "isAssisted? ${isAssisted}\n" + - "Annotations: $annotationsString}", - targetClass, - ) - return null - } - val assistedParams = - if (useProvider) { - // Nothing to do here, we'll just use the provider directly. - CodeBlock.of("") - } else { - creatorOrConstructor?.assistedParameters( - symbols = symbols, - logger = logger, - screenType = screenKSType, - allowNavigator = factoryType == FactoryType.PRESENTER, - // Don't include parameter names if a factory lambda is going to be used. - includeParameterNames = !(isAssisted && mode == KOTLIN_INJECT_ANVIL), + val assistedParams = + if (useProvider) { + // Nothing to do here, we'll just use the provider directly. + CodeBlock.of("") + } else { + creatorOrConstructor?.assistedParameters( + symbols = symbols, + target = target, + screenType = screenKSType, + allowNavigator = factoryType == FactoryType.PRESENTER, + // Don't include parameter names if a factory lambda is going to be used. + includeParameterNames = !(isAssisted && mode == KOTLIN_INJECT_ANVIL), + ) ?: CodeBlock.of("") + } + val codeBlock = + if (useProvider) { + // Inject a Provider that we'll call get() on. + constructorParams.add( + ParameterSpec.builder( + "provider", + mode.runtime.asProvider(targetClass.toClassName(), options), ) + .build() + ) + mode.runtime.getProviderBlock(CodeBlock.of("provider")) + } else { + // isAssisted: inject the target class's assisted factory and call create() on it. + when (mode) { + KOTLIN_INJECT_ANVIL -> { + val factoryLambda = + LambdaTypeName.get( + receiver = null, + parameters = + assistedKSParams.map { ksParam -> + ParameterSpec.builder(ksParam.name!!.getShortName(), ksParam.type.toTypeName()) + .build() + }, + returnType = targetClass.toClassName(), + ) + constructorParams.add(ParameterSpec.builder("factory", factoryLambda).build()) + CodeBlock.of("factory(%L)", assistedParams) } - codeBlock = - if (useProvider) { - // Inject a Provider that we'll call get() on. + else -> { constructorParams.add( - ParameterSpec.builder( - "provider", - mode.runtime.asProvider(targetClass.toClassName(), options), - ) - .build() + ParameterSpec.builder("factory", declaration.toClassName()).build() + ) + CodeBlock.of( + "factory.%L(%L)", + creatorOrConstructor!!.simpleName.getShortName(), + assistedParams, ) - mode.runtime.getProviderBlock(CodeBlock.of("provider")) - } else { - // isAssisted: inject the target class's assisted factory and call create() on it. - when (mode) { - KOTLIN_INJECT_ANVIL -> { - val factoryLambda = - LambdaTypeName.get( - receiver = null, - parameters = - assistedKSParams.map { ksParam -> - ParameterSpec.builder( - ksParam.name!!.getShortName(), - ksParam.type.toTypeName(), - ) - .build() - }, - returnType = targetClass.toClassName(), - ) - constructorParams.add(ParameterSpec.builder("factory", factoryLambda).build()) - CodeBlock.of("factory(%L)", assistedParams) - } - else -> { - constructorParams.add( - ParameterSpec.builder("factory", declaration.toClassName()).build() - ) - CodeBlock.of( - "factory.%L(%L)", - creatorOrConstructor!!.simpleName.getShortName(), - assistedParams, - ) - } - } } + } } - } return FactoryData( className, packageName, factoryType, constructorParams, codeBlock, - hoistedBindings, + // Class-based instantiation is not composable, so nothing to hoist. + emptyList(), ) } -} -private data class AssistedType( - val factoryName: String, - val type: TypeName, - val name: String, - val includeExplicitCast: Boolean = false, -) + private data class AssistedType( + val factoryName: String, + val type: TypeName, + val name: String, + val includeExplicitCast: Boolean = false, + ) -/** - * Returns a [CodeBlock] representation of all named assisted parameters on this - * [KSFunctionDeclaration] to be used in generated invocation code. - * - * If [screenType] is an object, then the CodeBlock will include an explicit cast to the type since - * objects are checked by equality and don't benefit from smart casts. - * - * 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, - allowNavigator: Boolean, - includeParameterNames: 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) + /** + * Returns a [CodeBlock] representation of all named assisted parameters on this + * [KSFunctionDeclaration] to be used in generated invocation code. + * + * If [screenType] is an object, then the CodeBlock will include an explicit cast to the type + * since objects are checked by equality and don't benefit from smart casts. + * + * Example: this function + * + * ```kotlin + * @Composable + * fun HomePresenter(screen: Screen, navigator: Navigator) + * ``` + * + * Yields this CodeBlock: `screen = screen, navigator = navigator` + */ + private fun KSFunctionDeclaration.assistedParameters( + symbols: CircuitSymbols, + target: CodegenTarget, + screenType: KSType, + allowNavigator: Boolean, + includeParameterNames: 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)) { - val screenIsObject = - screenType.declaration.let { - it is KSClassDeclaration && it.classKind == ClassKind.OBJECT - } - addOrError( - AssistedType( - factoryName = "screen", - type = type.toTypeName(), - name = param.name!!.getShortName(), - includeExplicitCast = screenIsObject, + val type = param.type.resolve() + when { + target.isScreenType(type, symbols) -> { + if (screenType.isSameDeclarationAs(type)) { + val screenIsObject = + screenType.declaration.let { + it is KSClassDeclaration && it.classKind == ClassKind.OBJECT + } + addOrError( + AssistedType( + factoryName = "screen", + type = type.toTypeName(), + name = param.name!!.getShortName(), + includeExplicitCast = screenIsObject, + ) ) - ) - } else { - logger.error("Screen type mismatch. Expected $screenType but found $type", param) + } else { + logger.error("Screen type mismatch. Expected $screenType but found $type", param) + } } - } - type.isInstanceOf(symbols.navigator) -> { - if (allowNavigator) { + target.isNavigatorType(type, symbols) -> { + if (allowNavigator) { + addOrError( + AssistedType( + factoryName = "navigator", + type = type.toTypeName(), + name = param.name!!.getShortName(), + ) + ) + } else { + logger.error( + "Navigator type mismatch. Navigators are not injectable on this type.", + param, + ) + } + } + target.isContextType(type, symbols) -> { addOrError( AssistedType( - factoryName = "navigator", + factoryName = "context", type = type.toTypeName(), name = param.name!!.getShortName(), ) ) - } else { - logger.error( - "Navigator type mismatch. Navigators are not injectable on this type.", - param, - ) } } - type.isInstanceOf(symbols.circuitContext) -> { - addOrError( - AssistedType( - factoryName = "context", - type = type.toTypeName(), - name = param.name!!.getShortName(), - ) - ) - } } } - } - .toList() - .map { - val prefix = - if (includeParameterNames) { - "${it.name} = " + .toList() + .map { + val prefix = + if (includeParameterNames) { + "${it.name} = " + } else { + "" + } + if (it.includeExplicitCast) { + CodeBlock.of("$prefix${it.factoryName}·as·%T", it.type) } else { - "" + CodeBlock.of("$prefix${it.factoryName}") } - if (it.includeExplicitCast) { - CodeBlock.of("$prefix${it.factoryName}·as·%T", it.type) - } else { - CodeBlock.of("$prefix${it.factoryName}") } - } - .joinToCode(",·") + .joinToCode(",·") + } } private fun KSType.isSameDeclarationAs(type: KSType): Boolean { @@ -753,18 +794,6 @@ private fun KSType.isInstanceOf(type: KSType): Boolean { return type.isAssignableFrom(this) } -/** Whether this type matches one of the parameter types that circuit provides for a function. */ -private fun KSType.isCircuitProvidedParameter( - symbols: CircuitSymbols, - factoryType: FactoryType, -): Boolean { - if (isInstanceOf(symbols.screen) || isInstanceOf(symbols.circuitContext)) return true - return when (factoryType) { - FactoryType.PRESENTER -> isInstanceOf(symbols.navigator) - FactoryType.UI -> isInstanceOf(symbols.circuitUiState) || isInstanceOf(symbols.modifier) - } -} - /** * Whether this type is already an "indirect" reference to a dependency (a provider, a `Lazy`, or * `() -> T`). Such types are passed through to the generated factory as-is rather than being @@ -790,19 +819,24 @@ private fun KSType.toPassThroughTypeName(): TypeName { return LambdaTypeName.get(returnType = returnType) } -private fun TypeSpec.Builder.buildUiFactory( +/** + * Builds the generated factory [TypeSpec] for either a UI or presenter, emitting the target's + * factory superinterface, `create` signature, and a single `when (screen)` branch. + */ +private fun TypeSpec.Builder.buildFactory( originatingSymbol: KSAnnotated, + target: CodegenTarget, + factoryType: FactoryType, screenBranch: CodeBlock, instantiationCodeBlock: CodeBlock, hoistedBindings: List, ): TypeSpec { - return addSuperinterface(CircuitNames.CIRCUIT_UI_FACTORY) + return addSuperinterface(target.factorySuperinterface(factoryType)) .addFunction( FunSpec.builder("create") .addModifiers(KModifier.OVERRIDE) - .addParameter("screen", CircuitNames.SCREEN) - .addParameter("context", CircuitNames.CIRCUIT_CONTEXT) - .returns(CircuitNames.CIRCUIT_UI.parameterizedBy(STAR).copy(nullable = true)) + .addParameters(target.createParams(factoryType)) + .returns(target.createReturn(factoryType)) .beginControlFlow("return·when·(screen)") .addWhenBranch(screenBranch, instantiationCodeBlock, hoistedBindings) .addStatement("else·->·null") @@ -813,43 +847,6 @@ private fun TypeSpec.Builder.buildUiFactory( .build() } -private fun TypeSpec.Builder.buildPresenterFactory( - originatingSymbol: KSAnnotated, - screenBranch: CodeBlock, - instantiationCodeBlock: CodeBlock, - hoistedBindings: List, -): 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(CircuitNames.CIRCUIT_PRESENTER_FACTORY) - .addFunction( - FunSpec.builder("create") - .addModifiers(KModifier.OVERRIDE) - .addParameter("screen", CircuitNames.SCREEN) - .addParameter("navigator", CircuitNames.NAVIGATOR) - .addParameter("context", CircuitNames.CIRCUIT_CONTEXT) - .returns(CircuitNames.CIRCUIT_PRESENTER.parameterizedBy(STAR).copy(nullable = true)) - .beginControlFlow("return when (screen)") - .addWhenBranch(screenBranch, instantiationCodeBlock, hoistedBindings) - .addStatement("else·->·null") - .endControlFlow() - .build() - ) - .addOriginatingKSFile(originatingSymbol.containingFile!!) - .build() -} - /** * Emits a single branch of the `when (screen)` block. If [hoistedBindings] is empty it emits a * simple `is Screen -> expr` line; otherwise it emits a block-bodied branch that declares each @@ -874,16 +871,9 @@ private fun FunSpec.Builder.addWhenBranch( return this } -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 val Visibility.isVisible: Boolean + get() = this != Visibility.PRIVATE && this != Visibility.LOCAL private fun KSDeclaration.topLevelDeclaration(): KSDeclaration { return parentDeclaration?.topLevelDeclaration() ?: this } - -private val Visibility.isVisible: Boolean - get() = this != Visibility.PRIVATE && this != Visibility.LOCAL diff --git a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbols.kt b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbols.kt index 750664bfad..0cadac1165 100644 --- a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbols.kt +++ b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CircuitSymbols.kt @@ -4,30 +4,63 @@ package com.slack.circuit.codegen import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.ClassName +/** + * Runtime [KSType]s the processor matches parameters against. + * + * Circuit and SubCircuit runtimes are independent, so a consumer may have only one on its + * classpath. Each type is loaded lazily and only accessed by its own [CodegenTarget]; accessing an + * absent type is an [error], since the matching annotation guarantees its runtime is present. + */ internal class CircuitSymbols private constructor(resolver: Resolver) { - val modifier = resolver.loadKSType(CircuitNames.MODIFIER.canonicalName) - val circuitUiState = resolver.loadKSType(CircuitNames.CIRCUIT_UI_STATE.canonicalName) - val screen = resolver.loadKSType(CircuitNames.SCREEN.canonicalName) - val navigator = resolver.loadKSType(CircuitNames.NAVIGATOR.canonicalName) - val circuitContext = resolver.loadKSType(CircuitNames.CIRCUIT_CONTEXT.canonicalName) - - 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()) + val modifier = resolver.requireKSType(CircuitNames.MODIFIER) + + // Circuit runtime types. + private val circuitUiStateOrNull = resolver.loadOptionalKSType(CircuitNames.CIRCUIT_UI_STATE) + private val screenOrNull = resolver.loadOptionalKSType(CircuitNames.SCREEN) + private val navigatorOrNull = resolver.loadOptionalKSType(CircuitNames.NAVIGATOR) + private val circuitContextOrNull = resolver.loadOptionalKSType(CircuitNames.CIRCUIT_CONTEXT) + + // SubCircuit runtime types. + private val subCircuitUiStateOrNull = + resolver.loadOptionalKSType(CircuitNames.SUB_CIRCUIT_UI_STATE) + + val circuitUiState: KSType + get() = circuitUiStateOrNull.require(CircuitNames.CIRCUIT_UI_STATE) + + val screen: KSType + get() = screenOrNull.require(CircuitNames.SCREEN) + + val navigator: KSType + get() = navigatorOrNull.require(CircuitNames.NAVIGATOR) + + val circuitContext: KSType + get() = circuitContextOrNull.require(CircuitNames.CIRCUIT_CONTEXT) + + val subCircuitUiState: KSType + get() = subCircuitUiStateOrNull.require(CircuitNames.SUB_CIRCUIT_UI_STATE) + + private fun KSType?.require(name: ClassName): KSType = + this ?: error("Could not find ${name.canonicalName} in classpath") + + private fun Resolver.requireKSType(name: ClassName): KSType = + loadOptionalKSType(name) ?: error("Could not find ${name.canonicalName} in classpath") + + private fun Resolver.loadOptionalKSType(name: ClassName): KSType? { + return getClassDeclarationByName(getKSNameFromString(name.canonicalName))?.asType(emptyList()) } companion object { fun create(resolver: Resolver): CircuitSymbols? { - @Suppress("SwallowedException") - return try { - CircuitSymbols(resolver) - } catch (e: IllegalStateException) { - null - } + // Modifier (from compose-ui) is the one type both the Circuit and SubCircuit runtimes share, + // so it's the minimal precondition for generating anything. Bail cleanly if it's absent. + // Runtime-specific types load lazily and are required only by their own CodegenTarget. + val hasModifier = + resolver.getClassDeclarationByName( + resolver.getKSNameFromString(CircuitNames.MODIFIER.canonicalName) + ) != null + return if (hasModifier) CircuitSymbols(resolver) else null } } } diff --git a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenMode.kt b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenMode.kt index 8c643f4ed6..dc6a96a092 100644 --- a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenMode.kt +++ b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenMode.kt @@ -7,7 +7,6 @@ import com.google.devtools.ksp.processing.PlatformInfo import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSDeclaration import com.google.devtools.ksp.symbol.KSFunctionDeclaration -import com.slack.circuit.codegen.FactoryType.UI import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock @@ -103,6 +102,7 @@ internal enum class CodegenMode { factoryType: FactoryType, scope: TypeName, topLevelClass: ClassName?, + target: CodegenTarget, ): TypeSpec { val moduleAnnotations = listOfNotNull( @@ -125,12 +125,7 @@ internal enum class CodegenMode { AnnotationSpec.builder(CircuitNames.DAGGER_INTO_SET).build(), ) - val providerReturns = - if (factoryType == UI) { - CircuitNames.CIRCUIT_UI_FACTORY - } else { - CircuitNames.CIRCUIT_PRESENTER_FACTORY - } + val providerReturns = target.factorySuperinterface(factoryType) val factoryName = factory.simpleName @@ -263,6 +258,7 @@ internal enum class CodegenMode { factoryType: FactoryType, scope: TypeName, topLevelClass: ClassName?, + target: CodegenTarget, ): TypeSpec? { return null } diff --git a/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenTarget.kt b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenTarget.kt new file mode 100644 index 0000000000..ef8fac384c --- /dev/null +++ b/circuit-codegen/src/main/kotlin/com/slack/circuit/codegen/CodegenTarget.kt @@ -0,0 +1,285 @@ +// Copyright (C) 2026 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.codegen + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.MemberName +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.STAR +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.ksp.toClassName + +/** + * The flavor of Circuit the processor is generating for: the runtime types and naming rules that + * differ between `@CircuitInject` ([Circuit]) and `@SubCircuitInject` ([SubCircuit]). Everything + * else — DI-mode wiring, provider hoisting, qualifier/origin propagation, the `when (screen)` + * skeleton — is shared and lives in the processor. + * + * A single [CircuitSymbolProcessor][com.slack.circuit.codegen.CircuitSymbolProcessorProvider] scans + * both annotations and drives generation with the matching target per element. Pairs with + * [CodegenMode], which selects the DI framework. + */ +internal sealed interface CodegenTarget { + /** The inject annotation this target scans and reads the `screen`/`scope` arguments from. */ + val injectAnnotation: ClassName + /** Simple name of [injectAnnotation], used in diagnostics. */ + val annotationSimpleName: String + + /** Human-readable noun for the UI type, used in diagnostics (e.g. `UI`, `SubUi`). */ + val uiNoun: String + /** Human-readable noun for the presenter type, used in diagnostics. */ + val presenterNoun: String + + /** The UI factory interface the generated UI factory implements. */ + val uiFactory: ClassName + /** The presenter factory interface the generated presenter factory implements. */ + val presenterFactory: ClassName + + /** Fallback UI-state type used for UI functions that omit a state parameter. */ + val defaultUiState: ClassName + + /** + * Whether this target provides assisted arguments (screen/navigator/context) to top-level + * functions. Circuit does; SubCircuit functions only receive state and modifier. + */ + val providesAssistedParamsToFunctions: Boolean + + /** The UI-state [KSType] used to detect a function's state parameter. */ + fun uiState(symbols: CircuitSymbols): KSType + + /** Maps a supertype's qualified name to its [FactoryType], or null if it isn't a UI/presenter. */ + fun factoryTypeForSuperType(qualifiedName: String): FactoryType? + + /** The [FactoryType] a top-level function produces. */ + fun functionFactoryType(functionName: String): FactoryType + + /** Whether [type] is (or extends) this target's screen type. */ + fun isScreenType(type: KSType, symbols: CircuitSymbols): Boolean + + /** Whether [type] is this target's navigator type. Only Circuit provides a navigator. */ + fun isNavigatorType(type: KSType, symbols: CircuitSymbols): Boolean = false + + /** Whether [type] is this target's context type. Only Circuit provides a context. */ + fun isContextType(type: KSType, symbols: CircuitSymbols): Boolean = false + + /** + * Whether a top-level function parameter of [type] is framework-provided (and so skipped from + * injection) rather than an injected dependency. + */ + fun isProvidedFunctionParameter( + type: KSType, + factoryType: FactoryType, + symbols: CircuitSymbols, + ): Boolean + + /** + * The leading token of the UI-function wrapper, applied as ` { state, modifier -> + * fn(...) }`. Circuit uses the `ui` member function; SubCircuit uses the `SubUi` SAM type. + */ + fun uiFunctionWrapperPrefix(): CodeBlock + + /** The parameters of the generated factory's `create` function for the given [factoryType]. */ + fun createParams(factoryType: FactoryType): List + + /** The (nullable) return type of the generated factory's `create` function. */ + fun createReturn(factoryType: FactoryType): TypeName + + /** The factory interface the generated class implements for the given [factoryType]. */ + fun factorySuperinterface(factoryType: FactoryType): ClassName = + if (factoryType == FactoryType.UI) uiFactory else presenterFactory + + /** + * The base name of the generated factory class (before capitalization + suffix), for the + * class-based instantiation path. + */ + fun classBaseName( + annotatedDeclaration: KSClassDeclaration, + targetClass: KSClassDeclaration, + isAssisted: Boolean, + isKotlinInjectAnvil: Boolean, + ): String + + /** The package the generated factory class is emitted into, for the class-based path. */ + fun classPackageName( + annotatedDeclaration: KSClassDeclaration, + targetClass: KSClassDeclaration, + ): String + + /** The suffix appended to the base name to form the generated factory class name. */ + fun factorySuffix(factoryType: FactoryType): String + + /** Diagnostic emitted when an annotated class isn't injectable. */ + fun notInjectableError(): String + + data object Circuit : CodegenTarget { + override val injectAnnotation = CircuitNames.CIRCUIT_INJECT_ANNOTATION + override val annotationSimpleName = injectAnnotation.simpleName + override val uiNoun = "UI" + override val presenterNoun = "Presenter" + override val uiFactory = CircuitNames.CIRCUIT_UI_FACTORY + override val presenterFactory = CircuitNames.CIRCUIT_PRESENTER_FACTORY + override val defaultUiState = CircuitNames.CIRCUIT_UI_STATE + override val providesAssistedParamsToFunctions = true + + override fun uiState(symbols: CircuitSymbols) = symbols.circuitUiState + + override fun factoryTypeForSuperType(qualifiedName: String): FactoryType? = + when (qualifiedName) { + CircuitNames.CIRCUIT_UI.canonicalName -> FactoryType.UI + CircuitNames.CIRCUIT_PRESENTER.canonicalName -> FactoryType.PRESENTER + else -> null + } + + override fun functionFactoryType(functionName: String): FactoryType = + if (functionName.endsWith("Presenter")) FactoryType.PRESENTER else FactoryType.UI + + override fun isScreenType(type: KSType, symbols: CircuitSymbols): Boolean = + symbols.screen.isAssignableFrom(type) + + override fun isNavigatorType(type: KSType, symbols: CircuitSymbols): Boolean = + symbols.navigator.isAssignableFrom(type) + + override fun isContextType(type: KSType, symbols: CircuitSymbols): Boolean = + symbols.circuitContext.isAssignableFrom(type) + + override fun isProvidedFunctionParameter( + type: KSType, + factoryType: FactoryType, + symbols: CircuitSymbols, + ): Boolean { + if (isScreenType(type, symbols) || isContextType(type, symbols)) return true + return when (factoryType) { + FactoryType.PRESENTER -> isNavigatorType(type, symbols) + FactoryType.UI -> + symbols.circuitUiState.isAssignableFrom(type) || symbols.modifier.isAssignableFrom(type) + } + } + + override fun uiFunctionWrapperPrefix(): CodeBlock = + CodeBlock.of("%M", MemberName(CircuitNames.CIRCUIT_RUNTIME_UI_PACKAGE, "ui")) + + override fun createParams(factoryType: FactoryType): List = buildList { + add(ParameterSpec.builder("screen", CircuitNames.SCREEN).build()) + if (factoryType == FactoryType.PRESENTER) { + add(ParameterSpec.builder("navigator", CircuitNames.NAVIGATOR).build()) + } + add(ParameterSpec.builder("context", CircuitNames.CIRCUIT_CONTEXT).build()) + } + + override fun createReturn(factoryType: FactoryType): TypeName { + val base = + if (factoryType == FactoryType.UI) CircuitNames.CIRCUIT_UI + else CircuitNames.CIRCUIT_PRESENTER + return base.parameterizedBy(STAR).copy(nullable = true) + } + + override fun classBaseName( + annotatedDeclaration: KSClassDeclaration, + targetClass: KSClassDeclaration, + isAssisted: Boolean, + isKotlinInjectAnvil: Boolean, + ): String = targetClass.simpleName.getShortName() + + override fun classPackageName( + annotatedDeclaration: KSClassDeclaration, + targetClass: KSClassDeclaration, + ): String = targetClass.packageName.asString() + + override fun factorySuffix(factoryType: FactoryType): String = CircuitNames.FACTORY + + override fun notInjectableError(): String = + "@$annotationSimpleName-annotated classes must be injectable: annotate the class or a " + + "constructor with @Inject." + } + + data object SubCircuit : CodegenTarget { + override val injectAnnotation = CircuitNames.SUB_CIRCUIT_INJECT_ANNOTATION + override val annotationSimpleName = injectAnnotation.simpleName + override val uiNoun = "SubUi" + override val presenterNoun = "SubPresenter" + override val uiFactory = CircuitNames.SUB_UI_FACTORY + override val presenterFactory = CircuitNames.SUB_PRESENTER_FACTORY + override val defaultUiState = CircuitNames.SUB_CIRCUIT_UI_STATE + override val providesAssistedParamsToFunctions = false + + override fun uiState(symbols: CircuitSymbols) = symbols.subCircuitUiState + + override fun factoryTypeForSuperType(qualifiedName: String): FactoryType? = + when (qualifiedName) { + CircuitNames.SUB_UI.canonicalName -> FactoryType.UI + CircuitNames.SUB_PRESENTER.canonicalName -> FactoryType.PRESENTER + else -> null + } + + // SubCircuit has no `presenterOf` equivalent and SubPresenter is not a fun interface, so + // function presenters are unsupported: annotated functions are always UI. + override fun functionFactoryType(functionName: String): FactoryType = FactoryType.UI + + // SubScreen is generic, so a plain isAssignableFrom against the raw declaration is unreliable; + // walk the declaration's supertypes by qualified name instead. + override fun isScreenType(type: KSType, symbols: CircuitSymbols): Boolean { + val declaration = type.declaration as? KSClassDeclaration ?: return false + if (declaration.qualifiedName?.asString() == CircuitNames.SUB_SCREEN.canonicalName) { + return true + } + return declaration.getAllSuperTypes().any { + it.declaration.qualifiedName?.asString() == CircuitNames.SUB_SCREEN.canonicalName + } + } + + override fun isProvidedFunctionParameter( + type: KSType, + factoryType: FactoryType, + symbols: CircuitSymbols, + ): Boolean = + symbols.subCircuitUiState.isAssignableFrom(type) || symbols.modifier.isAssignableFrom(type) + + override fun uiFunctionWrapperPrefix(): CodeBlock = CodeBlock.of("%T", CircuitNames.SUB_UI) + + override fun createParams(factoryType: FactoryType): List = + listOf(ParameterSpec.builder("screen", CircuitNames.SUB_SCREEN.parameterizedBy(STAR)).build()) + + override fun createReturn(factoryType: FactoryType): TypeName = + if (factoryType == FactoryType.UI) { + CircuitNames.SUB_UI.parameterizedBy(STAR).copy(nullable = true) + } else { + CircuitNames.SUB_PRESENTER.parameterizedBy(STAR, STAR).copy(nullable = true) + } + + // For the Dagger/Anvil assisted path the generated factory is named after the annotated + // @AssistedFactory interface (e.g. TestPresenter.Factory -> TestPresenter_Factory). For the + // provider/kotlin-inject path it's named after the target class. + override fun classBaseName( + annotatedDeclaration: KSClassDeclaration, + targetClass: KSClassDeclaration, + isAssisted: Boolean, + isKotlinInjectAnvil: Boolean, + ): String = + if (isAssisted && !isKotlinInjectAnvil) { + annotatedDeclaration.toClassName().simpleNames.joinToString("_") + } else { + targetClass.simpleName.getShortName() + } + + override fun classPackageName( + annotatedDeclaration: KSClassDeclaration, + targetClass: KSClassDeclaration, + ): String = annotatedDeclaration.packageName.asString() + + override fun factorySuffix(factoryType: FactoryType): String = + if (factoryType == FactoryType.UI) { + CircuitNames.SUB_UI_FACTORY_SUFFIX + } else { + CircuitNames.SUB_PRESENTER_FACTORY_SUFFIX + } + + override fun notInjectableError(): String = + "@$annotationSimpleName-annotated classes must be injectable: annotate the class or a " + + "constructor with @Inject, or annotate an @AssistedFactory interface." + } +} diff --git a/circuit-codegen/src/test/kotlin/com/slack/circuit/codegen/SubCircuitTargetProcessorTest.kt b/circuit-codegen/src/test/kotlin/com/slack/circuit/codegen/SubCircuitTargetProcessorTest.kt new file mode 100644 index 0000000000..56836fb811 --- /dev/null +++ b/circuit-codegen/src/test/kotlin/com/slack/circuit/codegen/SubCircuitTargetProcessorTest.kt @@ -0,0 +1,1384 @@ +// Copyright (C) 2026 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.codegen + +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.KotlinCompilation.ExitCode +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.SourceFile.Companion.kotlin +import com.tschuchort.compiletesting.configureKsp +import com.tschuchort.compiletesting.kspProcessorOptions +import com.tschuchort.compiletesting.kspSourcesDir +import java.io.File +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.Test + +@Suppress("LargeClass") +@OptIn(ExperimentalCompilerApi::class) +class SubCircuitTargetProcessorTest { + + private val subCircuitStubs = + kotlin( + "SubCircuitStubs.kt", + """ + package com.slack.circuit.subcircuit + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import kotlin.reflect.KClass + + interface SubCircuitOuterEvent + + interface SubCircuitUiState + + interface SubScreen + + interface SubPresenter { + @Composable fun present(outerEventSink: (OuterEvent) -> Unit): State + } + + fun interface SubPresenterFactory { + fun create(screen: SubScreen<*>): SubPresenter<*, *>? + } + + fun interface SubUi { + @Composable fun Content(state: State, modifier: Modifier) + } + + fun interface SubUiFactory { + fun create(screen: SubScreen<*>): SubUi<*>? + } + + @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) + @Retention(AnnotationRetention.BINARY) + annotation class SubCircuitInject(val screen: KClass<*>, val scope: KClass<*>) + """ + .trimIndent(), + ) + + private val composableStub = + kotlin( + "Composable.kt", + """ + package androidx.compose.runtime + + @Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPE, + AnnotationTarget.TYPE_PARAMETER, + AnnotationTarget.PROPERTY_GETTER + ) + @Retention(AnnotationRetention.BINARY) + annotation class Composable + """ + .trimIndent(), + ) + + private val modifierStub = + kotlin( + "Modifier.kt", + """ + package androidx.compose.ui + + interface Modifier { + companion object : Modifier + } + """ + .trimIndent(), + ) + + private val assistedAnnotationsStub = + kotlin( + "AssistedAnnotations.kt", + """ + package dagger.assisted + + annotation class Assisted + annotation class AssistedInject + annotation class AssistedFactory + """ + .trimIndent(), + ) + + private val daggerStub = + kotlin( + "DaggerAnnotations.kt", + """ + package dagger + + annotation class Module + annotation class Binds + """ + .trimIndent(), + ) + + private val daggerHiltStub = + kotlin( + "HiltAnnotations.kt", + """ + package dagger.hilt + + import kotlin.reflect.KClass + + annotation class InstallIn(vararg val components: KClass<*>) + """ + .trimIndent(), + ) + + private val daggerHiltCodegenStub = + kotlin( + "HiltCodegenAnnotations.kt", + """ + package dagger.hilt.codegen + + import kotlin.reflect.KClass + + annotation class OriginatingElement(val topLevelClass: KClass<*>) + """ + .trimIndent(), + ) + + private val daggerMultibindingsStub = + kotlin( + "MultibindingsAnnotations.kt", + """ + package dagger.multibindings + + annotation class IntoSet + """ + .trimIndent(), + ) + + private val jakartaStub = + kotlin( + "JakartaInject.kt", + """ + package jakarta.inject + + annotation class Inject + annotation class Qualifier + + interface Provider { + fun get(): T + } + """ + .trimIndent(), + ) + + private val javaxStub = + kotlin( + "JavaxInject.kt", + """ + package javax.inject + + annotation class Inject + + interface Provider { + fun get(): T + } + """ + .trimIndent(), + ) + + private val metroStub = + kotlin( + "Metro.kt", + """ + package dev.zacsweers.metro + + import kotlin.reflect.KClass + + annotation class Inject + annotation class Assisted + annotation class AssistedFactory + annotation class Origin(val value: KClass<*>) + annotation class ContributesIntoSet(val scope: KClass<*>) + """ + .trimIndent(), + ) + + private val kotlinInjectStub = + kotlin( + "KotlinInject.kt", + """ + package me.tatarka.inject.annotations + + annotation class Inject + annotation class Assisted + """ + .trimIndent(), + ) + + private val kotlinInjectAnvilStub = + kotlin( + "KotlinInjectAnvil.kt", + """ + package software.amazon.lastmile.kotlin.inject.anvil + + import kotlin.reflect.KClass + + annotation class ContributesBinding(val scope: KClass<*>, val multibinding: Boolean = false) + """ + .trimIndent(), + ) + + private val kotlinInjectAnvilOriginStub = + kotlin( + "KotlinInjectAnvilOrigin.kt", + """ + package software.amazon.lastmile.kotlin.inject.anvil.internal + + import kotlin.reflect.KClass + + annotation class Origin(val value: KClass<*>) + """ + .trimIndent(), + ) + + private val qualifierStub = + kotlin( + "Named.kt", + """ + package test + + import jakarta.inject.Qualifier + + @Qualifier + annotation class MyQualifier + """ + .trimIndent(), + ) + + private val scopeStub = + kotlin( + "AppScope.kt", + """ + package test + + annotation class AppScope + """ + .trimIndent(), + ) + + private val anvilAnnotations = + kotlin( + "AnvilAnnotations.kt", + """ + package com.squareup.anvil.annotations + import kotlin.reflect.KClass + + annotation class ContributesMultibinding(val scope: KClass<*>) + """ + .trimIndent(), + ) + + @Test + fun presenterFactory_anvil() { + assertGeneratedFile( + sourceFile = + kotlin( + "TestPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import dagger.assisted.Assisted + import dagger.assisted.AssistedFactory + import dagger.assisted.AssistedInject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + class TestPresenter @AssistedInject constructor( + @Assisted val screen: TestScreen + ) : SubPresenter { + + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + + @SubCircuitInject(TestScreen::class, AppScope::class) + @AssistedFactory + interface Factory { + fun create(screen: TestScreen): TestPresenter + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/TestPresenter_Factory_SubPresenterFactory.kt", + expectedContent = + """ + package test + + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubPresenterFactory + import com.slack.circuit.subcircuit.SubScreen + import com.squareup.anvil.annotations.ContributesMultibinding + import jakarta.inject.Inject + + @ContributesMultibinding(AppScope::class) + public class TestPresenter_Factory_SubPresenterFactory @Inject constructor( + private val factory: TestPresenter.Factory, + ) : SubPresenterFactory { + override fun create(screen: SubScreen<*>): SubPresenter<*, *>? = when (screen) { + is TestScreen -> factory.create(screen = screen) + else -> null + } + } + """ + .trimIndent(), + ) + } + + @Test + fun presenterFactory_metro() { + assertGeneratedContains( + sourceFile = + kotlin( + "MetroPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import dev.zacsweers.metro.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Inject + class MetroPresenter : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/MetroPresenter_SubPresenterFactory.kt", + mode = CodegenMode.METRO, + expectedSubstrings = + listOf( + "import dev.zacsweers.metro.ContributesIntoSet", + "import dev.zacsweers.metro.Inject", + "@Inject", + "@ContributesIntoSet(AppScope::class)", + "is TestScreen -> provider()", + ), + ) + } + + @Test + fun presenterAssistedFactory_metro() { + // The provider-path Metro presenter is covered by presenterFactory_metro; this pins the + // @AssistedFactory path in Metro mode (Metro's @AssistedFactory + @Assisted, factory.create()). + assertGeneratedContains( + sourceFile = + kotlin( + "MetroAssistedPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import dev.zacsweers.metro.Assisted + import dev.zacsweers.metro.AssistedFactory + import dev.zacsweers.metro.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + class MetroAssistedPresenter @Inject constructor( + @Assisted val screen: TestScreen + ) : SubPresenter { + + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + + @SubCircuitInject(TestScreen::class, AppScope::class) + @AssistedFactory + interface Factory { + fun create(screen: TestScreen): MetroAssistedPresenter + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/MetroAssistedPresenter_Factory_SubPresenterFactory.kt", + mode = CodegenMode.METRO, + expectedSubstrings = + listOf( + "import dev.zacsweers.metro.ContributesIntoSet", + "import dev.zacsweers.metro.Inject", + "@Inject", + "@ContributesIntoSet(AppScope::class)", + "public class MetroAssistedPresenter_Factory_SubPresenterFactory", + "private val factory: MetroAssistedPresenter.Factory,", + "is TestScreen -> factory.create(screen = screen)", + ), + ) + } + + @Test + fun presenterProviderFactory_anvil() { + assertGeneratedFile( + sourceFile = + kotlin( + "PlainPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import jakarta.inject.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + class PlainPresenter @Inject constructor() : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/PlainPresenter_SubPresenterFactory.kt", + expectedContent = + """ + package test + + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubPresenterFactory + import com.slack.circuit.subcircuit.SubScreen + import com.squareup.anvil.annotations.ContributesMultibinding + import jakarta.inject.Inject + import jakarta.inject.Provider + + @ContributesMultibinding(AppScope::class) + public class PlainPresenter_SubPresenterFactory @Inject constructor( + private val provider: Provider, + ) : SubPresenterFactory { + override fun create(screen: SubScreen<*>): SubPresenter<*, *>? = when (screen) { + is TestScreen -> provider.get() + else -> null + } + } + """ + .trimIndent(), + ) + } + + @Test + fun uiFactory_anvil() { + assertGeneratedFile( + sourceFile = + kotlin( + "TestUi.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun TestUi(state: TestState, modifier: Modifier = Modifier) { + // UI implementation + } + """ + .trimIndent(), + ), + generatedFilePath = "test/TestUi_SubUiFactory.kt", + expectedContent = + """ + package test + + import com.slack.circuit.subcircuit.SubScreen + import com.slack.circuit.subcircuit.SubUi + import com.slack.circuit.subcircuit.SubUiFactory + import com.squareup.anvil.annotations.ContributesMultibinding + import jakarta.inject.Inject + + @ContributesMultibinding(AppScope::class) + public class TestUi_SubUiFactory @Inject constructor() : SubUiFactory { + override fun create(screen: SubScreen<*>): SubUi<*>? = when (screen) { + is TestScreen -> SubUi { state, modifier -> TestUi(state = state, modifier = modifier) } + else -> null + } + } + """ + .trimIndent(), + ) + } + + @Test + fun uiFactory_metro() { + assertGeneratedFile( + sourceFile = + kotlin( + "TestUi.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun TestUi(state: TestState, modifier: Modifier = Modifier) { + // UI implementation + } + """ + .trimIndent(), + ), + generatedFilePath = "test/TestUi_SubUiFactory.kt", + mode = CodegenMode.METRO, + expectedContent = + """ + package test + + import com.slack.circuit.subcircuit.SubScreen + import com.slack.circuit.subcircuit.SubUi + import com.slack.circuit.subcircuit.SubUiFactory + import dev.zacsweers.metro.ContributesIntoSet + import dev.zacsweers.metro.Inject + + @Inject + @ContributesIntoSet(AppScope::class) + public class TestUi_SubUiFactory : SubUiFactory { + override fun create(screen: SubScreen<*>): SubUi<*>? = when (screen) { + is TestScreen -> SubUi { state, modifier -> TestUi(state = state, modifier = modifier) } + else -> null + } + } + """ + .trimIndent(), + ) + } + + @Test + fun uiClassFactory_anvil() { + assertGeneratedFile( + sourceFile = + kotlin( + "TestUiClass.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import jakarta.inject.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + import com.slack.circuit.subcircuit.SubUi + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + class TestUiClass @Inject constructor() : SubUi { + @Composable + override fun Content(state: TestState, modifier: Modifier) { + // UI implementation + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/TestUiClass_SubUiFactory.kt", + expectedContent = + """ + package test + + import com.slack.circuit.subcircuit.SubScreen + import com.slack.circuit.subcircuit.SubUi + import com.slack.circuit.subcircuit.SubUiFactory + import com.squareup.anvil.annotations.ContributesMultibinding + import jakarta.inject.Inject + import jakarta.inject.Provider + + @ContributesMultibinding(AppScope::class) + public class TestUiClass_SubUiFactory @Inject constructor( + private val provider: Provider, + ) : SubUiFactory { + override fun create(screen: SubScreen<*>): SubUi<*>? = when (screen) { + is TestScreen -> provider.get() + else -> null + } + } + """ + .trimIndent(), + ) + } + + @Test + fun presenterFactory_topLevel() { + assertGeneratedFile( + sourceFile = + kotlin( + "TopLevelPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import dagger.assisted.Assisted + import dagger.assisted.AssistedFactory + import dagger.assisted.AssistedInject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + class TopLevelPresenter @AssistedInject constructor( + @Assisted val screen: TestScreen + ) : SubPresenter { + + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + + @SubCircuitInject(TestScreen::class, AppScope::class) + @AssistedFactory + interface TopLevelPresenterFactory { + fun create(screen: TestScreen): TopLevelPresenter + } + """ + .trimIndent(), + ), + generatedFilePath = "test/TopLevelPresenterFactory_SubPresenterFactory.kt", + expectedContent = + """ + package test + + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubPresenterFactory + import com.slack.circuit.subcircuit.SubScreen + import com.squareup.anvil.annotations.ContributesMultibinding + import jakarta.inject.Inject + + @ContributesMultibinding(AppScope::class) + public class TopLevelPresenterFactory_SubPresenterFactory @Inject constructor( + private val factory: TopLevelPresenterFactory, + ) : SubPresenterFactory { + override fun create(screen: SubScreen<*>): SubPresenter<*, *>? = when (screen) { + is TestScreen -> factory.create(screen = screen) + else -> null + } + } + """ + .trimIndent(), + ) + } + + @Test + fun objectScreen_anvil() { + assertGeneratedContains( + sourceFile = + kotlin( + "ObjectScreenUi.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + object TestScreen : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun ObjectScreenUi(state: TestState, modifier: Modifier = Modifier) { + // UI implementation + } + """ + .trimIndent(), + ), + generatedFilePath = "test/ObjectScreenUi_SubUiFactory.kt", + mode = CodegenMode.ANVIL, + expectedSubstrings = listOf("TestScreen -> SubUi"), + unexpectedSubstrings = listOf("is TestScreen ->"), + ) + } + + @Test + fun useJavaxOnly_anvil() { + assertGeneratedContains( + sourceFile = + kotlin( + "JavaxUi.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun JavaxUi(state: TestState, modifier: Modifier = Modifier) { + // UI implementation + } + """ + .trimIndent(), + ), + generatedFilePath = "test/JavaxUi_SubUiFactory.kt", + mode = CodegenMode.ANVIL, + processorOptions = mapOf(CircuitOptions.USE_JAVAX_ONLY to "true"), + expectedSubstrings = listOf("import javax.inject.Inject"), + unexpectedSubstrings = listOf("import jakarta.inject.Inject"), + ) + } + + @Test + fun legacySubCircuitModeOption_stillHonored() { + // The old subcircuit-codegen artifact used `subcircuit.codegen.mode`. That artifact now + // relocates to circuit-codegen, so the legacy key must still select the mode when the new + // `circuit.codegen.mode` key is absent. + val sourceFile = + kotlin( + "LegacyModeUi.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun LegacyModeUi(state: TestState, modifier: Modifier = Modifier) { + // UI implementation + } + """ + .trimIndent(), + ) + val compilation = + KotlinCompilation().apply { + jvmTarget = "11" + sources = + listOf( + sourceFile, + subCircuitStubs, + composableStub, + modifierStub, + assistedAnnotationsStub, + daggerStub, + daggerHiltStub, + daggerHiltCodegenStub, + daggerMultibindingsStub, + jakartaStub, + javaxStub, + metroStub, + kotlinInjectStub, + kotlinInjectAnvilStub, + kotlinInjectAnvilOriginStub, + qualifierStub, + scopeStub, + anvilAnnotations, + ) + inheritClassPath = true + configureKsp { + // Only the legacy key is set; the new `circuit.codegen.mode` is intentionally omitted. + kspProcessorOptions += "subcircuit.codegen.mode" to "metro" + symbolProcessorProviders += CircuitSymbolProcessorProvider() + } + } + val result = compilation.compile() + assertEquals(ExitCode.OK, result.exitCode, result.messages) + val content = + generatedFile(compilation, "test/LegacyModeUi_SubUiFactory.kt", result.messages).readText() + assertContains(content, "import dev.zacsweers.metro.ContributesIntoSet") + assertContains(content, "@ContributesIntoSet(AppScope::class)") + } + + @Test + fun subCircuitOnly_withoutCircuitRuntimeOnClasspath() { + // The whole point of CircuitSymbols loading each runtime type lazily is that a SubCircuit-only + // consumer need not have circuit-runtime on its classpath. Every other test inherits the host + // classpath (which includes circuit-runtime), so this one compiles WITHOUT it: only the + // SubCircuit stubs + Compose/DI stubs are on the classpath, no com.slack.circuit.runtime.*. + // If the SubCircuit path ever touched a circuit-only symbol (screen/navigator/context/state), + // its lazy getter would throw "Could not find ... in classpath" and fail this compilation. + val sourceFile = + kotlin( + "SubOnlyUi.kt", + """ + package test + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun SubOnlyUi(state: TestState, modifier: Modifier = Modifier) { + // UI implementation + } + """ + .trimIndent(), + ) + val compilation = + KotlinCompilation().apply { + jvmTarget = "11" + sources = + listOf( + sourceFile, + subCircuitStubs, + composableStub, + modifierStub, + assistedAnnotationsStub, + daggerStub, + daggerHiltStub, + daggerHiltCodegenStub, + daggerMultibindingsStub, + jakartaStub, + javaxStub, + metroStub, + kotlinInjectStub, + kotlinInjectAnvilStub, + kotlinInjectAnvilOriginStub, + qualifierStub, + scopeStub, + anvilAnnotations, + ) + // Deliberately do NOT inherit the host classpath, so circuit-runtime is genuinely absent. + inheritClassPath = false + configureKsp { + kspProcessorOptions += CircuitOptions.MODE to CodegenMode.ANVIL.name + symbolProcessorProviders += CircuitSymbolProcessorProvider() + } + } + val result = compilation.compile() + assertEquals(ExitCode.OK, result.exitCode, result.messages) + val content = + generatedFile(compilation, "test/SubOnlyUi_SubUiFactory.kt", result.messages).readText() + assertContains(content, ": SubUiFactory") + assertContains(content, "is TestScreen -> SubUi") + } + + @Test + fun lenient_matchesBySimpleName() { + // With lenient matching on, the dagger.assisted.AssistedFactory annotation is matched purely by + // simple name, so an unrelated AssistedFactory annotation still triggers generation. + assertGeneratedContains( + sourceFile = + kotlin( + "LenientPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import dagger.assisted.AssistedFactory + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + class LenientPresenter : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + + @SubCircuitInject(TestScreen::class, AppScope::class) + @AssistedFactory + interface Factory { + fun create(screen: TestScreen): LenientPresenter + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/LenientPresenter_Factory_SubPresenterFactory.kt", + mode = CodegenMode.ANVIL, + processorOptions = mapOf(CircuitOptions.LENIENT to "true"), + expectedSubstrings = listOf("is TestScreen -> factory.create(screen = screen)"), + ) + } + + @Test + fun qualifierPropagation_anvil() { + assertGeneratedContains( + sourceFile = + kotlin( + "QualifiedPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import jakarta.inject.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @MyQualifier + @SubCircuitInject(TestScreen::class, AppScope::class) + class QualifiedPresenter @Inject constructor() : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/QualifiedPresenter_SubPresenterFactory.kt", + mode = CodegenMode.ANVIL, + expectedSubstrings = listOf("@MyQualifier"), + ) + } + + @Test + fun hiltModule_presenter() { + assertGeneratedContains( + sourceFile = + kotlin( + "HiltPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import jakarta.inject.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + class HiltPresenter @Inject constructor() : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/HiltPresenter_SubPresenterFactoryModule.kt", + mode = CodegenMode.HILT, + expectedSubstrings = + listOf( + "@Module", + "@InstallIn(AppScope::class)", + "@Binds", + "@IntoSet", + ": SubPresenterFactory", + ), + ) + } + + @Test + fun kotlinInjectAnvil_presenter() { + assertGeneratedContains( + sourceFile = + kotlin( + "KiPresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import me.tatarka.inject.annotations.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Inject + class KiPresenter : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + """ + .trimIndent(), + ), + generatedFilePath = "test/KiPresenter_SubPresenterFactory.kt", + mode = CodegenMode.KOTLIN_INJECT_ANVIL, + expectedSubstrings = + listOf( + "import me.tatarka.inject.annotations.Inject", + "multibinding = true", + "() -> KiPresenter", + "is TestScreen -> provider()", + ), + ) + } + + @Test + fun error_nonComposableFunction() { + assertProcessingError( + sourceFile = + kotlin( + "NotComposable.kt", + """ + package test + + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + fun NotComposable(state: TestState) { + // Not a composable function + } + """ + .trimIndent(), + ) + ) { messages -> + assertContains(messages, "SubUi composable functions must have a Modifier parameter!") + } + } + + @Test + fun error_missingModifierParameter() { + assertProcessingError( + sourceFile = + kotlin( + "NoModifier.kt", + """ + package test + + import androidx.compose.runtime.Composable + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + @Composable + fun NoModifier(state: TestState) { + // Missing modifier parameter + } + """ + .trimIndent(), + ) + ) { messages -> + assertContains(messages, "SubUi composable functions must have a Modifier parameter!") + } + } + + @Test + fun error_factoryReturnsNonPresenter() { + assertProcessingError( + sourceFile = + kotlin( + "BadFactory.kt", + """ + package test + + import dagger.assisted.AssistedFactory + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestScreen(val id: String) : SubScreen + + class NotAPresenter + + @SubCircuitInject(TestScreen::class, AppScope::class) + @AssistedFactory + interface BadFactory { + fun create(): NotAPresenter + } + """ + .trimIndent(), + ) + ) { messages -> + assertContains(messages, "Factory must be for a SubUi or SubPresenter class") + } + } + + @Test + fun error_privateClass() { + assertProcessingError( + sourceFile = + kotlin( + "PrivatePresenter.kt", + """ + package test + + import androidx.compose.runtime.Composable + import jakarta.inject.Inject + import com.slack.circuit.subcircuit.SubCircuitInject + import com.slack.circuit.subcircuit.SubCircuitOuterEvent + import com.slack.circuit.subcircuit.SubCircuitUiState + import com.slack.circuit.subcircuit.SubPresenter + import com.slack.circuit.subcircuit.SubScreen + + sealed interface TestEvent : SubCircuitOuterEvent + + data class TestState(val data: String) : SubCircuitUiState + + data class TestScreen(val id: String) : SubScreen + + @SubCircuitInject(TestScreen::class, AppScope::class) + private class PrivatePresenter @Inject constructor() : SubPresenter { + @Composable + override fun present(outerEventSink: (TestEvent) -> Unit): TestState { + return TestState("test") + } + } + """ + .trimIndent(), + ) + ) { messages -> + assertContains( + messages, + "SubCircuitInject is not applicable to private or local functions and classes.", + ) + } + } + + private fun assertGeneratedFile( + sourceFile: SourceFile, + generatedFilePath: String, + @Language("kotlin") expectedContent: String, + mode: CodegenMode = CodegenMode.ANVIL, + ) { + val compilation = prepareCompilation(sourceFile, mode = mode) + val result = compilation.compile() + assertEquals(ExitCode.OK, result.exitCode, result.messages) + val generatedFile = generatedFile(compilation, generatedFilePath, result.messages) + assertEquals(expectedContent, generatedFile.readText().trim()) + } + + private fun assertGeneratedContains( + sourceFile: SourceFile, + generatedFilePath: String, + mode: CodegenMode, + expectedSubstrings: List, + unexpectedSubstrings: List = emptyList(), + processorOptions: Map = emptyMap(), + ) { + val compilation = + prepareCompilation(sourceFile, mode = mode, processorOptions = processorOptions) + val result = compilation.compile() + assertEquals(ExitCode.OK, result.exitCode, result.messages) + val content = generatedFile(compilation, generatedFilePath, result.messages).readText() + for (substring in expectedSubstrings) { + assertContains( + content, + substring, + message = "Expected generated file to contain:\n$substring\n\n$content", + ) + } + for (substring in unexpectedSubstrings) { + assertTrue( + !content.contains(substring), + "Expected generated file NOT to contain:\n$substring\n\n$content", + ) + } + } + + private fun generatedFile( + compilation: KotlinCompilation, + generatedFilePath: String, + messages: String, + ): File { + val generatedSourcesDir = compilation.kspSourcesDir + val generatedFile = File(generatedSourcesDir, "kotlin/$generatedFilePath") + if (!generatedFile.exists()) { + throw AssertionError("No generated file found at path $generatedFilePath\n$messages") + } + return generatedFile + } + + private fun assertProcessingError(sourceFile: SourceFile, body: (messages: String) -> Unit) { + val compilation = prepareCompilation(sourceFile) + val result = compilation.compile() + assertEquals(ExitCode.COMPILATION_ERROR, result.exitCode, result.messages) + body(result.messages) + } + + private fun prepareCompilation( + vararg sourceFiles: SourceFile, + mode: CodegenMode = CodegenMode.ANVIL, + processorOptions: Map = emptyMap(), + ): KotlinCompilation = + KotlinCompilation().apply { + jvmTarget = "11" + sources = + sourceFiles.toList() + + listOf( + subCircuitStubs, + composableStub, + modifierStub, + assistedAnnotationsStub, + daggerStub, + daggerHiltStub, + daggerHiltCodegenStub, + daggerMultibindingsStub, + jakartaStub, + javaxStub, + metroStub, + kotlinInjectStub, + kotlinInjectAnvilStub, + kotlinInjectAnvilOriginStub, + qualifierStub, + scopeStub, + anvilAnnotations, + ) + inheritClassPath = true + configureKsp { + kspProcessorOptions += CircuitOptions.MODE to mode.name + kspProcessorOptions += processorOptions + symbolProcessorProviders += CircuitSymbolProcessorProvider() + } + } +} diff --git a/circuitx/subcircuit-codegen/api/subcircuit-codegen.api b/circuitx/subcircuit-codegen/api/subcircuit-codegen.api deleted file mode 100644 index 446eb6736d..0000000000 --- a/circuitx/subcircuit-codegen/api/subcircuit-codegen.api +++ /dev/null @@ -1,18 +0,0 @@ -public final class com/slack/circuit/subcircuit/codegen/CodegenMode : java/lang/Enum { - public static final field ANVIL Lcom/slack/circuit/subcircuit/codegen/CodegenMode; - public static final field METRO Lcom/slack/circuit/subcircuit/codegen/CodegenMode; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Lcom/slack/circuit/subcircuit/codegen/CodegenMode; - public static fun values ()[Lcom/slack/circuit/subcircuit/codegen/CodegenMode; -} - -public final class com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessor : com/google/devtools/ksp/processing/SymbolProcessor { - public fun (Lcom/google/devtools/ksp/processing/SymbolProcessorEnvironment;)V - public fun process (Lcom/google/devtools/ksp/processing/Resolver;)Ljava/util/List; -} - -public final class com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessor$Provider : com/google/devtools/ksp/processing/SymbolProcessorProvider { - public fun ()V - public fun create (Lcom/google/devtools/ksp/processing/SymbolProcessorEnvironment;)Lcom/google/devtools/ksp/processing/SymbolProcessor; -} - diff --git a/circuitx/subcircuit-codegen/build.gradle.kts b/circuitx/subcircuit-codegen/build.gradle.kts index 5e6155e4ab..3965803f1c 100644 --- a/circuitx/subcircuit-codegen/build.gradle.kts +++ b/circuitx/subcircuit-codegen/build.gradle.kts @@ -1,28 +1,30 @@ // Copyright (C) 2026 Slack Technologies, LLC // SPDX-License-Identifier: Apache-2.0 +import com.vanniktech.maven.publish.MavenPublishBaseExtension + +// Maven relocation pointer to circuit-codegen, which handles @SubCircuitInject. Ships no code. plugins { - kotlin("jvm") - alias(libs.plugins.ksp) - id("circuit.base") - id("circuit.publish") + `java-library` + alias(libs.plugins.mavenPublish) } -dependencies { - compileOnly(libs.ksp.api) - compileOnly(libs.anvil.annotations) - ksp(libs.autoService.ksp) - implementation(libs.autoService.annotations) - implementation(libs.dagger) - implementation(libs.kotlinpoet) - implementation(libs.kotlinpoet.ksp) - - testImplementation(libs.junit) - testImplementation(libs.kct) - testImplementation(libs.kct.ksp) - testImplementation(libs.kotlin.compilerEmbeddable) - testImplementation(libs.ksp) - testImplementation(libs.ksp.api) - testImplementation(libs.truth) - testImplementation(projects.circuitx.subcircuitCodegenAnnotations) - testImplementation(projects.circuitx.subcircuit) +configure { + publishToMavenCentral(automaticRelease = true) + signAllPublications() + pom { + distributionManagement { + relocation { + groupId.set("com.slack.circuit") + artifactId.set("circuit-codegen") + message.set( + "circuitx-subcircuit-codegen has been merged into circuit-codegen. " + + "@SubCircuitInject is handled by the main Circuit KSP processor." + ) + } + } + } } + +// Gradle module metadata does not honor Maven , and consumers prefer it over the POM. +// Disable it so both Gradle and Maven consumers read the POM and follow the relocation. +tasks.withType().configureEach { enabled = false } diff --git a/circuitx/subcircuit-codegen/dependencies/runtimeClasspath.txt b/circuitx/subcircuit-codegen/dependencies/runtimeClasspath.txt deleted file mode 100644 index 1b3bdf315c..0000000000 --- a/circuitx/subcircuit-codegen/dependencies/runtimeClasspath.txt +++ /dev/null @@ -1,12 +0,0 @@ -com.google.auto.service:auto-service-annotations -com.google.dagger:dagger -com.squareup:kotlinpoet-jvm -com.squareup:kotlinpoet-ksp -com.squareup:kotlinpoet -jakarta.inject:jakarta.inject-api -javax.inject:javax.inject -org.jetbrains.kotlin:kotlin-bom -org.jetbrains.kotlin:kotlin-reflect -org.jetbrains.kotlin:kotlin-stdlib -org.jetbrains:annotations -org.jspecify:jspecify diff --git a/circuitx/subcircuit-codegen/gradle.properties b/circuitx/subcircuit-codegen/gradle.properties index ebc12009e8..6926547f27 100644 --- a/circuitx/subcircuit-codegen/gradle.properties +++ b/circuitx/subcircuit-codegen/gradle.properties @@ -1,3 +1,4 @@ POM_ARTIFACT_ID=circuitx-subcircuit-codegen POM_NAME=CircuitX (SubCircuit Codegen) -POM_DESCRIPTION=SubCircuit KSP code generation processor +POM_DESCRIPTION=Relocated to circuit-codegen. @SubCircuitInject is handled by the main Circuit KSP processor. +POM_PACKAGING=jar diff --git a/circuitx/subcircuit-codegen/src/main/kotlin/com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessor.kt b/circuitx/subcircuit-codegen/src/main/kotlin/com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessor.kt deleted file mode 100644 index fecb5a5ff4..0000000000 --- a/circuitx/subcircuit-codegen/src/main/kotlin/com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessor.kt +++ /dev/null @@ -1,440 +0,0 @@ -// Copyright (C) 2026 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.subcircuit.codegen - -import com.google.auto.service.AutoService -import com.google.devtools.ksp.getAllSuperTypes -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.ClassKind -import com.google.devtools.ksp.symbol.KSAnnotated -import com.google.devtools.ksp.symbol.KSAnnotation -import com.google.devtools.ksp.symbol.KSClassDeclaration -import com.google.devtools.ksp.symbol.KSFunctionDeclaration -import com.google.devtools.ksp.symbol.KSType -import com.squareup.kotlinpoet.AnnotationSpec -import com.squareup.kotlinpoet.ClassName -import com.squareup.kotlinpoet.FileSpec -import com.squareup.kotlinpoet.FunSpec -import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy -import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.STAR -import com.squareup.kotlinpoet.TypeName -import com.squareup.kotlinpoet.TypeSpec -import com.squareup.kotlinpoet.ksp.addOriginatingKSFile -import com.squareup.kotlinpoet.ksp.toClassName -import com.squareup.kotlinpoet.ksp.writeTo - -private const val SUB_CIRCUIT_INJECT_FQNAME = "com.slack.circuit.subcircuit.SubCircuitInject" -private const val ASSISTED_FACTORY_FQNAME = "dagger.assisted.AssistedFactory" -private const val METRO_ASSISTED_FACTORY_FQNAME = "dev.zacsweers.metro.AssistedFactory" -private const val OPTION_CODEGEN_MODE = "subcircuit.codegen.mode" - -private val SUB_PRESENTER_FACTORY_CN = - ClassName("com.slack.circuit.subcircuit", "SubPresenterFactory") -private val SUB_UI_FACTORY_CN = ClassName("com.slack.circuit.subcircuit", "SubUiFactory") -private val SUB_PRESENTER_CN = ClassName("com.slack.circuit.subcircuit", "SubPresenter") -private val SUB_UI_CN = ClassName("com.slack.circuit.subcircuit", "SubUi") -private val SUB_SCREEN_CN = ClassName("com.slack.circuit.subcircuit", "SubScreen") -private val COMPOSABLE_CN = ClassName("androidx.compose.runtime", "Composable") - -private val CONTRIBUTES_MULTIBINDING_CN = - ClassName("com.squareup.anvil.annotations", "ContributesMultibinding") - -private val METRO_CONTRIBUTES_INTO_SET_CN = ClassName("dev.zacsweers.metro", "ContributesIntoSet") - -private val INJECT_CN = ClassName("javax.inject", "Inject") -private val METRO_INJECT_CN = ClassName("dev.zacsweers.metro", "Inject") - -/** Codegen mode for dependency injection framework support. */ -public enum class CodegenMode { - /** Uses Anvil's @ContributesMultibinding with @Inject on constructor */ - ANVIL, - /** Uses Metro's @ContributesIntoSet with @Inject on class */ - METRO, -} - -/** - * KSP Symbol Processor that generates factory implementations for `@SubCircuitInject` annotations. - * - * For presenter classes: Annotate the `@AssistedFactory` interface with `@SubCircuitInject`. The - * factory doesn't need to be nested in the presenter class. - * - * For `@Composable` UI functions: Annotate the function directly with `@SubCircuitInject`. - * - * Supports both Anvil (default) and Metro codegen modes via the `subcircuit.codegen.mode` option. - */ -public class SubCircuitSymbolProcessor(private val env: SymbolProcessorEnvironment) : - SymbolProcessor { - - private lateinit var subCircuitInjectType: KSType - - private val codegenMode: CodegenMode by lazy { - val modeOption = env.options[OPTION_CODEGEN_MODE] - when (modeOption?.lowercase()) { - "metro" -> CodegenMode.METRO - "anvil", - null -> CodegenMode.ANVIL - else -> { - env.logger.warn("Unknown codegen mode '$modeOption', defaulting to ANVIL") - CodegenMode.ANVIL - } - } - } - - override fun process(resolver: Resolver): List { - subCircuitInjectType = - resolver - .getClassDeclarationByName(resolver.getKSNameFromString(SUB_CIRCUIT_INJECT_FQNAME)) - ?.asType(emptyList()) ?: return emptyList() - - resolver.getSymbolsWithAnnotation(SUB_CIRCUIT_INJECT_FQNAME).forEach { symbol -> - when (symbol) { - is KSClassDeclaration -> processClassAnnotation(symbol) - is KSFunctionDeclaration -> processUiFunction(symbol) - else -> { - env.logger.error( - "@SubCircuitInject can only be applied to @AssistedFactory interfaces or @Composable functions", - symbol, - ) - } - } - } - - return emptyList() - } - - private fun KSAnnotated.getSubCircuitInjectAnnotation(): KSAnnotation? = annotations.firstOrNull { - it.annotationType.resolve() == subCircuitInjectType - } - - private fun KSAnnotation.getScreenType(): ClassName? { - val screenArg = arguments.find { it.name?.asString() == "screen" } - return (screenArg?.value as? KSType)?.toClassName() - } - - private fun KSAnnotation.getScopeType(): ClassName? { - val scopeArg = arguments.find { it.name?.asString() == "scope" } - return (scopeArg?.value as? KSType)?.toClassName() - } - - private fun KSAnnotated.hasAnnotation(fqName: String): Boolean = annotations.any { - it.annotationType.resolve().declaration.qualifiedName?.asString() == fqName - } - - @Suppress("ReturnCount") - private fun processClassAnnotation(classDecl: KSClassDeclaration) { - if (classDecl.classKind != ClassKind.INTERFACE) { - env.logger.error( - "@SubCircuitInject on classes is only valid for @AssistedFactory interfaces", - classDecl, - ) - return - } - - val assistedFactoryFqName = - when (codegenMode) { - CodegenMode.ANVIL -> ASSISTED_FACTORY_FQNAME - CodegenMode.METRO -> METRO_ASSISTED_FACTORY_FQNAME - } - if (!classDecl.hasAnnotation(assistedFactoryFqName)) { - env.logger.error( - "@SubCircuitInject must be combined with @AssistedFactory on factory interfaces", - classDecl, - ) - return - } - - val annotation = classDecl.getSubCircuitInjectAnnotation() - if (annotation == null) { - env.logger.error("Could not read @SubCircuitInject annotation", classDecl) - return - } - - val screenClassName = annotation.getScreenType() - val scopeClassName = annotation.getScopeType() - - if (screenClassName == null || scopeClassName == null) { - env.logger.error("@SubCircuitInject requires both screen and scope parameters", classDecl) - return - } - - val createMethod = - classDecl.getAllFunctions().firstOrNull { func -> - func.simpleName.asString() == "create" || func.simpleName.asString().startsWith("create") - } - - if (createMethod == null) { - env.logger.error("@AssistedFactory interface must have a create method", classDecl) - return - } - - val presenterType = createMethod.returnType?.resolve() - if (presenterType == null) { - env.logger.error("Could not determine presenter type from factory create method", classDecl) - return - } - - val presenterDecl = presenterType.declaration as? KSClassDeclaration - if (presenterDecl == null) { - env.logger.error("Factory create method must return a presenter class", classDecl) - return - } - - val implementsSubPresenter = - presenterDecl.getAllSuperTypes().any { - it.declaration.qualifiedName?.asString() == SUB_PRESENTER_CN.canonicalName - } - - if (!implementsSubPresenter) { - env.logger.error( - "Presenter class ${presenterDecl.simpleName.asString()} must implement SubPresenter", - classDecl, - ) - return - } - - generatePresenterFactory( - factoryInterface = classDecl, - screenClassName = screenClassName, - scopeClassName = scopeClassName, - ) - } - - private fun generatePresenterFactory( - factoryInterface: KSClassDeclaration, - screenClassName: ClassName, - scopeClassName: ClassName, - ) { - val factoryClassName = factoryInterface.toClassName() - val generatedClassName = "${factoryClassName.simpleNames.joinToString("_")}_SubPresenterFactory" - val packageName = factoryClassName.packageName - - val factorySpec = - when (codegenMode) { - CodegenMode.ANVIL -> - buildAnvilPresenterFactory( - generatedClassName, - factoryClassName, - screenClassName, - scopeClassName, - ) - CodegenMode.METRO -> - buildMetroPresenterFactory( - generatedClassName, - factoryClassName, - screenClassName, - scopeClassName, - ) - } - - FileSpec.builder(packageName, generatedClassName) - .addFileComment("Generated by ${SubCircuitSymbolProcessor::class.simpleName}") - .addType(factorySpec.addOriginatingKSFile(factoryInterface.containingFile!!).build()) - .build() - .writeTo(env.codeGenerator, aggregating = false) - } - - private fun buildAnvilPresenterFactory( - generatedClassName: String, - factoryClassName: ClassName, - screenClassName: ClassName, - scopeClassName: ClassName, - ): TypeSpec.Builder = - TypeSpec.classBuilder(generatedClassName) - .addAnnotation( - AnnotationSpec.builder(CONTRIBUTES_MULTIBINDING_CN) - .addMember("%T::class", scopeClassName) - .build() - ) - .primaryConstructor( - FunSpec.constructorBuilder() - .addAnnotation(INJECT_CN) - .addParameter("factory", factoryClassName) - .build() - ) - .addProperty( - PropertySpec.builder("factory", factoryClassName) - .initializer("factory") - .addModifiers(KModifier.PRIVATE) - .build() - ) - .addSuperinterface(SUB_PRESENTER_FACTORY_CN) - .addFunction(buildPresenterCreateFunction(screenClassName)) - - private fun buildMetroPresenterFactory( - generatedClassName: String, - factoryClassName: ClassName, - screenClassName: ClassName, - scopeClassName: ClassName, - ): TypeSpec.Builder = - TypeSpec.classBuilder(generatedClassName) - .addAnnotation(METRO_INJECT_CN) - .addAnnotation( - AnnotationSpec.builder(METRO_CONTRIBUTES_INTO_SET_CN) - .addMember("%T::class", scopeClassName) - .build() - ) - .primaryConstructor( - FunSpec.constructorBuilder().addParameter("factory", factoryClassName).build() - ) - .addProperty( - PropertySpec.builder("factory", factoryClassName) - .initializer("factory") - .addModifiers(KModifier.PRIVATE) - .build() - ) - .addSuperinterface(SUB_PRESENTER_FACTORY_CN) - .addFunction(buildPresenterCreateFunction(screenClassName)) - - private fun buildPresenterCreateFunction(screenClassName: ClassName): FunSpec = - FunSpec.builder("create") - .addModifiers(KModifier.OVERRIDE) - .addParameter("screen", SUB_SCREEN_CN.parameterizedBy(STAR)) - .returns(SUB_PRESENTER_CN.parameterizedBy(STAR, STAR).copy(nullable = true)) - .addStatement("return if (screen is %T) factory.create(screen) else null", screenClassName) - .build() - - private fun processUiFunction(funcDecl: KSFunctionDeclaration) { - val isComposable = - funcDecl.annotations.any { - it.annotationType.resolve().declaration.qualifiedName?.asString() == - COMPOSABLE_CN.canonicalName - } - - if (!isComposable) { - env.logger.error( - "@SubCircuitInject on functions is only valid for @Composable functions", - funcDecl, - ) - return - } - - val annotation = funcDecl.getSubCircuitInjectAnnotation() - if (annotation == null) { - env.logger.error("Could not read @SubCircuitInject annotation", funcDecl) - return - } - - val screenClassName = annotation.getScreenType() - val scopeClassName = annotation.getScopeType() - - if (screenClassName == null || scopeClassName == null) { - env.logger.error("@SubCircuitInject requires both screen and scope parameters", funcDecl) - return - } - - val parameters = funcDecl.parameters - if (parameters.isEmpty()) { - env.logger.error( - "@SubCircuitInject UI functions must have at least a state parameter", - funcDecl, - ) - return - } - - val stateParameter = parameters.first() - val stateType = stateParameter.type.resolve().toClassName() - - val funcName = funcDecl.simpleName.asString() - val packageName = funcDecl.packageName.asString() - val generatedClassName = "${funcName}_SubUiFactory" - - val factorySpec = - when (codegenMode) { - CodegenMode.ANVIL -> - buildAnvilUiFactory( - generatedClassName, - funcName, - stateType, - screenClassName, - scopeClassName, - ) - CodegenMode.METRO -> - buildMetroUiFactory( - generatedClassName, - funcName, - stateType, - screenClassName, - scopeClassName, - ) - } - - FileSpec.builder(packageName, generatedClassName) - .addFileComment("Generated by ${SubCircuitSymbolProcessor::class.simpleName}") - .addType(factorySpec.addOriginatingKSFile(funcDecl.containingFile!!).build()) - .build() - .writeTo(env.codeGenerator, aggregating = false) - } - - private fun buildAnvilUiFactory( - generatedClassName: String, - funcName: String, - stateType: ClassName, - screenClassName: ClassName, - scopeClassName: ClassName, - ): TypeSpec.Builder = - TypeSpec.classBuilder(generatedClassName) - .addAnnotation( - AnnotationSpec.builder(CONTRIBUTES_MULTIBINDING_CN) - .addMember("%T::class", scopeClassName) - .build() - ) - .primaryConstructor(FunSpec.constructorBuilder().addAnnotation(INJECT_CN).build()) - .addSuperinterface(SUB_UI_FACTORY_CN) - .addFunction(buildUiCreateFunction(funcName, stateType, screenClassName)) - - private fun buildMetroUiFactory( - generatedClassName: String, - funcName: String, - stateType: ClassName, - screenClassName: ClassName, - scopeClassName: ClassName, - ): TypeSpec.Builder = - TypeSpec.classBuilder(generatedClassName) - .addAnnotation(METRO_INJECT_CN) - .addAnnotation( - AnnotationSpec.builder(METRO_CONTRIBUTES_INTO_SET_CN) - .addMember("%T::class", scopeClassName) - .build() - ) - .primaryConstructor(FunSpec.constructorBuilder().build()) - .addSuperinterface(SUB_UI_FACTORY_CN) - .addFunction(buildUiCreateFunction(funcName, stateType, screenClassName)) - - private fun buildUiCreateFunction( - funcName: String, - stateType: ClassName, - screenClassName: ClassName, - ): FunSpec = - FunSpec.builder("create") - .addModifiers(KModifier.OVERRIDE) - .addParameter("screen", SUB_SCREEN_CN.parameterizedBy(STAR)) - .returns(SUB_UI_CN.parameterizedBy(STAR).copy(nullable = true)) - .beginControlFlow("return if (screen is %T)", screenClassName) - .addStatement( - "%T<%T> { state, modifier -> %L(state, modifier) }", - SUB_UI_CN, - stateType, - funcName, - ) - .nextControlFlow("else") - .addStatement("null") - .endControlFlow() - .build() - - @AutoService(SymbolProcessorProvider::class) - public class Provider : SymbolProcessorProvider { - override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { - return SubCircuitSymbolProcessor(environment) - } - } -} - -private fun ClassName.parameterizedBy(vararg types: TypeName) = - com.squareup.kotlinpoet.ParameterizedTypeName.Companion.run { - this@parameterizedBy.parameterizedBy(*types) - } diff --git a/circuitx/subcircuit-codegen/src/test/kotlin/com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessorTest.kt b/circuitx/subcircuit-codegen/src/test/kotlin/com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessorTest.kt deleted file mode 100644 index 6425c0747e..0000000000 --- a/circuitx/subcircuit-codegen/src/test/kotlin/com/slack/circuit/subcircuit/codegen/SubCircuitSymbolProcessorTest.kt +++ /dev/null @@ -1,669 +0,0 @@ -// Copyright (C) 2026 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuit.subcircuit.codegen - -import com.google.common.truth.Truth.assertThat -import com.tschuchort.compiletesting.KotlinCompilation -import com.tschuchort.compiletesting.KotlinCompilation.ExitCode -import com.tschuchort.compiletesting.SourceFile -import com.tschuchort.compiletesting.SourceFile.Companion.kotlin -import com.tschuchort.compiletesting.configureKsp -import com.tschuchort.compiletesting.kspProcessorOptions -import com.tschuchort.compiletesting.kspSourcesDir -import java.io.File -import org.intellij.lang.annotations.Language -import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi -import org.junit.Test - -@Suppress("LargeClass") -@OptIn(ExperimentalCompilerApi::class) -class SubCircuitSymbolProcessorTest { - - private val subCircuitStubs = - kotlin( - "SubCircuitStubs.kt", - """ - package com.slack.circuit.subcircuit - - import kotlin.reflect.KClass - - interface SubCircuitOuterEvent - - interface SubCircuitUiState - - interface SubScreen - - interface SubPresenter { - fun present(outerEventSink: (OuterEvent) -> Unit): State - } - - fun interface SubPresenterFactory { - fun create(screen: SubScreen<*>): SubPresenter<*, *>? - } - - fun interface SubUi { - fun Content(state: State, modifier: androidx.compose.ui.Modifier) - } - - fun interface SubUiFactory { - fun create(screen: SubScreen<*>): SubUi<*>? - } - - @Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) - @Retention(AnnotationRetention.BINARY) - annotation class SubCircuitInject(val screen: KClass<*>, val scope: KClass<*>) - """ - .trimIndent(), - ) - - private val composableStub = - kotlin( - "Composable.kt", - """ - package androidx.compose.runtime - - @Target( - AnnotationTarget.FUNCTION, - AnnotationTarget.TYPE, - AnnotationTarget.TYPE_PARAMETER, - AnnotationTarget.PROPERTY_GETTER - ) - @Retention(AnnotationRetention.BINARY) - annotation class Composable - """ - .trimIndent(), - ) - - private val modifierStub = - kotlin( - "Modifier.kt", - """ - package androidx.compose.ui - - interface Modifier { - companion object : Modifier - } - """ - .trimIndent(), - ) - - private val assistedAnnotationsStub = - kotlin( - "AssistedAnnotations.kt", - """ - package dagger.assisted - - annotation class Assisted - annotation class AssistedInject - annotation class AssistedFactory - """ - .trimIndent(), - ) - - private val scopeStub = - kotlin( - "AppScope.kt", - """ - package test - - annotation class AppScope - """ - .trimIndent(), - ) - - private val anvilAnnotations = - kotlin( - "AnvilAnnotations.kt", - """ - package com.squareup.anvil.annotations - import kotlin.reflect.KClass - - annotation class ContributesMultibinding(val scope: KClass<*>) - """ - .trimIndent(), - ) - - private val metroAnnotations = - kotlin( - "MetroAnnotations.kt", - """ - package dev.zacsweers.metro - - annotation class Assisted - annotation class AssistedInject - annotation class AssistedFactory - annotation class Inject - """ - .trimIndent(), - ) - - private val metroContributesAnnotations = - kotlin( - "MetroContributesAnnotations.kt", - """ - package dev.zacsweers.metro - import kotlin.reflect.KClass - - annotation class ContributesIntoSet(val scope: KClass<*>) - """ - .trimIndent(), - ) - - @Test - fun presenterFactory_anvil() { - assertGeneratedFile( - sourceFile = - kotlin( - "TestPresenter.kt", - """ - package test - - import androidx.compose.runtime.Composable - import dagger.assisted.Assisted - import dagger.assisted.AssistedFactory - import dagger.assisted.AssistedInject - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubCircuitUiState - import com.slack.circuit.subcircuit.SubPresenter - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestState(val data: String) : SubCircuitUiState - - data class TestScreen(val id: String) : SubScreen - - class TestPresenter @AssistedInject constructor( - @Assisted val screen: TestScreen - ) : SubPresenter { - - @Composable - override fun present(outerEventSink: (TestEvent) -> Unit): TestState { - return TestState("test") - } - - @SubCircuitInject(TestScreen::class, AppScope::class) - @AssistedFactory - interface Factory { - fun create(screen: TestScreen): TestPresenter - } - } - """ - .trimIndent(), - ), - generatedFilePath = "test/TestPresenter_Factory_SubPresenterFactory.kt", - expectedContent = - """ - // Generated by SubCircuitSymbolProcessor - package test - - import com.slack.circuit.subcircuit.SubPresenter - import com.slack.circuit.subcircuit.SubPresenterFactory - import com.slack.circuit.subcircuit.SubScreen - import com.squareup.anvil.annotations.ContributesMultibinding - import javax.inject.Inject - - @ContributesMultibinding(AppScope::class) - public class TestPresenter_Factory_SubPresenterFactory @Inject constructor( - private val factory: TestPresenter.Factory, - ) : SubPresenterFactory { - override fun create(screen: SubScreen<*>): SubPresenter<*, *>? = if (screen is TestScreen) factory.create(screen) else null - } - """ - .trimIndent(), - ) - } - - @Test - fun uiFactory_anvil() { - assertGeneratedFile( - sourceFile = - kotlin( - "TestUi.kt", - """ - package test - - import androidx.compose.runtime.Composable - import androidx.compose.ui.Modifier - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubCircuitUiState - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestState(val data: String) : SubCircuitUiState - - data class TestScreen(val id: String) : SubScreen - - @SubCircuitInject(TestScreen::class, AppScope::class) - @Composable - fun TestUi(state: TestState, modifier: Modifier = Modifier) { - // UI implementation - } - """ - .trimIndent(), - ), - generatedFilePath = "test/TestUi_SubUiFactory.kt", - expectedContent = - """ - // Generated by SubCircuitSymbolProcessor - package test - - import com.slack.circuit.subcircuit.SubScreen - import com.slack.circuit.subcircuit.SubUi - import com.slack.circuit.subcircuit.SubUiFactory - import com.squareup.anvil.annotations.ContributesMultibinding - import javax.inject.Inject - - @ContributesMultibinding(AppScope::class) - public class TestUi_SubUiFactory @Inject constructor() : SubUiFactory { - override fun create(screen: SubScreen<*>): SubUi<*>? = if (screen is TestScreen) { - SubUi { state, modifier -> TestUi(state, modifier) } - } else { - null - } - } - """ - .trimIndent(), - ) - } - - @Test - fun presenterFactory_metro() { - assertGeneratedFile( - codegenMode = CodegenMode.METRO, - sourceFile = - kotlin( - "TestPresenter.kt", - """ - package test - - import androidx.compose.runtime.Composable - import dev.zacsweers.metro.Assisted - import dev.zacsweers.metro.AssistedFactory - import dev.zacsweers.metro.AssistedInject - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubCircuitUiState - import com.slack.circuit.subcircuit.SubPresenter - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestState(val data: String) : SubCircuitUiState - - data class TestScreen(val id: String) : SubScreen - - class TestPresenter @AssistedInject constructor( - @Assisted val screen: TestScreen - ) : SubPresenter { - - @Composable - override fun present(outerEventSink: (TestEvent) -> Unit): TestState { - return TestState("test") - } - - @SubCircuitInject(TestScreen::class, AppScope::class) - @AssistedFactory - interface Factory { - fun create(screen: TestScreen): TestPresenter - } - } - """ - .trimIndent(), - ), - generatedFilePath = "test/TestPresenter_Factory_SubPresenterFactory.kt", - expectedContent = - """ - // Generated by SubCircuitSymbolProcessor - package test - - import com.slack.circuit.subcircuit.SubPresenter - import com.slack.circuit.subcircuit.SubPresenterFactory - import com.slack.circuit.subcircuit.SubScreen - import dev.zacsweers.metro.ContributesIntoSet - import dev.zacsweers.metro.Inject - - @Inject - @ContributesIntoSet(AppScope::class) - public class TestPresenter_Factory_SubPresenterFactory( - private val factory: TestPresenter.Factory, - ) : SubPresenterFactory { - override fun create(screen: SubScreen<*>): SubPresenter<*, *>? = if (screen is TestScreen) factory.create(screen) else null - } - """ - .trimIndent(), - ) - } - - @Test - fun uiFactory_metro() { - assertGeneratedFile( - codegenMode = CodegenMode.METRO, - sourceFile = - kotlin( - "TestUi.kt", - """ - package test - - import androidx.compose.runtime.Composable - import androidx.compose.ui.Modifier - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubCircuitUiState - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestState(val data: String) : SubCircuitUiState - - data class TestScreen(val id: String) : SubScreen - - @SubCircuitInject(TestScreen::class, AppScope::class) - @Composable - fun TestUi(state: TestState, modifier: Modifier = Modifier) { - // UI implementation - } - """ - .trimIndent(), - ), - generatedFilePath = "test/TestUi_SubUiFactory.kt", - expectedContent = - """ - // Generated by SubCircuitSymbolProcessor - package test - - import com.slack.circuit.subcircuit.SubScreen - import com.slack.circuit.subcircuit.SubUi - import com.slack.circuit.subcircuit.SubUiFactory - import dev.zacsweers.metro.ContributesIntoSet - import dev.zacsweers.metro.Inject - - @Inject - @ContributesIntoSet(AppScope::class) - public class TestUi_SubUiFactory() : SubUiFactory { - override fun create(screen: SubScreen<*>): SubUi<*>? = if (screen is TestScreen) { - SubUi { state, modifier -> TestUi(state, modifier) } - } else { - null - } - } - """ - .trimIndent(), - ) - } - - @Test - fun presenterFactory_topLevel() { - assertGeneratedFile( - sourceFile = - kotlin( - "TopLevelPresenter.kt", - """ - package test - - import androidx.compose.runtime.Composable - import dagger.assisted.Assisted - import dagger.assisted.AssistedFactory - import dagger.assisted.AssistedInject - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubCircuitUiState - import com.slack.circuit.subcircuit.SubPresenter - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestState(val data: String) : SubCircuitUiState - - data class TestScreen(val id: String) : SubScreen - - class TopLevelPresenter @AssistedInject constructor( - @Assisted val screen: TestScreen - ) : SubPresenter { - - @Composable - override fun present(outerEventSink: (TestEvent) -> Unit): TestState { - return TestState("test") - } - } - - @SubCircuitInject(TestScreen::class, AppScope::class) - @AssistedFactory - interface TopLevelPresenterFactory { - fun create(screen: TestScreen): TopLevelPresenter - } - """ - .trimIndent(), - ), - generatedFilePath = "test/TopLevelPresenterFactory_SubPresenterFactory.kt", - expectedContent = - """ - // Generated by SubCircuitSymbolProcessor - package test - - import com.slack.circuit.subcircuit.SubPresenter - import com.slack.circuit.subcircuit.SubPresenterFactory - import com.slack.circuit.subcircuit.SubScreen - import com.squareup.anvil.annotations.ContributesMultibinding - import javax.inject.Inject - - @ContributesMultibinding(AppScope::class) - public class TopLevelPresenterFactory_SubPresenterFactory @Inject constructor( - private val factory: TopLevelPresenterFactory, - ) : SubPresenterFactory { - override fun create(screen: SubScreen<*>): SubPresenter<*, *>? = if (screen is TestScreen) factory.create(screen) else null - } - """ - .trimIndent(), - ) - } - - @Test - fun error_nonInterface() { - assertProcessingError( - sourceFile = - kotlin( - "NotAnInterface.kt", - """ - package test - - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestScreen(val id: String) : SubScreen - - @SubCircuitInject(TestScreen::class, AppScope::class) - class NotAnInterface - """ - .trimIndent(), - ) - ) { messages -> - assertThat(messages) - .contains("@SubCircuitInject on classes is only valid for @AssistedFactory interfaces") - } - } - - @Test - fun error_missingAssistedFactory() { - assertProcessingError( - sourceFile = - kotlin( - "NotAssistedFactory.kt", - """ - package test - - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestScreen(val id: String) : SubScreen - - @SubCircuitInject(TestScreen::class, AppScope::class) - interface NotAssistedFactory { - fun create(): String - } - """ - .trimIndent(), - ) - ) { messages -> - assertThat(messages) - .contains("@SubCircuitInject must be combined with @AssistedFactory on factory interfaces") - } - } - - @Test - fun error_nonComposableFunction() { - assertProcessingError( - sourceFile = - kotlin( - "NotComposable.kt", - """ - package test - - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubCircuitUiState - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestState(val data: String) : SubCircuitUiState - - data class TestScreen(val id: String) : SubScreen - - @SubCircuitInject(TestScreen::class, AppScope::class) - fun NotComposable(state: TestState) { - // Not a composable function - } - """ - .trimIndent(), - ) - ) { messages -> - assertThat(messages) - .contains("@SubCircuitInject on functions is only valid for @Composable functions") - } - } - - @Test - fun error_missingStateParameter() { - assertProcessingError( - sourceFile = - kotlin( - "NoStateParameter.kt", - """ - package test - - import androidx.compose.runtime.Composable - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestScreen(val id: String) : SubScreen - - @SubCircuitInject(TestScreen::class, AppScope::class) - @Composable - fun NoStateParameter() { - // Missing state parameter - } - """ - .trimIndent(), - ) - ) { messages -> - assertThat(messages) - .contains("@SubCircuitInject UI functions must have at least a state parameter") - } - } - - @Test - fun error_factoryReturnsNonPresenter() { - assertProcessingError( - sourceFile = - kotlin( - "BadFactory.kt", - """ - package test - - import dagger.assisted.AssistedFactory - import com.slack.circuit.subcircuit.SubCircuitInject - import com.slack.circuit.subcircuit.SubCircuitOuterEvent - import com.slack.circuit.subcircuit.SubScreen - - sealed interface TestEvent : SubCircuitOuterEvent - - data class TestScreen(val id: String) : SubScreen - - class NotAPresenter - - @SubCircuitInject(TestScreen::class, AppScope::class) - @AssistedFactory - interface BadFactory { - fun create(): NotAPresenter - } - """ - .trimIndent(), - ) - ) { messages -> - assertThat(messages).contains("must implement SubPresenter") - } - } - - private fun assertGeneratedFile( - sourceFile: SourceFile, - generatedFilePath: String, - @Language("kotlin") expectedContent: String, - codegenMode: CodegenMode = CodegenMode.ANVIL, - ) { - val compilation = prepareCompilation(sourceFile, codegenMode = codegenMode) - val result = compilation.compile() - assertThat(result.exitCode).isEqualTo(ExitCode.OK) - val generatedSourcesDir = compilation.kspSourcesDir - val generatedFile = File(generatedSourcesDir, "kotlin/$generatedFilePath") - if (!generatedFile.exists()) { - throw AssertionError("No generated file found at path $generatedFilePath\n${result.messages}") - } - assertThat(generatedFile.readText().trim()).isEqualTo(expectedContent) - } - - private fun assertProcessingError( - sourceFile: SourceFile, - codegenMode: CodegenMode = CodegenMode.ANVIL, - body: (messages: String) -> Unit, - ) { - val compilation = prepareCompilation(sourceFile, codegenMode = codegenMode) - val result = compilation.compile() - assertThat(result.exitCode).isEqualTo(ExitCode.COMPILATION_ERROR) - body(result.messages) - } - - private fun prepareCompilation( - vararg sourceFiles: SourceFile, - codegenMode: CodegenMode = CodegenMode.ANVIL, - ): KotlinCompilation = - KotlinCompilation().apply { - jvmTarget = "11" - val diStubs = - when (codegenMode) { - CodegenMode.ANVIL -> listOf(assistedAnnotationsStub, anvilAnnotations) - CodegenMode.METRO -> listOf(metroAnnotations, metroContributesAnnotations) - } - sources = - sourceFiles.toList() + - listOf(subCircuitStubs, composableStub, modifierStub, scopeStub) + - diStubs - inheritClassPath = true - configureKsp { - kspProcessorOptions += "subcircuit.codegen.mode" to codegenMode.name - symbolProcessorProviders += SubCircuitSymbolProcessor.Provider() - } - } -} diff --git a/docs/circuitx/subcircuit.md b/docs/circuitx/subcircuit.md index 52ea5762cf..927e0fe971 100644 --- a/docs/circuitx/subcircuit.md +++ b/docs/circuitx/subcircuit.md @@ -6,8 +6,8 @@ SubCircuit is a lightweight framework for rendering nested presenter/UI pairs th ```kotlin dependencies { implementation("com.slack.circuit:circuitx-subcircuit:") - // For code generation - ksp("com.slack.circuit:circuitx-subcircuit-codegen:") + // For code generation. `@SubCircuitInject` is handled by the same processor as `@CircuitInject`. + ksp("com.slack.circuit:circuit-codegen:") // For testing testImplementation("com.slack.circuit:circuitx-subcircuit-test:") } @@ -177,7 +177,10 @@ fun TeamMembersUi(state: TeamMembersState, modifier: Modifier = Modifier) { ## Code Generation -SubCircuit uses KSP to generate factory classes that wire presenters and UIs into the DI graph. +SubCircuit uses KSP to generate factory classes that wire presenters and UIs into the DI graph. `@SubCircuitInject` is handled by the same `circuit-codegen` processor as `@CircuitInject`, so it shares the same setup and options — see [Circuit's code gen](../docs/code-gen.md). + +!!! note "Migrating from `circuitx-subcircuit-codegen`" + The old `circuitx-subcircuit-codegen` artifact is now a relocation pointer to `circuit-codegen`, so existing dependencies keep resolving, but you should depend on `circuit-codegen` directly. The `subcircuit.codegen.*` KSP options still work as fallbacks; prefer the `circuit.codegen.*` equivalents. Generated factories are behaviorally equivalent, though the exact source formatting now matches `@CircuitInject` output (named arguments, a `when (screen)` branch, and a `jakarta.inject.Inject` default — override with `circuit.codegen.useJavaxOnly`). ### Presenter Factories @@ -224,15 +227,23 @@ Requirements: ### DI Modes -The code generator supports two DI frameworks: +The code generator supports four DI frameworks via the `circuit.codegen.mode` KSP option: === "Anvil (default)" Generates `@ContributesMultibinding(Scope::class)` + `@Inject`. +=== "Hilt" + + Set `circuit.codegen.mode=hilt`. Generates a `@Module`/`@InstallIn` with a `@Binds @IntoSet` provider for the factory. + +=== "kotlin-inject-anvil" + + Set `circuit.codegen.mode=kotlin_inject_anvil`. Generates `@Inject` + `@ContributesBinding(Scope::class, multibinding = true)`. + === "Metro" - Set `subcircuit.codegen.mode=metro` as a KSP argument. Generates `@ContributesIntoSet(Scope::class)` + `@Inject`. + Set `circuit.codegen.mode=metro`. Generates `@Inject` + `@ContributesIntoSet(Scope::class)`. ### Wiring