diff --git a/.gitignore b/.gitignore index 127240d..99628ac 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,9 @@ .idea/deploymentTargetSelector.xml .idea/migrations.xml .idea/other.xml +.idea/AndroidProjectSystem.xml +.idea/deviceManager.xml +.idea/markdown.xml # mpeltonen/sbt-idea plugin .idea_modules/ diff --git a/README.md b/README.md index 7fce70f..7d7f9e0 100644 --- a/README.md +++ b/README.md @@ -257,9 +257,13 @@ Given the example `Store`s and `Action`s explained before, the workflow would be - The Store changes its state to the given values from `LoginCompletedAction`. - The view will react (for example, redirecting to another home view) if the task was success or shows an error if not. -You can execute another sample in the `app` package. It contains two different samples executing two types of `StateContainer`s: +You can execute the sample in the `app` package. It contains two different samples executing two types of `StateContainer`s: - `StoreSampleActivity` class uses a `Store` as a `StateContainer`. - `ViewModelSampleActivity` class uses a `ViewModel` as a `StateContainer`. +- `CounterFeatureSampleActivity` class shows a feature module running with its own local Mini runtime inside the main sample app. + +The repository also includes `mini-processor-multiregistry-test`, a small JVM example that validates isolated coexistence for generated registries coming from different modules. +For a separate consumer project example outside the main app build, see `samples/isolated-consumer/`, which contains its own `app` and `message-feature` modules. ## How to use ### Setting up Mini @@ -267,23 +271,49 @@ You'll need to add the following snippet to the class that initializes your appl ```kotlin val stores = listOf>() // Here you'll set-up you store list, you can retrieve it using your preferred DI framework -val dispatcher = MiniGen.newDispatcher() // Create a new dispatcher +val dispatcher = Dispatcher() // Create a new dispatcher +val registry = mini.codegen.Mini_Generated() // Generated MiniRegistry for this module // Initialize Mini -storeSubscriptions = MiniGen.subscribe(dispatcher, stores) +storeSubscriptions = Mini.link(registry, dispatcher, stores) stores.forEach { store -> store.initialize() } // Optional: add logging middleware to log action events -dispatcher.addMiddleware(LoggerMiddleware(stores)) { tag, msg -> - Log.d(tag, msg) -} +dispatcher.addMiddleware( + LoggerMiddleware(stores, logger = { _, tag, msg -> + Log.d(tag, msg) + }) +) ``` As soon as you do this, you'll have Mini up and running. You'll then need to declare your `Action`s, `Store`s and `State` as mentioned previously. The sample [app](app) contains examples regarding app configuration. ## Advanced usages + +### Multi-module support +Mini supports multi-module and multi-library setups. Each Mini-enabled module generates its own registry, so modules coexist on the classpath without class collisions. Each module bootstraps its own `Dispatcher`, stores, and registry independently. + +If more than one module uses Mini, assign a distinct `mini.registryName` to each to avoid duplicate class errors: + +KAPT: +```kotlin +kapt { + arguments { + arg("mini.registryName", "feature") + } +} +``` + +KSP: +```kotlin +ksp { + arg("mini.registryName", "feature") +} +``` + +The generated registry will be placed under `mini.codegen..Mini_Generated` (e.g. `mini.codegen.feature.Mini_Generated`). ### Kotlin Flow Utils Mini includes some utility extensions over Kotlin `Flow` to make easier listen state changes over the `StateContainer`s. @@ -447,6 +477,22 @@ kapt.use.worker.api=true org.gradle.caching=true ``` +## Verification +You can verify the repository with these commands: + +```bash +./gradlew :mini-common:test +./gradlew :mini-processor-test:test +./gradlew :mini-processor-ksp-test:test +./gradlew :mini-processor-reducer-only-test:test +./gradlew :mini-processor-multiregistry-test:test +./gradlew test +``` + +These checks cover explicit local registry bootstrap, reducer-only modules, KAPT and KSP generation paths, isolated coexistence across generated registries, and the repository test suite. + +The Android sample app also includes `CounterFeatureSampleActivity`, which uses a feature module that owns its own local Mini runtime. + ## Known issues ### KSP gotchas #### KSP code is not recognized by the IntelliJ IDEs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e2bd9a6..0f44871 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2024 HyperDevs + * Copyright 2026 HyperDevs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,10 @@ plugins { alias(libs.plugins.convention.androidApp) } +ksp { + arg("mini.registryName", "app_sample") +} + android { namespace = "mini.android.sample" @@ -69,6 +73,8 @@ android { dependencies { implementation(project(":mini-android")) implementation(project(":mini-kodein-android")) + implementation(project(":sample-counter-feature")) + implementation(project(":isolated-consumer-message-feature")) // kapt(project(":mini-processor")) ksp(project(":mini-processor")) @@ -91,4 +97,4 @@ dependencies { androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.espresso) androidTestImplementation(libs.androidx.test.junit) -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ff0a311..e459e82 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -46,6 +46,12 @@ android:exported="false" android:label="@string/view_model_sample_label" android:theme="@style/AppTheme" /> + + - \ No newline at end of file + diff --git a/app/src/main/kotlin/mini/android/sample/CounterFeatureSampleActivity.kt b/app/src/main/kotlin/mini/android/sample/CounterFeatureSampleActivity.kt new file mode 100644 index 0000000..dbd99be --- /dev/null +++ b/app/src/main/kotlin/mini/android/sample/CounterFeatureSampleActivity.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.android.sample + +import android.util.Log +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import mini.android.sample.counter.CounterState +import mini.android.sample.counter.CounterFeatureRuntime +import mini.android.sample.ui.theme.AppTheme +import sample.consumer.message.MessageFeatureRuntime + +class CounterFeatureSampleActivity : AppCompatActivity() { + + private val counterFeature = CounterFeatureRuntime() + private val messageFeature = MessageFeatureRuntime() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + Log.d("MiniSample", "Counter feature and external message feature run with separate local Mini runtimes") + + setContent { + AppTheme { + CounterFeatureSampleScreen() + } + } + } + + override fun onDestroy() { + counterFeature.close() + messageFeature.close() + super.onDestroy() + } + + @Composable + private fun CounterFeatureSampleScreen() { + val coroutineScope = rememberCoroutineScope() + val counterState by counterFeature.flow().collectAsState(initial = CounterState()) + val messageState by messageFeature.flow().collectAsState(initial = messageFeature.state) + + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text("Counter feature state: ${counterState.count}") + Button(onClick = { + counterFeature.increment(coroutineScope) + }) { + Text("Run counter feature") + } + + Text("External message feature state: ${messageState.text}") + Button(onClick = { + coroutineScope.launch { + messageFeature.advance() + } + }) { + Text("Advance external message feature") + } + + Button(onClick = { + coroutineScope.launch { + messageFeature.setMessage("from-main-app") + } + }) { + Text("Set external message") + } + } + } + } +} diff --git a/app/src/main/kotlin/mini/android/sample/MainActivity.kt b/app/src/main/kotlin/mini/android/sample/MainActivity.kt index 7bacd7c..938fd85 100644 --- a/app/src/main/kotlin/mini/android/sample/MainActivity.kt +++ b/app/src/main/kotlin/mini/android/sample/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 HyperDevs + * Copyright 2026 HyperDevs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,29 +17,20 @@ package mini.android.sample import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.widget.Button import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import mini.android.sample.ui.theme.AppTheme @@ -64,6 +55,11 @@ class MainActivity : AppCompatActivity() { Intent(this, ViewModelSampleActivity::class.java).apply { startActivity(this) } + }, + onGoToFeatureRuntimeSampleClicked = { + Intent(this, CounterFeatureSampleActivity::class.java).apply { + startActivity(this) + } } ) } @@ -74,14 +70,16 @@ class MainActivity : AppCompatActivity() { @Composable private fun MainScreen(modifier: Modifier = Modifier, onGoToStoreSampleClicked: () -> Unit = {}, - onGoToViewModelSampleClicked: () -> Unit = {}) { + onGoToViewModelSampleClicked: () -> Unit = {}, + onGoToFeatureRuntimeSampleClicked: () -> Unit = {}) { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> MainContent( modifier = modifier .fillMaxSize() .padding(innerPadding), onGoToStoreSampleClicked = onGoToStoreSampleClicked, - onGoToViewModelSampleClicked = onGoToViewModelSampleClicked + onGoToViewModelSampleClicked = onGoToViewModelSampleClicked, + onGoToFeatureRuntimeSampleClicked = onGoToFeatureRuntimeSampleClicked ) } } @@ -90,7 +88,8 @@ private fun MainScreen(modifier: Modifier = Modifier, private fun MainContent( modifier: Modifier = Modifier, onGoToStoreSampleClicked: () -> Unit = {}, - onGoToViewModelSampleClicked: () -> Unit = {} + onGoToViewModelSampleClicked: () -> Unit = {}, + onGoToFeatureRuntimeSampleClicked: () -> Unit = {} ) { Column( modifier = modifier.fillMaxSize(), @@ -103,6 +102,9 @@ private fun MainContent( Button(onClick = onGoToViewModelSampleClicked) { Text("Go to ViewModel sample") } + Button(onClick = onGoToFeatureRuntimeSampleClicked) { + Text("Go to Counter feature sample") + } } } diff --git a/app/src/main/kotlin/mini/android/sample/StoreSampleActivity.kt b/app/src/main/kotlin/mini/android/sample/StoreSampleActivity.kt index 4ceca41..101237e 100644 --- a/app/src/main/kotlin/mini/android/sample/StoreSampleActivity.kt +++ b/app/src/main/kotlin/mini/android/sample/StoreSampleActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 HyperDevs + * Copyright 2026 HyperDevs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,13 +39,15 @@ import kotlinx.coroutines.launch import mini.* import mini.android.FluxActivity import mini.android.sample.ui.theme.AppTheme +import mini.codegen.app_sample.Mini_Generated private val dispatcher = Dispatcher() +private val appRegistry = Mini_Generated() class MainStore : Store() { init { - Mini.link(dispatcher, this).track() + Mini.link(appRegistry, dispatcher, this).track() } @Reducer diff --git a/app/src/main/kotlin/mini/android/sample/ViewModelSampleActivity.kt b/app/src/main/kotlin/mini/android/sample/ViewModelSampleActivity.kt index a6df393..36d83e1 100644 --- a/app/src/main/kotlin/mini/android/sample/ViewModelSampleActivity.kt +++ b/app/src/main/kotlin/mini/android/sample/ViewModelSampleActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024 HyperDevs + * Copyright 2026 HyperDevs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,8 +39,10 @@ import mini.* import mini.android.FluxActivity import mini.android.FluxStoreViewModel import mini.android.sample.ui.theme.AppTheme +import mini.codegen.app_sample.Mini_Generated private val dispatcher = Dispatcher() +private val appRegistry = Mini_Generated() class MainViewModelReducer : NestedStateContainer() { @@ -60,7 +62,7 @@ class MainStoreViewModel(savedStateHandle: SavedStateHandle) : private val reducerSlice = MainViewModelReducer().apply { parent = this@MainStoreViewModel } init { - Mini.link(dispatcher, listOf(this, reducerSlice)).track() + Mini.link(appRegistry, dispatcher, listOf(this, reducerSlice)).track() } override fun saveState(state: MainState, handle: SavedStateHandle) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e27044d..3ff9f21 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,4 +20,5 @@ Mini Sample Store Sample View Model Sample + Counter Feature Sample diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fb00a08..c763015 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -110,4 +110,4 @@ androidx-lifecycle = ["androidx-lifecycle-runtime", "androidx-lifecycle-viewmode kotlinpoet = ["kotlinpoet", "kotlinpoet-ksp"] androidx = ["androidx-appcompat", "androidx-appcompat-resources", "androidx-core", "androidx-core-splashscreen", "androidx-lifecycle-runtime", "androidx-lifecycle-viewmodel", "androidx-lifecycle-livedata", "androidx-lifecycle-runtime-compose", "androidx-annotation", "androidx-webkit", "androidx-activity", "androidx-activity-compose"] -compose = ["compose-runtime", "compose-runtime-livedata", "compose-ui", "compose-ui-tooling", "compose-ui-util", "compose-foundation", "compose-material", "compose-materialicons", "compose-navigation", "compose-material3"] \ No newline at end of file +compose = ["compose-runtime", "compose-runtime-livedata", "compose-ui", "compose-ui-tooling", "compose-ui-util", "compose-foundation", "compose-material", "compose-materialicons", "compose-navigation", "compose-material3"] diff --git a/mini-common/src/main/java/mini/Mini.kt b/mini-common/src/main/java/mini/Mini.kt index c7e6bc5..768a043 100644 --- a/mini-common/src/main/java/mini/Mini.kt +++ b/mini-common/src/main/java/mini/Mini.kt @@ -19,59 +19,57 @@ package mini import java.io.Closeable -import kotlin.reflect.KClass -const val DISPATCHER_FACTORY_CLASS_NAME = "mini.codegen.Mini_Generated" - -abstract class Mini { +abstract class Mini : MiniRegistry { companion object { - private val miniInstance: Mini by lazy { - try { - Class.forName(DISPATCHER_FACTORY_CLASS_NAME).getField("INSTANCE").get(null) as Mini - } catch (ex: Throwable) { - throw ClassNotFoundException("Failed to load generated class $DISPATCHER_FACTORY_CLASS_NAME, " + - "most likely the annotation processor did not run, add it as dependency to the project", ex) - } - } - /** * Generate all subscriptions from @[Reducer] annotated methods and bundle * into a single Closeable. */ - fun link(dispatcher: Dispatcher, container: StateContainer<*>): Closeable { - ensureDispatcherInitialized(dispatcher) - return miniInstance.subscribe(dispatcher, container) + fun link(registry: MiniRegistry, + dispatcher: Dispatcher, + container: StateContainer<*>): Closeable { + ensureDispatcherInitialized(registry, dispatcher) + val c = CompositeCloseable() + c.add(registry.subscribe(dispatcher, container)) + return c } /** * Generate all subscriptions from @[Reducer] annotated methods and bundle * into a single Closeable. */ - fun link(dispatcher: Dispatcher, containers: Iterable>): Closeable { - ensureDispatcherInitialized(dispatcher) - return miniInstance.subscribe(dispatcher, containers) + fun link(registry: MiniRegistry, + dispatcher: Dispatcher, + containers: Iterable>): Closeable { + ensureDispatcherInitialized(registry, dispatcher) + val c = CompositeCloseable() + try { + containers.forEach { container -> + c.add(registry.subscribe(dispatcher, container)) + } + return c + } catch (e: Throwable) { + c.close() + throw e + } } - private fun ensureDispatcherInitialized(dispatcher: Dispatcher) { + private fun ensureDispatcherInitialized(registry: MiniRegistry, dispatcher: Dispatcher) { if (dispatcher.actionTypeMap.isEmpty()) { - dispatcher.actionTypeMap = miniInstance.actionTypes + dispatcher.actionTypeMap = registry.actionTypes } } } - /** - * All the types an action can be subscribed as. - */ - abstract val actionTypes: Map, List>> - /** * Link all [Reducer] functions present in the store to the dispatcher. */ - protected abstract fun subscribe(dispatcher: Dispatcher, - container: StateContainer): Closeable + abstract override fun subscribe(dispatcher: Dispatcher, + container: StateContainer): Closeable /** * Link all [Reducer] functions present in the store to the dispatcher. @@ -83,4 +81,4 @@ abstract class Mini { } return c } -} \ No newline at end of file +} diff --git a/mini-common/src/main/java/mini/MiniRegistry.kt b/mini-common/src/main/java/mini/MiniRegistry.kt new file mode 100644 index 0000000..abc6b6f --- /dev/null +++ b/mini-common/src/main/java/mini/MiniRegistry.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini + +import java.io.Closeable +import kotlin.reflect.KClass + +interface MiniRegistry { + val actionTypes: Map, List>> + + fun subscribe(dispatcher: Dispatcher, container: StateContainer): Closeable +} diff --git a/mini-common/src/test/kotlin/mini/MiniTest.kt b/mini-common/src/test/kotlin/mini/MiniTest.kt new file mode 100644 index 0000000..735140c --- /dev/null +++ b/mini-common/src/test/kotlin/mini/MiniTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini + +import org.amshove.kluent.`should be equal to` +import org.junit.Test +import java.io.Closeable +import kotlin.reflect.KClass + +class MiniTest { + + @Test + fun `link initializes dispatcher action types from explicit registry`() { + val dispatcher = Dispatcher() + val store = SampleStore() + val registry = TestRegistry( + actionTypes = mapOf(TestAction::class to listOf(TestAction::class, Any::class)) + ) + + Mini.link(registry, dispatcher, store).close() + + dispatcher.actionTypeMap[TestAction::class] `should be equal to` listOf(TestAction::class, Any::class) + } + + @Test + fun `link delegates subscriptions only to explicit registry`() { + val dispatcher = Dispatcher() + val store = SampleStore() + val registry = TestRegistry(emptyMap()) + + Mini.link(registry, dispatcher, store).close() + + registry.subscriptionCount `should be equal to` 1 + } + + @Test + fun `link supports multiple containers with one local registry`() { + val dispatcher = Dispatcher() + val firstStore = SampleStore() + val secondStore = SampleStore() + val registry = TestRegistry(emptyMap()) + + Mini.link(registry, dispatcher, listOf(firstStore, secondStore)).close() + + registry.subscriptionCount `should be equal to` 2 + } + + @Test + fun `link does not override a dispatcher action map that is already initialized`() { + val existingActionMap: Map, List>> = mapOf( + TestAction::class to listOf(State::class) + ) + val dispatcher = Dispatcher().apply { + actionTypeMap = existingActionMap + } + val store = SampleStore() + val registry = TestRegistry( + actionTypes = mapOf(TestAction::class to listOf(TestAction::class, Any::class)) + ) + + Mini.link(registry, dispatcher, store).close() + + dispatcher.actionTypeMap `should be equal to` existingActionMap + } + + private class TestRegistry( + override val actionTypes: Map, List>> + ) : MiniRegistry { + var subscriptionCount = 0 + + override fun subscribe(dispatcher: Dispatcher, container: StateContainer): Closeable { + subscriptionCount++ + return Closeable { } + } + } +} diff --git a/mini-processor-ksp-test/.gitignore b/mini-processor-ksp-test/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/mini-processor-ksp-test/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mini-processor-ksp-test/build.gradle.kts b/mini-processor-ksp-test/build.gradle.kts new file mode 100644 index 0000000..0e51c92 --- /dev/null +++ b/mini-processor-ksp-test/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.ksp) +} + +ksp { + arg("mini.registryName", "processor_ksp_test") +} + +dependencies { + implementation(project(":mini-common")) + ksp(project(":mini-processor")) + + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.kluent) +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = libs.versions.java.sdk.get() + } +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) +} + +kotlin { + jvmToolchain(libs.versions.java.sdk.get().toInt()) +} diff --git a/mini-processor-ksp-test/src/main/java/mini/ksptest/KspAnyAction.kt b/mini-processor-ksp-test/src/main/java/mini/ksptest/KspAnyAction.kt new file mode 100644 index 0000000..13cea2a --- /dev/null +++ b/mini-processor-ksp-test/src/main/java/mini/ksptest/KspAnyAction.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.ksptest + +import mini.Action + +@Action +data class KspAnyAction(val value: String) diff --git a/mini-processor-ksp-test/src/main/java/mini/ksptest/KspBasicState.kt b/mini-processor-ksp-test/src/main/java/mini/ksptest/KspBasicState.kt new file mode 100644 index 0000000..238334a --- /dev/null +++ b/mini-processor-ksp-test/src/main/java/mini/ksptest/KspBasicState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.ksptest + +import mini.State + +data class KspBasicState(val value: String = "initial") : State diff --git a/mini-processor-ksp-test/src/main/java/mini/ksptest/KspReducersStore.kt b/mini-processor-ksp-test/src/main/java/mini/ksptest/KspReducersStore.kt new file mode 100644 index 0000000..a5292f3 --- /dev/null +++ b/mini-processor-ksp-test/src/main/java/mini/ksptest/KspReducersStore.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.ksptest + +import mini.Reducer +import mini.Store + +class KspReducersStore : Store() { + @Reducer + fun pureReducer(state: KspBasicState, action: KspAnyAction): KspBasicState { + return state.copy(value = action.value) + } +} diff --git a/mini-processor-ksp-test/src/test/java/mini/ksptest/KspReducersStoreTest.kt b/mini-processor-ksp-test/src/test/java/mini/ksptest/KspReducersStoreTest.kt new file mode 100644 index 0000000..8ae375e --- /dev/null +++ b/mini-processor-ksp-test/src/test/java/mini/ksptest/KspReducersStoreTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.ksptest + +import kotlinx.coroutines.runBlocking +import mini.Dispatcher +import mini.Mini +import mini.codegen.processor_ksp_test.Mini_Generated +import org.amshove.kluent.`should be equal to` +import org.junit.Test + +internal class KspReducersStoreTest { + + private val store = KspReducersStore() + private val dispatcher = Dispatcher().apply { + Mini.link(Mini_Generated(), this, listOf(store)) + } + + @Test + fun `pure reducers are called through ksp generated registry`() { + runBlocking { + dispatcher.dispatch(KspAnyAction("changed")) + store.state.value `should be equal to` "changed" + } + } +} diff --git a/mini-processor-multiregistry-test/.gitignore b/mini-processor-multiregistry-test/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/mini-processor-multiregistry-test/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mini-processor-multiregistry-test/build.gradle.kts b/mini-processor-multiregistry-test/build.gradle.kts new file mode 100644 index 0000000..c7b1d49 --- /dev/null +++ b/mini-processor-multiregistry-test/build.gradle.kts @@ -0,0 +1,44 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +dependencies { + implementation(project(":mini-common")) + implementation(project(":mini-processor-test")) + implementation(project(":mini-processor-ksp-test")) + + testImplementation(libs.junit) + testImplementation(libs.kluent) + testImplementation(libs.kotlinx.coroutines.core) +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = libs.versions.java.sdk.get() + } +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) +} + +kotlin { + jvmToolchain(libs.versions.java.sdk.get().toInt()) +} diff --git a/mini-processor-multiregistry-test/src/test/java/mini/multiregistry/MultiRegistryIntegrationTest.kt b/mini-processor-multiregistry-test/src/test/java/mini/multiregistry/MultiRegistryIntegrationTest.kt new file mode 100644 index 0000000..0e87fd8 --- /dev/null +++ b/mini-processor-multiregistry-test/src/test/java/mini/multiregistry/MultiRegistryIntegrationTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.multiregistry + +import kotlinx.coroutines.runBlocking +import mini.Dispatcher +import mini.Mini +import mini.codegen.processor_ksp_test.Mini_Generated as KspMiniGenerated +import mini.codegen.processor_test.Mini_Generated as KaptMiniGenerated +import mini.ksptest.KspAnyAction +import mini.ksptest.KspReducersStore +import mini.test.AnyAction +import mini.test.ReducersStore +import org.amshove.kluent.`should be equal to` +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +internal class MultiRegistryIntegrationTest { + + private val kaptStore = ReducersStore() + private val kspStore = KspReducersStore() + private val kaptDispatcher = Dispatcher().apply { + Mini.link(KaptMiniGenerated(), this, listOf(kaptStore)) + } + private val kspDispatcher = Dispatcher().apply { + Mini.link(KspMiniGenerated(), this, listOf(kspStore)) + } + + @Test + fun `explicit local registries keep action maps isolated across modules`() { + assertTrue(kaptDispatcher.actionTypeMap.containsKey(AnyAction::class)) + assertFalse(kaptDispatcher.actionTypeMap.containsKey(KspAnyAction::class)) + assertTrue(kspDispatcher.actionTypeMap.containsKey(KspAnyAction::class)) + assertFalse(kspDispatcher.actionTypeMap.containsKey(AnyAction::class)) + } + + @Test + fun `generated registries from different modules coexist without collisions`() { + runBlocking { + kaptDispatcher.dispatch(AnyAction("kapt-changed")) + kspDispatcher.dispatch(KspAnyAction("ksp-changed")) + + kaptStore.state.value `should be equal to` "kapt-changed" + kspStore.state.value `should be equal to` "ksp-changed" + } + } +} diff --git a/mini-processor-reducer-only-test/.gitignore b/mini-processor-reducer-only-test/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/mini-processor-reducer-only-test/.gitignore @@ -0,0 +1 @@ +/build diff --git a/mini-processor-reducer-only-test/build.gradle.kts b/mini-processor-reducer-only-test/build.gradle.kts new file mode 100644 index 0000000..61e5c36 --- /dev/null +++ b/mini-processor-reducer-only-test/build.gradle.kts @@ -0,0 +1,51 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.kapt) +} + +kapt { + arguments { + arg("mini.registryName", "reducer_only_test") + } +} + +dependencies { + implementation(project(":mini-common")) + implementation(project(":mini-processor-test")) + kapt(project(":mini-processor")) + + testImplementation(libs.junit) + testImplementation(libs.kluent) + testImplementation(libs.kotlinx.coroutines.core) +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = libs.versions.java.sdk.get() + } +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) +} + +kotlin { + jvmToolchain(libs.versions.java.sdk.get().toInt()) +} diff --git a/mini-processor-reducer-only-test/src/main/java/mini/reduceronly/ReducerOnlyState.kt b/mini-processor-reducer-only-test/src/main/java/mini/reduceronly/ReducerOnlyState.kt new file mode 100644 index 0000000..c523396 --- /dev/null +++ b/mini-processor-reducer-only-test/src/main/java/mini/reduceronly/ReducerOnlyState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.reduceronly + +import mini.State + +data class ReducerOnlyState(val value: String = "initial") : State diff --git a/mini-processor-reducer-only-test/src/main/java/mini/reduceronly/ReducerOnlyStore.kt b/mini-processor-reducer-only-test/src/main/java/mini/reduceronly/ReducerOnlyStore.kt new file mode 100644 index 0000000..7fc6157 --- /dev/null +++ b/mini-processor-reducer-only-test/src/main/java/mini/reduceronly/ReducerOnlyStore.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.reduceronly + +import mini.Reducer +import mini.Store +import mini.test.AnyAction + +class ReducerOnlyStore : Store() { + @Reducer + fun pureReducer(state: ReducerOnlyState, action: AnyAction): ReducerOnlyState { + return state.copy(value = action.value) + } +} diff --git a/mini-processor-reducer-only-test/src/test/java/mini/reduceronly/ReducerOnlyStoreTest.kt b/mini-processor-reducer-only-test/src/test/java/mini/reduceronly/ReducerOnlyStoreTest.kt new file mode 100644 index 0000000..5565067 --- /dev/null +++ b/mini-processor-reducer-only-test/src/test/java/mini/reduceronly/ReducerOnlyStoreTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.reduceronly + +import kotlinx.coroutines.runBlocking +import mini.Dispatcher +import mini.Mini +import mini.codegen.reducer_only_test.Mini_Generated +import mini.test.AnyAction +import org.amshove.kluent.`should be equal to` +import org.junit.Test + +internal class ReducerOnlyStoreTest { + + private val store = ReducerOnlyStore() + private val dispatcher = Dispatcher().apply { + Mini.link(Mini_Generated(), this, listOf(store)) + } + + @Test + fun `reducers are generated without local actions`() { + runBlocking { + dispatcher.dispatch(AnyAction("changed")) + store.state.value `should be equal to` "changed" + } + } +} diff --git a/mini-processor-test/build.gradle.kts b/mini-processor-test/build.gradle.kts index edcd12d..08da749 100644 --- a/mini-processor-test/build.gradle.kts +++ b/mini-processor-test/build.gradle.kts @@ -1,5 +1,5 @@ /* - * Copyright 2024 HyperDevs + * Copyright 2026 HyperDevs * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,12 @@ plugins { alias(libs.plugins.kotlin.kapt) } +kapt { + arguments { + arg("mini.registryName", "processor_test") + } +} + dependencies { implementation(project(":mini-common")) kapt(project(":mini-processor")) diff --git a/mini-processor-test/src/test/java/mini/test/ReducersStoreTest.kt b/mini-processor-test/src/test/java/mini/test/ReducersStoreTest.kt index 11c39eb..676308c 100644 --- a/mini-processor-test/src/test/java/mini/test/ReducersStoreTest.kt +++ b/mini-processor-test/src/test/java/mini/test/ReducersStoreTest.kt @@ -19,6 +19,7 @@ package mini.test import kotlinx.coroutines.runBlocking import mini.Dispatcher import mini.Mini +import mini.codegen.processor_test.Mini_Generated import org.amshove.kluent.`should equal` import org.junit.Test @@ -26,7 +27,7 @@ internal class ReducersStoreTest { private val store = ReducersStore() private val dispatcher = Dispatcher().apply { - Mini.link(this, listOf(store)) + Mini.link(Mini_Generated(), this, listOf(store)) } @Test @@ -44,4 +45,4 @@ internal class ReducersStoreTest { store.state.value.`should equal`("changed") } } -} \ No newline at end of file +} diff --git a/mini-processor/src/main/java/mini/processor/common/ContainerBuilders.kt b/mini-processor/src/main/java/mini/processor/common/ContainerBuilders.kt index 6ada906..c819c85 100644 --- a/mini-processor/src/main/java/mini/processor/common/ContainerBuilders.kt +++ b/mini-processor/src/main/java/mini/processor/common/ContainerBuilders.kt @@ -19,20 +19,46 @@ package mini.processor.common import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.TypeSpec -import mini.DISPATCHER_FACTORY_CLASS_NAME import mini.Mini +const val MINI_REGISTRY_NAME_OPTION = "mini.registryName" +const val MINI_REGISTRY_PACKAGE_NAME = "mini.codegen" + +private const val GENERATED_REGISTRY_SIMPLE_NAME = "Mini_Generated" + data class ContainerBuilders( val fileSpecBuilder: FileSpec.Builder, - val typeSpecBuilder: TypeSpec.Builder + val typeSpecBuilder: TypeSpec.Builder, + val className: ClassName ) -fun getContainerBuilders(): ContainerBuilders { - val containerClassName = ClassName.bestGuess(DISPATCHER_FACTORY_CLASS_NAME) +fun getContainerBuilders(registryName: String?): ContainerBuilders { + val containerClassName = generatedRegistryClassName(registryName) val containerFile = FileSpec.builder(containerClassName.packageName, containerClassName.simpleName) - val container = TypeSpec.objectBuilder(containerClassName) + val container = TypeSpec.classBuilder(containerClassName) .addKdoc("Automatically generated, do not edit.\n") .superclass(Mini::class) - return ContainerBuilders(containerFile, container) -} \ No newline at end of file + return ContainerBuilders(containerFile, container, containerClassName) +} + +fun generatedRegistryClassName(registryName: String?): ClassName { + val registryPackageSegment = registryName + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let(::sanitizeRegistryName) + ?: "" + + val packageName = if (registryPackageSegment.isEmpty()) { + MINI_REGISTRY_PACKAGE_NAME + } else { + "$MINI_REGISTRY_PACKAGE_NAME.$registryPackageSegment" + } + + return ClassName(packageName, GENERATED_REGISTRY_SIMPLE_NAME) +} + +private fun sanitizeRegistryName(name: String): String { + val sanitized = name.lowercase().replace(Regex("[^a-z0-9_]"), "_") + return if (sanitized.first().isDigit()) "_$sanitized" else sanitized +} diff --git a/mini-processor/src/main/java/mini/processor/kapt/MiniAnnotationProcessor.java b/mini-processor/src/main/java/mini/processor/kapt/MiniAnnotationProcessor.java index 7655be7..d5f4dea 100644 --- a/mini-processor/src/main/java/mini/processor/kapt/MiniAnnotationProcessor.java +++ b/mini-processor/src/main/java/mini/processor/kapt/MiniAnnotationProcessor.java @@ -32,7 +32,7 @@ * Dummy Java wrapper that delegates to Kotlin one */ @IncrementalAnnotationProcessor(IncrementalAnnotationProcessorType.AGGREGATING) -@SupportedOptions("kapt.kotlin.generated") +@SupportedOptions({"kapt.kotlin.generated", "mini.registryName"}) public class MiniAnnotationProcessor extends AbstractProcessor { private final Processor processor = new Processor(); diff --git a/mini-processor/src/main/java/mini/processor/kapt/Processor.kt b/mini-processor/src/main/java/mini/processor/kapt/Processor.kt index f550820..7477885 100644 --- a/mini-processor/src/main/java/mini/processor/kapt/Processor.kt +++ b/mini-processor/src/main/java/mini/processor/kapt/Processor.kt @@ -21,13 +21,17 @@ package mini.processor.kapt import mini.Action import mini.Reducer import mini.processor.common.ProcessorException +import mini.processor.common.MINI_REGISTRY_NAME_OPTION import mini.processor.common.actions.ActionTypesGenerator import mini.processor.common.getContainerBuilders import mini.processor.common.reducers.ReducersGenerator +import mini.processor.kapt.isSuspending import mini.processor.kapt.actions.KaptActionTypesGeneratorDelegate import mini.processor.kapt.reducers.KaptReducersGeneratorDelegate import javax.annotation.processing.ProcessingEnvironment import javax.annotation.processing.RoundEnvironment +import javax.lang.model.element.Element +import javax.lang.model.element.ExecutableElement import javax.lang.model.SourceVersion class Processor { @@ -49,12 +53,14 @@ class Processor { val roundActions = roundEnv.getElementsAnnotatedWith(Action::class.java) val roundReducers = roundEnv.getElementsAnnotatedWith(Reducer::class.java) - if (roundActions.isEmpty()) return false + if (roundActions.isEmpty() && roundReducers.isEmpty()) return false - val (containerFile, container) = getContainerBuilders() + val registryName = env.options[MINI_REGISTRY_NAME_OPTION] + val (containerFile, container, _) = getContainerBuilders(registryName) + val referencedActionElements = reducerActionElements(roundReducers) try { - ActionTypesGenerator(KaptActionTypesGeneratorDelegate(roundActions)).generate(container) + ActionTypesGenerator(KaptActionTypesGeneratorDelegate(roundActions + referencedActionElements)).generate(container) ReducersGenerator(KaptReducersGeneratorDelegate(roundReducers)).generate(container) } catch (e: Throwable) { if (e !is ProcessorException) { @@ -72,4 +78,20 @@ class Processor { return true } + + private fun reducerActionElements(reducers: Set): Set { + return reducers + .filterIsInstance() + .mapNotNull { reducer -> + val parameters = if (reducer.isSuspending()) reducer.parameters.dropLast(1) else reducer.parameters + val actionIndex = when (parameters.size) { + 1 -> 0 + 2 -> 1 + else -> return@mapNotNull null + } + typeUtils.asElement(parameters[actionIndex].asType()) + } + .distinctBy { it.toString() } + .toSet() + } } diff --git a/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessor.kt b/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessor.kt index 6875d9b..106f485 100644 --- a/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessor.kt +++ b/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessor.kt @@ -5,6 +5,8 @@ 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 com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.squareup.kotlinpoet.ksp.writeTo import mini.Action import mini.Reducer @@ -17,28 +19,30 @@ import mini.processor.ksp.actions.KspActionTypesGeneratorDelegate import mini.processor.ksp.reducers.KspReducersGeneratorDelegate class MiniSymbolProcessor( - private val codeGenerator: CodeGenerator + private val codeGenerator: CodeGenerator, + private val registryName: String? ) : SymbolProcessor { override fun process(resolver: Resolver): List { // Get elements with the @Reducer or @Action annotations - val actionSymbols = resolver.getSymbolsWithAnnotation(Action::class.java.canonicalName) - val reducerSymbols = resolver.getSymbolsWithAnnotation(Reducer::class.java.canonicalName) + val actionSymbols = resolver.getSymbolsWithAnnotation(Action::class.java.canonicalName).toList() + val reducerSymbols = resolver.getSymbolsWithAnnotation(Reducer::class.java.canonicalName).toList() // Collect the files that contain the symbols, we will use this to set the originating files // for the generated code and incremental processing. - val originatingKsFiles = (actionSymbols + reducerSymbols). - filterIsInstance() + val originatingKsFiles = (actionSymbols + reducerSymbols) + .filterIsInstance() .mapNotNull { it.containingFile } .distinct() .toList() - if (!actionSymbols.iterator().hasNext()) return emptyList() + if (actionSymbols.isEmpty() && reducerSymbols.isEmpty()) return emptyList() - val (containerFile, container) = getContainerBuilders() + val (containerFile, container, _) = getContainerBuilders(registryName) + val referencedActionSymbols = reducerActionDeclarations(reducerSymbols) try { - ActionTypesGenerator(KspActionTypesGeneratorDelegate(actionSymbols)).generate(container) - ReducersGenerator(KspReducersGeneratorDelegate(reducerSymbols)).generate(container) + ActionTypesGenerator(KspActionTypesGeneratorDelegate((actionSymbols + referencedActionSymbols).asSequence())).generate(container) + ReducersGenerator(KspReducersGeneratorDelegate(reducerSymbols.asSequence())).generate(container) } catch (e: Throwable) { if (e !is ProcessorException) { kspLogError( @@ -59,4 +63,19 @@ class MiniSymbolProcessor( return emptyList() } -} \ No newline at end of file + + private fun reducerActionDeclarations(reducerSymbols: List): List { + return reducerSymbols + .filterIsInstance() + .mapNotNull { reducer -> + val parameters = reducer.parameters + val actionIndex = when (parameters.size) { + 1 -> 0 + 2 -> 1 + else -> return@mapNotNull null + } + parameters[actionIndex].type.resolve().declaration as? KSClassDeclaration + } + .distinctBy { it.qualifiedName?.asString() ?: it.simpleName.asString() } + } +} diff --git a/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessorProvider.kt b/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessorProvider.kt index 3b095ad..1169fe1 100644 --- a/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessorProvider.kt +++ b/mini-processor/src/main/java/mini/processor/ksp/MiniSymbolProcessorProvider.kt @@ -3,13 +3,15 @@ package mini.processor.ksp import com.google.devtools.ksp.processing.SymbolProcessor import com.google.devtools.ksp.processing.SymbolProcessorEnvironment import com.google.devtools.ksp.processing.SymbolProcessorProvider +import mini.processor.common.MINI_REGISTRY_NAME_OPTION class MiniSymbolProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { logger = environment.logger return MiniSymbolProcessor( - codeGenerator = environment.codeGenerator + codeGenerator = environment.codeGenerator, + registryName = environment.options[MINI_REGISTRY_NAME_OPTION] ) } -} \ No newline at end of file +} diff --git a/mini-processor/src/test/java/mini/processor/common/ContainerBuildersTest.kt b/mini-processor/src/test/java/mini/processor/common/ContainerBuildersTest.kt new file mode 100644 index 0000000..59b9f1b --- /dev/null +++ b/mini-processor/src/test/java/mini/processor/common/ContainerBuildersTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.processor.common + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ContainerBuildersTest { + + @Test + fun `uses stable default registry package when name is missing`() { + val className = generatedRegistryClassName(null) + + assertEquals("mini.codegen", className.packageName) + assertEquals("Mini_Generated", className.simpleName) + } + + @Test + fun `uses stable default registry package when name is blank`() { + val className = generatedRegistryClassName(" ") + + assertEquals("mini.codegen", className.packageName) + assertEquals("Mini_Generated", className.simpleName) + } + + @Test + fun `uses explicit registry name as package segment`() { + val className = generatedRegistryClassName("feature") + + assertEquals("mini.codegen.feature", className.packageName) + assertEquals("Mini_Generated", className.simpleName) + } + + @Test + fun `sanitizes explicit registry name`() { + val className = generatedRegistryClassName("my-feature name") + + assertEquals("mini.codegen.my_feature_name", className.packageName) + assertEquals("Mini_Generated", className.simpleName) + } +} diff --git a/sample-counter-feature/.gitignore b/sample-counter-feature/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/sample-counter-feature/.gitignore @@ -0,0 +1 @@ +/build diff --git a/sample-counter-feature/build.gradle.kts b/sample-counter-feature/build.gradle.kts new file mode 100644 index 0000000..08973f4 --- /dev/null +++ b/sample-counter-feature/build.gradle.kts @@ -0,0 +1,54 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.ksp) + alias(libs.plugins.convention.androidLib) +} + +ksp { + arg("mini.registryName", "counter_feature") +} + +android { + namespace = "mini.android.sample.counter" + + compileSdk = libs.versions.android.compileSdk.get().toInt() + buildToolsVersion = libs.versions.android.buildTools.get() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.java.sdk.get() + } +} + +dependencies { + implementation(project(":mini-common")) + ksp(project(":mini-processor")) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/sample-counter-feature/src/main/AndroidManifest.xml b/sample-counter-feature/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/sample-counter-feature/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample-counter-feature/src/main/kotlin/mini/android/sample/counter/CounterFeature.kt b/sample-counter-feature/src/main/kotlin/mini/android/sample/counter/CounterFeature.kt new file mode 100644 index 0000000..a665ae5 --- /dev/null +++ b/sample-counter-feature/src/main/kotlin/mini/android/sample/counter/CounterFeature.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package mini.android.sample.counter + +import kotlinx.coroutines.CoroutineScope +import mini.Dispatcher +import mini.Mini +import mini.Action +import mini.Reducer +import mini.State +import mini.Store +import mini.codegen.counter_feature.Mini_Generated +import mini.flow +import java.io.Closeable + +data class CounterState(val count: Int = 0) : State + +@Action +data class IncrementCounterAction(val amount: Int = 1) + +class CounterStore : Store() { + @Reducer + fun increment(state: CounterState, action: IncrementCounterAction): CounterState { + return state.copy(count = state.count + action.amount) + } +} + +class CounterFeatureRuntime : Closeable { + private val registry = Mini_Generated() + private val dispatcher = Dispatcher() + private val store = CounterStore() + private val subscriptions = Mini.link(registry, dispatcher, store) + + init { + store.initialize() + } + + fun flow() = store.flow() + + fun increment(scope: CoroutineScope, amount: Int = 1) { + dispatcher.dispatchOn(IncrementCounterAction(amount), scope) + } + + override fun close() { + subscriptions.close() + store.close() + } +} diff --git a/samples/isolated-consumer/.gitignore b/samples/isolated-consumer/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/samples/isolated-consumer/.gitignore @@ -0,0 +1 @@ +/build diff --git a/samples/isolated-consumer/app/.gitignore b/samples/isolated-consumer/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/samples/isolated-consumer/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/samples/isolated-consumer/app/build.gradle.kts b/samples/isolated-consumer/app/build.gradle.kts new file mode 100644 index 0000000..e3152aa --- /dev/null +++ b/samples/isolated-consumer/app/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + application +} + +dependencies { + implementation(project(":mini-common")) + implementation(project(":message-feature")) + implementation(libs.kotlinx.coroutines.core) + + testImplementation(libs.junit) + testImplementation(libs.kluent) + testImplementation(libs.kotlinx.coroutines.core) +} + +application { + mainClass.set("sample.consumer.app.MainKt") +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = libs.versions.java.sdk.get() + } +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) +} + +kotlin { + jvmToolchain(libs.versions.java.sdk.get().toInt()) +} diff --git a/samples/isolated-consumer/app/src/main/kotlin/sample/consumer/app/Main.kt b/samples/isolated-consumer/app/src/main/kotlin/sample/consumer/app/Main.kt new file mode 100644 index 0000000..d2c6639 --- /dev/null +++ b/samples/isolated-consumer/app/src/main/kotlin/sample/consumer/app/Main.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.consumer.app + +import kotlinx.coroutines.runBlocking +import sample.consumer.message.MessageFeatureRuntime + +fun main() = runBlocking { + MessageFeatureRuntime().use { runtime -> + runtime.advance() + println(runtime.state) + } +} diff --git a/samples/isolated-consumer/app/src/test/kotlin/sample/consumer/app/ExternalSampleIntegrationTest.kt b/samples/isolated-consumer/app/src/test/kotlin/sample/consumer/app/ExternalSampleIntegrationTest.kt new file mode 100644 index 0000000..48601a7 --- /dev/null +++ b/samples/isolated-consumer/app/src/test/kotlin/sample/consumer/app/ExternalSampleIntegrationTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.consumer.app + +import kotlinx.coroutines.runBlocking +import org.amshove.kluent.`should be equal to` +import org.junit.Test +import sample.consumer.message.MessageFeatureRuntime + +class ExternalSampleIntegrationTest { + + @Test + fun `external consumer can use message feature with its own local mini runtime`() { + runBlocking { + MessageFeatureRuntime().use { runtime -> + runtime.advance() + runtime.state.text `should be equal to` "message-1" + + runtime.setMessage("external-consumer") + runtime.state.text `should be equal to` "external-consumer" + } + } + } +} diff --git a/samples/isolated-consumer/build.gradle.kts b/samples/isolated-consumer/build.gradle.kts new file mode 100644 index 0000000..a489e77 --- /dev/null +++ b/samples/isolated-consumer/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + base +} diff --git a/samples/isolated-consumer/message-feature/.gitignore b/samples/isolated-consumer/message-feature/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/samples/isolated-consumer/message-feature/.gitignore @@ -0,0 +1 @@ +/build diff --git a/samples/isolated-consumer/message-feature/build.gradle.kts b/samples/isolated-consumer/message-feature/build.gradle.kts new file mode 100644 index 0000000..05a61c1 --- /dev/null +++ b/samples/isolated-consumer/message-feature/build.gradle.kts @@ -0,0 +1,47 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.ksp) +} + +ksp { + arg("mini.registryName", "external_message_feature") +} + +dependencies { + implementation(project(":mini-common")) + ksp(project(":mini-processor")) + + implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.coroutines.core) +} + +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = libs.versions.java.sdk.get() + } +} + +java { + sourceCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.java.sdk.get()) +} + +kotlin { + jvmToolchain(libs.versions.java.sdk.get().toInt()) +} diff --git a/samples/isolated-consumer/message-feature/src/main/kotlin/sample/consumer/message/MessageFeature.kt b/samples/isolated-consumer/message-feature/src/main/kotlin/sample/consumer/message/MessageFeature.kt new file mode 100644 index 0000000..106102a --- /dev/null +++ b/samples/isolated-consumer/message-feature/src/main/kotlin/sample/consumer/message/MessageFeature.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2026 HyperDevs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sample.consumer.message + +import mini.Action +import mini.Dispatcher +import mini.Mini +import mini.Reducer +import mini.State +import mini.Store +import mini.codegen.external_message_feature.Mini_Generated +import mini.flow +import java.io.Closeable + +data class MessageState( + val text: String = "idle", + val version: Int = 0 +) : State + +@Action +data class SetMessageAction(val value: String) + +@Action +data class AdvanceMessageAction(val prefix: String = "message") + +class MessageStore : Store() { + @Reducer + fun setMessage(state: MessageState, action: SetMessageAction): MessageState { + return state.copy(text = action.value) + } + + @Reducer + fun advanceMessage(state: MessageState, action: AdvanceMessageAction): MessageState { + val nextVersion = state.version + 1 + return state.copy(text = "${action.prefix}-$nextVersion", version = nextVersion) + } +} + +class MessageFeatureRuntime : Closeable { + private val registry = Mini_Generated() + private val dispatcher = Dispatcher() + private val store = MessageStore() + private val subscriptions = Mini.link(registry, dispatcher, store) + + val state: MessageState + get() = store.state + + fun flow() = store.flow() + + init { + store.initialize() + } + + suspend fun advance(prefix: String = "message") { + dispatcher.dispatch(AdvanceMessageAction(prefix)) + } + + suspend fun setMessage(value: String) { + dispatcher.dispatch(SetMessageAction(value)) + } + + override fun close() { + subscriptions.close() + store.close() + } +} diff --git a/samples/isolated-consumer/settings.gradle.kts b/samples/isolated-consumer/settings.gradle.kts new file mode 100644 index 0000000..137ffa1 --- /dev/null +++ b/samples/isolated-consumer/settings.gradle.kts @@ -0,0 +1,39 @@ +@file:Suppress("UnstableApiUsage") + +rootProject.name = "isolated-consumer" + +include(":app") +include(":message-feature") +include(":mini-common") +include(":mini-processor") + +project(":mini-common").projectDir = file("../../mini-common") +project(":mini-processor").projectDir = file("../../mini-processor") + +includeBuild("../../convention-plugins") + +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { url = java.net.URI("https://jitpack.io") } + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + google() + mavenCentral() + gradlePluginPortal() + maven { url = java.net.URI("https://jitpack.io") } + } + + versionCatalogs { + create("libs") { + from(files("../../gradle/libs.versions.toml")) + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index a876413..9e5247c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,15 +17,22 @@ @file:Suppress("UnstableApiUsage") include(":app") +include(":sample-counter-feature") +include(":isolated-consumer-message-feature") include(":mini-processor") include(":mini-common") include(":mini-android") include(":mini-processor-test") +include(":mini-processor-ksp-test") +include(":mini-processor-multiregistry-test") +include(":mini-processor-reducer-only-test") include(":mini-kodein") include(":mini-kodein-android") include(":mini-kodein-android-compose") include(":mini-testing") +project(":isolated-consumer-message-feature").projectDir = file("samples/isolated-consumer/message-feature") + // Modules to add as composite builds includeBuild("convention-plugins") @@ -47,4 +54,4 @@ dependencyResolutionManagement { gradlePluginPortal() maven { url = java.net.URI("https://jitpack.io") } } -} \ No newline at end of file +}