diff --git a/CHANGELOG.md b/CHANGELOG.md index ced04d0..b8b77d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 0.9.9 - 2026-07-31 + +* Add flavor-aware `.dot` file routing to `way-gradle-plugin`: a product flavor (or build type, or + flavor+buildType variant) can override any `.dot` file from a lower-priority source set by + placing a file at the same relative path — full-file replacement, same precedence Android uses + for resources (`main` < flavor < buildType < variant-specific) + * Per-variant `generateWayClasses` tasks are registered only when a variant actually + resolves to a distinct file set, so single-flavor and non-Android projects keep the existing + cheap single-task path + * Variant-specific-only overrides (e.g. `src/googleDebug/way/`) are detected via filesystem + scanning at the point AGP's DSL source set container is still incomplete, avoiding the + timing gap where such overrides would otherwise be silently ignored + * Blank `.dot` files no longer NPE in the ANTLR parser; empty/unmatched override directories + surface a clear warning or validation error instead of silently generating nothing +* Add a flavor-dot-routing sample to `sample-compose:app:routing` (`google`/`huawei` flavors) with + matching flavor-specific hand-written `FlowNode` implementations and unit tests proving both + flavors' generated code compiles and behaves correctly + ## 0.9.8 - 2026-07-07 The headline of this release is **parallel navigation**, built on the W3C SCXML statechart diff --git a/README.md b/README.md index b1a48f3..87eec91 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ The Compose integration (`:way-compose`) renders active nodes with `NodeHost`. - `:way-gradle-plugin` - Gradle plugin (`id("ru.kode.way")`) that generates code from `.dot` schemas. - `:sample` - JVM sample (KMP multi-target support is not yet released). - `:sample-compose:*` - Android sample split into feature modules. + - `:sample-compose:main-parallel:*` - tab-bar example using `ParallelFlowNode` (see [Compose Integration](#compose-integration)). + - `:sample-compose:app:routing` - `google`/`huawei` product flavors with per-flavor `.dot` overrides (see [Flavor-Specific Routing](#flavor-specific-routing)). ## Requirements @@ -122,6 +124,45 @@ Typical generated types (from graph id `App`): - Android + KSP: variant `kspKotlin` tasks depend on generation and receive generated roots. - Kotlin/JVM: generated dir is added to `main`. +### Flavor-Specific Routing + +Android product flavors can each get their own `.dot` file for the same schema — e.g. a step only +some flavors need. Resolution works exactly like Android resource merging: a file at +`src//way/.dot` **fully replaces** the file at the same relative path in a +lower-priority source set (never merged). Priority, low to high: +`main` < flavor < build type < flavor+buildType variant-specific. + +``` +src/main/way/app-flow.dot # base: app -> login -> main +src/huawei/way/app-flow.dot # override: app -> main (huawei skips login) +``` + +The plugin registers one `generateWayClasses` task per Android variant +(`generateGoogleDebugWayClasses`, `generateHuaweiDebugWayClasses`, ...), each writing to +`build/generated/way/code//`. A flavor that doesn't override anything simply reuses +`main`'s file as its own input — there is no `generated/way/code/main/`, since `main` is a +contributing source set, never a buildable variant on its own. + +**The gotcha: hand-written code can't straddle flavors.** `.dot` overrides use full-file +*replacement*; ordinary Kotlin/Java source sets (`src/main/kotlin`, `src/google/kotlin`, +`src/huawei/kotlin`) use plain Gradle *union* — every flavor's compilation always includes +`main/kotlin`. So if one flavor's `.dot` file declares a node another flavor's doesn't, the +flavor missing it won't generate the matching class member (e.g. `AppChildFinishRequest.Login`), +and hand-written code in `src/main/kotlin` referencing it fails with `Unresolved reference` for +that flavor. + +Rule: anything touching a symbol that isn't in **every** flavor's generated output must live in +that flavor's own Kotlin source set (`src/google/kotlin`, `src/huawei/kotlin`, ...), not +`src/main/kotlin`. Code that stays in `main` may only reference the intersection of what every +flavor generates. + +Worked example: `sample-compose/app/routing` (`google` / `huawei` flavors) — +`src/huawei/way/app-flow.dot` overrides `src/main/way/app-flow.dot` to drop a Google-only login +step, and the hand-written `AppFlow.kt` / `AppFlowNode.kt` / `di/AppFlowComponent.kt` are +duplicated per flavor under `src/google/kotlin` / `src/huawei/kotlin` accordingly. +`src/test/kotlin/.../AppFlowNodeTest.kt` is a shared test that only touches the symbols common to +both flavors, compiled once per flavor to prove both generate usable code. + ## Runtime Model Core runtime types in `:way`: diff --git a/gradle.properties b/gradle.properties index 0d03156..4ddb0c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ kotlin.native.ignoreDisabledTargets=true android.useAndroidX=true -versionName=0.9.8 +versionName=0.9.9 pomGroupId=ru.kode pomDescription=Navigation library based on statechart-like node graphs pomUrl=https://github.com/appKODE/way diff --git a/sample-compose/app/build.gradle.kts b/sample-compose/app/build.gradle.kts index 9ca137c..134cc3f 100644 --- a/sample-compose/app/build.gradle.kts +++ b/sample-compose/app/build.gradle.kts @@ -25,6 +25,12 @@ android { targetCompatibility = JavaVersion.VERSION_11 } + flavorDimensions += "store" + productFlavors { + create("google") { dimension = "store" } + create("huawei") { dimension = "store" } + } + buildFeatures { compose = true } diff --git a/sample-compose/app/routing/build.gradle.kts b/sample-compose/app/routing/build.gradle.kts index 1391b18..0889b79 100644 --- a/sample-compose/app/routing/build.gradle.kts +++ b/sample-compose/app/routing/build.gradle.kts @@ -16,6 +16,12 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } + + flavorDimensions += "store" + productFlavors { + create("google") { dimension = "store" } + create("huawei") { dimension = "store" } + } } kotlin { @@ -32,4 +38,11 @@ dependencies { implementation(libs.dagger) ksp(libs.daggerCompiler) + + testImplementation(libs.bundles.koTestCommon) + testImplementation(libs.bundles.koTestJvm) +} + +tasks.withType { + useJUnitPlatform() } diff --git a/sample-compose/app/routing/src/main/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt b/sample-compose/app/routing/src/google/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt similarity index 100% rename from sample-compose/app/routing/src/main/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt rename to sample-compose/app/routing/src/google/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt diff --git a/sample-compose/app/routing/src/main/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt b/sample-compose/app/routing/src/google/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt similarity index 100% rename from sample-compose/app/routing/src/main/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt rename to sample-compose/app/routing/src/google/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt diff --git a/sample-compose/app/routing/src/main/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt b/sample-compose/app/routing/src/google/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt similarity index 100% rename from sample-compose/app/routing/src/main/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt rename to sample-compose/app/routing/src/google/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt diff --git a/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt b/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt new file mode 100644 index 0000000..048a0a6 --- /dev/null +++ b/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlow.kt @@ -0,0 +1,14 @@ +package ru.kode.way.sample.compose.app.routing + +import ru.kode.way.sample.compose.app.routing.di.AppFlowComponent +import ru.kode.way.sample.compose.main.parallel.routing.MainParallelFlow +import ru.kode.way.sample.compose.main.routing.MainFlow + +object AppFlow { + fun nodeBuilder(component: AppFlowComponent): AppNodeBuilder = AppNodeBuilder(component.nodeFactory(), schema) + + val schema: AppSchema = AppSchema( + MainFlow.schema, + MainParallelFlow.schema, + ) +} diff --git a/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt b/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt new file mode 100644 index 0000000..419753c --- /dev/null +++ b/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNode.kt @@ -0,0 +1,22 @@ +package ru.kode.way.sample.compose.app.routing + +import ru.kode.way.Event +import ru.kode.way.Finish +import ru.kode.way.FlowNode +import ru.kode.way.FlowTransition +import ru.kode.way.Ignore +import ru.kode.way.Target +import javax.inject.Inject + +// Huawei flavor's app-flow.dot (src/huawei/way/app-flow.dot) drops the Google-only login step, +// so this flow starts directly at "main" instead of "login". +class AppFlowNode @Inject constructor() : FlowNode { + override val initial: Target = Target.app.main + + override val dismissResult: Unit = Unit + + override fun transition(event: Event): FlowTransition = when (event) { + is AppChildFinishRequest.Main -> Finish(Unit) + else -> Ignore + } +} diff --git a/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt b/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt new file mode 100644 index 0000000..f6482c3 --- /dev/null +++ b/sample-compose/app/routing/src/huawei/kotlin/ru/kode/way/sample/compose/app/routing/di/AppFlowComponent.kt @@ -0,0 +1,41 @@ +package ru.kode.way.sample.compose.app.routing.di + +import dagger.Module +import dagger.Provides +import dagger.Subcomponent +import ru.kode.way.FlowNode +import ru.kode.way.NodeBuilder +import ru.kode.way.sample.compose.app.routing.AppFlowNode +import ru.kode.way.sample.compose.app.routing.AppNodeBuilder +import ru.kode.way.sample.compose.main.parallel.routing.MainParallelFlow +import ru.kode.way.sample.compose.main.parallel.routing.di.MainParallelFlowComponent +import ru.kode.way.sample.compose.main.routing.MainFlow +import ru.kode.way.sample.compose.main.routing.di.MainFlowComponent +import javax.inject.Provider +import javax.inject.Scope + +@Scope +annotation class AppFlowScope + +@Subcomponent(modules = [AppFlowModule::class]) +@AppFlowScope +interface AppFlowComponent { + fun mainFlowComponent(): MainFlowComponent + fun mainParallelFlowComponent(): MainParallelFlowComponent + + fun nodeFactory(): AppNodeBuilder.Factory +} + +@Module +object AppFlowModule { + + @Provides + @AppFlowScope + fun provideNodeFactory(component: AppFlowComponent, appFlowNode: Provider): AppNodeBuilder.Factory = + object : AppNodeBuilder.Factory { + override fun createRootNode(): FlowNode<*> = appFlowNode.get() + override fun createMainNodeBuilder(): NodeBuilder = MainFlow.nodeBuilder(component.mainFlowComponent()) + override fun createMainParallelNodeBuilder(): NodeBuilder = + MainParallelFlow.nodeBuilder(component.mainParallelFlowComponent()) + } +} diff --git a/sample-compose/app/routing/src/huawei/way/app-flow.dot b/sample-compose/app/routing/src/huawei/way/app-flow.dot new file mode 100644 index 0000000..3e5d1a7 --- /dev/null +++ b/sample-compose/app/routing/src/huawei/way/app-flow.dot @@ -0,0 +1,10 @@ +digraph App { + package = "ru.kode.way.sample.compose.app.routing" + + app [type=flow] + main [type=schema, resultType = "ru.kode.way.sample.compose.main.routing.MainFlowResult"] + mainParallel [type=schema] + + app -> main + app -> mainParallel +} diff --git a/sample-compose/app/routing/src/test/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeTest.kt b/sample-compose/app/routing/src/test/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeTest.kt new file mode 100644 index 0000000..15ea376 --- /dev/null +++ b/sample-compose/app/routing/src/test/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeTest.kt @@ -0,0 +1,17 @@ +package ru.kode.way.sample.compose.app.routing + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import ru.kode.way.Finish +import ru.kode.way.sample.compose.main.routing.MainFlowResult + +// Lives in src/test (not per-flavor), so this same file is compiled and run once per flavor +// (testGoogleDebugUnitTest, testHuaweiDebugUnitTest) against that flavor's own generated +// AppChildFinishRequest/Finish types — proving both flavors' generated code is usable, not just +// that it compiles for one of them. +class AppFlowNodeTest : + ShouldSpec({ + should("finish the app flow when the main flow finishes, regardless of flavor") { + AppFlowNode().transition(AppChildFinishRequest.Main(MainFlowResult.Dismissed)) shouldBe Finish(Unit) + } + }) diff --git a/sample-compose/app/routing/src/testGoogle/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeGoogleTest.kt b/sample-compose/app/routing/src/testGoogle/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeGoogleTest.kt new file mode 100644 index 0000000..baf090c --- /dev/null +++ b/sample-compose/app/routing/src/testGoogle/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeGoogleTest.kt @@ -0,0 +1,15 @@ +package ru.kode.way.sample.compose.app.routing + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import ru.kode.way.Target + +// Target.app.login only exists because google's app-flow.dot (src/main/way) declares a "login" +// node — a huawei test referencing it would fail to compile, which is itself part of the proof +// that flavor dot-file overrides produce genuinely different generated schemas. +class AppFlowNodeGoogleTest : + ShouldSpec({ + should("start at login in the google flavor's app flow") { + AppFlowNode().initial shouldBe Target.app.login + } + }) diff --git a/sample-compose/app/routing/src/testHuawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeHuaweiTest.kt b/sample-compose/app/routing/src/testHuawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeHuaweiTest.kt new file mode 100644 index 0000000..c6d45a2 --- /dev/null +++ b/sample-compose/app/routing/src/testHuawei/kotlin/ru/kode/way/sample/compose/app/routing/AppFlowNodeHuaweiTest.kt @@ -0,0 +1,12 @@ +package ru.kode.way.sample.compose.app.routing + +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import ru.kode.way.Target + +class AppFlowNodeHuaweiTest : + ShouldSpec({ + should("skip the google-only login step and start at main in the huawei flavor's app flow") { + AppFlowNode().initial shouldBe Target.app.main + } + }) diff --git a/way-gradle-plugin/build.gradle.kts b/way-gradle-plugin/build.gradle.kts index 1f87e24..74704c4 100644 --- a/way-gradle-plugin/build.gradle.kts +++ b/way-gradle-plugin/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { testImplementation(libs.bundles.koTestJvm) testImplementation(libs.okio) testImplementation(libs.kotlin.plugin) + testImplementation(libs.android.plugin) testImplementation(libs.ksp.gradle.plugin) testImplementation(gradleTestKit()) diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FlavorSourceResolution.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FlavorSourceResolution.kt new file mode 100644 index 0000000..99e53df --- /dev/null +++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/FlavorSourceResolution.kt @@ -0,0 +1,61 @@ +package ru.kode.way.gradle + +import com.android.build.api.variant.ComponentIdentity +import java.io.File + +/** + * Resolves the ordered list of Android source set names that contribute to [identity], from + * lowest to highest priority — mirroring AGP's own precedence for merging per-source-set content + * (resources, manifests, etc): `main` < product flavor(s) < build type < the variant-specific + * (flavor+buildType combined) source set. + * + * [ComponentIdentity.getProductFlavors] is ordered by `flavorDimensions` declaration order, and per + * AGP's own documented precedence the FIRST-declared dimension has the HIGHEST priority among + * flavors ("Gradle determines the priority between flavor dimensions based on the order in which + * they appear next to the flavorDimensions property, with the first dimension having a higher + * priority than the second, and so on." — + * https://developer.android.com/build/build-variants#flavor-dimensions). Since this function + * builds an ASCENDING-priority list (consumed by [resolveOverriddenDotFiles], which lets later + * entries win), the flavor list must be walked in REVERSE declaration order so the first-declared + * dimension's flavor ends up last among the flavor entries — i.e. highest-priority. + * + * The variant-specific name is only included when it differs from the build type name — for a + * project with no product flavors, [ComponentIdentity.getFlavorName] is empty and the variant + * name equals the build type name (e.g. "debug"), so there is no distinct fourth source set. + */ +internal fun variantSourceSetNamesInPriorityOrder(identity: ComponentIdentity): List { + val names = mutableListOf("main") + identity.productFlavors.asReversed().forEach { (_, flavorName) -> names.add(flavorName) } + identity.buildType?.let { names.add(it) } + if (identity.productFlavors.isNotEmpty() && identity.name != identity.buildType) { + names.add(identity.name) + } + return names.distinct() +} + +/** + * File-level full-replacement override resolver. + * + * [sourceSetDirsInPriorityOrder] holds, for each contributing source set in ascending priority + * order (as produced by [variantSourceSetNamesInPriorityOrder]), the list of `way/` directories + * belonging to that source set. Every `.dot` file found under those directories is keyed by its + * path relative to the containing directory; when a higher-priority source set has a `.dot` file + * at the same relative path as a lower-priority one, it fully replaces it. Files present in only + * one source set pass through unchanged. + */ +internal fun resolveOverriddenDotFiles(sourceSetDirsInPriorityOrder: List>): List { + val fileByRelativePath = LinkedHashMap() + sourceSetDirsInPriorityOrder.forEach { dirs -> + dirs.forEach { dir -> + if (dir.isDirectory) { + dir.walkTopDown() + .filter { candidate -> candidate.isFile && candidate.extension == "dot" } + .forEach { dotFile -> + val relativePath = dotFile.relativeTo(dir).path + fileByRelativePath[relativePath] = dotFile + } + } + } + } + return fileByRelativePath.values.toList() +} diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/GenerateClassesTask.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/GenerateClassesTask.kt index 1dfeaa7..255bd6e 100644 --- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/GenerateClassesTask.kt +++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/GenerateClassesTask.kt @@ -56,6 +56,21 @@ abstract class GenerateClassesTask : SourceTask() { parseSchemaDotFile(file, projectDir, warn = logger::warn) } validateNoOutputFileCollisions(parseResults, config) + // An empty (or whitespace-only) schema file parses to an empty adjacencyList with no + // validator errors — SchemaRegistry.from() already skips these silently for registry-matching + // purposes, but the codegen path below has no equivalent guard and would otherwise fail deep + // inside codegen (e.g. a root-node lookup on an empty graph) with an unhelpful, unrelated + // exception. Fail fast here with a message naming the specific file, since this most likely + // means a flavor/buildType override file accidentally replaced a real base graph with a blank + // stub. + val emptySchemaFiles = parseResults.filter { it.adjacencyList.isEmpty() } + if (emptySchemaFiles.isNotEmpty()) { + error( + emptySchemaFiles.joinToString("\n") { parseResult -> + "${parseResult.filePath} parsed to an empty navigation graph — check for a blank or invalid override file." + }, + ) + } // Build the cross-file registry once so every per-file codegen pass agrees on segment ids // at schema boundaries (parent emits the same id for `homeFlow [type=schema]` that the // child schema emits for its own rootSegment). See `SchemaRegistry`. diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Parser.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Parser.kt index 827e77d..1395bce 100644 --- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Parser.kt +++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/Parser.kt @@ -86,7 +86,10 @@ private class Visitor : DotBaseVisitor() { } private fun findGraphAttributeValue(ctx: GraphContext, name: String): String? { - for (stmt in ctx.stmt_list().stmt()) { + // A genuinely blank/whitespace-only `.dot` file fails ANTLR's grammar check on immediate EOF, + // leaving `ctx.stmt_list()` null. Route that into the same empty-adjacency-list path (and its + // clear "empty navigation graph" error in GenerateClassesTask.generate()) instead of NPEing here. + for (stmt in ctx.stmt_list()?.stmt().orEmpty()) { // A graph attribute statement is `attrName = attrValue`: id_(0) is the name, id_(1) the value. val attrName = stmt.id_(0)?.asString() if (attrName == name) { diff --git a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/WayPlugin.kt b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/WayPlugin.kt index c400972..f3cfe9d 100644 --- a/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/WayPlugin.kt +++ b/way-gradle-plugin/src/main/kotlin/ru/kode/way/gradle/WayPlugin.kt @@ -48,7 +48,18 @@ class WayPlugin : Plugin { "project '${project.path}' but no supported Kotlin plugin was found" } - val mainSources = findMainSources() + // AGP types (CommonExtension / AndroidComponentsExtension) must only ever be resolved when + // Android is genuinely applied to this project. `afterAndroid == true` is only ever passed + // from the `project.pluginManager.withPlugin("com.android.base")` callback in `apply()`, so + // reaching `findAndroidMainSources()` / `setupAndroidPerVariantWayTasksIfFlavored()` here is + // safe. The `afterAndroid == false` branch (from `project.afterEvaluate`) must stay entirely + // clear of those types, since it also runs for plain Kotlin/JVM consumers that never apply + // Android and may not even have AGP on their classpath (`compileOnly` here). + val mainSources = if (afterAndroid) { + if (setupAndroidPerVariantWayTasksIfFlavored()) emptyList() else findAndroidMainSources() + } else { + findNonAndroidMainSources() + } if (mainSources.isNotEmpty()) { val mainTask = project.tasks.register( "generateWayClasses", @@ -95,22 +106,16 @@ class WayPlugin : Plugin { } } - private fun Project.findMainSources(): List { - // Multiplatform project - project.extensions.findByType(KotlinMultiplatformExtension::class.java)?.run { - val commonMain = sourceSets.findByName("commonMain") - if (commonMain != null) { - return listOf( - Source( - name = "commonMain", - sourceDirectories = providers.provider { commonMain.kotlin.srcDirs.toList() }, - registerGeneratedDir = { taskProvider -> - commonMain.kotlin.srcDir(taskProvider) - }, - ), - ) - } - } + /** + * Main-sources resolution for a project where Android is confirmed applied (only ever called + * from the `afterAndroid = true` branch of [setupWayTasks], itself only reachable from the + * `project.pluginManager.withPlugin("com.android.base")` callback in [apply] — so it is safe + * here to resolve AGP's `CommonExtension`/`AndroidComponentsExtension` types). KMP still takes + * priority (a KMP module that also targets Android applies "com.android.base" too, but its + * sources must still be resolved via `commonMain`, not the Android source sets). + */ + private fun Project.findAndroidMainSources(): List { + findKotlinMultiplatformMainSource()?.let { return listOf(it) } // Android project val androidExtension = project.extensions.findByType(CommonExtension::class.java) @@ -157,25 +162,53 @@ class WayPlugin : Plugin { } } - // Kotlin project - (project.extensions.findByName("kotlin") as? KotlinProjectExtension)?.run { - val mainSourceSet = sourceSets.findByName("main") - if (mainSourceSet != null) { - return listOf( - Source( - name = "main", - sourceDirectories = providers.provider { mainSourceSet.kotlin.srcDirs.toList() }, - registerGeneratedDir = { taskProvider -> - mainSourceSet.kotlin.srcDir(taskProvider) - }, - ), - ) - } - } + findPlainKotlinMainSource()?.let { return listOf(it) } return emptyList() } + /** + * Main-sources resolution for a project where Android is NOT applied (only ever called from the + * `afterAndroid = false` branch of [setupWayTasks], reached via `project.afterEvaluate`, which + * runs unconditionally for every consumer — including plain Kotlin/JVM projects that may not + * have AGP on their classpath at all). Must never reference `CommonExtension` / + * `AndroidComponentsExtension`, unlike [findAndroidMainSources]. + */ + private fun Project.findNonAndroidMainSources(): List { + findKotlinMultiplatformMainSource()?.let { return listOf(it) } + findPlainKotlinMainSource()?.let { return listOf(it) } + return emptyList() + } + + /** Shared by [findAndroidMainSources] and [findNonAndroidMainSources]; touches no AGP types. */ + private fun Project.findKotlinMultiplatformMainSource(): Source? { + val commonMain = project.extensions.findByType(KotlinMultiplatformExtension::class.java) + ?.sourceSets + ?.findByName("commonMain") + ?: return null + return Source( + name = "commonMain", + sourceDirectories = providers.provider { commonMain.kotlin.srcDirs.toList() }, + registerGeneratedDir = { taskProvider -> + commonMain.kotlin.srcDir(taskProvider) + }, + ) + } + + /** Shared by [findAndroidMainSources] and [findNonAndroidMainSources]; touches no AGP types. */ + private fun Project.findPlainKotlinMainSource(): Source? { + val kotlinExtension = project.extensions.findByName("kotlin") as? KotlinProjectExtension + ?: return null + val mainSourceSet = kotlinExtension.sourceSets.findByName("main") ?: return null + return Source( + name = "main", + sourceDirectories = providers.provider { mainSourceSet.kotlin.srcDirs.toList() }, + registerGeneratedDir = { taskProvider -> + mainSourceSet.kotlin.srcDir(taskProvider) + }, + ) + } + private fun Project.findTestSources(): List { project.extensions.findByType(KotlinMultiplatformExtension::class.java)?.run { val commonTest = sourceSets.findByName("commonTest") @@ -194,6 +227,163 @@ class WayPlugin : Plugin { return emptyList() } + /** + * Per-VARIANT (flavor x buildType) DOT file routing for Android projects that have at least one + * variant-affecting `way/` override — a buildType-, flavor-, or variant-specific-named source + * set that contributes its own `.dot` file(s) under `way/` on top of (or replacing) `main`. Registers one + * [GenerateClassesTask] per distinct resolved `.dot` file set (usually one per variant, but + * variants that resolve to an identical file set — e.g. no overrides at all for any of their + * constituent source sets — reuse the same task) and wires ONLY the matching variant to it, + * instead of the single glob-union task + wire-everything approach used for the (far more common) + * no-override case. + * + * Returns `false` (doing nothing) for any project that isn't Android, or that IS Android but has + * no such override anywhere (e.g. a project with `productFlavors` declared that never actually + * puts anything under a flavor/buildType `way/` directory) — so the caller can fall back to the + * pre-existing [findAndroidMainSources] path, which remains completely unchanged and therefore + * byte-identical for every consumer that doesn't use per-variant overrides. Note this gate is + * intentionally NOT keyed on `productFlavors.isEmpty()`: a project with zero product flavors but + * a buildType-only override (e.g. `src/debug/way/app.dot` overriding `src/main/way/app.dot`) must + * also take the per-variant path, since the flat glob-union path has no override precedence logic. + */ + private fun Project.setupAndroidPerVariantWayTasksIfFlavored(): Boolean { + // KMP module targeting Android: leave entirely to the commonMain path in findAndroidMainSources(). + if (project.extensions.findByType(KotlinMultiplatformExtension::class.java) + ?.sourceSets + ?.findByName("commonMain") != null + ) { + return false + } + val androidExtension = project.extensions.findByType(CommonExtension::class.java) ?: return false + if (androidExtension.sourceSets.none { sourceSet -> !isWayTestSourceSet(sourceSet.name) }) return false + // Only activate the (more expensive) per-variant path when some non-main, non-test source set + // genuinely contributes a `way/*.dot` file that could override something. A project that merely + // declares `productFlavors` but never puts anything under a flavor/buildType `way/` dir should + // keep using the cheap single-task `findAndroidMainSources()` path. + // + // This walks `src/` on disk directly (by the same naming convention `androidWaySourceSetDirectories` + // uses) instead of querying `androidExtension.sourceSets` for candidate names: at `finalizeDsl` + // time (when this runs, before `onVariants` fires) that DSL container only contains single-level + // names ("main", each buildType, each flavor) and does NOT yet contain composite/variant-specific + // names like "googleDebug" — so a variant-specific-only override would be silently missed by a + // DSL-container scan. Scanning the filesystem directly finds any such directory regardless of + // whether AGP's DSL container knows about it yet. + val overrideDirNames = waySourceSetOverrideDirNames() + if (overrideDirNames.isEmpty()) return false + val androidComponents = project.extensions.findByType(AndroidComponentsExtension::class.java) ?: return false + + val tasksByResolvedFileSet = mutableMapOf, TaskProvider>() + val consumedSourceSetNames = mutableSetOf() + + androidComponents.onVariants(androidComponents.selector().all()) { variant -> + val sourceSetNames = variantSourceSetNamesInPriorityOrder(variant) + consumedSourceSetNames += sourceSetNames + val sourceSetDirs = sourceSetNames.map { sourceSetName -> + androidWaySourceSetDirectories(sourceSetName) + } + // Eagerly resolve once here (at configuration time) purely to compute the dedup key below — + // task creation/naming/dedup is inherently a configuration-time decision. The actual file + // list handed to `task.source(...)` is re-resolved lazily via a Provider so it defers the + // directory walk to execution time and correctly reacts to filesystem changes, matching the + // laziness convention used elsewhere in this file (see `configureTask()`). + val resolvedFiles = resolveOverriddenDotFiles(sourceSetDirs) + if (resolvedFiles.isEmpty()) return@onVariants + + val resolvedFileSetKey = resolvedFiles.map { it.absolutePath }.sorted() + val taskProvider = tasksByResolvedFileSet.getOrPut(resolvedFileSetKey) { + project.tasks.register( + "generate${variant.name.replaceFirstChar(Char::uppercase)}WayClasses", + GenerateClassesTask::class.java, + ) { task -> + task.group = "way" + task.outputDirectory.set( + project.layout.buildDirectory.dir("generated/way/code/${variant.name}"), + ) + task.source(providers.provider { resolveOverriddenDotFiles(sourceSetDirs) }) + } + } + + variant.sources.kotlin?.addGeneratedSourceDirectory( + taskProvider, + GenerateClassesTask::outputDirectory, + ) ?: variant.sources.java?.addGeneratedSourceDirectory( + taskProvider, + GenerateClassesTask::outputDirectory, + ) + + project.pluginManager.withPlugin("com.google.devtools.ksp") { + val kspTaskName = variant.computeTaskName("ksp", "Kotlin") + project.configureKspTask(kspTaskName, taskProvider) + } + } + + project.afterEvaluate { + val unmatchedDirNames = overrideDirNames - consumedSourceSetNames + if (unmatchedDirNames.isNotEmpty()) { + logger.warn( + "way: found way/*.dot file(s) under src/{} but no build variant resolves to " + + "that source set name — this override will never be applied. Check for a typo " + + "against your declared productFlavors/buildTypes.", + unmatchedDirNames.sorted().joinToString(", src/"), + ) + } + } + + return true + } + + /** + * The `way/` directory contributed by a single named Android source set (e.g. "google", "debug", + * or a variant-specific composite like "googleDebug"), resolved purely by the `src//` + * naming convention — not by querying AGP's `CommonExtension.sourceSets` DSL container. + * + * Per official AGP guidance (developer.android.com/build/extend-agp), third-party plugins should + * discover source directories via `AndroidComponentsExtension.onVariants()` + `variant.sources` + * rather than the DSL. However AGP 9's Variant API only exposes `variant.sources.kotlin`/`.java` as + * a `SourceDirectories.Flat` — the final MERGED list of directories for a variant, with no + * per-source-set-tier breakdown (unlike `res`/`assets`/`jniLibs`, which are `SourceDirectories.Layered` + * and DO expose a tiered `List>`). Since [resolveOverriddenDotFiles] needs the + * per-tier breakdown (main vs. flavor vs. buildType vs. variant-specific, each as its own directory + * list), neither the Variant API nor the DSL container (which also doesn't contain composite names + * pre-`onVariants`, see [waySourceSetOverrideDirNames]) can supply it directly. Resolving by naming + * convention sidesteps both problems, using `ComponentIdentity`-derived source set names (official + * Variant API, via [variantSourceSetNamesInPriorityOrder]) with directories derived by convention. + * + * This assumes the default source set layout (`src//...`), same as [resolveWaySourceDir] + * already does for the non-flavored path; a consumer that relocates a source set's root away from + * that convention is not supported here. + */ + private fun Project.androidWaySourceSetDirectories(sourceSetName: String): List = + listOf(File(project.projectDir, "src/$sourceSetName").resolveWaySourceDir(sourceSetName)) + + /** + * Names of non-main, non-test Android source sets anywhere under `src/` that contribute a `.dot` + * file under their `way/` directory. Checked directly against the filesystem, by the same + * `src//way/` convention [androidWaySourceSetDirectories] uses, rather than via + * `androidExtension.sourceSets` — that DSL container does not yet contain composite/variant-specific + * source set names (e.g. "googleDebug") at the point this runs (`finalizeDsl`, before `onVariants`), + * so a DSL-container scan would silently miss a variant-specific-only override. + * + * Names returned here that never end up matching any real variant's + * [variantSourceSetNamesInPriorityOrder] (e.g. a typo'd flavor/buildType name) are reported via a + * build warning by the caller — see the `project.afterEvaluate` block in + * [setupAndroidPerVariantWayTasksIfFlavored]. + */ + private fun Project.waySourceSetOverrideDirNames(): Set { + val sourceSetDirs = File(project.projectDir, "src").listFiles { file -> file.isDirectory } ?: return emptySet() + return sourceSetDirs + .filter { sourceSetDir -> + val sourceSetName = sourceSetDir.name + sourceSetName != "main" && + !isWayTestSourceSet(sourceSetName) && + androidWaySourceSetDirectories(sourceSetName).any { dir -> + dir.isDirectory && dir.walkTopDown().any { candidate -> candidate.isFile && candidate.extension == "dot" } + } + } + .map { it.name } + .toSet() + } + private fun Project.findAndroidSourceDirectories( androidExtension: CommonExtension, kotlinExtension: KotlinProjectExtension?, diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/KspTaskConfigurerTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/KspTaskConfigurerTest.kt index 152ad1c..c187ae0 100644 --- a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/KspTaskConfigurerTest.kt +++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/KspTaskConfigurerTest.kt @@ -80,6 +80,41 @@ class KspTaskConfigurerTest : kspTask.kspConfig.javaSourceRoots.files.contains(outputDirectory) shouldBe false kspTask.kspConfig.commonSourceRoots.files.contains(outputDirectory) shouldBe false } + + should("wire two distinct variants' generate tasks to their own ksp task, never to each other's") { + val project = ProjectBuilder.builder().build() + + val googleDebugDir = project.layout.buildDirectory.dir("generated/way/code/googleDebug") + val googleDebugGenerateTask = project.tasks.register( + "generateGoogleDebugWayClasses", + GenerateClassesTask::class.java, + ) { task -> task.outputDirectory.set(googleDebugDir) } + val googleDebugKspTask = project.tasks.register("kspGoogleDebugKotlin", FakeKspAATask::class.java) + + val otherDebugDir = project.layout.buildDirectory.dir("generated/way/code/otherDebug") + val otherDebugGenerateTask = project.tasks.register( + "generateOtherDebugWayClasses", + GenerateClassesTask::class.java, + ) { task -> task.outputDirectory.set(otherDebugDir) } + val otherDebugKspTask = project.tasks.register("kspOtherDebugKotlin", FakeKspAATask::class.java) + + project.configureKspTask(kspTaskName = "kspGoogleDebugKotlin", taskProvider = googleDebugGenerateTask) + project.configureKspTask(kspTaskName = "kspOtherDebugKotlin", taskProvider = otherDebugGenerateTask) + + val googleDebugOutput = googleDebugDir.get().asFile + val otherDebugOutput = otherDebugDir.get().asFile + + // Each variant's ksp task only sees its own generate task's output, never the other's. + googleDebugKspTask.get().kspConfig.sourceRoots.files.contains(googleDebugOutput) shouldBe true + googleDebugKspTask.get().kspConfig.sourceRoots.files.contains(otherDebugOutput) shouldBe false + otherDebugKspTask.get().kspConfig.sourceRoots.files.contains(otherDebugOutput) shouldBe true + otherDebugKspTask.get().kspConfig.sourceRoots.files.contains(googleDebugOutput) shouldBe false + + googleDebugKspTask.get().taskDependencies.getDependencies(googleDebugKspTask.get()) + .contains(otherDebugGenerateTask.get()) shouldBe false + otherDebugKspTask.get().taskDependencies.getDependencies(otherDebugKspTask.get()) + .contains(googleDebugGenerateTask.get()) shouldBe false + } }) private open class FakeKspAATask diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaGenerateTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaGenerateTest.kt index cf8b136..9e5ea06 100644 --- a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaGenerateTest.kt +++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/SchemaGenerateTest.kt @@ -49,5 +49,21 @@ class SchemaGenerateTest : ), testName = "multiple parallel in one schema", ), + // These two fixtures only prove that two independently valid graphs codegen correctly and + // differently from each other — they do NOT exercise resolveOverriddenDotFiles itself (no + // source-set resolution happens here, each file is codegenned standalone). The actual + // override-resolution behavior (picking the higher-priority file at a shared relative path, + // including an end-to-end resolve+codegen assertion using these same two fixtures) is + // covered in WayPluginSourceResolutionTest. + TestCase( + schemaFile = "flavor-override-base.dot", + expectedOutputFiles = listOf("flavor-override-base-schema.txt"), + testName = "codegen fixture: base graph (paired with the overriding-variant fixture below)", + ), + TestCase( + schemaFile = "flavor-override-google.dot", + expectedOutputFiles = listOf("flavor-override-google-schema.txt"), + testName = "codegen fixture: overriding-variant graph produces different generated code than the base fixture", + ), ) { runTest(it) } }) diff --git a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/WayPluginSourceResolutionTest.kt b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/WayPluginSourceResolutionTest.kt index c7e8a23..56b0ff8 100644 --- a/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/WayPluginSourceResolutionTest.kt +++ b/way-gradle-plugin/src/test/kotlin/ru/kode/way/gradle/WayPluginSourceResolutionTest.kt @@ -1,10 +1,32 @@ package ru.kode.way.gradle +import com.android.build.api.variant.ComponentIdentity +import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.ShouldSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain import org.gradle.testfixtures.ProjectBuilder import org.jetbrains.kotlin.gradle.dsl.KotlinProjectExtension import java.io.File +import java.nio.file.Files + +private fun applyWayPluginToPlainKotlinJvmProject() { + val project = ProjectBuilder.builder().build() + project.pluginManager.apply("org.jetbrains.kotlin.jvm") + project.pluginManager.apply(WayPlugin::class.java) + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() +} + +private data class FakeComponentIdentity( + override val name: String, + override val buildType: String?, + override val productFlavors: List>, + override val flavorName: String, +) : ComponentIdentity + +private fun createTempSourceSetDir(): File = Files.createTempDirectory("way-source-set").toFile() class WayPluginSourceResolutionTest : ShouldSpec({ @@ -80,4 +102,448 @@ class WayPluginSourceResolutionTest : registerGeneratedDirInKotlinMainSourceSet(null, taskProvider) }.isSuccess shouldBe true } + + should("compute main, build type and flavor source set names for a flavorless variant") { + val identity = FakeComponentIdentity( + name = "debug", + buildType = "debug", + productFlavors = emptyList(), + flavorName = "", + ) + + variantSourceSetNamesInPriorityOrder(identity) shouldBe listOf("main", "debug") + } + + should("compute full main, flavor, buildType, variant-specific chain for a flavored variant") { + val identity = FakeComponentIdentity( + name = "googleDebug", + buildType = "debug", + productFlavors = listOf("tier" to "google"), + flavorName = "google", + ) + + variantSourceSetNamesInPriorityOrder(identity) shouldBe + listOf("main", "google", "debug", "googleDebug") + } + + should("put the first-declared flavor dimension last (highest priority) among multiple dimensions") { + // flavorDimensions("tier", "region") — per AGP's documented precedence ("the first dimension + // having a higher priority than the second, and so on" — + // https://developer.android.com/build/build-variants#flavor-dimensions), "tier" (declared + // first) must win over "region" (declared second). ComponentIdentity.productFlavors is + // ordered by declaration order, i.e. [("tier", "free"), ("region", "us")]. + val identity = FakeComponentIdentity( + name = "freeUsDebug", + buildType = "debug", + productFlavors = listOf("tier" to "free", "region" to "us"), + flavorName = "freeUs", + ) + + // "free" (tier, first-declared, highest priority) must sit AFTER "us" (region) in this + // ascending-priority list, since resolveOverriddenDotFiles lets later entries win. + variantSourceSetNamesInPriorityOrder(identity) shouldBe + listOf("main", "us", "free", "debug", "freeUsDebug") + } + + should("override a lower-priority dot file with a higher-priority same-relative-path file") { + val mainDir = createTempSourceSetDir() + val flavorDir = createTempSourceSetDir() + try { + File(mainDir, "app.dot").writeText("main") + val flavorDotFile = File(flavorDir, "app.dot").apply { writeText("flavor") } + + resolveOverriddenDotFiles(listOf(listOf(mainDir), listOf(flavorDir))) shouldBe + listOf(flavorDotFile) + } finally { + mainDir.deleteRecursively() + flavorDir.deleteRecursively() + } + } + + should("fall back to the lower-priority file for a relative path with no override") { + val mainDir = createTempSourceSetDir() + val flavorDir = createTempSourceSetDir() + try { + val mainAppFile = File(mainDir, "app.dot").apply { writeText("main-app") } + val mainOtherFile = File(mainDir, "other.dot").apply { writeText("main-other") } + val flavorAppFile = File(flavorDir, "app.dot").apply { writeText("flavor-app") } + + resolveOverriddenDotFiles(listOf(listOf(mainDir), listOf(flavorDir))).toSet() shouldBe + setOf(flavorAppFile, mainOtherFile) + + // sanity check that the non-overridden file really did fall back to main's content + mainOtherFile.readText() shouldBe "main-other" + mainAppFile.readText() shouldBe "main-app" + } finally { + mainDir.deleteRecursively() + flavorDir.deleteRecursively() + } + } + + should("resolve the full main < flavor < buildType < variant-specific precedence chain") { + val mainDir = createTempSourceSetDir() + val flavorDir = createTempSourceSetDir() + val buildTypeDir = createTempSourceSetDir() + val variantDir = createTempSourceSetDir() + try { + File(mainDir, "a.dot").writeText("main-a") + File(mainDir, "b.dot").writeText("main-b") + File(mainDir, "c.dot").writeText("main-c") + val flavorB = File(flavorDir, "b.dot").apply { writeText("flavor-b") } + val flavorD = File(flavorDir, "d.dot").apply { writeText("flavor-d") } + val buildTypeC = File(buildTypeDir, "c.dot").apply { writeText("buildType-c") } + val variantA = File(variantDir, "a.dot").apply { writeText("variant-a") } + + val resolved = resolveOverriddenDotFiles( + listOf(listOf(mainDir), listOf(flavorDir), listOf(buildTypeDir), listOf(variantDir)), + ) + + resolved.toSet() shouldBe setOf(variantA, flavorB, buildTypeC, flavorD) + } finally { + mainDir.deleteRecursively() + flavorDir.deleteRecursively() + buildTypeDir.deleteRecursively() + variantDir.deleteRecursively() + } + } + + should( + "end-to-end: resolveOverriddenDotFiles picks the override file at the same relative path, " + + "and codegen on the resolved file reflects the overriding graph, not the base one", + ) { + val mainDir = createTempSourceSetDir() + val flavorDir = createTempSourceSetDir() + try { + // Same relative path ("app.dot") in both source sets — the flavor dir's file must fully + // replace the main dir's file, exactly like a real "src/main/way/app.dot" vs + // "src/google/way/app.dot" override pair. + File(mainDir, "app.dot").writeText( + File("src/test/resources/flavor-override-base.dot").readText(), + ) + val overrideFile = File(flavorDir, "app.dot").apply { + writeText(File("src/test/resources/flavor-override-google.dot").readText()) + } + + val resolved = resolveOverriddenDotFiles(listOf(listOf(mainDir), listOf(flavorDir))) + + // The resolver must pick the higher-priority (flavor) file, not the main one. + resolved shouldBe listOf(overrideFile) + + val generatedSchema = buildSpecs( + file = resolved.single(), + // Must be an ancestor of the resolved file with a matching (here: absolute) path type, + // or `file.toPath().relativeTo(projectDir.toPath())` in parseSchemaDotFile throws. + projectDir = resolved.single().parentFile, + config = CodeGenConfig( + outputPackageName = "ru.kode.test.app.schema", + outputSchemaClassName = "DefaultTestNavSchema", + ), + ).schemaFileSpec.toString() + + // Proof the resolved (override) file, not the base one, is what actually got codegenned: + // "countrySelect" only exists in the overriding graph. + generatedSchema shouldContain "FlavorGoogleAppSchema" + generatedSchema shouldContain "countrySelect" + generatedSchema shouldNotContain "FlavorBaseAppSchema" + } finally { + mainDir.deleteRecursively() + flavorDir.deleteRecursively() + } + } + + should("apply cleanly to a plain Kotlin/JVM project with no Android plugin involved") { + // Smoke test only — NOT a regression test for the NoClassDefFoundError bug this change + // fixes. AGP is `testImplementation` on this module's own test classpath (see + // build.gradle.kts), so `CommonExtension` resolves fine here regardless of whether the fix + // is applied; this test can't reproduce the real consumer scenario (AGP absent entirely). + // The actual fix is verified by call-graph tracing: findNonAndroidMainSources() (reached + // from the afterAndroid=false path, which runs unconditionally) never references any AGP + // type, unlike findAndroidMainSources()/setupAndroidPerVariantWayTasksIfFlavored() which + // are only reachable via the withId("com.android.base") callback. + runCatching { applyWayPluginToPlainKotlinJvmProject() }.isSuccess shouldBe true + } + + should("detect Android and register the way task when com.android.library is applied AFTER the way plugin") { + val project = ProjectBuilder.builder().build() + project.pluginManager.apply(WayPlugin::class.java) + project.pluginManager.apply("com.android.library") + (project.extensions.getByName("android") as com.android.build.api.dsl.LibraryExtension).apply { + namespace = "ru.kode.way.gradle.test.afterorder" + compileSdk = 34 + } + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + project.tasks.findByName("generateWayClasses") shouldNotBe null + } + + should("detect Android and register the way task when com.android.library is applied BEFORE the way plugin") { + val project = ProjectBuilder.builder().build() + project.pluginManager.apply("com.android.library") + (project.extensions.getByName("android") as com.android.build.api.dsl.LibraryExtension).apply { + namespace = "ru.kode.way.gradle.test.beforeorder" + compileSdk = 34 + } + project.pluginManager.apply(WayPlugin::class.java) + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + project.tasks.findByName("generateWayClasses") shouldNotBe null + } + + should("still register a per-variant way task via the flavored path when a flavor overrides a way dot file") { + // Real end-to-end check (not just the pure resolveOverriddenDotFiles unit tests above) that + // setupAndroidPerVariantWayTasksIfFlavored() is still reached now that it's only invoked + // from the withPlugin("com.android.base")-guarded branch of setupWayTasks. A `google`-flavor + // "way/app.dot" override is required here: merely declaring productFlavors with no actual + // override anywhere under way/ must now use the cheap single-task path instead (see the + // build-type-only-override test below), so this test needs a genuine override to still + // exercise the per-variant path. + val projectDir = Files.createTempDirectory("way-flavor-project").toFile() + try { + File(projectDir, "src/main/way").mkdirs() + File(projectDir, "src/main/way/app.dot").writeText("digraph { }") + File(projectDir, "src/google/way").mkdirs() + File(projectDir, "src/google/way/app.dot").writeText("digraph { }") + + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.pluginManager.apply(WayPlugin::class.java) + project.pluginManager.apply("com.android.library") + (project.extensions.getByName("android") as com.android.build.api.dsl.LibraryExtension).apply { + namespace = "ru.kode.way.gradle.test.flavored" + compileSdk = 34 + flavorDimensions += "tier" + productFlavors { + // Plain Action SAM here (no Gradle Kotlin DSL receiver-style extension applied, + // since this is a regular Kotlin test file, not a `.gradle.kts` script), so the + // created flavor must be taken as an explicit parameter, not via `this`. + create("google") { flavor -> + (flavor as com.android.build.api.dsl.ProductFlavor).dimension = "tier" + } + } + } + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + project.tasks.findByName("generateGoogleDebugWayClasses") shouldNotBe null + // Flavored projects go through the per-variant path only, not the no-flavor union task. + project.tasks.findByName("generateWayClasses") shouldBe null + } finally { + projectDir.deleteRecursively() + } + } + + should("keep using the cheap single-task path when productFlavors are declared but nothing overrides way/") { + // The gate must not be tripped by mere flavor declaration — only by an actual override + // somewhere under way/. Neither of the two existing no-override tests above declares + // productFlavors, so they don't exercise this branch of the gate on their own. + val projectDir = Files.createTempDirectory("way-flavors-no-override-project").toFile() + try { + File(projectDir, "src/main/way").mkdirs() + File(projectDir, "src/main/way/app.dot").writeText("digraph { }") + + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.pluginManager.apply(WayPlugin::class.java) + project.pluginManager.apply("com.android.library") + (project.extensions.getByName("android") as com.android.build.api.dsl.LibraryExtension).apply { + namespace = "ru.kode.way.gradle.test.flavorsnooverride" + compileSdk = 34 + flavorDimensions += "tier" + productFlavors { + create("google") { flavor -> + (flavor as com.android.build.api.dsl.ProductFlavor).dimension = "tier" + } + } + } + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + project.tasks.findByName("generateWayClasses") shouldNotBe null + project.tasks.findByName("generateGoogleDebugWayClasses") shouldBe null + } finally { + projectDir.deleteRecursively() + } + } + + should( + "activate the per-variant override path for a build-type-only override with zero " + + "productFlavors declared, and reflect the override content in the debug variant's source", + ) { + // Regression test for the critical gap: a project with NO productFlavors but a + // buildType-specific "way/" override (e.g. src/debug/way/app.dot overriding + // src/main/way/app.dot) must still take the per-variant override-resolution path — the old + // gate keyed purely on `productFlavors.isEmpty()` fell through to the flat glob-union path + // here instead, which has no override precedence logic at all. + val projectDir = Files.createTempDirectory("way-buildtype-only-override-project").toFile() + try { + File(projectDir, "src/main/way").mkdirs() + File(projectDir, "src/main/way/app.dot").writeText( + File("src/test/resources/flavor-override-base.dot").readText(), + ) + File(projectDir, "src/debug/way").mkdirs() + File(projectDir, "src/debug/way/app.dot").writeText( + File("src/test/resources/flavor-override-google.dot").readText(), + ) + + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.pluginManager.apply(WayPlugin::class.java) + project.pluginManager.apply("com.android.library") + (project.extensions.getByName("android") as com.android.build.api.dsl.LibraryExtension).apply { + namespace = "ru.kode.way.gradle.test.buildtypeonlyoverride" + compileSdk = 34 + } + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + // (a) the per-variant path activated even though productFlavors is empty. + val debugTask = project.tasks.findByName("generateDebugWayClasses") + debugTask shouldNotBe null + project.tasks.findByName("generateWayClasses") shouldBe null + + // (b) the debug variant's resolved source reflects the override content, not the base — + // "countrySelect" only exists in the overriding graph. + val resolvedFiles = (debugTask as GenerateClassesTask).source.files + resolvedFiles.size shouldBe 1 + resolvedFiles.single().readText() shouldContain "countrySelect" + } finally { + projectDir.deleteRecursively() + } + } + + should( + "activate the per-variant override path for a variant-specific-only override " + + "(e.g. src/googleDebug/way/), with nothing overridden at the flavor or buildType level", + ) { + // Regression test for the critical gap: an override that exists ONLY at the composite + // (flavor+buildType) source set — "googleDebug" — with no override at "google" or "debug" + // individually, used to be invisible to the old gate: hasVariantOverrideSourceSet() scanned + // `androidExtension.sourceSets`, which at `finalizeDsl` time (before `onVariants` fires) only + // contains single-level names ("main", "google", "debug") and never "googleDebug". That made + // the per-variant path never activate, so `generateGoogleDebugWayClasses` was never + // registered and the project silently fell through to the old flat-glob-union + // `generateWayClasses` task instead — reproducing the exact double-codegen/collision failure + // mode the per-variant mechanism exists to prevent. + val projectDir = Files.createTempDirectory("way-variant-specific-only-override-project").toFile() + try { + File(projectDir, "src/main/way").mkdirs() + File(projectDir, "src/main/way/app.dot").writeText( + File("src/test/resources/flavor-override-base.dot").readText(), + ) + File(projectDir, "src/googleDebug/way").mkdirs() + File(projectDir, "src/googleDebug/way/app.dot").writeText( + File("src/test/resources/flavor-override-google.dot").readText(), + ) + // Deliberately nothing under src/google/way/ or src/debug/way/ — the override exists ONLY + // at the composite "googleDebug" source set. + + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + project.pluginManager.apply(WayPlugin::class.java) + project.pluginManager.apply("com.android.library") + (project.extensions.getByName("android") as com.android.build.api.dsl.LibraryExtension).apply { + namespace = "ru.kode.way.gradle.test.variantspecificonlyoverride" + compileSdk = 34 + flavorDimensions += "tier" + productFlavors { + create("google") { flavor -> + (flavor as com.android.build.api.dsl.ProductFlavor).dimension = "tier" + } + } + } + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + // (a) the per-variant path activated even though the override is only visible on disk at + // the composite "googleDebug" source set, never at "google" or "debug" alone. + val googleDebugTask = project.tasks.findByName("generateGoogleDebugWayClasses") + googleDebugTask shouldNotBe null + project.tasks.findByName("generateWayClasses") shouldBe null + + // (b) the googleDebug variant's resolved source reflects the override content, not the + // base — "countrySelect" only exists in the overriding graph. + val resolvedFiles = (googleDebugTask as GenerateClassesTask).source.files + resolvedFiles.size shouldBe 1 + resolvedFiles.single().readText() shouldContain "countrySelect" + } finally { + projectDir.deleteRecursively() + } + } + + should("throw a clear, actionable error naming the file when a dot file parses to an empty navigation graph") { + // A syntactically VALID but EMPTY graph body (`digraph { }`, already used elsewhere in this + // file as the "no-op" fixture) parses successfully to an empty adjacencyList — this is the + // scenario GenerateClassesTask.generate() must guard against: a flavor/buildType override + // file that accidentally replaced a real base graph with a blank stub must fail with a clear, + // actionable message instead of an unrelated low-level crash (previously an + // IllegalStateException("internal error: no root node in graph") from deep inside codegen). + val projectDir = Files.createTempDirectory("way-empty-schema-project").toFile() + try { + val emptySchemaFile = File(projectDir, "app.dot").apply { writeText("digraph { }") } + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + val task = project.tasks.create("generateWayClassesForEmptySchema", GenerateClassesTask::class.java) { t -> + t.outputDirectory.set(project.layout.buildDirectory.dir("generated/way/code/emptySchema")) + t.source(emptySchemaFile) + t.include("**/*.dot") + } + + val exception = shouldThrow { task.generate() } + exception.message shouldContain "app.dot" + exception.message shouldContain "empty navigation graph" + } finally { + projectDir.deleteRecursively() + } + } + + should("also route a genuinely blank/whitespace-only dot file into the same clear empty-graph error") { + // Regression test: a genuinely blank/whitespace-only .dot file used to fail much earlier, at + // the ANTLR grammar level, with an unrelated NullPointerException from the parser itself + // (DotParser.GraphContext.stmt_list() returns null on immediate EOF) — before ever reaching + // the "empty navigation graph" guard exercised by the test above. findGraphAttributeValue() + // now null-safely treats a null stmt_list() as having no statements, so this case reaches the + // same clear, actionable error instead of an unrelated NPE. + val projectDir = Files.createTempDirectory("way-blank-schema-project").toFile() + try { + val blankSchemaFile = File(projectDir, "app.dot").apply { writeText(" \n\t\n") } + val project = ProjectBuilder.builder().withProjectDir(projectDir).build() + val task = project.tasks.create("generateWayClassesForBlankSchema", GenerateClassesTask::class.java) { t -> + t.outputDirectory.set(project.layout.buildDirectory.dir("generated/way/code/blankSchema")) + t.source(blankSchemaFile) + t.include("**/*.dot") + } + + val exception = shouldThrow { task.generate() } + exception.message shouldContain "app.dot" + exception.message shouldContain "empty navigation graph" + } finally { + projectDir.deleteRecursively() + } + } + + should("put the first-declared dimension highest-priority for 3+ flavor dimensions, not just 2") { + // flavorDimensions("tier", "region", "channel") + val identity = FakeComponentIdentity( + name = "freeUsStableDebug", + buildType = "debug", + productFlavors = listOf("tier" to "free", "region" to "us", "channel" to "stable"), + flavorName = "freeUsStable", + ) + + // "free" (tier, first-declared) must still end up last (highest priority) among the flavor + // entries, even with a third dimension ("channel") in play. + variantSourceSetNamesInPriorityOrder(identity) shouldBe + listOf("main", "stable", "us", "free", "debug", "freeUsStableDebug") + } + + should("detect Android and register the way task for a com.android.dynamic-feature module") { + // The com.android.base-guarded detection path (WayPlugin.apply()'s + // project.plugins.withId("com.android.base") { ... }) was previously only exercised against + // com.android.application/com.android.library in this test file. com.android.dynamic-feature + // also applies "com.android.base" and should be detected the same way. (com.android.test was + // considered too, but it requires a `targetProjectPath` pointing at a real base app module + // and fails project configuration in this ProjectBuilder-based, no-GradleTestKit harness; + // dynamic-feature applies and evaluates cleanly with just a namespace/compileSdk.) + val project = ProjectBuilder.builder().build() + project.pluginManager.apply(WayPlugin::class.java) + project.pluginManager.apply("com.android.dynamic-feature") + (project.extensions.getByName("android") as com.android.build.api.dsl.DynamicFeatureExtension).apply { + namespace = "ru.kode.way.gradle.test.dynamicfeature" + compileSdk = 34 + } + (project as org.gradle.api.internal.project.ProjectInternal).evaluate() + + project.tasks.findByName("generateWayClasses") shouldNotBe null + } }) diff --git a/way-gradle-plugin/src/test/resources/flavor-override-base-schema.txt b/way-gradle-plugin/src/test/resources/flavor-override-base-schema.txt new file mode 100644 index 0000000..582c58e --- /dev/null +++ b/way-gradle-plugin/src/test/resources/flavor-override-base-schema.txt @@ -0,0 +1,80 @@ +package ru.kode.test.app.schema + +import kotlin.Any +import kotlin.collections.List +import kotlin.collections.Map +import ru.kode.way.Event +import ru.kode.way.Path +import ru.kode.way.RegionId +import ru.kode.way.Schema +import ru.kode.way.Segment + +public class FlavorBaseAppSchema : Schema { + override val rootSegment: Segment = + Segment("app@FlavorBaseApp:src/test/resources/flavor-override-base.dot") + + override val childSchemas: Map = emptyMap() + + override val regions: List = + listOf(RegionId(Path(listOf(Segment("app@FlavorBaseApp:src/test/resources/flavor-override-base.dot"))))) + + public val appRegionId: RegionId + get() = regions[0] + + override fun target( + regionId: RegionId, + segment: Segment, + rootSegmentAlias: Segment?, + ): Path? = when (regionId) { + regions[0] -> { + val rootSegment = rootSegmentAlias ?: Segment("app@FlavorBaseApp:src/test/resources/flavor-override-base.dot") + when(segment.id) { + "app@FlavorBaseApp:src/test/resources/flavor-override-base.dot" -> Path(rootSegment) + "login@FlavorBaseApp:src/test/resources/flavor-override-base.dot" -> Path(listOf(rootSegment, Segment("login@FlavorBaseApp:src/test/resources/flavor-override-base.dot"))) + "home@FlavorBaseApp:src/test/resources/flavor-override-base.dot" -> Path(listOf(rootSegment, Segment("login@FlavorBaseApp:src/test/resources/flavor-override-base.dot"), Segment("home@FlavorBaseApp:src/test/resources/flavor-override-base.dot"))) + else -> null + } + } + else -> { + error("""unknown regionId=$regionId""") + } + } + + override fun nodeType( + regionId: RegionId, + path: Path, + rootSegmentAlias: Segment?, + ): Schema.NodeType = when (regionId) { + regions[0] -> { + val rootSegment = rootSegmentAlias ?: Segment("app@FlavorBaseApp:src/test/resources/flavor-override-base.dot") + when { + path == Path(rootSegment) -> Schema.NodeType.Flow + path == Path(listOf(rootSegment, Segment("login@FlavorBaseApp:src/test/resources/flavor-override-base.dot"))) -> Schema.NodeType.Screen + path == Path(listOf(rootSegment, Segment("login@FlavorBaseApp:src/test/resources/flavor-override-base.dot"), Segment("home@FlavorBaseApp:src/test/resources/flavor-override-base.dot"))) -> Schema.NodeType.Screen + else -> { + error("""internal error: no nodeType for path=$path""") + } + } + } + else -> { + error("""unknown regionId=$regionId""") + } + } + + override fun createChildFlowFinishRequestEvent( + regionId: RegionId, + path: Path, + result: Any, + ): Event = when (regionId) { + regions[0] -> { + when(path) { + else -> { + error("""internal error: failed to build child finish event for path=$path""") + } + } + } + else -> { + error("""unknown regionId=$regionId""") + } + } +} diff --git a/way-gradle-plugin/src/test/resources/flavor-override-base.dot b/way-gradle-plugin/src/test/resources/flavor-override-base.dot new file mode 100644 index 0000000..a9fb074 --- /dev/null +++ b/way-gradle-plugin/src/test/resources/flavor-override-base.dot @@ -0,0 +1,6 @@ +digraph FlavorBaseApp { + schemaFileName = "flavor-override-base-schema" + + app [type = flow, shape = hexagon] + app -> login -> home +} diff --git a/way-gradle-plugin/src/test/resources/flavor-override-google-schema.txt b/way-gradle-plugin/src/test/resources/flavor-override-google-schema.txt new file mode 100644 index 0000000..45d82cb --- /dev/null +++ b/way-gradle-plugin/src/test/resources/flavor-override-google-schema.txt @@ -0,0 +1,82 @@ +package ru.kode.test.app.schema + +import kotlin.Any +import kotlin.collections.List +import kotlin.collections.Map +import ru.kode.way.Event +import ru.kode.way.Path +import ru.kode.way.RegionId +import ru.kode.way.Schema +import ru.kode.way.Segment + +public class FlavorGoogleAppSchema : Schema { + override val rootSegment: Segment = + Segment("app@FlavorGoogleApp:src/test/resources/flavor-override-google.dot") + + override val childSchemas: Map = emptyMap() + + override val regions: List = + listOf(RegionId(Path(listOf(Segment("app@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))))) + + public val appRegionId: RegionId + get() = regions[0] + + override fun target( + regionId: RegionId, + segment: Segment, + rootSegmentAlias: Segment?, + ): Path? = when (regionId) { + regions[0] -> { + val rootSegment = rootSegmentAlias ?: Segment("app@FlavorGoogleApp:src/test/resources/flavor-override-google.dot") + when(segment.id) { + "app@FlavorGoogleApp:src/test/resources/flavor-override-google.dot" -> Path(rootSegment) + "login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot" -> Path(listOf(rootSegment, Segment("login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))) + "countrySelect@FlavorGoogleApp:src/test/resources/flavor-override-google.dot" -> Path(listOf(rootSegment, Segment("login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"), Segment("countrySelect@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))) + "home@FlavorGoogleApp:src/test/resources/flavor-override-google.dot" -> Path(listOf(rootSegment, Segment("login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"), Segment("countrySelect@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"), Segment("home@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))) + else -> null + } + } + else -> { + error("""unknown regionId=$regionId""") + } + } + + override fun nodeType( + regionId: RegionId, + path: Path, + rootSegmentAlias: Segment?, + ): Schema.NodeType = when (regionId) { + regions[0] -> { + val rootSegment = rootSegmentAlias ?: Segment("app@FlavorGoogleApp:src/test/resources/flavor-override-google.dot") + when { + path == Path(rootSegment) -> Schema.NodeType.Flow + path == Path(listOf(rootSegment, Segment("login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))) -> Schema.NodeType.Screen + path == Path(listOf(rootSegment, Segment("login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"), Segment("countrySelect@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))) -> Schema.NodeType.Screen + path == Path(listOf(rootSegment, Segment("login@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"), Segment("countrySelect@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"), Segment("home@FlavorGoogleApp:src/test/resources/flavor-override-google.dot"))) -> Schema.NodeType.Screen + else -> { + error("""internal error: no nodeType for path=$path""") + } + } + } + else -> { + error("""unknown regionId=$regionId""") + } + } + + override fun createChildFlowFinishRequestEvent( + regionId: RegionId, + path: Path, + result: Any, + ): Event = when (regionId) { + regions[0] -> { + when(path) { + else -> { + error("""internal error: failed to build child finish event for path=$path""") + } + } + } + else -> { + error("""unknown regionId=$regionId""") + } + } +} diff --git a/way-gradle-plugin/src/test/resources/flavor-override-google.dot b/way-gradle-plugin/src/test/resources/flavor-override-google.dot new file mode 100644 index 0000000..80871d2 --- /dev/null +++ b/way-gradle-plugin/src/test/resources/flavor-override-google.dot @@ -0,0 +1,6 @@ +digraph FlavorGoogleApp { + schemaFileName = "flavor-override-google-schema" + + app [type = flow, shape = hexagon] + app -> login -> countrySelect -> home +}