diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6331c57..fcd0ec9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,11 +38,17 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Build and run checks - id: gradle-build + id: gradle-check run: | ./gradlew --quiet --continue --no-configuration-cache \ check + - name: Build sample app + id: gradle-sample-build + run: | + ./gradlew --quiet --continue --no-configuration-cache \ + :sample:desktopApp:build + - name: (Fail-only) Upload reports if: failure() uses: actions/upload-artifact@v4 diff --git a/.run/Desktop App.run.xml b/.run/Desktop App.run.xml new file mode 100644 index 0000000..1d0f8e9 --- /dev/null +++ b/.run/Desktop App.run.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt b/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt index 3ed89b7..ed63135 100644 --- a/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt +++ b/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt @@ -9,4 +9,9 @@ import kotlin.reflect.KClass annotation class ContributesSubcomponent( val scope: KClass<*>, val parentScope: KClass<*>, -) +) { + + @MustBeDocumented + @Retention(AnnotationRetention.SOURCE) + annotation class Factory +} diff --git a/build.gradle.kts b/build.gradle.kts index 81791cc..0a4b573 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,9 +11,14 @@ import java.net.URI import org.jetbrains.dokka.gradle.DokkaTaskPartial plugins { + alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.compose.multiplatform) apply false alias(libs.plugins.dokka) + alias(libs.plugins.kotlinAndroid) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.kotlinParcelize) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.mavenPublish) apply false alias(libs.plugins.spotless) diff --git a/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Functions.kt b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Functions.kt new file mode 100644 index 0000000..fc968b4 --- /dev/null +++ b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Functions.kt @@ -0,0 +1,10 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +package com.r0adkll.kimchi.util.kotlinpoet + +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.squareup.kotlinpoet.ParameterSpec + +fun KSFunctionDeclaration.parameterSpecs(): List { + return parameters.map { it.toParameterSpec() } +} diff --git a/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Parameters.kt b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Parameters.kt new file mode 100644 index 0000000..16e2115 --- /dev/null +++ b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Parameters.kt @@ -0,0 +1,18 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +package com.r0adkll.kimchi.util.kotlinpoet + +import com.google.devtools.ksp.symbol.KSValueParameter +import com.squareup.kotlinpoet.ParameterSpec +import com.squareup.kotlinpoet.ksp.toAnnotationSpec +import com.squareup.kotlinpoet.ksp.toTypeName + +fun KSValueParameter.toParameterSpec(): ParameterSpec { + return ParameterSpec.builder(name!!.asString(), type.toTypeName()) + .addAnnotations( + annotations + .map { it.toAnnotationSpec() } + .toList(), + ) + .build() +} diff --git a/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt b/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt index 28bdc16..4059650 100644 --- a/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt +++ b/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt @@ -20,6 +20,8 @@ import com.r0adkll.kimchi.util.addIfNonNull import com.r0adkll.kimchi.util.buildClass import com.r0adkll.kimchi.util.buildFile import com.r0adkll.kimchi.util.kotlinpoet.addBinding +import com.r0adkll.kimchi.util.kotlinpoet.parameterSpecs +import com.r0adkll.kimchi.util.ksp.SubcomponentDeclaration import com.r0adkll.kimchi.util.ksp.findAnnotation import com.r0adkll.kimchi.util.ksp.getScope import com.r0adkll.kimchi.util.ksp.getSymbolsWithClassAnnotation @@ -129,7 +131,8 @@ internal class MergeComponentSymbolProcessor( parent: ClassName? = null, ): TypeSpec { val classSimpleName = "Merged${element.simpleName.asString()}" - val className = ClassName(packageName, classSimpleName) + val className = parent?.nestedClass(classSimpleName) + ?: ClassName(packageName, classSimpleName) val isSubcomponent: Boolean = parent != null val annotationKlass = if (isSubcomponent) ContributesSubcomponent::class else MergeComponent::class @@ -142,7 +145,7 @@ internal class MergeComponentSymbolProcessor( val subcomponents = classScanner.findContributedClasses( annotation = ContributesSubcomponent::class, scope = scope, - ) + ).map { SubcomponentDeclaration(it) } val modules = classScanner.findContributedClasses( annotation = ContributesTo::class, @@ -184,7 +187,14 @@ internal class MergeComponentSymbolProcessor( superclass(element.toClassName()) } - val constructorParams = getConstructorParameters(element) + // If we are generating a subcomponent, then parse the underlying component constructor params + // from its defined factory class and function. + val constructorParams = if (isSubcomponent) { + val subcomponent = SubcomponentDeclaration(element) + subcomponent.factoryClass.factoryFunction.parameterSpecs() + } else { + getConstructorParameters(element) + } // If this is a subcomponent, i.e it has a parent, // then we need to add it's parent as an @Component parameter, but @@ -211,8 +221,13 @@ internal class MergeComponentSymbolProcessor( .build(), ) - constructorParams.map { it.name }.forEach { - addSuperclassConstructorParameter("%L", it) + // Subcomponents currently have a hard restriction on being interfaces with using a Factory + // to define how the merged component implements its constructor parameters. So we can just + // skip adding superclass constructor params in this case. + if (!isSubcomponent) { + constructorParams.map { it.name }.forEach { + addSuperclassConstructorParameter("%L", it) + } } // Add all the contributed interfaces @@ -234,29 +249,17 @@ internal class MergeComponentSymbolProcessor( // Now iterate through all the subcomponents, and add them subcomponents.forEach { subcomponent -> - val subcomponentClassSimpleName = "Merged${subcomponent.simpleName.asString()}" - val subcomponentClassName = className.nestedClass(subcomponentClassSimpleName) - val subcomponentConstructorParams = getConstructorParameters(subcomponent) - - // Generate a creation method for the component - addFunction( - FunSpec.builder("create${subcomponent.simpleName.asString().replaceFirstChar { it.uppercaseChar() }}") - .returns(subcomponentClassName) - .addParameters(subcomponentConstructorParams) - .addStatement( - "return %T.create(${subcomponentConstructorParams.joinToString { "%L" }}" + - "${if (subcomponentConstructorParams.isNotEmpty()) ", " else ""}this)", - subcomponentClassName, - *subcomponentConstructorParams.map { it.name }.toTypedArray(), - ) - .build(), - ) + // Add this subcomponents factory to the parent + addSuperinterface(subcomponent.factoryClass.toClassName()) + + // Generate the factory creation function overload to generate this subcomponent + addFunction(subcomponent.createFactoryFunctionOverload()) // Generate the Subcomponent addType( generateComponent( classScanner = classScanner, - packageName = "$packageName.$classSimpleName", + packageName = packageName, element = subcomponent, parent = className, ), diff --git a/compiler/src/main/kotlin/com/r0adkll/kimchi/util/ksp/SubcomponentDeclaration.kt b/compiler/src/main/kotlin/com/r0adkll/kimchi/util/ksp/SubcomponentDeclaration.kt new file mode 100644 index 0000000..4f31d41 --- /dev/null +++ b/compiler/src/main/kotlin/com/r0adkll/kimchi/util/ksp/SubcomponentDeclaration.kt @@ -0,0 +1,94 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +package com.r0adkll.kimchi.util.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getDeclaredFunctions +import com.google.devtools.ksp.isAnnotationPresent +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.r0adkll.kimchi.annotations.ContributesSubcomponent +import com.r0adkll.kimchi.util.buildFun +import com.r0adkll.kimchi.util.kotlinpoet.parameterSpecs +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName + +/** + * A custom overlay of [KSClassDeclaration] to provide a unified way of + * accessing the specific components and assumptions of declarations + * annotated with [com.r0adkll.kimchi.annotations.ContributesSubcomponent] + */ +class SubcomponentDeclaration( + private val clazz: KSClassDeclaration, +) : KSClassDeclaration by clazz { + + val subcomponentSimpleName: String + get() = "Merged${simpleName.asString()}" + + @OptIn(KspExperimental::class) + val factoryClass: FactoryDeclaration by lazy { + declarations + .filterIsInstance() + .filter { it.isAnnotationPresent(ContributesSubcomponent.Factory::class) } + .map { FactoryDeclaration(this, it) } + .firstOrNull() + ?: throw IllegalStateException( + "@ContributesSubcomponent must define a factory interface annotated with " + + "@ContributesSubcomponent.Factory", + ) + } + + fun createFactoryFunctionOverload(): FunSpec = with(factoryClass) { + return FunSpec.buildFun(factoryFunction.simpleName.asString()) { + addModifiers(KModifier.OVERRIDE) + + returns(factoryFunction.returnType!!.toTypeName()) + + val factoryParameters = factoryFunction.parameterSpecs() + addParameters(factoryParameters) + + // Build the return statement constructing the expected merged subcomponent, including + // parent. + addStatement( + "return %L.create(${factoryParameters.joinToString { "%L" }}" + + "${if (factoryParameters.isNotEmpty()) ", " else ""}this)", + subcomponentSimpleName, + *factoryParameters.map { it.name }.toTypedArray(), + ) + } + } + + /** + * A custom overlay of [KSClassDeclaration] to provide a unified way of accessing + * the specific components and assumptions of declarations annotated with + * [com.r0adkll.kimchi.annotations.ContributesSubcomponent.Factory] + */ + class FactoryDeclaration( + private val subcomponent: SubcomponentDeclaration, + private val clazz: KSClassDeclaration, + ) : KSClassDeclaration by clazz { + + init { + require(isInterface) { + "@ContributesSubcomponent.Factory annotated declarations must be an interface" + } + } + + val factoryFunction: KSFunctionDeclaration by lazy { + getDeclaredFunctions() + .singleOrNull() + ?.also { + require(it.returnType != null) { "Factory methods are required to return their component" } + require(it.returnType!!.toTypeName() == subcomponent.toClassName()) { + "Factory methods are required to return their component" + } + } + ?: throw IllegalStateException( + "@ContributeSubcomponent.Factory interfaces must only have a " + + "single function declared", + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 80fe410..a6393cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,9 @@ [versions] agp = "8.5.2" +androidx-activityCompose = "1.9.1" autoservice = "1.1.1" +circuit = "0.23.1" +compose-multiplatform = "1.6.11" dokka = "1.9.20" kotlin = "2.0.10" kotlin-inject = "0.7.1" @@ -10,10 +13,10 @@ ksp-autoservce = "1.2.0" ktlint = "0.49.1" mavenPublish = "0.29.0" spotless = "6.25.0" -jvmTarget = "11" -publishedJvmTarget = "1.8" +material3Android = "1.2.1" [libraries] +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } autoservice-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" } kotlin-ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -23,13 +26,24 @@ kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet" } ksp-autoservice = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "ksp-autoservce" } +# Sample app dependencies +circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" } +circuit-overlay = { module = "com.slack.circuit:circuit-overlay", version.ref = "circuit" } +circuit-runtime = { module = "com.slack.circuit:circuit-runtime", version.ref = "circuit" } +circuit-retained = { module = "com.slack.circuit:circuit-retained", version.ref = "circuit" } +circuitx-gesturenav = { module = "com.slack.circuit:circuitx-gesture-navigation", version.ref = "circuit" } +androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } + [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" } +kotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts new file mode 100644 index 0000000..fc074df --- /dev/null +++ b/sample/androidApp/build.gradle.kts @@ -0,0 +1,63 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) + alias(libs.plugins.compose.compiler) + alias(libs.plugins.compose.multiplatform) + alias(libs.plugins.ksp) +} + +android { + namespace = "com.r0adkll.kimchi.restaurant.android" + compileSdk = 34 + + defaultConfig { + applicationId = "com.r0adkll.kimchi.restaurant.android" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation(projects.annotations) + implementation(projects.sample.shared) + + implementation(libs.androidx.activity.compose) + implementation(libs.circuit.runtime) + implementation(libs.circuit.foundation) + + api(compose.foundation) + api(compose.material) + api(compose.material3) + api(compose.materialIconsExtended) + api(compose.animation) + + ksp(projects.compiler) + ksp(libs.kotlininject.ksp) +} diff --git a/sample/androidApp/src/main/AndroidManifest.xml b/sample/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f5ba8a0 --- /dev/null +++ b/sample/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + diff --git a/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MainActivity.kt b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MainActivity.kt new file mode 100644 index 0000000..f31c27e --- /dev/null +++ b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MainActivity.kt @@ -0,0 +1,15 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +package com.r0adkll.kimchi.restaurant.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + } + } +} diff --git a/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MyApplicationTheme.kt b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MyApplicationTheme.kt new file mode 100644 index 0000000..1565a5e --- /dev/null +++ b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MyApplicationTheme.kt @@ -0,0 +1,57 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +package com.r0adkll.kimchi.restaurant.android + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun MyApplicationTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colors = if (darkTheme) { + darkColorScheme( + primary = Color(0xFFBB86FC), + secondary = Color(0xFF03DAC5), + tertiary = Color(0xFF3700B3), + ) + } else { + lightColorScheme( + primary = Color(0xFF6200EE), + secondary = Color(0xFF03DAC5), + tertiary = Color(0xFF3700B3), + ) + } + val typography = Typography( + bodyMedium = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + ), + ) + val shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp), + ) + + MaterialTheme( + colorScheme = colors, + typography = typography, + shapes = shapes, + content = content, + ) +} diff --git a/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/RestaurantApplication.kt b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/RestaurantApplication.kt new file mode 100644 index 0000000..431845a --- /dev/null +++ b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/RestaurantApplication.kt @@ -0,0 +1,12 @@ +// Copyright (C) 2024 r0adkll +// SPDX-License-Identifier: Apache-2.0 +package com.r0adkll.kimchi.restaurant.android + +import android.app.Application + +class RestaurantApplication : Application() { + + override fun onCreate() { + super.onCreate() + } +} diff --git a/sample/androidApp/src/main/res/values/styles.xml b/sample/androidApp/src/main/res/values/styles.xml new file mode 100644 index 0000000..6b4fa3d --- /dev/null +++ b/sample/androidApp/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + +