Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/v3 factory generator #105

Open
wants to merge 7 commits into
base: feature/v3-navigation
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package app.futured.kmptemplate.gradle.ext

import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithNativeShortcuts
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget

/**
* Configures supported native iOS targets all at once.
*/
fun KotlinTargetContainerWithNativeShortcuts.iosTargets(
fun KotlinMultiplatformExtension.iosTargets(
targets: List<KotlinNativeTarget> = listOf(
iosX64(),
iosArm64(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import gradle.kotlin.dsl.accessors._411868000f8e772c6f299e8a28e6e73d.ksp
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.withType
import org.gradle.accessors.dm.LibrariesForLibs
import org.gradle.kotlin.dsl.the

// As of now, we cannot use Gradle version catalogs with Precompiled Script plugins: https://github.com/gradle/gradle/issues/15383
plugins {
id("com.google.devtools.ksp")
}

// https://github.com/gradle/gradle/issues/15383
val libs = the<LibrariesForLibs>()
// Enable source generation by KSP to commonMain only
dependencies {
add("kspCommonMainMetadata", project(":shared:factory-generator:processor"))
// DO NOT add bellow dependencies
// add("kspAndroid", Deps.Koin.kspCompiler)
// add("kspIosX64", Deps.Koin.kspCompiler)
// add("kspIosArm64", Deps.Koin.kspCompiler)
// add("kspIosSimulatorArm64", Deps.Koin.kspCompiler)
}

// WORKAROUND: ADD this dependsOn("kspCommonMainKotlinMetadata") instead of above dependencies
tasks.withType<org.jetbrains.kotlin.gradle.dsl.KotlinCompile<*>>().configureEach {
if (name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
6 changes: 6 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ profileinstaller = "1.3.1"
dokkaVersion = "1.9.20"
google-servicesPlugin = "4.4.2"
google-firebaseAppDistributionPlugin = "5.0.0"
poet = "2.0.0"

# Android Namespaces
project-android-namespace = "app.futured.kmptemplate.android"
Expand Down Expand Up @@ -92,6 +93,10 @@ decompose = { group = "com.arkivanov.decompose", name = "decompose", version.ref
decompose-compose-ext = { group = "com.arkivanov.decompose", name = "extensions-compose", version.ref = "decompose" }
essenty = { group = "com.arkivanov.essenty", name = "lifecycle", version.ref = "essenty" }

# KSP api
ksp-lib = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
poet-interop = { module = "com.squareup:kotlinpoet-ksp", version.ref = "poet"}

# Testing
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
Expand Down Expand Up @@ -164,6 +169,7 @@ firebase-distribution = { id = "com.google.firebase.appdistribution", version.re
# Precompiled script plugins
conventions-lint = { id = "conventions-lint" }
koin-annotations-plugin = { id = "koin-annotations" }
component-factory-annotations-plugin = { id = "component-factory-annotations" }

[bundles]
compose = [
Expand Down
2 changes: 2 additions & 0 deletions init_template.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ renameGradleSubproject("shared/network/rest", appPackageName)
renameGradleSubproject("shared/persistence", appPackageName)
renameGradleSubproject("shared/platform", appPackageName)
renameGradleSubproject("shared/resources", appPackageName)
//"factory-generator/annotation", TODO ?
//"factory-generator/processor", TODO ?

findAndReplaceInFileTree(
parent = Path.of("shared/arkitekt-decompose"),
Expand Down
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ include(":shared:feature")
include(":shared:persistence")
include(":shared:platform")
include(":shared:resources")
include(":shared:factory-generator:annotation")
include(":shared:factory-generator:processor")

includeBuild("convention-plugins")
include(":baselineprofile")
22 changes: 22 additions & 0 deletions shared/factory-generator/annotation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import app.futured.kmptemplate.gradle.configuration.ProjectSettings
import app.futured.kmptemplate.gradle.ext.iosTargets

plugins {
id(libs.plugins.kotlin.multiplatform.get().pluginId)
}

kotlin {
jvmToolchain(ProjectSettings.Kotlin.JvmToolchainVersion)

applyDefaultHierarchyTemplate()

jvm()

iosTargets()

sourceSets {
commonMain {
dependencies { }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package app.futured.factorygenerator.annotation

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class GenerateFactory
33 changes: 33 additions & 0 deletions shared/factory-generator/processor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import app.futured.kmptemplate.gradle.configuration.ProjectSettings
import app.futured.kmptemplate.gradle.ext.iosTargets

plugins {
id(libs.plugins.kotlin.multiplatform.get().pluginId)
}

kotlin {
jvmToolchain(ProjectSettings.Kotlin.JvmToolchainVersion)

applyDefaultHierarchyTemplate()

jvm()

iosTargets()

sourceSets {
commonMain {
dependencies {
implementation(projects.shared.factoryGenerator.annotation)
}
}

jvmMain {
dependencies {
implementation(libs.ksp.lib)
implementation(libs.poet.interop)
}
kotlin.srcDir("src/main/kotlin")
resources.srcDir("src/main/resources")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package app.futured.factorygenerator.processor

import app.futured.factorygenerator.annotation.GenerateFactory
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.symbol.KSAnnotated
import com.google.devtools.ksp.symbol.KSClassDeclaration
import kotlin.reflect.KClass

class ComponentFactoryProcessor(
private val codeGenerator: CodeGenerator,
) : SymbolProcessor {
override fun process(resolver: Resolver): List<KSAnnotated> {
val components: Sequence<KSClassDeclaration> = resolver.findAnnotationsForClass(GenerateFactory::class)

components.forEach { generateComponent(it) }

return emptyList()
}

private fun generateComponent(component: KSClassDeclaration) =
PoetFactoryComponentGenerator.generateFactory(component, codeGenerator)

private fun Resolver.findAnnotationsForClass(kClass: KClass<*>): Sequence<KSClassDeclaration> =
this.getSymbolsWithAnnotation(kClass.qualifiedName.toString())
.filterIsInstance<KSClassDeclaration>()
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package app.futured.factorygenerator.processor

import com.google.devtools.ksp.processing.SymbolProcessor
import com.google.devtools.ksp.processing.SymbolProcessorEnvironment
import com.google.devtools.ksp.processing.SymbolProcessorProvider

class ComponentFactoryProcessorProvider : SymbolProcessorProvider {
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor =
ComponentFactoryProcessor(codeGenerator = environment.codeGenerator)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package app.futured.factorygenerator.processor

import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSValueParameter
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.toTypeName
import com.squareup.kotlinpoet.ksp.writeTo

/**
* `PoetFactoryComponentGenerator` is responsible for generating the factory object for a given component.
*
* This object utilizes KotlinPoet to generate the necessary code for creating a factory that
* uses Koin for dependency injection. It analyzes a component's constructor parameters to
* determine which dependencies need to be manually injected and generates a `createComponent` function
* that retrieves these dependencies from the Koin container.
*
* The generated factory object implements the `KoinComponent` interface and provides a
* `createComponent` function to instantiate the target component.
*
* Key functionalities:
* 1. **Generates Factory Objects:** Creates a factory object for each component annotated
* with a specific annotation (implicitly handled by the ksp processor).
* 2. **Handles Dependency Injection:** Uses Koin's `get()` function to retrieve necessary
* dependencies from the Koin container.
* 3. **Supports Constructor Parameters:** Inspects the component's constructor to identify
* parameters requiring manual injection (marked with `@InjectedParam`).
* 4. **Handles Special Types:** Detects and handles special types like `AppComponentContext`,
* `Navigation`, and `Arg` for specific component needs.
*/
object PoetFactoryComponentGenerator {

private const val INJECTED_PARAM_ANNOTATION = "InjectedParam"
private const val APP_COMPONENT_CONTEXT_TYPE_NAME = "AppComponentContext"
private const val NAVIGATION_TYPE_NAME = "Navigation"

private object Imports {
const val KOIN_COMPONENT_PACKAGE = "org.koin.core.component"
const val KOIN_PARAMETER_PACKAGE = "org.koin.core.parameter"
const val KOIN_COMPONENT_CLASS_NAME = "KoinComponent"
const val KOIN_GET_FUNCTION_NAME = "get"
const val KOIN_PARAMETERS_OF_FUNCTION_NAME = "parametersOf"
}

fun generateFactory(
factoryComponent: KSClassDeclaration,
codeGenerator: CodeGenerator,
) {
// Component name without package e.g. FirstComponent
val baseName: String = factoryComponent.qualifiedName?.asString()?.substringAfterLast('.')
?: error("Unable to get base name")

val factoryComponentPackageName = factoryComponent.packageName.asString()
val factoryClassName = ClassName(
packageName = factoryComponentPackageName,
simpleNames = listOf("${baseName}Factory"),
)

val koinComponentClass = ClassName(
packageName = "org.koin.core.component",
simpleNames = listOf("KoinComponent"),
)

val componentTypeSpec = createComponentTypeSpec(
factoryClassName = factoryClassName,
koinComponentClass = koinComponentClass,
createComponentFunction = createComponentFunction(
baseName,
factoryComponentPackageName,
factoryComponent
),
)

val fileSpec = createFileSpec(factoryClassName, componentTypeSpec)

fileSpec.writeTo(codeGenerator, aggregating = true)
}

private fun createComponentTypeSpec(
factoryClassName: ClassName,
koinComponentClass: ClassName,
createComponentFunction: FunSpec,
) = TypeSpec.objectBuilder(factoryClassName)
.addModifiers(KModifier.INTERNAL)
.addSuperinterface(superinterface = koinComponentClass)
.addFunction(createComponentFunction)
.build()

private fun createFileSpec(factoryClassName: ClassName, componentTypeSpec: TypeSpec) =
FileSpec.builder(factoryClassName)
.addImport(Imports.KOIN_COMPONENT_PACKAGE, Imports.KOIN_COMPONENT_CLASS_NAME)
.addImport(Imports.KOIN_COMPONENT_PACKAGE, Imports.KOIN_GET_FUNCTION_NAME)
.addImport(Imports.KOIN_PARAMETER_PACKAGE, Imports.KOIN_PARAMETERS_OF_FUNCTION_NAME)
.addType(componentTypeSpec)
.build()

private fun createComponentFunction(
baseName: String,
factoryComponentPackageName: String,
factoryComponent: KSClassDeclaration,
): FunSpec {
// All constructor parameters that are annotated with @InjectedParam
val unInjectedConstructorParams = factoryComponent.primaryConstructor?.parameters
?.filter { it.annotations.any { it.shortName.asString() == INJECTED_PARAM_ANNOTATION } }

val appComponentContextType = unInjectedConstructorParams
?.findTypeByName(APP_COMPONENT_CONTEXT_TYPE_NAME)
?: error("Unable to find $APP_COMPONENT_CONTEXT_TYPE_NAME in $baseName's constructor")
val navigationType = unInjectedConstructorParams
.findTypeByName(NAVIGATION_TYPE_NAME)
val argsTypes = unInjectedConstructorParams
.filter {
it.containsTypeName(NAVIGATION_TYPE_NAME).not() && it.containsTypeName(
APP_COMPONENT_CONTEXT_TYPE_NAME
).not()
}
.map { it.type.toTypeName() }

val returnType = ClassName(
packageName = factoryComponentPackageName,
simpleNames = listOf(baseName),
)
val paramNameAndType = argsTypes.mapIndexed { index, arg ->
val paramName =
unInjectedConstructorParams.find { it.type.toTypeName() == arg }?.name?.asString()
?: "param$index"
paramName to arg
}
val paramNames = paramNameAndType.joinToString { it.first }

val params = when {
argsTypes.isNotEmpty() && navigationType != null -> "parameters = { parametersOf(componentContext, navigation, $paramNames) }"
argsTypes.isNotEmpty() -> "parameters = { parametersOf(componentContext, $paramNames) }"
navigationType != null -> "parameters = { parametersOf(componentContext, navigation) }"
else -> "parameters = { parametersOf(componentContext) }"
}

val createComponentFunSpec = FunSpec.builder("createComponent")
.addParameter(name = "componentContext", appComponentContextType)

if (navigationType != null) {
createComponentFunSpec.addParameter(name = "navigation", navigationType)
}

if (paramNameAndType.isNotEmpty()) {
paramNameAndType.forEach { (name, type) ->
createComponentFunSpec.addParameter(
name = name, type
)
}
}

return createComponentFunSpec
.returns(returnType)
.addStatement(
"return get(\n" +
"qualifier = null,\n" +
"$params,\n" +
")",
)
.build()
}

private fun List<KSValueParameter>.findTypeByName(name: String): TypeName? = this
.find { it.containsTypeName(name) }
?.type?.toTypeName()

private fun KSValueParameter.containsTypeName(name: String): Boolean =
this.type.toTypeName().toString().contains(name)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
app.futured.factorygenerator.processor.ComponentFactoryProcessorProvider
3 changes: 3 additions & 0 deletions shared/feature/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ plugins {
id(libs.plugins.kotlin.parcelize.get().pluginId)
id(libs.plugins.conventions.lint.get().pluginId)
id(libs.plugins.koin.annotations.plugin.get().pluginId)
id(libs.plugins.component.factory.annotations.plugin.get().pluginId)

alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
Expand Down Expand Up @@ -47,6 +48,8 @@ kotlin {
implementation(projects.shared.persistence)
implementation(projects.shared.arkitektDecompose)
implementation(projects.shared.resources)
implementation(projects.shared.factoryGenerator.annotation)

implementation(libs.logging.kermit)
implementation(libs.skie.annotations)
implementation(libs.network.ktor.http)
Expand Down
Loading
Loading