Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `generate<Variant>WayClasses` 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
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -122,6 +124,45 @@ Typical generated types (from graph id `App`):
- Android + KSP: variant `ksp<Variant>Kotlin` 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/<flavor>/way/<name>.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 `generate<Variant>WayClasses` task per Android variant
(`generateGoogleDebugWayClasses`, `generateHuaweiDebugWayClasses`, ...), each writing to
`build/generated/way/code/<variant>/`. 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`:
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions sample-compose/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ android {
targetCompatibility = JavaVersion.VERSION_11
}

flavorDimensions += "store"
productFlavors {
create("google") { dimension = "store" }
create("huawei") { dimension = "store" }
}

buildFeatures {
compose = true
}
Expand Down
13 changes: 13 additions & 0 deletions sample-compose/app/routing/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -32,4 +38,11 @@ dependencies {

implementation(libs.dagger)
ksp(libs.daggerCompiler)

testImplementation(libs.bundles.koTestCommon)
testImplementation(libs.bundles.koTestJvm)
}

tasks.withType<Test> {
useJUnitPlatform()
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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<Unit> {
override val initial: Target = Target.app.main

override val dismissResult: Unit = Unit

override fun transition(event: Event): FlowTransition<Unit> = when (event) {
is AppChildFinishRequest.Main -> Finish(Unit)
else -> Ignore
}
}
Original file line number Diff line number Diff line change
@@ -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<AppFlowNode>): 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())
}
}
10 changes: 10 additions & 0 deletions sample-compose/app/routing/src/huawei/way/app-flow.dot
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
})
Original file line number Diff line number Diff line change
@@ -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
}
})
Original file line number Diff line number Diff line change
@@ -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
}
})
1 change: 1 addition & 0 deletions way-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {
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<File>>): List<File> {
val fileByRelativePath = LinkedHashMap<String, File>()
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ private class Visitor : DotBaseVisitor<Unit>() {
}

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) {
Expand Down
Loading
Loading