Skip to content

Commit

Permalink
Sample App + Subcomponent Factories (#18)
Browse files Browse the repository at this point in the history
_WIP_

Fixes #17
Fixes #8

---------

Co-authored-by: Drew Heavner <[email protected]>
  • Loading branch information
r0adkll and Drew Heavner committed Sep 4, 2024
1 parent 4e05b23 commit 701de1b
Show file tree
Hide file tree
Showing 58 changed files with 2,550 additions and 27 deletions.
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions .run/Desktop App.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Desktop App" type="Application" factoryName="Application">
<option name="ALTERNATIVE_JRE_PATH" value="azul-17" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="MAIN_CLASS_NAME" value="com.r0adkll.kimchi.restaurant.MainKt" />
<module name="kimchi.sample.desktopApp.main" />
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ import kotlin.reflect.KClass
annotation class ContributesSubcomponent(
val scope: KClass<*>,
val parentScope: KClass<*>,
)
) {

@MustBeDocumented
@Retention(AnnotationRetention.SOURCE)
annotation class Factory
}
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ParameterSpec> {
return parameters.map { it.toParameterSpec() }
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<KSClassDeclaration>()
.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",
)
}
}
}
18 changes: 16 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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" }
Expand All @@ -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" }
63 changes: 63 additions & 0 deletions sample/androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 701de1b

Please sign in to comment.